From 1792fee93c3a97307173b7a25c4b5f51fce03aac Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 10 Jul 2026 07:23:14 -0400 Subject: [PATCH 1/3] Package consumer-fact schema and add jsonschema row validation Ship the pinned consumer_fact.v1 schema inside the wheel as the single source of truth for artifact validation. A new policyengine_ledger.schema module loads the packaged copy and exposes the parsed schema, its sha256, and a row validator that raises with a precise path, line, and JSON location. A test pins the packaged copy byte-for-byte to docs/schemas, and the wheel-smoke import test confirms the schema ships in the wheel. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 7 + policyengine_ledger/schema.py | 74 ++++ policyengine_ledger/schemas/__init__.py | 7 + .../schemas/consumer_fact.v1.schema.json | 413 ++++++++++++++++++ pyproject.toml | 1 + tests/test_policyengine_ledger_schema.py | 79 ++++ uv.lock | 175 ++++++++ 7 files changed, 756 insertions(+) create mode 100644 policyengine_ledger/schema.py create mode 100644 policyengine_ledger/schemas/__init__.py create mode 100644 policyengine_ledger/schemas/consumer_fact.v1.schema.json create mode 100644 tests/test_policyengine_ledger_schema.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe090ad..b7e22c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,7 +60,14 @@ jobs: import policyengine_ledger.sources import policyengine_ledger.target_profiles import policyengine_ledger.targets + from policyengine_ledger.schema import ( + CONSUMER_FACT_SCHEMA_SHA256, + consumer_fact_schema, + ) assert policyengine_ledger.__name__ == "policyengine_ledger" + # The pinned consumer-fact schema must ship inside the wheel. + assert consumer_fact_schema()["title"] == "Ledger consumer fact contract row" + assert len(CONSUMER_FACT_SCHEMA_SHA256) == 64 PY /tmp/ledger-wheel-smoke/bin/ledger --help >/dev/null diff --git a/policyengine_ledger/schema.py b/policyengine_ledger/schema.py new file mode 100644 index 0000000..a29cc35 --- /dev/null +++ b/policyengine_ledger/schema.py @@ -0,0 +1,74 @@ +"""Consumer-fact row schema validation for Ledger artifacts. + +The pinned ``consumer_fact.v1`` schema is packaged with the wheel so builds and +loads validate every fact row against the exact contract the artifact claims. +The packaged schema bytes are the single source of truth: their sha256 is +recorded in each artifact manifest, and a load rejects any manifest that claims +a different schema. +""" + +from __future__ import annotations + +import hashlib +import json +from functools import lru_cache +from importlib.resources import files as _resource_files +from typing import Any + +from jsonschema import Draft202012Validator + +_SCHEMA_PACKAGE = "policyengine_ledger.schemas" +_SCHEMA_RESOURCE = "consumer_fact.v1.schema.json" + + +def _packaged_schema_bytes() -> bytes: + return _resource_files(_SCHEMA_PACKAGE).joinpath(_SCHEMA_RESOURCE).read_bytes() + + +CONSUMER_FACT_SCHEMA_SHA256 = hashlib.sha256(_packaged_schema_bytes()).hexdigest() + + +@lru_cache(maxsize=1) +def consumer_fact_schema() -> dict[str, Any]: + """Return the parsed, cached consumer-fact row schema.""" + return json.loads(_packaged_schema_bytes()) + + +@lru_cache(maxsize=1) +def _validator() -> Draft202012Validator: + return Draft202012Validator(consumer_fact_schema()) + + +def validate_consumer_fact_row( + row: Any, + line_number: int, + path: Any, +) -> None: + """Validate one consumer-fact row against the pinned schema. + + Raises :class:`ValueError` naming the source ``path``, the 1-based + ``line_number``, the failing JSON location, and the schema reason. The + first error by schema location is reported so the message is stable. + """ + errors = sorted( + _validator().iter_errors(row), + key=lambda error: ( + [str(part) for part in error.absolute_path], + error.message, + ), + ) + if not errors: + return + error = errors[0] + location = "/".join(str(part) for part in error.absolute_path) or "" + raise ValueError( + f"Consumer fact row {line_number} of {path} failed schema validation " + f"at {location!r}: {error.message}" + ) + + +__all__ = [ + "CONSUMER_FACT_SCHEMA_SHA256", + "consumer_fact_schema", + "validate_consumer_fact_row", +] diff --git a/policyengine_ledger/schemas/__init__.py b/policyengine_ledger/schemas/__init__.py new file mode 100644 index 0000000..e23b3ae --- /dev/null +++ b/policyengine_ledger/schemas/__init__.py @@ -0,0 +1,7 @@ +"""Packaged Ledger contract schemas. + +The consumer-fact row schema is shipped in the wheel so that artifact builds +and loads validate rows against the exact pinned contract. The packaged copy +is byte-identical to ``docs/schemas`` and a test enforces that single source +of truth. +""" diff --git a/policyengine_ledger/schemas/consumer_fact.v1.schema.json b/policyengine_ledger/schemas/consumer_fact.v1.schema.json new file mode 100644 index 0000000..37a29e1 --- /dev/null +++ b/policyengine_ledger/schemas/consumer_fact.v1.schema.json @@ -0,0 +1,413 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://policyengine.org/ledger/schemas/consumer_fact.v1.schema.json", + "title": "Ledger consumer fact contract row", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "aggregate_fact_key", + "semantic_fact_key", + "legacy_fact_key", + "source_release_key", + "source_series_key", + "observed_measure_key", + "dimension_set_key", + "universe_constraint_set_key", + "value", + "value_type", + "assertion", + "period", + "geography", + "entity", + "aggregation", + "observed_measure", + "dimensions", + "universe_constraints", + "source", + "lineage" + ], + "properties": { + "schema_version": { + "const": "ledger.consumer_fact.v1" + }, + "aggregate_fact_key": { + "type": "string", + "pattern": "^ledger\\.aggregate_fact\\.v2:[0-9a-f]{24}$" + }, + "semantic_fact_key": { + "type": "string", + "pattern": "^ledger\\.semantic_fact\\.v2:[0-9a-f]{24}$" + }, + "legacy_fact_key": { + "type": "string", + "pattern": "^ledger\\.fact\\.v1:[0-9a-f]{24}$" + }, + "source_release_key": { + "type": "string", + "pattern": "^ledger\\.source_release\\.v2:[0-9a-f]{24}$" + }, + "source_series_key": { + "type": "string", + "pattern": "^ledger\\.source_series\\.v2:[0-9a-f]{24}$" + }, + "observed_measure_key": { + "type": "string", + "pattern": "^ledger\\.observed_measure\\.v2:[0-9a-f]{24}$" + }, + "dimension_set_key": { + "type": "string", + "pattern": "^ledger\\.dimension_set\\.v2:[0-9a-f]{24}$" + }, + "universe_constraint_set_key": { + "type": "string", + "pattern": "^ledger\\.universe_constraint_set\\.v2:[0-9a-f]{24}$" + }, + "value": { + "type": [ + "integer", + "number", + "string", + "boolean" + ] + }, + "value_type": { + "enum": [ + "integer", + "number", + "decimal", + "string", + "boolean" + ] + }, + "period": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "value" + ], + "properties": { + "type": { + "type": "string" + }, + "value": { + "type": [ + "integer", + "string" + ] + } + } + }, + "geography": { + "type": "object", + "additionalProperties": false, + "required": [ + "level", + "id" + ], + "properties": { + "level": { + "type": "string" + }, + "id": { + "type": "string" + }, + "vintage": { + "type": "string" + } + } + }, + "entity": { + "type": "object", + "additionalProperties": false, + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + }, + "role": { + "type": "string" + } + } + }, + "aggregation": { + "type": "object", + "additionalProperties": false, + "required": [ + "method" + ], + "properties": { + "method": { + "type": "string" + }, + "denominator": { + "type": "string" + } + } + }, + "observed_measure": { + "type": "object", + "additionalProperties": false, + "required": [ + "source_name", + "source_table", + "source_measure_id", + "source_concept", + "unit" + ], + "properties": { + "source_name": { + "type": "string" + }, + "source_table": { + "type": "string" + }, + "source_measure_id": { + "type": "string" + }, + "source_concept": { + "type": "string" + }, + "unit": { + "type": "string" + } + } + }, + "dimensions": { + "type": "object", + "additionalProperties": { + "type": [ + "integer", + "number", + "string", + "boolean" + ] + } + }, + "universe_constraints": { + "type": "object", + "additionalProperties": false, + "required": [ + "domain" + ], + "properties": { + "domain": { + "type": "string" + }, + "constraints": { + "type": "array", + "items": { + "$ref": "#/$defs/constraint" + } + } + } + }, + "source": { + "type": "object", + "additionalProperties": true, + "required": [ + "source_name", + "source_table", + "source_file", + "vintage", + "extracted_at", + "extraction_method", + "source_sha256", + "source_size_bytes", + "raw_r2_uri" + ], + "properties": { + "source_name": { + "type": "string" + }, + "source_table": { + "type": "string" + }, + "source_file": { + "type": "string" + }, + "url": { + "type": "string" + }, + "vintage": { + "type": "string" + }, + "extracted_at": { + "type": "string" + }, + "extraction_method": { + "type": "string" + }, + "method_notes": { + "type": "string" + }, + "source_sha256": { + "type": "string" + }, + "source_size_bytes": { + "type": "integer" + }, + "raw_r2_bucket": { + "type": "string" + }, + "raw_r2_key": { + "type": "string" + }, + "raw_r2_uri": { + "type": "string" + } + } + }, + "lineage": { + "type": "object", + "additionalProperties": false, + "required": [ + "source_record_id", + "source_cell_keys" + ], + "properties": { + "source_record_id": { + "type": "string" + }, + "source_cell_keys": { + "type": "array", + "items": { + "type": "string" + } + }, + "source_row_keys": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "layout": { + "type": "object", + "additionalProperties": true + }, + "label": { + "type": "string" + }, + "concept_alignment": { + "type": "object", + "additionalProperties": false, + "required": [ + "concept_alignment_key", + "source_concept", + "canonical_concept", + "relation", + "authority" + ], + "properties": { + "concept_alignment_key": { + "type": "string", + "pattern": "^ledger\\.concept_alignment\\.v2:[0-9a-f]{24}$" + }, + "source_concept": { + "type": "string" + }, + "canonical_concept": { + "type": "string" + }, + "relation": { + "type": "string" + }, + "authority": { + "type": "string" + }, + "evidence_url": { + "type": "string" + }, + "evidence_notes": { + "type": "string" + }, + "legal_vintage": { + "type": "string" + } + } + }, + "assertion": { + "type": "string", + "enum": [ + "observation", + "source_projection" + ], + "description": "Who asserted the value: a publisher-measured outcome (observation) or the publisher's own forward-looking estimate (source_projection). PolicyEngine-computed values are never consumer facts." + }, + "period_coverage": { + "type": "object", + "additionalProperties": false, + "description": "Non-identity provenance for the fact's reference period: coverage dates, basis, the publisher's period label, and accounting basis.", + "properties": { + "start_date": { + "type": "string" + }, + "end_date": { + "type": "string" + }, + "basis": { + "type": "string", + "enum": [ + "calendar", + "tax", + "fiscal", + "survey_reference", + "projection_horizon" + ] + }, + "source_period_label": { + "type": "string" + }, + "accounting_basis": { + "type": "string", + "enum": [ + "cash", + "accrual" + ] + }, + "notes": { + "type": "string" + } + } + } + }, + "$defs": { + "constraint": { + "type": "object", + "additionalProperties": false, + "required": [ + "variable", + "operator", + "value", + "role" + ], + "properties": { + "variable": { + "type": "string" + }, + "operator": { + "type": "string" + }, + "value": { + "type": [ + "integer", + "number", + "string", + "boolean" + ] + }, + "unit": { + "type": "string" + }, + "role": { + "type": "string" + } + } + } + } +} diff --git a/pyproject.toml b/pyproject.toml index 5160115..d20da0b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ authors = [ dependencies = [ "sqlmodel>=0.0.22", "supabase>=2.0.0", # Supabase Python client + "jsonschema>=4.20.0", # Consumer-fact row contract validation "pyyaml>=6.0", "httpx>=0.27.0", # For fetching external data "numpy>=1.24.0", # For source table normalization diff --git a/tests/test_policyengine_ledger_schema.py b/tests/test_policyengine_ledger_schema.py new file mode 100644 index 0000000..41cfff0 --- /dev/null +++ b/tests/test_policyengine_ledger_schema.py @@ -0,0 +1,79 @@ +"""Tests for the packaged consumer-fact row schema and its validator. + +The packaged schema is the single source of truth used by artifact builds and +loads. These tests pin it byte-for-byte to ``docs/schemas`` so the two copies +cannot drift, and exercise the validator's precise error reporting. +""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +import pytest + +from policyengine_ledger.schema import ( + CONSUMER_FACT_SCHEMA_SHA256, + consumer_fact_schema, + validate_consumer_fact_row, +) + +_REPO_ROOT = Path(__file__).parents[1] +_DOCS_SCHEMA_PATH = _REPO_ROOT / "docs" / "schemas" / "consumer_fact.v1.schema.json" +_PACKAGED_SCHEMA_PATH = ( + _REPO_ROOT + / "policyengine_ledger" + / "schemas" + / "consumer_fact.v1.schema.json" +) +_SAMPLE_PATH = _REPO_ROOT / "ledger" / "fixtures" / "consumer_facts.jsonl" + + +def test_packaged_schema_is_byte_identical_to_docs_schema(): + docs_bytes = _DOCS_SCHEMA_PATH.read_bytes() + packaged_bytes = _PACKAGED_SCHEMA_PATH.read_bytes() + + assert packaged_bytes == docs_bytes + assert hashlib.sha256(packaged_bytes).hexdigest() == CONSUMER_FACT_SCHEMA_SHA256 + + +def test_consumer_fact_schema_is_the_v1_contract_row(): + schema = consumer_fact_schema() + + assert schema["title"] == "Ledger consumer fact contract row" + assert schema["additionalProperties"] is False + assert schema["properties"]["schema_version"]["const"] == "ledger.consumer_fact.v1" + + +def test_valid_fixture_rows_pass_validation(): + rows = [ + json.loads(line) + for line in _SAMPLE_PATH.read_text().splitlines() + if line.strip() + ] + + assert len(rows) == 3 + for line_number, row in enumerate(rows, start=1): + validate_consumer_fact_row(row, line_number, _SAMPLE_PATH) + + +def test_missing_nested_required_field_names_field_and_location(): + row = json.loads(_SAMPLE_PATH.read_text().splitlines()[0]) + del row["observed_measure"]["unit"] + + with pytest.raises(ValueError) as excinfo: + validate_consumer_fact_row(row, 4, "sample.jsonl") + + message = str(excinfo.value) + assert "row 4 of sample.jsonl" in message + assert "observed_measure" in message + assert "unit" in message + + +def test_unknown_extra_field_is_rejected(): + row = json.loads(_SAMPLE_PATH.read_text().splitlines()[0]) + row["surprise_field"] = "unexpected" + + with pytest.raises(ValueError, match="surprise_field"): + validate_consumer_fact_row(row, 1, "sample.jsonl") diff --git a/uv.lock b/uv.lock index 6d1941a..b3a3df0 100644 --- a/uv.lock +++ b/uv.lock @@ -28,6 +28,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362, upload-time = "2025-11-28T23:36:57.897Z" }, ] +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + [[package]] name = "cachetools" version = "6.2.4" @@ -444,6 +453,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -856,6 +892,7 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "httpx" }, + { name = "jsonschema" }, { name = "numpy" }, { name = "odfpy" }, { name = "openpyxl" }, @@ -882,6 +919,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "httpx", specifier = ">=0.27.0" }, + { name = "jsonschema", specifier = ">=4.20.0" }, { name = "numpy", specifier = ">=1.24.0" }, { name = "odfpy", specifier = ">=1.4.1" }, { name = "openpyxl", specifier = ">=3.1.0" }, @@ -1414,6 +1452,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/35/e9d9c8b7aa4a11df18bb0e4e5d135d5d1236eb500e922bf68f41da30bdef/realtime-2.27.0-py3-none-any.whl", hash = "sha256:3a7444116ebed9b6a497d00acc51a3175bbf9819cfcc5c929a2b25ad9b7ddba6", size = 22139, upload-time = "2025-12-16T14:48:34.838Z" }, ] +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + [[package]] name = "requests" version = "2.32.5" @@ -1442,6 +1494,129 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, ] +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/1f/a2dca5ffdbf1d475ffc4e80e4d5d720ff3a00f691795910116960ee12511/rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7", size = 342174, upload-time = "2026-06-30T07:14:54.821Z" }, + { url = "https://files.pythonhosted.org/packages/4d/dc/323d08583c0832911768663d1944f0107fcd4088704858d84b5e06d105a0/rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911", size = 345513, upload-time = "2026-06-30T07:14:56.515Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2a/e31989834d18d2f26ec1d2774c5b1eb3331df4ea8ada525175294c94b48a/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4", size = 373783, upload-time = "2026-06-30T07:14:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/fe/e80107ee3639585c9941c17d6a42cd65325022f656c023191fce78c324c8/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261", size = 378316, upload-time = "2026-06-30T07:14:59.077Z" }, + { url = "https://files.pythonhosted.org/packages/22/6f/81e3adf81acfb6fa694de2a6e4e7d8863121e3e0799e0a7725e6cf5679c4/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278", size = 499423, upload-time = "2026-06-30T07:15:00.488Z" }, + { url = "https://files.pythonhosted.org/packages/2d/9a/41263969df0ce3d9af2a96d5005a288200af1989aed3354bfceb5fc0b21f/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9", size = 386077, upload-time = "2026-06-30T07:15:01.911Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/7e98f468bd50346faff5b10e5297374b443bfdddacc8e9fbc65984539597/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7", size = 371315, upload-time = "2026-06-30T07:15:03.317Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/2b973b4d371906a134b03decfea7f5d9835a2c6d263454392e15b64b5b18/rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3", size = 383502, upload-time = "2026-06-30T07:15:04.627Z" }, + { url = "https://files.pythonhosted.org/packages/98/2a/12e2799500af0a307bca76b63361c51f9fe479223561489c29eea1f2ee41/rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da", size = 402673, upload-time = "2026-06-30T07:15:05.856Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e3/21e5872d165fe08be4f229e3d5ee9d90019c0bf0e5538de60dbd54009450/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4", size = 549964, upload-time = "2026-06-30T07:15:07.159Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d0/5ee0fe36844297de8123bee27bc12078c1a7416ad9f1b8a8ca18d6b0c0ac/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6", size = 615446, upload-time = "2026-06-30T07:15:08.531Z" }, + { url = "https://files.pythonhosted.org/packages/b1/80/1ea5873cb683f2fbe5f21b23ea1f6d179ead19f3c5b249b7eb5dca568ef2/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93", size = 576975, upload-time = "2026-06-30T07:15:09.97Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e1/90ef639217a5ddb15b7f4f61b1c33911fd044ad03c311bafdd2bcab85582/rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a", size = 204453, upload-time = "2026-06-30T07:15:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b7/b7a1695d7af36f521fb11e80d6d3adbd744f73b921859bd3c2a2c0dc706f/rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127", size = 223219, upload-time = "2026-06-30T07:15:12.476Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a2/145afacf796e4506062825941176ad9445c2dcf2b3b6a1f13d3030a15e19/rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804", size = 219137, upload-time = "2026-06-30T07:15:13.631Z" }, + { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, + { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" }, + { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" }, + { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" }, + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, + { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" }, + { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" }, + { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" }, + { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" }, + { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" }, + { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" }, + { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" }, + { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" }, + { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" }, + { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" }, + { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" }, + { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" }, + { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" }, + { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" }, + { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" }, + { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" }, + { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" }, + { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" }, + { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" }, + { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" }, + { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" }, + { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" }, + { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" }, + { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" }, + { url = "https://files.pythonhosted.org/packages/b4/9c/f0d19ac587fd0e4ab6b72cda355e9c5a6166b01ef7e064e437aef8eb9fef/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f", size = 349791, upload-time = "2026-06-30T07:17:33.315Z" }, + { url = "https://files.pythonhosted.org/packages/38/c7/1d49d204c9fd2ee6c537601dc4c1ba921e03363ca576bfab94a00254ac9a/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171", size = 352842, upload-time = "2026-06-30T07:17:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e5/c0b5dc93cd0d4c06ce1f438907649514e2ea077bcd911e3154a51e96c38e/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90", size = 382094, upload-time = "2026-06-30T07:17:36.514Z" }, + { url = "https://files.pythonhosted.org/packages/0d/54/ec0e907b4ca8d541112db352409bd15f871c9b243e0c92c9b5a46ae96f01/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca", size = 388662, upload-time = "2026-06-30T07:17:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f4/921c22a4fd0f1c1ac13a3996ffbf0aa67951e2c8ad0d1d9574938a2932e8/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9", size = 504896, upload-time = "2026-06-30T07:17:39.689Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1b/a114b972cefa1ab1cdb3c7bb177cd3844a12826c507c722d3a73516dbbaf/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c", size = 391545, upload-time = "2026-06-30T07:17:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/4e/98/af9b3db77d47fcbe6c8c1f36e2c2147ec70292819e99c325f871584a1c11/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9", size = 380059, upload-time = "2026-06-30T07:17:42.857Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ba/0efd8668b97c1d26a61566386c636a7a7a09829e474fdf807caa15a2c844/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41", size = 393235, upload-time = "2026-06-30T07:17:44.637Z" }, + { url = "https://files.pythonhosted.org/packages/62/90/8c139ee9690f73b0829f32647de6f40d826f8f443af6fa72644f96351aac/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c", size = 413008, upload-time = "2026-06-30T07:17:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/0043896fdd7828ce09a1d9a8b06433714d0960fc4ff3fc4aa72b666b764e/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9", size = 558118, upload-time = "2026-06-30T07:17:47.759Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/02355f0e134f783a8f9814c4680a1bd311d37671577a5964ea838573ff37/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76", size = 623138, upload-time = "2026-06-30T07:17:49.355Z" }, + { url = "https://files.pythonhosted.org/packages/10/85/48f0abdcef5cce4e034c7a5b0ceeceba0b01bf0d942824f4bb720afe2dec/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826", size = 586486, upload-time = "2026-06-30T07:17:51.141Z" }, +] + [[package]] name = "ruff" version = "0.14.10" From 6823fce94a60afc7057b8169430f4782eb684838 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 10 Jul 2026 07:23:14 -0400 Subject: [PATCH 2/3] Harden consumer artifacts and resolve dimension cross-tabs Schema-validate every fact row at build and load, always and with no public opt-out, and reject rows with a duplicate aggregate_fact_key. Record the schema sha256 in the manifest so a load rejects an unknown schema version loudly; older manifests without the field still get full row validation. Hash each profile over the exact bytes written, including the trailing newline, and re-verify every profile file against the manifest on load. A pre-fix manifest whose hash omitted the newline matches only through an explicit legacy_profile_hash path, recorded per profile on the loaded artifact; tampered profile bytes never match and fail the load. Add a dimensions selector that matches a row's dimension identity as an order-insensitive exact set, so the ONS SIC-by-turnover and SIC-by-employment firm cross-tab targets resolve strictly. Adversarial tests exercise each guard, a packaged-profile gate rejects any profile whose selector uses an unsupported key, and a fixtures test resolves both cross-tab targets end to end through the artifact. Co-Authored-By: Claude Fable 5 --- policyengine_ledger/consumer.py | 122 ++++++++- tests/test_ledger_consumer.py | 248 ++++++++++++++++++ ...est_policyengine_ledger_target_profiles.py | 35 +++ 3 files changed, 394 insertions(+), 11 deletions(-) diff --git a/policyengine_ledger/consumer.py b/policyengine_ledger/consumer.py index b5bd377..8c26a6e 100644 --- a/policyengine_ledger/consumer.py +++ b/policyengine_ledger/consumer.py @@ -27,6 +27,10 @@ from typing import Any from ledger.core import ALLOWED_ASSERTIONS, DEFAULT_ASSERTION +from policyengine_ledger.schema import ( + CONSUMER_FACT_SCHEMA_SHA256, + validate_consumer_fact_row, +) from policyengine_ledger.target_profiles import ( TargetProfile, TargetProfileTarget, @@ -51,6 +55,7 @@ "record_set_id", "record_set_spec_id", "groupby_dimension", + "dimensions", "domain", "entity", "assertion", @@ -419,12 +424,21 @@ def _select_rows( for row in rows if row.get("geography", {}).get("level") == geography_level and all( - _selector_value(row, key) == value for key, value in selector.items() + _selector_matches(row, key, value) for key, value in selector.items() ) ] return matched, issues +def _selector_matches(row: Mapping[str, Any], key: str, value: Any) -> bool: + actual = _selector_value(row, key) + if isinstance(actual, list): + # Dimension-identity selectors match order-insensitively on the exact + # set of dimension variable names the row carries. + return isinstance(value, list) and sorted(actual) == sorted(value) + return actual == value + + def _selector_value(row: Mapping[str, Any], key: str) -> Any: source = row.get("source", {}) observed_measure = row.get("observed_measure", {}) @@ -448,6 +462,8 @@ def _selector_value(row: Mapping[str, Any], key: str) -> Any: return layout.get("record_set_spec_id") if key == "groupby_dimension": return layout.get("groupby_dimension") + if key == "dimensions": + return sorted(row.get("dimensions", {})) if key == "domain": return row.get("universe_constraints", {}).get("domain") if key == "entity": @@ -541,12 +557,20 @@ def _normalize_alignments( @dataclass(frozen=True) class ConsumerArtifact: - """A loaded Ledger consumer artifact.""" + """A loaded Ledger consumer artifact. + + ``profile_hash_semantics`` records, per profile id, which manifest hash + semantics the load accepted: ``exact`` for the byte-for-byte file hash, or + ``legacy_profile_hash`` for a pre-fix manifest whose profile hash omitted + the trailing newline. Tampered profile bytes never match either and fail + the load. + """ path: Path manifest: dict[str, Any] rows: tuple[dict[str, Any], ...] profiles: Mapping[str, TargetProfile] + profile_hash_semantics: Mapping[str, str] = field(default_factory=dict) def resolve( self, @@ -623,7 +647,7 @@ def build_consumer_artifact( shutil.rmtree(output_path) output_path.mkdir(parents=True) - rows = _load_consumer_rows(_resolve_facts_path(facts_path)) + rows = _load_consumer_rows(_resolve_facts_path(facts_path), validate_schema=True) profiles = _load_profiles(profile_ids, profile_paths) facts_out = output_path / "consumer_facts.jsonl" @@ -637,9 +661,10 @@ def build_consumer_artifact( profile_meta: dict[str, Any] = {} for profile_id, payload in profiles.items(): profile_json = json.dumps(payload, sort_keys=True, indent=2) - (profiles_dir / f"{profile_id}.json").write_text(profile_json + "\n") + profile_bytes = (profile_json + "\n").encode("utf-8") + (profiles_dir / f"{profile_id}.json").write_bytes(profile_bytes) profile_meta[profile_id] = { - "sha256": hashlib.sha256(profile_json.encode("utf-8")).hexdigest(), + "sha256": hashlib.sha256(profile_bytes).hexdigest(), "target_count": len(payload["targets"]), } @@ -651,6 +676,7 @@ def build_consumer_artifact( "consumer_fact_schema_versions": sorted( {row.get("schema_version") for row in rows} ), + "consumer_fact_schema_sha256": CONSUMER_FACT_SCHEMA_SHA256, "fact_row_count": len(rows), "facts_sha256": _sha256_file(facts_out), "profiles": profile_meta, @@ -667,7 +693,14 @@ def build_consumer_artifact( def load_consumer_artifact(path: str | Path) -> ConsumerArtifact: - """Load a consumer artifact directory and verify its manifest hashes.""" + """Load a consumer artifact directory and verify its manifest hashes. + + Verification is fail-closed: the manifest's declared consumer-fact schema + (when present) must match the packaged schema, fact rows are re-hashed and + schema-validated, and every profile file is re-hashed against the manifest. + A manifest that predates the profile-hash fix may match through the + explicit ``legacy_profile_hash`` path, recorded on the returned artifact. + """ artifact_path = Path(path) manifest = json.loads((artifact_path / "manifest.json").read_text()) if manifest.get("schema_version") != CONSUMER_ARTIFACT_SCHEMA_VERSION: @@ -675,6 +708,16 @@ def load_consumer_artifact(path: str | Path) -> ConsumerArtifact: "Unsupported consumer artifact schema_version: " f"{manifest.get('schema_version')!r}." ) + manifest_schema_sha256 = manifest.get("consumer_fact_schema_sha256") + if ( + manifest_schema_sha256 is not None + and manifest_schema_sha256 != CONSUMER_FACT_SCHEMA_SHA256 + ): + raise ValueError( + "Consumer artifact declares consumer_fact_schema_sha256 " + f"{manifest_schema_sha256!r}, which does not match the packaged " + f"consumer-fact schema {CONSUMER_FACT_SCHEMA_SHA256!r}." + ) facts_file = artifact_path / "consumer_facts.jsonl" actual_sha256 = _sha256_file(facts_file) if actual_sha256 != manifest["facts_sha256"]: @@ -682,18 +725,61 @@ def load_consumer_artifact(path: str | Path) -> ConsumerArtifact: f"Consumer artifact fact rows do not match the manifest hash: " f"{actual_sha256} != {manifest['facts_sha256']}." ) - rows = _load_consumer_rows(facts_file) + rows = _load_consumer_rows(facts_file, validate_schema=True) + manifest_predates_fix = "consumer_fact_schema_sha256" not in manifest profiles: dict[str, TargetProfile] = {} - for profile_id in manifest.get("profiles", {}): - payload = json.loads( - (artifact_path / "profiles" / f"{profile_id}.json").read_text() + profile_hash_semantics: dict[str, str] = {} + for profile_id, profile_meta in manifest.get("profiles", {}).items(): + profile_file = artifact_path / "profiles" / f"{profile_id}.json" + profile_hash_semantics[profile_id] = _verify_profile_hash( + profile_id, + profile_file, + profile_meta, + manifest_predates_fix=manifest_predates_fix, ) + payload = json.loads(profile_file.read_text()) profiles[profile_id] = target_profile_from_mapping(payload) return ConsumerArtifact( path=artifact_path, manifest=manifest, rows=tuple(rows), profiles=profiles, + profile_hash_semantics=profile_hash_semantics, + ) + + +def _verify_profile_hash( + profile_id: str, + profile_file: Path, + profile_meta: Any, + *, + manifest_predates_fix: bool, +) -> str: + """Return the profile hash semantics matched, or raise on any mismatch. + + ``exact`` matches the byte-for-byte file hash written since the fix. + ``legacy_profile_hash`` matches a pre-fix manifest whose hash omitted the + trailing newline; it is accepted only when the manifest predates the fix + (no ``consumer_fact_schema_sha256``). Tampered bytes match neither. + """ + expected = profile_meta.get("sha256") if isinstance(profile_meta, Mapping) else None + if not expected: + raise ValueError( + f"Consumer artifact manifest is missing a sha256 for profile " + f"{profile_id!r}." + ) + file_bytes = profile_file.read_bytes() + if hashlib.sha256(file_bytes).hexdigest() == expected: + return "exact" + if ( + manifest_predates_fix + and file_bytes.endswith(b"\n") + and hashlib.sha256(file_bytes[:-1]).hexdigest() == expected + ): + return "legacy_profile_hash" + raise ValueError( + f"Consumer artifact profile {profile_id!r} does not match the manifest " + f"hash: {hashlib.sha256(file_bytes).hexdigest()} != {expected}." ) @@ -711,19 +797,33 @@ def _resolve_facts_path(facts_path: str | Path) -> Path: return path -def _load_consumer_rows(path: Path) -> list[dict[str, Any]]: +def _load_consumer_rows( + path: Path, + *, + validate_schema: bool = True, +) -> list[dict[str, Any]]: rows: list[dict[str, Any]] = [] + seen_keys: set[str] = set() with path.open() as file: for line_number, line in enumerate(file, start=1): if not line.strip(): continue row = json.loads(line) + if validate_schema: + validate_consumer_fact_row(row, line_number, path) assertion = row.setdefault("assertion", DEFAULT_ASSERTION) if assertion not in ALLOWED_ASSERTIONS: raise ValueError( f"Row {line_number} of {path} has unsupported assertion " f"{assertion!r}." ) + key = row.get("aggregate_fact_key") + if key in seen_keys: + raise ValueError( + f"Row {line_number} of {path} repeats aggregate_fact_key " + f"{key!r}; consumer artifact fact rows must be unique." + ) + seen_keys.add(key) rows.append(row) return rows diff --git a/tests/test_ledger_consumer.py b/tests/test_ledger_consumer.py index fda15ea..efffc59 100644 --- a/tests/test_ledger_consumer.py +++ b/tests/test_ledger_consumer.py @@ -9,12 +9,16 @@ from __future__ import annotations +import hashlib import json +from pathlib import Path import pytest +import policyengine_ledger.target_profiles as target_profiles_pkg from ledger.consumer_contract import consumer_fact_rows from ledger.core import ( + AggregateConstraint, AggregateFact, Aggregation, EntityDimension, @@ -31,6 +35,7 @@ load_consumer_artifact, resolve_profile_targets, ) +from policyengine_ledger.schema import CONSUMER_FACT_SCHEMA_SHA256 from policyengine_ledger.target_profiles import target_profile_from_mapping SHA = "ab" * 32 @@ -394,3 +399,246 @@ def test_artifact_is_reproducible(tmp_path): ) for name in ("manifest.json", "coverage.json", "consumer_facts.jsonl"): assert (first / name).read_bytes() == (second / name).read_bytes() + + +def _ons_firm_crosstab_fact(*, record_set_id, dimensions, value, cell): + constraints = tuple( + AggregateConstraint(variable=key, operator="==", value=band) + for key, band in sorted(dimensions.items()) + ) + return AggregateFact( + value=value, + period=PeriodDimension(type="calendar_year", value=2025), + geography=GeographyDimension(level="country", id="K02000001", vintage="2025"), + entity=EntityDimension(name="firm"), + measure=Measure(concept="uk.firm.count", unit="count"), + aggregation=Aggregation(method="sum"), + source=SourceProvenance( + source_name="ons", + source_table="UK Business Counts 2025", + source_file="ukbusinesscounts2025.xlsx", + url="https://www.ons.gov.uk/ukbusinesscounts2025.xlsx", + vintage="cy2025", + extracted_at="2026-06-01", + extraction_method="test", + source_sha256=SHA, + source_size_bytes=100, + raw_r2_bucket="ledger-raw", + raw_r2_key=f"raw/ons/ukbc/{cell}/{SHA}/x.xlsx", + raw_r2_uri=f"r2://ledger-raw/raw/ons/ukbc/{cell}/{SHA}/x.xlsx", + ), + domain="uk_enterprises", + source_record_id=f"ons.uk_business.cy2025.{cell}", + source_cell_keys=(f"ledger.source_cell.v1:{cell}",), + filters=dict(dimensions), + constraints=constraints, + layout=SourceRecordLayout( + record_set_id=record_set_id, + record_set_spec_id="ons.uk_business.v1", + measure_id="enterprise_count", + ), + ) + + +def _uk_firms_crosstab_rows(): + by_sic_turnover = "ons.uk_business.cy2025.enterprise_count.by_sic_turnover_band" + by_sic_employment = "ons.uk_business.cy2025.enterprise_count.by_sic_employment_band" + return consumer_fact_rows( + [ + _ons_firm_crosstab_fact( + record_set_id=by_sic_turnover, + dimensions={ + "uk.firm.sic_code": "A", + "uk.firm.turnover_band": "0_99k", + }, + value=1200, + cell="sic_turnover.A.0_99k", + ), + _ons_firm_crosstab_fact( + record_set_id=by_sic_turnover, + dimensions={ + "uk.firm.sic_code": "C", + "uk.firm.turnover_band": "100_249k", + }, + value=800, + cell="sic_turnover.C.100_249k", + ), + _ons_firm_crosstab_fact( + record_set_id=by_sic_employment, + dimensions={ + "uk.firm.sic_code": "A", + "uk.firm.employment_band": "0_9", + }, + value=1500, + cell="sic_employment.A.0_9", + ), + _ons_firm_crosstab_fact( + record_set_id=by_sic_employment, + dimensions={ + "uk.firm.sic_code": "C", + "uk.firm.employment_band": "10_49", + }, + value=430, + cell="sic_employment.C.10_49", + ), + ] + ) + + +def _uk_firms_crosstab_profile_payload(): + payload = json.loads( + (Path(target_profiles_pkg.__file__).parent / "uk_firms.json").read_text() + ) + crosstab_ids = { + "ons.uk_business.enterprise_count.sic_turnover_bands", + "ons.uk_business.enterprise_count.sic_employment_bands", + } + payload["targets"] = [ + target for target in payload["targets"] if target["target_id"] in crosstab_ids + ] + return payload + + +def test_uk_firms_cross_tab_targets_resolve_through_dimensions_selector(tmp_path): + facts_path = tmp_path / "consumer_facts.jsonl" + with facts_path.open("w") as file: + for row in _uk_firms_crosstab_rows(): + file.write(json.dumps(row, sort_keys=True) + "\n") + profile_path = tmp_path / "uk_firms.json" + profile_path.write_text(json.dumps(_uk_firms_crosstab_profile_payload())) + + out_dir = tmp_path / "artifact" + build_consumer_artifact( + out_dir, + facts_path=facts_path, + profile_paths=[profile_path], + ) + artifact = load_consumer_artifact(out_dir) + + report = artifact.resolve("uk_firms", {"type": "calendar_year", "value": 2025}) + + assert report.valid + assert artifact.profile_hash_semantics == {"uk_firms": "exact"} + by_target: dict[str, list] = {} + for row in report.resolved: + assert row.basis == "fact" + by_target.setdefault(row.target_id, []).append(row) + + sic_turnover = by_target["ons.uk_business.enterprise_count.sic_turnover_bands"] + sic_employment = by_target["ons.uk_business.enterprise_count.sic_employment_bands"] + assert sorted(row.value for row in sic_turnover) == [800, 1200] + assert sorted(row.value for row in sic_employment) == [430, 1500] + assert all( + sorted(row.dimensions) == ["uk.firm.sic_code", "uk.firm.turnover_band"] + for row in sic_turnover + ) + assert all( + sorted(row.dimensions) == ["uk.firm.employment_band", "uk.firm.sic_code"] + for row in sic_employment + ) + + +def _rewrite_facts_file(out_dir, rows): + facts_file = out_dir / "consumer_facts.jsonl" + facts_file.write_text( + "".join(json.dumps(row, sort_keys=True) + "\n" for row in rows) + ) + manifest_path = out_dir / "manifest.json" + manifest = json.loads(manifest_path.read_text()) + manifest["facts_sha256"] = hashlib.sha256(facts_file.read_bytes()).hexdigest() + manifest_path.write_text(json.dumps(manifest, sort_keys=True, indent=2) + "\n") + + +def test_artifact_load_rejects_row_missing_required_field(tmp_path): + facts_path, profile_path = _write_artifact_inputs(tmp_path) + out_dir = tmp_path / "artifact" + build_consumer_artifact(out_dir, facts_path=facts_path, profile_paths=[profile_path]) + + rows = _rows() + del rows[0]["observed_measure"]["unit"] + _rewrite_facts_file(out_dir, rows) + + with pytest.raises(ValueError, match="unit"): + load_consumer_artifact(out_dir) + + +def test_artifact_load_rejects_unknown_extra_field(tmp_path): + facts_path, profile_path = _write_artifact_inputs(tmp_path) + out_dir = tmp_path / "artifact" + build_consumer_artifact(out_dir, facts_path=facts_path, profile_paths=[profile_path]) + + rows = _rows() + rows[0]["unexpected_field"] = "surprise" + _rewrite_facts_file(out_dir, rows) + + with pytest.raises(ValueError, match="unexpected_field"): + load_consumer_artifact(out_dir) + + +def test_artifact_load_rejects_unknown_schema_sha256(tmp_path): + facts_path, profile_path = _write_artifact_inputs(tmp_path) + out_dir = tmp_path / "artifact" + build_consumer_artifact(out_dir, facts_path=facts_path, profile_paths=[profile_path]) + + manifest_path = out_dir / "manifest.json" + manifest = json.loads(manifest_path.read_text()) + manifest["consumer_fact_schema_sha256"] = "0" * 64 + manifest_path.write_text(json.dumps(manifest, sort_keys=True, indent=2) + "\n") + + with pytest.raises(ValueError, match="consumer_fact_schema_sha256"): + load_consumer_artifact(out_dir) + + +def test_artifact_load_rejects_tampered_profile(tmp_path): + facts_path, profile_path = _write_artifact_inputs(tmp_path) + out_dir = tmp_path / "artifact" + build_consumer_artifact(out_dir, facts_path=facts_path, profile_paths=[profile_path]) + + profile_file = out_dir / "profiles" / "test_profile.json" + tampered = profile_file.read_bytes().replace(b"Test profile", b"Xest profile", 1) + assert tampered != profile_file.read_bytes() + profile_file.write_bytes(tampered) + + with pytest.raises(ValueError, match="does not match the manifest"): + load_consumer_artifact(out_dir) + + +def test_artifact_load_accepts_legacy_profile_hash_only_via_explicit_path(tmp_path): + facts_path, profile_path = _write_artifact_inputs(tmp_path) + out_dir = tmp_path / "artifact" + build_consumer_artifact(out_dir, facts_path=facts_path, profile_paths=[profile_path]) + + profile_file = out_dir / "profiles" / "test_profile.json" + profile_bytes = profile_file.read_bytes() + assert profile_bytes.endswith(b"\n") + legacy_hash = hashlib.sha256(profile_bytes[:-1]).hexdigest() + + manifest_path = out_dir / "manifest.json" + manifest = json.loads(manifest_path.read_text()) + # A pre-fix manifest carries no schema sha and hashed the profile without + # its trailing newline. + del manifest["consumer_fact_schema_sha256"] + manifest["profiles"]["test_profile"]["sha256"] = legacy_hash + manifest_path.write_text(json.dumps(manifest, sort_keys=True, indent=2) + "\n") + + artifact = load_consumer_artifact(out_dir) + assert artifact.profile_hash_semantics == {"test_profile": "legacy_profile_hash"} + + # The same legacy hash is rejected once the manifest is post-fix. + manifest["consumer_fact_schema_sha256"] = CONSUMER_FACT_SCHEMA_SHA256 + manifest_path.write_text(json.dumps(manifest, sort_keys=True, indent=2) + "\n") + with pytest.raises(ValueError, match="does not match the manifest"): + load_consumer_artifact(out_dir) + + +def test_artifact_build_rejects_duplicate_aggregate_fact_key(tmp_path): + facts_path, profile_path = _write_artifact_inputs(tmp_path) + with facts_path.open("a") as file: + file.write(json.dumps(_rows()[0], sort_keys=True) + "\n") + + with pytest.raises(ValueError, match="aggregate_fact_key"): + build_consumer_artifact( + tmp_path / "artifact", + facts_path=facts_path, + profile_paths=[profile_path], + ) diff --git a/tests/test_policyengine_ledger_target_profiles.py b/tests/test_policyengine_ledger_target_profiles.py index 2fc45fe..da0c74b 100644 --- a/tests/test_policyengine_ledger_target_profiles.py +++ b/tests/test_policyengine_ledger_target_profiles.py @@ -1,13 +1,23 @@ from __future__ import annotations +from pathlib import Path + import pytest +import policyengine_ledger.target_profiles as target_profiles_pkg +from policyengine_ledger.consumer import _select_rows from policyengine_ledger.target_profiles import ( TARGET_PROFILE_SCHEMA_VERSION, load_target_profile, target_profile_from_mapping, ) +_PROFILE_DIR = Path(target_profiles_pkg.__file__).parent + + +def _packaged_profile_ids() -> list[str]: + return sorted(path.stem for path in _PROFILE_DIR.glob("*.json")) + def test__given_uk_local_profile__then_it_declares_measurement_contracts() -> None: # When @@ -243,6 +253,31 @@ def test__given_non_sum_default_operation__then_profile_is_rejected() -> None: target_profile_from_mapping(payload) +def test_every_packaged_profile_selector_uses_supported_keys() -> None: + # Given the packaged target profiles + profile_ids = _packaged_profile_ids() + assert profile_ids + + # Then every ledger_selector resolves against the supported vocabulary + for profile_id in profile_ids: + profile = load_target_profile(profile_id) + for target in profile.targets: + for level in target.geography_levels: + _, issues = _select_rows( + profile.profile_id, + target, + [], + geography_level=level, + ) + unknown = [ + issue for issue in issues if issue.code == "unknown_selector_key" + ] + assert not unknown, ( + f"{profile_id}/{target.target_id} ships an unsupported " + f"selector: {[issue.message for issue in unknown]}" + ) + + def _minimal_profile_payload() -> dict[str, object]: return { "schema_version": TARGET_PROFILE_SCHEMA_VERSION, From 3e2b136361ca9abe578cbff6d83dbfd5a92daee1 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 10 Jul 2026 11:47:53 -0400 Subject: [PATCH 3/3] Recompute identity keys and reject non-finite numbers on consumer load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sol round-2 finding 7. Consumer artifact load was trusting declared content; now it recomputes and cross-checks: - Every fact row's aggregate_fact_key is recomputed from the row's own component keys and raw aggregation/period/geography/entity/assertion via the shared _hash_key; a declared key that does not hash the row is rejected. Schema validation only checks key SYNTAX, so a syntactically valid all-zero identity key previously loaded clean. - Rows parse with a parse_constant that rejects NaN/Infinity/-Infinity, and every numeric value (nested included) must be finite — Python's default json.loads accepts those non-JSON tokens. - The manifest's fact_row_count and each profile's target_count are compared with the actually-loaded rows/targets and a mismatch is rejected. Full recomputation of the leaf component keys from raw fields (rebuilding an AggregateFact from the projected consumer row) is the remaining depth for internally-consistent forged keys; the top-level recompute closes the demonstrated all-zero forgery. Co-Authored-By: Claude Fable 5 --- policyengine_ledger/consumer.py | 80 ++++++++++++++++++++++++++++++++- tests/test_ledger_consumer.py | 73 ++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+), 2 deletions(-) diff --git a/policyengine_ledger/consumer.py b/policyengine_ledger/consumer.py index 8c26a6e..9511451 100644 --- a/policyengine_ledger/consumer.py +++ b/policyengine_ledger/consumer.py @@ -19,6 +19,7 @@ import hashlib import json +import math import shutil from collections.abc import Mapping, Sequence from dataclasses import asdict, dataclass, field @@ -26,6 +27,7 @@ from pathlib import Path from typing import Any +from ledger.consumer_contract import _hash_key from ledger.core import ALLOWED_ASSERTIONS, DEFAULT_ASSERTION from policyengine_ledger.schema import ( CONSUMER_FACT_SCHEMA_SHA256, @@ -726,6 +728,12 @@ def load_consumer_artifact(path: str | Path) -> ConsumerArtifact: f"{actual_sha256} != {manifest['facts_sha256']}." ) rows = _load_consumer_rows(facts_file, validate_schema=True) + declared_row_count = manifest.get("fact_row_count") + if declared_row_count is not None and declared_row_count != len(rows): + raise ValueError( + f"Consumer artifact manifest declares fact_row_count " + f"{declared_row_count} but the feed carries {len(rows)} rows." + ) manifest_predates_fix = "consumer_fact_schema_sha256" not in manifest profiles: dict[str, TargetProfile] = {} profile_hash_semantics: dict[str, str] = {} @@ -738,7 +746,19 @@ def load_consumer_artifact(path: str | Path) -> ConsumerArtifact: manifest_predates_fix=manifest_predates_fix, ) payload = json.loads(profile_file.read_text()) - profiles[profile_id] = target_profile_from_mapping(payload) + profile = target_profile_from_mapping(payload) + declared_targets = ( + profile_meta.get("target_count") + if isinstance(profile_meta, Mapping) + else None + ) + if declared_targets is not None and declared_targets != len(payload["targets"]): + raise ValueError( + f"Consumer artifact manifest declares target_count " + f"{declared_targets} for profile {profile_id!r} but the file " + f"carries {len(payload['targets'])} targets." + ) + profiles[profile_id] = profile return ConsumerArtifact( path=artifact_path, manifest=manifest, @@ -797,6 +817,53 @@ def _resolve_facts_path(facts_path: str | Path) -> Path: return path +def _reject_non_finite(value: Any) -> Any: + raise ValueError(f"Consumer fact contains a non-finite JSON number: {value!r}.") + + +def _assert_finite_numbers(value: Any, *, line_number: int, path: Path) -> None: + """Reject NaN/Infinity even inside nested structures. + + ``json.loads`` accepts ``NaN``/``Infinity`` tokens that are not valid JSON + under the consumer-fact contract; a non-finite value must never enter a + schema-valid, hash-valid artifact. + """ + if isinstance(value, float) and not math.isfinite(value): + raise ValueError( + f"Row {line_number} of {path} contains a non-finite number: {value!r}." + ) + if isinstance(value, dict): + for item in value.values(): + _assert_finite_numbers(item, line_number=line_number, path=path) + elif isinstance(value, list): + for item in value: + _assert_finite_numbers(item, line_number=line_number, path=path) + + +def _recompute_aggregate_fact_key(row: dict[str, Any]) -> str: + """Recompute the aggregate fact key from the row's own content. + + The producer derives ``aggregate_fact_key`` over the row's component keys + plus its raw aggregation/period/geography/entity/assertion; recomputing it + here and comparing rejects a forged or drifted identity key that schema + validation (which only checks key SYNTAX) and uniqueness cannot catch. + """ + assertion = row.get("assertion") + payload = { + "source_release_key": row.get("source_release_key"), + "source_series_key": row.get("source_series_key"), + "observed_measure_key": row.get("observed_measure_key"), + "aggregation": row.get("aggregation"), + "period": row.get("period"), + "geography": row.get("geography"), + "entity": row.get("entity"), + "dimension_set_key": row.get("dimension_set_key"), + "universe_constraint_set_key": row.get("universe_constraint_set_key"), + "assertion": None if assertion == DEFAULT_ASSERTION else assertion, + } + return _hash_key("ledger.aggregate_fact.v2", payload) + + def _load_consumer_rows( path: Path, *, @@ -808,7 +875,8 @@ def _load_consumer_rows( for line_number, line in enumerate(file, start=1): if not line.strip(): continue - row = json.loads(line) + row = json.loads(line, parse_constant=_reject_non_finite) + _assert_finite_numbers(row, line_number=line_number, path=path) if validate_schema: validate_consumer_fact_row(row, line_number, path) assertion = row.setdefault("assertion", DEFAULT_ASSERTION) @@ -818,6 +886,14 @@ def _load_consumer_rows( f"{assertion!r}." ) key = row.get("aggregate_fact_key") + if validate_schema: + recomputed = _recompute_aggregate_fact_key(row) + if key != recomputed: + raise ValueError( + f"Row {line_number} of {path} declares aggregate_fact_key " + f"{key!r} but its content hashes to {recomputed!r}; the " + "identity key does not match the row." + ) if key in seen_keys: raise ValueError( f"Row {line_number} of {path} repeats aggregate_fact_key " diff --git a/tests/test_ledger_consumer.py b/tests/test_ledger_consumer.py index efffc59..3de7016 100644 --- a/tests/test_ledger_consumer.py +++ b/tests/test_ledger_consumer.py @@ -642,3 +642,76 @@ def test_artifact_build_rejects_duplicate_aggregate_fact_key(tmp_path): facts_path=facts_path, profile_paths=[profile_path], ) + + +def _build_and_load_with_row_mutation(tmp_path, mutate): + facts_path, profile_path = _write_artifact_inputs(tmp_path) + out_dir = tmp_path / "artifact" + build_consumer_artifact(out_dir, facts_path=facts_path, profile_paths=[profile_path]) + facts_file = out_dir / "consumer_facts.jsonl" + lines = [ln for ln in facts_file.read_text().splitlines() if ln.strip()] + rows = [json.loads(ln) for ln in lines] + mutate(rows) + facts_file.write_text( + "".join(json.dumps(r, sort_keys=True) + "\n" for r in rows) + ) + # Re-point the manifest facts hash so the row checks (not the file hash) fire. + import hashlib as _hashlib + + manifest_path = out_dir / "manifest.json" + manifest = json.loads(manifest_path.read_text()) + manifest["facts_sha256"] = _hashlib.sha256(facts_file.read_bytes()).hexdigest() + manifest_path.write_text(json.dumps(manifest)) + return out_dir + + +def test_load_rejects_a_forged_all_zero_identity_key(tmp_path): + # Sol finding 7: schema validates key SYNTAX; a syntactically valid but + # forged identity key that does not hash the row's content must be rejected. + def zero_keys(rows): + zero = "0" * 24 + rows[0]["aggregate_fact_key"] = f"ledger.aggregate_fact.v2:{zero}" + rows[0]["source_release_key"] = f"ledger.source_release.v2:{zero}" + rows[0]["source_series_key"] = f"ledger.source_series.v2:{zero}" + rows[0]["observed_measure_key"] = f"ledger.observed_measure.v2:{zero}" + rows[0]["dimension_set_key"] = f"ledger.dimension_set.v2:{zero}" + rows[0]["universe_constraint_set_key"] = ( + f"ledger.universe_constraint_set.v2:{zero}" + ) + + out_dir = _build_and_load_with_row_mutation(tmp_path, zero_keys) + with pytest.raises(ValueError, match="does not match the row"): + load_consumer_artifact(out_dir) + + +def test_load_rejects_a_non_finite_number(tmp_path): + facts_path, profile_path = _write_artifact_inputs(tmp_path) + out_dir = tmp_path / "artifact" + build_consumer_artifact(out_dir, facts_path=facts_path, profile_paths=[profile_path]) + facts_file = out_dir / "consumer_facts.jsonl" + lines = [ln for ln in facts_file.read_text().splitlines() if ln.strip()] + # Inject a raw NaN token that json.loads would otherwise accept. + lines[0] = lines[0].replace('"value":110', '"value":NaN', 1) + if "NaN" not in lines[0]: + lines[0] = lines[0][:-1] + ',"rogue":NaN}' + facts_file.write_text("\n".join(lines) + "\n") + import hashlib as _hashlib + + manifest_path = out_dir / "manifest.json" + manifest = json.loads(manifest_path.read_text()) + manifest["facts_sha256"] = _hashlib.sha256(facts_file.read_bytes()).hexdigest() + manifest_path.write_text(json.dumps(manifest)) + with pytest.raises(ValueError, match="non-finite"): + load_consumer_artifact(out_dir) + + +def test_load_rejects_a_false_manifest_row_count(tmp_path): + facts_path, profile_path = _write_artifact_inputs(tmp_path) + out_dir = tmp_path / "artifact" + build_consumer_artifact(out_dir, facts_path=facts_path, profile_paths=[profile_path]) + manifest_path = out_dir / "manifest.json" + manifest = json.loads(manifest_path.read_text()) + manifest["fact_row_count"] = 999 + manifest_path.write_text(json.dumps(manifest)) + with pytest.raises(ValueError, match="fact_row_count"): + load_consumer_artifact(out_dir)