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,410 @@
1
+ """Explicit, single-consumption DuckDB capture into a detached Arrow artifact.
2
+
3
+ This is a trusted-query boundary, not a SQL sandbox or a distributed snapshot.
4
+ The comparison kernel neither owns a connection nor executes this query again.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import re
12
+ import sys
13
+ import tempfile
14
+ import time
15
+ import weakref
16
+ from collections.abc import Callable, Mapping
17
+ from datetime import datetime
18
+ from datetime import timezone as _timezone
19
+ from importlib.metadata import version
20
+ from pathlib import Path
21
+ from threading import Event, Lock, Thread
22
+ from typing import TYPE_CHECKING, Annotated
23
+ from uuid import uuid4
24
+
25
+ from pydantic import Field, TypeAdapter
26
+
27
+ from equiframe._base import FrozenModel, NonNegativeInt, PositiveInt, Text
28
+ from equiframe._comparison_io import file_ref
29
+ from equiframe._duckdb_transport import (
30
+ duckdb_arrow_schema,
31
+ duckdb_parameters,
32
+ verify_duckdb_table,
33
+ )
34
+ from equiframe._regular_io import local_output_directory
35
+ from equiframe.errors import (
36
+ CapabilityError,
37
+ ExecutionCancelled,
38
+ ExecutionTimeout,
39
+ InvalidExecutionInput,
40
+ NativeEngineError,
41
+ ResourceLimitError,
42
+ )
43
+ from equiframe.execution import CancellationToken
44
+ from equiframe.execution_types import PhysicalColumn, TableArtifact
45
+ from equiframe.tables import require_dependency
46
+
47
+ UTC = _timezone.utc
48
+
49
+ if TYPE_CHECKING:
50
+ import duckdb
51
+ import pyarrow as pa
52
+
53
+
54
+ class DuckDBCaptureOptions(FrozenModel):
55
+ """Cooperative capture limits; not an engine or whole-process RSS quota.
56
+
57
+ Limits apply to rows, each Arrow batch and the final serialized IPC file.
58
+ A native fetch/allocation can exceed a limit before the next checkpoint.
59
+ Borrowed sessions keep their own memory/thread/spill/timezone settings.
60
+ """
61
+
62
+ batch_rows: PositiveInt = 4096
63
+ max_rows: NonNegativeInt = 1_000_000
64
+ max_batch_bytes: PositiveInt = 64 * 1024 * 1024
65
+ max_bytes: PositiveInt = 256 * 1024 * 1024
66
+ timeout_seconds: Annotated[float, Field(gt=0)] | None = None
67
+
68
+
69
+ # Locks coordinate only captures through this module, never unrelated caller SQL.
70
+ # Weak keys do not retain a caller's connection after capture has returned.
71
+ _LOCKS: weakref.WeakKeyDictionary[object, Lock] = weakref.WeakKeyDictionary()
72
+ _LOCKS_GUARD = Lock()
73
+ _SETTINGS = (
74
+ "TimeZone",
75
+ "threads",
76
+ "default_collation",
77
+ "default_order",
78
+ "default_null_order",
79
+ "preserve_insertion_order",
80
+ "memory_limit",
81
+ "max_temp_directory_size",
82
+ "enable_external_access",
83
+ "autoinstall_known_extensions",
84
+ "autoload_known_extensions",
85
+ )
86
+
87
+
88
+ def _parameters(parameters: Mapping[str, object] | None) -> dict[str, object]:
89
+ values = dict(parameters) if parameters is not None else {}
90
+ for name in values:
91
+ if type(name) is not str or re.fullmatch(r"[A-Za-z][A-Za-z0-9_]*", name) is None:
92
+ raise InvalidExecutionInput("capture parameters require ASCII identifier names")
93
+ return values
94
+
95
+
96
+ def capture_duckdb(
97
+ sql: str,
98
+ *,
99
+ work_dir: Path,
100
+ name: str = "result",
101
+ connection: duckdb.DuckDBPyConnection | None = None,
102
+ exclusive_connection: bool = False,
103
+ parameters: Mapping[str, object] | None = None,
104
+ options: DuckDBCaptureOptions | None = None,
105
+ cancellation: CancellationToken | None = None,
106
+ ) -> TableArtifact:
107
+ """Execute one trusted SELECT/WITH and persist a reusable, complete snapshot.
108
+
109
+ A borrowed connection requires ``exclusive_connection=True``: the caller
110
+ promises no outstanding result/reader, concurrent SQL or mutable external
111
+ source changes during capture. The API may interrupt that connection and
112
+ consumes its current result slot. It never closes it, commits/rolls back,
113
+ changes session settings, or creates/removes registered tables. A query
114
+ error can abort the caller's transaction; recovery belongs to the caller.
115
+
116
+ Without a connection, create/close a private in-memory session: UTC, one
117
+ thread, 512MB engine-managed memory, no spill, external access, extension
118
+ autoload/install or Python replacement scans. No global DuckDB connection.
119
+ Borrowed SQL has the caller's capabilities, including external effects:
120
+ SELECT and bound parameters do not make untrusted SQL safe. Borrowed sessions
121
+ must already disable extension autoinstall and autoload; capture will reject
122
+ rather than change those settings for the caller.
123
+
124
+ Raw relations, already-executed results and readers are intentionally not
125
+ accepted: ownership, previous consumption and pre-Arrow types are unknown.
126
+ The new reader is consumed once, closed, and never escapes this call. No
127
+ pandas conversion, schema inference, output casts or SQL replay occurs.
128
+ Decimal parameters are explicitly typed from exact text to avoid native
129
+ inference falling back to float; precision and scale are retained.
130
+
131
+ Returns an existing TableArtifact accepted by compare/diff with an explicit
132
+ contract whose output name matches ``name``. The new capture directory has
133
+ snapshot.arrow and capture.json (the returned TableArtifact); only complete
134
+ captures are published. Exceptions leave no success artifact. Callers own
135
+ retention; capture contains their data. Notes record fixed safe session
136
+ settings and a capture interval, not SQL/parameters or a source-version
137
+ attestation. A snapshot freezes captured bytes, not external systems.
138
+ """
139
+ if type(sql) is not str or not sql.strip():
140
+ raise InvalidExecutionInput("capture requires a nonempty trusted SQL string")
141
+ checked = DuckDBCaptureOptions.model_validate(
142
+ DuckDBCaptureOptions() if options is None else options
143
+ )
144
+ name = TypeAdapter(Text).validate_python(name)
145
+ if type(exclusive_connection) is not bool:
146
+ raise InvalidExecutionInput("exclusive_connection must be a boolean")
147
+ if connection is not None and not exclusive_connection:
148
+ raise InvalidExecutionInput(
149
+ "borrowed capture requires exclusive_connection=True and no outstanding reader"
150
+ )
151
+ work_dir = local_output_directory(work_dir)
152
+ values = _parameters(parameters)
153
+ signal = cancellation if cancellation is not None else CancellationToken()
154
+ started = datetime.now(UTC).isoformat()
155
+ tick = time.monotonic()
156
+
157
+ def check() -> None:
158
+ if signal.cancelled:
159
+ raise ExecutionCancelled("DuckDB capture cancelled; no complete snapshot published")
160
+ if (
161
+ checked.timeout_seconds is not None
162
+ and time.monotonic() - tick >= checked.timeout_seconds
163
+ ):
164
+ raise ExecutionTimeout("DuckDB capture timed out; no complete snapshot published")
165
+
166
+ check()
167
+ require_dependency("duckdb", "duckdb")
168
+ require_dependency("pyarrow", "duckdb")
169
+ import duckdb
170
+
171
+ values = duckdb_parameters(values)
172
+ owned = connection is None
173
+ if connection is not None and type(connection) is not duckdb.DuckDBPyConnection:
174
+ raise InvalidExecutionInput("capture requires an explicit DuckDBPyConnection")
175
+ session = connection
176
+ lock: Lock | None = None
177
+ stopped = Event()
178
+ watcher: Thread | None = None
179
+ relation: duckdb.DuckDBPyRelation | None = None
180
+ reader: pa.RecordBatchReader | None = None
181
+ completed = False
182
+ try:
183
+ if session is None:
184
+ session = duckdb.connect(
185
+ ":memory:",
186
+ config={
187
+ "threads": 1,
188
+ "memory_limit": "512MB",
189
+ "max_temp_directory_size": "0B",
190
+ "enable_external_access": False,
191
+ "autoinstall_known_extensions": False,
192
+ "autoload_known_extensions": False,
193
+ "python_enable_replacements": False,
194
+ },
195
+ )
196
+ session.execute("SET TimeZone = 'UTC'")
197
+ with _LOCKS_GUARD:
198
+ lock = _LOCKS.setdefault(session, Lock())
199
+ if not lock.acquire(blocking=False):
200
+ lock = None
201
+ raise InvalidExecutionInput("this connection already has an active DuckDB capture")
202
+ check()
203
+
204
+ def watch() -> None:
205
+ if not (session is not None):
206
+ raise AssertionError("Internal invariant check failed.")
207
+ while not stopped.wait(0.01):
208
+ if signal.cancelled or (
209
+ checked.timeout_seconds is not None
210
+ and time.monotonic() - tick >= checked.timeout_seconds
211
+ ):
212
+ # Repeat until stopped to cover the race with starting native execution.
213
+ session.interrupt()
214
+
215
+ watcher = Thread(target=watch, name="equiframe-duckdb-capture", daemon=False)
216
+ watcher.start()
217
+ statements = session.extract_statements(sql)
218
+ if len(statements) != 1 or statements[0].type != duckdb.StatementType.SELECT:
219
+ raise InvalidExecutionInput("DuckDB capture accepts exactly one SELECT/WITH query")
220
+ if set(statements[0].named_parameters) != set(values):
221
+ raise InvalidExecutionInput("SQL parameters must match capture parameter names exactly")
222
+ settings_query = "SELECT " + ", ".join(f"current_setting('{key}')" for key in _SETTINGS)
223
+ row = session.execute(settings_query).fetchone()
224
+ if row is None:
225
+ raise NativeEngineError("DuckDB did not return the required session description")
226
+ settings = dict(zip(_SETTINGS, row, strict=True))
227
+ if settings["autoinstall_known_extensions"] or settings["autoload_known_extensions"]:
228
+ raise CapabilityError(
229
+ "DuckDB capture requires extension autoinstall/autoload already disabled; "
230
+ "configure your own connection explicitly before capture"
231
+ )
232
+ timezone = str(settings["TimeZone"])
233
+ # DuckDB can execute/materialize parameterized SQL here, before Arrow
234
+ # batch limits apply. Unparameterized binding can also evaluate constants.
235
+ relation = session.sql(sql, params=values or None)
236
+ native_types = tuple(zip(relation.columns, map(str, relation.types), strict=True))
237
+ schema = duckdb_arrow_schema(native_types, timezone)
238
+ check()
239
+ reader = relation.to_arrow_reader(checked.batch_rows)
240
+ if not reader.schema.equals(schema, check_metadata=True):
241
+ raise CapabilityError("DuckDB reader schema differs from its admitted native types")
242
+ work_dir.mkdir(parents=True, exist_ok=True)
243
+ final = work_dir / ("duckdb-" + uuid4().hex)
244
+ with tempfile.TemporaryDirectory(prefix=".duckdb-capture-", dir=work_dir) as temporary:
245
+ stage = Path(temporary)
246
+ path = stage / "snapshot.arrow"
247
+ rows, nulls, nans = _write_reader(reader, path, native_types, timezone, checked, check)
248
+ check()
249
+ data = file_ref(path, "arrow", check=check).model_copy(
250
+ update={"uri": (final / path.name).as_uri()}
251
+ )
252
+ artifact = TableArtifact(
253
+ name=name,
254
+ data=data,
255
+ row_count=rows,
256
+ columns=tuple(
257
+ PhysicalColumn(
258
+ name=column,
259
+ engine_type=dtype,
260
+ arrow_type=str(schema.field(i).type),
261
+ null_count=nulls[i],
262
+ nan_count=nans[i],
263
+ )
264
+ for i, (column, dtype) in enumerate(native_types)
265
+ ),
266
+ notes=(
267
+ "DuckDB explicit capture: one query/result consumption; detached Arrow IPC.",
268
+ "Observed values/types only; original source nullability, collations, "
269
+ "per-value timezone labels and distinctions already lost are not inferred.",
270
+ "Observed order retained; reproducibility needs an explicitly total SQL order.",
271
+ "Capture interval is not a coordinated or externally attested source snapshot.",
272
+ "DuckDB capture context v1: "
273
+ + json.dumps(
274
+ {
275
+ "started_at": started,
276
+ "finished_at": datetime.now(UTC).isoformat(),
277
+ "connection_owner": "equiframe" if owned else "caller",
278
+ "session": settings,
279
+ "versions": {
280
+ "duckdb": version("duckdb"),
281
+ "pyarrow": version("pyarrow"),
282
+ },
283
+ "options": checked.model_dump(mode="json"),
284
+ },
285
+ sort_keys=True,
286
+ ensure_ascii=True,
287
+ allow_nan=False,
288
+ ),
289
+ ),
290
+ )
291
+ with (stage / "capture.json").open("x", encoding="utf-8") as receipt:
292
+ receipt.write(artifact.model_dump_json(indent=2) + "\n")
293
+ receipt.flush()
294
+ os.fsync(receipt.fileno())
295
+ # Keep cancellation active until native resources are closed.
296
+ reader.close()
297
+ reader = None
298
+ relation.close()
299
+ relation = None
300
+ stopped.set()
301
+ watcher.join()
302
+ if owned:
303
+ session.close()
304
+ session = None
305
+ check()
306
+ if final.exists():
307
+ raise FileExistsError("capture destination already exists")
308
+ stage.rename(final)
309
+ completed = True
310
+ return artifact
311
+ except duckdb.OutOfMemoryException as error:
312
+ raise ResourceLimitError("DuckDB capture exhausted engine-managed memory") from error
313
+ except duckdb.InterruptException as error:
314
+ check()
315
+ raise NativeEngineError("DuckDB capture was interrupted externally") from error
316
+ except duckdb.Error as error:
317
+ check()
318
+ # Native exceptions may contain SQL literals/paths. Keep those out of ordinary diagnostics.
319
+ raise NativeEngineError("DuckDB capture failed; no complete snapshot published") from error
320
+ finally:
321
+ primary_error = None if completed else sys.exc_info()[1]
322
+ try:
323
+ try:
324
+ if reader is not None:
325
+ reader.close()
326
+ finally:
327
+ try:
328
+ # DuckDB close() executes an unstarted lazy relation. If
329
+ # export never returned a reader, discard the plan instead:
330
+ # cleanup must not start SQL after cancellation or timeout.
331
+ if relation is not None and reader is not None:
332
+ relation.close()
333
+ finally:
334
+ relation = None
335
+ stopped.set()
336
+ try:
337
+ if watcher is not None and watcher.ident is not None:
338
+ watcher.join()
339
+ finally:
340
+ if lock is not None:
341
+ lock.release()
342
+ if owned and session is not None:
343
+ session.close()
344
+ except BaseException as cleanup_error:
345
+ if primary_error is not None:
346
+ raise primary_error from cleanup_error
347
+ raise
348
+
349
+
350
+ def _write_reader(
351
+ reader: pa.RecordBatchReader,
352
+ path: Path,
353
+ native_types: tuple[tuple[str, str], ...],
354
+ timezone: str,
355
+ options: DuckDBCaptureOptions,
356
+ check: Callable[[], None],
357
+ ) -> tuple[int, list[int], list[int]]:
358
+ # Imported here to keep the public module optional-dependency-free.
359
+ import pyarrow as pa
360
+ import pyarrow.compute as pc
361
+
362
+ rows = 0
363
+ nulls, nans = [0] * len(native_types), [0] * len(native_types)
364
+ with path.open("xb") as sink:
365
+ with pa.ipc.new_file(sink, reader.schema) as writer:
366
+ while True:
367
+ check()
368
+ try:
369
+ batch = reader.read_next_batch()
370
+ except StopIteration:
371
+ break
372
+ except (OSError, pa.ArrowException) as error:
373
+ # The Arrow stream bridge can wrap DuckDB conversion/UDF and
374
+ # interrupt errors in OSError, outside duckdb.Error's hierarchy.
375
+ # Inspect cancellation/deadline before assigning a native failure.
376
+ # Keep this boundary around fetch only: disk write/fsync failures
377
+ # below remain filesystem errors rather than being mislabeled.
378
+ check()
379
+ if isinstance(error, pa.ArrowMemoryError):
380
+ raise ResourceLimitError("DuckDB capture exhausted Arrow memory") from error
381
+ raise NativeEngineError(
382
+ "DuckDB capture fetch failed; no complete snapshot published"
383
+ ) from error
384
+ check()
385
+ rows += batch.num_rows
386
+ if rows > options.max_rows:
387
+ raise ResourceLimitError("DuckDB capture row budget exhausted")
388
+ if batch.nbytes > options.max_batch_bytes:
389
+ raise ResourceLimitError("DuckDB capture batch byte budget exhausted")
390
+ table = pa.Table.from_batches([batch])
391
+ verify_duckdb_table(table, native_types, timezone)
392
+ for i, column in enumerate(batch.columns):
393
+ nulls[i] += column.null_count
394
+ if pa.types.is_floating(column.type):
395
+ nans[i] += int(pc.sum(pc.is_nan(column)).as_py() or 0)
396
+ writer.write_batch(batch)
397
+ sink.flush()
398
+ if sink.tell() > options.max_bytes:
399
+ raise ResourceLimitError("DuckDB capture serialized byte budget exhausted")
400
+ check()
401
+ # Arrow writes its footer while closing the writer. Flush and sync that
402
+ # complete file through the original writable descriptor: Windows rejects
403
+ # fsync on a descriptor reopened read-only.
404
+ sink.flush()
405
+ if sink.tell() > options.max_bytes:
406
+ raise ResourceLimitError("DuckDB capture serialized byte budget exhausted")
407
+ os.fsync(sink.fileno())
408
+ if path.stat().st_size > options.max_bytes:
409
+ raise ResourceLimitError("DuckDB capture serialized byte budget exhausted")
410
+ return rows, nulls, nans
@@ -0,0 +1,36 @@
1
+ """Optional standalone diagnostics; no migration, detector or approval gate."""
2
+
3
+ from .api import analyze, analyze_artifact, analyze_migration, read_result
4
+ from .models import (
5
+ ColumnProfile,
6
+ Conversion,
7
+ DuplicateProfile,
8
+ EdaConfig,
9
+ EdaManifest,
10
+ EdaResult,
11
+ Finding,
12
+ KeyRule,
13
+ NumericRule,
14
+ Resources,
15
+ ResultFile,
16
+ Source,
17
+ )
18
+
19
+ __all__ = [
20
+ "ColumnProfile",
21
+ "Conversion",
22
+ "DuplicateProfile",
23
+ "EdaConfig",
24
+ "EdaManifest",
25
+ "EdaResult",
26
+ "Finding",
27
+ "KeyRule",
28
+ "NumericRule",
29
+ "Resources",
30
+ "ResultFile",
31
+ "Source",
32
+ "analyze",
33
+ "analyze_artifact",
34
+ "analyze_migration",
35
+ "read_result",
36
+ ]
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()