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,523 @@
|
|
|
1
|
+
"""Small, inspectable builders over the existing immutable comparison contract.
|
|
2
|
+
|
|
3
|
+
Inference reads only an Arrow schema supplied by the caller. It never inspects
|
|
4
|
+
rows, invents a key, converts values, or derives domain constraints from samples.
|
|
5
|
+
The ``unspecified`` numeric unit is an exact scalar-equality label, not an
|
|
6
|
+
inferred physical unit. Declare ColumnSpec explicitly for units or tolerances.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
from typing import TYPE_CHECKING, Literal, TypeVar, cast
|
|
13
|
+
|
|
14
|
+
from pydantic import Field, model_validator
|
|
15
|
+
from typing_extensions import Self
|
|
16
|
+
|
|
17
|
+
from equiframe._base import FrozenModel, Text, fail
|
|
18
|
+
from equiframe._timezones import timezone
|
|
19
|
+
from equiframe.comparison_types import ComparisonPolicy, ComparisonSpec
|
|
20
|
+
from equiframe.contracts import (
|
|
21
|
+
BooleanType,
|
|
22
|
+
ColumnSpec,
|
|
23
|
+
Contract,
|
|
24
|
+
DatePolicy,
|
|
25
|
+
DateType,
|
|
26
|
+
DecimalType,
|
|
27
|
+
FloatType,
|
|
28
|
+
IgnoredOrder,
|
|
29
|
+
IntegerType,
|
|
30
|
+
KeySpec,
|
|
31
|
+
LogicalType,
|
|
32
|
+
Multiplicity,
|
|
33
|
+
NaNPolicy,
|
|
34
|
+
NullPolicy,
|
|
35
|
+
NumericPolicy,
|
|
36
|
+
OrderPolicy,
|
|
37
|
+
SignificantOrder,
|
|
38
|
+
SortSpec,
|
|
39
|
+
StringType,
|
|
40
|
+
TableKeyScope,
|
|
41
|
+
TableSchema,
|
|
42
|
+
TableSpec,
|
|
43
|
+
TimestampPolicy,
|
|
44
|
+
TimestampType,
|
|
45
|
+
)
|
|
46
|
+
from equiframe.errors import CapabilityError, InvalidExecutionInput
|
|
47
|
+
from equiframe.tables import require_dependency
|
|
48
|
+
from equiframe.values import TimeUnit
|
|
49
|
+
|
|
50
|
+
_T = TypeVar("_T")
|
|
51
|
+
|
|
52
|
+
if TYPE_CHECKING:
|
|
53
|
+
import pyarrow as pa
|
|
54
|
+
|
|
55
|
+
Origin = Literal["user", "metadata", "reference", "default"]
|
|
56
|
+
Reference = Literal["peer", "expected"]
|
|
57
|
+
Incomplete = Literal["reject", "allow"]
|
|
58
|
+
Ties = Literal["preserve", "reject"]
|
|
59
|
+
_Source = tuple[Origin, str]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class _DefaultText(str):
|
|
63
|
+
"""Keep readable defaults while distinguishing an explicitly passed value."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class _DefaultRevision(int):
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
_DEFAULT_REFERENCE = cast(Reference, _DefaultText("expected"))
|
|
71
|
+
_DEFAULT_MULTIPLICITY = cast(Multiplicity, _DefaultText("preserve"))
|
|
72
|
+
_DEFAULT_INCOMPLETE = cast(Incomplete, _DefaultText("reject"))
|
|
73
|
+
_DEFAULT_TIES = cast(Ties, _DefaultText("preserve"))
|
|
74
|
+
_DEFAULT_ID = _DefaultText("diff")
|
|
75
|
+
_DEFAULT_REVISION = _DefaultRevision(1)
|
|
76
|
+
_DEFAULT: _Source = ("default", "Builder default; no observed values were used")
|
|
77
|
+
_USER: _Source = ("user", "Explicit caller selection")
|
|
78
|
+
_COLUMN: _Source = ("user", "Caller-supplied ColumnSpec, including its declared defaults")
|
|
79
|
+
_METADATA: _Source = ("metadata", "Declared Arrow reference field name or physical type")
|
|
80
|
+
_SCHEMA_PATHS = ("/spec/contract/inputs/0/schema_spec", "/spec/contract/output/schema_spec")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _leaf_paths(value: object, path: str) -> tuple[str, ...]:
|
|
84
|
+
if isinstance(value, dict) and value:
|
|
85
|
+
return tuple(
|
|
86
|
+
leaf
|
|
87
|
+
for name, item in value.items()
|
|
88
|
+
for leaf in _leaf_paths(
|
|
89
|
+
item, path + "/" + str(name).replace("~", "~0").replace("/", "~1")
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
if isinstance(value, (tuple, list)) and value:
|
|
93
|
+
return tuple(
|
|
94
|
+
leaf
|
|
95
|
+
for index, item in enumerate(value)
|
|
96
|
+
for leaf in _leaf_paths(item, path + "/" + str(index))
|
|
97
|
+
)
|
|
98
|
+
# None and empty containers are policy decisions too.
|
|
99
|
+
return (path,)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class PolicyProvenance(FrozenModel):
|
|
103
|
+
"""A nonsecret explanation for one canonical absolute JSON-pointer leaf.
|
|
104
|
+
|
|
105
|
+
Explanations are supplied by the caller or generated without row values or
|
|
106
|
+
arbitrary Arrow metadata. Do not put secrets in a custom ``detail``.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
path: Text = Field(pattern=r"^(?:/(?:[^~/]|~[01])*)+$")
|
|
110
|
+
origin: Origin
|
|
111
|
+
detail: Text = Field(description="Nonsecret explanation; do not include row values or secrets")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class DiffContract(FrozenModel):
|
|
115
|
+
"""Versioned comparison spec and complete, immutable policy provenance.
|
|
116
|
+
|
|
117
|
+
Coverage validates the declared explanation, not its historical authenticity.
|
|
118
|
+
An author can supply a new explanation; provenance is not a signature.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
schema_version: Literal["1.0"] = "1.0"
|
|
122
|
+
spec: ComparisonSpec
|
|
123
|
+
reference: Reference = "expected"
|
|
124
|
+
provenance: tuple[PolicyProvenance, ...]
|
|
125
|
+
|
|
126
|
+
@model_validator(mode="after")
|
|
127
|
+
def complete_provenance(self) -> Self:
|
|
128
|
+
expected = set(_leaf_paths(self.spec.model_dump(mode="json"), "/spec")) | {"/reference"}
|
|
129
|
+
seen: set[str] = set()
|
|
130
|
+
for index, item in enumerate(self.provenance):
|
|
131
|
+
if item.path in seen:
|
|
132
|
+
fail("duplicate_provenance", "provenance paths must be unique", "provenance", index)
|
|
133
|
+
if item.path not in expected:
|
|
134
|
+
fail(
|
|
135
|
+
"unknown_provenance",
|
|
136
|
+
"provenance path is not a policy leaf",
|
|
137
|
+
"provenance",
|
|
138
|
+
index,
|
|
139
|
+
)
|
|
140
|
+
seen.add(item.path)
|
|
141
|
+
if seen != expected:
|
|
142
|
+
fail(
|
|
143
|
+
"missing_provenance",
|
|
144
|
+
"every spec and reference leaf requires provenance",
|
|
145
|
+
"provenance",
|
|
146
|
+
)
|
|
147
|
+
return self
|
|
148
|
+
|
|
149
|
+
def provenance_for(self, path: str) -> PolicyProvenance:
|
|
150
|
+
"""Return the exact leaf explanation; raise KeyError for unknown paths."""
|
|
151
|
+
for item in self.provenance:
|
|
152
|
+
if item.path == path:
|
|
153
|
+
return item
|
|
154
|
+
raise KeyError(path)
|
|
155
|
+
|
|
156
|
+
@classmethod
|
|
157
|
+
def from_contract(
|
|
158
|
+
cls,
|
|
159
|
+
contract: Contract,
|
|
160
|
+
*,
|
|
161
|
+
policy: ComparisonPolicy | None = None,
|
|
162
|
+
reference: Reference = _DEFAULT_REFERENCE,
|
|
163
|
+
) -> Self:
|
|
164
|
+
"""Wrap an existing contract unchanged; its entire declaration is user-origin."""
|
|
165
|
+
spec = ComparisonSpec(
|
|
166
|
+
contract=contract, policy=policy if policy is not None else ComparisonPolicy()
|
|
167
|
+
)
|
|
168
|
+
sources = {
|
|
169
|
+
path: _USER
|
|
170
|
+
for path in _leaf_paths(spec.contract.model_dump(mode="json"), "/spec/contract")
|
|
171
|
+
}
|
|
172
|
+
_policy_sources(spec, policy, sources)
|
|
173
|
+
chosen_reference, source = _choice(reference, _DEFAULT_REFERENCE, "expected")
|
|
174
|
+
sources["/reference"] = source
|
|
175
|
+
return cls(spec=spec, reference=chosen_reference, provenance=_provenance(spec, sources))
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _choice(value: _T, marker: _T, default: _T) -> tuple[_T, _Source]:
|
|
179
|
+
return (default, _DEFAULT) if value is marker else (value, _USER)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _provenance(spec: ComparisonSpec, sources: dict[str, _Source]) -> tuple[PolicyProvenance, ...]:
|
|
183
|
+
paths = (*_leaf_paths(spec.model_dump(mode="json"), "/spec"), "/reference")
|
|
184
|
+
return tuple(
|
|
185
|
+
PolicyProvenance(
|
|
186
|
+
path=path, origin=sources.get(path, _DEFAULT)[0], detail=sources.get(path, _DEFAULT)[1]
|
|
187
|
+
)
|
|
188
|
+
for path in paths
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _mark(sources: dict[str, _Source], value: object, path: str, source: _Source) -> None:
|
|
193
|
+
sources.update(dict.fromkeys(_leaf_paths(value, path), source))
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _policy_sources(
|
|
197
|
+
spec: ComparisonSpec, policy: ComparisonPolicy | None, sources: dict[str, _Source]
|
|
198
|
+
) -> None:
|
|
199
|
+
if policy is not None:
|
|
200
|
+
_mark(sources, spec.policy.model_dump(mode="json"), "/spec/policy", _USER)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _build(
|
|
204
|
+
columns: Sequence[ColumnSpec],
|
|
205
|
+
*,
|
|
206
|
+
keys: tuple[KeySpec, ...],
|
|
207
|
+
order: OrderPolicy,
|
|
208
|
+
schema_sources: dict[str, _Source],
|
|
209
|
+
policy: ComparisonPolicy | None,
|
|
210
|
+
multiplicity: Multiplicity,
|
|
211
|
+
reference: Reference,
|
|
212
|
+
contract_id: str,
|
|
213
|
+
revision: int,
|
|
214
|
+
) -> DiffContract:
|
|
215
|
+
chosen_multiplicity, multiplicity_source = _choice(
|
|
216
|
+
multiplicity, _DEFAULT_MULTIPLICITY, "preserve"
|
|
217
|
+
)
|
|
218
|
+
chosen_reference, reference_source = _choice(reference, _DEFAULT_REFERENCE, "expected")
|
|
219
|
+
chosen_id, id_source = _choice(contract_id, _DEFAULT_ID, "diff")
|
|
220
|
+
chosen_revision, revision_source = _choice(revision, _DEFAULT_REVISION, 1)
|
|
221
|
+
schema = TableSchema(
|
|
222
|
+
columns=tuple(columns), keys=keys, multiplicity=chosen_multiplicity, order=order
|
|
223
|
+
)
|
|
224
|
+
contract = Contract(
|
|
225
|
+
schema_version="1.0",
|
|
226
|
+
contract_id=chosen_id,
|
|
227
|
+
revision=chosen_revision,
|
|
228
|
+
family="nulls_types",
|
|
229
|
+
inputs=(TableSpec(name="source", schema_spec=schema),),
|
|
230
|
+
output=TableSpec(name="result", schema_spec=schema),
|
|
231
|
+
preconditions=(),
|
|
232
|
+
rules=(),
|
|
233
|
+
)
|
|
234
|
+
spec = ComparisonSpec(
|
|
235
|
+
contract=contract, policy=policy if policy is not None else ComparisonPolicy()
|
|
236
|
+
)
|
|
237
|
+
sources: dict[str, _Source] = {
|
|
238
|
+
"/spec/contract/contract_id": id_source,
|
|
239
|
+
"/spec/contract/revision": revision_source,
|
|
240
|
+
"/reference": reference_source,
|
|
241
|
+
"/spec/contract/family": (
|
|
242
|
+
"default",
|
|
243
|
+
"Required contract bookkeeping family; no domain was inferred",
|
|
244
|
+
),
|
|
245
|
+
}
|
|
246
|
+
for path in _SCHEMA_PATHS:
|
|
247
|
+
_mark(sources, schema.model_dump(mode="json")["columns"], path + "/columns", _COLUMN)
|
|
248
|
+
sources[path + "/multiplicity"] = multiplicity_source
|
|
249
|
+
sources.update({path + suffix: source for suffix, source in schema_sources.items()})
|
|
250
|
+
_policy_sources(spec, policy, sources)
|
|
251
|
+
return DiffContract(
|
|
252
|
+
spec=spec, reference=chosen_reference, provenance=_provenance(spec, sources)
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def bag_contract(
|
|
257
|
+
columns: Sequence[ColumnSpec],
|
|
258
|
+
*,
|
|
259
|
+
policy: ComparisonPolicy | None = None,
|
|
260
|
+
multiplicity: Multiplicity = _DEFAULT_MULTIPLICITY,
|
|
261
|
+
reference: Reference = _DEFAULT_REFERENCE,
|
|
262
|
+
contract_id: str = _DEFAULT_ID,
|
|
263
|
+
revision: int = _DEFAULT_REVISION,
|
|
264
|
+
) -> DiffContract:
|
|
265
|
+
"""Declare an unordered bag, preserving all occurrences by default."""
|
|
266
|
+
return _build(
|
|
267
|
+
columns,
|
|
268
|
+
keys=(),
|
|
269
|
+
order=IgnoredOrder(),
|
|
270
|
+
schema_sources={"/order/kind": ("user", "Selected unordered bag constructor")},
|
|
271
|
+
policy=policy,
|
|
272
|
+
multiplicity=multiplicity,
|
|
273
|
+
reference=reference,
|
|
274
|
+
contract_id=contract_id,
|
|
275
|
+
revision=revision,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def keyed_contract(
|
|
280
|
+
columns: Sequence[ColumnSpec],
|
|
281
|
+
*,
|
|
282
|
+
keys: Sequence[str],
|
|
283
|
+
incomplete: Incomplete = _DEFAULT_INCOMPLETE,
|
|
284
|
+
policy: ComparisonPolicy | None = None,
|
|
285
|
+
multiplicity: Multiplicity = _DEFAULT_MULTIPLICITY,
|
|
286
|
+
reference: Reference = _DEFAULT_REFERENCE,
|
|
287
|
+
contract_id: str = _DEFAULT_ID,
|
|
288
|
+
revision: int = _DEFAULT_REVISION,
|
|
289
|
+
) -> DiffContract:
|
|
290
|
+
"""Declare a unique table key; the existing kernel verifies it and owns fallback.
|
|
291
|
+
|
|
292
|
+
Incomplete keys are rejected by default. ``incomplete='allow'`` retains the
|
|
293
|
+
kernel's bag fallback; neither path assumes uniqueness from sampled rows.
|
|
294
|
+
"""
|
|
295
|
+
if isinstance(keys, (str, bytes)):
|
|
296
|
+
raise InvalidExecutionInput("keys must be a sequence of column names, not a string")
|
|
297
|
+
chosen_incomplete, incomplete_source = _choice(incomplete, _DEFAULT_INCOMPLETE, "reject")
|
|
298
|
+
key = KeySpec(
|
|
299
|
+
name="diff_key",
|
|
300
|
+
columns=tuple(keys),
|
|
301
|
+
unique=True,
|
|
302
|
+
incomplete=chosen_incomplete,
|
|
303
|
+
scope=TableKeyScope(),
|
|
304
|
+
)
|
|
305
|
+
sources: dict[str, _Source] = {
|
|
306
|
+
"/order/kind": ("user", "Selected keyed constructor with unordered fallback"),
|
|
307
|
+
"/keys/0/unique": (
|
|
308
|
+
"user",
|
|
309
|
+
"Selected keyed constructor; uniqueness must be verified by the kernel",
|
|
310
|
+
),
|
|
311
|
+
"/keys/0/incomplete": incomplete_source,
|
|
312
|
+
}
|
|
313
|
+
_mark(sources, key.columns, "/keys/0/columns", _USER)
|
|
314
|
+
return _build(
|
|
315
|
+
columns,
|
|
316
|
+
keys=(key,),
|
|
317
|
+
order=IgnoredOrder(),
|
|
318
|
+
schema_sources=sources,
|
|
319
|
+
policy=policy,
|
|
320
|
+
multiplicity=multiplicity,
|
|
321
|
+
reference=reference,
|
|
322
|
+
contract_id=contract_id,
|
|
323
|
+
revision=revision,
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def sequence_contract(
|
|
328
|
+
columns: Sequence[ColumnSpec],
|
|
329
|
+
*,
|
|
330
|
+
by: Sequence[SortSpec],
|
|
331
|
+
ties: Ties = _DEFAULT_TIES,
|
|
332
|
+
policy: ComparisonPolicy | None = None,
|
|
333
|
+
multiplicity: Multiplicity = _DEFAULT_MULTIPLICITY,
|
|
334
|
+
reference: Reference = _DEFAULT_REFERENCE,
|
|
335
|
+
contract_id: str = _DEFAULT_ID,
|
|
336
|
+
revision: int = _DEFAULT_REVISION,
|
|
337
|
+
) -> DiffContract:
|
|
338
|
+
"""Declare existing significant-order semantics without sorting either input."""
|
|
339
|
+
chosen_ties, ties_source = _choice(ties, _DEFAULT_TIES, "preserve")
|
|
340
|
+
order = SignificantOrder(by=tuple(by), ties=chosen_ties)
|
|
341
|
+
sources: dict[str, _Source] = {
|
|
342
|
+
"/order/kind": ("user", "Selected significant sequence constructor"),
|
|
343
|
+
"/order/ties": ties_source,
|
|
344
|
+
}
|
|
345
|
+
_mark(sources, order.model_dump(mode="json")["by"], "/order/by", _USER)
|
|
346
|
+
return _build(
|
|
347
|
+
columns,
|
|
348
|
+
keys=(),
|
|
349
|
+
order=order,
|
|
350
|
+
schema_sources=sources,
|
|
351
|
+
policy=policy,
|
|
352
|
+
multiplicity=multiplicity,
|
|
353
|
+
reference=reference,
|
|
354
|
+
contract_id=contract_id,
|
|
355
|
+
revision=revision,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _infer_column(field: pa.Field) -> ColumnSpec:
|
|
360
|
+
import pyarrow as pa
|
|
361
|
+
|
|
362
|
+
dtype = field.type
|
|
363
|
+
logical: LogicalType
|
|
364
|
+
if pa.types.is_integer(dtype):
|
|
365
|
+
logical = IntegerType(bits=dtype.bit_width, signed=pa.types.is_signed_integer(dtype))
|
|
366
|
+
elif pa.types.is_floating(dtype) and dtype.bit_width in (32, 64):
|
|
367
|
+
logical = FloatType(bits=dtype.bit_width)
|
|
368
|
+
elif pa.types.is_boolean(dtype):
|
|
369
|
+
logical = BooleanType()
|
|
370
|
+
elif (
|
|
371
|
+
pa.types.is_string(dtype)
|
|
372
|
+
or pa.types.is_large_string(dtype)
|
|
373
|
+
or pa.types.is_string_view(dtype)
|
|
374
|
+
):
|
|
375
|
+
logical = StringType()
|
|
376
|
+
elif pa.types.is_decimal128(dtype) or pa.types.is_decimal256(dtype):
|
|
377
|
+
if dtype.scale < 0 or dtype.scale > dtype.precision:
|
|
378
|
+
raise CapabilityError(
|
|
379
|
+
"reference decimal scale is unsupported; no implicit rescaling is allowed"
|
|
380
|
+
)
|
|
381
|
+
logical = DecimalType(precision=dtype.precision, scale=dtype.scale)
|
|
382
|
+
elif pa.types.is_date(dtype):
|
|
383
|
+
logical = DateType()
|
|
384
|
+
elif pa.types.is_timestamp(dtype):
|
|
385
|
+
if dtype.tz is not None:
|
|
386
|
+
try:
|
|
387
|
+
timezone(dtype.tz)
|
|
388
|
+
except ValueError as error:
|
|
389
|
+
raise CapabilityError(
|
|
390
|
+
"reference timestamp has an unsupported zone; an IANA zone is required "
|
|
391
|
+
"without guessing or converting a zone"
|
|
392
|
+
) from error
|
|
393
|
+
logical = TimestampType(unit=cast(TimeUnit, dtype.unit), timezone=dtype.tz)
|
|
394
|
+
elif pa.types.is_null(dtype):
|
|
395
|
+
raise CapabilityError(
|
|
396
|
+
"null-only reference type is ambiguous; supply an explicit ColumnSpec"
|
|
397
|
+
)
|
|
398
|
+
else:
|
|
399
|
+
raise CapabilityError(
|
|
400
|
+
"unsupported reference schema type; supply an explicit ColumnSpec only "
|
|
401
|
+
"when the existing transport supports that physical type"
|
|
402
|
+
)
|
|
403
|
+
temporal: DatePolicy | TimestampPolicy | None = None
|
|
404
|
+
if isinstance(logical, DateType):
|
|
405
|
+
temporal = DatePolicy(closed="both")
|
|
406
|
+
elif isinstance(logical, TimestampType):
|
|
407
|
+
temporal = TimestampPolicy(
|
|
408
|
+
granularity=logical.unit,
|
|
409
|
+
timezone=logical.timezone,
|
|
410
|
+
naive="preserve" if logical.timezone is None else "reject",
|
|
411
|
+
comparison="wall_time" if logical.timezone is None else "instant",
|
|
412
|
+
ambiguous="reject",
|
|
413
|
+
closed="both",
|
|
414
|
+
)
|
|
415
|
+
return ColumnSpec(
|
|
416
|
+
name=field.name,
|
|
417
|
+
logical_type=logical,
|
|
418
|
+
nullable=True,
|
|
419
|
+
null_policy=NullPolicy(representations=("null",)),
|
|
420
|
+
nan_policy=NaNPolicy(handling="distinct") if isinstance(logical, FloatType) else None,
|
|
421
|
+
numeric_policy=NumericPolicy(comparison="exact", unit="unspecified")
|
|
422
|
+
if isinstance(logical, (IntegerType, FloatType, DecimalType))
|
|
423
|
+
else None,
|
|
424
|
+
temporal_policy=temporal,
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def infer_diff_contract(
|
|
429
|
+
reference_schema: pa.Schema,
|
|
430
|
+
*,
|
|
431
|
+
keys: Sequence[str] | None = None,
|
|
432
|
+
column_overrides: Sequence[ColumnSpec] = (),
|
|
433
|
+
policy: ComparisonPolicy | None = None,
|
|
434
|
+
reference: Reference = _DEFAULT_REFERENCE,
|
|
435
|
+
) -> DiffContract:
|
|
436
|
+
"""Infer supported physical types from reference metadata, never from values.
|
|
437
|
+
|
|
438
|
+
Nullability remains True even for a nonnullable Arrow field: a transported
|
|
439
|
+
schema is not a verified domain constraint. Custom schema/field metadata is
|
|
440
|
+
rejected rather than silently interpreted as keys, units, tolerances, casts,
|
|
441
|
+
or domain restrictions. Use an explicit builder for such domains.
|
|
442
|
+
Null-only fields require an explicit ColumnSpec; unsupported physical types
|
|
443
|
+
still cannot be transported by declaring a different logical type.
|
|
444
|
+
"""
|
|
445
|
+
require_dependency("pyarrow", "arrow")
|
|
446
|
+
import pyarrow as pa
|
|
447
|
+
|
|
448
|
+
if not isinstance(reference_schema, pa.Schema):
|
|
449
|
+
raise InvalidExecutionInput(
|
|
450
|
+
"reference_schema must be an Arrow Schema; rows are not accepted"
|
|
451
|
+
)
|
|
452
|
+
if reference_schema.metadata or any(field.metadata for field in reference_schema):
|
|
453
|
+
raise CapabilityError(
|
|
454
|
+
"Arrow schema or field metadata requires an explicit contract; "
|
|
455
|
+
"use bag_contract, keyed_contract, or sequence_contract with explicit ColumnSpec"
|
|
456
|
+
)
|
|
457
|
+
names = reference_schema.names
|
|
458
|
+
if (
|
|
459
|
+
not names
|
|
460
|
+
or len(set(names)) != len(names)
|
|
461
|
+
or any(not name.strip() or name != name.strip() for name in names)
|
|
462
|
+
):
|
|
463
|
+
raise InvalidExecutionInput("reference schema must have nonempty, unique column names")
|
|
464
|
+
overrides: dict[str, ColumnSpec] = {}
|
|
465
|
+
for column in column_overrides:
|
|
466
|
+
column = ColumnSpec.model_validate(column)
|
|
467
|
+
if column.name not in names:
|
|
468
|
+
raise InvalidExecutionInput("column override must name an existing reference column")
|
|
469
|
+
if column.name in overrides:
|
|
470
|
+
raise InvalidExecutionInput("column overrides must not repeat a reference column")
|
|
471
|
+
overrides[column.name] = column
|
|
472
|
+
selected: list[ColumnSpec] = []
|
|
473
|
+
for field in reference_schema:
|
|
474
|
+
override = overrides.get(field.name)
|
|
475
|
+
# Null is transportable but has no logical domain without an override.
|
|
476
|
+
# Other unsupported physical types cannot be enabled by a declaration.
|
|
477
|
+
if override is not None and pa.types.is_null(field.type):
|
|
478
|
+
selected.append(override)
|
|
479
|
+
else:
|
|
480
|
+
inferred = _infer_column(field)
|
|
481
|
+
selected.append(override if override is not None else inferred)
|
|
482
|
+
columns = tuple(selected)
|
|
483
|
+
result = (
|
|
484
|
+
bag_contract(columns, policy=policy, reference=reference)
|
|
485
|
+
if keys is None
|
|
486
|
+
else keyed_contract(columns, keys=keys, policy=policy, reference=reference)
|
|
487
|
+
)
|
|
488
|
+
sources = {item.path: (item.origin, item.detail) for item in result.provenance}
|
|
489
|
+
for path in _SCHEMA_PATHS:
|
|
490
|
+
if keys is None:
|
|
491
|
+
sources[path + "/order/kind"] = (
|
|
492
|
+
"default",
|
|
493
|
+
"Unordered comparison default; no order or key inferred from rows",
|
|
494
|
+
)
|
|
495
|
+
for index, column in enumerate(columns):
|
|
496
|
+
if column.name in overrides:
|
|
497
|
+
continue
|
|
498
|
+
prefix = path + "/columns/" + str(index)
|
|
499
|
+
_mark(sources, column.model_dump(mode="json"), prefix, _DEFAULT)
|
|
500
|
+
sources[prefix + "/name"] = _METADATA
|
|
501
|
+
_mark(
|
|
502
|
+
sources,
|
|
503
|
+
column.logical_type.model_dump(mode="json"),
|
|
504
|
+
prefix + "/logical_type",
|
|
505
|
+
_METADATA,
|
|
506
|
+
)
|
|
507
|
+
sources[prefix + "/nullable"] = (
|
|
508
|
+
"default",
|
|
509
|
+
"Nullable domain retained; transported schema or absent observed NULLs "
|
|
510
|
+
"do not prove nonnullability",
|
|
511
|
+
)
|
|
512
|
+
if column.numeric_policy is not None:
|
|
513
|
+
sources[prefix + "/numeric_policy/unit"] = (
|
|
514
|
+
"default",
|
|
515
|
+
"Unspecified exact scalar-equality label; "
|
|
516
|
+
"no physical unit inferred or converted",
|
|
517
|
+
)
|
|
518
|
+
if isinstance(column.temporal_policy, TimestampPolicy):
|
|
519
|
+
for field_name in ("granularity", "timezone"):
|
|
520
|
+
sources[prefix + "/temporal_policy/" + field_name] = _METADATA
|
|
521
|
+
return DiffContract(
|
|
522
|
+
spec=result.spec, reference=result.reference, provenance=_provenance(result.spec, sources)
|
|
523
|
+
)
|