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/tables.py ADDED
@@ -0,0 +1,459 @@
1
+ """Explicit in-memory/local inputs and loss-aware Arrow IPC capture.
2
+
3
+ Arrow is optional and imported only when a table is accessed. Expected contract
4
+ types never drive casts of observed data.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import tempfile
10
+ from collections.abc import Mapping
11
+ from dataclasses import dataclass
12
+ from datetime import date, datetime
13
+ from decimal import Decimal
14
+ from importlib import import_module
15
+ from pathlib import Path
16
+ from typing import TYPE_CHECKING, Any, Literal, cast
17
+ from uuid import uuid4
18
+
19
+ from equiframe.errors import CapabilityError, InvalidExecutionInput, MissingDependencyError
20
+ from equiframe.execution_types import PhysicalColumn, TableArtifact
21
+ from equiframe.types import ArtifactRef
22
+
23
+ if TYPE_CHECKING:
24
+ import pandas as pd
25
+ import polars as pl
26
+ import pyarrow as pa
27
+
28
+ from equiframe.execution import RunContext
29
+
30
+
31
+ def require_dependency(name: str, extra: str) -> None:
32
+ try:
33
+ import_module(name)
34
+ except ImportError as error:
35
+ raise MissingDependencyError(
36
+ f"optional dependency {name} is unavailable; install 'equiframe[{extra}]' "
37
+ "from the team's wheel/checkout (public PyPI release is pending)"
38
+ ) from error
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class LoadedTable:
43
+ table: pa.Table
44
+ native: object | None = None
45
+ native_types: tuple[tuple[str, str], ...] = ()
46
+ index: pa.Table | None = None
47
+ meaningful_index: bool = False
48
+ origin: ArtifactRef | None = None
49
+ notes: tuple[str, ...] = ()
50
+
51
+
52
+ def check_arrow(table: pa.Table) -> None:
53
+ import pyarrow as pa
54
+
55
+ if not isinstance(table, pa.Table):
56
+ raise InvalidExecutionInput("an Arrow Table is required")
57
+ if len(set(table.column_names)) != table.num_columns or any(not n for n in table.column_names):
58
+ raise CapabilityError("table column names must be nonempty and unique")
59
+ try:
60
+ table.validate(full=True)
61
+ except pa.ArrowException as error:
62
+ raise InvalidExecutionInput(
63
+ "Arrow table violates physical type/value invariants"
64
+ ) from error
65
+ for field in table.schema:
66
+ dtype = field.type
67
+ if not (
68
+ pa.types.is_null(dtype)
69
+ or pa.types.is_boolean(dtype)
70
+ or pa.types.is_integer(dtype)
71
+ or pa.types.is_string(dtype)
72
+ or pa.types.is_large_string(dtype)
73
+ or pa.types.is_string_view(dtype)
74
+ or (pa.types.is_floating(dtype) and dtype.bit_width in (32, 64))
75
+ or pa.types.is_decimal128(dtype)
76
+ or pa.types.is_decimal256(dtype)
77
+ or pa.types.is_date32(dtype)
78
+ or pa.types.is_date64(dtype)
79
+ or pa.types.is_timestamp(dtype)
80
+ ):
81
+ raise CapabilityError(f"unsupported faithful table transport type: {dtype}")
82
+
83
+
84
+ def detached_arrow(table: pa.Table) -> pa.Table:
85
+ import pyarrow as pa
86
+
87
+ check_arrow(table)
88
+ sink = pa.BufferOutputStream()
89
+ with pa.ipc.new_file(sink, table.schema) as writer:
90
+ writer.write_table(table)
91
+ with pa.ipc.open_file(sink.getvalue()) as reader:
92
+ return cast("pa.Table", reader.read_all())
93
+
94
+
95
+ def copy_pandas_frame(frame: pd.DataFrame) -> pd.DataFrame:
96
+ """Isolate both values and index/column buffers, including injected native frames."""
97
+ copied = frame.copy(deep=True)
98
+ copied.index = frame.index.copy(deep=True)
99
+ copied.columns = frame.columns.copy(deep=True)
100
+ return copied
101
+
102
+
103
+ def pandas_capture(frame: pd.DataFrame) -> LoadedTable:
104
+ import pandas as pd
105
+ import pyarrow as pa
106
+
107
+ if not isinstance(frame, pd.DataFrame):
108
+ raise InvalidExecutionInput("Pandas transformation/input must produce a DataFrame")
109
+ if isinstance(frame.columns, pd.CategoricalIndex):
110
+ raise CapabilityError("categorical Pandas column labels cannot be captured faithfully")
111
+ if (
112
+ not all(isinstance(name, str) and name for name in frame.columns)
113
+ or not frame.columns.is_unique
114
+ ):
115
+ raise CapabilityError("Pandas column labels must be unique nonempty strings")
116
+ if frame.shape[1] == 0 and frame.shape[0] != 0:
117
+ raise CapabilityError(
118
+ "Pandas tables with rows but zero columns cannot be captured faithfully"
119
+ )
120
+ notes = [
121
+ "Pandas missing sentinels already conflated by input construction cannot be recovered."
122
+ ]
123
+
124
+ def array_of(series: pd.Series[Any], name: str) -> pa.Array | pa.ChunkedArray:
125
+ dtype = series.dtype
126
+ if isinstance(dtype, pd.CategoricalDtype):
127
+ raise CapabilityError("Pandas categories/ordering cannot be captured as scalar labels")
128
+ if isinstance(dtype, pd.ArrowDtype):
129
+ if pa.types.is_timestamp(dtype.pyarrow_dtype):
130
+ notes.append(
131
+ f"{name}: Arrow zone metadata; original offset/provider version absent."
132
+ )
133
+ return pa.array(series.array, from_pandas=False)
134
+ if pd.api.types.is_datetime64_any_dtype(dtype):
135
+ tzinfo = getattr(dtype, "tz", None)
136
+ provider = type(tzinfo).__module__ + "." + type(tzinfo).__qualname__
137
+ notes.append(f"{name}: input tzinfo provider={provider}; IANA release not exposed.")
138
+ return pa.array(series, from_pandas=True)
139
+ if (
140
+ pd.api.types.is_integer_dtype(dtype)
141
+ or pd.api.types.is_bool_dtype(dtype)
142
+ or pd.api.types.is_float_dtype(dtype)
143
+ or isinstance(dtype, pd.StringDtype)
144
+ ):
145
+ if pd.api.types.is_extension_array_dtype(dtype):
146
+ # Extension arrays expose physical widths and their independent validity mask.
147
+ return pa.array(series.array, from_pandas=False)
148
+ if pd.api.types.is_float_dtype(dtype):
149
+ notes.append(
150
+ f"{name}: NumPy float has no independent NULL mask; observed NaNs retained."
151
+ )
152
+ return pa.array(series.to_numpy(copy=True), from_pandas=False)
153
+ if not pd.api.types.is_object_dtype(dtype):
154
+ raise CapabilityError("unsupported native Pandas dtype for faithful scalar transport")
155
+ values = [None if value is pd.NA or value is pd.NaT else value for value in series]
156
+ if any(isinstance(v, datetime) for v in values if v is not None):
157
+ raise CapabilityError(
158
+ "object timestamps need a temporal/Arrow dtype to preserve precision"
159
+ )
160
+ if any(
161
+ v is not None and not isinstance(v, (str, bool, int, float, Decimal, date))
162
+ for v in values
163
+ ):
164
+ raise CapabilityError(
165
+ "Pandas object columns support only immutable scalar transport values"
166
+ )
167
+ # Inferring a common float/decimal dtype would erase per-cell numeric kinds.
168
+ kinds = {type(v) for v in values if v is not None}
169
+ if len(kinds) > 1:
170
+ raise CapabilityError(
171
+ "heterogeneous Pandas object scalars require an explicit faithful dtype"
172
+ )
173
+ decimals = [v for v in values if isinstance(v, Decimal)]
174
+ if decimals:
175
+ if any(not v.is_finite() or (v.is_zero() and v.is_signed()) for v in decimals):
176
+ raise CapabilityError(
177
+ "Arrow decimal cannot retain nonfinite or signed-zero Decimal"
178
+ )
179
+ if len({v.as_tuple().exponent for v in decimals}) != 1:
180
+ raise CapabilityError(
181
+ "Arrow fixed-scale decimal cannot retain per-cell exponent differences"
182
+ )
183
+ try:
184
+ return pa.array(values, from_pandas=False)
185
+ except (pa.ArrowException, TypeError, ValueError, OverflowError) as error:
186
+ raise CapabilityError(
187
+ "Pandas values cannot be captured faithfully as Arrow scalars"
188
+ ) from error
189
+
190
+ table = pa.Table.from_arrays(
191
+ [array_of(frame[n], n) for n in frame.columns], names=list(frame.columns)
192
+ )
193
+ check_arrow(table)
194
+ index_frame = frame.index.to_frame(index=False)
195
+ index_frame.columns = [f"level_{i}" for i in range(len(index_frame.columns))]
196
+ index = pa.Table.from_arrays(
197
+ [array_of(index_frame[n], "index." + n) for n in index_frame.columns],
198
+ names=list(index_frame.columns),
199
+ )
200
+ check_arrow(index)
201
+ notes.append("Pandas index stored separately; names=" + repr(tuple(frame.index.names)))
202
+ notes.append("Pandas index dtypes=" + repr(tuple(str(t) for t in index_frame.dtypes)))
203
+ notes.append("Pandas column axis name=" + repr(frame.columns.name))
204
+ meaningful = not (
205
+ isinstance(frame.index, pd.RangeIndex)
206
+ and frame.index.start == 0
207
+ and frame.index.step == 1
208
+ and frame.index.name is None
209
+ )
210
+ return LoadedTable(
211
+ table=table,
212
+ native=frame,
213
+ native_types=tuple((str(n), str(t)) for n, t in frame.dtypes.items()),
214
+ index=index,
215
+ meaningful_index=meaningful,
216
+ notes=tuple(notes),
217
+ )
218
+
219
+
220
+ def capture_artifact(
221
+ name: str, loaded: LoadedTable, context: RunContext, *, output: bool = False
222
+ ) -> TableArtifact:
223
+ import pyarrow as pa
224
+ import pyarrow.compute as pc
225
+
226
+ check_arrow(loaded.table)
227
+ context.check_table(loaded.table, output=output, enforce_rows=False)
228
+ if loaded.index is not None:
229
+ check_arrow(loaded.index)
230
+ if loaded.index.num_rows != loaded.table.num_rows:
231
+ raise InvalidExecutionInput("index row count must match table row count")
232
+ context.check_table(loaded.index, output=output, enforce_rows=False)
233
+
234
+ def persist(table: pa.Table) -> ArtifactRef:
235
+ path = context.directory / (uuid4().hex + ".arrow")
236
+ try:
237
+ with pa.OSFile(str(path), "wb") as sink, pa.ipc.new_file(sink, table.schema) as writer:
238
+ writer.write_table(table)
239
+ return context.store.put(path, media_type="application/vnd.apache.arrow.file")
240
+ finally:
241
+ path.unlink(missing_ok=True)
242
+
243
+ def describe(
244
+ table: pa.Table, native: tuple[tuple[str, str], ...]
245
+ ) -> tuple[PhysicalColumn, ...]:
246
+ types = dict(native)
247
+ return tuple(
248
+ PhysicalColumn(
249
+ name=field.name,
250
+ engine_type=types.get(field.name, str(field.type)),
251
+ arrow_type=str(field.type),
252
+ null_count=table.column(field.name).null_count,
253
+ nan_count=(
254
+ int(pc.sum(pc.is_nan(table.column(field.name))).as_py() or 0)
255
+ if pa.types.is_floating(field.type)
256
+ else 0
257
+ ),
258
+ )
259
+ for field in table.schema
260
+ )
261
+
262
+ from pydantic import TypeAdapter
263
+
264
+ from equiframe._base import Text
265
+
266
+ # Validate all metadata before publishing bytes. A successful data publication
267
+ # is retained in the failed result even if the separate index publication fails.
268
+ notes = TypeAdapter(tuple[Text, ...]).validate_python(loaded.notes, strict=True)
269
+ origin = ArtifactRef.model_validate(loaded.origin) if loaded.origin is not None else None
270
+ columns = describe(loaded.table, loaded.native_types)
271
+ index_columns = describe(loaded.index, ()) if loaded.index is not None else ()
272
+ artifact = TableArtifact(
273
+ name=name,
274
+ data=persist(loaded.table),
275
+ origin=origin,
276
+ row_count=loaded.table.num_rows,
277
+ columns=columns,
278
+ notes=notes
279
+ + (
280
+ ("Index capture incomplete; see execution failure.",)
281
+ if loaded.index is not None
282
+ else ()
283
+ ),
284
+ )
285
+ if output:
286
+ context.captured_output = artifact
287
+ else:
288
+ context.captured_inputs.append(artifact)
289
+ if loaded.index is not None:
290
+ index_ref = persist(loaded.index)
291
+ artifact = TableArtifact(
292
+ name=name,
293
+ data=artifact.data,
294
+ origin=origin,
295
+ row_count=loaded.table.num_rows,
296
+ columns=columns,
297
+ index=index_ref,
298
+ index_columns=index_columns,
299
+ notes=notes,
300
+ )
301
+ if output:
302
+ context.captured_output = artifact
303
+ else:
304
+ context.captured_inputs[-1] = artifact
305
+ return artifact
306
+
307
+
308
+ @dataclass(frozen=True)
309
+ class ArrowInput:
310
+ table: pa.Table
311
+
312
+ def load(self, context: RunContext) -> LoadedTable:
313
+ require_dependency("pyarrow", "arrow")
314
+ context.check_table(self.table, output=False)
315
+ return LoadedTable(
316
+ detached_arrow(self.table),
317
+ notes=(
318
+ "Arrow retains distinctions already encoded in types, values and validity masks.",
319
+ ),
320
+ )
321
+
322
+
323
+ @dataclass(frozen=True)
324
+ class PandasInput:
325
+ frame: pd.DataFrame
326
+ index_policy: Literal["preserve", "columns", "discard"] = "preserve"
327
+
328
+ def load(self, context: RunContext) -> LoadedTable:
329
+ require_dependency("pandas", "pandas")
330
+ require_dependency("pyarrow", "pandas")
331
+ import pandas as pd
332
+
333
+ if not isinstance(self.frame, pd.DataFrame):
334
+ raise InvalidExecutionInput("PandasInput requires a DataFrame")
335
+ if self.index_policy not in ("preserve", "columns", "discard"):
336
+ raise InvalidExecutionInput("unknown Pandas index policy")
337
+ # Supported cells are immutable scalars; deep=True then isolates container mutation.
338
+ copied = copy_pandas_frame(self.frame)
339
+ if self.index_policy == "columns":
340
+ copied = copied.reset_index()
341
+ elif self.index_policy == "discard":
342
+ copied = copied.reset_index(drop=True)
343
+ loaded = pandas_capture(copied)
344
+ context.check_table(loaded.table, output=False)
345
+ return loaded
346
+
347
+
348
+ @dataclass(frozen=True)
349
+ class PolarsInput:
350
+ frame: pl.DataFrame | pl.LazyFrame
351
+
352
+ def load(self, context: RunContext) -> LoadedTable:
353
+ require_dependency("polars", "polars")
354
+ require_dependency("pyarrow", "polars")
355
+ import polars as pl
356
+
357
+ if not isinstance(self.frame, (pl.DataFrame, pl.LazyFrame)):
358
+ raise InvalidExecutionInput("PolarsInput requires a DataFrame or LazyFrame")
359
+ with context.measure("input_materialization"):
360
+ frame = (
361
+ self.frame.collect() if isinstance(self.frame, pl.LazyFrame) else self.frame.clone()
362
+ )
363
+ table = frame.to_arrow()
364
+ if table.shape != frame.shape:
365
+ raise CapabilityError("Polars input capture changed table dimensions")
366
+ check_arrow(table)
367
+ context.check_table(table, output=False)
368
+ return LoadedTable(
369
+ table, native=frame, native_types=tuple((n, str(t)) for n, t in frame.schema.items())
370
+ )
371
+
372
+
373
+ @dataclass(frozen=True)
374
+ class ArtifactInput:
375
+ ref: ArtifactRef
376
+
377
+ def load(self, context: RunContext) -> LoadedTable:
378
+ # Reject remote URIs even if the optional transport is not installed.
379
+ from equiframe.local import local_path
380
+
381
+ local_path(self.ref)
382
+ if self.ref.format not in ("arrow", "parquet"):
383
+ raise CapabilityError("tabular artifact format must be Arrow IPC file or Parquet")
384
+ require_dependency("pyarrow", "arrow")
385
+ import pyarrow as pa
386
+ import pyarrow.parquet as pq
387
+
388
+ if (
389
+ context.plan.options.max_transport_bytes is not None
390
+ and self.ref.size_bytes > context.plan.options.max_transport_bytes
391
+ ):
392
+ from equiframe.errors import ResourceLimitError
393
+
394
+ raise ResourceLimitError("input artifact exceeds transport byte budget")
395
+ path = context.directory / ("input_" + uuid4().hex + "." + self.ref.format)
396
+ try:
397
+ context.store.get(self.ref, path)
398
+ if self.ref.format == "parquet":
399
+ table = pq.read_table(path)
400
+ else:
401
+ with path.open("rb") as source_file, pa.ipc.open_file(source_file) as reader:
402
+ table = reader.read_all()
403
+ check_arrow(table)
404
+ context.check_table(table, output=False)
405
+ return LoadedTable(
406
+ table, origin=self.ref, notes=("Source file SHA-256 and size verified.",)
407
+ )
408
+ finally:
409
+ path.unlink(missing_ok=True)
410
+
411
+
412
+ if TYPE_CHECKING:
413
+ from typing import Protocol
414
+
415
+ class TableInput(Protocol):
416
+ def load(self, context: RunContext) -> LoadedTable: ...
417
+
418
+
419
+ class LocalDataSource:
420
+ def __init__(self, inputs: Mapping[str, TableInput | ArtifactRef]) -> None:
421
+ self._inputs = dict(inputs)
422
+
423
+ @property
424
+ def table_names(self) -> tuple[str, ...]:
425
+ return tuple(self._inputs)
426
+
427
+ def resolve(self, name: str, *, context: RunContext) -> LoadedTable:
428
+ try:
429
+ value = self._inputs[name]
430
+ except KeyError:
431
+ raise InvalidExecutionInput("input table is unavailable: " + name) from None
432
+ return (
433
+ ArtifactInput(value).load(context)
434
+ if isinstance(value, ArtifactRef)
435
+ else value.load(context)
436
+ )
437
+
438
+
439
+ def read_table(ref: ArtifactRef) -> pa.Table:
440
+ """Read a local Arrow IPC/Parquet reference after verifying its bytes and size."""
441
+ from equiframe.local import LocalArtifactStore, local_path
442
+
443
+ local_path(ref)
444
+ if ref.format not in ("arrow", "parquet"):
445
+ raise CapabilityError("tabular artifact format must be Arrow IPC file or Parquet")
446
+ require_dependency("pyarrow", "arrow")
447
+ import pyarrow as pa
448
+ import pyarrow.parquet as pq
449
+
450
+ with tempfile.TemporaryDirectory(prefix="equiframe-read-") as directory:
451
+ path = Path(directory) / ("input." + ref.format)
452
+ LocalArtifactStore(Path(directory)).get(ref, path)
453
+ if ref.format == "parquet":
454
+ table = pq.read_table(path)
455
+ else:
456
+ with path.open("rb") as source_file, pa.ipc.open_file(source_file) as reader:
457
+ table = reader.read_all()
458
+ check_arrow(table)
459
+ return cast("pa.Table", table)
equiframe/types.py ADDED
@@ -0,0 +1,71 @@
1
+ """Public states and minimal immutable artifact metadata (no execution results yet)."""
2
+
3
+ import re
4
+ from typing import Annotated, Literal
5
+ from urllib.parse import urlsplit
6
+
7
+ from pydantic import Field, model_validator
8
+ from typing_extensions import Self
9
+
10
+ from equiframe._base import FrozenModel, Identifier, NonNegativeInt, PositiveInt, Text, fail
11
+
12
+ ExecutionState = Literal[
13
+ "completed", "invalid_input", "failed", "timeout", "resource_exhausted", "cancelled"
14
+ ]
15
+ DecisionState = Literal["alert", "no_alert", "not_evaluable"]
16
+ ComparisonState = Literal["equivalent", "different", "not_evaluable"]
17
+ OracleState = Literal["correct", "divergent", "pending", "not_evaluable"]
18
+ FitState = Literal["ready", "not_estimable", "incompatible"]
19
+ JobState = Literal["queued", "running", "succeeded", "failed", "cancelled"]
20
+ Sha256 = Annotated[str, Field(pattern=r"^[0-9a-f]{64}$")]
21
+
22
+
23
+ class SchemaRef(FrozenModel):
24
+ schema_id: Identifier
25
+ revision: PositiveInt
26
+ sha256: Sha256
27
+
28
+
29
+ class ArtifactRef(FrozenModel):
30
+ schema_version: Literal["1.0"]
31
+ uri: Text
32
+ sha256: Sha256
33
+ format: Literal["json", "jsonl", "arrow", "parquet"]
34
+ media_type: Text
35
+ size_bytes: NonNegativeInt
36
+ schema_ref: SchemaRef | None = None
37
+
38
+ @model_validator(mode="after")
39
+ def reference_without_credentials(self) -> Self:
40
+ try:
41
+ parts = urlsplit(self.uri)
42
+ host = parts.hostname
43
+ port = parts.port
44
+ except ValueError:
45
+ fail("artifact_uri", "invalid artifact URI", "uri")
46
+ if parts.scheme not in ("https", "file", "s3", "gs"):
47
+ fail("artifact_uri", "allowed URI schemes: https, file, s3, gs", "uri")
48
+ if (
49
+ parts.username is not None
50
+ or parts.password is not None
51
+ or parts.query
52
+ or parts.fragment
53
+ ):
54
+ fail(
55
+ "artifact_uri_secret",
56
+ "artifact URI must not contain credentials, query or fragment",
57
+ "uri",
58
+ )
59
+ if re.search(r"[\x00-\x20\x7f]", self.uri):
60
+ fail(
61
+ "artifact_uri", "artifact URI must not contain whitespace/control characters", "uri"
62
+ )
63
+ if "\\" in self.uri or re.search(r"%(?![0-9a-fA-F]{2})", self.uri):
64
+ fail("artifact_uri", "artifact URI has an invalid separator or percent escape", "uri")
65
+ if parts.scheme != "https" and port is not None:
66
+ fail("artifact_uri", "only HTTPS artifact URIs may declare a port", "uri")
67
+ if parts.scheme != "file" and not host:
68
+ fail("artifact_uri", "remote artifact URI requires a host/bucket", "uri")
69
+ if parts.scheme == "file" and not parts.path.startswith("/"):
70
+ fail("artifact_uri", "file URI requires an absolute path", "uri")
71
+ return self