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 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/explanation/two-writers-one-model.md b/docs/explanation/two-writers-one-model.md new file mode 100644 index 0000000..9346446 --- /dev/null +++ b/docs/explanation/two-writers-one-model.md @@ -0,0 +1,111 @@ +# 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 + +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" +``` + +The most aggressive is compressed output: one object per line, no comments, no +blank separators, no header. + +```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. +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/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 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..5045a25 --- /dev/null +++ b/docs/snippets/explanation/two_writers_one_model.py @@ -0,0 +1,22 @@ +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", + version_first=False, +) +# --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..8ab5f3c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,13 +65,13 @@ 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 # 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"