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/errors.py ADDED
@@ -0,0 +1,73 @@
1
+ """`WeftError` — the root of Weft's error hierarchy.
2
+
3
+ Specified in `docs/02-extension-model.md` section 1 ("What a plugin
4
+ receives"): packs raise subclasses of `WeftError`, which carries a
5
+ `transient` marker so a pack can say what is worth retrying — the kernel
6
+ runs no retry engine of its own, so a caller deciding whether to retry needs
7
+ the pack's opinion, not a guess read off the exception's class name. Every
8
+ kernel-raised error is loud and specific: it says what was wanted, why it is
9
+ unavailable, and what the valid options are, never a bare
10
+ `unknown plugin 'x'` that names no alternative.
11
+
12
+ **Attribution is data this type carries, not logic it performs.**
13
+ `docs/06-phase-0-build.md` step 3 builds the registration seam that catches
14
+ whatever escapes a plugin's `run()` and wraps it, naming the pack, contract,
15
+ plugin and stage, with `__cause__` preserved so no traceback is hidden. This
16
+ step only gives that seam somewhere to put the four names — a plain
17
+ `WeftError` raised directly by a pack has no reason to know its own
18
+ attribution, and none of the four fields defaulting to `None` is exactly
19
+ that: a stage does not know which pack it runs under or which pipeline slot
20
+ it fills, and the one place that does is the seam wrapping the call, not the
21
+ stage itself.
22
+ """
23
+
24
+
25
+ class WeftError(Exception):
26
+ """Root of Weft's error hierarchy. Every kernel- and pack-raised error is one of these.
27
+
28
+ Catching `WeftError` catches everything Weft itself raises, kernel or
29
+ pack, without also swallowing an unrelated `KeyError` or `TypeError` a
30
+ caller would want to see — the catch-specific-exceptions rule applies to
31
+ callers of this library exactly as it applies inside it.
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ message: str,
37
+ *,
38
+ transient: bool = False,
39
+ pack: str | None = None,
40
+ contract: str | None = None,
41
+ plugin: str | None = None,
42
+ stage: str | None = None,
43
+ ) -> None:
44
+ super().__init__(message)
45
+ self.transient = transient
46
+ self.pack = pack
47
+ self.contract = contract
48
+ self.plugin = plugin
49
+ self.stage = stage
50
+
51
+
52
+ class UnresolvedNameError:
53
+ """Marks a `WeftError` subclass whose failure mode is a name that did not resolve
54
+ against a known, enumerable set of alternatives — fitness function 12's family
55
+ (`docs/01-high-level-plan.md` → *Fitness functions*, item 12; grilling session G11).
56
+
57
+ `valid_options` is the typed field the function requires: the alternatives a caller
58
+ could have supplied instead, carried as a structural fact a renderer can format
59
+ however it likes, rather than only interpolated into `str(self)` where a reviewer
60
+ has to notice its absence. `01` requirement 5's own words: *"an unknown name fails
61
+ loudly, naming the valid options."*
62
+
63
+ **Not itself a `WeftError`, and defines no `__init__`.** A family member already
64
+ inherits one from wherever it actually sits — `PipelineResolutionError`'s four
65
+ fields, `LLMError`'s `provider`/`model`, or plain `WeftError` — and cooperative
66
+ multiple-inheritance `__init__` chaining across those different shapes is exactly
67
+ the machinery this class exists to avoid needing. This is mixed in as a second base
68
+ purely so `issubclass(cls, UnresolvedNameError)` is a real, mechanical fact fitness
69
+ function 12 can check; each concrete subclass sets `self.valid_options` itself, in
70
+ its own `__init__`, the same way it already sets any of its other typed fields.
71
+ """
72
+
73
+ valid_options: tuple[str, ...]
@@ -0,0 +1,159 @@
1
+ """`try_in_order` — the fallback combinator, over any contract at all.
2
+
3
+ `04` → the `_try_extractors` row: a fallback-chain executor belongs
4
+ in the kernel "as a combinator", because **it composes plugins rather than
5
+ doing work**. Nothing here inspects a payload, imports a capability or knows
6
+ what a document is; it matches on the `Outcome` type a contract already
7
+ defines and calls the next candidate or stops. That is what lets one
8
+ implementation serve extraction today and a store, an embedder or a retriever
9
+ tomorrow without a line changing.
10
+
11
+ A separate module rather than a method on `Runner`, for two reasons that are
12
+ not style: its cost is visible on its own line in fitness function 3's
13
+ per-file numbers, and it is testable with no `Runner`, no `Registry` and no
14
+ resolved pipeline — which is what makes the three-outcome table below a
15
+ checked fact rather than a claim about the runner.
16
+
17
+ **A `fail_silently` channel has no equivalent here, and this module is where
18
+ that is enforced.** Such a channel returns an empty result indistinguishable
19
+ downstream from a successfully-parsed empty document (`payload/outcome.py`) —
20
+ the shape a two-state success/failure model is forced into once the real
21
+ problem has three states. `Outcome` has the third, and `02` §1 already assigns
22
+ the meanings:
23
+
24
+ - **`Produced` — stop, return it.** Success is contract-defined, never guessed at
25
+ by looking inside the value the way `fail_silently` had to.
26
+ - **`NothingToProduce` — stop, return it unchanged.** `02` §1, on a backend that
27
+ legitimately extracted nothing: it can now say so, "**and it stops the chain**".
28
+ *"I looked, and there is nothing here"* is a claim a second backend cannot
29
+ improve on.
30
+ - **`Failed` — record it, try the next.** *"I could not see it"* — another backend
31
+ might.
32
+ - **raises `WeftError` — record it, try the next.** See below.
33
+ - **raises `BlockingCallError` — stop, and let it out.** See below.
34
+
35
+ **Catching `WeftError` is the one deliberate breadth in this module.** Every
36
+ candidate reaches here already wrapped by `weft_kernel.seam.wrap`, which turns
37
+ anything a plugin lets escape into a `WeftError` with `__cause__` preserved and
38
+ the pack, contract, plugin and stage attributed. So the broad tolerance a
39
+ chain over third-party format libraries genuinely needs is declared once, at
40
+ the combinator, against the one class the seam guarantees — rather than as a
41
+ blind `except Exception` inside every backend, buried where no reviewer of
42
+ the combinator would ever see it. `CancelledError`, `KeyboardInterrupt` and
43
+ `SystemExit` are `BaseException`, so they are never caught here: cancellation
44
+ propagates by construction, not by an added clause.
45
+
46
+ **`BlockingCallError` is the one exception to that breadth, and it is the
47
+ kernel's own, not a backend's.** The seam arms `weft_kernel.blocking.guard`
48
+ around every candidate, so fitness function 7(b) raises it from *inside* the
49
+ offending call — and it subclasses `WeftError`, so the clause above would file
50
+ it as *"this backend could not do its job"* and let the next candidate answer.
51
+ That is a `fallback:` line silently opting a stage out of the colour rule: the
52
+ run exits with a success, the refusal list is discarded, and with the default
53
+ no-op tracer the violation is named nowhere. The distinction the chain is
54
+ allowed to walk past is a backend's report about a **document**; a report that
55
+ the plugin itself broke the runtime contract is not a refusal to be tried
56
+ around, so it propagates untouched. Any future kernel-raised diagnostic of a
57
+ contract violation belongs beside it here.
58
+
59
+ **The author rule that makes the `NothingToProduce` row honest**, stated on
60
+ `Attempt` too because a backend author is who has to obey it:
61
+
62
+ > Return `NothingToProduce` only when you can distinguish "there is nothing
63
+ > here" from "I could not see it". If your backend cannot tell those apart for
64
+ > this input, return `Failed`.
65
+
66
+ `weft-pdf` ships both cases as unit tests — a page with a text layer drawing no
67
+ glyphs against a page with no text and an embedded image — so the distinction
68
+ the chain rests on is checked rather than promised.
69
+ """
70
+
71
+ from collections.abc import Awaitable, Callable, Sequence
72
+ from dataclasses import dataclass
73
+
74
+ from weft_kernel.blocking import BlockingCallError
75
+ from weft_kernel.context import Context
76
+ from weft_kernel.errors import WeftError
77
+ from weft_kernel.payload import Failed, Outcome
78
+
79
+
80
+ @dataclass(frozen=True, slots=True, kw_only=True)
81
+ class Attempt[In, Out]:
82
+ """One candidate in a chain: a name, and an already-seam-wrapped call.
83
+
84
+ `run` is expected to carry its own span, attribution, blocking guard and
85
+ transient stripping — the caller builds it through `weft_kernel.seam.wrap`
86
+ — which is why **this module writes no span of its own**. A chain of three
87
+ backends produces three spans, each attributed to the backend that
88
+ actually ran, and a fourth wrapping span naming only the chain would say
89
+ less than the three already do.
90
+
91
+ `name` is the plugin name, not the stage id: the stage is one position and
92
+ every candidate fills it, so it is the *name* that answers the question a
93
+ reader of the trace or of an exhausted chain's reason is asking — which
94
+ backend answered.
95
+ """
96
+
97
+ name: str
98
+ run: Callable[[In, Context], Awaitable[Outcome[Out]]]
99
+
100
+
101
+ async def try_in_order[In, Out](
102
+ payload: In,
103
+ ctx: Context,
104
+ *,
105
+ stage: str,
106
+ attempts: Sequence[Attempt[In, Out]],
107
+ ) -> Outcome[Out]:
108
+ """Try each of `attempts` in order until one answers something other than a refusal.
109
+
110
+ `attempts` is `[primary, *fallbacks]` — the stage's own `use:` plugin is
111
+ simply the first candidate, so there is no special-cased primary path that
112
+ could diverge from the fallback path as either changes. Ordering is the
113
+ caller's data (`StageDeclaration.fallback`, written in a document), never a
114
+ constructor sequence compiled in here.
115
+
116
+ Returns the first `Produced` or `NothingToProduce`. When every candidate
117
+ refused, returns a single `Failed` naming **every** one of them and what
118
+ each said, in order — refusals accumulate and are reported together, because
119
+ a chain that reports only its last candidate's reason hides the fact that
120
+ the first one was the interesting failure.
121
+
122
+ Raises `BlockingCallError` rather than recording it — see the module
123
+ docstring: a chain is tolerant of what a backend says about a *document*,
124
+ never of a candidate breaking the runtime contract.
125
+ """
126
+ if not attempts:
127
+ raise ValueError(
128
+ f"stage '{stage}' was given an empty chain, so there is nothing to try. A chain "
129
+ f"is [primary, *fallbacks] and its first entry is the stage's own plugin; an "
130
+ f"empty one is a caller's bug, and answering Failed would file it as a backend's."
131
+ )
132
+
133
+ refusals: list[str] = []
134
+ for attempt in attempts:
135
+ try:
136
+ outcome = await attempt.run(payload, ctx)
137
+ except BlockingCallError:
138
+ # Before the broad clause, and never recorded: the seam's guard reports that
139
+ # this candidate broke the runtime contract, not that it met a document it
140
+ # could not read. See the module docstring's fifth row.
141
+ raise
142
+ except WeftError as exc:
143
+ refusals.append(f"'{attempt.name}' raised: {exc}")
144
+ continue
145
+ if isinstance(outcome, Failed):
146
+ refusals.append(f"'{attempt.name}' failed: {outcome.reason}")
147
+ continue
148
+ # `Produced` and `NothingToProduce` both stop the chain, and they are returned by
149
+ # the same line on purpose: `Failed` is the only refusal, and a reader looking for
150
+ # what continues a chain finds exactly one answer rather than two rules to compare.
151
+ return outcome
152
+
153
+ return Failed(
154
+ reason=(
155
+ f"every candidate for stage '{stage}' refused, in order: {'; '.join(refusals)}. "
156
+ f"No backend produced anything, and none of them reported the document as "
157
+ f"legitimately empty."
158
+ )
159
+ )
@@ -0,0 +1,43 @@
1
+ """The payload types — the domain model every stage signature names.
2
+
3
+ Settled in G5. `NodeId`, `SourceId`, `Lineage`, `MediaType`, `Node`, `ExtModel`,
4
+ `ExtMap`, `Vector` and `Outcome`. See `docs/02-extension-model.md` section 1.
5
+
6
+ `Property` is G2's addition (task 1.2, `02` §3 → *Ordering constraints*): the
7
+ marker `intact`/`destroys` name, on the same namespaced-tag footing
8
+ `ExtModel` gives `requires`/`provides`, but never itself node data.
9
+
10
+ `Applies` is G2's other addition (task 1.6, `02` §3 → *Applicability*): what
11
+ a stage's `applies_to` tuple carries — a fact, declared as data, that the
12
+ runner evaluates at the seam. See `weft_kernel.payload.applicability`.
13
+ """
14
+
15
+ from weft_kernel.payload.applicability import Applies
16
+ from weft_kernel.payload.ext import SCHEMA_VERSION_KEY, ExtMap, ExtModel, SchemaVersionRefusedError
17
+ from weft_kernel.payload.ids import NodeId, SourceId
18
+ from weft_kernel.payload.lineage import Lineage
19
+ from weft_kernel.payload.media_type import MediaType
20
+ from weft_kernel.payload.node import Node, SyntheticOrigin
21
+ from weft_kernel.payload.outcome import Failed, NothingToProduce, Outcome, Produced
22
+ from weft_kernel.payload.property import Property
23
+ from weft_kernel.payload.vector import Vector
24
+
25
+ __all__ = [
26
+ "SCHEMA_VERSION_KEY",
27
+ "Applies",
28
+ "ExtMap",
29
+ "ExtModel",
30
+ "Failed",
31
+ "Lineage",
32
+ "MediaType",
33
+ "Node",
34
+ "NodeId",
35
+ "NothingToProduce",
36
+ "Outcome",
37
+ "Produced",
38
+ "Property",
39
+ "SchemaVersionRefusedError",
40
+ "SourceId",
41
+ "SyntheticOrigin",
42
+ "Vector",
43
+ ]
@@ -0,0 +1,298 @@
1
+ """`Applies` — what a stage operates on, declared as data. Task 1.6, extended by 9.2.
2
+
3
+ Settled in G2, `docs/02-extension-model.md` §3 → *Applicability*: "A stage
4
+ declares what it operates on; the runner routes everything else past it,
5
+ untouched." That makes applicability a mechanism the kernel enforces itself
6
+ rather than a rule every stage's author has to remember and apply by hand.
7
+ `docs/11-multimodal.md` §2's own worked example is
8
+ the ingest-path illustration: "An atomic node passes the chunker unsplit,
9
+ and the chunker does not have to know that."
10
+
11
+ **Why a predicate cannot be a callable.** A callable can be *run*; it cannot
12
+ be *printed*, checked at registration, or diffed between two resolutions of
13
+ the same pipeline — exactly the complaint `weft_kernel.pipeline`'s own
14
+ module docstring raises against a second construction path, one level down:
15
+ a predicate a plugin author hands the kernel as a function is a second,
16
+ unauditable grammar sitting next to the one `02` §3 already made data. So
17
+ `Applies` is a frozen `pydantic.BaseModel` the kernel publishes, exactly the
18
+ footing `weft_kernel.payload.property.Property` gives `intact`/`destroys` —
19
+ data a plugin's `applies_to` tuple carries, evaluated by whoever runs the
20
+ pipeline, never executed by the plugin itself.
21
+
22
+ **What a fact is.** `Applies` wraps an `ExtModel` *subclass* — a namespaced
23
+ fact a node may or may not carry, on the same footing `requires`/`provides`
24
+ already give ext models. `Applies(Language)` matches any node carrying that
25
+ fact at all; `Applies(Language, code="pl")` narrows to nodes whose `Language`
26
+ additionally has `code == "pl"`. There is no other spelling: matching is
27
+ always "this fact is present" or "this fact is present *and* these fields
28
+ equal these values" — never "this fact is absent", because the safe-side
29
+ reading below already gives absence a meaning, and giving it two would make
30
+ one of them redundant with the other.
31
+
32
+ **Keyword names are checked against the fact's own fields, immediately.**
33
+ `Applies(Language, code="pl")` validates `code` against `Language.model_fields`
34
+ the moment it is constructed — which, for the way every real plugin uses
35
+ this (a class-level `applies_to = (Applies(Language, code="pl"),)` tuple,
36
+ evaluated when the plugin's module is imported for registration), *is*
37
+ "at registration": a typo'd field name fails on the pack's own import, not
38
+ silently at the first pipeline that happens to route a node past a stage
39
+ that should have claimed it. This is the identical failure class `02` §3
40
+ rules out everywhere else — a typo that becomes a stage which silently never
41
+ applies is indistinguishable, from the outside, from a stage that correctly
42
+ declined every node it saw, and there is no doctor command that can tell
43
+ those apart after the fact. Refusing it before the mistake can be observed
44
+ is the only fix; a `TypeError`, not a `WeftError` — this is a plugin
45
+ author's own class body failing to construct, the same footing
46
+ `ExtModel.__pydantic_init_subclass__` and `Property.__init_subclass__`
47
+ already refuse a missing `__namespace__` on, neither of which is a
48
+ `PipelineResolutionError` either: nothing about a pipeline document is
49
+ involved yet.
50
+
51
+ **Absence fails to the safe side.** `02` §3 → *Language, and what a var is
52
+ for*: "unknown language flows past" a language-specific stage — a fact this
53
+ module's own `matches` makes literal. A stage that declares `applies_to`
54
+ narrows itself to nodes it can positively confirm it should touch; every
55
+ node it cannot confirm — the fact missing entirely, or present with a
56
+ different value — passes it by. There is no third state and no negation:
57
+ a chunker that wants to leave a table node alone does not declare
58
+ "not `Atomic`" (which this module does not let it spell), it declares what
59
+ it *does* want — the prose fact its own splitting logic actually depends on
60
+ — and a table simply never carries that. This is the whole mechanism behind
61
+ "the chunker does not have to know that" tables exist: the declaration
62
+ names the chunker's own requirement, in the chunker's own vocabulary, and
63
+ routing a stranger's `Atomic`-marked node past it is a byproduct of that
64
+ requirement never being met, not a rule about tables the chunker's author
65
+ had to think of and add.
66
+
67
+ **A media-type constraint, task 9.2's addition.** `media_type` is already a core `Node`
68
+ field — G5's admission rule, `node.py`'s own module docstring — rather than a namespaced
69
+ fact, so it cannot be spelled as `Applies(SomeFact, ...)`; there is no `ExtModel` to narrow.
70
+ `Applies(media_type=MediaType.TEXT)` claims a node of that type; `Applies(media_type=
71
+ (MediaType.TEXT, MediaType.IMAGE))` claims any node whose type is one of those listed —
72
+ the "any of these" reading lives *inside* one `Applies`, never across two, because a node
73
+ has exactly one media type and two media-type `Applies` in one (conjunctive) tuple would
74
+ jointly match nothing. `Applies(SomeFact, media_type=...)` is refused with a `ValueError` naming
75
+ `media_type`: one `Applies` states one kind of constraint, and a fact constraint (narrows
76
+ an `ExtModel` a node may carry) and a media-type constraint (narrows a field every node
77
+ already has) are two different conjunction rules that a single object cannot mean at once.
78
+ `Applies()` claiming neither is refused for the same reason it matters here at all — a
79
+ constraint that matches nothing is a stage that silently never runs.
80
+
81
+ **Vars never participate.** Nothing here reads `weft_kernel.pipeline`'s
82
+ `vars:` block, and nothing could: `applies_to` is a class-level declaration
83
+ a plugin's own module carries, never a field a pipeline document writes, so
84
+ there is no `${var:NAME}` token for `weft_kernel.resolution`'s substitution
85
+ to ever reach. `02` §3: "A var can say translate into English; it can never
86
+ say pretend this document is English" — enforced here not by a check but by
87
+ there being no document-authored surface for a var to land on in the first
88
+ place.
89
+
90
+ **A stage that declares no `applies_to` applies to everything, silently.**
91
+ Read defensively — `getattr(instance, "applies_to", ())` — the identical
92
+ convention `weft_kernel.runner` already uses for `requires`/`provides`/
93
+ `intact`/`destroys`. An empty tuple is not a special case the seam checks
94
+ for; it is simply zero constraints to fail, so every node satisfies it
95
+ vacuously. This is what keeps every stage written before this task — none
96
+ of which declares `applies_to` at all — running exactly as it did.
97
+ """
98
+
99
+ import sys
100
+ from typing import Annotated
101
+
102
+ from pydantic import BaseModel, BeforeValidator, ConfigDict, PlainSerializer
103
+
104
+ from weft_kernel.payload.ext import ExtModel
105
+ from weft_kernel.payload.media_type import MediaType
106
+ from weft_kernel.payload.node import Node
107
+
108
+
109
+ def _fact_to_ref(fact: type[ExtModel]) -> str:
110
+ """`module:QualName` — enough to find the class again, and nothing more."""
111
+ return f"{fact.__module__}:{fact.__qualname__}"
112
+
113
+
114
+ def _fact_from_ref(value: object) -> object:
115
+ """A persisted `module:QualName` back into the class, **without importing anything**.
116
+
117
+ The pack that declares a fact was imported at discovery if it is installed at all, so a
118
+ reference this cannot resolve means the pack is *gone* — and that is a refusal, not an import.
119
+ Resolving by importing whatever a persisted file names would turn a JSON artefact into an
120
+ instruction to execute code, which is a much larger promise than reading a run record needs.
121
+ """
122
+ if not isinstance(value, str):
123
+ return value
124
+ module_name, _, qualname = value.partition(":")
125
+ if not qualname:
126
+ raise ValueError(
127
+ f"{value!r} is not a fact reference. Expected 'module:QualName' — a fact written by a "
128
+ f"version of Weft before this form was introduced cannot be read, and the record "
129
+ f"carrying it should be deleted."
130
+ )
131
+ module = sys.modules.get(module_name)
132
+ if module is None:
133
+ raise ValueError(
134
+ f"no installed pack has imported '{module_name}', so the fact '{qualname}' this record "
135
+ f"was written against cannot be resolved. Install the distribution that provides it, "
136
+ f"or delete the record. Nothing is imported to answer this — a persisted name is data."
137
+ )
138
+ resolved: object = module
139
+ for part in qualname.split("."):
140
+ resolved = getattr(resolved, part, None)
141
+ if resolved is None:
142
+ raise ValueError(
143
+ f"'{module_name}' no longer declares '{qualname}'. The pack is installed and this "
144
+ f"fact has been renamed or removed since the record was written."
145
+ )
146
+ return resolved
147
+
148
+
149
+ type _FactRef = Annotated[
150
+ type[ExtModel],
151
+ PlainSerializer(_fact_to_ref, return_type=str, when_used="json"),
152
+ BeforeValidator(_fact_from_ref),
153
+ ]
154
+ """`Applies.fact` in a document: the class in memory, `module:QualName` once dumped to JSON.
155
+
156
+ pydantic has no serializer at all for an arbitrary `type`, so `model_dump(mode='json')` on a
157
+ `ResolvedStage.applies_to` would otherwise raise outright rather than merely print something ugly.
158
+
159
+ **It used to dump the bare `__name__` and had no validator, which made it write-only** — found at
160
+ Phase 8's close review by running the binary. The serialising half worked from the day it was
161
+ written, so nothing ever failed while records were being created; the failure arrived later and
162
+ somewhere else, in three commands that merely *read* the directory those records live in. A bare
163
+ name is also not enough to find a class again, which is why the form changed rather than only
164
+ gaining a validator: two packs may each declare a `Language`, and the record has to say whose.
165
+ """
166
+
167
+
168
+ class _Unset:
169
+ """The absence of an authored `fact`, distinguishable from every value one could hold."""
170
+
171
+ __slots__ = ()
172
+
173
+
174
+ _UNSET = _Unset()
175
+
176
+
177
+ class Applies(BaseModel):
178
+ """One constraint a stage's `applies_to` tuple carries: a fact, optionally narrowed.
179
+
180
+ See the module docstring for the reasoning; this class carries only the
181
+ shape and the one check that has to happen at construction, before a
182
+ typo can ship as a stage that silently never runs.
183
+ """
184
+
185
+ model_config = ConfigDict(frozen=True)
186
+
187
+ fact: _FactRef | None = None
188
+ constraints: tuple[tuple[str, object], ...] = ()
189
+ media_type: tuple[MediaType, ...] = ()
190
+ """The media types this constraint claims, its own typed field rather than an entry in
191
+ `constraints` — which is `tuple[tuple[str, object], ...]` because a *fact's* narrowed values
192
+ are arbitrary, and `object` is exactly the annotation that makes pydantic hand a persisted
193
+ `"text"` back as the string `"text"`. A constraint that dumps correctly and reads back as
194
+ something `matches` compares false against is write-only, which is the defect `_FactRef`'s
195
+ docstring above records this module already paying for once: it "worked from the day it was
196
+ written, so nothing ever failed while records were being created", and surfaced later in three
197
+ commands that merely read. Typed here, pydantic validates the round trip rather than this
198
+ module hoping for it.
199
+ """
200
+
201
+ def __init__(
202
+ self,
203
+ fact: type[ExtModel] | _Unset = _UNSET,
204
+ /,
205
+ *,
206
+ media_type: MediaType | tuple[MediaType, ...] | _Unset = _UNSET,
207
+ **field_values: object,
208
+ ) -> None:
209
+ """Authored as `Applies(Language, code="pl")` **or** `Applies(media_type=...)`,
210
+ and **rebuilt from JSON as well**.
211
+
212
+ `fact` is positional-only so that a fact model declaring its own `fact` field is still
213
+ narrowable, and that is exactly what broke reading one back: pydantic validates a persisted
214
+ `{"fact": "Language", "constraints": [["code", "pl"]]}` by calling `__init__(**data)`, where
215
+ a positional-only parameter cannot be reached — so `fact` and `constraints` both landed in
216
+ `field_values`, `fact` was never supplied, and `model_validate` raised
217
+ *"missing 1 required positional argument: 'fact'"*.
218
+
219
+ **The writing half always worked, which is why nothing failed for a phase.** Found at Phase
220
+ 8's close review by running the binary, not by the suite: `index-polish` declares
221
+ `applies_to = (Applies(Language, code="pl"),)`, so one `weft eval run` of it wrote a run
222
+ record that nothing could read afterwards — and `weft_cli.commands._participating_stores`
223
+ loads every record for `weft index`, `weft reconcile` **and** `weft delete`, so a single
224
+ opaque JSON file stopped all three in that project with no hint which file. `L6.14` says a
225
+ read method with no writer answers emptily; this is the reverse, and the reverse is worse,
226
+ because the artefact persists and the failure surfaces somewhere else entirely.
227
+
228
+ The unset sentinel is what lets one `__init__` serve every caller: `fact` and `media_type`
229
+ both absent means pydantic is rebuilding and `field_values` already holds the model's own
230
+ fields (`fact`, `constraints`); `fact` given with `media_type` given too is the one
231
+ combination task 9.2 refuses outright, one `Applies` stating two conjunction rules at once.
232
+ """
233
+ if not isinstance(fact, _Unset) and not isinstance(media_type, _Unset):
234
+ raise ValueError(
235
+ f"Applies({fact.__name__}, media_type=...) is not allowed: a fact constraint "
236
+ f"narrows an ExtModel a node may carry, and media_type narrows a field every "
237
+ f"node already has. One Applies states one kind of constraint — declare two "
238
+ f"separate stages, or drop whichever constraint this stage does not need."
239
+ )
240
+ if isinstance(fact, _Unset):
241
+ if "fact" in field_values:
242
+ if not isinstance(media_type, _Unset):
243
+ field_values["media_type"] = media_type
244
+ super().__init__(**field_values)
245
+ return
246
+ if isinstance(media_type, _Unset):
247
+ raise TypeError(
248
+ "Applies() states no constraint at all. Pass a fact to narrow "
249
+ "(Applies(Language, code='pl')) or a media type to claim "
250
+ "(Applies(media_type=MediaType.TEXT)); an Applies claiming nothing would "
251
+ "match no node and say nothing about why."
252
+ )
253
+ if field_values:
254
+ unexpected = ", ".join(sorted(field_values))
255
+ raise TypeError(
256
+ f"Applies(media_type=...) accepts no keyword but media_type; got "
257
+ f"{unexpected}. A media-type constraint narrows a field every node has, "
258
+ f"with nothing left to name."
259
+ )
260
+ claimed = (media_type,) if isinstance(media_type, MediaType) else tuple(media_type)
261
+ super().__init__(fact=None, media_type=claimed)
262
+ return
263
+ unknown = sorted(set(field_values) - set(fact.model_fields))
264
+ if unknown:
265
+ valid = ", ".join(sorted(fact.model_fields)) or "(no fields)"
266
+ raise TypeError(
267
+ f"Applies({fact.__name__}, {', '.join(f'{key}=...' for key in unknown)}) named "
268
+ f"field(s) {fact.__name__} does not have. {fact.__name__} declares: {valid}."
269
+ )
270
+ super().__init__(fact=fact, constraints=tuple(sorted(field_values.items())))
271
+
272
+ def matches(self, node: Node) -> bool:
273
+ """Whether `node` satisfies this constraint — a fact, or a media type, never both.
274
+
275
+ `fact is None` means this `Applies` was built from `media_type=...`, and `media_type`
276
+ is checked directly against `node.media_type` — a core field every node has, so there
277
+ is no absence case to fail to the safe side of, unlike a fact.
278
+
279
+ Otherwise, `node.ext_as(self.fact)` returning `None` is an ordinary absence — "the
280
+ stage does not apply", the safe-side reading the module docstring describes — never
281
+ treated as an error here, unlike a namespace collision, which `ext_as` itself already
282
+ raises on and this method makes no attempt to catch.
283
+ """
284
+ if self.fact is None:
285
+ return node.media_type in self.media_type
286
+ value = node.ext_as(self.fact)
287
+ if value is None:
288
+ return False
289
+ return all(getattr(value, name) == expected for name, expected in self.constraints)
290
+
291
+ def __repr__(self) -> str:
292
+ if self.fact is None:
293
+ types = ", ".join(claimed.value for claimed in self.media_type)
294
+ return f"Applies(media_type=({types}))"
295
+ if not self.constraints:
296
+ return f"Applies({self.fact.__name__})"
297
+ fields = ", ".join(f"{name}={value!r}" for name, value in self.constraints)
298
+ return f"Applies({self.fact.__name__}, {fields})"