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/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, ...]
|
weft_kernel/fallback.py
ADDED
|
@@ -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})"
|