|
| 1 | +"""The front door and the back door — one build, several shapes. |
| 2 | +
|
| 3 | + hero.export(format="hdc") -> bytes |
| 4 | + hero.export(format="json") -> dict |
| 5 | + load_build(source, format="json") -> LoadedHero |
| 6 | +
|
| 7 | +**JSON here is a transport encoding of the HDC element tree, not a second |
| 8 | +serializer.** That distinction is the whole design, and it is a repair. |
| 9 | +
|
| 10 | +The build doc used to be a hand-written subset in both directions: a list of |
| 11 | +``if getattr(...)`` lines on the way out, and three lookup tables (``_ATTR``, |
| 12 | +``_BOOL``, ``_TYPED_ATTR``) on the way in. Meanwhile the XML side wrote from |
| 13 | +DECLARED descriptors (``XML_ATTRS`` / ``xml_schema()``), so an attribute added |
| 14 | +to a class appeared in the .hdc automatically and in the doc only if somebody |
| 15 | +remembered. Nobody remembered five times running: TEXT, NOTES, a power's NAME, |
| 16 | +a modifier's ALIAS (HD's "Only With Tail" — the descriptor that makes a |
| 17 | +limitation a limitation), and AFFECTS_PRIMARY / AFFECTS_TOTAL. Every one of |
| 18 | +them is cost-neutral, and the doc's only gate compared summed cost, so the |
| 19 | +losses were invisible until each was found by hand, downstream, in the |
| 20 | +database. |
| 21 | +
|
| 22 | +Measured before this module existed: **0 of 794 corpus characters** survived |
| 23 | +``.hdc -> hero -> doc -> hero -> .hdc`` intact — 609 element kinds dropped |
| 24 | +(59,099 NOTES elements, 3,055 FOCUS modifiers, whole adder families) and 5,014 |
| 25 | +attribute keys churned. |
| 26 | +
|
| 27 | +So JSON does not get its own opinion about which fields exist. It encodes the |
| 28 | +element tree the .hdc writer already produces — ``{tag, attrs, children, |
| 29 | +text}`` — and decodes back to the same shape. Completeness is STRUCTURAL: there |
| 30 | +is no field list to drift, because there is no second field list. Anything the |
| 31 | +XML writer learns to say, JSON says the same day. |
| 32 | +""" |
| 33 | +from __future__ import annotations |
| 34 | + |
| 35 | +from typing import Any, Callable |
| 36 | + |
| 37 | +from kirby_cost.io.hdc_loader import BuildNode, HDCLoader, LoadedHero |
| 38 | +from kirby_cost.io.hdc_writer import hero_to_bytes, hero_to_element |
| 39 | + |
| 40 | + |
| 41 | +class UnknownFormat(ValueError): |
| 42 | + """No door of that name. Names the ones there are, because a typo here |
| 43 | + would otherwise read as 'this build cannot be exported'.""" |
| 44 | + |
| 45 | + |
| 46 | +_EXPORTERS: dict[str, Callable[[Any], Any]] = {} |
| 47 | +_IMPORTERS: dict[str, Callable[[Any], LoadedHero]] = {} |
| 48 | + |
| 49 | + |
| 50 | +def export_format(name: str): |
| 51 | + """Register a back door. Additive: a new shape is a registration, never an |
| 52 | + edit to a dispatcher that has to be taught about it.""" |
| 53 | + def register(fn): |
| 54 | + _EXPORTERS[name] = fn |
| 55 | + return fn |
| 56 | + return register |
| 57 | + |
| 58 | + |
| 59 | +def import_format(name: str): |
| 60 | + """Register a front door, symmetric with its back door.""" |
| 61 | + def register(fn): |
| 62 | + _IMPORTERS[name] = fn |
| 63 | + return fn |
| 64 | + return register |
| 65 | + |
| 66 | + |
| 67 | +def _known(registry: dict) -> str: |
| 68 | + return ", ".join(sorted(registry)) or "none registered" |
| 69 | + |
| 70 | + |
| 71 | +# ── the encoding ─────────────────────────────────────────────────────────── |
| 72 | + |
| 73 | +def element_to_json(element) -> dict[str, Any]: |
| 74 | + """An element tree as plain JSON-able data. |
| 75 | +
|
| 76 | + Deliberately dumb: tag, attributes verbatim as the strings the document |
| 77 | + holds, children in document order, and text when there is any. No key is |
| 78 | + renamed and no value is coerced, so nothing here can decide a field is |
| 79 | + uninteresting — the decision about what an object states was already made |
| 80 | + once, by ``write_xml_attrs``, from the declared schema. |
| 81 | + """ |
| 82 | + node: dict[str, Any] = {"tag": element.tag, "attrs": dict(element.attrib)} |
| 83 | + children = [element_to_json(child) for child in element |
| 84 | + if isinstance(child.tag, str)] |
| 85 | + if children: |
| 86 | + node["children"] = children |
| 87 | + text = (element.text or "").strip() |
| 88 | + if text: |
| 89 | + node["text"] = text |
| 90 | + return node |
| 91 | + |
| 92 | + |
| 93 | +def json_to_element(node: Any) -> BuildNode: |
| 94 | + """The inverse. Returns a ``BuildNode``, the loader's element-compatible |
| 95 | + adapter, so the decoded tree goes through the SAME construction core an |
| 96 | + .hdc does rather than a parallel one.""" |
| 97 | + if not isinstance(node, dict) or "tag" not in node: |
| 98 | + raise ValueError(f"not an encoded element: {node!r}") |
| 99 | + attrs = {str(k): str(v) for k, v in (node.get("attrs") or {}).items()} |
| 100 | + return BuildNode( |
| 101 | + str(node["tag"]), |
| 102 | + attrs, |
| 103 | + [json_to_element(child) for child in (node.get("children") or [])], |
| 104 | + text=node.get("text"), |
| 105 | + # These attributes ARE what the document stated, in its order — that is |
| 106 | + # what makes this encoding faithful rather than a curated subset, and |
| 107 | + # the loader has to be told so or the rebuild writes back a different |
| 108 | + # set. See BuildNode.stated. |
| 109 | + stated=tuple(attrs), |
| 110 | + ) |
| 111 | + |
| 112 | + |
| 113 | +# ── the doors ────────────────────────────────────────────────────────────── |
| 114 | + |
| 115 | +@export_format("hdc") |
| 116 | +def _export_hdc(hero) -> bytes: |
| 117 | + return hero_to_bytes(hero) |
| 118 | + |
| 119 | + |
| 120 | +@export_format("json") |
| 121 | +def _export_json(hero) -> dict[str, Any]: |
| 122 | + """The document, plus the document facts that live outside its tree. |
| 123 | +
|
| 124 | + ``source_encoding`` is the one that bites: HD writes UTF-16 and some files |
| 125 | + are UTF-8, ``hero_to_bytes`` defaults to the encoding the character was |
| 126 | + READ from, and a hero rebuilt from JSON has not read anything. Without it |
| 127 | + two corpus characters came back XML-identical and byte-different — the |
| 128 | + same document in the wrong encoding, which is still not the file HD |
| 129 | + wrote. |
| 130 | + """ |
| 131 | + doc: dict[str, Any] = {"document": element_to_json(hero_to_element(hero))} |
| 132 | + encoding = getattr(hero, "source_encoding", "") |
| 133 | + if encoding: |
| 134 | + doc["encoding"] = encoding |
| 135 | + return doc |
| 136 | + |
| 137 | + |
| 138 | +@import_format("hdc") |
| 139 | +def _import_hdc(source) -> LoadedHero: |
| 140 | + return HDCLoader().load_file(str(source)) |
| 141 | + |
| 142 | + |
| 143 | +@import_format("json") |
| 144 | +def _import_json(source) -> LoadedHero: |
| 145 | + """Accepts the envelope, or a bare encoded document for hand-authored |
| 146 | + input — a document with no envelope simply states no encoding.""" |
| 147 | + if isinstance(source, dict) and "document" in source: |
| 148 | + root, encoding = source["document"], source.get("encoding", "") |
| 149 | + else: |
| 150 | + root, encoding = source, "" |
| 151 | + hero = HDCLoader()._build_hero_from_root(json_to_element(root)) |
| 152 | + if encoding: |
| 153 | + hero.source_encoding = encoding |
| 154 | + return hero |
| 155 | + |
| 156 | + |
| 157 | +def export_build(hero, *, format: str = "hdc"): |
| 158 | + """The back door. ``LoadedHero.export`` is the method form of this.""" |
| 159 | + try: |
| 160 | + exporter = _EXPORTERS[format] |
| 161 | + except KeyError: |
| 162 | + raise UnknownFormat( |
| 163 | + f"no exporter for {format!r}; have: {_known(_EXPORTERS)}") from None |
| 164 | + return exporter(hero) |
| 165 | + |
| 166 | + |
| 167 | +def load_build(source, *, format: str = "hdc") -> LoadedHero: |
| 168 | + """The front door. ``source`` is a path for 'hdc', decoded data for 'json'.""" |
| 169 | + try: |
| 170 | + importer = _IMPORTERS[format] |
| 171 | + except KeyError: |
| 172 | + raise UnknownFormat( |
| 173 | + f"no importer for {format!r}; have: {_known(_IMPORTERS)}") from None |
| 174 | + return importer(source) |
0 commit comments