Give the build.toml reader a well-defined API - #123
Conversation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The module only reads build.toml now, so the name should say so instead of suggesting it also handles ~/.xmsconan.toml. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
load_build_filter, ci_python_versions, and ci_filter_effects now take the typed BuildToml instead of the raw parsed dict, so they can stop reaching for filter/matrix/ci defaults by hand. The two callers (gen, ci) convert the loaded toml_data right after validating it and pass the dataclass through; everything else in those callers stays dict-based for now. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One-shot reader that runs load_toml, validate_top_level_keys, and toml_to_dataclass in sequence and names the file on parse errors, plus read_optional_build_toml for the VS2019 driver's missing-file case. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the load_toml / validate_top_level_keys / toml_to_dataclass trio with a single read_build_toml call at every read site. vs2019_build's _library_build_toml collapses to read_optional_build_toml, since the reader now names the file itself on parse errors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
load_toml, validate_top_level_keys, toml_to_dataclass, and KNOWN_KEYS were only ever meant as stepping stones toward read_build_toml and read_optional_build_toml. Now that every caller goes through those two functions, rename the internals to underscore-private names so the public surface of build_toml.py is just the two readers and the four dataclasses. Delete test_build_toml_fields_match_known_keys: once _KNOWN_KEYS is defined as frozenset(f.name for f in fields(BuildToml)), that test compares a set against its own definition and can never fail. The real guard is test_known_keys_matches_the_documented_option_table, which now derives its expected key set from dataclasses.fields(BuildToml) instead of importing the constant, and still compares against docs/USAGE.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Update docs/USAGE.md's build.toml intro: drop the stale setdefault mention (there is no setdefault in the reader anymore) and note that the [ci] table's key/type check now runs in every tool that reads the file, not only xmsconan ci. Note in the xms_dependencies row that each entry must be a table with name and version, with any other key rejected rather than ignored. Reword three test docstrings that still referenced the now-private KNOWN_KEYS constant to describe what the tests actually compare: the BuildToml field set against USAGE.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
read_build_toml's bare except FileNotFoundError: raise was a no-op -- FileNotFoundError is an OSError, not a ValueError, so it never hit the except ValueError clause it sat beside. Drop it; the docstring already documents that FileNotFoundError propagates. Add one-line contract docstrings to the four private helpers that lacked them. Make a non-numeric [coverage] threshold name its file and key instead of raising the bare float() message. Rename render_template_with_toml's toml_data local to context to match what it holds after _render_context runs, and drop a comment that only restated the next line. Annotate ci_python_versions's return as Optional[list] since it returns None for an empty python_versions with no platform overrides. Soften _library_build_toml's docstring: [ci] validation errors do not name the file, only parse errors do. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
build_toml imported validate_ci_table from ci_options, so ci_options could only name BuildToml under a TYPE_CHECKING guard -- the one such guard in the package. The key/type table now lives in build_toml next to CiTable, which also lets [ci] errors name the file like every other reader error. ci_options keeps only the wheel-repair decision and imports normally. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Where one call fed another directly -- read then parse, resolve then check -- the value now gets a name first. _warn_filter_conflicts also stops importing its own module's _resolve_coverage_python_version back through coverage_generator; the function is defined a few screens up. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tests built a BuildToml inline inside the call under test; the fixture now gets a name first. The tests also str()-wrapped every tmp_path only because the callees were annotated str while forwarding to functions that already take a Path, so the callees now say str | Path and the conversions are gone. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
dfc9323 to
0b084e3
Compare
The reader checked key names for both sub-tables but coerced or passed through their values, so `cpp_threshold = true` read as 1.0, a scalar `filters` string was iterated into one-character gcovr patterns, an unquoted `python_version = 3.10` became "3.1", and `no_python = "false"` dropped a dependency from the wheel. Each is now rejected naming the file and key. The top-level key check reuses _reject_unknown_keys instead of duplicating it, and the coverage rejection test now fails loudly if the run ever shells out before validating. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
make_build_toml fed a dict through the private converter, so every test module depended on a name the reader had declared implementation detail. The tests now construct BuildToml, CiTable and CoverageTable themselves; only test_build_toml.py, which tests the converter, still reaches past the underscore. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review —
|
| Commit | |
|---|---|
c1d192c |
Read build.toml through load_toml in publish and docker_run |
d5402f1 |
Validate top-level keys everywhere build.toml is read |
36099a1 |
Rename toml_utils to build_toml |
727a506 |
Add the BuildToml dataclasses |
6e5c95a |
Add toml_to_dataclass for build.toml |
10cfc10 |
Add a BuildToml factory for tests. |
ad293ee |
Read the [filter] inputs from BuildToml |
6bed15e |
Resolve the Windows wheel repair switch from BuildToml |
4a1503e |
Resolve coverage settings from BuildToml |
0e7185b |
Render the generated build files from BuildToml |
7c08ae5 |
Annotate the xms_dependencies parameter with its element type |
94d6cb0 |
Generate CI files from BuildToml |
d527eaa |
Generate profiles from BuildToml |
73cbf22 |
Run coverage from BuildToml |
1f1f59e |
Pick the Docker image from BuildToml |
f9ebaac |
Read build.toml once in publish |
b5ed1c7 |
Read each library's build.toml as BuildToml in the VS2019 driver |
f2d4cea |
Add read_build_toml |
83981d8 |
Read build.toml through read_build_toml everywhere |
c284693 |
Make the build.toml reader's building blocks private |
c6e28b4 |
Document the build.toml checks every reader now makes |
4811277 |
Tidy the build.toml reader after review |
60a38e6 |
Keep the [ci] schema with its dataclass |
c367ab3 |
Name intermediate results instead of nesting calls |
5b40308 |
Name the joined key lists in error messages |
0b084e3 |
Name test fixtures before use and accept Path arguments |
docs/USAGE.md | 6 +-
tests/test_build_file_generator.py | 20 +-
tests/test_build_filter.py | 101 ++++---
tests/test_build_toml.py | 239 ++++++++++++++++
tests/test_ci_options.py | 90 +------
tests/test_coverage_generator.py | 56 ++--
tests/test_docker_run.py | 8 +
tests/test_publish.py | 44 +--
tests/test_utils.py | 9 +-
tests/test_vs2019_build.py | 16 +-
tests/utils.py | 14 +
xmsconan/build_toml.py | 329 +++++++++++++++++++++++
xmsconan/build_tools/vs2019_build.py | 40 ++-
xmsconan/ci_options.py | 104 ++-----
xmsconan/ci_tools/docker_run.py | 16 +-
xmsconan/ci_tools/publish.py | 44 ++-
xmsconan/coverage_tools/coverage_generator.py | 20 +-
xmsconan/generator_tools/build_file_generator.py | 107 +++-----
xmsconan/generator_tools/build_filter.py | 31 ++-
xmsconan/generator_tools/ci_file_generator.py | 126 ++++-----
xmsconan/generator_tools/profile_generator.py | 20 +-
xmsconan/toml_utils.py | 102 -------
22 files changed, 919 insertions(+), 623 deletions(-)
Review: origin/master..HEAD — introduce xmsconan/build_toml.py and read build.toml through one typed reader
What changed. 26 commits, 22 files, +919/-623. The branch replaces xmsconan/toml_utils.py with xmsconan/build_toml.py, which parses build.toml into frozen BuildToml / CiTable / CoverageTable / XmsDependency dataclasses, validates top-level and [ci] keys, and is now the only path every generator, CI tool, coverage runner, and the VS2019 driver uses to read the file. ci_options.py shrinks to one function, the [ci] schema moves next to its dataclass, and a make_build_toml test factory plus a new tests/test_build_toml.py (239 lines) are added. docs/USAGE.md is updated. This is a wide but mechanical refactor with no security-sensitive surface.
Notably, xmsconan/build_toml.py:229-231 guards the [ci] integer keys against TOML booleans with a comment explaining the non-obvious reason (bool subclasses int), so test_shards = true fails loudly instead of becoming 1.
Verdict: BLOCK (C=0 M=0 m=15)
The BLOCK is carried solely by writer-reviewer's discipline verdict: no DESIGN: or EVIDENCE: marker anywhere in the range. The validator dropped both marker findings, reasoning that no tracked or untracked file in this repository defines that convention, and it also dropped both test-smell MAJORs as pre-existing style or test-quality preference. No Critical or Major code finding survives. On code findings alone the state would be APPROVE WITH MINOR ITEMS; if the discipline markers are not a requirement for this project, treat the verdict as that.
Minor items
tests/test_build_filter.py:176-184, 189-192andtests/test_build_toml.py:21-22— two exercise-verify cycles per test (raise then accept; default then override), so the name describes half of what can fail; split into one test per cycle.tests/test_build_toml.py:25-40, 49-65— 13 and 15 bare assertions enumerate every default; lines 52-53and-join two checks and line 54 slipsCiTable(linux=False)into a defaults test; compare against an expected-object literal, split line 54.tests/test_build_toml.py:210-211(alsotest_coverage_generator.py:1769-1770,test_docker_run.py:293-294,test_publish.py:47-48,test_vs2019_build.py:1361-1363) — the samelibrary_name = "xmscore"\nhas_test_files = truewrite and unknown-key regex copy-pasted into five modules; add a sharedwrite_build_toml(tmp_path, ...)helper intests/utils.py.tests/test_coverage_generator.py:1767-1774— nothing doublessubprocess; if the reader ever stops raising,run_coverageshells out fromtmp_pathinstead of failing cleanly; patchsubprocess.runas a guard.tests/utils.py:5,44(alsotests/test_build_toml.py:7) — shared factory and reader tests import private_toml_to_dataclass,_load_toml,_CI_KEY_TYPES, undercutting the "make building blocks private" commit; and because the factory skips_validate_top_level_keys,make_build_toml(bogus=1)raises a dataclassTypeErrorrather than the reader'sValueError; expose one public dict-to-BuildTomlfunction or route the factory throughread_build_toml. (writer-reviewer, test-smell-reviewer)xmsconan/build_toml.py:172—_validate_top_level_keys(182-186) duplicates_reject_unknown_keys(215-219) with a slightly different message; call_reject_unknown_keys(toml_data, _KNOWN_KEYS, ...)instead.xmsconan/build_toml.py:245-263—_coverage_tablechecks key names but not the types offilters/excludes; a scalarfilters = "xmscore/"passes andlist(...)atci_file_generator.py:193-194splits it into one-character gcovr filters; requirelistfor both.xmsconan/build_toml.py:251-258—float(values[key])acceptscpp_threshold = trueas1.0, while the[ci]validator at 229-231 deliberately rejects bool-for-int; rejectboolbefore callingfloat. (writer-reviewer, silent-failure-reviewer)xmsconan/build_toml.py:259-262—str(values["python_version"])turns unquotedpython_version = 3.10(TOML float 3.1) into"3.1", whichci_file_generator.py:145-153then rejects as a version the user never wrote; reject non-strwith an error that names the quoting problem.xmsconan/build_toml.py:266-274—_xms_dependencychecks key presence but not types;no_python = "false"is a truthy string thatpyproject.toml.jinja:19reads as "exclude", silently dropping a runtime requirement from the wheel; type-checkname/versionasstrandno_pythonasbool. (writer-reviewer, silent-failure-reviewer)xmsconan/ci_options.py:19-41— module now holds one 3-line function whose only inputs live onBuildToml; consider aBuildTomlproperty and delete the module.xmsconan/ci_tools/docker_run.py:46— theresolve_docker_imagebranch returningconfig.ci.docker_imagehas no test; add one that writes[ci] docker_image = "..."tobuild.toml.xmsconan/ci_tools/publish.py:118—publish()takes 10 parameters;url/username/passwordare one credentials concept (pre-existing; range only annotatedtoml_path); group credentials into a parameter object.xmsconan/coverage_tools/coverage_generator.py:617—if config.ci.xvfb: _reexec_under_xvfb()is never reached by arun_coveragetest; add one with[ci] xvfb = trueand_reexec_under_xvfbpatched.xmsconan/generator_tools/ci_file_generator.py:242—generate_cibody runs ~195 lines (257-452) mixing validation, version resolution, a 44-line context dict, and rendering (pre-existing; range shortened it by ~14 lines); extract_validate_ci_config(config)and_ci_context(...).
Dropped in validation (not counted above): the two discipline-marker MAJORs, the test_build_filter.py:125-128 for-loop finding (loop is a pre-existing context line), and the test_publish.py:283-324 _check_xvfb temp-file setup finding (correct tests; style preference).
Response — review of
|
| Finding | Outcome | |
|---|---|---|
| m1 | Two exercise-verify cycles per test | Fixed for test_build_toml.py; declined for test_build_filter.py (pre-existing) |
| m2 | Bare-assert defaults tests | Declined |
| m3 | Unknown-key test copy-pasted into five modules | Declined |
| m4 | Coverage rejection test could shell out | Fixed |
| m5 | Test factory imports the private converter | Fixed, by removing the factory |
| m6 | _validate_top_level_keys duplicates _reject_unknown_keys |
Fixed |
| m7 | [coverage].filters/excludes not type-checked |
Fixed |
| m8 | Boolean threshold read as 1.0 |
Fixed |
| m9 | Unquoted python_version coerced to a wrong version |
Fixed |
| m10 | xms_dependencies values not type-checked |
Fixed |
| m11 | ci_options.py is one function |
Declined |
| m12 | docker_image branch untested |
Declined (pre-existing) |
| m13 | publish() has 10 parameters |
Declined (pre-existing) |
| m14 | xvfb dispatch in run_coverage untested |
Declined (pre-existing) |
| m15 | generate_ci is ~195 lines |
Declined (pre-existing) |
Fixed
m1 (part) — test_build_toml.py:21-22 split in two
test_build_toml_derives_python_namespaced_dir now checks only the derived
default; test_build_toml_keeps_an_explicit_python_namespaced_dir checks the
override. The two test_build_filter.py sites in the same finding are declined
below.
m4 — the coverage rejection test fails loudly if the run shells out
test_run_coverage_rejects_an_unknown_top_level_key patches
coverage_generator._run with side_effect=AssertionError(...). If the
reader ever stops raising on an unknown key, the test now fails with
"run_coverage shelled out before rejecting build.toml" instead of trying to run
xmsconan gen from a temp directory.
m5 — tests build BuildToml directly; the factory is gone
The finding was right about the coupling but the suggested fix (a public
dict-to-BuildToml function) would have widened the reader's surface to serve
tests. Instead, make_build_toml was deleted from tests/utils.py along with
its _toml_to_dataclass import, and the 28 call sites in
test_build_filter.py, test_ci_options.py, and test_coverage_generator.py
construct the value they need:
config = BuildToml(
library_name="xmscore",
ci=CiTable(python_versions=["3.10", "3.13"]),
filter={"options": {"python_version": "3.10"}},
)tests/test_build_toml.py still imports _load_toml, _toml_to_dataclass,
and _CI_KEY_TYPES on purpose — it is the converter's own test module. No
other test file reaches past the underscore.
m6 — one unknown-key check
_validate_top_level_keys now calls
_reject_unknown_keys(toml_data, _KNOWN_KEYS, str(toml_path), kind="top-level key").
The kind parameter keeps the message the seven existing tests match on
(... has unknown top-level key(s) ...).
m7, m8, m9, m10 — the sub-table values are checked, not just the keys
All four are in xmsconan/build_toml.py, which is new on this branch, so all
four are this branch's to fix. Each rejection names the file and key.
| Input | Before | Now | |
|---|---|---|---|
| m7 | filters = "xmscore/" |
passed through; iterated into one-character gcovr patterns | [coverage].filters must be a list, got str |
| m8 | cpp_threshold = true |
float(True) → 1.0 |
[coverage].cpp_threshold must be a number, got a boolean |
| m9 | python_version = 3.10 (unquoted) |
str(3.1) → "3.1", rejected downstream as a version nobody wrote |
[coverage].python_version must be a quoted string like "3.13", got 3.1 |
| m10 | no_python = "false" |
truthy string; the template dropped the dependency from the wheel | no_python must be true or false, got 'false'; name/version must be strings |
The threshold logic moved into _coverage_threshold so _coverage_table stays
flat. The python_version coercion is gone rather than tightened — the
converter no longer guesses at what an unquoted number meant.
New tests: test_toml_to_dataclass_rejects_a_boolean_threshold,
test_toml_to_dataclass_rejects_a_scalar_coverage_pattern_list (parametrized
over filters/excludes),
test_toml_to_dataclass_rejects_an_unquoted_coverage_python_version, and two
new cases in test_toml_to_dataclass_rejects_malformed_xms_dependencies
(numeric-version, quoted-boolean). docs/USAGE.md §5.3 and §5.7 gained one
sentence each describing the new checks.
Declined
m1 (part) — test_build_filter.py:176-184, 189-192
Both test shapes exist on origin/master
(test_load_build_filter_checks_the_filter_against_the_narrowed_matrix was
already raise-then-accept; test_ci_python_versions_defaults_to_none already
had two asserts). This branch only swapped their dict literals for dataclass
construction.
m2 — bare asserts in the defaults tests
An expected-object literal would restate the same 13 and 15 values in a
different shape without making a failure easier to read. Line 54
(CiTable(linux=False).linux_enabled is False) is the one non-default case in
that test and belongs there.
m3 — the repeated unknown-key test
Five three-line tests share a write_text call and a regex. The repetition is
small enough that a shared helper would cost more indirection than it saves.
m11 — ci_options.py
The module was already this thin before the branch; the branch removed the
[ci] schema from it, it did not shrink a larger module down. Turning
repairs_windows_wheel into a BuildToml property would put a CI-publishing
decision on the configuration dataclass, which is the wrong home for it.
m12, m14 — untested branches in docker_run.py:46 and coverage_generator.py:617
Both branches and both test gaps exist on origin/master (toml_image = ci_config.get("docker_image"), if ci_config.get("xvfb"):). The branch
changed how the value is read, not whether it is tested. Worth tests, but not
this PR's.
m13 — publish()'s parameter list
Pre-existing. The branch only annotated toml_path: str | Path.
m15 — generate_ci length
Pre-existing, and the branch shortened it by ~14 lines. The extraction the
review suggests is a reasonable follow-up on its own.
Dropped in validation
Listed so the review reads as fully answered.
DESIGN:/EVIDENCE:markers absent from the range. These are
conventions of the review tooling, not of this repository. The design
artifact isdocs/superpowers/plans/2026-08-27-build-toml-reader.md; the
evidence is the Gates section below.test_build_filter.py:125-128forloop around exercise and assertion.
The loop is a context line in the diff; this branch rewrote only its body.test_publish.py_check_xvfbtests still write a temp file and call
read_build_toml. Left as-is. The tests are correct; if the direct-
construction style from m5 should extend here, it is a four-line change per
test (BuildToml(library_name="xmscore", ci=CiTable(xvfb=True))).
Gates
At 92e8b08:
$ .venv/Scripts/python.exe -m pytest tests -q -p no:cacheprovider
2153 passed, 2 skipped in 31.17s
$ .venv/Scripts/python.exe -m flake8 xmsconan tests
0
The count is six higher than at 0b084e3 (2147 → 2154 after 4ce5659's seven
new tests → 2153 after 92e8b08 removed the factory's smoke test).
master's #126 reversed the [coverage].parallel default to off, after the overlap raced conan's local cache and broke xmsvtk's Coverage stage, and measured 1.57x slower once the legs contended for CPU. Take that decision: CoverageTable.parallel defaults to False. master's inline bool check in _coverage_context is dropped in favor of the read-time check in build_toml, which every tool that reads build.toml reaches rather than only the two that build a coverage context. The parallel tests are consolidated into TestConcurrentCoverageBuilds against the typed config. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The rebase onto master brought in #123, which replaced the ad-hoc dict reader with read_build_toml() and a typed BuildToml. Three sites on this branch predate that and the merge could not have caught them: the code they conflict with was deleted rather than changed, so git took both sides cleanly and left calls to a function that no longer exists. _report_coverage is the substantive one. It was split out of run_coverage by this branch, so it never had a master-side counterpart to merge with and kept calling _load_toml, then indexing the result as a dict. It now reads config.coverage and config.library_name the way _collect_coverage already does, which is the same pattern master left there. Both phase helpers also widen to str | Path. That is not cosmetic: master widened run_coverage, and these two receive whatever it was handed and pass it to Path() unchanged. The last site is the coverage-pin test, which still built a filter as a bare dict. This lands as its own commit rather than folded into the three it repairs because interactive rebase is not available here; the branch is green at the tip, and the intermediate commits are the pre-#123 originals.
The various tools all read the
build.tomlinto a plain dict, then do their own validation and extraction on it. Some of them hand-roll their own readers and/or forget to validate. Validation is piecemeal and some tools will accept things others won't, even though they should all work on the same file. The expected content of thebuild.tomlis also spread out all over the place.This centralizes reading
build.tomlin one place and converts it into adataclassbefore handing it off to anything else. That ensures everything reads and validates it the same way. It also gives all the tools a well-defined API to the file, with all its contents and defaults explicitly spelled out so they know exactly what is (and isn't) there.Only basic validation is centralized at this point. The reader checks for low level problems like unknown keys, but doesn't handle high-level things like making sure there's at least one valid build.