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.
- equiframe/__init__.py +832 -0
- equiframe/__main__.py +3 -0
- equiframe/_base.py +84 -0
- equiframe/_cli_support.py +125 -0
- equiframe/_comparison_io.py +161 -0
- equiframe/_dataset_provenance.py +146 -0
- equiframe/_decimal.py +22 -0
- equiframe/_diff_capture.py +433 -0
- equiframe/_doctor.py +123 -0
- equiframe/_document_io.py +61 -0
- equiframe/_documents.py +79 -0
- equiframe/_duckdb_transport.py +149 -0
- equiframe/_error_reporting.py +52 -0
- equiframe/_first_use.py +102 -0
- equiframe/_path_compat.py +26 -0
- equiframe/_physical.py +133 -0
- equiframe/_r_review.R +308 -0
- equiframe/_regular_io.py +167 -0
- equiframe/_report_format_v1.py +298 -0
- equiframe/_report_io.py +591 -0
- equiframe/_stability_inventory.py +1421 -0
- equiframe/_timezones.py +257 -0
- equiframe/_vendor/cyclonedx/LICENSE +201 -0
- equiframe/_vendor/cyclonedx/PROVENANCE.md +18 -0
- equiframe/_vendor/cyclonedx/bom-1.6.schema.json +5673 -0
- equiframe/_vendor/cyclonedx/jsf-0.82.schema.json +240 -0
- equiframe/_vendor/cyclonedx/spdx.schema.json +712 -0
- equiframe/adapters.py +454 -0
- equiframe/assertion_types.py +122 -0
- equiframe/assertions.py +271 -0
- equiframe/benchmark.py +101 -0
- equiframe/bundle.py +240 -0
- equiframe/cli.py +693 -0
- equiframe/comparison.py +937 -0
- equiframe/comparison_report.py +660 -0
- equiframe/comparison_types.py +257 -0
- equiframe/compatibility.py +546 -0
- equiframe/contracts.py +879 -0
- equiframe/datasets.py +1301 -0
- equiframe/decision.py +34 -0
- equiframe/destinations.py +295 -0
- equiframe/detector_types.py +431 -0
- equiframe/detectors.py +389 -0
- equiframe/diff.py +192 -0
- equiframe/diff_contracts.py +523 -0
- equiframe/docs/adoption.md +329 -0
- equiframe/docs/api.md +534 -0
- equiframe/docs/arquitecto.md +143 -0
- equiframe/docs/choosing-api.md +84 -0
- equiframe/docs/cientifico.md +180 -0
- equiframe/docs/compatibility.md +254 -0
- equiframe/docs/contributing.md +171 -0
- equiframe/docs/dependencies.md +237 -0
- equiframe/docs/eda.md +166 -0
- equiframe/docs/extending.md +158 -0
- equiframe/docs/fast-ci.md +52 -0
- equiframe/docs/first-use.md +78 -0
- equiframe/docs/index.md +142 -0
- equiframe/docs/ingestion.md +145 -0
- equiframe/docs/inicio.md +129 -0
- equiframe/docs/installed-private.md +97 -0
- equiframe/docs/journeys.md +362 -0
- equiframe/docs/lectura-conversion.md +211 -0
- equiframe/docs/licensing.md +17 -0
- equiframe/docs/maintenance.md +233 -0
- equiframe/docs/migration-ir.md +333 -0
- equiframe/docs/option-audit.md +45 -0
- equiframe/docs/outcome-gates.md +134 -0
- equiframe/docs/performance.md +263 -0
- equiframe/docs/piloto-simulado.md +56 -0
- equiframe/docs/quickstart.md +156 -0
- equiframe/docs/r_review.md +212 -0
- equiframe/docs/recipes.md +248 -0
- equiframe/docs/releasing.md +183 -0
- equiframe/docs/security.md +218 -0
- equiframe/docs/semantics.md +274 -0
- equiframe/docs/sqlite-snapshots.md +262 -0
- equiframe/docs/universal-sources.md +49 -0
- equiframe/docs/universalidad-cinco-pasos.md +107 -0
- equiframe/docs/upgrading.md +538 -0
- equiframe/duckdb_capture.py +410 -0
- equiframe/eda/__init__.py +36 -0
- equiframe/eda/__main__.py +3 -0
- equiframe/eda/_backend.py +612 -0
- equiframe/eda/_inventory.py +49 -0
- equiframe/eda/_paths.py +49 -0
- equiframe/eda/_report.py +291 -0
- equiframe/eda/api.py +535 -0
- equiframe/eda/cli.py +102 -0
- equiframe/eda/models.py +239 -0
- equiframe/errors.py +83 -0
- equiframe/evidence.py +375 -0
- equiframe/examples/integrations/eda_generic.py +60 -0
- equiframe/examples/integrations/outcome_gates.py +94 -0
- equiframe/examples/integrations/sqlite_snapshot.py +246 -0
- equiframe/examples/integrations/sqlite_snapshot_demo.py +226 -0
- equiframe/examples/integrations/universal_first_use.py +296 -0
- equiframe/examples/journeys/README.md +31 -0
- equiframe/examples/journeys/__init__.py +1 -0
- equiframe/examples/journeys/a_pandas_refactor.py +88 -0
- equiframe/examples/journeys/b_native_migration.py +181 -0
- equiframe/examples/journeys/c_retail_etl.py +460 -0
- equiframe/examples/journeys/common.py +136 -0
- equiframe/examples/journeys/d_financial_reconciliation.py +96 -0
- equiframe/examples/journeys/e_telemetry.py +135 -0
- equiframe/examples/journeys/f_data_science_preparation.py +84 -0
- equiframe/examples/journeys/g_external_snapshots.py +197 -0
- equiframe/examples/journeys/h_portable_ci.py +129 -0
- equiframe/examples/journeys/requirements.lock +249 -0
- equiframe/examples/journeys/verify_journeys.py +117 -0
- equiframe/examples/load_convert_compare.py +85 -0
- equiframe/execution.py +355 -0
- equiframe/execution_types.py +177 -0
- equiframe/ingestion.py +744 -0
- equiframe/local.py +126 -0
- equiframe/migration/__init__.py +94 -0
- equiframe/migration/_physical.py +8 -0
- equiframe/migration/backends/_timestamp_transport.py +109 -0
- equiframe/migration/backends/duckdb.py +651 -0
- equiframe/migration/backends/duckdb_decimal.py +125 -0
- equiframe/migration/backends/duckdb_time.py +87 -0
- equiframe/migration/backends/pandas.py +544 -0
- equiframe/migration/backends/polars.py +915 -0
- equiframe/migration/backends/polars_decimal.py +231 -0
- equiframe/migration/io.py +198 -0
- equiframe/migration/ports.py +96 -0
- equiframe/migration/runner.py +545 -0
- equiframe/migration/runner_types.py +405 -0
- equiframe/migration/types.py +354 -0
- equiframe/migration/validation.py +619 -0
- equiframe/normalization.py +358 -0
- equiframe/pipeline.py +274 -0
- equiframe/pipeline_types.py +247 -0
- equiframe/ports.py +39 -0
- equiframe/preprocessing.py +110 -0
- equiframe/profile_types.py +343 -0
- equiframe/profiles.py +447 -0
- equiframe/py.typed +0 -0
- equiframe/r_review.py +706 -0
- equiframe/reduction.py +518 -0
- equiframe/registry.py +121 -0
- equiframe/report_rendering.py +366 -0
- equiframe/report_types.py +294 -0
- equiframe/reporting.py +26 -0
- equiframe/rule_types.py +178 -0
- equiframe/rules.py +819 -0
- equiframe/schemas/r-execution-binding-1.0.schema.json +46 -0
- equiframe/schemas/r-review-binding-1.0.schema.json +28 -0
- equiframe/schemas/r-review-options-1.0.schema.json +38 -0
- equiframe/schemas/r-review-result-1.0.schema.json +207 -0
- equiframe/schemas/reviewed-decision-1.0.schema.json +3394 -0
- equiframe/serialization.py +152 -0
- equiframe/snapshot.py +296 -0
- equiframe/sources.py +283 -0
- equiframe/tables.py +459 -0
- equiframe/types.py +71 -0
- equiframe/values.py +252 -0
- equiframe/verification_request.py +35 -0
- equiframe/verify.py +799 -0
- equiframe/verify_types.py +255 -0
- equiframe/workflow.py +510 -0
- equiframe/workflow_request.py +38 -0
- equiframe-0.12.4.dist-info/METADATA +268 -0
- equiframe-0.12.4.dist-info/RECORD +170 -0
- equiframe-0.12.4.dist-info/WHEEL +4 -0
- equiframe-0.12.4.dist-info/entry_points.txt +3 -0
- equiframe-0.12.4.dist-info/licenses/LICENSE +21 -0
- equiframe-0.12.4.dist-info/licenses/THIRD_PARTY_NOTICES.md +42 -0
- equiframe-0.12.4.dist-info/licenses/tools/cyclonedx/LICENSE +201 -0
- 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
|
+
)
|