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
equiframe/decision.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Three-state OR preserving incomplete alerts; MAD is deliberately absent."""
|
|
2
|
+
|
|
3
|
+
from equiframe.detector_types import ComponentDecision, HybridDecision
|
|
4
|
+
from equiframe.rule_types import RuleEvaluation
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def combine_decisions(
|
|
8
|
+
r: ComponentDecision | RuleEvaluation, m: ComponentDecision
|
|
9
|
+
) -> HybridDecision:
|
|
10
|
+
if isinstance(r, RuleEvaluation):
|
|
11
|
+
rule = RuleEvaluation.model_validate(r)
|
|
12
|
+
r = ComponentDecision(
|
|
13
|
+
decision=rule.decision,
|
|
14
|
+
complete=rule.complete,
|
|
15
|
+
reason=None if rule.complete else rule.diagnostic or "incomplete_rules",
|
|
16
|
+
)
|
|
17
|
+
r = ComponentDecision.model_validate(r)
|
|
18
|
+
m = ComponentDecision.model_validate(m)
|
|
19
|
+
complete = r.complete and m.complete
|
|
20
|
+
return HybridDecision(
|
|
21
|
+
r=r,
|
|
22
|
+
m=m,
|
|
23
|
+
result=ComponentDecision(
|
|
24
|
+
decision=(
|
|
25
|
+
"alert"
|
|
26
|
+
if "alert" in (r.decision, m.decision)
|
|
27
|
+
else "no_alert"
|
|
28
|
+
if r.decision == m.decision == "no_alert"
|
|
29
|
+
else "not_evaluable"
|
|
30
|
+
),
|
|
31
|
+
complete=complete,
|
|
32
|
+
reason=None if complete else "incomplete_components",
|
|
33
|
+
),
|
|
34
|
+
)
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""Versioned local Parquet publication with a single atomic visibility pointer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Literal, Protocol, cast
|
|
11
|
+
from uuid import uuid4
|
|
12
|
+
|
|
13
|
+
from pydantic import TypeAdapter, model_validator
|
|
14
|
+
from typing_extensions import Self
|
|
15
|
+
|
|
16
|
+
from equiframe._base import FrozenModel, Identifier, NonNegativeInt
|
|
17
|
+
from equiframe._regular_io import local_output_directory
|
|
18
|
+
from equiframe.errors import ArtifactIntegrityError
|
|
19
|
+
from equiframe.evidence import canonical_bytes, describe_file, load_json, publish_json, verify_files
|
|
20
|
+
from equiframe.local import local_path
|
|
21
|
+
from equiframe.pipeline_types import EvidenceFile
|
|
22
|
+
from equiframe.tables import read_table, require_dependency
|
|
23
|
+
from equiframe.types import ArtifactRef, Sha256
|
|
24
|
+
from equiframe.verify_types import VerificationResult
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PublishedTable(FrozenModel):
|
|
28
|
+
name: Identifier
|
|
29
|
+
source_sha256: Sha256
|
|
30
|
+
source_size_bytes: NonNegativeInt
|
|
31
|
+
source_format: Literal["arrow", "parquet"]
|
|
32
|
+
parquet: EvidenceFile
|
|
33
|
+
row_count: NonNegativeInt
|
|
34
|
+
verification_id: Identifier
|
|
35
|
+
verification_sha256: Sha256
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class LoadReceipt(FrozenModel):
|
|
39
|
+
schema_version: Literal["1.0"] = "1.0"
|
|
40
|
+
run_id: Identifier
|
|
41
|
+
policy: Literal["create", "replace"]
|
|
42
|
+
identity_sha256: Sha256
|
|
43
|
+
previous_run: Identifier | None
|
|
44
|
+
tables: tuple[PublishedTable, ...]
|
|
45
|
+
guarantee: Literal["local_atomic_pointer"] = "local_atomic_pointer"
|
|
46
|
+
|
|
47
|
+
@model_validator(mode="after")
|
|
48
|
+
def coherent(self) -> Self:
|
|
49
|
+
if not self.tables or len({t.name for t in self.tables}) != len(self.tables):
|
|
50
|
+
raise ValueError("publication table names must be nonempty and unique")
|
|
51
|
+
if len({table.name.casefold() for table in self.tables}) != len(self.tables):
|
|
52
|
+
raise ValueError(
|
|
53
|
+
"publication table names must be unique on case-insensitive filesystems"
|
|
54
|
+
)
|
|
55
|
+
if any(t.parquet.path != t.name + ".parquet" for t in self.tables):
|
|
56
|
+
raise ValueError("publication Parquet paths must correspond to table names")
|
|
57
|
+
identity = hashlib.sha256(
|
|
58
|
+
canonical_bytes(
|
|
59
|
+
{
|
|
60
|
+
table.name: {
|
|
61
|
+
"sha256": table.source_sha256,
|
|
62
|
+
"size": table.source_size_bytes,
|
|
63
|
+
"format": table.source_format,
|
|
64
|
+
}
|
|
65
|
+
for table in sorted(self.tables, key=lambda item: item.name)
|
|
66
|
+
}
|
|
67
|
+
)
|
|
68
|
+
).hexdigest()
|
|
69
|
+
if identity != self.identity_sha256:
|
|
70
|
+
raise ValueError("publication identity differs from source inventory")
|
|
71
|
+
if self.previous_run == self.run_id:
|
|
72
|
+
raise ValueError("publication cannot be its own previous version")
|
|
73
|
+
return self
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class PublicationPointer(FrozenModel):
|
|
77
|
+
schema_version: Literal["1.0"] = "1.0"
|
|
78
|
+
run_id: Identifier
|
|
79
|
+
receipt_sha256: Sha256
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class Destination(Protocol):
|
|
83
|
+
def publish(
|
|
84
|
+
self,
|
|
85
|
+
tables: Mapping[str, ArtifactRef],
|
|
86
|
+
*,
|
|
87
|
+
run_id: str,
|
|
88
|
+
verifications: Mapping[str, VerificationResult | Path],
|
|
89
|
+
policy: Literal["create", "replace"] = "create",
|
|
90
|
+
) -> LoadReceipt: ...
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class ParquetDestination:
|
|
94
|
+
"""One local writer under an exclusive lock. Versions remain immutable.
|
|
95
|
+
|
|
96
|
+
Directory rename publishes a new version; os.replace switches current.json
|
|
97
|
+
atomically on its local filesystem. This is not a power-loss durability or
|
|
98
|
+
distributed transaction guarantee. An interrupted writer can leave staging,
|
|
99
|
+
an unreferenced version or a lock; inspect those before removing a stale lock.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
def __init__(self, root: Path) -> None:
|
|
103
|
+
self.root = local_output_directory(root)
|
|
104
|
+
|
|
105
|
+
def current(self) -> LoadReceipt | None:
|
|
106
|
+
pointer_path = self.root / "current.json"
|
|
107
|
+
if not pointer_path.exists():
|
|
108
|
+
return None
|
|
109
|
+
pointer = load_json(PublicationPointer, pointer_path)
|
|
110
|
+
directory = self.root / "versions" / pointer.run_id
|
|
111
|
+
manifest = directory / "load.json"
|
|
112
|
+
if describe_file(manifest, directory).sha256 != pointer.receipt_sha256:
|
|
113
|
+
raise ArtifactIntegrityError("publication receipt hash differs from visibility pointer")
|
|
114
|
+
result = load_json(LoadReceipt, manifest)
|
|
115
|
+
if result.run_id != pointer.run_id:
|
|
116
|
+
raise ArtifactIntegrityError("publication identity differs from pointer")
|
|
117
|
+
verify_files(directory, tuple(table.parquet for table in result.tables))
|
|
118
|
+
require_dependency("pyarrow", "arrow")
|
|
119
|
+
import pyarrow.parquet as pq
|
|
120
|
+
|
|
121
|
+
for table in result.tables:
|
|
122
|
+
if pq.ParquetFile(directory / table.parquet.path).metadata.num_rows != table.row_count:
|
|
123
|
+
raise ArtifactIntegrityError("publication row count differs from Parquet metadata")
|
|
124
|
+
actual = {p.relative_to(directory).as_posix() for p in directory.rglob("*") if p.is_file()}
|
|
125
|
+
if actual != {"load.json", *(table.parquet.path for table in result.tables)}:
|
|
126
|
+
raise ArtifactIntegrityError("published version inventory contains unexpected files")
|
|
127
|
+
return result
|
|
128
|
+
|
|
129
|
+
def publish(
|
|
130
|
+
self,
|
|
131
|
+
tables: Mapping[str, ArtifactRef],
|
|
132
|
+
*,
|
|
133
|
+
run_id: str,
|
|
134
|
+
verifications: Mapping[str, VerificationResult | Path],
|
|
135
|
+
policy: Literal["create", "replace"] = "create",
|
|
136
|
+
) -> LoadReceipt:
|
|
137
|
+
require_dependency("pyarrow", "arrow")
|
|
138
|
+
import pyarrow.parquet as pq
|
|
139
|
+
|
|
140
|
+
run_id = TypeAdapter(Identifier).validate_python(run_id, strict=True)
|
|
141
|
+
if not tables or set(tables) != set(verifications):
|
|
142
|
+
raise ValueError("every published table requires exactly one verification gate")
|
|
143
|
+
for name in tables:
|
|
144
|
+
TypeAdapter(Identifier).validate_python(name, strict=True)
|
|
145
|
+
if len({name.casefold() for name in tables}) != len(tables):
|
|
146
|
+
raise ValueError(
|
|
147
|
+
"publication table names must be unique on case-insensitive filesystems"
|
|
148
|
+
)
|
|
149
|
+
if policy not in ("create", "replace"):
|
|
150
|
+
raise ValueError("publication policy must be create or replace")
|
|
151
|
+
checked: dict[str, ArtifactRef] = {}
|
|
152
|
+
reports: dict[str, VerificationResult] = {}
|
|
153
|
+
for name, ref in tables.items():
|
|
154
|
+
ref = ArtifactRef.model_validate(ref)
|
|
155
|
+
if ref.format not in ("arrow", "parquet"):
|
|
156
|
+
raise ValueError("published sources require Arrow or Parquet")
|
|
157
|
+
from equiframe.verify import read_verification
|
|
158
|
+
|
|
159
|
+
provided = verifications[name]
|
|
160
|
+
if isinstance(provided, Path):
|
|
161
|
+
report = read_verification(provided)
|
|
162
|
+
else:
|
|
163
|
+
provided = VerificationResult.model_validate(provided)
|
|
164
|
+
if not provided.outputs:
|
|
165
|
+
raise ValueError("publication requires persisted verification evidence")
|
|
166
|
+
report = read_verification(local_path(provided.outputs[0][1]).parent)
|
|
167
|
+
if provided != report:
|
|
168
|
+
raise ArtifactIntegrityError(
|
|
169
|
+
"provided verification differs from persisted evidence"
|
|
170
|
+
)
|
|
171
|
+
if not report.publishable:
|
|
172
|
+
raise ValueError("publication gate is not approved for table " + name)
|
|
173
|
+
if ref.sha256 not in {hash for _, hash in report.binding.outputs}:
|
|
174
|
+
raise ValueError("verification gate does not bind the published output bytes")
|
|
175
|
+
checked[name] = ref
|
|
176
|
+
reports[name] = report
|
|
177
|
+
identity = hashlib.sha256(
|
|
178
|
+
canonical_bytes(
|
|
179
|
+
{
|
|
180
|
+
name: {"sha256": ref.sha256, "size": ref.size_bytes, "format": ref.format}
|
|
181
|
+
for name, ref in sorted(checked.items())
|
|
182
|
+
}
|
|
183
|
+
)
|
|
184
|
+
).hexdigest()
|
|
185
|
+
local_output_directory(self.root)
|
|
186
|
+
local_output_directory(self.root / "versions")
|
|
187
|
+
local_output_directory(self.root / ".staging")
|
|
188
|
+
local_output_directory(self.root / ".validation")
|
|
189
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
190
|
+
lock = self.root / ".publication.lock"
|
|
191
|
+
with lock.open("xb") as stream:
|
|
192
|
+
stream.write(run_id.encode("utf-8"))
|
|
193
|
+
staging = self.root / ".staging" / (run_id + "-" + uuid4().hex)
|
|
194
|
+
try:
|
|
195
|
+
current = self.current()
|
|
196
|
+
final = self.root / "versions" / run_id
|
|
197
|
+
if final.exists():
|
|
198
|
+
receipt = load_json(LoadReceipt, final / "load.json")
|
|
199
|
+
if receipt.run_id != run_id:
|
|
200
|
+
raise ArtifactIntegrityError(
|
|
201
|
+
"stored publication receipt has a different run identity"
|
|
202
|
+
)
|
|
203
|
+
verify_files(final, tuple(table.parquet for table in receipt.tables))
|
|
204
|
+
actual_files = {
|
|
205
|
+
path.relative_to(final).as_posix()
|
|
206
|
+
for path in final.rglob("*")
|
|
207
|
+
if path.is_file()
|
|
208
|
+
}
|
|
209
|
+
if actual_files != {"load.json", *(table.parquet.path for table in receipt.tables)}:
|
|
210
|
+
raise ArtifactIntegrityError(
|
|
211
|
+
"stored publication version inventory is not closed"
|
|
212
|
+
)
|
|
213
|
+
if any(
|
|
214
|
+
pq.ParquetFile(final / table.parquet.path).metadata.num_rows != table.row_count
|
|
215
|
+
for table in receipt.tables
|
|
216
|
+
):
|
|
217
|
+
raise ArtifactIntegrityError(
|
|
218
|
+
"stored publication row count differs from Parquet"
|
|
219
|
+
)
|
|
220
|
+
if receipt.identity_sha256 != identity:
|
|
221
|
+
raise ValueError("run identity already exists with different source bytes")
|
|
222
|
+
# A retry cannot silently roll back a newer visible version.
|
|
223
|
+
if current and current.run_id != run_id and receipt.previous_run != current.run_id:
|
|
224
|
+
raise ValueError("idempotent retry conflicts with a newer current publication")
|
|
225
|
+
if current is None or current.run_id != run_id:
|
|
226
|
+
self._switch(final, receipt)
|
|
227
|
+
return receipt
|
|
228
|
+
if current is not None and policy == "create":
|
|
229
|
+
raise FileExistsError(
|
|
230
|
+
"destination already has a published version; request replace explicitly"
|
|
231
|
+
)
|
|
232
|
+
staging.mkdir(parents=True, exist_ok=False)
|
|
233
|
+
published = []
|
|
234
|
+
for name, reference in sorted(checked.items()):
|
|
235
|
+
table = read_table(reference)
|
|
236
|
+
target = staging / (name + ".parquet")
|
|
237
|
+
pq.write_table(table, target)
|
|
238
|
+
restored = pq.read_table(target)
|
|
239
|
+
# Validate complete values through the same explicit semantic policy;
|
|
240
|
+
# byte equality between IPC and Parquet is neither expected nor useful.
|
|
241
|
+
from equiframe.comparison import compare
|
|
242
|
+
from equiframe.tables import ArrowInput
|
|
243
|
+
|
|
244
|
+
report = reports[name]
|
|
245
|
+
comparison = compare(
|
|
246
|
+
ArrowInput(table),
|
|
247
|
+
ArrowInput(restored),
|
|
248
|
+
contract=report.contract,
|
|
249
|
+
policy=report.comparisons[0].spec.policy,
|
|
250
|
+
work_dir=self.root / ".validation",
|
|
251
|
+
)
|
|
252
|
+
if comparison.state != "equivalent" or not comparison.complete:
|
|
253
|
+
raise ArtifactIntegrityError("staged Parquet differs from verified output")
|
|
254
|
+
published.append(
|
|
255
|
+
PublishedTable(
|
|
256
|
+
name=name,
|
|
257
|
+
source_sha256=reference.sha256,
|
|
258
|
+
source_size_bytes=reference.size_bytes,
|
|
259
|
+
source_format=cast("Literal['arrow', 'parquet']", reference.format),
|
|
260
|
+
parquet=describe_file(target, staging),
|
|
261
|
+
row_count=table.num_rows,
|
|
262
|
+
verification_id=report.verification_id,
|
|
263
|
+
verification_sha256=hashlib.sha256(
|
|
264
|
+
canonical_bytes(report.model_dump(mode="json"))
|
|
265
|
+
).hexdigest(),
|
|
266
|
+
)
|
|
267
|
+
)
|
|
268
|
+
receipt = LoadReceipt(
|
|
269
|
+
run_id=run_id,
|
|
270
|
+
policy=policy,
|
|
271
|
+
identity_sha256=identity,
|
|
272
|
+
previous_run=current.run_id if current else None,
|
|
273
|
+
tables=tuple(published),
|
|
274
|
+
)
|
|
275
|
+
publish_json(receipt, staging / "load.json")
|
|
276
|
+
final.parent.mkdir(parents=True, exist_ok=True)
|
|
277
|
+
os.rename(staging, final)
|
|
278
|
+
self._switch(final, receipt)
|
|
279
|
+
return receipt
|
|
280
|
+
finally:
|
|
281
|
+
if staging.exists():
|
|
282
|
+
shutil.rmtree(staging)
|
|
283
|
+
lock.unlink(missing_ok=True)
|
|
284
|
+
|
|
285
|
+
def _switch(self, directory: Path, receipt: LoadReceipt) -> None:
|
|
286
|
+
pointer = PublicationPointer(
|
|
287
|
+
run_id=receipt.run_id,
|
|
288
|
+
receipt_sha256=describe_file(directory / "load.json", directory).sha256,
|
|
289
|
+
)
|
|
290
|
+
temporary = self.root / ("current-" + uuid4().hex + ".json")
|
|
291
|
+
try:
|
|
292
|
+
publish_json(pointer, temporary)
|
|
293
|
+
os.replace(temporary, self.root / "current.json")
|
|
294
|
+
finally:
|
|
295
|
+
temporary.unlink(missing_ok=True)
|