XML ↔ JSON Converter — Python source
Convert XML to JSON and back, preserving attributes. Validates input and reports errors clearly, runs entirely in your browser, with a shareable link to your exact input.
This is the Python implementation — the same logic the interactive tool runs, in a shareable, citable form.
"""xml-to-json — bidirectional XML <-> JSON converter.
Language: Python (3.9+, standard library only)
Source: CosmoDev polyglot showcase port of the XML-to-JSON tool, ported from
cli/xml-to-json/xml-to-json.go (the canonical Go CLI twin — this port
mirrors its contract rather than the TS lib, which wraps
fast-xml-parser).
License: display source — part of CosmoDev's polyglot tool pages.
Design goals:
- Pure + deterministic; never raises (malformed input returns ``None``).
- Functionally equivalent to the Go twin: same inputs -> same JSON structure.
- Self-contained: stdlib only (``xml.etree.ElementTree`` + ``json``).
Contract (matches the Go twin, ``encoding/xml`` + ``encoding/json``):
- Malformed XML (any parse error, no root, multiple roots) -> ``None``.
- Attributes become ``@_<name>`` string keys.
- A text-only element with no attributes becomes its bare string value.
- An element with attributes and/or children becomes a dict; its direct
text becomes the ``#text`` key.
- Repeated child tags become a JSON array.
Note: ``ElementTree`` is a strict parser (raises ``ParseError`` on mismatched
tags and on documents without a single root), so the malformed-input contract
falls out of a single ``try/except``. The Go twin leaves element text as
strings (no number coercion) — we do the same.
Security note: the stdlib parser resolves external entities by default. For
UNTRUSTED input, swap ``xml.etree.ElementTree`` for ``defusedxml.ElementTree``
(a drop-in that disables entity resolution and billion-laughs expansion). This
showcase stays stdlib-only to mirror the Go twin (``encoding/xml``) with no pip
dependencies; the live web tool parses in the browser via fast-xml-parser.
"""
from __future__ import annotations
import json
import xml.etree.ElementTree as ET
from typing import Any, Optional
__all__ = ["xml_to_json", "json_to_xml"]
def _strip_ns(tag: str) -> str:
"""Return the local part of a possibly namespaced ElementTree tag.
ElementTree renders namespaced tags as ``{uri}local``; the Go twin uses
``Name.Local`` (namespace stripped). We mirror that.
"""
if tag.startswith("{"):
return tag.split("}", 1)[1]
return tag
def _direct_text(elem: ET.Element) -> str:
"""Concatenate the element's DIRECT text (text + child tails), trimmed.
This mirrors the Go twin accumulating ``CharData`` tokens within an element
(the text before/after child tags), NOT the flattened descendant text.
"""
parts: list[str] = []
if elem.text:
parts.append(elem.text)
for child in elem:
if child.tail:
parts.append(child.tail)
return "".join(parts).strip()
def _element_to_value(elem: ET.Element) -> Any:
"""Convert an ElementTree element to the JSON value the Go twin produces."""
attrs = {f"@_{_strip_ns(k)}": v for k, v in elem.attrib.items()}
children = list(elem)
text = _direct_text(elem)
if not children and not attrs:
# Pure text element (or empty element) — value is the bare string.
return text
obj: dict[str, Any] = dict(attrs)
for child in children:
tag = _strip_ns(child.tag)
val = _element_to_value(child)
if tag in obj:
cur = obj[tag]
if isinstance(cur, list):
cur.append(val)
else:
obj[tag] = [cur, val]
else:
obj[tag] = val
if text:
obj["#text"] = text
return obj
def xml_to_json(xml: str) -> Optional[str]:
"""Parse a well-formed XML string to pretty-printed JSON (2-space indent,
sorted keys for deterministic output matching the Go twin's
``json.MarshalIndent``), or ``None`` if malformed."""
try:
root = ET.fromstring(xml)
except ET.ParseError:
return None
result = {_strip_ns(root.tag): _element_to_value(root)}
return json.dumps(result, indent=2, ensure_ascii=False, sort_keys=True)
def _stringify(value: Any) -> str:
"""Render a JSON-decoded scalar as a string for XML text/attribute content
(mirrors the Go twin's ``stringify``)."""
if isinstance(value, bool):
return "true" if value else "false"
if value is None:
return ""
if isinstance(value, float):
if value == int(value):
return str(int(value))
return repr(value)
if isinstance(value, int):
return str(value)
return str(value)
def _build_element(tag: str, value: Any) -> ET.Element:
"""Build an ElementTree element from a JSON value, mirroring the Go twin's
``writeElement``: ``@_<name>`` -> attribute, ``#text`` -> text, every other
key -> child element, array value -> repeated siblings."""
el = ET.Element(tag)
if isinstance(value, dict):
# Sort keys for deterministic output, matching the Go twin's
# sortedKeys() iteration.
for key in sorted(value):
v = value[key]
if isinstance(key, str) and key.startswith("@_"):
el.set(key[2:], _stringify(v))
elif key == "#text":
el.text = _stringify(v)
else:
if isinstance(v, list):
for item in v:
el.append(_build_element(key, item))
else:
el.append(_build_element(key, v))
else:
el.text = _stringify(value)
return el
def json_to_xml(json_str: str) -> Optional[str]:
"""Build an indented XML string from a JSON string whose root is an object.
Returns ``None`` if the JSON does not parse or the root is not an object
(numbers, strings, bools, null, arrays are rejected)."""
try:
obj = json.loads(json_str)
except (ValueError, TypeError):
return None
if not isinstance(obj, dict):
return None
parts: list[str] = []
for key in sorted(obj):
el = _build_element(key, obj[key])
ET.indent(el, space=" ") # 2-space indent, matching the Go twin.
parts.append(ET.tostring(el, encoding="unicode"))
return "".join(parts)
# ---------- showcase tests (the canonical suite lives in src/lib) ----------
def _run_showcase_tests() -> None:
"""Mirror the src/lib/xml-to-json.test.ts vectors (assert on parsed
structure so formatting differences don't mask contract drift)."""
# 1. simple parse
parsed = json.loads(xml_to_json("<root><name>Alice</name></root>"))
assert parsed["root"]["name"] == "Alice"
# 2. attributes preserved as @_<name> strings
parsed = json.loads(xml_to_json('<user id="7"><name>Alice</name></user>'))
assert parsed["user"]["@_id"] == "7"
assert parsed["user"]["name"] == "Alice"
# 3. repeated child tags become a JSON array
parsed = json.loads(xml_to_json("<list><item>a</item><item>b</item></list>"))
assert parsed["list"]["item"] == ["a", "b"]
# 4. malformed XML -> None
assert xml_to_json("<a><b></a>") is None
assert xml_to_json("not xml at all") is None
# 5. json_to_xml containment + attributes + invalid roots
assert "<name>Alice</name>" in json_to_xml('{"root":{"name":"Alice"}}')
assert 'id="7"' in json_to_xml('{"user":{"@_id":"7","name":"Alice"}}')
assert json_to_xml("{not valid json") is None
assert json_to_xml("42") is None
assert json_to_xml("null") is None
if __name__ == "__main__":
_run_showcase_tests()
print("ok")
Also available in 13 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →