feat(api): load a contract exactly as fluid plan sees it - #688
Merged
Merged
Conversation
Add fluid_build.api.load_contract (a contract file or a bundle) and its in-memory siblings load_contract_from_text / load_contract_from_dict. They return a LoadedContract: the dict plan.json embeds as `contract` (parse, $ref composition, overlay, alias values, legacy build: rewrite, in the engine's order), its plan-digest canonicalisation, and provenance (source, overlay, every file composed, $ref values left in place). The in-memory forms read no file unless base_dir is given. Every failure is one typed ContractLoadError with a stable event. The module composes the engine's own loader and adds no rewrite of its own. tests/api/test_contract_load.py runs the real `fluid plan` on fixtures that exercise every rewrite and fails if the two differ, so a downstream caller no longer has to import the private _contract_loader helpers. fluid_build.api.__api_version__ 1.0 -> 1.1 (additive).
…d every failure is typed load_contract(env=...) handed env to the engine unchecked, and the engine builds overlay paths from it, so an absolute or ../ env merged any .yaml/.yml/.json file into the returned contract. env must now match the grammar fluid publish --env accepts (fluid_build._env_names), or the load fails with contract_env_invalid before a file is read. "" is refused, not read as None. The in-memory forms always merged an overlay. The engine's auto-bundle step drops it whenever a $ref survives the merge (a $ref in the overlay, or a same-document #/ pointer), so for those shapes the in-memory result was not what plan plans. They now replay that decision: return the base and log contract_overlay_not_applied, as load_contract does. Without base_dir and with file $ref values, the decision depends on the fragments, so an overlay is refused with contract_overlay_needs_base_dir. Both functions take a logger. The in-memory rewrites run from _ENGINE_REWRITES, and a guard test parses load_contract_with_overlay and fails when the engine gains, loses or reorders a step, so a new engine rewrite cannot reach the file form only. A YAML list root in a contract or overlay file is contract_not_a_mapping (it was contract_load_failed; the text form already said not_a_mapping), and a file that is not UTF-8 is contract_parse_failed. .digest raises contract_not_serialisable instead of a bare TypeError for a value JSON cannot hold (an unquoted YAML date; fluid plan fails on it too). Docs: .digest is the planned contract's digest, not the fluid contract digest / upstreamDigest value; contract equals plan.json's after keys are written as strings (a YAML on: key stays a bool), so compare by digest; bundle_not_found is listed as an engine event.
…s, NUL paths are typed Without base_dir, load_contract_from_dict copied the document with deepcopy, which keeps YAML anchor/alias sharing. The engine's $ref resolver rebuilds every dict and list, so in the file form an aliased node is two objects by the time the overlay merge and the alias rewrites change nodes in place. In memory they were one, so an overlay patch or a rewrite at one path reached every alias: same input, different contract and different .digest. The base document is now rebuilt iteratively before the merge (sharing inside the overlay is kept, as the engine keeps it), and a document that contains itself fails with contract_load_failed, as the file form does. contract_not_a_mapping is now raised only for the loader's own root checks. Any plain ValueError used to map to it, so a $ref holding a NUL byte was reported as "the root is not an object"; it is contract_load_failed again. env was held to the fluid publish --env grammar, which refused names fluid plan loads (_staging, prod+eu, a 65-character name). Only what makes env a path is refused now: empty, "." or "..", "/", "\" or NUL, absolute or drive-qualified. A path or base_dir holding a NUL byte raised a bare ValueError from Path.resolve(); it is contract_not_found. The engine-step guard test saw only calls taking contract positionally. It now also sees keyword calls and every other write to contract (a non-call assignment, an item or attribute write, a method call, augmented assignment, del, walrus), with a parametrised negative control over the engine's real source. The docs say what the guard does not cover.
…env and guard rules hold as documented
With env set and an overlay present, the engine merges the overlay into
dict(base). The loader checks a YAML root but not a JSON one (nor one a root
$ref composes), so a list root either failed with a message that names no
root (contract_load_failed) or was coerced into a dict and loaded:
[["k", "v"]] became {"k": "v"}, [] became the overlay alone. The same file
without an env is contract_not_a_mapping. load_contract now reads the base
before the engine call when env is set and refuses a non-object root with
contract_not_a_mapping. A base that fails to read is left to the engine's
own load, so its event does not change.
env "C:prod" was refused on Windows and loaded on POSIX, because the drive
check used os.path.splitdrive, which finds no drive on POSIX. The docs and
the docstring listed it as refused everywhere. The drive check now uses
ntpath.splitdrive, so the rule is the same on every platform. The isabs
check is gone: every absolute path holds a separator, already refused.
The engine-step guard missed changes made through a part of contract, an
alias, a loop over it, or a changed return. It now counts a call handed
any part of contract as a step, reports a return of anything but contract,
a rebinding as a loop or tuple target, and every other read of contract
(alias, sub-tree passed on, loop, container). It does not decide whether
such a read changes the contract; it fails on it. Negative controls cover
the shapes from review: 13 of them pass the previous guard.
📄 Documentation ReminderThis PR appears to be missing a documentation reference. Our docs live in a separate repo. Please update the PR description with one of:
See the Contributing Guide for details. |
GitHub code quality flagged fluid_build.api imported with both 'import' and 'from ... import'. It is now 'from fluid_build import api', beside the existing 'from fluid_build import _contract_loader'.
With #687 on main, a $ref holding a NUL byte is refused by the ref confinement check as RefConfinementError, a RefResolutionError, so both the file and the in-memory forms report contract_ref_unresolved instead of contract_load_failed. The test keeps its point (only the loader's root checks mean contract_not_a_mapping) and now pins the typed outcome.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds
fluid_build.api.load_contract(a contract file or afluid bundle.tgz) and two in-memory forms,load_contract_from_textandload_contract_from_dict. Each returns aLoadedContractwith:fluid planplans. It goes through parse,$refcomposition, the env overlay, alias rewrites and the legacybuild:rewrite, in the engine's order.$refvalues left in place.Every failure raises a single
ContractLoadErrorcarrying a stableevent. The module only composes the engine's own loader; it adds no rewrite of its own. Docs:docs/CONTRACT_LOADING_API.md.Hardening from review (second commit)
envis a name, never a path. The engine builds overlay paths fromenv. Before this fix, an absolute or../env merged any.yaml/.yml/.jsonfile into the returned contract (reproduced with a Dockerconfig.json-shaped file).envmust now match thefluid publish --envgrammar (fluid_build._env_names), or the call raisescontract_env_invalidbefore any file is read.""is refused;Nonemeans no env.$refsurvives the merge (a$refin the overlay, or a same-document#/pointer), the engine's auto-bundle step drops the overlay. The in-memory forms now do the same: they return the base and logcontract_overlay_not_applied, asload_contractdoes.base_dir, if the document holds file$refvalues, the engine's choice depends on the fragments. Passing an overlay then raisescontract_overlay_needs_base_dirinstead of guessing.logger._ENGINE_REWRITES. A guard test parsesload_contract_with_overlayand fails if the engine gains, loses or reorders a step. The docs' stability claim now says exactly what each form guarantees.contract_not_a_mapping(it wascontract_load_failed; the text form already saidcontract_not_a_mapping)contract_parse_failedbundle_not_foundis now documented.digestraisescontract_not_serialisableinstead of a bareTypeError(for example, an unquoted YAML date, whichfluid planalso fails on).digestis the digest of the planned contract, not thefluid contract digest/upstreamDigestvalue.contractequalsplan.json["contract"]once keys are written as strings. A YAMLon:key stays abool, as it does inside the engine, so compare contracts by.digest.Release notes (for the release CHANGELOG; not edited here)
fluid_build.api.load_contract,load_contract_from_text,load_contract_from_dict,LoadedContractandContractLoadError(fluid_build.api1.1).Tests
tests/api/test_contract_load.pychecks the following against the realfluid planrun():build:,$ref, overlay, bundle, a magic-word key) and both overlay-drop shapes../, empty, separator)UnicodeErrormapping turns the non-UTF-8 test red.pytest tests/(-n auto, Python 3.12): 19747 passed, 637 skipped.tests/apion Python 3.14: 78 passed.ruff check fluid_build/ tests/passes.black==24.10.0 --check fluid_build/ tests/passes.Tested live
This is library-only: there is no CLI or UI surface.
fluid planwas exercised for real inside the tests (plan_cmd.run). The probes for the date contract (planner_failed), the same-document pointer plus overlay (plan drops the overlay), a missing.tgz(bundle_not_found) and the magic-word key were run against the realplancommand.Decisions for the reviewer
contractwith the engine's bool and int keys and changed the docs (equality holds once keys are strings; compare by .digest), rather than returning coerce_keys_to_str(contract). One reviewer preferred coercing. I didn't, because coercion would be a rewrite of this module's own, would make the dict differ from what the engine's validator and planner see, and would merge a YAMLon:key with a"True"/"true"key. Confirm or overrule.fluid plan --env ../x) still accepts path-shaped env values. This PR only closes the gap at the library boundary, as scoped. Whether the CLI should also enforce is_env_name is a separate decision, and it would touch _contract_loader.py/loader.py, which belong to the other PRs' file sets.--env prodsilently plan the base contract with no warning. In addition, _has_ref_pointers fires on any dict that has a '$ref' key, not only on pure ref nodes. A fix belongs with whoever owns _contract_loader.py (the $ref-confinement PR's file); the new API's provenance already reports this honestly, and its tests are written to stay green after such a fix.build:is turned intobuilds:, and the alias table's paths start atbuilds. So an alias under a singularbuild:(for examplesource.kind: pg) is never rewritten, andfluid planthen rejects it at the schema gate (reproduced: local_plan_validation_failed). The new API mirrors the engine's order, and a test pins that. Swapping the order in _contract_loader.load_contract_with_overlay would fix it; the API would follow automatically, but the pinning test test_alias_under_a_legacy_build_matches_the_engine_loader would then need its last assertion updated.fluid_build.api.load_contract_from_dict(parsed)once this release ships, gated on fluid_build.api.api_version >= '1.1'. Its existing contract_refs refusal can switch to checking LoadedContract.unresolved_refs.Changes after review
These changes came from adversarial review rounds; each finding was upheld by at least 2 of 3 independent skeptics before it was fixed. A final re-review of the branch found nothing further.
R1 — 1c342d2
Review round 2: fixes in 1c342d2
base_dir,load_contract_from_dictcopied the document withdeepcopy, anddeepcopykeeps anchor/alias sharing. The engine's$refresolver rebuilds every dict and list, so in the file form an aliased node is already two objects when the overlay merge and the alias rewrites change nodes in place. In memory it was one object, so a change at one path reached every alias. The same input gave a differentcontractand a different.digest. The base document is now unshared before the merge. Sharing inside the overlay is kept, because the engine keeps it there too. A document that contains itself now fails withcontract_load_failed, as the file form does.contract_not_a_mappingis raised only for the loader's root checks. It used to cover any plainValueError, so a$refholding a NUL byte was reported as "the root is not an object". That case iscontract_load_failedagain.envrefuses only path-like values: empty,.,..,/,\, NUL, absolute or drive-qualified. Namesfluid plan --envaccepts andfluid publish --envdoes not (_staging,prod+eu, more than 64 characters) load again.pathorbase_diris nowcontract_not_found. It used to escape as a bareValueError.contract. It has a parametrised negative control over the engine's real source. The docs now say what the guard does not cover.Tested: each fix is negative-controlled, both against the previous head and with each fix reverted on its own. black 24.10.0, ruff, lint-imports and mypy-strict pass. tests/api passes on 3.10 and 3.12. The full suite on 3.12, run as CI does, has 19740 passed and one setup timeout in the live MCP test under xdist. That test file passes when run alone.
R2 — 4cc39a4
Follow-up review fixes (4cc39a4)
$refcomposes), and it merges an overlay intodict(base). So with an env and an overlay, a JSON list root either failed withcontract_load_failedor was quietly turned into a dict and loaded:[["k","v"]]became{"k":"v"}, and[]became the overlay alone. When an env is set,load_contractnow reads the base before calling the engine and raisescontract_not_a_mapping, the same event the same file gives without an env. If the base cannot be read, the engine's own load raises the error, so that event does not change either.ntpath.splitdrive, soenv="C:prod"is refused on Linux and macOS too, as the docs already said. Before, it was refused only on Windows.contractas a step. It also reports any return of something other thancontract, rebindingcontractas a loop or tuple target, and any other read ofcontract(an alias, a sub-tree passed on, a loop over it, a container holding it). The docs describe exactly this.C:prod, and for 16 guard mutation shapes. 13 of those shapes pass the previous guard unnoticed.Tested locally. black 24.10.0 and ruff are clean.
tests/apipasses on py3.10 and py3.12 (132 tests). The full suite passes on py3.10: 19667 passed, 771 skipped.Documentation
Companion docs PR: Agenticstiger/forge_docs#139 (new pages for
$refconfinement, the DuckDB sandbox and the contract-loading API, plus 0.18.0 release notes). It merges after 0.18.0 is on PyPI.