weft-kernel 0.1.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.
- weft_kernel/__init__.py +178 -0
- weft_kernel/blocking.py +370 -0
- weft_kernel/context.py +250 -0
- weft_kernel/discovery.py +1181 -0
- weft_kernel/errors.py +73 -0
- weft_kernel/fallback.py +159 -0
- weft_kernel/payload/__init__.py +43 -0
- weft_kernel/payload/applicability.py +298 -0
- weft_kernel/payload/ext.py +172 -0
- weft_kernel/payload/ids.py +22 -0
- weft_kernel/payload/lineage.py +90 -0
- weft_kernel/payload/media_type.py +18 -0
- weft_kernel/payload/node.py +260 -0
- weft_kernel/payload/outcome.py +40 -0
- weft_kernel/payload/property.py +60 -0
- weft_kernel/payload/vector.py +28 -0
- weft_kernel/pipeline.py +748 -0
- weft_kernel/py.typed +0 -0
- weft_kernel/registry.py +692 -0
- weft_kernel/resolution.py +1582 -0
- weft_kernel/runner.py +1436 -0
- weft_kernel/seam.py +725 -0
- weft_kernel-0.1.0.dist-info/METADATA +88 -0
- weft_kernel-0.1.0.dist-info/RECORD +27 -0
- weft_kernel-0.1.0.dist-info/WHEEL +4 -0
- weft_kernel-0.1.0.dist-info/licenses/LICENSE +21 -0
- weft_kernel-0.1.0.dist-info/licenses/NOTICE +77 -0
weft_kernel/pipeline.py
ADDED
|
@@ -0,0 +1,748 @@
|
|
|
1
|
+
"""The pipeline as data — the authored document, published by the kernel.
|
|
2
|
+
|
|
3
|
+
Settled in G2 and specified in `docs/02-extension-model.md` §3 → *One model,
|
|
4
|
+
two directions*: "The pipeline **is** a frozen Pydantic model published by the
|
|
5
|
+
kernel. YAML is a serialisation of it; Python code constructs the same model
|
|
6
|
+
directly. That is 'both' with one implementation — one validator, one error
|
|
7
|
+
set, one resolved form, no builder DSL and therefore no second grammar to keep
|
|
8
|
+
in step."
|
|
9
|
+
|
|
10
|
+
**Two directions, one model, and no third way in.** `Pipeline.model_validate`
|
|
11
|
+
takes the mapping a YAML loader hands back; `Pipeline(...)` takes the same
|
|
12
|
+
thing as arguments. There is no builder, no `PipelineBuilder.add_stage()`, no
|
|
13
|
+
`from_yaml` classmethod — a second construction path is a second grammar, and
|
|
14
|
+
a second grammar is a place for the two to disagree about what a pipeline is.
|
|
15
|
+
For the same reason **the kernel parses no YAML**: G1 fixes its dependencies at
|
|
16
|
+
`pydantic` and `opentelemetry-api`, so whoever opens the file brings the loader
|
|
17
|
+
and hands a mapping in, exactly as `weft-cli` already does for `weft.toml`.
|
|
18
|
+
|
|
19
|
+
**The error set is `pydantic.ValidationError`, deliberately, and it is not
|
|
20
|
+
`PipelineResolutionError`.** `02` §3 → *When resolution fails* gives every
|
|
21
|
+
resolution failure its own `WeftError` subclass carrying the pipeline, the
|
|
22
|
+
stage ids, the distributions in conflict and the remedy. None of that exists
|
|
23
|
+
yet at this point: a document that will not validate has no resolved parent, no
|
|
24
|
+
registry lookup and no distributions to name — it is malformed text, not a
|
|
25
|
+
pipeline that could not be resolved. Keeping the two apart is what stops the
|
|
26
|
+
resolution family from becoming the place every failure is filed, which is the
|
|
27
|
+
shape `02` §3 rules out for the failure-mode ratchet's sake.
|
|
28
|
+
|
|
29
|
+
**Loud is still required of it.** `extra="forbid"` alone names the key it
|
|
30
|
+
refused but never the keys that exist, which fails `01`'s rule that an unknown
|
|
31
|
+
name says what the valid options are. `_document_keys_only` runs first and says
|
|
32
|
+
both, so a mistyped `steps:` is answered with `stages`.
|
|
33
|
+
|
|
34
|
+
**What this module deliberately does not carry.** The declarations behind ordering
|
|
35
|
+
constraints (task 1.2 — `intact`/`destroys`) live on a *plugin class*, never on a
|
|
36
|
+
document: a pipeline names a plugin, it does not restate what that plugin declares, so
|
|
37
|
+
there is nothing of task 1.2's for this module to hold. `extends` and `vars` are here as
|
|
38
|
+
*data* — resolution gives them meaning in tasks 1.3 and 1.14 — because they are what `02`
|
|
39
|
+
§3's own `base-de.yaml` is made of, and a model that could not hold the specification's
|
|
40
|
+
smallest example would not be the model the specification describes.
|
|
41
|
+
|
|
42
|
+
**Slots — task 1.11, `02` §3 → *Slots*.** `Pipeline.slots` is where the root opens a
|
|
43
|
+
named position for a pack's contribution: "a pipeline may contribute into a slot a
|
|
44
|
+
pipeline opted into. It may never rewrite a pipeline that did not ask." Declaring one is
|
|
45
|
+
the opt-in; `SlotDeclaration.after`/`before` position it against the root's own stages,
|
|
46
|
+
on `InsertOperator`'s own terms. What this module refuses, rather than half-reads: a
|
|
47
|
+
slot id wearing a pack's qualified spelling (`_refuse_qualified_id`, shared with
|
|
48
|
+
`StageDeclaration.id` and `remove`'s own targets), a slot colliding with a stage id or
|
|
49
|
+
another slot, and `slots:` alongside `extends` — a slot is a position in *this*
|
|
50
|
+
pipeline's own list, so it belongs where `stages:` does. What this module does **not**
|
|
51
|
+
do: fill a slot. That needs a registry, a caller's contributions and the resolved chain
|
|
52
|
+
`stages:` alone cannot see — `weft_kernel.resolution.resolve` (task 1.11's other half) is
|
|
53
|
+
where a slot actually gets a contribution, or is recorded as landing nowhere.
|
|
54
|
+
|
|
55
|
+
**The four derivation operators — task 1.4, `02` §3 → the operator table.**
|
|
56
|
+
`insert`, `replace`, `remove` and `set` are **four keyed blocks**, exactly as
|
|
57
|
+
`02` §3's `specific.yaml` prints them, never flattened into one tagged
|
|
58
|
+
sequence: a document says `insert:` and `remove:` as its own top-level keys,
|
|
59
|
+
and a model that instead carried one `operators: list[Operator]` field would
|
|
60
|
+
have to invent a discriminator the specification never asks an author to
|
|
61
|
+
write, purely to get back to the shape already on the page.
|
|
62
|
+
|
|
63
|
+
**Four separate fields cannot, by themselves, say what order they apply in.**
|
|
64
|
+
`02` §3: "Operators apply in written order" — and a `remove` undoing an
|
|
65
|
+
`insert` at the same id, or the reverse, are different pipelines with
|
|
66
|
+
different resolved forms. A pydantic model's *field* order is fixed once for
|
|
67
|
+
every document — declaration order, not the order a particular author's
|
|
68
|
+
mapping happened to list keys in — so if application order were read off
|
|
69
|
+
field order, one of *remove-then-insert* and *insert-then-remove* would be
|
|
70
|
+
permanently unwritable no matter which order the four fields were declared
|
|
71
|
+
in. Application order is therefore read from the *document* (or the *call*),
|
|
72
|
+
not assumed from the schema: `_operator_order` is a private attribute filled
|
|
73
|
+
by `_track_operator_order`, a `mode="wrap"` validator that inspects the raw
|
|
74
|
+
mapping's key order before delegating to ordinary field validation, and
|
|
75
|
+
`operator_order` is its public, read-only face — `weft_kernel.resolution`
|
|
76
|
+
reads it to decide which block to apply first. `Pipeline(remove=..., insert=
|
|
77
|
+
...)` gets the identical treatment: Python's own keyword-argument evaluation
|
|
78
|
+
already preserves call order into the `**data` dict `BaseModel.__init__`
|
|
79
|
+
builds, which is the same dict a document's loader hands to
|
|
80
|
+
`model_validate`, so there is exactly one code path computing "the order",
|
|
81
|
+
not one per direction. This is what task 1.4 settles from the note 1.1 left
|
|
82
|
+
open in `docs/build-ledger.md`.
|
|
83
|
+
|
|
84
|
+
**`fallback` is data here too.** `02` §1 gives the kernel a fallback combinator
|
|
85
|
+
over any contract, and `11` §4 keeps `fallback:` a per-stage list "tried in
|
|
86
|
+
order until one produces". This module records the list; nothing in it runs —
|
|
87
|
+
`weft_kernel.fallback.try_in_order` is what walks it, reached through
|
|
88
|
+
`Runner._invoke_stage` at task 2.28, which is two steps further on than any
|
|
89
|
+
document model has business knowing about.
|
|
90
|
+
|
|
91
|
+
**Two stage-shaped models now live in the kernel, and 1.3 reconciles them.**
|
|
92
|
+
`StageDeclaration` here is a stage as *written* — a bare `use:` name and an
|
|
93
|
+
unvalidated `with:` block; `weft_kernel.runner.StageSpec` is a stage as
|
|
94
|
+
*resolved enough to run* — a contract type, a plugin name and that plugin's
|
|
95
|
+
own config object. They are the two ends of resolution, which is the step
|
|
96
|
+
neither of them performs, and the resolved form task 1.3 builds is what turns
|
|
97
|
+
one into the other. Until it exists they are deliberately not related by
|
|
98
|
+
inheritance or conversion, because a converter written before resolution
|
|
99
|
+
exists would have to guess at the contract a bare name belongs to — which is
|
|
100
|
+
exactly the lookup resolution does against a registry.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
from __future__ import annotations
|
|
104
|
+
|
|
105
|
+
from collections.abc import Mapping
|
|
106
|
+
from types import MappingProxyType
|
|
107
|
+
from typing import Annotated, Final, Self, cast
|
|
108
|
+
|
|
109
|
+
from pydantic import (
|
|
110
|
+
BaseModel,
|
|
111
|
+
ConfigDict,
|
|
112
|
+
Field,
|
|
113
|
+
ModelWrapValidatorHandler,
|
|
114
|
+
PlainSerializer,
|
|
115
|
+
PrivateAttr,
|
|
116
|
+
SerializerFunctionWrapHandler,
|
|
117
|
+
field_validator,
|
|
118
|
+
model_serializer,
|
|
119
|
+
model_validator,
|
|
120
|
+
)
|
|
121
|
+
from pydantic.fields import FieldInfo
|
|
122
|
+
|
|
123
|
+
type Scalar = str | int | float | bool
|
|
124
|
+
"""What a `vars:` value may be. `02` §3: "Scalars only; no var may reference another"."""
|
|
125
|
+
|
|
126
|
+
_QUALIFIER: Final[str] = ":"
|
|
127
|
+
"""What separates a distribution from a stage id in a pack's contribution. `02` §3 → *Slots*."""
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _refuse_qualified_id(value: str, *, subject: str) -> str:
|
|
131
|
+
"""Refuse a `:`-qualified spelling wherever an author, not a pack, is naming something.
|
|
132
|
+
|
|
133
|
+
Shared by every place task 1.11 draws the same line: a stage id
|
|
134
|
+
(`StageDeclaration.id`), a slot id (`SlotDeclaration.id`), and a `remove` target
|
|
135
|
+
(`Pipeline._remove_targets_are_not_a_packs_to_name`). All three read the identical
|
|
136
|
+
reserved spelling `weft-kg:entities` and refuse it for the identical reason — the
|
|
137
|
+
qualifier belongs to a pack's contribution, never to anything an author writes,
|
|
138
|
+
including the string an author writes to *remove* one. `subject` only changes the
|
|
139
|
+
message's noun, never the check.
|
|
140
|
+
"""
|
|
141
|
+
if _QUALIFIER in value:
|
|
142
|
+
raise ValueError(
|
|
143
|
+
f"{subject} '{value}' contains '{_QUALIFIER}', which is reserved: a pack's "
|
|
144
|
+
f"contribution into a slot is qualified by its distribution name "
|
|
145
|
+
f"(weft-kg{_QUALIFIER}entities) so that it can never collide with anything "
|
|
146
|
+
f"you write. Name this {subject} without it."
|
|
147
|
+
)
|
|
148
|
+
return value
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
type ConfigBlock = Annotated[
|
|
152
|
+
Mapping[str, object], PlainSerializer(dict, return_type=dict[str, object], when_used="always")
|
|
153
|
+
]
|
|
154
|
+
"""A `with:` block: read-only in memory (see `_read_only`), a plain mapping in a document."""
|
|
155
|
+
|
|
156
|
+
type VarBlock = Annotated[
|
|
157
|
+
Mapping[str, Scalar], PlainSerializer(dict, return_type=dict[str, Scalar], when_used="always")
|
|
158
|
+
]
|
|
159
|
+
"""A `vars:` block, on the same terms as `ConfigBlock`."""
|
|
160
|
+
|
|
161
|
+
_NO_CONFIG: Final[Mapping[str, object]] = MappingProxyType({})
|
|
162
|
+
_NO_VARS: Final[Mapping[str, Scalar]] = MappingProxyType({})
|
|
163
|
+
"""The empty blocks, shared rather than built per instance — safe precisely because `_read_only`
|
|
164
|
+
makes every mapping on these models un-writable, so there is nothing an instance could do to a
|
|
165
|
+
shared default. It is also what keeps `model_dump(exclude_defaults=True)` able to recognise an
|
|
166
|
+
untouched block and leave it out of the document it writes back."""
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class StageDeclaration(BaseModel):
|
|
170
|
+
"""One stage as written in a pipeline document: a position, a plugin name, its configuration.
|
|
171
|
+
|
|
172
|
+
`use` is a **bare** plugin name, which G3 settled and `02` §2 records:
|
|
173
|
+
"Pipelines select plugins by bare name (§3 — `use: docling`)". It is not
|
|
174
|
+
qualified by contract or by distribution, and a collision between two packs
|
|
175
|
+
claiming it is refused at registration and pinned in `weft.toml` — never
|
|
176
|
+
disambiguated here, because that would put operator policy in a document
|
|
177
|
+
that has to stay portable between machines.
|
|
178
|
+
|
|
179
|
+
`config` is spelled `with` in the document and `config` in Python, which is
|
|
180
|
+
the one place the two directions cannot use the same word: `with` is a
|
|
181
|
+
Python keyword, so a field literally named for it could never be passed by
|
|
182
|
+
a Python caller at all. It is a `validation_alias`/`serialization_alias`
|
|
183
|
+
pair rather than a plain `alias` for exactly that reason — a plain `alias`
|
|
184
|
+
is what a type checker reads as the constructor's parameter name, and it
|
|
185
|
+
would report every `StageDeclaration(config=...)` call as an error while
|
|
186
|
+
offering a keyword in its place. `populate_by_name` accepts either
|
|
187
|
+
spelling, and `model_dump(by_alias=True)` writes the document's back.
|
|
188
|
+
|
|
189
|
+
**That has a cost worth naming in a module whose thesis is "no second
|
|
190
|
+
grammar": a document writing `config:` validates too.** `populate_by_name`
|
|
191
|
+
cannot tell a YAML mapping from a Python call — both arrive here as a
|
|
192
|
+
mapping — so accepting `config` for the Python direction accepts it for
|
|
193
|
+
the document as well, and a document that used it would be written back as
|
|
194
|
+
`with:`. The alternative is worse: refusing the field name would make the
|
|
195
|
+
Python direction unwritable, since `with=` is a syntax error. `with` is the
|
|
196
|
+
spelling this module names, teaches and writes.
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
model_config = ConfigDict(frozen=True, extra="forbid", populate_by_name=True)
|
|
200
|
+
|
|
201
|
+
id: str = Field(min_length=1)
|
|
202
|
+
use: str = Field(min_length=1)
|
|
203
|
+
config: ConfigBlock = Field(
|
|
204
|
+
default_factory=lambda: _NO_CONFIG,
|
|
205
|
+
validation_alias="with",
|
|
206
|
+
serialization_alias="with",
|
|
207
|
+
)
|
|
208
|
+
fallback: tuple[str, ...] = ()
|
|
209
|
+
|
|
210
|
+
@model_validator(mode="before")
|
|
211
|
+
@classmethod
|
|
212
|
+
def _stage_keys_only(cls, value: object) -> object:
|
|
213
|
+
return _known_keys_only(cls, value, subject="stage")
|
|
214
|
+
|
|
215
|
+
@field_validator("id", mode="after")
|
|
216
|
+
@classmethod
|
|
217
|
+
def _id_is_not_a_pack_s_to_give(cls, value: str) -> str:
|
|
218
|
+
"""`weft-kg:entities` is a contributed id, and an author may not write one.
|
|
219
|
+
|
|
220
|
+
`02` §3 → *Slots*: a contributed stage id "is qualified by
|
|
221
|
+
distribution (`weft-kg:entities`) so they cannot collide with the
|
|
222
|
+
author's". Nothing makes that true unless the qualified spelling is
|
|
223
|
+
reserved — an author free to use it could collide with a pack that is
|
|
224
|
+
not installed yet, and the collision would arrive with the
|
|
225
|
+
installation rather than with the edit that caused it. This is an
|
|
226
|
+
invariant that holds with no registry present, which is what puts it
|
|
227
|
+
in the authored form rather than in slot filling.
|
|
228
|
+
"""
|
|
229
|
+
return _refuse_qualified_id(value, subject="stage id")
|
|
230
|
+
|
|
231
|
+
@field_validator("config", mode="after")
|
|
232
|
+
@classmethod
|
|
233
|
+
def _config_is_read_only(cls, value: Mapping[str, object]) -> Mapping[str, object]:
|
|
234
|
+
return _read_only(value)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
PIPELINE_OPERATOR_MARK: Final[str] = "pipeline_operator"
|
|
238
|
+
"""The `json_schema_extra` key task 1.15's ratchet reads off `Pipeline.model_fields`.
|
|
239
|
+
|
|
240
|
+
`tests/architecture/test_ff11_pipeline_integrity.py` pins the closed operator set as a
|
|
241
|
+
ratchet, and `01` -> *Fitness functions* item 11(a) requires its "actual" side to be
|
|
242
|
+
"derived from the code (the actual operator fields on the model), never a hand-written
|
|
243
|
+
list" — a second tuple of the same four strings, sitting in the test file with no
|
|
244
|
+
structural link back to this class, is exactly the drift `docs/README.md` opens by
|
|
245
|
+
describing. Each of the four operator fields below carries `Field(...,
|
|
246
|
+
json_schema_extra={PIPELINE_OPERATOR_MARK: True})` for exactly that reason: it is the
|
|
247
|
+
one place in the tree that *decides* a field is an operator block, so the ratchet reads
|
|
248
|
+
`Pipeline.model_fields` itself rather than retyping the decision. `_OPERATOR_KEYS`
|
|
249
|
+
immediately below is the first reader of that mark, not a second one — see its own
|
|
250
|
+
docstring.
|
|
251
|
+
"""
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _operator_key_order(value: object) -> tuple[str, ...]:
|
|
255
|
+
"""Which of `_OPERATOR_KEYS` a document (or call) wrote, in the order it wrote them.
|
|
256
|
+
|
|
257
|
+
Reads the **raw** value `Pipeline` was handed — a `Mapping` for both a YAML loader's
|
|
258
|
+
result and a Python call's `**kwargs`, per the module docstring — before any field
|
|
259
|
+
validation reorders or drops anything. A `Pipeline` passed back in (pydantic re-
|
|
260
|
+
validating an already-built instance, e.g. through a nested model) carries its own
|
|
261
|
+
`operator_order` forward instead of losing it to a fresh, order-blind read of `dict(
|
|
262
|
+
instance)`. Anything else — a mapping missing entirely, or garbage that later
|
|
263
|
+
validation will refuse anyway — resolves to no operators written, which is exactly
|
|
264
|
+
what an empty `_OPERATOR_KEYS` intersection already means.
|
|
265
|
+
"""
|
|
266
|
+
if isinstance(value, Pipeline):
|
|
267
|
+
return value.operator_order
|
|
268
|
+
if isinstance(value, Mapping):
|
|
269
|
+
written = cast("Mapping[object, object]", value)
|
|
270
|
+
return tuple(str(key) for key in written if str(key) in _OPERATOR_KEYS)
|
|
271
|
+
return ()
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _reorder_operator_keys(dumped: dict[str, object], order: tuple[str, ...]) -> dict[str, object]:
|
|
275
|
+
"""Rewrite `dumped`'s operator keys into `order`, everything else exactly where it was.
|
|
276
|
+
|
|
277
|
+
Pydantic's default serializer walks fields in **declaration** order — `insert` before
|
|
278
|
+
`replace` before `remove` before `set`, always — which is precisely the field-order
|
|
279
|
+
assumption the module docstring rules out for *reading* a document, and round-
|
|
280
|
+
tripping would silently reintroduce it on the way back out: a document that wrote
|
|
281
|
+
`remove:` above `insert:` would serialise with `insert:` first, indistinguishable from
|
|
282
|
+
a document that never made that choice. This runs after `handler(self)` has already
|
|
283
|
+
applied `by_alias`/`exclude_defaults`/`mode`, so it only ever reorders keys that
|
|
284
|
+
survived those — an operator block excluded by `exclude_defaults=True` because it was
|
|
285
|
+
never written is not reinserted here either. Non-operator keys keep their original
|
|
286
|
+
relative order and position; the four operator keys, wherever they were, are emitted
|
|
287
|
+
together at the position the first of them held, in `order`.
|
|
288
|
+
"""
|
|
289
|
+
present = [key for key in order if key in dumped]
|
|
290
|
+
if not present:
|
|
291
|
+
return dumped
|
|
292
|
+
reordered: dict[str, object] = {}
|
|
293
|
+
operators_placed = False
|
|
294
|
+
for key, value in dumped.items():
|
|
295
|
+
if key in _OPERATOR_KEYS:
|
|
296
|
+
if not operators_placed:
|
|
297
|
+
for operator_key in present:
|
|
298
|
+
reordered[operator_key] = dumped[operator_key]
|
|
299
|
+
operators_placed = True
|
|
300
|
+
continue
|
|
301
|
+
reordered[key] = value
|
|
302
|
+
return reordered
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
class SlotDeclaration(BaseModel):
|
|
306
|
+
"""A named position the root opens for a pack to fill — `02` §3 → *Slots*.
|
|
307
|
+
|
|
308
|
+
"A pack may ship complete named pipelines, and it may contribute into a slot a
|
|
309
|
+
pipeline opted into. It may never rewrite a pipeline that did not ask." A slot is that
|
|
310
|
+
opt-in, made data: declaring one is choosing, deliberately, that some installed pack's
|
|
311
|
+
contribution may land at this exact position — never a stage id, which `replace` and
|
|
312
|
+
`remove` already exist to change, and never ambient just because a pack happens to be
|
|
313
|
+
on the machine. `after`/`before` position it against one of the pipeline's own stages,
|
|
314
|
+
on the identical terms `InsertOperator` already gives a newly inserted stage — the
|
|
315
|
+
validator below is that one's twin for exactly that reason, not a coincidence of
|
|
316
|
+
shape.
|
|
317
|
+
|
|
318
|
+
`id` is checked against the same reserved-qualifier rule `StageDeclaration.id` already
|
|
319
|
+
carries: a slot is the *author's* name for a position, so the qualified spelling a
|
|
320
|
+
pack's contribution wears (`weft-kg:entities`) can never be it — the two vocabularies
|
|
321
|
+
(an author's slot names, a pack's contributed ids) must never collide, or a document
|
|
322
|
+
could accidentally "declare" a slot that is actually the ghost of some other pack's
|
|
323
|
+
contribution.
|
|
324
|
+
"""
|
|
325
|
+
|
|
326
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
327
|
+
|
|
328
|
+
id: str = Field(min_length=1)
|
|
329
|
+
after: str | None = None
|
|
330
|
+
before: str | None = None
|
|
331
|
+
|
|
332
|
+
@field_validator("id", mode="after")
|
|
333
|
+
@classmethod
|
|
334
|
+
def _id_is_the_authors_not_a_packs(cls, value: str) -> str:
|
|
335
|
+
return _refuse_qualified_id(value, subject="slot")
|
|
336
|
+
|
|
337
|
+
@model_validator(mode="after")
|
|
338
|
+
def _exactly_one_position(self) -> Self:
|
|
339
|
+
if (self.after is None) == (self.before is None):
|
|
340
|
+
got = "neither" if self.after is None else "both 'after' and 'before'"
|
|
341
|
+
raise ValueError(
|
|
342
|
+
f"slot '{self.id}' names {got}. A slot names exactly one existing stage id "
|
|
343
|
+
f"to position against — 'after:' or 'before:', never both, never neither."
|
|
344
|
+
)
|
|
345
|
+
return self
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
class InsertOperator(BaseModel):
|
|
349
|
+
"""One `insert:` entry — `02` §3's operator table: add `stage`, `after:` or `before:` an id.
|
|
350
|
+
|
|
351
|
+
Exactly one of `after`/`before` is required, never both and never neither: a
|
|
352
|
+
position with two anchors or none is not a position, and refusing it here — before a
|
|
353
|
+
parent exists to check the anchor against — keeps that check as cheap as
|
|
354
|
+
`StageDeclaration`'s own invariants are.
|
|
355
|
+
"""
|
|
356
|
+
|
|
357
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
358
|
+
|
|
359
|
+
after: str | None = None
|
|
360
|
+
before: str | None = None
|
|
361
|
+
stage: StageDeclaration
|
|
362
|
+
|
|
363
|
+
@model_validator(mode="after")
|
|
364
|
+
def _exactly_one_position(self) -> Self:
|
|
365
|
+
if (self.after is None) == (self.before is None):
|
|
366
|
+
got = "neither" if self.after is None else "both 'after' and 'before'"
|
|
367
|
+
raise ValueError(
|
|
368
|
+
f"insert operator for stage '{self.stage.id}' names {got}. An insert names "
|
|
369
|
+
f"exactly one existing stage id to position against — 'after:' or 'before:', "
|
|
370
|
+
f"never both, never neither."
|
|
371
|
+
)
|
|
372
|
+
return self
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
class SetOperator(BaseModel):
|
|
376
|
+
"""One `set:` entry — `02` §3's operator table: override `id`'s configuration in place.
|
|
377
|
+
|
|
378
|
+
No `use:` field on purpose: `set` is the operator that "override[s] configuration of
|
|
379
|
+
an existing stage **without changing the plugin**" — a document that wants a
|
|
380
|
+
different plugin at that id writes `replace`, and this model refusing `use:` by name
|
|
381
|
+
(`extra="forbid"`) is what makes the distinction a checked one rather than a naming
|
|
382
|
+
convention an author has to remember.
|
|
383
|
+
|
|
384
|
+
`config`'s `with`/`config` split is `StageDeclaration.config`'s own, for the same
|
|
385
|
+
reason: `with` is a Python keyword, so `populate_by_name=True` alongside the alias is
|
|
386
|
+
what keeps `SetOperator(config=...)` constructible from Python at all.
|
|
387
|
+
"""
|
|
388
|
+
|
|
389
|
+
model_config = ConfigDict(frozen=True, extra="forbid", populate_by_name=True)
|
|
390
|
+
|
|
391
|
+
id: str = Field(min_length=1)
|
|
392
|
+
config: ConfigBlock = Field(
|
|
393
|
+
default_factory=lambda: _NO_CONFIG,
|
|
394
|
+
validation_alias="with",
|
|
395
|
+
serialization_alias="with",
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
@field_validator("config", mode="after")
|
|
399
|
+
@classmethod
|
|
400
|
+
def _config_is_read_only(cls, value: Mapping[str, object]) -> Mapping[str, object]:
|
|
401
|
+
return _read_only(value)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
class Pipeline(BaseModel):
|
|
405
|
+
"""A pipeline document as its author wrote it — frozen, checked, not yet resolved.
|
|
406
|
+
|
|
407
|
+
This is the *authored* form, distinct from the resolved one: `extends` is
|
|
408
|
+
unfollowed, `vars` are unsubstituted, and no plugin named in `use` has been
|
|
409
|
+
looked up. Everything here can be true of a pipeline whose parent does not
|
|
410
|
+
exist and whose plugins are not installed, which is precisely why
|
|
411
|
+
resolution is a separate step that produces a separate frozen value.
|
|
412
|
+
|
|
413
|
+
The document's invariants are the ones that hold without a registry: a
|
|
414
|
+
pipeline is named, its keys are keys this model has, its stage ids are
|
|
415
|
+
unique, and its vars are scalars.
|
|
416
|
+
"""
|
|
417
|
+
|
|
418
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
419
|
+
|
|
420
|
+
name: str = Field(min_length=1)
|
|
421
|
+
extends: str | None = None
|
|
422
|
+
vars: VarBlock = Field(default_factory=lambda: _NO_VARS)
|
|
423
|
+
stages: tuple[StageDeclaration, ...] = ()
|
|
424
|
+
slots: tuple[SlotDeclaration, ...] = ()
|
|
425
|
+
insert: tuple[InsertOperator, ...] = Field(
|
|
426
|
+
default=(), json_schema_extra={PIPELINE_OPERATOR_MARK: True}
|
|
427
|
+
)
|
|
428
|
+
replace: tuple[StageDeclaration, ...] = Field(
|
|
429
|
+
default=(), json_schema_extra={PIPELINE_OPERATOR_MARK: True}
|
|
430
|
+
)
|
|
431
|
+
remove: tuple[str, ...] = Field(default=(), json_schema_extra={PIPELINE_OPERATOR_MARK: True})
|
|
432
|
+
set: tuple[SetOperator, ...] = Field(
|
|
433
|
+
default=(), json_schema_extra={PIPELINE_OPERATOR_MARK: True}
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
_operator_order: tuple[str, ...] = PrivateAttr(default=())
|
|
437
|
+
|
|
438
|
+
@property
|
|
439
|
+
def operator_order(self) -> tuple[str, ...]:
|
|
440
|
+
"""Which of `insert`/`replace`/`remove`/`set` this pipeline wrote, in written order.
|
|
441
|
+
|
|
442
|
+
See the module docstring's *The four derivation operators* section for why this
|
|
443
|
+
cannot be read off field declaration order. `weft_kernel.resolution` is the one
|
|
444
|
+
caller: it applies this pipeline's operator blocks against its resolved parent in
|
|
445
|
+
exactly this order, which is what makes `remove` written above `insert` a move and
|
|
446
|
+
the reverse a collision — `02` §3, settled by task 1.4.
|
|
447
|
+
"""
|
|
448
|
+
return self._operator_order
|
|
449
|
+
|
|
450
|
+
@model_validator(mode="wrap")
|
|
451
|
+
@classmethod
|
|
452
|
+
def _track_operator_order(cls, value: object, handler: ModelWrapValidatorHandler[Self]) -> Self:
|
|
453
|
+
"""Read operator key order off the raw input, before delegating to field validation.
|
|
454
|
+
|
|
455
|
+
`mode="wrap"` is the one validator kind that sees both ends: the value exactly as
|
|
456
|
+
`model_validate`/`__init__` received it, and the constructed instance `handler`
|
|
457
|
+
returns. Every other `model_validator` here only ever sees one side — `mode=
|
|
458
|
+
"before"` transforms the raw value onward but builds nothing, `mode="after"` gets
|
|
459
|
+
the built instance but never the mapping that built it — so key order, which only
|
|
460
|
+
the raw value carries, would already be gone by the time any of them ran. Stamped
|
|
461
|
+
onto `_operator_order` through an ordinary attribute assignment, not `object.
|
|
462
|
+
__setattr__`: `BaseModel.__setattr__` already special-cases a declared private
|
|
463
|
+
attribute so the write lands in `__pydantic_private__` (where equality and
|
|
464
|
+
serialization actually look), and `frozen=True` never enters into it either way —
|
|
465
|
+
a private attribute is not a field, so it sits outside the rebinding check that
|
|
466
|
+
blocks `pipeline.name = ...`. `object.__setattr__` would instead bypass that
|
|
467
|
+
override and land the value in the instance's own `__dict__`, which every *later*
|
|
468
|
+
plain read of `instance._operator_order` would still see (attribute lookup finds
|
|
469
|
+
it before falling through to `__pydantic_private__`) while `==` — which compares
|
|
470
|
+
`__pydantic_private__` directly, never the shadow — would not: two pipelines built
|
|
471
|
+
with their operators in opposite written order would then wrongly compare equal.
|
|
472
|
+
"""
|
|
473
|
+
order = _operator_key_order(value)
|
|
474
|
+
instance = handler(value)
|
|
475
|
+
instance._operator_order = order
|
|
476
|
+
return instance
|
|
477
|
+
|
|
478
|
+
@model_serializer(mode="wrap")
|
|
479
|
+
def _serialise_operators_in_written_order(
|
|
480
|
+
self, handler: SerializerFunctionWrapHandler
|
|
481
|
+
) -> object:
|
|
482
|
+
"""Undo pydantic's field-declaration serialisation order for the four operator keys.
|
|
483
|
+
|
|
484
|
+
`handler(self)` already applied whatever `by_alias`/`exclude_defaults`/`mode` the
|
|
485
|
+
caller asked `model_dump` for; this only reshuffles the operator keys that
|
|
486
|
+
survived that call, back into `operator_order` — the order this pipeline's own
|
|
487
|
+
document (or constructor call) actually wrote them in. Without this, round-
|
|
488
|
+
tripping a document that wrote `remove:` above `insert:` would come back with
|
|
489
|
+
`insert:` first every time, because pydantic serialises fields in the order they
|
|
490
|
+
are *declared* on the class, and that is exactly the assumption the rest of this
|
|
491
|
+
module goes to the trouble of not making. See `_reorder_operator_keys`.
|
|
492
|
+
"""
|
|
493
|
+
dumped = handler(self)
|
|
494
|
+
if not isinstance(dumped, dict):
|
|
495
|
+
return dumped
|
|
496
|
+
return _reorder_operator_keys(cast("dict[str, object]", dumped), self.operator_order)
|
|
497
|
+
|
|
498
|
+
@model_validator(mode="before")
|
|
499
|
+
@classmethod
|
|
500
|
+
def _document_keys_only(cls, value: object) -> object:
|
|
501
|
+
return _known_keys_only(cls, value, subject="pipeline")
|
|
502
|
+
|
|
503
|
+
@field_validator("vars", mode="before")
|
|
504
|
+
@classmethod
|
|
505
|
+
def _vars_are_scalars(cls, value: object) -> object:
|
|
506
|
+
"""Refuse a structured var in one sentence, rather than in four union branches.
|
|
507
|
+
|
|
508
|
+
Pydantic would refuse it anyway — `Mapping[str, Scalar]` sees to that —
|
|
509
|
+
but its answer is one error per branch of the union and none of them
|
|
510
|
+
says what a var is for. This one does.
|
|
511
|
+
"""
|
|
512
|
+
if not isinstance(value, Mapping):
|
|
513
|
+
return value
|
|
514
|
+
|
|
515
|
+
declared = cast("Mapping[object, object]", value)
|
|
516
|
+
for key, item in declared.items():
|
|
517
|
+
if not isinstance(item, str | int | float | bool):
|
|
518
|
+
raise ValueError(
|
|
519
|
+
f"var '{key}' is a {type(item).__name__}; a var is a scalar — a "
|
|
520
|
+
f"string, an integer, a float or a boolean. A var carries a decision "
|
|
521
|
+
f"several stages must agree on and is substituted into a stage's "
|
|
522
|
+
f"`with:` value, so there is nothing structured for it to be."
|
|
523
|
+
)
|
|
524
|
+
return declared
|
|
525
|
+
|
|
526
|
+
@field_validator("vars", mode="after")
|
|
527
|
+
@classmethod
|
|
528
|
+
def _vars_are_read_only(cls, value: Mapping[str, Scalar]) -> Mapping[str, Scalar]:
|
|
529
|
+
return _read_only(value)
|
|
530
|
+
|
|
531
|
+
@model_validator(mode="after")
|
|
532
|
+
def _extends_and_stages_are_mutually_exclusive_with_operators(self) -> Self:
|
|
533
|
+
"""A pipeline is either a root that lists `stages:`, or a child that operates on one.
|
|
534
|
+
|
|
535
|
+
`02` §3 → *Derivation*: a child changes its parent "by operator and never by
|
|
536
|
+
copy" — `insert`, `replace`, `remove`, `set` (task 1.4). That splits this model's
|
|
537
|
+
two "what do I run" surfaces cleanly by whether `extends` is set: `extends` plus a
|
|
538
|
+
non-empty `stages:` still has no meaning — a document that both names a parent and
|
|
539
|
+
lists its own stages has not said whether that list is the whole pipeline or a
|
|
540
|
+
change to hand the parent, and guessing either answer is exactly the silent
|
|
541
|
+
fallback `01` rules out. The new half, now that operators exist to guess with: no
|
|
542
|
+
`extends` plus a non-empty operator block is refused too, because `insert`/
|
|
543
|
+
`replace`/`remove`/`set` change a **parent**, and a pipeline with none named has
|
|
544
|
+
nothing for them to change — silently ignoring the block would be the same
|
|
545
|
+
species of silent fallback in the other direction. This is an invariant that holds
|
|
546
|
+
with no registry and no parent lookup present, which is what keeps it in the
|
|
547
|
+
authored form rather than in resolution (task 1.3): resolving a parent this
|
|
548
|
+
pipeline may not even name yet would be answering a question that has not been
|
|
549
|
+
asked.
|
|
550
|
+
|
|
551
|
+
Task 1.11 gives `slots:` the identical restriction `stages:` already has, for the
|
|
552
|
+
identical reason: a slot is a position in *this* pipeline's own stage list, exactly
|
|
553
|
+
as a stage is, so it belongs on the root that owns that list — never on a pipeline
|
|
554
|
+
whose own contribution to the chain is a set of operators. A child that wants to
|
|
555
|
+
refuse a slot it inherited writes `remove: <slot>` (below); it does not declare a
|
|
556
|
+
second, competing one.
|
|
557
|
+
"""
|
|
558
|
+
if self.extends is not None and self.stages:
|
|
559
|
+
raise ValueError(
|
|
560
|
+
f"pipeline '{self.name}' sets 'extends: {self.extends}' and also lists its own "
|
|
561
|
+
f"'stages:'. A pipeline that extends a parent expresses what changes with an "
|
|
562
|
+
f"operator (insert, replace, remove, set), never with its own 'stages:' list — "
|
|
563
|
+
f"drop 'stages:', or drop 'extends' and author this as a standalone pipeline."
|
|
564
|
+
)
|
|
565
|
+
if self.extends is not None and self.slots:
|
|
566
|
+
raise ValueError(
|
|
567
|
+
f"pipeline '{self.name}' sets 'extends: {self.extends}' and also declares its "
|
|
568
|
+
f"own 'slots:'. A slot is a position in the root's own stage list — declare it "
|
|
569
|
+
f"there, or drop 'extends' and author this as a standalone pipeline. A pipeline "
|
|
570
|
+
f"that inherited a slot and wants to refuse it writes 'remove: <slot-id>'."
|
|
571
|
+
)
|
|
572
|
+
if self.extends is None and self._writes_an_operator():
|
|
573
|
+
raise ValueError(
|
|
574
|
+
f"pipeline '{self.name}' writes an operator (insert, replace, remove, set) but "
|
|
575
|
+
f"sets no 'extends:'. An operator changes a parent pipeline, and this pipeline "
|
|
576
|
+
f"names none to change — add 'extends: <parent>', or drop the operator and "
|
|
577
|
+
f"author this pipeline's own 'stages:' directly."
|
|
578
|
+
)
|
|
579
|
+
return self
|
|
580
|
+
|
|
581
|
+
def _writes_an_operator(self) -> bool:
|
|
582
|
+
return bool(self.insert or self.replace or self.remove or self.set)
|
|
583
|
+
|
|
584
|
+
@model_validator(mode="after")
|
|
585
|
+
def _stage_ids_are_unique(self) -> Self:
|
|
586
|
+
"""A stage id is the only handle anything else has on a position, so it names one.
|
|
587
|
+
|
|
588
|
+
Operators address stages by id, slots and resolution errors report
|
|
589
|
+
them by id, and `02` §3 makes every operator strict about the id it
|
|
590
|
+
names. Two stages answering to `chunk` would make `insert: {after:
|
|
591
|
+
chunk}` mean two different things depending on which one resolution
|
|
592
|
+
happened to reach first.
|
|
593
|
+
"""
|
|
594
|
+
seen: set[str] = set()
|
|
595
|
+
for stage in self.stages:
|
|
596
|
+
if stage.id in seen:
|
|
597
|
+
raise ValueError(
|
|
598
|
+
f"two stages share the id '{stage.id}'. A stage id is how an operator, a "
|
|
599
|
+
f"slot and a resolution error each name one position in this pipeline, so "
|
|
600
|
+
f"it is unique within it."
|
|
601
|
+
)
|
|
602
|
+
seen.add(stage.id)
|
|
603
|
+
return self
|
|
604
|
+
|
|
605
|
+
@field_validator("remove", mode="after")
|
|
606
|
+
@classmethod
|
|
607
|
+
def _remove_targets_are_not_a_packs_to_name(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
|
608
|
+
"""`02` §3 → *Slots*: a contributed stage "may be `set` but never `replaced` or
|
|
609
|
+
`removed`". `remove`'s targets are a bare `tuple[str, ...]` — unlike `replace`'s
|
|
610
|
+
(`StageDeclaration.id`, already refused by `_id_is_not_a_pack_s_to_give`) — so
|
|
611
|
+
without this, `remove: [weft-kg:entities]` would validate as a document today
|
|
612
|
+
and only fail later, as an ordinary `StaleOperatorTargetError`, indistinguishable
|
|
613
|
+
from a typo. Refusing it here says what it actually is: a pack's contribution can
|
|
614
|
+
never be named away, only the slot that admits it — `remove: <slot-id>` is the
|
|
615
|
+
document's way to refuse every contribution to a slot without naming any pack.
|
|
616
|
+
"""
|
|
617
|
+
for target in value:
|
|
618
|
+
_refuse_qualified_id(target, subject="remove target")
|
|
619
|
+
return value
|
|
620
|
+
|
|
621
|
+
@model_validator(mode="after")
|
|
622
|
+
def _slot_ids_are_unique_and_free(self) -> Self:
|
|
623
|
+
"""A slot id is a position name, on the identical footing a stage id already is.
|
|
624
|
+
|
|
625
|
+
Checked here, alongside `_stage_ids_are_unique`, because both only ever run
|
|
626
|
+
against the root's own lists — `slots:` carries the same `extends`-exclusivity
|
|
627
|
+
`stages:` does, so a pipeline with either non-empty is always the pipeline that
|
|
628
|
+
owns both. A slot sharing a stage's id would make `remove: <that-id>` genuinely
|
|
629
|
+
ambiguous between the two `02` §3 gives it the power to drop; a slot sharing
|
|
630
|
+
another slot's id would make the same operator target two positions at once.
|
|
631
|
+
"""
|
|
632
|
+
stage_ids = {stage.id for stage in self.stages}
|
|
633
|
+
seen: set[str] = set()
|
|
634
|
+
for slot in self.slots:
|
|
635
|
+
if slot.id in stage_ids:
|
|
636
|
+
raise ValueError(
|
|
637
|
+
f"slot '{slot.id}' shares its id with a stage in this pipeline. A slot id "
|
|
638
|
+
f"and a stage id are both names 'remove' can target, so the two "
|
|
639
|
+
f"vocabularies must stay disjoint — rename the slot or the stage."
|
|
640
|
+
)
|
|
641
|
+
if slot.id in seen:
|
|
642
|
+
raise ValueError(
|
|
643
|
+
f"two slots share the id '{slot.id}'. A slot id is how 'remove' and a "
|
|
644
|
+
f"pack's contribution both name one position in this pipeline, so it is "
|
|
645
|
+
f"unique within it."
|
|
646
|
+
)
|
|
647
|
+
seen.add(slot.id)
|
|
648
|
+
return self
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def is_pipeline_operator_field(field: FieldInfo) -> bool:
|
|
652
|
+
"""Whether `field` carries `PIPELINE_OPERATOR_MARK` — the one thing task 1.15's ratchet
|
|
653
|
+
(`_OPERATOR_KEYS` just below, and `tests/architecture/test_ff11_pipeline_integrity.py`,
|
|
654
|
+
which imports this function rather than re-deriving the same narrowing itself) needs to
|
|
655
|
+
know about a `Pipeline` field. Public for exactly that reason: the ratchet's "actual"
|
|
656
|
+
side has to read this off the model somehow, and a second, private copy of the
|
|
657
|
+
`json_schema_extra` narrowing below would be the same two-lists risk one function away.
|
|
658
|
+
|
|
659
|
+
Pydantic types `FieldInfo.json_schema_extra` as `dict[str, JsonValue] | JsonSchemaExtraCallable
|
|
660
|
+
| None` — a union `pyright` cannot narrow `.get(...)` through cleanly even after `isinstance
|
|
661
|
+
(..., dict)`, since a plain-`dict` branch of that union is still typed as `dict[Unknown,
|
|
662
|
+
Unknown]` in strict mode. The `cast` below states the one fact this module already relies on:
|
|
663
|
+
every `json_schema_extra` it ever sets is a `dict[str, object]` literal, never the callable
|
|
664
|
+
form pydantic also allows.
|
|
665
|
+
"""
|
|
666
|
+
extra = field.json_schema_extra
|
|
667
|
+
if not isinstance(extra, dict):
|
|
668
|
+
return False
|
|
669
|
+
return cast("dict[str, object]", extra).get(PIPELINE_OPERATOR_MARK) is True
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
_OPERATOR_KEYS: Final[tuple[str, ...]] = tuple(
|
|
673
|
+
name for name, field in Pipeline.model_fields.items() if is_pipeline_operator_field(field)
|
|
674
|
+
)
|
|
675
|
+
"""`02` §3's closed operator table's four names — **read off `Pipeline.model_fields`
|
|
676
|
+
itself**, never retyped. Defined only now, after the class body that carries the marks
|
|
677
|
+
`PIPELINE_OPERATOR_MARK` documents, because a module-level constant evaluates
|
|
678
|
+
immediately at import time and `Pipeline.model_fields` does not exist until the class
|
|
679
|
+
statement above has finished running; the functions that consume this tuple
|
|
680
|
+
(`_operator_key_order`, `_reorder_operator_keys`) are declared earlier in the file but,
|
|
681
|
+
being function bodies, read the name only when *called* — well after the whole module,
|
|
682
|
+
this constant included, has finished loading. Not itself an ordering claim — it is only
|
|
683
|
+
the vocabulary `_operator_key_order` filters a document's own keys against, and the
|
|
684
|
+
sequence `_reorder_operator_keys` reorders a serialised document's keys back into, once
|
|
685
|
+
the actual written order is known (`Pipeline.operator_order`, per-instance). The set
|
|
686
|
+
stays closed at the ratchet in `tests/architecture/test_ff11_pipeline_integrity.py`
|
|
687
|
+
(`01` -> *Fitness functions* item 11(a)): a fifth field carrying
|
|
688
|
+
`PIPELINE_OPERATOR_MARK` changes what this tuple *contains*, which is exactly what
|
|
689
|
+
that ratchet compares against its own pinned expectation.
|
|
690
|
+
"""
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _known_keys_only(model: type[BaseModel], value: object, *, subject: str) -> object:
|
|
694
|
+
"""Refuse an unknown key by naming it *and* the keys that exist. `01`'s loud-failure rule.
|
|
695
|
+
|
|
696
|
+
Runs before validation proper, so the answer to a mistyped key is the list
|
|
697
|
+
of real ones rather than `extra="forbid"`'s bare "Extra inputs are not
|
|
698
|
+
permitted" — which tells an author that the word they wrote is wrong and
|
|
699
|
+
nothing about which word is right. `extra="forbid"` stays on underneath as
|
|
700
|
+
the backstop for anything that reaches the model another way.
|
|
701
|
+
|
|
702
|
+
Keys are reported in the document's spelling, so `with` rather than
|
|
703
|
+
`config`; both are accepted, per `StageDeclaration`.
|
|
704
|
+
"""
|
|
705
|
+
if not isinstance(value, Mapping):
|
|
706
|
+
return value
|
|
707
|
+
|
|
708
|
+
written = cast("Mapping[object, object]", value)
|
|
709
|
+
known = {_document_spelling(name, field) for name, field in model.model_fields.items()}
|
|
710
|
+
accepted = known | set(model.model_fields)
|
|
711
|
+
unknown = sorted(str(key) for key in written if key not in accepted)
|
|
712
|
+
if unknown:
|
|
713
|
+
raise ValueError(
|
|
714
|
+
f"unknown {subject} key(s): {', '.join(repr(key) for key in unknown)}. "
|
|
715
|
+
f"A {subject} accepts {', '.join(sorted(known))}."
|
|
716
|
+
)
|
|
717
|
+
return written
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def _read_only[K, V](value: Mapping[K, V]) -> Mapping[K, V]:
|
|
721
|
+
"""Make a mapping field as frozen as the model that carries it.
|
|
722
|
+
|
|
723
|
+
`frozen=True` stops a field being rebound; it does nothing about what a
|
|
724
|
+
field *holds*, and pydantic validates `Mapping[...]` into an ordinary
|
|
725
|
+
mutable `dict`. That gap matters here rather than being a nicety: `02` §3
|
|
726
|
+
makes derivation "the parent is referenced, never copied", so a child's
|
|
727
|
+
resolved stages share their parent's `with:` mapping — and an operator
|
|
728
|
+
implemented as an in-place update would edit the parent through it,
|
|
729
|
+
silently, for every other child too. A read-only view makes that a
|
|
730
|
+
`TypeError` at the moment of the write instead of a mystery two pipelines
|
|
731
|
+
away.
|
|
732
|
+
|
|
733
|
+
The cost is one annotation each: pydantic knows how to write a `dict` back
|
|
734
|
+
to a document and refuses a `mappingproxy`, so `ConfigBlock` and `VarBlock`
|
|
735
|
+
carry a `PlainSerializer` that unwraps the view. It has to be an annotated
|
|
736
|
+
serializer rather than a `field_serializer` method, because a
|
|
737
|
+
field-serializer method also switches off `exclude_defaults` — and an
|
|
738
|
+
untouched `with:` block reappearing in every document written back is
|
|
739
|
+
exactly the kind of drift between the two directions this module exists to
|
|
740
|
+
prevent. The view is the model's own protection, not part of what a
|
|
741
|
+
pipeline document says.
|
|
742
|
+
"""
|
|
743
|
+
return MappingProxyType(dict(value))
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def _document_spelling(name: str, field: FieldInfo) -> str:
|
|
747
|
+
"""The key a field wears in a document: its alias where it has one, its own name otherwise."""
|
|
748
|
+
return field.serialization_alias or name
|