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/sources.py ADDED
@@ -0,0 +1,283 @@
1
+ """Explicit bounded inputs for trusted byte streams and read-only SQLite queries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import re
8
+ import sqlite3
9
+ from collections.abc import Callable, Iterable, Mapping
10
+ from contextlib import AbstractContextManager
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING, BinaryIO
14
+ from uuid import uuid4
15
+
16
+ from pydantic import TypeAdapter
17
+
18
+ from equiframe._base import Identifier
19
+ from equiframe.contracts import DecimalType
20
+ from equiframe.errors import (
21
+ ArtifactIntegrityError,
22
+ ExecutionCancelled,
23
+ ExecutionTimeout,
24
+ InvalidExecutionInput,
25
+ ResourceLimitError,
26
+ )
27
+ from equiframe.ingestion import FileInput, IngestionSpec
28
+ from equiframe.types import Sha256
29
+
30
+ if TYPE_CHECKING:
31
+ import pyarrow as pa
32
+
33
+ from equiframe.execution import RunContext
34
+ from equiframe.ports import DataSource
35
+ from equiframe.tables import LoadedTable
36
+
37
+
38
+ class CompositeDataSource:
39
+ """Bind logical names to explicitly injected providers and their table names.
40
+
41
+ Composition does not create cross-provider transaction/snapshot consistency.
42
+ Providers own their immutable identity and must return faithful LoadedTables.
43
+ """
44
+
45
+ def __init__(self, bindings: Mapping[str, tuple[DataSource, str]]) -> None:
46
+ self._bindings = dict(bindings)
47
+ for name, (provider, table) in self._bindings.items():
48
+ TypeAdapter(Identifier).validate_python(name, strict=True)
49
+ if table not in provider.table_names:
50
+ raise InvalidExecutionInput("bound provider table is unavailable")
51
+
52
+ @property
53
+ def table_names(self) -> tuple[str, ...]:
54
+ return tuple(self._bindings)
55
+
56
+ def resolve(self, name: str, *, context: RunContext) -> LoadedTable:
57
+ from equiframe.tables import check_arrow
58
+
59
+ context.check()
60
+ try:
61
+ provider, table = self._bindings[name]
62
+ except KeyError:
63
+ raise InvalidExecutionInput("composite input table is unavailable: " + name) from None
64
+ if table not in provider.table_names:
65
+ raise InvalidExecutionInput("bound provider table is no longer available")
66
+ loaded = provider.resolve(table, context=context)
67
+ check_arrow(loaded.table)
68
+ context.check_table(loaded.table, output=False)
69
+ return loaded
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class RecordBatchInput:
74
+ """Consume caller-owned Arrow batches without inference or silent casts.
75
+
76
+ Batch schemas, including metadata, must match the explicit schema exactly.
77
+ Cumulative rows/buffer bytes are checked before final table materialization.
78
+ This bounds admitted data cooperatively, not process RSS or blocked I/O.
79
+ """
80
+
81
+ open_batches: Callable[[], AbstractContextManager[Iterable[pa.RecordBatch]]]
82
+ schema: pa.Schema
83
+
84
+ def load(self, context: RunContext) -> LoadedTable:
85
+ from equiframe.tables import LoadedTable, check_arrow, require_dependency
86
+
87
+ require_dependency("pyarrow", "arrow")
88
+ import pyarrow as pa
89
+
90
+ if not isinstance(self.schema, pa.Schema):
91
+ raise InvalidExecutionInput("record batches require an explicit Arrow schema")
92
+ check_arrow(pa.Table.from_batches([], schema=self.schema))
93
+ batches: list[pa.RecordBatch] = []
94
+ rows = size = 0
95
+ context.check()
96
+ with self.open_batches() as reader:
97
+ for batch in reader:
98
+ context.check()
99
+ if not isinstance(batch, pa.RecordBatch) or not batch.schema.equals(
100
+ self.schema, check_metadata=True
101
+ ):
102
+ raise InvalidExecutionInput("record batch schema differs from declared schema")
103
+ check_arrow(pa.Table.from_batches([batch]))
104
+ if any(
105
+ not field.nullable and batch.column(index).null_count
106
+ for index, field in enumerate(self.schema)
107
+ ):
108
+ raise InvalidExecutionInput("record batch violates nonnullable field")
109
+ rows += batch.num_rows
110
+ size += batch.nbytes
111
+ options = context.plan.options
112
+ if options.max_input_rows is not None and rows > options.max_input_rows:
113
+ raise ResourceLimitError("record batches exceed cumulative row budget")
114
+ if options.max_transport_bytes is not None and size > options.max_transport_bytes:
115
+ raise ResourceLimitError("record batches exceed cumulative Arrow byte budget")
116
+ batches.append(batch)
117
+ table = pa.Table.from_batches(batches, schema=self.schema)
118
+ context.check_table(table, output=False)
119
+ return LoadedTable(table, notes=("explicit_schema_record_batches",))
120
+
121
+
122
+ @dataclass(frozen=True)
123
+ class StreamInput:
124
+ """Use a caller-owned API/file response; the caller owns authentication and timeouts.
125
+
126
+ No URL is opened implicitly. The context manager closes the stream on every
127
+ outcome. Reads, parsing and transport have cooperative, explicit budgets.
128
+ The original response and conversion receipts are retained by ingestion.
129
+ """
130
+
131
+ open_stream: Callable[[], AbstractContextManager[BinaryIO]]
132
+ spec: IngestionSpec
133
+ expected_sha256: str | None = None
134
+ batch_rows: int = 4096
135
+
136
+ def load(self, context: RunContext) -> LoadedTable:
137
+ spec = IngestionSpec.model_validate(self.spec)
138
+ expected = (
139
+ TypeAdapter(Sha256).validate_python(self.expected_sha256, strict=True)
140
+ if self.expected_sha256 is not None
141
+ else None
142
+ )
143
+ path = context.directory / ("response_" + uuid4().hex + "." + spec.format)
144
+ digest = hashlib.sha256()
145
+ size = 0
146
+ try:
147
+ context.check()
148
+ with self.open_stream() as source, path.open("xb") as target:
149
+ while True:
150
+ context.check()
151
+ block = source.read(64 * 1024)
152
+ if not isinstance(block, bytes) or len(block) > 64 * 1024:
153
+ raise InvalidExecutionInput("stream must honor bounded binary reads")
154
+ if not block:
155
+ break
156
+ size += len(block)
157
+ if size > spec.max_source_bytes:
158
+ raise ResourceLimitError("response exceeds ingestion byte budget")
159
+ digest.update(block)
160
+ target.write(block)
161
+ if expected is not None and digest.hexdigest() != expected:
162
+ raise ArtifactIntegrityError("response differs from the declared SHA-256")
163
+ return FileInput(path, spec, self.batch_rows).load(context)
164
+ finally:
165
+ path.unlink(missing_ok=True)
166
+
167
+
168
+ @dataclass(frozen=True)
169
+ class SQLiteInput:
170
+ """Read a trusted parameterized query in one owned read-only transaction.
171
+
172
+ Explicit JSONL field mappings preserve textual identifiers and Decimal TEXT.
173
+ SQLite REAL is rejected for Decimal: a binary float cannot recover the
174
+ original amount. This local input neither manages a shared DW nor promises
175
+ another SQL provider's isolation or type mappings.
176
+ """
177
+
178
+ path: Path
179
+ query: str
180
+ spec: IngestionSpec
181
+ parameters: tuple[str | int | float | bytes | None, ...] = ()
182
+ batch_rows: int = 4096
183
+
184
+ def load(self, context: RunContext) -> LoadedTable:
185
+ spec = IngestionSpec.model_validate(self.spec)
186
+ if (
187
+ spec.format != "jsonl"
188
+ or spec.records_path
189
+ or any(field.scope != "record" or len(field.path) != 1 for field in spec.fields)
190
+ ):
191
+ raise InvalidExecutionInput("SQLite requires direct JSONL record field mappings")
192
+ if not re.match(r"\s*(SELECT|WITH)\b", self.query, re.IGNORECASE):
193
+ raise InvalidExecutionInput("SQLite input requires one trusted SELECT/WITH query")
194
+ if (
195
+ isinstance(self.batch_rows, bool)
196
+ or not isinstance(self.batch_rows, int)
197
+ or self.batch_rows <= 0
198
+ ):
199
+ raise ValueError("batch_rows must be a positive integer")
200
+ path = self.path.resolve()
201
+ if not path.is_file():
202
+ raise InvalidExecutionInput("SQLite input must name an existing local database")
203
+ target = context.directory / ("sqlite_" + uuid4().hex + ".jsonl")
204
+ context.check()
205
+ connection = sqlite3.connect(path.as_uri() + "?mode=ro", uri=True, timeout=5)
206
+ decimal_fields = {
207
+ field.path[0]
208
+ for field in spec.fields
209
+ if isinstance(field.column.logical_type, DecimalType)
210
+ }
211
+ records = size = 0
212
+ interruption: ExecutionCancelled | ExecutionTimeout | None = None
213
+
214
+ def progress() -> int:
215
+ nonlocal interruption
216
+ try:
217
+ context.check()
218
+ except (ExecutionCancelled, ExecutionTimeout) as error:
219
+ interruption = error
220
+ return 1
221
+ return 0
222
+
223
+ try:
224
+ connection.set_progress_handler(progress, 1000)
225
+ # Some Python builds omit extension-loading support entirely.
226
+ # Fresh SQLite connections keep it disabled; never require that API.
227
+ if hasattr(connection, "enable_load_extension"):
228
+ connection.enable_load_extension(False)
229
+ connection.execute("PRAGMA query_only = ON")
230
+ connection.execute("BEGIN")
231
+ cursor = connection.execute(self.query, self.parameters)
232
+ names = tuple(item[0] for item in cursor.description or ())
233
+ if not names or any(not name for name in names) or len(names) != len(set(names)):
234
+ raise InvalidExecutionInput("SQLite query columns must be unique and nonempty")
235
+ with target.open("xb") as output:
236
+ while True:
237
+ context.check()
238
+ batch = cursor.fetchmany(self.batch_rows)
239
+ if not batch:
240
+ break
241
+ for row in batch:
242
+ context.check()
243
+ records += 1
244
+ maximum = context.plan.options.max_input_rows
245
+ if records > spec.max_records or (
246
+ maximum is not None and records > maximum
247
+ ):
248
+ raise ResourceLimitError("SQLite query exceeds input row budget")
249
+ record = dict(zip(names, row, strict=True))
250
+ if any(
251
+ record.get(name) is not None and type(record[name]) is not str
252
+ for name in decimal_fields
253
+ ):
254
+ raise InvalidExecutionInput(
255
+ "SQLite Decimal requires exact TEXT, never REAL"
256
+ )
257
+ if any(isinstance(value, bytes) for value in row):
258
+ raise InvalidExecutionInput(
259
+ "SQLite BLOB has no declared scalar JSON mapping"
260
+ )
261
+ try:
262
+ raw = (
263
+ json.dumps(record, ensure_ascii=False, allow_nan=False) + "\n"
264
+ ).encode("utf-8")
265
+ except (ValueError, TypeError) as error:
266
+ raise InvalidExecutionInput(
267
+ "SQLite returned an unsupported scalar"
268
+ ) from error
269
+ size += len(raw)
270
+ if size > spec.max_source_bytes:
271
+ raise ResourceLimitError(
272
+ "SQLite response exceeds ingestion byte budget"
273
+ )
274
+ output.write(raw)
275
+ loaded = FileInput(target, spec, self.batch_rows).load(context)
276
+ return loaded
277
+ except sqlite3.OperationalError:
278
+ if interruption is not None:
279
+ raise interruption from None
280
+ raise
281
+ finally:
282
+ connection.close()
283
+ target.unlink(missing_ok=True)