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