setspec 0.2.0__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.
- setspec/__about__.py +3 -0
- setspec/__init__.py +114 -0
- setspec/artifacts.py +4 -0
- setspec/base.py +256 -0
- setspec/benchmark/v1.py +594 -0
- setspec/capability/v1.py +394 -0
- setspec/envelope.py +468 -0
- setspec/error/v1.py +4 -0
- setspec/errors.py +68 -0
- setspec/event/v1.py +4 -0
- setspec/goal/v1.py +390 -0
- setspec/goldens/.gitkeep +0 -0
- setspec/machine/v1.py +131 -0
- setspec/metrics.py +184 -0
- setspec/model/v1.py +169 -0
- setspec/provenance.py +59 -0
- setspec/py.typed +0 -0
- setspec/schemas/.gitkeep +0 -0
- setspec/serialization.py +330 -0
- setspec/vocabulary.py +205 -0
- setspec-0.2.0.dist-info/METADATA +160 -0
- setspec-0.2.0.dist-info/RECORD +24 -0
- setspec-0.2.0.dist-info/WHEEL +4 -0
- setspec-0.2.0.dist-info/licenses/LICENSE +201 -0
setspec/envelope.py
ADDED
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
"""Contract module — the schema envelope, version parsing and the reader policy.
|
|
2
|
+
|
|
3
|
+
Imports pydantic and :mod:`baseaicore`; performs no I/O. Reads a clock only through the injected
|
|
4
|
+
``clock`` parameter of :func:`dump_envelope`, never by calling :func:`~datetime.datetime.now`
|
|
5
|
+
directly (coding standards §5).
|
|
6
|
+
|
|
7
|
+
The envelope marks a document that outlives the request that produced it — an export, an evidence
|
|
8
|
+
bundle, a result, an event. An HTTP API's own request and response bodies are *not* enveloped;
|
|
9
|
+
they are versioned by their path and documented by OpenAPI. That boundary, and the membership test
|
|
10
|
+
for it, is ADR-0025; the envelope's own shape and the
|
|
11
|
+
reader policy are ADR-0009.
|
|
12
|
+
|
|
13
|
+
The reader policy is one sentence with two halves, and both halves matter:
|
|
14
|
+
|
|
15
|
+
* A **newer minor** within a supported major is accepted, unknown fields and all. Minor means
|
|
16
|
+
additive, so a v1.0 reader can read a v1.1 document without knowing what was added.
|
|
17
|
+
* An **unsupported major** is rejected outright, never partially parsed. Major means a field
|
|
18
|
+
changed meaning, so reading "the parts we recognise" would produce confident nonsense — the one
|
|
19
|
+
outcome worse than refusing to read at all.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import re
|
|
25
|
+
import warnings
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from types import MappingProxyType
|
|
28
|
+
from typing import TYPE_CHECKING, Any, Final, Self
|
|
29
|
+
|
|
30
|
+
from baseaicore import Clock, ValidationError, utc_now
|
|
31
|
+
from pydantic import BaseModel
|
|
32
|
+
from pydantic import ValidationError as PydanticValidationError
|
|
33
|
+
|
|
34
|
+
from setspec.base import PreservingPayload
|
|
35
|
+
from setspec.errors import SchemaVersionUnsupported
|
|
36
|
+
from setspec.serialization import (
|
|
37
|
+
MAX_PAYLOAD_BYTES,
|
|
38
|
+
MAX_PAYLOAD_DEPTH,
|
|
39
|
+
TimestampField,
|
|
40
|
+
canonical_dumps,
|
|
41
|
+
parse_json,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
if TYPE_CHECKING:
|
|
45
|
+
from collections.abc import Mapping, Sequence
|
|
46
|
+
from datetime import datetime
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"DRAFT_SCHEMAS",
|
|
50
|
+
"SUPPORTED_SCHEMAS",
|
|
51
|
+
"GeneratorInfo",
|
|
52
|
+
"SchemaEnvelope",
|
|
53
|
+
"SchemaVersion",
|
|
54
|
+
"dump_envelope",
|
|
55
|
+
"load_envelope",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
_VERSION_PATTERN: Final[re.Pattern[str]] = re.compile(r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$")
|
|
59
|
+
_SCHEMA_NAME_PATTERN: Final[re.Pattern[str]] = re.compile(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True, slots=True, order=True)
|
|
63
|
+
class SchemaVersion:
|
|
64
|
+
"""A payload type's ``MAJOR.MINOR`` version.
|
|
65
|
+
|
|
66
|
+
Two numbers, no patch component. A patch has no meaning for a data contract — either the shape
|
|
67
|
+
changed or it did not — and offering one would invite writers to record a change that readers
|
|
68
|
+
cannot act on (ADR-0009, rejected
|
|
69
|
+
alternatives).
|
|
70
|
+
|
|
71
|
+
Ordering is by major then minor, so versions sort and compare as a reader expects. Ordering is
|
|
72
|
+
*not* the compatibility test: a higher version is not necessarily readable, and
|
|
73
|
+
:func:`load_envelope` decides acceptance by major, never by comparison.
|
|
74
|
+
|
|
75
|
+
Attributes:
|
|
76
|
+
major: Incremented by a breaking change — a removed or renamed field, a changed type or
|
|
77
|
+
meaning, or tightened validation. Starts at 1; a published schema has no 0.x phase.
|
|
78
|
+
minor: Incremented by an additive change — a new optional field. Reset to 0 on a major
|
|
79
|
+
bump.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
major: int
|
|
83
|
+
minor: int
|
|
84
|
+
|
|
85
|
+
def __post_init__(self) -> None:
|
|
86
|
+
"""Reject versions that cannot appear on the wire.
|
|
87
|
+
|
|
88
|
+
Raises:
|
|
89
|
+
ValidationError: If ``major`` is below 1 or ``minor`` is negative.
|
|
90
|
+
"""
|
|
91
|
+
if self.major < 1:
|
|
92
|
+
raise ValidationError(
|
|
93
|
+
f"Schema major version must be at least 1; got {self.major}. Version 1.0 is where "
|
|
94
|
+
"a published payload type starts — there is no 0.x phase for a wire contract, "
|
|
95
|
+
"because a consumer cannot pin against a shape that admits it is provisional.",
|
|
96
|
+
details={"field": "major", "value": self.major},
|
|
97
|
+
)
|
|
98
|
+
if self.minor < 0:
|
|
99
|
+
raise ValidationError(
|
|
100
|
+
f"Schema minor version cannot be negative; got {self.minor}.",
|
|
101
|
+
details={"field": "minor", "value": self.minor},
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
@classmethod
|
|
105
|
+
def parse(cls, text: str) -> Self:
|
|
106
|
+
"""Parse a ``"MAJOR.MINOR"`` string.
|
|
107
|
+
|
|
108
|
+
Only the canonical spelling is accepted: no leading zeros, no third component, no ``v``
|
|
109
|
+
prefix, no whitespace. The strictness buys byte-stability — ``"1.00"`` and ``"1.0"`` would
|
|
110
|
+
parse to the same version but re-serialize to one form, so a document that used the other
|
|
111
|
+
would not survive a load/dump round trip unchanged (spec §11.3, §11.4).
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
text: The version string, e.g. ``"1.0"``.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
The parsed version.
|
|
118
|
+
|
|
119
|
+
Raises:
|
|
120
|
+
ValidationError: If ``text`` is not a canonical ``MAJOR.MINOR`` string, or names a
|
|
121
|
+
major below 1.
|
|
122
|
+
"""
|
|
123
|
+
match = _VERSION_PATTERN.fullmatch(text)
|
|
124
|
+
if match is None:
|
|
125
|
+
raise ValidationError(
|
|
126
|
+
f"Not a canonical schema version: {text!r}. Expected exactly 'MAJOR.MINOR' with "
|
|
127
|
+
"no leading zeros, no patch component and no 'v' prefix — for example '1.0'.",
|
|
128
|
+
details={"field": "schema_version", "value": text},
|
|
129
|
+
)
|
|
130
|
+
return cls(major=int(match.group(1)), minor=int(match.group(2)))
|
|
131
|
+
|
|
132
|
+
def __str__(self) -> str:
|
|
133
|
+
"""Return the canonical ``"MAJOR.MINOR"`` spelling, as it appears on the wire."""
|
|
134
|
+
return f"{self.major}.{self.minor}"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
SUPPORTED_SCHEMAS: Final[Mapping[str, Mapping[int, SchemaVersion]]] = MappingProxyType(
|
|
138
|
+
{
|
|
139
|
+
"model.identity": MappingProxyType({1: SchemaVersion(1, 0)}),
|
|
140
|
+
"machine.profile": MappingProxyType({1: SchemaVersion(1, 0)}),
|
|
141
|
+
"benchmark.result": MappingProxyType({1: SchemaVersion(1, 0)}),
|
|
142
|
+
"benchmark.run_summary": MappingProxyType({1: SchemaVersion(1, 0)}),
|
|
143
|
+
"capability.evidence": MappingProxyType({1: SchemaVersion(1, 0)}),
|
|
144
|
+
"benchmark.evidence_bundle": MappingProxyType({1: SchemaVersion(1, 0)}),
|
|
145
|
+
"benchmark.goal_pack": MappingProxyType({1: SchemaVersion(1, 0)}),
|
|
146
|
+
"benchmark.calibration_report": MappingProxyType({1: SchemaVersion(1, 0)}),
|
|
147
|
+
}
|
|
148
|
+
)
|
|
149
|
+
"""Every payload type this build can read, as ``{schema name: {major: highest known minor}}``.
|
|
150
|
+
|
|
151
|
+
Keyed by **major**, not by exact version, because the reader policy accepts any minor within a
|
|
152
|
+
supported major — including a minor newer than this build has heard of. Exact-version matching
|
|
153
|
+
would contradict that policy and would make every additive schema change a breaking one for every
|
|
154
|
+
consumer (ADR-0009 rule 9). The recorded minor is
|
|
155
|
+
therefore documentation of what this build knows, never a ceiling on what it accepts.
|
|
156
|
+
|
|
157
|
+
``benchmark.goal_pack`` and ``benchmark.calibration_report`` were added for FreeWeight's
|
|
158
|
+
user-authored goal benchmarks (ADR-0031, ADR-0032). The calibration report is registered as a
|
|
159
|
+
payload in its own right rather than folded into ``capability.evidence`` because it is meaningful
|
|
160
|
+
precisely when **no** evidence was emitted: a goal below its calibration gate produces no evidence
|
|
161
|
+
record at all, so without a separate schema the most informative outcome a user can get would have
|
|
162
|
+
no wire form.
|
|
163
|
+
|
|
164
|
+
**Draft status (as of `0.2.0`, Phase 2).** The six entries above are registered so FreeWeight has
|
|
165
|
+
concrete, negotiable schemas to build against, but none is frozen: [development plan Phase 4]
|
|
166
|
+
(../../docs/packages/setspec/development-plan.md) may still change a field's shape after
|
|
167
|
+
FreeWeight has produced real results, and that change may need to happen at ``1.0`` rather than as
|
|
168
|
+
a new major, because nothing has shipped against these schemas outside this repository yet. A
|
|
169
|
+
reader pinning to exactly ``1.0`` today is pinning to a draft; the schema names and this fact are
|
|
170
|
+
documented wherever each payload module defines its fields, not encoded into the version number
|
|
171
|
+
itself — ``SchemaVersion`` has no "draft" concept, and adding one here would weaken the exact
|
|
172
|
+
``MAJOR.MINOR`` contract every *frozen* schema relies on for every consumer that is not SetSpec's
|
|
173
|
+
own Phase 4.
|
|
174
|
+
|
|
175
|
+
Two payload types remain unregistered at `0.2.0`: ``event.envelope`` and ``error.envelope``
|
|
176
|
+
arrive in Phase 3, and ``prompt.record``/``prompt.manifest`` in Phase 5. Naming them here before
|
|
177
|
+
their fields exist would be the same guess Phase 2's own risk note warns against, one layer up.
|
|
178
|
+
|
|
179
|
+
A :func:`load_envelope` call naming a schema that is not registered raises
|
|
180
|
+
:class:`~setspec.errors.SchemaVersionUnsupported` with an empty ``supported`` list, which reads as
|
|
181
|
+
"this build publishes no version of that schema" — the honest answer, and the same one a consumer
|
|
182
|
+
gets for a schema retired in a future major.
|
|
183
|
+
"""
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
DRAFT_SCHEMAS: Final[frozenset[str]] = frozenset(
|
|
187
|
+
{
|
|
188
|
+
"model.identity",
|
|
189
|
+
"machine.profile",
|
|
190
|
+
"benchmark.result",
|
|
191
|
+
"benchmark.run_summary",
|
|
192
|
+
"capability.evidence",
|
|
193
|
+
"benchmark.evidence_bundle",
|
|
194
|
+
"benchmark.goal_pack",
|
|
195
|
+
"benchmark.calibration_report",
|
|
196
|
+
}
|
|
197
|
+
)
|
|
198
|
+
"""Which registered schemas are still provisional, and may change shape without a major bump.
|
|
199
|
+
|
|
200
|
+
[Development plan Phase 2](../../docs/packages/setspec/development-plan.md) requires draft status
|
|
201
|
+
to be *visible in the API*, not only described in prose, and asks for it to be marked "in
|
|
202
|
+
``SUPPORTED_SCHEMAS``". It is recorded here instead of inside the version itself, because the
|
|
203
|
+
alternative — a ``"1.0-draft"`` version string — would have to be parsed by
|
|
204
|
+
:class:`SchemaVersion`, whose whole contract is that a version is exactly two integers and that
|
|
205
|
+
non-canonical spellings are refused. Loosening that to carry a temporary status would weaken the
|
|
206
|
+
version format every *frozen* schema depends on, permanently, to describe a condition that ends at
|
|
207
|
+
Phase 4. A separate set says the same thing, is equally machine-readable, and disappears cleanly:
|
|
208
|
+
freezing a schema is one deletion from this set.
|
|
209
|
+
|
|
210
|
+
A schema listed here is registered and readable — negotiation treats it exactly like any other —
|
|
211
|
+
but a consumer outside this repository should know that pinning to its ``1.0`` today is pinning to
|
|
212
|
+
a shape [Phase 4](../../docs/packages/setspec/development-plan.md) may still correct once
|
|
213
|
+
FreeWeight has produced real results against it. Empty is the goal state.
|
|
214
|
+
"""
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class GeneratorInfo(PreservingPayload):
|
|
218
|
+
"""Which component produced a document, and at which version.
|
|
219
|
+
|
|
220
|
+
Every envelope carries one. It is what makes an exported file self-describing months later,
|
|
221
|
+
and it is why an event payload needs no ``source`` field of its own — the generator already
|
|
222
|
+
names the producing application
|
|
223
|
+
(ADR-0025 §3).
|
|
224
|
+
|
|
225
|
+
Preserving rather than strict, like the envelope that holds it: a future SetSpec that adds a
|
|
226
|
+
field here must not make today's readers *reject* documents, which is what a strict model would
|
|
227
|
+
do. Preservation degrades to carrying an unknown key; strictness degrades to refusing the file.
|
|
228
|
+
|
|
229
|
+
Attributes:
|
|
230
|
+
name: The producing component's distribution name, lowercase — ``"freeweight"``,
|
|
231
|
+
``"loadcoach"``, ``"setspec"``.
|
|
232
|
+
version: That component's version string, as it reports it. Not constrained to semver: a
|
|
233
|
+
development build may legitimately report a git description, and refusing to record
|
|
234
|
+
what actually produced a document helps nobody.
|
|
235
|
+
"""
|
|
236
|
+
|
|
237
|
+
name: str
|
|
238
|
+
version: str
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# `schema` is the field name ADR-0009 §1 fixes on the wire, and it shadows pydantic v2's
|
|
242
|
+
# deprecated `BaseModel.schema()` classmethod, which warns on class creation. The method is
|
|
243
|
+
# superseded by `model_json_schema()` and is never called here, so the shadowing is harmless —
|
|
244
|
+
# but a library that warns on import is not, and renaming the field would change the wire format
|
|
245
|
+
# to work around a deprecation in a dependency.
|
|
246
|
+
with warnings.catch_warnings():
|
|
247
|
+
warnings.filterwarnings(
|
|
248
|
+
"ignore",
|
|
249
|
+
message=r'Field name "schema" .*shadows an attribute',
|
|
250
|
+
category=UserWarning,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
class SchemaEnvelope[PayloadT](PreservingPayload):
|
|
254
|
+
"""A versioned wrapper around one transferable payload.
|
|
255
|
+
|
|
256
|
+
The five fields are fixed by ADR-0009 §1::
|
|
257
|
+
|
|
258
|
+
{"schema": "benchmark.result", "schema_version": "1.0",
|
|
259
|
+
"generated_at": "2026-08-21T09:14:02.318Z",
|
|
260
|
+
"generator": {"name": "freeweight", "version": "1.0.0"},
|
|
261
|
+
"payload": { … }}
|
|
262
|
+
|
|
263
|
+
Generic in its payload so that a caller who knows what they are reading gets a typed
|
|
264
|
+
``payload``; :func:`load_envelope` returns ``SchemaEnvelope[Any]`` with the payload still a
|
|
265
|
+
plain mapping, because deciding *which* model to validate it into is the caller's
|
|
266
|
+
business and depends on which of the two — ``Out`` or ``In`` — they need.
|
|
267
|
+
|
|
268
|
+
Preserving rather than strict, for the reason in
|
|
269
|
+
ADR-0009 rule 4 applied to the wrapper: an
|
|
270
|
+
old reader re-exporting a document written by a newer one must not silently drop an envelope
|
|
271
|
+
field it has not heard of.
|
|
272
|
+
|
|
273
|
+
Attributes:
|
|
274
|
+
schema: The payload type name, e.g. ``"benchmark.result"``. Two or more
|
|
275
|
+
lowercase segments joined by dots.
|
|
276
|
+
schema_version: The canonical ``"MAJOR.MINOR"`` string. Kept as text, not as a
|
|
277
|
+
:class:`SchemaVersion`, so the document round-trips byte-for-byte; read
|
|
278
|
+
:attr:`version` for the parsed form.
|
|
279
|
+
generated_at: When the document was produced — UTC, millisecond precision.
|
|
280
|
+
generator: The component that produced it.
|
|
281
|
+
payload: The document itself.
|
|
282
|
+
"""
|
|
283
|
+
|
|
284
|
+
# Shadows pydantic v2's deprecated `BaseModel.schema()` classmethod. The name is fixed
|
|
285
|
+
# by ADR-0009 §1 and the method is superseded by `model_json_schema()`, so the collision
|
|
286
|
+
# is with something already on its way out; renaming the field would change the wire.
|
|
287
|
+
schema: str # type: ignore[assignment] # deliberate: ADR-0009 §1 fixes this field name
|
|
288
|
+
schema_version: str
|
|
289
|
+
generated_at: TimestampField
|
|
290
|
+
generator: GeneratorInfo
|
|
291
|
+
payload: PayloadT
|
|
292
|
+
|
|
293
|
+
@property
|
|
294
|
+
def version(self) -> SchemaVersion:
|
|
295
|
+
"""Return :attr:`schema_version` parsed.
|
|
296
|
+
|
|
297
|
+
Returns:
|
|
298
|
+
The parsed version. Always succeeds: the string was validated on construction, so a
|
|
299
|
+
constructed envelope cannot hold an unparsable version.
|
|
300
|
+
"""
|
|
301
|
+
return SchemaVersion.parse(self.schema_version)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def load_envelope(
|
|
305
|
+
data: bytes | str | Mapping[str, Any],
|
|
306
|
+
*,
|
|
307
|
+
expect: str,
|
|
308
|
+
supported: Sequence[SchemaVersion] | None = None,
|
|
309
|
+
max_bytes: int = MAX_PAYLOAD_BYTES,
|
|
310
|
+
max_depth: int = MAX_PAYLOAD_DEPTH,
|
|
311
|
+
) -> SchemaEnvelope[Any]:
|
|
312
|
+
"""Parse and validate an envelope, applying the reader policy to its version.
|
|
313
|
+
|
|
314
|
+
The payload is left as a plain mapping. Which model it belongs in — the strict ``Out`` or the
|
|
315
|
+
preserving ``In`` — depends on whether the caller is about to re-export it, and this function
|
|
316
|
+
does not guess.
|
|
317
|
+
|
|
318
|
+
Args:
|
|
319
|
+
data: The document, as JSON text, UTF-8 bytes, or an already-parsed mapping. Text and
|
|
320
|
+
bytes go through the size and depth guards; a mapping the caller parsed itself is
|
|
321
|
+
depth-checked but not size-checked, since its cost has already been paid.
|
|
322
|
+
expect: The schema name the caller intends to read. A document declaring a different
|
|
323
|
+
schema is rejected rather than duck-typed — two payload types with compatible fields
|
|
324
|
+
are still two payload types.
|
|
325
|
+
supported: The versions this caller accepts, overriding :data:`SUPPORTED_SCHEMAS`.
|
|
326
|
+
Acceptance is decided by **major**: listing ``1.0`` accepts ``1.7`` as well. Defaults
|
|
327
|
+
to whatever this build registers for ``expect``.
|
|
328
|
+
max_bytes: Size guard for text and bytes input; see
|
|
329
|
+
:func:`~setspec.serialization.parse_json`.
|
|
330
|
+
max_depth: Depth guard; see :func:`~setspec.serialization.parse_json`.
|
|
331
|
+
|
|
332
|
+
Returns:
|
|
333
|
+
The validated envelope, with ``payload`` as the raw mapping the document carried.
|
|
334
|
+
|
|
335
|
+
Raises:
|
|
336
|
+
SchemaVersionUnsupported: If the document's major version is not among the supported
|
|
337
|
+
majors. ``details`` names the schema, the received version and every supported one.
|
|
338
|
+
ValidationError: If the document is oversized, too deeply nested, not valid UTF-8, not
|
|
339
|
+
parsable JSON, not an object at the top level, missing envelope fields, or declares a
|
|
340
|
+
schema other than ``expect``. Structural failures name every offending field path at
|
|
341
|
+
once in ``details["errors"]``, so one call reports everything that is wrong.
|
|
342
|
+
"""
|
|
343
|
+
document = _as_mapping(data, max_bytes=max_bytes, max_depth=max_depth)
|
|
344
|
+
envelope = _validate_envelope(document)
|
|
345
|
+
if envelope.schema != expect:
|
|
346
|
+
raise ValidationError(
|
|
347
|
+
f"Expected a {expect!r} document but the envelope declares {envelope.schema!r}. "
|
|
348
|
+
"Payload types are not interchangeable even when their fields happen to line up.",
|
|
349
|
+
details={"field": "schema", "expected": expect, "received": envelope.schema},
|
|
350
|
+
)
|
|
351
|
+
_negotiate(envelope, supported=supported)
|
|
352
|
+
return envelope
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def dump_envelope(
|
|
356
|
+
payload: BaseModel | Mapping[str, Any],
|
|
357
|
+
*,
|
|
358
|
+
schema: str,
|
|
359
|
+
version: SchemaVersion,
|
|
360
|
+
generator: GeneratorInfo,
|
|
361
|
+
generated_at: datetime | None = None,
|
|
362
|
+
clock: Clock = utc_now,
|
|
363
|
+
) -> str:
|
|
364
|
+
"""Wrap a payload in an envelope and serialize it to canonical JSON.
|
|
365
|
+
|
|
366
|
+
Canonical means byte-identical for equal input, which is what makes an exported document
|
|
367
|
+
hashable, diffable between releases and comparable across the CI matrix (spec §11.4, §16).
|
|
368
|
+
|
|
369
|
+
Args:
|
|
370
|
+
payload: The document to wrap — a payload model, dumped through its own serializers so
|
|
371
|
+
measurements and timestamps take their wire forms, or an already-plain mapping.
|
|
372
|
+
schema: The payload type name, e.g. ``"benchmark.result"``.
|
|
373
|
+
version: The version being written. This is the writer's declaration of what it emitted,
|
|
374
|
+
so it must match the model actually used, not the newest version that exists.
|
|
375
|
+
generator: The component doing the writing.
|
|
376
|
+
generated_at: The document's timestamp. Pass this to reproduce an existing document
|
|
377
|
+
byte-for-byte; leave it unset for a new one and the clock supplies it.
|
|
378
|
+
clock: Where "now" comes from when ``generated_at`` is not given. Injected so that a test
|
|
379
|
+
can produce a fixed document without patching the interpreter (coding standards §5).
|
|
380
|
+
|
|
381
|
+
Returns:
|
|
382
|
+
Canonical JSON text. Encode as UTF-8 for the bytes.
|
|
383
|
+
|
|
384
|
+
Raises:
|
|
385
|
+
ValidationError: If ``schema`` is not a well-formed payload type name, if ``generated_at``
|
|
386
|
+
is naive, or if the payload holds something canonical JSON cannot represent
|
|
387
|
+
unambiguously.
|
|
388
|
+
"""
|
|
389
|
+
if _SCHEMA_NAME_PATTERN.fullmatch(schema) is None:
|
|
390
|
+
raise ValidationError(
|
|
391
|
+
f"Not a well-formed schema name: {schema!r}. Expected two or more lowercase segments "
|
|
392
|
+
"joined by dots, such as 'benchmark.result' or 'capability.evidence'.",
|
|
393
|
+
details={"field": "schema", "value": schema},
|
|
394
|
+
)
|
|
395
|
+
body = payload.model_dump() if isinstance(payload, BaseModel) else dict(payload)
|
|
396
|
+
envelope = SchemaEnvelope[Any](
|
|
397
|
+
schema=schema,
|
|
398
|
+
schema_version=str(version),
|
|
399
|
+
generated_at=generated_at if generated_at is not None else clock(),
|
|
400
|
+
generator=generator,
|
|
401
|
+
payload=body,
|
|
402
|
+
)
|
|
403
|
+
return canonical_dumps(envelope)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _as_mapping(
|
|
407
|
+
data: bytes | str | Mapping[str, Any],
|
|
408
|
+
*,
|
|
409
|
+
max_bytes: int,
|
|
410
|
+
max_depth: int,
|
|
411
|
+
) -> Mapping[str, Any]:
|
|
412
|
+
"""Return the document as a mapping, guarding untrusted text and rejecting non-objects."""
|
|
413
|
+
document = (
|
|
414
|
+
parse_json(data, max_bytes=max_bytes, max_depth=max_depth)
|
|
415
|
+
if isinstance(data, bytes | str)
|
|
416
|
+
else data
|
|
417
|
+
)
|
|
418
|
+
if not isinstance(document, dict):
|
|
419
|
+
raise ValidationError(
|
|
420
|
+
f"An envelope is a JSON object; got {type(document).__name__}. A bare array or scalar "
|
|
421
|
+
"carries no schema name or version, so there is nothing to negotiate against.",
|
|
422
|
+
details={"received_type": type(document).__name__},
|
|
423
|
+
)
|
|
424
|
+
return document
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _validate_envelope(document: Mapping[str, Any]) -> SchemaEnvelope[Any]:
|
|
428
|
+
"""Validate the envelope structure, reporting every offending field path in one error."""
|
|
429
|
+
try:
|
|
430
|
+
return SchemaEnvelope[Any].model_validate(dict(document))
|
|
431
|
+
except PydanticValidationError as exc:
|
|
432
|
+
paths = [
|
|
433
|
+
{
|
|
434
|
+
"field": ".".join(str(part) for part in error["loc"]),
|
|
435
|
+
"problem": error["msg"],
|
|
436
|
+
}
|
|
437
|
+
for error in exc.errors()
|
|
438
|
+
]
|
|
439
|
+
summary = "; ".join(f"{item['field']}: {item['problem']}" for item in paths)
|
|
440
|
+
raise ValidationError(
|
|
441
|
+
f"Envelope is structurally invalid ({len(paths)} problem(s)): {summary}.",
|
|
442
|
+
details={"errors": paths},
|
|
443
|
+
) from exc
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _negotiate(envelope: SchemaEnvelope[Any], *, supported: Sequence[SchemaVersion] | None) -> None:
|
|
447
|
+
"""Apply the reader policy, raising if the document's major version is not supported."""
|
|
448
|
+
accepted = (
|
|
449
|
+
tuple(supported)
|
|
450
|
+
if supported is not None
|
|
451
|
+
else tuple(SUPPORTED_SCHEMAS.get(envelope.schema, {}).values())
|
|
452
|
+
)
|
|
453
|
+
received = envelope.version
|
|
454
|
+
if any(candidate.major == received.major for candidate in accepted):
|
|
455
|
+
return
|
|
456
|
+
formatted = [str(candidate) for candidate in sorted(accepted)]
|
|
457
|
+
supported_text = ", ".join(formatted) if formatted else "no version of this schema"
|
|
458
|
+
raise SchemaVersionUnsupported(
|
|
459
|
+
f"Cannot read {envelope.schema!r} version {received}: this build supports "
|
|
460
|
+
f"{supported_text}. A major version means fields changed meaning, so the document is not "
|
|
461
|
+
"partially parsed. Upgrade the reader, or re-export from the producer at a supported "
|
|
462
|
+
"major.",
|
|
463
|
+
details={
|
|
464
|
+
"schema": envelope.schema,
|
|
465
|
+
"received": str(received),
|
|
466
|
+
"supported": formatted,
|
|
467
|
+
},
|
|
468
|
+
)
|
setspec/error/v1.py
ADDED
setspec/errors.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Contract module — the errors SetSpec raises across a package boundary.
|
|
2
|
+
|
|
3
|
+
Imports pydantic but performs no I/O. Every error here derives from
|
|
4
|
+
:class:`baseaicore.SuiteError`, so a caller that already handles suite errors handles these too,
|
|
5
|
+
and every ``code`` is part of the public contract
|
|
6
|
+
([spec §13](../../docs/packages/setspec/spec.md)): adding a code is a minor change, changing what
|
|
7
|
+
one means is a major one.
|
|
8
|
+
|
|
9
|
+
Two error types reach callers of this package, and the split is deliberate:
|
|
10
|
+
|
|
11
|
+
* :class:`SchemaVersionUnsupported` — the payload is well-formed but this build cannot read its
|
|
12
|
+
major version. It is a *negotiation* outcome, not a defect in the data, and consumers branch on
|
|
13
|
+
it to fall back or to tell the user to upgrade.
|
|
14
|
+
* :class:`~baseaicore.ValidationError` — the payload is malformed, oversized, too deeply nested, or
|
|
15
|
+
structurally wrong. Re-exported here so callers import both from one place.
|
|
16
|
+
|
|
17
|
+
Pydantic's own ``ValidationError`` is deliberately **not** part of this package's contract at the
|
|
18
|
+
envelope boundary. :func:`~setspec.envelope.load_envelope` converts it, preserving every offending
|
|
19
|
+
field path in ``details["errors"]`` — so a caller gets one error type and still learns everything
|
|
20
|
+
that was wrong at once (spec §13). Constructing a payload model directly is ordinary pydantic use
|
|
21
|
+
and raises pydantic's error, as a pydantic user expects.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from typing import Any, ClassVar
|
|
27
|
+
|
|
28
|
+
from baseaicore import SuiteError, ValidationError
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"SchemaVersionUnsupported",
|
|
32
|
+
"ValidationError",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SchemaVersionUnsupported(SuiteError):
|
|
37
|
+
"""A payload declares a schema major version this build cannot read.
|
|
38
|
+
|
|
39
|
+
Raised only for an unsupported **major**. A newer *minor* within a supported major is accepted
|
|
40
|
+
by design — that is the reader policy in
|
|
41
|
+
ADR-0009 rule 3, and it is what lets two
|
|
42
|
+
applications at different versions exchange documents. An unsupported major is never partially
|
|
43
|
+
parsed: a breaking change means the fields no longer mean what this build thinks they mean, so
|
|
44
|
+
reading "the parts we recognise" would produce confident nonsense.
|
|
45
|
+
|
|
46
|
+
``details`` always carries three keys, because a user who sees this error needs to know what
|
|
47
|
+
they have and what would read it:
|
|
48
|
+
|
|
49
|
+
* ``schema`` — the payload type, e.g. ``"benchmark.result"``.
|
|
50
|
+
* ``received`` — the version string found in the document.
|
|
51
|
+
* ``supported`` — every version this build accepts, formatted, in ascending order. An empty
|
|
52
|
+
list means this build knows the schema name but publishes no version of it yet.
|
|
53
|
+
|
|
54
|
+
Attributes:
|
|
55
|
+
code: ``"SCHEMA_VERSION_UNSUPPORTED"``, stable and part of the public contract.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
code: ClassVar[str] = "SCHEMA_VERSION_UNSUPPORTED"
|
|
59
|
+
|
|
60
|
+
def __init__(self, message: str, *, details: dict[str, Any] | None = None) -> None:
|
|
61
|
+
"""Build the error.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
message: What was received, what is supported, and what the caller can do — upgrade
|
|
65
|
+
the reader, or re-export from the producer at an older version.
|
|
66
|
+
details: Structured context; see the class docstring for the keys callers rely on.
|
|
67
|
+
"""
|
|
68
|
+
super().__init__(message, details=details)
|
setspec/event/v1.py
ADDED