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,433 @@
1
+ """Bounded native snapshots for ``diff``, including caller-supplied DuckDB relations.
2
+
3
+ Native size estimates and Arrow buffer checks are cooperative limits, not hard
4
+ RSS or latency guarantees. No expected schema participates in capture.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ import sys
11
+ from collections.abc import Iterable
12
+ from dataclasses import dataclass, replace
13
+ from datetime import date
14
+ from decimal import Decimal
15
+ from importlib.metadata import PackageNotFoundError, version
16
+ from typing import TYPE_CHECKING, Literal, cast
17
+
18
+ from equiframe._base import FrozenModel, PositiveInt
19
+ from equiframe.errors import (
20
+ CapabilityError,
21
+ ExecutionCancelled,
22
+ InvalidExecutionInput,
23
+ ResourceLimitError,
24
+ )
25
+ from equiframe.tables import (
26
+ LoadedTable,
27
+ check_arrow,
28
+ copy_pandas_frame,
29
+ detached_arrow,
30
+ pandas_capture,
31
+ require_dependency,
32
+ )
33
+
34
+ if TYPE_CHECKING:
35
+ import duckdb
36
+ import pandas as pd
37
+ import polars as pl
38
+ import pyarrow as pa
39
+
40
+ from equiframe.execution import CancellationToken
41
+
42
+
43
+ class DiffCaptureOptions(FrozenModel):
44
+ """Per-input eager capture limits, separate from comparison budgets.
45
+
46
+ ``max_bytes`` bounds native estimated size before copying/conversion and
47
+ captured Arrow data plus meaningful index buffers afterward. Intermediate
48
+ allocations, Python overhead and serialization buffers are not hard-capped.
49
+ Passing an actual DuckDB relation explicitly authorizes consuming that
50
+ relation. Capture limits rows with max_rows+1 and checks Arrow batches before
51
+ detachment. Native planning/first-batch allocations are cooperative, not hard
52
+ memory/time limits. Polars lazy frames, SQL strings and connections are not
53
+ accepted as native tables.
54
+ """
55
+
56
+ max_rows: PositiveInt = 1_000_000
57
+ max_bytes: PositiveInt = 256 * 1024 * 1024
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class _NativeInput:
62
+ value: object
63
+ options: DiffCaptureOptions
64
+ cancellation: CancellationToken | None = None
65
+
66
+ def load(self) -> LoadedTable:
67
+ return capture_native(self.value, self.options, cancellation=self.cancellation)
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class _CapturedInput:
72
+ loaded: LoadedTable
73
+
74
+ def load(self) -> LoadedTable:
75
+ return self.loaded
76
+
77
+
78
+ def _is_loaded_type(value: object, module_name: str, type_name: str) -> bool:
79
+ """Check actual engine classes without importing an unrelated extra."""
80
+ module = sys.modules.get(module_name)
81
+ native_type = getattr(module, type_name, None) if module is not None else None
82
+ return isinstance(native_type, type) and isinstance(value, native_type)
83
+
84
+
85
+ def _require_base_class(value: object, module_name: str, type_name: str) -> None:
86
+ # A subclass may attach units/index semantics or replace copy/conversion.
87
+ # Merely being isinstance-compatible does not authorize discarding those.
88
+ if type(value) is not getattr(sys.modules[module_name], type_name):
89
+ raise CapabilityError(
90
+ f"{module_name}.{type_name} subclasses require an explicit metadata/capture policy"
91
+ )
92
+
93
+
94
+ def _capture_versions(engine: Literal["arrow", "pandas", "polars", "duckdb"]) -> tuple[str, ...]:
95
+ """Record installed capture distributions, not a tested compatibility matrix.
96
+
97
+ Only fixed distribution names and bounded version tokens enter evidence.
98
+ Loaded Polars runtime modules identify the variant without importing another
99
+ engine or consulting paths, session settings, or module version internals.
100
+ """
101
+ names = ["pyarrow"]
102
+ if engine != "arrow":
103
+ names.append(engine)
104
+ if engine == "polars":
105
+ for variant in ("32", "64", "compat"):
106
+ if f"_polars_runtime_{variant}" in sys.modules:
107
+ names.append(f"polars-runtime-{variant}")
108
+ notes = []
109
+ for name in names:
110
+ try:
111
+ installed = version(name)
112
+ except PackageNotFoundError:
113
+ installed = "not-installed"
114
+ if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9.!+_-]{0,127}", installed) is None:
115
+ installed = "invalid-version-metadata"
116
+ notes.append(f"Native capture installed distribution: {name}={installed}")
117
+ return tuple(notes)
118
+
119
+
120
+ def _check_size(rows: int, size: int, options: DiffCaptureOptions) -> None:
121
+ if rows > options.max_rows:
122
+ raise ResourceLimitError("native diff input exceeds capture row budget")
123
+ if size > options.max_bytes:
124
+ raise ResourceLimitError("native diff input exceeds capture byte budget")
125
+
126
+
127
+ def _check_metadata(table: pa.Table) -> None:
128
+ # Schema metadata may contain units, extension types or an embedded index.
129
+ # Keeping its bytes is insufficient when the comparator cannot interpret it.
130
+ if table.schema.metadata or any(field.metadata for field in table.schema):
131
+ raise CapabilityError(
132
+ "native Arrow schema/field metadata has no supported comparison policy; "
133
+ "provide an explicitly prepared faithful capture"
134
+ )
135
+
136
+
137
+ def _finish(loaded: LoadedTable, options: DiffCaptureOptions) -> LoadedTable:
138
+ check_arrow(loaded.table)
139
+ index = loaded.index if loaded.meaningful_index else None
140
+ if index is not None:
141
+ check_arrow(index)
142
+ if index.num_rows != loaded.table.num_rows:
143
+ raise InvalidExecutionInput("captured index row count must match the data")
144
+ _check_size(
145
+ loaded.table.num_rows,
146
+ loaded.table.nbytes + (index.nbytes if index is not None else 0),
147
+ options,
148
+ )
149
+ # Arrow can alias writable NumPy buffers, including in pandas extension
150
+ # arrays and Polars frames. IPC detachment isolates every accepted snapshot.
151
+ table = detached_arrow(loaded.table)
152
+ index = detached_arrow(index) if index is not None else None
153
+ _check_size(table.num_rows, table.nbytes + (index.nbytes if index is not None else 0), options)
154
+ return replace(loaded, table=table, native=None, index=index)
155
+
156
+
157
+ def _check_pandas_metadata(frame: pd.DataFrame) -> None:
158
+ import pandas as pd
159
+
160
+ if type(frame.columns) not in (pd.Index, pd.RangeIndex):
161
+ raise CapabilityError("unsupported Pandas column index type/metadata for diff capture")
162
+ if any(type(name) is not str for name in frame.columns):
163
+ raise CapabilityError("Pandas column labels must be plain strings without extra metadata")
164
+ if any(name is not None for name in frame.columns.names):
165
+ raise CapabilityError("Pandas column axis names require an explicit metadata policy")
166
+ if frame.attrs:
167
+ raise CapabilityError("Pandas attrs require an explicit metadata policy")
168
+ if type(frame.index) not in (pd.Index, pd.RangeIndex, pd.MultiIndex, pd.DatetimeIndex):
169
+ raise CapabilityError("unsupported Pandas index type/metadata for faithful diff capture")
170
+ # pandas_capture understands only these native extension representations.
171
+ # Generic is_integer_dtype/is_float_dtype alone would also accept arbitrary
172
+ # user extensions whose conversion might erase additional semantics.
173
+ supported_extensions = (
174
+ pd.ArrowDtype,
175
+ pd.BooleanDtype,
176
+ pd.Int8Dtype,
177
+ pd.Int16Dtype,
178
+ pd.Int32Dtype,
179
+ pd.Int64Dtype,
180
+ pd.UInt8Dtype,
181
+ pd.UInt16Dtype,
182
+ pd.UInt32Dtype,
183
+ pd.UInt64Dtype,
184
+ pd.Float32Dtype,
185
+ pd.Float64Dtype,
186
+ pd.StringDtype,
187
+ pd.DatetimeTZDtype,
188
+ )
189
+ index_dtypes = (
190
+ tuple(level.dtype for level in frame.index.levels)
191
+ if isinstance(frame.index, pd.MultiIndex)
192
+ else (frame.index.dtype,)
193
+ )
194
+ for dtype in (*frame.dtypes, *index_dtypes):
195
+ if isinstance(dtype, pd.CategoricalDtype):
196
+ raise CapabilityError("Pandas categories/ordering cannot be captured as scalar labels")
197
+ if (
198
+ isinstance(dtype, pd.api.extensions.ExtensionDtype)
199
+ and type(dtype) not in supported_extensions
200
+ ):
201
+ raise CapabilityError("unsupported Pandas extension dtype for faithful diff capture")
202
+ if getattr(dtype, "metadata", None):
203
+ raise CapabilityError("Pandas dtype metadata requires an explicit comparison policy")
204
+ if any(name is not None and type(name) not in (str, int) for name in frame.index.names):
205
+ raise CapabilityError("unsupported Pandas index name metadata")
206
+ if isinstance(frame.index, pd.MultiIndex):
207
+ names = tuple(name for name in frame.index.names if name is not None)
208
+ if len(names) != len(set(names)):
209
+ raise CapabilityError("duplicate Pandas index names cannot be captured faithfully")
210
+
211
+ def check_scalars(values: Iterable[object]) -> None:
212
+ for value in values:
213
+ if value is None or value is pd.NA or value is pd.NaT:
214
+ continue
215
+ if type(value) not in (str, bool, int, float, Decimal, date):
216
+ raise CapabilityError(
217
+ "Pandas object values must be plain immutable scalars; "
218
+ "subclasses and their metadata require an explicit capture policy"
219
+ )
220
+
221
+ for _, series in frame.items():
222
+ if pd.api.types.is_object_dtype(series.dtype):
223
+ check_scalars(series)
224
+ levels = frame.index.levels if isinstance(frame.index, pd.MultiIndex) else (frame.index,)
225
+ for level in levels:
226
+ if pd.api.types.is_object_dtype(level.dtype):
227
+ check_scalars(level)
228
+
229
+
230
+ def _check_polars_schema(frame: pl.DataFrame) -> None:
231
+ import polars as pl
232
+
233
+ supported = (
234
+ pl.Null,
235
+ pl.Boolean,
236
+ pl.Int8,
237
+ pl.Int16,
238
+ pl.Int32,
239
+ pl.Int64,
240
+ pl.UInt8,
241
+ pl.UInt16,
242
+ pl.UInt32,
243
+ pl.UInt64,
244
+ pl.Float32,
245
+ pl.Float64,
246
+ pl.String,
247
+ pl.Decimal,
248
+ pl.Date,
249
+ pl.Datetime,
250
+ )
251
+ if any(dtype.base_type() not in supported for dtype in frame.schema.values()):
252
+ raise CapabilityError("unsupported Polars dtype for faithful scalar diff capture")
253
+
254
+
255
+ def _capture_duckdb_relation(
256
+ relation: duckdb.DuckDBPyRelation,
257
+ options: DiffCaptureOptions,
258
+ cancellation: CancellationToken | None,
259
+ ) -> LoadedTable:
260
+ """Consume only the real relation explicitly supplied by the caller.
261
+
262
+ No SQL text is accepted, reconstructed or loaded from evidence. The caller
263
+ owns the relation's connection/settings and must trust its native plan.
264
+ Cancellation is checked before execution and between batches; a blocking
265
+ native first fetch is not an interruptible or sandboxed operation.
266
+ """
267
+ require_dependency("pyarrow", "duckdb")
268
+ import duckdb
269
+ import pyarrow as pa
270
+
271
+ from equiframe._duckdb_transport import duckdb_arrow_schema, verify_duckdb_table
272
+
273
+ def checkpoint() -> None:
274
+ if cancellation is not None and cancellation.cancelled:
275
+ raise ExecutionCancelled("cancelled during native DuckDB relation capture")
276
+
277
+ checkpoint()
278
+ reader = None
279
+ try:
280
+ native_types = tuple(zip(relation.columns, map(str, relation.types), strict=True))
281
+ duckdb_arrow_schema(native_types, "UTC")
282
+ # No count query, fetch-all or second consumption: this one result has
283
+ # at most one excess row, which lets capture reject rather than truncate.
284
+ reader = relation.limit(options.max_rows + 1).to_arrow_reader(
285
+ min(4096, options.max_rows + 1)
286
+ )
287
+ zones = {
288
+ field.type.tz
289
+ for field in reader.schema
290
+ if pa.types.is_timestamp(field.type) and field.type.tz is not None
291
+ }
292
+ if len(zones) > 1:
293
+ raise CapabilityError("DuckDB relation exposes inconsistent timestamp timezones")
294
+ timezone = next(iter(zones), "UTC")
295
+ expected = duckdb_arrow_schema(native_types, timezone)
296
+ if not reader.schema.equals(expected, check_metadata=True):
297
+ raise CapabilityError("DuckDB relation Arrow schema differs from its native types")
298
+ batches = []
299
+ rows = size = 0
300
+ while True:
301
+ checkpoint()
302
+ try:
303
+ batch = reader.read_next_batch()
304
+ except StopIteration:
305
+ break
306
+ rows += batch.num_rows
307
+ size += batch.nbytes
308
+ _check_size(rows, size, options)
309
+ batches.append(batch)
310
+ checkpoint()
311
+ table = pa.Table.from_batches(batches, schema=expected)
312
+ verify_duckdb_table(table, native_types, timezone)
313
+ captured = _finish(
314
+ LoadedTable(
315
+ table,
316
+ native_types=native_types,
317
+ notes=(
318
+ "Caller-supplied DuckDB relation explicitly consumed once for this input.",
319
+ "Native row order, Decimal precision, NULL and IEEE NaN are retained; "
320
+ "no index.",
321
+ "The caller owns relation/session settings; no SQL text or connection "
322
+ "is accepted.",
323
+ *_capture_versions("duckdb"),
324
+ ),
325
+ ),
326
+ options,
327
+ )
328
+ checkpoint()
329
+ return captured
330
+ except (duckdb.Error, pa.ArrowException) as error:
331
+ raise InvalidExecutionInput(
332
+ "DuckDB relation cannot be captured as a native table"
333
+ ) from error
334
+ finally:
335
+ if reader is not None:
336
+ reader.close()
337
+
338
+
339
+ def capture_native(
340
+ value: object, options: DiffCaptureOptions, *, cancellation: CancellationToken | None = None
341
+ ) -> LoadedTable:
342
+ """Capture a real eager table or explicitly supplied DuckDB relation.
343
+
344
+ Unsupported inputs raise a typed input/capability error. Native row/size
345
+ preflight runs before the first copy or conversion. Repeated calls create
346
+ separate snapshots; reuse is represented internally by ``_CapturedInput``.
347
+ """
348
+ options = DiffCaptureOptions.model_validate(options)
349
+ if cancellation is not None and cancellation.cancelled:
350
+ raise ExecutionCancelled("cancelled before native input capture")
351
+ if _is_loaded_type(value, "polars", "LazyFrame"):
352
+ raise CapabilityError(
353
+ "diff does not execute a Polars LazyFrame; materialize it through "
354
+ "explicit native execution/capture first"
355
+ )
356
+ if _is_loaded_type(value, "duckdb", "DuckDBPyRelation") or _is_loaded_type(
357
+ value, "_duckdb", "DuckDBPyRelation"
358
+ ):
359
+ module_name = (
360
+ "duckdb" if _is_loaded_type(value, "duckdb", "DuckDBPyRelation") else "_duckdb"
361
+ )
362
+ _require_base_class(value, module_name, "DuckDBPyRelation")
363
+ return _capture_duckdb_relation(
364
+ cast("duckdb.DuckDBPyRelation", value), options, cancellation
365
+ )
366
+ if _is_loaded_type(value, "pyarrow", "Table"):
367
+ _require_base_class(value, "pyarrow", "Table")
368
+ table = cast("pa.Table", value)
369
+ _check_size(table.num_rows, table.nbytes, options)
370
+ _check_metadata(table)
371
+ return _finish(
372
+ LoadedTable(
373
+ table,
374
+ native_types=tuple((field.name, str(field.type)) for field in table.schema),
375
+ notes=(
376
+ "Arrow retains distinctions already encoded in types, values "
377
+ "and validity masks.",
378
+ "Direct eager diff input captured once; no native execution performed.",
379
+ *_capture_versions("arrow"),
380
+ ),
381
+ ),
382
+ options,
383
+ )
384
+ if _is_loaded_type(value, "pandas", "DataFrame"):
385
+ _require_base_class(value, "pandas", "DataFrame")
386
+ frame = cast("pd.DataFrame", value)
387
+ _check_size(len(frame), 0, options)
388
+ _check_pandas_metadata(frame)
389
+ _check_size(len(frame), int(frame.memory_usage(index=True, deep=True).sum()), options)
390
+ require_dependency("pyarrow", "pandas")
391
+ import pyarrow as pa
392
+
393
+ try:
394
+ captured = pandas_capture(copy_pandas_frame(frame))
395
+ except (pa.ArrowException, TypeError, ValueError, OverflowError) as error:
396
+ raise CapabilityError("Pandas input cannot be captured faithfully as Arrow") from error
397
+ return _finish(
398
+ replace(
399
+ captured,
400
+ notes=(
401
+ *captured.notes,
402
+ "Direct eager diff input captured once; no native execution performed.",
403
+ *_capture_versions("pandas"),
404
+ ),
405
+ ),
406
+ options,
407
+ )
408
+ if _is_loaded_type(value, "polars", "DataFrame"):
409
+ _require_base_class(value, "polars", "DataFrame")
410
+ frame_pl = cast("pl.DataFrame", value)
411
+ _check_size(frame_pl.height, int(frame_pl.estimated_size()), options)
412
+ _check_polars_schema(frame_pl)
413
+ require_dependency("pyarrow", "polars")
414
+ copied = frame_pl.clone()
415
+ table = copied.to_arrow()
416
+ if table.shape != copied.shape:
417
+ raise CapabilityError("Polars direct capture changed table dimensions")
418
+ _check_metadata(table)
419
+ return _finish(
420
+ LoadedTable(
421
+ table,
422
+ native_types=tuple((name, str(dtype)) for name, dtype in copied.schema.items()),
423
+ notes=(
424
+ "Direct eager diff input captured once; no native execution performed.",
425
+ *_capture_versions("polars"),
426
+ ),
427
+ ),
428
+ options,
429
+ )
430
+ raise InvalidExecutionInput(
431
+ "diff native input must be an eager pyarrow.Table, pandas.DataFrame, polars.DataFrame "
432
+ "or an explicitly supplied DuckDBPyRelation"
433
+ )
equiframe/_doctor.py ADDED
@@ -0,0 +1,123 @@
1
+ """Local installation metadata checks; no optional imports or network access."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from enum import Enum
7
+ from importlib import metadata
8
+ from importlib.util import find_spec
9
+ from typing import Any
10
+
11
+
12
+ class Extra(str, Enum):
13
+ base = "base"
14
+ arrow = "arrow"
15
+ pandas = "pandas"
16
+ polars = "polars"
17
+ duckdb = "duckdb"
18
+ engines = "engines"
19
+ files = "files"
20
+ ml = "ml"
21
+
22
+
23
+ _BASE = (
24
+ ("typing-extensions", "typing_extensions"),
25
+ ("pydantic", "pydantic"),
26
+ ("typer", "typer"),
27
+ ("tzdata", "tzdata"),
28
+ )
29
+ _ARROW = (("pyarrow", "pyarrow"),)
30
+ _EXTRAS = {
31
+ Extra.base: (),
32
+ Extra.arrow: _ARROW,
33
+ Extra.pandas: (*_ARROW, ("pandas", "pandas")),
34
+ Extra.polars: (*_ARROW, ("polars", "polars")),
35
+ Extra.duckdb: (*_ARROW, ("duckdb", "duckdb")),
36
+ Extra.engines: (*_ARROW, ("pandas", "pandas"), ("polars", "polars"), ("duckdb", "duckdb")),
37
+ Extra.files: (
38
+ *_ARROW,
39
+ ("pandas", "pandas"),
40
+ ("openpyxl", "openpyxl"),
41
+ ("xlrd", "xlrd"),
42
+ ("defusedxml", "defusedxml"),
43
+ ),
44
+ Extra.ml: (("numpy", "numpy"), ("scikit-learn", "sklearn")),
45
+ }
46
+
47
+
48
+ def diagnose(extra: Extra) -> dict[str, Any]:
49
+ """Report availability only; pip check and a native operation remain necessary."""
50
+ from equiframe import __version__
51
+
52
+ dependencies: list[dict[str, str | None]] = []
53
+ for distribution, module in _BASE + _EXTRAS[extra]:
54
+ try:
55
+ version = metadata.version(distribution)
56
+ except metadata.PackageNotFoundError:
57
+ version = None
58
+ available = find_spec(module) is not None
59
+ dependencies.append(
60
+ {
61
+ "distribution": distribution,
62
+ "version": version,
63
+ "status": "available" if version is not None and available else "missing",
64
+ }
65
+ )
66
+ try:
67
+ installed_version = metadata.version("equiframe")
68
+ except metadata.PackageNotFoundError:
69
+ installed_version = None
70
+ identity = installed_version == __version__
71
+ python_supported = (3, 10) <= sys.version_info[:2] < (3, 14)
72
+ ready = (
73
+ identity
74
+ and python_supported
75
+ and all(item["status"] == "available" for item in dependencies)
76
+ )
77
+ if not identity:
78
+ next_action = (
79
+ "Reinstall the identified private candidate in this interpreter; "
80
+ "code and distribution metadata must have the same version."
81
+ )
82
+ elif not python_supported:
83
+ next_action = "Use a supported Python minor (3.10-3.13) with its matching artifact receipt."
84
+ elif not ready:
85
+ requirement = "equiframe" + (f"[{extra.value}]" if extra != Extra.base else "")
86
+ next_action = (
87
+ f"Install {requirement}=={__version__} from the reviewed private wheelhouse "
88
+ "in this interpreter; do not install an unrelated public homonym."
89
+ )
90
+ else:
91
+ next_action = (
92
+ "Run python -m pip check in this interpreter, then the documented native "
93
+ "example for your task. Metadata availability does not test binary compatibility."
94
+ )
95
+ return {
96
+ "schema_version": "1.0",
97
+ "extra": extra.value,
98
+ "code_version": __version__,
99
+ "installed_version": installed_version,
100
+ "identity_matches": identity,
101
+ "python": ".".join(map(str, sys.version_info[:3])),
102
+ "python_admitted": python_supported,
103
+ "dependencies": dependencies,
104
+ "ready_metadata": ready,
105
+ "native_execution_tested": False,
106
+ "dependency_ranges_tested": False,
107
+ "next_action": next_action,
108
+ }
109
+
110
+
111
+ def diagnostic_text(result: dict[str, Any]) -> str:
112
+ """A short human view of the same machine-readable diagnosis."""
113
+ lines = [
114
+ f"EquiFrame {result['code_version']} | Python {result['python']} | extra {result['extra']}",
115
+ f"Distribution version: {result['installed_version'] or 'missing'}",
116
+ f"Metadata ready: {result['ready_metadata']}",
117
+ ]
118
+ lines.extend(
119
+ f" {item['distribution']}: {item['status']} ({item['version'] or 'missing'})"
120
+ for item in result["dependencies"]
121
+ )
122
+ lines.append("Next action: " + result["next_action"])
123
+ return "\n".join(lines)
@@ -0,0 +1,61 @@
1
+ """Shared local document I/O with unchanged historical byte and error contracts.
2
+
3
+ This module does not serialize models or canonicalize JSON. Each caller keeps
4
+ its existing encoding, newline policy, fingerprint and format version.
5
+ """
6
+
7
+ import json
8
+ import os
9
+ from collections.abc import Iterable
10
+ from pathlib import Path
11
+ from uuid import uuid4
12
+
13
+ from equiframe._regular_io import local_input_path, open_regular_file, regular_directory
14
+ from equiframe.errors import ArtifactIOError, ResourceLimitError
15
+ from equiframe.serialization import _constant, _JsonObject, _unique_objects
16
+
17
+
18
+ def write_document(path: Path, payload: bytes) -> None:
19
+ """Publish exclusively. A previous profile or bundle is never overwritten."""
20
+ write_document_chunks(path, (payload,))
21
+
22
+
23
+ def write_document_chunks(path: Path, payloads: Iterable[bytes]) -> None:
24
+ """Publish byte chunks exclusively; iterator failures never publish a prefix."""
25
+ path = local_input_path(path)
26
+ try:
27
+ regular_directory(path.parent)
28
+ except ArtifactIOError as error:
29
+ cause = error.__cause__
30
+ if isinstance(cause, OSError):
31
+ raise type(cause)(cause.errno, "document directory cannot be used") from None
32
+ raise
33
+ temporary = path.with_name(path.name + "." + uuid4().hex + ".part")
34
+ try:
35
+ with temporary.open("xb") as output:
36
+ for payload in payloads:
37
+ output.write(payload)
38
+ output.flush()
39
+ os.fsync(output.fileno())
40
+ os.link(temporary, path)
41
+ finally:
42
+ temporary.unlink(missing_ok=True)
43
+
44
+
45
+ def read_document(path: Path) -> bytes:
46
+ try:
47
+ with open_regular_file(path) as stream:
48
+ raw = stream.read(64 * 1024 * 1024 + 1)
49
+ except ArtifactIOError as error:
50
+ # Keep the original OS-error contract used by profile/detector and
51
+ # benchmark readers, while omitting filenames and external messages.
52
+ cause = error.__cause__
53
+ if isinstance(cause, OSError):
54
+ raise type(cause)(cause.errno, "document cannot be read") from None
55
+ raise
56
+ if len(raw) > 64 * 1024 * 1024:
57
+ raise ResourceLimitError("document exceeds the 64 MiB read budget")
58
+ _unique_objects(
59
+ json.loads(raw.decode("utf-8"), object_pairs_hook=_JsonObject, parse_constant=_constant)
60
+ )
61
+ return raw
@@ -0,0 +1,79 @@
1
+ """Private strict document primitives shared with the experimental package.
2
+
3
+ This preserves the historical ASCII-escaped JSON v1 encoding exactly. It is
4
+ intentionally distinct from contract canonicalization; do not silently switch
5
+ encodings or normalize existing fingerprints when refactoring consumers.
6
+ """
7
+
8
+ import hashlib
9
+ import json
10
+ import os
11
+ from pathlib import Path
12
+ from typing import Annotated, Literal
13
+ from uuid import uuid4
14
+
15
+ from pydantic import AfterValidator, BaseModel, ConfigDict, StringConstraints
16
+
17
+ from equiframe.types import ArtifactRef
18
+
19
+
20
+ def canonical_text(value: str) -> str:
21
+ if not value.strip() or value != value.strip():
22
+ raise ValueError("text and identities must be canonical, without surrounding whitespace")
23
+ if any(0xD800 <= ord(c) <= 0xDFFF for c in value):
24
+ raise ValueError("invalid Unicode scalar")
25
+ return value
26
+
27
+
28
+ Text = Annotated[str, StringConstraints(min_length=1), AfterValidator(canonical_text)]
29
+ Digest = Annotated[str, StringConstraints(pattern=r"^[0-9a-f]{64}$")]
30
+
31
+
32
+ class Document(BaseModel):
33
+ model_config = ConfigDict(
34
+ strict=True,
35
+ frozen=True,
36
+ extra="forbid",
37
+ allow_inf_nan=False,
38
+ revalidate_instances="always",
39
+ validate_default=True,
40
+ )
41
+ schema_version: Literal["1.0"] = "1.0"
42
+
43
+
44
+ def canonical_document_bytes(document: BaseModel) -> bytes:
45
+ """Preserve the historical document encoding; callers revalidate their model."""
46
+ return json.dumps(
47
+ document.model_dump(mode="json"),
48
+ sort_keys=True,
49
+ separators=(",", ":"),
50
+ ensure_ascii=True,
51
+ allow_nan=False,
52
+ ).encode()
53
+
54
+
55
+ def digest(document: BaseModel) -> str:
56
+ return hashlib.sha256(canonical_document_bytes(document)).hexdigest()
57
+
58
+
59
+ def checked_artifact(ref: ArtifactRef) -> ArtifactRef:
60
+ if not ref.sha256 or ref.size_bytes is None or ref.format not in ("arrow", "parquet"):
61
+ raise ValueError("tabular evidence requires SHA-256, size and Arrow/Parquet format")
62
+ return ref
63
+
64
+
65
+ Evidence = Annotated[ArtifactRef, AfterValidator(checked_artifact)]
66
+
67
+
68
+ def save_document(document: BaseModel, path: Path) -> None:
69
+ document = type(document).model_validate(document)
70
+ data = canonical_document_bytes(document)
71
+ temporary = path.with_name(path.name + "." + uuid4().hex + ".part")
72
+ try:
73
+ with temporary.open("xb") as stream:
74
+ stream.write(data + b"\n")
75
+ stream.flush()
76
+ os.fsync(stream.fileno())
77
+ os.link(temporary, path)
78
+ finally:
79
+ temporary.unlink(missing_ok=True)