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/registry.py
ADDED
|
@@ -0,0 +1,692 @@
|
|
|
1
|
+
"""`Registry` — where a pack's `register()` adds what it provides.
|
|
2
|
+
|
|
3
|
+
Specified across `docs/02-extension-model.md` section 2 ("Packs and
|
|
4
|
+
discovery"). At Phase 0, before G2 settled arbitration between two packs
|
|
5
|
+
registering the same name, this module took the reversible choice —
|
|
6
|
+
**refuse the second registration outright, naming both distributions**, and
|
|
7
|
+
implement no last-wins, first-wins or qualification. A registration path that
|
|
8
|
+
overwrites silently with no check at all is a bug someone eventually has to
|
|
9
|
+
find; refusing could be
|
|
10
|
+
relaxed later without anyone having silently lost a registration first,
|
|
11
|
+
which is why it was the fixed choice rather than an improvement on it. G2
|
|
12
|
+
closed 2026-08-16 on exactly that relaxation — see task 1.12 below.
|
|
13
|
+
|
|
14
|
+
Lookup is the other side of the same design: an unresolvable name is loud —
|
|
15
|
+
naming what was wanted, that nothing registered it, and what the valid
|
|
16
|
+
options are — never a bare failure with no further information
|
|
17
|
+
(`docs/02-extension-model.md` section 2, *the trust model*).
|
|
18
|
+
|
|
19
|
+
**This is the base mechanism only.** The kernel names no capability:
|
|
20
|
+
`contract` here is any type a pack publishes, and `Registry` never imports or
|
|
21
|
+
knows what any particular contract is for. Discovery (step 5) decides how
|
|
22
|
+
a pack's own distribution name reaches `add`'s `distribution` argument; the
|
|
23
|
+
entry-point trust model, conditional registration and pack settings are all
|
|
24
|
+
later steps.
|
|
25
|
+
|
|
26
|
+
**`destroys` mandatoriness, added at task 1.2.** `docs/02-extension-model.md`
|
|
27
|
+
§3 → *Ordering constraints*: "`destroys` is mandatory wherever a contract
|
|
28
|
+
publishes a property vocabulary, an explicit empty tuple included;
|
|
29
|
+
registration is refused otherwise, naming the missing declaration." Detected
|
|
30
|
+
generically off `contract` — `getattr(contract, "publishes_property_vocabulary",
|
|
31
|
+
False)` — never off a hardcoded list of contract names, because the module
|
|
32
|
+
docstring above already states the rule this obeys: "the kernel never
|
|
33
|
+
imports or knows what any particular contract is for." A contract opts in
|
|
34
|
+
the same way a real contract publishes `version` (`weft_extract.contract`,
|
|
35
|
+
`weft_chunk.contract`): a `ClassVar` declared under `if TYPE_CHECKING:` and
|
|
36
|
+
assigned for real after the class body, so it never joins
|
|
37
|
+
`__protocol_attrs__` and costs a structurally-conforming plugin nothing. The
|
|
38
|
+
asymmetry with `intact`, which stays a convention no seam enforces, is
|
|
39
|
+
`02` §3's own: "forgetting `intact` harms only your own stage and you find
|
|
40
|
+
out, while forgetting `destroys` silently corrupts a stranger's, and the
|
|
41
|
+
pack that caused it never sees a failure" — a defect this refusal exists to
|
|
42
|
+
make impossible rather than merely documented against.
|
|
43
|
+
|
|
44
|
+
**Read through `unwrap_factory`, never off `factory` directly.** A plugin
|
|
45
|
+
that binds pack settings ahead of time — `functools.partial(PluginClass,
|
|
46
|
+
settings)`, the only shape available to one, and the one `weft_store`
|
|
47
|
+
actually registers — has no attributes of its own: `hasattr` on a `partial`
|
|
48
|
+
never reaches `.func`. Skipping the unwrap would make this refusal loud for
|
|
49
|
+
the wrong reason (a plugin that *did* declare `destroys`, refused anyway,
|
|
50
|
+
pointed at a remedy already satisfied) and, in `weft_kernel.resolution`,
|
|
51
|
+
silent for the worse one — `getattr(factory, "requires", ())` on an
|
|
52
|
+
unwrapped `partial` just returns the default, invisibly. `unwrap_factory`
|
|
53
|
+
is the one place that peeling happens; every reader of a class-level plugin
|
|
54
|
+
declaration goes through it.
|
|
55
|
+
|
|
56
|
+
**Task 3.1 — `destroys` generalised into `required_declarations`, not duplicated.**
|
|
57
|
+
`docs/03-cli.md` → *Permissions*: "a plugin-contributed command must declare
|
|
58
|
+
its [permission] class, and there is no default (G3)... a command that
|
|
59
|
+
declares none fails to register, loudly, while its author is standing right
|
|
60
|
+
there" — the identical shape `destroys` already has, needed for
|
|
61
|
+
`weft_command.contract.Command`'s `permission_class`. Writing a second,
|
|
62
|
+
parallel `_require_permission_class_if_command` would mean this module
|
|
63
|
+
learning the string `"permission_class"` and, worse, the word `Command` —
|
|
64
|
+
exactly the capability-naming G1 forbids in the kernel. So the check below
|
|
65
|
+
generalises instead: `_required_declarations(contract)` reads *two* sources
|
|
66
|
+
into one tuple — `contract.required_declarations`, a plain tuple of
|
|
67
|
+
attribute names any contract may set (`Command` sets it to
|
|
68
|
+
`("permission_class",)`), and the pre-existing
|
|
69
|
+
`publishes_property_vocabulary` flag, folded in as `"destroys"` for
|
|
70
|
+
`Chunker`, `Cleaner` and every contract that already opted in that way, so
|
|
71
|
+
none of them needed to change. One loop, `_require_declarations_present`,
|
|
72
|
+
walks whatever that tuple contains and raises for the first name
|
|
73
|
+
`unwrap_factory(factory)` lacks — there is exactly one place that checks
|
|
74
|
+
`hasattr` and exactly one place that raises, never one path per contract.
|
|
75
|
+
`MissingDestroysDeclarationError` is kept as a **subclass** of the new
|
|
76
|
+
`MissingRequiredDeclarationError`, specifically for the `"destroys"` name,
|
|
77
|
+
because task 1.2's own tests already catch it by that name and CLAUDE.md's
|
|
78
|
+
existing-test rule means that symbol does not move; every other required
|
|
79
|
+
name (`"permission_class"` included) raises the base class directly, with a
|
|
80
|
+
message built from the declaration name alone — nothing here says
|
|
81
|
+
`Command`, `permission` or any other capability word, so a future contract
|
|
82
|
+
adopting the identical mechanism costs this module nothing.
|
|
83
|
+
|
|
84
|
+
**Added at step 6 (the linear runner).** `entry()` returns the full
|
|
85
|
+
`RegistryEntry` — factory *and* distribution — where `lookup()` returns only
|
|
86
|
+
the factory. The runner needs the distribution to attribute the spans and
|
|
87
|
+
errors `weft_kernel.seam.wrap` produces to the pack that registered a
|
|
88
|
+
resolved stage; nothing before step 6 needed it, which is why `lookup` alone
|
|
89
|
+
was the whole surface until now.
|
|
90
|
+
|
|
91
|
+
**`contracts()` and `distributions_for()` added at task 0.12.** Until then,
|
|
92
|
+
`Registry` had no enumeration API a caller outside the kernel could walk —
|
|
93
|
+
`weft_cli.plugins_report`'s own module docstring named the gap explicitly,
|
|
94
|
+
as a real limitation rather than an oversight this module papered over.
|
|
95
|
+
`docs/08-manuals.md` §3 clause (b) is the first caller that actually needs
|
|
96
|
+
one: the generated contract reference has to know what got registered
|
|
97
|
+
without a second, hand-kept list of contract names, or it reproduces the
|
|
98
|
+
two-lists bug it exists to prevent. Both methods stay contract-agnostic —
|
|
99
|
+
`Registry` still never imports or knows what any particular contract is
|
|
100
|
+
for — and both are read-only projections of `_entries`, adding no new
|
|
101
|
+
state.
|
|
102
|
+
|
|
103
|
+
**Task 1.12 — G2 relaxed the refusal rather than tightening a silence.**
|
|
104
|
+
`docs/06-phase-0-build.md`'s own trap 3 already named the direction: "If G2
|
|
105
|
+
later chooses last-wins, first-wins or explicit qualification, it relaxes a
|
|
106
|
+
refusal rather than tightening a silence." G2 closed on qualification, but
|
|
107
|
+
qualification by the *operator*, not the pack — `docs/02-extension-model.md`
|
|
108
|
+
§3 → *When resolution fails*: "G3 settled that pipelines keep bare names, so
|
|
109
|
+
the data cannot break the tie and the operator does." `plugin_pins` is that
|
|
110
|
+
operator's `[plugins]` table from `weft.toml`, already parsed into a
|
|
111
|
+
`{"Contract:name": "distribution"}` mapping by whoever reads the file —
|
|
112
|
+
`weft-cli`'s `registry_bootstrap`, never this module, which is why `Registry`
|
|
113
|
+
takes a plain `Mapping[str, str]` rather than a path.
|
|
114
|
+
|
|
115
|
+
A pin changes what a collision *does*, never whether one is checked for:
|
|
116
|
+
`add`/`add_many` still refuse an unpinned collision exactly as before, now
|
|
117
|
+
printing the `[plugins]` line that would resolve it — the fixed refusal is
|
|
118
|
+
still the default, not replaced. A pinned collision instead resolves without
|
|
119
|
+
raising: the named distribution's registration stands (or takes over, if it
|
|
120
|
+
was the second to arrive), and the other is recorded in `displaced()` —
|
|
121
|
+
`docs/03-cli.md`'s own words, "installed, active, and one of its plugins is
|
|
122
|
+
unreachable" — never silently dropped the way an unchecked overwrite drops
|
|
123
|
+
one every time. This is also why a pinned collision no
|
|
124
|
+
longer fails `add_many`'s whole batch the way an unpinned one still does: the
|
|
125
|
+
pack that lost is not broken, so nothing about the rest of what it registered
|
|
126
|
+
should be either.
|
|
127
|
+
|
|
128
|
+
**A pin is a claim about a real fight, not a standing wildcard.** A pin
|
|
129
|
+
naming a distribution that is neither side of the collision it targets is
|
|
130
|
+
refused immediately, by `UnresolvedPluginPinError` — resolving it anyway
|
|
131
|
+
would silently start honouring a pin for a pack that never even contended,
|
|
132
|
+
which is exactly the kind of quiet drift a pin exists to prevent, not enable.
|
|
133
|
+
A pin that never sees a collision at all — because the name was never
|
|
134
|
+
claimed twice, or ever — is checked separately, by `unconsulted_pins()`;
|
|
135
|
+
`weft_kernel.discovery.discover` raises `InertPluginPinError` once discovery
|
|
136
|
+
finishes if any pin it was given never got the chance to arbitrate anything
|
|
137
|
+
and the caller has not opted out with `strict_pins=False` (a diagnostic
|
|
138
|
+
caller only — see `InertPluginPinError`'s own docstring), the same way an
|
|
139
|
+
unclaimed `packs:` settings key already raises there. Both directions share
|
|
140
|
+
one reasoning: `docs/02` says it as "an inert pin is a lie about what is
|
|
141
|
+
running."
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
import functools
|
|
145
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
146
|
+
from dataclasses import dataclass
|
|
147
|
+
from typing import cast
|
|
148
|
+
|
|
149
|
+
from weft_kernel.errors import UnresolvedNameError, WeftError
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class DuplicateRegistrationError(WeftError):
|
|
153
|
+
"""Two distributions registered the same name under the same contract, and no pin resolves it.
|
|
154
|
+
|
|
155
|
+
Fixed for Phase 0 by `docs/06-phase-0-build.md`: refusal, not silent
|
|
156
|
+
arbitration. Task 1.12 relaxes this exactly the way that trap said it
|
|
157
|
+
would — a `[plugins]` pin in `weft.toml` lets the operator name the
|
|
158
|
+
winner (see `Registry.__init__` and the module docstring); this class is
|
|
159
|
+
raised only when no such pin exists for the colliding name, and its
|
|
160
|
+
message prints the pin that would resolve it.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class UnresolvedPluginPinError(WeftError, UnresolvedNameError):
|
|
165
|
+
"""A `[plugins]` pin exists for this collision, but names neither side of it.
|
|
166
|
+
|
|
167
|
+
`docs/02-extension-model.md` §3: a pin naming a distribution that did not
|
|
168
|
+
claim the name must fail loudly rather than being ignored — resolving it
|
|
169
|
+
anyway would silently honour a pin for a pack that never contended, and
|
|
170
|
+
an inert pin is exactly the lie about what is running this refusal
|
|
171
|
+
exists to catch. Distinct from `DuplicateRegistrationError`: that one
|
|
172
|
+
fires when *no* pin exists for the key at all; this one fires when a pin
|
|
173
|
+
exists but points somewhere neither contender is.
|
|
174
|
+
|
|
175
|
+
Fitness function 12's family: `valid_options` is the two distributions
|
|
176
|
+
actually contending for the name — the pin can only ever be pointed at
|
|
177
|
+
one of them.
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
def __init__(self, message: str, *, valid_options: tuple[str, ...]) -> None:
|
|
181
|
+
super().__init__(message)
|
|
182
|
+
self.valid_options = valid_options
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class MissingRequiredDeclarationError(WeftError):
|
|
186
|
+
"""A plugin registered for a contract that requires a class-level declaration states none.
|
|
187
|
+
|
|
188
|
+
Task **3.1**'s generalisation of what task 1.2 built for `destroys`
|
|
189
|
+
alone — see the module docstring, *"`destroys` generalised into
|
|
190
|
+
`required_declarations`, not duplicated."* Raised for any name a
|
|
191
|
+
contract lists in `required_declarations` (or folds in via the legacy
|
|
192
|
+
`publishes_property_vocabulary` flag) that `unwrap_factory(factory)`
|
|
193
|
+
does not carry. `MissingDestroysDeclarationError` below is this class's
|
|
194
|
+
one fixed subclass, kept only because existing tests already catch it by
|
|
195
|
+
that name; every other required declaration — `permission_class`
|
|
196
|
+
included — raises this base class directly, so this module never learns
|
|
197
|
+
a second capability-specific name to special-case.
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class MissingDestroysDeclarationError(MissingRequiredDeclarationError):
|
|
202
|
+
"""A plugin registered for a contract that publishes a property vocabulary states no `destroys`.
|
|
203
|
+
|
|
204
|
+
`docs/02-extension-model.md` §3 → *Ordering constraints*: `destroys` is
|
|
205
|
+
mandatory wherever a contract publishes a property vocabulary — an
|
|
206
|
+
explicit empty tuple counts as stating it, silence does not. Refused
|
|
207
|
+
here, at registration, rather than left to surface later as a stranger's
|
|
208
|
+
ordering-sensitive stage silently corrupted by this one at resolution
|
|
209
|
+
time, with neither pack ever seeing a failure that names the cause.
|
|
210
|
+
|
|
211
|
+
Subclasses `MissingRequiredDeclarationError` since task 3.1 generalised
|
|
212
|
+
the check this raises from — `destroys` is now one required declaration
|
|
213
|
+
among however many a contract names, not a bespoke mechanism, but this
|
|
214
|
+
symbol stays exactly as task 1.2 left it because `tests/unit/weft_kernel/
|
|
215
|
+
test_registry.py` already asserts `pytest.raises(
|
|
216
|
+
MissingDestroysDeclarationError)` and CLAUDE.md's existing-test rule
|
|
217
|
+
means that assertion does not move.
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class UnknownPluginError(WeftError, UnresolvedNameError):
|
|
222
|
+
"""`Registry.lookup` was asked for a name no distribution registered.
|
|
223
|
+
|
|
224
|
+
The message states the contract and name that were wanted, that no
|
|
225
|
+
distribution registered it, and the full set of names that *are*
|
|
226
|
+
registered for that contract, so a typo reads as a typo rather than a
|
|
227
|
+
mystery — the property refused here: a request for an unregistered name
|
|
228
|
+
returning no error and no score at all.
|
|
229
|
+
|
|
230
|
+
Fitness function 12's canonical example: `valid_options` is every name
|
|
231
|
+
registered for the contract that was asked, carried structurally rather
|
|
232
|
+
than only inside `available` above.
|
|
233
|
+
"""
|
|
234
|
+
|
|
235
|
+
def __init__(self, message: str, *, valid_options: tuple[str, ...]) -> None:
|
|
236
|
+
super().__init__(message)
|
|
237
|
+
self.valid_options = valid_options
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@dataclass(frozen=True, slots=True)
|
|
241
|
+
class RegistryEntry:
|
|
242
|
+
"""One `(contract, name)` slot: what it resolves to, and which distribution put it there.
|
|
243
|
+
|
|
244
|
+
Public — unlike `lookup`, which hands back only the factory, the linear
|
|
245
|
+
runner (`06` step 6) needs the distribution too, to attribute the spans
|
|
246
|
+
and errors `weft_kernel.seam.wrap` produces to the pack that registered
|
|
247
|
+
the stage. `entry()` returns this; `lookup()` is `entry(...).factory` for
|
|
248
|
+
callers that only need the callable.
|
|
249
|
+
"""
|
|
250
|
+
|
|
251
|
+
factory: Callable[..., object]
|
|
252
|
+
distribution: str
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@dataclass(frozen=True, slots=True)
|
|
256
|
+
class DisplacedRegistration:
|
|
257
|
+
"""A registration a `[plugins]` pin resolved *away from* — recorded, never dropped.
|
|
258
|
+
|
|
259
|
+
`docs/03-cli.md`: "the pack lost a `(contract, name)` collision to an
|
|
260
|
+
operator's pin, so it is installed, active, and one of its plugins is
|
|
261
|
+
unreachable." `distribution` is the pack that lost; `winner` is the one
|
|
262
|
+
the pin named instead; `pin` is the exact `"Contract:name"` key from
|
|
263
|
+
`weft.toml`'s `[plugins]` table, so `weft plugins doctor` can print the
|
|
264
|
+
line an operator would need to change to reverse the decision.
|
|
265
|
+
"""
|
|
266
|
+
|
|
267
|
+
contract: type[object]
|
|
268
|
+
name: str
|
|
269
|
+
distribution: str
|
|
270
|
+
winner: str
|
|
271
|
+
pin: str
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@dataclass(frozen=True, slots=True)
|
|
275
|
+
class _CollisionResolution:
|
|
276
|
+
"""What a pinned collision resolves to — computed without mutating the registry.
|
|
277
|
+
|
|
278
|
+
`entry` is `None` when the already-registered side wins (nothing to
|
|
279
|
+
write); otherwise it is the replacement to write. Kept side-effect-free
|
|
280
|
+
so `add_many` can resolve every entry in a batch *before* committing any
|
|
281
|
+
of it — the same all-or-nothing discipline the unpinned path already has.
|
|
282
|
+
"""
|
|
283
|
+
|
|
284
|
+
entry: RegistryEntry | None
|
|
285
|
+
displaced: DisplacedRegistration
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
class Registry:
|
|
289
|
+
"""Where every pack's `register()` adds what it provides, keyed by contract and name.
|
|
290
|
+
|
|
291
|
+
Two names collide only if they share both the contract *and* the string
|
|
292
|
+
name: two packs each publishing the name `"fast"`, but under two
|
|
293
|
+
different contract types, are unrelated registrations, because the
|
|
294
|
+
kernel treats the contract as part of the key, never as a shared
|
|
295
|
+
namespace plugins must avoid colliding in by convention.
|
|
296
|
+
"""
|
|
297
|
+
|
|
298
|
+
def __init__(self, *, plugin_pins: Mapping[str, str] | None = None) -> None:
|
|
299
|
+
"""`plugin_pins` is the parsed `[plugins]` table — see the module docstring.
|
|
300
|
+
|
|
301
|
+
Keyed `"Contract:name"` to the distribution that should win a
|
|
302
|
+
collision on that name — `docs/02-extension-model.md` §3's exact
|
|
303
|
+
shape. Copied into a plain `dict` so a caller mutating the mapping it
|
|
304
|
+
passed in afterwards cannot change this registry's policy out from
|
|
305
|
+
under it. Absent (`None`, the default) behaves exactly as Phase 0
|
|
306
|
+
did: every collision is refused, unconditionally.
|
|
307
|
+
"""
|
|
308
|
+
self._entries: dict[tuple[type[object], str], RegistryEntry] = {}
|
|
309
|
+
self._plugin_pins: dict[str, str] = dict(plugin_pins or {})
|
|
310
|
+
self._displaced: list[DisplacedRegistration] = []
|
|
311
|
+
self._consulted_pins: set[str] = set()
|
|
312
|
+
|
|
313
|
+
def add(
|
|
314
|
+
self,
|
|
315
|
+
contract: type[object],
|
|
316
|
+
name: str,
|
|
317
|
+
factory: Callable[..., object],
|
|
318
|
+
*,
|
|
319
|
+
distribution: str,
|
|
320
|
+
) -> None:
|
|
321
|
+
"""Register `factory` as `name` for `contract`, attributed to `distribution`.
|
|
322
|
+
|
|
323
|
+
`distribution` is supplied by the registration seam (step 3), never
|
|
324
|
+
by a pack author: attribution is a cross-cutting concern, and
|
|
325
|
+
CLAUDE.md places those at the seam rather than in a rule authors
|
|
326
|
+
must remember. A pack's own `register()` calls the seam-bound
|
|
327
|
+
surface with `(contract, name, factory)`; this lower-level `add` is
|
|
328
|
+
what the seam calls once it has filled `distribution` in itself.
|
|
329
|
+
|
|
330
|
+
If `(contract, name)` is already taken, a `[plugins]` pin for this
|
|
331
|
+
exact key resolves it — see the module docstring, *"Task 1.12."* With
|
|
332
|
+
no pin, this still refuses outright, naming both the distribution
|
|
333
|
+
that registered first and the one attempting to register now, and
|
|
334
|
+
printing the pin that would resolve it — the fixed, reversible
|
|
335
|
+
choice `docs/06-phase-0-build.md` took for G2's open arbitration
|
|
336
|
+
question, still the default now that G2 has closed.
|
|
337
|
+
|
|
338
|
+
Also refuses `factory` outright — before either check, since this is
|
|
339
|
+
a property of the registration attempt itself rather than of what
|
|
340
|
+
else is already here — if `contract` names any required declaration
|
|
341
|
+
(`required_declarations`, or the legacy `publishes_property_vocabulary`
|
|
342
|
+
flag) that `factory` never mentions; see the module docstring,
|
|
343
|
+
*"Task 3.1 — `destroys` generalised into `required_declarations`."*
|
|
344
|
+
"""
|
|
345
|
+
_require_declarations_present(contract, name, factory, distribution=distribution)
|
|
346
|
+
key = (contract, name)
|
|
347
|
+
existing = self._entries.get(key)
|
|
348
|
+
if existing is None:
|
|
349
|
+
self._entries[key] = RegistryEntry(factory=factory, distribution=distribution)
|
|
350
|
+
return
|
|
351
|
+
resolution = self._resolve_collision(
|
|
352
|
+
contract, name, existing=existing, factory=factory, distribution=distribution
|
|
353
|
+
)
|
|
354
|
+
self._commit_resolution(key, resolution)
|
|
355
|
+
|
|
356
|
+
def add_many(
|
|
357
|
+
self,
|
|
358
|
+
entries: Iterable[tuple[type[object], str, Callable[..., object]]],
|
|
359
|
+
*,
|
|
360
|
+
distribution: str,
|
|
361
|
+
) -> None:
|
|
362
|
+
"""Register every `(contract, name, factory)` in `entries`, all at once or not at all.
|
|
363
|
+
|
|
364
|
+
The commit half of a pack's transactional registration
|
|
365
|
+
(`weft_kernel.discovery.PackRegistrar`): a pack's own `register()`
|
|
366
|
+
buffers every `add` call instead of writing immediately, precisely so
|
|
367
|
+
that a pack raising partway through never leaves some of its
|
|
368
|
+
registrations standing while its `PackReport` claims zero — the
|
|
369
|
+
defect this method exists to make structurally impossible. Every key
|
|
370
|
+
in `entries` is checked against both the registry and the rest of
|
|
371
|
+
the batch *before* anything is written — an unpinned collision
|
|
372
|
+
against an existing registration, or `entries` registering the same
|
|
373
|
+
`(contract, name)` twice within this one call, both still raise
|
|
374
|
+
`DuplicateRegistrationError` and leave the registry exactly as it
|
|
375
|
+
was. A *pinned* collision against an existing registration resolves
|
|
376
|
+
instead of raising (task 1.12): the rest of the batch still commits,
|
|
377
|
+
because the pack that lost one name is installed and active, not
|
|
378
|
+
broken — `docs/03-cli.md`'s own words. Only once every entry is
|
|
379
|
+
either free or pin-resolved does any write happen, so a failed call
|
|
380
|
+
is still always a no-op. The mandatory-declaration check `add`
|
|
381
|
+
performs (see its own docstring) runs here too, for the same reason
|
|
382
|
+
and on the same all-or-nothing terms.
|
|
383
|
+
"""
|
|
384
|
+
batch = list(entries)
|
|
385
|
+
pending_new: dict[tuple[type[object], str], RegistryEntry] = {}
|
|
386
|
+
pending_resolutions: list[tuple[tuple[type[object], str], _CollisionResolution]] = []
|
|
387
|
+
for contract, name, factory in batch:
|
|
388
|
+
_require_declarations_present(contract, name, factory, distribution=distribution)
|
|
389
|
+
key = (contract, name)
|
|
390
|
+
existing = self._entries.get(key)
|
|
391
|
+
if existing is not None:
|
|
392
|
+
resolution = self._resolve_collision(
|
|
393
|
+
contract, name, existing=existing, factory=factory, distribution=distribution
|
|
394
|
+
)
|
|
395
|
+
pending_resolutions.append((key, resolution))
|
|
396
|
+
continue
|
|
397
|
+
if key in pending_new:
|
|
398
|
+
raise DuplicateRegistrationError(
|
|
399
|
+
f"'{name}' is registered twice for {contract.__name__} within "
|
|
400
|
+
f"distribution '{distribution}'s own register() call; a pack cannot "
|
|
401
|
+
f"register the same name for the same contract more than once."
|
|
402
|
+
)
|
|
403
|
+
pending_new[key] = RegistryEntry(factory=factory, distribution=distribution)
|
|
404
|
+
|
|
405
|
+
for key, entry in pending_new.items():
|
|
406
|
+
self._entries[key] = entry
|
|
407
|
+
for key, resolution in pending_resolutions:
|
|
408
|
+
self._commit_resolution(key, resolution)
|
|
409
|
+
|
|
410
|
+
def displaced(self) -> tuple[DisplacedRegistration, ...]:
|
|
411
|
+
"""Every registration a `[plugins]` pin resolved away from, in the order it happened.
|
|
412
|
+
|
|
413
|
+
`weft_cli.plugins_report.render_doctor`'s source for the "displaced"
|
|
414
|
+
report `docs/03-cli.md` describes — never dropped, per the module
|
|
415
|
+
docstring's *Task 1.12* note.
|
|
416
|
+
"""
|
|
417
|
+
return tuple(self._displaced)
|
|
418
|
+
|
|
419
|
+
def unconsulted_pins(self) -> frozenset[str]:
|
|
420
|
+
"""Every `[plugins]` pin this registry was given that never resolved an actual collision.
|
|
421
|
+
|
|
422
|
+
`weft_kernel.discovery.discover` raises `InertPluginPinError` off
|
|
423
|
+
this once discovery finishes, the same way it already raises for an
|
|
424
|
+
unclaimed `packs:` settings key — see the module docstring's *Task
|
|
425
|
+
1.12* note. A pin naming a `(contract, name)` nothing ever claimed
|
|
426
|
+
twice stays in this set forever; one that actually arbitrated a
|
|
427
|
+
collision (`add`/`add_many` calling `_resolve_collision`) leaves it.
|
|
428
|
+
"""
|
|
429
|
+
return frozenset(self._plugin_pins) - self._consulted_pins
|
|
430
|
+
|
|
431
|
+
def _resolve_collision(
|
|
432
|
+
self,
|
|
433
|
+
contract: type[object],
|
|
434
|
+
name: str,
|
|
435
|
+
*,
|
|
436
|
+
existing: RegistryEntry,
|
|
437
|
+
factory: Callable[..., object],
|
|
438
|
+
distribution: str,
|
|
439
|
+
) -> _CollisionResolution:
|
|
440
|
+
"""What a pin says to do about this collision, or the refusal if none applies.
|
|
441
|
+
|
|
442
|
+
Pure — reads `self._plugin_pins` but writes nothing, so `add_many`
|
|
443
|
+
can call this for every colliding entry in a batch before committing
|
|
444
|
+
any of them. Raises `DuplicateRegistrationError` (no pin for this
|
|
445
|
+
key) or `UnresolvedPluginPinError` (a pin exists but names neither
|
|
446
|
+
`existing.distribution` nor `distribution`) directly; a caller never
|
|
447
|
+
has to distinguish those from an ordinary return.
|
|
448
|
+
"""
|
|
449
|
+
pin_key = _pin_key(contract, name)
|
|
450
|
+
pin = self._plugin_pins.get(pin_key)
|
|
451
|
+
if pin is None:
|
|
452
|
+
raise DuplicateRegistrationError(
|
|
453
|
+
f"'{name}' is already registered for {contract.__name__} by "
|
|
454
|
+
f"distribution '{existing.distribution}'; distribution '{distribution}' "
|
|
455
|
+
f"cannot register it too. Weft refuses to arbitrate between them — pin the "
|
|
456
|
+
f"winner in weft.toml:\n\n[plugins]\n"
|
|
457
|
+
f'"{pin_key}" = "{existing.distribution}" # or "{distribution}"\n\n'
|
|
458
|
+
f"to keep the other distribution's claim instead. See the duplicate-name "
|
|
459
|
+
f"trap in docs/02-extension-model.md §3, 'When resolution fails'."
|
|
460
|
+
)
|
|
461
|
+
if pin not in (existing.distribution, distribution):
|
|
462
|
+
contenders = (existing.distribution, distribution)
|
|
463
|
+
raise UnresolvedPluginPinError(
|
|
464
|
+
f"[plugins] pins '{pin_key}' to '{pin}', but '{pin}' registered neither "
|
|
465
|
+
f"claim on '{name}' for {contract.__name__} — {', '.join(contenders)} are "
|
|
466
|
+
f"the two distributions actually contending for it. Point the pin at one "
|
|
467
|
+
f"of them, or remove it if it was meant for a different collision.",
|
|
468
|
+
valid_options=contenders,
|
|
469
|
+
)
|
|
470
|
+
if pin == existing.distribution:
|
|
471
|
+
return _CollisionResolution(
|
|
472
|
+
entry=None,
|
|
473
|
+
displaced=DisplacedRegistration(
|
|
474
|
+
contract=contract,
|
|
475
|
+
name=name,
|
|
476
|
+
distribution=distribution,
|
|
477
|
+
winner=existing.distribution,
|
|
478
|
+
pin=pin_key,
|
|
479
|
+
),
|
|
480
|
+
)
|
|
481
|
+
return _CollisionResolution(
|
|
482
|
+
entry=RegistryEntry(factory=factory, distribution=distribution),
|
|
483
|
+
displaced=DisplacedRegistration(
|
|
484
|
+
contract=contract,
|
|
485
|
+
name=name,
|
|
486
|
+
distribution=existing.distribution,
|
|
487
|
+
winner=distribution,
|
|
488
|
+
pin=pin_key,
|
|
489
|
+
),
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
def _commit_resolution(
|
|
493
|
+
self, key: tuple[type[object], str], resolution: _CollisionResolution
|
|
494
|
+
) -> None:
|
|
495
|
+
"""Write what `_resolve_collision` decided: the replacement (if any), and the record."""
|
|
496
|
+
if resolution.entry is not None:
|
|
497
|
+
self._entries[key] = resolution.entry
|
|
498
|
+
self._displaced.append(resolution.displaced)
|
|
499
|
+
self._consulted_pins.add(resolution.displaced.pin)
|
|
500
|
+
|
|
501
|
+
def contracts(self) -> frozenset[type[object]]:
|
|
502
|
+
"""Every contract type with at least one registered name.
|
|
503
|
+
|
|
504
|
+
Two names under one contract still count once — the key this counts
|
|
505
|
+
over is `_entries`' contract half, not the full `(contract, name)`
|
|
506
|
+
pair. See the module docstring for why this exists and who the
|
|
507
|
+
first caller is.
|
|
508
|
+
"""
|
|
509
|
+
return frozenset(contract for contract, _ in self._entries)
|
|
510
|
+
|
|
511
|
+
def names_for(self, contract: type[object]) -> frozenset[str]:
|
|
512
|
+
"""Every name registered under `contract`, whoever registered it.
|
|
513
|
+
|
|
514
|
+
The kernel names no capability, and this method is why that is
|
|
515
|
+
affordable: a caller that needs to know *what a contract's plugins
|
|
516
|
+
collectively claim* — the ingest accept set is the first, the union of
|
|
517
|
+
the file extensions every registered extractor declares — derives it
|
|
518
|
+
from what actually registered rather than from a list in one pack's
|
|
519
|
+
module. `docs/02-extension-model.md` §1: capability is derived, never
|
|
520
|
+
declared. Without this the derivation is impossible from outside and
|
|
521
|
+
the list gets hand-maintained, which is exactly the drift it exists to
|
|
522
|
+
prevent.
|
|
523
|
+
|
|
524
|
+
Empty, never an error, for a contract nothing registered — symmetric
|
|
525
|
+
with `distributions_for`: a caller enumerating what is installed is
|
|
526
|
+
asking a question, and "nothing" is an answer.
|
|
527
|
+
"""
|
|
528
|
+
return frozenset(name for (registered, name) in self._entries if registered is contract)
|
|
529
|
+
|
|
530
|
+
def distributions_for(self, contract: type[object]) -> frozenset[str]:
|
|
531
|
+
"""Every distribution that registered at least one name under `contract`.
|
|
532
|
+
|
|
533
|
+
A contract is not one pack's alone — two distributions may each
|
|
534
|
+
register a different name under the same contract, exactly the
|
|
535
|
+
shape `docs/02-extension-model.md` describes for a second store
|
|
536
|
+
backend — so this returns every contributor, never a single owner.
|
|
537
|
+
Empty, never an error, for a contract nothing registered.
|
|
538
|
+
"""
|
|
539
|
+
return frozenset(
|
|
540
|
+
entry.distribution
|
|
541
|
+
for (registered, _), entry in self._entries.items()
|
|
542
|
+
if registered is contract
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
def lookup(self, contract: type[object], name: str) -> Callable[..., object]:
|
|
546
|
+
"""The factory registered as `name` for `contract`.
|
|
547
|
+
|
|
548
|
+
`self.entry(contract, name).factory` — see `entry` for the same
|
|
549
|
+
lookup with the providing distribution attached, and for the error
|
|
550
|
+
both raise.
|
|
551
|
+
"""
|
|
552
|
+
return self.entry(contract, name).factory
|
|
553
|
+
|
|
554
|
+
def entry(self, contract: type[object], name: str) -> RegistryEntry:
|
|
555
|
+
"""The full registration for `(contract, name)`: its factory and providing distribution.
|
|
556
|
+
|
|
557
|
+
Raises `UnknownPluginError`, naming the contract and name that were
|
|
558
|
+
wanted, stating that nothing registered it, and listing every name
|
|
559
|
+
that *is* registered for that contract.
|
|
560
|
+
"""
|
|
561
|
+
found = self._entries.get((contract, name))
|
|
562
|
+
if found is not None:
|
|
563
|
+
return found
|
|
564
|
+
|
|
565
|
+
options = tuple(
|
|
566
|
+
sorted(
|
|
567
|
+
registered_name
|
|
568
|
+
for registered_contract, registered_name in self._entries
|
|
569
|
+
if registered_contract == contract
|
|
570
|
+
)
|
|
571
|
+
)
|
|
572
|
+
available = ", ".join(f"'{option}'" for option in options) if options else "none"
|
|
573
|
+
raise UnknownPluginError(
|
|
574
|
+
f"no '{name}' is registered for {contract.__name__}. It is "
|
|
575
|
+
f"unavailable because no distribution has registered that name for this "
|
|
576
|
+
f"contract. Names registered for {contract.__name__}: {available}.",
|
|
577
|
+
valid_options=options,
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _pin_key(contract: type[object], name: str) -> str:
|
|
582
|
+
"""The exact `"Contract:name"` string a `[plugins]` pin uses for this collision.
|
|
583
|
+
|
|
584
|
+
`docs/02-extension-model.md` §3's own shape — `"Enhancer:keybert" =
|
|
585
|
+
"weft-kw"`. `contract.__name__` rather than a fully-qualified path,
|
|
586
|
+
matching every other message in this module that already names a
|
|
587
|
+
contract this way (`DuplicateRegistrationError`, `UnknownPluginError`):
|
|
588
|
+
the operator writing `weft.toml` sees the same short name doctor and
|
|
589
|
+
every refusal already print.
|
|
590
|
+
"""
|
|
591
|
+
return f"{contract.__name__}:{name}"
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def unwrap_factory(factory: Callable[..., object]) -> object:
|
|
595
|
+
"""The plugin class (or callable) a factory constructs, `functools.partial` peeled away.
|
|
596
|
+
|
|
597
|
+
A plugin needing pack settings has only one shape available to it —
|
|
598
|
+
`functools.partial(PluginClass, settings)`, exactly what
|
|
599
|
+
`weft_store.pgvector_store.register` does — and `functools.partial` does
|
|
600
|
+
not proxy attribute access to `.func`: `hasattr(functools.partial(C), "x")`
|
|
601
|
+
is `False` even when `C.x` is set, for any `x`. Every read site in the
|
|
602
|
+
kernel that inspects a *class* attribute a plugin declared — `destroys`
|
|
603
|
+
here, and `requires`/`provides`/`intact`/`destroys` in
|
|
604
|
+
`weft_kernel.resolution.resolve` — must therefore unwrap the same way
|
|
605
|
+
before reading, or a pack binding its own settings this way has its
|
|
606
|
+
declarations silently invisible to one reader while
|
|
607
|
+
`weft_kernel.runner.Runner`, which reads a *constructed instance* instead
|
|
608
|
+
of the factory, sees them correctly. Walking `.func` repeatedly handles a
|
|
609
|
+
`partial` of a `partial`, which `functools.partial` itself collapses on
|
|
610
|
+
construction but nothing guarantees a caller building one by hand did.
|
|
611
|
+
"""
|
|
612
|
+
target: object = factory
|
|
613
|
+
while isinstance(target, functools.partial):
|
|
614
|
+
target = target.func
|
|
615
|
+
return target
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _required_declarations(contract: type[object]) -> tuple[str, ...]:
|
|
619
|
+
"""Every class attribute `contract` requires each of its implementations to declare.
|
|
620
|
+
|
|
621
|
+
Task **3.1**'s generalisation — see the module docstring, *"`destroys`
|
|
622
|
+
generalised into `required_declarations`, not duplicated."* Two sources,
|
|
623
|
+
merged, neither one a hardcoded contract or capability name:
|
|
624
|
+
|
|
625
|
+
- `contract.required_declarations` — a plain `ClassVar[tuple[str, ...]]`
|
|
626
|
+
any contract may set, read generically off `contract` the same way
|
|
627
|
+
`publishes_property_vocabulary` already was. `weft_command.contract.
|
|
628
|
+
Command` sets this to `("permission_class",)`; this module never reads
|
|
629
|
+
or writes that string anywhere else.
|
|
630
|
+
- The legacy `publishes_property_vocabulary` flag, folded in as
|
|
631
|
+
`"destroys"` whenever it is set and not already present — `Chunker`,
|
|
632
|
+
`Cleaner` and every task-1.2-era contract keeps meaning exactly what
|
|
633
|
+
it meant, at zero cost, because neither its flag nor its plugins had
|
|
634
|
+
to change for this generalisation to land.
|
|
635
|
+
|
|
636
|
+
An ungoverned contract (the overwhelming majority) returns `()`, and
|
|
637
|
+
`_require_declarations_present` below is then a no-op, exactly as
|
|
638
|
+
`_require_destroys_if_governed` was before this task.
|
|
639
|
+
"""
|
|
640
|
+
declared = cast("tuple[str, ...]", tuple(getattr(contract, "required_declarations", ())))
|
|
641
|
+
if getattr(contract, "publishes_property_vocabulary", False) and "destroys" not in declared:
|
|
642
|
+
declared = (*declared, "destroys")
|
|
643
|
+
return declared
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def _require_declarations_present(
|
|
647
|
+
contract: type[object], name: str, factory: Callable[..., object], *, distribution: str
|
|
648
|
+
) -> None:
|
|
649
|
+
"""Refuse `factory` if `contract` requires a declaration `factory` never states.
|
|
650
|
+
|
|
651
|
+
One loop over `_required_declarations(contract)`, one `hasattr` check,
|
|
652
|
+
one raise — the single code path task 3.1's own module-docstring note
|
|
653
|
+
describes, replacing the `destroys`-only version task 1.2 wrote.
|
|
654
|
+
|
|
655
|
+
`hasattr(unwrap_factory(factory), declaration)` reads the *class*, not
|
|
656
|
+
an instance — `factory` is ordinarily the plugin class itself
|
|
657
|
+
(`registrar.add(Chunker, "fixed-size", FixedSizeChunker)`), sometimes a
|
|
658
|
+
`functools.partial` binding pack settings ahead of it (see
|
|
659
|
+
`unwrap_factory`), and no config exists yet to build an instance from at
|
|
660
|
+
registration time either way. A class that never mentions the
|
|
661
|
+
declaration — not even inherited — answers `False`; one that assigns it
|
|
662
|
+
explicitly (`destroys = ()`, `permission_class = PermissionClass.READ`)
|
|
663
|
+
answers `True`, the same asymmetry `02` §3 states for `destroys`: the
|
|
664
|
+
value must be *written*, not merely true by silence.
|
|
665
|
+
|
|
666
|
+
The exact error type is the one place this function still knows the
|
|
667
|
+
name `"destroys"` — not as a capability, but as the one required
|
|
668
|
+
declaration old enough to have tests pinned to its own exception class
|
|
669
|
+
(see `MissingDestroysDeclarationError`'s docstring). Every other
|
|
670
|
+
required declaration raises `MissingRequiredDeclarationError` directly,
|
|
671
|
+
with a message built from `declaration`, `contract` and `distribution`
|
|
672
|
+
alone.
|
|
673
|
+
"""
|
|
674
|
+
for declaration in _required_declarations(contract):
|
|
675
|
+
if hasattr(unwrap_factory(factory), declaration):
|
|
676
|
+
continue
|
|
677
|
+
if declaration == "destroys":
|
|
678
|
+
raise MissingDestroysDeclarationError(
|
|
679
|
+
f"'{name}' registers for {contract.__name__} (distribution '{distribution}') "
|
|
680
|
+
f"without declaring `destroys`. {contract.__name__} publishes a property "
|
|
681
|
+
f"vocabulary (docs/02-extension-model.md §3 → Ordering constraints), so every "
|
|
682
|
+
f"implementation states what it destroys — an explicit empty tuple if it "
|
|
683
|
+
f"destroys nothing. Add `destroys: tuple[type[Property], ...] = (...)` to the "
|
|
684
|
+
f"plugin class."
|
|
685
|
+
)
|
|
686
|
+
raise MissingRequiredDeclarationError(
|
|
687
|
+
f"'{name}' registers for {contract.__name__} (distribution '{distribution}') "
|
|
688
|
+
f"without declaring `{declaration}`. {contract.__name__}.required_declarations "
|
|
689
|
+
f"names it as mandatory, with no default silently assumed — see the contract's "
|
|
690
|
+
f"own docstring for what it means and what value to give it. Add `{declaration} "
|
|
691
|
+
f"= ...` to the plugin class."
|
|
692
|
+
)
|