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
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)
|