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.
- equiframe/__init__.py +832 -0
- equiframe/__main__.py +3 -0
- equiframe/_base.py +84 -0
- equiframe/_cli_support.py +125 -0
- equiframe/_comparison_io.py +161 -0
- equiframe/_dataset_provenance.py +146 -0
- equiframe/_decimal.py +22 -0
- equiframe/_diff_capture.py +433 -0
- equiframe/_doctor.py +123 -0
- equiframe/_document_io.py +61 -0
- equiframe/_documents.py +79 -0
- equiframe/_duckdb_transport.py +149 -0
- equiframe/_error_reporting.py +52 -0
- equiframe/_first_use.py +102 -0
- equiframe/_path_compat.py +26 -0
- equiframe/_physical.py +133 -0
- equiframe/_r_review.R +308 -0
- equiframe/_regular_io.py +167 -0
- equiframe/_report_format_v1.py +298 -0
- equiframe/_report_io.py +591 -0
- equiframe/_stability_inventory.py +1421 -0
- equiframe/_timezones.py +257 -0
- equiframe/_vendor/cyclonedx/LICENSE +201 -0
- equiframe/_vendor/cyclonedx/PROVENANCE.md +18 -0
- equiframe/_vendor/cyclonedx/bom-1.6.schema.json +5673 -0
- equiframe/_vendor/cyclonedx/jsf-0.82.schema.json +240 -0
- equiframe/_vendor/cyclonedx/spdx.schema.json +712 -0
- equiframe/adapters.py +454 -0
- equiframe/assertion_types.py +122 -0
- equiframe/assertions.py +271 -0
- equiframe/benchmark.py +101 -0
- equiframe/bundle.py +240 -0
- equiframe/cli.py +693 -0
- equiframe/comparison.py +937 -0
- equiframe/comparison_report.py +660 -0
- equiframe/comparison_types.py +257 -0
- equiframe/compatibility.py +546 -0
- equiframe/contracts.py +879 -0
- equiframe/datasets.py +1301 -0
- equiframe/decision.py +34 -0
- equiframe/destinations.py +295 -0
- equiframe/detector_types.py +431 -0
- equiframe/detectors.py +389 -0
- equiframe/diff.py +192 -0
- equiframe/diff_contracts.py +523 -0
- equiframe/docs/adoption.md +329 -0
- equiframe/docs/api.md +534 -0
- equiframe/docs/arquitecto.md +143 -0
- equiframe/docs/choosing-api.md +84 -0
- equiframe/docs/cientifico.md +180 -0
- equiframe/docs/compatibility.md +254 -0
- equiframe/docs/contributing.md +171 -0
- equiframe/docs/dependencies.md +237 -0
- equiframe/docs/eda.md +166 -0
- equiframe/docs/extending.md +158 -0
- equiframe/docs/fast-ci.md +52 -0
- equiframe/docs/first-use.md +78 -0
- equiframe/docs/index.md +142 -0
- equiframe/docs/ingestion.md +145 -0
- equiframe/docs/inicio.md +129 -0
- equiframe/docs/installed-private.md +97 -0
- equiframe/docs/journeys.md +362 -0
- equiframe/docs/lectura-conversion.md +211 -0
- equiframe/docs/licensing.md +17 -0
- equiframe/docs/maintenance.md +233 -0
- equiframe/docs/migration-ir.md +333 -0
- equiframe/docs/option-audit.md +45 -0
- equiframe/docs/outcome-gates.md +134 -0
- equiframe/docs/performance.md +263 -0
- equiframe/docs/piloto-simulado.md +56 -0
- equiframe/docs/quickstart.md +156 -0
- equiframe/docs/r_review.md +212 -0
- equiframe/docs/recipes.md +248 -0
- equiframe/docs/releasing.md +183 -0
- equiframe/docs/security.md +218 -0
- equiframe/docs/semantics.md +274 -0
- equiframe/docs/sqlite-snapshots.md +262 -0
- equiframe/docs/universal-sources.md +49 -0
- equiframe/docs/universalidad-cinco-pasos.md +107 -0
- equiframe/docs/upgrading.md +538 -0
- equiframe/duckdb_capture.py +410 -0
- equiframe/eda/__init__.py +36 -0
- equiframe/eda/__main__.py +3 -0
- equiframe/eda/_backend.py +612 -0
- equiframe/eda/_inventory.py +49 -0
- equiframe/eda/_paths.py +49 -0
- equiframe/eda/_report.py +291 -0
- equiframe/eda/api.py +535 -0
- equiframe/eda/cli.py +102 -0
- equiframe/eda/models.py +239 -0
- equiframe/errors.py +83 -0
- equiframe/evidence.py +375 -0
- equiframe/examples/integrations/eda_generic.py +60 -0
- equiframe/examples/integrations/outcome_gates.py +94 -0
- equiframe/examples/integrations/sqlite_snapshot.py +246 -0
- equiframe/examples/integrations/sqlite_snapshot_demo.py +226 -0
- equiframe/examples/integrations/universal_first_use.py +296 -0
- equiframe/examples/journeys/README.md +31 -0
- equiframe/examples/journeys/__init__.py +1 -0
- equiframe/examples/journeys/a_pandas_refactor.py +88 -0
- equiframe/examples/journeys/b_native_migration.py +181 -0
- equiframe/examples/journeys/c_retail_etl.py +460 -0
- equiframe/examples/journeys/common.py +136 -0
- equiframe/examples/journeys/d_financial_reconciliation.py +96 -0
- equiframe/examples/journeys/e_telemetry.py +135 -0
- equiframe/examples/journeys/f_data_science_preparation.py +84 -0
- equiframe/examples/journeys/g_external_snapshots.py +197 -0
- equiframe/examples/journeys/h_portable_ci.py +129 -0
- equiframe/examples/journeys/requirements.lock +249 -0
- equiframe/examples/journeys/verify_journeys.py +117 -0
- equiframe/examples/load_convert_compare.py +85 -0
- equiframe/execution.py +355 -0
- equiframe/execution_types.py +177 -0
- equiframe/ingestion.py +744 -0
- equiframe/local.py +126 -0
- equiframe/migration/__init__.py +94 -0
- equiframe/migration/_physical.py +8 -0
- equiframe/migration/backends/_timestamp_transport.py +109 -0
- equiframe/migration/backends/duckdb.py +651 -0
- equiframe/migration/backends/duckdb_decimal.py +125 -0
- equiframe/migration/backends/duckdb_time.py +87 -0
- equiframe/migration/backends/pandas.py +544 -0
- equiframe/migration/backends/polars.py +915 -0
- equiframe/migration/backends/polars_decimal.py +231 -0
- equiframe/migration/io.py +198 -0
- equiframe/migration/ports.py +96 -0
- equiframe/migration/runner.py +545 -0
- equiframe/migration/runner_types.py +405 -0
- equiframe/migration/types.py +354 -0
- equiframe/migration/validation.py +619 -0
- equiframe/normalization.py +358 -0
- equiframe/pipeline.py +274 -0
- equiframe/pipeline_types.py +247 -0
- equiframe/ports.py +39 -0
- equiframe/preprocessing.py +110 -0
- equiframe/profile_types.py +343 -0
- equiframe/profiles.py +447 -0
- equiframe/py.typed +0 -0
- equiframe/r_review.py +706 -0
- equiframe/reduction.py +518 -0
- equiframe/registry.py +121 -0
- equiframe/report_rendering.py +366 -0
- equiframe/report_types.py +294 -0
- equiframe/reporting.py +26 -0
- equiframe/rule_types.py +178 -0
- equiframe/rules.py +819 -0
- equiframe/schemas/r-execution-binding-1.0.schema.json +46 -0
- equiframe/schemas/r-review-binding-1.0.schema.json +28 -0
- equiframe/schemas/r-review-options-1.0.schema.json +38 -0
- equiframe/schemas/r-review-result-1.0.schema.json +207 -0
- equiframe/schemas/reviewed-decision-1.0.schema.json +3394 -0
- equiframe/serialization.py +152 -0
- equiframe/snapshot.py +296 -0
- equiframe/sources.py +283 -0
- equiframe/tables.py +459 -0
- equiframe/types.py +71 -0
- equiframe/values.py +252 -0
- equiframe/verification_request.py +35 -0
- equiframe/verify.py +799 -0
- equiframe/verify_types.py +255 -0
- equiframe/workflow.py +510 -0
- equiframe/workflow_request.py +38 -0
- equiframe-0.12.4.dist-info/METADATA +268 -0
- equiframe-0.12.4.dist-info/RECORD +170 -0
- equiframe-0.12.4.dist-info/WHEEL +4 -0
- equiframe-0.12.4.dist-info/entry_points.txt +3 -0
- equiframe-0.12.4.dist-info/licenses/LICENSE +21 -0
- equiframe-0.12.4.dist-info/licenses/THIRD_PARTY_NOTICES.md +42 -0
- equiframe-0.12.4.dist-info/licenses/tools/cyclonedx/LICENSE +201 -0
- 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
|
+
]
|