diff --git a/changelog.d/570.added.md b/changelog.d/570.added.md new file mode 100644 index 00000000..267ecbf2 --- /dev/null +++ b/changelog.d/570.added.md @@ -0,0 +1 @@ +Add execution receipt schema v1 (`policyengine.provenance.receipt`), which records exactly what produced a UK result. It records the installed wrapper, model and core versions, with wheel hashes only when the builder verified them against the wheel RECORD, plus the data release and the SHA-256 of the simulated dataset bytes. The certificate digest is attached only when the certificate covers that model, core, dataset and wrapper. The receipt also carries reform and baseline digests, which refuse modifiers they cannot bind, and the fiscal-year convention. Digests use RFC 8785 canonical JSON over strict JSON values (`policyengine.provenance.canonical`), and the JSON Schema ships at `policyengine/data/schemas/execution_receipt.v1.schema.json`. diff --git a/docs/_quarto.yml b/docs/_quarto.yml index 09e631bd..e477f834 100644 --- a/docs/_quarto.yml +++ b/docs/_quarto.yml @@ -55,6 +55,7 @@ website: - reference/index.md - countries.md - release-bundles.md + - receipts.md - data-publishing-design.md - examples.md - dev.md diff --git a/docs/receipts.md b/docs/receipts.md new file mode 100644 index 00000000..d7f1dea0 --- /dev/null +++ b/docs/receipts.md @@ -0,0 +1,164 @@ +# Execution receipts + +An execution receipt travels with a published number, such as a dashboard row, a briefing figure or an API response. It names everything that determined that number, so a reader can tell exactly which model, data and certificate produced it, and two numbers can be compared knowing whether they came from the same run. + +Schema v1 covers UK results and is defined by `policyengine.provenance.receipt.ExecutionReceipt`. The JSON Schema ships in the wheel at `policyengine/data/schemas/execution_receipt.v1.schema.json`, and the same file is in the repository at [`src/policyengine/data/schemas/execution_receipt.v1.schema.json`](https://github.com/PolicyEngine/policyengine.py/blob/main/src/policyengine/data/schemas/execution_receipt.v1.schema.json). + +## What a receipt records + +| Field | Contents | +|---|---| +| `wrapper`, `model`, `core` | The `policyengine`, `policyengine-uk` and `policyengine-core` versions that actually ran, read from the installed distributions rather than from the bundle manifest. `install_origin` records a PEP 610 direct URL as `archive`, `directory`, `editable` or `vcs`; it is absent for an ordinary index install with no `direct_url.json`. `wheel_sha256` appears only when the receipt builder verified it. One way is `wheel_paths=` (evidence `installed_record_verified`): the installed files, and any bytecode cache the interpreter would load instead of a source file, are checked against that wheel's RECORD. The other is the installer's recorded archive hash (`installer_archive_hash`), taken only when the install came from a `.whl` URL; a source archive's hash is never recorded as a wheel hash. | +| `data` | The data release and build, and the model version the data was built with (`build_model_version`, distinct from `model.version`). Also the dataset's logical name, URI, revision and the SHA-256 of the bytes that were simulated. The dataset is resolved exactly as `materialize_dataset` resolves it (overlays, per-dataset repository, package and revision, and the release manifest revision when an artifact has none of its own), and the bytes must be the ones the bundle pins for it: a receipt never gives an artifact's name to other bytes. | +| `certification_status`, `certificate` | `certified` only when the bundle certifies all of: the installed model and core versions, that dataset name and those bytes, and the running `policyengine` version. Each package must also have an absent or `archive` install origin, or a `directory` or `vcs` origin with `installed_record_verified` wheel evidence. A directory or VCS install without that evidence produces an `uncertified` receipt, which carries no certificate. An editable install is never certified, whatever the evidence: its wheel holds a pointer to a source tree (a `.pth` file or an import hook) rather than the source, so checking the installed files against it does not bind the code that ran. `certificate_source` records whether the builder read the certificate from the installed bundle (`packaged`) or from a manifest the caller passed (`caller_supplied`). It is the producer's statement, not evidence: trust a certificate only after `verify_certificate` (below). With the packaged bundle, the composition fingerprint of its TRACE TRO is recorded as well. | +| `reform`, `baseline` | Both are required. Each is `current_law` or a digest of the reform bound to its representation (see below). | +| `fiscal_year` | What a year label means: year 2027 is the fiscal year from 6 April 2027 to 5 April 2028, shown as `2027-28`. v1 allows only this convention. | +| `years` | The reported years, sorted and unique. | +| `supported_years` | The years the installed model projects the dataset to, from the dataset's own year (policyengine-uk's `extend_single_year_dataset`, without running a formula; the same rule as the qualification `supported_years` gate). A reported year outside them is unsupported: policyengine-core fills it by carrying or uprating the latest inputs, so its results are not evidence. `unsupported_years` lists them. The certified 1.58.0 dataset projects to 2024–2030, so a 2031-32 figure on it is unsupported. | +| `outputs_sha256` | Optional digest of the output payload the receipt binds. | +| `runtime` | Python version, platform and CI run URL. It is for diagnosis and is part of no digest. | + +Malformed `direct_url.json` is refused because the builder cannot determine the package's install origin. + +For each loaded top-level package (`policyengine`, `policyengine_uk` and `policyengine_core`), the builder also checks that its `__init__.py` comes from the installed distribution it names. Index, archive, VCS and non-editable directory installs must load the package file listed in that distribution's RECORD, resolved through `locate_file`. Only editable installs (`dir_info.editable: true`) must load from under the directory their PEP 610 URL names, including a `src/` layout. Paths are compared after resolving symlinks. A checkout ahead of a copied install on `PYTHONPATH` is refused because the receipt cannot name the code that ran. An unloaded package has no source path to compare; the version checks still apply. + +## Building one + +```python +from policyengine.provenance import materialize_dataset +from policyengine.provenance.receipt import build_execution_receipt, reform_identity + +source = materialize_dataset("uk") # the certified release artifact, hash-verified +reform = {"gov.hmrc.income_tax.rates.uk[0].rate": {"2027-04-06": 0.21}} + +receipt = build_execution_receipt( + country_id="uk", + dataset_path=source.path, + years=[2027, 2028], + reform=reform_identity( + reform, "policyengine.reform_dict/v1", name="basic rate 21p" + ), + baseline=reform_identity(None), + outputs={"budgetary_impact": {"2027": 6.1e9, "2028": 6.4e9}}, +) +print(receipt.certification_status, receipt.execution_identity_sha256) +receipt_json = receipt.to_canonical_json() +``` + +For a country `Microsimulation` built with `managed_microsimulation()`, call `build_execution_receipt_for_microsimulation(sim, years=..., reform=..., baseline=...)`. It reads the dataset that was loaded from `sim.policyengine_bundle`. The file must still hash to the SHA-256 verified when it was loaded, and the receipt must name the dataset, URI, revision and package recorded then; otherwise it is refused, because it would describe bytes that did not run. A simulation loaded from an unmanaged file or URI recorded no load-time hash, so it cannot be receipted. The microsimulation does not record its reform, so pass the reform you built it with. + +There are two ways to bind a policyengine-uk `Scenario`: +- A scenario built from `parameter_changes` is bound by content with `scenario_reform_identity(scenario)`. +- `Scenario.from_reform(d)` wraps `d` in a closure that cannot be bound by its code, so bind `d` itself with `reform_identity(d, "policyengine-uk.reform_dict/v1")`. + +A scenario with a module-level `simulation_modifier` needs `code_revision={"repository": ..., "git_sha": }`. The identity then says `binding: "code_revision"` and binds: +- the function's module and qualified name; +- the SHA-256 of its source and of its module's source file; +- the default arguments of every function in that module, encoded with their types and floats by their bits, so `1` and `1.0`, `0.0` and `-0.0`, a tuple and a list, or `None` and a missing key never share a digest (defaults with no exact encoding, such as objects, are refused, and so are two defaults that are one mutable object, such as one list set as both `first` and `second`, since the encoding cannot tell that from two equal lists); +- that revision. + +None of these depends on where the code is checked out or which Python version runs it, so CI, a laptop and the server compute the same digest for the same code. + +Build the receipt after the run. An identity returned by `scenario_reform_identity` keeps a private handle to the same scenario and code revision. Both receipt builders recompute `reform` and `baseline` identities with handles, and refuse if recomputing is refused or an identity member differs. The handle is excluded from equality, serialization, schemas and digests. A serialized identity loaded back has no handle and is not re-checked; its producer must ensure it describes the run. These checks take snapshots at identity time and receipt build time, so they cannot describe later changes. + +**The modifier's module.** A digest can only name what the module's source fixes, so the module holding a bound modifier must have a shape whose state that source determines. At module level it may hold only: +- docstrings and `pass`; +- imports; +- assignments of a literal to one name, annotated or not (`RATE = 0.21`, `YEARS: tuple = (2026, 2027)`); +- undecorated `def` statements, none named like a dunder, whose type parameters (`def pennies[T](amounts: T)`) have no bound, constraints or default; +- an `if __name__ == "__main__":` block with no `else` (it never runs on import, so what it binds is ignored). + +Each name is bound once. Defaults hold no `:=`, lambda, comprehension, `await` or `yield`. Annotations allow names, attributes, subscripts, constants, directly signed numeric constants and `X | Y`, with no calls. Anything else is refused, naming the line. That covers: +- a type parameter's bound, constraints or default (`def pennies[T: list[Decimal | float]]`), which Python evaluates only when something such as Pydantic first reads it, so typing's order-insensitive alias cache can decide what it holds; +- conditional, repeated or unpacked bindings; +- objects built at import (`PAYMENT = partial(pay, amount=120)`, whose captured values can change without the source changing); +- values computed from the environment, a file or the clock; +- decorators, class definitions, and code run at import. + +The accepted module shape also refuses these constructs: +- arithmetic on literals, such as `WEEKLY = 520 / 52` or `MILLION = 10 ** 6`: use the computed literal (`WEEKLY = 10.0`, `MILLION = 1000000`), or compute the value inside a function; +- `if TYPE_CHECKING:` blocks: put type-only imports in a `.pyi` file or inside a function; +- `type` aliases, such as `type Rate = float`: use the concrete type in annotations, or put the alias in a `.pyi` file; +- starred annotations such as `*args: *Ts`: use a concrete argument annotation or `ParamSpec`'s `P.args` and `P.kwargs`. + +Anywhere in the module, code may not: +- tell equal values apart by identity: `is` is allowed only against `None`, `True`, `False`, `...` or `type(...)`, and `type` may not be rebound. The compiler may make equal immutable constants one object, and a digest of values cannot tell one object from two equal ones. +- use `id`, `operator.is_` or `is_not`, `globals`, `vars`, `locals`, `exec`, `eval`, `compile`, `__import__` or `breakpoint`, however imported, or import `builtins`; +- read annotation values through `typing.get_type_hints`, `inspect.signature`, `inspect.getfullargspec`, `get_annotations` or `from_callable`; these explicit metadata readers remain outside the accepted module shape. +- declare a name `global`. +- read dunder attributes other than `__init__` and `__name__`, or dunder names other than `__name__` and `__file__`. Reading a function's or module's own metadata is introspection. +- use `getattr`, `hasattr`, `setattr` or `delattr` with a non-literal attribute name, or a literal dunder other than `__init__` and `__name__`. + +Put measure values in literal constants or in default arguments. + +**What was imported.** A digest must only name code that ran, so at identity time, and again when a receipt is built with that identity's private handle, the module's source as read now is compiled and checked against what was imported, ignoring file names and line numbers: +- **Versions.** At receipt build time, any recorded version of policyengine, policyengine-uk or policyengine-core must still match its installed version. Independently, each package's distribution metadata must not have been written after this process started: the builder compares the latest modification or change time of its `.dist-info` directory and existing `METADATA`, `RECORD`, `INSTALLER` and `direct_url.json` files against `psutil.Process().create_time()`. Metadata written after process start is refused regardless of import order or whether a version was recorded; an unavailable metadata location is refused too. Restart the process and run again. This also refuses installing a package and then importing it in the same process, such as a notebook `pip install` without a restart, even when that import loaded the new code. +- **Functions.** The source is compiled as a whole, so each function compiles in its module's context, imports included. The modifier and every other function defined in that file must use the module's own globals and match that code, nested code and constants included. Constants are compared by exact type, by IEEE 754 bytes (so a NaN's sign counts) and by how they share objects. Annotation functions that Python 3.14 compiles (`__annotate__`) are the compiler's, not the module's, and a generic function (`def helper[T](...)`, Python 3.12+) is checked through the wrapper the compiler makes to bind its type parameters. +- **Function names.** A `def` name must still be bound to that function, not to another function of the same file or any other object. +- **Code inside functions.** It runs after import, so a type it builds is made then: an expression in a function body, or the annotations, type-parameter bounds and class annotations of a helper defined there (quoted or not). typing caches its generic aliases (`List`, `Dict`, `Optional`, `Annotated`) and user generics (`Generic` subclasses, Pydantic models) by argument equality, and a union equals one holding its members in any order. So `List[Union[Decimal, float]]` built there can return an alias cached for `List[Union[float, Decimal]]`, and Pydantic tries a union's members in order. A function whose code subscripts such a generic with a union or `Literal` is refused. Builtin generics, `collections.abc` classes and classes whose `__class_getitem__` is implemented in C (NumPy's `ndarray` and `dtype`) build a new alias in source order and are allowed (`list[Decimal | float]`), as is a bare `Union[Decimal, float]`. Resolution is conservative. A name stands for every object the module, or any import anywhere inside the function, binds it to, so a helper importing `typing.Sequence` is caught even when a sibling imports `collections.abc.Sequence`. Every string in the function's code that parses as an expression is inspected as a type, since a forward reference such as `Optional['List[Union[Decimal, float]]']` is evaluated when something resolves it. `Literal` values and `Annotated` metadata are left as strings. Local aliases of typing objects (`L = List`) are not followed. The module's own function signatures are compared against their source instead (below). +- **Function metadata.** Each function's name, qualified name and docstring must still be what its `def` made, compared as the compiler stores them. A generic function's `__type_params__` must still be bare type parameters of the names and kinds its source declares, in order, with no bound, constraints, default or declared variance. Each annotation must still hold what its source names, resolved against the module's globals and builtins (and those type parameters): named objects are compared by identity; reconstructed generics, unions and `ParamSpec` argument proxies must have the same origin and arguments, recursively and in order. Source arguments are resolved separately so typing's cache cannot hide a reordered union. Parameterised typing aliases are refused when their source order cannot be checked independently of that cache. Subscripted annotations that do not expose a typing origin and arguments, such as generated Pydantic model classes, are also refused. Quoted annotations and strings stored by `from __future__ import annotations` must match their source, and their resolvable type expressions must also match source order. This includes annotations consumed implicitly by libraries such as `functools.singledispatch`. It must use the builtins module and carry no other attributes (`helper.amount = 240` is state). +- **Defaults.** Literal default arguments in the source, such as `rate=0.21`, must still be the function's defaults, with the same type. +- **Literal constants.** Every literal constant must still hold its value, compared exactly: the same types throughout, floats by their bits, dictionary keys in order, for every literal kind including bytes, sets and complex numbers. +- **No shared mutable objects.** No two literal constants or defaults may be one mutable object, which a value check cannot tell from two equal objects. +- **Imports.** Every imported name must still be what its import binds: `from math import floor` must still be `math.floor`, and `import math` the loaded `math` module. It must hold a module or a callable other than a `functools.partial`; `from __future__` features are the exception. An imported value such as `from params import AMOUNT` is refused even when unused, since code can read a name without naming it. For the same reason, a star import of a module that exports data values is refused; import the callables you use by name. Each name a star import provides must be that module's own. +- **No other names.** The namespace may hold no name the source does not bind, other than the dunders the import system and annotations set. The module's docstring and builtins must be what importing it made. A name set on the module after import is refused however the code would read it. + +A measures file edited after import, or a constant, default, import or function reassigned before either snapshot, is refused. If it changes after identity time, a receipt built with the identity's handle refuses the changed modifier, naming the difference or the reason it can no longer be bound. The check does not cover state in other modules (such as `params.PAYMENT` read through `import params`), or data the code reads at run time. Code in other modules is bound by `code_revision` or the installed versions, and `code_revision` names the code, not the process. Receipts are claims by their producer: these checks protect an honest producer against mistakes, not a reader against a producer who builds code to mislead. Code that reaches into frames, the garbage collector or other modules' namespaces is beyond them. + +Some modifiers are refused outright because their behaviour depends on values the digest cannot bind: closures, lambdas, nested functions, methods, `functools.partial` and builtins. Anything that is not a `Scenario` is refused too, rather than read as current law. + +To check a certificate, call `verify_certificate(receipt, bundle)` with the policyengine manifest for `certificate.bundle_version`. It rebuilds the certificate from that manifest's certification record, the same way the builder does. Then it compares every identity member: the record digest, the basis, the certified model, core and dataset digest, the certifier and the bundle version. It also checks that the receipt's whole `data` identity is the certified dataset with the certified bytes. An empty list means the bundle certifies exactly what the receipt claims, and through the receipt's own rules that pins `model.version`, `core.version` and `wrapper.version` too. `certificate_source`, `qualification_receipt_sha256`, `tro_composition_fingerprint` and wheel hashes are not determined by the bundle, so it does not check them. + +Pass the release artifact that was loaded as `dataset_path`. A derived per-year file such as `enhanced_frs_2024_25_year_2026.h5` has different bytes from the artifact the bundle pins, so a receipt for it is refused. + +## Digests + +Every receipt digest is the lowercase hexadecimal SHA-256 of the [RFC 8785](https://www.rfc-editor.org/rfc/rfc8785) canonical JSON of a value, computed in two steps: +1. **Strict JSON conversion.** Only JSON-native values are accepted: strings, booleans, null, integers within ±(2^53−1), finite doubles, arrays and objects with string keys. NumPy boolean, integer and floating scalars are converted to their values, integer keys to their decimal strings, and `datetime.date` to `YYYY-MM-DD`. Anything else is refused rather than silently stringified, including sets, `Decimal`, arrays (0-d and masked ones too, so `np.ma.masked` never becomes `0.0`), NaN and colliding keys. +2. **Null removal.** Object members whose value is `null` are removed. Array elements are kept. + +RFC 8785 fixes member order, string escaping and number formatting, so Python and JavaScript produce the same bytes. Removing nulls means a producer that writes `"field": null` and one that omits the field compute the same digest. In Python use `policyengine.provenance.receipt.content_sha256`. In JavaScript: + +```javascript +import canonicalize from "canonicalize"; +import { createHash } from "node:crypto"; + +const dropNulls = (v) => + Array.isArray(v) ? v.map(dropNulls) + : v && typeof v === "object" + ? Object.fromEntries(Object.entries(v).filter(([, x]) => x !== null).map(([k, x]) => [k, dropNulls(x)])) + : v; +const digest = (value) => createHash("sha256").update(canonicalize(dropNulls(value)), "utf8").digest("hex"); +``` + +Reform digests are the exception: a reform payload is digested with its nulls kept, because there a null can mean something. `Scenario.from_reform({"p": {"2027": None}})` removes a value that `{"p": {}}` keeps, so the two must not share a digest. + +The pre-promotion replay interface with policyengine-scorecard follows a different rule, set by its own schemas: plain RFC 8785 over the document as sent, nulls included. `policyengine.provenance.canonical.canonical_sha256` computes that. + +| Digest | Value hashed | +|---|---| +| `execution_identity_sha256` | `schema_version`, `country_id`, `wrapper`, `model`, `core`, `data`, `certification_status`, and the certificate's content: bundle version, record digest, basis, certified versions, dataset digest and certifier. Results with the same execution identity recorded the same package identities (versions, install origins and wheel evidence) and the same data under the same certificate, however the receipt was built. For a certified receipt those identities name the code that ran. An uncertified one does not bind its packages' contents: two runs from an editable checkout edited in between share an execution identity. | +| `receipt_sha256` | The whole receipt except `runtime` and the reform and baseline `name` labels. Renaming a measure therefore does not change it. | +| `certificate.certificate_sha256` | The bundle manifest's `data_releases.uk` record. | +| `reform.reform_sha256` | `{"representation": ..., "payload": ...}`, with the payload exactly as given, nulls kept (strict JSON conversion, then RFC 8785, with no null removal). | +| `outputs_sha256` | The output payload. | + +Reform digests are never normalized across representations. The same keys can legitimately mean different things in different representations: for example, a bare year in a policyengine-uk country dictionary changes that one year only. So each digest is tied to the representation that was submitted. The v1 representations are `policyengine.parameter_values/v1`, `policyengine.reform_dict/v1`, `policyengine-uk.reform_dict/v1`, `policyengine-uk.scenario/v1` and `policyengine-sim-api.policy/v1`. + +## Rules JSON Schema cannot express + +Consumers that validate with the JSON Schema rather than the Python model must also check the rules listed under `x-policyengine-rules` in the schema file: + +- `years` is sorted with no duplicates, and each year is written as an integer (`2027`, not `2027.0`); +- `supported_years` is sorted with no duplicates, and a year in `years` but not in `supported_years` must be shown as provisional or not at all; +- a certificate is present exactly when the receipt is certified, and it names the receipt's country, model and core versions, dataset digest and `policyengine` version; +- a `current_law` reform has no digest, while any other reform has both a representation and a digest; +- a wheel digest always comes with its evidence; +- a certified receipt has no `editable` package, and no `directory` or `vcs` package without `installed_record_verified` wheel evidence; +- a certificate is trusted only after it, and the receipt's `data`, have been checked against the published manifest it names (`verify_certificate`). + +The schema itself fixes the package names (`policyengine`, `policyengine-uk`, `policyengine-core`) and the UK fiscal-year convention, which starts on 6 April. + +## Compatibility + +Adding an optional field is a compatible v1 change. Optional fields are omitted from digests when absent, so existing digests stay valid. Removing or redefining a field, or changing what a digest covers, requires schema v2. diff --git a/pyproject.toml b/pyproject.toml index 22e077b5..74e04dcf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] dependencies = [ - "pydantic>=2.0.0", + "pydantic>=2.10.0", "pandas>=2.0.0", "h5py>=3.0.0", "microdf_python>=1.3.0,<1.4", @@ -69,6 +69,7 @@ dev = [ "mypy>=1.11.0", "pytest-cov>=5.0.0", "hypothesis>=6.100.0", + "rfc8785>=0.1.4", "policyengine-core==3.33.0", "policyengine-us==2.2.1", "policyengine-uk==2.130.1", @@ -96,6 +97,9 @@ testpaths = [ filterwarnings = [ "ignore::pydantic.warnings.PydanticDeprecatedSince20", ] +markers = [ + "real_install: runs the receipt's loaded-package check against the real installation", +] [tool.ruff] line-length = 88 diff --git a/scripts/generate_receipt_schemas.py b/scripts/generate_receipt_schemas.py new file mode 100644 index 00000000..8340948e --- /dev/null +++ b/scripts/generate_receipt_schemas.py @@ -0,0 +1,60 @@ +"""Write the published JSON Schemas for policyengine.py receipt contracts. + +The Pydantic models in ``policyengine.provenance.receipt`` are the source; +the schema files under ``src/policyengine/data/schemas/`` are what other +languages consume. ``tests/test_execution_receipt.py`` checks that the two +accept and reject the same receipts. + +Usage:: + + python scripts/generate_receipt_schemas.py # write + python scripts/generate_receipt_schemas.py --check # fail if stale +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +SCHEMA_DIR = REPO_ROOT / "src" / "policyengine" / "data" / "schemas" +os.environ.setdefault("POLICYENGINE_SKIP_COUNTRY_IMPORTS", "1") +sys.path.insert(0, str(REPO_ROOT / "src")) + +from policyengine.provenance.receipt import ExecutionReceipt # noqa: E402 + +SCHEMAS = { + "execution_receipt.v1.schema.json": ExecutionReceipt.json_schema, +} + + +def render(builder) -> str: + return json.dumps(builder(), indent=2, sort_keys=True, ensure_ascii=False) + "\n" + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--check", action="store_true") + args = parser.parse_args(argv) + stale = [] + for filename, builder in SCHEMAS.items(): + path = SCHEMA_DIR / filename + content = render(builder) + if path.exists() and path.read_text() == content: + continue + if args.check: + stale.append(path) + continue + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + print(f"wrote {path.relative_to(REPO_ROOT)}") + for path in stale: + print(f"{path.relative_to(REPO_ROOT)} is not up to date.", file=sys.stderr) + return 1 if stale else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/policyengine/__init__.py b/src/policyengine/__init__.py index 587cc254..55af1947 100644 --- a/src/policyengine/__init__.py +++ b/src/policyengine/__init__.py @@ -48,3 +48,11 @@ uk = None # type: ignore[assignment] __all__ = ["Simulation", "outputs", "uk", "us"] + +# The wrapper's own version as imported, for receipts to compare with what is +# installed when they are built. +from policyengine.provenance.loaded import ( # noqa: E402 + record_loaded_versions as _record_loaded_versions, +) + +_record_loaded_versions(("policyengine",)) diff --git a/src/policyengine/data/schemas/execution_receipt.v1.schema.json b/src/policyengine/data/schemas/execution_receipt.v1.schema.json new file mode 100644 index 00000000..d10e4555 --- /dev/null +++ b/src/policyengine/data/schemas/execution_receipt.v1.schema.json @@ -0,0 +1,719 @@ +{ + "$defs": { + "CertificateIdentity": { + "additionalProperties": false, + "description": "The bundle certification that admitted this model, core and data.", + "properties": { + "bundle_version": { + "description": "policyengine.py version carrying the certificate.", + "title": "Bundle Version", + "type": "string" + }, + "certificate_sha256": { + "description": "RFC 8785 SHA-256 of the bundle manifest's data_releases. record (see certificate_sha256()).", + "pattern": "^[0-9a-f]{64}$", + "title": "Certificate Sha256", + "type": "string" + }, + "certificate_source": { + "description": "packaged: read from the installed policyengine bundle; caller_supplied: a bundle manifest passed by the caller.", + "enum": [ + "packaged", + "caller_supplied" + ], + "title": "Certificate Source", + "type": "string" + }, + "certified_by": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Certified By" + }, + "certified_dataset_sha256": { + "pattern": "^[0-9a-f]{64}$", + "title": "Certified Dataset Sha256", + "type": "string" + }, + "certified_for_core_version": { + "title": "Certified For Core Version", + "type": "string" + }, + "certified_for_model_version": { + "title": "Certified For Model Version", + "type": "string" + }, + "compatibility_basis": { + "description": "How the pair was admitted, e.g. built_with_model_package.", + "title": "Compatibility Basis", + "type": "string" + }, + "country_id": { + "const": "uk", + "title": "Country Id", + "type": "string" + }, + "qualification_receipt_sha256": { + "anyOf": [ + { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Digest of the candidate qualification receipt, when one exists.", + "title": "Qualification Receipt Sha256" + }, + "tro_composition_fingerprint": { + "anyOf": [ + { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Composition fingerprint of the packaged bundle TRACE TRO.", + "title": "Tro Composition Fingerprint" + } + }, + "required": [ + "bundle_version", + "country_id", + "certificate_sha256", + "compatibility_basis", + "certified_for_model_version", + "certified_for_core_version", + "certified_dataset_sha256", + "certificate_source" + ], + "title": "CertificateIdentity", + "type": "object" + }, + "CoreIdentity": { + "additionalProperties": false, + "description": "The policyengine-core distribution.", + "properties": { + "install_origin": { + "anyOf": [ + { + "enum": [ + "archive", + "directory", + "editable", + "vcs" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Install origin recorded by PEP 610 direct_url.json; absent for an ordinary index install. Directory and VCS installs require installed_record_verified wheel evidence for certification; an editable install is never certified.", + "title": "Install Origin" + }, + "name": { + "const": "policyengine-core", + "title": "Name", + "type": "string" + }, + "version": { + "description": "Installed version (importlib.metadata).", + "title": "Version", + "type": "string" + }, + "wheel_sha256": { + "anyOf": [ + { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "SHA-256 of the wheel the installed files came from. Present only when verified by the receipt builder: by checking the installed files against the wheel's RECORD, or from the installer's recorded archive hash.", + "title": "Wheel Sha256" + }, + "wheel_sha256_evidence": { + "anyOf": [ + { + "enum": [ + "installed_record_verified", + "installer_archive_hash" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "How wheel_sha256 was established.", + "title": "Wheel Sha256 Evidence" + } + }, + "required": [ + "name", + "version" + ], + "title": "CoreIdentity", + "type": "object" + }, + "DataIdentity": { + "additionalProperties": false, + "description": "The data release and the exact dataset bytes that were simulated.", + "properties": { + "build_fingerprint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Producer data-build fingerprint, when published.", + "title": "Build Fingerprint" + }, + "build_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Producer build identity, e.g. policyengine-uk-data-1.58.0.", + "title": "Build Id" + }, + "build_model_version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Country model version the data was built with (not the one that ran).", + "title": "Build Model Version" + }, + "data_package": { + "description": "Data package, e.g. policyengine-uk-data.", + "title": "Data Package", + "type": "string" + }, + "dataset": { + "description": "Logical dataset name, e.g. enhanced_frs_2024_25.", + "title": "Dataset", + "type": "string" + }, + "dataset_revision": { + "description": "Revision the dataset was read at.", + "title": "Dataset Revision", + "type": "string" + }, + "dataset_sha256": { + "description": "SHA-256 of the dataset bytes that were simulated.", + "pattern": "^[0-9a-f]{64}$", + "title": "Dataset Sha256", + "type": "string" + }, + "dataset_uri": { + "description": "Pinned dataset URI, e.g. hf://...@1.58.0.", + "title": "Dataset Uri", + "type": "string" + }, + "release_manifest_revision": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Hugging Face commit of the release manifest.", + "title": "Release Manifest Revision" + }, + "release_version": { + "description": "Data release version or artifact revision.", + "title": "Release Version", + "type": "string" + } + }, + "required": [ + "data_package", + "release_version", + "dataset", + "dataset_uri", + "dataset_revision", + "dataset_sha256" + ], + "title": "DataIdentity", + "type": "object" + }, + "FiscalYearConvention": { + "additionalProperties": false, + "description": "What an annual period label means in this result.\n\nv1 receipts are UK results, so the convention is the UK fiscal year: year\nY is the period from 6 April Y to 5 April Y+1. Another convention needs a\nnew schema version.", + "properties": { + "convention": { + "const": "uk_fiscal_year", + "title": "Convention", + "type": "string" + }, + "parameter_annualisation": { + "description": "Identifier of the annualisation rule the recorded model version implements, e.g. policyengine-uk/fiscal-year-parameters.", + "title": "Parameter Annualisation", + "type": "string" + }, + "period_start": { + "const": "04-06", + "description": "Month-day the labelled period starts (6 April).", + "title": "Period Start", + "type": "string" + }, + "year_label": { + "const": "start_year", + "default": "start_year", + "description": "Year Y labels the period that starts in Y (2027 is 2027-28).", + "title": "Year Label", + "type": "string" + } + }, + "required": [ + "convention", + "period_start", + "parameter_annualisation" + ], + "title": "FiscalYearConvention", + "type": "object" + }, + "ModelIdentity": { + "additionalProperties": false, + "description": "The country model distribution (v1: policyengine-uk).", + "properties": { + "install_origin": { + "anyOf": [ + { + "enum": [ + "archive", + "directory", + "editable", + "vcs" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Install origin recorded by PEP 610 direct_url.json; absent for an ordinary index install. Directory and VCS installs require installed_record_verified wheel evidence for certification; an editable install is never certified.", + "title": "Install Origin" + }, + "name": { + "const": "policyengine-uk", + "title": "Name", + "type": "string" + }, + "version": { + "description": "Installed version (importlib.metadata).", + "title": "Version", + "type": "string" + }, + "wheel_sha256": { + "anyOf": [ + { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "SHA-256 of the wheel the installed files came from. Present only when verified by the receipt builder: by checking the installed files against the wheel's RECORD, or from the installer's recorded archive hash.", + "title": "Wheel Sha256" + }, + "wheel_sha256_evidence": { + "anyOf": [ + { + "enum": [ + "installed_record_verified", + "installer_archive_hash" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "How wheel_sha256 was established.", + "title": "Wheel Sha256 Evidence" + } + }, + "required": [ + "name", + "version" + ], + "title": "ModelIdentity", + "type": "object" + }, + "ReformIdentity": { + "additionalProperties": false, + "description": "A reform or baseline, bound by the digest of its canonical form.", + "properties": { + "binding": { + "default": "content", + "description": "content: the digest determines the reform's behaviour. code_revision: the reform runs a module-level Python function, bound by its source, its module's source, its default arguments and the caller's code revision.", + "enum": [ + "content", + "code_revision" + ], + "title": "Binding", + "type": "string" + }, + "kind": { + "description": "current_law means no parameter or structural change.", + "enum": [ + "current_law", + "reform" + ], + "title": "Kind", + "type": "string" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Human label; part of no digest.", + "title": "Name" + }, + "reform_sha256": { + "anyOf": [ + { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "SHA-256 of the RFC 8785 serialisation of {'representation': ..., 'payload': ...}, nulls kept (unlike content_sha256).", + "title": "Reform Sha256" + }, + "representation": { + "anyOf": [ + { + "enum": [ + "policyengine.parameter_values/v1", + "policyengine.reform_dict/v1", + "policyengine-uk.reform_dict/v1", + "policyengine-sim-api.policy/v1", + "policyengine-uk.scenario/v1" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Representation" + } + }, + "required": [ + "kind" + ], + "title": "ReformIdentity", + "type": "object" + }, + "RuntimeContext": { + "additionalProperties": false, + "description": "Where the execution ran. Part of no digest.", + "properties": { + "ci_run_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ci Run Url" + }, + "platform": { + "title": "Platform", + "type": "string" + }, + "python_version": { + "title": "Python Version", + "type": "string" + } + }, + "required": [ + "python_version", + "platform" + ], + "title": "RuntimeContext", + "type": "object" + }, + "WrapperIdentity": { + "additionalProperties": false, + "description": "The policyengine.py distribution, optionally pinned to a source commit.", + "properties": { + "git_sha": { + "anyOf": [ + { + "pattern": "^[0-9a-f]{40}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "policyengine.py commit, when running from a checkout.", + "title": "Git Sha" + }, + "install_origin": { + "anyOf": [ + { + "enum": [ + "archive", + "directory", + "editable", + "vcs" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Install origin recorded by PEP 610 direct_url.json; absent for an ordinary index install. Directory and VCS installs require installed_record_verified wheel evidence for certification; an editable install is never certified.", + "title": "Install Origin" + }, + "name": { + "const": "policyengine", + "title": "Name", + "type": "string" + }, + "version": { + "description": "Installed version (importlib.metadata).", + "title": "Version", + "type": "string" + }, + "wheel_sha256": { + "anyOf": [ + { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "SHA-256 of the wheel the installed files came from. Present only when verified by the receipt builder: by checking the installed files against the wheel's RECORD, or from the installer's recorded archive hash.", + "title": "Wheel Sha256" + }, + "wheel_sha256_evidence": { + "anyOf": [ + { + "enum": [ + "installed_record_verified", + "installer_archive_hash" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "How wheel_sha256 was established.", + "title": "Wheel Sha256 Evidence" + } + }, + "required": [ + "name", + "version" + ], + "title": "WrapperIdentity", + "type": "object" + } + }, + "$id": "https://policyengine.org/schemas/execution_receipt.v1.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "Schema v1 execution receipt. See the module docstring for its rules.", + "properties": { + "baseline": { + "$ref": "#/$defs/ReformIdentity" + }, + "certificate": { + "anyOf": [ + { + "$ref": "#/$defs/CertificateIdentity" + }, + { + "type": "null" + } + ], + "default": null + }, + "certification_status": { + "enum": [ + "certified", + "uncertified" + ], + "title": "Certification Status", + "type": "string" + }, + "core": { + "$ref": "#/$defs/CoreIdentity" + }, + "country_id": { + "const": "uk", + "title": "Country Id", + "type": "string" + }, + "data": { + "$ref": "#/$defs/DataIdentity" + }, + "fiscal_year": { + "$ref": "#/$defs/FiscalYearConvention" + }, + "model": { + "$ref": "#/$defs/ModelIdentity" + }, + "outputs_sha256": { + "anyOf": [ + { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "content_sha256 of the canonical output payload this receipt binds.", + "title": "Outputs Sha256" + }, + "receipt_type": { + "const": "policyengine.execution_receipt", + "title": "Receipt Type", + "type": "string" + }, + "reform": { + "$ref": "#/$defs/ReformIdentity" + }, + "runtime": { + "anyOf": [ + { + "$ref": "#/$defs/RuntimeContext" + }, + { + "type": "null" + } + ], + "default": null + }, + "schema_version": { + "const": 1, + "title": "Schema Version", + "type": "integer" + }, + "supported_years": { + "description": "Years the installed model projects this dataset to, from its data year (policyengine-uk's extend_single_year_dataset, no formula run). A year in years that is not here is unsupported: policyengine-core fills it by carrying or uprating the latest inputs, so its results are not evidence and must be shown as provisional or not at all.", + "items": { + "type": "integer" + }, + "title": "Supported Years", + "type": "array" + }, + "wrapper": { + "$ref": "#/$defs/WrapperIdentity" + }, + "years": { + "items": { + "type": "integer" + }, + "minItems": 1, + "title": "Years", + "type": "array" + } + }, + "required": [ + "schema_version", + "receipt_type", + "country_id", + "wrapper", + "model", + "core", + "data", + "certification_status", + "reform", + "baseline", + "fiscal_year", + "years", + "supported_years" + ], + "title": "ExecutionReceipt", + "type": "object", + "x-policyengine-digests": { + "canonicalization": "RFC 8785 (JSON Canonicalization Scheme) over the stated value after removing object members whose value is null (array elements are kept). Numbers are IEEE 754 doubles; integers must lie within +/-(2**53 - 1).", + "certificate.certificate_sha256": "the bundle manifest's data_releases. record", + "execution_identity_sha256": "{schema_version, country_id, wrapper, model, core, data, certification_status, certificate?} where certificate keeps only bundle_version, country_id, certificate_sha256, compatibility_basis, certified_for_model_version, certified_for_core_version, certified_dataset_sha256 and certified_by", + "outputs_sha256": "the canonical output payload the receipt binds", + "receipt_sha256": "the whole receipt without runtime and without reform.name and baseline.name", + "reform.reform_sha256": "RFC 8785 over {representation, payload} where payload is the reform exactly as given in that representation, nulls kept (a null in a reform can mean something, unlike an absent receipt field)" + }, + "x-policyengine-rules": [ + "years is sorted ascending with no duplicates, and each year is written as an integer (2027, not 2027.0).", + "supported_years is sorted ascending with no duplicates. A year in years that is not in supported_years is unsupported: the model does not project the dataset to it, so a consumer must show its results as provisional or not at all.", + "certification_status certified requires certificate; uncertified forbids it.", + "certificate.country_id equals country_id.", + "certificate.certified_for_model_version equals model.version.", + "certificate.certified_for_core_version equals core.version.", + "certificate.certified_dataset_sha256 equals data.dataset_sha256.", + "certificate.bundle_version equals wrapper.version.", + "reform/baseline kind current_law has no representation or reform_sha256 and binding content; kind reform has both.", + "wheel_sha256 and wheel_sha256_evidence are present together or not at all.", + "certification_status certified requires each of wrapper, model and core to have install_origin absent or archive, or install_origin directory or vcs with wheel_sha256_evidence installed_record_verified; an editable install is never certified.", + "A consumer trusts a certificate only after checking it against the data_releases. record of the published policyengine manifest for certificate.bundle_version (verify_certificate()): certificate_sha256 is that record's digest, every other member of certificate except certificate_source, qualification_receipt_sha256 and tro_composition_fingerprint equals what the record states, and data equals the data identity the record determines. certificate_source is the producer's statement and proves nothing." + ] +} diff --git a/src/policyengine/provenance/__init__.py b/src/policyengine/provenance/__init__.py index fe1f8046..91d0bdca 100644 --- a/src/policyengine/provenance/__init__.py +++ b/src/policyengine/provenance/__init__.py @@ -15,6 +15,12 @@ ) """ +from .canonical import ( + canonical_json as canonical_json, +) +from .canonical import ( + canonical_sha256 as canonical_sha256, +) from .certification import ( CertificationError as CertificationError, ) @@ -87,6 +93,18 @@ from .manifest import ( resolve_managed_dataset_reference as resolve_managed_dataset_reference, ) +from .receipt import ( + ExecutionReceipt as ExecutionReceipt, +) +from .receipt import ( + build_execution_receipt as build_execution_receipt, +) +from .receipt import ( + reform_identity as reform_identity, +) +from .receipt import ( + verify_certificate as verify_certificate, +) from .trace import ( build_simulation_trace_tro as build_simulation_trace_tro, ) diff --git a/src/policyengine/provenance/bytecode.py b/src/policyengine/provenance/bytecode.py new file mode 100644 index 00000000..3a8d9586 --- /dev/null +++ b/src/policyengine/provenance/bytecode.py @@ -0,0 +1,179 @@ +"""Compare compiled code with source, ignoring where it was compiled. + +A code object records the file it was compiled from and its line numbers. +Two compilations of the same source in different checkouts differ only +there, so :func:`code_shape` leaves both out; everything that changes what +the code does (instructions, names, constants, nested code) stays in. +""" + +from __future__ import annotations + +import importlib.util +import marshal +import os +import struct +from pathlib import Path +from types import CodeType +from typing import Any, Optional + +__all__ = ["CompiledFile", "code_shape", "compiled_file", "compile_source"] + +#: ``compile()`` optimisation level held by each bytecode cache name suffix +#: (``x.cpython-313.pyc``, ``x.cpython-313.opt-1.pyc``, ``...opt-2.pyc``). +OPTIMIZATION_LEVELS = {"": 0, "1": 1, "2": 2} + +_HEADER = 16 +_HASH_BASED = 0b01 +_CHECK_SOURCE = 0b10 + + +def _float_bits(value: float) -> bytes: + """A float's binary64 bytes: its sign, NaN payload and zero sign included.""" + + return struct.pack(" tuple[Any, ...]: + """A code constant with its exact type, so equal-but-different values differ. + + ``(1,) == (1.0,)``, ``True == 1`` and ``0.0 == -0.0`` in Python, but code + holding one behaves differently from code holding the other, so tuples + and frozensets are encoded item by item and floats by their IEEE 754 + bytes (``float.hex`` writes every NaN as ``nan``, but ``math.copysign`` + tells a NaN's sign apart). + + With ``seen``, an object met before in the same code tree is encoded as a + reference to its first occurrence, so sharing counts too: in + ``items = ((1,), (1,))`` the compiler makes the two inner tuples one + object, and ``items[0] is items[1]`` is true for it but false for a cache + holding two equal tuples. The compiler and marshal both keep sharing, so + a cache Python wrote numbers its objects as a fresh compilation does. + """ + + if seen is not None: + if id(value) in seen: + return ("ref", seen[id(value)]) + seen[id(value)] = len(seen) + kind = type(value) + if kind is float: + return (kind, _float_bits(value)) + if kind is complex: + return (kind, _float_bits(value.real), _float_bits(value.imag)) + if kind is tuple: + return (kind, tuple(_constant(item, seen) for item in value)) + if kind is frozenset: + # Members in an order fixed by their values alone, so two equal sets + # number their shared objects alike whatever their hash order. + members = sorted(value, key=lambda item: repr(_constant(item, None))) + return (kind, tuple(_constant(item, seen) for item in members)) + if isinstance(value, CodeType): + return (CodeType, code_shape(value, _seen=seen)) + if kind in (type(None), type(Ellipsis)): + return (kind,) + return (kind, value) + + +def code_shape( + code: CodeType, *, _seen: Optional[dict[int, int]] = None +) -> tuple[Any, ...]: + """A code object without where it was compiled: no file, no line numbers. + + Constants are compared with their types, float bits and sharing across + the whole code tree (see :func:`_constant`). + """ + + seen: dict[int, int] = {} if _seen is None else _seen + return ( + code.co_name, + code.co_qualname, + code.co_argcount, + code.co_posonlyargcount, + code.co_kwonlyargcount, + code.co_flags, + code.co_stacksize, + code.co_code, + code.co_names, + code.co_varnames, + code.co_freevars, + code.co_cellvars, + code.co_exceptiontable, + tuple(_constant(const, seen) for const in code.co_consts), + ) + + +def compile_source( + source: str | bytes, filename: str, *, flags: int = 0, optimize: int = -1 +) -> Optional[CodeType]: + """``source`` compiled as a module, or ``None`` when it does not compile.""" + + try: + compiled: CodeType = compile( + source, filename, "exec", flags=flags, dont_inherit=True, optimize=optimize + ) + except (SyntaxError, ValueError): + return None + return compiled + + +class CompiledFile: + """What a bytecode cache file holds and whether the interpreter loads it.""" + + def __init__(self, status: str, detail: str = "") -> None: + #: ``matches`` (loaded, and compiles from the source), ``differs`` + #: (loaded, but does not), ``inert`` (the interpreter would ignore it + #: and compile the source instead) or ``invalid``. + self.status = status + self.detail = detail + + +def compiled_file(pyc: Path, source: Path, optimization: str) -> CompiledFile: + """Check a ``__pycache__`` file against the source it caches. + + The interpreter loads a timestamp-based cache only while its recorded + source modification time and size match the source, and a checked + hash-based cache only while its recorded source hash matches; otherwise + it compiles the source and the cache is ``inert``. A checked cache is + treated as loaded whatever its hash, because ``--check-hash-based-pycs + never`` loads it unchecked, and an unchecked one is always loaded. A + loaded cache must compile from the source at its optimisation level. The + cache is unmarshalled, never executed. + """ + + level = OPTIMIZATION_LEVELS.get(optimization) + if level is None: + return CompiledFile("invalid", f"optimisation level {optimization!r}") + try: + data = pyc.read_bytes() + source_bytes = source.read_bytes() + except OSError as exc: + # The interpreter ignores a cache it cannot read and compiles the source. + return CompiledFile("inert", f"unreadable ({type(exc).__name__})") + if len(data) < _HEADER or data[:4] != importlib.util.MAGIC_NUMBER: + return CompiledFile("inert", "written for another interpreter") + flags = int.from_bytes(data[4:8], "little") + if flags & ~(_HASH_BASED | _CHECK_SOURCE): + # importlib refuses unknown flag bits and falls back to the source. + return CompiledFile("inert", "unknown header flags") + if not flags & _HASH_BASED: + # Timestamp-based, whatever the check-source bit says (importlib + # reads that bit only for hash-based caches). + stat = os.stat(source) + recorded = ( + int.from_bytes(data[8:12], "little"), + int.from_bytes(data[12:16], "little"), + ) + current = (int(stat.st_mtime) & 0xFFFFFFFF, stat.st_size & 0xFFFFFFFF) + if recorded != current: + return CompiledFile("inert", "stale timestamp") + try: + loaded = marshal.loads(data[_HEADER:]) + except (EOFError, ValueError, TypeError): + return CompiledFile("differs", "unreadable bytecode") + compiled = compile_source(source_bytes, str(source), optimize=level) + if ( + not isinstance(loaded, CodeType) + or compiled is None + or code_shape(loaded) != code_shape(compiled) + ): + return CompiledFile("differs", "does not compile from the verified source") + return CompiledFile("matches") diff --git a/src/policyengine/provenance/canonical.py b/src/policyengine/provenance/canonical.py new file mode 100644 index 00000000..cf77cad4 --- /dev/null +++ b/src/policyengine/provenance/canonical.py @@ -0,0 +1,200 @@ +"""Cross-language canonical JSON and content digests for receipts. + +Receipts are hashed in Python (this package, the scorecard), JavaScript (the +Budget dashboard) and other consumers, so their digests use the JSON +Canonicalization Scheme (RFC 8785) rather than Python's ``json.dumps`` +spelling. Two implementations that agree on RFC 8785 produce identical bytes +for the same JSON value, so a digest computed anywhere can be checked +anywhere. + +RFC 8785 in brief: + +- object members are sorted by the UTF-16 code units of their names; +- no insignificant whitespace; +- strings are escaped as ECMAScript ``JSON.stringify`` escapes them, and + everything else is written as literal UTF-8; +- numbers are written as ECMAScript ``Number.prototype.toString`` writes + IEEE 754 doubles. + +The TRACE TRO digests in :mod:`policyengine.provenance.trace` keep their own +documented serialization; this module is for receipt contracts. +""" + +from __future__ import annotations + +import hashlib +import math +from collections.abc import Mapping, Sequence +from typing import Any + +__all__ = [ + "CanonicalJSONError", + "canonical_json", + "canonical_sha256", + "drop_null_members", + "MAX_SAFE_INTEGER", +] + +#: Largest integer every IEEE 754 double consumer reads exactly (2**53 - 1). +#: Larger integers are refused, as the rfc8785 reference implementation does. +MAX_SAFE_INTEGER = 2**53 - 1 + +_SHORT_ESCAPES = { + '"': '\\"', + "\\": "\\\\", + "\b": "\\b", + "\f": "\\f", + "\n": "\\n", + "\r": "\\r", + "\t": "\\t", +} + + +class CanonicalJSONError(ValueError): + """Raised for values RFC 8785 cannot serialize identically everywhere.""" + + +def _string(value: str) -> str: + parts = ['"'] + for character in value: + escaped = _SHORT_ESCAPES.get(character) + if escaped is not None: + parts.append(escaped) + continue + code_point = ord(character) + if code_point < 0x20: + parts.append(f"\\u{code_point:04x}") + elif 0xD800 <= code_point <= 0xDFFF: + raise CanonicalJSONError( + "Strings with unpaired surrogate code points have no canonical " + "UTF-8 form." + ) + else: + parts.append(character) + parts.append('"') + return "".join(parts) + + +def _number(value: float) -> str: + """Write a double as ECMAScript ``Number.prototype.toString`` does.""" + if math.isnan(value) or math.isinf(value): + raise CanonicalJSONError("NaN and infinite numbers are not valid JSON.") + if value == 0: + return "0" + sign = "-" if value < 0 else "" + # ``repr`` gives the shortest digit string that round-trips, which is the + # digit string ECMAScript uses; only the layout around it differs. + # ``float(...)`` first: subclasses such as ``numpy.float64`` have their own + # ``repr`` (``np.float64(1.5)``), which is not a JSON number. + mantissa, _, exponent_text = repr(float(abs(value))).partition("e") + integer_part, _, fraction_part = mantissa.partition(".") + all_digits = integer_part + fraction_part + # ``point`` counts digits before the decimal point in ``all_digits``. + point = len(integer_part) + (int(exponent_text) if exponent_text else 0) + significant = all_digits.lstrip("0") + point -= len(all_digits) - len(significant) + digits = significant.rstrip("0") + # ECMAScript names: value = digits * 10 ** (n - k). + k = len(digits) + n = point + if k <= n <= 21: + text = digits + "0" * (n - k) + elif 0 < n <= 21: + text = digits[:n] + "." + digits[n:] + elif -6 < n <= 0: + text = "0." + "0" * (-n) + digits + else: + exponent_sign = "+" if n - 1 >= 0 else "-" + tail = digits[0] if k == 1 else digits[0] + "." + digits[1:] + text = f"{tail}e{exponent_sign}{abs(n - 1)}" + return sign + text + + +def _serialize(value: Any, out: list[str]) -> None: + if value is None: + out.append("null") + elif value is True: + out.append("true") + elif value is False: + out.append("false") + elif isinstance(value, str): + out.append(_string(value)) + elif isinstance(value, int): + if abs(value) > MAX_SAFE_INTEGER: + # Matches the rfc8785 reference implementation, which refuses + # integers it cannot be sure every consumer reads identically. + raise CanonicalJSONError( + f"Integer {value} is outside +/-(2**53 - 1); write it as a " + "string or a float." + ) + # ``int.__repr__`` ignores an int subclass's own ``__str__``/``__repr__`` + # (``IntEnum`` members are written as their value). + out.append(int.__repr__(int(value))) + elif isinstance(value, float): + out.append(_number(value)) + elif isinstance(value, Mapping): + items = [] + for key, item in value.items(): + if not isinstance(key, str): + raise CanonicalJSONError( + f"Object member names must be strings, got {type(key).__name__}." + ) + items.append((key, item)) + items.sort(key=lambda pair: pair[0].encode("utf-16-be", "surrogatepass")) + out.append("{") + for index, (key, item) in enumerate(items): + if index: + out.append(",") + out.append(_string(key)) + out.append(":") + _serialize(item, out) + out.append("}") + elif isinstance(value, Sequence) and not isinstance(value, (bytes, bytearray)): + out.append("[") + for index, item in enumerate(value): + if index: + out.append(",") + _serialize(item, out) + out.append("]") + else: + raise CanonicalJSONError( + f"{type(value).__name__} is not a JSON value; serialize it first." + ) + + +def canonical_json(value: Any) -> bytes: + """Return the RFC 8785 canonical UTF-8 bytes of a JSON value.""" + + out: list[str] = [] + try: + _serialize(value, out) + except RecursionError as exc: + raise CanonicalJSONError( + "The value is nested too deeply to serialize." + ) from exc + return "".join(out).encode("utf-8") + + +def canonical_sha256(value: Any) -> str: + """Return the lowercase hex SHA-256 of a value's canonical JSON bytes.""" + + return hashlib.sha256(canonical_json(value)).hexdigest() + + +def drop_null_members(value: Any) -> Any: + """Remove object members whose value is ``null``, recursively. + + Receipt digests are taken after this step, so a producer that writes + ``"field": null`` and one that omits the field compute the same digest. + Array elements are kept as they are. + """ + + if isinstance(value, Mapping): + return { + key: drop_null_members(item) + for key, item in value.items() + if item is not None + } + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + return [drop_null_members(item) for item in value] + return value diff --git a/src/policyengine/provenance/loaded.py b/src/policyengine/provenance/loaded.py new file mode 100644 index 00000000..d78c7a10 --- /dev/null +++ b/src/policyengine/provenance/loaded.py @@ -0,0 +1,34 @@ +"""The versions of packages as they were when this process loaded them. + +Installing another version while a process runs changes what the +distribution metadata reports but not the code already imported, so a +receipt that read versions only from the metadata would name code that did +not run. policyengine records its own version when it is imported, and each +country model records its model, core and wrapper versions when it loads +the country package; receipts refuse a run whose installed versions differ. +""" + +from __future__ import annotations + +from collections.abc import Iterable +from importlib import metadata + +_LOADED: dict[str, str] = {} + + +def record_loaded_versions(names: Iterable[str]) -> None: + """Record each distribution's installed version, keeping the first seen.""" + + for name in names: + if name in _LOADED: + continue + try: + _LOADED[name] = metadata.version(name) + except metadata.PackageNotFoundError: + continue + + +def loaded_versions() -> dict[str, str]: + """The versions recorded when each distribution was loaded.""" + + return dict(_LOADED) diff --git a/src/policyengine/provenance/receipt.py b/src/policyengine/provenance/receipt.py new file mode 100644 index 00000000..2ccb9345 --- /dev/null +++ b/src/policyengine/provenance/receipt.py @@ -0,0 +1,3477 @@ +"""Execution receipts: exactly what produced a PolicyEngine result (schema v1). + +A receipt travels with a published number (a dashboard row, a briefing +figure, an API response) and names everything that determined it: + +- the wrapper, country model and core versions that actually ran, read + from the installed distributions rather than from the bundle manifest; +- the data release, its build identity, and the dataset's revision and + SHA-256 as read from the bytes that were simulated, resolved as + :func:`~policyengine.provenance.dataset_materialization.materialize_dataset` + resolves it; +- the certificate that admitted that model, core and data, as a digest of + the bundle's certification record; +- the reform and baseline, as digests of their canonical representation; +- the fiscal-year convention that gives the reported years their meaning. + +Schema v1 covers UK results. Digests use RFC 8785 canonical JSON +(:mod:`policyengine.provenance.canonical`) over strict JSON values, so a +JavaScript or Python consumer can recompute them. The JSON Schema ships in +the wheel at ``policyengine/data/schemas/execution_receipt.v1.schema.json`` +and is regenerated by ``scripts/generate_receipt_schemas.py``. + +Rules every valid v1 receipt satisfies: + +- ``certificate`` is present only when the certificate covers the installed + model and core versions, the simulated dataset bytes and the running + wrapper version; otherwise the receipt is ``uncertified``. +- ``years`` is sorted and unique, and each reported year has the meaning + ``fiscal_year`` gives it. ``supported_years`` are the years the installed + model projects the dataset to; a reported year outside them is + unsupported, and its results are provisional. +- ``data`` names the artifact the bundle resolves for ``data.dataset``, and + ``data.dataset_sha256`` is the hash the bundle pins for it: a receipt + never gives an artifact's name to other bytes. A receipt built from a + microsimulation also requires the bytes verified when it was loaded. +- ``wheel_sha256`` with evidence ``installed_record_verified`` means the + installed files and any bytecode the interpreter would load instead of + them match that wheel; ``installer_archive_hash`` comes only from a + ``.whl`` direct URL. +- The execution identity digest covers only what determines the execution + (wrapper, model, core, data and the certificate's content); two results + with the same digest recorded the same package identities, data and + certificate. Certified, those identities name the code that ran; an + uncertified receipt (an editable install, say) does not bind its + packages' contents. The + receipt digest also covers reform, baseline, years, convention and + outputs, but not runtime context or human labels. +- A reform digest binds content (``binding="content"``) unless the reform + runs Python code, in which case it binds that function's source, its + module's source and its module's typed default arguments plus the + caller's code revision, + and says so (``binding="code_revision"``). The digest is the same wherever + the code is checked out and whichever Python runs it. +- A certificate is trusted only after :func:`verify_certificate` has checked + it, and the receipt's data identity, against the published bundle manifest + it names. ``certificate_source`` is the producer's statement, not evidence. + +Adding an optional field is a compatible v1 change. Removing or redefining a +field, or changing what a digest covers, requires schema v2. +""" + +from __future__ import annotations +import __future__ + +import ast +import builtins +import csv +import datetime +import functools +import hashlib +import importlib.util +import inspect +import io +import json +import math +import operator +import platform +import re +import struct +import sys +import zipfile +from collections.abc import Callable, Iterator, Mapping, Sequence +from importlib import metadata +from importlib.metadata import PackageNotFoundError +from pathlib import Path +from types import ( + ClassMethodDescriptorType, + CodeType, + GenericAlias, + ModuleType, + UnionType, +) +from typing import ( + Annotated, + Any, + ClassVar, + Final, + ForwardRef, + Literal, + Optional, + ParamSpec, + ParamSpecArgs, + ParamSpecKwargs, + TypeVar, + TypeVarTuple, + Union, + get_args, + get_origin, +) +from urllib.parse import urlparse +from urllib.request import url2pathname + +import psutil +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +from policyengine.provenance.bytecode import code_shape as _code_shape +from policyengine.provenance.bytecode import compile_source +from policyengine.provenance.canonical import ( + MAX_SAFE_INTEGER, + CanonicalJSONError, + canonical_json, + canonical_sha256, + drop_null_members, +) +from policyengine.provenance.loaded import loaded_versions +from policyengine.utils.hashing import sha256_file + +__all__ = [ + "RECEIPT_SCHEMA_VERSION", + "CertificateIdentity", + "CoreIdentity", + "DataIdentity", + "ExecutionReceipt", + "FiscalYearConvention", + "ModelIdentity", + "PackageIdentity", + "ReceiptError", + "ReformIdentity", + "RuntimeContext", + "WrapperIdentity", + "build_execution_receipt", + "build_execution_receipt_for_microsimulation", + "certificate_sha256", + "content_sha256", + "fiscal_year_label", + "installed_package_identity", + "reform_identity", + "scenario_reform_identity", + "strict_json_value", + "uk_fiscal_year_convention", + "verify_certificate", +] + +RECEIPT_SCHEMA_VERSION: Literal[1] = 1 + +SHA256_PATTERN = r"^[0-9a-f]{64}$" +GIT_SHA_PATTERN = r"^[0-9a-f]{40}$" +#: PEP 503 normalized distribution name. +DISTRIBUTION_PATTERN = r"^[a-z0-9]+(-[a-z0-9]+)*$" + +UK_PACKAGES = { + "wrapper": "policyengine", + "model": "policyengine-uk", + "core": "policyengine-core", +} + +#: Reform representations whose digests a receipt can carry. Different +#: representations can legitimately mean different things for the same +#: keys (a country-dict bare year changes one year; ``Reform.from_dict`` +#: continues after it), so a digest is always taken together with its +#: representation and never normalized across representations. +ReformRepresentation = Literal[ + # ``policyengine.core.run_record.reform_specification`` without ``name``: + # sorted ``{parameter, value, start_date, end_date}`` entries. + "policyengine.parameter_values/v1", + # A wrapper dictionary policy: ``{parameter_path: {date_key: value}}``. + "policyengine.reform_dict/v1", + # A policyengine-uk country dictionary reform, as passed to + # ``Scenario.from_reform`` / ``Simulation(reform=...)``. + "policyengine-uk.reform_dict/v1", + # A request body sent to the simulation API. + "policyengine-sim-api.policy/v1", + # A policyengine-uk ``Scenario`` built from parameter changes and, at + # most, a module-level modifier: see scenario_reform_identity(). + "policyengine-uk.scenario/v1", +] + +#: v1 rules that JSON Schema cannot express. Published in the schema file. +CROSS_FIELD_RULES: tuple[str, ...] = ( + "years is sorted ascending with no duplicates, and each year is written as an integer (2027, not 2027.0).", + "supported_years is sorted ascending with no duplicates. A year in years that is not in supported_years is unsupported: the model does not project the dataset to it, so a consumer must show its results as provisional or not at all.", + "certification_status certified requires certificate; uncertified forbids it.", + "certificate.country_id equals country_id.", + "certificate.certified_for_model_version equals model.version.", + "certificate.certified_for_core_version equals core.version.", + "certificate.certified_dataset_sha256 equals data.dataset_sha256.", + "certificate.bundle_version equals wrapper.version.", + "reform/baseline kind current_law has no representation or reform_sha256 and binding content; kind reform has both.", + "wheel_sha256 and wheel_sha256_evidence are present together or not at all.", + "certification_status certified requires each of wrapper, model and core to have install_origin absent or archive, or install_origin directory or vcs with wheel_sha256_evidence installed_record_verified; an editable install is never certified.", + "A consumer trusts a certificate only after checking it against the data_releases. record of the published policyengine manifest for certificate.bundle_version (verify_certificate()): certificate_sha256 is that record's digest, every other member of certificate except certificate_source, qualification_receipt_sha256 and tro_composition_fingerprint equals what the record states, and data equals the data identity the record determines. certificate_source is the producer's statement and proves nothing.", +) + +#: How each digest is computed. +DIGEST_RULES: dict[str, str] = { + "canonicalization": "RFC 8785 (JSON Canonicalization Scheme) over the stated value after removing object members whose value is null (array elements are kept). Numbers are IEEE 754 doubles; integers must lie within +/-(2**53 - 1).", + "execution_identity_sha256": "{schema_version, country_id, wrapper, model, core, data, certification_status, certificate?} where certificate keeps only bundle_version, country_id, certificate_sha256, compatibility_basis, certified_for_model_version, certified_for_core_version, certified_dataset_sha256 and certified_by", + "receipt_sha256": "the whole receipt without runtime and without reform.name and baseline.name", + "certificate.certificate_sha256": "the bundle manifest's data_releases. record", + "reform.reform_sha256": "RFC 8785 over {representation, payload} where payload is the reform exactly as given in that representation, nulls kept (a null in a reform can mean something, unlike an absent receipt field)", + "outputs_sha256": "the canonical output payload the receipt binds", +} + +HashEvidence = Literal["installed_record_verified", "installer_archive_hash"] + + +class ReceiptError(ValueError): + """Raised when a receipt would claim something the runtime cannot back.""" + + +class _Contract(BaseModel): + """Strict, immutable, JSON round-trippable contract model. + + Strict mode refuses to coerce (``"2027"`` is not a year), so a parsed + receipt digests to exactly what was received. + """ + + model_config = ConfigDict(frozen=True, extra="forbid", strict=True) + + def canonical_payload(self) -> dict[str, Any]: + """The JSON value canonicalized for digests (``None`` members omitted).""" + + return self.model_dump(mode="json", exclude_none=True) + + +class PackageIdentity(_Contract): + """A Python distribution that took part in the execution.""" + + name: str = Field( + pattern=DISTRIBUTION_PATTERN, + description="PEP 503 normalized distribution name, e.g. policyengine-uk.", + ) + version: str = Field(description="Installed version (importlib.metadata).") + wheel_sha256: Optional[str] = Field( + default=None, + pattern=SHA256_PATTERN, + description=( + "SHA-256 of the wheel the installed files came from. Present only " + "when verified by the receipt builder: by checking the installed " + "files against the wheel's RECORD, or from the installer's recorded " + "archive hash." + ), + ) + wheel_sha256_evidence: Optional[HashEvidence] = Field( + default=None, description="How wheel_sha256 was established." + ) + install_origin: Optional[Literal["archive", "directory", "editable", "vcs"]] = ( + Field( + default=None, + description=( + "Install origin recorded by PEP 610 direct_url.json; absent for an " + "ordinary index install. Directory and VCS installs require " + "installed_record_verified wheel evidence for certification; an " + "editable install is never certified." + ), + ) + ) + + @model_validator(mode="after") + def _hash_has_evidence(self) -> PackageIdentity: + if (self.wheel_sha256 is None) != (self.wheel_sha256_evidence is None): + raise ValueError( + "wheel_sha256 and wheel_sha256_evidence must be given together." + ) + return self + + +class WrapperIdentity(PackageIdentity): + """The policyengine.py distribution, optionally pinned to a source commit.""" + + name: Literal["policyengine"] + git_sha: Optional[str] = Field( + default=None, + pattern=GIT_SHA_PATTERN, + description="policyengine.py commit, when running from a checkout.", + ) + + +class ModelIdentity(PackageIdentity): + """The country model distribution (v1: policyengine-uk).""" + + name: Literal["policyengine-uk"] + + +class CoreIdentity(PackageIdentity): + """The policyengine-core distribution.""" + + name: Literal["policyengine-core"] + + +class DataIdentity(_Contract): + """The data release and the exact dataset bytes that were simulated.""" + + data_package: str = Field(description="Data package, e.g. policyengine-uk-data.") + release_version: str = Field( + description="Data release version or artifact revision." + ) + build_id: Optional[str] = Field( + default=None, + description="Producer build identity, e.g. policyengine-uk-data-1.58.0.", + ) + build_fingerprint: Optional[str] = Field( + default=None, description="Producer data-build fingerprint, when published." + ) + build_model_version: Optional[str] = Field( + default=None, + description="Country model version the data was built with (not the one that ran).", + ) + release_manifest_revision: Optional[str] = Field( + default=None, description="Hugging Face commit of the release manifest." + ) + dataset: str = Field(description="Logical dataset name, e.g. enhanced_frs_2024_25.") + dataset_uri: str = Field(description="Pinned dataset URI, e.g. hf://...@1.58.0.") + dataset_revision: str = Field(description="Revision the dataset was read at.") + dataset_sha256: str = Field( + pattern=SHA256_PATTERN, + description="SHA-256 of the dataset bytes that were simulated.", + ) + + +class CertificateIdentity(_Contract): + """The bundle certification that admitted this model, core and data.""" + + bundle_version: str = Field( + description="policyengine.py version carrying the certificate." + ) + country_id: Literal["uk"] + certificate_sha256: str = Field( + pattern=SHA256_PATTERN, + description=( + "RFC 8785 SHA-256 of the bundle manifest's data_releases. " + "record (see certificate_sha256())." + ), + ) + compatibility_basis: str = Field( + description="How the pair was admitted, e.g. built_with_model_package." + ) + certified_for_model_version: str + certified_for_core_version: str + certified_dataset_sha256: str = Field(pattern=SHA256_PATTERN) + certified_by: Optional[str] = None + certificate_source: Literal["packaged", "caller_supplied"] = Field( + description=( + "packaged: read from the installed policyengine bundle; " + "caller_supplied: a bundle manifest passed by the caller." + ) + ) + qualification_receipt_sha256: Optional[str] = Field( + default=None, + pattern=SHA256_PATTERN, + description="Digest of the candidate qualification receipt, when one exists.", + ) + tro_composition_fingerprint: Optional[str] = Field( + default=None, + pattern=SHA256_PATTERN, + description="Composition fingerprint of the packaged bundle TRACE TRO.", + ) + + #: Members that identify the certificate's content (execution identity). + IDENTITY_FIELDS: ClassVar[tuple[str, ...]] = ( + "bundle_version", + "country_id", + "certificate_sha256", + "compatibility_basis", + "certified_for_model_version", + "certified_for_core_version", + "certified_dataset_sha256", + "certified_by", + ) + + +class ReformIdentity(_Contract): + """A reform or baseline, bound by the digest of its canonical form.""" + + kind: Literal["current_law", "reform"] = Field( + description="current_law means no parameter or structural change." + ) + representation: Optional[ReformRepresentation] = None + reform_sha256: Optional[str] = Field( + default=None, + pattern=SHA256_PATTERN, + description=( + "SHA-256 of the RFC 8785 serialisation of {'representation': ..., " + "'payload': ...}, nulls kept (unlike content_sha256)." + ), + ) + binding: Literal["content", "code_revision"] = Field( + default="content", + description=( + "content: the digest determines the reform's behaviour. " + "code_revision: the reform runs a module-level Python function, " + "bound by its source, its module's source, its default arguments " + "and the caller's code revision." + ), + ) + name: Optional[str] = Field( + default=None, description="Human label; part of no digest." + ) + + # Stored outside Pydantic fields and private attributes so equality, + # serialization and digests ignore this process-local re-check handle. + _recompute_identity: ClassVar[Optional[Callable[[], ReformIdentity]]] = None + + def _recheck(self) -> ReformIdentity: + """Recompute a live identity; loaded identities have no handle.""" + + if self._recompute_identity is None: + return self + try: + recomputed = self._recompute_identity() + except ReceiptError as exc: + raise ReceiptError( + f"The modifier changed after its identity was computed: {exc}" + ) from exc + differences = [ + field + for field in type(self).model_fields + if getattr(self, field) != getattr(recomputed, field) + ] + if differences: + raise ReceiptError( + "The modifier changed after its identity was computed: " + + ", ".join(differences) + + " differs." + ) + return recomputed + + @model_validator(mode="after") + def _current_law_has_no_digest(self) -> ReformIdentity: + if self.kind == "current_law": + if self.reform_sha256 is not None or self.representation is not None: + raise ValueError("current_law carries no representation or digest.") + if self.binding != "content": + raise ValueError("current_law is bound by content.") + elif self.reform_sha256 is None or self.representation is None: + raise ValueError("A reform needs a representation and a reform_sha256.") + return self + + +class FiscalYearConvention(_Contract): + """What an annual period label means in this result. + + v1 receipts are UK results, so the convention is the UK fiscal year: year + Y is the period from 6 April Y to 5 April Y+1. Another convention needs a + new schema version. + """ + + convention: Literal["uk_fiscal_year"] + year_label: Literal["start_year"] = Field( + default="start_year", + description="Year Y labels the period that starts in Y (2027 is 2027-28).", + ) + period_start: Literal["04-06"] = Field( + description="Month-day the labelled period starts (6 April)." + ) + parameter_annualisation: str = Field( + description=( + "Identifier of the annualisation rule the recorded model version " + "implements, e.g. policyengine-uk/fiscal-year-parameters." + ) + ) + + +class RuntimeContext(_Contract): + """Where the execution ran. Part of no digest.""" + + python_version: str + platform: str + ci_run_url: Optional[str] = None + + +class ExecutionReceipt(_Contract): + """Schema v1 execution receipt. See the module docstring for its rules.""" + + SCHEMA_ID: ClassVar[str] = ( + "https://policyengine.org/schemas/execution_receipt.v1.json" + ) + + schema_version: Literal[1] + receipt_type: Literal["policyengine.execution_receipt"] + country_id: Literal["uk"] + wrapper: WrapperIdentity + model: ModelIdentity + core: CoreIdentity + data: DataIdentity + certification_status: Literal["certified", "uncertified"] + certificate: Optional[CertificateIdentity] = None + reform: ReformIdentity + baseline: ReformIdentity + fiscal_year: FiscalYearConvention + years: list[int] = Field(min_length=1) + supported_years: list[int] = Field( + description=( + "Years the installed model projects this dataset to, from its data " + "year (policyengine-uk's extend_single_year_dataset, no formula run). " + "A year in years that is not here is unsupported: policyengine-core " + "fills it by carrying or uprating the latest inputs, so its results " + "are not evidence and must be shown as provisional or not at all." + ), + ) + outputs_sha256: Optional[str] = Field( + default=None, + pattern=SHA256_PATTERN, + description="content_sha256 of the canonical output payload this receipt binds.", + ) + runtime: Optional[RuntimeContext] = None + + @field_validator("schema_version", mode="before") + @classmethod + def _schema_version_is_an_integer(cls, value: Any) -> Any: + # ``True == 1``, so a Literal[1] alone would accept a boolean. + if _is_boolean(value): + raise ValueError("schema_version must be the integer 1, not a boolean.") + return value + + @field_validator("years", "supported_years") + @classmethod + def _sorted_unique_years(cls, years: list[int]) -> list[int]: + if years != sorted(set(years)): + raise ValueError("years must be sorted and unique.") + return years + + @property + def unsupported_years(self) -> list[int]: + """Reported years outside the dataset's projection (see supported_years).""" + + return sorted(set(self.years) - set(self.supported_years)) + + @model_validator(mode="after") + def _identity_rules(self) -> ExecutionReceipt: + if self.certification_status == "uncertified": + if self.certificate is not None: + raise ValueError("An uncertified receipt carries no certificate.") + return self + certificate = self.certificate + if certificate is None: + raise ValueError("A certified receipt needs a certificate.") + for package in (self.wrapper, self.model, self.core): + if not _package_can_be_certified(package): + raise ValueError( + f"A certified receipt cannot name {package.name} installed from " + f"{package.install_origin}: {_uncertifiable_reason(package)}." + ) + if certificate.country_id != self.country_id: + raise ValueError("Certificate country differs from the receipt country.") + if certificate.certified_for_model_version != self.model.version: + raise ValueError( + f"The certificate covers model {certificate.certified_for_model_version}, " + f"but {self.model.version} ran." + ) + if certificate.certified_for_core_version != self.core.version: + raise ValueError( + f"The certificate covers core {certificate.certified_for_core_version}, " + f"but {self.core.version} ran." + ) + if certificate.certified_dataset_sha256 != self.data.dataset_sha256: + raise ValueError( + "The certificate covers different dataset bytes from those simulated." + ) + if certificate.bundle_version != self.wrapper.version: + raise ValueError( + "The certificate belongs to a different policyengine.py version." + ) + return self + + # --- Digests --------------------------------------------------------- + def execution_identity(self) -> dict[str, Any]: + """The canonical value that identifies the runtime, data and certificate.""" + + identity: dict[str, Any] = { + "schema_version": self.schema_version, + "country_id": self.country_id, + "wrapper": self.wrapper.canonical_payload(), + "model": self.model.canonical_payload(), + "core": self.core.canonical_payload(), + "data": self.data.canonical_payload(), + "certification_status": self.certification_status, + } + if self.certificate is not None: + payload = self.certificate.canonical_payload() + identity["certificate"] = { + key: payload[key] + for key in CertificateIdentity.IDENTITY_FIELDS + if key in payload + } + return identity + + @property + def execution_identity_sha256(self) -> str: + return canonical_sha256(self.execution_identity()) + + def receipt_payload(self) -> dict[str, Any]: + """What the receipt digest covers: no runtime context, no labels.""" + + payload = self.canonical_payload() + payload.pop("runtime", None) + for key in ("reform", "baseline"): + payload[key].pop("name", None) + return payload + + @property + def receipt_sha256(self) -> str: + return canonical_sha256(self.receipt_payload()) + + def to_canonical_json(self) -> bytes: + """RFC 8785 bytes of the full receipt, including runtime context.""" + + return canonical_json(self.canonical_payload()) + + @classmethod + def json_schema(cls) -> dict[str, Any]: + """JSON Schema (2020-12), with the rules it cannot express attached.""" + + schema = cls.model_json_schema() + return { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": cls.SCHEMA_ID, + **schema, + "x-policyengine-rules": list(CROSS_FIELD_RULES), + "x-policyengine-digests": dict(DIGEST_RULES), + } + + +# --- Strict JSON values ------------------------------------------------------- + + +def _numpy_scalar(value: Any) -> bool: + """Whether ``value`` is a NumPy boolean, integer or floating scalar. + + Only true scalars: a masked value (``np.ma.masked``, a masked 0-d array) + or a 0-d array is an array, and structured, complex, datetime and string + scalars have no one exact JSON number. + """ + + numpy = sys.modules.get("numpy") + return numpy is not None and isinstance( + value, (numpy.bool_, numpy.integer, numpy.floating) + ) + + +def strict_json_value(value: Any, path: str = "$") -> Any: + """Convert to a JSON value, refusing anything without one exact JSON form. + + Accepted: ``None``, booleans, strings, integers (including ``IntEnum`` and + numpy integer scalars), finite floats (including numpy float scalars), + ``datetime.date`` (as ISO 8601, ``YYYY-MM-DD``), lists and tuples, and + mappings whose keys are strings or integers (integers become their + decimal string; a key collision is refused). Everything else (sets, + ``Decimal``, bytes, arrays, objects, numpy ``longdouble``, values nested + too deeply to walk) raises :class:`ReceiptError`, so no value is silently + stringified. + """ + + try: + return _strict_json_value(value, path) + except RecursionError as exc: + raise ReceiptError(f"{path}: the value is nested too deeply.") from exc + + +def _strict_json_value(value: Any, path: str) -> Any: + if value is None or isinstance(value, (bool, str)): + return value + if _numpy_scalar(value): + item = value.item() + if _numpy_scalar(item): + # longdouble (and similar) has no Python equivalent to unwrap to. + raise ReceiptError( + f"{path}: {type(value).__name__} has no exact JSON form; convert it first." + ) + return _strict_json_value(item, path) + if isinstance(value, int): + return int(value) + if isinstance(value, float): + number = float(value) + if not math.isfinite(number): + raise ReceiptError(f"{path}: {number} is not a finite number.") + return number + if isinstance(value, datetime.datetime): + raise ReceiptError(f"{path}: datetimes are ambiguous; pass an ISO 8601 string.") + if isinstance(value, datetime.date): + return value.isoformat() + if isinstance(value, Mapping): + result: dict[str, Any] = {} + for key, item in value.items(): + if isinstance(key, bool) or not isinstance(key, (str, int)): + raise ReceiptError(f"{path}: object keys must be strings, not {key!r}.") + text = key if isinstance(key, str) else str(int(key)) + if text in result: + raise ReceiptError(f"{path}: keys {key!r} and {text!r} collide.") + result[text] = _strict_json_value(item, f"{path}.{text}") + return result + if isinstance(value, (list, tuple)): + return [ + _strict_json_value(item, f"{path}[{index}]") + for index, item in enumerate(value) + ] + raise ReceiptError( + f"{path}: {type(value).__name__} has no exact JSON form; convert it first." + ) + + +def content_sha256(value: Any) -> str: + """Receipt digest of a value: strict JSON, nulls dropped, RFC 8785, SHA-256. + + Raises :class:`ReceiptError` for anything without one exact JSON form, + including integers outside +/-(2**53 - 1). + """ + + converted = strict_json_value(value) + try: + return canonical_sha256(drop_null_members(converted)) + except RecursionError as exc: + raise ReceiptError("The value is nested too deeply.") from exc + except CanonicalJSONError as exc: + raise ReceiptError(str(exc)) from exc + + +def _payload_sha256(value: Any) -> str: + """Digest of a reform payload: strict JSON with nulls kept, RFC 8785, SHA-256. + + Unlike :func:`content_sha256`, nulls are not removed: in a reform a null + can mean something (``Scenario.from_reform({"p": {"2027": None}})`` + removes a value that ``{"p": {}}`` keeps), so the payload is digested + exactly as given. + """ + + converted = strict_json_value(value) + try: + return canonical_sha256(converted) + except RecursionError as exc: + raise ReceiptError("The value is nested too deeply.") from exc + except CanonicalJSONError as exc: + raise ReceiptError(str(exc)) from exc + + +# --- Helpers ------------------------------------------------------------- + + +def normalize_distribution_name(name: str) -> str: + """PEP 503 normalization: lowercase, runs of -_. become -.""" + + return re.sub(r"[-_.]+", "-", name).lower() + + +def uk_fiscal_year_convention() -> FiscalYearConvention: + """The UK convention: year Y is the fiscal year 6 April Y to 5 April Y+1.""" + + return FiscalYearConvention( + convention="uk_fiscal_year", + period_start="04-06", + parameter_annualisation="policyengine-uk/fiscal-year-parameters", + ) + + +def fiscal_year_label(year: int, convention: FiscalYearConvention) -> str: + """Display label for a reported year under ``convention``: ``2027-28``.""" + + if convention.convention != "uk_fiscal_year": # pragma: no cover - v1 is UK + raise ReceiptError(f"Unknown fiscal year convention {convention.convention}.") + return f"{year}-{(year + 1) % 100:02d}" + + +def certificate_sha256(certification_record: Mapping[str, Any]) -> str: + """Digest of a bundle manifest's ``data_releases.`` record.""" + + return content_sha256(certification_record) + + +def reform_identity( + payload: Optional[Union[Mapping[str, Any], Sequence[Any]]], + representation: Optional[ReformRepresentation] = None, + *, + name: Optional[str] = None, +) -> ReformIdentity: + """Bind a reform payload (a JSON object or array) by digest. + + ``None`` or an empty payload means current law. The payload is digested + exactly as given in its representation, after strict JSON conversion; no + normalization across representations is attempted. A policyengine-uk + ``Scenario`` goes through :func:`scenario_reform_identity`; for + ``Scenario.from_reform(d)`` bind ``d`` with + ``"policyengine-uk.reform_dict/v1"``. + """ + + if payload is None: + return ReformIdentity(kind="current_law", name=name) + if isinstance(payload, (str, bytes)) or not isinstance( + payload, (Mapping, list, tuple) + ): + raise ReceiptError( + f"A reform payload is a JSON object or array, not {type(payload).__name__}." + ) + if not payload: + return ReformIdentity(kind="current_law", name=name) + if representation is None: + raise ReceiptError("A non-empty reform needs its representation.") + if representation == "policyengine-uk.scenario/v1": + raise ReceiptError("Bind a Scenario with scenario_reform_identity().") + digest = _payload_sha256({"representation": representation, "payload": payload}) + return ReformIdentity( + kind="reform", representation=representation, reform_sha256=digest, name=name + ) + + +#: Compiler flags ``from __future__`` imports set. A function's source is +#: recompiled with the flags its module was compiled with. +_FUTURE_FLAGS = functools.reduce( + operator.or_, + (getattr(__future__, name).compiler_flag for name in __future__.all_feature_names), + 0, +) + +_SCENARIO_ATTRIBUTES = ( + "applied_before_data_load", + "parameter_changes", + "simulation_modifier", +) + + +def _compile_module_source(source: str, code: CodeType) -> Optional[CodeType]: + """``source`` compiled as a module, with the flags ``code`` was compiled with.""" + + return compile_source(source, code.co_filename, flags=code.co_flags & _FUTURE_FLAGS) + + +def _top_level_code(module_code: CodeType) -> dict[str, list[CodeType]]: + """Code objects of the functions a module defines at module scope.""" + + found: dict[str, list[CodeType]] = {} + for const in module_code.co_consts: + if not isinstance(const, CodeType): + continue + if const.co_name.startswith(" dict[str, Any]: + """A lossless JSON encoding of a Python literal value, or ``ReceiptError``. + + Unlike :func:`strict_json_value`, it keeps what changes a function's + behaviour: ``1`` and ``1.0``, a tuple and a list, ``None`` and a missing + member, and the order of a dictionary's keys. + """ + + kind = type(value) + if kind is type(None): + return {"type": "none"} + if kind is bool: + return {"type": "bool", "value": value} + if kind is int: + if abs(value) > MAX_SAFE_INTEGER: + raise ReceiptError(f"{path}: {value} is outside +/-(2**53 - 1).") + return {"type": "int", "value": value} + if kind is float: + if not math.isfinite(value): + raise ReceiptError(f"{path}: {value} is not a finite number.") + # By its bits: 0.0 and -0.0 compare equal but behave differently. + return {"type": "float", "hex": value.hex()} + if kind is str: + return {"type": "str", "value": value} + if kind in (tuple, list): + return { + "type": kind.__name__, + "items": [ + _typed_value(item, f"{path}[{index}]") + for index, item in enumerate(value) + ], + } + if kind is dict: + items = [] + for key, item in value.items(): + if type(key) is not str: + raise ReceiptError(f"{path}: dictionary keys must be strings.") + items.append([key, _typed_value(item, f"{path}.{key}")]) + return {"type": "dict", "items": items} + raise ReceiptError( + f"{path}: {kind.__name__} has no exact encoding; use None, bool, int, " + "float, str, tuple, list or dict." + ) + + +_COMPREHENSIONS = (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp) +_SCOPES = (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda) + + +def _walrus_targets(node: ast.AST) -> list[str]: + """``:=`` targets inside a comprehension, which bind its enclosing scope. + + A lambda inside it is a scope of its own: only its default values are + evaluated outside it. + """ + + names: list[str] = [] + if isinstance(node, ast.NamedExpr) and isinstance(node.target, ast.Name): + names.append(node.target.id) + if isinstance(node, ast.Lambda): + for default in [*node.args.defaults, *node.args.kw_defaults]: + if default is not None: + names.extend(_walrus_targets(default)) + return names + for child in ast.iter_child_nodes(node): + names.extend(_walrus_targets(child)) + return names + + +def _expression_bindings(node: Optional[ast.AST]) -> list[str]: + """Names an expression binds in its enclosing scope. + + Plain targets bind. A comprehension's own loop variables do not, but a + ``:=`` inside it binds the enclosing scope (PEP 572). A lambda's body is + its own scope; only its default values are evaluated outside it. + """ + + if node is None: + return [] + if isinstance(node, ast.Name): + return [node.id] if isinstance(node.ctx, ast.Store) else [] + if isinstance(node, ast.Lambda): + names: list[str] = [] + for default in [*node.args.defaults, *node.args.kw_defaults]: + names.extend(_expression_bindings(default)) + return names + if isinstance(node, _COMPREHENSIONS): + return _walrus_targets(node) + names = [] + for child in ast.iter_child_nodes(node): + names.extend(_expression_bindings(child)) + return names + + +def _definition_time_expressions(statement: ast.stmt) -> list[ast.AST]: + """What a ``def`` or ``class`` evaluates in its enclosing scope.""" + + if isinstance(statement, ast.ClassDef): + return [ + *statement.decorator_list, + *statement.bases, + *(keyword.value for keyword in statement.keywords), + ] + if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef)): + arguments = statement.args + annotations = [ + argument.annotation + for argument in [ + *arguments.posonlyargs, + *arguments.args, + *arguments.kwonlyargs, + *(item for item in (arguments.vararg, arguments.kwarg) if item), + ] + if argument.annotation is not None + ] + return [ + *statement.decorator_list, + *arguments.defaults, + *(default for default in arguments.kw_defaults if default is not None), + *annotations, + *([statement.returns] if statement.returns is not None else []), + ] + return [] + + +def _scope_statements(body: Sequence[ast.stmt]) -> list[ast.stmt]: + """Every statement that runs at module scope, inside blocks included.""" + + statements: list[ast.stmt] = [] + for statement in body: + if _is_main_guard(statement): + continue # never runs on import + statements.append(statement) + if isinstance(statement, _SCOPES): + continue + for field in ("body", "orelse", "finalbody"): + statements.extend(_scope_statements(getattr(statement, field, None) or [])) + for handler in getattr(statement, "handlers", None) or []: + statements.extend(_scope_statements(handler.body)) + for case in getattr(statement, "cases", None) or []: + statements.extend(_scope_statements(case.body)) + return statements + + +def _statement_bindings(statement: ast.stmt) -> list[str]: + """Names a module-scope statement itself binds (not its nested statements).""" + + if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + names = [statement.name] + for expression in _definition_time_expressions(statement): + names += _expression_bindings(expression) + return names + if isinstance(statement, (ast.Import, ast.ImportFrom)): + return [(alias.asname or alias.name).split(".")[0] for alias in statement.names] + if isinstance(statement, (ast.For, ast.AsyncFor)): + return _expression_bindings(statement.target) + _expression_bindings( + statement.iter + ) + if isinstance(statement, (ast.If, ast.While)): + return _expression_bindings(statement.test) + if isinstance(statement, (ast.With, ast.AsyncWith)): + names = [] + for item in statement.items: + names += _expression_bindings(item.context_expr) + names += _expression_bindings(item.optional_vars) + return names + if isinstance(statement, ast.Try) or type(statement).__name__ == "TryStar": + return [h.name for h in statement.handlers if h.name] # type: ignore[attr-defined] + if isinstance(statement, ast.Match): + names = _expression_bindings(statement.subject) + for case in statement.cases: + for node in ast.walk(case.pattern): + for attribute in ("name", "rest"): + value = getattr(node, attribute, None) + if isinstance(value, str): + names.append(value) + names += _expression_bindings(case.guard) + return names + return _expression_bindings(statement) + + +def _literal_bindings(tree: ast.Module) -> dict[str, list[Any]]: + """Module-scope names every one of whose bindings assigns a literal. + + Maps each such name to the literal values it can take, from top-level + assignments and assignments inside module-level blocks alike. A name + with any other binding (a computed value, an import, a loop variable, a + ``def``) is not a constant and is left out. + """ + + literals: dict[str, list[Any]] = {} + computed: set[str] = set() + for statement in _scope_statements(tree.body): + value: Optional[ast.expr] = None + names: list[str] = [] + if isinstance(statement, ast.Assign) and all( + isinstance(target, ast.Name) for target in statement.targets + ): + names = [target.id for target in statement.targets] # type: ignore[attr-defined] + value = statement.value + elif ( + isinstance(statement, ast.AnnAssign) + and isinstance(statement.target, ast.Name) + and statement.value is not None + ): + names, value = [statement.target.id], statement.value + # Module annotations run unless postponed; counting a := in one + # when it does not run only refuses more. + computed.update(_expression_bindings(statement.annotation)) + if value is not None: + try: + literal = ast.literal_eval(value) + except (ValueError, TypeError, SyntaxError, MemoryError, RecursionError): + computed.update(names) + else: + for name in names: + literals.setdefault(name, []).append(literal) + computed.update(_expression_bindings(value)) + continue + computed.update(_statement_bindings(statement)) + return {name: values for name, values in literals.items() if name not in computed} + + +def _literal_defaults(definition: ast.FunctionDef) -> Optional[dict[str, Any]]: + """A ``def``'s default values when every default is a literal, else ``None``.""" + + try: + defaults = [ast.literal_eval(node) for node in definition.args.defaults] + kwdefaults = { + arg.arg: ast.literal_eval(node) + for arg, node in zip( + definition.args.kwonlyargs, definition.args.kw_defaults + ) + if node is not None + } + except (ValueError, TypeError, SyntaxError, MemoryError, RecursionError): + return None + return {"defaults": defaults, "kwdefaults": kwdefaults} + + +def _same_literal(left: Any, right: Any) -> bool: + """Whether two literal values are the same value of the same types. + + Covers everything ``ast.literal_eval`` returns. Unlike ``==``, ``1`` is + not ``1.0`` or ``True``, a tuple is not a list, floats compare by their + bits (``0.0`` is not ``-0.0``) and a dictionary's key order counts. + """ + + kind = type(left) + if kind is not type(right): + return False + if kind is float: + return struct.pack(" dict[str, Any]: + return { + "defaults": list(function.__defaults__ or ()), + "kwdefaults": dict(function.__kwdefaults__ or {}), + } + + +def _shared_default_object( + functions: Sequence[Any], constants: Optional[Mapping[str, Any]] = None +) -> Optional[str]: + """Where two default values are one mutable object, if any are. + + ``first=[], second=[]`` makes two lists, but ``__defaults__`` can be set + to one list twice, and code that mutates one or tests ``first is + second`` then behaves differently under the same digest. Looks through + every default of every function given, inside tuples, lists and + dictionaries too (a container holding itself counts), and through the + module's literal constants given as ``constants``. + """ + + owners: dict[int, str] = {} + roots: list[tuple[str, Any]] = [ + (f"constant {name}", value) for name, value in (constants or {}).items() + ] + for function in functions: + name = function.__code__.co_qualname + defaults = list(enumerate(function.__defaults__ or ())) + kwdefaults = list((function.__kwdefaults__ or {}).items()) + roots.extend( + (f"{name} default {key}", value) for key, value in defaults + kwdefaults + ) + pending = roots + while pending: + path, value = pending.pop() + if isinstance(value, (list, dict, set, bytearray)): + if id(value) in owners: + return ( + f"{path} and {owners[id(value)]} are one " + f"{type(value).__name__} object" + ) + owners[id(value)] = path + if isinstance(value, dict): + pending.extend((f"{path}[{key!r}]", item) for key, item in value.items()) + elif isinstance(value, (list, tuple)): + pending.extend( + (f"{path}[{index}]", item) for index, item in enumerate(value) + ) + return None + + +def _module_functions(module: Any, filename: str) -> list[Any]: + """The functions an imported module defines at module scope in ``filename``. + + Includes those a decorator wrapped (found through ``__wrapped__``). A + function counts as module-level by its code object's own qualified name, + which ``functools.wraps`` cannot copy onto a wrapper. + """ + + found: dict[int, Any] = {} + for value in vars(module).values(): + function = _unwrapped(value, module, filename) + if function is not None: + found[id(function)] = function + return list(found.values()) + + +def _from_this_file(value: Any, module: Any, filename: str) -> bool: + """A function defined at module scope in ``filename`` (not a wrapper).""" + + return ( + inspect.isfunction(value) + and value.__module__ == module.__name__ + and value.__code__.co_filename == filename + and "." not in value.__code__.co_qualname + and "<" not in value.__code__.co_qualname + # Python 3.14 compiles a module's and each function's annotations to + # functions named __annotate__; they are the compiler's, not defs. + and value.__code__.co_name != "__annotate__" + ) + + +def _unwrapped(value: Any, module: Any, filename: str) -> Any: + """The function a decorator wrapped (``__wrapped__``), when there is one.""" + + for _ in range(16): + if _from_this_file(value, module, filename): + return value + value = getattr(value, "__wrapped__", None) + if value is None: + return None + return None + + +def _module_differs_from_source( + module: Any, module_source: str, code: CodeType, *, also_check: Any = None +) -> Optional[str]: + """How the imported module differs from its source as read now, if it does. + + The source is compiled as a whole module (so a function compiles in its + module's context, imports included) and compared with what was imported, + ignoring file names and line numbers: + - every function defined at module scope in that file, and ``also_check`` + (the modifier), must use the module's own globals, match that code, and, + where the source's defaults are literals, hold those defaults; + - a name the source binds only with ``def`` must still be that function + (or a decorator's wrapper of it, through ``__wrapped__``), not another + module's function or any other object; + - every module-scope name all of whose bindings assign literals + (``RATE = 0.21``, in a block or not) must still hold one of those + values, with the same type. + """ + + module_code = _compile_module_source(module_source, code) + if module_code is None: + return "its source no longer compiles" + tree = ast.parse(module_source) + compiled = _top_level_code(module_code) + filename = code.co_filename + definitions: dict[str, list[ast.FunctionDef]] = {} + otherwise_bound: set[str] = set() + for statement in _scope_statements(tree.body): + if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef)): + definitions.setdefault(statement.name, []).append(statement) # type: ignore[arg-type] + for expression in _definition_time_expressions(statement): + otherwise_bound.update(_expression_bindings(expression)) + else: + otherwise_bound.update(_statement_bindings(statement)) + namespace = vars(module) + for name in definitions: + if name in otherwise_bound: + continue + if name not in namespace: + return f"function {name} is no longer defined" + function = _unwrapped(namespace[name], module, filename) + if function is None: + return ( + f"{name} is no longer the function its source defines " + f"(it is {type(namespace[name]).__name__})" + ) + if function.__code__.co_qualname != name: + # Another function from the file, each still matching its source. + return f"{name} is bound to {function.__code__.co_qualname}" + functions = _module_functions(module, filename) + if also_check is not None and all(item is not also_check for item in functions): + functions.append(also_check) + for function in functions: + name = function.__code__.co_qualname + if function.__globals__ is not namespace: + return f"function {name} does not use its module's globals" + shapes = {_code_shape(item) for item in compiled.get(name, [])} + if _code_shape(function.__code__) not in shapes: + return f"function {name} no longer matches it" + if len(shapes) > 1: + # Which branch the source selects now cannot be read from it. + return ( + f"function {name} has {len(shapes)} different definitions, so " + "which one ran cannot be checked" + ) + sources = [_literal_defaults(node) for node in definitions.get(name, [])] + literal_sources = [item for item in sources if item is not None] + if literal_sources and len(literal_sources) == len(sources): + if not any( + _same_literal(item, _function_defaults(function)) + for item in literal_sources + ): + return f"function {name}'s default arguments differ from the source" + for name, values in _literal_bindings(tree).items(): + if name not in namespace: + return f"{name} is no longer defined" + current = namespace[name] + if not any(_same_literal(current, value) for value in values): + shown = values[0] if len(values) == 1 else values + return f"{name} is {current!r} at run time but {shown!r} in the source" + return None + + +#: Names the import system sets on every module. Reading one reads nothing +#: the module's source and location do not already fix. +_MODULE_DUNDERS = frozenset( + { + "__builtins__", + "__cached__", + "__doc__", + "__file__", + "__loader__", + "__name__", + "__package__", + "__spec__", + } +) +#: Dunder names a module's namespace may hold without its source binding +#: them: those the import system sets, and the annotation machinery's. +_NAMESPACE_DUNDERS = _MODULE_DUNDERS | { + "__annotations__", + "__annotate__", + "__conditional_annotations__", +} + + +def _type_name(value: Any) -> str: + kind = type(value) + if kind.__module__ == "builtins": + return kind.__qualname__ + return f"{kind.__module__}.{kind.__qualname__}" + + +#: Nodes that bind names or run code in a scope of their own; a module whose +#: top level a digest can bind holds none of them in definition-time code. +_SCOPED_EVALUATION = ( + ast.NamedExpr, + ast.Lambda, + ast.ListComp, + ast.SetComp, + ast.DictComp, + ast.GeneratorExp, + ast.Await, + ast.Yield, + ast.YieldFrom, +) +#: Singletons whose identity is the same in every process. +_IDENTITY_CONSTANTS = (None, True, False, Ellipsis) +#: What an annotation may contain: names, attributes, subscripts, constants, +#: tuples and lists of them, ``X | Y``, and signed numeric constants. +_ANNOTATION_NODES = ( + ast.Name, + ast.Attribute, + ast.Subscript, + ast.Constant, + ast.Tuple, + ast.List, + ast.BinOp, + ast.BitOr, + ast.Load, + ast.Slice, + ast.UnaryOp, + ast.UAdd, + ast.USub, +) + + +def _is_annotation_expression(expression: ast.AST) -> bool: + return all( + isinstance(node, _ANNOTATION_NODES) + and ( + not isinstance(node, ast.UnaryOp) + or ( + isinstance(node.operand, ast.Constant) + and type(node.operand.value) in (int, float, complex) + ) + ) + for node in ast.walk(expression) + ) + + +def _function_annotations( + statement: Union[ast.FunctionDef, ast.AsyncFunctionDef], +) -> list[Optional[ast.expr]]: + arguments = statement.args + return [ + argument.annotation + for argument in [ + *arguments.posonlyargs, + *arguments.args, + *arguments.kwonlyargs, + *(item for item in (arguments.vararg, arguments.kwarg) if item), + ] + ] + [statement.returns] + + +def _is_literal(node: ast.expr) -> bool: + try: + ast.literal_eval(node) + except (ValueError, TypeError, SyntaxError, MemoryError, RecursionError): + return False + return True + + +def _is_main_guard(statement: ast.stmt) -> bool: + """``if __name__ == "__main__":`` with no ``else``: it never runs on import.""" + + if not isinstance(statement, ast.If) or statement.orelse: + return False + test = statement.test + if not ( + isinstance(test, ast.Compare) + and len(test.ops) == 1 + and isinstance(test.ops[0], ast.Eq) + ): + return False + sides = [test.left, test.comparators[0]] + names = [side for side in sides if isinstance(side, ast.Name)] + constants = [side for side in sides if isinstance(side, ast.Constant)] + return ( + len(names) == 1 + and names[0].id == "__name__" + and len(constants) == 1 + and constants[0].value == "__main__" + ) + + +#: Names that reach module state, run code, compare identities or read annotation +#: values; refused anywhere in a bound modifier's module. +_REFUSED_NAMES = frozenset( + { + "globals", + "vars", + "locals", + "exec", + "eval", + "compile", + "__import__", + "breakpoint", + "id", + "is_", + "is_not", + # Annotation metadata readers remain outside the accepted shape. + "get_type_hints", + "get_annotations", + "signature", + "getfullargspec", + "from_callable", + } +) +#: Attributes refused anywhere: operator.is_ and is_not compare identities, +#: and typing.get_type_hints, inspect.signature and the like expose metadata +#: through introspection, outside the accepted module shape. +_REFUSED_ATTRIBUTES = frozenset( + { + "is_", + "is_not", + "get_type_hints", + "get_annotations", + "signature", + "getfullargspec", + "from_callable", + } +) +_ANNOTATION_READERS = frozenset( + { + "get_type_hints", + "get_annotations", + "signature", + "getfullargspec", + "from_callable", + } +) +_ATTRIBUTE_OPERATIONS = frozenset({"getattr", "hasattr", "setattr", "delattr"}) +#: Dunder attributes a measure may read; any other is introspection. +_ALLOWED_DUNDER_ATTRIBUTES = frozenset({"__init__", "__name__"}) +#: Dunder names a measure may read: its module's name and file. +_ALLOWED_DUNDER_NAMES = frozenset({"__name__", "__file__"}) + + +def _is_dunder(name: str) -> bool: + return len(name) > 4 and name.startswith("__") and name.endswith("__") + + +def _bound_names(tree: ast.AST) -> Iterator[tuple[str, int]]: + """Every name bound anywhere in ``tree``, with its line.""" + + for node in ast.walk(tree): + line = getattr(node, "lineno", 0) + if isinstance(node, ast.Name) and not isinstance(node.ctx, ast.Load): + yield node.id, line + elif isinstance(node, ast.arg): + yield node.arg, line + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + yield node.name, line + elif isinstance(node, (ast.Import, ast.ImportFrom)): + for alias in node.names: + yield (alias.asname or alias.name).split(".")[0], line + elif isinstance(node, (ast.Global, ast.Nonlocal)): + for name in node.names: + yield name, line + elif isinstance(node, ast.ExceptHandler) and node.name: + yield node.name, line + elif isinstance(node, (ast.MatchAs, ast.MatchStar)) and node.name: + yield node.name, line + elif isinstance(node, ast.MatchMapping) and node.rest: + yield node.rest, line + + +def _introspection_problem(tree: ast.Module) -> Optional[str]: + """Code that reads state a digest of values cannot bind, anywhere in the module. + + - ``is`` is allowed only against ``None``, ``True``, ``False``, ``...`` or + ``type(...)``, and ``type`` may not be rebound anywhere in the module: + the compiler may make equal immutable constants one object. + - ``id``, ``operator.is_``/``is_not``, ``globals``, ``vars``, ``locals``, + ``exec``, ``eval``, ``compile``, ``__import__`` and ``breakpoint`` are + refused by name, however imported, and so is importing ``builtins``. + - Explicit annotation readers are refused as introspection. + - Dunder attributes other than ``__init__`` and ``__name__``, and dunder + names other than ``__name__`` and ``__file__``, are introspection. + Attribute operations must name a literal string, so the same check + applies when the attribute is accessed through ``getattr`` and friends. + """ + + for name, line in _bound_names(tree): + if name == "type": + return f"line {line} rebinds type, which identity checks rely on" + for node in ast.walk(tree): + if isinstance(node, ast.Global): + return ( + f"line {node.lineno} declares {', '.join(node.names)} global, so " + "what the module holds depends on earlier calls" + ) + for node in ast.walk(tree): + line = getattr(node, "lineno", 0) + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id in _ATTRIBUTE_OPERATIONS + ): + name = node.args[1] if len(node.args) > 1 else None + if not (isinstance(name, ast.Constant) and type(name.value) is str): + return ( + f"line {line} uses {node.func.id} with a non-literal attribute " + "name, so introspection cannot be checked" + ) + if _is_dunder(name.value) and name.value not in _ALLOWED_DUNDER_ATTRIBUTES: + return ( + f"line {line} uses {node.func.id} with {name.value}, " + "which is introspection" + ) + elif isinstance(node, ast.Compare): + lefts = [node.left, *node.comparators[:-1]] + for left, op, right in zip(lefts, node.ops, node.comparators): + if isinstance(op, (ast.Is, ast.IsNot)) and not ( + _plain_identity_operand(left) or _plain_identity_operand(right) + ): + return ( + f"line {line} compares identities, but a digest of values " + "cannot tell one object from two equal ones (compare with " + "== instead)" + ) + elif isinstance(node, ast.Name): + if node.id in _REFUSED_NAMES: + return f"line {line} uses {node.id}, which {_refused_why(node.id)}" + if ( + isinstance(node.ctx, ast.Load) + and _is_dunder(node.id) + and node.id not in _ALLOWED_DUNDER_NAMES + ): + return f"line {line} reads {node.id}, which is introspection" + elif isinstance(node, ast.Attribute): + if node.attr in _REFUSED_ATTRIBUTES: + return f"line {line} uses .{node.attr}, which {_refused_why(node.attr)}" + if _is_dunder(node.attr) and node.attr not in _ALLOWED_DUNDER_ATTRIBUTES: + return f"line {line} reads .{node.attr}, which is introspection" + elif isinstance(node, (ast.Import, ast.ImportFrom)): + modules = ( + [alias.name for alias in node.names] + if isinstance(node, ast.Import) + else [node.module or ""] + ) + if any(module.split(".")[0] == "builtins" for module in modules): + return f"line {line} imports builtins" + for alias in node.names: + if alias.name in _REFUSED_NAMES: + return f"line {line} imports {alias.name}, which {_refused_why(alias.name)}" + return None + + +_REFUSED_WHY = ( + "reaches module state, runs code or compares identities without naming " + "what it reads" +) + + +def _refused_why(name: str) -> str: + if name in _ANNOTATION_READERS: + return "reads annotation values through introspection" + return _REFUSED_WHY + + +def _plain_identity_operand(node: ast.expr) -> bool: + """A singleton constant or ``type(...)``, safe on one side of ``is``.""" + + if isinstance(node, ast.Constant) and any( + node.value is constant for constant in _IDENTITY_CONSTANTS + ): + return True + return ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "type" + ) + + +def _module_statement_problem(tree: ast.Module) -> Optional[str]: + """Why a modifier's module is not one whose state a digest can bind. + + At module level the module may hold only docstrings, ``pass``, imports, + assignments of a literal to one name (annotated or not), undecorated + ``def`` statements, and an ``if __name__ == "__main__":`` block (which + never runs on import). A generic function's type parameters carry no + bound, constraints or default: Python evaluates those only when something + first reads them, so typing's order-insensitive alias cache can decide + what they hold. Each name is bound once. Definition-time code + (defaults, annotations) holds no ``:=``, lambda, comprehension, ``await`` + or ``yield``. Then every module-level name is a literal (checked by + value), a function (checked against the source) or an import (checked + against what the import binds), and nothing else can hold state: + conditional or repeated bindings, objects built at import, decorators + and class definitions are what this shuts out. + """ + + bound: dict[str, int] = {} + + def bind(name: str, line: int) -> Optional[str]: + if name in bound: + return f"{name} is bound twice (lines {bound[name]} and {line})" + bound[name] = line + return None + + def evaluates(nodes: Sequence[Optional[ast.AST]]) -> Optional[ast.AST]: + for root in nodes: + if root is None: + continue + for node in ast.walk(root): + if isinstance(node, _SCOPED_EVALUATION): + return node + return None + + def computed_annotation(nodes: Sequence[Optional[ast.AST]]) -> bool: + # An annotation runs at import (before 3.14 or without postponed + # annotations), so it may only name types, not compute values. + return any( + not _is_annotation_expression(root) for root in nodes if root is not None + ) + + for statement in tree.body: + line = statement.lineno + problem: Optional[str] = None + if isinstance(statement, ast.Pass) or _is_main_guard(statement): + continue + if isinstance(statement, ast.Expr) and isinstance( + statement.value, ast.Constant + ): + if isinstance(statement.value.value, str): + continue + if isinstance(statement, (ast.Import, ast.ImportFrom)): + for alias in statement.names: + if alias.name == "*": + continue + name = alias.asname or alias.name.split(".")[0] + problem = problem or bind(name, line) + elif isinstance(statement, ast.Assign): + if len(statement.targets) != 1 or not isinstance( + statement.targets[0], ast.Name + ): + return f"line {line} assigns to something other than one name" + name = statement.targets[0].id + if not _is_literal(statement.value): + return f"line {line} assigns {name} something other than a literal" + problem = bind(name, line) + elif isinstance(statement, ast.AnnAssign): + if not isinstance(statement.target, ast.Name): + return f"line {line} assigns to something other than one name" + name = statement.target.id + if evaluates([statement.annotation]): + return f"line {line} binds a name in the annotation of {name}" + if computed_annotation([statement.annotation]): + return f"line {line} computes the annotation of {name}" + if statement.value is not None: + if not _is_literal(statement.value): + return f"line {line} assigns {name} something other than a literal" + problem = bind(name, line) + elif isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef)): + if statement.decorator_list: + return f"line {line} decorates {statement.name}" + qualified = [ + parameter.name + for parameter in getattr(statement, "type_params", None) or [] + if getattr(parameter, "bound", None) is not None + or getattr(parameter, "default_value", None) is not None + ] + if qualified: + return ( + f"line {line}: type parameter {qualified[0]} of " + f"{statement.name} has a bound, constraints or default, " + "which Python evaluates only when first read" + ) + if evaluates(_definition_time_expressions(statement)): + return ( + f"line {line}: a default or annotation of {statement.name} " + "binds a name or runs code in a scope of its own" + ) + if computed_annotation(_function_annotations(statement)): + return f"line {line} computes an annotation of {statement.name}" + if _is_dunder(statement.name): + return f"line {line} defines {statement.name}" + problem = bind(statement.name, line) + elif isinstance(statement, ast.ClassDef): + return f"line {line} defines class {statement.name}" + else: + return f"line {line} is a module-level {type(statement).__name__} statement" + if problem: + return problem + return None + + +def _module_shape_problem(tree: ast.Module) -> Optional[str]: + """Why a modifier's module cannot be bound, with the reason, if it cannot.""" + + problem = _module_statement_problem(tree) + if problem: + return ( + f"{problem}; a module whose modifier is bound holds at module level " + "only imports, literal constants and undecorated functions, each " + "name bound once" + ) + return _introspection_problem(tree) + + +def _same_annotation( + actual: Any, + declared: Any, + namespace: Mapping[str, Any], + type_argument: bool = True, +) -> bool: + """Compare resolved annotation origins and arguments, preserving order.""" + + if ( + actual is declared + and type(actual) is not str + and not isinstance(actual, ForwardRef) + ): + return True + actual_origin, declared_origin = get_origin(actual), get_origin(declared) + if isinstance(actual, (ParamSpecArgs, ParamSpecKwargs)) or isinstance( + declared, (ParamSpecArgs, ParamSpecKwargs) + ): + return type(actual) is type(declared) and actual_origin is declared_origin + if isinstance(actual, ForwardRef) or isinstance(declared, ForwardRef): + same = ( + type(actual) is type(declared) + and actual.__forward_arg__ == declared.__forward_arg__ + and actual.__forward_module__ == declared.__forward_module__ + and actual.__forward_is_argument__ == declared.__forward_is_argument__ + and actual.__forward_is_class__ == declared.__forward_is_class__ + ) + if same and type_argument: + resolved, source = _annotation_string_state( + declared.__forward_arg__, namespace + ) + if not _same_annotation( + resolved, source, namespace, type(resolved) is not str + ): + return False + if getattr(actual, "__forward_evaluated__", False): + return _same_annotation(actual.__forward_value__, source, namespace) + return same + if actual_origin is not None or declared_origin is not None: + actual_args, declared_args = get_args(actual), get_args(declared) + return ( + actual_origin is declared_origin + and len(actual_args) == len(declared_args) + and all( + _same_annotation( + left, + right, + namespace, + type_argument + and actual_origin is not Literal + and (actual_origin is not Annotated or position == 0), + ) + for position, (left, right) in enumerate( + zip(actual_args, declared_args) + ) + ) + ) + if type(actual) in (list, tuple) and type(actual) is type(declared): + return len(actual) == len(declared) and all( + _same_annotation(left, right, namespace, type_argument) + for left, right in zip(actual, declared) + ) + if ( + type_argument + and type(actual) is str + and type(declared) is str + and actual == declared + ): + resolved, source = _annotation_string_state(declared, namespace) + return _same_annotation(resolved, source, namespace, type(resolved) is not str) + return actual is declared or ( + type(actual) + in (type(None), bool, int, float, complex, str, bytes, type(Ellipsis)) + and _same_literal(actual, declared) + ) + + +def _annotation_string_state( + value: str, namespace: Mapping[str, Any] +) -> tuple[Any, Any]: + """Resolve root annotation strings while checking every quoted expression.""" + + seen: set[str] = set() + while type(value) is str: + if value in seen: + raise ValueError("an annotation string has a resolution cycle") + seen.add(value) + try: + expression = ast.parse(value, mode="eval").body + except SyntaxError: + return value, value + if not _is_annotation_expression(expression): + raise ValueError("an annotation string computes a value") + try: + resolved = eval( + compile( + ast.Expression(expression), + "", + "eval", + dont_inherit=True, + ), + dict(namespace), + ) + except (NameError, AttributeError): + return value, value + declared = _source_annotation(expression, namespace) + if type(resolved) is str and resolved == declared and resolved != value: + value = resolved + else: + return resolved, declared + return value, value + + +#: Python 3.13 made ``Final['int']`` and ``ClassVar['int']`` build +#: ``ForwardRef('int', is_class=True)``; 3.11 and 3.12 build it with +#: ``is_class=False``. A fixed fact of the interpreter, so no probe of +#: typing's (cached, mutable) objects decides it. +_SPECIAL_FORM_FORWARD_IS_CLASS = sys.version_info >= (3, 13) + + +def _source_annotation(expression: ast.expr, namespace: Mapping[str, Any]) -> Any: + """Resolve source arguments without typing's order-insensitive alias cache. + + GenericAlias is only a structural comparison value here; it does not run + the annotation. Re-evaluating a whole List[Union[X, Y]] can return an alias + cached for List[Union[Y, X]], so each source argument must be read separately. + """ + + def evaluate(node: ast.expr) -> Any: + return eval( + compile(ast.Expression(node), "", "eval", dont_inherit=True), + dict(namespace), + ) + + def union(arguments: Sequence[Any], origin: Any) -> Any: + members: list[Any] = [] + for argument in arguments: + if argument is None: + argument = type(None) + argument_origin = get_origin(argument) + values = ( + get_args(argument) + if argument_origin is Union or argument_origin is UnionType + else (argument,) + ) + for value in values: + if not any( + _same_annotation(value, member, namespace) for member in members + ): + members.append(value) + return members[0] if len(members) == 1 else GenericAlias(origin, tuple(members)) + + if isinstance(expression, (ast.List, ast.Tuple)): + values = [_source_annotation(item, namespace) for item in expression.elts] + return values if isinstance(expression, ast.List) else tuple(values) + if isinstance(expression, ast.BinOp): + return union( + [ + _source_annotation(expression.left, namespace), + _source_annotation(expression.right, namespace), + ], + get_origin(evaluate(expression)), + ) + if not isinstance(expression, ast.Subscript): + return evaluate(expression) + base = evaluate(expression.value) + arguments = _source_annotation(expression.slice, namespace) + args = arguments if isinstance(arguments, tuple) else (arguments,) + + def forward_argument(value: Any, *, is_class: bool = False) -> Any: + if type(value) is str: + return ForwardRef(value, is_class=is_class) + if type(value) in (list, tuple): + values = [forward_argument(item, is_class=is_class) for item in value] + return values if type(value) is list else tuple(values) + return value + + if base is Union: + return union( + [forward_argument(item) for item in args], get_origin(evaluate(expression)) + ) + if base is Optional: + return union( + [*[forward_argument(item) for item in args], type(None)], + get_origin(evaluate(expression)), + ) + if base is Annotated: + first, *metadata_args = args + first = forward_argument(first, is_class=True) + if get_origin(first) is Annotated: + first, *existing = get_args(first) + metadata_args = [*existing, *metadata_args] + return GenericAlias(Annotated, (first, *metadata_args)) + if base is Literal: + values: list[Any] = [] + for argument in args: + members = ( + get_args(argument) if get_origin(argument) is Literal else (argument,) + ) + for member in members: + if not any( + _same_annotation(member, value, namespace, False) + for value in values + ): + values.append(member) + return GenericAlias(Literal, tuple(values)) + if get_args(base): + # A parameterised alias (for example numpy.typing.NDArray) substitutes + # its arguments into a template rather than taking them directly. + # typing's parameterised aliases can use the same ambiguous cache. + if type(base) is not GenericAlias: + raise ValueError("a parameterised typing alias cannot be checked") + return base[arguments] + if getattr(base, "__module__", None) == "typing": + args = tuple( + forward_argument( + item, + is_class=(base is ClassVar or base is Final) + and _SPECIAL_FORM_FORWARD_IS_CLASS, + ) + for item in args + ) + declared = evaluate(expression) + origin = get_origin(declared) + if origin is None: + # Generated classes (for example Pydantic generics) do not expose + # their arguments through typing, so their source order cannot be checked. + raise ValueError("a subscripted annotation has no typing origin") + declared_args = get_args(declared) + if len(args) == len(declared_args): + args = tuple( + type(None) if value is None and old is type(None) else value + for value, old in zip(args, declared_args) + ) + return GenericAlias(origin, args) + + +_TYPE_PARAMETER_KINDS = { + "TypeVar": TypeVar, + "ParamSpec": ParamSpec, + "TypeVarTuple": TypeVarTuple, +} + + +def _type_parameters_problem( + function: Any, node: Union[ast.FunctionDef, ast.AsyncFunctionDef] +) -> Optional[str]: + """Why a generic function's type parameters are not those its ``def`` made. + + ``__type_params__`` is writable, so each parameter must still be a bare + TypeVar, ParamSpec or TypeVarTuple of the name and kind the source + declares, in order. The module shape check refuses source that gives one + a bound, constraints or default, so none may hold one, nor a declared + variance (the ``def`` infers it). + """ + + declared = getattr(node, "type_params", None) or [] + actual = getattr(function, "__type_params__", ()) + if type(actual) is not tuple or len(actual) != len(declared): + return "type parameters are not those its source declares" + for parameter, source in zip(actual, declared): + kind = _TYPE_PARAMETER_KINDS.get(type(source).__name__) + if type(parameter) is not kind or parameter.__name__ != source.name: + return "type parameters are not those its source declares" + has_default = getattr(parameter, "has_default", None) + qualified = has_default is not None and has_default() + if kind is not TypeVarTuple: + qualified = ( + qualified + or parameter.__bound__ is not None + or getattr(parameter, "__constraints__", ()) != () + or parameter.__covariant__ + or parameter.__contravariant__ + or not parameter.__infer_variance__ + ) + if qualified: + return ( + f"type parameter {source.name} holds a bound, constraints, " + "default or variance its source does not declare" + ) + return None + + +def _function_state_problem( + function: Any, nodes: Mapping[str, Union[ast.FunctionDef, ast.AsyncFunctionDef]] +) -> Optional[str]: + """How a module function's own attributes differ from what its def made. + + A function's name, qualified name, docstring and annotations are writable, + and its builtins are those of the namespace it was created in; none is in + its code, so each must still be what the ``def`` in the source made. Any + other attribute (``helper.amount = 240``) is state the digest does not + bind. Annotation values must still be what their source names. + """ + + code = function.__code__ + name = code.co_qualname + if function.__dict__: + attributes = ", ".join(sorted(function.__dict__)) + return f"function {name} carries attributes ({attributes}), state the digest does not bind" + if function.__name__ != code.co_name or function.__qualname__ != name: + return f"function {name} has been renamed to {function.__qualname__}" + if getattr(function, "__builtins__", vars(builtins)) is not vars(builtins): + return f"function {name} does not use the builtins module" + node = nodes.get(name) + if node is None: + return None + # The compiler cleans docstrings (3.13 strips indentation) and keeps the + # result as the first constant of the code, which matches the source. + declared = ast.get_docstring(node, clean=False) is not None + expected_doc = code.co_consts[0] if declared and code.co_consts else None + if function.__doc__ != expected_doc: + return f"function {name}'s __doc__ is not its source docstring" + problem = _type_parameters_problem(function, node) + if problem: + return f"function {name}'s {problem}" + expected = { + argument.arg: argument.annotation + for argument in [ + *node.args.posonlyargs, + *node.args.args, + *(node.args.vararg,) * bool(node.args.vararg), + *node.args.kwonlyargs, + *(node.args.kwarg,) * bool(node.args.kwarg), + ] + if argument is not None and argument.annotation is not None + } + if node.returns is not None: + expected["return"] = node.returns + try: + annotations = function.__annotations__ + except Exception: # evaluating postponed annotations can fail + return f"function {name}'s annotations cannot be read" + if sorted(annotations) != sorted(expected): + differing = ", ".join(sorted(set(annotations) ^ set(expected))) + return ( + f"function {name}'s annotations are not those its source declares " + f"({differing})" + ) + postponed = bool(code.co_flags & __future__.annotations.compiler_flag) + if postponed: + # Compile only the annotations: no defaults, decorators or body can + # run. This gives the strings this Python's compiler actually stores. + arguments = node.args + probe = ast.FunctionDef( + name="_annotation_probe", + args=ast.arguments( + posonlyargs=arguments.posonlyargs, + args=arguments.args, + vararg=arguments.vararg, + kwonlyargs=arguments.kwonlyargs, + kw_defaults=[None] * len(arguments.kwonlyargs), + kwarg=arguments.kwarg, + defaults=[], + ), + body=[ast.Pass()], + decorator_list=[], + returns=node.returns, + type_params=[], + ) + source = ast.fix_missing_locations(ast.Module(body=[probe], type_ignores=[])) + namespace: dict[str, Any] = {} + exec( + compile( + source, + "", + "exec", + flags=__future__.annotations.compiler_flag, + dont_inherit=True, + ), + namespace, + ) + declared_annotations = namespace["_annotation_probe"].__annotations__ + else: + declared_annotations = {} + namespace = dict(function.__globals__) + # Generic functions bind their type parameters in the compiler's + # wrapper, rather than in the module's globals. + namespace.update( + (parameter.__name__, parameter) + for parameter in getattr(function, "__type_params__", ()) + ) + for parameter, expression in expected.items(): + try: + declared = ( + declared_annotations[parameter] + if postponed + else _source_annotation(expression, namespace) + ) + actual = annotations[parameter] + proxy = isinstance(declared, (ParamSpecArgs, ParamSpecKwargs)) + if postponed: + same = type(actual) is str and actual == declared + elif isinstance(expression, (ast.Name, ast.Attribute)) and not proxy: + same = actual is declared + elif isinstance(expression, (ast.Subscript, ast.BinOp)) or proxy: + # Generics and ParamSpec proxies can be new objects. Their + # resolved origins and arguments must match in source order. + same = _same_annotation(actual, declared, namespace) + else: + same = _same_literal(actual, declared) + if same and type(actual) is str: + resolved, source = _annotation_string_state(actual, namespace) + same = _same_annotation(resolved, source, namespace) + except Exception: + return f"function {name}'s annotation for {parameter} cannot be checked" + if not same: + return f"function {name}'s annotation for {parameter} is not what its source names" + return None + + +def _module_docstring(module_source: str, tree: ast.Module) -> Optional[str]: + """The module docstring as this Python's compiler stores it, or None.""" + + if ast.get_docstring(tree, clean=False) is None: + return None + statement = ast.get_source_segment(module_source, tree.body[0]) + namespace: dict[str, Any] = {"__builtins__": {}} + # A lone string literal: compiling and running it only sets __doc__. + exec(compile(statement or "", "", "exec", dont_inherit=True), namespace) + return namespace.get("__doc__") + + +_TYPING_MODULES = frozenset({"typing", "typing_extensions"}) + +#: Builtin generics: subscripting one builds a new ``types.GenericAlias`` in +#: source order, never a cached alias. +_ORDERED_GENERICS = (list, tuple, dict, set, frozenset, type) + + +def _subscript_reuses_equal_aliases(base: Any) -> bool: + """Whether ``base[...]`` can return an alias cached for equal arguments. + + typing caches its generic aliases (``List``, ``Dict``, ``Optional``, + ``Annotated``) and user generics (``Generic`` subclasses, Pydantic models) + by argument equality, and a union or ``Literal`` equals one holding its + members in any order. Builtin generics, ``collections.abc`` classes and + classes whose ``__class_getitem__`` is implemented in C (NumPy's + ``ndarray`` and ``dtype``) build a new ``types.GenericAlias`` in source + order. + """ + + if isinstance(base, type): + if base in _ORDERED_GENERICS: + return False + try: + static = inspect.getattr_static(base, "__class_getitem__") + except AttributeError: + return base.__module__ in _TYPING_MODULES + if isinstance(static, ClassMethodDescriptorType): + return False + return not (isinstance(static, classmethod) and static.__func__ is GenericAlias) + return type(base).__module__ in _TYPING_MODULES + + +def _is_typing_form(value: Any, *forms: str) -> bool: + return any( + value is getattr(sys.modules.get(name), form, None) + for name in _TYPING_MODULES + for form in forms + ) + + +def _holds_union(value: Any, depth: int = 0) -> bool: + """Whether a resolved type holds a union or ``Literal``, at any depth.""" + + if depth > 20: + return True + if isinstance(value, UnionType) or get_origin(value) in (Union, Literal): + return True + try: + arguments = get_args(value) + except Exception: + return False + return any(_holds_union(argument, depth + 1) for argument in arguments) + + +def _function_bindings(module: Any, nodes: Sequence[ast.AST]) -> dict[str, list[Any]]: + """What each name an import inside a function binds, across all its scopes. + + Every binding is kept, so a name one helper imports from typing and + another from collections.abc counts as both. + """ + + bindings: dict[str, list[Any]] = {} + for node in nodes: + if isinstance(node, ast.Import): + for alias in node.names: + name = alias.asname or alias.name.split(".")[0] + target = alias.name if alias.asname else name + bindings.setdefault(name, []).append(sys.modules.get(target)) + elif isinstance(node, ast.ImportFrom): + try: + source_name = ( + importlib.util.resolve_name( + "." * node.level + (node.module or ""), module.__package__ + ) + if node.level + else node.module or "" + ) + except (ImportError, ValueError): + continue + source = sys.modules.get(source_name) + for alias in node.names: + if alias.name != "*": + bindings.setdefault(alias.asname or alias.name, []).append( + getattr( + source, + alias.name, + sys.modules.get(f"{source_name}.{alias.name}"), + ) + ) + return bindings + + +def _runtime_alias_problem(module: Any, tree: ast.Module) -> Optional[str]: + """Where a module function's code builds a type typing may reuse reordered. + + Code inside a function runs after import: its expressions, and the + annotations, defaults and type-parameter bounds of helpers and classes + it defines, are evaluated (or read by Pydantic) when it runs. A typing + generic subscripted with a union (``List[Union[Decimal, float]]``, + ``Optional[Decimal | float]``) can then return an alias cached earlier + for ``List[Union[float, Decimal]]``, and Pydantic tries a union's members + in order, so earlier code decides what runs. The module's own function + signatures are compared against source elsewhere; this covers the code + inside them. + + Resolution is conservative: a name stands for every object the module + or any import inside the function binds it to, and every string in the + function's code that parses as an expression is inspected as a type + (except ``Literal`` values and ``Annotated`` metadata), since a forward + reference is evaluated when something resolves it. Local aliases of + typing objects (``L = List``) are not followed. + """ + + namespace = vars(module) + for statement in tree.body: + if not isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + nodes = [node for root in statement.body for node in ast.walk(root)] + local = _function_bindings(module, nodes) + + def resolve(node: ast.AST) -> list[Any]: + if isinstance(node, ast.Name): + values = list(local.get(node.id, ())) + if node.id in namespace: + values.append(namespace[node.id]) + # A builtin (list, dict, type) never reuses equal aliases. + return values + if isinstance(node, ast.Attribute): + return [ + getattr(owner, node.attr) + for owner in resolve(node.value) + if isinstance(owner, ModuleType) and hasattr(owner, node.attr) + ] + return [] + + def is_form(node: ast.AST, *forms: str) -> bool: + return any(_is_typing_form(value, *forms) for value in resolve(node)) + + # Strings that are values, not forward references. ast.walk yields a + # node before its children, so a Literal or Annotated subscript marks + # its values before they are reached. + values: set[int] = set() + inspected: list[ast.AST] = list(nodes) + position = 0 + while position < len(inspected): + node = inspected[position] + position += 1 + if isinstance(node, ast.Subscript): + if is_form(node.value, "Literal"): + values.update(id(child) for child in ast.walk(node.slice)) + elif is_form(node.value, "Annotated") and isinstance( + node.slice, ast.Tuple + ): + for metadata in node.slice.elts[1:]: + values.update(id(child) for child in ast.walk(metadata)) + if ( + isinstance(node, ast.Constant) + and isinstance(node.value, str) + and id(node) not in values + and len(inspected) < 100_000 + ): + try: + parsed = ast.parse(node.value.strip(), mode="eval").body + except (SyntaxError, ValueError): + continue + for child in ast.walk(parsed): + ast.copy_location(child, node) + inspected.append(child) + + def holds_union(expression: ast.AST) -> bool: + for child in ast.walk(expression): + if isinstance(child, ast.BinOp) and isinstance(child.op, ast.BitOr): + return True + if isinstance(child, ast.Subscript) and is_form( + child.value, "Union", "Optional", "Literal" + ): + return True + if isinstance(child, (ast.Name, ast.Attribute)) and any( + _holds_union(value) for value in resolve(child) + ): + return True + if ( + isinstance(child, ast.Constant) + and isinstance(child.value, str) + and id(child) not in values + ): + try: + parsed = ast.parse(child.value.strip(), mode="eval").body + except (SyntaxError, ValueError): + continue + if holds_union(parsed): + return True + return False + + for node in inspected: + if not isinstance(node, ast.Subscript): + continue + if any( + _subscript_reuses_equal_aliases(value) for value in resolve(node.value) + ) and holds_union(node.slice): + line = getattr(node, "lineno", statement.lineno) + return ( + f"function {statement.name} subscripts " + f"{ast.unparse(node.value)} with a union (line {line}); typing " + "can return an alias it cached for the same members in another " + "order, so earlier code decides what runs (use a builtin " + "generic such as list[Decimal | float])" + ) + return None + + +def _module_state_problem( + module: Any, tree: ast.Module, functions: Sequence[Any], module_source: str +) -> Optional[str]: + """How the imported module's namespace holds state its source does not fix. + + For a module :func:`_module_shape_problem` accepts. Every name in the + namespace other than dunders must be one the source binds: a literal or + a function (checked elsewhere), or an import, which must still be what + its import statement binds and must hold a module or a callable other + than a ``functools.partial`` (``from __future__`` features excepted). + A star import supplies names its source does not list; each must be + that module's own attribute, judged the same way. Functions must carry + no attributes, and no two literal constants or defaults may be one + mutable object. + """ + + namespace = vars(module) + missing = object() + literals: set[str] = set() + definitions: set[str] = set() + imported: dict[str, tuple[Any, str, bool]] = {} + star_modules: list[Any] = [] + for statement in tree.body: + if isinstance(statement, ast.Assign): + literals.add(statement.targets[0].id) # type: ignore[attr-defined] + elif isinstance(statement, ast.AnnAssign) and statement.value is not None: + literals.add(statement.target.id) # type: ignore[attr-defined] + elif isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef)): + definitions.add(statement.name) + elif isinstance(statement, ast.Import): + for alias in statement.names: + target = alias.name if alias.asname else alias.name.split(".")[0] + imported[alias.asname or target] = ( + sys.modules.get(target, missing), + f"import {alias.name}", + False, + ) + elif isinstance(statement, ast.ImportFrom): + base = importlib.util.resolve_name( + "." * statement.level + (statement.module or ""), + module.__package__, + ) + source_module = sys.modules.get(base, missing) + for alias in statement.names: + if alias.name == "*": + star_modules.append(source_module) + continue + expected = getattr(source_module, alias.name, missing) + if expected is missing: + expected = sys.modules.get(f"{base}.{alias.name}", missing) + imported[alias.asname or alias.name] = ( + expected, + f"from {base} import {alias.name}", + base == "__future__", + ) + + def judged(name: str, value: Any) -> Optional[str]: + if inspect.ismodule(value) or ( + callable(value) and not isinstance(value, functools.partial) + ): + return None + return ( + f"{name} is an imported value of type {_type_name(value)}, which no " + "source the digest names fixes" + ) + + for name, (expected, statement, future) in imported.items(): + value = namespace.get(name, missing) + if expected is missing or value is not expected: + return f"{name} is not what `{statement}` binds" + problem = None if future else judged(name, value) + if problem: + return problem + for name, value in namespace.items(): + if name in _NAMESPACE_DUNDERS: + continue + if name in literals or name in definitions or name in imported: + continue + if any(getattr(source, name, missing) is value for source in star_modules): + problem = judged(name, value) + if problem: + return problem + continue + return ( + f"{name} is a value of type {_type_name(value)} set on the module, " + "not bound by its source" + ) + if namespace.get("__doc__") != _module_docstring(module_source, tree): + return "the module's __doc__ is not its source docstring" + if namespace.get("__builtins__") not in (builtins, vars(builtins)): + return "the module's __builtins__ is not the builtins module" + nodes = { + statement.name: statement + for statement in tree.body + if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef)) + } + for function in functions: + problem = _function_state_problem(function, nodes) + if problem: + return problem + return _shared_default_object( + functions, {name: namespace.get(name) for name in sorted(literals)} + ) + + +def _sha256_text(text: str) -> str: + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + +def _modifier_identity( + modifier: Any, code_revision: Mapping[str, str] +) -> dict[str, Any]: + """What binds a module-level function's behaviour, or refuse it. + + The identity is the function's module and qualified name, the SHA-256 of + its source and of its module's source as read now, its default arguments, + and ``code_revision``. None of these depends on where the code is checked + out or which Python runs it. + + The module must have a shape whose state its source fixes + (:func:`_module_shape_problem`): at module level only imports, literal + constants and undecorated functions, each name bound once, and no code + that tells equal values apart by identity. Then the module's source is + checked against what was imported (ignoring file names and line numbers): + every function must compile to the loaded code, use the module's globals, + carry no attributes and keep its literal defaults; every literal constant + must keep its exact value; every import must still be what it binds and + hold a module or a callable other than a partial; the namespace may hold + no other names; no two constants or defaults may be one mutable object; + and the modifier may not reach module state through ``globals()`` and the + like or assign module globals. A file edited after import, or anything + reassigned at run time, is refused. Default arguments are bound by value, + with their types (``1`` is not ``1.0``, a tuple is not a list, ``None`` is + not a missing key). + + Not covered: state in other modules (``params.PAYMENT`` read through + ``import params``) and data read at run time; code in other modules is + bound by ``code_revision`` or the installed versions, and + ``code_revision`` names the code, not the process. Receipts are claims by + their producer: this protects an honest producer against mistakes, not a + reader against a producer who builds code to mislead. + + Closures, lambdas, nested functions, methods, partials, builtins and + functions whose defaults have no exact encoding are refused: their + behaviour depends on values the digest cannot bind (two + ``Scenario.from_reform`` closures share one source but apply different + reforms). + """ + + if isinstance(modifier, functools.partial): + raise ReceiptError( + "A functools.partial modifier cannot be bound; use parameter_changes." + ) + if inspect.ismethod(modifier) or not inspect.isfunction(modifier): + raise ReceiptError( + "Only module-level functions can be bound as simulation modifiers, " + f"not {type(modifier).__name__}." + ) + code = modifier.__code__ + if modifier.__closure__ or code.co_freevars: + raise ReceiptError( + "A modifier that closes over values (for example Scenario.from_reform's) " + "cannot be bound by its code; receipt the reform dictionary instead." + ) + if modifier.__name__ == "" or "" in modifier.__qualname__: + raise ReceiptError( + "Lambdas and nested functions cannot be bound; define it at module level." + ) + if "." in modifier.__qualname__: + raise ReceiptError( + f"{modifier.__qualname__} is a method, not a module-level function, so " + "it cannot be bound; define it at module level." + ) + module = sys.modules.get(modifier.__module__) + if module is None or modifier.__globals__ is not vars(module): + raise ReceiptError( + "The modifier does not belong to an imported module, so it cannot be bound." + ) + try: + source = inspect.getsource(modifier) + module_source = inspect.getsource(module) + except (OSError, TypeError) as exc: + raise ReceiptError( + "The modifier's source is unavailable, so it cannot be bound." + ) from exc + tree = ast.parse(module_source) + shape = _module_shape_problem(tree) + if shape is not None: + raise ReceiptError(f"{modifier.__module__} cannot be bound: {shape}.") + difference = _module_differs_from_source( + module, module_source, code, also_check=modifier + ) + if difference is not None: + raise ReceiptError( + f"{modifier.__module__} no longer matches its source file: {difference} " + "(edited or reassigned after import?), so it cannot be bound." + ) + # The modifier is among the module's functions: a def name missing from + # its module is refused above. + functions = sorted( + _module_functions(module, code.co_filename), + key=lambda f: f.__code__.co_qualname, + ) + state = _module_state_problem( + module, tree, functions, module_source + ) or _runtime_alias_problem(module, tree) + if state is not None: + raise ReceiptError( + f"{modifier.__module__}: {state}, so the modifier cannot be bound." + ) + # Default arguments are runtime values, so they are bound by value, with + # their types, for the modifier and every function in its module. + module_defaults: dict[str, Any] = {} + for function in functions: + if not function.__defaults__ and not function.__kwdefaults__: + continue + try: + module_defaults[function.__code__.co_qualname] = _typed_value( + _function_defaults(function), function.__code__.co_qualname + ) + except ReceiptError as exc: + raise ReceiptError( + f"{function.__code__.co_qualname}'s default arguments cannot be bound: {exc}" + ) from exc + return { + "module": modifier.__module__, + "qualname": modifier.__qualname__, + "source_sha256": _sha256_text(source), + "module_source_sha256": _sha256_text(module_source), + "module_defaults": module_defaults or None, + "code_revision": strict_json_value(dict(code_revision)), + } + + +def scenario_reform_identity( + scenario: Any, + *, + code_revision: Optional[Mapping[str, str]] = None, + name: Optional[str] = None, +) -> ReformIdentity: + """Bind a policyengine-uk ``Scenario`` by digest. + + The payload is ``{applied_before_data_load, parameter_changes, + simulation_modifier}``. A ``simulation_modifier`` must be a module-level + function: it is bound as :func:`_modifier_identity` describes, together + with ``code_revision`` (``{"repository": ..., "git_sha": ...}``), and + marked ``binding="code_revision"``. Closures, lambdas, partials and bound + methods are refused, which includes ``Scenario.from_reform(d)``: bind + ``d`` with :func:`reform_identity` and ``"policyengine-uk.reform_dict/v1"``. + Anything that is not a Scenario (a dictionary, a ``Reform`` class, a + policyengine.py ``Policy``) is refused rather than read as current law. + """ + + missing = [ + attribute + for attribute in _SCENARIO_ATTRIBUTES + if not hasattr(scenario, attribute) + ] + if missing or isinstance(scenario, (Mapping, str, bytes, list, tuple, type)): + raise ReceiptError( + "scenario_reform_identity binds a policyengine-uk Scenario, not " + f"{type(scenario).__name__}; bind a reform dictionary with " + "reform_identity()." + ) + applied_before_data_load = scenario.applied_before_data_load + parameter_changes = scenario.parameter_changes + modifier = scenario.simulation_modifier + if not isinstance(applied_before_data_load, bool): + raise ReceiptError("Scenario.applied_before_data_load must be a bool.") + if parameter_changes is not None and not isinstance(parameter_changes, Mapping): + raise ReceiptError("Scenario.parameter_changes must be a mapping or None.") + if modifier is not None and not callable(modifier): + raise ReceiptError("Scenario.simulation_modifier must be callable or None.") + + def checked(identity: ReformIdentity) -> ReformIdentity: + revision = dict(code_revision) if code_revision is not None else None + object.__setattr__( + identity, + "_recompute_identity", + lambda: scenario_reform_identity( + scenario, code_revision=revision, name=name + ), + ) + return identity + + if not parameter_changes and modifier is None: + return checked(ReformIdentity(kind="current_law", name=name)) + payload: dict[str, Any] = { + "applied_before_data_load": applied_before_data_load, + "parameter_changes": strict_json_value(parameter_changes) + if parameter_changes + else None, + "simulation_modifier": None, + } + binding: Literal["content", "code_revision"] = "content" + if modifier is not None: + if ( + not code_revision + or not isinstance(code_revision.get("repository"), str) + or not code_revision["repository"] + or not isinstance(code_revision.get("git_sha"), str) + or not re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", code_revision["git_sha"]) + ): + raise ReceiptError( + "A scenario with a simulation_modifier can only be receipted with " + "code_revision={'repository': ..., 'git_sha': } " + "naming the code that defines the modifier." + ) + payload["simulation_modifier"] = _modifier_identity(modifier, code_revision) + binding = "code_revision" + digest = _payload_sha256( + {"representation": "policyengine-uk.scenario/v1", "payload": payload} + ) + return checked( + ReformIdentity( + kind="reform", + representation="policyengine-uk.scenario/v1", + reform_sha256=digest, + binding=binding, + name=name, + ) + ) + + +def _distribution_metadata_directory(name: str) -> Path: + """Locate dist-info through the distribution's public metadata API.""" + + distribution = metadata.distribution(name) + read_text = getattr(distribution, "read_text", None) + if read_text is None: + paths = (str(path) for path in distribution.files or ()) + else: + # Scan RECORD strings without allocating a PackagePath per installed + # file; only the metadata entry needs to be resolved. + paths = ( + row[0] for row in csv.reader(io.StringIO(read_text("RECORD") or "")) if row + ) + directories = { + Path(distribution.locate_file(path)).parent + for path in paths + if path.endswith(".dist-info/METADATA") + } + if len(directories) == 1: + return directories.pop() + + # A missing or empty RECORD leaves no file listing. Match the metadata + # under locate_file's public root, refusing an ambiguous location. + root = Path(distribution.locate_file("")) + directories = set() + for directory in root.glob("*.dist-info"): + candidate = metadata.Distribution.at(directory) + if ( + normalize_distribution_name(candidate.metadata.get("Name", "")) + == normalize_distribution_name(name) + and candidate.version == distribution.version + ): + directories.add(directory) + if len(directories) == 1: + return directories.pop() + raise ReceiptError( + f"Cannot locate the installed metadata for {name}; restart the process " + "and run again after checking the installation." + ) + + +def _refuse_package_changed_after_process_start(name: str) -> None: + """Refuse metadata installed or changed during this process's lifetime.""" + + try: + directory = _distribution_metadata_directory(name) + paths = [directory] + paths.extend( + directory / filename + for filename in ("METADATA", "RECORD", "INSTALLER", "direct_url.json") + if (directory / filename).exists() + ) + installed_at = max( + max(stat.st_mtime, stat.st_ctime) + for stat in (path.stat() for path in paths) + ) + process_started_at = psutil.Process().create_time() + except (OSError, TypeError, ValueError, PackageNotFoundError, psutil.Error) as exc: + raise ReceiptError( + f"Cannot locate or read the installed metadata for {name}; restart " + "the process and run again after checking the installation." + ) from exc + if installed_at > process_started_at: + raise ReceiptError( + f"{name} was installed or changed after this process started, so the " + "installed version may not be the code that ran; restart the " + "process and run again." + ) + + +def installed_package_identity( + name: str, + *, + wheel_path: Optional[Path] = None, +) -> PackageIdentity: + """Identity of an installed distribution, with a hash only when verified. + + With ``wheel_path``, the installed files are checked against that wheel's + RECORD; any difference raises :class:`ReceiptError`, and a match records + the wheel's SHA-256 as ``installed_record_verified``. Otherwise the + installer's PEP 610 ``direct_url.json`` archive hash is used when present. + A bundle manifest's wheel hash is never assumed to describe installed + files. Versions always come from :mod:`importlib.metadata`; a caller + cannot supply them. + """ + + normalized = normalize_distribution_name(name) + version = metadata.version(name) + _refuse_loaded_package_origin(name) + direct_url = _read_direct_url(name) + origin = _install_origin(direct_url) + if wheel_path is not None: + from policyengine.provenance.wheels import verify_installed_against_wheel + + try: + evidence = verify_installed_against_wheel( + name, Path(wheel_path), metadata=metadata + ) + except (OSError, ValueError, zipfile.BadZipFile) as exc: + raise ReceiptError( + f"{Path(wheel_path).name} cannot be verified against installed " + f"{name}: {exc}" + ) from exc + if evidence["problems"]: + raise ReceiptError( + f"Installed {name} differs from {Path(wheel_path).name}: " + + "; ".join(evidence["problems"][:5]) + ) + return PackageIdentity( + name=normalized, + version=version, + wheel_sha256=evidence["wheel_sha256"], + wheel_sha256_evidence="installed_record_verified", + install_origin=origin, + ) + archive_hash = _installer_archive_sha256(direct_url) + return PackageIdentity( + name=normalized, + version=version, + wheel_sha256=archive_hash, + wheel_sha256_evidence="installer_archive_hash" if archive_hash else None, + install_origin=origin, + ) + + +def _read_direct_url(name: str) -> Optional[Mapping[str, Any]]: + """Read an install's PEP 610 origin, refusing malformed metadata.""" + + return _distribution_direct_url(name, metadata.distribution(name)) + + +def _distribution_direct_url( + name: str, distribution: metadata.Distribution +) -> Optional[Mapping[str, Any]]: + try: + text = distribution.read_text("direct_url.json") + except OSError as exc: + raise ReceiptError(f"{name} direct_url.json cannot be read: {exc}") from exc + if text is None: + return None + try: + direct_url = json.loads(text) + except ValueError as exc: + raise ReceiptError(f"{name} direct_url.json is malformed: {exc}") from exc + if ( + not isinstance(direct_url, Mapping) + or not isinstance(direct_url.get("url"), str) + or not direct_url["url"] + or sum(key in direct_url for key in ("archive_info", "dir_info", "vcs_info")) + != 1 + or any( + not isinstance(direct_url[key], Mapping) + for key in ("archive_info", "dir_info", "vcs_info") + if key in direct_url + ) + or ( + "editable" in direct_url.get("dir_info", {}) + and not isinstance(direct_url["dir_info"]["editable"], bool) + ) + ): + raise ReceiptError( + f"{name} direct_url.json is malformed; cannot identify its install origin." + ) + archive = direct_url.get("archive_info", {}) + if ("hash" in archive and not isinstance(archive["hash"], str)) or ( + "hashes" in archive + and ( + not isinstance(archive["hashes"], Mapping) + or any(not isinstance(value, str) for value in archive["hashes"].values()) + ) + ): + raise ReceiptError( + f"{name} direct_url.json is malformed; invalid archive hashes." + ) + return direct_url + + +def _refuse_loaded_package_origin(name: str) -> None: + normalized = normalize_distribution_name(name) + if normalized not in UK_PACKAGES.values(): + return + import_name = normalized.replace("-", "_") + module = sys.modules.get(import_name) + # An unloaded package has no running source to compare. The version + # checks still cover the installed package that will be loaded. + if module is None: + return + distribution = metadata.distribution(name) + loaded_file = getattr(module, "__file__", None) + problem = ( + f"{import_name} loaded from {loaded_file!r} does not match installed " + f"distribution {name} {distribution.version}; the receipt cannot name " + "the code that ran" + ) + if not isinstance(loaded_file, (str, Path)) or not loaded_file: + raise ReceiptError(problem + ": the loaded package has no source file.") + try: + loaded_path = Path(loaded_file).resolve() + direct_url = _distribution_direct_url(name, distribution) + if ( + direct_url is not None + and direct_url.get("dir_info", {}).get("editable") is True + ): + url = urlparse(direct_url["url"]) + if url.scheme != "file" or url.netloc not in ("", "localhost"): + raise ReceiptError(problem + ": the install directory is not local.") + directory = Path(url2pathname(url.path)).resolve() + if not loaded_path.is_relative_to(directory): + raise ReceiptError(problem + f": expected code under {directory}.") + else: + record = distribution.read_text("RECORD") + rows = list(csv.reader(io.StringIO(record or ""), strict=True)) + if any(len(row) != 3 for row in rows): + raise ReceiptError(problem + ": the installed RECORD is malformed.") + entry = f"{import_name}/__init__.py" + if not any(row[0] == entry for row in rows): + raise ReceiptError(problem + f": the installed RECORD lacks {entry}.") + expected = Path(distribution.locate_file(entry)).resolve() + if loaded_path != expected: + raise ReceiptError(problem + f": expected {expected} from RECORD.") + except ReceiptError: + raise + except (OSError, ValueError, csv.Error) as exc: + raise ReceiptError( + problem + f": its source location cannot be read ({exc})." + ) from exc + + +def _install_origin( + direct_url: Optional[Mapping[str, Any]], +) -> Optional[Literal["archive", "directory", "editable", "vcs"]]: + if direct_url is None: + return None + if "dir_info" in direct_url: + return ( + "editable" if direct_url["dir_info"].get("editable", False) else "directory" + ) + return "vcs" if "vcs_info" in direct_url else "archive" + + +def _package_can_be_certified(package: PackageIdentity) -> bool: + """Whether a receipt can name this installed package as the code that ran. + + An editable install never can: its wheel holds a pointer to a source tree + (a ``.pth`` file or an import hook) rather than the source, so verifying + the installed files against that wheel says nothing about the code loaded. + """ + + if package.install_origin == "editable": + return False + return ( + package.install_origin in (None, "archive") + or package.wheel_sha256_evidence == "installed_record_verified" + ) + + +def _uncertifiable_reason(package: PackageIdentity) -> str: + if package.install_origin == "editable": + return "an editable install's wheel does not hold the source that runs" + return "it lacks installed_record_verified wheel evidence" + + +def _installer_archive_sha256(direct_url: Optional[Mapping[str, Any]]) -> Optional[str]: + """The SHA-256 of the wheel a PEP 610 direct URL install came from, if any. + + Only a ``.whl`` URL qualifies: a source archive's hash does not describe + the installed files, so it is never recorded as ``wheel_sha256``. + """ + + if direct_url is None: + return None + try: + url_path = urlparse(str(direct_url.get("url") or "")).path + archive = direct_url.get("archive_info") or {} + except (ValueError, AttributeError): + return None + if not url_path.endswith(".whl") or not isinstance(archive, Mapping): + return None + candidate = (archive.get("hashes") or {}).get("sha256") + if candidate is None: + legacy = archive.get("hash") or "" + if legacy.startswith("sha256="): + candidate = legacy.removeprefix("sha256=") + if candidate and re.fullmatch(SHA256_PATTERN[1:-1], candidate): + return str(candidate) + return None + + +def _packaged_tro_fingerprint(path: Path) -> Optional[str]: + if not path.is_file(): + return None + from policyengine.provenance.trace import extract_bundle_tro_reference + + try: + fingerprint = extract_bundle_tro_reference(json.loads(path.read_text()))[ + "fingerprint" + ] + except (ValueError, KeyError): + return None + return str(fingerprint) if fingerprint else None + + +def build_execution_receipt( + *, + country_id: Literal["uk"], + dataset_path: Union[str, Path], + years: Sequence[int], + reform: ReformIdentity, + baseline: ReformIdentity, + dataset: Optional[str] = None, + outputs: Optional[Any] = None, + wheel_paths: Optional[Mapping[str, Union[str, Path]]] = None, + wrapper_git_sha: Optional[str] = None, + qualification_receipt_sha256: Optional[str] = None, + bundle: Optional[Mapping[str, Any]] = None, + ci_run_url: Optional[str] = None, +) -> ExecutionReceipt: + """Build a receipt for a UK result computed in this process. + + ``reform`` and ``baseline`` are required: a receipt never assumes current + law. Versions come from the installed distributions. The dataset digest + comes from hashing ``dataset_path``, which must be the release artifact + that was loaded (what :func:`policyengine.provenance.dataset_materialization. + materialize_dataset` returns), not a derived per-year file. The + certificate is attached only when the bundle certifies the installed + model and core versions, those dataset bytes, that dataset name and the + running wrapper version; otherwise the receipt is ``uncertified``. + ``bundle`` defaults to the installed policyengine bundle + (``certificate_source="packaged"``); a caller-supplied bundle is recorded + as ``caller_supplied``. ``wheel_paths`` maps distribution names to wheels + whose RECORD the installed files are verified against. + """ + + if country_id != "uk": + raise ReceiptError("Execution receipt schema v1 covers UK results only.") + source: Literal["packaged", "caller_supplied"] = "caller_supplied" + tro_fingerprint: Optional[str] = None + if bundle is None: + from policyengine.provenance.certification import default_bundle_source_path + + manifest_path = default_bundle_source_path() + bundle = json.loads(manifest_path.read_text()) + source = "packaged" + tro_fingerprint = _packaged_tro_fingerprint( + manifest_path.parent / f"{country_id}.trace.tro.jsonld" + ) + try: + return _build( + country_id=country_id, + dataset_path=Path(dataset_path), + years=years, + reform=reform, + baseline=baseline, + dataset=dataset, + outputs=outputs, + wheel_paths=wheel_paths or {}, + wrapper_git_sha=wrapper_git_sha, + qualification_receipt_sha256=qualification_receipt_sha256, + bundle=bundle, + source=source, + tro_fingerprint=tro_fingerprint, + ci_run_url=ci_run_url, + ) + except KeyError as exc: + raise ReceiptError( + f"The bundle manifest lacks {exc}; it cannot back a receipt." + ) from exc + + +def _is_boolean(value: Any) -> bool: + numpy = sys.modules.get("numpy") + return isinstance(value, bool) or ( + numpy is not None and isinstance(value, numpy.bool_) + ) + + +def _receipt_years(years: Any) -> list[int]: + """Sorted unique years, refusing anything that is not a sequence of integers.""" + + if isinstance(years, (str, bytes)) or not isinstance(years, Sequence): + raise ReceiptError(f"years must be a sequence of integers, not {years!r}.") + result = set() + for year in years: + if _is_boolean(year) or not ( + isinstance(year, int) + or _numpy_scalar(year) + and isinstance(year.item(), int) + ): + raise ReceiptError(f"years must be integers, not {year!r}.") + result.add(int(year)) + if not result: + raise ReceiptError("A receipt covers at least one year.") + return sorted(result) + + +@functools.lru_cache(maxsize=8) +def _projected_years( + dataset_path: str, dataset_sha256: str, model_version: str +) -> tuple[int, ...]: + """Years the installed policyengine-uk projects a single-year dataset to. + + The same rule as the qualification ``supported_years`` gate: build the + multi-year dataset as ``policyengine_uk.Simulation`` does for a + single-year file, without running a formula, and keep the years from the + dataset's own year onwards. Cached by file bytes and model version. + """ + + from policyengine_uk.data.dataset_schema import UKSingleYearDataset + from policyengine_uk.data.economic_assumptions import extend_single_year_dataset + from policyengine_uk.system import system + + dataset = UKSingleYearDataset(dataset_path) + first = int(str(dataset.time_period)[:4]) + multi_year = extend_single_year_dataset(dataset, system.parameters) + years = getattr(multi_year, "years", None) + if years is None: + years = list(multi_year.datasets) + return tuple(sorted(int(year) for year in years if int(year) >= first)) + + +def _certificate_fields( + country_id: str, record: Mapping[str, Any], bundle: Mapping[str, Any] +) -> dict[str, Any]: + """The certificate identity a bundle's certification record determines. + + One derivation for both :func:`build_execution_receipt` and + :func:`verify_certificate`, keyed by ``CertificateIdentity.IDENTITY_FIELDS``. + """ + + certification = record.get("certification") or {} + certified_artifact = record.get("certified_data_artifact") or {} + core = ((bundle.get("packages") or {}).get(UK_PACKAGES["core"]) or {}).get( + "version" + ) + return { + "bundle_version": bundle.get("policyengine_version"), + "country_id": country_id, + "certificate_sha256": certificate_sha256(record), + "compatibility_basis": certification.get("compatibility_basis"), + "certified_for_model_version": certification.get("certified_for_model_version"), + "certified_for_core_version": certification.get("certified_for_core_version") + or core, + "certified_dataset_sha256": certified_artifact.get("sha256"), + "certified_by": certification.get("certified_by"), + } + + +def _data_identity( + country_id: str, + record: Mapping[str, Any], + bundle: Mapping[str, Any], + dataset_name: str, + dataset_sha256: str, +) -> DataIdentity: + """The data identity of ``dataset_name`` with those bytes, from the bundle. + + The dataset is resolved exactly as + :func:`policyengine.provenance.dataset_materialization.materialize_dataset` + resolves it (overlays, per-dataset repository, package and revision + overrides, the release manifest revision), so the receipt names the + artifact a managed simulation downloads. The bytes must be that + artifact's: a receipt never gives one artifact's name to other bytes. + Release-level members (build, fingerprint, manifest revision) are filled + only for datasets in the certified release, never for overlays. + """ + + from pydantic import ValidationError + + from policyengine.provenance.dataset_materialization import ( + DatasetMaterializationError, + _resolve_bundle_dataset, + ) + from policyengine.provenance.manifest import ( + CountryReleaseManifest, + _apply_dataset_overlays, + ) + + try: + manifest = CountryReleaseManifest.model_validate( + _apply_dataset_overlays(country_id, dict(record), dict(bundle)) + ) + spec = _resolve_bundle_dataset(country_id, dataset_name, manifest=manifest) + except (DatasetMaterializationError, ValidationError, ValueError) as exc: + raise ReceiptError( + f"The {country_id} bundle cannot resolve dataset {dataset_name!r}: {exc}" + ) from exc + if spec.sha256 != dataset_sha256: + raise ReceiptError( + f"The file is not the bundle's {dataset_name!r}: it hashes to " + f"{dataset_sha256}, but {spec.source_uri} is {spec.sha256}." + ) + release = dataset_name in (record.get("datasets") or {}) + data_package = record["data_package"] + certified_artifact = record.get("certified_data_artifact") or {} + certification = record.get("certification") or {} + return DataIdentity( + data_package=spec.data_package_name, + release_version=data_package["version"] if release else spec.revision, + build_id=( + certification.get("data_build_id") or certified_artifact.get("build_id") + if release + else None + ), + build_fingerprint=certification.get("data_build_fingerprint") + if release + else None, + build_model_version=certification.get("built_with_model_version") + if release + else None, + release_manifest_revision=data_package.get("release_manifest_revision") + if release + else None, + dataset=dataset_name, + dataset_uri=spec.source_uri, + dataset_revision=spec.revision, + dataset_sha256=dataset_sha256, + ) + + +def _build( + *, + country_id: Literal["uk"], + dataset_path: Path, + years: Sequence[int], + reform: ReformIdentity, + baseline: ReformIdentity, + dataset: Optional[str], + outputs: Optional[Any], + wheel_paths: Mapping[str, Union[str, Path]], + wrapper_git_sha: Optional[str], + qualification_receipt_sha256: Optional[str], + bundle: Mapping[str, Any], + source: Literal["packaged", "caller_supplied"], + tro_fingerprint: Optional[str], + ci_run_url: Optional[str], +) -> ExecutionReceipt: + record = bundle["data_releases"][country_id] + reform._recheck() + baseline._recheck() + wheels = {normalize_distribution_name(k): Path(v) for k, v in wheel_paths.items()} + + def identity(name: str) -> dict[str, Any]: + return installed_package_identity( + name, wheel_path=wheels.get(normalize_distribution_name(name)) + ).model_dump() + + wrapper = WrapperIdentity( + **identity(UK_PACKAGES["wrapper"]), git_sha=wrapper_git_sha + ) + model = ModelIdentity(**identity(UK_PACKAGES["model"])) + core = CoreIdentity(**identity(UK_PACKAGES["core"])) + loaded = loaded_versions() + for installed in (wrapper, model, core): + # An absent record is safe when the install predates this process: + # that installed version has been unchanged throughout the process. + recorded = loaded.get(installed.name) + if recorded is not None and recorded != installed.version: + raise ReceiptError( + f"{installed.name} {recorded} was loaded in this process, but " + f"{installed.version} is installed now, so the installed version " + "is not the code that ran; restart the process and run again." + ) + _refuse_package_changed_after_process_start(installed.name) + + dataset_name = dataset or record["default_dataset"] + actual_sha256 = sha256_file(dataset_path) + data = _data_identity(country_id, record, bundle, dataset_name, actual_sha256) + + fields = _certificate_fields(country_id, record, bundle) + certified_artifact = record.get("certified_data_artifact") or {} + certificate = None + status: Literal["certified", "uncertified"] = "uncertified" + if ( + fields["certified_for_model_version"] == model.version + and fields["certified_for_core_version"] == core.version + and fields["certified_dataset_sha256"] == actual_sha256 + and dataset_name == certified_artifact.get("dataset") + and fields["bundle_version"] == wrapper.version + and record.get("policyengine_version", wrapper.version) == wrapper.version + and all( + _package_can_be_certified(package) for package in (wrapper, model, core) + ) + ): + if not fields["compatibility_basis"]: + raise ReceiptError( + "The bundle certifies this run but states no compatibility_basis." + ) + certificate = CertificateIdentity( + **fields, + certificate_source=source, + qualification_receipt_sha256=qualification_receipt_sha256, + tro_composition_fingerprint=tro_fingerprint, + ) + status = "certified" + + return ExecutionReceipt( + schema_version=RECEIPT_SCHEMA_VERSION, + receipt_type="policyengine.execution_receipt", + country_id=country_id, + wrapper=wrapper, + model=model, + core=core, + data=data, + certification_status=status, + certificate=certificate, + reform=reform, + baseline=baseline, + fiscal_year=uk_fiscal_year_convention(), + years=_receipt_years(years), + supported_years=list( + _projected_years(str(dataset_path), actual_sha256, model.version) + ), + outputs_sha256=content_sha256(outputs) if outputs is not None else None, + runtime=RuntimeContext( + python_version=platform.python_version(), + platform=sys.platform, + ci_run_url=ci_run_url, + ), + ) + + +def verify_certificate( + receipt: ExecutionReceipt, bundle: Mapping[str, Any] +) -> list[str]: + """Problems with a receipt's certificate against a published bundle manifest. + + ``bundle`` is the policyengine manifest for ``certificate.bundle_version`` + (from the release assets, or the installed wheel of that version). The + certificate is rebuilt from that bundle's certification record exactly as + :func:`build_execution_receipt` builds it, and compared member by member: + + - every member in ``CertificateIdentity.IDENTITY_FIELDS``, including the + record digest, the basis and the certified model, core and dataset; + - the receipt's whole ``data`` identity, which must be the certified + dataset with the certified bytes. + + Through the receipt's own rules, that also pins ``model.version``, + ``core.version`` and ``wrapper.version``. ``certificate_source``, + ``qualification_receipt_sha256``, ``tro_composition_fingerprint`` and + wheel hashes are not determined by the bundle and are not checked here. + An empty list means the receipt's certificate and data are what the bundle + certifies. + """ + + certificate = receipt.certificate + if certificate is None: + return ["the receipt is uncertified"] + record = (bundle.get("data_releases") or {}).get(receipt.country_id) + if not isinstance(record, Mapping): + return [f"the bundle has no {receipt.country_id} data release"] + problems = [] + for package in (receipt.wrapper, receipt.model, receipt.core): + if not _package_can_be_certified(package): + problems.append( + f"{package.name} installed from {package.install_origin}: " + f"{_uncertifiable_reason(package)}" + ) + expected = _certificate_fields(receipt.country_id, record, bundle) + claimed = certificate.model_dump(include=set(CertificateIdentity.IDENTITY_FIELDS)) + for field in CertificateIdentity.IDENTITY_FIELDS: + if claimed.get(field) != expected[field]: + problems.append( + f"certificate.{field} is {claimed.get(field)!r}, " + f"but the bundle states {expected[field]!r}" + ) + record_version = record.get("policyengine_version", expected["bundle_version"]) + if record_version != certificate.bundle_version: + problems.append( + f"the bundle's {receipt.country_id} record belongs to policyengine " + f"{record_version}, not {certificate.bundle_version}" + ) + certified_dataset = (record.get("certified_data_artifact") or {}).get("dataset") + if receipt.data.dataset != certified_dataset: + problems.append( + f"data.dataset is {receipt.data.dataset!r}, " + f"but the bundle certifies {certified_dataset!r}" + ) + return problems + try: + expected_data = _data_identity( + receipt.country_id, + record, + bundle, + certified_dataset, + expected["certified_dataset_sha256"] or receipt.data.dataset_sha256, + ) + except (KeyError, ReceiptError, ValueError) as exc: + return problems + [f"the bundle cannot determine the data identity: {exc}"] + claimed_data = receipt.data.model_dump() + for field, value in expected_data.model_dump().items(): + if claimed_data[field] != value: + problems.append( + f"data.{field} is {claimed_data[field]!r}, " + f"but the bundle determines {value!r}" + ) + return problems + + +def build_execution_receipt_for_microsimulation( + microsimulation: Any, + *, + years: Sequence[int], + reform: ReformIdentity, + baseline: ReformIdentity, + outputs: Optional[Any] = None, + **kwargs: Any, +) -> ExecutionReceipt: + """Receipt for a country Microsimulation built by ``managed_microsimulation``. + + Reads the dataset that was loaded from ``microsimulation.policyengine_bundle``. + The bytes on disk must still be the bytes verified when it was loaded + (``runtime_dataset_sha256``), and the receipt must name the dataset, + artifact URI, revision and package recorded at load time; otherwise the + receipt would describe a file that did not run, so it is refused. A + simulation loaded from an unmanaged file or URI recorded no load-time + hash and cannot be receipted. ``reform`` and ``baseline`` must describe + what the microsimulation was built with (``scenario_reform_identity`` or + ``reform_identity``); the microsimulation itself does not record them. + """ + + bundle_fields = getattr(microsimulation, "policyengine_bundle", None) + if not bundle_fields or not bundle_fields.get("runtime_dataset_source"): + raise ReceiptError( + "This microsimulation records no runtime dataset; build it with " + "managed_microsimulation()." + ) + if bundle_fields.get("country_id") != "uk": + raise ReceiptError("Execution receipt schema v1 covers UK results only.") + loaded_sha256 = bundle_fields.get("runtime_dataset_sha256") + if not loaded_sha256: + raise ReceiptError( + "This microsimulation loaded a dataset without verifying its SHA-256 " + "(an unmanaged file or URI), so a receipt cannot say which bytes ran; " + "load a bundle dataset through managed_microsimulation()." + ) + try: + receipt = build_execution_receipt( + country_id="uk", + dataset_path=bundle_fields["runtime_dataset_source"], + dataset=bundle_fields.get("runtime_dataset"), + years=years, + reform=reform, + baseline=baseline, + outputs=outputs, + **kwargs, + ) + except FileNotFoundError as exc: + raise ReceiptError( + f"The loaded dataset {bundle_fields['runtime_dataset_source']} is gone." + ) from exc + if receipt.data.dataset_sha256 != loaded_sha256: + raise ReceiptError( + f"{bundle_fields['runtime_dataset_source']} has changed since the " + f"microsimulation loaded it ({loaded_sha256}); it now hashes to " + f"{receipt.data.dataset_sha256}." + ) + loaded = { + "dataset": bundle_fields.get("runtime_dataset"), + "dataset_uri": bundle_fields.get("runtime_dataset_uri"), + "dataset_revision": bundle_fields.get("runtime_dataset_revision"), + "data_package": bundle_fields.get("runtime_dataset_data_package"), + } + for field, value in loaded.items(): + named = getattr(receipt.data, field) + if value is not None and named != value: + raise ReceiptError( + f"The receipt would name data.{field} {named!r}, but the " + f"microsimulation loaded {value!r}." + ) + return receipt diff --git a/src/policyengine/provenance/wheels.py b/src/policyengine/provenance/wheels.py new file mode 100644 index 00000000..3fa9ad48 --- /dev/null +++ b/src/policyengine/provenance/wheels.py @@ -0,0 +1,438 @@ +"""Check that an installed distribution is byte-identical to a wheel. + +A wheel's ``*.dist-info/RECORD`` lists its files and their SHA-256 hashes. +Verification has three parts, and all must hold: + +1. **The wheel is what its RECORD says.** Every archive member is listed in + the RECORD with the hash of its bytes, and every RECORD entry is an + archive member. RECORD lists itself with empty hash and size fields. + The only membership exceptions are the optional ``RECORD.jws`` and + ``RECORD.p7s`` signatures, which the wheel specification excludes from + RECORD. Without this, a zip holding a copied RECORD would "verify" and + its SHA-256 would name a wheel the installed files never came from. +2. **The installed files are the wheel's.** Every hashed file in the wheel's + RECORD is installed with the same hash, and the bytes on disk hash to it. + Nothing else is installed beyond what an installer adds: ``INSTALLER``, + ``REQUESTED``, ``direct_url.json``, ``uv_cache.json`` and ``uv_build.json`` + in its ``.dist-info`` directory, the rewritten ``RECORD``, signatures + whose bytes match the archive, bytecode caches, and + the console and GUI script launchers it generates in the scripts + directory, outside the site directory, for the entry points the wheel's + ``entry_points.txt`` declares. Launchers are generated per environment + (they embed the interpreter path), so they are named, not compared. +3. **The bytecode that runs is the wheel's.** The interpreter runs a + ``__pycache__`` file instead of compiling its source, so every cache + this interpreter would load for a verified ``.py`` file (at any + optimisation level, listed in the RECORD or not, under + ``sys.pycache_prefix`` when set) must compile from that source; see + :func:`policyengine.provenance.bytecode.compiled_file`. A cache this + interpreter would ignore (another Python's tag, a stale timestamp) is + inert and only reported. A listed cache for this interpreter whose + source is not a verified file is not the wheel's. + +Not covered: files on disk that the installed RECORD does not list, other +than bytecode caches of verified sources, and anything outside the +distribution (other distributions, ``.pth`` files in the site directory). + +A wheel with a ``*.data`` directory installs those files under other paths, +so it does not verify; none of the UK bundle's distributions has one. +""" + +from __future__ import annotations + +import base64 +import configparser +import csv +import hashlib +import importlib.util +import io +import sys +import zipfile +from importlib import metadata as importlib_metadata +from pathlib import Path, PurePosixPath +from typing import Any, Optional + +from policyengine.provenance.bytecode import OPTIMIZATION_LEVELS, compiled_file +from policyengine.utils.hashing import sha256_file + +__all__ = [ + "INSTALLER_FILES", + "SCRIPT_DIRECTORIES", + "parse_record", + "record_digest_to_hex", + "verify_installed_against_wheel", + "verify_wheel_archive", + "wheel_record", +] + +#: Files an installer adds to a distribution that are not in the wheel RECORD. +INSTALLER_FILES = frozenset( + {"INSTALLER", "REQUESTED", "direct_url.json", "uv_cache.json", "uv_build.json"} +) + +#: Directories installers write entry-point launchers to (POSIX, Windows). +SCRIPT_DIRECTORIES = frozenset({"bin", "Scripts"}) + +#: Entry-point groups an installer turns into launchers. +SCRIPT_GROUPS = ("console_scripts", "gui_scripts") + +#: Windows launcher suffixes, longest first. +_LAUNCHER_SUFFIXES = ("-script.pyw", "-script.py", ".exe") + +_RECORD_SIGNATURE_FILES = ("RECORD.jws", "RECORD.p7s") + + +def record_digest_to_hex(value: str) -> str: + """Convert a RECORD ``sha256=`` digest to hex.""" + + algorithm, _, encoded = value.partition("=") + if algorithm != "sha256": + raise ValueError(f"Unsupported RECORD hash {algorithm!r}.") + padded = encoded + "=" * (-len(encoded) % 4) + return base64.urlsafe_b64decode(padded).hex() + + +def parse_record(text: str) -> dict[str, Optional[str]]: + """``{path: hex sha256 or None}`` from a RECORD file.""" + + entries: dict[str, Optional[str]] = {} + for row in csv.reader(io.StringIO(text)): + if not row: + continue + path, digest = row[0], (row[1] if len(row) > 1 else "") + if path in entries: + raise ValueError(f"RECORD lists {path} twice.") + entries[path] = record_digest_to_hex(digest) if digest else None + return entries + + +def _record_member(names: list[str], wheel_name: str) -> str: + records = [ + name + for name in names + if name.endswith(".dist-info/RECORD") and name.count("/") == 1 + ] + if len(records) != 1: + raise ValueError(f"{wheel_name} has {len(records)} RECORD files.") + return records[0] + + +def wheel_record(wheel_path: Path) -> dict[str, Optional[str]]: + """The wheel's RECORD as ``{path: hex sha256 or None}`` (not verified).""" + + with zipfile.ZipFile(wheel_path) as archive: + name = _record_member(archive.namelist(), wheel_path.name) + return parse_record(archive.read(name).decode("utf-8")) + + +def _member_sha256(archive: zipfile.ZipFile, name: str) -> str: + digest = hashlib.sha256() + with archive.open(name) as stream: + for chunk in iter(lambda: stream.read(1 << 20), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _entry_point_scripts(text: str) -> frozenset[str]: + parser = configparser.ConfigParser(delimiters=("=",), interpolation=None) + parser.optionxform = str # type: ignore[assignment,method-assign] + parser.read_string(text) + return frozenset( + name.strip() + for group in SCRIPT_GROUPS + if parser.has_section(group) + for name in parser.options(group) + ) + + +def verify_wheel_archive(wheel_path: Path) -> dict[str, Any]: + """Check a wheel's members against its own RECORD; never raises on mismatch. + + Returns ``record`` (``{path: hex sha256 or None}``), ``scripts`` (entry + point names an installer turns into launchers), ``members_verified`` and + ``problems`` (empty when members and RECORD match, except the optional + signature files). A wheel without exactly one top-level + ``*.dist-info/RECORD`` raises ``ValueError``. + """ + + problems: list[str] = [] + with zipfile.ZipFile(wheel_path) as archive: + members = [info.filename for info in archive.infolist() if not info.is_dir()] + record_name = _record_member(members, wheel_path.name) + dist_info = record_name.rsplit("/", 1)[0] + seen: set[str] = set() + for name in members: + if name in seen: + problems.append(f"{name}: the wheel holds it twice") + seen.add(name) + record_text = archive.read(record_name).decode("utf-8") + record = parse_record(record_text) + self_entry = next( + ( + row + for row in csv.reader(io.StringIO(record_text)) + if row and row[0] == record_name + ), + None, + ) + if self_entry is None: + problems.append(f"{record_name}: RECORD does not list itself") + elif self_entry[1:] != ["", ""]: + problems.append(f"{record_name}: RECORD needs empty hash and size fields") + signatures = {f"{dist_info}/{name}" for name in _RECORD_SIGNATURE_FILES} + verified = 0 + for name in sorted(seen): + if name == record_name: + continue + if name in signatures: + if name in record: + problems.append(f"{name}: signature files must not be in RECORD") + continue + if name not in record: + problems.append(f"{name}: in the wheel but not in its RECORD") + elif record[name] is None: + problems.append(f"{name}: RECORD gives no hash") + elif _member_sha256(archive, name) != record[name]: + problems.append(f"{name}: bytes differ from the wheel's RECORD") + else: + verified += 1 + problems.extend( + f"{path}: in the wheel's RECORD but not in the wheel" + for path in sorted(record) + if path not in seen + ) + entry_points = f"{dist_info}/entry_points.txt" + scripts: frozenset[str] = frozenset() + if entry_points in seen: + try: + scripts = _entry_point_scripts( + archive.read(entry_points).decode("utf-8") + ) + except (configparser.Error, UnicodeDecodeError) as exc: + problems.append(f"{entry_points}: unreadable ({type(exc).__name__})") + signature_hashes = { + path: _member_sha256(archive, path) for path in sorted(seen & signatures) + } + return { + "record": record, + "dist_info": dist_info, + "signatures": signature_hashes, + "scripts": scripts, + "members_verified": verified, + "problems": problems, + } + + +def _launcher_name(path: str) -> Optional[str]: + """The entry point a generated launcher path is for, if it is one.""" + + parts = PurePosixPath(path.replace("\\", "/")).parts + if len(parts) < 2 or parts[0] != ".." or parts[-2] not in SCRIPT_DIRECTORIES: + return None + name = parts[-1] + for suffix in _LAUNCHER_SUFFIXES: + if name.endswith(suffix): + return name[: -len(suffix)] + return name + + +def _launcher_located(distribution: Any, path: str) -> bool: + """Whether a launcher exists in a scripts directory outside the site root.""" + + site = Path(distribution.locate_file("")).resolve() + located = Path(distribution.locate_file(path)).resolve() + return ( + located.is_file() + and located.parent.name in SCRIPT_DIRECTORIES + and site not in located.parents + and located != site + ) + + +def _installer_file(path: str, dist_info: str) -> bool: + """Whether an installed path is one an installer writes in ``.dist-info``.""" + + location = PurePosixPath(path) + return str(location.parent) == dist_info and ( + location.name in INSTALLER_FILES or location.name == "RECORD" + ) + + +def _cache_entry(path: str) -> Optional[tuple[str, str, str]]: + """``(source path, cache tag, optimisation)`` for a ``__pycache__`` path.""" + + location = PurePosixPath(path) + if location.parent.name != "__pycache__" or location.suffix != ".pyc": + return None + parts = location.name[: -len(".pyc")].split(".") + optimization = "" + if len(parts) == 3 and parts[2].startswith("opt-"): + optimization = parts.pop()[len("opt-") :] + if len(parts) != 2 or not parts[0] or not parts[1]: + return None + module, tag = parts + return str(location.parent.parent / f"{module}.py"), tag, optimization + + +def _verify_bytecode( + distribution: Any, + sources: list[str], + installed: dict[str, Optional[str]], + cache_tag: Optional[str], +) -> dict[str, Any]: + """Check every bytecode cache this interpreter would load for ``sources``.""" + + problems: list[str] = [] + # One check per cache *and* the source it stands for: a cache reached + # through a link can stand for several sources, and must match each. + checked: dict[tuple[Path, str, str], str] = {} + inert: list[str] = [] + verified = set(sources) + + def check(pyc: Path, source: str, optimization: str, label: str) -> None: + key = (pyc.resolve(), source, optimization) + if key in checked: + return + result = compiled_file( + pyc, Path(distribution.locate_file(source)), optimization + ) + checked[key] = result.status + if result.status == "inert": + inert.append(f"{label}: {result.detail}") + elif result.status != "matches": + problems.append(f"{label}: {result.detail}") + + site = Path(distribution.locate_file("")) + + def label(pyc: Path) -> str: + try: + return pyc.relative_to(site).as_posix() + except ValueError: # under sys.pycache_prefix + return str(pyc) + + if cache_tag is not None: + for source in sources: + located = Path(distribution.locate_file(source)) + for optimization in OPTIMIZATION_LEVELS: + pyc = Path( + importlib.util.cache_from_source( + str(located), optimization=optimization + ) + ) + if pyc.is_file(): + check(pyc, source, optimization, label(pyc)) + for path in sorted(installed): + entry = _cache_entry(path) + if entry is None: + continue + source, tag, optimization = entry + if tag != cache_tag: + inert.append(f"{path}: another interpreter's cache") + continue + if source not in verified: + problems.append(f"{path}: caches {source}, which is not in the wheel") + continue + pyc = Path(distribution.locate_file(path)) + if not pyc.is_file(): + continue + check(pyc, source, optimization, path) + return { + "verified": sum(1 for status in checked.values() if status == "matches"), + "inert": sorted(inert), + "problems": sorted(problems), + } + + +def verify_installed_against_wheel( + name: str, + wheel_path: Path, + *, + metadata: Any = importlib_metadata, + cache_tag: Optional[str] = sys.implementation.cache_tag, +) -> dict[str, Any]: + """Compare an installed distribution with a wheel; never raises on mismatch. + + Returns evidence: the wheel's SHA-256, how many archive members, + installed files and bytecode caches were verified, the launchers + exempted, inert bytecode caches, any files installed beyond the wheel, + and ``problems`` (empty only when the wheel matches its RECORD and the + installed files and the bytecode that would run are exactly the wheel's). + ``cache_tag`` is the interpreter's bytecode cache tag (``cpython-313``). + """ + + archive = verify_wheel_archive(wheel_path) + expected: dict[str, Optional[str]] = archive["record"] + problems: list[str] = list(archive["problems"]) + distribution = metadata.distribution(name) + installed: dict[str, Optional[str]] = {} + for file in distribution.files or []: + installed[str(file)] = ( + record_digest_to_hex(f"{file.hash.mode}={file.hash.value}") + if file.hash is not None + else None + ) + verified = 0 + for path, digest in sorted(expected.items()): + if digest is None: + continue + if installed.get(path, "absent") != digest: + problems.append(f"{path}: installed RECORD differs from the wheel") + continue + located = Path(distribution.locate_file(path)) + if not located.is_file(): + problems.append(f"{path}: missing on disk") + continue + if sha256_file(located) != digest: + problems.append(f"{path}: bytes differ from the wheel") + continue + verified += 1 + signatures = archive["signatures"] + listed_signatures = installed.keys() & signatures.keys() + read_text = getattr(distribution, "read_text", None) + if signatures and callable(read_text): + installed_record = read_text("RECORD") + if installed_record is not None: + # Distribution.files omits listed files that are missing on disk. + record_paths = parse_record(installed_record) + listed_signatures |= record_paths.keys() & signatures.keys() + for path in sorted(listed_signatures): + located = Path(distribution.locate_file(path)) + if not located.is_file(): + problems.append(f"{path}: missing on disk") + elif sha256_file(located) != signatures[path]: + problems.append(f"{path}: bytes differ from the wheel") + launchers = sorted( + path + for path in installed + if path not in expected + and _launcher_name(path) in archive["scripts"] + and _launcher_located(distribution, path) + ) + extra = sorted( + path + for path in installed + if path not in expected + and path not in signatures + and path not in launchers + and not _installer_file(path, archive["dist_info"]) + and _cache_entry(path) is None + ) + problems.extend(f"{path}: installed but not in the wheel" for path in extra) + sources = sorted( + path + for path, digest in expected.items() + if digest is not None and path.endswith(".py") and not path.startswith("..") + ) + bytecode = _verify_bytecode(distribution, sources, installed, cache_tag) + problems.extend(bytecode["problems"]) + return { + "wheel": wheel_path.name, + "wheel_sha256": sha256_file(wheel_path), + "archive_members_verified": archive["members_verified"], + "files_verified": verified, + "bytecode_verified": bytecode["verified"], + "inert_bytecode": bytecode["inert"], + "generated_launchers": launchers, + "unexpected_installed_files": extra, + "problems": problems, + } diff --git a/src/policyengine/tax_benefit_models/common/model_version.py b/src/policyengine/tax_benefit_models/common/model_version.py index 4d3cce7a..849f7478 100644 --- a/src/policyengine/tax_benefit_models/common/model_version.py +++ b/src/policyengine/tax_benefit_models/common/model_version.py @@ -128,6 +128,11 @@ def __init__(self, **kwargs: Any) -> None: kwargs["version"] = manifest.model_package.version installed_model_version = metadata.version(self.package_name) + # What this process loads: a receipt refuses a run whose installed + # versions change afterwards. + from policyengine.provenance.loaded import record_loaded_versions + + record_loaded_versions((self.package_name, "policyengine-core", "policyengine")) if installed_model_version != manifest.model_package.version: warnings.warn( f"Installed {self.package_name} version " diff --git a/tests/fixtures/uk_replay/DIGESTS.json b/tests/fixtures/uk_replay/DIGESTS.json new file mode 100644 index 00000000..b7e61f62 --- /dev/null +++ b/tests/fixtures/uk_replay/DIGESTS.json @@ -0,0 +1,8 @@ +{ + "qualification_request_golden.json": { + "request_id": "2e4dd8350bfdc5a39780e002c14ab49c02cbf28b77b222af420e38c68bb945cf" + }, + "replay_receipt_golden.json": { + "receipt_sha256": "a070ac40cb4e6d7daef520cfc1be4ed4958cdd4fe275975dc6d55e856d4ff515" + } +} diff --git a/tests/fixtures/uk_replay/README.md b/tests/fixtures/uk_replay/README.md new file mode 100644 index 00000000..98c80f60 --- /dev/null +++ b/tests/fixtures/uk_replay/README.md @@ -0,0 +1,10 @@ +# UK replay interface golden fixtures + +Vendored unchanged from PolicyEngine/policyengine-scorecard at commit +`7cfa3f9` (`tests/fixtures/uk_replay/`), the W6 side of the pre-promotion +replay interface. The scorecard computes digests with `rfc8785==0.1.4`; +`tests/test_canonical_json.py` checks that +`policyengine.provenance.canonical` produces the same bytes and digests. + +Refresh by copying the files from a newer scorecard commit and updating the +commit above; never edit them by hand. diff --git a/tests/fixtures/uk_replay/qualification_request_golden.jcs b/tests/fixtures/uk_replay/qualification_request_golden.jcs new file mode 100644 index 00000000..0e008c93 --- /dev/null +++ b/tests/fixtures/uk_replay/qualification_request_golden.jcs @@ -0,0 +1 @@ +{"attempt":1,"candidate":{"data":{"build_id":"policyengine-uk-data-1.58.0","dataset":"enhanced_frs_2024_25","path":"enhanced_frs_2024_25.h5","release_manifest_sha256":"ddd63e1f7755ee358c4ede9050bfdde09f3a80f48a5c0d8ee9d1fb531e4a1a07","repo_id":"policyengine/policyengine-uk-data-private","repo_type":"model","revision":"1.58.0","sha256":"5ba399ea5ab9a6c7d4e9aaf6c623080beb34d9ad5f330f6244dbad72b8d960b6","size_bytes":128369110},"expected_release_bundle":{"bundle_id":"uk-6.2.6","certified_by":"policyengine.py bundle certification","certified_data_artifact_sha256":"5ba399ea5ab9a6c7d4e9aaf6c623080beb34d9ad5f330f6244dbad72b8d960b6","certified_data_build_id":"policyengine-uk-data-1.58.0","compatibility_basis":"legacy_compatible_model_package","country_id":"uk","data_build_fingerprint":"sha256:c094d7006fdd81df220090284212f16bbffb15624643bc442c4bb3e1b1e5dac8","data_build_model_git_sha":"4cbedbecca352f52dffe47e849553c2759a00711","data_build_model_version":"2.122.2","data_package":"policyengine-uk-data","data_version":"1.58.0","default_dataset":"enhanced_frs_2024_25","default_dataset_uri":"hf://policyengine/policyengine-uk-data-private/enhanced_frs_2024_25.h5@1.58.0","model_package":"policyengine-uk","model_version":"2.125.1","policyengine_version":"6.2.6"},"policyengine":{"packaged_bundle_manifest_sha256":"8633ad814c9c85e53c7c513f69c4c7b1500a03cd2f6ef133d6e2ea8346b2db54","source":{"git":{"commit":"481940eca44fc75e41fbf9de4ff454e0a0d7c0e0","repository":"PolicyEngine/policyengine.py"}},"version":"6.2.6"},"policyengine_core":{"version":"3.33.0","wheel_sha256":"2e32d02a8910ab683a2b7600987904b351d058fc2bd5910a8ea6641438bcd789"},"policyengine_uk":{"version":"2.125.1","wheel_sha256":"a0983b4cef868467578a4fd0d163a977a0da8f710dd5194ec01c75f66d512c5a"}},"compare_to":"latest_certified","kind":"candidate","requested_by":{"commit":"481940eca44fc75e41fbf9de4ff454e0a0d7c0e0","jcs_probe":[1e-7,15000000000000000,0.30000000000000004,0,1e+21,5e-324,123456789012345680000,9007199254740991],"note":"Golden fixture: JCS probes for non-ASCII (Budget £, café, 予算) and doubles.","repository":"PolicyEngine/policyengine.py","run_attempt":1,"workflow_run_id":1},"schema":"policyengine.uk-replay-qualification-request/v1"} \ No newline at end of file diff --git a/tests/fixtures/uk_replay/qualification_request_golden.json b/tests/fixtures/uk_replay/qualification_request_golden.json new file mode 100644 index 00000000..1f6d69c0 --- /dev/null +++ b/tests/fixtures/uk_replay/qualification_request_golden.json @@ -0,0 +1,72 @@ +{ + "schema": "policyengine.uk-replay-qualification-request/v1", + "kind": "candidate", + "attempt": 1, + "candidate": { + "policyengine": { + "version": "6.2.6", + "source": { + "git": { + "repository": "PolicyEngine/policyengine.py", + "commit": "481940eca44fc75e41fbf9de4ff454e0a0d7c0e0" + } + }, + "packaged_bundle_manifest_sha256": "8633ad814c9c85e53c7c513f69c4c7b1500a03cd2f6ef133d6e2ea8346b2db54" + }, + "policyengine_uk": { + "version": "2.125.1", + "wheel_sha256": "a0983b4cef868467578a4fd0d163a977a0da8f710dd5194ec01c75f66d512c5a" + }, + "policyengine_core": { + "version": "3.33.0", + "wheel_sha256": "2e32d02a8910ab683a2b7600987904b351d058fc2bd5910a8ea6641438bcd789" + }, + "data": { + "build_id": "policyengine-uk-data-1.58.0", + "dataset": "enhanced_frs_2024_25", + "repo_id": "policyengine/policyengine-uk-data-private", + "repo_type": "model", + "revision": "1.58.0", + "path": "enhanced_frs_2024_25.h5", + "sha256": "5ba399ea5ab9a6c7d4e9aaf6c623080beb34d9ad5f330f6244dbad72b8d960b6", + "size_bytes": 128369110, + "release_manifest_sha256": "ddd63e1f7755ee358c4ede9050bfdde09f3a80f48a5c0d8ee9d1fb531e4a1a07" + }, + "expected_release_bundle": { + "bundle_id": "uk-6.2.6", + "country_id": "uk", + "policyengine_version": "6.2.6", + "model_package": "policyengine-uk", + "model_version": "2.125.1", + "data_package": "policyengine-uk-data", + "data_version": "1.58.0", + "default_dataset": "enhanced_frs_2024_25", + "default_dataset_uri": "hf://policyengine/policyengine-uk-data-private/enhanced_frs_2024_25.h5@1.58.0", + "certified_data_build_id": "policyengine-uk-data-1.58.0", + "certified_data_artifact_sha256": "5ba399ea5ab9a6c7d4e9aaf6c623080beb34d9ad5f330f6244dbad72b8d960b6", + "data_build_model_version": "2.122.2", + "data_build_model_git_sha": "4cbedbecca352f52dffe47e849553c2759a00711", + "data_build_fingerprint": "sha256:c094d7006fdd81df220090284212f16bbffb15624643bc442c4bb3e1b1e5dac8", + "compatibility_basis": "legacy_compatible_model_package", + "certified_by": "policyengine.py bundle certification" + } + }, + "compare_to": "latest_certified", + "requested_by": { + "repository": "PolicyEngine/policyengine.py", + "workflow_run_id": 1, + "run_attempt": 1, + "commit": "481940eca44fc75e41fbf9de4ff454e0a0d7c0e0", + "note": "Golden fixture: JCS probes for non-ASCII (Budget £, café, 予算) and doubles.", + "jcs_probe": [ + 1e-07, + 1.5e+16, + 0.30000000000000004, + -0.0, + 1e+21, + 5e-324, + 1.2345678901234568e+20, + 9007199254740991 + ] + } +} diff --git a/tests/fixtures/uk_replay/replay_receipt_golden.jcs b/tests/fixtures/uk_replay/replay_receipt_golden.jcs new file mode 100644 index 00000000..871f1d32 --- /dev/null +++ b/tests/fixtures/uk_replay/replay_receipt_golden.jcs @@ -0,0 +1 @@ +{"corpus":{"corpus_version":"uk-fiscal-event-replay/1","events":{"spring_budget_2023":{"artifacts":8,"comparison_provenance_sha256":"8888888888888888888888888888888888888888888888888888888888888888","full_supported_grid_complete":true,"inert_measure_years":[],"registry_sha256":"5555555555555555555555555555555555555555555555555555555555555555","run_manifest_sha256":"6666666666666666666666666666666666666666666666666666666666666666","staging_manifest_sha256":"7777777777777777777777777777777777777777777777777777777777777777","supported_years":[2024,2025,2026,2027],"unsupported_years":[{"reason":"before_data_year","year":2023}]}}},"diff":{"base_variant_id":"pe5.0.2_uk2.89.2_populace-uk-2023-dd68c73-4aa4b14-20260619T023711Z","diff_sha256":"9999999999999999999999999999999999999999999999999999999999999999","flag_counts":{"construction":0,"coverage":0,"inertness":0,"mapping":0,"numerical":2,"structural":1,"support":1},"flagged_rows":[{"base_gbp":-15000000000000000,"event":"spring_budget_2023","flags":["numerical"],"head_gbp":0.30000000000000004,"key":"universal_credit","level":"head","measure_key":"spring_budget_2023__uc_childcare_cap_increase","year":2024},{"base_gbp":1e-7,"event":"spring_budget_2023","flags":["numerical","structural"],"head_gbp":-123456789.125,"key":"total","level":"measure_year","measure_key":"spring_budget_2023__uc_childcare_cap_increase","year":2025},{"base_gbp":-2500000,"event":"spring_budget_2023","flags":["support"],"head_gbp":null,"key":"obr-pmd-row-123","level":"source_row","measure_key":"spring_budget_2023__uc_childcare_cap_increase","year":2023}]},"execution":{"dataset_sha256":"5ba399ea5ab9a6c7d4e9aaf6c623080beb34d9ad5f330f6244dbad72b8d960b6","packaged_bundle_manifest_sha256":"8633ad814c9c85e53c7c513f69c4c7b1500a03cd2f6ef133d6e2ea8346b2db54","packages":{"policyengine":{"record_sha256":"1111111111111111111111111111111111111111111111111111111111111111","version":"6.2.6"},"policyengine-core":{"record_sha256":"3333333333333333333333333333333333333333333333333333333333333333","version":"3.33.0"},"policyengine-uk":{"record_sha256":"2222222222222222222222222222222222222222222222222222222222222222","version":"2.125.1"}},"platform":"Golden fixture (synthetic values; café £)","python":"3.12.14","release_bundle":{"bundle_id":"uk-6.2.6","certified_by":"policyengine.py bundle certification","certified_data_artifact_sha256":"5ba399ea5ab9a6c7d4e9aaf6c623080beb34d9ad5f330f6244dbad72b8d960b6","certified_data_build_id":"policyengine-uk-data-1.58.0","compatibility_basis":"legacy_compatible_model_package","country_id":"uk","data_build_fingerprint":"sha256:c094d7006fdd81df220090284212f16bbffb15624643bc442c4bb3e1b1e5dac8","data_build_model_git_sha":"4cbedbecca352f52dffe47e849553c2759a00711","data_build_model_version":"2.122.2","data_package":"policyengine-uk-data","data_version":"1.58.0","default_dataset":"enhanced_frs_2024_25","default_dataset_uri":"hf://policyengine/policyengine-uk-data-private/enhanced_frs_2024_25.h5@1.58.0","model_package":"policyengine-uk","model_version":"2.125.1","policyengine_version":"6.2.6"},"requirements_sha256":"4444444444444444444444444444444444444444444444444444444444444444","runner":"github-actions ubuntu-latest"},"integrity":{"failures":[],"status":"complete"},"rejection":null,"request_id":"2e4dd8350bfdc5a39780e002c14ab49c02cbf28b77b222af420e38c68bb945cf","results":{"commit":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","path":"requests/2e4dd8350bfdc5a39780e002c14ab49c02cbf28b77b222af420e38c68bb945cf/results/","ref":"replay-state","repository":"PolicyEngine/policyengine-scorecard"},"schema":"policyengine.uk-replay-receipt/v1","status":"complete","variant_id":"pe6.2.6_uk2.125.1_policyengine-uk-data-1.58.0","variant_identity_sha256":"0000000000000000000000000000000000000000000000000000000000000000"} \ No newline at end of file diff --git a/tests/fixtures/uk_replay/replay_receipt_golden.json b/tests/fixtures/uk_replay/replay_receipt_golden.json new file mode 100644 index 00000000..433f095c --- /dev/null +++ b/tests/fixtures/uk_replay/replay_receipt_golden.json @@ -0,0 +1,137 @@ +{ + "schema": "policyengine.uk-replay-receipt/v1", + "request_id": "2e4dd8350bfdc5a39780e002c14ab49c02cbf28b77b222af420e38c68bb945cf", + "status": "complete", + "rejection": null, + "variant_id": "pe6.2.6_uk2.125.1_policyengine-uk-data-1.58.0", + "variant_identity_sha256": "0000000000000000000000000000000000000000000000000000000000000000", + "execution": { + "packages": { + "policyengine": { + "version": "6.2.6", + "record_sha256": "1111111111111111111111111111111111111111111111111111111111111111" + }, + "policyengine-uk": { + "version": "2.125.1", + "record_sha256": "2222222222222222222222222222222222222222222222222222222222222222" + }, + "policyengine-core": { + "version": "3.33.0", + "record_sha256": "3333333333333333333333333333333333333333333333333333333333333333" + } + }, + "packaged_bundle_manifest_sha256": "8633ad814c9c85e53c7c513f69c4c7b1500a03cd2f6ef133d6e2ea8346b2db54", + "release_bundle": { + "bundle_id": "uk-6.2.6", + "country_id": "uk", + "policyengine_version": "6.2.6", + "model_package": "policyengine-uk", + "model_version": "2.125.1", + "data_package": "policyengine-uk-data", + "data_version": "1.58.0", + "default_dataset": "enhanced_frs_2024_25", + "default_dataset_uri": "hf://policyengine/policyengine-uk-data-private/enhanced_frs_2024_25.h5@1.58.0", + "certified_data_build_id": "policyengine-uk-data-1.58.0", + "certified_data_artifact_sha256": "5ba399ea5ab9a6c7d4e9aaf6c623080beb34d9ad5f330f6244dbad72b8d960b6", + "data_build_model_version": "2.122.2", + "data_build_model_git_sha": "4cbedbecca352f52dffe47e849553c2759a00711", + "data_build_fingerprint": "sha256:c094d7006fdd81df220090284212f16bbffb15624643bc442c4bb3e1b1e5dac8", + "compatibility_basis": "legacy_compatible_model_package", + "certified_by": "policyengine.py bundle certification" + }, + "dataset_sha256": "5ba399ea5ab9a6c7d4e9aaf6c623080beb34d9ad5f330f6244dbad72b8d960b6", + "requirements_sha256": "4444444444444444444444444444444444444444444444444444444444444444", + "python": "3.12.14", + "platform": "Golden fixture (synthetic values; café £)", + "runner": "github-actions ubuntu-latest" + }, + "corpus": { + "corpus_version": "uk-fiscal-event-replay/1", + "events": { + "spring_budget_2023": { + "registry_sha256": "5555555555555555555555555555555555555555555555555555555555555555", + "supported_years": [ + 2024, + 2025, + 2026, + 2027 + ], + "unsupported_years": [ + { + "year": 2023, + "reason": "before_data_year" + } + ], + "run_manifest_sha256": "6666666666666666666666666666666666666666666666666666666666666666", + "staging_manifest_sha256": "7777777777777777777777777777777777777777777777777777777777777777", + "comparison_provenance_sha256": "8888888888888888888888888888888888888888888888888888888888888888", + "artifacts": 8, + "full_supported_grid_complete": true, + "inert_measure_years": [] + } + } + }, + "integrity": { + "status": "complete", + "failures": [] + }, + "diff": { + "base_variant_id": "pe5.0.2_uk2.89.2_populace-uk-2023-dd68c73-4aa4b14-20260619T023711Z", + "diff_sha256": "9999999999999999999999999999999999999999999999999999999999999999", + "flag_counts": { + "numerical": 2, + "structural": 1, + "coverage": 0, + "mapping": 0, + "support": 1, + "inertness": 0, + "construction": 0 + }, + "flagged_rows": [ + { + "event": "spring_budget_2023", + "measure_key": "spring_budget_2023__uc_childcare_cap_increase", + "year": 2024, + "level": "head", + "key": "universal_credit", + "flags": [ + "numerical" + ], + "base_gbp": -1.5e+16, + "head_gbp": 0.30000000000000004 + }, + { + "event": "spring_budget_2023", + "measure_key": "spring_budget_2023__uc_childcare_cap_increase", + "year": 2025, + "level": "measure_year", + "key": "total", + "flags": [ + "numerical", + "structural" + ], + "base_gbp": 1e-07, + "head_gbp": -123456789.125 + }, + { + "event": "spring_budget_2023", + "measure_key": "spring_budget_2023__uc_childcare_cap_increase", + "year": 2023, + "level": "source_row", + "key": "obr-pmd-row-123", + "flags": [ + "support" + ], + "base_gbp": -2500000.0, + "head_gbp": null + } + ] + }, + "results": { + "repository": "PolicyEngine/policyengine-scorecard", + "ref": "replay-state", + "commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "path": "requests/2e4dd8350bfdc5a39780e002c14ab49c02cbf28b77b222af420e38c68bb945cf/results/" + }, + "receipt_sha256": "a070ac40cb4e6d7daef520cfc1be4ed4958cdd4fe275975dc6d55e856d4ff515" +} diff --git a/tests/receipt_modifiers.py b/tests/receipt_modifiers.py new file mode 100644 index 00000000..4f1298b5 --- /dev/null +++ b/tests/receipt_modifiers.py @@ -0,0 +1,25 @@ +"""Module-level simulation modifiers for receipt binding tests. + +The module holds only functions, so its modifiers can be bound; shapes that +cannot be bound are in ``receipt_unbindable_modifiers``. +""" + + +def raise_basic_rate(simulation): + simulation.tax_benefit_system.parameters.gov.hmrc.income_tax.rates.uk[ + 0 + ].rate.update(period="year:2027:10", value=0.21) + + +def raise_higher_rate(simulation): + simulation.tax_benefit_system.parameters.gov.hmrc.income_tax.rates.uk[ + 1 + ].rate.update(period="year:2027:10", value=0.41) + + +def raise_basic_rate_to(simulation, rate=0.21): + simulation.rate = rate + + +def raise_basic_rate_from(simulation, *, start=2027): + simulation.start = start diff --git a/tests/receipt_unbindable_modifiers.py b/tests/receipt_unbindable_modifiers.py new file mode 100644 index 00000000..98629db9 --- /dev/null +++ b/tests/receipt_unbindable_modifiers.py @@ -0,0 +1,16 @@ +"""Modifier shapes a receipt refuses: a method and a closure.""" + + +class Measure: + def __init__(self, rate): + self.rate = rate + + def apply(self, simulation): + simulation.rate = self.rate + + +def make_modifier(rate): + def modifier(simulation): + simulation.rate = rate + + return modifier diff --git a/tests/test_canonical_json.py b/tests/test_canonical_json.py new file mode 100644 index 00000000..4c0cf4d5 --- /dev/null +++ b/tests/test_canonical_json.py @@ -0,0 +1,231 @@ +"""RFC 8785 canonical JSON used for receipt digests. + +``policyengine.provenance.canonical`` is checked against the independent +``rfc8785`` implementation (a differential test), against the RFC's own +examples, and for the properties every canonical form needs. +""" + +import json +import math +from pathlib import Path + +import pytest +import rfc8785 +from hypothesis import given, settings +from hypothesis import strategies as st + +from policyengine.provenance.canonical import ( + MAX_SAFE_INTEGER, + CanonicalJSONError, + canonical_json, + canonical_sha256, + drop_null_members, +) + +# Strings without unpaired surrogates: those have no UTF-8 form. +TEXT = st.text(alphabet=st.characters(blacklist_categories=("Cs",)), max_size=12) +FINITE = st.floats(allow_nan=False, allow_infinity=False) +SAFE_INT = st.integers(min_value=-MAX_SAFE_INTEGER, max_value=MAX_SAFE_INTEGER) +JSON_VALUES = st.recursive( + st.none() | st.booleans() | SAFE_INT | FINITE | TEXT, + lambda children: ( + st.lists(children, max_size=4) | st.dictionaries(TEXT, children, max_size=4) + ), + max_leaves=20, +) + + +@settings(max_examples=500, deadline=None) +@given(JSON_VALUES) +def test_matches_the_rfc8785_reference_implementation(value): + assert canonical_json(value) == rfc8785.dumps(value) + + +@settings(max_examples=2000, deadline=None) +@given(FINITE) +def test_numbers_match_the_reference_implementation(number): + assert canonical_json(number) == rfc8785.dumps(number) + + +@pytest.mark.parametrize( + ("number", "expected"), + [ + # RFC 8785 section 3.2.2.3 and appendix B examples. + (333333333.33333329, b"333333333.3333333"), + (1e30, b"1e+30"), + (4.50, b"4.5"), + (2e-3, b"0.002"), + (0.000000000000000000000000001, b"1e-27"), + (-0.0, b"0"), + (1e21, b"1e+21"), + (1e20, b"100000000000000000000"), + (1e-7, b"1e-7"), + (1.5e16, b"15000000000000000"), + (5e-324, b"5e-324"), + (1.7976931348623157e308, b"1.7976931348623157e+308"), + ], +) +def test_rfc8785_number_examples(number, expected): + assert canonical_json(number) == expected + + +def test_rfc8785_member_ordering_example(): + value = { + "€": "Euro Sign", + "\r": "Carriage Return", + "דּ": "Hebrew Letter Dalet With Dagesh", + "1": "One", + "\U0001f600": "Emoji: Grinning Face", + "\u0080": "Control", + "ö": "Latin Small Letter O With Diaeresis", + } + keys = list(json.loads(canonical_json(value))) + assert keys == ["\r", "1", "\u0080", "ö", "€", "\U0001f600", "דּ"] + + +@settings(max_examples=300, deadline=None) +@given(JSON_VALUES) +def test_canonical_form_is_a_fixed_point(value): + once = canonical_json(value) + assert canonical_json(_read_as_jcs_consumer(once)) == once + + +@settings(max_examples=300, deadline=None) +@given(st.dictionaries(TEXT, JSON_VALUES, max_size=6), st.randoms()) +def test_member_order_does_not_change_bytes(mapping, random): + items = list(mapping.items()) + random.shuffle(items) + assert canonical_json(dict(items)) == canonical_json(mapping) + + +@settings(max_examples=300, deadline=None) +@given(FINITE) +def test_numbers_round_trip_exactly(number): + # RFC 8785 numbers are doubles; read them back as doubles. + parsed = json.loads(canonical_json(number), parse_int=float) + assert parsed == number + + +@pytest.mark.parametrize( + "value", + [ + math.nan, + math.inf, + -math.inf, + 2**53, + -(2**53), + 10**400, + "\ud800", + {1: "non-string key"}, + b"bytes", + object(), + ], +) +def test_refuses_values_without_one_canonical_form(value): + with pytest.raises(CanonicalJSONError): + canonical_json(value) + + +def test_large_integer_valued_doubles_are_written_as_doubles(): + assert canonical_json(float(2**53)) == b"9007199254740992" + assert canonical_json(1e22) == b"1e+22" + + +def _read_as_jcs_consumer(text: bytes): + """Parse as a double-based consumer would: unsafe integers are doubles.""" + + def parse_int(literal: str): + number = int(literal) + return number if abs(number) <= MAX_SAFE_INTEGER else float(literal) + + return json.loads(text, parse_int=parse_int) + + +def test_digest_is_sha256_of_canonical_bytes(): + import hashlib + + value = {"b": [1, 2.5, None], "a": "é"} + assert canonical_sha256(value) == hashlib.sha256(canonical_json(value)).hexdigest() + + +@settings(max_examples=300, deadline=None) +@given(JSON_VALUES) +def test_dropping_null_members_leaves_no_null_member(value): + def has_null_member(item): + if isinstance(item, dict): + return any(v is None or has_null_member(v) for v in item.values()) + if isinstance(item, list): + return any(has_null_member(v) for v in item) + return False + + stripped = drop_null_members(value) + assert not has_null_member(stripped) + assert drop_null_members(stripped) == stripped + + +FIXTURES = Path(__file__).parent / "fixtures" / "uk_replay" + + +@pytest.mark.parametrize( + ("name", "digest_key", "drop"), + [ + ("qualification_request_golden", "request_id", None), + ("replay_receipt_golden", "receipt_sha256", "receipt_sha256"), + ], +) +def test_reproduces_the_scorecard_golden_bytes(name, digest_key, drop): + """Cross-implementation check against the replay corpus owner's fixtures.""" + + import hashlib + + value = json.loads((FIXTURES / f"{name}.json").read_text(encoding="utf-8")) + if drop: + value.pop(drop) + expected = (FIXTURES / f"{name}.jcs").read_bytes() + assert canonical_json(value) == expected + digests = json.loads((FIXTURES / "DIGESTS.json").read_text()) + assert canonical_sha256(value) == digests[f"{name}.json"][digest_key] + assert hashlib.sha256(expected).hexdigest() == digests[f"{name}.json"][digest_key] + + +def test_line_separators_are_written_literally(): + # RFC 8785 escapes only the JSON-mandatory characters; ECMAScript writes + # U+2028 and U+2029 literally. + assert canonical_json("

") == '"

"'.encode() + assert canonical_json("\x7f") == b'"\x7f"' + + +def test_dropping_nulls_keeps_array_elements(): + assert drop_null_members([None, {"a": None, "b": [None]}]) == [None, {"b": [None]}] + + +def test_numeric_subclasses_are_written_as_their_values(): + import enum + + import numpy as np + + class Rate(enum.IntEnum): + BASIC = 20 + + class Loud(int): + def __str__(self): + return "twenty" + + __repr__ = __str__ + + value = { + "a": np.float64(6.1e9), + "b": np.float64(1e22), + "c": Rate.BASIC, + "d": Loud(20), + } + plain = {"a": 6.1e9, "b": 1e22, "c": 20, "d": 20} + assert canonical_json(value) == canonical_json(plain) == rfc8785.dumps(plain) + + +def test_deep_nesting_raises_a_canonical_error(): + value: list = [] + for _ in range(5000): + value = [value] + with pytest.raises(CanonicalJSONError): + canonical_json(value) diff --git a/tests/test_execution_receipt.py b/tests/test_execution_receipt.py new file mode 100644 index 00000000..e9a96ba5 --- /dev/null +++ b/tests/test_execution_receipt.py @@ -0,0 +1,5693 @@ +"""Execution receipt schema v1 (policyengine.provenance.receipt). + +Invariants tested here: + +- a receipt claims certification only when the certificate covers the + installed model and core versions, the simulated dataset bytes and name, + and the running wrapper version; +- verify_certificate accepts exactly the receipts the bundle certifies: a + receipt built from a bundle verifies against it, and changing any certified + member of the certificate or data, however consistently, is reported + (differential with the builder); +- a wheel hash is recorded only when the builder verified it; +- the execution identity depends only on runtime, data and certificate + content, never on how the builder was called; the receipt digest adds + reform, baseline, years and outputs, never runtime context or labels; +- digests are over strict JSON: nothing is silently stringified, member + order and null-versus-omitted do not matter, and colliding keys are refused; +- a reform digest binds content, or a module-level modifier's source, + module source and defaults plus a revision, independent of where the code + is checked out; code edited after import, closures, lambdas, partials, + methods and non-Scenario objects are refused; +- the Pydantic model and the published JSON Schema accept the same valid + receipts and reject the same malformed ones (differential test). +""" + +from __future__ import annotations + +import copy +import datetime +import decimal +import enum +import functools +import hashlib +import importlib.util +import inspect +import json +import linecache +import os +import re +import subprocess +import sys +from importlib import metadata +from pathlib import Path +from types import CodeType, SimpleNamespace +from typing import Union, get_args +from urllib.parse import urlparse + +import jsonschema +import numpy as np +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st +from pydantic import ValidationError + +from policyengine.provenance import receipt as receipt_module +from policyengine.provenance.canonical import canonical_sha256 +from policyengine.provenance.receipt import ( + ExecutionReceipt, + FiscalYearConvention, + ReceiptError, + ReformIdentity, + build_execution_receipt, + build_execution_receipt_for_microsimulation, + certificate_sha256, + content_sha256, + fiscal_year_label, + installed_package_identity, + reform_identity, + scenario_reform_identity, + strict_json_value, + uk_fiscal_year_convention, + verify_certificate, +) +from tests import receipt_modifiers, receipt_unbindable_modifiers + +REPO_ROOT = Path(__file__).resolve().parents[1] +BUNDLE_DIR = REPO_ROOT / "src" / "policyengine" / "data" / "bundle" +BUNDLE_PATH = BUNDLE_DIR / "manifest.json" +SCHEMA_PATH = ( + REPO_ROOT + / "src" + / "policyengine" + / "data" + / "schemas" + / "execution_receipt.v1.schema.json" +) +SHA = st.text(alphabet="0123456789abcdef", min_size=64, max_size=64) +INSTALLED_UK = metadata.version("policyengine-uk") +INSTALLED_CORE = metadata.version("policyengine-core") +INSTALLED_WRAPPER = metadata.version("policyengine") +CURRENT_LAW = ReformIdentity(kind="current_law") +REVISION = { + "repository": "PolicyEngine/uk-autumn-budget-dashboard-2026", + "git_sha": "a" * 40, +} + + +#: What the certified 1.58.0 dataset projects to under policyengine-uk 2.125.1 +#: and 2.126.12 (measured with extend_single_year_dataset, 12 Oct 2026). +PROJECTED_YEARS = tuple(range(2024, 2031)) + + +@pytest.fixture(autouse=True) +def _projection(monkeypatch, request): + """Builder tests use a fixed projection; the real one needs the real file.""" + + if request.node.originalname == "test_the_certified_dataset_projects_to_2030": + return + monkeypatch.setattr( + receipt_module, + "_projected_years", + lambda path, sha256, model_version: PROJECTED_YEARS, + ) + + +def _bundle_for( + dataset_bytes: bytes, *, model_version=INSTALLED_UK, core_version=INSTALLED_CORE +) -> dict: + """The committed bundle with its UK certificate pointed at these bytes.""" + + bundle = json.loads(BUNDLE_PATH.read_text()) + record = bundle["data_releases"]["uk"] + digest = hashlib.sha256(dataset_bytes).hexdigest() + record["certified_data_artifact"]["sha256"] = digest + record["datasets"][record["default_dataset"]]["sha256"] = digest + record["certification"]["certified_for_model_version"] = model_version + bundle["packages"]["policyengine-core"]["version"] = core_version + bundle["policyengine_version"] = INSTALLED_WRAPPER + record["policyengine_version"] = INSTALLED_WRAPPER + return bundle + + +def _build(dataset_file, bundle=None, **kwargs): + kwargs.setdefault("years", [2026]) + kwargs.setdefault("reform", CURRENT_LAW) + kwargs.setdefault("baseline", CURRENT_LAW) + return build_execution_receipt( + country_id="uk", + dataset_path=dataset_file, + bundle=bundle if bundle is not None else _bundle_for(dataset_file.read_bytes()), + **kwargs, + ) + + +#: The real policyengine, policyengine_uk and policyengine_core modules this +#: test process loaded, so a test can tell them from synthetic stand-ins. +_REAL_PACKAGE_MODULES = { + name.replace("-", "_"): sys.modules.get(name.replace("-", "_")) + for name in receipt_module.UK_PACKAGES.values() +} + + +#: The interpreter's import path when this module is collected. Release-script +#: tests import scripts/bundle.py at module level, which puts the checkout's +#: ``src`` first on sys.path. An editable build leaves +#: ``src/policyengine.egg-info`` there, which then shadows the installed +#: distribution's metadata (receipts rightly refuse that), so each receipt test +#: runs with the import path as it was before those modules were collected. +_COLLECTION_SYS_PATH = list(sys.path) + + +@pytest.fixture(autouse=True) +def _import_path_as_collected(monkeypatch): + monkeypatch.setattr(sys, "path", list(_COLLECTION_SYS_PATH)) + + +#: The distribution lookup the receipt module's origin check uses, before any +#: test replaces it. +_REAL_DISTRIBUTION = receipt_module.metadata.distribution + + +def _this_checkout_is_the_installed_policyengine() -> bool: + """Whether the installed policyengine is an editable install of this checkout.""" + + try: + direct_url = receipt_module._read_direct_url("policyengine") + except ReceiptError: + return False + if not direct_url or not (direct_url.get("dir_info") or {}).get("editable"): + return False + loaded = Path(sys.modules["policyengine"].__file__).resolve() + source = Path(urlparse(direct_url["url"]).path).resolve() + return source in loaded.parents + + +@pytest.fixture(autouse=True) +def _real_packages_load_from_their_install(request, monkeypatch): + """Treat the real packages as loaded from the distributions they name. + + How this repository is installed differs between environments: CI + installs it editable and then installs a released policyengine over it, + so the tests run this checkout's code under another version's metadata, + which the receipt rightly refuses. Receipt tests are about receipts, not + about the test environment, so the real modules are taken as loaded from + their install, but only while the distribution lookup the check reads + (``metadata.distribution``) is the real one too. + A synthetic module a test puts in ``sys.modules``, or a synthetic + distribution it supplies for a real module, is still checked, and the + real check runs unchanged in tests marked ``real_install``. + """ + + if request.node.get_closest_marker("real_install"): + return + original = receipt_module._refuse_loaded_package_origin + + def refuse(name): + import_name = receipt_module.normalize_distribution_name(name).replace("-", "_") + module = sys.modules.get(import_name) + if ( + module is not None + and module is _REAL_PACKAGE_MODULES.get(import_name) + and getattr(receipt_module.metadata, "distribution", None) + is _REAL_DISTRIBUTION + ): + return None + return original(name) + + monkeypatch.setattr(receipt_module, "_refuse_loaded_package_origin", refuse) + + +@pytest.fixture +def index_installs(monkeypatch): + """Simulate index certification; loaded paths still use real install metadata.""" + + monkeypatch.setattr(receipt_module, "_read_direct_url", lambda name: None) + + +@pytest.fixture +def dataset_file(tmp_path, index_installs): + path = tmp_path / "enhanced_frs_2024_25.h5" + path.write_bytes(b"certified dataset bytes") + return path + + +@pytest.fixture +def certified_receipt(dataset_file): + return _build(dataset_file, years=[2027, 2026, 2027]) + + +# --- Certification ----------------------------------------------------------- + + +def test_certified_when_certificate_covers_what_ran(certified_receipt, dataset_file): + receipt = certified_receipt + assert receipt.certification_status == "certified" + assert all( + package.install_origin is None + for package in (receipt.wrapper, receipt.model, receipt.core) + ) + assert ( + receipt.model.version + == receipt.certificate.certified_for_model_version + == INSTALLED_UK + ) + assert ( + receipt.core.version + == receipt.certificate.certified_for_core_version + == INSTALLED_CORE + ) + assert ( + receipt.data.dataset_sha256 + == hashlib.sha256(dataset_file.read_bytes()).hexdigest() + ) + assert receipt.years == [2026, 2027] + assert receipt.certificate.certificate_source == "caller_supplied" + record = _bundle_for(dataset_file.read_bytes())["data_releases"]["uk"] + assert receipt.certificate.certificate_sha256 == certificate_sha256(record) + + +@pytest.mark.parametrize( + "change", + [ + "model version", + "core version", + "wrapper version in bundle", + "wrapper version in record", + "dataset name", + ], +) +def test_any_uncovered_component_makes_the_receipt_uncertified( + dataset_file, tmp_path, change +): + data = dataset_file.read_bytes() + bundle = _bundle_for(data) + path = dataset_file + kwargs = {} + if change == "model version": + bundle = _bundle_for(data, model_version="0.0.1") + elif change == "core version": + bundle = _bundle_for(data, core_version="0.0.1") + elif change == "wrapper version in bundle": + bundle["policyengine_version"] = "0.0.0" + elif change == "wrapper version in record": + bundle["data_releases"]["uk"]["policyengine_version"] = "0.0.0" + elif change == "dataset name": + # Same bytes registered under another certified dataset name. + record = bundle["data_releases"]["uk"] + record["datasets"]["frs_2024_25"]["sha256"] = record["certified_data_artifact"][ + "sha256" + ] + kwargs["dataset"] = "frs_2024_25" + receipt = _build(path, bundle, **kwargs) + assert receipt.certification_status == "uncertified" + assert receipt.certificate is None + assert receipt.model.version == INSTALLED_UK # installed, never the manifest's + + +@pytest.mark.parametrize( + "years", + [[2027.9], [True], "2027", [], [2027, "2028"], 2027, [np.float64(2027)]], + ids=["float", "bool", "string", "empty", "mixed", "scalar", "numpy-float"], +) +def test_receipt_years_must_be_integers(dataset_file, years): + """Round-4 review L8: years are never coerced.""" + + with pytest.raises(ReceiptError, match="years|at least one year"): + _build(dataset_file, years=years) + + +def test_numpy_integer_years_are_accepted(dataset_file): + assert _build(dataset_file, years=[np.int64(2027), 2026]).years == [2026, 2027] + + +def test_a_boolean_schema_version_is_refused(certified_receipt, schema_validator): + payload = json.loads(certified_receipt.model_dump_json()) + payload["schema_version"] = True + with pytest.raises(ValidationError): + ExecutionReceipt.model_validate(payload) + with pytest.raises(jsonschema.ValidationError): + schema_validator.validate(payload) + + +def test_unknown_dataset_and_incomplete_bundles_are_refused(dataset_file): + with pytest.raises(ReceiptError): + _build(dataset_file, dataset="not_a_bundle_dataset") + bundle = _bundle_for(dataset_file.read_bytes()) + del bundle["data_releases"]["uk"]["data_package"]["name"] + with pytest.raises(ReceiptError): + _build(dataset_file, bundle) + with pytest.raises(ReceiptError): + build_execution_receipt( + country_id="us", + dataset_path=dataset_file, + years=[2026], + reform=CURRENT_LAW, + baseline=CURRENT_LAW, + ) + + +def test_bytes_that_are_not_the_named_dataset_are_refused(dataset_file, tmp_path): + """A receipt never gives one artifact's name and URI to other bytes.""" + + other = tmp_path / "other.h5" + other.write_bytes(b"different bytes") + with pytest.raises(ReceiptError, match="is not the bundle's"): + _build(other, _bundle_for(dataset_file.read_bytes())) + + +def test_overlay_datasets_receive_uncertified_receipts(dataset_file): + bundle = _bundle_for(dataset_file.read_bytes()) + overlay_name = next(iter(bundle["dataset_overlays"]["uk"])) + bundle["dataset_overlays"]["uk"][overlay_name]["sha256"] = hashlib.sha256( + dataset_file.read_bytes() + ).hexdigest() + receipt = _build(dataset_file, bundle, dataset=overlay_name) + assert receipt.certification_status == "uncertified" + assert receipt.data.dataset == overlay_name + assert receipt.data.build_id is None + + +def test_an_omitted_artifact_revision_is_the_release_manifest_revision(dataset_file): + """Round-4 review M5: the receipt names what the materializer downloads.""" + + bundle = _bundle_for(dataset_file.read_bytes()) + record = bundle["data_releases"]["uk"] + del record["datasets"][record["default_dataset"]]["revision"] + receipt = _build(dataset_file, bundle) + pinned = record["data_package"]["release_manifest_revision"] + assert receipt.data.dataset_revision == pinned + assert receipt.data.dataset_uri.endswith(f"@{pinned}") + assert receipt.data.release_version == record["data_package"]["version"] + assert verify_certificate(receipt, bundle) == [] + + +def test_a_datasets_own_package_is_kept(dataset_file): + bundle = _bundle_for(dataset_file.read_bytes()) + record = bundle["data_releases"]["uk"] + record["datasets"][record["default_dataset"]]["data_package_name"] = "other-data" + assert _build(dataset_file, bundle).data.data_package == "other-data" + + +def test_every_packaged_dataset_resolves_as_the_materializer_resolves_it(): + """Differential: the receipt and ``materialize_dataset`` name one artifact.""" + + from policyengine.provenance.dataset_materialization import ( + _resolve_bundle_dataset, + ) + + bundle = json.loads(BUNDLE_PATH.read_text()) + record = bundle["data_releases"]["uk"] + names = [*record["datasets"], *(bundle.get("dataset_overlays") or {}).get("uk", {})] + assert names + for name in names: + spec = _resolve_bundle_dataset("uk", name) # the packaged manifest + identity = receipt_module._data_identity( + "uk", record, bundle, name, spec.sha256 + ) + assert (identity.dataset_uri, identity.dataset_revision) == ( + spec.source_uri, + spec.revision, + ) + assert identity.data_package == spec.data_package_name + + +@pytest.mark.usefixtures("index_installs") +def test_packaged_bundle_certificate_records_its_source_and_tro(tmp_path, monkeypatch): + """With the installed bundle, the certificate says packaged and names the TRO.""" + + from policyengine.provenance import certification + from policyengine.provenance.trace import extract_bundle_tro_reference + + dataset = tmp_path / "data.h5" + dataset.write_bytes(b"packaged certified bytes") + packaged = tmp_path / "bundle" + packaged.mkdir() + (packaged / "manifest.json").write_text( + json.dumps(_bundle_for(dataset.read_bytes())) + ) + (packaged / "uk.trace.tro.jsonld").write_bytes( + (BUNDLE_DIR / "uk.trace.tro.jsonld").read_bytes() + ) + monkeypatch.setattr( + certification, "default_bundle_source_path", lambda: packaged / "manifest.json" + ) + receipt = build_execution_receipt( + country_id="uk", + dataset_path=dataset, + years=[2026], + reform=CURRENT_LAW, + baseline=CURRENT_LAW, + ) + assert receipt.certification_status == "certified" + assert receipt.certificate.certificate_source == "packaged" + expected = extract_bundle_tro_reference( + json.loads((BUNDLE_DIR / "uk.trace.tro.jsonld").read_text()) + ) + assert receipt.certificate.tro_composition_fingerprint == expected["fingerprint"] + + +@pytest.mark.parametrize( + ("path", "value"), + [ + (("certificate", "certified_for_model_version"), "0.0.1"), + (("certificate", "certified_for_core_version"), "0.0.1"), + (("certificate", "certified_dataset_sha256"), "0" * 64), + (("certificate", "bundle_version"), "0.0.0"), + (("certification_status",), "uncertified"), + (("years",), [2027, 2026]), + (("years",), [2026, 2026]), + (("model", "name"), "policyengine-us"), + (("core", "name"), "numpy"), + (("wrapper", "name"), "not-policyengine"), + ], +) +def test_receipts_cannot_claim_what_did_not_run(certified_receipt, path, value): + payload = certified_receipt.model_dump(mode="json") + target = payload + for key in path[:-1]: + target = target[key] + target[path[-1]] = value + with pytest.raises(ValidationError): + ExecutionReceipt.model_validate(payload) + + +def test_strict_parsing_never_coerces(certified_receipt): + payload = certified_receipt.model_dump(mode="json") + for mutate in ( + lambda p: p.update(years=["2026"]), + lambda p: p.update(years=[True]), + lambda p: p["model"].update(name="Policyengine_UK"), + lambda p: p["fiscal_year"].update(period_start="01-01"), + ): + changed = copy.deepcopy(payload) + mutate(changed) + with pytest.raises(ValidationError): + ExecutionReceipt.model_validate(changed) + + +def test_verify_certificate_against_a_published_bundle(certified_receipt, dataset_file): + bundle = _bundle_for(dataset_file.read_bytes()) + assert verify_certificate(certified_receipt, bundle) == [] + tampered = copy.deepcopy(bundle) + tampered["data_releases"]["uk"]["certification"]["compatibility_basis"] = "trust me" + assert verify_certificate(certified_receipt, tampered) + uncertified = _build( + dataset_file, _bundle_for(dataset_file.read_bytes(), model_version="0.0.1") + ) + assert verify_certificate(uncertified, bundle) == ["the receipt is uncertified"] + assert verify_certificate(certified_receipt, {"data_releases": {}}) == [ + "the bundle has no uk data release" + ] + + +def _forge(receipt: ExecutionReceipt, **changes) -> ExecutionReceipt: + """A receipt edited consistently, so the model's own rules still accept it.""" + + payload = copy.deepcopy(receipt.model_dump(mode="json")) + for path, value in changes.items(): + target = payload + *parents, last = path.split("__") + for key in parents: + target = target[key] + target[last] = value + return ExecutionReceipt.model_validate(payload) + + +def test_a_forged_receipt_with_the_genuine_record_digest_does_not_verify( + certified_receipt, dataset_file +): + """Round-2 review N1: the forgery kept certificate_sha256 and changed the rest.""" + + bundle = _bundle_for(dataset_file.read_bytes()) + forged = _forge( + certified_receipt, + model__version="2.199.0", + certificate__certified_for_model_version="2.199.0", + data__dataset_sha256="e" * 64, + certificate__certified_dataset_sha256="e" * 64, + certificate__compatibility_basis="trust me", + certificate__certificate_source="packaged", + ) + assert forged.certification_status == "certified" + assert forged.certificate.certificate_sha256 == ( + certified_receipt.certificate.certificate_sha256 + ) + problems = verify_certificate(forged, bundle) + assert any("certified_for_model_version" in p for p in problems) + assert any("certified_dataset_sha256" in p for p in problems) + assert any("compatibility_basis" in p for p in problems) + assert any("data.dataset_sha256" in p for p in problems) + + +#: Consistent edits a forger could make, each of which the model accepts. +FORGERIES = { + "bundle version": { + "wrapper__version": "9.9.9", + "certificate__bundle_version": "9.9.9", + }, + "model version": { + "model__version": "9.9.9", + "certificate__certified_for_model_version": "9.9.9", + }, + "core version": { + "core__version": "9.9.9", + "certificate__certified_for_core_version": "9.9.9", + }, + "dataset bytes": { + "data__dataset_sha256": "e" * 64, + "certificate__certified_dataset_sha256": "e" * 64, + }, + "record digest": {"certificate__certificate_sha256": "e" * 64}, + "basis": {"certificate__compatibility_basis": "built_with_model_package"}, + "certifier": {"certificate__certified_by": "someone else"}, + "dataset name": {"data__dataset": "frs_2024_25"}, + "data release": {"data__release_version": "9.9.9"}, + "data package": {"data__data_package": "policyengine-uk-data-public"}, + "data build": {"data__build_id": "policyengine-uk-data-9.9.9"}, + "build fingerprint": {"data__build_fingerprint": "sha256:" + "e" * 64}, + "build model": {"data__build_model_version": "9.9.9"}, + "manifest revision": {"data__release_manifest_revision": "e" * 40}, + "dataset uri": {"data__dataset_uri": "hf://someone/else.h5@1.58.0"}, + "dataset revision": {"data__dataset_revision": "main"}, +} + + +@pytest.mark.parametrize("name", sorted(FORGERIES)) +def test_each_consistent_forgery_alone_is_reported(dataset_file, name): + bundle = _bundle_for(dataset_file.read_bytes()) + genuine = _build(dataset_file, bundle) + assert verify_certificate(_forge(genuine, **FORGERIES[name]), bundle) + + +def test_a_record_from_another_policyengine_version_is_named( + certified_receipt, dataset_file +): + bundle = _bundle_for(dataset_file.read_bytes()) + bundle["data_releases"]["uk"]["policyengine_version"] = "0.0.0" + problems = verify_certificate(certified_receipt, bundle) + assert any("record belongs to policyengine 0.0.0" in p for p in problems) + + +def test_a_certifying_bundle_must_state_its_basis(dataset_file): + bundle = _bundle_for(dataset_file.read_bytes()) + del bundle["data_releases"]["uk"]["certification"]["compatibility_basis"] + with pytest.raises(ReceiptError, match="compatibility_basis"): + _build(dataset_file, bundle) + + +@settings(max_examples=40, deadline=None) +@given( + names=st.lists( + st.sampled_from(sorted(FORGERIES)), min_size=1, max_size=4, unique=True + ) +) +@pytest.mark.usefixtures("index_installs") +def test_every_consistent_forgery_is_reported(tmp_path_factory, names): + dataset = tmp_path_factory.mktemp("forgery") / "data.h5" + dataset.write_bytes(b"forgery bytes") + bundle = _bundle_for(dataset.read_bytes()) + genuine = _build(dataset, bundle) + assert verify_certificate(genuine, bundle) == [] + changes = {path: value for name in names for path, value in FORGERIES[name].items()} + assert verify_certificate(_forge(genuine, **changes), bundle) + + +@pytest.mark.parametrize( + "edit", + [ + lambda b: b["data_releases"]["uk"]["certification"].update( + certified_by="someone else" + ), + lambda b: b["data_releases"]["uk"]["data_package"].update(version="9.9.9"), + lambda b: b["data_releases"]["uk"]["certified_data_artifact"].update( + dataset="frs_2024_25" + ), + lambda b: b["data_releases"]["uk"].update(policyengine_version="0.0.0"), + lambda b: b.update(policyengine_version="0.0.0"), + lambda b: b["packages"]["policyengine-core"].update(version="0.0.1"), + ], + ids=[ + "certifier", + "data version", + "certified dataset", + "record version", + "bundle version", + "core", + ], +) +def test_verification_and_building_agree_on_each_bundle(dataset_file, edit): + """Differential: a receipt verifies against a bundle iff that bundle built it.""" + + bundle = _bundle_for(dataset_file.read_bytes()) + other = copy.deepcopy(bundle) + edit(other) + receipt = _build(dataset_file, bundle) + assert verify_certificate(receipt, bundle) == [] + assert verify_certificate(receipt, other) + rebuilt = _build(dataset_file, other) + if rebuilt.certification_status == "certified": + assert verify_certificate(rebuilt, other) == [] + assert verify_certificate(rebuilt, bundle) + + +# --- Wheel hashes ---------------------------------------------------------------- + + +def test_wheel_hashes_need_the_builder_to_verify_them(dataset_file, monkeypatch): + seen = {} + + def verify(name, wheel_path, metadata): + seen[name] = wheel_path + return {"wheel_sha256": "a" * 64, "problems": []} + + monkeypatch.setattr( + "policyengine.provenance.wheels.verify_installed_against_wheel", verify + ) + receipt = _build(dataset_file, wheel_paths={"policyengine_uk": Path("uk.whl")}) + assert receipt.model.wheel_sha256 == "a" * 64 + assert receipt.model.wheel_sha256_evidence == "installed_record_verified" + assert seen == {"policyengine-uk": Path("uk.whl")} + monkeypatch.setattr( + "policyengine.provenance.wheels.verify_installed_against_wheel", + lambda name, wheel_path, metadata: { + "wheel_sha256": "a" * 64, + "problems": ["x.py: bytes differ"], + }, + ) + with pytest.raises(ReceiptError, match="differs"): + _build(dataset_file, wheel_paths={"policyengine-uk": Path("uk.whl")}) + + +@pytest.mark.parametrize("content", [b"not a zip", None]) +def test_unreadable_wheels_raise_receipt_errors(tmp_path, content): + """Round-3 review R4: no ValueError or BadZipFile escapes.""" + + import zipfile + + wheel = tmp_path / "policyengine_uk-0-py3-none-any.whl" + if content is not None: + wheel.write_bytes(content) + else: + with zipfile.ZipFile(wheel, "w") as archive: + archive.writestr("x.dist-info/RECORD", "a.py,,\na.py,,\n") + with pytest.raises(ReceiptError, match="cannot be verified"): + installed_package_identity("policyengine-uk", wheel_path=wheel) + + +def _direct_url_identity(monkeypatch, direct_url): + # These metadata-only fixtures do not load a country package. + monkeypatch.delitem(sys.modules, "policyengine_uk", raising=False) + + class Distribution: + version = INSTALLED_UK + + def read_text(self, name): + assert name == "direct_url.json" + return json.dumps(direct_url) + + fake = SimpleNamespace( + version=lambda name: INSTALLED_UK, distribution=lambda name: Distribution() + ) + monkeypatch.setattr(receipt_module, "metadata", fake) + identity = installed_package_identity("policyengine-uk") + return identity.wheel_sha256, identity.wheel_sha256_evidence + + +def test_installer_archive_hash_is_read_from_direct_url(monkeypatch): + archive = {"hashes": {"sha256": "b" * 64}} + assert _direct_url_identity( + monkeypatch, {"url": "file:///w-1-py3-none-any.whl", "archive_info": archive} + ) == ("b" * 64, "installer_archive_hash") + assert _direct_url_identity( + monkeypatch, + { + "url": "https://files.example/w-1-py3-none-any.whl#sha256=" + "b" * 64, + "archive_info": {"hash": "sha256=" + "b" * 64}, + }, + ) == ("b" * 64, "installer_archive_hash") + + +@pytest.mark.parametrize( + "direct_url", + [ + {"url": "file:///w-1.tar.gz", "archive_info": {"hashes": {"sha256": "b" * 64}}}, + {"url": "https://x/w-1.zip", "archive_info": {"hashes": {"sha256": "b" * 64}}}, + {"url": "file:///src/w", "dir_info": {"editable": True}}, + ], + ids=["sdist", "zip", "directory"], +) +def test_only_a_wheel_archive_hash_is_recorded(monkeypatch, direct_url): + """Round-4 review M6: a source archive's hash is not a wheel hash.""" + + assert _direct_url_identity(monkeypatch, direct_url) == (None, None) + + +def test_callers_cannot_supply_the_versions_a_receipt_records(dataset_file): + """Round-2 review N5: there is no public way to inject package metadata.""" + + for function in (build_execution_receipt, installed_package_identity): + assert "metadata_module" not in inspect.signature(function).parameters + with pytest.raises(TypeError): + _build(dataset_file, metadata_module=SimpleNamespace()) + + +def test_hash_without_evidence_is_refused(): + with pytest.raises(ValidationError): + receipt_module.PackageIdentity(name="x", version="1", wheel_sha256="c" * 64) + + +@pytest.mark.parametrize("package", list(receipt_module.UK_PACKAGES.values())) +@pytest.mark.parametrize("origin", ["directory", "editable", "vcs", "archive"]) +def test_install_origin_controls_certification( + dataset_file, monkeypatch, package, origin +): + direct_url = {"url": "file:///package"} + if origin in ("directory", "editable"): + direct_url["dir_info"] = {"editable": origin == "editable"} + elif origin == "vcs": + direct_url["vcs_info"] = {"vcs": "git", "commit_id": "a" * 40} + else: + direct_url["archive_info"] = {} + monkeypatch.setattr( + receipt_module, + "_read_direct_url", + lambda name: direct_url if name == package else None, + ) + receipt = _build(dataset_file) + identity = next( + item + for item in (receipt.wrapper, receipt.model, receipt.core) + if item.name == package + ) + assert identity.install_origin == origin + assert identity.wheel_sha256 is None + assert receipt.certification_status == ( + "certified" if origin == "archive" else "uncertified" + ) + assert (receipt.certificate is not None) == (origin == "archive") + + +@pytest.mark.real_install +@pytest.mark.skipif( + not _this_checkout_is_the_installed_policyengine(), + reason="policyengine here is not an editable install of this checkout " + "(CI installs a released policyengine over it)", +) +def test_real_editable_install_is_recorded_and_uncertified(tmp_path): + identity = installed_package_identity("policyengine") + assert identity.install_origin == "editable" + path = tmp_path / "data.h5" + path.write_bytes(b"dataset bytes") + receipt = _build(path) + assert receipt.wrapper.install_origin == "editable" + assert receipt.certification_status == "uncertified" + assert receipt.certificate is None + assert verify_certificate(receipt, _bundle_for(path.read_bytes())) == [ + "the receipt is uncertified" + ] + + +def _loaded_distribution(tmp_path, monkeypatch, package, *, direct_url=None): + import_name = package.replace("-", "_") + version = metadata.version(package) + root = tmp_path / "installed" + source = root / import_name / "__init__.py" + source.parent.mkdir(parents=True) + source.write_text("VALUE = 1\n") + directory = root / f"{import_name}-{version}.dist-info" + directory.mkdir() + (directory / "METADATA").write_text(f"Name: {package}\nVersion: {version}\n") + (directory / "RECORD").write_text(f"{import_name}/__init__.py,,\n") + if direct_url is not None: + (directory / "direct_url.json").write_text(json.dumps(direct_url)) + distribution = metadata.Distribution.at(directory) + original = metadata.distribution + monkeypatch.setattr( + receipt_module.metadata, + "distribution", + lambda name: distribution if name == package else original(name), + ) + module = SimpleNamespace(__file__=str(source)) + monkeypatch.setitem(sys.modules, import_name, module) + return distribution, module, source, directory + + +@pytest.mark.parametrize("package", list(receipt_module.UK_PACKAGES.values())) +@pytest.mark.parametrize("archive", [False, True]) +def test_loaded_index_and_archive_packages_match_record( + tmp_path, monkeypatch, package, archive +): + direct_url = {"url": "file:///package.whl", "archive_info": {}} if archive else None + _, _, source, _ = _loaded_distribution( + tmp_path, monkeypatch, package, direct_url=direct_url + ) + identity = installed_package_identity(package) + assert identity.name == package + assert identity.install_origin == ("archive" if archive else None) + assert source.is_file() + + +@pytest.mark.parametrize("package", list(receipt_module.UK_PACKAGES.values())) +@pytest.mark.parametrize("archive", [False, True]) +def test_loaded_checkout_cannot_name_an_index_or_archive_install( + tmp_path, monkeypatch, package, archive +): + direct_url = {"url": "file:///package.whl", "archive_info": {}} if archive else None + _, module, source, _ = _loaded_distribution( + tmp_path, monkeypatch, package, direct_url=direct_url + ) + checkout = tmp_path / "checkout" / source.parent.name / "__init__.py" + checkout.parent.mkdir(parents=True) + checkout.write_text("VALUE = 2\n") + module.__file__ = str(checkout) + with pytest.raises(ReceiptError) as error: + installed_package_identity(package) + message = str(error.value) + assert package in message and str(checkout) in message + assert ( + "installed distribution" in message + and "cannot name the code that ran" in message + ) + + +@pytest.mark.parametrize("package", list(receipt_module.UK_PACKAGES.values())) +def test_verified_wheel_cannot_name_another_loaded_checkout( + tmp_path, monkeypatch, package +): + from policyengine.provenance import wheels + + _, module, _, _ = _loaded_distribution(tmp_path, monkeypatch, package) + module.__file__ = str(tmp_path / "checkout" / "__init__.py") + verified = [] + monkeypatch.setattr( + wheels, + "verify_installed_against_wheel", + lambda *args, **kwargs: verified.append(args), + ) + with pytest.raises(ReceiptError, match="cannot name the code that ran"): + installed_package_identity(package, wheel_path=tmp_path / "package.whl") + assert verified == [] + + +@pytest.mark.parametrize("builder", ["direct", "microsimulation"]) +@pytest.mark.parametrize("package", list(receipt_module.UK_PACKAGES.values())) +@pytest.mark.parametrize("dir_info", [None, {}, {"editable": False}]) +def test_receipt_builders_refuse_loaded_checkout( + dataset_file, tmp_path, monkeypatch, builder, package, dir_info +): + bundle = _bundle_for(dataset_file.read_bytes()) + if builder == "microsimulation": + simulation = _managed_simulation(dataset_file, bundle) + direct_url = ( + {"url": (tmp_path / "checkout").as_uri(), "dir_info": dir_info} + if dir_info is not None + else None + ) + _, module, _, _ = _loaded_distribution( + tmp_path, monkeypatch, package, direct_url=direct_url + ) + module.__file__ = str(tmp_path / "checkout" / "__init__.py") + with pytest.raises(ReceiptError, match="cannot name the code that ran"): + if builder == "direct": + _build(dataset_file) + else: + _microsimulation_receipt(simulation, bundle) + + +@pytest.mark.parametrize("package", list(receipt_module.UK_PACKAGES.values())) +@pytest.mark.parametrize("layout", ["", "src"]) +def test_loaded_editable_packages_are_under_the_direct_url( + tmp_path, monkeypatch, package, layout +): + checkout = tmp_path / "checkout with spaces" + direct_url = {"url": checkout.as_uri(), "dir_info": {"editable": True}} + _, module, _, _ = _loaded_distribution( + tmp_path, monkeypatch, package, direct_url=direct_url + ) + source = checkout / layout / package.replace("-", "_") / "__init__.py" + source.parent.mkdir(parents=True) + source.write_text("VALUE = 1\n") + module.__file__ = str(source) + identity = installed_package_identity(package) + assert identity.install_origin == "editable" + + +@pytest.mark.parametrize("package", list(receipt_module.UK_PACKAGES.values())) +def test_loaded_editable_packages_refuse_another_checkout( + tmp_path, monkeypatch, package +): + checkout = tmp_path / "checkout" + direct_url = {"url": checkout.as_uri(), "dir_info": {"editable": True}} + _loaded_distribution(tmp_path, monkeypatch, package, direct_url=direct_url) + with pytest.raises( + ReceiptError, match="cannot name the code that ran.*expected code under" + ): + installed_package_identity(package) + + +@pytest.mark.parametrize("package", list(receipt_module.UK_PACKAGES.values())) +@pytest.mark.parametrize("dir_info", [{}, {"editable": False}]) +def test_loaded_noneditable_directory_packages_match_record( + tmp_path, monkeypatch, package, dir_info +): + direct_url = {"url": (tmp_path / "checkout").as_uri(), "dir_info": dir_info} + _loaded_distribution(tmp_path, monkeypatch, package, direct_url=direct_url) + assert installed_package_identity(package).install_origin == "directory" + + +@pytest.mark.parametrize("package", list(receipt_module.UK_PACKAGES.values())) +@pytest.mark.parametrize("dir_info", [{}, {"editable": False}]) +@pytest.mark.parametrize("layout", ["", "src"]) +def test_loaded_noneditable_directory_packages_refuse_source_checkout( + tmp_path, monkeypatch, package, dir_info, layout +): + from policyengine.provenance import wheels + + checkout = tmp_path / "checkout with spaces" + direct_url = {"url": checkout.as_uri(), "dir_info": dir_info} + _, module, _, _ = _loaded_distribution( + tmp_path, monkeypatch, package, direct_url=direct_url + ) + source = checkout / layout / package.replace("-", "_") / "__init__.py" + source.parent.mkdir(parents=True) + source.write_text("VALUE = 2\n") + module.__file__ = str(source) + verified = [] + monkeypatch.setattr( + wheels, + "verify_installed_against_wheel", + lambda *args, **kwargs: verified.append(args), + ) + with pytest.raises( + ReceiptError, match="cannot name the code that ran.*from RECORD" + ): + installed_package_identity(package, wheel_path=tmp_path / "package.whl") + assert verified == [] + + +@pytest.mark.parametrize("dir_info", [{}, {"editable": False}]) +def test_loaded_noneditable_directory_packages_require_the_exact_record_path( + tmp_path, monkeypatch, dir_info +): + direct_url = {"url": (tmp_path / "checkout").as_uri(), "dir_info": dir_info} + _, module, source, _ = _loaded_distribution( + tmp_path, monkeypatch, "policyengine", direct_url=direct_url + ) + other = source.parent.parent / "other" / "__init__.py" + other.parent.mkdir() + other.write_text("VALUE = 2\n") + module.__file__ = str(other) + with pytest.raises( + ReceiptError, match="cannot name the code that ran.*from RECORD" + ): + installed_package_identity("policyengine") + + +def test_loaded_directory_origin_requires_path_components(tmp_path, monkeypatch): + checkout = tmp_path / "checkout" + _, module, _, _ = _loaded_distribution( + tmp_path, + monkeypatch, + "policyengine", + direct_url={"url": checkout.as_uri(), "dir_info": {"editable": True}}, + ) + module.__file__ = str(tmp_path / "checkout-copy" / "policyengine" / "__init__.py") + with pytest.raises(ReceiptError, match="cannot name the code that ran"): + installed_package_identity("policyengine") + + +@pytest.mark.parametrize("package", list(receipt_module.UK_PACKAGES.values())) +def test_loaded_package_origin_compares_real_paths(tmp_path, monkeypatch, package): + _, module, source, _ = _loaded_distribution(tmp_path, monkeypatch, package) + link = tmp_path / "linked-package" + link.symlink_to(source.parent, target_is_directory=True) + module.__file__ = str(link / "__init__.py") + assert installed_package_identity(package).name == package + + +def test_loaded_package_origin_resolves_record_paths(tmp_path, monkeypatch): + distribution, _, source, _ = _loaded_distribution( + tmp_path, monkeypatch, "policyengine" + ) + link = tmp_path / "linked-installation" + link.symlink_to(source.parent.parent, target_is_directory=True) + monkeypatch.setattr(distribution, "locate_file", lambda entry: link / entry) + assert installed_package_identity("policyengine").name == "policyengine" + + +def test_loaded_editable_origin_resolves_its_url(tmp_path, monkeypatch): + checkout = tmp_path / "checkout" + checkout.mkdir() + link = tmp_path / "linked-checkout" + link.symlink_to(checkout, target_is_directory=True) + _, module, _, _ = _loaded_distribution( + tmp_path, + monkeypatch, + "policyengine", + direct_url={"url": link.as_uri(), "dir_info": {"editable": True}}, + ) + module.__file__ = str(checkout / "src" / "policyengine" / "__init__.py") + assert installed_package_identity("policyengine").install_origin == "editable" + + +@pytest.mark.parametrize("package", list(receipt_module.UK_PACKAGES.values())) +def test_unloaded_packages_have_no_source_to_compare(tmp_path, monkeypatch, package): + _, _, _, directory = _loaded_distribution(tmp_path, monkeypatch, package) + (directory / "RECORD").unlink() + monkeypatch.delitem(sys.modules, package.replace("-", "_")) + assert installed_package_identity(package).name == package + + +def test_package_identity_remains_available_for_other_distributions(monkeypatch): + distribution = SimpleNamespace(version="1", read_text=lambda name: None) + original = metadata.distribution + monkeypatch.setattr( + metadata, + "distribution", + lambda name: distribution if name == "example" else original(name), + ) + monkeypatch.setitem(sys.modules, "example", SimpleNamespace(__file__="example.py")) + assert installed_package_identity("example").version == "1" + + +@pytest.mark.parametrize( + "record", + [ + None, + "", + "other.py,,\n", + "policyengine/__init__.py,\n", + '"unclosed,,\n', + 'policyengine/__init__.py,"hash","3"junk\n', + ], +) +def test_loaded_package_requires_its_record_entry(tmp_path, monkeypatch, record): + _, _, _, directory = _loaded_distribution(tmp_path, monkeypatch, "policyengine") + if record is None: + (directory / "RECORD").unlink() + else: + (directory / "RECORD").write_text(record) + with pytest.raises(ReceiptError, match="cannot name the code that ran"): + installed_package_identity("policyengine") + + +@pytest.mark.parametrize("loaded_file", [None, "", 7]) +def test_loaded_package_requires_a_source_file(tmp_path, monkeypatch, loaded_file): + _, module, _, _ = _loaded_distribution(tmp_path, monkeypatch, "policyengine") + module.__file__ = loaded_file + with pytest.raises(ReceiptError, match="loaded package has no source file"): + installed_package_identity("policyengine") + + +def test_loaded_package_refuses_an_unreadable_record(tmp_path, monkeypatch): + distribution, _, _, _ = _loaded_distribution(tmp_path, monkeypatch, "policyengine") + original = distribution.read_text + + def read_text(name): + if name == "RECORD": + raise OSError("unreadable") + return original(name) + + monkeypatch.setattr(distribution, "read_text", read_text) + with pytest.raises(ReceiptError, match="source location cannot be read"): + installed_package_identity("policyengine") + + +@pytest.mark.parametrize("url", ["https://localhost", "file://other-host"]) +def test_loaded_editable_origin_requires_a_local_file_url(tmp_path, monkeypatch, url): + _loaded_distribution( + tmp_path, + monkeypatch, + "policyengine", + direct_url={ + "url": url + str(tmp_path / "installed"), + "dir_info": {"editable": True}, + }, + ) + with pytest.raises(ReceiptError, match="install directory is not local"): + installed_package_identity("policyengine") + + +@pytest.mark.parametrize("role", ["wrapper", "model", "core"]) +@pytest.mark.parametrize("origin", ["directory", "editable", "vcs"]) +@pytest.mark.parametrize("archive_hash", [False, True]) +def test_certified_model_and_verification_refuse_unverified_origins( + certified_receipt, dataset_file, role, origin, archive_hash +): + payload = certified_receipt.model_dump() + payload[role]["install_origin"] = origin + if archive_hash: + payload[role].update( + wheel_sha256="b" * 64, wheel_sha256_evidence="installer_archive_hash" + ) + reason = ( + "an editable install's wheel does not hold the source that runs" + if origin == "editable" + else "installed_record_verified" + ) + with pytest.raises(ValidationError, match=reason): + ExecutionReceipt.model_validate(payload) + package = type(getattr(certified_receipt, role)).model_validate(payload[role]) + forged = certified_receipt.model_copy(update={role: package}) + problems = verify_certificate(forged, _bundle_for(dataset_file.read_bytes())) + assert len(problems) == 1 + assert package.name in problems[0] and reason in problems[0] + + +@pytest.mark.parametrize("role", ["wrapper", "model", "core"]) +def test_certified_model_and_verification_refuse_verified_editable_installs( + certified_receipt, dataset_file, role +): + payload = certified_receipt.model_dump() + payload[role].update( + install_origin="editable", + wheel_sha256="b" * 64, + wheel_sha256_evidence="installed_record_verified", + ) + with pytest.raises(ValidationError, match="editable install"): + ExecutionReceipt.model_validate(payload) + package = type(getattr(certified_receipt, role)).model_validate(payload[role]) + forged = certified_receipt.model_copy(update={role: package}) + problems = verify_certificate(forged, _bundle_for(dataset_file.read_bytes())) + assert problems == [ + f"{package.name} installed from editable: an editable install's wheel " + "does not hold the source that runs" + ] + + +@pytest.mark.parametrize("role", ["wrapper", "model", "core"]) +@pytest.mark.parametrize("origin", ["directory", "editable", "vcs"]) +def test_verified_origin_can_be_certified_unless_editable( + dataset_file, monkeypatch, role, origin +): + direct_url = {"url": "file:///source"} + if origin == "vcs": + direct_url["vcs_info"] = {"vcs": "git", "commit_id": "a" * 40} + else: + direct_url["dir_info"] = {"editable": origin == "editable"} + package = receipt_module.UK_PACKAGES[role] + monkeypatch.setattr( + receipt_module, + "_read_direct_url", + lambda name: direct_url if name == package else None, + ) + monkeypatch.setattr( + "policyengine.provenance.wheels.verify_installed_against_wheel", + lambda name, wheel_path, metadata: {"wheel_sha256": "a" * 64, "problems": []}, + ) + receipt = _build(dataset_file, wheel_paths={package: Path("package.whl")}) + assert getattr(receipt, role).install_origin == origin + assert getattr(receipt, role).wheel_sha256_evidence == "installed_record_verified" + if origin == "editable": + assert receipt.certification_status == "uncertified" + assert receipt.certificate is None + else: + assert receipt.certification_status == "certified" + assert verify_certificate(receipt, _bundle_for(dataset_file.read_bytes())) == [] + + +@pytest.mark.parametrize( + "text", + [ + "", + "{", + "[]", + "null", + "{}", + '{"url": "file:///x"}', + '{"archive_info": {}}', + '{"url": "x", "dir_info": null}', + '{"url": "x", "dir_info": {"editable": "yes"}}', + '{"url": "x", "dir_info": {}, "archive_info": {}}', + '{"url": "", "archive_info": {}}', + '{"url": "file:///source.zip", "archive_info": {"hashes": 42}}', + '{"url": "file:///source.zip", "archive_info": {"hash": true}}', + '{"url": "file:///package.whl", "archive_info": {"hashes": {"sha256": 42}}}', + ], +) +def test_malformed_direct_url_is_refused(monkeypatch, text): + monkeypatch.setattr( + receipt_module.metadata, + "distribution", + lambda name: SimpleNamespace( + version=INSTALLED_WRAPPER, read_text=lambda filename: text + ), + ) + with pytest.raises(ReceiptError, match="policyengine.*direct_url.json.*malformed"): + installed_package_identity("policyengine") + + +def test_unreadable_direct_url_is_refused(monkeypatch): + def read_text(filename): + raise OSError("unreadable") + + monkeypatch.setattr( + receipt_module.metadata, + "distribution", + lambda name: SimpleNamespace(version=INSTALLED_WRAPPER, read_text=read_text), + ) + with pytest.raises(ReceiptError, match="direct_url.json cannot be read"): + installed_package_identity("policyengine") + + +# --- Digests ---------------------------------------------------------------------- + + +def test_json_round_trip_preserves_receipt_and_digests(certified_receipt): + for text in ( + certified_receipt.model_dump_json(), + certified_receipt.to_canonical_json(), + ): + parsed = ExecutionReceipt.model_validate_json(text) + assert parsed == certified_receipt + assert parsed.receipt_sha256 == certified_receipt.receipt_sha256 + assert ( + parsed.execution_identity_sha256 + == certified_receipt.execution_identity_sha256 + ) + + +def test_null_and_omitted_fields_digest_identically(certified_receipt): + with_nulls = certified_receipt.model_dump(mode="json") + assert with_nulls["outputs_sha256"] is None + assert ( + ExecutionReceipt.model_validate(with_nulls).receipt_sha256 + == ExecutionReceipt.model_validate( + certified_receipt.canonical_payload() + ).receipt_sha256 + ) + assert content_sha256({"a": 1, "b": None}) == content_sha256({"a": 1}) + assert content_sha256([None, {"a": None}]) == canonical_sha256([None, {}]) + + +@pytest.mark.usefixtures("index_installs") +def test_execution_identity_ignores_how_the_builder_was_called(tmp_path, monkeypatch): + from policyengine.provenance import certification + + dataset = tmp_path / "data.h5" + dataset.write_bytes(b"identity bytes") + bundle = _bundle_for(dataset.read_bytes()) + packaged = tmp_path / "bundle" + packaged.mkdir() + (packaged / "manifest.json").write_text(json.dumps(bundle)) + (packaged / "uk.trace.tro.jsonld").write_bytes( + (BUNDLE_DIR / "uk.trace.tro.jsonld").read_bytes() + ) + monkeypatch.setattr( + certification, "default_bundle_source_path", lambda: packaged / "manifest.json" + ) + packaged_receipt = build_execution_receipt( + country_id="uk", + dataset_path=dataset, + years=[2026], + reform=CURRENT_LAW, + baseline=CURRENT_LAW, + ) + supplied = _build(dataset, bundle, qualification_receipt_sha256="9" * 64) + assert packaged_receipt.certificate.tro_composition_fingerprint is not None + assert supplied.certificate.tro_composition_fingerprint is None + assert ( + packaged_receipt.execution_identity_sha256 == supplied.execution_identity_sha256 + ) + + +REFORM_PAYLOADS = st.dictionaries( + st.sampled_from( + [ + "gov.hmrc.income_tax.rates.uk[0].rate", + "gov.hmrc.income_tax.allowances.personal_allowance.amount", + "gov.dwp.universal_credit.standard_allowance.amount.SINGLE_OLD", + ] + ), + st.dictionaries( + st.sampled_from(["2027", "2027-04-06", "2027-04-06.2028-04-05"]), + st.floats(min_value=0, max_value=1e6, allow_nan=False), + min_size=1, + max_size=3, + ), + min_size=1, + max_size=3, +) + + +@settings(max_examples=60, deadline=None) +@given( + payload=REFORM_PAYLOADS, + years=st.lists(st.integers(2024, 2035), min_size=1, max_size=6), + outputs=st.dictionaries( + st.sampled_from(["budgetary_impact", "poverty_rate", "gini"]), + st.floats(allow_nan=False, allow_infinity=False), + max_size=3, + ), + label=st.text(max_size=10), +) +def test_digest_separation(tmp_path_factory, payload, years, outputs, label): + directory = tmp_path_factory.mktemp("receipt") + dataset = directory / "data.h5" + dataset.write_bytes(b"same bytes") + base = _build(dataset) + varied = _build( + dataset, + years=years, + reform=reform_identity( + payload, "policyengine.reform_dict/v1", name=label or None + ), + outputs=outputs or None, + ci_run_url="https://github.com/o/r/actions/runs/1", + ) + assert varied.execution_identity_sha256 == base.execution_identity_sha256 + assert ( + varied.model_copy(update={"runtime": None}).receipt_sha256 + == varied.receipt_sha256 + ) + renamed = varied.model_copy( + update={"reform": varied.reform.model_copy(update={"name": "other label"})} + ) + assert renamed.receipt_sha256 == varied.receipt_sha256 + assert varied.receipt_sha256 != base.receipt_sha256 + + +@settings(max_examples=60, deadline=None) +@given(payload=REFORM_PAYLOADS, random=st.randoms()) +def test_reform_digest_ignores_member_order_but_not_representation(payload, random): + items = list(payload.items()) + random.shuffle(items) + shuffled = {key: dict(reversed(list(value.items()))) for key, value in items} + a = reform_identity(payload, "policyengine.reform_dict/v1") + assert ( + a.reform_sha256 + == reform_identity(shuffled, "policyengine.reform_dict/v1").reform_sha256 + ) + assert ( + a.reform_sha256 + != reform_identity(payload, "policyengine-uk.reform_dict/v1").reform_sha256 + ) + + +class Rate(enum.IntEnum): + BASIC = 20 + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + (np.int64(1234), 1234), + (np.float64(6.1e9), 6.1e9), + (np.bool_(True), True), + (Rate.BASIC, 20), + (datetime.date(2027, 4, 6), "2027-04-06"), + ({2027: 0.21}, {"2027": 0.21}), + ((1, 2), [1, 2]), + ], +) +def test_strict_json_converts_exact_values(value, expected): + converted = strict_json_value(value) + assert converted == expected and type(converted) is type(expected) + + +@pytest.mark.parametrize( + "value", + [ + {1, 2}, + decimal.Decimal("1.5"), + b"bytes", + np.arange(2000), + float("nan"), + np.float64("inf"), + {2027: 0.21, "2027": 0.25}, + {(1, 2): 3}, + datetime.datetime(2027, 4, 6, 12, 0), + object(), + ], +) +def test_strict_json_refuses_values_without_one_json_form(value): + with pytest.raises(ReceiptError): + strict_json_value(value) + + +@pytest.mark.parametrize( + "value", + [ + np.ma.masked, + np.ma.array(42, mask=True), + np.ma.array(42.0, mask=False), + np.array(1.5), # a 0-d array, not a scalar + np.complex128(1), + np.datetime64("2027-04-06"), + np.zeros(1, dtype=[("a", "i4")])[0], # a structured scalar + ], + ids=[ + "masked", + "masked-42", + "unmasked-0d", + "0d-array", + "complex", + "datetime", + "void", + ], +) +def test_strict_json_refuses_numpy_values_that_are_not_plain_scalars(value): + """Round-4 review M7: a masked value never becomes an ordinary number.""" + + with pytest.raises(ReceiptError): + strict_json_value(value) + with pytest.raises(ReceiptError): + content_sha256({"total": value}) + + +def test_strict_json_unwraps_numpy_scalars_exactly(): + assert strict_json_value([np.int64(7), np.float32(0.5), np.bool_(True)]) == [ + 7, + 0.5, + True, + ] + + +def test_strict_json_refuses_rather_than_overflowing_the_stack(): + """Round-2 review N8: longdouble and deep nesting raise ReceiptError.""" + + longdouble = np.longdouble("0.1") + if isinstance(longdouble.item(), float): # pragma: no cover - platform + assert strict_json_value(longdouble) == longdouble.item() + else: + with pytest.raises(ReceiptError): + strict_json_value(longdouble) + nested: list = [] + for _ in range(sys.getrecursionlimit() + 100): + nested = [nested] + with pytest.raises(ReceiptError): + strict_json_value(nested) + with pytest.raises(ReceiptError): + content_sha256(nested) + with pytest.raises(ReceiptError): + content_sha256({"total": 2**53}) + + +def test_numpy_scalars_digest_like_the_published_json(): + assert content_sha256({"budgetary_impact": np.int64(1234)}) == content_sha256( + {"budgetary_impact": 1234} + ) + assert content_sha256({"budgetary_impact": np.int64(1234)}) != content_sha256( + {"budgetary_impact": "1234"} + ) + + +def test_reform_identity_accepts_only_json_payloads(): + assert reform_identity(None).kind == "current_law" + assert reform_identity({}).kind == "current_law" + with pytest.raises(ReceiptError): + reform_identity({"a": {"2027": 1}}) + for bad in ("gov.x", object(), SimpleNamespace(parameter_changes={"a": 1})): + with pytest.raises(ReceiptError): + reform_identity(bad, "policyengine.reform_dict/v1") + with pytest.raises(ReceiptError, match="scenario_reform_identity"): + reform_identity({"a": 1}, "policyengine-uk.scenario/v1") + with pytest.raises(ValidationError): + ReformIdentity(kind="current_law", reform_sha256="0" * 64) + with pytest.raises(ValidationError): + ReformIdentity(kind="reform", representation="policyengine.reform_dict/v1") + with pytest.raises(ValidationError): + ReformIdentity(kind="current_law", binding="code_revision") + + +# --- Scenario binding ------------------------------------------------------------- + + +def _scenario(parameter_changes=None, simulation_modifier=None, early=False): + return SimpleNamespace( + parameter_changes=parameter_changes, + simulation_modifier=simulation_modifier, + applied_before_data_load=early, + ) + + +class _ReformClass: + """Stands in for a policyengine-core Reform subclass.""" + + parameter_changes = None + + +@pytest.mark.parametrize( + "value", + [ + {"gov.hmrc.income_tax.rates.uk[0].rate": {"2027": 0.21}}, + [{"parameter": "gov.x", "value": 1}], + "gov.hmrc.income_tax.rates.uk[0].rate", + _ReformClass, + SimpleNamespace(parameter_values=[], name="policyengine.py Policy"), + SimpleNamespace(parameter_changes={"a": {"2027": 1}}), + _scenario(early="yes"), + _scenario(parameter_changes=[("a", 1)]), + _scenario(simulation_modifier="raise_basic_rate"), + ], + ids=[ + "reform dict", + "list", + "string", + "reform class", + "policy", + "partial scenario", + "non-bool timing", + "non-mapping changes", + "non-callable modifier", + ], +) +def test_objects_that_are_not_scenarios_are_refused_not_read_as_current_law(value): + """Round-2 review N6.""" + + with pytest.raises(ReceiptError): + scenario_reform_identity(value, code_revision=REVISION) + + +def test_real_scenarios_are_accepted(): + from policyengine_uk.utils.scenario import Scenario + + changes = {"gov.hmrc.income_tax.rates.uk[0].rate": {"2027-04-06": 0.21}} + assert scenario_reform_identity(Scenario()).kind == "current_law" + assert scenario_reform_identity( + Scenario(parameter_changes=changes) + ).reform_sha256 == (scenario_reform_identity(_scenario(changes)).reform_sha256) + bound = scenario_reform_identity( + Scenario(simulation_modifier=receipt_modifiers.raise_basic_rate), + code_revision=REVISION, + ) + assert bound.binding == "code_revision" + + +def test_a_null_in_a_reform_is_part_of_its_digest(): + """Round-5 review: policyengine-uk reads {"2027": None} unlike {}.""" + + removes = reform_identity( + {"gov.hmrc.income_tax.rates.uk[0].rate": {"2027": None}}, + "policyengine-uk.reform_dict/v1", + ) + keeps = reform_identity( + {"gov.hmrc.income_tax.rates.uk[0].rate": {}}, + "policyengine-uk.reform_dict/v1", + ) + assert removes.reform_sha256 != keeps.reform_sha256 + assert removes.reform_sha256 == canonical_sha256( + { + "representation": "policyengine-uk.reform_dict/v1", + "payload": {"gov.hmrc.income_tax.rates.uk[0].rate": {"2027": None}}, + } + ) + + +def test_parameter_scenarios_are_bound_by_content(): + changes = {"gov.hmrc.income_tax.rates.uk[0].rate": {"2027-04-06": 0.21}} + identity = scenario_reform_identity(_scenario(changes)) + assert identity.binding == "content" + # Reform payloads keep their nulls (canonical_sha256, not content_sha256). + assert identity.reform_sha256 == canonical_sha256( + { + "representation": "policyengine-uk.scenario/v1", + "payload": { + "applied_before_data_load": False, + "parameter_changes": changes, + "simulation_modifier": None, + }, + } + ) + assert ( + scenario_reform_identity(_scenario(changes, early=True)).reform_sha256 + != identity.reform_sha256 + ) + assert scenario_reform_identity(_scenario()).kind == "current_law" + + +def test_module_level_modifiers_are_bound_by_code_and_revision(): + with pytest.raises(ReceiptError): + scenario_reform_identity( + _scenario(simulation_modifier=receipt_modifiers.raise_basic_rate) + ) + basic = scenario_reform_identity( + _scenario(simulation_modifier=receipt_modifiers.raise_basic_rate), + code_revision=REVISION, + ) + higher = scenario_reform_identity( + _scenario(simulation_modifier=receipt_modifiers.raise_higher_rate), + code_revision=REVISION, + ) + moved = scenario_reform_identity( + _scenario(simulation_modifier=receipt_modifiers.raise_basic_rate), + code_revision={**REVISION, "git_sha": "b" * 40}, + ) + assert basic.binding == "code_revision" + assert len({basic.reform_sha256, higher.reform_sha256, moved.reform_sha256}) == 3 + + +@pytest.mark.parametrize( + "modifier", + [ + receipt_unbindable_modifiers.make_modifier(0.21), + lambda simulation: None, + functools.partial(receipt_modifiers.raise_basic_rate), + receipt_unbindable_modifiers.Measure(0.21).apply, + print, + ], + ids=["closure", "lambda", "partial", "bound method", "builtin"], +) +def test_modifiers_whose_behaviour_the_digest_cannot_bind_are_refused(modifier): + with pytest.raises(ReceiptError): + scenario_reform_identity( + _scenario(simulation_modifier=modifier), code_revision=REVISION + ) + + +@pytest.mark.parametrize( + "revision", + [ + None, + {}, + {"repository": "PolicyEngine/x"}, + {"repository": "", "git_sha": "a" * 40}, + {"repository": "PolicyEngine/x", "git_sha": "main"}, + {"repository": "PolicyEngine/x", "git_sha": "A" * 40}, + ], +) +def test_modifiers_need_a_full_code_revision(revision): + with pytest.raises(ReceiptError): + scenario_reform_identity( + _scenario(simulation_modifier=receipt_modifiers.raise_basic_rate), + code_revision=revision, + ) + + +MEASURE_SOURCE = """ +RATE = {rate} + + +def raise_rate(simulation): + simulation.rate = RATE +""" + + +def _import_measures(directory: Path, name: str, rate: float = 0.21): + """Import a measures module from ``directory`` as top-level ``name``.""" + + directory.mkdir(parents=True, exist_ok=True) + path = directory / f"{name}.py" + path.write_text(MEASURE_SOURCE.format(rate=rate)) + linecache.checkcache(str(path)) + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module, path + + +def _measure_identity(module): + return scenario_reform_identity( + _scenario(simulation_modifier=module.raise_rate), code_revision=REVISION + ) + + +@pytest.mark.parametrize("role", ["reform", "baseline"]) +@pytest.mark.parametrize("managed", [False, True]) +@pytest.mark.parametrize("change", ["reassign", "memo"]) +def test_builder_rechecks_modifier_after_the_run( + dataset_file, tmp_path, role, managed, change +): + name = "w4_receipt_r10_recheck" + source = ( + "CACHE = {}\n\n\ndef raise_rate(simulation):\n" + " CACHE[2027] = 0.30\n simulation.rate = CACHE[2027]\n" + if change == "memo" + else MEASURE_SOURCE.format(rate=0.21) + ) + try: + module, _ = _import_source(tmp_path, name, source) + identity = _measure_identity(module) + if change == "reassign": + module.RATE = 0.30 + simulation = SimpleNamespace() + module.raise_rate(simulation) + assert simulation.rate == 0.30 + identities = {"reform": CURRENT_LAW, "baseline": CURRENT_LAW, role: identity} + with pytest.raises( + ReceiptError, match="modifier changed after its identity was computed" + ) as refused: + if managed: + bundle = _bundle_for(dataset_file.read_bytes()) + build_execution_receipt_for_microsimulation( + _managed_simulation(dataset_file, bundle), + years=[2026], + bundle=bundle, + **identities, + ) + else: + _build(dataset_file, **identities) + assert ("CACHE" if change == "memo" else "RATE") in str(refused.value) + finally: + sys.modules.pop(name, None) + + +def test_unchanged_scenario_still_builds_after_a_run(dataset_file): + scenario = _scenario(simulation_modifier=receipt_modifiers.raise_basic_rate_to) + identity = scenario_reform_identity(scenario, code_revision=REVISION) + simulation = SimpleNamespace() + scenario.simulation_modifier(simulation) + assert simulation.rate == 0.21 + receipt = _build(dataset_file, reform=identity, baseline=identity) + assert receipt.reform == receipt.baseline == identity + loaded = ReformIdentity.model_validate_json(identity.model_dump_json()) + from_loaded = _build(dataset_file, reform=loaded, baseline=loaded) + assert receipt == from_loaded + assert receipt.receipt_sha256 == from_loaded.receipt_sha256 + assert receipt.execution_identity_sha256 == from_loaded.execution_identity_sha256 + + +@pytest.mark.parametrize( + "field,value", + [ + ("reform_sha256", "b" * 64), + ("binding", "content"), + ("representation", "policyengine.reform_dict/v1"), + ("name", "updated"), + ], +) +def test_copying_an_identity_with_updated_fields_does_not_bypass_recheck( + dataset_file, field, value +): + identity = scenario_reform_identity( + _scenario(simulation_modifier=receipt_modifiers.raise_basic_rate), + code_revision=REVISION, + ) + changed = identity.model_copy(update={field: value}) + with pytest.raises(ReceiptError, match=f"{field} differs"): + _build(dataset_file, reform=changed) + + +def test_loaded_identity_is_accepted_without_rechecking(dataset_file, tmp_path): + name = "w4_receipt_r10_loaded" + try: + module, _ = _import_measures(tmp_path, name) + identity = _measure_identity(module) + loaded = ReformIdentity.model_validate(identity.model_dump()) + module.RATE = 0.30 + assert loaded._recompute_identity is None + assert _build(dataset_file, reform=loaded).reform == identity + with pytest.raises(ReceiptError, match="modifier changed"): + _build(dataset_file, reform=identity) + finally: + sys.modules.pop(name, None) + + +@settings(max_examples=40, deadline=None) +@given( + changes=st.none() + | st.dictionaries( + st.sampled_from(["rate", "cap"]), st.integers(0, 100), max_size=2 + ), + modifier=st.booleans(), + label=st.none() | st.text(max_size=20), +) +def test_identity_handle_preserves_equality_dump_and_digest(changes, modifier, label): + scenario = _scenario( + changes, receipt_modifiers.raise_basic_rate if modifier else None + ) + identity = scenario_reform_identity(scenario, code_revision=REVISION, name=label) + independently_bound = scenario_reform_identity( + scenario, code_revision=REVISION, name=label + ) + loaded = ReformIdentity.model_validate(identity.model_dump()) + loaded_json = ReformIdentity.model_validate_json(identity.model_dump_json()) + assert identity._recompute_identity is not independently_bound._recompute_identity + for other in (independently_bound, loaded, loaded_json, identity.model_copy()): + assert identity == other and other == identity + assert identity.model_dump() == other.model_dump() + assert identity.model_dump_json() == other.model_dump_json() + assert content_sha256(identity.canonical_payload()) == content_sha256( + other.canonical_payload() + ) + assert identity.reform_sha256 == other.reform_sha256 + assert "_recompute_identity" not in identity.model_dump_json() + assert "_recompute_identity" not in json.dumps(ReformIdentity.model_json_schema()) + + +@pytest.mark.parametrize("before", [None, {"rate": 1}]) +def test_scenario_parameter_changes_are_rechecked(dataset_file, before): + scenario = _scenario(before) + identity = scenario_reform_identity(scenario) + scenario.parameter_changes = {"rate": 2} + with pytest.raises(ReceiptError, match="reform_sha256.*differs"): + _build(dataset_file, reform=identity) + + +def test_recheck_keeps_the_original_code_revision(dataset_file): + revision = dict(REVISION) + identity = scenario_reform_identity( + _scenario(simulation_modifier=receipt_modifiers.raise_basic_rate), + code_revision=revision, + ) + revision["git_sha"] = "b" * 40 + assert _build(dataset_file, reform=identity).reform == identity + + +def test_modifier_digest_does_not_depend_on_the_checkout_location(tmp_path): + """Round-2 review N2: the same code and revision give the same digest anywhere.""" + + name = "w4_receipt_measures_location" + try: + first, _ = _import_measures(tmp_path / "checkout_a", name) + a = _measure_identity(first) + del sys.modules[name] + second, _ = _import_measures(tmp_path / "elsewhere" / "checkout_b", name) + b = _measure_identity(second) + assert first.raise_rate.__code__.co_filename != ( + second.raise_rate.__code__.co_filename + ) + assert a.reform_sha256 == b.reform_sha256 + del sys.modules[name] + third, _ = _import_measures(tmp_path / "checkout_c", name, rate=0.99) + # The function is unchanged, but its module's source is not. + assert _measure_identity(third).reform_sha256 != a.reform_sha256 + finally: + sys.modules.pop(name, None) + + +def test_a_modifier_edited_after_import_is_refused(tmp_path): + name = "w4_receipt_measures_edited" + try: + module, path = _import_measures(tmp_path, name) + _measure_identity(module) + path.write_text( + path.read_text().replace( + "simulation.rate = RATE", "simulation.rate = RATE * 2 # edited" + ) + ) + with pytest.raises(ReceiptError, match="no longer matches"): + _measure_identity(module) + finally: + sys.modules.pop(name, None) + + +HELPER_SOURCE = """ +RATE = 0.21 +LABELS = ("basic", "higher") + + +def _helper(value): + return value * 2 + + +def raise_rate(simulation): + simulation.rate = _helper(RATE) + simulation.labels = list(label for label in LABELS if label) +""" + + +def _import_source(directory: Path, name: str, source: str): + directory.mkdir(parents=True, exist_ok=True) + path = directory / f"{name}.py" + path.write_text(source) + linecache.checkcache(str(path)) + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module, path + + +def _edit(path: Path, old: str, new: str) -> None: + """Edit a file so linecache sees it even when the size is unchanged.""" + + import os + + stat = path.stat() + path.write_text(path.read_text().replace(old, new)) + os.utime(path, ns=(stat.st_atime_ns, stat.st_mtime_ns + 10**9)) + + +@pytest.mark.parametrize( + ("old", "new"), + [ + ("return value * 2", "return value * 3"), # a helper the modifier calls + ("RATE = 0.21", "RATE = 0.30"), # a literal constant + ('LABELS = ("basic", "higher")', 'LABELS = ("basic", "additional")'), + ("value * 2", "value + 2"), # an operator only: same names and constants + ("label for label", "label.upper() for label"), # nested code only + ], + ids=["helper", "constant", "tuple", "operator", "nested"], +) +def test_module_code_that_did_not_run_is_never_named(tmp_path, old, new): + """Round-3 review R1: edits anywhere in the module after import are refused.""" + + name = "w4_receipt_measures_module" + try: + module, path = _import_source(tmp_path, name, HELPER_SOURCE) + _measure_identity(module) + _edit(path, old, new) + with pytest.raises(ReceiptError, match="no longer matches"): + _measure_identity(module) + finally: + sys.modules.pop(name, None) + + +def test_a_constant_reassigned_at_run_time_is_refused(tmp_path): + name = "w4_receipt_measures_runtime" + try: + module, _ = _import_source(tmp_path, name, HELPER_SOURCE) + _measure_identity(module) + module.RATE = 0.99 + with pytest.raises(ReceiptError, match="RATE is 0.99 at run time"): + _measure_identity(module) + module.RATE = 0.21 + _measure_identity(module) + finally: + sys.modules.pop(name, None) + + +def test_methods_are_refused_with_a_clear_reason(): + class Holder: + @staticmethod + def apply(simulation): + simulation.rate = 0.21 + + Holder.apply.__qualname__ = "Holder.apply" # as if defined at module level + with pytest.raises(ReceiptError, match="is a method"): + scenario_reform_identity( + _scenario(simulation_modifier=Holder.apply), code_revision=REVISION + ) + + +def test_a_function_with_foreign_globals_is_refused(): + import types + + forged = types.FunctionType( + receipt_modifiers.raise_basic_rate.__code__, {}, "raise_basic_rate" + ) + forged.__module__ = receipt_modifiers.__name__ + forged.__qualname__ = "raise_basic_rate" + with pytest.raises(ReceiptError, match="does not belong to an imported module"): + scenario_reform_identity( + _scenario(simulation_modifier=forged), code_revision=REVISION + ) + + +def _identity(modifier): + return scenario_reform_identity( + _scenario(simulation_modifier=modifier), code_revision=REVISION + ).reform_sha256 + + +def test_literal_defaults_changed_at_run_time_are_refused(): + """Round-2 review N7: a default that differs from the source never binds.""" + + to_rate = receipt_modifiers.raise_basic_rate_to + from_year = receipt_modifiers.raise_basic_rate_from + original = _identity(to_rate), _identity(from_year) + try: + to_rate.__defaults__ = (0.99,) + with pytest.raises(ReceiptError, match="default arguments differ"): + _identity(to_rate) + # Any function in the module counts, not only the modifier. + with pytest.raises(ReceiptError, match="raise_basic_rate_to's default"): + _identity(receipt_modifiers.raise_basic_rate) + to_rate.__defaults__ = (0.21,) + from_year.__kwdefaults__ = {"start": 2030} + with pytest.raises(ReceiptError, match="default arguments differ"): + _identity(from_year) + finally: + to_rate.__defaults__ = (0.21,) + from_year.__kwdefaults__ = {"start": 2027} + assert (_identity(to_rate), _identity(from_year)) == original + + +COMPUTED_DEFAULT_SOURCE = """ +import ast +import os +import re + + +def _helper(value, scale=ast.literal_eval(os.environ["W4_HELPER_DEFAULT"])): + return value * scale + + +def raise_rate(simulation, *, rate=ast.literal_eval(os.environ["W4_RATE_DEFAULT"])): + simulation.rate = _helper(rate) +""" + + +def _computed_default_identity(tmp_path, monkeypatch, rate, helper="2"): + name = "w4_receipt_measures_computed_default" + monkeypatch.setenv("W4_RATE_DEFAULT", rate) + monkeypatch.setenv("W4_HELPER_DEFAULT", helper) + try: + module, _ = _import_source(tmp_path, name, COMPUTED_DEFAULT_SOURCE) + return _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +@pytest.mark.parametrize( + ("first", "second"), + [ + ("0.21", "0.99"), + ("1", "1.0"), + ("False", "0"), + ("True", "1"), + ("(0.21,)", "[0.21]"), + ("{}", "{'rate': None}"), + ("{'a': 1, 'b': 2}", "{'b': 2, 'a': 1}"), + ("None", "'None'"), + ], +) +def test_computed_defaults_are_bound_by_typed_value( + tmp_path, monkeypatch, first, second +): + """Round-4 review M4: values that behave differently never share a digest.""" + + one = _computed_default_identity(tmp_path / "a", monkeypatch, first) + again = _computed_default_identity(tmp_path / "b", monkeypatch, first) + other = _computed_default_identity(tmp_path / "c", monkeypatch, second) + assert one == again + assert one != other + + +def test_a_helpers_computed_default_is_bound(tmp_path, monkeypatch): + one = _computed_default_identity(tmp_path / "a", monkeypatch, "0.21", "2") + other = _computed_default_identity(tmp_path / "b", monkeypatch, "0.21", "3") + assert one != other + + +def test_defaults_without_an_exact_encoding_are_refused(tmp_path): + source = ( + "import decimal\n\n\n" + "def raise_rate(simulation, measure=decimal.Decimal('0.21')):\n" + " simulation.rate = measure\n" + ) + name = "w4_receipt_measures_object_default" + try: + module, _ = _import_source(tmp_path, name, source) + with pytest.raises(ReceiptError, match="default arguments cannot be bound"): + _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +def test_a_helpers_unencodable_default_refuses_the_module(tmp_path): + source = ( + "import decimal\n\n\n" + "def _helper(value, scale=decimal.Decimal('2')):\n" + " return value * scale\n\n\n" + "def raise_rate(simulation):\n simulation.rate = 0.21\n" + ) + name = "w4_receipt_measures_helper_object_default" + try: + module, _ = _import_source(tmp_path, name, source) + with pytest.raises(ReceiptError, match="_helper's default arguments"): + _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +@pytest.mark.parametrize( + ("source_value", "runtime_value"), + [(False, 0), (0, False), (1, 1.0), (1.0, 1), ((1,), [1]), ("1", 1)], +) +def test_literal_defaults_compare_with_their_types( + tmp_path, source_value, runtime_value +): + """A runtime default equal in Python but of another type is refused.""" + + source = ( + f"def raise_rate(simulation, value={source_value!r}):\n" + " simulation.value = value\n" + ) + name = "w4_receipt_measures_typed_literal_default" + try: + module, _ = _import_source(tmp_path, name, source) + _identity(module.raise_rate) + module.raise_rate.__defaults__ = (runtime_value,) + with pytest.raises(ReceiptError, match="default arguments differ"): + _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +@pytest.mark.parametrize( + ("source_value", "runtime_value"), + [(False, 0), (0, False), (1, 1.0), ((1,), [1])], +) +def test_literal_constants_compare_with_their_types( + tmp_path, source_value, runtime_value +): + source = ( + f"ENABLED = {source_value!r}\n\n\n" + "def raise_rate(simulation):\n simulation.enabled = ENABLED\n" + ) + name = "w4_receipt_measures_typed_constant" + try: + module, _ = _import_source(tmp_path, name, source) + _identity(module.raise_rate) + module.ENABLED = runtime_value + with pytest.raises(ReceiptError, match="ENABLED is"): + _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +def test_a_helper_with_foreign_globals_is_refused(tmp_path): + import types + + name = "w4_receipt_measures_foreign_helper" + try: + module, _ = _import_source(tmp_path, name, HELPER_SOURCE) + _measure_identity(module) + module._helper = types.FunctionType( + module._helper.__code__, {"__name__": name}, "_helper" + ) + with pytest.raises(ReceiptError, match="does not use its module's globals"): + _measure_identity(module) + finally: + sys.modules.pop(name, None) + + +def test_from_reform_scenarios_are_refused_in_favour_of_their_dictionary(): + from policyengine_uk.utils.scenario import Scenario + + basic = Scenario.from_reform( + {"gov.hmrc.income_tax.rates.uk[0].rate": {"2027": 0.21}} + ) + with pytest.raises(ReceiptError, match="reform dictionary"): + scenario_reform_identity(basic, code_revision=REVISION) + a = reform_identity( + {"gov.hmrc.income_tax.rates.uk[0].rate": {"2027": 0.21}}, + "policyengine-uk.reform_dict/v1", + ) + b = reform_identity( + {"gov.hmrc.income_tax.rates.uk[0].rate": {"2027": 0.25}}, + "policyengine-uk.reform_dict/v1", + ) + assert a.reform_sha256 != b.reform_sha256 + + +def _managed_simulation(dataset_file, bundle, **overrides): + """A microsimulation as ``managed_microsimulation`` records its dataset.""" + + from policyengine.provenance.dataset_materialization import MaterializedDataset + from policyengine.tax_benefit_models.common.model_version import ( + build_runtime_dataset_provenance, + ) + + record = bundle["data_releases"]["uk"] + reference = record["datasets"][record["default_dataset"]] + uri = f"hf://{reference['repo_id']}/{reference['path']}@{reference['revision']}" + fields = {"country_id": "uk"} + fields.update( + build_runtime_dataset_provenance( + uri, + str(dataset_file), + MaterializedDataset( + data_package_name=record["data_package"]["name"], + repo_type="model", + revision=reference["revision"], + source_uri=uri, + sha256=hashlib.sha256(dataset_file.read_bytes()).hexdigest(), + path=dataset_file, + ), + logical_name=record["default_dataset"], + ) + ) + fields.update(overrides) + return SimpleNamespace(policyengine_bundle=fields) + + +def _microsimulation_receipt(simulation, bundle): + return build_execution_receipt_for_microsimulation( + simulation, + years=[2026], + reform=CURRENT_LAW, + baseline=CURRENT_LAW, + bundle=bundle, + ) + + +def test_microsimulation_receipt_needs_its_reform_and_reads_the_loaded_dataset( + dataset_file, +): + bundle = _bundle_for(dataset_file.read_bytes()) + simulation = _managed_simulation(dataset_file, bundle) + with pytest.raises(TypeError): + build_execution_receipt_for_microsimulation( + simulation, years=[2026], bundle=bundle + ) + receipt = _microsimulation_receipt(simulation, bundle) + assert receipt.certification_status == "certified" + assert ( + receipt.data.dataset_uri + == simulation.policyengine_bundle["runtime_dataset_uri"] + ) + assert ( + receipt.data.build_model_version + == bundle["data_releases"]["uk"]["certification"]["built_with_model_version"] + ) + with pytest.raises(ReceiptError): + build_execution_receipt_for_microsimulation( + object(), years=[2026], reform=CURRENT_LAW, baseline=CURRENT_LAW + ) + + +def test_a_dataset_replaced_after_loading_is_refused(dataset_file, tmp_path): + """Round-4 review H2: the receipt describes the bytes that were loaded.""" + + loaded = tmp_path / "loaded.h5" + loaded.write_bytes(b"the bytes that ran") + certified = dataset_file.read_bytes() + bundle = _bundle_for(certified) + simulation = _managed_simulation(loaded, bundle) + loaded.write_bytes(certified) # swapped for the certified bytes afterwards + with pytest.raises(ReceiptError, match="has changed since the microsimulation"): + _microsimulation_receipt(simulation, bundle) + loaded.unlink() + with pytest.raises(ReceiptError, match="is gone"): + _microsimulation_receipt(simulation, bundle) + + +def test_a_dataset_loaded_without_a_hash_cannot_be_receipted(dataset_file): + bundle = _bundle_for(dataset_file.read_bytes()) + simulation = _managed_simulation(dataset_file, bundle) + del simulation.policyengine_bundle["runtime_dataset_sha256"] + with pytest.raises(ReceiptError, match="without verifying its SHA-256"): + _microsimulation_receipt(simulation, bundle) + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("runtime_dataset_uri", "hf://policyengine/other/enhanced_frs_2024_25.h5@1"), + ("runtime_dataset_revision", "0" * 40), + ("runtime_dataset_data_package", "other-data"), + ], +) +def test_load_time_provenance_must_match_the_receipt(dataset_file, field, value): + bundle = _bundle_for(dataset_file.read_bytes()) + simulation = _managed_simulation(dataset_file, bundle, **{field: value}) + with pytest.raises(ReceiptError, match="but the microsimulation loaded"): + _microsimulation_receipt(simulation, bundle) + + +# --- Fiscal years -------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("year", "label"), [(2027, "2027-28"), (1999, "1999-00"), (2009, "2009-10")] +) +def test_uk_fiscal_year_labels(year, label): + assert fiscal_year_label(year, uk_fiscal_year_convention()) == label + + +def test_fiscal_year_convention_start_dates_are_fixed(): + with pytest.raises(ValidationError): + FiscalYearConvention( + convention="uk_fiscal_year", + period_start="01-01", + parameter_annualisation="x", + ) + + +# --- The published JSON Schema agrees with the Pydantic model ------------------------ + + +@pytest.fixture(scope="module") +def schema_validator(): + schema = json.loads(SCHEMA_PATH.read_text()) + jsonschema.Draft202012Validator.check_schema(schema) + return jsonschema.Draft202012Validator(schema) + + +def test_published_schema_is_current(): + assert json.loads(SCHEMA_PATH.read_text()) == ExecutionReceipt.json_schema() + + +@settings(max_examples=40, deadline=None) +@given( + years=st.lists(st.integers(2020, 2040), min_size=1, max_size=5), + payload=st.none() | REFORM_PAYLOADS, + certified=st.booleans(), + modifier=st.booleans(), + git_sha=st.none() | st.just("c" * 40), + qualification=st.none() | SHA, +) +@pytest.mark.usefixtures("index_installs") +def test_schema_accepts_every_valid_receipt( + tmp_path_factory, + schema_validator, + years, + payload, + certified, + modifier, + git_sha, + qualification, +): + directory = tmp_path_factory.mktemp("schema") + dataset = directory / "data.h5" + dataset.write_bytes(b"bytes") + reform = ( + scenario_reform_identity( + _scenario(simulation_modifier=receipt_modifiers.raise_basic_rate), + code_revision=REVISION, + ) + if modifier + else reform_identity(payload, "policyengine.reform_dict/v1") + ) + receipt = _build( + dataset, + _bundle_for(b"bytes", model_version=INSTALLED_UK if certified else "0.0.1"), + years=years, + reform=reform, + wrapper_git_sha=git_sha, + qualification_receipt_sha256=qualification, + outputs={"budgetary_impact": 1.5e9}, + ) + schema_validator.validate(json.loads(receipt.model_dump_json())) + schema_validator.validate(json.loads(receipt.to_canonical_json())) + + +MUTATIONS = [ + ("extra top-level member", lambda r: r.update(unexpected=1)), + ("bad schema version", lambda r: r.update(schema_version=2)), + ("bad receipt type", lambda r: r.update(receipt_type="other")), + ("US receipt", lambda r: r.update(country_id="us")), + ("missing data", lambda r: r.pop("data")), + ("bad dataset digest", lambda r: r["data"].update(dataset_sha256="XYZ")), + ("short dataset digest", lambda r: r["data"].update(dataset_sha256="a" * 63)), + ("extra data member", lambda r: r["data"].update(rows=10)), + ("empty years", lambda r: r.update(years=[])), + ("string year", lambda r: r.update(years=["2026"])), + ("bad period start", lambda r: r["fiscal_year"].update(period_start="April")), + ("calendar period start", lambda r: r["fiscal_year"].update(period_start="01-01")), + ( + "calendar convention", + lambda r: r["fiscal_year"].update( + convention="calendar_year", period_start="01-01" + ), + ), + ("US model", lambda r: r["model"].update(name="policyengine-us")), + ("wrong core", lambda r: r["core"].update(name="numpy")), + ("wrong wrapper", lambda r: r["wrapper"].update(name="policyengine-uk")), + ("manifest digest", lambda r: r["data"].update(release_manifest_sha256="a" * 64)), + ("bad wheel digest", lambda r: r["model"].update(wheel_sha256="1234")), + ("unnormalized name", lambda r: r["model"].update(name="Policyengine_UK")), + ("bad git sha", lambda r: r["wrapper"].update(git_sha="abc")), + ( + "missing certificate source", + lambda r: r["certificate"].pop("certificate_source"), + ), + ( + "missing certified core", + lambda r: r["certificate"].pop("certified_for_core_version"), + ), + ( + "unknown representation", + lambda r: r["reform"].update( + kind="reform", representation="pickle", reform_sha256="a" * 64 + ), + ), +] + + +@pytest.mark.parametrize(("label", "mutate"), MUTATIONS, ids=[m[0] for m in MUTATIONS]) +def test_schema_and_model_reject_the_same_malformed_receipts( + certified_receipt, schema_validator, label, mutate +): + payload = copy.deepcopy(certified_receipt.model_dump(mode="json")) + mutate(payload) + assert not schema_validator.is_valid(payload), label + with pytest.raises(ValidationError): + ExecutionReceipt.model_validate(payload) + + +def test_schema_publishes_the_rules_it_cannot_express(schema_validator): + rules = schema_validator.schema["x-policyengine-rules"] + assert "certificate.certified_for_core_version equals core.version." in rules + assert any("verify_certificate()" in rule for rule in rules) + assert any( + "certificate_source is the producer's statement" in rule for rule in rules + ) + assert not any("requires certificate_source packaged" in rule for rule in rules) + properties = schema_validator.schema["$defs"] + assert properties["ModelIdentity"]["properties"]["name"]["const"] == ( + "policyengine-uk" + ) + assert properties["FiscalYearConvention"]["properties"]["period_start"][ + "const" + ] == ("04-06") + assert ( + "RFC 8785" + in schema_validator.schema["x-policyengine-digests"]["canonicalization"] + ) + + +def test_execution_identity_preimage(certified_receipt): + identity = certified_receipt.execution_identity() + assert set(identity) == { + "schema_version", + "country_id", + "wrapper", + "model", + "core", + "data", + "certification_status", + "certificate", + } + assert "certificate_source" not in identity["certificate"] + assert certified_receipt.execution_identity_sha256 == canonical_sha256(identity) + + +# --- Supported years ---------------------------------------------------------------- + + +def test_years_outside_the_projection_are_unsupported(dataset_file): + """A 2031-32 figure on data projected to 2030 is not evidence.""" + + receipt = _build(dataset_file, years=[2029, 2030, 2031]) + assert receipt.supported_years == list(PROJECTED_YEARS) + assert receipt.unsupported_years == [2031] + assert _build(dataset_file, years=[2026, 2027]).unsupported_years == [] + + +def test_supported_years_are_part_of_the_receipt_digest(dataset_file, monkeypatch): + receipt = _build(dataset_file, years=[2026]) + monkeypatch.setattr( + receipt_module, + "_projected_years", + lambda path, sha256, model_version: tuple(range(2024, 2032)), + ) + longer = _build(dataset_file, years=[2026]) + assert longer.receipt_sha256 != receipt.receipt_sha256 + assert longer.execution_identity_sha256 == receipt.execution_identity_sha256 + + +@pytest.mark.parametrize( + "supported", [[2027, 2026], [2026, 2026], ["2026"], [2026.0], None] +) +def test_supported_years_must_be_sorted_unique_integers(certified_receipt, supported): + payload = json.loads(certified_receipt.model_dump_json()) + if supported is None: + del payload["supported_years"] + else: + payload["supported_years"] = supported + with pytest.raises(ValidationError): + ExecutionReceipt.model_validate(payload) + + +def test_the_projection_is_computed_once_per_file_and_model(dataset_file, monkeypatch): + calls = [] + + def projected(path, sha256, model_version): + calls.append((path, sha256, model_version)) + return PROJECTED_YEARS + + monkeypatch.setattr(receipt_module, "_projected_years", projected) + receipt = _build(dataset_file, years=[2026]) + assert calls == [ + (str(dataset_file), receipt.data.dataset_sha256, receipt.model.version) + ] + + +def test_the_certified_dataset_projects_to_2030(): + """Runs only with the certified 1.58.0 file: POLICYENGINE_UK_CERTIFIED_DATASET.""" + + import os + + path = os.environ.get("POLICYENGINE_UK_CERTIFIED_DATASET") + if not path: + pytest.skip("set POLICYENGINE_UK_CERTIFIED_DATASET to the 1.58.0 file") + sha256 = hashlib.sha256(Path(path).read_bytes()).hexdigest() + years = receipt_module._projected_years(path, sha256, INSTALLED_UK) + assert years == PROJECTED_YEARS + + +# --- Round-5 review findings ------------------------------------------------------ + + +@pytest.mark.parametrize( + ("first", "second"), + [ + ("(1,)", "(1.0,)"), + ("(True,)", "(1,)"), + ("0.0", "-0.0"), + ("((1, 2),)", "((1.0, 2),)"), + ("frozenset({1})", "frozenset({1.0})"), + ("1j", "-1j"), + ], +) +def test_code_constants_compare_with_exact_types(first, second): + """Round-5 review: equal-but-different constants are different code.""" + + from policyengine.provenance.bytecode import code_shape + + left = compile(f"value = {first}", "x", "exec", dont_inherit=True) + right = compile(f"value = {second}", "x", "exec", dont_inherit=True) + assert code_shape(left) != code_shape(right) + assert code_shape(left) == code_shape( + compile(f"value = {first}", "elsewhere", "exec", dont_inherit=True) + ) + + +def test_a_helper_rebound_to_another_modules_function_is_refused(tmp_path): + """Round-5 review: a source-defined helper may not silently change owner.""" + + import types + + name = "w4_receipt_r5_rebound_helper" + try: + module, _ = _import_source(tmp_path, name, HELPER_SOURCE) + _measure_identity(module) + foreign = types.ModuleType("w4_receipt_r5_foreign") + exec("def _helper(value):\n return value * 99\n", foreign.__dict__) + module._helper = foreign._helper + with pytest.raises(ReceiptError, match="no longer the function its source"): + _measure_identity(module) + module._helper = 7 + with pytest.raises(ReceiptError, match="no longer the function its source"): + _measure_identity(module) + del module._helper + with pytest.raises(ReceiptError, match="no longer defined"): + _measure_identity(module) + finally: + sys.modules.pop(name, None) + + +PARTIAL_SOURCE = """ +from functools import partial + + +def apply_payment(simulation, *, amount, year): + simulation.payment = (amount, year) + + +PAYMENT = partial(apply_payment, amount=120, year=2027) + + +def modifier(simulation): + return PAYMENT(simulation) +""" + + +def test_a_module_level_partial_the_modifier_calls_is_refused(tmp_path): + """W5's review of uk-autumn-budget-dashboard-2026#23: editing the partial's + captured amount from 120 to 240 changed the payment under one digest.""" + + name = "w4_receipt_globals_partial" + try: + module, _ = _import_source(tmp_path, name, PARTIAL_SOURCE) + with pytest.raises( + ReceiptError, + match="line 9 assigns PAYMENT something other than a literal", + ): + _identity(module.modifier) + module.PAYMENT.keywords["amount"] = 240 + with pytest.raises(ReceiptError, match="assigns PAYMENT"): + _identity(module.modifier) + finally: + sys.modules.pop(name, None) + + +@pytest.mark.parametrize( + ("source", "match"), + [ + pytest.param( + "import os\nRATE = float(os.environ.get('W4_RATE', '0.21'))\n\n\n" + "def raise_rate(simulation):\n simulation.rate = RATE\n", + "assigns RATE something other than a literal", + id="computed-from-the-environment", + ), + pytest.param( + "from functools import partial\n\n\n" + "def _pay(simulation, amount):\n simulation.paid = amount\n\n\n" + "PAY = partial(_pay, amount=120)\n\n\n" + "def _helper(simulation):\n PAY(simulation)\n\n\n" + "def raise_rate(simulation):\n _helper(simulation)\n", + "assigns PAY something other than a literal", + id="partial-through-a-helper", + ), + pytest.param( + "import functools\nSCALE = float('2')\n\n\n" + "def scaled(function):\n @functools.wraps(function)\n" + " def wrapper(value):\n return function(value) * SCALE\n" + " return wrapper\n\n\n" + "@scaled\ndef _helper(value):\n return value\n\n\n" + "def raise_rate(simulation):\n simulation.rate = _helper(0.21)\n", + "assigns SCALE something other than a literal", + id="computed-global-in-a-decorator-wrapper", + ), + pytest.param( + "import os\nSCALE = len(os.sep)\n\n\n" + "def raise_rate(simulation):\n scale = lambda rate: rate * SCALE\n" + " simulation.rate = scale(0.21)\n", + "assigns SCALE something other than a literal", + id="computed-global-in-nested-code", + ), + pytest.param( + "class Measure:\n rate = 0.21\n\n\n" + "def raise_rate(simulation):\n simulation.rate = Measure.rate\n", + "defines class Measure", + id="class-in-the-file", + ), + pytest.param( + "from math import pi\n\n\n" + "def raise_rate(simulation):\n simulation.rate = pi / 10\n", + r"pi\W.*an imported value of type float", + id="imported-value", + ), + pytest.param( + "from functools import partial\nfrom operator import mul\n" + "from math import floor as PAY\n" + "PAY = partial(mul, 2)\n\n\n" + "def raise_rate(simulation):\n simulation.rate = PAY(0.21)\n", + "line 4 assigns PAY something other than a literal", + id="import-rebound-to-a-partial", + ), + pytest.param( + "from math import *\n\n\n" + "def raise_rate(simulation):\n simulation.rate = floor(e)\n", + "an imported value of type float", + id="star-imported-value", + ), + pytest.param( + "COUNT = 0\n\n\n" + "def raise_rate(simulation):\n global COUNT\n COUNT += 1\n" + " simulation.rate = 0.21\n", + "line 5 declares COUNT global", + id="assigns-a-global", + ), + ], +) +def test_modifiers_reading_module_state_the_digest_cannot_bind_are_refused( + tmp_path, source, match +): + name = "w4_receipt_globals_refused" + try: + module, _ = _import_source(tmp_path, name, source) + with pytest.raises(ReceiptError, match=match) as refused: + _identity(module.raise_rate) + assert "cannot be bound" in str(refused.value) + finally: + sys.modules.pop(name, None) + + +def test_a_partial_imported_from_another_module_is_refused(tmp_path): + """``from params import PAY`` puts the partial in the modifier's module.""" + + params = "w4_receipt_globals_params" + name = "w4_receipt_globals_imports_partial" + try: + _import_source( + tmp_path, + params, + "from functools import partial\nfrom operator import mul\n\n" + "PAY = partial(mul, 120)\n", + ) + module, _ = _import_source( + tmp_path, + name, + f"from {params} import PAY\n\n\n" + "def raise_rate(simulation):\n simulation.paid = PAY(1)\n", + ) + with pytest.raises( + ReceiptError, + match=r"PAY is an imported value of type functools\.partial", + ): + _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + sys.modules.pop(params, None) + + +def test_a_name_set_on_the_module_after_import_is_refused(tmp_path): + source = "def raise_rate(simulation):\n simulation.rate = EXTRA\n" + name = "w4_receipt_globals_injected" + try: + module, _ = _import_source(tmp_path, name, source) + _identity(module.raise_rate) # EXTRA is unbound: running it fails + module.EXTRA = 0.30 + with pytest.raises( + ReceiptError, match="EXTRA is a value of type float set on the module" + ): + _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +ALLOWED_GLOBALS_SOURCE = """ +import math +from functools import partial +from math import floor + +RATE = 0.21 + + +def _countdown(depth): + return 0 if depth == 0 else _countdown(depth - 1) + + +def raise_rate(simulation): + class Local: + value = RATE + + simulation.rate = floor(Local.value * 100) / 100 + _countdown(2) + simulation.tau = math.tau + simulation.module = __name__ + simulation.count = len([RATE]) +""" + + +def test_literals_functions_imports_and_builtins_bind(tmp_path): + """Recursion terminates, a class inside the modifier reads its own body, + and a module attribute is bound by the code revision.""" + + name = "w4_receipt_globals_allowed" + try: + module, _ = _import_source(tmp_path, name, ALLOWED_GLOBALS_SOURCE) + identity = scenario_reform_identity( + _scenario(simulation_modifier=module.raise_rate), code_revision=REVISION + ) + assert identity.binding == "code_revision" + finally: + sys.modules.pop(name, None) + + +#: Each kind of module global, as (definition, expression reading it, bindable). +GLOBAL_KINDS = { + "literal": ("K_LITERAL = 0.21", "K_LITERAL", True), + "function": ("def k_function():\n return 1", "k_function()", True), + "imported_callable": ("from math import floor as k_floor", "k_floor(1.5)", True), + "module": ("import math as k_math", "k_math.tau", True), + "builtin": ("", "len(())", True), + "partial": ( + "from functools import partial as _partial\nK_PARTIAL = _partial(abs)", + "K_PARTIAL(-1)", + False, + ), + "computed": ("K_COMPUTED = abs(-1)", "K_COMPUTED", False), + "class": ("class KClass:\n pass", "KClass", False), + "imported_value": ("from math import e as k_e", "k_e", False), +} +_GLOBAL_MODULES = iter(range(10**9)) + + +@settings(max_examples=60, deadline=None) +@given( + kinds=st.lists( + st.sampled_from(sorted(GLOBAL_KINDS)), min_size=1, max_size=5, unique=True + ), + through_helper=st.booleans(), +) +def test_a_modifier_binds_exactly_when_every_global_it_reads_binds( + kinds, through_helper +): + """Invariant: an identity is issued if and only if every module global the + modifier's code reads, directly or through a function in the file, is a + literal, a function in the file, an imported callable or module, or a + builtin; otherwise the refusal names an unbindable global.""" + + import tempfile + + definitions = "\n\n\n".join( + GLOBAL_KINDS[kind][0] for kind in kinds if GLOBAL_KINDS[kind][0] + ) + reads = ", ".join(GLOBAL_KINDS[kind][1] for kind in kinds) + if through_helper: + body = ( + f"def _reader():\n return ({reads},)\n\n\n" + "def raise_rate(simulation):\n simulation.values = _reader()\n" + ) + else: + body = f"def raise_rate(simulation):\n simulation.values = ({reads},)\n" + source = f"{definitions}\n\n\n{body}" + name = f"w4_receipt_globals_property_{next(_GLOBAL_MODULES)}" + unbindable = [kind for kind in kinds if not GLOBAL_KINDS[kind][2]] + with tempfile.TemporaryDirectory() as directory: + try: + module, _ = _import_source(Path(directory), name, source) + if not unbindable: + assert _identity(module.raise_rate) + return + with pytest.raises(ReceiptError) as refused: + _identity(module.raise_rate) + named = [ + kind + for kind in unbindable + if GLOBAL_KINDS[kind][1].split("(")[0] in str(refused.value) + ] + assert named, str(refused.value) + finally: + sys.modules.pop(name, None) + + +def test_signed_zero_defaults_differ(tmp_path, monkeypatch): + """Round-5 review: copysign tells 0.0 from -0.0, so the digest must too.""" + + plus = _computed_default_identity(tmp_path / "a", monkeypatch, "0.0") + minus = _computed_default_identity(tmp_path / "b", monkeypatch, "-0.0") + assert plus != minus + source = "def raise_rate(simulation, value=0.0):\n simulation.value = value\n" + name = "w4_receipt_r5_literal_zero" + try: + module, _ = _import_source(tmp_path / "c", name, source) + module.raise_rate.__defaults__ = (-0.0,) + with pytest.raises(ReceiptError, match="default arguments differ"): + _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +def test_a_module_that_imports_what_its_modifier_calls_binds(tmp_path): + """Round-5 review: compiled alone, math.copysign compiled differently.""" + + source = ( + "import math\n\n\ndef raise_rate(simulation):\n" + " simulation.rate = math.copysign(0.21, 1.0)\n" + ) + name = "w4_receipt_r5_import_math" + try: + module, _ = _import_source(tmp_path, name, source) + assert _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +def test_numpy_booleans_are_neither_years_nor_schema_versions( + dataset_file, certified_receipt +): + """Round-5 review: np.bool_ is a boolean, not 1 or 0.""" + + with pytest.raises(ReceiptError, match="years must be integers"): + _build(dataset_file, years=[np.bool_(True)]) + payload = certified_receipt.model_dump() + payload["schema_version"] = np.bool_(True) + with pytest.raises(ValidationError): + ExecutionReceipt.model_validate(payload) + + +def test_default_dictionary_keys_must_be_exact_strings(tmp_path, monkeypatch): + class Key(str): + pass + + with pytest.raises(ReceiptError, match="keys must be strings"): + receipt_module._typed_value({Key("rate"): 0.21}) + + +def _flip_nan_signs(code): + import math + + def flipped(constant): + if isinstance(constant, float) and math.isnan(constant): + return -constant + if isinstance(constant, complex) and math.isnan(constant.real): + return complex(-constant.real, constant.imag) + return constant + + return code.replace(co_consts=tuple(flipped(c) for c in code.co_consts)) + + +@pytest.mark.parametrize( + "source", + ["value = 1e999 - 1e999", "value = complex(1e999 - 1e999, 1)"], + ids=["float", "complex"], +) +def test_a_nan_constant_of_the_other_sign_is_other_code(source): + """Round-6 review H1: float.hex() writes both signs of NaN as "nan".""" + + from policyengine.provenance.bytecode import code_shape + + if "complex" in source: + source = "value = (1e999 - 1e999) + 1j" + code = compile(source, "x", "exec", dont_inherit=True) + assert code_shape(_flip_nan_signs(code)) != code_shape(code) + assert code_shape(code) == code_shape( + compile(source, "y", "exec", dont_inherit=True) + ) + + +def test_code_differing_only_in_its_exception_table_is_other_code(): + """A survivor of round-6 mutation testing: the table decides what is caught.""" + + from policyengine.provenance.bytecode import code_shape + + source = "try:\n value = int('x')\nexcept ValueError:\n value = 0\n" + code = compile(source, "x", "exec", dont_inherit=True) + assert code.co_exceptiontable + assert code_shape(code.replace(co_exceptiontable=b"")) != code_shape(code) + + +REBINDING_SOURCE = """ +def helper(): + return 0.21 + + +def alternative(): + return 0.99 + + +def raise_rate(simulation): + simulation.rate = helper() +""" + + +def test_a_helper_rebound_to_another_function_of_the_file_is_refused(tmp_path): + """Round-6 review M2: each function still matches its source, but helper + now names alternative's code, which returns 0.99.""" + + name = "w4_receipt_r6_rebound_helper" + try: + module, _ = _import_source(tmp_path, name, REBINDING_SOURCE) + _identity(module.raise_rate) + module.original_helper = module.helper + module.helper = module.alternative + with pytest.raises(ReceiptError, match="helper is bound to alternative"): + _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +SHARED_DEFAULTS_SOURCE = """ +def _helper(value, scales=[2.0]): + return value * scales[0] + + +def raise_rate(simulation, first=list(), second=[], scales=[2.0]): + first.append(1) # visible through second only when they are one list + simulation.rate = 0.99 if second else _helper(0.21, scales) + first.clear() +""" + + +def _shared_lists(module): + shared = [] + module.raise_rate.__defaults__ = (shared, shared, [2.0]) + + +def _list_holding_itself(module): + loop = [] + loop.append(loop) + module.raise_rate.__defaults__ = (loop, [], [2.0]) + + +def _shared_across_functions(module): + module._helper.__defaults__ = (module.raise_rate.__defaults__[2],) + + +@pytest.mark.parametrize( + "share", + [_shared_lists, _list_holding_itself, _shared_across_functions], + ids=["two-defaults", "itself", "two-functions"], +) +def test_defaults_that_are_one_mutable_object_are_refused(tmp_path, share): + """Round-6 review M4: one shared list and two equal lists encode alike.""" + + name = "w4_receipt_r6_shared_defaults" + try: + module, _ = _import_source(tmp_path, name, SHARED_DEFAULTS_SOURCE) + _identity(module.raise_rate) + share(module) + with pytest.raises(ReceiptError, match="are one list object"): + _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +def test_bytes_set_and_complex_constants_bind_and_are_checked(tmp_path): + """Round-6 review L5: exact literal comparison, not the defaults encoder.""" + + source = ( + "TOKEN = b'publication'\nYEARS = {2026, 2027}\nPHASE = 1j\n" + "ZEROS = {0.0}\n\n\n" + "def raise_rate(simulation):\n" + " simulation.rate = 0.21 if TOKEN and 2026 in YEARS else 0.0\n" + " simulation.phase = (PHASE, ZEROS)\n" + ) + name = "w4_receipt_r6_literal_kinds" + try: + module, _ = _import_source(tmp_path, name, source) + _identity(module.raise_rate) + for attribute, value in [ + ("YEARS", {2026, 2028}), + ("TOKEN", bytearray(b"publication")), + ("PHASE", -1j), + ("ZEROS", {-0.0}), # equal under ==, another bit pattern + ]: + original = getattr(module, attribute) + setattr(module, attribute, value) + with pytest.raises(ReceiptError, match=f"{attribute} is"): + _identity(module.raise_rate) + setattr(module, attribute, original) + _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +LITERALS = st.recursive( + st.none() + | st.booleans() + | st.integers(-(2**70), 2**70) + | st.floats(allow_nan=False) + | st.complex_numbers(allow_nan=False) + | st.text(max_size=5) + | st.binary(max_size=5) + | st.just(Ellipsis), + lambda children: ( + st.lists(children, max_size=3) + | st.lists(children, max_size=3).map(tuple) + | st.dictionaries(st.text(max_size=3), children, max_size=3) + | st.frozensets(st.integers(-3, 3) | st.text(max_size=2), max_size=3) + ), + max_leaves=8, +) + + +@settings(max_examples=300, deadline=None) +@given(left=LITERALS, right=LITERALS) +def test_same_literal_is_exact_equality(left, right): + """Invariants: a value is the same as itself and as its own round trip + through source; values the same are equal under ``==``; and changing one + float's sign of zero or one int to float makes them differ.""" + + import ast as _ast + + same = receipt_module._same_literal + assert same(left, left) + try: + written = _ast.literal_eval(repr(left)) + except (ValueError, SyntaxError): + written = None # inf, frozenset() and Ellipsis have no literal form + # repr writes complex(-0.0, 0.0) as "(-0+0j)", which reads back +0.0. + if written is not None and "j" not in repr(left): + assert same(left, written) + if same(left, right): + assert left == right or left != left # a complex NaN cannot occur + assert not same(0.0, -0.0) + assert not same([1], [1.0]) + assert not same((1,), [1]) + assert not same({"a": 1, "b": 2}, {"b": 2, "a": 1}) + + +_REAL_PROJECTED_YEARS = receipt_module._projected_years + + +def test_projected_years_start_at_the_datasets_own_year(monkeypatch): + """A survivor of round-6 mutation testing: an extension that also holds + earlier years must not make them supported.""" + + import types + + stubs = { + "policyengine_uk": types.ModuleType("policyengine_uk"), + "policyengine_uk.data": types.ModuleType("policyengine_uk.data"), + "policyengine_uk.data.dataset_schema": types.ModuleType("schema"), + "policyengine_uk.data.economic_assumptions": types.ModuleType("assumptions"), + "policyengine_uk.system": types.ModuleType("system"), + } + stubs["policyengine_uk.data.dataset_schema"].UKSingleYearDataset = lambda path: ( + types.SimpleNamespace(time_period=2023) + ) + stubs["policyengine_uk.data.economic_assumptions"].extend_single_year_dataset = ( + lambda dataset, parameters: types.SimpleNamespace(years=[2022, 2023, 2024]) + ) + stubs["policyengine_uk.system"].system = types.SimpleNamespace(parameters=None) + for module_name, module in stubs.items(): + monkeypatch.setitem(sys.modules, module_name, module) + unwrapped = getattr(_REAL_PROJECTED_YEARS, "__wrapped__", _REAL_PROJECTED_YEARS) + assert unwrapped("unused.h5", "0" * 64, "0.0.0") == (2023, 2024) + + +# --- Round-7 review findings: the module shape a digest can bind ------------------ + + +MEASURE_TAIL = "\n\ndef raise_rate(simulation):\n simulation.rate = 0.21\n" + + +@pytest.mark.parametrize( + ("source", "match"), + [ + pytest.param( + "import os\nDEBUG = False\nif os.environ.get('W4_X'):\n DEBUG = True\n" + + MEASURE_TAIL, + "line 3 is a module-level If statement", + id="conditional-literal", + ), + pytest.param( + "import os\nif os.environ.get('W4_ALT'):\n from math import ceil as pick\n" + "else:\n from math import floor as pick\n" + MEASURE_TAIL, + "module-level If statement", + id="conditional-import", + ), + pytest.param( + "import sys\nif sys.version_info >= (3,):\n" + " def raise_rate(simulation):\n simulation.rate = 0.21\n", + "module-level If statement", + id="function-in-a-block", + ), + pytest.param( + "try:\n import numpy\nexcept ImportError:\n numpy = None\n" + + MEASURE_TAIL, + "module-level Try statement", + id="guarded-import", + ), + pytest.param( + "RATE = 0.21\nRATES = [RATE for RATE in range(3)]\n" + MEASURE_TAIL, + "line 2 assigns RATES something other than a literal", + id="comprehension", + ), + pytest.param( + "RATE = 0.21\nmapper = lambda value: (RATE := value)\n" + MEASURE_TAIL, + "assigns mapper something other than a literal", + id="lambda", + ), + pytest.param( + "RATE, UNUSED = 0.21, 1\n" + MEASURE_TAIL, + "line 1 assigns to something other than one name", + id="unpacking", + ), + pytest.param( + "FIRST = SECOND = []\n" + MEASURE_TAIL, + "assigns to something other than one name", + id="chained-assignment", + ), + pytest.param( + "RATE = 0.21\nRATE = 0.30\n" + MEASURE_TAIL, + "RATE is bound twice", + id="rebinding", + ), + pytest.param( + "RATE = 0.21\nRATE += 0.01\n" + MEASURE_TAIL, + "module-level AugAssign statement", + id="augmented-assignment", + ), + pytest.param( + "RATE = 0.21\nx: (RATE := 0.30) = 1\n" + MEASURE_TAIL, + "binds a name in the annotation of x", + id="annotation-walrus", + ), + pytest.param( + "RATE = 0.21\n\n\ndef _helper(value=(RATE := 0.30)):\n return value\n" + + MEASURE_TAIL, + "a default or annotation of _helper binds a name", + id="default-walrus", + ), + pytest.param( + "import functools\n\n\n@functools.cache\ndef _helper():\n return 0.21\n" + + MEASURE_TAIL, + "line 5 decorates _helper", + id="decorator", + ), + pytest.param( + "class Holder:\n rate = 0.21\n" + MEASURE_TAIL, + "line 1 defines class Holder", + id="class", + ), + pytest.param( + "def _helper():\n return _helper.amount\n\n\n_helper.amount = 120\n" + + MEASURE_TAIL, + "line 5 assigns to something other than one name", + id="function-attribute", + ), + pytest.param( + "import logging\nlogging.basicConfig()\n" + MEASURE_TAIL, + "line 2 is a module-level Expr statement", + id="call-at-import", + ), + pytest.param( + "WEEKLY = 520 / 52\n" + MEASURE_TAIL, + "line 1 assigns WEEKLY something other than a literal", + id="literal-arithmetic-division", + ), + pytest.param( + "MILLION = 10 ** 6\n" + MEASURE_TAIL, + "line 1 assigns MILLION something other than a literal", + id="literal-arithmetic-power", + ), + pytest.param( + "from typing import TYPE_CHECKING\nif TYPE_CHECKING:\n" + " from collections import OrderedDict\n" + MEASURE_TAIL, + "line 2 is a module-level If statement", + id="type-checking-block", + ), + pytest.param( + "type Rate = float\n" + MEASURE_TAIL, + "line 1 is a module-level TypeAlias statement", + id="type-alias", + ), + pytest.param( + "def _helper[*Ts](*args: *Ts) -> tuple[*Ts]:\n return args\n" + + MEASURE_TAIL, + "line 1 computes an annotation of _helper", + id="unpacked-parameter-annotation", + ), + pytest.param( + # Round-13 review M1: Pydantic reads the bound when it validates, + # and typing's cache can hand it List[Union[float, Decimal]]. + "from decimal import Decimal\nfrom typing import List, Union\n\n\n" + "def _pennies[T: List[Union[Decimal, float]]](amounts: T):\n" + " return int(amounts[0] * 100)\n" + MEASURE_TAIL, + "line 5: type parameter T of _pennies has a bound", + id="type-parameter-bound", + ), + pytest.param( + "def _helper[T: (int, float)](value: T) -> T:\n return value\n" + + MEASURE_TAIL, + "line 1: type parameter T of _helper has a bound, constraints", + id="type-parameter-constraints", + ), + pytest.param( + "def _helper[T = float](value: T) -> T:\n return value\n" + MEASURE_TAIL, + "line 1: type parameter T of _helper has a bound, constraints or default", + id="type-parameter-default", + ), + pytest.param( + "def _helper[U, **P = [int]](*args: P.args, **kwargs: P.kwargs):\n" + " return args\n" + MEASURE_TAIL, + "line 1: type parameter P of _helper has a bound, constraints or default", + id="paramspec-default", + ), + pytest.param( + "def _helper[*Ts = *tuple[int]](*args):\n return args\n" + MEASURE_TAIL, + "line 1: type parameter Ts of _helper has a bound, constraints or default", + id="typevartuple-default", + ), + ], +) +def test_module_shapes_a_digest_cannot_bind_are_refused(tmp_path, source, match): + """Rounds 4-7: conditional, repeated or computed bindings, decorators, + classes and code run at import hold state the source does not fix, so a + modifier's module may hold only imports, literals and undecorated + functions, each name bound once.""" + + try: + compile(source, "x", "exec", dont_inherit=True) + except SyntaxError: + pytest.skip("this Python refuses the construct itself") + name = "w4_receipt_r7_shape" + try: + module, _ = _import_source(tmp_path, name, source) + with pytest.raises(ReceiptError, match=match) as refused: + _identity(module.raise_rate) + assert "cannot be bound" in str(refused.value) + finally: + sys.modules.pop(name, None) + + +def test_docstrings_annotations_and_a_main_guard_are_allowed(tmp_path): + source = ( + '"""A measure."""\n\nfrom __future__ import annotations\n\nimport math\n' + "RATE: float = 0.21\nLIMIT: int\n\n\n" + "def raise_rate(simulation, cap: float = 1.0) -> None:\n" + ' """Raise the rate."""\n simulation.rate = min(RATE, cap) * math.tau\n\n\n' + 'if __name__ == "__main__":\n raise_rate(object())\n' + ) + name = "w4_receipt_r7_allowed_shape" + try: + module, _ = _import_source(tmp_path, name, source) + assert _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +def test_a_main_guard_with_an_else_branch_is_refused(tmp_path): + """The else branch runs on import.""" + + source = ( + 'if __name__ == "__main__":\n pass\nelse:\n RATE = 0.30\n' + MEASURE_TAIL + ) + name = "w4_receipt_r7_main_else" + try: + module, _ = _import_source(tmp_path, name, source) + with pytest.raises(ReceiptError, match="module-level If statement"): + _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +INDIRECT_SOURCE = """ +import sys + +RATE = 0.21 + + +def raise_rate(simulation): + module = sys.modules[__name__] + simulation.rate = getattr(module, "PAYMENT", lambda: RATE)() +""" + + +def test_names_set_on_the_module_are_refused_however_they_are_read(tmp_path): + """Round-7 review M2: getattr on the module reads PAYMENT without a + global load; every name in the namespace must be one the source binds.""" + + name = "w4_receipt_r7_indirect" + try: + module, _ = _import_source(tmp_path, name, INDIRECT_SOURCE) + _identity(module.raise_rate) + module.PAYMENT = functools.partial(float, "0.99") + with pytest.raises( + ReceiptError, match="PAYMENT is a value of type functools.partial set on" + ): + _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +@pytest.mark.parametrize( + ("body", "match"), + [ + ("globals()['RATE']", "uses globals"), + ("vars()['RATE']", "uses vars"), + ("locals().get('RATE', RATE)", "uses locals"), + ("eval('RATE')", "uses eval"), + ("exec('pass') or RATE", "uses exec"), + ("compile('RATE', 'x', 'eval') and RATE", "uses compile"), + ("__import__('math').floor(RATE)", "uses __import__"), + ("breakpoint() or RATE", "uses breakpoint"), + ("id(RATE) and RATE", "uses id"), + ], +) +def test_names_that_reach_the_namespace_or_identities_are_refused( + tmp_path, body, match +): + """Round-7 review M2 and round-8 M4: refused by name anywhere.""" + + source = ( + f"RATE = 0.21\n\n\ndef raise_rate(simulation):\n simulation.rate = {body}\n" + ) + name = "w4_receipt_r8_refused_names" + try: + module, _ = _import_source(tmp_path, name, source) + with pytest.raises(ReceiptError, match=match): + _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +@pytest.mark.parametrize( + ("header", "body", "match"), + [ + ("from builtins import eval as run\n", "run('RATE')", "imports builtins"), + ("import builtins\n", "builtins.eval('RATE')", "imports builtins"), + ( + "from operator import is_\n", + "0.99 if is_(RATE, RATE) else RATE", + "imports is_", + ), + ( + "import operator\n", + "0.99 if operator.is_(RATE, RATE) else RATE", + r"uses \.is_", + ), + ("import operator\n", "operator.is_not(RATE, None) and RATE", r"uses \.is_not"), + ], + ids=[ + "builtins-alias", + "builtins-module", + "operator-is", + "operator-attribute", + "is-not", + ], +) +def test_aliases_of_refused_names_are_refused(tmp_path, header, body, match): + """Round-8 review M3/M4: from builtins import eval as run, operator.is_.""" + + source = ( + header + + f"RATE = 0.21\n\n\ndef raise_rate(simulation):\n simulation.rate = {body}\n" + ) + name = "w4_receipt_r8_aliases" + try: + module, _ = _import_source(tmp_path, name, source) + with pytest.raises(ReceiptError, match=match): + _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +def test_an_import_rebound_at_run_time_is_refused(tmp_path): + """Round-7 review M4: the name must still be what its import binds.""" + + import math + + source = "from math import floor\n\n\ndef raise_rate(simulation):\n simulation.rate = floor(0.21)\n" + name = "w4_receipt_r7_import_rebound" + try: + module, _ = _import_source(tmp_path, name, source) + _identity(module.raise_rate) + module.floor = math.ceil + with pytest.raises( + ReceiptError, match="floor is not what `from math import floor` binds" + ): + _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +def test_an_imported_module_replaced_at_run_time_is_refused(tmp_path): + source = "import math\n\n\ndef raise_rate(simulation):\n simulation.rate = math.floor(0.21)\n" + name = "w4_receipt_r7_module_rebound" + try: + module, _ = _import_source(tmp_path, name, source) + _identity(module.raise_rate) + module.math = SimpleNamespace(floor=lambda value: 0.99) + with pytest.raises(ReceiptError, match="math is not what `import math` binds"): + _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +def test_an_unused_imported_value_is_refused(tmp_path): + """Indirect reads cannot be ruled out, so every imported name is judged.""" + + source = "from math import pi\n" + MEASURE_TAIL + name = "w4_receipt_r7_unused_import" + try: + module, _ = _import_source(tmp_path, name, source) + with pytest.raises(ReceiptError, match="pi is an imported value of type float"): + _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +@pytest.mark.parametrize("target", ["_helper", "raise_rate"]) +def test_functions_carrying_attributes_are_refused(tmp_path, target): + """Round-7 review M5: helper.amount set at run time is state.""" + + source = ( + "def _helper():\n return getattr(_helper, 'amount', 0.21)\n\n\n" + "def raise_rate(simulation):\n simulation.rate = _helper()\n" + ) + name = "w4_receipt_r7_function_attributes" + try: + module, _ = _import_source(tmp_path, name, source) + _identity(module.raise_rate) + getattr(module, target).amount = 240 + with pytest.raises(ReceiptError, match=f"function {target} carries attributes"): + _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +def test_literal_constants_that_are_one_mutable_object_are_refused(tmp_path): + """Round-7 review M6: rebinding B to A's list passes each value check.""" + + source = ( + "A = []\nB = []\n\n\ndef raise_rate(simulation):\n" + " A.append(1)\n simulation.rate = 0.99 if B else 0.21\n A.clear()\n" + ) + name = "w4_receipt_r7_constant_alias" + try: + module, _ = _import_source(tmp_path, name, source) + _identity(module.raise_rate) + module.B = module.A + with pytest.raises(ReceiptError, match="constant [AB] .*are one list object"): + _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +@pytest.mark.parametrize( + ("expression", "refused"), + [ + ("first is second", True), + ("first is not second", True), + ("id(first) == id(second)", True), + ("first is None", False), + ("second is not None", False), + ("type(first) is tuple", False), + ("first == second", False), + ], +) +def test_identity_comparisons_are_refused(tmp_path, expression, refused): + """Round-7 review M6: equal immutable values may or may not be one object + (the compiler merges equal constants), so code telling them apart by + identity cannot be bound by their values.""" + + source = ( + "def raise_rate(simulation, first=(1,), second=(1,)):\n" + f" simulation.rate = 0.99 if {expression} else 0.21\n" + ) + name = "w4_receipt_r7_identity" + try: + module, _ = _import_source(tmp_path, name, source) + if refused: + with pytest.raises(ReceiptError, match="compares identities|calls id"): + _identity(module.raise_rate) + else: + assert _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +def test_a_class_inside_the_modifier_reads_its_own_names(tmp_path): + """Round-7 review L7: LOAD_NAME in a class body reads the class's own + binding; a module literal of the same name is checked by value too.""" + + for prefix in ("", "first = 5\n"): + source = ( + prefix + "\n\ndef raise_rate(simulation):\n" + " class Local:\n first = 120\n second = first * 2\n" + " simulation.rate = Local.second\n" + ) + name = "w4_receipt_r7_class_local" + try: + module, _ = _import_source(tmp_path, name, source) + assert _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +@pytest.mark.parametrize( + ("body", "match", "minimum"), + [ + (" class Local:\n value = vars()\n", "uses vars", (3, 11)), + (" class Local:\n type Alias = vars\n", "uses vars", (3, 12)), + (" global RATE\n del RATE\n", "declares RATE global", (3, 11)), + ], + ids=["class-body", "class-type-alias", "delete-global"], +) +def test_refusals_reach_class_bodies_type_aliases_and_globals( + tmp_path, body, match, minimum +): + """Round-7 review L8: these compile to LOAD_NAME, LOAD_FROM_DICT_OR_GLOBALS + and DELETE_GLOBAL; the module-wide rules see them in the source.""" + + if sys.version_info < minimum: + pytest.skip("this Python has no such construct") + source = ( + "RATE = 0.21\n\n\ndef raise_rate(simulation):\n" + + body + + " simulation.rate = 0.21\n" + ) + name = "w4_receipt_r7_opcodes" + try: + module, _ = _import_source(tmp_path, name, source) + with pytest.raises(ReceiptError, match=match): + _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +def _with_constants(code, constants): + return code.replace(co_consts=tuple(constants)) + + +def _skip_if_constants_are_merged(): + """Python 3.14 merges equal constants whenever it makes a code object, a + loaded cache included, so two equal tuples cannot stay apart there.""" + + code = compile("x = None", "x", "exec", dont_inherit=True) + split = _with_constants(code, [((1,), tuple([1])), None]).co_consts[0] + if split[0] is split[1]: + pytest.skip("this Python merges equal constants in every code object") + + +def test_shared_constants_are_other_code_than_equal_ones(): + """Round-7 review H1: ``items[0] is items[1]`` differs between a shared + tuple and two equal tuples, so code_shape keeps sharing.""" + + _skip_if_constants_are_merged() + from policyengine.provenance.bytecode import code_shape + + code = compile("items = None\n", "x", "exec", dont_inherit=True) + inner = (1,) + shared = _with_constants(code, [(inner, inner), None]) + split = _with_constants(code, [((1,), tuple([1])), None]) + assert code_shape(shared) != code_shape(split) + other = tuple([1]) # another object, shared the same way + assert code_shape(shared) == code_shape( + _with_constants(code, [(other, other), None]) + ) + + +def test_sharing_counts_across_nested_code_and_in_frozensets(): + """Round-8 review: a constant shared by a function and its module, or + inside a frozenset, is told apart from equal copies.""" + + _skip_if_constants_are_merged() + from policyengine.provenance.bytecode import code_shape + + module = compile("def f():\n return None\n", "x", "exec", dont_inherit=True) + function = next(c for c in module.co_consts if isinstance(c, CodeType)) + item = (1, 2) + nested_shared = _with_constants( + module, + [ + _with_constants(function, [item]) + if c is function + else (item if c is None else c) + for c in module.co_consts + ], + ) + nested_split = _with_constants( + module, + [ + _with_constants(function, [tuple([1, 2])]) + if c is function + else (item if c is None else c) + for c in module.co_consts + ], + ) + assert code_shape(nested_shared) != code_shape(nested_split) + + member = (3,) + frozen_shared = _with_constants(module, [frozenset({(member, member)}), None]) + frozen_split = _with_constants(module, [frozenset({(member, tuple([3]))}), None]) + assert code_shape(frozen_shared) != code_shape(frozen_split) + + +# --- Round-8 review findings ------------------------------------------------------- + + +def test_annotated_constants_and_functions_bind_on_every_python(tmp_path): + """Round-8 review M5: Python 3.14 compiles annotations to __annotate__ + functions, which are the compiler's, not the module's definitions.""" + + source = ( + "RATE: float = 0.21\n\n\n" + "def raise_rate(simulation: object, cap: float = 1.0) -> None:\n" + " simulation.rate = min(RATE, cap)\n" + ) + name = "w4_receipt_r8_annotations" + try: + module, _ = _import_source(tmp_path, name, source) + assert _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +def test_a_main_guard_binds_nothing_on_import(tmp_path): + """Round-8 review L6: names bound only in the guard are not missing.""" + + source = ( + "RATE = 0.21\n\n\ndef raise_rate(simulation):\n simulation.rate = RATE\n\n\n" + 'if __name__ == "__main__":\n DEMO = 1\n\n def demo():\n return DEMO\n' + ) + name = "w4_receipt_r8_guard_bindings" + try: + module, _ = _import_source(tmp_path, name, source) + assert _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +@pytest.mark.parametrize( + ("guard", "allowed"), + [ + ('if __name__ == "__main__":', True), + ('if "__main__" == __name__:', True), + ('if __name__ != "__main__":', False), + ('if __name__ == "measures":', False), + ('if __name__ == "__main__" and RATE:', False), + ], +) +def test_only_a_true_main_guard_is_allowed(tmp_path, guard, allowed): + source = "RATE = 0.21\n" + guard + "\n pass\n" + MEASURE_TAIL + name = "w4_receipt_r8_guards" + try: + module, _ = _import_source(tmp_path, name, source) + if allowed: + assert _identity(module.raise_rate) + else: + with pytest.raises(ReceiptError, match="module-level If statement"): + _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +@pytest.mark.parametrize( + "source", + [ + "def type(value):\n return value\n\n\n" + "def raise_rate(simulation, first=(1,), second=(1,)):\n" + " simulation.rate = 0.99 if first is type(second) else 0.21\n", + "def raise_rate(simulation, type=tuple):\n simulation.rate = 0.21\n", + "def raise_rate(simulation):\n type = str\n simulation.rate = 0.21\n", + ], + ids=["def", "parameter", "local"], +) +def test_rebinding_type_is_refused(tmp_path, source): + """Round-8 review M3: is type(x) is safe only while type is the builtin.""" + + name = "w4_receipt_r8_type" + try: + module, _ = _import_source(tmp_path, name, source) + with pytest.raises(ReceiptError, match="rebinds type"): + _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +@pytest.mark.parametrize( + ("body", "match"), + [ + ("_helper.__doc__", r"reads \.__doc__"), + ("raise_rate.__annotations__", r"reads \.__annotations__"), + ("simulation.__dict__", r"reads \.__dict__"), + ("__doc__", "reads __doc__"), + ("__builtins__", "reads __builtins__"), + ("__annotations__", "reads __annotations__"), + ], +) +def test_introspection_is_refused(tmp_path, body, match): + """Round-8 review M1/M2: what a function or module says about itself.""" + + source = ( + '"""Doc."""\n\n\ndef _helper():\n """0.21"""\n\n\n' + f"def raise_rate(simulation):\n simulation.rate = {body}\n" + ) + name = "w4_receipt_r8_introspection" + try: + module, _ = _import_source(tmp_path, name, source) + with pytest.raises(ReceiptError, match=match): + _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +def test_name_and_file_may_be_read(tmp_path): + source = ( + "def raise_rate(simulation):\n" + " simulation.where = (__name__, __file__, raise_rate.__name__)\n" + ) + name = "w4_receipt_r8_name_file" + try: + module, _ = _import_source(tmp_path, name, source) + assert _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +FUNCTION_STATE_SOURCE = ( + '"""Measures."""\n\n\ndef _helper(value: float) -> float:\n' + ' """Double it.\n\n Indented.\n """\n return value * 2\n\n\n' + "def raise_rate(simulation):\n simulation.rate = _helper(0.21)\n" +) + + +def _rename(module): + module._helper.__name__ = "other" + + +def _redoc(module): + module._helper.__doc__ = "240" + + +def _reannotate(module): + module._helper.__annotations__ = {"value": float} + + +def _redoc_module(module): + module.__doc__ = "240" + + +def _inject_dunder(module): + module.__payment__ = 240 + + +def _foreign_builtins(module): + import builtins + + namespace = vars(module) + namespace["__builtins__"] = {**vars(builtins), "min": max} + + +def _function_with_foreign_builtins(module): + import builtins + import types + + namespace = vars(module) + original = namespace["__builtins__"] + namespace["__builtins__"] = {**vars(builtins), "min": max} + try: # a function takes its builtins from its globals when it is made + helper = types.FunctionType(module._helper.__code__, namespace) + finally: + namespace["__builtins__"] = original + module._helper = helper + + +@pytest.mark.parametrize( + ("change", "match"), + [ + (_rename, "renamed"), + (_function_with_foreign_builtins, "_helper does not use the builtins module"), + (_redoc, "_helper's __doc__ is not its source docstring"), + (_reannotate, "annotations are not those its source declares"), + (_redoc_module, "module's __doc__ is not its source docstring"), + (_inject_dunder, "__payment__ is a value of type int set on the module"), + (_foreign_builtins, "__builtins__ is not the builtins module"), + ], +) +def test_metadata_changed_at_run_time_is_refused(tmp_path, change, match): + """Round-8 review M1/M2: names, docstrings, annotations, builtins and + dunder names are not in the code, so each must be what the source made. + A multi-line docstring compares as the compiler stores it.""" + + name = "w4_receipt_r8_metadata" + try: + module, _ = _import_source(tmp_path, name, FUNCTION_STATE_SOURCE) + assert _identity(module.raise_rate) + change(module) + with pytest.raises(ReceiptError, match=match): + _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +@pytest.mark.parametrize( + ("annotation", "match"), + [ + ("int(1)", "computes the annotation of x"), + ("[t for t in (int,)]", "annotation of x"), + ("(lambda: int)", "annotation of x"), + ], + ids=["call", "comprehension", "lambda"], +) +def test_computed_annotations_are_refused(tmp_path, annotation, match): + """Round-8 review M2: a module annotation runs at import and computes + __annotations__; an annotation may only name types.""" + + source = f"x: {annotation} = 1\n" + MEASURE_TAIL + try: + compile(source, "x", "exec", dont_inherit=True) + except SyntaxError: + pytest.skip("this Python refuses the annotation itself") + name = "w4_receipt_r8_annotation" + try: + module, _ = _import_source(tmp_path, name, source) + with pytest.raises(ReceiptError, match=match): + _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +def test_a_computed_parameter_annotation_is_refused(tmp_path): + source = "def raise_rate(simulation: int(1)):\n simulation.rate = 0.21\n" + name = "w4_receipt_r8_parameter_annotation" + try: + module, _ = _import_source(tmp_path, name, source) + with pytest.raises(ReceiptError, match="computes an annotation of raise_rate"): + _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +def test_a_star_imported_name_rebound_at_run_time_is_refused(tmp_path): + """Round-8 review L7: a star-provided name must be that module's own and + judged like an explicit import.""" + + exporter = "w4_receipt_r8_star_source" + name = "w4_receipt_r8_star" + try: + _import_source( + tmp_path, + exporter, + '__all__ = ["double"]\n\n\ndef double(value):\n return value * 2\n', + ) + module, _ = _import_source( + tmp_path, + name, + f"from {exporter} import *\n\n\n" + "def raise_rate(simulation):\n simulation.rate = double(0.105)\n", + ) + assert _identity(module.raise_rate) + module.double = lambda value: 0.99 + with pytest.raises( + ReceiptError, match="double is a value of type function set on" + ): + _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + sys.modules.pop(exporter, None) + + +def test_a_star_import_of_data_values_is_refused(tmp_path): + source = "from math import *\n" + MEASURE_TAIL + name = "w4_receipt_r8_star_data" + try: + module, _ = _import_source(tmp_path, name, source) + with pytest.raises(ReceiptError, match="an imported value of type float"): + _identity(module.raise_rate) # math exports pi, e, tau, inf, nan + finally: + sys.modules.pop(name, None) + + +@pytest.mark.parametrize( + ("definition", "match"), + [ + ("__measure__", "defines __measure__"), + # A module __getattr__ answers every attribute, inspect's included. + ("__getattr__", "cannot be bound"), + ], +) +def test_dunder_definitions_are_refused(tmp_path, definition, match): + source = f"def {definition}(name=None):\n return 0.21\n" + MEASURE_TAIL + name = "w4_receipt_r8_dunder_def" + try: + module, _ = _import_source(tmp_path, name, source) + with pytest.raises(ReceiptError, match=match): + _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +# --- Round-9 review findings ------------------------------------------------------- + + +@pytest.mark.parametrize( + "package", ["policyengine", "policyengine-uk", "policyengine-core"] +) +def test_versions_installed_after_loading_are_refused( + dataset_file, monkeypatch, package +): + """Round-9 review H1: upgrading a package mid-session changes what the + metadata says but not the code already imported.""" + + from policyengine.provenance import loaded + + _build(dataset_file) # the installed versions are what was loaded + monkeypatch.setitem(loaded._LOADED, package, "0.0.1") + with pytest.raises( + ReceiptError, + match=rf"{package} 0\.0\.1 was loaded in this process, but .* is installed now", + ): + _build(dataset_file) + + +def test_the_first_loaded_version_is_the_one_kept(monkeypatch): + from policyengine.provenance import loaded + + monkeypatch.setattr(loaded, "_LOADED", {}) + loaded.record_loaded_versions(["policyengine-uk", "no-such-distribution"]) + first = loaded.loaded_versions() + assert set(first) == {"policyengine-uk"} + monkeypatch.setattr( + loaded.metadata, "version", lambda name: "99.0.0" + ) # an upgrade after loading + loaded.record_loaded_versions(["policyengine-uk"]) + assert loaded.loaded_versions() == first + + +def _assert_recording_subprocess(source): + result = subprocess.run( + [sys.executable, "-c", source], + env={**os.environ, "POLICYENGINE_SKIP_COUNTRY_IMPORTS": "1"}, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stdout + result.stderr + + +def test_importing_the_wrapper_records_its_actual_version(): + _assert_recording_subprocess( + "from importlib import metadata\n" + "import policyengine\n" + "from policyengine.provenance.loaded import loaded_versions\n" + "assert loaded_versions() == {'policyengine': metadata.version('policyengine')}\n" + ) + + +def test_constructing_a_model_records_all_actual_package_versions(): + _assert_recording_subprocess( + "from importlib import metadata\n" + "from policyengine.provenance import loaded\n" + "from policyengine.tax_benefit_models.common.model_version import MicrosimulationModelVersion\n" + "loaded._LOADED.clear()\n" + "class Recorded(Exception):\n pass\n" + "class RecordingProbe(MicrosimulationModelVersion):\n" + " country_code = 'uk'\n" + " package_name = 'policyengine-uk'\n" + " @classmethod\n" + " def _get_runtime_data_build_metadata(cls):\n" + " raise Recorded\n" + "try:\n RecordingProbe()\n" + "except Recorded:\n pass\n" + "else:\n raise AssertionError('probe did not reach its recording hook')\n" + "assert loaded.loaded_versions() == {\n" + " name: metadata.version(name)\n" + " for name in ('policyengine', 'policyengine-uk', 'policyengine-core')\n" + "}\n" + ) + + +def test_missing_loaded_versions_build_when_installation_predates_process( + dataset_file, monkeypatch +): + from policyengine.provenance import loaded + + monkeypatch.setattr(loaded, "_LOADED", {}) + receipt = _build(dataset_file) + assert receipt.wrapper.version == INSTALLED_WRAPPER + assert receipt.model.version == INSTALLED_UK + assert receipt.core.version == INSTALLED_CORE + + +@pytest.mark.parametrize( + "package", ["policyengine", "policyengine-uk", "policyengine-core"] +) +def test_each_package_installed_during_process_is_refused( + dataset_file, monkeypatch, tmp_path, package +): + directory = tmp_path / "new.dist-info" + directory.mkdir() + (directory / "METADATA").write_text("new installation") + original = receipt_module._distribution_metadata_directory + monkeypatch.setattr( + receipt_module, + "_distribution_metadata_directory", + lambda name: directory if name == package else original(name), + ) + with pytest.raises( + ReceiptError, + match=rf"{package} was installed or changed after this process started.*restart", + ): + _build(dataset_file) + + +@pytest.mark.parametrize( + "filename", ["", "METADATA", "RECORD", "INSTALLER", "direct_url.json"] +) +@pytest.mark.parametrize("clock", ["st_mtime", "st_ctime"]) +def test_every_install_metadata_timestamp_counts(monkeypatch, filename, clock): + class MetadataPath: + def __init__(self, name=""): + self.name = name + + def __truediv__(self, name): + return MetadataPath(name) + + def exists(self): + return True + + def stat(self): + values = {"st_mtime": 1.0, "st_ctime": 1.0} + if self.name == filename: + values[clock] = 3.0 + return SimpleNamespace(**values) + + monkeypatch.setattr( + receipt_module, "_distribution_metadata_directory", lambda name: MetadataPath() + ) + monkeypatch.setattr( + receipt_module.psutil, + "Process", + lambda: SimpleNamespace(create_time=lambda: 2.0), + ) + with pytest.raises(ReceiptError, match="installed or changed after this process"): + receipt_module._refuse_package_changed_after_process_start("policyengine") + + +def test_unlocatable_install_metadata_is_refused(monkeypatch, tmp_path): + distribution = SimpleNamespace( + files=[], locate_file=lambda path: tmp_path, version="1.0" + ) + monkeypatch.setattr( + receipt_module.metadata, "distribution", lambda name: distribution + ) + with pytest.raises(ReceiptError, match="Cannot locate.*policyengine.*restart"): + receipt_module._refuse_package_changed_after_process_start("policyengine") + + +def test_install_metadata_with_empty_record_uses_the_public_root(monkeypatch, tmp_path): + directory = tmp_path / "policyengine-1.0.dist-info" + directory.mkdir() + (directory / "METADATA").write_text( + "Metadata-Version: 2.1\nName: policyengine\nVersion: 1.0\n" + ) + (directory / "RECORD").write_text("") + distribution = metadata.Distribution.at(directory) + monkeypatch.setattr( + receipt_module.metadata, "distribution", lambda name: distribution + ) + assert receipt_module._distribution_metadata_directory("policyengine") == directory + + +@pytest.mark.parametrize("stem", ["policyengine-1.0", "policyengine,local-1.0"]) +def test_install_metadata_uses_record_without_allocating_all_file_paths( + monkeypatch, tmp_path, stem +): + directory = tmp_path / f"{stem}.dist-info" + directory.mkdir() + record = f'\nother/module.py,,\npackage/METADATA,,\n"{stem}.dist-info/METADATA",,\n' + + class Distribution: + @property + def files(self): + raise AssertionError( + "RECORD is available; a full file listing is unnecessary" + ) + + def read_text(self, filename): + assert filename == "RECORD" + return record + + def locate_file(self, path): + return tmp_path / path if path else tmp_path / "no-root" + + monkeypatch.setattr( + receipt_module.metadata, "distribution", lambda name: Distribution() + ) + assert receipt_module._distribution_metadata_directory("policyengine") == directory + + +def test_install_metadata_prefers_the_distributions_public_file_listing( + monkeypatch, tmp_path +): + directory = tmp_path / "policyengine-1.0.dist-info" + directory.mkdir() + listed = Path("policyengine-1.0.dist-info/METADATA") + distribution = SimpleNamespace( + files=[listed], + locate_file=lambda path: tmp_path / path if path else tmp_path / "no-root", + ) + monkeypatch.setattr( + receipt_module.metadata, "distribution", lambda name: distribution + ) + assert receipt_module._distribution_metadata_directory("policyengine") == directory + + +def test_install_metadata_fallback_matches_both_package_and_version( + monkeypatch, tmp_path +): + for package, version in [ + ("policyengine", "1.0"), + ("policyengine", "2.0"), + ("other", "1.0"), + ]: + directory = tmp_path / f"{package}-{version}.dist-info" + directory.mkdir() + (directory / "METADATA").write_text( + f"Metadata-Version: 2.1\nName: {package}\nVersion: {version}\n" + ) + distribution = SimpleNamespace( + files=[], locate_file=lambda path: tmp_path, version="1.0" + ) + monkeypatch.setattr( + receipt_module.metadata, "distribution", lambda name: distribution + ) + assert receipt_module._distribution_metadata_directory("policyengine") == ( + tmp_path / "policyengine-1.0.dist-info" + ) + + +def test_ambiguous_install_metadata_is_refused(monkeypatch, tmp_path): + for stem in ("policyengine-1.0", "policyengine_duplicate-1.0"): + directory = tmp_path / f"{stem}.dist-info" + directory.mkdir() + (directory / "METADATA").write_text( + "Metadata-Version: 2.1\nName: policyengine\nVersion: 1.0\n" + ) + distribution = SimpleNamespace( + files=[], locate_file=lambda path: tmp_path, version="1.0" + ) + monkeypatch.setattr( + receipt_module.metadata, "distribution", lambda name: distribution + ) + with pytest.raises(ReceiptError, match="Cannot locate.*policyengine"): + receipt_module._distribution_metadata_directory("policyengine") + + +def test_unreadable_install_metadata_is_refused(monkeypatch): + def unreadable(name): + raise OSError("metadata unreadable") + + monkeypatch.setattr(receipt_module, "_distribution_metadata_directory", unreadable) + with pytest.raises( + ReceiptError, match="Cannot locate or read.*policyengine.*restart" + ): + receipt_module._refuse_package_changed_after_process_start("policyengine") + + +def test_unreadable_process_start_time_is_refused(monkeypatch): + def unreadable(): + raise receipt_module.psutil.AccessDenied() + + monkeypatch.setattr(receipt_module.psutil, "Process", unreadable) + with pytest.raises( + ReceiptError, match="Cannot locate or read.*policyengine.*restart" + ): + receipt_module._refuse_package_changed_after_process_start("policyengine") + + +@pytest.mark.parametrize( + ("header", "body", "match"), + [ + ("import typing\n", "typing.get_type_hints(_helper)", r"\.get_type_hints"), + ( + "from typing import get_type_hints\n", + "get_type_hints(_helper)", + "get_type_hints", + ), + ("import inspect\n", "inspect.signature(_helper)", r"\.signature"), + ("import inspect\n", "inspect.getfullargspec(_helper)", r"\.getfullargspec"), + ("import inspect\n", "inspect.get_annotations(_helper)", r"\.get_annotations"), + ("from inspect import signature\n", "signature(_helper)", "signature"), + ( + "from inspect import getfullargspec\n", + "getfullargspec(_helper)", + "getfullargspec", + ), + ( + "from inspect import get_annotations\n", + "get_annotations(_helper)", + "get_annotations", + ), + ( + "import inspect\n", + "inspect.Signature.from_callable(_helper)", + r"\.from_callable", + ), + ], + ids=[ + "get-type-hints", + "imported-get-type-hints", + "signature", + "argspec", + "get-annotations", + "imported-signature", + "imported-argspec", + "imported-get-annotations", + "signature-from-callable", + ], +) +def test_readers_of_annotation_values_are_refused(tmp_path, header, body, match): + """Explicit annotation readers remain outside the accepted module shape, + even though annotation values are checked.""" + + source = ( + header + "\n\ndef _helper(value: float) -> float:\n return value\n\n\n" + f"def raise_rate(simulation):\n simulation.hints = {body}\n" + ) + name = "w4_receipt_r9_annotation_readers" + try: + module, _ = _import_source(tmp_path, name, source) + with pytest.raises(ReceiptError, match=match) as refused: + _identity(module.raise_rate) + assert "reads annotation values" in str(refused.value) + finally: + sys.modules.pop(name, None) + + +DISPATCH_SOURCE = ( + "import functools\n\n\n" + "def default_annual_income(income):\n return income\n\n\n" + "def weekly_annual_income(income: int):\n return income * 52\n\n\n" + "def raise_rate(simulation):\n" + " annual_income = functools.singledispatch(default_annual_income)\n" + " annual_income.register(weekly_annual_income)\n" + " simulation.result = annual_income(simulation.income)\n" +) + + +@pytest.mark.parametrize("managed", [False, True], ids=["builder", "microsimulation"]) +def test_singledispatch_annotations_are_rechecked_at_receipt_build( + tmp_path, dataset_file, managed +): + name = "w4_receipt_r11_dispatch" + try: + module, _ = _import_source(tmp_path, name, DISPATCH_SOURCE) + identity = scenario_reform_identity( + _scenario(simulation_modifier=module.raise_rate), code_revision=REVISION + ) + bundle = _bundle_for(dataset_file.read_bytes()) + + def build(result): + if managed: + return build_execution_receipt_for_microsimulation( + _managed_simulation(dataset_file, bundle), + years=[2026], + reform=identity, + baseline=reform_identity(None), + bundle=bundle, + outputs={"annual_income": result}, + ) + return _build( + dataset_file, reform=identity, outputs={"annual_income": result} + ) + + simulation = SimpleNamespace(income=1) + module.raise_rate(simulation) + assert simulation.result == 52 + assert build(simulation.result).certification_status == "certified" + module.weekly_annual_income.__annotations__["income"] = float + module.raise_rate(simulation) + assert simulation.result == 1 + with pytest.raises( + ReceiptError, + match="weekly_annual_income.*annotation for income.*source names", + ): + build(simulation.result) + finally: + sys.modules.pop(name, None) + + +def test_unedited_singledispatch_helper_binds(tmp_path): + name = "w4_receipt_r11_unedited_dispatch" + try: + module, _ = _import_source(tmp_path, name, DISPATCH_SOURCE) + before = _identity(module.raise_rate) + simulation = SimpleNamespace(income=1) + module.raise_rate(simulation) + assert simulation.result == 52 + assert _identity(module.raise_rate) == before + finally: + sys.modules.pop(name, None) + + +@pytest.mark.parametrize( + ("annotation", "changed"), + [ + ("int", float), + ("datetime.date", datetime.datetime), + ("list[int]", list[float]), + ("dict[str, list[int]]", dict[str, list[float]]), + ("int | str", int | float), + ("'int'", "float"), + ("None", float), + ("1", True), + ], + ids=[ + "name", + "attribute", + "generic", + "nested-generic", + "union", + "string", + "none", + "literal-type", + ], +) +def test_annotation_values_must_match_source(tmp_path, annotation, changed): + source = ( + "import datetime\n\n\n" + f"def _helper(value: {annotation}):\n return value\n\n\n" + "def raise_rate(simulation):\n simulation.rate = _helper(0.21)\n" + ) + name = "w4_receipt_r11_annotation_values" + try: + module, _ = _import_source(tmp_path, name, source) + assert _identity(module.raise_rate) + module._helper.__annotations__["value"] = changed + with pytest.raises( + ReceiptError, match="_helper.*annotation for value.*source names" + ): + _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +@pytest.mark.parametrize("parameter", ["value", "args", "keyword", "kwargs", "return"]) +def test_each_annotation_position_is_checked(tmp_path, parameter): + source = ( + "def _helper(value: int, /, *args: int, keyword: int, **kwargs: int) -> int:\n" + " return value\n\n\n" + "def raise_rate(simulation):\n simulation.rate = 0.21\n" + ) + name = "w4_receipt_r11_annotation_positions" + try: + module, _ = _import_source(tmp_path, name, source) + assert _identity(module.raise_rate) + module._helper.__annotations__[parameter] = float + with pytest.raises( + ReceiptError, match=f"_helper.*annotation for {parameter}.*source names" + ): + _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +@pytest.mark.parametrize( + ("annotation", "changed"), + [ + ("list[int]", list[int]), + ("int | str", int | str), + ], + ids=["generic", "union"], +) +def test_equal_reconstructed_annotations_bind(tmp_path, annotation, changed): + source = ( + f"def _helper(value: {annotation}):\n return value\n\n\n" + "def raise_rate(simulation):\n simulation.rate = 0.21\n" + ) + name = "w4_receipt_r11_equal_annotations" + try: + module, _ = _import_source(tmp_path, name, source) + before = _identity(module.raise_rate) + original = module._helper.__annotations__["value"] + assert changed is not original + assert changed == original + module._helper.__annotations__["value"] = changed + assert _identity(module.raise_rate) == before + finally: + sys.modules.pop(name, None) + + +@pytest.mark.parametrize( + ("annotation", "changed"), + [ + ("int | str", "str | int"), + ("list[int | str]", "list[str | int]"), + ("dict[str, list[int | str]]", "dict[str, list[str | int]]"), + ("tuple[int, str]", "tuple[str, int]"), + ("list[int]", "tuple[int]"), + ("list[int]", "list[int, str]"), + ("Callable[[int, str], float]", "Callable[[str, int], float]"), + ("Callable[[int, str], float]", "Callable[[int, str, bool], float]"), + ("Literal[1]", "Literal[True]"), + ], + ids=[ + "union", + "generic-union", + "nested-union", + "arguments", + "origin", + "arity", + "callable-order", + "callable-arity", + "literal-type", + ], +) +def test_annotation_origins_and_arguments_must_match_in_order( + tmp_path, annotation, changed +): + source = ( + "from typing import Callable, Literal\n\n\n" + f"def _helper(value: {annotation}):\n return value\n\n\n" + "def raise_rate(simulation):\n simulation.rate = 0.21\n" + ) + name = "w4_receipt_r12_ordered_annotations" + try: + module, _ = _import_source(tmp_path, name, source) + assert _identity(module.raise_rate) + module._helper.__annotations__["value"] = eval(changed, module.__dict__) + with pytest.raises( + ReceiptError, match="_helper.*annotation for value.*source names" + ): + _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +def test_callable_annotation_argument_containers_are_checked(tmp_path): + from collections.abc import Callable + from types import GenericAlias + + source = ( + "from typing import Callable\n\n\n" + "def _helper(value: Callable[[int, str], float]):\n return value\n\n\n" + "def raise_rate(simulation):\n simulation.rate = 0.21\n" + ) + name = "w4_receipt_r12_callable_argument_containers" + try: + module, _ = _import_source(tmp_path, name, source) + assert _identity(module.raise_rate) + module._helper.__annotations__["value"] = GenericAlias( + Callable, ((int, str), float) + ) + with pytest.raises( + ReceiptError, match="_helper.*annotation for value.*source names" + ): + _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +def test_distinct_union_origins_are_refused(tmp_path): + if sys.version_info >= (3, 14): + pytest.skip("Python 3.14 unifies typing and built-in union origins") + source = ( + "def _helper(value: int | str):\n return value\n\n\n" + "def raise_rate(simulation):\n simulation.rate = 0.21\n" + ) + name = "w4_receipt_r12_union_origins" + try: + module, _ = _import_source(tmp_path, name, source) + assert _identity(module.raise_rate) + module._helper.__annotations__["value"] = Union[int, str] + with pytest.raises( + ReceiptError, match="_helper.*annotation for value.*source names" + ): + _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +@pytest.mark.parametrize("typing_alias", [False, True], ids=["builtin", "typing"]) +def test_parameterised_aliases_only_use_cache_free_substitution( + tmp_path, monkeypatch, typing_alias +): + from typing import List, TypeVar + + parameter = TypeVar("T") + provider = SimpleNamespace( + Alias=List[parameter] if typing_alias else list[parameter] + ) + monkeypatch.setitem(sys.modules, "w4_receipt_alias_provider", provider) + source = ( + "from w4_receipt_alias_provider import Alias\n" + "from decimal import Decimal\nfrom typing import Union\n\n\n" + "def _helper(value: Alias[Union[Decimal, float]]):\n return value\n\n\n" + "def raise_rate(simulation):\n simulation.rate = 0.21\n" + ) + name = "w4_receipt_r12_alias_substitution" + try: + module, _ = _import_source(tmp_path, name, source) + if typing_alias: + with pytest.raises( + ReceiptError, match="_helper.*annotation for value.*cannot be checked" + ): + _identity(module.raise_rate) + else: + assert _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +@pytest.mark.parametrize( + "annotation", + [ + "List['int']", + "Optional['int']", + "Union['int', float]", + "Annotated['int', 'meta']", + "Callable[['int'], 'str']", + "Final['int']", + "ClassVar['int']", + "list['int']", + ], +) +def test_generic_forward_reference_annotations_bind(tmp_path, annotation): + source = ( + "from typing import Annotated, Callable, ClassVar, Final, List, Optional, Union\n\n\n" + f"def _helper(value: {annotation}):\n return value\n\n\n" + "def raise_rate(simulation):\n simulation.rate = 0.21\n" + ) + name = "w4_receipt_r12_forward_references" + try: + module, _ = _import_source(tmp_path, name, source) + assert _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +@pytest.mark.parametrize("special", ["Final", "ClassVar"]) +def test_quoted_special_form_flags_match_this_python(special): + """Round-18 review: the reconstruction's is_class flag is the + interpreter's own, so it matches a fresh typing object, not a cached + (and mutable) one.""" + + import typing + + fresh = typing.ForwardRef( + "x", is_class=receipt_module._SPECIAL_FORM_FORWARD_IS_CLASS + ) + (built,) = typing.get_args(getattr(typing, special)["_w4_flag_check_" + special]) + assert built.__forward_is_class__ == fresh.__forward_is_class__ + + +@pytest.mark.parametrize("quoted", ["int", "_receipt_forward_probe"]) +def test_a_quoted_special_form_with_a_flipped_flag_is_refused(tmp_path, quoted): + """Round-18 review P2: flipping a quoted Final's ForwardRef flag is an + annotation that no longer matches its source.""" + + source = ( + "from typing import Final\n\n\n" + f"def _helper(value: Final['{quoted}']):\n return value\n\n\n" + "def raise_rate(simulation):\n simulation.rate = 0.21\n" + ) + name = "w4_receipt_r18_flipped_flag" + try: + module, _ = _import_source(tmp_path, name, source) + assert _identity(module.raise_rate) + (reference,) = get_args(module._helper.__annotations__["value"]) + reference.__forward_is_class__ = not reference.__forward_is_class__ + with pytest.raises(ReceiptError, match="_helper.*annotation for value"): + _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +def test_subscripted_annotations_without_typing_origins_are_refused( + tmp_path, monkeypatch +): + from typing import Generic, TypeVar + + from pydantic import BaseModel + + parameter = TypeVar("T") + + class Box(BaseModel, Generic[parameter]): + pass + + # Older Pydantic versions add a generated class to the caller's module. + Box[list[int]] + monkeypatch.setitem( + sys.modules, "w4_receipt_generic_model", SimpleNamespace(Box=Box) + ) + source = ( + "from w4_receipt_generic_model import Box\n\n\n" + "def _helper(value: Box[list[int]]):\n return value\n\n\n" + "def raise_rate(simulation):\n simulation.rate = 0.21\n" + ) + name = "w4_receipt_r12_generated_annotation_class" + try: + module, _ = _import_source(tmp_path, name, source) + with pytest.raises( + ReceiptError, match="_helper.*annotation for value.*cannot be checked" + ): + _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +@pytest.mark.parametrize("annotation", ["'Alias'", "list['Alias']", "List['Alias']"]) +def test_recursive_named_annotation_aliases_bind(tmp_path, monkeypatch, annotation): + from types import GenericAlias + + provider = SimpleNamespace(Alias=GenericAlias(list, ("Alias",))) + monkeypatch.setitem(sys.modules, "w4_receipt_recursive_alias", provider) + source = ( + "from w4_receipt_recursive_alias import Alias\nfrom typing import List\n\n\n" + f"def _helper(value: {annotation}):\n return value\n\n\n" + "def raise_rate(simulation):\n simulation.rate = 0.21\n" + ) + name = "w4_receipt_r12_recursive_annotation_alias" + try: + module, _ = _import_source(tmp_path, name, source) + assert _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +@pytest.mark.parametrize("field", ["text", "module", "is_argument", "is_class"]) +def test_forward_reference_annotation_structure_is_checked(tmp_path, field): + from types import GenericAlias + from typing import ForwardRef + + source = ( + "from typing import List\n\n\n" + "def _helper(value: List['int']):\n return value\n\n\n" + "def raise_rate(simulation):\n simulation.rate = 0.21\n" + ) + name = "w4_receipt_r12_forward_reference_state" + try: + module, _ = _import_source(tmp_path, name, source) + assert _identity(module.raise_rate) + changed = ForwardRef( + "float" if field == "text" else "int", + module="another_module" if field == "module" else None, + is_argument=field != "is_argument", + is_class=field == "is_class", + ) + module._helper.__annotations__["value"] = GenericAlias(list, (changed,)) + with pytest.raises( + ReceiptError, match="_helper.*annotation for value.*source names" + ): + _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +@pytest.mark.parametrize( + "changed", [False, True], ids=["source-value", "changed-value"] +) +def test_evaluated_forward_reference_annotations_match_source(tmp_path, changed): + if sys.version_info >= (3, 14): + pytest.skip("Python 3.14 forward references do not cache evaluated values") + from typing import get_args + + source = ( + "from typing import List\n\n\n" + "def _helper(value: List['int']):\n return value\n\n\n" + "def raise_rate(simulation):\n simulation.rate = 0.21\n" + ) + name = "w4_receipt_r12_evaluated_forward_reference" + try: + module, _ = _import_source(tmp_path, name, source) + reference = get_args(module._helper.__annotations__["value"])[0] + reference.__forward_evaluated__ = True + reference.__forward_value__ = float if changed else int + if changed: + with pytest.raises( + ReceiptError, match="_helper.*annotation for value.*source names" + ): + _identity(module.raise_rate) + else: + assert _identity(module.raise_rate) + finally: + # typing's cache can keep this reference across test cases. + reference.__forward_evaluated__ = False + reference.__forward_value__ = None + sys.modules.pop(name, None) + + +@pytest.mark.parametrize( + "float_first", [False, True], ids=["source-order", "cached-order"] +) +@pytest.mark.parametrize("postponed", [False, True], ids=["eager", "postponed"]) +@pytest.mark.parametrize( + "annotation_kind", + [ + "unquoted", + "quoted", + "double-quoted", + "builtin-nested-string", + "typing-nested-string", + "typing-string-before-validation", + ], +) +def test_cached_typing_union_order_is_checked_against_source( + tmp_path, float_first, postponed, annotation_kind +): + """A fresh process keeps the reproduction independent of typing's cache.""" + + annotation = "List[Union[Decimal, float]]" + nested = annotation_kind in ( + "builtin-nested-string", + "typing-nested-string", + "typing-string-before-validation", + ) + if annotation_kind == "double-quoted": + annotation = repr(repr(annotation)) + elif annotation_kind == "quoted": + annotation = repr(annotation) + elif nested: + outer = "list" if annotation_kind == "builtin-nested-string" else "List" + annotation = f"{outer}[{annotation!r}]" + amount = "amounts[0][0]" if nested else "amounts[0]" + values = [["0.29"]] if nested else ["0.29"] + source = ("from __future__ import annotations\n" if postponed else "") + ( + "from decimal import Decimal\nfrom typing import List, Union\n" + "from pydantic import validate_call\n\n\n" + f"def pennies(amounts: {annotation}):\n" + f" return int({amount} * 100)\n\n\n" + "def raise_rate(simulation):\n" + f" simulation.result = validate_call(pennies)({values!r})\n" + ) + path = tmp_path / "money_measure.py" + path.write_text(source) + script = f""" +import importlib.util +import json +import sys +from decimal import Decimal +from types import SimpleNamespace +from typing import List, Union +from policyengine.provenance.receipt import ReceiptError, _modifier_identity +prime = List[Union[float, Decimal]] if {float_first!r} else List[Union[Decimal, float]] +spec = importlib.util.spec_from_file_location('money_measure', {str(path)!r}) +module = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = module +spec.loader.exec_module(module) +simulation = SimpleNamespace() +if {annotation_kind != "typing-string-before-validation"!r}: + module.raise_rate(simulation) +try: + _modifier_identity(module.raise_rate, {{'revision': 'a' * 40}}) + outcome = 'accepted' +except ReceiptError as error: + assert 'annotation for amounts is not what its source names' in str(error) + outcome = 'refused' +if {annotation_kind == "typing-string-before-validation"!r}: + module.raise_rate(simulation) +print(json.dumps({{'pennies': simulation.result, 'outcome': outcome}})) +""" + result = subprocess.run( + [sys.executable, "-c", script], + env={**os.environ, "POLICYENGINE_SKIP_COUNTRY_IMPORTS": "1"}, + capture_output=True, + text=True, + check=True, + ) + assert json.loads(result.stdout) == { + "pennies": 28 if float_first else 29, + "outcome": "refused" if float_first else "accepted", + } + + +@pytest.mark.parametrize("annotation", ["NamedType", "list[NamedType]"]) +def test_named_annotation_types_are_compared_by_identity( + tmp_path, monkeypatch, annotation +): + class EqualTypes(type): + def __eq__(self, other): + return True + + class NamedType(metaclass=EqualTypes): + pass + + class OtherType(metaclass=EqualTypes): + pass + + provider = SimpleNamespace(NamedType=NamedType) + monkeypatch.setitem(sys.modules, "w4_receipt_named_type", provider) + source = ( + "from w4_receipt_named_type import NamedType\n\n\n" + f"def _helper(value: {annotation}):\n return value\n\n\n" + "def raise_rate(simulation):\n simulation.rate = 0.21\n" + ) + name = "w4_receipt_r11_named_type_identity" + try: + module, _ = _import_source(tmp_path, name, source) + assert _identity(module.raise_rate) + module._helper.__annotations__["value"] = eval( + annotation, {"NamedType": OtherType} + ) + with pytest.raises( + ReceiptError, match="_helper.*annotation for value.*source names" + ): + _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +@pytest.mark.parametrize( + "annotation", + ["Literal[-1, 0, +1]", "Literal[-1.5, +2.0]", "Literal[-1j, +2j]"], + ids=["integer", "float", "complex"], +) +def test_signed_numeric_literal_annotations_bind(tmp_path, annotation): + source = ( + "from typing import Literal\n\n\n" + f"def _helper(value: {annotation} = -1):\n return value\n\n\n" + "def raise_rate(simulation):\n simulation.rate = _helper()\n" + ) + name = "w4_receipt_r12_signed_literals" + try: + module, _ = _import_source(tmp_path, name, source) + assert _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +@pytest.mark.parametrize( + "annotation", + [ + "Literal[-True]", + "Literal[-VALUE]", + "Literal[--1]", + "Literal[-(1 + 2)]", + "Literal[~1]", + ], + ids=["boolean", "name", "nested-unary", "expression", "invert"], +) +def test_signed_annotations_only_allow_direct_numeric_constants(tmp_path, annotation): + source = ( + "from __future__ import annotations\nfrom typing import Literal\nVALUE = 1\n\n\n" + f"def _helper(value: {annotation}):\n return value\n\n\n" + "def raise_rate(simulation):\n simulation.rate = 0.21\n" + ) + name = "w4_receipt_r12_computed_signed_literals" + try: + module, _ = _import_source(tmp_path, name, source) + with pytest.raises(ReceiptError, match="computes an annotation of _helper"): + _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +@pytest.mark.parametrize( + "annotation", + ["print('annotation expression ran')", "Literal[-True]", "Literal[-VALUE]"], +) +def test_computed_annotation_strings_are_refused_without_running_them( + tmp_path, capsys, annotation +): + source = ( + "from typing import Literal\nVALUE = 1\n\n\n" + f"def _helper(value: {annotation!r}):\n return value\n\n\n" + "def raise_rate(simulation):\n simulation.rate = 0.21\n" + ) + name = "w4_receipt_r12_computed_annotation_strings" + try: + module, _ = _import_source(tmp_path, name, source) + with pytest.raises( + ReceiptError, match="_helper.*annotation for value.*cannot be checked" + ): + _identity(module.raise_rate) + assert capsys.readouterr().out == "" + finally: + sys.modules.pop(name, None) + + +@pytest.mark.parametrize( + "annotation", + [ + "'unbound_name'", + "'return'", + "Literal['print(1)']", + "Literal['print(1)', 'print(1)']", + "Annotated[int, 'print(1)']", + ], +) +def test_annotation_string_literals_and_metadata_bind(tmp_path, annotation): + source = ( + "from typing import Annotated, Literal\n\n\n" + f"def _helper(value: {annotation}):\n return value\n\n\n" + "def raise_rate(simulation):\n simulation.rate = 0.21\n" + ) + name = "w4_receipt_r12_annotation_string_literals" + try: + module, _ = _import_source(tmp_path, name, source) + assert _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +def test_cyclic_annotation_strings_are_refused(): + script = """ +from policyengine.provenance.receipt import _annotation_string_state +try: + _annotation_string_state('X', {'X': 'Y', 'Y': 'X'}) +except ValueError as error: + assert 'resolution cycle' in str(error) + print('refused') +else: + raise AssertionError('cyclic annotation accepted') +""" + result = subprocess.run( + [sys.executable, "-c", script], + env={**os.environ, "POLICYENGINE_SKIP_COUNTRY_IMPORTS": "1"}, + capture_output=True, + text=True, + check=True, + timeout=120, + ) + assert result.stdout == "refused\n" + + +def test_future_annotations_match_the_compilers_strings(tmp_path): + source = ( + "from __future__ import annotations\n\n\n" + "def _helper(value: ((list [ int ])), literal: 'str', /, " + "*args: tuple[int, ...], optional: (int | None), " + "**kwargs: dict[str, list[float]]) -> 'return':\n" + " return value\n\n\n" + "def raise_rate(simulation):\n simulation.rate = 0.21\n" + ) + name = "w4_receipt_r11_future_annotations" + try: + module, _ = _import_source(tmp_path, name, source) + assert module._helper.__annotations__ == { + "value": "list[int]", + "literal": "'str'", + "args": "tuple[int, ...]", + "optional": "int | None", + "kwargs": "dict[str, list[float]]", + "return": "'return'", + } + assert _identity(module.raise_rate) + module._helper.__annotations__["value"] = "list[float]" + with pytest.raises( + ReceiptError, match="_helper.*annotation for value.*source names" + ): + _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +GENERIC_SOURCE = ( + "def helper[T](value: T) -> T:\n return value\n\n\n" + "def raise_rate(simulation):\n simulation.rate = helper(0.21)\n" +) + + +@pytest.mark.parametrize("changed", ["other-parameter", "args-proxy"]) +def test_generic_paramspec_annotations_bind_and_are_checked(tmp_path, changed): + if sys.version_info < (3, 12): + pytest.skip("type parameters need Python 3.12") + from typing import ParamSpec + + source = ( + "def helper[**P](*args: P.args, **kwargs: P.kwargs):\n" + " return args\n\n\n" + "def raise_rate(simulation):\n simulation.rate = helper(0.21)[0]\n" + ) + name = "w4_receipt_r11_generic_paramspec" + try: + module, _ = _import_source(tmp_path, name, source) + assert _identity(module.raise_rate) + module.helper.__annotations__["kwargs"] = ( + ParamSpec("Other").kwargs + if changed == "other-parameter" + else module.helper.__type_params__[0].args + ) + with pytest.raises( + ReceiptError, match="helper.*annotation for kwargs.*source names" + ): + _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +def test_generic_helpers_bind_and_are_checked(tmp_path): + """Round-9 review L3: ``def helper[T]`` compiles inside a wrapper that + binds T; the function is still checked against its source.""" + + if sys.version_info < (3, 12): + pytest.skip("type parameters need Python 3.12") + name = "w4_receipt_r9_generic" + try: + module, path = _import_source(tmp_path, name, GENERIC_SOURCE) + assert _identity(module.raise_rate) + _edit(path, "return value", "return value * 2") + with pytest.raises(ReceiptError, match="no longer matches"): + _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +def _replacement_type_parameters(module, change): + from typing import ParamSpec, TypeVar, TypeVarTuple + + original = module.helper.__type_params__ + # Built only for the case asked for: TypeVar(default=...) needs 3.13. + return { + "none": lambda: (), + "extra": lambda: (*original, TypeVar("U", infer_variance=True)), + "renamed": lambda: (TypeVar("U", infer_variance=True),), + "paramspec": lambda: (ParamSpec("T", infer_variance=True),), + "typevartuple": lambda: (TypeVarTuple("T"),), + "bound": lambda: (TypeVar("T", bound=int, infer_variance=True),), + "constraints": lambda: (TypeVar("T", int, float, infer_variance=True),), + "covariant": lambda: (TypeVar("T", covariant=True),), + "declared-invariant": lambda: (TypeVar("T"),), + "default": lambda: (TypeVar("T", default=int, infer_variance=True),), + }[change]() + + +@pytest.mark.parametrize( + ("change", "match"), + [ + ("none", "type parameters are not those its source declares"), + ("extra", "type parameters are not those its source declares"), + ("renamed", "type parameters are not those its source declares"), + ("paramspec", "type parameters are not those its source declares"), + ("typevartuple", "type parameters are not those its source declares"), + ("bound", "type parameter T holds a bound"), + ("constraints", "type parameter T holds a bound"), + ("covariant", "type parameter T holds a bound"), + ("declared-invariant", "type parameter T holds a bound"), + pytest.param( + "default", + "type parameter T holds a bound", + marks=pytest.mark.skipif( + sys.version_info < (3, 13), reason="type parameter defaults need 3.13" + ), + ), + # A bare stand-in passes the parameter check, but the annotation + # still names the parameter the def made. + ("stand-in", "annotation for value is not what its source names"), + ], +) +def test_generic_type_parameters_must_be_those_the_def_made(tmp_path, change, match): + """Round-13 review M1: ``__type_params__`` is writable, and a bound, + constraints or default would be read lazily through typing's cache.""" + + if sys.version_info < (3, 12): + pytest.skip("type parameters need Python 3.12") + from typing import TypeVar + + name = "w4_receipt_r13_type_parameters" + try: + module, _ = _import_source(tmp_path, name, GENERIC_SOURCE) + assert _identity(module.raise_rate) + module.helper.__type_params__ = ( + (TypeVar("T", infer_variance=True),) + if change == "stand-in" + else _replacement_type_parameters(module, change) + ) + with pytest.raises(ReceiptError, match=f"helper's {match}"): + _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +RUNTIME_ALIAS_HEAD = ( + "import collections.abc\nimport typing\nfrom decimal import Decimal\n" + "from typing import Annotated, Dict, List, Literal, Optional, Union\n\n\n" +) + + +@pytest.mark.parametrize( + ("body", "base"), + [ + pytest.param( + " def pennies(amounts: List[Union[Decimal, float]]):\n" + " return int(amounts[0] * 100)\n\n" + " simulation.rate = pennies([0.21])\n", + "List", + id="nested-annotation", + ), + pytest.param( + " def pennies[T: List[Union[Decimal, float]]](amounts: T):\n" + " return int(amounts[0] * 100)\n\n" + " simulation.rate = pennies([0.21])\n", + "List", + id="nested-generic-bound", + ), + pytest.param( + " def pennies(amounts: 'List[Union[Decimal, float]]'):\n" + " return int(amounts[0] * 100)\n\n" + " simulation.rate = pennies([0.21])\n", + "List", + id="quoted-nested-annotation", + ), + pytest.param( + " def pennies(amount: Optional[Decimal | float] = None):\n" + " return amount\n\n" + " simulation.rate = pennies(0.21)\n", + "Optional", + id="optional-of-a-union", + ), + pytest.param( + " shape = Dict[str, Union[Decimal, float]]\n" + " simulation.rate = 0.21 if shape else 0.0\n", + "Dict", + id="expression-in-the-body", + ), + pytest.param( + " shape = typing.List[Decimal | float]\n" + " simulation.rate = 0.21 if shape else 0.0\n", + "typing.List", + id="attribute-of-typing", + ), + pytest.param( + " from typing import Sequence as Seq\n\n" + " shape = Seq[Union[Decimal, float]]\n" + " simulation.rate = 0.21 if shape else 0.0\n", + "Seq", + id="import-inside-the-function", + ), + pytest.param( + " class Amounts(typing.NamedTuple):\n" + " values: List[Decimal | float]\n\n" + " simulation.rate = 0.21 if Amounts else 0.0\n", + "List", + id="class-body-annotation", + ), + pytest.param( + " class Amounts(typing.NamedTuple):\n" + " values: 'List[Decimal | float]'\n\n" + " simulation.rate = 0.21 if Amounts else 0.0\n", + "List", + id="quoted-class-body-annotation", + ), + pytest.param( + " shape = Union[Union[Decimal, float], None]\n" + " simulation.rate = 0.21 if shape else 0.0\n", + "Union", + id="union-of-a-union", + # Python 3.14 made Union a class that builds unions in source + # order without typing's cache, so the rule rightly allows it. + marks=pytest.mark.skipif( + sys.version_info >= (3, 14), reason="ordered on Python 3.14" + ), + ), + pytest.param( + # Round-15 review M1: one name, imported differently in two scopes. + " def pennies(amounts):\n" + " from typing import Sequence\n\n" + " shape = Sequence[Union[Decimal, float]]\n" + " return shape and amounts\n\n" + " def is_many(values):\n" + " from collections.abc import Sequence\n\n" + " return isinstance(values, Sequence)\n\n" + " simulation.rate = 0.21 if is_many(pennies([1])) else 0.0\n", + "Sequence", + id="name-imported-in-two-scopes", + ), + pytest.param( + " def is_many(values):\n" + " from collections.abc import Sequence\n\n" + " return isinstance(values, Sequence)\n\n" + " def pennies(amounts):\n" + " from typing import Sequence\n\n" + " shape = Sequence[Union[Decimal, float]]\n" + " return shape and amounts\n\n" + " simulation.rate = 0.21 if is_many(pennies([1])) else 0.0\n", + "Sequence", + id="name-imported-in-two-scopes-typing-last", + ), + pytest.param( + " from w4_receipt_r15_aliases import MONEY\n\n" + " shape = List[MONEY]\n" + " simulation.rate = 0.21 if shape else 0.0\n", + "List", + id="imported-union-alias", + ), + pytest.param( + # Round-15 review M2: a forward reference inside a subscript. + " def pennies(amounts: Optional['List[Union[Decimal, float]]'] = None):\n" + " return amounts\n\n" + " simulation.rate = 0.21 if pennies() is None else 0.0\n", + "Optional", + id="forward-reference-inside-a-subscript", + ), + pytest.param( + " def pennies[T: 'List[Union[Decimal, float]]'](amounts: T):\n" + " return amounts\n\n" + " simulation.rate = pennies([0.21])[0]\n", + "List", + id="quoted-nested-generic-bound", + ), + pytest.param( + " def pennies[T = 'List[Union[Decimal, float]]'](amounts: T):\n" + " return amounts\n\n" + " simulation.rate = pennies([0.21])[0]\n", + "List", + id="quoted-nested-generic-default", + ), + pytest.param( + " def period(kind: List[Literal['weekly', 'monthly']]):\n" + " return kind\n\n" + " simulation.rate = 0.21 if period(['weekly']) else 0.0\n", + "List", + id="list-of-a-literal", + ), + pytest.param( + " from pydantic import RootModel\n\n" + " amount = RootModel[Decimal | float](0.21)\n" + " simulation.rate = float(amount.root)\n", + "RootModel", + id="pydantic-generic-model", + ), + ], +) +def test_typing_aliases_built_with_a_union_inside_functions_are_refused( + tmp_path, body, base +): + """Round-14 review M1: code inside a function runs after import, so a + typing generic subscripted with a union there can return an alias cached + for the same members in another order; Pydantic tries them in order.""" + + source = RUNTIME_ALIAS_HEAD + "def raise_rate(simulation):\n" + body + try: + compile(source, "x", "exec", dont_inherit=True) + except SyntaxError: + pytest.skip("this Python refuses the construct itself") + name = "w4_receipt_r14_runtime_alias" + try: + _import_source( + tmp_path, + "w4_receipt_r15_aliases", + "from decimal import Decimal\n\nMONEY = Decimal | float\n", + ) + module, _ = _import_source(tmp_path, name, source) + with pytest.raises( + ReceiptError, + match=rf"function raise_rate subscripts {re.escape(base)} with a union", + ) as refused: + _identity(module.raise_rate) + assert "cannot be bound" in str(refused.value) + finally: + sys.modules.pop(name, None) + sys.modules.pop("w4_receipt_r15_aliases", None) + + +@pytest.mark.parametrize( + "body", + [ + pytest.param( + " def pennies(amounts: list[Decimal | float]):\n" + " return int(amounts[0] * 100)\n\n" + " simulation.rate = pennies([0.21])\n", + id="builtin-generic-of-a-union", + ), + pytest.param( + " def pennies(amounts: dict[str, Decimal | float] | None = None):\n" + " return amounts\n\n" + " simulation.rate = 0.21 if pennies() is None else 0.0\n", + id="builtin-dict-and-pipe", + ), + pytest.param( + " def pennies(amounts: collections.abc.Sequence[Decimal | float]):\n" + " return int(amounts[0] * 100)\n\n" + " simulation.rate = pennies([0.21])\n", + id="collections-abc-generic-of-a-union", + ), + pytest.param( + " def pennies(amount: Union[Decimal, float]):\n" + " return amount\n\n" + " simulation.rate = pennies(0.21)\n", + id="bare-union", + ), + pytest.param( + " def pennies(amounts: List[float], scale: Optional[float] = None):\n" + " return amounts\n\n" + " simulation.rate = pennies([0.21])[0]\n", + id="typing-generics-without-a-union", + ), + pytest.param( + " rates = [0.20, 0.21]\n mask = 1 | 0\n" + " simulation.rate = rates[mask | 1]\n", + id="value-subscript-with-bitwise-or", + ), + pytest.param( + " def helper[T](value: T) -> T:\n return value\n\n" + " simulation.rate = helper(0.21)\n", + id="nested-bare-generic", + ), + pytest.param( + # Round-15 review L1: NumPy builds an ordered types.GenericAlias. + " import numpy as np\n\n" + " def scale(values: np.ndarray[tuple[int], np.dtype[np.float64 | np.int64]]):\n" + " return values\n\n" + " simulation.rate = float(scale(np.array([0.21]))[0])\n", + id="numpy-array-of-a-union", + ), + pytest.param( + # Literal values and Annotated metadata are strings, not types. + " def period(kind: list[Literal['List[Union[Decimal, float]]']]):\n" + " return kind\n\n" + " def rate(value: Annotated[float, 'Optional[Decimal | float]']):\n" + " return value\n\n" + " simulation.rate = rate(0.21)\n", + id="literal-values-and-annotated-metadata", + ), + pytest.param( + " label = 'income_tax'\n path = 'gov.hmrc.rates[0]'\n" + " simulation.rate = 0.21 if label and path else 0.0\n", + id="ordinary-strings", + ), + ], +) +def test_ordinary_types_and_subscripts_inside_functions_bind(tmp_path, body): + source = RUNTIME_ALIAS_HEAD + "def raise_rate(simulation):\n" + body + try: + compile(source, "x", "exec", dont_inherit=True) + except SyntaxError: + pytest.skip("this Python refuses the construct itself") + name = "w4_receipt_r14_runtime_alias_ok" + try: + module, _ = _import_source(tmp_path, name, source) + assert _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +@pytest.mark.skipif( + sys.version_info < (3, 14), reason="Union caches by equality before 3.14" +) +def test_a_union_of_a_union_binds_where_typing_keeps_its_order(tmp_path): + source = ( + RUNTIME_ALIAS_HEAD + "def raise_rate(simulation):\n" + " shape = Union[Union[Decimal, float], None]\n" + " simulation.rate = 0.21 if shape else 0.0\n" + ) + name = "w4_receipt_union_of_union_314" + try: + module, _ = _import_source(tmp_path, name, source) + assert _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +def test_a_top_level_signature_with_a_typing_union_still_binds(tmp_path): + """Module-level signatures are compared with their source in order, so + the function-code rule leaves them alone.""" + + source = ( + RUNTIME_ALIAS_HEAD + + "def _pennies(amounts: List[Union[Decimal, float]]) -> int:\n" + " return int(amounts[0] * 100)\n" + MEASURE_TAIL + ) + name = "w4_receipt_r14_top_level_union" + try: + module, _ = _import_source(tmp_path, name, source) + assert _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +@pytest.mark.parametrize("replaced", ["distribution", "metadata"]) +def test_the_install_fixture_still_checks_synthetic_metadata( + tmp_path, monkeypatch, replaced +): + """Round-14 review L: the fixture takes a real module as loaded from its + install only while the install metadata is the real one too.""" + + site = tmp_path / "site" + real = receipt_module.metadata + synthetic = SimpleNamespace( + version=real.version("policyengine"), + read_text=lambda name: ( + "policyengine/__init__.py,,\n" if name == "RECORD" else None + ), + locate_file=lambda name: site / name, + ) + + def distribution(name): + return synthetic if name == "policyengine" else real.distribution(name) + + if replaced == "distribution": + monkeypatch.setattr(real, "distribution", distribution) + else: + monkeypatch.setattr( + receipt_module, + "metadata", + SimpleNamespace( + distribution=distribution, + version=real.version, + PackageNotFoundError=real.PackageNotFoundError, + ), + ) + with pytest.raises(ReceiptError, match="from RECORD"): + receipt_module._refuse_loaded_package_origin("policyengine") + + +def _code(source, name): + namespace = {} + exec(compile(source, "x", "exec", dont_inherit=True), namespace) + return namespace[name].__code__ + + +_FUNCTION = "def f(a, /, b, *, c):\n return x + a + b + c\n" + + +def _inner_code(): + namespace = {} + exec(compile(_CLOSURE, "x", "exec", dont_inherit=True), namespace) + return namespace["outer"]().__code__ + + +_CLOSURE = "def outer():\n v = 1\n\n def inner():\n return v\n\n return inner\n" + + +@pytest.mark.parametrize( + ("make", "change"), + [ + (lambda: _code(_FUNCTION, "f"), {"co_name": "g"}), + (lambda: _code(_FUNCTION, "f"), {"co_qualname": "g"}), + (lambda: _code(_FUNCTION, "f"), {"co_argcount": 1}), + (lambda: _code(_FUNCTION, "f"), {"co_posonlyargcount": 0}), + (lambda: _code(_FUNCTION, "f"), {"co_kwonlyargcount": 0}), + (lambda: _code(_FUNCTION, "f"), {"co_flags": "toggle"}), + (lambda: _code(_FUNCTION, "f"), {"co_stacksize": "grow"}), + (lambda: _code(_FUNCTION, "f"), {"co_names": ("y",)}), + (lambda: _code(_FUNCTION, "f"), {"co_varnames": ("a", "b", "d")}), + (lambda: _code(_CLOSURE, "outer"), {"co_cellvars": ("w",)}), + (lambda: _inner_code(), {"co_freevars": ("w",)}), + ], + ids=[ + "name", + "qualname", + "argcount", + "posonlyargcount", + "kwonlyargcount", + "flags", + "stacksize", + "names", + "varnames", + "cellvars", + "freevars", + ], +) +def test_every_code_field_counts(make, change): + """Round-9 review: dropping any field from code_shape survived; each is + what the interpreter runs (co_flags, for one, tells a coroutine).""" + + from policyengine.provenance.bytecode import code_shape + + code = make() + field, value = next(iter(change.items())) + if value == "toggle": + value = code.co_flags ^ inspect.CO_NESTED + elif value == "grow": + value = code.co_stacksize + 1 + assert code_shape(code.replace(**{field: value})) != code_shape(code) + + +def test_a_guard_on_another_name_is_refused(tmp_path): + """Round-9 review: only __name__ makes the main guard.""" + + source = 'if __file__ == "__main__":\n pass\n' + MEASURE_TAIL + name = "w4_receipt_r9_guard_name" + try: + module, _ = _import_source(tmp_path, name, source) + with pytest.raises(ReceiptError, match="module-level If statement"): + _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +def test_is_against_a_call_other_than_type_is_refused(tmp_path): + """Round-9 review: only ``type(...)`` is a safe identity operand.""" + + source = ( + "def raise_rate(simulation, first=(1,), second=(1,)):\n" + " simulation.rate = 0.99 if first is tuple(second) else 0.21\n" + ) + name = "w4_receipt_r9_identity_call" + try: + module, _ = _import_source(tmp_path, name, source) + with pytest.raises(ReceiptError, match="compares identities"): + _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +def test_an_imported_from_callable_is_refused(tmp_path, monkeypatch): + provider = SimpleNamespace(from_callable=inspect.Signature.from_callable) + monkeypatch.setitem(sys.modules, "w4_receipt_annotation_provider", provider) + source = ( + "from w4_receipt_annotation_provider import from_callable as reader\n" + "\n\ndef _helper(value: float) -> float:\n return value\n\n\n" + "def raise_rate(simulation):\n simulation.hints = reader(_helper)\n" + ) + name = "w4_receipt_r10_imported_from_callable" + try: + module, _ = _import_source(tmp_path, name, source) + with pytest.raises( + ReceiptError, match="imports from_callable.*reads annotation values" + ): + _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +@pytest.mark.parametrize("operation", ["getattr", "hasattr", "setattr", "delattr"]) +@pytest.mark.parametrize( + ("attribute", "match"), + [ + ('"__annotations__"', "__annotations__.*introspection"), + ('"__doc__"', "__doc__.*introspection"), + ('"__anno" + "tations__"', "non-literal attribute name"), + ("attribute", "non-literal attribute name"), + ("123", "non-literal attribute name"), + ], + ids=["annotations", "docstring", "computed", "variable", "non-string"], +) +def test_attribute_operations_cannot_expose_unbound_state( + tmp_path, operation, attribute, match +): + extra = ", {}" if operation == "setattr" else "" + source = ( + "def _helper(value: float) -> float:\n return value\n\n\n" + "def raise_rate(simulation):\n attribute = '__annotations__'\n" + f" {operation}(_helper, {attribute}{extra})\n" + " simulation.rate = 0.21\n" + ) + name = "w4_receipt_r10_attribute_operations" + try: + module, _ = _import_source(tmp_path, name, source) + with pytest.raises(ReceiptError, match=match): + _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +@pytest.mark.parametrize("attribute", ["rate", "__init__", "__name__"]) +def test_attribute_operations_allow_plain_names_and_allowed_dunders( + tmp_path, attribute +): + source = ( + "def raise_rate(simulation):\n" + f" simulation.rate = getattr(simulation, '{attribute}', 0.21)\n" + ) + name = "w4_receipt_r10_allowed_attribute" + try: + module, _ = _import_source(tmp_path, name, source) + assert _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +@pytest.mark.parametrize("kind", [set, bytearray]) +def test_shared_set_and_bytearray_defaults_are_refused(tmp_path, kind): + source = ( + f"def raise_rate(simulation, first={kind.__name__}(), second={kind.__name__}()):\n" + " simulation.rate = 0.21\n" + ) + name = "w4_receipt_r10_shared_defaults" + try: + module, _ = _import_source(tmp_path, name, source) + assert receipt_module._shared_default_object([module.raise_rate]) is None + shared = kind() + module.raise_rate.__defaults__ = (shared, shared) + with pytest.raises(ReceiptError, match=f"are one {kind.__name__} object"): + _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +def test_shared_set_constants_are_refused(tmp_path): + source = ( + "FIRST = {2026}\nSECOND = {2026}\n\n\n" + "def raise_rate(simulation):\n" + " FIRST.add(2027)\n" + " simulation.rate = 0.30 if 2027 in SECOND else 0.21\n" + " FIRST.remove(2027)\n" + ) + name = "w4_receipt_r10_shared_set_constants" + try: + module, _ = _import_source(tmp_path, name, source) + assert _identity(module.raise_rate) + simulation = SimpleNamespace() + module.raise_rate(simulation) + assert simulation.rate == 0.21 + module.SECOND = module.FIRST + module.raise_rate(simulation) + assert simulation.rate == 0.30 + with pytest.raises(ReceiptError, match="are one set object"): + _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +@pytest.mark.parametrize( + ("literal", "changed"), + [("1j", complex(-0.0, 1.0)), ("0j", complex(0.0, -0.0))], + ids=["real-zero", "imaginary-zero"], +) +def test_complex_literal_components_keep_the_sign_of_zero(tmp_path, literal, changed): + source = f"PHASE = {literal}\n" + MEASURE_TAIL + name = "w4_receipt_r10_complex_zero" + try: + module, _ = _import_source(tmp_path, name, source) + assert _identity(module.raise_rate) + assert module.PHASE == changed + module.PHASE = changed + with pytest.raises(ReceiptError, match="PHASE is.*at run time"): + _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) + + +def test_a_main_guard_redefining_a_function_is_refused(tmp_path): + source = ( + "def _helper(value):\n return value\n\n\n" + "def raise_rate(simulation):\n simulation.rate = _helper(0.21)\n\n\n" + 'if __name__ == "__main__":\n' + " def _helper(value):\n return value * 2\n" + ) + name = "w4_receipt_r10_multiple_shapes" + try: + module, _ = _import_source(tmp_path, name, source) + with pytest.raises( + ReceiptError, match="function _helper has 2 different definitions" + ): + _identity(module.raise_rate) + finally: + sys.modules.pop(name, None) diff --git a/tests/test_wheels.py b/tests/test_wheels.py new file mode 100644 index 00000000..fbc29eb0 --- /dev/null +++ b/tests/test_wheels.py @@ -0,0 +1,853 @@ +"""Installed files are verified byte for byte against a wheel's RECORD. + +Invariants: + +- an untouched install verifies, including the console-script launchers an + installer generates for the wheel's declared entry points; +- mutating any single installed file, or installing a file the wheel lacks + (a launcher it does not declare or that is not in a scripts directory + outside the site directory, a stray ``.pyc`` or ``INSTALLER`` outside + where installers write them), fails verification; +- bytecode this interpreter would load instead of a verified source (any + optimisation level, listed in the RECORD or not, timestamp or hash based) + verifies only when it compiles from that source; caches the interpreter + would ignore are inert and reported, never failed; +- the wheel itself verifies only when its members and its RECORD match, with + RECORD listing itself with empty hash and size fields, and the optional + RECORD.jws and RECORD.p7s signatures absent from RECORD. Changing, adding + or removing other archive members fails, so a copied RECORD in a junk zip + never names a wheel the files did not come from. +""" + +from __future__ import annotations + +import base64 +import hashlib +import importlib.util +import marshal +import math +import os +import sys +import zipfile +from importlib import metadata +from pathlib import Path +from types import SimpleNamespace + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from policyengine.provenance.wheels import ( + parse_record, + record_digest_to_hex, + verify_installed_against_wheel, + verify_wheel_archive, +) + +DIST_INFO = "policyengine_uk-2.126.8.dist-info" +FILES = { + "policyengine_uk/__init__.py": b"VERSION = '2.126.8'\n", + "policyengine_uk/system.py": b"system = object()\n", + "policyengine_uk/parameters/rate.yaml": b"values:\n 2027-04-06: 0.2\n", + f"{DIST_INFO}/entry_points.txt": ( + b"[console_scripts]\npolicyengine-uk = policyengine_uk.cli:main\n\n" + b"[gui_scripts]\npolicyengine-uk-gui = policyengine_uk.gui:main\n\n" + b"[policyengine.plugins]\nnot-a-script = policyengine_uk.plugin\n" + ), +} +LAUNCHERS = { + "../../../bin/policyengine-uk": b"#!/venv/bin/python\nimport policyengine_uk\n", + "../../../bin/policyengine-uk-gui": b"#!/venv/bin/python\n", +} + + +def _b64(data: bytes) -> str: + return base64.urlsafe_b64encode(hashlib.sha256(data).digest()).decode().rstrip("=") + + +def _line(path: str, data: bytes) -> str: + return f"{path},sha256={_b64(data)},{len(data)}" + + +def write_wheel(path: Path, members: dict, record_lines: list[str]) -> Path: + with zipfile.ZipFile(path, "w") as archive: + for name, data in members.items(): + archive.writestr(name, data) + archive.writestr(f"{DIST_INFO}/RECORD", "\n".join(record_lines) + "\n") + return path + + +def build_wheel(tmp_path: Path, files=FILES, launchers=LAUNCHERS): + """A wheel, an installed copy of it, and the installed RECORD lines. + + The install adds what installers add: INSTALLER, REQUESTED and a launcher + in ``bin`` for each console and GUI script the wheel declares. + """ + + record = [_line(path, data) for path, data in files.items()] + record.append(f"{DIST_INFO}/RECORD,,") + wheel = write_wheel( + tmp_path / "policyengine_uk-2.126.8-py3-none-any.whl", files, record + ) + site = tmp_path / "venv" / "lib" / "python3.13" / "site-packages" + for path, data in {**files, **launchers}.items(): + (site / path).parent.mkdir(parents=True, exist_ok=True) + (site / path).write_bytes(data) + installed = ( + record + + [_line(path, data) for path, data in launchers.items()] + + [ + f"{DIST_INFO}/INSTALLER,sha256={_b64(b'uv')},2", + f"{DIST_INFO}/REQUESTED,,", + ] + ) + return wheel, site, installed + + +class _File(str): + pass + + +def fake_metadata(site: Path, record_lines): + def distribution(name): + files = [] + for line in record_lines: + path, digest, *_ = line.split(",") + item = _File(path) + item.hash = ( + SimpleNamespace(mode="sha256", value=digest.split("=", 1)[1]) + if digest + else None + ) + files.append(item) + return SimpleNamespace(files=files, locate_file=lambda path: site / path) + + return SimpleNamespace(distribution=distribution) + + +def installed_metadata(site: Path, record_lines): + """Read the installed RECORD through the same metadata API as pip and uv.""" + + dist_info = site / DIST_INFO + (dist_info / "RECORD").write_text("\n".join(record_lines) + "\n") + distribution = metadata.Distribution.at(dist_info) + return SimpleNamespace(distribution=lambda name: distribution) + + +def build_signed_wheel(tmp_path: Path, signature: str): + wheel, site, record = build_wheel(tmp_path) + path = f"{DIST_INFO}/{signature}" + data = b"signature bytes" + write_wheel(wheel, {**FILES, path: data}, record[: len(FILES) + 1]) + (site / path).write_bytes(data) + return wheel, site, record, path, data + + +def test_record_hash_decoding(): + data = b"hello" + assert ( + record_digest_to_hex(f"sha256={_b64(data)}") == hashlib.sha256(data).hexdigest() + ) + assert parse_record(f"a.py,sha256={_b64(data)},5\nRECORD,,\n") == { + "a.py": hashlib.sha256(data).hexdigest(), + "RECORD": None, + } + with pytest.raises(ValueError): + record_digest_to_hex("md5=abc") + + +def test_untouched_install_verifies(tmp_path): + wheel, site, record = build_wheel(tmp_path) + evidence = verify_installed_against_wheel( + "policyengine-uk", wheel, metadata=fake_metadata(site, record) + ) + assert evidence["problems"] == [] + assert evidence["files_verified"] == len(FILES) + assert evidence["archive_members_verified"] == len(FILES) + assert evidence["generated_launchers"] == sorted(LAUNCHERS) + assert evidence["wheel_sha256"] == hashlib.sha256(wheel.read_bytes()).hexdigest() + + +@pytest.mark.parametrize("signature", ["RECORD.jws", "RECORD.p7s"]) +@pytest.mark.parametrize("hashed", [False, True]) +def test_installed_record_signatures_match_archive(tmp_path, signature, hashed): + wheel, site, record, path, data = build_signed_wheel(tmp_path, signature) + record.append(_line(path, data) if hashed else f"{path},,") + evidence = verify_installed_against_wheel( + "policyengine-uk", wheel, metadata=installed_metadata(site, record) + ) + assert evidence["problems"] == [] + assert evidence["unexpected_installed_files"] == [] + + +@pytest.mark.parametrize("signature", ["RECORD.jws", "RECORD.p7s"]) +def test_an_installer_may_omit_record_signatures(tmp_path, signature): + wheel, site, record, path, _ = build_signed_wheel(tmp_path, signature) + (site / path).unlink() + evidence = verify_installed_against_wheel( + "policyengine-uk", wheel, metadata=installed_metadata(site, record) + ) + assert evidence["problems"] == [] + + +@pytest.mark.parametrize("signature", ["RECORD.jws", "RECORD.p7s"]) +def test_installed_record_signature_needs_an_archive_member(tmp_path, signature): + wheel, site, record = build_wheel(tmp_path) + path = f"{DIST_INFO}/{signature}" + (site / path).write_bytes(b"signature bytes") + record.append(f"{path},,") + evidence = verify_installed_against_wheel( + "policyengine-uk", wheel, metadata=installed_metadata(site, record) + ) + assert evidence["problems"] == [f"{path}: installed but not in the wheel"] + + +@pytest.mark.parametrize("signature", ["RECORD.jws", "RECORD.p7s"]) +@pytest.mark.parametrize("edit", ["change", "remove"]) +def test_installed_record_signature_bytes_are_checked(tmp_path, signature, edit): + wheel, site, record, path, data = build_signed_wheel(tmp_path, signature) + record.append(f"{path},,") # pip preserves signature entries without hashes + if edit == "change": + (site / path).write_bytes(data + b"changed") + problem = "bytes differ from the wheel" + else: + (site / path).unlink() + problem = "missing on disk" + evidence = verify_installed_against_wheel( + "policyengine-uk", wheel, metadata=installed_metadata(site, record) + ) + assert evidence["problems"] == [f"{path}: {problem}"] + + +def test_uv_build_bookkeeping_verifies(tmp_path): + wheel, site, record = build_wheel(tmp_path) + path = f"{DIST_INFO}/uv_build.json" + (site / path).write_bytes(b"{}") + record.append(_line(path, b"{}")) + evidence = verify_installed_against_wheel( + "policyengine-uk", wheel, metadata=installed_metadata(site, record) + ) + assert evidence["problems"] == [] + + +@pytest.mark.parametrize( + "path", + [ + "policyengine_uk/uv_build.json", + "other-1.0.dist-info/uv_build.json", + f"{DIST_INFO}/nested/uv_build.json", + f"{DIST_INFO}/injected.json", + "other-1.0.dist-info/RECORD.jws", + "policyengine_uk/RECORD.p7s", + ], +) +def test_installer_exceptions_do_not_allow_other_files(tmp_path, path): + wheel, site, record = build_wheel(tmp_path) + (site / path).parent.mkdir(parents=True, exist_ok=True) + (site / path).write_bytes(b"{}") + record.append(_line(path, b"{}")) + evidence = verify_installed_against_wheel( + "policyengine-uk", wheel, metadata=installed_metadata(site, record) + ) + assert evidence["problems"] == [f"{path}: installed but not in the wheel"] + + +@pytest.mark.parametrize("origin", ["directory", "vcs"]) +def test_verified_wheel_allows_certification_of_a_direct_install( + tmp_path, monkeypatch, origin +): + from policyengine.provenance import receipt + + wheel, site, record = build_wheel(tmp_path) + fake = fake_metadata(site, record) + distribution = fake.distribution("policyengine-uk") + direct_url = {"url": "file:///source"} + if origin == "vcs": + direct_url["vcs_info"] = {"vcs": "git", "commit_id": "a" * 40} + else: + direct_url["dir_info"] = {"editable": origin == "editable"} + import json + + distribution.read_text = lambda name: json.dumps(direct_url) + fake.distribution = lambda name: distribution + fake.version = lambda name: "2.126.8" + monkeypatch.setattr(receipt, "metadata", fake) + # This fixture verifies installed files without loading a country package. + monkeypatch.delitem(sys.modules, "policyengine_uk", raising=False) + unverified = receipt.installed_package_identity("policyengine-uk") + assert unverified.install_origin == origin + assert not receipt._package_can_be_certified(unverified) + verified = receipt.installed_package_identity("policyengine-uk", wheel_path=wheel) + assert verified.install_origin == origin + assert verified.wheel_sha256_evidence == "installed_record_verified" + assert verified.wheel_sha256 == hashlib.sha256(wheel.read_bytes()).hexdigest() + assert receipt._package_can_be_certified(verified) + + +def test_a_thin_editable_wheel_verifies_but_is_never_certified(tmp_path, monkeypatch): + """A PEP 660 editable wheel holds a pointer to a checkout, not its source. + + Its installed files verify against it exactly, yet the code that runs is + whatever the checkout holds, so the install is never certified. + """ + + from policyengine.provenance import receipt + + files = { + "__editable__.policyengine_uk-2.126.8.pth": b"/home/dev/policyengine-uk/src\n", + f"{DIST_INFO}/METADATA": ( + b"Metadata-Version: 2.1\nName: policyengine-uk\nVersion: 2.126.8\n" + ), + } + wheel, site, record = build_wheel(tmp_path, files=files, launchers={}) + evidence = verify_installed_against_wheel( + "policyengine-uk", wheel, metadata=installed_metadata(site, record) + ) + assert evidence["problems"] == [] + fake = fake_metadata(site, record) + distribution = fake.distribution("policyengine-uk") + import json + + direct_url = { + "url": "file:///home/dev/policyengine-uk", + "dir_info": {"editable": True}, + } + distribution.read_text = lambda name: json.dumps(direct_url) + fake.distribution = lambda name: distribution + fake.version = lambda name: "2.126.8" + monkeypatch.setattr(receipt, "metadata", fake) + monkeypatch.delitem(sys.modules, "policyengine_uk", raising=False) + verified = receipt.installed_package_identity("policyengine-uk", wheel_path=wheel) + assert verified.install_origin == "editable" + assert verified.wheel_sha256_evidence == "installed_record_verified" + assert not receipt._package_can_be_certified(verified) + + +def test_windows_launchers_are_recognised(tmp_path): + launchers = { + "../../Scripts/policyengine-uk.exe": b"MZ", + "../../Scripts/policyengine-uk-gui-script.pyw": b"import x\n", + } + wheel, site, record = build_wheel(tmp_path, launchers=launchers) + evidence = verify_installed_against_wheel( + "policyengine-uk", wheel, metadata=fake_metadata(site, record) + ) + assert evidence["problems"] == [] + assert evidence["generated_launchers"] == sorted(launchers) + + +@pytest.mark.parametrize( + "path", + [ + "../../../bin/not-a-script", # a plugin entry point, not a script + "../../../bin/undeclared", + "../../../share/policyengine-uk", # declared name, not a scripts dir + "bin/policyengine-uk", # inside site-packages + "../site-packages/policyengine_uk/bin/policyengine-uk", # resolves inside + "policyengine_uk/injected.pyc", # not in __pycache__ + "policyengine_uk/INSTALLER", # not in .dist-info + ], +) +def test_files_installers_do_not_write_fail(tmp_path, path): + wheel, site, record = build_wheel(tmp_path) + (site / path).parent.mkdir(parents=True, exist_ok=True) + (site / path).write_bytes(b"x") + evidence = verify_installed_against_wheel( + "policyengine-uk", + wheel, + metadata=fake_metadata(site, record + [_line(path, b"x")]), + ) + assert evidence["problems"] == [f"{path}: installed but not in the wheel"] + + +TAG = sys.implementation.cache_tag + + +def _pyc( + source: bytes, *, mtime: int, size: int, flags: int = 0, optimize: int = 0 +) -> bytes: + """Bytecode for ``source`` with a header claiming ``mtime`` and ``size``.""" + + code = compile(source, "system.py", "exec", dont_inherit=True, optimize=optimize) + if flags: + middle = importlib.util.source_hash(source) + else: + middle = mtime.to_bytes(4, "little") + size.to_bytes(4, "little") + return ( + importlib.util.MAGIC_NUMBER + + flags.to_bytes(4, "little") + + middle + + marshal.dumps(code) + ) + + +def _cache_path(site: Path, optimization: str = "") -> Path: + return Path( + importlib.util.cache_from_source( + str(site / "policyengine_uk/system.py"), optimization=optimization + ) + ) + + +def _write_cache(site: Path, compiled_from: bytes, **kwargs) -> Path: + """Write a cache for ``system.py`` whose header matches the installed file.""" + + optimization = kwargs.pop("optimization", "") + source = site / "policyengine_uk/system.py" + stat = source.stat() + path = _cache_path(site, optimization) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes( + _pyc( + compiled_from, + mtime=kwargs.pop("mtime", int(stat.st_mtime)), + size=kwargs.pop("size", stat.st_size), + optimize=int(optimization or 0), + **kwargs, + ) + ) + return path + + +def _verify(site, wheel, record, **kwargs): + return verify_installed_against_wheel( + "policyengine-uk", wheel, metadata=fake_metadata(site, record), **kwargs + ) + + +def _relative(site: Path, path: Path) -> str: + return path.relative_to(site).as_posix() + + +SYSTEM = FILES["policyengine_uk/system.py"] +TAMPERED = b"import os\nsystem = os.system('x')\n" + + +@pytest.mark.parametrize("listed", [False, True], ids=["unlisted", "listed"]) +@pytest.mark.parametrize("optimization", ["", "1", "2"]) +def test_bytecode_compiled_from_the_source_verifies(tmp_path, listed, optimization): + wheel, site, record = build_wheel(tmp_path) + path = _write_cache(site, SYSTEM, optimization=optimization) + if listed: + record = record + [_line(_relative(site, path), path.read_bytes())] + evidence = _verify(site, wheel, record) + assert evidence["problems"] == [] + assert evidence["bytecode_verified"] == 1 + + +@pytest.mark.parametrize("optimization", ["", "1", "2"]) +def test_each_cache_is_compared_at_its_own_optimisation_level(tmp_path, optimization): + """-O drops asserts and -OO docstrings, so each level compiles differently.""" + + source = b'"""Docstring."""\nassert True\nsystem = object()\n' + files = {**FILES, "policyengine_uk/system.py": source} + wheel, site, record = build_wheel(tmp_path, files=files) + _write_cache(site, source, optimization=optimization) + evidence = _verify(site, wheel, record) + assert evidence["problems"] == [] + assert evidence["bytecode_verified"] == 1 + for other in {"", "1", "2"} - {optimization}: + _cache_path(site, optimization).rename(_cache_path(site, other)) + problems = _verify(site, wheel, record)["problems"] + assert problems and "does not compile" in problems[0] + _cache_path(site, other).rename(_cache_path(site, optimization)) + + +@pytest.mark.parametrize("listed", [False, True], ids=["unlisted", "listed"]) +@pytest.mark.parametrize( + ("optimization", "flags"), + [("", 0), ("1", 0), ("2", 0), ("", 0b01), ("", 0b11)], + ids=["timestamp", "opt-1", "opt-2", "unchecked-hash", "checked-hash"], +) +def test_bytecode_that_would_run_instead_of_the_source_fails( + tmp_path, listed, optimization, flags +): + """Round-4 review H1: a planted cache runs, so it must be the wheel's code.""" + + wheel, site, record = build_wheel(tmp_path) + path = _write_cache(site, TAMPERED, optimization=optimization, flags=flags) + if listed: + record = record + [_line(_relative(site, path), path.read_bytes())] + problems = _verify(site, wheel, record)["problems"] + assert len(problems) == 1 + assert "does not compile from the verified source" in problems[0] + + +def test_a_timestamp_cache_with_the_check_source_bit_is_still_compared(tmp_path): + """Round-5 review: importlib reads flags 2 as a timestamp cache.""" + + wheel, site, record = build_wheel(tmp_path) + path = _write_cache(site, TAMPERED, flags=0) + data = bytearray(path.read_bytes()) + data[4:8] = (2).to_bytes(4, "little") + path.write_bytes(bytes(data)) + problems = _verify(site, wheel, record)["problems"] + assert problems == [ + f"{_relative(site, path)}: does not compile from the verified source" + ] + + +def test_a_cache_with_unknown_header_flags_is_inert(tmp_path): + """importlib refuses flag bits beyond 0b11 and compiles the source.""" + + wheel, site, record = build_wheel(tmp_path) + path = _write_cache(site, TAMPERED) + data = bytearray(path.read_bytes()) + data[4:8] = (0b100).to_bytes(4, "little") + path.write_bytes(bytes(data)) + evidence = _verify(site, wheel, record) + assert evidence["problems"] == [] + assert evidence["inert_bytecode"] == [ + f"{_relative(site, path)}: unknown header flags" + ] + + +def _with_nan_sign_flipped(code): + return code.replace( + co_consts=tuple( + -constant + if isinstance(constant, float) and math.isnan(constant) + else constant + for constant in code.co_consts + ) + ) + + +def test_a_cache_whose_nan_constant_has_the_other_sign_fails(tmp_path): + """Round-6 review H1: float.hex() writes both signs of NaN as "nan".""" + + source = b"import math\nvalue = 1e999 - 1e999\nsystem = math.copysign(1.0, value)\n" + files = {**FILES, "policyengine_uk/system.py": source} + wheel, site, record = build_wheel(tmp_path, files=files) + path = _write_cache(site, source) + code = compile(source, "system.py", "exec", dont_inherit=True, optimize=0) + assert any(isinstance(c, float) and math.isnan(c) for c in code.co_consts) + # inf - inf is a NaN whose sign depends on the CPU (negative on x86-64, + # positive on arm64); either way the flipped constant behaves differently. + original: dict = {} + exec(code, original) + flipped: dict = {} + exec(_with_nan_sign_flipped(code), flipped) + assert flipped["system"] == -original["system"] + data = path.read_bytes() + path.write_bytes(data[:16] + marshal.dumps(_with_nan_sign_flipped(code))) + problems = _verify(site, wheel, record)["problems"] + assert problems == [ + f"{_relative(site, path)}: does not compile from the verified source" + ] + + +@pytest.mark.parametrize("payload", [None, 0, b"bytes", ("a", "tuple")]) +def test_a_cache_holding_a_value_other_than_code_fails(tmp_path, payload): + """A well-formed marshal payload that is not code is refused, not raised.""" + + wheel, site, record = build_wheel(tmp_path) + path = _write_cache(site, FILES["policyengine_uk/system.py"]) + data = path.read_bytes() + path.write_bytes(data[:16] + marshal.dumps(payload)) + problems = _verify(site, wheel, record)["problems"] + assert problems == [ + f"{_relative(site, path)}: does not compile from the verified source" + ] + + +def test_a_stale_cache_with_the_check_source_bit_is_inert(tmp_path): + """Flags 2 is timestamp-based: a stale one is ignored, not compared.""" + + wheel, site, record = build_wheel(tmp_path) + path = _write_cache(site, TAMPERED, mtime=12345) + data = bytearray(path.read_bytes()) + data[4:8] = (2).to_bytes(4, "little") + path.write_bytes(bytes(data)) + evidence = _verify(site, wheel, record) + assert evidence["problems"] == [] + assert evidence["inert_bytecode"] == [f"{_relative(site, path)}: stale timestamp"] + + +def test_a_cache_linked_from_two_sources_must_match_each(tmp_path): + """Round-5 review: one cache standing for two sources is checked for both. + + Two wheel sources of equal length and modification time but different + code share one cache through a link; the interpreter would run the + first's code for both. + """ + + other = b"system = 'abcdef'\n" + assert len(other) == len(SYSTEM) + files = {**FILES, "policyengine_uk/other.py": other} + wheel, site, record = build_wheel(tmp_path, files=files) + first = site / "policyengine_uk/other.py" # checked first (sorted) + second = site / "policyengine_uk/system.py" + stat = first.stat() + os.utime(second, ns=(stat.st_atime_ns, stat.st_mtime_ns)) + cache = Path(importlib.util.cache_from_source(str(first))) + cache.parent.mkdir(parents=True, exist_ok=True) + cache.write_bytes(_pyc(other, mtime=int(stat.st_mtime), size=stat.st_size)) + linked = Path(importlib.util.cache_from_source(str(second))) + linked.symlink_to(cache.name) + problems = _verify(site, wheel, record)["problems"] + assert problems == [ + f"{_relative(site, linked)}: does not compile from the verified source" + ] + + +def test_an_unreadable_cache_is_inert(tmp_path): + """Round-5 review: the interpreter ignores a cache it cannot read.""" + + wheel, site, record = build_wheel(tmp_path) + path = _write_cache(site, TAMPERED) + path.chmod(0) + try: + evidence = _verify(site, wheel, record) + finally: + path.chmod(0o644) + if os.geteuid() == 0: # pragma: no cover - root can read anything + pytest.skip("root reads unreadable files") + assert evidence["problems"] == [] + assert any("unreadable" in item for item in evidence["inert_bytecode"]) + + +def test_bytecode_the_interpreter_would_ignore_is_inert(tmp_path): + wheel, site, record = build_wheel(tmp_path) + stale = _write_cache(site, TAMPERED, mtime=12345) + foreign = site / "policyengine_uk/__pycache__/system.cpython-27.pyc" + foreign.write_bytes(b"anything") + record = record + [_line(_relative(site, foreign), b"anything")] + evidence = _verify(site, wheel, record) + assert evidence["problems"] == [] + assert evidence["bytecode_verified"] == 0 + assert evidence["inert_bytecode"] == [ + f"{_relative(site, foreign)}: another interpreter's cache", + f"{_relative(site, stale)}: stale timestamp", + ] + + +def test_a_listed_cache_of_a_file_the_wheel_lacks_fails(tmp_path): + wheel, site, record = build_wheel(tmp_path) + path = f"policyengine_uk/__pycache__/injected.{TAG}.pyc" + (site / path).parent.mkdir(parents=True, exist_ok=True) + (site / path).write_bytes(b"pyc") + problems = _verify(site, wheel, record + [_line(path, b"pyc")])["problems"] + assert problems == [ + f"{path}: caches policyengine_uk/injected.py, which is not in the wheel" + ] + + +def test_unreadable_bytecode_fails(tmp_path): + wheel, site, record = build_wheel(tmp_path) + path = _write_cache(site, SYSTEM) + path.write_bytes(path.read_bytes()[:16] + b"\xff\x00garbage") + problems = _verify(site, wheel, record)["problems"] + assert problems == [f"{_relative(site, path)}: unreadable bytecode"] + + +def test_bytecode_under_a_pycache_prefix_is_checked(tmp_path, monkeypatch): + wheel, site, record = build_wheel(tmp_path) + monkeypatch.setattr(sys, "pycache_prefix", str(tmp_path / "prefix")) + path = _write_cache(site, TAMPERED) + assert str(path).startswith(str(tmp_path / "prefix")) + problems = _verify(site, wheel, record)["problems"] + assert problems == [f"{path}: does not compile from the verified source"] + + +def test_without_a_cache_tag_only_listed_caches_are_classified(tmp_path): + wheel, site, record = build_wheel(tmp_path) + _write_cache(site, TAMPERED) + evidence = _verify(site, wheel, record, cache_tag=None) + assert evidence["problems"] == [] + + +@settings(max_examples=25, deadline=None) +@given( + source=st.sampled_from( + [ + b"system = object()\n", + b"system = object() \n", # same code, different bytes + b"system = 1\n", + b"system = object\n", + b"def system():\n return 1\n", + b"def system():\n return 2\n", + ] + ) +) +def test_cache_verifies_exactly_when_it_compiles_to_the_same_code( + tmp_path_factory, source +): + """Differential: the verdict equals comparing the two compiled modules.""" + + wheel, site, record = build_wheel(tmp_path_factory.mktemp("cache")) + _write_cache(site, source) + same = marshal.dumps( + compile(source, "x", "exec", dont_inherit=True) + ) == marshal.dumps(compile(SYSTEM, "x", "exec", dont_inherit=True)) + assert (_verify(site, wheel, record)["problems"] == []) == same + + +def test_a_listed_launcher_missing_on_disk_is_not_exempt(tmp_path): + """Round-4 review L9: a launcher is exempt only where it actually is.""" + + wheel, site, record = build_wheel(tmp_path) + (site / "../../../bin/policyengine-uk").unlink() + problems = _verify(site, wheel, record)["problems"] + assert problems == ["../../../bin/policyengine-uk: installed but not in the wheel"] + + +def test_a_junk_wheel_with_a_copied_record_does_not_verify(tmp_path): + """Round-2 review N4: the RECORD alone proves nothing about the archive.""" + + wheel, site, record = build_wheel(tmp_path) + junk = write_wheel( + tmp_path / "junk-2.126.8-py3-none-any.whl", + {"policyengine_uk/__init__.py": b"import os; os.system('x')\n"}, + [line for line in record if line.split(",")[0] in FILES or "RECORD" in line], + ) + evidence = verify_installed_against_wheel( + "policyengine-uk", junk, metadata=fake_metadata(site, record) + ) + assert any( + "bytes differ from the wheel's RECORD" in p for p in evidence["problems"] + ) + assert any("not in the wheel" in p for p in evidence["problems"]) + + +ARCHIVE_EDITS = ["change", "add", "remove", "unhash", "hash record"] + + +@settings(max_examples=40, deadline=None) +@given( + edit=st.sampled_from(ARCHIVE_EDITS), + index=st.integers(0, len(FILES) - 1), + extra=st.binary(min_size=1, max_size=8), +) +def test_any_edit_to_the_archive_fails(tmp_path_factory, edit, index, extra): + directory = tmp_path_factory.mktemp("archive") + members = dict(FILES) + record = [_line(path, data) for path, data in FILES.items()] + record.append(f"{DIST_INFO}/RECORD,,") + target = list(FILES)[index] + if edit == "change": + members[target] = members[target] + extra + elif edit == "add": + members["policyengine_uk/smuggled.py"] = extra + elif edit == "remove": + del members[target] + elif edit == "unhash": + record[index] = f"{target},," + elif edit == "hash record": + record[-1] = f"{DIST_INFO}/RECORD,sha256={_b64(extra)},{len(extra)}" + wheel = write_wheel(directory / "w-1-py3-none-any.whl", members, record) + assert verify_wheel_archive(wheel)["problems"] + + +def test_a_duplicated_member_fails(tmp_path): + import warnings + + record = [_line(path, data) for path, data in FILES.items()] + record.append(f"{DIST_INFO}/RECORD,,") + wheel = tmp_path / "w-1-py3-none-any.whl" + with warnings.catch_warnings(): + warnings.simplefilter("ignore") # zipfile warns about the duplicate + with zipfile.ZipFile(wheel, "w") as archive: + for name, data in FILES.items(): + archive.writestr(name, data) + archive.writestr("policyengine_uk/system.py", b"system = object()\n") + archive.writestr(f"{DIST_INFO}/RECORD", "\n".join(record) + "\n") + problems = verify_wheel_archive(wheel)["problems"] + assert "policyengine_uk/system.py: the wheel holds it twice" in problems + + +def test_an_untouched_archive_verifies(tmp_path): + wheel, _, _ = build_wheel(tmp_path) + archive = verify_wheel_archive(wheel) + assert archive["problems"] == [] + assert archive["members_verified"] == len(FILES) + assert archive["scripts"] == {"policyengine-uk", "policyengine-uk-gui"} + + +@pytest.mark.parametrize("signature", [None, "RECORD.jws", "RECORD.p7s"]) +def test_record_must_list_itself(tmp_path, signature): + members = dict(FILES) + if signature is not None: + members[f"{DIST_INFO}/{signature}"] = b"signature" + record = [_line(path, data) for path, data in FILES.items()] + wheel = write_wheel(tmp_path / "w-1-py3-none-any.whl", members, record) + assert verify_wheel_archive(wheel)["problems"] == [ + f"{DIST_INFO}/RECORD: RECORD does not list itself" + ] + + +@pytest.mark.parametrize( + "fields", + ["", ",", ",0", f"sha256={_b64(b'not RECORD')},", ",,extra"], +) +def test_record_self_entry_needs_empty_hash_and_size(tmp_path, fields): + record = [_line(path, data) for path, data in FILES.items()] + record.append(f"{DIST_INFO}/RECORD,{fields}") + wheel = write_wheel(tmp_path / "w-1-py3-none-any.whl", FILES, record) + if fields == ",": + assert verify_wheel_archive(wheel)["problems"] == [] + else: + assert verify_wheel_archive(wheel)["problems"] == [ + f"{DIST_INFO}/RECORD: RECORD needs empty hash and size fields" + ] + + +@pytest.mark.parametrize("signature", ["RECORD.jws", "RECORD.p7s"]) +@pytest.mark.parametrize("listed", [None, "unhashed", "hashed"]) +def test_record_signatures_are_absent_from_record(tmp_path, signature, listed): + path = f"{DIST_INFO}/{signature}" + data = b"signature" + members = {**FILES, path: data} + record = [_line(path, data) for path, data in FILES.items()] + record.append(f"{DIST_INFO}/RECORD,,") + if listed is not None: + record.append(_line(path, data) if listed == "hashed" else f"{path},,") + wheel = write_wheel(tmp_path / "w-1-py3-none-any.whl", members, record) + assert verify_wheel_archive(wheel)["problems"] == ( + [] if listed is None else [f"{path}: signature files must not be in RECORD"] + ) + + +def test_a_wheel_needs_exactly_one_record(tmp_path): + wheel = tmp_path / "w-1-py3-none-any.whl" + with zipfile.ZipFile(wheel, "w") as archive: + archive.writestr("x.py", b"x") + with pytest.raises(ValueError, match="0 RECORD"): + verify_wheel_archive(wheel) + with pytest.raises(ValueError, match="twice"): + parse_record("a.py,,\na.py,,\n") + + +@settings(max_examples=30, deadline=None) +@given(index=st.integers(0, len(FILES) - 1), extra=st.binary(min_size=1, max_size=8)) +def test_any_mutated_installed_file_fails(tmp_path_factory, index, extra): + wheel, site, record = build_wheel(tmp_path_factory.mktemp("wheel")) + target = site / list(FILES)[index] + target.write_bytes(target.read_bytes() + extra) + assert verify_installed_against_wheel( + "policyengine-uk", wheel, metadata=fake_metadata(site, record) + )["problems"] + + +def test_extra_installed_file_fails(tmp_path): + wheel, site, record = build_wheel(tmp_path) + (site / "policyengine_uk" / "injected.py").write_bytes(b"x = 1\n") + record = record + [f"policyengine_uk/injected.py,sha256={_b64(b'x = 1')},6"] + evidence = verify_installed_against_wheel( + "policyengine-uk", wheel, metadata=fake_metadata(site, record) + ) + assert any("injected.py" in problem for problem in evidence["problems"]) + + +def test_an_installed_record_that_differs_from_the_wheel_is_reported(tmp_path): + """Round-9 review: the installed RECORD must list the wheel's digests even + when the bytes on disk still match the wheel.""" + + wheel, site, record = build_wheel(tmp_path) + path = "policyengine_uk/system.py" + edited = [ + _line(path, b"something else") if line.startswith(f"{path},") else line + for line in record + ] + problems = _verify(site, wheel, edited)["problems"] + assert problems == [f"{path}: installed RECORD differs from the wheel"] diff --git a/uv.lock b/uv.lock index d57c0a11..51777114 100644 --- a/uv.lock +++ b/uv.lock @@ -197,7 +197,7 @@ name = "cffi" version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pycparser" }, + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } wheels = [ @@ -1767,7 +1767,7 @@ name = "pexpect" version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ptyprocess" }, + { name = "ptyprocess", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } wheels = [ @@ -1836,6 +1836,7 @@ dev = [ { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, + { name = "rfc8785" }, { name = "ruff" }, { name = "spm-calculator" }, { name = "towncrier" }, @@ -1891,11 +1892,12 @@ requires-dist = [ { name = "policyengine-us", marker = "extra == 'models'", specifier = "==2.2.1" }, { name = "policyengine-us", marker = "extra == 'us'", specifier = "==2.2.1" }, { name = "psutil", specifier = ">=5.9.0" }, - { name = "pydantic", specifier = ">=2.0.0" }, + { name = "pydantic", specifier = ">=2.10.0" }, { name = "pytest", marker = "extra == 'dev'" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.26.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0.0" }, { name = "requests", specifier = ">=2.31.0" }, + { name = "rfc8785", marker = "extra == 'dev'", specifier = ">=0.1.4" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.9.0" }, { name = "spm-calculator", marker = "extra == 'dev'", specifier = "==1.0.0" }, { name = "spm-calculator", marker = "extra == 'models'", specifier = "==1.0.0" }, @@ -2445,6 +2447,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] +[[package]] +name = "rfc8785" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/2f/fa1d2e740c490191b572d33dbca5daa180cb423c24396b856f5886371d8b/rfc8785-0.1.4.tar.gz", hash = "sha256:e545841329fe0eee4f6a3b44e7034343100c12b4ec566dc06ca9735681deb4da", size = 14321, upload-time = "2024-09-27T16:33:31.206Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/78/119878110660b2ad709888c8a1614fce7e2fab39080ab960656dc8605bf6/rfc8785-0.1.4-py3-none-any.whl", hash = "sha256:520d690b448ecf0703691c76e1a34a24ddcd4fc5bc41d589cb7c58ec651bcd48", size = 9240, upload-time = "2024-09-27T16:33:29.683Z" }, +] + [[package]] name = "rpds-py" version = "0.27.1"