equiframe 0.12.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- equiframe/__init__.py +832 -0
- equiframe/__main__.py +3 -0
- equiframe/_base.py +84 -0
- equiframe/_cli_support.py +125 -0
- equiframe/_comparison_io.py +161 -0
- equiframe/_dataset_provenance.py +146 -0
- equiframe/_decimal.py +22 -0
- equiframe/_diff_capture.py +433 -0
- equiframe/_doctor.py +123 -0
- equiframe/_document_io.py +61 -0
- equiframe/_documents.py +79 -0
- equiframe/_duckdb_transport.py +149 -0
- equiframe/_error_reporting.py +52 -0
- equiframe/_first_use.py +102 -0
- equiframe/_path_compat.py +26 -0
- equiframe/_physical.py +133 -0
- equiframe/_r_review.R +308 -0
- equiframe/_regular_io.py +167 -0
- equiframe/_report_format_v1.py +298 -0
- equiframe/_report_io.py +591 -0
- equiframe/_stability_inventory.py +1421 -0
- equiframe/_timezones.py +257 -0
- equiframe/_vendor/cyclonedx/LICENSE +201 -0
- equiframe/_vendor/cyclonedx/PROVENANCE.md +18 -0
- equiframe/_vendor/cyclonedx/bom-1.6.schema.json +5673 -0
- equiframe/_vendor/cyclonedx/jsf-0.82.schema.json +240 -0
- equiframe/_vendor/cyclonedx/spdx.schema.json +712 -0
- equiframe/adapters.py +454 -0
- equiframe/assertion_types.py +122 -0
- equiframe/assertions.py +271 -0
- equiframe/benchmark.py +101 -0
- equiframe/bundle.py +240 -0
- equiframe/cli.py +693 -0
- equiframe/comparison.py +937 -0
- equiframe/comparison_report.py +660 -0
- equiframe/comparison_types.py +257 -0
- equiframe/compatibility.py +546 -0
- equiframe/contracts.py +879 -0
- equiframe/datasets.py +1301 -0
- equiframe/decision.py +34 -0
- equiframe/destinations.py +295 -0
- equiframe/detector_types.py +431 -0
- equiframe/detectors.py +389 -0
- equiframe/diff.py +192 -0
- equiframe/diff_contracts.py +523 -0
- equiframe/docs/adoption.md +329 -0
- equiframe/docs/api.md +534 -0
- equiframe/docs/arquitecto.md +143 -0
- equiframe/docs/choosing-api.md +84 -0
- equiframe/docs/cientifico.md +180 -0
- equiframe/docs/compatibility.md +254 -0
- equiframe/docs/contributing.md +171 -0
- equiframe/docs/dependencies.md +237 -0
- equiframe/docs/eda.md +166 -0
- equiframe/docs/extending.md +158 -0
- equiframe/docs/fast-ci.md +52 -0
- equiframe/docs/first-use.md +78 -0
- equiframe/docs/index.md +142 -0
- equiframe/docs/ingestion.md +145 -0
- equiframe/docs/inicio.md +129 -0
- equiframe/docs/installed-private.md +97 -0
- equiframe/docs/journeys.md +362 -0
- equiframe/docs/lectura-conversion.md +211 -0
- equiframe/docs/licensing.md +17 -0
- equiframe/docs/maintenance.md +233 -0
- equiframe/docs/migration-ir.md +333 -0
- equiframe/docs/option-audit.md +45 -0
- equiframe/docs/outcome-gates.md +134 -0
- equiframe/docs/performance.md +263 -0
- equiframe/docs/piloto-simulado.md +56 -0
- equiframe/docs/quickstart.md +156 -0
- equiframe/docs/r_review.md +212 -0
- equiframe/docs/recipes.md +248 -0
- equiframe/docs/releasing.md +183 -0
- equiframe/docs/security.md +218 -0
- equiframe/docs/semantics.md +274 -0
- equiframe/docs/sqlite-snapshots.md +262 -0
- equiframe/docs/universal-sources.md +49 -0
- equiframe/docs/universalidad-cinco-pasos.md +107 -0
- equiframe/docs/upgrading.md +538 -0
- equiframe/duckdb_capture.py +410 -0
- equiframe/eda/__init__.py +36 -0
- equiframe/eda/__main__.py +3 -0
- equiframe/eda/_backend.py +612 -0
- equiframe/eda/_inventory.py +49 -0
- equiframe/eda/_paths.py +49 -0
- equiframe/eda/_report.py +291 -0
- equiframe/eda/api.py +535 -0
- equiframe/eda/cli.py +102 -0
- equiframe/eda/models.py +239 -0
- equiframe/errors.py +83 -0
- equiframe/evidence.py +375 -0
- equiframe/examples/integrations/eda_generic.py +60 -0
- equiframe/examples/integrations/outcome_gates.py +94 -0
- equiframe/examples/integrations/sqlite_snapshot.py +246 -0
- equiframe/examples/integrations/sqlite_snapshot_demo.py +226 -0
- equiframe/examples/integrations/universal_first_use.py +296 -0
- equiframe/examples/journeys/README.md +31 -0
- equiframe/examples/journeys/__init__.py +1 -0
- equiframe/examples/journeys/a_pandas_refactor.py +88 -0
- equiframe/examples/journeys/b_native_migration.py +181 -0
- equiframe/examples/journeys/c_retail_etl.py +460 -0
- equiframe/examples/journeys/common.py +136 -0
- equiframe/examples/journeys/d_financial_reconciliation.py +96 -0
- equiframe/examples/journeys/e_telemetry.py +135 -0
- equiframe/examples/journeys/f_data_science_preparation.py +84 -0
- equiframe/examples/journeys/g_external_snapshots.py +197 -0
- equiframe/examples/journeys/h_portable_ci.py +129 -0
- equiframe/examples/journeys/requirements.lock +249 -0
- equiframe/examples/journeys/verify_journeys.py +117 -0
- equiframe/examples/load_convert_compare.py +85 -0
- equiframe/execution.py +355 -0
- equiframe/execution_types.py +177 -0
- equiframe/ingestion.py +744 -0
- equiframe/local.py +126 -0
- equiframe/migration/__init__.py +94 -0
- equiframe/migration/_physical.py +8 -0
- equiframe/migration/backends/_timestamp_transport.py +109 -0
- equiframe/migration/backends/duckdb.py +651 -0
- equiframe/migration/backends/duckdb_decimal.py +125 -0
- equiframe/migration/backends/duckdb_time.py +87 -0
- equiframe/migration/backends/pandas.py +544 -0
- equiframe/migration/backends/polars.py +915 -0
- equiframe/migration/backends/polars_decimal.py +231 -0
- equiframe/migration/io.py +198 -0
- equiframe/migration/ports.py +96 -0
- equiframe/migration/runner.py +545 -0
- equiframe/migration/runner_types.py +405 -0
- equiframe/migration/types.py +354 -0
- equiframe/migration/validation.py +619 -0
- equiframe/normalization.py +358 -0
- equiframe/pipeline.py +274 -0
- equiframe/pipeline_types.py +247 -0
- equiframe/ports.py +39 -0
- equiframe/preprocessing.py +110 -0
- equiframe/profile_types.py +343 -0
- equiframe/profiles.py +447 -0
- equiframe/py.typed +0 -0
- equiframe/r_review.py +706 -0
- equiframe/reduction.py +518 -0
- equiframe/registry.py +121 -0
- equiframe/report_rendering.py +366 -0
- equiframe/report_types.py +294 -0
- equiframe/reporting.py +26 -0
- equiframe/rule_types.py +178 -0
- equiframe/rules.py +819 -0
- equiframe/schemas/r-execution-binding-1.0.schema.json +46 -0
- equiframe/schemas/r-review-binding-1.0.schema.json +28 -0
- equiframe/schemas/r-review-options-1.0.schema.json +38 -0
- equiframe/schemas/r-review-result-1.0.schema.json +207 -0
- equiframe/schemas/reviewed-decision-1.0.schema.json +3394 -0
- equiframe/serialization.py +152 -0
- equiframe/snapshot.py +296 -0
- equiframe/sources.py +283 -0
- equiframe/tables.py +459 -0
- equiframe/types.py +71 -0
- equiframe/values.py +252 -0
- equiframe/verification_request.py +35 -0
- equiframe/verify.py +799 -0
- equiframe/verify_types.py +255 -0
- equiframe/workflow.py +510 -0
- equiframe/workflow_request.py +38 -0
- equiframe-0.12.4.dist-info/METADATA +268 -0
- equiframe-0.12.4.dist-info/RECORD +170 -0
- equiframe-0.12.4.dist-info/WHEEL +4 -0
- equiframe-0.12.4.dist-info/entry_points.txt +3 -0
- equiframe-0.12.4.dist-info/licenses/LICENSE +21 -0
- equiframe-0.12.4.dist-info/licenses/THIRD_PARTY_NOTICES.md +42 -0
- equiframe-0.12.4.dist-info/licenses/tools/cyclonedx/LICENSE +201 -0
- equiframe-0.12.4.dist-info/licenses/tools/cyclonedx/PROVENANCE.md +18 -0
equiframe/values.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Lossless, tagged contract literals; these are metadata, not table encoders."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import re
|
|
5
|
+
from datetime import date, datetime, timedelta
|
|
6
|
+
from decimal import Decimal, InvalidOperation, localcontext
|
|
7
|
+
from typing import Annotated, Literal
|
|
8
|
+
|
|
9
|
+
from pydantic import (
|
|
10
|
+
AfterValidator,
|
|
11
|
+
BeforeValidator,
|
|
12
|
+
Field,
|
|
13
|
+
PlainSerializer,
|
|
14
|
+
WithJsonSchema,
|
|
15
|
+
model_validator,
|
|
16
|
+
)
|
|
17
|
+
from typing_extensions import Self
|
|
18
|
+
|
|
19
|
+
from equiframe._base import FrozenModel, Text, fail
|
|
20
|
+
from equiframe._timezones import timezone
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _decimal(value: object) -> Decimal:
|
|
24
|
+
if isinstance(value, Decimal):
|
|
25
|
+
result = value
|
|
26
|
+
elif isinstance(value, str) and re.fullmatch(
|
|
27
|
+
r"[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?", value
|
|
28
|
+
):
|
|
29
|
+
try:
|
|
30
|
+
result = Decimal(value)
|
|
31
|
+
except InvalidOperation as exc:
|
|
32
|
+
raise ValueError("invalid decimal text") from exc
|
|
33
|
+
else:
|
|
34
|
+
raise ValueError("decimal requires a Decimal or exact decimal string; numbers are rejected")
|
|
35
|
+
if not result.is_finite():
|
|
36
|
+
raise ValueError("decimal must be finite; use tagged float special values")
|
|
37
|
+
return result
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _decimal_text(value: Decimal) -> str:
|
|
41
|
+
# str(Decimal) consults context.capitals even though it never rounds.
|
|
42
|
+
with localcontext() as decimal_context:
|
|
43
|
+
decimal_context.capitals = 1
|
|
44
|
+
return str(value)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
ExactDecimal = Annotated[
|
|
48
|
+
Decimal,
|
|
49
|
+
BeforeValidator(_decimal),
|
|
50
|
+
PlainSerializer(_decimal_text, return_type=str),
|
|
51
|
+
WithJsonSchema(
|
|
52
|
+
{
|
|
53
|
+
"type": "string",
|
|
54
|
+
"pattern": r"^[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?$",
|
|
55
|
+
}
|
|
56
|
+
),
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _zone(value: str) -> str:
|
|
61
|
+
timezone(value)
|
|
62
|
+
return value
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
TimeZone = Annotated[Text, AfterValidator(_zone)]
|
|
66
|
+
TimeUnit = Literal["s", "ms", "us", "ns"]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class NullValue(FrozenModel):
|
|
70
|
+
kind: Literal["null"] = "null"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class NaNValue(FrozenModel):
|
|
74
|
+
kind: Literal["nan"] = "nan"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class InfinityValue(FrozenModel):
|
|
78
|
+
kind: Literal["infinity"] = "infinity"
|
|
79
|
+
sign: Literal["positive", "negative"]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class BooleanValue(FrozenModel):
|
|
83
|
+
kind: Literal["boolean"] = "boolean"
|
|
84
|
+
value: bool
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class IntegerValue(FrozenModel):
|
|
88
|
+
kind: Literal["integer"] = "integer"
|
|
89
|
+
value: int
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class StringValue(FrozenModel):
|
|
93
|
+
kind: Literal["string"] = "string"
|
|
94
|
+
value: str
|
|
95
|
+
|
|
96
|
+
@model_validator(mode="after")
|
|
97
|
+
def valid_unicode(self) -> Self:
|
|
98
|
+
if any(0xD800 <= ord(char) <= 0xDFFF for char in self.value):
|
|
99
|
+
fail("invalid_unicode", "string contains a surrogate code point", "value")
|
|
100
|
+
return self
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class DecimalValue(FrozenModel):
|
|
104
|
+
kind: Literal["decimal"] = "decimal"
|
|
105
|
+
value: ExactDecimal
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _float(value: object) -> float:
|
|
109
|
+
if isinstance(value, bool) or not isinstance(value, (float, int)):
|
|
110
|
+
raise ValueError("float literal requires a JSON number")
|
|
111
|
+
try:
|
|
112
|
+
result = float(value)
|
|
113
|
+
except OverflowError as exc:
|
|
114
|
+
raise ValueError("float literal exceeds representable range") from exc
|
|
115
|
+
if not math.isfinite(result):
|
|
116
|
+
raise ValueError("nonfinite floats require a tagged special value")
|
|
117
|
+
if isinstance(value, int) and int(result) != value:
|
|
118
|
+
raise ValueError("integer cannot be represented as a float without loss")
|
|
119
|
+
return result
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class FloatValue(FrozenModel):
|
|
123
|
+
kind: Literal["float"] = "float"
|
|
124
|
+
value: Annotated[float, BeforeValidator(_float)]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class DateValue(FrozenModel):
|
|
128
|
+
kind: Literal["date"] = "date"
|
|
129
|
+
value: str = Field(pattern=r"^[0-9]{4}-[0-9]{2}-[0-9]{2}$")
|
|
130
|
+
|
|
131
|
+
@model_validator(mode="after")
|
|
132
|
+
def valid_date(self) -> Self:
|
|
133
|
+
try:
|
|
134
|
+
date.fromisoformat(self.value)
|
|
135
|
+
except ValueError:
|
|
136
|
+
fail("invalid_date", "invalid calendar date", "value")
|
|
137
|
+
return self
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
_TIMESTAMP = re.compile(
|
|
141
|
+
r"(?P<date>[0-9]{4}-[0-9]{2}-[0-9]{2})T"
|
|
142
|
+
r"(?P<hour>[0-9]{2}):(?P<minute>[0-9]{2}):(?P<second>[0-9]{2})"
|
|
143
|
+
r"(?P<fraction>\.[0-9]{1,9})?(?P<offset>Z|[+-][0-9]{2}:[0-9]{2})?"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _timestamp_datetime(value: str) -> datetime:
|
|
148
|
+
"""Calendar/offset validation without relying on the Python 3.11 ISO parser.
|
|
149
|
+
|
|
150
|
+
The declared literal grammar admits 1-9 fraction digits. datetime stores only
|
|
151
|
+
microseconds; exact nanosecond ordering is calculated separately from the
|
|
152
|
+
original unmodified literal by TimestampValue.nanoseconds.
|
|
153
|
+
"""
|
|
154
|
+
match = _TIMESTAMP.fullmatch(value)
|
|
155
|
+
if match is None:
|
|
156
|
+
raise ValueError("invalid declared timestamp grammar")
|
|
157
|
+
fraction = (match["fraction"] or "").removeprefix(".")
|
|
158
|
+
microseconds = ("." + fraction[:6].ljust(6, "0")) if fraction else ""
|
|
159
|
+
offset = "+00:00" if match["offset"] == "Z" else (match["offset"] or "")
|
|
160
|
+
calendar = (
|
|
161
|
+
f"{match['date']}T{match['hour']}:{match['minute']}:{match['second']}{microseconds}{offset}"
|
|
162
|
+
)
|
|
163
|
+
return datetime.fromisoformat(calendar)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class TimestampValue(FrozenModel):
|
|
167
|
+
kind: Literal["timestamp"] = "timestamp"
|
|
168
|
+
value: str
|
|
169
|
+
zone: TimeZone | None = None
|
|
170
|
+
|
|
171
|
+
@model_validator(mode="after")
|
|
172
|
+
def valid_timestamp(self) -> Self:
|
|
173
|
+
match = _TIMESTAMP.fullmatch(self.value)
|
|
174
|
+
if match is None:
|
|
175
|
+
fail(
|
|
176
|
+
"invalid_timestamp",
|
|
177
|
+
"expected ISO timestamp with at most 9 fractional digits",
|
|
178
|
+
"value",
|
|
179
|
+
)
|
|
180
|
+
offset = match["offset"]
|
|
181
|
+
if offset == "-00:00":
|
|
182
|
+
fail(
|
|
183
|
+
"unknown_offset", "-00:00 denotes an unknown offset and is not an instant", "value"
|
|
184
|
+
)
|
|
185
|
+
if offset not in (None, "Z") and (int(offset[1:3]) > 23 or int(offset[4:6]) > 59):
|
|
186
|
+
fail("invalid_offset", "invalid timestamp offset", "value")
|
|
187
|
+
try:
|
|
188
|
+
instant = _timestamp_datetime(self.value)
|
|
189
|
+
except ValueError:
|
|
190
|
+
fail("invalid_timestamp", "invalid calendar date or clock time", "value")
|
|
191
|
+
if self.zone is not None:
|
|
192
|
+
if instant.tzinfo is None:
|
|
193
|
+
fail("missing_offset", "a zoned literal requires an explicit offset", "value")
|
|
194
|
+
try:
|
|
195
|
+
actual_offset = instant.astimezone(timezone(self.zone)).utcoffset()
|
|
196
|
+
except (OverflowError, ValueError):
|
|
197
|
+
fail("timestamp_range", "zoned instant exceeds supported calendar range", "value")
|
|
198
|
+
if actual_offset != instant.utcoffset():
|
|
199
|
+
fail("zone_offset_mismatch", "offset disagrees with the declared IANA zone", "zone")
|
|
200
|
+
return self
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def has_offset(self) -> bool:
|
|
204
|
+
return bool(re.search(r"(?:Z|[+-][0-9]{2}:[0-9]{2})$", self.value))
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def fractional_digits(self) -> int:
|
|
208
|
+
match = _TIMESTAMP.fullmatch(self.value)
|
|
209
|
+
if not (match is not None):
|
|
210
|
+
raise AssertionError("Internal invariant check failed.")
|
|
211
|
+
return len((match["fraction"] or "")[1:])
|
|
212
|
+
|
|
213
|
+
def nanoseconds(self, *, instant: bool) -> int:
|
|
214
|
+
"""Exact ordering key; no float timestamp or sub-microsecond truncation."""
|
|
215
|
+
match = _TIMESTAMP.fullmatch(self.value)
|
|
216
|
+
if not (match is not None):
|
|
217
|
+
raise AssertionError("Internal invariant check failed.")
|
|
218
|
+
day = date.fromisoformat(match["date"]).toordinal()
|
|
219
|
+
seconds = day * 86400 + int(match["hour"]) * 3600
|
|
220
|
+
seconds += int(match["minute"]) * 60 + int(match["second"])
|
|
221
|
+
offset = match["offset"]
|
|
222
|
+
if instant and not self.has_offset:
|
|
223
|
+
raise ValueError("an instant ordering key requires an explicit offset")
|
|
224
|
+
if instant and offset not in (None, "Z"):
|
|
225
|
+
shift = int(offset[1:3]) * 3600 + int(offset[4:6]) * 60
|
|
226
|
+
seconds -= shift if offset[0] == "+" else -shift
|
|
227
|
+
return seconds * 10**9 + int((match["fraction"] or "")[1:].ljust(9, "0") or "0")
|
|
228
|
+
|
|
229
|
+
def in_zone(self, zone: str, *, fold: int) -> datetime:
|
|
230
|
+
"""Used only to validate an explicitly declared naive-time policy."""
|
|
231
|
+
return _timestamp_datetime(self.value).replace(tzinfo=timezone(zone), fold=fold)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
ScalarValue = Annotated[
|
|
235
|
+
NullValue
|
|
236
|
+
| NaNValue
|
|
237
|
+
| InfinityValue
|
|
238
|
+
| BooleanValue
|
|
239
|
+
| IntegerValue
|
|
240
|
+
| StringValue
|
|
241
|
+
| DecimalValue
|
|
242
|
+
| FloatValue
|
|
243
|
+
| DateValue
|
|
244
|
+
| TimestampValue,
|
|
245
|
+
Field(discriminator="kind"),
|
|
246
|
+
]
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def offset_nanoseconds(offset: timedelta | None) -> int:
|
|
250
|
+
if offset is None:
|
|
251
|
+
raise ValueError("missing UTC offset")
|
|
252
|
+
return (offset.days * 86400 + offset.seconds) * 10**9 + offset.microseconds * 1000
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Data-only requests for verification of explicitly supplied external artifacts."""
|
|
2
|
+
|
|
3
|
+
from pydantic import model_validator
|
|
4
|
+
from typing_extensions import Self
|
|
5
|
+
|
|
6
|
+
from equiframe._base import FrozenModel
|
|
7
|
+
from equiframe.comparison_types import ComparisonOptions, ComparisonPolicy
|
|
8
|
+
from equiframe.contracts import Contract
|
|
9
|
+
from equiframe.pipeline_types import PipelineInput
|
|
10
|
+
from equiframe.types import ArtifactRef, Sha256
|
|
11
|
+
from equiframe.verify_types import ExpectedProvenance, IndependentReviewEvidence
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class VerificationRequest(FrozenModel):
|
|
15
|
+
contract: Contract
|
|
16
|
+
outputs: tuple[PipelineInput, ...]
|
|
17
|
+
expected: ArtifactRef | None = None
|
|
18
|
+
provenance: ExpectedProvenance | None = None
|
|
19
|
+
reviews: tuple[IndependentReviewEvidence, ...] = ()
|
|
20
|
+
snapshots: tuple[PipelineInput, ...] = ()
|
|
21
|
+
plan_sha256: Sha256 | None = None
|
|
22
|
+
policy: ComparisonPolicy = ComparisonPolicy()
|
|
23
|
+
options: ComparisonOptions = ComparisonOptions()
|
|
24
|
+
require_expected: bool = False
|
|
25
|
+
require_rules: bool = False
|
|
26
|
+
request_review: bool = False
|
|
27
|
+
|
|
28
|
+
@model_validator(mode="after")
|
|
29
|
+
def names(self) -> Self:
|
|
30
|
+
if not self.outputs:
|
|
31
|
+
raise ValueError("at least one output is required")
|
|
32
|
+
for entries in (self.outputs, self.snapshots):
|
|
33
|
+
if len({item.name for item in entries}) != len(entries):
|
|
34
|
+
raise ValueError("artifact binding names must be unique")
|
|
35
|
+
return self
|