From 0febc8c8603da17c8132b42d65980955b65b3779 Mon Sep 17 00:00:00 2001 From: Samuel Letellier-Duchesne Date: Fri, 4 Sep 2026 01:23:38 -0400 Subject: [PATCH 1/6] Document the seven ways the two writers disagree The parity record carried these in its differences field while write was partial on both sides. Feature 002 closed the five controls, so the entry is complete, and the gate does not keep a differences field on a capability that is complete: a reader would have no way to tell a difference that still matters from one that was left behind. So they come here. If this page goes away the information exists nowhere, and the record says so at the point it stopped carrying them. The measurement is stated rather than asserted, and was re-taken the day the entry closed: 5ZoneAirCooled.idf from EnergyPlus 26.1.0, 359 objects, 4,031 lines against 4,125. A reader can re-derive it in three lines. The page says plainly that neither writer is more correct, that both are published, that byte-identical output across the two languages is not promised and is not coming, and that the thing to do instead is pass EnergyPlus the model rather than diff two outputs. The Python examples run from docs/snippets. The TypeScript ones are referenced at the vendored path every other page uses; the snippet itself lives in idfkit-js and reaches this repository through a docs release, so the TypeScript half of this page renders once that release is cut. --- docs/explanation/two-writers-one-model.md | 107 ++++++++++++++++++ .../explanation/two_writers_one_model.py | 21 ++++ mkdocs.yml | 1 + pyproject.toml | 2 +- tests/test_copy_shipped_assets.py | 4 +- 5 files changed, 131 insertions(+), 4 deletions(-) create mode 100644 docs/explanation/two-writers-one-model.md create mode 100644 docs/snippets/explanation/two_writers_one_model.py diff --git a/docs/explanation/two-writers-one-model.md b/docs/explanation/two-writers-one-model.md new file mode 100644 index 0000000..d3bc666 --- /dev/null +++ b/docs/explanation/two-writers-one-model.md @@ -0,0 +1,107 @@ +# Two writers, one model + +Both libraries write IDF. Hand them the same model and you get two files that +EnergyPlus reads identically and `diff` does not. + +{{ parity("write") }} + +That is not a defect in either one, and it is not going to be resolved. This +page says what the seven differences are, why they are not being removed, and +what to do instead of diffing. + +## The seven differences, measured + +Measured on `5ZoneAirCooled.idf` from EnergyPlus 26.1.0, read and written back +by each library with no options set: **359 objects, 4,031 lines against 4,125.** + +A measured claim reads differently from an asserted one, so the number is +re-derivable: load the file, write it, count the lines. + +| | Python | TypeScript | +| --- | --- | --- | +| Generator header | `!-Generator idfkit v…` and `!-Option SortedOrder` | none | +| Object ordering | sorted by type name, `Version` first | insertion order, `Version` first | +| Indent | two spaces | four spaces | +| Comment overflow | a long value pushes the comment right | the same, from a different padding calculation | +| Float rendering | `%g`, so `30.0` becomes `30` | the schema decides, so a number field keeps `30.0` | +| Comment capitalisation | every word title-cased, `Number Of Timesteps Per Hour` | minor words lowercased, `Number of Timesteps per Hour` | +| Extensible-group comment numbering | first group unsuffixed, then ` 2`, ` 3` | the same scheme, applied at a different point | + +The two that surprise people are float rendering and comment capitalisation, +because they touch almost every line of a large file and neither looks like a +choice until you see the other one. + +## Neither writer is more correct + +Both outputs are valid IDF. Both are read by EnergyPlus without complaint. Both +have been published for long enough that somebody's diff, somebody's test +fixture and somebody's version-controlled model depend on the exact bytes. + +So neither default moves. Changing Python's `%g` to match TypeScript would be +just as much of a break as the reverse, and picking a winner would mean picking +whose files churn. + +**Byte-identical output across the two languages is not promised, and it is not +coming.** If a workflow depends on it, that workflow needs to change rather than +wait. + +## What to do instead of diffing + +Pass EnergyPlus the model. It is the thing that reads IDF, it does not care +which library wrote the file, and it is the only opinion that decides whether a +model runs. + +When you do need to compare two models, compare them as models: read both files +and compare the parsed documents, which is what the conformance corpus does. It +re-reads each library's own output and compares the resulting documents field by +field, never the text. Two files that differ on all seven of the above compare +equal that way, because all seven are presentation. + +## The controls, which do not change any of this + +Five controls exist on both writers, and closing them was +[feature 002](conformance.md). They let you ask for output shaped differently. +They do not make the two writers agree, because none of them touches the seven +defaults above. + +=== "Python" + + ```python + --8<-- "docs/snippets/explanation/two_writers_one_model.py:controls" + ``` + +=== "TypeScript" + + ```ts + --8<-- "docs/snippets/js/explanation/two-writers-one-model/controls.ts:controls" + ``` + +The most aggressive is compressed output: one object per line, no comments, no +blank separators, no header. + +=== "Python" + + ```python + --8<-- "docs/snippets/explanation/two_writers_one_model.py:compressed" + ``` + +=== "TypeScript" + + ```ts + --8<-- "docs/snippets/js/explanation/two-writers-one-model/controls.ts:compressed" + ``` + +Compressed output from the two libraries is still not byte-identical: it removes +comments, indentation and blank lines, and it does not touch float rendering. +What it does guarantee, and what the corpus checks, is that a document written +under any of these controls re-reads to the same document it came from. + +## Where this is recorded + +The parity record used to carry these seven differences in its `differences` +field, because `write` was a partial capability on both sides. Closing the five +controls made it complete, and the record does not keep a `differences` field on +a capability that is complete: a reader would have no way to tell a difference +that still matters from one that was left behind. + +So they live here. If this page goes away, the information exists nowhere. diff --git a/docs/snippets/explanation/two_writers_one_model.py b/docs/snippets/explanation/two_writers_one_model.py new file mode 100644 index 0000000..d5068af --- /dev/null +++ b/docs/snippets/explanation/two_writers_one_model.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +# --8<-- [start:controls] +import idfkit + +model = idfkit.load_idf("5ZoneAirCooled.idf") + +# Every control, at a value that is not the default. +text = idfkit.write_idf( + model, + indent=4, + comment_column=45, + ordering="source", +) +# --8<-- [end:controls] + +# --8<-- [start:compressed] +compact = idfkit.write_idf(model, output_type="compressed") +# --8<-- [end:compressed] + +_ = text, compact diff --git a/mkdocs.yml b/mkdocs.yml index 3132c92..49fca0d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -157,6 +157,7 @@ nav: - explanation/index.md - The hazards of a positional format: explanation/positional-format-hazards.md - Why field names come from epJSON: explanation/epjson-field-names.md + - Two writers, one model: explanation/two-writers-one-model.md # Both generated from the pinned governance tag. They were reachable only by # direct link until now, which made a generated page that nothing navigates to. - Capability parity: explanation/parity.md diff --git a/pyproject.toml b/pyproject.toml index 5f33769..3699b4b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,7 +71,7 @@ level = "conformance-2026.7" # idfkit-conformance. Read by docs/hooks/parity_macro.py, scripts/render_parity_page.py and # scripts/render_naming_map.py through the duplicated scripts/_governance_source.py. [tool.idfkit.governance] -level = "governance-2026.9" +level = "governance-2026.10" # Documentation artifact level the TypeScript half of the site renders from, as an immutable tag # in idfkit-js. It carries the TypeScript examples the pages include and the TypeDoc JSON the diff --git a/tests/test_copy_shipped_assets.py b/tests/test_copy_shipped_assets.py index ad9a7f5..afcfeaf 100644 --- a/tests/test_copy_shipped_assets.py +++ b/tests/test_copy_shipped_assets.py @@ -56,9 +56,7 @@ def test_the_destination_is_created(fake_distribution: Path, tmp_path: Path) -> @pytest.mark.parametrize("absent", [relative for relative, _ in ASSETS]) -def test_a_missing_asset_stops_the_build( - fake_distribution: Path, tmp_path: Path, absent: str -) -> None: +def test_a_missing_asset_stops_the_build(fake_distribution: Path, tmp_path: Path, absent: str) -> None: """Each of the four, one at a time. A build must stop, not render a broken widget.""" (fake_distribution / absent).unlink() destination = tmp_path / "docs" / "weather" / "browse" From 65859de905d0634211ab249ed944c1fda01f72a8 Mon Sep 17 00:00:00 2001 From: Samuel Letellier-Duchesne Date: Fri, 4 Sep 2026 01:28:50 -0400 Subject: [PATCH 2/6] Say that logging is no longer the only way to reach a parse finding The how-to opened by explaining that the two libraries report recoverable findings differently and that the difference was idiomatic. That was true and is not any more: both hand the findings back beside the document now, in one call, with nothing to configure first. The logging path keeps its own section rather than being deleted, because it still works, still fires every record it did, and is still the better choice for watching a long batch go by. What it is no longer is the only way in. The logging concept page gains the same note from the other end, with a table saying which of the two to reach for, and a reminder that everything which is not a parse finding, the timings and the mmap notices and the simulation records, is still reachable only through logging. --- docs/concepts/logging.md | 22 +++++++++ docs/how-to/collect-diagnostics.md | 75 +++++++++++++++++++++--------- 2 files changed, 75 insertions(+), 22 deletions(-) diff --git a/docs/concepts/logging.md b/docs/concepts/logging.md index 6d5e422..2907c09 100644 --- a/docs/concepts/logging.md +++ b/docs/concepts/logging.md @@ -156,6 +156,28 @@ Here are some representative messages you will see at each level: | `idfkit.idf_parser` | `Skipping unknown object type 'FooBar'` | | `idfkit.simulation.runner` | `Simulation exited with code 1 in 5.2s` | +## Logging is no longer the only way to reach a parse finding + +The parser's WARNING records were once the only way to see what a non-strict +parse skipped. They are not any more: +[`load_idf_with_diagnostics`](../how-to/collect-diagnostics.md) returns the +findings beside the document, in one call, with no handler installed first. + +**Every record above still fires, unchanged.** The returning path was added +beside the logging path, not in place of it, and code that installed a handler +sees exactly what it saw before. + +Which to reach for: + +| | Use | +| --- | --- | +| You want to act on what was skipped | `load_idf_with_diagnostics`. A finding is a structured value with a `code`, a line and a column; a log record is a formatted sentence you would have to parse back. | +| You want to watch a long batch go by | Logging. It fires as the parse proceeds, and nothing accumulates unless you accumulate it. | +| You want both | Do both. They are independent. | + +Logging still carries everything that is not a parse finding: the timings, the +mmap notices, the simulation records. None of that is reachable any other way. + ## See Also - [How to handle simulation errors](../simulation/errors.md) — Handling simulation failures diff --git a/docs/how-to/collect-diagnostics.md b/docs/how-to/collect-diagnostics.md index afec866..57b87ca 100644 --- a/docs/how-to/collect-diagnostics.md +++ b/docs/how-to/collect-diagnostics.md @@ -38,29 +38,23 @@ has nothing to do with how the file was parsed. ## Collect what was skipped -The two libraries report the recoverable findings differently, and the -difference is idiomatic rather than accidental. TypeScript returns them beside -the document, because a browser caller that gets a throw loses the partial -model it could still have shown. Python sends them to the standard library's -logging module, because a Python caller expects a failure it must handle to -arrive as an exception and everything else to arrive as a log record. +Both libraries hand the recoverable findings back beside the document, in one +call, with nothing to configure first. + +Python did not, until recently. The findings went to the standard library's +logging module and nowhere else, so reaching them meant installing a handler +before the parse. That still works and is still supported, and the section +below shows it; it is no longer the only way. === "Python" ```python - import logging - - from idfkit import parse_idf - - - class CollectDiagnostics(logging.Handler): - def emit(self, record: logging.LogRecord) -> None: - print(f"{record.name}: {record.getMessage()}") + from idfkit import load_idf_with_diagnostics - - logging.getLogger("idfkit").addHandler(CollectDiagnostics()) - model = parse_idf("model.idf", strict_parsing=False) - # idfkit.idf_parser: Skipped 1 unknown object type(s): Nonsense:Type + result = load_idf_with_diagnostics("model.idf") + for d in result.diagnostics: + print(f"{d.code} at line {d.line}: {d.message}") + # UnknownObjectType at line 12: Unknown object type 'Nonsense:Type' ``` === "TypeScript" @@ -69,6 +63,36 @@ arrive as an exception and everything else to arrive as a log record. --8<-- "docs/snippets/js/how-to/collect-diagnostics/collect_what_was_skipped.ts:example" ``` +`load_idf_with_diagnostics` always parses non-strictly, because a strict parse +has no recoverable findings: the first one stops it. There is one finding per +problem, not one per distinct kind of problem, and each carries its own line. + +### The logging path still works + +Every log record the parser emitted before it still fires, unchanged. A caller +who installed a handler sees exactly what they saw, whether or not anything +calls the returning path. + +```python +import logging + +from idfkit import parse_idf + + +class CollectDiagnostics(logging.Handler): + def emit(self, record: logging.LogRecord) -> None: + print(f"{record.name}: {record.getMessage()}") + + +logging.getLogger("idfkit").addHandler(CollectDiagnostics()) +model = parse_idf("model.idf", strict_parsing=False) +# idfkit.idf_parser: Skipped 1 unknown object type(s): Nonsense:Type +``` + +The log record is a formatted sentence and the finding is a structured value, +so the returning path is the better one to build on. The logging path is the +better one for a long batch you only want to watch. + For a large batch, TypeScript's `onDiagnostic` fires as each problem is found, so you never hold every diagnostic for every file at once. The callback fires in addition to the array being populated, not instead of it. Python's logging @@ -82,8 +106,13 @@ accumulates unless you accumulate it. ## Read the error when you do want to stop Strict parsing is the right default for a script, and it still tells you where -the problem is. Every diagnostic carries a message and a line, plus the object -type when the parser knew which object it was inside. +the problem is. Both libraries raise, and the error carries every finding that +stopped the parse rather than one flattened into fields. + +Every diagnostic carries a message, a machine-readable `code`, and as much +location as the parser had: a line, and the object type and column when it knew +them. Match on `code`, never on `message`: the codes are the same eight values +in both languages, and the wording is free to improve. === "Python" @@ -114,8 +143,10 @@ diagnostics with it. Use `loadIdfWithDiagnostics` when you want both. --8<-- "docs/snippets/js/how-to/collect-diagnostics/keep_the_diagnostics_when_reading_from_a_file.ts:example" ``` -Python has no equivalent pair: `parse_idf` reads from a path already, and the -recoverable findings reach you through logging whichever way you call it. +Python has the same pair, spelled the same way round: `load_idf` returns the +document and drops the findings, `load_idf_with_diagnostics` returns both. The +result type is called `ParseResult` in each language and carries the same two +members, `document` and `diagnostics`, in that order. ## Diagnostics are not validation From 8907dec827d880cf200ea6eb2950a97ab5544a9f Mon Sep 17 00:00:00 2001 From: Samuel Letellier-Duchesne Date: Fri, 4 Sep 2026 01:56:10 -0400 Subject: [PATCH 3/6] Stop the new page from breaking every build of the site The page included a vendored TypeScript snippet that does not exist yet, and pymdownx.snippets runs with check_paths: true, so mkdocs build aborted with SnippetMissingError and produced no site at all. Not a missing tab on one page: the whole build. The snippet lives in idfkit-js and reaches this repository through a docs release, which has not been cut. The includes are removed and the page says so in prose, so it builds now and gains its TypeScript examples when the release lands. The page also claimed five controls exist on both writers. Three do; ordering and versionFirst are each spelled on one side only, and the page now names them. --- docs/explanation/two-writers-one-model.md | 45 ++++++++++------------- 1 file changed, 20 insertions(+), 25 deletions(-) diff --git a/docs/explanation/two-writers-one-model.md b/docs/explanation/two-writers-one-model.md index d3bc666..a33c02e 100644 --- a/docs/explanation/two-writers-one-model.md +++ b/docs/explanation/two-writers-one-model.md @@ -59,37 +59,32 @@ equal that way, because all seven are presentation. ## The controls, which do not change any of this -Five controls exist on both writers, and closing them was -[feature 002](conformance.md). They let you ask for output shaped differently. -They do not make the two writers agree, because none of them touches the seven +Five controls were closed by [feature 002](conformance.md), so that no control +sits on one writer with no answer on the other. Two of them are still spelled on +one side only: Python's `ordering` has no TypeScript counterpart, because that +writer keeps insertion order and offers no sort, and TypeScript's `versionFirst` +has no Python counterpart. They let you ask for output shaped differently. They +do not make the two writers agree, because none of them touches the seven defaults above. -=== "Python" - - ```python - --8<-- "docs/snippets/explanation/two_writers_one_model.py:controls" - ``` - -=== "TypeScript" - - ```ts - --8<-- "docs/snippets/js/explanation/two-writers-one-model/controls.ts:controls" - ``` +```python +--8<-- "docs/snippets/explanation/two_writers_one_model.py:controls" +``` The most aggressive is compressed output: one object per line, no comments, no blank separators, no header. -=== "Python" - - ```python - --8<-- "docs/snippets/explanation/two_writers_one_model.py:compressed" - ``` - -=== "TypeScript" - - ```ts - --8<-- "docs/snippets/js/explanation/two-writers-one-model/controls.ts:compressed" - ``` +```python +--8<-- "docs/snippets/explanation/two_writers_one_model.py:compressed" +``` + +The TypeScript half of both examples is written and type-checked in +`idfkit-js` at `docs-snippets/explanation/two-writers-one-model/controls.ts`. It +appears here as a tab beside the Python one once `idfkit-js` cuts the docs +release that carries it and `scripts/sync_js_artifacts.py` vendors it into +`docs/snippets/js/`; that directory is vendored wholesale from the pinned +`[tool.idfkit.docs]` level and must match it exactly, so the file cannot be +added here by hand. Compressed output from the two libraries is still not byte-identical: it removes comments, indentation and blank lines, and it does not touch float rendering. From 50401f3c2099a6ff3bd364b76476c0ba7a464363 Mon Sep 17 00:00:00 2001 From: Samuel Letellier-Duchesne Date: Fri, 4 Sep 2026 06:33:16 -0400 Subject: [PATCH 4/6] The controls table, now that every one exists on both writers The page said five controls were closed and two were still spelled on one side only. Both have been added since, so the claim is replaced with a table naming each control in each language and the defaults that still differ, which is the distinction the page exists to make: a control lets you ask for the other behaviour, it does not change what you get by asking for nothing. The Python example gains version_first, so every control on the page appears in a snippet that runs. --- docs/explanation/two-writers-one-model.md | 21 +++++++++++++------ .../explanation/two_writers_one_model.py | 1 + 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/docs/explanation/two-writers-one-model.md b/docs/explanation/two-writers-one-model.md index a33c02e..9346446 100644 --- a/docs/explanation/two-writers-one-model.md +++ b/docs/explanation/two-writers-one-model.md @@ -59,14 +59,23 @@ equal that way, because all seven are presentation. ## The controls, which do not change any of this -Five controls were closed by [feature 002](conformance.md), so that no control -sits on one writer with no answer on the other. Two of them are still spelled on -one side only: Python's `ordering` has no TypeScript counterpart, because that -writer keeps insertion order and offers no sort, and TypeScript's `versionFirst` -has no Python counterpart. They let you ask for output shaped differently. They -do not make the two writers agree, because none of them touches the seven +Every control now exists on both writers, closed by +[feature 002](conformance.md). They let you ask for output shaped differently. +They do not make the two writers agree, because none of them touches the seven defaults above. +| Control | Python | TypeScript | Defaults | +| --- | --- | --- | --- | +| Comment-free output | `output_type="nocomment"` | `comments: False` | on in both | +| Compressed output | `output_type="compressed"` | `compressed: True` | off in both | +| Indent | `indent` | `indent` | two spaces / four | +| Comment column | `comment_column` | `commentColumn` | 30 in both | +| Object ordering | `ordering` | `ordering` | `sorted` / `source` | +| Version pinned first | `version_first` | `versionFirst` | on in both | + +Where the defaults differ they stay differing: a control lets you ask for the +other behaviour, it does not change what you get by asking for nothing. + ```python --8<-- "docs/snippets/explanation/two_writers_one_model.py:controls" ``` diff --git a/docs/snippets/explanation/two_writers_one_model.py b/docs/snippets/explanation/two_writers_one_model.py index d5068af..5045a25 100644 --- a/docs/snippets/explanation/two_writers_one_model.py +++ b/docs/snippets/explanation/two_writers_one_model.py @@ -11,6 +11,7 @@ indent=4, comment_column=45, ordering="source", + version_first=False, ) # --8<-- [end:controls] From 72daa8f24e94b58fcaf036c120337ea8d3c42b57 Mon Sep 17 00:00:00 2001 From: Samuel Letellier-Duchesne Date: Fri, 4 Sep 2026 08:05:23 -0400 Subject: [PATCH 5/6] Adopt conformance-2026.8 The site states the level to the reader, so it moves with the libraries. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 3699b4b..8ab5f3c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,7 +65,7 @@ dev = [ # Conformance corpus level the site states, as an immutable tag in idfkit-conformance. The corpus # itself is the libraries' business; the site reports which level the pages describe. [tool.idfkit.conformance] -level = "conformance-2026.7" +level = "conformance-2026.8" # Governance artifact level the parity and naming pages are rendered from, as an immutable tag in # idfkit-conformance. Read by docs/hooks/parity_macro.py, scripts/render_parity_page.py and From 7359c7e380ff079f32a09cf3ab67450886337790 Mon Sep 17 00:00:00 2001 From: Samuel Letellier-Duchesne Date: Fri, 4 Sep 2026 09:40:47 -0400 Subject: [PATCH 6/6] Give the docs preview the Python version it needs deploy-pr-docs.yml called the shared setup action with no version, so it got the action's 3.10 default against a project requiring >=3.12 and failed at dependency resolution before reaching a single page. docs.yml has always passed 3.12 explicitly; this workflow never did, and has no successful run in its history. Pre-existing and unrelated to feature 002, but it is the only red check left on the site's pull request. --- .github/workflows/deploy-pr-docs.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/deploy-pr-docs.yml b/.github/workflows/deploy-pr-docs.yml index b9281b1..f30cb35 100644 --- a/.github/workflows/deploy-pr-docs.yml +++ b/.github/workflows/deploy-pr-docs.yml @@ -27,7 +27,12 @@ jobs: path: gh-pages-deploy - name: Set up the environment + # The shared action defaults to 3.10 and this project requires >=3.12, so without this the + # step fails at dependency resolution before it reaches a single page. `docs.yml` has + # always passed the version; this workflow never did, and had never succeeded. uses: ./.github/actions/setup-python-env + with: + python-version: "3.12" - name: Set up the parity ledger uses: ./.github/actions/setup-parity-ledger