Skip to content

Commit dfd76c7

Browse files
committed
Merge branch 'bundles/docs' into bundles/review-fixes
2 parents 60b459a + 87d2ff5 commit dfd76c7

5 files changed

Lines changed: 217 additions & 57 deletions

File tree

BUNDLE_API.md

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -426,10 +426,18 @@ the deserialize half is covered by
426426
imported** -- all `@official` sources share the
427427
`_lfx_ext.official.<bundle>.*` sys.modules namespace, so importing the
428428
losing copy would overwrite the winner's live modules; the skipped copy
429-
still surfaces the `bundle-shadowed` warning. New warning-only code
430-
`bundle-discovery-malformed` added to `ERROR_CODES` (additive) for
431-
unresolvable declarations and invalid provider directory names; it never
432-
aborts startup. Manifest-less bundles bypass the
429+
carries the same typed `bundle-shadowed` diagnostic (on `errors`, matching
430+
the resolver) so filtering by code never mixes severities. Namespace
431+
packages are walked across **all portions**, and resolved roots are
432+
deduplicated by path so duplicate declarations never self-shadow. New
433+
warning-only codes added to `ERROR_CODES` (additive), one per discovery
434+
failure mode and none of which abort startup: `bundle-discovery-malformed`
435+
(declaration does not resolve to an importable package directory --
436+
including a parent package whose `__init__` raises during `find_spec`),
437+
`bundles-provider-name-invalid` (provider folder is not a valid bundle
438+
name), `bundles-root-unreadable` (root cannot be enumerated), and
439+
`duplicate-lfx-bundles-provider` (same provider name in more than one
440+
root; first wins). Manifest-less bundles bypass the
433441
`version-constraint-unsatisfied` API-version gate by construction (no
434442
manifest to carry `lfx.compat`); install-time compatibility rides on the
435443
metapackage's PEP 508 `lfx>=X,<Y` pin instead. Manifest-less records

src/lfx/src/lfx/extension/errors.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,12 @@
9191
# (never aborts startup) so a broken third-party declaration degrades
9292
# to "that bundle root is skipped".
9393
"bundle-discovery-malformed",
94+
# lfx.bundles tier intra-tier diagnostics, mirroring the inline
95+
# tier's split (inline-bundle-name-invalid / inline-path-unreadable /
96+
# duplicate-inline-bundle). All warning-only: never abort startup.
97+
"bundles-provider-name-invalid",
98+
"bundles-root-unreadable",
99+
"duplicate-lfx-bundles-provider",
94100
"duplicate-extension-id",
95101
# Reload-specific codes
96102
"reload-in-progress",
@@ -296,6 +302,15 @@ def __bool__(self) -> bool: # pragma: no cover - convenience
296302
"bundle-discovery-malformed": (
297303
"lfx.bundles entry point {content!r} could not be resolved to a package directory: {message}"
298304
),
305+
"bundles-provider-name-invalid": (
306+
"lfx.bundles provider directory {content!r} (at {location}) is not a valid bundle name; "
307+
"bundle names are lowercase snake_case (a-z, 0-9, _), 2-64 characters."
308+
),
309+
"bundles-root-unreadable": ("lfx.bundles root {location} could not be enumerated: {message}"),
310+
"duplicate-lfx-bundles-provider": (
311+
"Provider {content!r} appears in more than one lfx.bundles root; the copy at {location} "
312+
"is skipped (the first discovered root wins)."
313+
),
299314
"duplicate-extension-id": ("Extension id {content!r} is registered more than once (already at {location})."),
300315
"reload-in-progress": (
301316
"Reload already in progress for bundle {content!r}; refuse to start a second concurrent reload."

src/lfx/src/lfx/extension/loader/_bundles_root.py

Lines changed: 94 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,19 @@
3030
3131
Failure policy
3232
--------------
33-
A declaration that cannot be resolved to a real package directory yields a
34-
sentinel :class:`LoadResult` carrying a ``bundle-discovery-malformed``
35-
*warning* (not an error), so a broken third-party declaration degrades to
36-
"that bundle root is skipped" rather than aborting server startup. The same
37-
warning is emitted for a top-level entry whose name is not a valid bundle
38-
name (e.g. a provider folder that was not lowercased), so the mistake is
39-
visible instead of silently dropping the provider.
33+
Every discovery failure degrades to a typed *warning* (never an error that
34+
aborts server startup), with one code per failure mode so the rendered
35+
message and hint fit the actual mistake:
36+
37+
- ``bundle-discovery-malformed`` -- the entry-point declaration does not
38+
resolve to an importable package directory (including a parent package
39+
whose ``__init__`` raises during ``find_spec``); fix the declaration.
40+
- ``bundles-provider-name-invalid`` -- a provider folder is not a valid
41+
bundle name (e.g. not lowercased); rename the directory.
42+
- ``bundles-root-unreadable`` -- a resolved root cannot be enumerated;
43+
check permissions.
44+
- ``duplicate-lfx-bundles-provider`` -- the same provider name appears in
45+
more than one root; the first discovered root wins.
4046
"""
4147

4248
from __future__ import annotations
@@ -100,7 +106,8 @@ def load_lfx_bundles_extensions(
100106
``claimed_bundles`` maps bundle names already won by a higher-precedence
101107
@official source (installed > seed) to ``(source_kind, source_path)``.
102108
A provider directory whose name is claimed is **never imported** -- its
103-
result carries a typed ``bundle-shadowed`` warning instead. Skipping the
109+
result carries the same typed ``bundle-shadowed`` error the cross-source
110+
resolver emits for every other shadow pair. Skipping the
104111
import (rather than letting :func:`_resolve_bundle_shadowing` drop the
105112
components afterwards) matters because all @official sources share the
106113
``_lfx_ext.official.<bundle>.*`` sys.modules namespace: importing the
@@ -128,6 +135,12 @@ def _resolve_bundle_roots(
128135
Returns ``(roots, sentinels)`` where ``roots`` are the resolvable bundle
129136
roots (sorted, deterministic) and ``sentinels`` are warning-only
130137
:class:`LoadResult` objects for declarations that failed to resolve.
138+
139+
A namespace package split across several sys.path entries yields one root
140+
per portion -- every portion may carry providers. Resolved roots are
141+
deduplicated by resolved path so two declarations naming the same package
142+
(or overlapping portions) never walk a directory twice, which would make
143+
every provider in it self-shadow.
131144
"""
132145
if entry_points is None:
133146
from importlib import metadata as importlib_metadata
@@ -136,6 +149,7 @@ def _resolve_bundle_roots(
136149

137150
roots: list[_BundleRoot] = []
138151
sentinels: list[LoadResult] = []
152+
seen_root_paths: set[Path] = set()
139153
ordered = sorted(
140154
entry_points,
141155
key=lambda ep: (getattr(ep, "name", "") or "", getattr(ep, "value", "") or ""),
@@ -148,24 +162,34 @@ def _resolve_bundle_roots(
148162
_malformed_sentinel(label, "entry-point value is empty; expected an importable package name.")
149163
)
150164
continue
151-
# ``find_spec`` locates the package without importing it, so a
152-
# discovery pass at startup does not trigger arbitrary ``__init__``
153-
# side-effects -- the same discipline as ``_manifest_via_entry_point``.
165+
# ``find_spec`` does not import the target module itself, but for a
166+
# dotted declaration like ``pkg.bundles`` it DOES import the parent
167+
# ``pkg`` -- arbitrary third-party ``__init__`` code that can raise
168+
# anything, not just ImportError. Catch broadly and degrade to the
169+
# malformed sentinel: an escape here would propagate to the palette
170+
# cache's catch-all, which replaces the WHOLE extension_components
171+
# mapping with {} -- one rotten declaration must not wipe every
172+
# installed/seed/dev/inline bundle for the boot.
154173
try:
155174
spec = importlib.util.find_spec(module_name)
156-
except (ImportError, ValueError, ModuleNotFoundError, AttributeError) as exc:
175+
except Exception as exc: # noqa: BLE001 -- third-party __init__ can raise anything
157176
sentinels.append(
158177
_malformed_sentinel(label, f"find_spec({module_name!r}) failed: {type(exc).__name__}: {exc}")
159178
)
160179
continue
161-
package_dir = _spec_package_dir(spec)
162-
if package_dir is None or not package_dir.is_dir():
180+
package_dirs = [d for d in _spec_package_dirs(spec) if d.is_dir()]
181+
if not package_dirs:
163182
sentinels.append(
164183
_malformed_sentinel(label, f"module {module_name!r} does not resolve to a package directory.")
165184
)
166185
continue
167186
extension_id, extension_version = _distribution_identity(ep, fallback_id=module_name)
168-
roots.append(_BundleRoot(path=package_dir, extension_id=extension_id, extension_version=extension_version))
187+
for package_dir in package_dirs:
188+
resolved = package_dir.resolve()
189+
if resolved in seen_root_paths:
190+
continue
191+
seen_root_paths.add(resolved)
192+
roots.append(_BundleRoot(path=package_dir, extension_id=extension_id, extension_version=extension_version))
169193
return roots, sentinels
170194

171195

@@ -177,13 +201,16 @@ def _load_bundle_roots(
177201
"""Folder-walk each resolved root, loading every valid subdirectory at @official.
178202
179203
First-wins on duplicate bundle names across roots (the loser emits a typed
180-
``bundle-shadowed`` warning); names in ``claimed_bundles`` (already won by
181-
a higher-precedence installed/seed source) are skipped *without importing*
182-
so the winner's ``_lfx_ext.official.<bundle>.*`` sys.modules entries are
183-
never overwritten. Subdirectories whose name is not a valid bundle name
184-
emit ``bundle-discovery-malformed`` and are skipped. Internal directories
185-
(dot-prefixed, underscore-prefixed, or in :data:`SKIP_DIR_NAMES`) are
186-
skipped silently -- they are package machinery, not providers.
204+
``duplicate-lfx-bundles-provider`` warning); names in ``claimed_bundles``
205+
(already won by a higher-precedence installed/seed source) are skipped
206+
*without importing* -- carrying the same ``bundle-shadowed`` error the
207+
cross-source resolver emits -- so the winner's
208+
``_lfx_ext.official.<bundle>.*`` sys.modules entries are never
209+
overwritten. Subdirectories whose name is not a valid bundle name emit
210+
``bundles-provider-name-invalid``; a root that cannot be enumerated emits
211+
``bundles-root-unreadable``. Internal directories (dot-prefixed,
212+
underscore-prefixed, or in :data:`SKIP_DIR_NAMES`) are skipped silently --
213+
they are package machinery, not providers.
187214
"""
188215
claimed = claimed_bundles or {}
189216
results: list[LoadResult] = []
@@ -197,7 +224,13 @@ def _load_bundle_roots(
197224
# rather than aborting the whole discovery pass.
198225
sentinel = LoadResult(slot=None, source_path=root.path)
199226
sentinel.warnings.append(
200-
_malformed_error(str(root.path), f"could not enumerate bundle root: {type(exc).__name__}: {exc}")
227+
ExtensionError(
228+
code="bundles-root-unreadable",
229+
message=f"{type(exc).__name__}: {exc}",
230+
location=str(root.path),
231+
content=str(root.path),
232+
hint="Check the directory's permissions; this root is skipped for this boot.",
233+
)
201234
)
202235
results.append(sentinel)
203236
continue
@@ -230,20 +263,27 @@ def _load_bundle_roots(
230263

231264
if not BUNDLE_NAME_RE.match(name):
232265
result.warnings.append(
233-
_malformed_error(
234-
name,
235-
f"provider directory {name!r} (under {root.path}) is not a valid bundle "
236-
"name; lowercase snake_case (a-z, 0-9, _), 2-64 characters.",
266+
ExtensionError(
267+
code="bundles-provider-name-invalid",
268+
message=(
269+
f"provider directory {name!r} (under {root.path}) is not a valid bundle "
270+
"name; lowercase snake_case (a-z, 0-9, _), 2-64 characters."
271+
),
237272
location=str(child),
273+
content=name,
274+
hint="Rename the provider directory to a valid bundle name.",
238275
)
239276
)
240277
results.append(result)
241278
continue
242279

243280
if name in claimed:
281+
# Cross-source shadow: same code AND same severity (errors)
282+
# as _resolve_bundle_shadowing emits for every other shadow
283+
# pair, so filtering by code never mixes semantics.
244284
winner_kind, winner_path = claimed[name]
245285
result.bundle = name
246-
result.warnings.append(
286+
result.errors.append(
247287
ExtensionError(
248288
code="bundle-shadowed",
249289
message=(
@@ -265,15 +305,20 @@ def _load_bundle_roots(
265305
continue
266306

267307
if name in seen_names:
308+
# Same-tier duplicate (two lfx.bundles roots ship the same
309+
# provider): a dedicated code, NOT ``bundle-shadowed`` --
310+
# that one means cross-source precedence and its rendered
311+
# template would mislead here. Mirrors the inline tier's
312+
# ``duplicate-inline-bundle``.
268313
result.bundle = name
269314
result.warnings.append(
270315
ExtensionError(
271-
code="bundle-shadowed",
316+
code="duplicate-lfx-bundles-provider",
272317
message=(
273318
f"Manifest-less bundle {name!r} already discovered from {seen_names[name]}; "
274319
f"skipping the copy at {child}."
275320
),
276-
location=f"{seen_names[name]} -> {child}",
321+
location=str(child),
277322
content=name,
278323
hint=(
279324
"A provider name must come from exactly one lfx.bundles root; rename or "
@@ -306,21 +351,20 @@ def _load_bundle_roots(
306351
return results
307352

308353

309-
def _spec_package_dir(spec: importlib.machinery.ModuleSpec | None) -> Path | None:
310-
"""Return the package directory a module spec points at, or ``None``.
354+
def _spec_package_dirs(spec: importlib.machinery.ModuleSpec | None) -> list[Path]:
355+
"""Return every on-disk directory a package spec points at.
311356
312357
Only package specs qualify (``submodule_search_locations`` is set for both
313-
regular and namespace packages, and equals the package directory). A
314-
plain-module spec returns ``None`` -- a single-file module has no provider
315-
subdirectories, and falling back to the module file's parent would
316-
folder-walk unrelated sibling directories as bundles.
358+
regular and namespace packages). A namespace package split across several
359+
sys.path entries carries one location per portion, and every portion may
360+
hold providers, so all of them are returned. A plain-module spec returns
361+
``[]`` -- a single-file module has no provider subdirectories, and falling
362+
back to the module file's parent would folder-walk unrelated sibling
363+
directories (in a real install: all of site-packages) as bundles.
317364
"""
318365
if spec is None:
319-
return None
320-
locations = list(spec.submodule_search_locations or [])
321-
if locations:
322-
return Path(locations[0])
323-
return None
366+
return []
367+
return [Path(location) for location in (spec.submodule_search_locations or [])]
324368

325369

326370
def _distribution_identity(
@@ -348,12 +392,18 @@ def _distribution_identity(
348392
return name or fallback_id, version or _DEFAULT_BUNDLE_VERSION
349393

350394

351-
def _malformed_error(content: str, message: str, *, location: str | None = None) -> ExtensionError:
352-
"""Build a ``bundle-discovery-malformed`` error payload."""
395+
def _malformed_error(content: str, message: str) -> ExtensionError:
396+
"""Build a ``bundle-discovery-malformed`` error payload.
397+
398+
Reserved for *declaration*-level failures (an entry point that does not
399+
resolve to an importable package directory) -- its hint tells the operator
400+
to fix the entry-point declaration. Provider-name and root-enumeration
401+
failures carry their own codes (``bundles-provider-name-invalid``,
402+
``bundles-root-unreadable``) whose hints fit those mistakes.
403+
"""
353404
return ExtensionError(
354405
code="bundle-discovery-malformed",
355406
message=message,
356-
location=location,
357407
content=content,
358408
hint=_MALFORMED_HINT,
359409
)

0 commit comments

Comments
 (0)