3030
3131Failure 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
4248from __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
326370def _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