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/discovery.py
ADDED
|
@@ -0,0 +1,1181 @@
|
|
|
1
|
+
"""Entry-point discovery and the trust model — G3 in code.
|
|
2
|
+
|
|
3
|
+
Specified in `docs/06-phase-0-build.md` step 5 and `docs/02-extension-model.md`
|
|
4
|
+
section 2 ("Packs and discovery", *The trust model*). The threat G3 settled on
|
|
5
|
+
is **installed-and-ambient, not malicious**: entry points turn *installed*
|
|
6
|
+
into *executed with the application's privileges*, and the only defensible
|
|
7
|
+
posture is to state that plainly rather than simulate a control CPython
|
|
8
|
+
cannot enforce. Two consequences follow directly into this module's shape.
|
|
9
|
+
|
|
10
|
+
**Discovery is eager, and refusal precedes it.** `discover()` enumerates every
|
|
11
|
+
distribution that declares a `weft.packs` entry point and, for each one still
|
|
12
|
+
permitted, imports it and calls its `register()` — because bare plugin names
|
|
13
|
+
(`docs/02` section 3) mean nothing can answer "who provides this?" without
|
|
14
|
+
running `register()` to find out. A pack outside an active `[packs] allow`
|
|
15
|
+
pin is never imported at all: the check happens before `entry_point.load()`
|
|
16
|
+
is ever called, not after, because "refused" that still executed is not
|
|
17
|
+
refusal. This is fitness function 8(a), the property the canary in
|
|
18
|
+
`testing/weft-canary/` exists to prove — see `tests/architecture/`.
|
|
19
|
+
|
|
20
|
+
**One status vocabulary answers "why is this pack not contributing?" for
|
|
21
|
+
every reason at once** — refused, failed, partially registered, or simply not
|
|
22
|
+
installed — because a refused pack and a pack that lost half its
|
|
23
|
+
registrations to a missing optional dependency are the same question asked
|
|
24
|
+
twice. `PARTIAL` is part of that vocabulary now because the vocabulary is one
|
|
25
|
+
piece; the *mechanism* that produces it is G4's conditional registration, a
|
|
26
|
+
later step's job.
|
|
27
|
+
|
|
28
|
+
**Two more pieces of the trust model attach here because there was nowhere
|
|
29
|
+
else for them to attach.** `DISCLOSURE` is a module-level, optional, purely
|
|
30
|
+
informational value the kernel reads immediately after import and before
|
|
31
|
+
`register()` runs — never a claim weft checks, never a permission it grants.
|
|
32
|
+
A `DISCLOSURE` that is present but not a `Disclosure` instance is not the
|
|
33
|
+
same fact as no `DISCLOSURE` at all — the pack tried to say something and
|
|
34
|
+
said it wrong — so that case is `FAILED`, naming the pack and what
|
|
35
|
+
the attribute must be, rather than silently read as "not disclosed."
|
|
36
|
+
Pack settings (`docs/02`'s `packs:` block, keyed by **pack** name — the
|
|
37
|
+
`weft.packs` entry-point name, so `[packs.store]`, never `[packs.store]`)
|
|
38
|
+
are validated against the pack's own Pydantic model *before* `register()` is
|
|
39
|
+
called, because a pack author's `register(registry, settings)` should never
|
|
40
|
+
have to guard against malformed input the kernel could have rejected first —
|
|
41
|
+
and `${env:VAR}` interpolation happens on that settings data here, so no pack
|
|
42
|
+
ever reads `os.environ` itself for a secret. A `packs:` key naming a
|
|
43
|
+
pack nothing installed declares is the strict half of that same
|
|
44
|
+
model: `docs/02` states the asymmetry exactly — "`packs:` expresses a
|
|
45
|
+
requirement, `allow` expresses a permission" — so once every entry point is
|
|
46
|
+
enumerated, any settings key no entry point claims raises, naming every pack
|
|
47
|
+
that did declare a `weft.packs` entry point, rather than being read and
|
|
48
|
+
quietly discarded.
|
|
49
|
+
|
|
50
|
+
**`pack` and `distribution` are two different identities and this module is
|
|
51
|
+
where they part.** A pack is what an entry point names; a distribution is what
|
|
52
|
+
an index ships. They were the same string while every pack had a wheel of its
|
|
53
|
+
own, and stopped being it when one wheel started shipping fourteen. Everything
|
|
54
|
+
an operator points at a *pack* keys on the entry-point name — the rows `weft
|
|
55
|
+
plugins list|doctor` prints, `[packs.<pack>]` settings, and the pack named in a
|
|
56
|
+
settings or disclosure failure. Everything about *provenance* keys on the
|
|
57
|
+
distribution — `[packs] allow`, the version column, and version skew. `docs/
|
|
58
|
+
02-extension-model.md` §2 owns the split.
|
|
59
|
+
|
|
60
|
+
**Registration is transactional per pack.** `PackRegistrar.add` buffers
|
|
61
|
+
every call instead of writing through to the `Registry` immediately; nothing
|
|
62
|
+
lands there until `register()` returns without raising, at which point the
|
|
63
|
+
whole buffer commits in one atomic step (`Registry.add_many`) or none of it
|
|
64
|
+
does. A pack that raises partway through therefore contributes exactly the
|
|
65
|
+
`0` its `FAILED` report claims — the report and the registry can never
|
|
66
|
+
disagree about how much of a half-finished pack is actually live.
|
|
67
|
+
|
|
68
|
+
**`PackRegistrar` exists because attribution is not a pack author's to supply, and
|
|
69
|
+
attribution is never a pack author's to supply** — `registry.py`'s own
|
|
70
|
+
docstring names this exact gap: "A pack's own `register()` calls the
|
|
71
|
+
seam-bound surface with `(contract, name, factory)`." This is that surface:
|
|
72
|
+
`Registry.add` minus its keyword-only `distribution` parameter, bound once
|
|
73
|
+
per pack by whatever called `discover()`, so a pack author writes
|
|
74
|
+
`registrar.add(Contract, "name", factory)` and never sees attribution at all.
|
|
75
|
+
|
|
76
|
+
**Task 5.2g — `add_ext_model` closes the gap `docs/02-extension-model.md` §1's
|
|
77
|
+
Phase 0 step 8 note left open: "a pack's `register()` does not contribute one
|
|
78
|
+
automatically."** Buffered exactly like `add_pipeline_resource` and
|
|
79
|
+
`deprecate` above, for the identical structural reason — a pack whose
|
|
80
|
+
`register()` raises partway through must not leave a namespace claimed that
|
|
81
|
+
was never actually committed. `weft_kernel.payload.ext.ExtModel` is a payload
|
|
82
|
+
primitive the kernel already owns (`Node.ext`'s own declared value type), not
|
|
83
|
+
a capability, so buffering a `type[ExtModel]` here teaches the kernel nothing
|
|
84
|
+
about stores: it is inert data until something that *does* know what a store
|
|
85
|
+
is reads `PackReport.ext_models` back off every report and registers each
|
|
86
|
+
class with the namespace-to-class registry that actually rehydrates one —
|
|
87
|
+
`weft_store.rehydrate.register_from_reports`, called once, generically, by
|
|
88
|
+
whatever already calls `discover()` (`weft_cli.registry_bootstrap.
|
|
89
|
+
build_dependencies`). No pack-specific knowledge sits in that call site: it
|
|
90
|
+
walks whatever `PackReport.ext_models` any report carries, so a future pack
|
|
91
|
+
shipping a new `ExtModel` needs no edit here and no edit in `weft-cli` at all.
|
|
92
|
+
|
|
93
|
+
**Task 5.3a (`S8`) — `add_contribution` closes the identical gap for `02` §3 →
|
|
94
|
+
*Slots*: a pack could describe a stage contribution as a `weft_kernel.resolution.
|
|
95
|
+
Contribution` value, but nothing let a pack's own `register()` hand one to anything.
|
|
96
|
+
Buffered exactly like `add_ext_model` just above, for the identical structural
|
|
97
|
+
reason — a pack whose `register()` raises partway through must not leave a slot
|
|
98
|
+
looking filled that was never actually committed. `Contribution` is a pipeline-model
|
|
99
|
+
value the kernel already owns (`weft_kernel.resolution` is this same distribution),
|
|
100
|
+
not a capability, so this teaches the kernel nothing new either: it stops at "this
|
|
101
|
+
pack offered this contribution," and `PackReport.contributions` is read back off
|
|
102
|
+
every report by whatever assembles a `resolve()` call's own `contributions=` tuple —
|
|
103
|
+
`weft_cli.registry_bootstrap.build_dependencies`, the same caller `Contribution`'s
|
|
104
|
+
own docstring names, now real. No pack-specific knowledge sits there either: it
|
|
105
|
+
concatenates whatever `PackReport.contributions` every report carries, so a future
|
|
106
|
+
pack contributing into a slot needs no edit here and no edit in `weft-cli` at all.
|
|
107
|
+
|
|
108
|
+
**Task 6.20 (G13) — `add_renderer` closes the analogous gap for a `Command` result:**
|
|
109
|
+
`docs/03-cli.md` → *Plugin-contributed commands*, "a result type nobody outside the CLI
|
|
110
|
+
can format is only half a contract." Buffered exactly like `add_ext_model` and
|
|
111
|
+
`add_contribution`, on the identical structural reason and the identical restraint: the
|
|
112
|
+
kernel names neither `weft_command.contract.CommandResult` nor `weft_command.render.
|
|
113
|
+
Rendered` — `RendererOffer` remembers only that this pack offered *some* type and *some*
|
|
114
|
+
callable, exactly as `add_ext_model` stops at "this pack declared this class." `PackReport.
|
|
115
|
+
renderers` is read back off every report by `weft_cli.render.register_renderers_from_reports`,
|
|
116
|
+
called once, generically, by whatever already calls `discover()` — the same shape one
|
|
117
|
+
surface over, and the CLI's own built-in renderers register through the identical call so
|
|
118
|
+
no built-in keeps a private path.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
from __future__ import annotations
|
|
122
|
+
|
|
123
|
+
import inspect
|
|
124
|
+
import os
|
|
125
|
+
import re
|
|
126
|
+
import sys
|
|
127
|
+
from collections.abc import Callable, Collection, Iterable, Mapping
|
|
128
|
+
from dataclasses import dataclass
|
|
129
|
+
from enum import StrEnum
|
|
130
|
+
from importlib import metadata
|
|
131
|
+
from typing import Protocol, cast, get_type_hints
|
|
132
|
+
|
|
133
|
+
from pydantic import BaseModel, ConfigDict, ValidationError
|
|
134
|
+
|
|
135
|
+
from weft_kernel.context import ServiceRole
|
|
136
|
+
from weft_kernel.errors import UnresolvedNameError, WeftError
|
|
137
|
+
from weft_kernel.payload.ext import ExtModel
|
|
138
|
+
from weft_kernel.pipeline import StageDeclaration
|
|
139
|
+
from weft_kernel.registry import Registry
|
|
140
|
+
from weft_kernel.resolution import Contribution
|
|
141
|
+
from weft_kernel.seam import Deprecation, Unavailable, removal_for, warn_deprecated
|
|
142
|
+
|
|
143
|
+
#: The one entry-point group a pack declares. `docs/02-extension-model.md` section 2.
|
|
144
|
+
ENTRY_POINT_GROUP = "weft.packs"
|
|
145
|
+
|
|
146
|
+
_ENV_TOKEN = re.compile(r"^\$\{env:([^}]+)\}$")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class PackStatus(StrEnum):
|
|
150
|
+
"""Why a pack is, or is not, contributing to the registry right now.
|
|
151
|
+
|
|
152
|
+
One vocabulary for every reason, shared with G4's conditional
|
|
153
|
+
registration (`docs/02-extension-model.md`, *The trust model*): a refused
|
|
154
|
+
pack and one that registered only part of what it offers are the same
|
|
155
|
+
question — *why is this not here?* — answered the same way.
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
ACTIVE = "active"
|
|
159
|
+
REFUSED = "refused"
|
|
160
|
+
FAILED = "failed"
|
|
161
|
+
PARTIAL = "partial"
|
|
162
|
+
ALLOWED_NOT_INSTALLED = "allowed, not installed"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class Disclosure(BaseModel):
|
|
166
|
+
"""What a pack says it touches. Optional, informational, and enforces nothing.
|
|
167
|
+
|
|
168
|
+
`docs/02-extension-model.md`: "a disclosure to the operator, never a claim
|
|
169
|
+
weft checks." Concrete strings, never booleans — a hostname is
|
|
170
|
+
information, `network: true` is noise. Absence (no module-level
|
|
171
|
+
`DISCLOSURE`) is reported by `doctor` as "not disclosed", which is honest;
|
|
172
|
+
an empty `Disclosure()` asserts something the kernel cannot verify either,
|
|
173
|
+
so this model exists to be read, never to gate anything.
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
177
|
+
|
|
178
|
+
network: tuple[str, ...] = ()
|
|
179
|
+
filesystem: tuple[str, ...] = ()
|
|
180
|
+
subprocess: tuple[str, ...] = ()
|
|
181
|
+
note: str = ""
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@dataclass(frozen=True, slots=True)
|
|
185
|
+
class PipelineResource:
|
|
186
|
+
"""One pipeline document a pack ships from inside its own installed package.
|
|
187
|
+
|
|
188
|
+
Task **2.8**. `weft_retrieve.contract.RouteCatalogue`'s own docstring: "populated by
|
|
189
|
+
the same eager discovery pass that builds the registry, from the pipelines packs
|
|
190
|
+
contribute — so a third party's pipeline becomes routable on install with no edit
|
|
191
|
+
anywhere under `packages/`." `package` and `resource` are exactly
|
|
192
|
+
`importlib.resources.files(package).joinpath(resource)`'s two arguments — a locator,
|
|
193
|
+
never an opened file: the kernel still names no capability and opens nothing, the same
|
|
194
|
+
restraint `weft_kernel.pipeline`'s own module docstring states for the `Pipeline`
|
|
195
|
+
model itself ("the kernel publishes the model and opens no file"). Reading `resource`
|
|
196
|
+
and parsing it as a pipeline document is `weft-cli`'s job — the one distribution that
|
|
197
|
+
already owns `weft_cli.pipeline_catalogue`'s YAML parser.
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
distribution: str
|
|
201
|
+
package: str
|
|
202
|
+
resource: str
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class RendererOffer(BaseModel):
|
|
206
|
+
"""One `(result type, renderer)` pair a pack offered, attributed to the pack that offered it.
|
|
207
|
+
|
|
208
|
+
Task **6.20**, G13's third repair (`docs/03-cli.md` → *Plugin-contributed commands*): a
|
|
209
|
+
result type nobody outside the CLI can format is only half a `Command` contract, so a
|
|
210
|
+
renderer is registered at the same seam a command is — `PackRegistrar.add_renderer`
|
|
211
|
+
buffers this, exactly as `add_ext_model` buffers a bare class reference. **The kernel
|
|
212
|
+
names neither of the two `weft-command` types involved** — not `weft_command.contract.
|
|
213
|
+
CommandResult`, the type a real `result_type` will always actually be, and not
|
|
214
|
+
`weft_command.render.Rendered`, the type a real `render` will always actually return: it
|
|
215
|
+
remembers only that this pack offered *some* type and *some* callable, and goes no
|
|
216
|
+
further. Turning the buffer into something a renderer dispatch can actually use is
|
|
217
|
+
`weft_cli.render.register_renderers_from_reports`'s job, run once every report is final.
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
model_config = ConfigDict(frozen=True, extra="forbid", arbitrary_types_allowed=True)
|
|
221
|
+
|
|
222
|
+
distribution: str
|
|
223
|
+
result_type: type[object]
|
|
224
|
+
render: Callable[[object], object]
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class ServiceRoleOffer(BaseModel):
|
|
228
|
+
"""One `ServiceRole` a pack declared, attributed to the pack that declared it.
|
|
229
|
+
|
|
230
|
+
Ledger task **9.0**: `docs/02-extension-model.md` §1's own Phase 0 narrowing named the
|
|
231
|
+
hole this closes — a pack had no seam through which to declare that `[services].<key>`
|
|
232
|
+
selects an implementation of a contract it publishes. `_read_service_roles` builds one of
|
|
233
|
+
these per entry in the pack's module-level `SERVICE_ROLES`: `distribution` is filled in
|
|
234
|
+
from the entry point currently being imported, never something the pack states and never
|
|
235
|
+
something it could misattribute to a distribution not its own. `role` is the declaration
|
|
236
|
+
itself; this wrapper adds only attribution.
|
|
237
|
+
"""
|
|
238
|
+
|
|
239
|
+
model_config = ConfigDict(frozen=True, extra="forbid", arbitrary_types_allowed=True)
|
|
240
|
+
|
|
241
|
+
distribution: str
|
|
242
|
+
role: ServiceRole
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class PackReport(BaseModel):
|
|
246
|
+
"""One pack's discovery outcome — the row `weft plugins list|doctor` will print.
|
|
247
|
+
|
|
248
|
+
`pack` is the pack's identity — its `weft.packs` **entry-point name** (`chunk =
|
|
249
|
+
"weft_chunk:register"` names the pack `chunk`), which is what every operator-facing
|
|
250
|
+
surface prints and what a `[packs.<pack>]` settings block keys on. `distribution` is a
|
|
251
|
+
different fact: the thing PyPI installs, which is what the `[packs] allow` trust
|
|
252
|
+
boundary, `weft plugins doctor`'s version column and `weft_cli.skew` all key on. They
|
|
253
|
+
were the same string while every pack shipped in a distribution of its own; they stopped
|
|
254
|
+
being the same the moment one distribution shipped fourteen packs, and a report that
|
|
255
|
+
carried only the second could no longer tell fourteen rows apart.
|
|
256
|
+
|
|
257
|
+
`pack` is `None` for exactly one row: `ALLOWED_NOT_INSTALLED`, where `[packs] allow`
|
|
258
|
+
named a distribution nothing installed claims. There is no entry point, so there is no
|
|
259
|
+
pack — said out loud rather than filled in with the distribution name, which would
|
|
260
|
+
assert a pack that does not exist.
|
|
261
|
+
|
|
262
|
+
`ambient` is only ever meaningful on `ACTIVE`: it means "running, and not
|
|
263
|
+
a direct dependency" (`docs/02-extension-model.md`), which `discover()`
|
|
264
|
+
can only judge when its caller supplies `direct_dependencies` — the
|
|
265
|
+
dependency graph itself lives outside the kernel, in whatever built the
|
|
266
|
+
project manifest. Left `None`-less and `False` by default rather than a
|
|
267
|
+
tri-state, because "cannot determine" is exactly what an absent
|
|
268
|
+
`direct_dependencies` already means to a caller that asked for it.
|
|
269
|
+
|
|
270
|
+
`pipeline_resources` is empty for every pack until task 2.8, and empty for any pack
|
|
271
|
+
that never calls `PackRegistrar.add_pipeline_resource` — most packs ship no pipeline
|
|
272
|
+
at all, and requiring an empty declaration from every one of them would be a
|
|
273
|
+
registration tax with no failure behind it, `weft_enhance.contract`'s own declining
|
|
274
|
+
argument for `destroys` applied here to a different field.
|
|
275
|
+
|
|
276
|
+
`deprecations` — task 5.2e — is every `weft_kernel.seam.Deprecation` the pack buffered
|
|
277
|
+
through `PackRegistrar.deprecate`, empty for every pack that never calls it. `docs/
|
|
278
|
+
02-extension-model.md` §2's status vocabulary gains no member for this: `weft plugins
|
|
279
|
+
doctor` reads a non-empty tuple as a flag beside `status`, the same way it already
|
|
280
|
+
reads `ambient`, never as a status of its own — `docs/09-release.md` §3, "a flag on
|
|
281
|
+
an existing status... no new status."
|
|
282
|
+
|
|
283
|
+
`ext_models` — task 5.2g — is every `weft_kernel.payload.ext.ExtModel` subclass the pack
|
|
284
|
+
buffered through `PackRegistrar.add_ext_model`, empty for any pack that ships no
|
|
285
|
+
namespaced extension data (most packs). This is the *declared* half of fitness function
|
|
286
|
+
14's runtime property; `weft_store.rehydrate.register_from_reports` reads it back off
|
|
287
|
+
every report to compute the *present* half.
|
|
288
|
+
|
|
289
|
+
`contributions` — task 5.3a — is every `weft_kernel.resolution.Contribution` the pack
|
|
290
|
+
buffered through `PackRegistrar.add_contribution`, empty for any pack that offers no
|
|
291
|
+
slot contribution (most packs). `weft_cli.registry_bootstrap.build_dependencies` is the
|
|
292
|
+
one place every report's own tuple is concatenated into the `contributions=` argument
|
|
293
|
+
every `weft_kernel.resolution.resolve` call site now passes — `02` §3 → *Slots*: "a pack
|
|
294
|
+
may... contribute into a slot a pipeline opted into."
|
|
295
|
+
|
|
296
|
+
`renderers` — task **6.20**, G13 — is every `RendererOffer` the pack buffered through
|
|
297
|
+
`PackRegistrar.add_renderer`, empty for any pack that contributes no `Command` result a
|
|
298
|
+
person needs formatted (most packs). `weft_cli.render.register_renderers_from_reports`
|
|
299
|
+
is the one place every report's own tuple is read back off and made reachable for
|
|
300
|
+
dispatch — the identical shape `ext_models` and `weft_store.rehydrate.
|
|
301
|
+
register_from_reports` already have, one surface over.
|
|
302
|
+
|
|
303
|
+
`service_roles` — task **9.0** — is every `ServiceRoleOffer` read from the pack's
|
|
304
|
+
module-level `SERVICE_ROLES`, empty for any pack that declares no `[services]` role (most
|
|
305
|
+
packs). Unlike `renderers`, it is **not** gated on a clean `commit()`: a role key is a
|
|
306
|
+
static fact about the pack, and a pack whose settings failed must still be able to tell an
|
|
307
|
+
operator that its `[services]` key exists — see `_read_service_roles` for the case that
|
|
308
|
+
settles it.
|
|
309
|
+
"""
|
|
310
|
+
|
|
311
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
312
|
+
|
|
313
|
+
pack: str | None
|
|
314
|
+
distribution: str
|
|
315
|
+
status: PackStatus
|
|
316
|
+
ambient: bool = False
|
|
317
|
+
contributed: int = 0
|
|
318
|
+
reason: str | None = None
|
|
319
|
+
disclosure: Disclosure | None = None
|
|
320
|
+
pipeline_resources: tuple[PipelineResource, ...] = ()
|
|
321
|
+
deprecations: tuple[Deprecation, ...] = ()
|
|
322
|
+
unavailable: tuple[Unavailable, ...] = ()
|
|
323
|
+
ext_models: tuple[type[ExtModel], ...] = ()
|
|
324
|
+
contributions: tuple[Contribution, ...] = ()
|
|
325
|
+
renderers: tuple[RendererOffer, ...] = ()
|
|
326
|
+
service_roles: tuple[ServiceRoleOffer, ...] = ()
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
class PackSettingsError(WeftError):
|
|
330
|
+
"""A pack's settings could not be built: wrong `register()` shape, or validation failed.
|
|
331
|
+
|
|
332
|
+
Raised before `register()` ever runs — `docs/02-extension-model.md`: "the
|
|
333
|
+
kernel validates before `register` is called and fails naming the pack
|
|
334
|
+
and the field." Inside `discover()` this is caught and folded into a
|
|
335
|
+
`FAILED` report rather than propagated, for the same reason a raising
|
|
336
|
+
`register()` is: one broken pack must not stop every other pack from
|
|
337
|
+
loading. Raised directly (uncaught) when the resolution helpers are
|
|
338
|
+
exercised on their own.
|
|
339
|
+
"""
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
class EnvInterpolationError(WeftError):
|
|
343
|
+
"""`${env:VAR}` named an environment variable this process does not have set.
|
|
344
|
+
|
|
345
|
+
Never resolves to an empty string instead — a silently-empty secret is a
|
|
346
|
+
credential that looks like it works and is not, which is a worse failure
|
|
347
|
+
than refusing to start.
|
|
348
|
+
"""
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
class InertPluginPinError(WeftError):
|
|
352
|
+
"""A `[plugins]` pin never got the chance to arbitrate anything, once discovery finished.
|
|
353
|
+
|
|
354
|
+
`docs/02-extension-model.md` §3: "a pin for a pair that has no collision"
|
|
355
|
+
must fail loudly rather than being read as a harmless no-op — "an inert
|
|
356
|
+
pin is a lie about what is running." Raised here, after every entry
|
|
357
|
+
point has been enumerated and every permitted pack activated, off
|
|
358
|
+
`weft_kernel.registry.Registry.unconsulted_pins()` — the same shape as
|
|
359
|
+
`UnknownPackSettingsError` just above: not folded into any one pack's
|
|
360
|
+
report, because this is not one pack's failure, it is the configuration
|
|
361
|
+
naming a fight that never happened. Distinct from
|
|
362
|
+
`weft_kernel.registry.UnresolvedPluginPinError`, which fires *during* a
|
|
363
|
+
real collision when the pin names neither side of it; this one fires
|
|
364
|
+
when no collision for the pinned key ever occurred at all.
|
|
365
|
+
|
|
366
|
+
Repair for a reviewer finding: this used to be unconditionally fatal to
|
|
367
|
+
every registry-needing command, `weft plugins doctor` and `weft plugins
|
|
368
|
+
list` included — the two commands whose whole job is explaining what is
|
|
369
|
+
installed, one of which `manual/troubleshooting.md`'s own remedy for this
|
|
370
|
+
exact error names as the next thing to run. `discover`'s `strict_pins`
|
|
371
|
+
parameter is what a diagnostic caller sets `False` to see every
|
|
372
|
+
`PackReport` anyway; `weft_cli.cli.dispatch` does this for `plugins
|
|
373
|
+
list`/`plugins doctor` and no other command, on the same reasoning
|
|
374
|
+
`weft_cli.registry_bootstrap`'s own module docstring already gives for
|
|
375
|
+
`WEFT_DATABASE_URL`: "a bare crash on every registry-needing command —
|
|
376
|
+
including `plugins doctor`, the one command meant to diagnose exactly
|
|
377
|
+
this — would be worse than" a report.
|
|
378
|
+
"""
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
class UnknownPackSettingsError(WeftError, UnresolvedNameError):
|
|
382
|
+
"""A `packs:` settings key names a pack no installed `weft.packs` entry point claims.
|
|
383
|
+
|
|
384
|
+
Raised by `discover()` itself, after every entry point has been
|
|
385
|
+
enumerated — never caught and folded into a `PackReport`, because this is
|
|
386
|
+
not one pack failing; it is the configuration asking for a pack that is
|
|
387
|
+
not there at all. `docs/02-extension-model.md`: "A `packs:` key naming a
|
|
388
|
+
pack that is not installed is an error... loud, naming the distribution
|
|
389
|
+
to install." Keyed on the **pack** — the entry-point name — since `02` §2's
|
|
390
|
+
settings block is `[packs.store]`, not `[packs.store]`: several packs may
|
|
391
|
+
ship in one distribution, and each configures itself. This is the strict half
|
|
392
|
+
of the `allow`/`packs:` asymmetry — `allow` naming an absent distribution is
|
|
393
|
+
`ALLOWED_NOT_INSTALLED`, reported and not fatal, because `allow` only ever
|
|
394
|
+
narrows what is already there; `packs:` names a requirement, and an unmet
|
|
395
|
+
requirement raises.
|
|
396
|
+
|
|
397
|
+
Fitness function 12's family: `valid_options` is every pack that does declare
|
|
398
|
+
a `weft.packs` entry point.
|
|
399
|
+
"""
|
|
400
|
+
|
|
401
|
+
def __init__(self, message: str, *, valid_options: tuple[str, ...]) -> None:
|
|
402
|
+
super().__init__(message)
|
|
403
|
+
self.valid_options = valid_options
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
class MalformedDisclosureError(WeftError):
|
|
407
|
+
"""A pack's module-level `DISCLOSURE` is present but is not a `Disclosure` instance.
|
|
408
|
+
|
|
409
|
+
Distinct from absence: `docs/02-extension-model.md` calls "not
|
|
410
|
+
disclosed" honest reporting of a pack that disclosed nothing, and that
|
|
411
|
+
honesty breaks if a pack that *tried* to disclose something malformed —
|
|
412
|
+
a bare dict, a `Disclosure` from an incompatible version — is reported
|
|
413
|
+
identically. Folded into a `FAILED` report by `_activate`, naming the
|
|
414
|
+
distribution and what `DISCLOSURE` must be, rather than propagated.
|
|
415
|
+
"""
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
class MalformedServiceRolesError(WeftError):
|
|
419
|
+
"""A pack's module-level `SERVICE_ROLES` is not a tuple of `ServiceRole`.
|
|
420
|
+
|
|
421
|
+
Task **9.0**, on `MalformedDisclosureError`'s own footing: a pack that tried to declare a
|
|
422
|
+
role and got the shape wrong is a different fact from a pack that declared none, and
|
|
423
|
+
collapsing the two would leave an operator with a `[services]` key that silently does not
|
|
424
|
+
exist. Folded into a `FAILED` report by `_activate`, naming the pack.
|
|
425
|
+
"""
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
class MissingDistributionMetadataError(WeftError):
|
|
429
|
+
"""An entry point in the `weft.packs` group carries no distribution metadata.
|
|
430
|
+
|
|
431
|
+
Folded into a `FAILED` report keyed by the entry point's own name, the
|
|
432
|
+
same way an import failure or a raising `register()` is — one malformed
|
|
433
|
+
`.dist-info` degrades to a single row, not a hard stop for every other
|
|
434
|
+
installed pack.
|
|
435
|
+
"""
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
class _DistributionLike(Protocol):
|
|
439
|
+
"""The one attribute `discover()` needs from `importlib.metadata`'s distribution object."""
|
|
440
|
+
|
|
441
|
+
@property
|
|
442
|
+
def name(self) -> str: ...
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
class EntryPointLike(Protocol):
|
|
446
|
+
"""The slice of `importlib.metadata.EntryPoint` `discover()` actually uses.
|
|
447
|
+
|
|
448
|
+
A `Protocol` rather than the concrete class so a test can hand `discover()`
|
|
449
|
+
a lightweight double instead of an installed distribution — real
|
|
450
|
+
`EntryPoint` objects already satisfy this structurally, with nothing to
|
|
451
|
+
subclass or register.
|
|
452
|
+
"""
|
|
453
|
+
|
|
454
|
+
@property
|
|
455
|
+
def name(self) -> str: ...
|
|
456
|
+
@property
|
|
457
|
+
def module(self) -> str: ...
|
|
458
|
+
@property
|
|
459
|
+
def dist(self) -> _DistributionLike | None: ...
|
|
460
|
+
def load(self) -> Callable[..., None]: ...
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
class PackRegistrar:
|
|
464
|
+
"""The registry view a pack's own `register()` receives — see the module docstring.
|
|
465
|
+
|
|
466
|
+
`Registry.add` minus its keyword-only `distribution` parameter: a pack
|
|
467
|
+
author calls `registrar.add(contract, name, factory)`, exactly the shape
|
|
468
|
+
`docs/02-extension-model.md` shows, and attribution is filled in from
|
|
469
|
+
whichever distribution `discover()` is currently importing — never
|
|
470
|
+
something the author states, and never something the author could get
|
|
471
|
+
wrong.
|
|
472
|
+
"""
|
|
473
|
+
|
|
474
|
+
def __init__(self, registry: Registry, *, distribution: str) -> None:
|
|
475
|
+
self._registry = registry
|
|
476
|
+
self._distribution = distribution
|
|
477
|
+
self._pending: list[tuple[type[object], str, Callable[..., object]]] = []
|
|
478
|
+
self._pending_resources: list[PipelineResource] = []
|
|
479
|
+
self._pending_deprecations: list[Deprecation] = []
|
|
480
|
+
self._pending_unavailable: list[Unavailable] = []
|
|
481
|
+
self._pending_ext_models: list[type[ExtModel]] = []
|
|
482
|
+
self._pending_contributions: list[Contribution] = []
|
|
483
|
+
self._pending_renderers: list[RendererOffer] = []
|
|
484
|
+
|
|
485
|
+
@property
|
|
486
|
+
def contributed(self) -> int:
|
|
487
|
+
"""How many registrations this pack has buffered — and, after `commit`, has landed."""
|
|
488
|
+
return len(self._pending)
|
|
489
|
+
|
|
490
|
+
def add(self, contract: type[object], name: str, factory: Callable[..., object]) -> None:
|
|
491
|
+
"""Buffer `factory` as `name` for `contract`, attributed to this pack.
|
|
492
|
+
|
|
493
|
+
Nothing reaches the shared `Registry` yet — see `commit`. A pack's
|
|
494
|
+
own `register()` never calls `commit` itself; `_activate` does, once
|
|
495
|
+
`register()` has returned without raising.
|
|
496
|
+
"""
|
|
497
|
+
self._pending.append((contract, name, factory))
|
|
498
|
+
|
|
499
|
+
def add_pipeline_resource(self, package: str, resource: str) -> None:
|
|
500
|
+
"""Buffer a `PipelineResource(distribution, package, resource)`, attributed to this pack.
|
|
501
|
+
|
|
502
|
+
Task **2.8**. Buffered exactly like `add` — see `commit` — and for the identical
|
|
503
|
+
reason: a pack whose `register()` raises after calling this must not leave a
|
|
504
|
+
catalogue advertising a pipeline it never actually shipped. A pack calls this once
|
|
505
|
+
per pipeline document it ships, typically its own package name and a
|
|
506
|
+
`pipelines/<name>.yaml` resource path — `weft_cli.pipeline_catalogue.
|
|
507
|
+
load_contributed` is what turns the buffered locator into a parsed `Pipeline`.
|
|
508
|
+
"""
|
|
509
|
+
self._pending_resources.append(
|
|
510
|
+
PipelineResource(distribution=self._distribution, package=package, resource=resource)
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
def deprecate(self, surface: str, *, reason: str) -> None:
|
|
514
|
+
"""Mark `surface` — a plugin name, a `"Contract:name"` pair, or the pack itself —
|
|
515
|
+
deprecated, attributed to this pack.
|
|
516
|
+
|
|
517
|
+
Task 5.2e. Buffered exactly like `add_pipeline_resource`, for the identical reason:
|
|
518
|
+
a pack whose `register()` raises after calling this must not leave a warning
|
|
519
|
+
standing about a mark that never actually committed. Marking is all this method
|
|
520
|
+
does — the warning itself is `weft_kernel.seam.warn_deprecated`'s job, run by
|
|
521
|
+
`_activate` once `register()` has returned without raising, so a pack author states
|
|
522
|
+
the fact once and never writes the warning by hand; see that function's own
|
|
523
|
+
docstring for why the notice is a `DeprecationWarning`, not a `WeftError`.
|
|
524
|
+
"""
|
|
525
|
+
self._pending_deprecations.append(
|
|
526
|
+
Deprecation(
|
|
527
|
+
distribution=self._distribution,
|
|
528
|
+
surface=surface,
|
|
529
|
+
reason=reason,
|
|
530
|
+
removal=removal_for(self._distribution),
|
|
531
|
+
)
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
def unavailable(self, surface: str, *, reason: str) -> None:
|
|
535
|
+
"""Declare that this pack cannot provide `surface`, and why — ledger task **6.29**.
|
|
536
|
+
|
|
537
|
+
`01` → *Fitness functions* 5's second half: a capability that does not resolve to a live
|
|
538
|
+
implementation must be *declared unavailable at discovery time*, with a reason. Calling
|
|
539
|
+
this makes the pack's report `PackStatus.PARTIAL` — `02` §2's own word for "registered,
|
|
540
|
+
but a conditional dependency it wanted was not available, so part of what it offers did
|
|
541
|
+
not" — so `weft plugins doctor` says it before a run does.
|
|
542
|
+
|
|
543
|
+
Buffered exactly like `deprecate`, for the identical reason: a pack whose `register()`
|
|
544
|
+
raises after calling this must not leave a report standing about a mark that never
|
|
545
|
+
committed. Registering nothing else and declaring one surface unavailable is a perfectly
|
|
546
|
+
ordinary pack; what it must never be is silent.
|
|
547
|
+
"""
|
|
548
|
+
self._pending_unavailable.append(
|
|
549
|
+
Unavailable(distribution=self._distribution, surface=surface, reason=reason)
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
def add_ext_model(self, model: type[ExtModel]) -> None:
|
|
553
|
+
"""Buffer `model` — a pack's own `ExtModel` subclass — attributed to this pack.
|
|
554
|
+
|
|
555
|
+
Task **5.2g**. Buffered exactly like `add_pipeline_resource` and `deprecate`, for
|
|
556
|
+
the identical reason: a pack whose `register()` raises after calling this must not
|
|
557
|
+
leave a namespace looking claimed that never actually committed. `model` is a bare
|
|
558
|
+
class reference, not an instance — nothing here validates, instantiates or reads
|
|
559
|
+
`model.__namespace__`; this method only remembers that this pack offered it.
|
|
560
|
+
Turning the buffer into an entry `weft_store.rehydrate.rehydrate_ext` can actually
|
|
561
|
+
use is `weft_store.rehydrate.register_from_reports`'s job, run once every report is
|
|
562
|
+
final — the kernel names no capability, so it stops at "this pack declared this
|
|
563
|
+
class" and goes no further.
|
|
564
|
+
"""
|
|
565
|
+
self._pending_ext_models.append(model)
|
|
566
|
+
|
|
567
|
+
def add_contribution(self, slot: str, stage: StageDeclaration) -> None:
|
|
568
|
+
"""Offer `stage` into `slot` of whichever pipeline declares it, attributed to this pack.
|
|
569
|
+
|
|
570
|
+
Task **5.3a** (`S8`). Buffered exactly like `add_ext_model`, for the identical reason:
|
|
571
|
+
a pack whose `register()` raises after calling this must not leave a slot looking
|
|
572
|
+
filled that was never actually committed. `distribution` — `weft_kernel.resolution.
|
|
573
|
+
Contribution`'s own required field — is filled in from this registrar, on `add`'s own
|
|
574
|
+
footing: attribution is never something a pack author states, or could misattribute to
|
|
575
|
+
a distribution that is not its own. `stage.id` is the contribution's own **local**
|
|
576
|
+
name, unqualified by distribution — `Contribution`'s own docstring, and `02` §3 →
|
|
577
|
+
*Slots*: qualification happens only once a contribution is actually placed. This
|
|
578
|
+
method validates nothing about `slot` itself: a slot no installed pipeline declares is
|
|
579
|
+
`02` §3's own "recorded no-op", not a registration-time refusal — `weft_kernel.
|
|
580
|
+
resolution.resolve` is where that distinction is actually drawn.
|
|
581
|
+
"""
|
|
582
|
+
self._pending_contributions.append(
|
|
583
|
+
Contribution(slot=slot, distribution=self._distribution, stage=stage)
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
def add_renderer(self, result_type: type[object], renderer: Callable[[object], object]) -> None:
|
|
587
|
+
"""Offer `renderer` for `result_type`, attributed to this pack.
|
|
588
|
+
|
|
589
|
+
Task **6.20**, G13's third repair. Buffered exactly like `add_ext_model` and
|
|
590
|
+
`add_contribution`, for the identical reason: a pack whose `register()` raises after
|
|
591
|
+
calling this must not leave a result type looking renderable that never actually
|
|
592
|
+
committed. `distribution` is filled in from this registrar, on `add`'s own footing —
|
|
593
|
+
attribution is never something a pack author states. This method validates nothing
|
|
594
|
+
about `result_type` or `renderer`: it does not know either is a `weft_command.
|
|
595
|
+
contract.CommandResult` subclass or a `weft_command.render.Rendered`-returning
|
|
596
|
+
callable — the kernel names neither type, exactly as the module docstring's own
|
|
597
|
+
rule requires. Turning the buffer into a working dispatch is `weft_cli.render.
|
|
598
|
+
register_renderers_from_reports`'s job.
|
|
599
|
+
"""
|
|
600
|
+
self._pending_renderers.append(
|
|
601
|
+
RendererOffer(distribution=self._distribution, result_type=result_type, render=renderer)
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
def commit(self) -> None:
|
|
605
|
+
"""Write every buffered registration to the `Registry`, all at once or not at all.
|
|
606
|
+
|
|
607
|
+
Delegates to `Registry.add_many`, which is what makes this atomic —
|
|
608
|
+
see the module docstring. Called by `_activate` exactly once, after
|
|
609
|
+
a pack's `register()` returns without raising; never called at all
|
|
610
|
+
if `register()` raises, which is what keeps a half-registered pack
|
|
611
|
+
from ever being written.
|
|
612
|
+
"""
|
|
613
|
+
self._registry.add_many(self._pending, distribution=self._distribution)
|
|
614
|
+
|
|
615
|
+
@property
|
|
616
|
+
def pipeline_resources(self) -> tuple[PipelineResource, ...]:
|
|
617
|
+
"""Every `PipelineResource` this pack has buffered so far — `_activate`'s own read,
|
|
618
|
+
once `register()` has returned without raising. See `add_pipeline_resource`.
|
|
619
|
+
"""
|
|
620
|
+
return tuple(self._pending_resources)
|
|
621
|
+
|
|
622
|
+
@property
|
|
623
|
+
def deprecations(self) -> tuple[Deprecation, ...]:
|
|
624
|
+
"""Every `Deprecation` this pack has buffered so far — `_activate`'s own read, once
|
|
625
|
+
`register()` has returned without raising. See `deprecate`.
|
|
626
|
+
"""
|
|
627
|
+
return tuple(self._pending_deprecations)
|
|
628
|
+
|
|
629
|
+
@property
|
|
630
|
+
def unavailable_surfaces(self) -> tuple[Unavailable, ...]:
|
|
631
|
+
"""Every `Unavailable` this pack has buffered so far — `_activate`'s own read, once
|
|
632
|
+
`register()` has returned without raising. See `unavailable`.
|
|
633
|
+
"""
|
|
634
|
+
return tuple(self._pending_unavailable)
|
|
635
|
+
|
|
636
|
+
@property
|
|
637
|
+
def ext_models(self) -> tuple[type[ExtModel], ...]:
|
|
638
|
+
"""Every `ExtModel` subclass this pack has buffered so far — `_activate`'s own read,
|
|
639
|
+
once `register()` has returned without raising. See `add_ext_model`.
|
|
640
|
+
"""
|
|
641
|
+
return tuple(self._pending_ext_models)
|
|
642
|
+
|
|
643
|
+
@property
|
|
644
|
+
def contributions(self) -> tuple[Contribution, ...]:
|
|
645
|
+
"""Every `Contribution` this pack has buffered so far — `_activate`'s own read, once
|
|
646
|
+
`register()` has returned without raising. See `add_contribution`.
|
|
647
|
+
"""
|
|
648
|
+
return tuple(self._pending_contributions)
|
|
649
|
+
|
|
650
|
+
@property
|
|
651
|
+
def renderers(self) -> tuple[RendererOffer, ...]:
|
|
652
|
+
"""Every `RendererOffer` this pack has buffered so far — `_activate`'s own read, once
|
|
653
|
+
`register()` has returned without raising. See `add_renderer`.
|
|
654
|
+
"""
|
|
655
|
+
return tuple(self._pending_renderers)
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def interpolate_env(value: object, *, environ: Mapping[str, str] | None = None) -> object:
|
|
659
|
+
"""Recursively resolve every `${env:VAR}` string in `value` against `environ`.
|
|
660
|
+
|
|
661
|
+
`docs/02-extension-model.md`: "`${env:VAR}` interpolation [is] performed
|
|
662
|
+
by the config loader, so no component reads the environment itself" —
|
|
663
|
+
this is that interpolation, and the only place in the kernel that reads
|
|
664
|
+
`os.environ` by default. A string that is *exactly* `${env:VAR}` becomes
|
|
665
|
+
the variable's value; a string that merely contains the token as a
|
|
666
|
+
substring is left untouched, because partial substitution inside a
|
|
667
|
+
longer string is a template engine this project does not have and does
|
|
668
|
+
not need for a credential or an endpoint. Walks `dict` and `list`
|
|
669
|
+
recursively so a whole settings block can be interpolated in one call;
|
|
670
|
+
every other type passes through unchanged. Raises `EnvInterpolationError`,
|
|
671
|
+
naming the variable, rather than substituting an empty string, if it is
|
|
672
|
+
unset.
|
|
673
|
+
"""
|
|
674
|
+
env = os.environ if environ is None else environ
|
|
675
|
+
return _interpolate(value, env)
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _interpolate(value: object, env: Mapping[str, str]) -> object:
|
|
679
|
+
if isinstance(value, str):
|
|
680
|
+
match = _ENV_TOKEN.match(value)
|
|
681
|
+
if match is None:
|
|
682
|
+
return value
|
|
683
|
+
name = match.group(1)
|
|
684
|
+
if name not in env:
|
|
685
|
+
raise EnvInterpolationError(
|
|
686
|
+
f"'${{env:{name}}}' names an environment variable that is not set. Set "
|
|
687
|
+
f"{name}, or remove the reference from the configuration."
|
|
688
|
+
)
|
|
689
|
+
return env[name]
|
|
690
|
+
if isinstance(value, Mapping):
|
|
691
|
+
items = cast("Mapping[str, object]", value)
|
|
692
|
+
return {key: _interpolate(item, env) for key, item in items.items()}
|
|
693
|
+
if isinstance(value, list):
|
|
694
|
+
entries = cast("list[object]", value)
|
|
695
|
+
return [_interpolate(item, env) for item in entries]
|
|
696
|
+
return value
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def allow_list_from_config(document: Mapping[str, object]) -> tuple[str, ...] | None:
|
|
700
|
+
"""The exhaustive pin from a parsed `weft.toml`-shaped mapping's `[packs] allow`.
|
|
701
|
+
|
|
702
|
+
`None` means absent, which `docs/02-extension-model.md` states plainly:
|
|
703
|
+
"`weft.toml` — optional. Absent means open." Present but empty
|
|
704
|
+
(`allow = []`) is a real, if severe, policy — refuse every pack — and is
|
|
705
|
+
returned as `()`, never coerced to `None`: the two mean different things
|
|
706
|
+
and this function does not collapse them.
|
|
707
|
+
|
|
708
|
+
**A `packs` key that is present but not a table is refused, not absorbed
|
|
709
|
+
into absence.** `docs/02` §2's *The trust model*:
|
|
710
|
+
`packs = ["weft-store"]` is the plausible typo for `[packs]\\nallow =
|
|
711
|
+
[...]` — TOML parses it to a `list`, and a bare `isinstance(packs,
|
|
712
|
+
Mapping)` guard used to fail that check the same way a genuinely absent
|
|
713
|
+
`packs` does, silently landing on open-by-default with the operator's
|
|
714
|
+
allow-list never read. `None` only for a key that is not in `document`
|
|
715
|
+
at all; anything present that is not a `Mapping` raises `WeftError`
|
|
716
|
+
naming the shape found and the shape expected, so the typo is loud
|
|
717
|
+
instead of a policy that quietly never applied. `allow = []` inside a
|
|
718
|
+
*valid* `[packs]` table is unaffected — that case reaches the check
|
|
719
|
+
below unchanged, and stays "refuse every pack", not this one.
|
|
720
|
+
"""
|
|
721
|
+
if "packs" not in document:
|
|
722
|
+
return None
|
|
723
|
+
packs = document["packs"]
|
|
724
|
+
if not isinstance(packs, Mapping):
|
|
725
|
+
raise WeftError(
|
|
726
|
+
f"weft.toml's [packs] must be a table, not {type(packs).__name__} — found "
|
|
727
|
+
f"`packs = {packs!r}`. Did you mean `[packs]\\nallow = [...]`? See "
|
|
728
|
+
f"docs/02-extension-model.md section 2, The trust model."
|
|
729
|
+
)
|
|
730
|
+
packs_table = cast("Mapping[str, object]", packs)
|
|
731
|
+
allow = packs_table.get("allow")
|
|
732
|
+
if allow is None:
|
|
733
|
+
return None
|
|
734
|
+
if not isinstance(allow, list):
|
|
735
|
+
raise WeftError(
|
|
736
|
+
f"[packs] allow must be a list of distribution names (strings); found "
|
|
737
|
+
f"{type(allow).__name__}."
|
|
738
|
+
)
|
|
739
|
+
entries = cast("list[object]", allow)
|
|
740
|
+
if not all(isinstance(item, str) for item in entries):
|
|
741
|
+
raise WeftError("[packs] allow must be a list of distribution names (strings).")
|
|
742
|
+
return tuple(cast("list[str]", entries))
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def plugin_pins_from_config(document: Mapping[str, object]) -> dict[str, str]:
|
|
746
|
+
"""The exhaustive `[plugins]` table from a parsed `weft.toml`-shaped mapping.
|
|
747
|
+
|
|
748
|
+
`docs/02-extension-model.md` §3's exact shape — `"Enhancer:keybert" =
|
|
749
|
+
"weft-kw"` — a `{"Contract:name": "distribution"}` mapping handed
|
|
750
|
+
straight to `weft_kernel.registry.Registry(plugin_pins=...)`, never
|
|
751
|
+
interpreted here: this function's whole job is the same one
|
|
752
|
+
`allow_list_from_config` already does for `[packs] allow`, turning a
|
|
753
|
+
piece of an already-parsed mapping into the shape the kernel accepts,
|
|
754
|
+
with no file ever opened by this module. Absent `[plugins]` returns
|
|
755
|
+
`{}` — "absent means open" is `[packs] allow`'s reading; here it means
|
|
756
|
+
no pin exists, so every collision still refuses exactly as it always
|
|
757
|
+
has. A key naming no real `(contract, name)` collision is not caught
|
|
758
|
+
here — that is `weft_kernel.discovery.discover`'s `InertPluginPinError`,
|
|
759
|
+
raised only once discovery knows what actually collided.
|
|
760
|
+
"""
|
|
761
|
+
plugins = document.get("plugins")
|
|
762
|
+
if not isinstance(plugins, Mapping):
|
|
763
|
+
return {}
|
|
764
|
+
table = cast("Mapping[str, object]", plugins)
|
|
765
|
+
if not all(isinstance(value, str) for value in table.values()):
|
|
766
|
+
raise WeftError('[plugins] must map "Contract:name" strings to distribution-name strings.')
|
|
767
|
+
return {str(key): cast("str", value) for key, value in table.items()}
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def discover(
|
|
771
|
+
registry: Registry,
|
|
772
|
+
*,
|
|
773
|
+
allow: Collection[str] | None = None,
|
|
774
|
+
pack_settings: Mapping[str, Mapping[str, object]] | None = None,
|
|
775
|
+
direct_dependencies: Collection[str] | None = None,
|
|
776
|
+
entry_points: Iterable[EntryPointLike] | None = None,
|
|
777
|
+
strict_pins: bool = True,
|
|
778
|
+
) -> tuple[PackReport, ...]:
|
|
779
|
+
"""Discover every installed `weft.packs` pack, importing and registering what is permitted.
|
|
780
|
+
|
|
781
|
+
`allow` is keyed on the **distribution**, not the pack: it is a trust
|
|
782
|
+
boundary, and trust attaches to the thing you installed from an index, not
|
|
783
|
+
to a name chosen inside a wheel you had already accepted.
|
|
784
|
+
|
|
785
|
+
`allow=None` is the open-by-default posture: everything installed is
|
|
786
|
+
imported and registered. `allow` present is an exhaustive pin — anything
|
|
787
|
+
installed but unlisted is `REFUSED` **and its entry point is never
|
|
788
|
+
loaded**, which is fitness function 8(a): refusal precedes execution, not
|
|
789
|
+
a `try` block around it. Every name in `allow` that no installed
|
|
790
|
+
distribution claims comes back as `ALLOWED_NOT_INSTALLED`, reported, not
|
|
791
|
+
fatal — `docs/02-extension-model.md`'s distinction between a permission
|
|
792
|
+
(`allow`) and a requirement (`packs:` settings, which errors on the same
|
|
793
|
+
absence).
|
|
794
|
+
|
|
795
|
+
`pack_settings` is keyed on the **pack** — the `weft.packs` entry-point name,
|
|
796
|
+
so `[packs.store]` and not `[packs.store]`. It is interpolated for
|
|
797
|
+
`${env:VAR}` once, up front, then
|
|
798
|
+
each pack's own slice is validated against the Pydantic model its
|
|
799
|
+
`register(registrar, settings: Settings)` declares — before `register` is
|
|
800
|
+
called. `direct_dependencies`, when supplied, is what lets an `ACTIVE`
|
|
801
|
+
report carry `ambient=True`; the dependency graph itself is not this
|
|
802
|
+
module's to compute.
|
|
803
|
+
|
|
804
|
+
Every `pack_settings` key that no enumerated entry point claims raises
|
|
805
|
+
`UnknownPackSettingsError`, naming the pack and every pack that did declare a
|
|
806
|
+
`weft.packs` entry point — `packs:` expresses a requirement, unlike `allow`,
|
|
807
|
+
so this is fatal where `ALLOWED_NOT_INSTALLED` is not.
|
|
808
|
+
|
|
809
|
+
`entry_points` defaults to every real, installed `weft.packs` entry
|
|
810
|
+
point; a caller passes its own only to test discovery without installing
|
|
811
|
+
a distribution.
|
|
812
|
+
|
|
813
|
+
`strict_pins=False` — repair for a reviewer finding against the task 1.12
|
|
814
|
+
commit — skips the `InertPluginPinError` check below rather than raising
|
|
815
|
+
it, so a caller whose whole job is *explaining* the environment (`weft
|
|
816
|
+
plugins list`/`doctor`) can still get every `PackReport` back even when a
|
|
817
|
+
`[plugins]` pin never arbitrated anything. Every other caller keeps the
|
|
818
|
+
default `True`: `registry.unconsulted_pins()` is unaffected either way —
|
|
819
|
+
a `plugins doctor` caller passing `strict_pins=False` can still read it
|
|
820
|
+
off the registry it was handed back and report it, rather than losing
|
|
821
|
+
the information the way a raised exception discarding every built
|
|
822
|
+
`PackReport` would.
|
|
823
|
+
"""
|
|
824
|
+
candidates: list[EntryPointLike] = (
|
|
825
|
+
list(entry_points)
|
|
826
|
+
if entry_points is not None
|
|
827
|
+
# `importlib.metadata.EntryPoint` satisfies this protocol at runtime — every
|
|
828
|
+
# attribute `EntryPointLike` names is present — but its typeshed stub mixes
|
|
829
|
+
# plain fields and properties in a way pyright's strict structural check
|
|
830
|
+
# cannot verify statically. The cast bridges that one stdlib boundary; it
|
|
831
|
+
# asserts nothing this module does not already rely on being true.
|
|
832
|
+
else cast("list[EntryPointLike]", list(metadata.entry_points(group=ENTRY_POINT_GROUP)))
|
|
833
|
+
)
|
|
834
|
+
allow_set = set(allow) if allow is not None else None
|
|
835
|
+
settings_source = _as_settings_source(interpolate_env(dict(pack_settings or {})))
|
|
836
|
+
|
|
837
|
+
reports: list[PackReport] = []
|
|
838
|
+
seen: set[str] = set()
|
|
839
|
+
seen_packs: set[str] = set()
|
|
840
|
+
|
|
841
|
+
for entry_point in candidates:
|
|
842
|
+
# The pack's own identity, and never the distribution's: an entry-point name is
|
|
843
|
+
# unique within the group and stays the pack's own even when fourteen packs ship
|
|
844
|
+
# in one wheel. Read before `_distribution_name`, because it is available even
|
|
845
|
+
# when distribution metadata is not.
|
|
846
|
+
pack = entry_point.name
|
|
847
|
+
try:
|
|
848
|
+
distribution = _distribution_name(entry_point)
|
|
849
|
+
except MissingDistributionMetadataError as exc:
|
|
850
|
+
# No distribution name means no attribution for the trust boundary and no
|
|
851
|
+
# version to report — fold into FAILED, keyed by the entry point's own name,
|
|
852
|
+
# rather than aborting discovery for every other pack. The pack identity is
|
|
853
|
+
# intact here; it is the distribution that is missing.
|
|
854
|
+
reports.append(
|
|
855
|
+
PackReport(
|
|
856
|
+
pack=pack,
|
|
857
|
+
distribution=entry_point.name,
|
|
858
|
+
status=PackStatus.FAILED,
|
|
859
|
+
reason=str(exc),
|
|
860
|
+
)
|
|
861
|
+
)
|
|
862
|
+
continue
|
|
863
|
+
seen.add(distribution)
|
|
864
|
+
seen_packs.add(pack)
|
|
865
|
+
|
|
866
|
+
# `allow` stays keyed on the **distribution**, deliberately. It is a trust
|
|
867
|
+
# boundary, and trust is placed in a thing you install from an index and can pin,
|
|
868
|
+
# audit and revoke — not in a name a pack chose for itself inside a wheel you had
|
|
869
|
+
# already accepted. `[packs] allow = ["weft-rag"]` therefore permits every pack
|
|
870
|
+
# that wheel ships, which is the same posture as before: what you installed is
|
|
871
|
+
# what you allowed. `[packs.<pack>]` settings key on `pack` instead, because that
|
|
872
|
+
# is configuration of one pack's behaviour, not a decision about provenance.
|
|
873
|
+
if allow_set is not None and distribution not in allow_set:
|
|
874
|
+
reports.append(
|
|
875
|
+
PackReport(
|
|
876
|
+
pack=pack,
|
|
877
|
+
distribution=distribution,
|
|
878
|
+
status=PackStatus.REFUSED,
|
|
879
|
+
reason=(
|
|
880
|
+
f"'{distribution}' is not listed in [packs] allow. Add it there to "
|
|
881
|
+
f"permit it."
|
|
882
|
+
),
|
|
883
|
+
)
|
|
884
|
+
)
|
|
885
|
+
continue
|
|
886
|
+
|
|
887
|
+
reports.append(
|
|
888
|
+
_activate(
|
|
889
|
+
entry_point,
|
|
890
|
+
pack=pack,
|
|
891
|
+
distribution=distribution,
|
|
892
|
+
registry=registry,
|
|
893
|
+
raw_settings=settings_source.get(pack, {}),
|
|
894
|
+
direct_dependencies=direct_dependencies,
|
|
895
|
+
)
|
|
896
|
+
)
|
|
897
|
+
|
|
898
|
+
if allow_set is not None:
|
|
899
|
+
for missing in sorted(allow_set - seen):
|
|
900
|
+
reports.append(
|
|
901
|
+
PackReport(pack=None, distribution=missing, status=PackStatus.ALLOWED_NOT_INSTALLED)
|
|
902
|
+
)
|
|
903
|
+
|
|
904
|
+
unclaimed = sorted(set(settings_source) - seen_packs)
|
|
905
|
+
if unclaimed:
|
|
906
|
+
named = ", ".join(f"'{name}'" for name in unclaimed)
|
|
907
|
+
options = tuple(sorted(seen_packs))
|
|
908
|
+
available = ", ".join(f"'{name}'" for name in options) or "none"
|
|
909
|
+
raise UnknownPackSettingsError(
|
|
910
|
+
f"[packs] settings name {named}, which {'is' if len(unclaimed) == 1 else 'are'} "
|
|
911
|
+
f"not installed. Install the distribution that ships it, or remove its settings "
|
|
912
|
+
f"block. Packs that declare a '{ENTRY_POINT_GROUP}' entry point: {available}.",
|
|
913
|
+
valid_options=options,
|
|
914
|
+
)
|
|
915
|
+
|
|
916
|
+
# Every pack that was going to collide with anyone already has, since every candidate
|
|
917
|
+
# above has been activated (or refused, or reported missing) by this point — so a pin
|
|
918
|
+
# `registry` was given that `Registry.unconsulted_pins()` still lists never arbitrated a
|
|
919
|
+
# real collision at all. `docs/02-extension-model.md` §3: "an inert pin is a lie about
|
|
920
|
+
# what is running." Raised here, not folded into any one pack's report — see
|
|
921
|
+
# `InertPluginPinError`'s own docstring for why. Skipped, not silenced, when
|
|
922
|
+
# `strict_pins` is `False`: the state is still sitting on `registry.unconsulted_pins()`
|
|
923
|
+
# for a caller that asked not to have it be fatal to read.
|
|
924
|
+
if strict_pins:
|
|
925
|
+
unconsulted = sorted(registry.unconsulted_pins())
|
|
926
|
+
if unconsulted:
|
|
927
|
+
named = ", ".join(f"'{pin}'" for pin in unconsulted)
|
|
928
|
+
raise InertPluginPinError(
|
|
929
|
+
f"[plugins] pins {named}, but weft never saw two distributions contend for "
|
|
930
|
+
f"what {'it names' if len(unconsulted) == 1 else 'they name'} — nothing to "
|
|
931
|
+
f"arbitrate. Remove the pin, or check that both distributions it should choose "
|
|
932
|
+
f"between are installed and actually registering that name."
|
|
933
|
+
)
|
|
934
|
+
|
|
935
|
+
return tuple(reports)
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
def _activate(
|
|
939
|
+
entry_point: EntryPointLike,
|
|
940
|
+
*,
|
|
941
|
+
pack: str,
|
|
942
|
+
distribution: str,
|
|
943
|
+
registry: Registry,
|
|
944
|
+
raw_settings: Mapping[str, object],
|
|
945
|
+
direct_dependencies: Collection[str] | None,
|
|
946
|
+
) -> PackReport:
|
|
947
|
+
"""Import one permitted pack, validate its settings, call `register()`, and report.
|
|
948
|
+
|
|
949
|
+
Registration is transactional: `registrar.commit()` — which is what
|
|
950
|
+
actually writes to `registry` — runs *inside* the same `try` as
|
|
951
|
+
`register()` itself, so a pack that raises, whether from `register()` or
|
|
952
|
+
from a collision `commit()` discovers, is caught by the same branch and
|
|
953
|
+
reported with `contributed=0` by default, which is true precisely because
|
|
954
|
+
nothing was written. Only a `register()` that returns and then commits
|
|
955
|
+
cleanly reaches `ACTIVE`, where `contributed=registrar.contributed`
|
|
956
|
+
reports what actually landed.
|
|
957
|
+
|
|
958
|
+
**Task 5.2e** — a clean commit is also the one point `registrar.deprecations`
|
|
959
|
+
is known to have actually landed, so this is where `weft_kernel.seam.warn_deprecated`
|
|
960
|
+
is called: once, with whatever the pack buffered, never for a pack that raised.
|
|
961
|
+
|
|
962
|
+
**Task 5.2g** — `registrar.ext_models` reaches `PackReport.ext_models` on the identical
|
|
963
|
+
terms: only once `register()` has returned and `commit()` has not raised. This function
|
|
964
|
+
does nothing else with it — reading the buffer back off every final report and making
|
|
965
|
+
the classes it names reachable for rehydration is `weft_store.rehydrate.
|
|
966
|
+
register_from_reports`'s job, run by whatever calls `discover()`.
|
|
967
|
+
|
|
968
|
+
**Task 5.3a** — `registrar.contributions` reaches `PackReport.contributions` on the
|
|
969
|
+
identical terms, for the identical reason: a pack that raises must never look like it
|
|
970
|
+
offered a slot contribution it never actually committed. This function does nothing else
|
|
971
|
+
with it either — assembling every report's own tuple into one `contributions=` argument
|
|
972
|
+
for `weft_kernel.resolution.resolve` is `weft_cli.registry_bootstrap.build_dependencies`'s
|
|
973
|
+
job, the one caller `weft_kernel.resolution.Contribution`'s own docstring names.
|
|
974
|
+
|
|
975
|
+
**Task 6.20** — `registrar.renderers` reaches `PackReport.renderers` on the identical
|
|
976
|
+
terms: only once `register()` has returned and `commit()` has not raised. A `register()`
|
|
977
|
+
that raises after calling `add_renderer` leaves `renderers == ()`, the same atomicity
|
|
978
|
+
every other buffer already has — the CLI must never advertise a way to format a result a
|
|
979
|
+
pack never actually finished offering.
|
|
980
|
+
|
|
981
|
+
**Task 9.0** — `service_roles` does **not** follow that pattern, deliberately. It is read
|
|
982
|
+
from the pack's module-level `SERVICE_ROLES` before settings are validated, and travels on
|
|
983
|
+
every report from that point on, `FAILED` included: which `[services]` keys exist is a fact
|
|
984
|
+
about what is installed, not about what successfully configured itself.
|
|
985
|
+
"""
|
|
986
|
+
ambient = direct_dependencies is not None and distribution not in direct_dependencies
|
|
987
|
+
|
|
988
|
+
try:
|
|
989
|
+
register_fn = entry_point.load()
|
|
990
|
+
except Exception as exc: # a pack's import can raise anything; that is FAILED, not a crash
|
|
991
|
+
return PackReport(
|
|
992
|
+
pack=pack, distribution=distribution, status=PackStatus.FAILED, reason=str(exc)
|
|
993
|
+
)
|
|
994
|
+
|
|
995
|
+
try:
|
|
996
|
+
disclosure = _read_disclosure(entry_point, pack=pack)
|
|
997
|
+
except MalformedDisclosureError as exc:
|
|
998
|
+
return PackReport(
|
|
999
|
+
pack=pack, distribution=distribution, status=PackStatus.FAILED, reason=str(exc)
|
|
1000
|
+
)
|
|
1001
|
+
|
|
1002
|
+
try:
|
|
1003
|
+
service_roles = _read_service_roles(entry_point, distribution=distribution)
|
|
1004
|
+
except MalformedServiceRolesError as exc:
|
|
1005
|
+
return PackReport(
|
|
1006
|
+
pack=pack, distribution=distribution, status=PackStatus.FAILED, reason=str(exc)
|
|
1007
|
+
)
|
|
1008
|
+
|
|
1009
|
+
registrar = PackRegistrar(registry, distribution=distribution)
|
|
1010
|
+
try:
|
|
1011
|
+
settings = _resolve_settings(register_fn, pack=pack, raw=raw_settings)
|
|
1012
|
+
register_fn(registrar, settings)
|
|
1013
|
+
registrar.commit()
|
|
1014
|
+
except Exception as exc: # one broken pack must not stop the rest from loading
|
|
1015
|
+
return PackReport(
|
|
1016
|
+
pack=pack,
|
|
1017
|
+
distribution=distribution,
|
|
1018
|
+
status=PackStatus.FAILED,
|
|
1019
|
+
ambient=ambient,
|
|
1020
|
+
reason=str(exc),
|
|
1021
|
+
disclosure=disclosure,
|
|
1022
|
+
service_roles=service_roles,
|
|
1023
|
+
)
|
|
1024
|
+
|
|
1025
|
+
deprecations = registrar.deprecations
|
|
1026
|
+
warn_deprecated(deprecations)
|
|
1027
|
+
|
|
1028
|
+
unavailable = registrar.unavailable_surfaces
|
|
1029
|
+
|
|
1030
|
+
return PackReport(
|
|
1031
|
+
pack=pack,
|
|
1032
|
+
distribution=distribution,
|
|
1033
|
+
# Task **6.29**: a pack that declared any surface unavailable registered *part* of what it
|
|
1034
|
+
# offers, which is exactly what `02` §2 reserves `PARTIAL` for. `ACTIVE` would say it is
|
|
1035
|
+
# contributing everything it has, and `FAILED` would say it contributed nothing — both
|
|
1036
|
+
# false, and the reason the vocabulary has a third word.
|
|
1037
|
+
status=PackStatus.PARTIAL if unavailable else PackStatus.ACTIVE,
|
|
1038
|
+
ambient=ambient,
|
|
1039
|
+
contributed=registrar.contributed,
|
|
1040
|
+
disclosure=disclosure,
|
|
1041
|
+
pipeline_resources=registrar.pipeline_resources,
|
|
1042
|
+
deprecations=deprecations,
|
|
1043
|
+
unavailable=unavailable,
|
|
1044
|
+
ext_models=registrar.ext_models,
|
|
1045
|
+
contributions=registrar.contributions,
|
|
1046
|
+
renderers=registrar.renderers,
|
|
1047
|
+
service_roles=service_roles,
|
|
1048
|
+
)
|
|
1049
|
+
|
|
1050
|
+
|
|
1051
|
+
def _read_disclosure(entry_point: EntryPointLike, *, pack: str) -> Disclosure | None:
|
|
1052
|
+
"""The pack's module-level `DISCLOSURE`, read after import, before `register()` runs.
|
|
1053
|
+
|
|
1054
|
+
`None` means genuinely absent — no `DISCLOSURE` attribute at all — which
|
|
1055
|
+
is the honest "not disclosed" `docs/02-extension-model.md` describes.
|
|
1056
|
+
Present but not a `Disclosure` instance is a different fact and is never
|
|
1057
|
+
collapsed into that same `None`: it raises `MalformedDisclosureError`,
|
|
1058
|
+
naming the pack and what the attribute must be, so the caller can
|
|
1059
|
+
fold it into a `FAILED` report instead of reporting a pack that tried to
|
|
1060
|
+
disclose something as one that disclosed nothing.
|
|
1061
|
+
"""
|
|
1062
|
+
module = sys.modules.get(entry_point.module)
|
|
1063
|
+
value = getattr(module, "DISCLOSURE", None) if module is not None else None
|
|
1064
|
+
if value is None:
|
|
1065
|
+
return None
|
|
1066
|
+
if isinstance(value, Disclosure):
|
|
1067
|
+
return value
|
|
1068
|
+
raise MalformedDisclosureError(
|
|
1069
|
+
f"'{pack}' defines DISCLOSURE but it is not a "
|
|
1070
|
+
f"weft_kernel.discovery.Disclosure instance (found {type(value).__name__}). "
|
|
1071
|
+
f"DISCLOSURE must be built from Disclosure(network=..., filesystem=..., "
|
|
1072
|
+
f"subprocess=..., note=...)."
|
|
1073
|
+
)
|
|
1074
|
+
|
|
1075
|
+
|
|
1076
|
+
def _read_service_roles(
|
|
1077
|
+
entry_point: EntryPointLike, *, distribution: str
|
|
1078
|
+
) -> tuple[ServiceRoleOffer, ...]:
|
|
1079
|
+
"""The pack's module-level `SERVICE_ROLES`, read after import, before `register()` runs.
|
|
1080
|
+
|
|
1081
|
+
Ledger task **9.0**, and the timing is the whole point. Which `[services]` roles a pack
|
|
1082
|
+
declares is a **static fact about the pack** — which key selects which contract — not a
|
|
1083
|
+
product of its configuration, so it is read exactly where `DISCLOSURE` is read: after the
|
|
1084
|
+
module imports, before settings are validated and before `register()` runs. A pack whose
|
|
1085
|
+
settings fail therefore still tells an operator that its role key *exists*.
|
|
1086
|
+
|
|
1087
|
+
That is not a nicety. `weft-store`'s `[packs.store] dsn` is required, so on a machine with
|
|
1088
|
+
no `weft.toml` the `store` pack reports `FAILED` and registers nothing — and had this
|
|
1089
|
+
declaration been buffered through `register()`, `[services] store = "qdrant"` would then be
|
|
1090
|
+
refused as an *unknown key*, naming the wrong problem entirely, on exactly the machine where
|
|
1091
|
+
an operator is trying to configure their way out of it. The pre-9.0 behaviour — the key
|
|
1092
|
+
parses, and the plugin name fails later through `weft_cli.registry_bootstrap.require_plugin`,
|
|
1093
|
+
which names the pack and its reason — is the better error, and reading the declaration here
|
|
1094
|
+
is what preserves it.
|
|
1095
|
+
|
|
1096
|
+
Absent means absent: most packs declare no role. Present but not a tuple of `ServiceRole`
|
|
1097
|
+
is a different fact and is never collapsed into the same empty answer, on
|
|
1098
|
+
`_read_disclosure`'s own footing.
|
|
1099
|
+
"""
|
|
1100
|
+
module = sys.modules.get(entry_point.module)
|
|
1101
|
+
raw: object = getattr(module, "SERVICE_ROLES", None) if module is not None else None
|
|
1102
|
+
if raw is None:
|
|
1103
|
+
return ()
|
|
1104
|
+
found = type(raw).__name__
|
|
1105
|
+
declared = cast("tuple[object, ...]", raw) if isinstance(raw, tuple) else ()
|
|
1106
|
+
if not isinstance(raw, tuple) or not all(isinstance(item, ServiceRole) for item in declared):
|
|
1107
|
+
raise MalformedServiceRolesError(
|
|
1108
|
+
f"'{entry_point.name}' defines SERVICE_ROLES but it is not a tuple of "
|
|
1109
|
+
f"weft_kernel.context.ServiceRole (found {found}). Declare it as "
|
|
1110
|
+
f"`SERVICE_ROLES = (MY_ROLE,)`, beside the contract the role selects for."
|
|
1111
|
+
)
|
|
1112
|
+
return tuple(
|
|
1113
|
+
ServiceRoleOffer(distribution=distribution, role=role)
|
|
1114
|
+
for role in declared
|
|
1115
|
+
if isinstance(role, ServiceRole)
|
|
1116
|
+
)
|
|
1117
|
+
|
|
1118
|
+
|
|
1119
|
+
def _resolve_settings(
|
|
1120
|
+
register_fn: Callable[..., None], *, pack: str, raw: Mapping[str, object]
|
|
1121
|
+
) -> BaseModel:
|
|
1122
|
+
"""The validated settings instance `register_fn`'s own model expects.
|
|
1123
|
+
|
|
1124
|
+
Introspects `register_fn`'s second parameter for a `pydantic.BaseModel`
|
|
1125
|
+
subclass annotation — `docs/02-extension-model.md`'s own shape,
|
|
1126
|
+
`register(registry: Registry, settings: Settings) -> None` — and
|
|
1127
|
+
validates `raw` against it before `register_fn` is ever called. A pack
|
|
1128
|
+
with nothing to configure still declares an (empty) model; there is no
|
|
1129
|
+
settings-less shape to special-case.
|
|
1130
|
+
"""
|
|
1131
|
+
parameters = list(inspect.signature(register_fn).parameters.values())
|
|
1132
|
+
if len(parameters) != 2:
|
|
1133
|
+
raise PackSettingsError(
|
|
1134
|
+
f"'{pack}' declares register() with {len(parameters)} parameter(s); "
|
|
1135
|
+
f"docs/02-extension-model.md requires exactly (registrar, settings)."
|
|
1136
|
+
)
|
|
1137
|
+
|
|
1138
|
+
hints = get_type_hints(register_fn)
|
|
1139
|
+
model = hints.get(parameters[1].name)
|
|
1140
|
+
if not (isinstance(model, type) and issubclass(model, BaseModel)):
|
|
1141
|
+
raise PackSettingsError(
|
|
1142
|
+
f"'{pack}' does not annotate its settings parameter with a "
|
|
1143
|
+
f"pydantic.BaseModel subclass; register(registrar, settings: Settings) is required."
|
|
1144
|
+
)
|
|
1145
|
+
|
|
1146
|
+
try:
|
|
1147
|
+
return model.model_validate(raw)
|
|
1148
|
+
except ValidationError as exc:
|
|
1149
|
+
raise PackSettingsError(f"'{pack}' settings failed validation: {exc}") from exc
|
|
1150
|
+
|
|
1151
|
+
|
|
1152
|
+
def _distribution_name(entry_point: EntryPointLike) -> str:
|
|
1153
|
+
dist = entry_point.dist
|
|
1154
|
+
if dist is None:
|
|
1155
|
+
raise MissingDistributionMetadataError(
|
|
1156
|
+
f"entry point '{entry_point.name}' in group '{ENTRY_POINT_GROUP}' carries no "
|
|
1157
|
+
f"distribution metadata; weft cannot attribute it to a pack."
|
|
1158
|
+
)
|
|
1159
|
+
return dist.name
|
|
1160
|
+
|
|
1161
|
+
|
|
1162
|
+
def _as_settings_source(value: object) -> Mapping[str, Mapping[str, object]]:
|
|
1163
|
+
"""Narrow `interpolate_env`'s `object` return back to the shape `discover()` needs.
|
|
1164
|
+
|
|
1165
|
+
`interpolate_env` is generic over any parsed config value, so its return
|
|
1166
|
+
type is `object`; `discover()` always feeds it a `dict[str, dict[str,
|
|
1167
|
+
object]]`, and gets exactly that back, since interpolation preserves
|
|
1168
|
+
container shape. This function states that fact once rather than
|
|
1169
|
+
scattering `cast` at every call site.
|
|
1170
|
+
"""
|
|
1171
|
+
if not isinstance(value, dict):
|
|
1172
|
+
raise WeftError(f"pack settings must be a mapping; found {type(value).__name__}.")
|
|
1173
|
+
raw = cast("dict[str, object]", value)
|
|
1174
|
+
result: dict[str, Mapping[str, object]] = {}
|
|
1175
|
+
for key, item in raw.items():
|
|
1176
|
+
if not isinstance(item, Mapping):
|
|
1177
|
+
raise WeftError(
|
|
1178
|
+
f"pack settings for '{key}' must be a mapping; found {type(item).__name__}."
|
|
1179
|
+
)
|
|
1180
|
+
result[key] = cast("Mapping[str, object]", item)
|
|
1181
|
+
return result
|