Skip to content

Commit 60b459a

Browse files
committed
Merge branch 'bundles/docs' into bundles/review-fixes
# Conflicts: # BUNDLE_API.md
2 parents dfb8222 + a505d36 commit 60b459a

10 files changed

Lines changed: 250 additions & 13 deletions

File tree

BUNDLE_API.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -421,13 +421,24 @@ the deserialize half is covered by
421421
`installed > seed > lfx_bundles > dev > inline` -- **manifest always
422422
wins**, so a manifest-shipping `lfx-<provider>` shadows the same-named
423423
provider in a metapackage with the existing `bundle-shadowed` warning
424-
(graduation requires no lockstep release). New warning-only code
424+
(graduation requires no lockstep release). A metapackage provider whose
425+
name is already claimed by an installed or seed source is **never
426+
imported** -- all `@official` sources share the
427+
`_lfx_ext.official.<bundle>.*` sys.modules namespace, so importing the
428+
losing copy would overwrite the winner's live modules; the skipped copy
429+
still surfaces the `bundle-shadowed` warning. New warning-only code
425430
`bundle-discovery-malformed` added to `ERROR_CODES` (additive) for
426431
unresolvable declarations and invalid provider directory names; it never
427432
aborts startup. Manifest-less bundles bypass the
428433
`version-constraint-unsatisfied` API-version gate by construction (no
429434
manifest to carry `lfx.compat`); install-time compatibility rides on the
430-
metapackage's PEP 508 `lfx>=X,<Y` pin instead.
435+
metapackage's PEP 508 `lfx>=X,<Y` pin instead. Manifest-less records
436+
register with `manifestless=True` (additive field on `LoadResult` /
437+
`BundleRecord`) and are **not hot-reloadable**: the reload pipeline
438+
refuses them with the new typed code `reload-manifestless-unsupported`
439+
(additive in `ERROR_CODES`) instead of failing `manifest-not-found`;
440+
pick up metapackage changes by upgrading the distribution and
441+
restarting the process.
431442
- **`import_mod` promoted to a stable public home.** The lazy-import helper
432443
that bundle packages call from their `__getattr__`-based `__init__.py`
433444
files moved from the internal `lfx.components._importing` to

src/lfx/src/lfx/extension/bundle_registry.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,10 @@ class BundleRecord:
9898
components: tuple[LoadedComponent, ...] = ()
9999
distribution: str | None = None
100100
source_path: Path | None = None
101+
# Provenance: True for manifest-less lfx.bundles metapackage providers.
102+
# The reload pipeline refuses these with reload-manifestless-unsupported
103+
# (no manifest exists for its load_extension stage to consume).
104+
manifestless: bool = False
101105

102106
@property
103107
def class_names(self) -> frozenset[str]:

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,10 @@
9797
"reload-bundle-not-installed",
9898
"reload-bundle-name-mismatch",
9999
"reload-source-missing",
100+
# Manifest-less lfx.bundles providers carry no manifest for the
101+
# reload pipeline's load_extension stage; hot reload refuses them
102+
# with this typed code instead of a misleading manifest-not-found.
103+
"reload-manifestless-unsupported",
100104
# Post-swap hook failures: the registry swap committed but a
101105
# downstream side-effect (e.g. component cache rebuild) raised.
102106
# Surfaced on ReloadResult.warnings so the API caller knows the
@@ -307,6 +311,10 @@ def __bool__(self) -> bool: # pragma: no cover - convenience
307311
"reload-source-missing": (
308312
"Reload source path {content!r} for bundle {location!r} does not exist or is not a directory."
309313
),
314+
"reload-manifestless-unsupported": (
315+
"Bundle {content!r} comes from a manifest-less lfx.bundles metapackage and cannot be "
316+
"hot-reloaded; upgrade the metapackage distribution and restart the process instead."
317+
),
310318
"reload-post-swap-hook-failed": (
311319
"Post-swap hook failed for bundle {content!r}; the bundle swap committed but a "
312320
"downstream side-effect (e.g. component cache rebuild) raised."

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

Lines changed: 53 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
from lfx.extension.manifest import BUNDLE_NAME_RE
5353

5454
if TYPE_CHECKING:
55-
from collections.abc import Iterable
55+
from collections.abc import Iterable, Mapping
5656
from importlib import metadata as importlib_metadata
5757

5858
LFX_BUNDLES_ENTRY_POINT_GROUP = "lfx.bundles"
@@ -86,6 +86,7 @@ class _BundleRoot(NamedTuple):
8686
def load_lfx_bundles_extensions(
8787
*,
8888
entry_points: Iterable[importlib_metadata.EntryPoint] | None = None,
89+
claimed_bundles: Mapping[str, tuple[str, str]] | None = None,
8990
) -> list[LoadResult]:
9091
"""Discover manifest-less ``lfx.bundles`` roots and load them at @official.
9192
@@ -96,6 +97,18 @@ def load_lfx_bundles_extensions(
9697
subdirectories -- each valid subdirectory is one manifest-less bundle at
9798
the @official slot.
9899
100+
``claimed_bundles`` maps bundle names already won by a higher-precedence
101+
@official source (installed > seed) to ``(source_kind, source_path)``.
102+
A provider directory whose name is claimed is **never imported** -- its
103+
result carries a typed ``bundle-shadowed`` warning instead. Skipping the
104+
import (rather than letting :func:`_resolve_bundle_shadowing` drop the
105+
components afterwards) matters because all @official sources share the
106+
``_lfx_ext.official.<bundle>.*`` sys.modules namespace: importing the
107+
losing copy would overwrite the winner's live modules. This is the
108+
normal graduation state -- a manifest-shipping ``lfx-<provider>``
109+
installed alongside an older metapackage that still contains the same
110+
provider -- not an operator error.
111+
99112
Returns one :class:`LoadResult` per discovered bundle, plus one sentinel
100113
:class:`LoadResult` carrying a ``bundle-discovery-malformed`` warning per
101114
declaration that could not be resolved. Order is deterministic: entry
@@ -104,7 +117,7 @@ def load_lfx_bundles_extensions(
104117
even when no valid bundle followed.
105118
"""
106119
roots, sentinels = _resolve_bundle_roots(entry_points)
107-
return [*sentinels, *_load_bundle_roots(roots)]
120+
return [*sentinels, *_load_bundle_roots(roots, claimed_bundles=claimed_bundles)]
108121

109122

110123
def _resolve_bundle_roots(
@@ -156,16 +169,23 @@ def _resolve_bundle_roots(
156169
return roots, sentinels
157170

158171

159-
def _load_bundle_roots(roots: Iterable[_BundleRoot]) -> list[LoadResult]:
172+
def _load_bundle_roots(
173+
roots: Iterable[_BundleRoot],
174+
*,
175+
claimed_bundles: Mapping[str, tuple[str, str]] | None = None,
176+
) -> list[LoadResult]:
160177
"""Folder-walk each resolved root, loading every valid subdirectory at @official.
161178
162179
First-wins on duplicate bundle names across roots (the loser emits a typed
163-
``bundle-shadowed`` warning); subdirectories whose name is not a valid
164-
bundle name emit ``bundle-discovery-malformed`` and are skipped.
165-
Internal directories (dot-prefixed, underscore-prefixed, or in
166-
:data:`SKIP_DIR_NAMES`) are skipped silently -- they are package
167-
machinery, not providers.
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.
168187
"""
188+
claimed = claimed_bundles or {}
169189
results: list[LoadResult] = []
170190
seen_names: dict[str, Path] = {}
171191
for root in roots:
@@ -206,7 +226,7 @@ def _load_bundle_roots(roots: Iterable[_BundleRoot]) -> list[LoadResult]:
206226
results.append(result)
207227
continue
208228

209-
result = LoadResult(slot=SLOT_OFFICIAL, source_path=child)
229+
result = LoadResult(slot=SLOT_OFFICIAL, source_path=child, manifestless=True)
210230

211231
if not BUNDLE_NAME_RE.match(name):
212232
result.warnings.append(
@@ -220,6 +240,30 @@ def _load_bundle_roots(roots: Iterable[_BundleRoot]) -> list[LoadResult]:
220240
results.append(result)
221241
continue
222242

243+
if name in claimed:
244+
winner_kind, winner_path = claimed[name]
245+
result.bundle = name
246+
result.warnings.append(
247+
ExtensionError(
248+
code="bundle-shadowed",
249+
message=(
250+
f"Manifest-less bundle {name!r} at {child} is shadowed by a "
251+
f"higher-precedence source at {winner_path} (source: {winner_kind}); "
252+
"the metapackage copy is not imported."
253+
),
254+
location=str(child),
255+
content=name,
256+
hint=(
257+
"Discovery precedence is installed > seed > lfx_bundles > dev > inline. "
258+
"This is expected after a provider graduates to a standalone "
259+
"lfx-<provider> package; upgrade the metapackage to a version without "
260+
"this provider to silence the warning."
261+
),
262+
)
263+
)
264+
results.append(result)
265+
continue
266+
223267
if name in seen_names:
224268
result.bundle = name
225269
result.warnings.append(

src/lfx/src/lfx/extension/loader/_types.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,12 @@ class LoadResult:
137137
slot: Literal["official", "extra"] | None = None
138138
source_path: Path | None = None
139139
distribution: str | None = None
140+
# True when the bundle came from a manifest-less ``lfx.bundles``
141+
# metapackage root. Provenance flag (not derivable from the other
142+
# fields): the reload pipeline uses it to refuse hot reload with a
143+
# typed ``reload-manifestless-unsupported`` instead of routing the
144+
# record through load_extension and failing manifest-not-found.
145+
manifestless: bool = False
140146

141147
@property
142148
def ok(self) -> bool:

src/lfx/src/lfx/extension/reload.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,34 @@ def reload_bundle(
261261
reload_id = uuid.uuid4().hex
262262
live = registry.get_bundle(bundle)
263263

264+
# Manifest-less lfx.bundles metapackage providers have no extension.json
265+
# for the pipeline's load_extension stage; refuse with a typed code
266+
# before resolving sources rather than failing later with a misleading
267+
# manifest-not-found. These records change only when the metapackage
268+
# distribution is upgraded, which requires a process restart anyway.
269+
if live is not None and live.manifestless:
270+
return _failure(
271+
bundle=bundle,
272+
reload_id=reload_id,
273+
errors=[
274+
ExtensionError(
275+
code="reload-manifestless-unsupported",
276+
message=(
277+
f"Bundle {bundle!r} comes from a manifest-less lfx.bundles metapackage "
278+
"and cannot be hot-reloaded."
279+
),
280+
location=str(live.source_path) if live.source_path else bundle,
281+
content=bundle,
282+
hint=(
283+
"Upgrade or reinstall the metapackage distribution and restart the "
284+
"server to pick up changes. For a hot-reload development loop, use "
285+
"`lfx extension dev <path>` with a manifest-shipping bundle instead."
286+
),
287+
)
288+
],
289+
previous=live,
290+
)
291+
264292
# Resolve effective source + slot from the live record when not given.
265293
effective_source = _resolve_source(source_path, live)
266294
effective_slot: Literal["official", "extra"] = slot or (live.slot if live else SLOT_OFFICIAL)

src/lfx/src/lfx/interface/components.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -812,6 +812,30 @@ def _emit_extension_diagnostics(results: list[LoadResult]) -> None:
812812
_DISCOVERY_PRECEDENCE: tuple[str, ...] = ("installed", "seed", "lfx_bundles", "dev", "inline")
813813

814814

815+
def _claimed_official_bundles(
816+
extension_results: list[LoadResult],
817+
seed_results: list[LoadResult],
818+
) -> dict[str, tuple[str, str]]:
819+
"""Map bundle names won by installed/seed sources to ``(kind, path)``.
820+
821+
Fed to :func:`load_lfx_bundles_extensions` so the manifest-less
822+
metapackage tier skips *importing* providers that would lose shadow
823+
resolution anyway -- the @official sources share one
824+
``_lfx_ext.official.<bundle>.*`` sys.modules namespace, and a
825+
lower-precedence import would overwrite the winner's live modules.
826+
Mirrors the winner-picking rule in :func:`_resolve_bundle_shadowing`:
827+
only results that actually produced components claim a name, and the
828+
first (highest-precedence) claimant wins.
829+
"""
830+
claimed: dict[str, tuple[str, str]] = {}
831+
for kind, results in (("installed", extension_results), ("seed", seed_results)):
832+
for result in results:
833+
if not result.bundle or not result.components or result.bundle in claimed:
834+
continue
835+
claimed[result.bundle] = (kind, str(result.source_path) if result.source_path else "<unknown>")
836+
return claimed
837+
838+
815839
def _resolve_bundle_shadowing(
816840
*,
817841
extension_results: list[LoadResult],
@@ -972,7 +996,18 @@ async def import_extension_components(
972996
# subdirectories are bundles, registered at @official with no manifest.
973997
# Loaded here so they enter the same shadow-resolution + registry +
974998
# palette pathway; a no-op when no distribution declares the group.
975-
lfx_bundles_results = load_lfx_bundles_extensions()
999+
#
1000+
# ``claimed_bundles`` carries the names already won by the two
1001+
# higher-precedence sources so the metapackage loader never *imports* a
1002+
# losing copy: all @official sources share the
1003+
# ``_lfx_ext.official.<bundle>.*`` sys.modules namespace, so importing a
1004+
# shadowed provider would overwrite the winner's live modules even though
1005+
# _resolve_bundle_shadowing later drops its components. This collision is
1006+
# the expected post-graduation state (standalone lfx-<provider> installed
1007+
# alongside an older metapackage), not a misconfiguration.
1008+
lfx_bundles_results = load_lfx_bundles_extensions(
1009+
claimed_bundles=_claimed_official_bundles(extension_results, seed_results)
1010+
)
9761011
# Dev extensions registered via ``lfx extension dev`` ship the same v0
9771012
# manifest contract as installed extensions; load them through the
9781013
# @official-slot pathway so they enter the BundleRegistry, expose the
@@ -1049,6 +1084,7 @@ async def import_extension_components(
10491084
components=tuple(result.components),
10501085
distribution=result.distribution,
10511086
source_path=result.source_path,
1087+
manifestless=result.manifestless,
10521088
)
10531089
registry.install_bundle(record)
10541090

src/lfx/tests/unit/extension/loader/test_load_lfx_bundles.py

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from __future__ import annotations
1818

1919
import importlib
20+
import sys
2021
from pathlib import Path
2122

2223
from lfx.extension import SLOT_OFFICIAL, LoadedComponent, LoadResult, load_lfx_bundles_extensions
@@ -26,7 +27,7 @@
2627
_load_bundle_roots,
2728
_resolve_bundle_roots,
2829
)
29-
from lfx.interface.components import _resolve_bundle_shadowing
30+
from lfx.interface.components import _claimed_official_bundles, _resolve_bundle_shadowing
3031

3132
from .conftest import component_source
3233

@@ -183,6 +184,71 @@ def test_duplicate_provider_across_roots_first_wins(tmp_path: Path) -> None:
183184
assert not shadowed[0].components
184185

185186

187+
def test_claimed_bundle_name_is_not_imported(tmp_path: Path) -> None:
188+
"""A name won by an installed/seed source is skipped *without importing*.
189+
190+
All @official sources share the ``_lfx_ext.official.<bundle>.*``
191+
sys.modules namespace; importing the metapackage's losing copy would
192+
overwrite the winner's live modules even though shadow resolution drops
193+
the loser's components afterwards. This is the expected post-graduation
194+
state (standalone ``lfx-<provider>`` next to an older metapackage).
195+
"""
196+
root = _make_bundles_root(tmp_path, "claimedprov", "freeprov")
197+
198+
results = _load_bundle_roots(
199+
[_BundleRoot(root, "lfx-bundles", "1.0.0")],
200+
claimed_bundles={"claimedprov": ("installed", "/site-packages/lfx_claimedprov")},
201+
)
202+
by_bundle = {r.bundle: r for r in results if r.bundle}
203+
204+
assert set(by_bundle) == {"claimedprov", "freeprov"}
205+
claimed = by_bundle["claimedprov"]
206+
assert not claimed.components
207+
assert claimed.ok # warning-only: never aborts startup
208+
assert [w.code for w in claimed.warnings] == ["bundle-shadowed"]
209+
assert "installed" in claimed.warnings[0].message
210+
# The decisive property: nothing was imported for the claimed name, so
211+
# the winner's live modules cannot have been overwritten.
212+
assert not [k for k in sys.modules if k.startswith("_lfx_ext.official.claimedprov")]
213+
# The unclaimed sibling in the same root still loads normally.
214+
assert by_bundle["freeprov"].components
215+
216+
217+
def test_provider_results_are_marked_manifestless(tmp_path: Path) -> None:
218+
"""Provider results carry the provenance flag the reload pipeline keys on."""
219+
root = _make_bundles_root(tmp_path, "flagged")
220+
221+
results = _load_bundle_roots([_BundleRoot(root, "lfx-bundles", "1.0.0")])
222+
223+
assert results
224+
assert all(r.manifestless for r in results if r.bundle)
225+
226+
227+
def test_claimed_official_bundles_first_wins_and_requires_components(tmp_path: Path) -> None:
228+
"""The claim map mirrors the resolver's winner rule.
229+
230+
Only results that produced components claim a name, and the
231+
highest-precedence claimant (installed before seed) wins.
232+
"""
233+
installed_alpha = LoadResult(
234+
slot=SLOT_OFFICIAL,
235+
bundle="alpha",
236+
source_path=tmp_path / "inst" / "alpha",
237+
components=[_component("alpha")],
238+
)
239+
seed_alpha = LoadResult(
240+
slot=SLOT_OFFICIAL,
241+
bundle="alpha",
242+
source_path=tmp_path / "seed" / "alpha",
243+
components=[_component("alpha")],
244+
)
245+
seed_empty = LoadResult(slot=SLOT_OFFICIAL, bundle="empty", source_path=tmp_path / "seed" / "empty")
246+
247+
claimed = _claimed_official_bundles([installed_alpha], [seed_alpha, seed_empty])
248+
249+
assert claimed == {"alpha": ("installed", str(tmp_path / "inst" / "alpha"))}
250+
251+
186252
# ---------------------------------------------------------------------------
187253
# Entry-point resolution + malformed declarations
188254
# ---------------------------------------------------------------------------

src/lfx/tests/unit/extension/test_errors.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ def _err(code: str, **kw: object) -> ExtensionError:
103103
"reload-bundle-not-installed": "error[reload-bundle-not-installed]: Cannot reload bundle 'content': it is not registered. Install the extension first or pass an explicit source path.", # noqa: E501
104104
"reload-bundle-name-mismatch": "error[reload-bundle-name-mismatch]: Reload source at loc declares bundle name 'content', which does not match the registered bundle being reloaded.", # noqa: E501
105105
"reload-source-missing": "error[reload-source-missing]: Reload source path 'content' for bundle 'loc' does not exist or is not a directory.", # noqa: E501
106+
"reload-manifestless-unsupported": "error[reload-manifestless-unsupported]: Bundle 'content' comes from a manifest-less lfx.bundles metapackage and cannot be hot-reloaded; upgrade the metapackage distribution and restart the process instead.", # noqa: E501
106107
"reload-post-swap-hook-failed": "error[reload-post-swap-hook-failed]: Post-swap hook failed for bundle 'content'; the bundle swap committed but a downstream side-effect (e.g. component cache rebuild) raised.", # noqa: E501
107108
"reload-class-retag-failed": "error[reload-class-retag-failed]: Could not retag content.__module__ after reload at loc: msg", # noqa: E501
108109
"reload-transport-error": "error[reload-transport-error]: Could not reach the reload endpoint at loc: msg",

0 commit comments

Comments
 (0)