"""Drive xmltodict's parse/unparse round trip and its two mapping options, and print one key=value per line. ONE INTERPRETER START DOES THE WHOLE OF IT. Every python3 start on this arm is a fresh load of one large static image (99-python3 records the cost), so the suite that drives this asserts on the OUTPUT rather than calling python3 once per case. Every line is `key=value`; a key is emitted at most once. The suite reads the keys it names and ignores the rest, so adding a fact here cannot break it. Failure is reported as a key too -- `*_error` -- rather than only as an exit status, because the case that reds should say which stage stopped and why. THE FIXTURE IS INLINE RATHER THAN SHIPPED IN data/. The parse expectation is a literal in this file and the round trip compares two parses of documents derived from it, so the document and the expectation have to be pinned together: a shipped .xml could drift from the literal by an editor's trailing newline and turn a correct library into a red. THE MAPPING IS ASSERTED AS A SORTED, FLATTENED PATH LIST, NOT AS A repr. xmltodict 0.13.0 returns plain `dict` on Python 3.7+ (`_dict = dict`, and the OrderedDict fallback is gated below 3.7), so a repr would be asserting dict insertion order -- a property of the interpreter rather than of this library. The flattened form states the same content and each red names ONE path instead of printing the whole document back. """ import hashlib import sys _seen = set() def emit(key, value): if key in _seen: return _seen.add(key) # One line per fact: the suite greps by key, and an embedded newline would # land as a second, unkeyed line. print("%s=%s" % (key, str(value).replace("\n", " ").replace("\r", " "))) def fail(key, exc): emit(key, "%s: %s" % (type(exc).__name__, exc)) raise SystemExit(1) # ------------------------------------------------------------------ the import try: import xmltodict except Exception as exc: # noqa: BLE001 - reported, not raised fail("import_error", exc) # THE EXPAT BINDING, REPORTED SEPARATELY AND FIRST. xmltodict's whole parser is # `xml.parsers.expat`; on this image pyexpat is BUILTIN rather than a lib-dynload # .so (99-python3/builtin-modules asserts that directly), so a python3 rebuilt # without it would present as a broken xmltodict. This key is what tells the two # apart. try: import pyexpat emit("pyexpat_builtin", "yes" if "pyexpat" in sys.builtin_module_names else "no") emit("expat_version", "%d.%d.%d" % pyexpat.version_info) except Exception as exc: # noqa: BLE001 fail("pyexpat_error", exc) emit("module_file", xmltodict.__file__) # THE VERSION FROM BOTH SIDES. xmltodict DOES carry `__version__` -- setup.py # reads it to set the sdist's own version -- so the module's answer and the # installed metadata's are two independent statements of the same fact, and the # two disagreeing means a mixed install: a dist-info from one version beside a # module from another. emit("version", getattr(xmltodict, "__version__", "")) try: from importlib.metadata import version as _dist_version emit("dist_version", _dist_version("xmltodict")) except Exception as exc: # noqa: BLE001 emit("dist_version_error", "%s: %s" % (type(exc).__name__, exc)) # THE PUBLIC SURFACE. xmltodict declares NO `__all__` -- it is one flat module, # and `dir()` is therefore the only statement of surface it has. The names # imported into module scope (expat, XMLGenerator, StringIO, ...) are part of that # answer and are deliberately not filtered out: they are what a `from xmltodict # import *` would bind, and a release that stopped importing one of them has # changed its parser, not its formatting. emit("api_surface", ",".join(sorted(n for n in dir(xmltodict) if not n.startswith("_")))) # ----------------------------------------------------------------- the fixture # One document exercising each rule the mapping has, and nothing else: # - ATTRIBUTES -> keys prefixed `@` (`@version`, `@id`, `@lang`) # - A REPEATED ELEMENT -> `book` must become a LIST, not a last-one-wins # scalar; this is the rule a naive dict mapping # gets wrong, and the two books differ in shape # (one has an attribute and a namespaced child, # the other does not) so a list of the wrong # order is distinguishable from the right one # - MIXED TEXT AND CHILDREN -> text under `#text`, the child beside it # - AN EMPTY ELEMENT -> None, which is NOT the same as the empty string # and is what unparse turns back into `` # - A DECLARED NAMESPACE -> `xmlns:dc` arrives as an ordinary `@xmlns:dc` # attribute with process_namespaces off, and is # CONSUMED with it on # # No indentation between elements: xmltodict's `strip_whitespace` default would # drop it, so pretty-printing the fixture would make this file's expectation a # statement about that option rather than about the mapping. FIXTURE = ( '' '' 'FirstAda' 'Second' 'beforeemphafter' '' '' ) emit("fixture_len", len(FIXTURE)) emit("fixture_sha", hashlib.sha256(FIXTURE.encode("utf-8")).hexdigest()[:16]) def flatten(obj, prefix=""): """`path=value` pairs, sorted, with a list index in the path. The suite asserts this rather than a repr for the reason in the header: the mapping's CONTENT is xmltodict's contract and the dict's iteration order is the interpreter's. `None` is spelled `` so an empty element is distinguishable from an element holding the empty string, which is the one pair a str() of either would collapse. """ out = [] if isinstance(obj, dict): for k in sorted(obj): out.extend(flatten(obj[k], "%s/%s" % (prefix, k))) elif isinstance(obj, list): for i, v in enumerate(obj): out.extend(flatten(v, "%s[%d]" % (prefix, i))) elif obj is None: out.append("%s=" % prefix) else: out.append("%s=%s" % (prefix, obj)) return out # -------------------------------------------------------------------- the parse try: doc = xmltodict.parse(FIXTURE) except Exception as exc: # noqa: BLE001 fail("parse_error", exc) emit("parse_ok", "yes") # THE MAPPING RULES, ONE KEY EACH, so a red names the rule that broke rather than # the document. Each is read off the parsed object directly; the flattened whole # below is what catches anything these six do not name. root = doc.get("catalog") emit("root_key", ",".join(sorted(doc))) emit("attr_root_version", root.get("@version")) books = root.get("book") emit("repeated_is_list", "yes" if isinstance(books, list) else "no") emit("repeated_count", len(books) if isinstance(books, list) else "") emit("attr_book_id", books[0].get("@id") if isinstance(books, list) else "") emit("child_text", books[0].get("title") if isinstance(books, list) else "") # The namespaced child WITHOUT process_namespaces: the prefix is part of the key, # verbatim, and the declaration is an ordinary attribute on the root. emit("ns_key_raw", books[0].get("dc:creator") if isinstance(books, list) else "") emit("ns_decl_attr", root.get("@xmlns:dc")) # MIXED CONTENT: the text lands under `#text` and the child beside it. The two # runs of text either side of are concatenated -- that is xmltodict's # documented behaviour and the reason a round trip cannot restore their order. note = root.get("note") emit("mixed_text", note.get("#text") if isinstance(note, dict) else "") emit("mixed_child", note.get("em") if isinstance(note, dict) else "") emit("mixed_attr", note.get("@level") if isinstance(note, dict) else "") # AN EMPTY ELEMENT IS None, not "". `flatten` spells it ``; here it is # stated as a type so the distinction is asserted rather than formatted. emit("empty_is_none", "yes" if root.get("empty", "sentinel") is None else "no") emit("root_type", type(doc).__name__) # THE WHOLE MAPPING, flattened and sorted. This is the key-by-key assertion the # ticket asks for: everything above names one rule, this states the document. emit("parse_flat", " ".join(flatten(doc))) # --------------------------------------------------------------- the round trip # unparse the parsed object, re-parse the result, and compare the two PARSES. # The comparison is deliberately not against the fixture BYTES: unparse emits its # own XML declaration and its own attribute order, and mixed content cannot be # restored to its original interleaving at all (see `mixed_text`). What the port # promises is that the STRUCTURE survives the trip, and that is what this asserts. try: xml2 = xmltodict.unparse(doc) except Exception as exc: # noqa: BLE001 fail("unparse_error", exc) emit("unparse_ok", "yes") emit("unparse_len", len(xml2)) emit("unparse_sha", hashlib.sha256(xml2.encode("utf-8")).hexdigest()[:16]) emit("unparse_declaration", "yes" if xml2.startswith('') else "no") # The empty element came back as an empty element rather than as an empty string # or a dropped key -- the one place a None that had become "" would show. emit("unparse_empty_element", "yes" if "" in xml2 or "" in xml2 else "no") try: doc2 = xmltodict.parse(xml2) except Exception as exc: # noqa: BLE001 fail("reparse_error", exc) emit("reparse_ok", "yes") emit("roundtrip_equal", "yes" if doc2 == doc else "no") # ...and the same statement in a form a red can be read from: if the two differ, # this key names WHERE, instead of leaving `roundtrip_equal=no` with nothing # behind it. _f1, _f2 = flatten(doc), flatten(doc2) emit("roundtrip_diff", " ".join(sorted(set(_f1) ^ set(_f2))) or "") # ------------------------------------------------------- option: namespaces --- # process_namespaces=True REWRITES THE KEY, which is a mapping change and not a # formatting one: `dc:creator` becomes the expanded `:creator`, and the # `xmlns:dc` declaration is consumed rather than carried as an attribute. Both # halves are asserted, because a build that expanded the key while leaving the # declaration behind would be neither behaviour. try: docns = xmltodict.parse(FIXTURE, process_namespaces=True) except Exception as exc: # noqa: BLE001 fail("ns_parse_error", exc) emit("ns_parse_ok", "yes") _nsroot = docns.get("catalog") _nsbooks = _nsroot.get("book") if isinstance(_nsroot, dict) else None emit("ns_expanded_key", "yes" if isinstance(_nsbooks, list) and "http://purl.org/dc/elements/1.1/:creator" in _nsbooks[0] else "no") emit("ns_expanded_value", _nsbooks[0].get("http://purl.org/dc/elements/1.1/:creator") if isinstance(_nsbooks, list) else "") emit("ns_prefixed_key_gone", "yes" if isinstance(_nsbooks, list) and "dc:creator" not in _nsbooks[0] else "no") emit("ns_decl_consumed", "yes" if isinstance(_nsroot, dict) and "@xmlns:dc" not in _nsroot else "no") # ------------------------------------------- option: attr_prefix / cdata_key --- # These two also change the MAPPING rather than the formatting: they decide which # key an attribute and a text node land under, so a caller that sets them reads a # different document out of the same bytes. Asserted on the keys themselves, and # on the ABSENCE of the defaults, so a build that added the new spelling beside # the old one is a red rather than a pass. try: docopt = xmltodict.parse(FIXTURE, attr_prefix="!", cdata_key="$text") except Exception as exc: # noqa: BLE001 fail("opt_parse_error", exc) emit("opt_parse_ok", "yes") _optroot = docopt.get("catalog") _optnote = _optroot.get("note") if isinstance(_optroot, dict) else None emit("opt_attr_key", "yes" if isinstance(_optroot, dict) and "!version" in _optroot else "no") emit("opt_attr_value", _optroot.get("!version") if isinstance(_optroot, dict) else "") emit("opt_default_attr_gone", "yes" if isinstance(_optroot, dict) and "@version" not in _optroot else "no") emit("opt_cdata_key", "yes" if isinstance(_optnote, dict) and "$text" in _optnote else "no") emit("opt_cdata_value", _optnote.get("$text") if isinstance(_optnote, dict) else "") emit("opt_default_cdata_gone", "yes" if isinstance(_optnote, dict) and "#text" not in _optnote else "no") # ------------------------------------------------------------ the CVE guards --- # The recipe carries the two CVE-2025-9375 backports, and they are BEHAVIOUR: an # element or attribute name carrying `<` or `>` is rejected with ValueError # instead of being written out to break the tag context. The port ships them, so # the suite states them -- an unpatched sdist would emit the injected markup and # report `no` here rather than failing to build. def _rejects(payload): try: xmltodict.unparse(payload, full_document=False) except ValueError: return "yes" except Exception as exc: # noqa: BLE001 return "other:%s" % type(exc).__name__ return "no" emit("cve_rejects_tag_name", _rejects({"m>contentcontent2", "#text": "x"}}, full_document=False) emit("cve_escapes_attr_value", "yes" if "1<middle>2" in _esc else "no") except Exception as exc: # noqa: BLE001 emit("cve_escapes_attr_value", "error:%s" % type(exc).__name__) emit("done", "yes")