format_convert
Convert between JSON, TOML, INI and a YAML subset. TOML is read with tomllib and written as tables, arrays of tables, inline arrays and quoted keys; INI goes through configparser (case kept, no interpolation); YAML block mappings and sequences, scalars with YAML 1.2 core types, quoted strings, block scalars, single-line flow collections and comments are read and written here, and anchors, aliases, tags and multi-document streams are refused clearly. Each result is read back to check the round trip and lists what is lost.
Install
/tools install format_convert /tools install format_convert@1.0.0
In a console session, or let the model find it in normal mode: axlr_catalog describes it and axlr_install_tool asks for the same install card. The card reads catalog axlr · format_convert 1.0.0 · verified once the index signature, the manifest digest and every file digest check out. Then the model runs it with axlr_run_tool format_convert; each run is approved as a local_exec.
Arguments
One JSON object on stdin, validated against the schema before the program starts.
| name | type | required | default | meaning |
|---|---|---|---|---|
text | string | no | The input text (with from) | |
data | no | Or a JSON value (from json) | ||
from | string | no | one of "json", "toml", "ini", "yaml" | |
to | string | yes | one of "json", "toml", "ini", "yaml" | |
indent | integer | no | 2 | JSON indentation. ≥ 0; ≤ 8 |
Input schema (JSON)
{
"type": "object",
"required": [
"to"
],
"additionalProperties": false,
"properties": {
"text": {
"type": "string",
"description": "The input text (with from)"
},
"data": {
"description": "Or a JSON value (from json)"
},
"from": {
"type": "string",
"enum": [
"json",
"toml",
"ini",
"yaml"
]
},
"to": {
"type": "string",
"enum": [
"json",
"toml",
"ini",
"yaml"
]
},
"indent": {
"type": "integer",
"minimum": 0,
"maximum": 8,
"default": 2,
"description": "JSON indentation"
}
}
}
Example
Input
{
"text": "name = \"Orange\"\nphysical.color = \"orange\"\nphysical.shape = \"round\"\nsite.\"google.com\" = true\n",
"from": "toml",
"to": "yaml"
}
Output (stdout)
{
"text": "name: Orange\nphysical:\n color: orange\n shape: round\nsite:\n google.com: true\n",
"round_trip_ok": true
}
Files
The command the install card registers is python3 format_convert.py, run in the workspace with PATH and HOME as its only environment. Each file's SHA-256 is in the signed index; the console checks them before showing the card and again before writing.
format_convert.py · sha256 90f45ed6b60befce31d30c3806dcad046943dd5e96ad04639088bc16dccfdb6c
#!/usr/bin/env python3
"""Convert configuration and data files between JSON, TOML, INI and a YAML subset. TOML is
read with tomllib (Python 3.11+) and written by this program (a subset: tables, arrays of
tables, inline arrays and scalars); INI goes through configparser; YAML is a block-style
subset read and written here (mappings, sequences, scalars, quoted strings, block scalars,
simple flow collections, comments), refusing anchors, aliases, tags and multi-document
streams with a clear message. Every conversion reports what does not survive a round trip.
Input (JSON on stdin):
text the input text (or data: a JSON value, which implies from "json")
from "json" | "toml" | "ini" | "yaml"
to "json" | "toml" | "ini" | "yaml"
indent JSON indentation, default 2
"""
import configparser
import datetime as dt
import json
import math
import re
import sys
try:
import tomllib
except ImportError: # Python 3.10
tomllib = None
FORMATS = ("json", "toml", "ini", "yaml")
class ConvertError(Exception):
pass
def fail(message):
print(json.dumps({"error": message}))
sys.exit(2)
# ---------------------------------------------------------------- TOML
def toml_plain(value, notes):
"""tomllib values to JSON: dates and times become ISO strings."""
if isinstance(value, dict):
return {k: toml_plain(v, notes) for k, v in value.items()}
if isinstance(value, list):
return [toml_plain(v, notes) for v in value]
if isinstance(value, (dt.datetime, dt.date, dt.time)):
notes.add("TOML dates and times became ISO 8601 strings (JSON has no date type)")
return value.isoformat()
if isinstance(value, float) and not math.isfinite(value):
notes.add("TOML inf and nan have no JSON equivalent: written as the strings \"inf\", \"-inf\", \"nan\"")
return "nan" if value != value else ("inf" if value > 0 else "-inf")
return value
BARE_KEY = re.compile(r"^[A-Za-z0-9_-]+$")
def toml_key(key):
return key if BARE_KEY.match(key) else json.dumps(key, ensure_ascii=False)
def toml_value(value, path):
if value is None:
raise ConvertError(f"{path or 'the value'} is null: TOML has no null (remove it or use a string)")
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, int):
return str(value)
if isinstance(value, float):
if value != value:
return "nan"
if math.isinf(value):
return "inf" if value > 0 else "-inf"
text = repr(value)
return text if any(c in text for c in ".eEn") else text + ".0"
if isinstance(value, str):
return json.dumps(value, ensure_ascii=False).replace("\\/", "/")
if isinstance(value, list):
return "[" + ", ".join(toml_value(v, f"{path}[{i}]") for i, v in enumerate(value)) + "]"
if isinstance(value, dict):
return "{" + ", ".join(f"{toml_key(k)} = {toml_value(v, f'{path}.{k}')}" for k, v in value.items()) + "}"
raise ConvertError(f"{path}: unsupported value")
def is_table_array(value):
return isinstance(value, list) and value and all(isinstance(v, dict) for v in value)
def write_toml(data):
if not isinstance(data, dict):
raise ConvertError("TOML needs a table (a JSON object) at the top level")
lines = []
def table(obj, prefix):
scalars = [(k, v) for k, v in obj.items() if not isinstance(v, dict) and not is_table_array(v)]
for k, v in scalars:
lines.append(f"{toml_key(k)} = {toml_value(v, '.'.join(prefix + [k]))}")
for k, v in obj.items():
name = ".".join(toml_key(p) for p in prefix + [k])
if isinstance(v, dict):
lines.append("")
lines.append(f"[{name}]")
table(v, prefix + [k])
elif is_table_array(v):
for item in v:
lines.append("")
lines.append(f"[[{name}]]")
table(item, prefix + [k])
table(data, [])
return "\n".join(lines).lstrip("\n") + "\n"
# ---------------------------------------------------------------- INI
def read_ini(text):
parser = configparser.ConfigParser(interpolation=None, default_section="\0no default section\0", strict=True)
parser.optionxform = str
try:
parser.read_string(text)
except configparser.MissingSectionHeaderError:
try:
parser.read_string("[DEFAULT]\n" + text)
except configparser.Error as error:
raise ConvertError(f"INI: {error}")
except configparser.Error as error:
raise ConvertError(f"INI: {' '.join(str(error).split())}")
return {section: dict(parser.items(section, raw=True)) for section in parser.sections()}
def ini_scalar(value, path):
if value is None:
return ""
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, (int, float, str)):
text = str(value)
if "\n" in text:
return text.replace("\n", "\n ")
return text
raise ConvertError(f"{path}: INI values must be scalars (lists and deeper nesting are not representable)")
def write_ini(data, notes):
if not isinstance(data, dict):
raise ConvertError("INI needs an object of sections")
loose = {k: v for k, v in data.items() if not isinstance(v, dict)}
sections = {k: v for k, v in data.items() if isinstance(v, dict)}
lines = []
if loose:
notes.add("top-level scalars were put in the [DEFAULT] section")
sections = {"DEFAULT": {**sections.get("DEFAULT", {}), **loose}, **{k: v for k, v in sections.items() if k != "DEFAULT"}}
for name, values in sections.items():
if "]" in name or "\n" in name:
raise ConvertError(f"section name {name!r} cannot be written in INI")
lines.append(f"[{name}]")
for key, value in values.items():
if any(c in str(key) for c in "=:\n[") or str(key).strip() != str(key) or not str(key):
raise ConvertError(f"key {key!r} cannot be written in INI")
lines.append(f"{key} = {ini_scalar(value, f'{name}.{key}')}")
lines.append("")
notes.add("INI has no types: every value is read back as a string")
return "\n".join(lines)
# ---------------------------------------------------------------- YAML subset
NULLS = {"", "~", "null", "Null", "NULL"}
TRUES = {"true", "True", "TRUE"}
FALSES = {"false", "False", "FALSE"}
YAML11_BOOLS = {"yes", "Yes", "YES", "no", "No", "NO", "on", "On", "ON", "off", "Off", "OFF", "y", "Y", "n", "N"}
INT_RE = re.compile(r"^[-+]?[0-9]+$")
OCT_RE = re.compile(r"^0o[0-7]+$")
HEX_RE = re.compile(r"^0x[0-9a-fA-F]+$")
FLOAT_RE = re.compile(r"^[-+]?(\.[0-9]+|[0-9]+(\.[0-9]*)?)([eE][-+]?[0-9]+)?$")
class YamlReader:
def __init__(self, text, notes):
self.notes = notes
self.lines = []
seen_content = False
for number, raw in enumerate(text.replace("\r\n", "\n").replace("\r", "\n").split("\n"), 1):
if "\t" in raw[:len(raw) - len(raw.lstrip())]:
raise ConvertError(f"YAML line {number}: tabs are not allowed for indentation")
stripped = self.strip_comment(raw, number).rstrip()
if not stripped.strip():
self.lines.append((None, "", number))
continue
body = stripped.strip()
if body.startswith("%"):
raise ConvertError(f"YAML line {number}: directives (%YAML, %TAG) are not supported")
if body == "---" or body.startswith("--- "):
if seen_content:
raise ConvertError(f"YAML line {number}: a second document (---): multi-document streams are not supported")
if body.startswith("--- "):
raise ConvertError(f"YAML line {number}: content after --- is not supported")
continue
if body == "...":
continue
seen_content = True
indent = len(stripped) - len(stripped.lstrip(" "))
self.lines.append((indent, body, number))
@staticmethod
def strip_comment(line, number):
quote = None
for index, char in enumerate(line):
if quote:
if char == quote:
if quote == "'" and index + 1 < len(line) and line[index + 1] == "'":
continue
if quote == '"' and line[index - 1] == "\\" and not line[max(0, index - 2):index] == "\\\\":
continue
quote = None
elif char in "'\"" and (index == 0 or line[index - 1] in " -:[{,?"):
quote = char
elif char == "#" and (index == 0 or line[index - 1] in " \t"):
return line[:index]
return line
def content(self, start):
index = start
while index < len(self.lines) and self.lines[index][0] is None:
index += 1
return index
def parse(self):
index = self.content(0)
if index >= len(self.lines):
return None
value, index = self.block(index, self.lines[index][0])
index = self.content(index)
if index < len(self.lines):
raise ConvertError(f"YAML line {self.lines[index][2]}: unexpected content (check the indentation)")
return value
def block(self, index, indent):
line_indent, body, number = self.lines[index]
if body == "-" or body.startswith("- "):
return self.sequence(index, line_indent)
if self.mapping_key(body, number) is not None:
return self.mapping(index, line_indent)
return self.scalar_lines(index, line_indent)
def check_node_properties(self, text, number):
if text[:1] in ("&", "*"):
raise ConvertError(f"YAML line {number}: anchors (&) and aliases (*) are not supported")
if text[:1] == "!":
raise ConvertError(f"YAML line {number}: tags (!) are not supported")
if text[:1] in ("?",) and (len(text) == 1 or text[1] == " "):
raise ConvertError(f"YAML line {number}: complex mapping keys (?) are not supported")
if text[:1] in ("@", "`"):
raise ConvertError(f"YAML line {number}: a plain scalar cannot start with {text[0]}")
def mapping_key(self, body, number):
"""(key, rest) when the line is 'key: value' or 'key:'; None otherwise."""
if body[:1] in ('"', "'"):
quote = body[0]
end = 1
while end < len(body):
if body[end] == "\\" and quote == '"':
end += 2
continue
if body[end] == quote:
if quote == "'" and end + 1 < len(body) and body[end + 1] == "'":
end += 2
continue
break
end += 1
after = body[end + 1:]
if after.startswith(":") and (len(after) == 1 or after[1] == " "):
return self.quoted(body[:end + 1], number), after[1:].strip()
return None
if body[:1] in "#[]{},|>" or body.startswith("- ") or body == "-":
return None
match = re.match(r"^(.+?):(?: +(.*)|)$", body)
if not match:
return None
key = match.group(1).strip()
if not key:
return None
self.check_node_properties(key, number)
return key, (match.group(2) or "").strip()
def mapping(self, index, indent):
result = {}
while True:
index = self.content(index)
if index >= len(self.lines) or self.lines[index][0] < indent:
break
line_indent, body, number = self.lines[index]
if line_indent > indent:
raise ConvertError(f"YAML line {number}: unexpected indentation")
pair = self.mapping_key(body, number)
if pair is None:
raise ConvertError(f"YAML line {number}: expected 'key: value' in this mapping")
key, rest = pair
if key in result:
raise ConvertError(f"YAML line {number}: duplicate key {key!r}")
index += 1
if rest:
value, index = self.inline_value(rest, number, index, indent)
else:
following = self.content(index)
if following < len(self.lines) and (self.lines[following][0] > indent or
(self.lines[following][0] == indent and (self.lines[following][1] == "-" or self.lines[following][1].startswith("- ")))):
value, index = self.block(following, self.lines[following][0])
else:
value = None
result[key] = value
return result, index
def sequence(self, index, indent):
result = []
while True:
index = self.content(index)
if index >= len(self.lines) or self.lines[index][0] < indent:
break
line_indent, body, number = self.lines[index]
if line_indent > indent:
raise ConvertError(f"YAML line {number}: unexpected indentation")
if not (body == "-" or body.startswith("- ")):
break
rest = body[1:].lstrip()
if not rest:
following = self.content(index + 1)
if following < len(self.lines) and self.lines[following][0] > indent:
value, index = self.block(following, self.lines[following][0])
else:
value, index = None, index + 1
elif rest == "-" or rest.startswith("- ") or (self.mapping_key(rest, number) is not None and rest[:1] not in "[{"):
# "- key: value" or "- - item": the item is a block that starts after the dash.
offset = len(body) - len(rest)
self.lines[index] = (line_indent + offset, rest, number)
value, index = self.block(index, line_indent + offset)
else:
value, index = self.inline_value(rest, number, index + 1, indent)
result.append(value)
return result, index
def inline_value(self, text, number, index, indent):
"""A value written after 'key:' or '- ', and the index of the next line to read."""
self.check_node_properties(text, number)
if text[:1] in ("|", ">") and re.match(r"^[|>][-+]?[1-9]?[-+]?$", text):
return self.block_scalar(text, index, indent)
if text[:1] in ("[", "{"):
parser = FlowParser(text, number, self)
value = parser.parse()
return value, index
if text[:1] in ('"', "'"):
closing = self.quoted_end(text)
if closing is None:
raise ConvertError(f"YAML line {number}: quoted strings spanning several lines are not supported")
if text[closing + 1:].strip():
raise ConvertError(f"YAML line {number}: unexpected text after the quoted string")
return self.quoted(text, number), index
# Plain scalar, possibly continued on more-indented lines (folded with spaces).
parts = [text]
while True:
following = self.content(index)
if following < len(self.lines) and self.lines[following][0] > indent and not self.lines[following][1].startswith("- ") \
and self.mapping_key(self.lines[following][1], self.lines[following][2]) is None:
parts.append(self.lines[following][1])
index = following + 1
else:
break
return self.resolve(" ".join(parts), number), index
def scalar_lines(self, index, indent):
line_indent, body, number = self.lines[index]
return self.inline_value(body, number, index + 1, indent - 1)
def block_scalar(self, header, index, indent):
style, chomp = header[0], ("-" if "-" in header else "+" if "+" in header else "")
digits = re.findall(r"[1-9]", header)
raw = []
while index < len(self.lines) and (self.lines[index][0] is None or self.lines[index][0] > indent):
raw.append(index)
index += 1
# Block scalars keep their text: reread the original lines (comments inside are text).
texts = []
block_indent = None
for position in raw:
line_indent, body, number = self.lines[position]
if line_indent is None:
texts.append(None)
continue
if block_indent is None:
block_indent = indent + int(digits[0]) if digits else line_indent
texts.append(" " * (line_indent - block_indent) + body)
while texts and texts[-1] is None:
texts.pop()
lines = ["" if t is None else t for t in texts]
if style == "|":
value = "\n".join(lines)
else:
value, previous = "", None
for line in lines:
if line == "":
value += "\n"
previous = "blank"
elif line.startswith(" "):
value += ("\n" if previous == "text" else "") + line + "\n"
previous = "indented"
else:
value += (" " if previous == "text" else "") + line
previous = "text"
if chomp == "-":
value = value.rstrip("\n")
elif lines:
value = value.rstrip("\n") + "\n"
self.notes.add("block scalars are read with simplified rules: comments inside them are removed and trailing blank lines are not kept")
return value, index
@staticmethod
def quoted_end(text):
quote, end = text[0], 1
while end < len(text):
if quote == '"' and text[end] == "\\":
end += 2
continue
if text[end] == quote:
if quote == "'" and end + 1 < len(text) and text[end + 1] == "'":
end += 2
continue
return end
end += 1
return None
@staticmethod
def quoted(text, number):
if text[0] == "'":
return text[1:-1].replace("''", "'")
inner = text[1:-1]
escapes = {"0": "\0", "a": "\a", "b": "\b", "t": "\t", "\t": "\t", "n": "\n", "v": "\v", "f": "\f", "r": "\r", "e": "\x1b",
" ": " ", '"': '"', "/": "/", "\\": "\\", "N": "\u0085", "_": " ", "L": "
", "P": "
"}
out, i = [], 0
while i < len(inner):
char = inner[i]
if char != "\\":
out.append(char)
i += 1
continue
if i + 1 >= len(inner):
raise ConvertError(f"YAML line {number}: a backslash ends the string")
code = inner[i + 1]
if code in escapes:
out.append(escapes[code])
i += 2
elif code in "xuU":
width = {"x": 2, "u": 4, "U": 8}[code]
digits = inner[i + 2:i + 2 + width]
if len(digits) != width or not re.fullmatch(r"[0-9a-fA-F]+", digits):
raise ConvertError(f"YAML line {number}: bad \\{code} escape")
out.append(chr(int(digits, 16)))
i += 2 + width
else:
raise ConvertError(f"YAML line {number}: unknown escape \\{code}")
return "".join(out)
def resolve(self, text, number):
"""YAML 1.2 core schema."""
if text in NULLS:
return None
if text in TRUES:
return True
if text in FALSES:
return False
if text in YAML11_BOOLS:
self.notes.add(f"{text!r} is a string in YAML 1.2 (the core schema used here) but a boolean in YAML 1.1 readers such as PyYAML")
if INT_RE.match(text):
return int(text)
if OCT_RE.match(text):
return int(text[2:], 8)
if HEX_RE.match(text):
return int(text[2:], 16)
if FLOAT_RE.match(text):
return float(text)
if re.fullmatch(r"[-+]?\.(inf|Inf|INF)", text):
self.notes.add("YAML .inf and .nan have no JSON equivalent")
return float("-inf") if text.startswith("-") else float("inf")
if text in (".nan", ".NaN", ".NAN"):
return float("nan")
if re.match(r"^\d{4}-\d{2}-\d{2}", text):
self.notes.add("dates are kept as strings (the YAML 1.2 core schema has no timestamp type)")
if text[:1] in ("&", "*", "!", "|", ">", "%", "@", "`"):
self.check_node_properties(text, number)
return text
class FlowParser:
"""Single-line flow collections: [a, b] and {k: v}, nested, with quoted or plain scalars."""
def __init__(self, text, number, reader):
self.text, self.number, self.reader, self.pos = text, number, reader, 0
def error(self, message):
raise ConvertError(f"YAML line {self.number}: {message}")
def skip(self):
while self.pos < len(self.text) and self.text[self.pos] == " ":
self.pos += 1
def parse(self):
value = self.value()
self.skip()
if self.pos != len(self.text):
self.error("unexpected text after the flow collection (flow collections must fit on one line)")
return value
def value(self):
self.skip()
if self.pos >= len(self.text):
self.error("flow collection not closed on this line (multi-line flow collections are not supported)")
char = self.text[self.pos]
if char == "[":
self.pos += 1
items = []
while True:
self.skip()
if self.pos < len(self.text) and self.text[self.pos] == "]":
self.pos += 1
return items
items.append(self.value())
self.skip()
if self.pos < len(self.text) and self.text[self.pos] == ",":
self.pos += 1
elif self.pos < len(self.text) and self.text[self.pos] == "]":
continue
else:
self.error("expected ',' or ']' in a flow sequence")
if char == "{":
self.pos += 1
result = {}
while True:
self.skip()
if self.pos < len(self.text) and self.text[self.pos] == "}":
self.pos += 1
return result
key = self.value()
self.skip()
if self.pos < len(self.text) and self.text[self.pos] == ":":
self.pos += 1
item = self.value()
else:
item = None
if isinstance(key, (dict, list)):
self.error("collection keys are not supported")
key = key if isinstance(key, str) else json.dumps(key)
if key in result:
self.error(f"duplicate key {key!r}")
result[key] = item
self.skip()
if self.pos < len(self.text) and self.text[self.pos] == ",":
self.pos += 1
elif self.pos < len(self.text) and self.text[self.pos] == "}":
continue
else:
self.error("expected ',' or '}' in a flow mapping")
if char in "'\"":
end = YamlReader.quoted_end(self.text[self.pos:])
if end is None:
self.error("unterminated quoted string")
token = self.text[self.pos:self.pos + end + 1]
self.pos += end + 1
return YamlReader.quoted(token, self.number)
start = self.pos
while self.pos < len(self.text) and self.text[self.pos] not in ",]}" and not (self.text[self.pos] == ":" and (self.pos + 1 == len(self.text) or self.text[self.pos + 1] in " ,]}")):
self.pos += 1
token = self.text[start:self.pos].strip()
self.reader.check_node_properties(token, self.number)
return self.reader.resolve(token, self.number)
YAML_PLAIN = re.compile(r"^[A-Za-z_/.][A-Za-z0-9_ ./@()+-]*$")
def yaml_scalar(value):
if value is None:
return "null"
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, int):
return str(value)
if isinstance(value, float):
if value != value:
return ".nan"
if math.isinf(value):
return ".inf" if value > 0 else "-.inf"
return repr(value)
text = str(value)
ambiguous = text in NULLS or text in TRUES or text in FALSES or text in YAML11_BOOLS or INT_RE.match(text) or FLOAT_RE.match(text) \
or text.startswith((".inf", ".nan", "-.inf")) or HEX_RE.match(text) or OCT_RE.match(text)
if YAML_PLAIN.match(text) and not ambiguous and text == text.strip() and ": " not in text and " #" not in text and not text.endswith(":"):
return text
return json.dumps(text, ensure_ascii=False)
def yaml_key(key):
key = str(key)
return yaml_scalar(key) if key else '""'
def write_yaml(data, indent=0):
pad = " " * indent
if isinstance(data, dict):
if not data:
return pad + "{}\n"
out = ""
for key, value in data.items():
if isinstance(value, dict) and value:
out += f"{pad}{yaml_key(key)}:\n" + write_yaml(value, indent + 2)
elif isinstance(value, list) and value:
out += f"{pad}{yaml_key(key)}:\n" + write_yaml(value, indent + 2)
else:
out += f"{pad}{yaml_key(key)}: {yaml_inline(value)}\n"
return out
if isinstance(data, list):
if not data:
return pad + "[]\n"
out = ""
for item in data:
if isinstance(item, (dict, list)) and item:
block = write_yaml(item, indent + 2)
out += pad + "- " + block[indent + 2:]
else:
out += f"{pad}- {yaml_inline(item)}\n"
return out
return pad + yaml_scalar(data) + "\n"
def yaml_inline(value):
if isinstance(value, dict):
return "{}"
if isinstance(value, list):
return "[]"
return yaml_scalar(value)
# ---------------------------------------------------------------- main
def json_safe(value, notes):
if isinstance(value, float) and not math.isfinite(value):
notes.add("inf and nan have no JSON equivalent: written as strings")
return "nan" if value != value else ("inf" if value > 0 else "-inf")
if isinstance(value, dict):
return {k: json_safe(v, notes) for k, v in value.items()}
if isinstance(value, list):
return [json_safe(v, notes) for v in value]
return value
def main():
try:
request = json.load(sys.stdin)
except ValueError as error:
fail(f"stdin is not JSON: {error}")
if not isinstance(request, dict):
fail("stdin must be a JSON object")
unknown = sorted(set(request) - {"text", "data", "from", "to", "indent"})
if unknown:
fail(f"unknown field: {unknown[0]}")
if ("text" in request) == ("data" in request):
fail("give exactly one of text (with from) or data (a JSON value)")
source = request.get("from", "json" if "data" in request else None)
target = request.get("to")
if source not in FORMATS:
fail("from must be json, toml, ini or yaml")
if target not in FORMATS:
fail("to must be json, toml, ini or yaml")
if "data" in request and source != "json":
fail("data is a JSON value: from must be json (or omitted)")
indent = request.get("indent", 2)
if isinstance(indent, bool) or not isinstance(indent, int) or not 0 <= indent <= 8:
fail("indent must be an integer from 0 to 8")
notes = set()
try:
if "data" in request:
data = request["data"]
else:
text = request["text"]
if not isinstance(text, str):
fail("text must be a string")
if len(text) > 10_000_000:
fail("text is longer than 10 MB")
if source == "json":
try:
data = json.loads(text)
except ValueError as error:
raise ConvertError(f"JSON: {error}")
elif source == "toml":
if tomllib is None:
fail("reading TOML needs Python 3.11 or later (tomllib)")
try:
data = toml_plain(tomllib.loads(text), notes)
except tomllib.TOMLDecodeError as error:
raise ConvertError(f"TOML: {error}")
elif source == "ini":
data = read_ini(text)
notes.add("INI has no types: every value is a string")
else:
data = YamlReader(text, notes).parse()
if target == "json":
data = json_safe(data, notes)
output = json.dumps(data, indent=indent or None, ensure_ascii=False) + "\n"
elif target == "toml":
output = write_toml(data)
if source == "json" or source == "yaml":
notes.add("TOML cannot hold null; strings that look like dates stay strings")
elif target == "ini":
output = write_ini(data, notes)
else:
output = write_yaml(data)
except ConvertError as error:
fail(str(error))
except RecursionError:
fail("the input is nested too deeply")
check = None
if target == "toml" and tomllib is not None:
check = toml_plain(tomllib.loads(output), set()) == json_safe(data, set())
elif target == "yaml":
try:
check = YamlReader(output, set()).parse() == data
except ConvertError:
check = False
elif target == "json":
check = True
result = {"from": source, "to": target, "text": output, "round_trip_ok": check, "round_trip_notes": sorted(notes)}
if target == "json":
result["data"] = json.loads(output)
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()
Where it is served
The registry serves only AXLR clients, so a browser gets 403; fetch with a User-Agent that starts with axlr:
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/format_convert/1.0.0/manifest.json
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/format_convert/1.0.0/format_convert.py