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
equiframe/contracts.py ADDED
@@ -0,0 +1,879 @@
1
+ """Strict declarative semantic contracts. No table processing or rule execution."""
2
+
3
+ import struct
4
+ from datetime import date
5
+ from datetime import timezone as _timezone
6
+ from typing import Annotated, Literal
7
+
8
+ from pydantic import BeforeValidator, Field, ValidationError, model_validator
9
+ from typing_extensions import Self
10
+
11
+ from equiframe._base import (
12
+ FrozenModel,
13
+ Identifier,
14
+ NonNegativeInt,
15
+ PositiveInt,
16
+ Text,
17
+ fail,
18
+ strict_int_literal,
19
+ unique,
20
+ )
21
+ from equiframe._timezones import timezone
22
+ from equiframe.values import (
23
+ DateValue,
24
+ ExactDecimal,
25
+ ScalarValue,
26
+ TimestampValue,
27
+ TimeUnit,
28
+ TimeZone,
29
+ _timestamp_datetime,
30
+ offset_nanoseconds,
31
+ )
32
+
33
+ UTC = _timezone.utc
34
+
35
+ SCHEMA_VERSION = "1.0"
36
+ Family = Literal["nulls_types", "joins", "duplicates_order", "aggregations", "dates"]
37
+
38
+
39
+ class BooleanType(FrozenModel):
40
+ kind: Literal["boolean"] = "boolean"
41
+
42
+
43
+ class IntegerType(FrozenModel):
44
+ kind: Literal["integer"] = "integer"
45
+ bits: Annotated[Literal[8, 16, 32, 64], BeforeValidator(strict_int_literal)]
46
+ signed: bool
47
+
48
+
49
+ class StringType(FrozenModel):
50
+ kind: Literal["string"] = "string"
51
+
52
+
53
+ class DecimalType(FrozenModel):
54
+ kind: Literal["decimal"] = "decimal"
55
+ precision: Annotated[int, Field(ge=1, le=76)]
56
+ scale: NonNegativeInt
57
+
58
+ @model_validator(mode="after")
59
+ def valid_scale(self) -> Self:
60
+ if self.scale > self.precision:
61
+ fail("decimal_scale", "scale must not exceed precision", "scale")
62
+ return self
63
+
64
+
65
+ class FloatType(FrozenModel):
66
+ kind: Literal["float"] = "float"
67
+ bits: Annotated[Literal[32, 64], BeforeValidator(strict_int_literal)]
68
+
69
+
70
+ class DateType(FrozenModel):
71
+ kind: Literal["date"] = "date"
72
+
73
+
74
+ class TimestampType(FrozenModel):
75
+ kind: Literal["timestamp"] = "timestamp"
76
+ unit: TimeUnit
77
+ timezone: TimeZone | None
78
+
79
+
80
+ LogicalType = Annotated[
81
+ BooleanType | IntegerType | StringType | DecimalType | FloatType | DateType | TimestampType,
82
+ Field(discriminator="kind"),
83
+ ]
84
+
85
+
86
+ class NullPolicy(FrozenModel):
87
+ representations: Annotated[
88
+ tuple[Literal["null", "empty_string", "zero"], ...], Field(min_length=1)
89
+ ]
90
+ justification: Text | None = None
91
+
92
+ @model_validator(mode="after")
93
+ def explicit_equivalences(self) -> Self:
94
+ unique(self.representations, "representations")
95
+ if "null" not in self.representations:
96
+ fail("missing_null", "logical absence must include null", "representations")
97
+ if len(self.representations) > 1 and self.justification is None:
98
+ fail(
99
+ "null_justification", "absence equivalences require justification", "justification"
100
+ )
101
+ return self
102
+
103
+
104
+ class NaNPolicy(FrozenModel):
105
+ handling: Literal["forbid", "distinct", "as_null"]
106
+ justification: Text | None = None
107
+
108
+ @model_validator(mode="after")
109
+ def explicit_equivalence(self) -> Self:
110
+ if self.handling == "as_null" and self.justification is None:
111
+ fail("nan_justification", "NaN as absence requires justification", "justification")
112
+ return self
113
+
114
+
115
+ class Tolerance(FrozenModel):
116
+ absolute: ExactDecimal | None = None
117
+ relative: ExactDecimal | None = None
118
+ unit: Text
119
+ justification: Text
120
+
121
+ @model_validator(mode="after")
122
+ def nonnegative_bounds(self) -> Self:
123
+ if self.absolute is None and self.relative is None:
124
+ fail("empty_tolerance", "declare absolute or relative tolerance", "absolute")
125
+ for field, value in (("absolute", self.absolute), ("relative", self.relative)):
126
+ if value is not None and value < 0:
127
+ fail("negative_tolerance", "tolerance must be nonnegative", field)
128
+ return self
129
+
130
+
131
+ class NumericPolicy(FrozenModel):
132
+ comparison: Literal["exact", "approximate"]
133
+ unit: Text
134
+ rounding: (
135
+ Literal[
136
+ "ROUND_DOWN",
137
+ "ROUND_UP",
138
+ "ROUND_CEILING",
139
+ "ROUND_FLOOR",
140
+ "ROUND_HALF_UP",
141
+ "ROUND_HALF_DOWN",
142
+ "ROUND_HALF_EVEN",
143
+ "ROUND_05UP",
144
+ ]
145
+ | None
146
+ ) = None
147
+ rounding_justification: Text | None = None
148
+ tolerance: Tolerance | None = None
149
+
150
+ @model_validator(mode="after")
151
+ def consistent_comparison(self) -> Self:
152
+ if self.comparison == "exact" and self.tolerance is not None:
153
+ fail("exact_tolerance", "exact comparison cannot have a tolerance", "tolerance")
154
+ if self.comparison == "approximate" and self.tolerance is None:
155
+ fail("missing_tolerance", "approximate comparison requires tolerance", "tolerance")
156
+ if self.tolerance is not None and self.tolerance.unit != self.unit:
157
+ fail("tolerance_unit", "tolerance unit must match the column unit", "tolerance", "unit")
158
+ if (self.rounding is None) != (self.rounding_justification is None):
159
+ fail(
160
+ "rounding_justification",
161
+ "rounding requires an explicit mode and justification",
162
+ "rounding",
163
+ )
164
+ return self
165
+
166
+
167
+ IntervalClosure = Literal["both", "left", "right", "neither"]
168
+
169
+
170
+ class DatePolicy(FrozenModel):
171
+ kind: Literal["date"] = "date"
172
+ granularity: Literal["day"] = "day"
173
+ lower: DateValue | None = None
174
+ upper: DateValue | None = None
175
+ closed: IntervalClosure
176
+
177
+ @model_validator(mode="after")
178
+ def ordered_interval(self) -> Self:
179
+ if self.lower is not None and self.upper is not None:
180
+ if self.lower.value > self.upper.value:
181
+ fail("temporal_interval", "lower bound exceeds upper bound", "upper")
182
+ if self.lower.value == self.upper.value and self.closed != "both":
183
+ fail("empty_interval", "equal bounds require both endpoints included", "closed")
184
+ return self
185
+
186
+
187
+ class TimestampPolicy(FrozenModel):
188
+ kind: Literal["timestamp"] = "timestamp"
189
+ granularity: TimeUnit
190
+ timezone: TimeZone | None
191
+ naive: Literal["reject", "preserve", "assume_zone"]
192
+ comparison: Literal["instant", "wall_time"]
193
+ ambiguous: Literal["reject", "earlier", "later"]
194
+ nonexistent: Literal["reject"] = "reject"
195
+ lower: TimestampValue | None = None
196
+ upper: TimestampValue | None = None
197
+ closed: IntervalClosure
198
+
199
+ def _bound(self, value: TimestampValue, field: str) -> int:
200
+ max_digits = {"s": 0, "ms": 3, "us": 6, "ns": 9}[self.granularity]
201
+ if value.fractional_digits > max_digits:
202
+ fail("temporal_precision", "bound exceeds declared granularity", field, "value")
203
+ if value.zone is not None and value.zone != self.timezone:
204
+ fail("temporal_zone", "bound zone must match policy zone", field, "zone")
205
+ if value.has_offset:
206
+ if self.timezone is not None:
207
+ stamp = _timestamp_datetime(value.value)
208
+ try:
209
+ actual_offset = stamp.astimezone(timezone(self.timezone)).utcoffset()
210
+ except (OverflowError, ValueError):
211
+ fail("timestamp_range", "zoned instant exceeds calendar range", field, "value")
212
+ if actual_offset != stamp.utcoffset():
213
+ fail(
214
+ "zone_offset_mismatch",
215
+ "bound offset disagrees with policy zone",
216
+ field,
217
+ "value",
218
+ )
219
+ return value.nanoseconds(instant=self.comparison == "instant")
220
+ if self.naive == "reject":
221
+ fail("naive_timestamp", "policy rejects timestamps without offset", field, "value")
222
+ if self.naive == "preserve":
223
+ return value.nanoseconds(instant=False)
224
+ if not (self.timezone is not None):
225
+ raise AssertionError("Internal invariant check failed.")
226
+ choices = [value.in_zone(self.timezone, fold=fold) for fold in (0, 1)]
227
+ try:
228
+ valid = [
229
+ choice
230
+ for choice in choices
231
+ if choice.astimezone(UTC).astimezone(choice.tzinfo).replace(tzinfo=None)
232
+ == choice.replace(tzinfo=None)
233
+ ]
234
+ except (OverflowError, ValueError):
235
+ fail("timestamp_range", "zoned instant exceeds calendar range", field, "value")
236
+ if not valid:
237
+ fail("nonexistent_time", "local timestamp falls in a DST gap", field, "value")
238
+ offsets = {offset_nanoseconds(choice.utcoffset()) for choice in valid}
239
+ if len(offsets) > 1 and self.ambiguous == "reject":
240
+ fail("ambiguous_time", "local timestamp has two possible UTC offsets", field, "value")
241
+ result = value.nanoseconds(instant=False)
242
+ if self.comparison == "wall_time":
243
+ return result
244
+ instants = [result - offset for offset in offsets]
245
+ return max(instants) if self.ambiguous == "later" else min(instants)
246
+
247
+ @model_validator(mode="after")
248
+ def consistent_time(self) -> Self:
249
+ if self.naive == "assume_zone" and self.timezone is None:
250
+ fail("missing_timezone", "assume_zone requires an IANA time zone", "timezone")
251
+ if self.naive == "preserve" and (
252
+ self.comparison != "wall_time" or self.timezone is not None
253
+ ):
254
+ fail("naive_policy", "preserved naive time requires wall_time and no zone", "naive")
255
+ if self.naive != "assume_zone" and self.ambiguous != "reject":
256
+ fail("ambiguous_policy", "DST resolution applies only to assume_zone", "ambiguous")
257
+ lower = self._bound(self.lower, "lower") if self.lower is not None else None
258
+ upper = self._bound(self.upper, "upper") if self.upper is not None else None
259
+ if (
260
+ self.naive == "preserve"
261
+ and self.lower is not None
262
+ and self.upper is not None
263
+ and self.lower.has_offset != self.upper.has_offset
264
+ ):
265
+ fail(
266
+ "mixed_temporal_bounds",
267
+ "bounds must both be naive or both have offsets",
268
+ "upper",
269
+ )
270
+ if lower is not None and upper is not None:
271
+ if lower > upper:
272
+ fail("temporal_interval", "lower bound exceeds upper bound", "upper")
273
+ if lower == upper and self.closed != "both":
274
+ fail("empty_interval", "equal bounds require both endpoints included", "closed")
275
+ return self
276
+
277
+
278
+ TemporalPolicy = Annotated[DatePolicy | TimestampPolicy, Field(discriminator="kind")]
279
+
280
+
281
+ class ColumnSpec(FrozenModel):
282
+ name: Text
283
+ logical_type: LogicalType
284
+ nullable: bool
285
+ null_policy: NullPolicy
286
+ nan_policy: NaNPolicy | None = None
287
+ numeric_policy: NumericPolicy | None = None
288
+ temporal_policy: TemporalPolicy | None = None
289
+ allowed_values: tuple[ScalarValue, ...] = ()
290
+
291
+ @model_validator(mode="after")
292
+ def compatible_policies(self) -> Self:
293
+ kind = self.logical_type.kind
294
+ representations = self.null_policy.representations
295
+ if len(representations) > 1 and not self.nullable:
296
+ fail(
297
+ "nonnullable_equivalence",
298
+ "absence equivalences require a nullable column",
299
+ "null_policy",
300
+ )
301
+ if "empty_string" in representations and kind != "string":
302
+ fail("null_type", "empty_string absence is only valid for strings", "null_policy")
303
+ if "zero" in representations and kind not in ("integer", "decimal", "float"):
304
+ fail("null_type", "zero absence is only valid for numeric columns", "null_policy")
305
+ if kind == "float":
306
+ if self.nan_policy is None:
307
+ fail("missing_nan_policy", "float requires an explicit NaN policy", "nan_policy")
308
+ if self.nan_policy.handling == "as_null" and not self.nullable:
309
+ fail("nan_nullable", "NaN as absence requires a nullable column", "nan_policy")
310
+ elif self.nan_policy is not None:
311
+ fail("nan_type", "NaN policy is only valid for floats", "nan_policy")
312
+ if kind in ("integer", "decimal", "float"):
313
+ if self.numeric_policy is None:
314
+ fail(
315
+ "missing_numeric_policy",
316
+ "numeric columns require an explicit numeric policy",
317
+ "numeric_policy",
318
+ )
319
+ if self.numeric_policy.rounding is not None and kind != "decimal":
320
+ fail(
321
+ "rounding_type",
322
+ "decimal rounding is only valid for decimal columns",
323
+ "numeric_policy",
324
+ "rounding",
325
+ )
326
+ if kind == "decimal" and self.numeric_policy.comparison != "exact":
327
+ fail(
328
+ "decimal_exact",
329
+ "decimal columns require exact comparison",
330
+ "numeric_policy",
331
+ "comparison",
332
+ )
333
+ elif self.numeric_policy is not None:
334
+ fail("numeric_type", "numeric policy requires a numeric type", "numeric_policy")
335
+ if kind in ("date", "timestamp"):
336
+ if self.temporal_policy is None or self.temporal_policy.kind != kind:
337
+ fail(
338
+ "temporal_type",
339
+ "temporal policy must match the logical type",
340
+ "temporal_policy",
341
+ )
342
+ if isinstance(self.logical_type, TimestampType):
343
+ if not (isinstance(self.temporal_policy, TimestampPolicy)):
344
+ raise AssertionError("Internal invariant check failed.")
345
+ if self.logical_type.timezone != self.temporal_policy.timezone:
346
+ fail(
347
+ "temporal_zone",
348
+ "type and policy zones must match",
349
+ "temporal_policy",
350
+ "timezone",
351
+ )
352
+ if self.logical_type.unit != self.temporal_policy.granularity:
353
+ fail(
354
+ "temporal_unit",
355
+ "type and policy granularities must match",
356
+ "temporal_policy",
357
+ "granularity",
358
+ )
359
+ elif self.temporal_policy is not None:
360
+ fail("temporal_type", "temporal policy requires a date or timestamp", "temporal_policy")
361
+ for index, value in enumerate(self.allowed_values):
362
+ self._literal(value, index)
363
+ return self
364
+
365
+ def _literal(self, value: ScalarValue, index: int) -> None:
366
+ location: tuple[str | int, ...] = ("allowed_values", index)
367
+ kind = self.logical_type.kind
368
+ if value.kind == "null":
369
+ if not self.nullable:
370
+ fail("literal_null", "null is not allowed in a nonnullable column", *location)
371
+ return
372
+ if value.kind in ("nan", "infinity"):
373
+ if kind != "float":
374
+ fail("literal_type", "special float value requires a float column", *location)
375
+ if (
376
+ value.kind == "nan"
377
+ and self.nan_policy is not None
378
+ and self.nan_policy.handling == "forbid"
379
+ ):
380
+ fail("literal_nan", "NaN is forbidden by the column policy", *location)
381
+ return
382
+ if value.kind != kind:
383
+ fail("literal_type", "literal kind must match column kind", *location)
384
+ if isinstance(self.logical_type, IntegerType) and value.kind == "integer":
385
+ bits = self.logical_type.bits
386
+ lower = -(2 ** (bits - 1)) if self.logical_type.signed else 0
387
+ upper = 2 ** (bits - (1 if self.logical_type.signed else 0)) - 1
388
+ if not lower <= value.value <= upper:
389
+ fail("integer_range", "literal exceeds integer width/sign", *location, "value")
390
+ if isinstance(self.logical_type, DecimalType) and value.kind == "decimal":
391
+ parts = value.value.as_tuple()
392
+ if not (isinstance(parts.exponent, int)):
393
+ raise AssertionError("Internal invariant check failed.")
394
+ if parts.exponent < -self.logical_type.scale:
395
+ fail(
396
+ "decimal_literal_scale",
397
+ "literal would lose fractional scale",
398
+ *location,
399
+ "value",
400
+ )
401
+ integer_digits = max(len(parts.digits) + parts.exponent, 0) if value.value != 0 else 0
402
+ if integer_digits > self.logical_type.precision - self.logical_type.scale:
403
+ fail(
404
+ "decimal_literal_precision",
405
+ "literal exceeds decimal precision",
406
+ *location,
407
+ "value",
408
+ )
409
+ if (
410
+ isinstance(self.logical_type, FloatType)
411
+ and value.kind == "float"
412
+ and self.logical_type.bits == 32
413
+ ):
414
+ try:
415
+ represented = struct.unpack("!f", struct.pack("!f", value.value))[0]
416
+ except OverflowError:
417
+ fail("float_range", "literal exceeds float32 range", *location, "value")
418
+ if (
419
+ self.numeric_policy is not None
420
+ and self.numeric_policy.comparison == "exact"
421
+ and represented != value.value
422
+ ):
423
+ fail(
424
+ "float_precision",
425
+ "exact float32 literal would require rounding or underflow",
426
+ *location,
427
+ "value",
428
+ )
429
+ if isinstance(self.temporal_policy, DatePolicy) and value.kind == "date":
430
+ policy = self.temporal_policy
431
+ lower = date.fromisoformat(policy.lower.value).toordinal() if policy.lower else None
432
+ upper = date.fromisoformat(policy.upper.value).toordinal() if policy.upper else None
433
+ self._interval(
434
+ date.fromisoformat(value.value).toordinal(), lower, upper, policy.closed, index
435
+ )
436
+ if isinstance(self.temporal_policy, TimestampPolicy) and value.kind == "timestamp":
437
+ try:
438
+ stamp_policy = self.temporal_policy
439
+ point = stamp_policy._bound(value, "value")
440
+ lower = (
441
+ stamp_policy._bound(stamp_policy.lower, "lower") if stamp_policy.lower else None
442
+ )
443
+ upper = (
444
+ stamp_policy._bound(stamp_policy.upper, "upper") if stamp_policy.upper else None
445
+ )
446
+ except ValidationError as exc:
447
+ issue = exc.errors(include_url=False, include_input=False)[0]
448
+ fail(issue["type"], issue["msg"], *location, *issue["loc"][1:])
449
+ self._interval(point, lower, upper, stamp_policy.closed, index)
450
+
451
+ @staticmethod
452
+ def _interval(
453
+ point: int, lower: int | None, upper: int | None, closed: IntervalClosure, index: int
454
+ ) -> None:
455
+ before = lower is not None and (
456
+ point < lower or (point == lower and closed not in ("both", "left"))
457
+ )
458
+ after = upper is not None and (
459
+ point > upper or (point == upper and closed not in ("both", "right"))
460
+ )
461
+ if before or after:
462
+ fail(
463
+ "temporal_literal_bounds",
464
+ "allowed literal falls outside the declared interval",
465
+ "allowed_values",
466
+ index,
467
+ "value",
468
+ )
469
+
470
+
471
+ class GlobalKeyScope(FrozenModel):
472
+ kind: Literal["global"] = "global"
473
+
474
+
475
+ class TableKeyScope(FrozenModel):
476
+ kind: Literal["table"] = "table"
477
+
478
+
479
+ class ColumnKeyScope(FrozenModel):
480
+ kind: Literal["columns"] = "columns"
481
+ columns: Annotated[tuple[Text, ...], Field(min_length=1)]
482
+
483
+ @model_validator(mode="after")
484
+ def unique_columns(self) -> Self:
485
+ unique(self.columns, "columns")
486
+ return self
487
+
488
+
489
+ KeyScope = Annotated[GlobalKeyScope | TableKeyScope | ColumnKeyScope, Field(discriminator="kind")]
490
+
491
+
492
+ class KeySpec(FrozenModel):
493
+ name: Identifier
494
+ columns: Annotated[tuple[Text, ...], Field(min_length=1)]
495
+ unique: bool
496
+ incomplete: Literal["reject", "allow"]
497
+ scope: KeyScope
498
+
499
+ @model_validator(mode="after")
500
+ def unique_columns(self) -> Self:
501
+ unique(self.columns, "columns")
502
+ if isinstance(self.scope, ColumnKeyScope) and set(self.scope.columns) & set(self.columns):
503
+ fail(
504
+ "key_scope_overlap",
505
+ "scope columns must be separate from key columns",
506
+ "scope",
507
+ "columns",
508
+ )
509
+ return self
510
+
511
+
512
+ class SortSpec(FrozenModel):
513
+ column: Text
514
+ direction: Literal["ascending", "descending"]
515
+ nulls: Literal["first", "last"]
516
+
517
+
518
+ class IgnoredOrder(FrozenModel):
519
+ kind: Literal["ignored"] = "ignored"
520
+
521
+
522
+ class SignificantOrder(FrozenModel):
523
+ kind: Literal["significant"] = "significant"
524
+ by: Annotated[tuple[SortSpec, ...], Field(min_length=1)]
525
+ ties: Literal["preserve", "reject"]
526
+
527
+ @model_validator(mode="after")
528
+ def unique_columns(self) -> Self:
529
+ unique(tuple(item.column for item in self.by), "by")
530
+ return self
531
+
532
+
533
+ OrderPolicy = Annotated[IgnoredOrder | SignificantOrder, Field(discriminator="kind")]
534
+ Multiplicity = Literal["preserve", "distinct"]
535
+
536
+
537
+ class TableSchema(FrozenModel):
538
+ columns: Annotated[tuple[ColumnSpec, ...], Field(min_length=1)]
539
+ keys: tuple[KeySpec, ...]
540
+ multiplicity: Multiplicity
541
+ order: OrderPolicy
542
+
543
+ @model_validator(mode="after")
544
+ def valid_references(self) -> Self:
545
+ names = tuple(column.name for column in self.columns)
546
+ for index, name in enumerate(names):
547
+ if name in names[:index]:
548
+ fail("duplicate_column", "column names must be unique", "columns", index, "name")
549
+ key_names = tuple(key.name for key in self.keys)
550
+ for index, name in enumerate(key_names):
551
+ if name in key_names[:index]:
552
+ fail("duplicate_key", "key names must be unique", "keys", index, "name")
553
+ for index, key in enumerate(self.keys):
554
+ scope_columns = key.scope.columns if isinstance(key.scope, ColumnKeyScope) else ()
555
+ for path, values in (
556
+ (("columns",), key.columns),
557
+ (("scope", "columns"), scope_columns),
558
+ ):
559
+ for ref_index, ref in enumerate(values):
560
+ if ref not in names:
561
+ fail(
562
+ "unknown_column",
563
+ "unknown key column: " + ref,
564
+ "keys",
565
+ index,
566
+ *path,
567
+ ref_index,
568
+ )
569
+ if isinstance(self.order, SignificantOrder):
570
+ for index, item in enumerate(self.order.by):
571
+ if item.column not in names:
572
+ fail(
573
+ "unknown_column",
574
+ "unknown order column: " + item.column,
575
+ "order",
576
+ "by",
577
+ index,
578
+ "column",
579
+ )
580
+ return self
581
+
582
+
583
+ class TableSpec(FrozenModel):
584
+ name: Identifier
585
+ schema_spec: TableSchema
586
+
587
+
588
+ class SchemaCheck(FrozenModel):
589
+ kind: Literal["schema"] = "schema"
590
+ table: Identifier
591
+
592
+
593
+ class KeyCheck(FrozenModel):
594
+ kind: Literal["key"] = "key"
595
+ table: Identifier
596
+ key: Identifier
597
+
598
+
599
+ class CardinalityCheck(FrozenModel):
600
+ kind: Literal["cardinality"] = "cardinality"
601
+ table: Identifier
602
+ minimum: NonNegativeInt
603
+ maximum: NonNegativeInt | None = None
604
+
605
+ @model_validator(mode="after")
606
+ def consistent_bounds(self) -> Self:
607
+ if self.maximum is not None and self.maximum < self.minimum:
608
+ fail("cardinality_bounds", "maximum must be at least minimum", "maximum")
609
+ return self
610
+
611
+
612
+ class JoinCheck(FrozenModel):
613
+ kind: Literal["join"] = "join"
614
+ left_table: Identifier
615
+ right_table: Identifier
616
+ left_columns: Annotated[tuple[Text, ...], Field(min_length=1)]
617
+ right_columns: Annotated[tuple[Text, ...], Field(min_length=1)]
618
+ relationship: Literal["one_to_one", "one_to_many", "many_to_one", "many_to_many"]
619
+ unmatched: Literal["allow", "reject"]
620
+
621
+ @model_validator(mode="after")
622
+ def paired_columns(self) -> Self:
623
+ unique(self.left_columns, "left_columns")
624
+ unique(self.right_columns, "right_columns")
625
+ if len(self.left_columns) != len(self.right_columns):
626
+ fail("join_arity", "join column lists must have equal length", "right_columns")
627
+ return self
628
+
629
+
630
+ class OrderCheck(FrozenModel):
631
+ kind: Literal["order"] = "order"
632
+ table: Identifier
633
+ columns: Annotated[tuple[Text, ...], Field(min_length=1)]
634
+
635
+ @model_validator(mode="after")
636
+ def unique_columns(self) -> Self:
637
+ unique(self.columns, "columns")
638
+ return self
639
+
640
+
641
+ class AggregateCheck(FrozenModel):
642
+ kind: Literal["aggregate"] = "aggregate"
643
+ table: Identifier
644
+ group_by: tuple[Text, ...]
645
+ measure: Text | None
646
+ function: Literal["sum", "count", "min", "max"]
647
+ output_table: Identifier
648
+ output_column: Text
649
+
650
+ @model_validator(mode="after")
651
+ def compatible_measure(self) -> Self:
652
+ unique(self.group_by, "group_by")
653
+ if self.function != "count" and self.measure is None:
654
+ fail("missing_measure", "this aggregation requires a measure", "measure")
655
+ return self
656
+
657
+
658
+ RuleDefinition = Annotated[
659
+ SchemaCheck | KeyCheck | CardinalityCheck | JoinCheck | OrderCheck | AggregateCheck,
660
+ Field(discriminator="kind"),
661
+ ]
662
+
663
+
664
+ class PreconditionSpec(FrozenModel):
665
+ precondition_id: Identifier
666
+ requirement: RuleDefinition
667
+
668
+
669
+ class RuleSpec(FrozenModel):
670
+ rule_id: Identifier
671
+ revision: PositiveInt
672
+ specification: RuleDefinition
673
+ severity: Literal["info", "warning", "error"]
674
+ required: bool
675
+ applicability: tuple[Identifier, ...] = ()
676
+
677
+ @model_validator(mode="after")
678
+ def unique_preconditions(self) -> Self:
679
+ unique(self.applicability, "applicability")
680
+ return self
681
+
682
+
683
+ class Contract(FrozenModel):
684
+ schema_version: Literal["1.0"]
685
+ contract_id: Identifier
686
+ revision: PositiveInt
687
+ family: Family
688
+ inputs: Annotated[tuple[TableSpec, ...], Field(min_length=1)]
689
+ output: TableSpec
690
+ preconditions: tuple[PreconditionSpec, ...]
691
+ rules: tuple[RuleSpec, ...]
692
+
693
+ @model_validator(mode="after")
694
+ def valid_references(self) -> Self:
695
+ tables = (*self.inputs, self.output)
696
+ input_names = tuple(table.name for table in self.inputs)
697
+ for index, name in enumerate(input_names):
698
+ if name in input_names[:index]:
699
+ fail("duplicate_table", "table names must be unique", "inputs", index, "name")
700
+ if self.output.name in input_names:
701
+ fail("duplicate_table", "output name must differ from input names", "output", "name")
702
+ unique(tuple(item.precondition_id for item in self.preconditions), "preconditions")
703
+ unique(tuple(item.rule_id for item in self.rules), "rules")
704
+ catalog = {table.name: table.schema_spec for table in tables}
705
+ preconditions = {item.precondition_id for item in self.preconditions}
706
+ for index, item in enumerate(self.preconditions):
707
+ self._definition(item.requirement, catalog, ("preconditions", index, "requirement"))
708
+ for index, rule in enumerate(self.rules):
709
+ self._definition(rule.specification, catalog, ("rules", index, "specification"))
710
+ for ref_index, ref in enumerate(rule.applicability):
711
+ if ref not in preconditions:
712
+ fail(
713
+ "unknown_precondition",
714
+ "unknown precondition: " + ref,
715
+ "rules",
716
+ index,
717
+ "applicability",
718
+ ref_index,
719
+ )
720
+ return self
721
+
722
+ @staticmethod
723
+ def _definition(
724
+ definition: RuleDefinition,
725
+ catalog: dict[str, TableSchema],
726
+ location: tuple[str | int, ...],
727
+ ) -> None:
728
+ def table(name: str, field: str) -> TableSchema:
729
+ if name not in catalog:
730
+ fail("unknown_table", "unknown table: " + name, *location, field)
731
+ return catalog[name]
732
+
733
+ def columns(name: str, refs: tuple[str, ...], field: str) -> None:
734
+ names = {column.name for column in catalog[name].columns}
735
+ for index, ref in enumerate(refs):
736
+ if ref not in names:
737
+ suffix: tuple[str | int, ...] = (
738
+ (field,) if field in ("measure", "output_column") else (field, index)
739
+ )
740
+ fail("unknown_column", "unknown column: " + ref, *location, *suffix)
741
+
742
+ if isinstance(definition, JoinCheck):
743
+ left = table(definition.left_table, "left_table")
744
+ right = table(definition.right_table, "right_table")
745
+ columns(definition.left_table, definition.left_columns, "left_columns")
746
+ columns(definition.right_table, definition.right_columns, "right_columns")
747
+ left_columns = {col.name: col for col in left.columns}
748
+ right_columns = {col.name: col for col in right.columns}
749
+ for index, (lcol, rcol) in enumerate(
750
+ zip(definition.left_columns, definition.right_columns, strict=True)
751
+ ):
752
+ if left_columns[lcol].logical_type != right_columns[rcol].logical_type:
753
+ fail(
754
+ "join_type",
755
+ "join columns require identical logical types",
756
+ *location,
757
+ "right_columns",
758
+ index,
759
+ )
760
+ if not _same_units(left_columns[lcol], right_columns[rcol]):
761
+ fail(
762
+ "join_unit",
763
+ "join columns require identical numeric units",
764
+ *location,
765
+ "right_columns",
766
+ index,
767
+ )
768
+ return
769
+ schema = table(definition.table, "table")
770
+ if isinstance(definition, KeyCheck):
771
+ if definition.key not in {key.name for key in schema.keys}:
772
+ fail("unknown_key", "unknown key: " + definition.key, *location, "key")
773
+ elif isinstance(definition, OrderCheck):
774
+ columns(definition.table, definition.columns, "columns")
775
+ if not isinstance(schema.order, SignificantOrder) or definition.columns != tuple(
776
+ item.column for item in schema.order.by
777
+ ):
778
+ fail(
779
+ "order_policy",
780
+ "order check must reference the complete declared significant order",
781
+ *location,
782
+ "columns",
783
+ )
784
+ elif isinstance(definition, AggregateCheck):
785
+ output = table(definition.output_table, "output_table")
786
+ columns(definition.table, definition.group_by, "group_by")
787
+ columns(definition.output_table, definition.group_by, "group_by")
788
+ columns(definition.output_table, (definition.output_column,), "output_column")
789
+ source = {col.name: col for col in schema.columns}
790
+ target = {col.name: col for col in output.columns}
791
+ for index, name in enumerate(definition.group_by):
792
+ if source[name].logical_type != target[name].logical_type:
793
+ fail(
794
+ "aggregate_group_type",
795
+ "grouping types must be preserved",
796
+ *location,
797
+ "group_by",
798
+ index,
799
+ )
800
+ if not _same_units(source[name], target[name]):
801
+ fail(
802
+ "aggregate_group_unit",
803
+ "grouping units must be preserved",
804
+ *location,
805
+ "group_by",
806
+ index,
807
+ )
808
+ if definition.measure is not None:
809
+ columns(definition.table, (definition.measure,), "measure")
810
+ if definition.function == "sum" and definition.measure is not None:
811
+ if source[definition.measure].logical_type.kind not in (
812
+ "integer",
813
+ "decimal",
814
+ "float",
815
+ ):
816
+ fail("aggregate_type", "sum requires a numeric measure", *location, "measure")
817
+ if not _sum_type(
818
+ source[definition.measure].logical_type,
819
+ target[definition.output_column].logical_type,
820
+ ):
821
+ fail(
822
+ "aggregate_type",
823
+ "sum must preserve numeric family without narrowing its value domain",
824
+ *location,
825
+ "output_column",
826
+ )
827
+ if (
828
+ definition.function == "count"
829
+ and target[definition.output_column].logical_type.kind != "integer"
830
+ ):
831
+ fail("aggregate_type", "count output must be integer", *location, "output_column")
832
+ if (
833
+ definition.function in ("min", "max")
834
+ and definition.measure is not None
835
+ and source[definition.measure].logical_type
836
+ != target[definition.output_column].logical_type
837
+ ):
838
+ fail(
839
+ "aggregate_type",
840
+ "min/max must preserve the logical type",
841
+ *location,
842
+ "output_column",
843
+ )
844
+ if (
845
+ definition.function in ("sum", "min", "max")
846
+ and definition.measure is not None
847
+ and not _same_units(source[definition.measure], target[definition.output_column])
848
+ ):
849
+ fail(
850
+ "aggregate_unit",
851
+ "sum/min/max must preserve numeric units",
852
+ *location,
853
+ "output_column",
854
+ )
855
+
856
+
857
+ def _same_units(source: ColumnSpec, target: ColumnSpec) -> bool:
858
+ return (
859
+ source.numeric_policy is None
860
+ or target.numeric_policy is None
861
+ or source.numeric_policy.unit == target.numeric_policy.unit
862
+ )
863
+
864
+
865
+ def _sum_type(source: LogicalType, target: LogicalType) -> bool:
866
+ if isinstance(source, IntegerType) and isinstance(target, IntegerType):
867
+ source_lower = -(2 ** (source.bits - 1)) if source.signed else 0
868
+ target_lower = -(2 ** (target.bits - 1)) if target.signed else 0
869
+ source_upper = 2 ** (source.bits - int(source.signed)) - 1
870
+ target_upper = 2 ** (target.bits - int(target.signed)) - 1
871
+ return target_lower <= source_lower and target_upper >= source_upper
872
+ if isinstance(source, DecimalType) and isinstance(target, DecimalType):
873
+ return (
874
+ target.scale >= source.scale
875
+ and target.precision - target.scale >= source.precision - source.scale
876
+ )
877
+ if isinstance(source, FloatType) and isinstance(target, FloatType):
878
+ return target.bits >= source.bits
879
+ return False