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,149 @@
1
+ """Closed native-to-Arrow admission shared by DuckDB execution and capture.
2
+
3
+ Arrow validation cannot prove native fidelity: DuckDB can export a UHUGEINT as
4
+ a valid decimal128 with a different value. Admit native types before export,
5
+ then require their exact Arrow representation and reject temporal sentinels.
6
+ Optional engines are imported only when a transport check actually runs.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from collections.abc import Mapping
13
+ from decimal import Decimal
14
+ from typing import TYPE_CHECKING
15
+
16
+ from equiframe.errors import CapabilityError
17
+ from equiframe.tables import check_arrow
18
+
19
+ if TYPE_CHECKING:
20
+ import pyarrow as pa
21
+
22
+
23
+ def duckdb_parameters(parameters: Mapping[str, object]) -> dict[str, object]:
24
+ """Bind admitted scalars without DuckDB's lossy Python Decimal inference.
25
+
26
+ Decimal is rendered exactly (no context arithmetic), then explicitly typed
27
+ before native execution. In particular, leading fractional zeros can make
28
+ DuckDB's automatic Decimal inference exceed precision 38 and use DOUBLE.
29
+ """
30
+ import duckdb
31
+
32
+ bound = {}
33
+ for name, value in parameters.items():
34
+ if value is None or type(value) in (bool, str, float):
35
+ bound[name] = value
36
+ continue
37
+ if type(value) is int and -(2**63) <= value < 2**64:
38
+ bound[name] = value
39
+ continue
40
+ if type(value) is Decimal:
41
+ if not value.is_finite() or (value.is_zero() and value.is_signed()):
42
+ raise CapabilityError("DuckDB parameters cannot retain this Decimal representation")
43
+ digits = value.as_tuple()
44
+ exponent = int(digits.exponent)
45
+ precision = max(len(digits.digits), -exponent)
46
+ if exponent <= 0 and precision <= 38:
47
+ bound[name] = duckdb.Value(
48
+ format(value, "f"), duckdb.decimal_type(precision, -exponent)
49
+ )
50
+ continue
51
+ raise CapabilityError(
52
+ "DuckDB parameters admit plain NULL/bool/text/float, 64-bit integers and "
53
+ "finite nonnegative-scale Decimal up to precision 38; use text with explicit "
54
+ "SQL casts for temporal or other intentionally converted literals"
55
+ )
56
+ return bound
57
+
58
+
59
+ def duckdb_arrow_schema(native_types: tuple[tuple[str, str], ...], timezone: str) -> pa.Schema:
60
+ """Return the exact admitted schema, without casting or erasing native types.
61
+
62
+ The names are observed result names and types are DuckDB's canonical native
63
+ descriptions, not user SQL aliases. DuckDB result fields are always nullable.
64
+ HUGEINT, UHUGEINT and unknown/extended types deliberately have no mapping.
65
+ """
66
+ import pyarrow as pa
67
+
68
+ if not isinstance(timezone, str) or not timezone:
69
+ raise CapabilityError("DuckDB result transport requires its effective session timezone")
70
+ if not native_types:
71
+ raise CapabilityError("DuckDB result transport requires a nonempty native schema")
72
+ scalar_types = {
73
+ "BOOLEAN": pa.bool_(),
74
+ "TINYINT": pa.int8(),
75
+ "SMALLINT": pa.int16(),
76
+ "INTEGER": pa.int32(),
77
+ "BIGINT": pa.int64(),
78
+ "UTINYINT": pa.uint8(),
79
+ "USMALLINT": pa.uint16(),
80
+ "UINTEGER": pa.uint32(),
81
+ "UBIGINT": pa.uint64(),
82
+ "FLOAT": pa.float32(),
83
+ "DOUBLE": pa.float64(),
84
+ "VARCHAR": pa.string(),
85
+ "DATE": pa.date32(),
86
+ "TIMESTAMP_S": pa.timestamp("s"),
87
+ "TIMESTAMP_MS": pa.timestamp("ms"),
88
+ "TIMESTAMP": pa.timestamp("us"),
89
+ "TIMESTAMP_NS": pa.timestamp("ns"),
90
+ "TIMESTAMP WITH TIME ZONE": pa.timestamp("us", tz=timezone),
91
+ }
92
+ fields = []
93
+ names = set()
94
+ for name, native_type in native_types:
95
+ if not isinstance(name, str) or not name or name in names:
96
+ raise CapabilityError("DuckDB result column names must be nonempty and unique")
97
+ names.add(name)
98
+ dtype = scalar_types.get(native_type)
99
+ if dtype is None:
100
+ decimal = re.fullmatch(r"DECIMAL\(([0-9]{1,2}),([0-9]{1,2})\)", native_type)
101
+ if decimal is not None:
102
+ precision, scale = (int(value) for value in decimal.groups())
103
+ if 1 <= precision <= 38 and 0 <= scale <= precision:
104
+ dtype = pa.decimal128(precision, scale)
105
+ if dtype is None:
106
+ if native_type in {"HUGEINT", "UHUGEINT"}:
107
+ raise CapabilityError(
108
+ "DuckDB HUGEINT/UHUGEINT results have no faithful admitted Arrow transport; "
109
+ "use an explicit checked BIGINT or DECIMAL cast in the caller's SQL "
110
+ "when its range and semantics permit it"
111
+ )
112
+ raise CapabilityError("unsupported faithful DuckDB native result type")
113
+ fields.append(pa.field(name, dtype, nullable=True))
114
+ return pa.schema(fields)
115
+
116
+
117
+ def duckdb_temporal_capabilities(table: pa.Table) -> None:
118
+ """Reject native infinity markers that Arrow exposes as ordinary ticks."""
119
+ import pyarrow as pa
120
+ import pyarrow.compute as pc
121
+
122
+ for field in table.schema:
123
+ if pa.types.is_timestamp(field.type):
124
+ bounds = pc.min_max(table[field.name].cast(pa.int64())).as_py()
125
+ if bounds["min"] is not None and (
126
+ bounds["min"] <= -(2**63 - 1) or bounds["max"] >= 2**63 - 1
127
+ ):
128
+ raise CapabilityError(
129
+ "DuckDB timestamp infinity/sentinel values cannot be represented "
130
+ "faithfully in Arrow"
131
+ )
132
+ elif pa.types.is_date32(field.type):
133
+ values = table[field.name].cast(pa.int32())
134
+ sentinels = pa.array([-(2**31 - 1), 2**31 - 1], type=pa.int32())
135
+ if pc.any(pc.is_in(values, value_set=sentinels)).as_py():
136
+ raise CapabilityError(
137
+ "DuckDB date infinity values cannot be represented faithfully in Arrow"
138
+ )
139
+
140
+
141
+ def verify_duckdb_table(
142
+ table: pa.Table, native_types: tuple[tuple[str, str], ...], timezone: str
143
+ ) -> None:
144
+ """Validate physical data, exact native mapping and finite temporal values."""
145
+ expected = duckdb_arrow_schema(native_types, timezone)
146
+ check_arrow(table)
147
+ if not table.schema.equals(expected, check_metadata=True):
148
+ raise CapabilityError("DuckDB Arrow result schema differs from its admitted native schema")
149
+ duckdb_temporal_capabilities(table)
@@ -0,0 +1,52 @@
1
+ """Safe runtime failure summaries and bounded, value-free debug frames."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import re
8
+
9
+ _LOG = logging.getLogger("equiframe.diagnostics")
10
+ _LOG.addHandler(logging.NullHandler())
11
+ _NAME = re.compile(r"[A-Za-z_][A-Za-z_0-9.]{0,127}\Z")
12
+
13
+
14
+ def report_failure(error: BaseException, message: str) -> str:
15
+ """Return an authored public message; never stringify the caught exception.
16
+
17
+ Debug output contains owned module/function/line metadata only. Exception
18
+ values, source lines, filenames, locals and external callback names are
19
+ excluded, including when an exception's __str__ itself is unsafe.
20
+ """
21
+ try:
22
+ enabled = _LOG.isEnabledFor(logging.DEBUG)
23
+ except Exception:
24
+ return message
25
+ if enabled:
26
+ try:
27
+ _debug_frames(error)
28
+ except Exception:
29
+ # Logging is best effort; a caller's failing handler must not
30
+ # expose values or replace the original operational outcome.
31
+ return message
32
+ return message
33
+
34
+
35
+ def _debug_frames(error: BaseException) -> None:
36
+ frames: list[tuple[str, str, int]] = []
37
+ cursor = error.__traceback__
38
+ while cursor is not None and len(frames) < 32:
39
+ frame = cursor.tb_frame
40
+ module = frame.f_globals.get("__name__")
41
+ function = frame.f_code.co_name
42
+ if (
43
+ type(module) is str
44
+ and module.startswith("equiframe.")
45
+ and _NAME.fullmatch(module)
46
+ and _NAME.fullmatch(function)
47
+ ):
48
+ frames.append((module, function, cursor.tb_lineno))
49
+ else:
50
+ frames.append(("external", "callback", 0))
51
+ cursor = cursor.tb_next
52
+ _LOG.debug("Operation failed; safe frames=%s", json.dumps(frames, ensure_ascii=True))
@@ -0,0 +1,102 @@
1
+ """Small synthetic role exercises using the existing public comparison APIs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from decimal import Decimal
6
+ from enum import Enum
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+
11
+ class Role(str, Enum):
12
+ architect = "architect"
13
+ scientist = "scientist"
14
+
15
+
16
+ class Case(str, Enum):
17
+ good = "good"
18
+ different = "different"
19
+ not_evaluable = "not-evaluable"
20
+
21
+
22
+ def run_demo(role: Role, case: Case, output: Path) -> dict[str, Any]:
23
+ """Save both report views before deciding; never replace existing evidence."""
24
+ import equiframe as ef
25
+ from equiframe.tables import require_dependency
26
+
27
+ require_dependency("pyarrow", "arrow")
28
+ import pyarrow as pa
29
+
30
+ # Reserve the caller's new destination before performing any native work.
31
+ output.mkdir(parents=True, exist_ok=False)
32
+ if role == Role.architect:
33
+ expected = pa.table({"id": ["A", "B"], "quantity": [10, 20]})
34
+ candidate = ef.capture_duckdb(
35
+ "SELECT 'A' AS id, 10::BIGINT AS quantity UNION ALL SELECT 'B', $quantity::BIGINT",
36
+ parameters={"quantity": 21 if case == Case.different else 20},
37
+ work_dir=output / "capture",
38
+ )
39
+ declaration = ef.infer_diff_contract(expected.schema, keys=["id"])
40
+ else:
41
+ expected = pa.table(
42
+ {
43
+ "sample_id": ["0001", "0002", "0003"],
44
+ "duration_s": pa.array(
45
+ [Decimal("1.250"), None, Decimal("1000000.001")], type=pa.decimal128(16, 3)
46
+ ),
47
+ "observed_at": pa.array([0, 1000000, None], type=pa.timestamp("us", tz="UTC")),
48
+ }
49
+ )
50
+ # Candidate calculation is separate from the literal expected durations.
51
+ durations = [Decimal("1250") / Decimal("1000"), None, Decimal("1000000001") / 1000]
52
+ if case == Case.different:
53
+ durations[0] = Decimal("1.251")
54
+ candidate = pa.table(
55
+ {
56
+ "sample_id": ["0001", "0002", "0003"],
57
+ "duration_s": pa.array(durations, type=pa.decimal128(16, 3)),
58
+ "observed_at": pa.array([0, 1000000, None], type=pa.timestamp("us", tz="UTC")),
59
+ }
60
+ )
61
+ duration = ef.ColumnSpec(
62
+ name="duration_s",
63
+ logical_type=ef.DecimalType(precision=16, scale=3),
64
+ nullable=True,
65
+ null_policy=ef.NullPolicy(representations=("null",)),
66
+ numeric_policy=ef.NumericPolicy(comparison="exact", unit="seconds"),
67
+ )
68
+ declaration = ef.infer_diff_contract(
69
+ expected.schema, keys=["sample_id"], column_overrides=(duration,)
70
+ )
71
+ result = ef.diff(
72
+ candidate,
73
+ ef.ArrowInput(expected),
74
+ contract=declaration,
75
+ work_dir=output / "private",
76
+ options=ef.ComparisonOptions(max_rows=1 if case == Case.not_evaluable else 100),
77
+ )
78
+ report = ef.write_comparison_report(result, output / "report")
79
+ detail = ef.write_comparison_report(
80
+ result,
81
+ output / "synthetic-detail",
82
+ options=ef.ReportOptions(include_values=True, include_metadata=True),
83
+ )
84
+ decision = ef.equivalence_decision(
85
+ result,
86
+ policy=ef.EquivalencePolicy(require_persisted_evidence=True),
87
+ evidence_root=output / "private",
88
+ )
89
+ return {
90
+ "role": role.value,
91
+ "case": case.value,
92
+ "synthetic": True,
93
+ "state": decision.state,
94
+ "complete": decision.complete,
95
+ "accepted": decision.accepted,
96
+ "code": decision.code,
97
+ "next_action": report.next_action,
98
+ "report": report.to_text(),
99
+ "synthetic_detail": detail.to_text(),
100
+ "report_html": str(output / "report" / "report.html"),
101
+ "synthetic_detail_html": str(output / "synthetic-detail" / "report.html"),
102
+ }
@@ -0,0 +1,26 @@
1
+ """Filesystem compatibility for the supported CPython versions.
2
+
3
+ Windows junctions are mount-point reparse points. The lstat tag has existed
4
+ since Python 3.8, so older Python must inspect it rather than assume no junction.
5
+ This is not a race-free sandbox and does not extend the supported filesystems.
6
+ """
7
+
8
+ import stat
9
+ import sys
10
+ from pathlib import Path
11
+
12
+
13
+ def is_junction(path: Path) -> bool:
14
+ """Identify a Windows junction without following it, including on 3.10/3.11.
15
+
16
+ POSIX has no Windows junctions. Missing paths are not junctions; other I/O
17
+ failures propagate so inaccessible paths cannot be silently accepted.
18
+ Symlinks are checked separately by the caller, preserving its error contract.
19
+ """
20
+ if sys.platform != "win32":
21
+ return False
22
+ try:
23
+ result = path.lstat()
24
+ except FileNotFoundError:
25
+ return False
26
+ return result.st_reparse_tag == stat.IO_REPARSE_TAG_MOUNT_POINT
equiframe/_physical.py ADDED
@@ -0,0 +1,133 @@
1
+ """Verify captured physical metadata without executing or normalizing a transformation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass
8
+ from typing import TYPE_CHECKING
9
+
10
+ from equiframe.errors import ArtifactIntegrityError, CapabilityError
11
+ from equiframe.local import local_path
12
+ from equiframe.tables import require_dependency
13
+
14
+ if TYPE_CHECKING:
15
+ from equiframe.execution_types import PhysicalColumn, TableArtifact
16
+ from equiframe.types import ArtifactRef
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class _Observation:
21
+ schema: tuple[tuple[str, str], ...]
22
+ rows: int
23
+ buffer_bytes: int
24
+ nulls: tuple[int, ...]
25
+ nans: tuple[int, ...]
26
+
27
+
28
+ def _observe(ref: ArtifactRef, *, check: Callable[[], None] | None = None) -> _Observation:
29
+ """Hash first, then decode batches; buffer bytes are distinct from file bytes."""
30
+ require_dependency("pyarrow", "arrow")
31
+ import pyarrow as pa
32
+ import pyarrow.compute as pc
33
+
34
+ if ref.format not in ("arrow", "parquet"):
35
+ raise ArtifactIntegrityError("physical table requires Arrow IPC or Parquet")
36
+ try:
37
+ with local_path(ref).open("rb") as source:
38
+ if check:
39
+ check()
40
+ digest = hashlib.sha256()
41
+ file_bytes = 0
42
+ while block := source.read(1024 * 1024):
43
+ if check:
44
+ check()
45
+ digest.update(block)
46
+ file_bytes += len(block)
47
+ if check:
48
+ check()
49
+ if (digest.hexdigest(), file_bytes) != (ref.sha256, ref.size_bytes):
50
+ raise ArtifactIntegrityError("physical table SHA-256 or size mismatch")
51
+ source.seek(0)
52
+ if ref.format == "arrow":
53
+ reader = pa.ipc.open_file(source)
54
+ schema = reader.schema
55
+ batches = (reader.get_batch(i) for i in range(reader.num_record_batches))
56
+ else:
57
+ import pyarrow.parquet as pq
58
+
59
+ parquet = pq.ParquetFile(source)
60
+ schema = parquet.schema_arrow
61
+ # Preserve row-group boundaries. A 65536-row subdivision is byte
62
+ # aligned for validity bitmaps and does not change materialized
63
+ # Arrow nbytes through additional partial bitmap boundaries.
64
+ batches = (
65
+ batch
66
+ for group in range(parquet.num_row_groups)
67
+ for batch in parquet.iter_batches(
68
+ batch_size=65536, row_groups=[group], use_threads=False
69
+ )
70
+ )
71
+ nulls = [0] * len(schema)
72
+ nans = [0] * len(schema)
73
+ rows = buffer_bytes = 0
74
+ batch_iterator = iter(batches)
75
+ while True:
76
+ if check:
77
+ check()
78
+ try:
79
+ batch = next(batch_iterator)
80
+ except StopIteration:
81
+ break
82
+ batch.validate(full=True)
83
+ if batch.schema != schema:
84
+ raise ArtifactIntegrityError("physical batch schema changed")
85
+ rows += batch.num_rows
86
+ buffer_bytes += batch.nbytes
87
+ for i, array in enumerate(batch.columns):
88
+ nulls[i] += array.null_count
89
+ if pa.types.is_floating(array.type):
90
+ nans[i] += int(pc.sum(pc.is_nan(array)).as_py() or 0)
91
+ if check:
92
+ check()
93
+ return _Observation(
94
+ schema=tuple((field.name, str(field.type)) for field in schema),
95
+ rows=rows,
96
+ buffer_bytes=buffer_bytes,
97
+ nulls=tuple(nulls),
98
+ nans=tuple(nans),
99
+ )
100
+ except (OSError, ValueError, CapabilityError, pa.ArrowException) as error:
101
+ raise ArtifactIntegrityError("cannot verify captured physical table") from error
102
+
103
+
104
+ def physical_size(ref: ArtifactRef, *, check: Callable[[], None] | None = None) -> tuple[int, int]:
105
+ """Return rows and materialized Arrow buffer bytes, with verified source bytes.
106
+
107
+ Decoding is by record batch. An IPC record batch can itself be large; this
108
+ function does not impose a process-memory quota or execute native engines.
109
+ """
110
+ observed = _observe(ref, check=check)
111
+ return observed.rows, observed.buffer_bytes
112
+
113
+
114
+ def _verify_columns(observed: _Observation, columns: tuple[PhysicalColumn, ...], rows: int) -> None:
115
+ if observed.rows != rows or observed.schema != tuple(
116
+ (column.name, column.arrow_type) for column in columns
117
+ ):
118
+ raise ArtifactIntegrityError("captured physical rows or schema contradict metadata")
119
+ if observed.nulls != tuple(column.null_count for column in columns) or (
120
+ observed.nans != tuple(column.nan_count for column in columns)
121
+ ):
122
+ raise ArtifactIntegrityError("captured physical NULL or NaN counts contradict metadata")
123
+
124
+
125
+ def verify_table_metadata(table: TableArtifact) -> None:
126
+ """Verify schema, counts and a separate index against captured table bytes.
127
+
128
+ Native ``engine_type`` is provenance and cannot be inferred from IPC/Parquet.
129
+ No casts, contract normalization, ETL, plugins or engine imports occur here.
130
+ """
131
+ _verify_columns(_observe(table.data), table.columns, table.row_count)
132
+ if table.index is not None:
133
+ _verify_columns(_observe(table.index), table.index_columns, table.row_count)