Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/deploy-pr-docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions docs/concepts/logging.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
111 changes: 111 additions & 0 deletions docs/explanation/two-writers-one-model.md
Original file line number Diff line number Diff line change
@@ -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.
75 changes: 53 additions & 22 deletions docs/how-to/collect-diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand All @@ -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"

Expand Down Expand Up @@ -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

Expand Down
22 changes: 22 additions & 0 deletions docs/snippets/explanation/two_writers_one_model.py
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 1 addition & 3 deletions tests/test_copy_shipped_assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading