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
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)