Skip to content

Commit 883789c

Browse files
committed
fix(extension): harden lfx.bundles discovery per review — broad find_spec guard, namespace portions, per-mode error codes
Review findings from ogabrielluiz on #13563: - find_spec on a dotted declaration imports the PARENT package, whose __init__ can raise anything; a non-Import error escaped the old catch tuple and (through the palette cache's catch-all) wiped every source's components for the boot. Catch Exception and degrade to the malformed sentinel; comment corrected. - Namespace packages: walk ALL submodule_search_locations portions (one root per portion) instead of only locations[0], and dedupe resolved roots by path so duplicate entry-point declarations (or overlapping portions) never walk a directory twice and self-shadow. _spec_package_dir -> _spec_package_dirs. - Split bundle-discovery-malformed into one code per failure mode, mirroring the inline tier: bundles-provider-name-invalid (rename the directory), bundles-root-unreadable (check permissions), keeping bundle-discovery-malformed for unresolvable declarations (fix the entry-point). Same-tier duplicates get duplicate-lfx-bundles-provider instead of overloading bundle-shadowed, whose rendered template (format_extension_error renders templates, not ad-hoc messages) was wrong on both counts for that case. - The claimed-bundles cross-source skip now emits bundle-shadowed on errors (matching _resolve_bundle_shadowing) so filtering by code never mixes severities; bundle-shadowed is already in the CLI warn-only set. 3 new regression tests (raising parent package, namespace portions, duplicate entry points); BUNDLE_API changelog updated.
1 parent 1aa104a commit 883789c

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
@@ -420,10 +420,18 @@ the deserialize half is covered by
420420
imported** -- all `@official` sources share the
421421
`_lfx_ext.official.<bundle>.*` sys.modules namespace, so importing the
422422
losing copy would overwrite the winner's live modules; the skipped copy
423-
still surfaces the `bundle-shadowed` warning. New warning-only code
424-
`bundle-discovery-malformed` added to `ERROR_CODES` (additive) for
425-
unresolvable declarations and invalid provider directory names; it never
426-
aborts startup. Manifest-less bundles bypass the
423+
carries the same typed `bundle-shadowed` diagnostic (on `errors`, matching
424+
the resolver) so filtering by code never mixes severities. Namespace
425+
packages are walked across **all portions**, and resolved roots are
426+
deduplicated by path so duplicate declarations never self-shadow. New
427+
warning-only codes added to `ERROR_CODES` (additive), one per discovery
428+
failure mode and none of which abort startup: `bundle-discovery-malformed`
429+
(declaration does not resolve to an importable package directory --
430+
including a parent package whose `__init__` raises during `find_spec`),
431+
`bundles-provider-name-invalid` (provider folder is not a valid bundle
432+
name), `bundles-root-unreadable` (root cannot be enumerated), and
433+
`duplicate-lfx-bundles-provider` (same provider name in more than one
434+
root; first wins). Manifest-less bundles bypass the
427435
`version-constraint-unsatisfied` API-version gate by construction (no
428436
manifest to carry `lfx.compat`); install-time compatibility rides on the
429437
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
@@ -213,20 +246,27 @@ def _load_bundle_roots(
213246

214247
if not BUNDLE_NAME_RE.match(name):
215248
result.warnings.append(
216-
_malformed_error(
217-
name,
218-
f"provider directory {name!r} (under {root.path}) is not a valid bundle "
219-
"name; lowercase snake_case (a-z, 0-9, _), 2-64 characters.",
249+
ExtensionError(
250+
code="bundles-provider-name-invalid",
251+
message=(
252+
f"provider directory {name!r} (under {root.path}) is not a valid bundle "
253+
"name; lowercase snake_case (a-z, 0-9, _), 2-64 characters."
254+
),
220255
location=str(child),
256+
content=name,
257+
hint="Rename the provider directory to a valid bundle name.",
221258
)
222259
)
223260
results.append(result)
224261
continue
225262

226263
if name in claimed:
264+
# Cross-source shadow: same code AND same severity (errors)
265+
# as _resolve_bundle_shadowing emits for every other shadow
266+
# pair, so filtering by code never mixes semantics.
227267
winner_kind, winner_path = claimed[name]
228268
result.bundle = name
229-
result.warnings.append(
269+
result.errors.append(
230270
ExtensionError(
231271
code="bundle-shadowed",
232272
message=(
@@ -248,15 +288,20 @@ def _load_bundle_roots(
248288
continue
249289

250290
if name in seen_names:
291+
# Same-tier duplicate (two lfx.bundles roots ship the same
292+
# provider): a dedicated code, NOT ``bundle-shadowed`` --
293+
# that one means cross-source precedence and its rendered
294+
# template would mislead here. Mirrors the inline tier's
295+
# ``duplicate-inline-bundle``.
251296
result.bundle = name
252297
result.warnings.append(
253298
ExtensionError(
254-
code="bundle-shadowed",
299+
code="duplicate-lfx-bundles-provider",
255300
message=(
256301
f"Manifest-less bundle {name!r} already discovered from {seen_names[name]}; "
257302
f"skipping the copy at {child}."
258303
),
259-
location=f"{seen_names[name]} -> {child}",
304+
location=str(child),
260305
content=name,
261306
hint=(
262307
"A provider name must come from exactly one lfx.bundles root; rename or "
@@ -289,21 +334,20 @@ def _load_bundle_roots(
289334
return results
290335

291336

292-
def _spec_package_dir(spec: importlib.machinery.ModuleSpec | None) -> Path | None:
293-
"""Return the package directory a module spec points at, or ``None``.
337+
def _spec_package_dirs(spec: importlib.machinery.ModuleSpec | None) -> list[Path]:
338+
"""Return every on-disk directory a package spec points at.
294339
295340
Only package specs qualify (``submodule_search_locations`` is set for both
296-
regular and namespace packages, and equals the package directory). A
297-
plain-module spec returns ``None`` -- a single-file module has no provider
298-
subdirectories, and falling back to the module file's parent would
299-
folder-walk unrelated sibling directories as bundles.
341+
regular and namespace packages). A namespace package split across several
342+
sys.path entries carries one location per portion, and every portion may
343+
hold providers, so all of them are returned. A plain-module spec returns
344+
``[]`` -- a single-file module has no provider subdirectories, and falling
345+
back to the module file's parent would folder-walk unrelated sibling
346+
directories (in a real install: all of site-packages) as bundles.
300347
"""
301348
if spec is None:
302-
return None
303-
locations = list(spec.submodule_search_locations or [])
304-
if locations:
305-
return Path(locations[0])
306-
return None
349+
return []
350+
return [Path(location) for location in (spec.submodule_search_locations or [])]
307351

308352

309353
def _distribution_identity(
@@ -331,12 +375,18 @@ def _distribution_identity(
331375
return name or fallback_id, version or _DEFAULT_BUNDLE_VERSION
332376

333377

334-
def _malformed_error(content: str, message: str, *, location: str | None = None) -> ExtensionError:
335-
"""Build a ``bundle-discovery-malformed`` error payload."""
378+
def _malformed_error(content: str, message: str) -> ExtensionError:
379+
"""Build a ``bundle-discovery-malformed`` error payload.
380+
381+
Reserved for *declaration*-level failures (an entry point that does not
382+
resolve to an importable package directory) -- its hint tells the operator
383+
to fix the entry-point declaration. Provider-name and root-enumeration
384+
failures carry their own codes (``bundles-provider-name-invalid``,
385+
``bundles-root-unreadable``) whose hints fit those mistakes.
386+
"""
336387
return ExtensionError(
337388
code="bundle-discovery-malformed",
338389
message=message,
339-
location=location,
340390
content=content,
341391
hint=_MALFORMED_HINT,
342392
)

0 commit comments

Comments
 (0)