equiframe 0.12.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. equiframe/__init__.py +832 -0
  2. equiframe/__main__.py +3 -0
  3. equiframe/_base.py +84 -0
  4. equiframe/_cli_support.py +125 -0
  5. equiframe/_comparison_io.py +161 -0
  6. equiframe/_dataset_provenance.py +146 -0
  7. equiframe/_decimal.py +22 -0
  8. equiframe/_diff_capture.py +433 -0
  9. equiframe/_doctor.py +123 -0
  10. equiframe/_document_io.py +61 -0
  11. equiframe/_documents.py +79 -0
  12. equiframe/_duckdb_transport.py +149 -0
  13. equiframe/_error_reporting.py +52 -0
  14. equiframe/_first_use.py +102 -0
  15. equiframe/_path_compat.py +26 -0
  16. equiframe/_physical.py +133 -0
  17. equiframe/_r_review.R +308 -0
  18. equiframe/_regular_io.py +167 -0
  19. equiframe/_report_format_v1.py +298 -0
  20. equiframe/_report_io.py +591 -0
  21. equiframe/_stability_inventory.py +1421 -0
  22. equiframe/_timezones.py +257 -0
  23. equiframe/_vendor/cyclonedx/LICENSE +201 -0
  24. equiframe/_vendor/cyclonedx/PROVENANCE.md +18 -0
  25. equiframe/_vendor/cyclonedx/bom-1.6.schema.json +5673 -0
  26. equiframe/_vendor/cyclonedx/jsf-0.82.schema.json +240 -0
  27. equiframe/_vendor/cyclonedx/spdx.schema.json +712 -0
  28. equiframe/adapters.py +454 -0
  29. equiframe/assertion_types.py +122 -0
  30. equiframe/assertions.py +271 -0
  31. equiframe/benchmark.py +101 -0
  32. equiframe/bundle.py +240 -0
  33. equiframe/cli.py +693 -0
  34. equiframe/comparison.py +937 -0
  35. equiframe/comparison_report.py +660 -0
  36. equiframe/comparison_types.py +257 -0
  37. equiframe/compatibility.py +546 -0
  38. equiframe/contracts.py +879 -0
  39. equiframe/datasets.py +1301 -0
  40. equiframe/decision.py +34 -0
  41. equiframe/destinations.py +295 -0
  42. equiframe/detector_types.py +431 -0
  43. equiframe/detectors.py +389 -0
  44. equiframe/diff.py +192 -0
  45. equiframe/diff_contracts.py +523 -0
  46. equiframe/docs/adoption.md +329 -0
  47. equiframe/docs/api.md +534 -0
  48. equiframe/docs/arquitecto.md +143 -0
  49. equiframe/docs/choosing-api.md +84 -0
  50. equiframe/docs/cientifico.md +180 -0
  51. equiframe/docs/compatibility.md +254 -0
  52. equiframe/docs/contributing.md +171 -0
  53. equiframe/docs/dependencies.md +237 -0
  54. equiframe/docs/eda.md +166 -0
  55. equiframe/docs/extending.md +158 -0
  56. equiframe/docs/fast-ci.md +52 -0
  57. equiframe/docs/first-use.md +78 -0
  58. equiframe/docs/index.md +142 -0
  59. equiframe/docs/ingestion.md +145 -0
  60. equiframe/docs/inicio.md +129 -0
  61. equiframe/docs/installed-private.md +97 -0
  62. equiframe/docs/journeys.md +362 -0
  63. equiframe/docs/lectura-conversion.md +211 -0
  64. equiframe/docs/licensing.md +17 -0
  65. equiframe/docs/maintenance.md +233 -0
  66. equiframe/docs/migration-ir.md +333 -0
  67. equiframe/docs/option-audit.md +45 -0
  68. equiframe/docs/outcome-gates.md +134 -0
  69. equiframe/docs/performance.md +263 -0
  70. equiframe/docs/piloto-simulado.md +56 -0
  71. equiframe/docs/quickstart.md +156 -0
  72. equiframe/docs/r_review.md +212 -0
  73. equiframe/docs/recipes.md +248 -0
  74. equiframe/docs/releasing.md +183 -0
  75. equiframe/docs/security.md +218 -0
  76. equiframe/docs/semantics.md +274 -0
  77. equiframe/docs/sqlite-snapshots.md +262 -0
  78. equiframe/docs/universal-sources.md +49 -0
  79. equiframe/docs/universalidad-cinco-pasos.md +107 -0
  80. equiframe/docs/upgrading.md +538 -0
  81. equiframe/duckdb_capture.py +410 -0
  82. equiframe/eda/__init__.py +36 -0
  83. equiframe/eda/__main__.py +3 -0
  84. equiframe/eda/_backend.py +612 -0
  85. equiframe/eda/_inventory.py +49 -0
  86. equiframe/eda/_paths.py +49 -0
  87. equiframe/eda/_report.py +291 -0
  88. equiframe/eda/api.py +535 -0
  89. equiframe/eda/cli.py +102 -0
  90. equiframe/eda/models.py +239 -0
  91. equiframe/errors.py +83 -0
  92. equiframe/evidence.py +375 -0
  93. equiframe/examples/integrations/eda_generic.py +60 -0
  94. equiframe/examples/integrations/outcome_gates.py +94 -0
  95. equiframe/examples/integrations/sqlite_snapshot.py +246 -0
  96. equiframe/examples/integrations/sqlite_snapshot_demo.py +226 -0
  97. equiframe/examples/integrations/universal_first_use.py +296 -0
  98. equiframe/examples/journeys/README.md +31 -0
  99. equiframe/examples/journeys/__init__.py +1 -0
  100. equiframe/examples/journeys/a_pandas_refactor.py +88 -0
  101. equiframe/examples/journeys/b_native_migration.py +181 -0
  102. equiframe/examples/journeys/c_retail_etl.py +460 -0
  103. equiframe/examples/journeys/common.py +136 -0
  104. equiframe/examples/journeys/d_financial_reconciliation.py +96 -0
  105. equiframe/examples/journeys/e_telemetry.py +135 -0
  106. equiframe/examples/journeys/f_data_science_preparation.py +84 -0
  107. equiframe/examples/journeys/g_external_snapshots.py +197 -0
  108. equiframe/examples/journeys/h_portable_ci.py +129 -0
  109. equiframe/examples/journeys/requirements.lock +249 -0
  110. equiframe/examples/journeys/verify_journeys.py +117 -0
  111. equiframe/examples/load_convert_compare.py +85 -0
  112. equiframe/execution.py +355 -0
  113. equiframe/execution_types.py +177 -0
  114. equiframe/ingestion.py +744 -0
  115. equiframe/local.py +126 -0
  116. equiframe/migration/__init__.py +94 -0
  117. equiframe/migration/_physical.py +8 -0
  118. equiframe/migration/backends/_timestamp_transport.py +109 -0
  119. equiframe/migration/backends/duckdb.py +651 -0
  120. equiframe/migration/backends/duckdb_decimal.py +125 -0
  121. equiframe/migration/backends/duckdb_time.py +87 -0
  122. equiframe/migration/backends/pandas.py +544 -0
  123. equiframe/migration/backends/polars.py +915 -0
  124. equiframe/migration/backends/polars_decimal.py +231 -0
  125. equiframe/migration/io.py +198 -0
  126. equiframe/migration/ports.py +96 -0
  127. equiframe/migration/runner.py +545 -0
  128. equiframe/migration/runner_types.py +405 -0
  129. equiframe/migration/types.py +354 -0
  130. equiframe/migration/validation.py +619 -0
  131. equiframe/normalization.py +358 -0
  132. equiframe/pipeline.py +274 -0
  133. equiframe/pipeline_types.py +247 -0
  134. equiframe/ports.py +39 -0
  135. equiframe/preprocessing.py +110 -0
  136. equiframe/profile_types.py +343 -0
  137. equiframe/profiles.py +447 -0
  138. equiframe/py.typed +0 -0
  139. equiframe/r_review.py +706 -0
  140. equiframe/reduction.py +518 -0
  141. equiframe/registry.py +121 -0
  142. equiframe/report_rendering.py +366 -0
  143. equiframe/report_types.py +294 -0
  144. equiframe/reporting.py +26 -0
  145. equiframe/rule_types.py +178 -0
  146. equiframe/rules.py +819 -0
  147. equiframe/schemas/r-execution-binding-1.0.schema.json +46 -0
  148. equiframe/schemas/r-review-binding-1.0.schema.json +28 -0
  149. equiframe/schemas/r-review-options-1.0.schema.json +38 -0
  150. equiframe/schemas/r-review-result-1.0.schema.json +207 -0
  151. equiframe/schemas/reviewed-decision-1.0.schema.json +3394 -0
  152. equiframe/serialization.py +152 -0
  153. equiframe/snapshot.py +296 -0
  154. equiframe/sources.py +283 -0
  155. equiframe/tables.py +459 -0
  156. equiframe/types.py +71 -0
  157. equiframe/values.py +252 -0
  158. equiframe/verification_request.py +35 -0
  159. equiframe/verify.py +799 -0
  160. equiframe/verify_types.py +255 -0
  161. equiframe/workflow.py +510 -0
  162. equiframe/workflow_request.py +38 -0
  163. equiframe-0.12.4.dist-info/METADATA +268 -0
  164. equiframe-0.12.4.dist-info/RECORD +170 -0
  165. equiframe-0.12.4.dist-info/WHEEL +4 -0
  166. equiframe-0.12.4.dist-info/entry_points.txt +3 -0
  167. equiframe-0.12.4.dist-info/licenses/LICENSE +21 -0
  168. equiframe-0.12.4.dist-info/licenses/THIRD_PARTY_NOTICES.md +42 -0
  169. equiframe-0.12.4.dist-info/licenses/tools/cyclonedx/LICENSE +201 -0
  170. equiframe-0.12.4.dist-info/licenses/tools/cyclonedx/PROVENANCE.md +18 -0
@@ -0,0 +1,152 @@
1
+ """Versioned canonical metadata bytes, strict JSON I/O, and SHA-256 fingerprints."""
2
+
3
+ import hashlib
4
+ import json
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from pydantic import ValidationError
10
+
11
+ from equiframe.contracts import SCHEMA_VERSION, Contract
12
+ from equiframe.errors import (
13
+ ContractIOError,
14
+ ContractParseError,
15
+ ContractValidationError,
16
+ SchemaVersionError,
17
+ ValidationIssue,
18
+ )
19
+
20
+ CANONICALIZATION = "equiframe-metadata-json-v1"
21
+
22
+
23
+ def _parse_issue(code: str, message: str) -> ContractParseError:
24
+ return ContractParseError((ValidationIssue(code, (), message),))
25
+
26
+
27
+ @dataclass
28
+ class _JsonObject:
29
+ pairs: list[tuple[str, object]]
30
+
31
+
32
+ def _unique_objects(value: object, location: tuple[str | int, ...] = ()) -> object:
33
+ if isinstance(value, _JsonObject):
34
+ result: dict[str, object] = {}
35
+ for key, member in value.pairs:
36
+ if key in result:
37
+ raise ContractParseError(
38
+ (
39
+ ValidationIssue(
40
+ "duplicate_json_key",
41
+ (*location, key),
42
+ "duplicate JSON object key: " + json.dumps(key, ensure_ascii=True),
43
+ ),
44
+ )
45
+ )
46
+ result[key] = _unique_objects(member, (*location, key))
47
+ return result
48
+ if isinstance(value, list):
49
+ return [_unique_objects(member, (*location, index)) for index, member in enumerate(value)]
50
+ return value
51
+
52
+
53
+ def _constant(value: str) -> object:
54
+ raise _parse_issue("nonstandard_json_number", "nonstandard JSON number: " + value)
55
+
56
+
57
+ def validate_contract(document: str | bytes | Mapping[str, object]) -> Contract:
58
+ """Validate JSON text/UTF-8 bytes or a JSON-compatible mapping with identical semantics."""
59
+ try:
60
+ if isinstance(document, bytes):
61
+ raw = document.decode("utf-8")
62
+ elif isinstance(document, str):
63
+ raw = document
64
+ else:
65
+ raw = json.dumps(dict(document), ensure_ascii=False, allow_nan=False)
66
+ parsed = _unique_objects(
67
+ json.loads(raw, object_pairs_hook=_JsonObject, parse_constant=_constant)
68
+ )
69
+ except (UnicodeError, TypeError, ValueError, RecursionError) as exc:
70
+ if isinstance(exc, json.JSONDecodeError):
71
+ raise _parse_issue(
72
+ "json_syntax", f"invalid JSON at line {exc.lineno}, column {exc.colno}"
73
+ ) from exc
74
+ raise _parse_issue("json_encoding", "expected a UTF-8, standard JSON document") from exc
75
+ if not isinstance(parsed, dict):
76
+ raise _parse_issue("json_object", "contract document must be an object")
77
+ version: object = parsed.get("schema_version")
78
+ if isinstance(version, str) and version != SCHEMA_VERSION:
79
+ raise SchemaVersionError(
80
+ (
81
+ ValidationIssue(
82
+ "unsupported_schema_version",
83
+ ("schema_version",),
84
+ "supported schema_version is " + SCHEMA_VERSION,
85
+ ),
86
+ )
87
+ )
88
+ try:
89
+ return Contract.model_validate_json(raw)
90
+ except ValidationError as exc:
91
+ raise ContractValidationError(
92
+ tuple(
93
+ ValidationIssue(item["type"], item["loc"], item["msg"])
94
+ for item in exc.errors(include_url=False, include_input=False)
95
+ )
96
+ ) from exc
97
+
98
+
99
+ def canonical_contract_bytes(contract: Contract) -> bytes:
100
+ """Canonical v1: all defaults/nulls, UTF-8, sorted keys, ordered arrays, no whitespace."""
101
+ # Revalidate copies made with Pydantic's explicitly unvalidated model_copy/model_construct.
102
+ try:
103
+ validated = Contract.model_validate(contract)
104
+ except ValidationError as exc:
105
+ raise ContractValidationError(
106
+ tuple(
107
+ ValidationIssue(item["type"], item["loc"], item["msg"])
108
+ for item in exc.errors(include_url=False, include_input=False)
109
+ )
110
+ ) from exc
111
+ return json.dumps(
112
+ validated.model_dump(mode="json"),
113
+ ensure_ascii=False,
114
+ sort_keys=True,
115
+ separators=(",", ":"),
116
+ allow_nan=False,
117
+ ).encode("utf-8")
118
+
119
+
120
+ def contract_fingerprint(contract: Contract) -> str:
121
+ """Hash canonical contract metadata, including identity/revision; never prove table equality."""
122
+ return hashlib.sha256(canonical_contract_bytes(contract)).hexdigest()
123
+
124
+
125
+ def read_contract(path: str | Path) -> Contract:
126
+ from equiframe._regular_io import local_input_path, open_regular_file
127
+ from equiframe.errors import ArtifactIOError, ResourceLimitError
128
+
129
+ try:
130
+ with open_regular_file(local_input_path(Path(path))) as stream:
131
+ document = stream.read(64 * 1024 * 1024 + 1)
132
+ except ArtifactIOError as exc:
133
+ cause = exc.__cause__ if isinstance(exc.__cause__, OSError) else exc
134
+ raise ContractIOError("cannot read contract file") from cause
135
+ except (OSError, ValueError) as exc:
136
+ raise ContractIOError("cannot read contract file") from exc
137
+ if len(document) > 64 * 1024 * 1024:
138
+ raise ResourceLimitError("contract exceeds the 64 MiB document budget")
139
+ return validate_contract(document)
140
+
141
+
142
+ def write_contract(contract: Contract, path: str | Path) -> None:
143
+ data = canonical_contract_bytes(contract)
144
+ try:
145
+ Path(path).write_bytes(data)
146
+ except (OSError, ValueError) as exc:
147
+ raise ContractIOError("cannot write contract file: " + ascii(str(path))) from exc
148
+
149
+
150
+ def contract_json_schema() -> dict[str, object]:
151
+ """Structural JSON Schema; cross-table/type/timezone constraints use validate_contract."""
152
+ return Contract.model_json_schema(mode="validation")
equiframe/snapshot.py ADDED
@@ -0,0 +1,296 @@
1
+ """Consume one explicitly pinned snapshot through a trusted, injected data client.
2
+
3
+ The client owns authentication, transport and the coordinating service. Fetching
4
+ the manifest in the constructor precedes RunContext budgets; clients must impose
5
+ their own transport timeouts. Downloads and verification use cooperative context
6
+ checkpoints. Neither a blocked client nor Arrow decoding can be preempted here.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import os
13
+ from pathlib import Path
14
+ from typing import TYPE_CHECKING, Annotated, Literal, Protocol
15
+ from uuid import uuid4
16
+
17
+ from pydantic import BeforeValidator, Field, StringConstraints, TypeAdapter, model_validator
18
+ from typing_extensions import Self
19
+
20
+ from equiframe._base import strict_bool_literal
21
+ from equiframe._documents import (
22
+ Document,
23
+ Evidence,
24
+ Text,
25
+ canonical_document_bytes,
26
+ digest,
27
+ save_document,
28
+ )
29
+ from equiframe.contracts import LogicalType
30
+ from equiframe.errors import (
31
+ ArtifactIntegrityError,
32
+ ExecutionCancelled,
33
+ InvalidExecutionInput,
34
+ ResourceLimitError,
35
+ )
36
+ from equiframe.execution import CancellationToken
37
+ from equiframe.normalization import logical_signature
38
+ from equiframe.tables import LoadedTable, check_arrow, require_dependency
39
+ from equiframe.types import ArtifactRef
40
+ from equiframe.values import StringValue
41
+
42
+ if TYPE_CHECKING:
43
+ import pyarrow as pa
44
+
45
+ from equiframe.execution import RunContext
46
+
47
+ TableName = Annotated[Text, StringConstraints(pattern=r"^[A-Za-z][A-Za-z0-9_.-]*$")]
48
+
49
+
50
+ class SnapshotColumn(Document):
51
+ """Exact observed Arrow type plus independently declared logical expectation."""
52
+
53
+ name: Text
54
+ physical_type: Text
55
+ logical_type: LogicalType
56
+ nullable: bool
57
+
58
+
59
+ class SnapshotTable(Document):
60
+ name: TableName
61
+ artifact: Evidence
62
+ row_count: Annotated[int, Field(ge=0)]
63
+ columns: Annotated[tuple[SnapshotColumn, ...], Field(min_length=1)]
64
+
65
+ @model_validator(mode="after")
66
+ def unique_columns(self) -> Self:
67
+ if len({column.name for column in self.columns}) != len(self.columns):
68
+ raise ValueError("snapshot columns must have unique names")
69
+ return self
70
+
71
+
72
+ class SnapshotManifest(Document):
73
+ snapshot_id: Text
74
+ domain_schema_revision: Text
75
+ extraction_revision: Text
76
+ provenance: Annotated[tuple[Text, ...], Field(min_length=1)]
77
+ tables: Annotated[tuple[SnapshotTable, ...], Field(min_length=1)]
78
+ state: Literal["published"] = "published"
79
+ immutable: Annotated[Literal[True], BeforeValidator(strict_bool_literal)] = True
80
+
81
+ @model_validator(mode="after")
82
+ def pinned_identity(self) -> Self:
83
+ if self.snapshot_id.casefold() == "latest":
84
+ raise ValueError("snapshot identity must be explicit; latest is not immutable")
85
+ if len({table.name for table in self.tables}) != len(self.tables):
86
+ raise ValueError("snapshot tables must have unique names")
87
+ if len(set(self.provenance)) != len(self.provenance):
88
+ raise ValueError("snapshot provenance must have unique entries")
89
+ return self
90
+
91
+
92
+ def snapshot_fingerprint(manifest: SnapshotManifest) -> str:
93
+ return digest(SnapshotManifest.model_validate(manifest))
94
+
95
+
96
+ class SnapshotClient(Protocol):
97
+ """Minimal client boundary; no HTTP, credentials or mutable DW handles here."""
98
+
99
+ def get_snapshot(self, snapshot_id: str) -> SnapshotManifest: ...
100
+
101
+ def download_artifact(self, ref: ArtifactRef, destination: Path) -> None: ...
102
+
103
+
104
+ class SnapshotDataSource:
105
+ """DataSource whose manifest is fetched and pinned once before any execution.
106
+
107
+ Each resolve downloads to a fresh directory beneath that run and checks the
108
+ original SHA-256, byte count, row count and declared column schema before
109
+ exposing Arrow data. A subsequent publication cannot alter this manifest.
110
+ A table selection limits this consumer's inputs while retaining the complete
111
+ pinned manifest and its fingerprint as provenance.
112
+ The source does not retry a failed client or perform implicit latest lookup.
113
+ """
114
+
115
+ def __init__(
116
+ self,
117
+ snapshot_id: str,
118
+ *,
119
+ client: SnapshotClient,
120
+ expected_domain_schema_revision: str | None = None,
121
+ cancellation: CancellationToken | None = None,
122
+ table_names: tuple[str, ...] | None = None,
123
+ ) -> None:
124
+ snapshot_id = TypeAdapter(Text).validate_python(snapshot_id, strict=True)
125
+ if snapshot_id.casefold() == "latest":
126
+ raise ValueError("snapshot identity must be explicit; latest is not immutable")
127
+ if expected_domain_schema_revision is not None:
128
+ TypeAdapter(Text).validate_python(expected_domain_schema_revision, strict=True)
129
+ if cancellation is not None and cancellation.cancelled:
130
+ raise ExecutionCancelled("snapshot manifest fetch was cancelled")
131
+ manifest = SnapshotManifest.model_validate(client.get_snapshot(snapshot_id))
132
+ if cancellation is not None and cancellation.cancelled:
133
+ raise ExecutionCancelled("snapshot manifest fetch returned after cancellation")
134
+ if manifest.snapshot_id != snapshot_id:
135
+ raise ArtifactIntegrityError("client returned a different snapshot identity")
136
+ if (
137
+ expected_domain_schema_revision is not None
138
+ and manifest.domain_schema_revision != expected_domain_schema_revision
139
+ ):
140
+ raise InvalidExecutionInput(
141
+ "snapshot domain schema revision differs from the requested revision"
142
+ )
143
+ self._client = client
144
+ self._manifest = manifest
145
+ self._fingerprint = snapshot_fingerprint(manifest)
146
+ available = {table.name: table for table in manifest.tables}
147
+ selected = (
148
+ tuple(available)
149
+ if table_names is None
150
+ else TypeAdapter(tuple[TableName, ...]).validate_python(table_names, strict=True)
151
+ )
152
+ if (
153
+ not selected
154
+ or len(set(selected)) != len(selected)
155
+ or not set(selected) <= set(available)
156
+ ):
157
+ raise ValueError("snapshot table selection must be nonempty, unique and available")
158
+ self._tables = {name: available[name] for name in selected}
159
+
160
+ @property
161
+ def snapshot_id(self) -> str:
162
+ return self._manifest.snapshot_id
163
+
164
+ @property
165
+ def manifest(self) -> SnapshotManifest:
166
+ return self._manifest
167
+
168
+ @property
169
+ def table_names(self) -> tuple[str, ...]:
170
+ return tuple(self._tables)
171
+
172
+ def resolve(self, name: str, *, context: RunContext) -> LoadedTable:
173
+ context.check()
174
+ try:
175
+ expected = self._tables[name]
176
+ except KeyError:
177
+ raise InvalidExecutionInput("table is absent from the pinned snapshot") from None
178
+ options = context.plan.options
179
+ if options.max_input_rows is not None and expected.row_count > options.max_input_rows:
180
+ raise ResourceLimitError("snapshot table exceeds the input row budget")
181
+ if (
182
+ options.max_transport_bytes is not None
183
+ and expected.artifact.size_bytes > options.max_transport_bytes
184
+ ):
185
+ raise ResourceLimitError("snapshot artifact exceeds the transport byte budget")
186
+ require_dependency("pyarrow", "arrow")
187
+ import pyarrow as pa
188
+ import pyarrow.parquet as pq
189
+
190
+ self._record_manifest(context)
191
+ directory = context.directory / ("snapshot_" + uuid4().hex)
192
+ directory.mkdir(mode=0o700, exist_ok=False)
193
+ partial = directory / ("download.part." + expected.artifact.format)
194
+ path = directory / ("verified." + expected.artifact.format)
195
+ try:
196
+ context.check()
197
+ self._client.download_artifact(expected.artifact, partial)
198
+ context.check()
199
+ if (
200
+ partial.is_symlink()
201
+ or not partial.is_file()
202
+ or not partial.resolve().is_relative_to(directory.resolve())
203
+ ):
204
+ raise ArtifactIntegrityError(
205
+ "snapshot client did not produce a local private artifact"
206
+ )
207
+ size = partial.stat().st_size
208
+ if options.max_transport_bytes is not None and size > options.max_transport_bytes:
209
+ raise ResourceLimitError("downloaded snapshot artifact exceeds the byte budget")
210
+ if size != expected.artifact.size_bytes:
211
+ raise ArtifactIntegrityError(
212
+ "snapshot artifact byte count differs from its manifest"
213
+ )
214
+ checksum = hashlib.sha256()
215
+ with partial.open("rb") as stream:
216
+ while block := stream.read(1024 * 1024):
217
+ context.check()
218
+ checksum.update(block)
219
+ context.check()
220
+ if checksum.hexdigest() != expected.artifact.sha256:
221
+ raise ArtifactIntegrityError("snapshot artifact SHA-256 differs from its manifest")
222
+ os.link(partial, path)
223
+ partial.unlink()
224
+ context.check()
225
+ if expected.artifact.format == "parquet":
226
+ table = pq.read_table(path)
227
+ else:
228
+ with path.open("rb") as stream, pa.ipc.open_file(stream) as reader:
229
+ table = reader.read_all()
230
+ check_arrow(table)
231
+ context.check_table(table, output=False)
232
+ self._verify_schema(table, expected)
233
+ context.check()
234
+ return LoadedTable(
235
+ table,
236
+ origin=expected.artifact,
237
+ notes=(
238
+ "Pinned snapshot ID and schema revision recorded in execution configuration.",
239
+ "Snapshot SHA-256, bytes, rows and physical/logical column schema verified.",
240
+ ),
241
+ )
242
+ finally:
243
+ partial.unlink(missing_ok=True)
244
+
245
+ def _record_manifest(self, context: RunContext) -> None:
246
+ # Workflow captures can use multiple contexts with distinct budgets but
247
+ # the same run directory and configuration. Keep no source-owned run
248
+ # cache: reuse only an exact published manifest and consistent records.
249
+ path = context.directory / "snapshot-manifest.json"
250
+ payload = canonical_document_bytes(self._manifest) + b"\n"
251
+ configuration = {
252
+ "snapshot_id": self.snapshot_id,
253
+ "snapshot_domain_schema_revision": self._manifest.domain_schema_revision,
254
+ "snapshot_extraction_revision": self._manifest.extraction_revision,
255
+ "snapshot_manifest_sha256": self._fingerprint,
256
+ }
257
+ recorded = [item for item in context.effective_configuration if item.name in configuration]
258
+ if len({item.name for item in recorded}) != len(recorded) or any(
259
+ not isinstance(item.value, StringValue) or item.value.value != configuration[item.name]
260
+ for item in recorded
261
+ ):
262
+ raise ArtifactIntegrityError("run configuration differs from its pinned snapshot")
263
+ if path.exists() or path.is_symlink():
264
+ if (
265
+ path.is_symlink()
266
+ or not path.is_file()
267
+ or path.stat().st_size != len(payload)
268
+ or path.read_bytes() != payload
269
+ ):
270
+ raise ArtifactIntegrityError("run manifest differs from its pinned snapshot")
271
+ elif recorded:
272
+ raise ArtifactIntegrityError("recorded snapshot manifest is missing from the run")
273
+ else:
274
+ save_document(self._manifest, path)
275
+ names = {item.name for item in recorded}
276
+ for name, value in configuration.items():
277
+ if name not in names:
278
+ context.record_configuration(name, value)
279
+
280
+ @staticmethod
281
+ def _verify_schema(table: pa.Table, expected: SnapshotTable) -> None:
282
+ if table.num_rows != expected.row_count:
283
+ raise ArtifactIntegrityError("snapshot row count differs from its manifest")
284
+ if tuple(table.column_names) != tuple(column.name for column in expected.columns):
285
+ raise ArtifactIntegrityError("snapshot column names/order differ from its manifest")
286
+ for field, column in zip(table.schema, expected.columns, strict=True):
287
+ if str(field.type) != column.physical_type or logical_signature(field.type) != tuple(
288
+ column.logical_type.model_dump().values()
289
+ ):
290
+ raise ArtifactIntegrityError(
291
+ "snapshot physical/logical column type differs from its manifest"
292
+ )
293
+ if not column.nullable and table[column.name].null_count:
294
+ raise ArtifactIntegrityError(
295
+ "snapshot contains NULL in a declared nonnullable column"
296
+ )