Skip to content

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 →