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/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
@@ -0,0 +1,4 @@
1
+ """setspec.error.v1.
2
+
3
+ TODO: implement per docs/packages/setspec/development-plan.md.
4
+ """
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
@@ -0,0 +1,4 @@
1
+ """setspec.event.v1.
2
+
3
+ TODO: implement per docs/packages/setspec/development-plan.md.
4
+ """