From ec3aea7ff8961ee7d2d4014e104bdadb9c38998d Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Wed, 20 May 2026 11:10:59 -0700 Subject: [PATCH] fix(extensions): discover editable bundles; clean up ticket refs and reload UX (#13219) * fix(extensions): discover editable installs via langflow.extensions entry-point `lfx extension list` was silently dropping bundles installed via `uv pip install -e` / `pip install -e`. Editable installs surface only `dist-info/` entries in `dist.files`, so the wheel-shaped scan in `_distribution_manifest_path` finds no `extension.json`. The bundle pyproject.toml comment already promised an entry-point fallback for this case, but it was never implemented in discovery.py. Add `_distribution_manifest_path_via_entry_points` that resolves the package via `importlib.util.find_spec` (no module body execution) and locates the manifest under the resulting package directory. The fallback runs only when `dist.files` produces no manifest, so wheel installs are unaffected. Tests: - editable distribution with dist-info-only files is discovered - entry-point pointing at an unimportable module yields no record - wheel-install path never consults find_spec (guards against re-importing every bundle package on startup) * chore(extensions): drop internal ticket refs; relax reload --bundle and --all Two related changes: 1. Strip internal `LE-NNNN` ticket references from the extension source, bundles, tests, and public docs. The references were not actionable outside the project and surfaced in user-visible CLI messages and the author guide. 2. Relax `lfx extension reload` CLI ergonomics now that local discovery (`discover_all_extensions`) gives us the install map without needing an HTTP list endpoint: - `lfx extension reload `: `--bundle` is now optional; when omitted, the bundle name is resolved from local discovery. Explicit `--bundle` still wins for cases where the local install isn't visible to the running server. - `lfx extension reload --all`: iterates over every locally-discovered bundle, POSTs reload to each, renders per-bundle status, and exits 1 if any reload fails. Previously hard-errored as "not yet wired". - `--all` is mutually exclusive with a positional id / `--bundle` (exit 2 with a clear message). - Missing both `extension_id` and `--all` exits 2 pointing at both. Updated `test_reload_requires_explicit_bundle` (which enforced the old "--bundle is mandatory" behavior) to cover the new resolution paths. * docs(bundle-api): changelog entries for editable-install discovery + reload CLI Satisfies the BUNDLE_API surface-change gate for the editable-install discovery fallback (entry-point lookup in discovery.py) and the relaxed `lfx extension reload` CLI (`--bundle` optional, `--all` implemented). No additional behavior change in this commit -- it only documents the two changes already shipped in this branch. * [autofix.ci] apply automated fixes --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Debojit Kaushik --- BUNDLE_API.md | 26 +++ .../DEPRECATED_MACOS_SUPPORT_INVESTIGATION.md | 7 +- ...RCH_MACOS_AMD64_PYTHON313_INVESTIGATION.md | 5 +- .../deployment-extensions-production.mdx | 4 +- docs/docs/Develop/extensions-author-guide.mdx | 8 +- docs/docs/Develop/extensions-quickstart.mdx | 2 +- pyproject.toml | 6 +- .../base/langflow/api/v1/extensions.py | 12 +- .../Structured Data Analysis Agent.json | 2 +- src/backend/base/langflow/plugin_routes.py | 2 +- src/backend/base/pyproject.toml | 8 +- src/backend/tests/unit/test_webhook.py | 2 +- .../duckduckgo/M1_DOGFOOD_CHECKLIST.md | 19 +- src/bundles/duckduckgo/README.md | 8 +- src/bundles/duckduckgo/pyproject.toml | 10 +- .../duckduckgo/src/lfx_duckduckgo/__init__.py | 4 +- .../src/lfx_duckduckgo/extension.json | 2 +- .../src/utils/__tests__/cleanEdges.test.ts | 4 +- src/lfx/src/lfx/cli/_extension_commands.py | 218 +++++++++++++++--- .../src/lfx/cli/_extension_reload_client.py | 2 +- src/lfx/src/lfx/extension/bundle_registry.py | 16 +- src/lfx/src/lfx/extension/dev_registry.py | 29 +-- src/lfx/src/lfx/extension/discovery.py | 131 ++++++++--- src/lfx/src/lfx/extension/errors.py | 6 +- src/lfx/src/lfx/extension/init_template.py | 10 +- .../src/lfx/extension/loader/_discovery.py | 8 +- .../src/lfx/extension/loader/_orchestrator.py | 10 +- src/lfx/src/lfx/extension/loader/_types.py | 12 +- .../src/lfx/extension/migration/__init__.py | 4 +- .../src/lfx/extension/migration/rewrite.py | 10 +- src/lfx/src/lfx/extension/migration/schema.py | 2 +- src/lfx/src/lfx/extension/registry.py | 17 +- src/lfx/src/lfx/extension/reload.py | 33 +-- src/lfx/src/lfx/graph/graph/base.py | 2 +- src/lfx/src/lfx/interface/components.py | 12 +- src/lfx/src/lfx/services/settings/base.py | 2 +- .../extension/test_pilot_arxiv_upgrade.py | 8 +- .../test_pilot_duckduckgo_upgrade.py | 12 +- .../extension/test_pip_install_discovery.py | 9 +- src/lfx/tests/unit/extension/test_cli.py | 58 +++-- src/lfx/tests/unit/extension/test_cli_list.py | 2 +- .../test_components_path_settings.py | 8 +- .../tests/unit/extension/test_dev_registry.py | 2 +- .../tests/unit/extension/test_discovery.py | 185 ++++++++++++++- .../unit/extension/test_init_template.py | 4 +- src/lfx/tests/unit/extension/test_registry.py | 9 +- src/lfx/tests/unit/extension/test_reload.py | 4 +- .../tests/unit/extension/test_reload_cli.py | 2 +- .../unit/inputs/test_model_input_fixes.py | 2 +- src/lfx/tests/unit/test_flow_builder.py | 13 +- 50 files changed, 706 insertions(+), 267 deletions(-) diff --git a/BUNDLE_API.md b/BUNDLE_API.md index 7c5850f6d5..5595b36fb5 100644 --- a/BUNDLE_API.md +++ b/BUNDLE_API.md @@ -333,3 +333,29 @@ the deserialize half is covered by / ``load_seed_extensions`` moved to ``lfx.extension.loader._startup``. Both are re-exported from their previous import paths so external imports are unchanged. + - **Editable installs are discovered via the entry-point fallback.** + ``_distribution_manifest_path`` now falls back to the + ``langflow.extensions`` entry-point group when ``dist.files`` only + surfaces ``dist-info/`` entries (the ``pip install -e`` / + ``uv pip install -e`` case). The entry-point value is resolved + via ``importlib.util.find_spec`` -- which runs import-system + finders but never executes the module body -- and the resulting + package directory is scanned for ``extension.json`` or a + ``[tool.langflow.extension]`` pyproject. Wheel installs are + unaffected: the fallback only fires when the primary ``dist.files`` + scan finds no manifest. Previously, editable-installed bundles + were silently dropped by ``lfx extension list`` and the registry, + even though the bundle pyproject already declared the + entry-point. + - **Reload CLI: ``--bundle`` is optional; ``--all`` is implemented.** + ``lfx extension reload `` now resolves the bundle name + from local ``discover_all_extensions`` when ``--bundle`` is + omitted; explicit ``--bundle`` still wins for cases where the + local install is not visible to the running server. + ``lfx extension reload --all`` iterates every locally-discovered + bundle, POSTs reload to each, and exits non-zero if any reload + fails (previously hard-errored as "not yet wired"). ``--all`` is + mutually exclusive with a positional id / ``--bundle`` (exit 2). + The HTTP wire contract (``POST /api/v1/extensions/{id}/bundles/ + {name}/reload`` per-bundle) is unchanged; this is a CLI-only + surface change. diff --git a/docs/DEPRECATED_MACOS_SUPPORT_INVESTIGATION.md b/docs/DEPRECATED_MACOS_SUPPORT_INVESTIGATION.md index 1af2d4d1ef..1da5ca664f 100644 --- a/docs/DEPRECATED_MACOS_SUPPORT_INVESTIGATION.md +++ b/docs/DEPRECATED_MACOS_SUPPORT_INVESTIGATION.md @@ -1,9 +1,8 @@ # Investigation: Deprecated macOS Support — Impact on Langflow -**Jira:** LE-265 **Date:** 2026-04-02 **Status:** Complete -**Related:** LE-172 (PyTorch macOS x86_64 + Python 3.13 — resolved in PR #12469) +**Related:** PyTorch macOS x86_64 + Python 3.13 (resolved in PR #12469) --- @@ -303,7 +302,5 @@ Line 345: gassist → sys_platform == 'win32' | Reference | Description | Status | |-----------|-------------|--------| -| LE-172 | PyTorch macOS x86_64 + Python 3.13 CI failure | ✅ Resolved (PR #12469) | -| LE-265 | Broader deprecated macOS support investigation | This document | -| [pytorch/pytorch#114602](https://github.com/pytorch/pytorch/issues/114602) | PyTorch RFC: Deprecate macOS x86_64 builds | Implemented | | PR #12469 | Exclude torch-dependent extras on macOS x86_64 | Merged/Open | +| [pytorch/pytorch#114602](https://github.com/pytorch/pytorch/issues/114602) | PyTorch RFC: Deprecate macOS x86_64 builds | Implemented | diff --git a/docs/TORCH_MACOS_AMD64_PYTHON313_INVESTIGATION.md b/docs/TORCH_MACOS_AMD64_PYTHON313_INVESTIGATION.md index 5c27a91129..5376edee84 100644 --- a/docs/TORCH_MACOS_AMD64_PYTHON313_INVESTIGATION.md +++ b/docs/TORCH_MACOS_AMD64_PYTHON313_INVESTIGATION.md @@ -1,8 +1,7 @@ # Investigation: PyTorch on macOS AMD64 (x86_64) + Python 3.13 -**Jira:** LE-172 -**Date:** 2026-04-02 -**Status:** Complete +**Date:** 2026-04-02 +**Status:** Complete --- diff --git a/docs/docs/Deployment/deployment-extensions-production.mdx b/docs/docs/Deployment/deployment-extensions-production.mdx index 49d3f87aa3..6b5b82456e 100644 --- a/docs/docs/Deployment/deployment-extensions-production.mdx +++ b/docs/docs/Deployment/deployment-extensions-production.mdx @@ -8,7 +8,7 @@ slug: /deployment-extensions-production This guide describes the production install pattern for Langflow Extensions in Modes A, B, and C of the Bundle Separation iteration. The mechanism is intentionally simple: **Python distributions installed into the environment are the primary install path**, and an optional **seed directory** is supported for Docker images that prefer an explicit on-disk layout. :::info First-delivery scope -This page documents the first-delivery slice of the Bundle Separation epic (LE-905). It covers discovery and registration of installed Extensions; the corresponding loader, dev server, and reload features ship in the same first-delivery cycle. Mutation verbs (`enable`, `disable`, `install`, `uninstall`) ship in B3/B4 follow-up epics; **runtime mutation of installed Extensions is intentionally not supported**. +This page documents the first-delivery slice of the Bundle Separation work. It covers discovery and registration of installed Extensions; the corresponding loader, dev server, and reload features ship in the same first-delivery cycle. Mutation verbs (`enable`, `disable`, `install`, `uninstall`) ship in follow-up work; **runtime mutation of installed Extensions is intentionally not supported**. ::: ## How discovery works @@ -22,7 +22,7 @@ Each Extension registered via these paths is **read-only at runtime**: * `autoUpdate` is forced to `false`. * Mutation through the registry service raises a typed error with code `installed-extension-immutable` or `seed-directory-immutable`. -* No HTTP route exists for installing or uninstalling Extensions; the LE-1017 router-trust CI guard blocks any attempt to add one. +* No HTTP route exists for installing or uninstalling Extensions; the router-trust CI guard blocks any attempt to add one. To change which Extensions are installed in Mode B/C, **rebuild the image** (or modify the seed directory layout) and restart the server. diff --git a/docs/docs/Develop/extensions-author-guide.mdx b/docs/docs/Develop/extensions-author-guide.mdx index 7312a1e8a6..fd5f2fc7f5 100644 --- a/docs/docs/Develop/extensions-author-guide.mdx +++ b/docs/docs/Develop/extensions-author-guide.mdx @@ -8,9 +8,9 @@ slug: /extensions-author-guide This guide covers how to design, build, and ship a Langflow Extension Bundle from scratch, plus the conventions that make the result safe to maintain alongside the rest of Langflow. If you just want to get something running, start with [Build your first Langflow Extension](./extensions-quickstart.mdx) — that's the three-minute path. Read this when you're ready to publish, port a component out of `lfx.components.`, or evaluate whether your extension idea fits the v0 contract. :::info First-delivery scope -This page documents the first-delivery slice of the Bundle Separation epic (LE-905). It covers the foundation (manifest, single-Bundle loader, atomic-swap reload) plus the pilot migration pattern. The wider Extension System vision — services, routes, registries, partner handover — is described in the project's internal design docs and ships in later epics. +This page documents the first-delivery slice of the Bundle Separation work. It covers the foundation (manifest, single-Bundle loader, atomic-swap reload) plus the pilot migration pattern. The wider Extension System vision — services, routes, registries, partner handover — ships in later phases. -The structured **events pipeline** (`ExtensionEventsService`, `GET /api/v1/extensions/events`, the `use-extension-events` React hook, `bundle_reloaded` / `flow-migrated` events) is deferred to LE-1017. Reload and migration emit through the standard logger today and the HTTP response carries the typed result body; the events stream is the additive surface that lands on top. +The structured **events pipeline** (`ExtensionEventsService`, `GET /api/v1/extensions/events`, the `use-extension-events` React hook, `bundle_reloaded` / `flow-migrated` events) is deferred. Reload and migration emit through the standard logger today and the HTTP response carries the typed result body; the events stream is the additive surface that lands on top. ::: ## Concepts @@ -109,7 +109,7 @@ Atomic-swap reload (Mode A only) is what makes the dev loop tolerable. The five 2. **Stage 2 — validate** the staging load. Any error aborts here; the live bundle is unchanged. 3. **Stage 3 — swap** under the registry write-lock. New flows resolve to the new class; in-flight flows keep their pre-swap class reference. 4. **Stage 4 — clean up** the staging namespace. -5. **Stage 5 — notify**. Today this is a logger emission of the added/removed component lists plus a registered post-swap hook that refreshes the component cache; the typed `bundle_reloaded` events pipeline (`ExtensionEventsService`, `GET /api/v1/extensions/events`, the `use-extension-events` React hook) is deferred to LE-1017. The reload route's HTTP response body already carries the typed result, so the palette's reload action gets immediate feedback through the existing mutation hook ([`use-reload-bundle.ts`](https://github.com/langflow-ai/langflow/blob/main/src/frontend/src/controllers/API/queries/extensions/use-reload-bundle.ts)) until the events stream lands. +5. **Stage 5 — notify**. Today this is a logger emission of the added/removed component lists plus a registered post-swap hook that refreshes the component cache; the typed `bundle_reloaded` events pipeline (`ExtensionEventsService`, `GET /api/v1/extensions/events`, the `use-extension-events` React hook) is deferred. The reload route's HTTP response body already carries the typed result, so the palette's reload action gets immediate feedback through the existing mutation hook ([`use-reload-bundle.ts`](https://github.com/langflow-ai/langflow/blob/main/src/frontend/src/controllers/API/queries/extensions/use-reload-bundle.ts)) until the events stream lands. :::note First reload after boot On the **first** reload after a fresh server boot, `components_added` and `components_removed` are always empty: the bundle is already registered at startup, so the post-swap diff is against an identical pre-swap snapshot. This is expected — non-empty deltas only appear once you edit a component (add/remove a class, rename, etc.) and reload again. @@ -119,7 +119,7 @@ Reload is **not** a trust boundary. Bundle code was trusted at install time; the ## Porting an in-tree component -The mechanical steps for extracting `src/lfx/src/lfx/components//` into a standalone bundle live in [`src/bundles/PORTING.md`](https://github.com/langflow-ai/langflow/blob/main/src/bundles/PORTING.md). That's the canonical recipe; this page covers the design choices around it. The DuckDuckGo extraction (LE-1023) is the reference implementation — diff `src/bundles/duckduckgo/` against any in-tree component to see the shape. +The mechanical steps for extracting `src/lfx/src/lfx/components//` into a standalone bundle live in [`src/bundles/PORTING.md`](https://github.com/langflow-ai/langflow/blob/main/src/bundles/PORTING.md). That's the canonical recipe; this page covers the design choices around it. The DuckDuckGo extraction is the reference implementation — diff `src/bundles/duckduckgo/` against any in-tree component to see the shape. A few things that surprise people: diff --git a/docs/docs/Develop/extensions-quickstart.mdx b/docs/docs/Develop/extensions-quickstart.mdx index 1193947387..3624c33ab8 100644 --- a/docs/docs/Develop/extensions-quickstart.mdx +++ b/docs/docs/Develop/extensions-quickstart.mdx @@ -6,7 +6,7 @@ slug: /extensions-quickstart This guide walks through building, validating, and running your first Langflow Extension Bundle in roughly three minutes. The result is a single-component extension you can pip-install or load via `lfx extension dev` and reload from the palette. :::info First-delivery scope -This page documents the first-delivery slice of the Bundle Separation epic (LE-905). Multi-bundle extensions, services and routes, remote install, and the curated registry are deferred to later milestones — see [Author guide for Langflow Extensions](./extensions-author-guide.mdx) for the full scope picture. +This page documents the first-delivery slice of the Bundle Separation work. Multi-bundle extensions, services and routes, remote install, and the curated registry are deferred to later milestones — see [Author guide for Langflow Extensions](./extensions-author-guide.mdx) for the full scope picture. ::: ## Prerequisites diff --git a/pyproject.toml b/pyproject.toml index 36d30f220f..d488b7247f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,10 +19,10 @@ maintainers = [ dependencies = [ "langflow-base[complete]>=0.10.0", # langflow-extensions:bundle-deps-start - # Bundle pilot (LE-1023): lfx-duckduckgo ships the DuckDuckGoSearchComponent + # Bundle pilot: lfx-duckduckgo ships the DuckDuckGoSearchComponent # as a standalone Extension distribution. Listed as a regular dep so - # ``pip install langflow`` still pulls the component in -- the AC for B1 - # is "no user-visible change at install time". + # ``pip install langflow`` still pulls the component in -- there must + # be no user-visible change at install time. "lfx-duckduckgo>=0.1.0", # Second pilot port (validates src/bundles/PORTING.md): lfx-arxiv ships # ArXivComponent as a standalone Bundle. Same regular-dep rationale -- diff --git a/src/backend/base/langflow/api/v1/extensions.py b/src/backend/base/langflow/api/v1/extensions.py index 96acefc0d1..b3e7f76459 100644 --- a/src/backend/base/langflow/api/v1/extensions.py +++ b/src/backend/base/langflow/api/v1/extensions.py @@ -1,13 +1,13 @@ -"""Extension System HTTP surface (LE-1018: reload). +"""Extension System HTTP surface: reload. Currently exposes a single endpoint, ``POST /extensions/{extension_id}/bundles/{bundle_name}/reload``, -which drives the LE-1018 atomic-swap pipeline against the process-default +which drives the atomic-swap reload pipeline against the process-default :class:`~lfx.extension.bundle_registry.BundleRegistry`. -LE-1019 (UI) and LE-1020 (migration) will add list / status / migrate -endpoints alongside this one. Mode A only -- in Mode B/C the path is to -rebuild the Docker image, and the runtime guard at the request layer -short-circuits with 404 when ``LANGFLOW_ENABLE_EXTENSION_RELOAD`` is off. +Future list / status / migrate endpoints will live alongside this one. +Mode A only -- in Mode B/C the path is to rebuild the Docker image, and +the runtime guard at the request layer short-circuits with 404 when +``LANGFLOW_ENABLE_EXTENSION_RELOAD`` is off. """ from __future__ import annotations diff --git a/src/backend/base/langflow/initial_setup/starter_projects/Structured Data Analysis Agent.json b/src/backend/base/langflow/initial_setup/starter_projects/Structured Data Analysis Agent.json index 2765bf58af..f5f1a21a33 100644 --- a/src/backend/base/langflow/initial_setup/starter_projects/Structured Data Analysis Agent.json +++ b/src/backend/base/langflow/initial_setup/starter_projects/Structured Data Analysis Agent.json @@ -5759,4 +5759,4 @@ "tags": [ "agents" ] -} +} \ No newline at end of file diff --git a/src/backend/base/langflow/plugin_routes.py b/src/backend/base/langflow/plugin_routes.py index bf8841a8cb..cb9253ce21 100644 --- a/src/backend/base/langflow/plugin_routes.py +++ b/src/backend/base/langflow/plugin_routes.py @@ -111,7 +111,7 @@ def load_plugin_routes(app: FastAPI) -> None: wrapper = _PluginAppWrapper(app, reserved) raw_eps = list(entry_points(group="langflow.plugins")) - # Manifest-first precedence (LE-1015): if a distribution ships an + # Manifest-first precedence: if a distribution ships an # extension manifest, its COMPONENT-typed entry-points are skipped here; # the Extension System loader registers those components via the # manifest. Non-component entry-points (route registrars like the ones diff --git a/src/backend/base/pyproject.toml b/src/backend/base/pyproject.toml index dd9202a3e7..95f49caefe 100644 --- a/src/backend/base/pyproject.toml +++ b/src/backend/base/pyproject.toml @@ -325,14 +325,14 @@ spider = ["spider-client>=0.0.27,<1.0.0"] # ALTK (Agent Lifecycle Toolkit) # Updated to 0.10.1+ for PyTorch 2.6.0 compatibility (addresses torch.load RCE vulnerability) # Upper bound prevents breaking changes in future major versions -# Excluded on macOS x86_64: PyTorch dropped Intel Mac wheel builds after v2.2.2 (see LE-172) +# Excluded on macOS x86_64: PyTorch dropped Intel Mac wheel builds after v2.2.2 altk = ["agent-lifecycle-toolkit>=0.10.1,<1.0; (sys_platform != 'darwin' or platform_machine != 'x86_64') and python_version < '3.14'"] # Toolguard Integration toolguard = ["toolguard>=0.2.16,<1.0.0"] # Additional LangChain integrations -# Excluded on macOS x86_64: transitive torch dependency (via sentence-transformers) has no Intel Mac wheels (see LE-172) +# Excluded on macOS x86_64: transitive torch dependency (via sentence-transformers) has no Intel Mac wheels langchain-huggingface = ["langchain-huggingface~=1.2.0; sys_platform != 'darwin' or platform_machine != 'x86_64'"] langchain-unstructured = ["langchain-unstructured~=1.0.0"] graph-retriever = ["langchain-graph-retriever==0.8.0", "graph-retriever==0.8.0"] @@ -430,8 +430,8 @@ complete = [ "langflow-base[google-api]", "langflow-base[wikipedia]", "langflow-base[fake-useragent]", - # duckduckgo was an extra of langflow-base; the LE-1023 pilot moved it - # to the standalone lfx-duckduckgo bundle (now a regular dep of langflow). + # duckduckgo was an extra of langflow-base; it has been moved to + # the standalone lfx-duckduckgo bundle (now a regular dep of langflow). "langflow-base[yfinance]", "langflow-base[wolframalpha]", "langflow-base[pyarrow]", diff --git a/src/backend/tests/unit/test_webhook.py b/src/backend/tests/unit/test_webhook.py index c936f00657..735e5ffd18 100644 --- a/src/backend/tests/unit/test_webhook.py +++ b/src/backend/tests/unit/test_webhook.py @@ -867,7 +867,7 @@ class TestWebhookEventsStreamAuth: ): """Regression test: cross-user access to /webhook-events/{id} returns 404, not 403. - This ensures no existence oracle (LE-639 compliance). + This ensures no existence oracle is exposed. """ flow_id = added_webhook_test["id"] endpoint = f"api/v1/webhook-events/{flow_id}" diff --git a/src/bundles/duckduckgo/M1_DOGFOOD_CHECKLIST.md b/src/bundles/duckduckgo/M1_DOGFOOD_CHECKLIST.md index 3194da034a..0456b3b538 100644 --- a/src/bundles/duckduckgo/M1_DOGFOOD_CHECKLIST.md +++ b/src/bundles/duckduckgo/M1_DOGFOOD_CHECKLIST.md @@ -1,11 +1,12 @@ # M1 dogfood checklist — `lfx-duckduckgo` pilot bundle -> **Status:** :hourglass: **OPEN** — the runtime half of LE-1023 has not been signed off. -> A non-Extension-team engineer must complete the steps below and paste the -> filled-in checklist into the PR before merge. Until that happens, the M1 -> proof-of-delivery gate is incomplete even if every automated check is green. +> **Status:** :hourglass: **OPEN** — the runtime half of the DuckDuckGo +> pilot has not been signed off. A non-Extension-team engineer must +> complete the steps below and paste the filled-in checklist into the +> PR before merge. Until that happens, the M1 proof-of-delivery gate is +> incomplete even if every automated check is green. -Per the [Bundle Separation Developer Guide §6 LE-1023], the M1 proof-of-delivery +Per the Bundle Separation Developer Guide, the M1 proof-of-delivery gate has two halves: 1. **Deserialize + build pipeline (automated)** — lives in @@ -36,7 +37,7 @@ look materially the same between the pre- and post-migration releases?" A unit-style integration test cannot answer that because it would have to span a Python environment swap and touch the live DuckDuckGo backend. A real dogfood run by an engineer who is *not* on the -Extension team is what actually closes LE-1023. +Extension team is what actually closes the DuckDuckGo pilot. The checklist below is intentionally a template — fill it in, do not edit it in advance. A pre-checked checklist is not evidence. @@ -95,10 +96,8 @@ in advance. A pre-checked checklist is not evidence. not "search results are deterministic." 6. **Sign-off.** - - [ ] Paste this completed checklist as a comment on the LE-1023 - ticket - - [ ] Cross-link the comment in the PR description under - "M1 dogfood evidence" + - [ ] Cross-link the completed checklist in the PR description + under "M1 dogfood evidence" --- diff --git a/src/bundles/duckduckgo/README.md b/src/bundles/duckduckgo/README.md index 34df9b6368..ea4a717386 100644 --- a/src/bundles/duckduckgo/README.md +++ b/src/bundles/duckduckgo/README.md @@ -2,10 +2,10 @@ DuckDuckGo Search component as a standalone Langflow Extension Bundle. -This is the LE-1023 (B1) pilot — the first provider extracted from -`lfx.components.` into a separate distribution. The bundle -ships a single component, `DuckDuckGoSearchComponent`, which performs -DuckDuckGo web searches via `langchain-community`. +This is the first provider extracted from `lfx.components.` +into a separate distribution. The bundle ships a single component, +`DuckDuckGoSearchComponent`, which performs DuckDuckGo web searches +via `langchain-community`. ## Install diff --git a/src/bundles/duckduckgo/pyproject.toml b/src/bundles/duckduckgo/pyproject.toml index fa342177e0..63458c9aa1 100644 --- a/src/bundles/duckduckgo/pyproject.toml +++ b/src/bundles/duckduckgo/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "lfx-duckduckgo" version = "0.1.0" -description = "DuckDuckGo Search component as a standalone Langflow Extension Bundle (LE-1023 pilot)." +description = "DuckDuckGo Search component as a standalone Langflow Extension Bundle." readme = "README.md" requires-python = ">=3.10,<3.15" license = { text = "MIT" } @@ -26,9 +26,11 @@ Documentation = "https://docs.langflow.org/extensions" Repository = "https://github.com/langflow-ai/langflow" # Manifest-shipping distributions are discovered via the -# ``langflow.extensions`` entry-point (LE-1022). The value is the dotted -# path to the package containing ``extension.json`` -- the loader walks -# ``importlib.metadata.files()`` from there to find the manifest. +# ``langflow.extensions`` entry-point. The value is the dotted path +# to the package containing ``extension.json`` -- the loader walks +# ``importlib.metadata.files()`` from there to find the manifest, and +# falls back to this entry-point for editable installs whose dist.files +# only surfaces dist-info entries. [project.entry-points."langflow.extensions"] lfx-duckduckgo = "lfx_duckduckgo" diff --git a/src/bundles/duckduckgo/src/lfx_duckduckgo/__init__.py b/src/bundles/duckduckgo/src/lfx_duckduckgo/__init__.py index 87e81abfd7..5b6405ada7 100644 --- a/src/bundles/duckduckgo/src/lfx_duckduckgo/__init__.py +++ b/src/bundles/duckduckgo/src/lfx_duckduckgo/__init__.py @@ -5,8 +5,8 @@ Langflow's loader discovers ``extension.json`` shipped alongside this ``__init__.py`` and registers ``DuckDuckGoSearchComponent`` under the namespaced ID ``ext:duckduckgo:DuckDuckGoSearchComponent@official``. -LE-1023 (B1) pilot: the first provider extracted from -``lfx.components.`` into a standalone Bundle. +The first provider extracted from ``lfx.components.`` into +a standalone Bundle. """ from lfx_duckduckgo.components.duckduckgo.duck_duck_go_search_run import ( diff --git a/src/bundles/duckduckgo/src/lfx_duckduckgo/extension.json b/src/bundles/duckduckgo/src/lfx_duckduckgo/extension.json index 71634bc672..11d165d1df 100644 --- a/src/bundles/duckduckgo/src/lfx_duckduckgo/extension.json +++ b/src/bundles/duckduckgo/src/lfx_duckduckgo/extension.json @@ -3,7 +3,7 @@ "id": "lfx-duckduckgo", "version": "0.1.0", "name": "DuckDuckGo Search", - "description": "DuckDuckGo Search component as a standalone Langflow Extension Bundle (LE-1023 pilot).", + "description": "DuckDuckGo Search component as a standalone Langflow Extension Bundle.", "lfx": { "compat": ["1"] }, diff --git a/src/frontend/src/utils/__tests__/cleanEdges.test.ts b/src/frontend/src/utils/__tests__/cleanEdges.test.ts index a9819cc819..283703c47f 100644 --- a/src/frontend/src/utils/__tests__/cleanEdges.test.ts +++ b/src/frontend/src/utils/__tests__/cleanEdges.test.ts @@ -518,8 +518,8 @@ describe("cleanEdges", () => { expect(result3.brokenEdges.length).toBe(1); }); - it("should preserve edge when EmbeddingModel has empty input_types and edge expects Embeddings (LE-278)", () => { - // This tests the fix for embedding model input type (LE-278) + it("should preserve edge when EmbeddingModel has empty input_types and edge expects Embeddings", () => { + // This tests the fix for embedding model input type const sourceNode: AllNodeType = { id: "EmbeddingModelComponent-123", type: "genericNode", diff --git a/src/lfx/src/lfx/cli/_extension_commands.py b/src/lfx/src/lfx/cli/_extension_commands.py index e05dd8629f..4be2a1d3df 100644 --- a/src/lfx/src/lfx/cli/_extension_commands.py +++ b/src/lfx/src/lfx/cli/_extension_commands.py @@ -138,9 +138,9 @@ def list_command( """Print every Extension currently visible to production discovery. Read-only. Mutation verbs (enable, disable, install, uninstall) are - intentionally absent in this milestone; they ship in B3/B4 follow-up - epics, and the router-trust CI guard (LE-1017) blocks them from - sneaking in via HTTP routes. + intentionally absent in this milestone; they ship in follow-up work, + and the router-trust CI guard blocks them from sneaking in via HTTP + routes. Output (text mode) is one row per Extension:: @@ -271,21 +271,21 @@ def list_command( @extension_app.command( name="reload", - help="Trigger an atomic-swap reload for an installed Bundle (LE-1018).", + help="Trigger an atomic-swap reload for an installed Bundle.", ) def reload_command( - extension_id: str = typer.Argument( - ..., - help="ID of the extension whose Bundle should be reloaded (e.g. lfx-pilot).", + extension_id: str | None = typer.Argument( + None, + help=("ID of the extension whose Bundle should be reloaded (e.g. lfx-arxiv). Omit when passing --all."), ), bundle: str | None = typer.Option( None, "--bundle", "-b", help=( - "Bundle name to reload. Required until the LE-1019 list endpoint " - "ships -- the conventional shape is extension id 'lfx-' " - "with bundle name '', so we cannot safely default." + "Bundle name to reload. Optional: when omitted, the bundle " + "name is resolved from the local extension discovery (the " + "same source ``lfx extension list`` uses)." ), ), target: str | None = typer.Option( @@ -308,44 +308,55 @@ def reload_command( reload_all: bool = typer.Option( False, # noqa: FBT003 - typer Option requires positional default "--all", - help=( - "Reload every installed Bundle. Requires the LE-1019 list endpoint and is not yet wired in this milestone." - ), + help="Reload every locally-discovered Bundle.", ), ) -> None: """POST to the reload endpoint and surface the typed result. + Resolution strategy: + * ``--all`` reloads every Bundle visible to local discovery + (``discover_all_extensions``); ``extension_id`` is ignored. + * Otherwise, ``extension_id`` is required. If ``--bundle`` is + omitted, the bundle name is looked up from local discovery. + * ``--bundle`` always wins when explicitly passed -- useful when + the local install isn't visible to the running server, or when + you want to target a bundle by name without re-running discovery. + Exit codes: - 0 -- reload succeeded and the registry now reflects the new code. - 1 -- reload failed (broken bundle, source missing, name mismatch, - or the dev server returned a typed error). - 2 -- argument error (e.g. ``--all`` requested before LE-1019 lands). + 0 -- reload(s) succeeded. + 1 -- one or more reloads failed (broken bundle, source missing, + name mismatch, transport error, or local discovery turned up + nothing for the requested extension). + 2 -- argument error (e.g. neither ``extension_id`` nor ``--all``). """ - from lfx.cli._extension_reload_client import reload_via_http - from lfx.extension.errors import ExtensionError, format_extension_error - - if reload_all: - typer.echo( - "extension reload --all requires the LE-1019 list endpoint and is not yet implemented in this milestone.", - err=True, - ) - raise typer.Exit(code=2) - if output_format not in {"text", "json"}: typer.echo("Invalid --format. Expected one of: text, json.", err=True) raise typer.Exit(code=2) - if not bundle: + if reload_all: + if extension_id is not None or bundle is not None: + typer.echo( + "extension reload --all does not take an extension id or --bundle; " + "pass only --all to reload every locally-discovered Bundle.", + err=True, + ) + raise typer.Exit(code=2) + _reload_all(target=target, api_key=api_key, output_format=output_format) + return + + if extension_id is None: typer.echo( - "extension reload requires --bundle until the LE-1019 list endpoint " - "ships. Pass --bundle ; conventional shape is " - "lfx- ext_id with as the bundle name.", + "extension reload requires an extension id (e.g. `lfx extension reload lfx-arxiv`), " + "or pass --all to reload every locally-discovered Bundle.", err=True, ) raise typer.Exit(code=2) - bundle_name = bundle - response = reload_via_http( + bundle_name = bundle or _resolve_bundle_from_discovery(extension_id) + if bundle_name is None: + raise typer.Exit(code=1) + + response = _post_reload( target=target, api_key=api_key, extension_id=extension_id, @@ -356,15 +367,75 @@ def reload_command( typer.echo(json.dumps(response.payload, indent=2, sort_keys=True)) raise typer.Exit(code=response.exit_code()) + _render_reload_text(response, extension_id=extension_id, bundle_name=bundle_name) + raise typer.Exit(code=response.exit_code()) + + +def _resolve_bundle_from_discovery(extension_id: str) -> str | None: + """Look up the single bundle name for ``extension_id`` via local discovery. + + Returns the bundle name on success, or ``None`` and prints a typed + error on stderr when the extension is not locally installed or when + discovery surfaces multiple bundles (a future-shape we do not yet + support; v0 ships one bundle per extension). + """ + from lfx.extension import discover_all_extensions + + extensions, _errors = discover_all_extensions() + matches = [ext for ext in extensions if ext.extension_id == extension_id] + if not matches: + typer.echo( + f"extension reload: no locally-discovered extension {extension_id!r}.\n" + " - Run `lfx extension list` to see what is installed locally.\n" + " - If the bundle is installed only on the remote server, " + "pass --bundle explicitly.", + err=True, + ) + return None + if len(matches) > 1: + # Can happen if two seed roots ship the same id; registry usually + # de-dupes via shadow-error, but be defensive at the CLI layer. + bundles = sorted({ext.bundle_name for ext in matches}) + typer.echo( + f"extension reload: extension {extension_id!r} resolves to multiple bundles " + f"({', '.join(bundles)}); pass --bundle to disambiguate.", + err=True, + ) + return None + return matches[0].bundle_name + + +def _post_reload( + *, + target: str | None, + api_key: str | None, + extension_id: str, + bundle_name: str, +): + """Thin wrapper around :func:`reload_via_http` for symmetry with --all.""" + from lfx.cli._extension_reload_client import reload_via_http + + return reload_via_http( + target=target, + api_key=api_key, + extension_id=extension_id, + bundle_name=bundle_name, + ) + + +def _render_reload_text(response, *, extension_id: str, bundle_name: str) -> None: + """Render a single reload response in text mode (success or failure).""" + from lfx.extension.errors import ExtensionError, format_extension_error + if response.ok: added = response.payload.get("components_added") or [] removed = response.payload.get("components_removed") or [] - typer.echo(f"reload: ok bundle={bundle_name}") + typer.echo(f"reload: ok extension={extension_id} bundle={bundle_name}") if added: typer.echo(f" added: {', '.join(added)}") if removed: typer.echo(f" removed: {', '.join(removed)}") - raise typer.Exit(code=0) + return # Failure path: render typed errors when present, fall back to status text. raw_errors = response.payload.get("errors") or [] @@ -397,10 +468,81 @@ def reload_command( rendered_any = True if not rendered_any: typer.echo( - f"reload: failed (HTTP {response.status} bundle={bundle_name})", + f"reload: failed (HTTP {response.status} extension={extension_id} bundle={bundle_name})", err=True, ) - raise typer.Exit(code=1) + + +def _reload_all(*, target: str | None, api_key: str | None, output_format: str) -> None: + """Reload every locally-discovered Bundle, aggregating results. + + Sequential by design: a parallel sweep would race for the registry's + per-bundle reload lock, and the cost of N sequential POSTs is dominated + by per-bundle import time on the server, not by round-trip latency. + + Exits 0 when every reload succeeds (or there were no bundles to reload); + exits 1 if any reload failed. + """ + from lfx.extension import discover_all_extensions + + extensions, _errors = discover_all_extensions() + + if output_format == "json": + results = [] + any_failed = False + for ext in extensions: + response = _post_reload( + target=target, + api_key=api_key, + extension_id=ext.extension_id, + bundle_name=ext.bundle_name, + ) + results.append( + { + "extension_id": ext.extension_id, + "bundle_name": ext.bundle_name, + "status": response.status, + "ok": response.ok, + "payload": response.payload, + } + ) + if not response.ok: + any_failed = True + typer.echo(json.dumps({"results": results}, indent=2, sort_keys=True)) + raise typer.Exit(code=1 if any_failed else 0) + + if not extensions: + typer.echo( + "extension reload --all: no locally-discovered bundles to reload.\n" + " - Run `lfx extension list` to confirm what is installed.", + ) + return + + any_failed = False + successes = 0 + failures = 0 + for ext in extensions: + response = _post_reload( + target=target, + api_key=api_key, + extension_id=ext.extension_id, + bundle_name=ext.bundle_name, + ) + _render_reload_text( + response, + extension_id=ext.extension_id, + bundle_name=ext.bundle_name, + ) + if response.ok: + successes += 1 + else: + failures += 1 + any_failed = True + + typer.echo("") + typer.echo(f"extension reload --all: {successes} ok, {failures} failed.") + if any_failed: + raise typer.Exit(code=1) @extension_app.command( @@ -475,7 +617,7 @@ def init_command( ) -> None: """Create a runnable extension skeleton you can iterate on with ``dev``. - Acceptance criteria for this command (LE-1016): + Acceptance criteria for this command: - ``init `` followed immediately by ``validate `` passes with zero errors. diff --git a/src/lfx/src/lfx/cli/_extension_reload_client.py b/src/lfx/src/lfx/cli/_extension_reload_client.py index 7d09ca2e3b..04c92a2b02 100644 --- a/src/lfx/src/lfx/cli/_extension_reload_client.py +++ b/src/lfx/src/lfx/cli/_extension_reload_client.py @@ -1,4 +1,4 @@ -"""HTTP client helper for ``lfx extension reload`` (LE-1018). +"""HTTP client helper for ``lfx extension reload``. Kept separate from the typer command so the request/response shape can be unit-tested without spinning up a full CLI invocation. The CLI command diff --git a/src/lfx/src/lfx/extension/bundle_registry.py b/src/lfx/src/lfx/extension/bundle_registry.py index 4373577726..ca8537d91b 100644 --- a/src/lfx/src/lfx/extension/bundle_registry.py +++ b/src/lfx/src/lfx/extension/bundle_registry.py @@ -1,4 +1,4 @@ -"""In-memory component registry for installed Bundles (LE-1018). +"""In-memory component registry for installed Bundles. The :class:`BundleRegistry` is the single piece of mutable shared state the Extension System owns at runtime. It maps a Bundle name to a frozen record @@ -35,8 +35,8 @@ Slot semantics The registry stores both ``official`` and ``extra`` slot bundles in the same map keyed by bundle name. Bundle names are unique across slots in -v0; the LE-1015 loader and ``discover_inline_bundles`` both enforce this -upstream so the registry does not need to disambiguate by slot. +v0; the component loader and ``discover_inline_bundles`` both enforce +this upstream so the registry does not need to disambiguate by slot. """ from __future__ import annotations @@ -268,8 +268,8 @@ class BundleRegistry: Tolerates filesystem errors silently: the index file is a cache, not a source of truth, and a transient write failure must not - abort an otherwise-successful registry mutation. The CLI's - ``extension status`` (LE-1019 territory) surfaces stale indexes. + abort an otherwise-successful registry mutation. A future + ``extension status`` CLI surfaces stale indexes. """ if self._index_path is None: return @@ -320,9 +320,9 @@ def get_default_registry() -> BundleRegistry: """Return the lazily-created process-wide registry. The HTTP endpoint and CLI both target this registry by default; tests - construct fresh registries directly so they do not bleed state. LE-1022 - will replace this lazy initializer with a startup-time install at the - @official slot; until then the registry begins empty. + construct fresh registries directly so they do not bleed state. A + future startup-time install at the @official slot will replace this + lazy initializer; until then the registry begins empty. """ global _default_registry # noqa: PLW0603 with _default_registry_lock: diff --git a/src/lfx/src/lfx/extension/dev_registry.py b/src/lfx/src/lfx/extension/dev_registry.py index f8ef878d44..e4596c4d10 100644 --- a/src/lfx/src/lfx/extension/dev_registry.py +++ b/src/lfx/src/lfx/extension/dev_registry.py @@ -1,4 +1,4 @@ -"""Local dev-extension registry for ``lfx extension dev`` (LE-1016). +"""Local dev-extension registry for ``lfx extension dev``. When an author types ``lfx extension dev ``, we don't ship the Extension via pip; instead we record the absolute path in a small JSON @@ -25,16 +25,17 @@ Concurrency: the state file is rewritten atomically (tempfile + rename). We do not take a long-lived lock; the file is read-mostly and the only writer is the ``extension dev`` CLI invoked synchronously by a developer. -Scope notes (kept here so the next milestone reads them): +Scope notes: - - LE-1016 only writes / reads / lists the state file and is - consumed by Langflow's startup hook. The runtime "remove" path - (``extension dev --unregister``) is a deliberate non-goal for v0; - authors clean up by deleting the state file or the directory. - - LE-1018 (reload) reuses :func:`load_dev_extensions` to refresh + - This module only writes / reads / lists the state file; the actual + registration is consumed by Langflow's startup hook. The runtime + "remove" path (``extension dev --unregister``) is a deliberate + non-goal for v0; authors clean up by deleting the state file or + the directory. + - The reload pipeline reuses :func:`load_dev_extensions` to refresh the @official slot mid-run; the reload UX itself ships there. - - LE-1022 (installed-package discovery) keeps its own primitives - and is orthogonal -- both flows produce ``LoadResult`` lists that + - Installed-package discovery keeps its own primitives and is + orthogonal -- both flows produce ``LoadResult`` lists that Langflow startup merges in the same step. """ @@ -117,8 +118,8 @@ class DevExtensionEntry: path: Path registered_at: str """ISO-8601 UTC timestamp ('YYYY-MM-DDTHH:MM:SSZ') of when this entry - was added. Surfaced for ``extension list`` (LE-1022) so authors can - audit what's registered.""" + was added. Surfaced for ``extension list`` so authors can audit + what's registered.""" def _utcnow_iso() -> str: @@ -314,9 +315,9 @@ def load_dev_extensions(*, state_dir: Path | None = None) -> list[LoadResult]: Missing entries (the directory was renamed, deleted, or moved) are surfaced as :class:`LoadResult` instances containing a single - ``local-extension-missing`` warning so the events pipeline (LE-1017) - can report them without aborting startup. The author sees a - typed warning rather than a stack trace. + ``local-extension-missing`` warning so the events pipeline can + report them without aborting startup. The author sees a typed + warning rather than a stack trace. The dev registry is never silently mutated by this function: stale entries stay in the file so the author can fix the underlying path diff --git a/src/lfx/src/lfx/extension/discovery.py b/src/lfx/src/lfx/extension/discovery.py index eece713c5c..2ff5c57b9b 100644 --- a/src/lfx/src/lfx/extension/discovery.py +++ b/src/lfx/src/lfx/extension/discovery.py @@ -1,4 +1,4 @@ -"""Production-install discovery for the Langflow Extension System (LE-1022). +"""Production-install discovery for the Langflow Extension System. This module owns the two production install sources for Modes A, B, and C: @@ -22,7 +22,7 @@ what actually pins these into the runtime; ``register_installed`` and ``register_seed`` consume the same :class:`DiscoveredExtension` shape. This is the production install path, not the loading path. Component -loading (LE-1015) is downstream of this; the Extension records here are +loading is downstream of this; the Extension records here are what the loader ultimately resolves to a concrete Bundle directory. Errors are surfaced as :class:`~lfx.extension.errors.ExtensionError` @@ -213,43 +213,112 @@ def _distribution_manifest_path(dist: importlib_metadata.Distribution) -> Path | if present; otherwise we fall back to a ``pyproject.toml`` that declares a ``[tool.langflow.extension]`` table. + For editable installs whose ``dist.files`` only surfaces ``dist-info/`` + entries (the ``pip install -e`` / ``uv pip install -e`` case), we fall + back to the ``langflow.extensions`` entry-point group declared in the + distribution's ``pyproject.toml``. + A missing or unreadable file iteration is treated as "no manifest" rather than as an error: the caller's scan should keep walking. """ files = dist.files - if files is None: + if files is not None: + pyproject_candidate: Path | None = None + for relative in files: + if not relative.parts: + continue + last = relative.parts[-1] + if last == "extension.json": + try: + located = Path(dist.locate_file(relative)) + except (OSError, ValueError): + continue + if located.is_file(): + return located + elif last == "pyproject.toml" and pyproject_candidate is None: + try: + located = Path(dist.locate_file(relative)) + except (OSError, ValueError): + continue + if located.is_file(): + pyproject_candidate = located + + if pyproject_candidate is not None: + try: + if _pyproject_declares_extension(pyproject_candidate): + return pyproject_candidate + except OSError: + # Unreadable pyproject that might declare an extension. Surface + # the candidate path so the downstream manifest-unreadable check + # fires with an actionable error instead of silently dropping + # the distribution. + return pyproject_candidate + + return _distribution_manifest_path_via_entry_points(dist) + + +def _distribution_manifest_path_via_entry_points( + dist: importlib_metadata.Distribution, +) -> Path | None: + """Resolve the manifest of an editable install via its entry-point. + + Editable installs (``pip install -e``, ``uv pip install -e``) record only + ``dist-info/`` entries in ``dist.files``: the package's source tree lives + behind a ``.pth`` file rather than under a wheel-installed directory, so + the ``files`` scan above never sees ``extension.json``. The + ``langflow.extensions`` entry-point points at the package that ships the + manifest; we resolve it via :func:`importlib.util.find_spec` (which runs + the import-system finders but **never executes the module's body**) and + look for the manifest in the resulting package directory. + + Returns ``None`` when the distribution has no usable entry-point, the + referenced module cannot be located on ``sys.path``, or the located + directory does not contain a manifest. + """ + import importlib.util + + try: + eps = dist.entry_points + except (OSError, AttributeError, TypeError): + return None + if eps is None: return None - pyproject_candidate: Path | None = None - for relative in files: - if not relative.parts: - continue - last = relative.parts[-1] - if last == "extension.json": - try: - located = Path(dist.locate_file(relative)) - except (OSError, ValueError): - continue - if located.is_file(): - return located - elif last == "pyproject.toml" and pyproject_candidate is None: - try: - located = Path(dist.locate_file(relative)) - except (OSError, ValueError): - continue - if located.is_file(): - pyproject_candidate = located + try: + selected = list(eps.select(group="langflow.extensions")) + except AttributeError: + # Older importlib.metadata returns a plain tuple of EntryPoint with + # no .select() helper; filter manually. + selected = [ep for ep in eps if getattr(ep, "group", None) == "langflow.extensions"] - if pyproject_candidate is not None: + for ep in selected: + module_name = (getattr(ep, "value", "") or "").split(":", 1)[0].strip() + if not module_name: + continue try: - if _pyproject_declares_extension(pyproject_candidate): - return pyproject_candidate - except OSError: - # Unreadable pyproject that might declare an extension. Surface - # the candidate path so the downstream manifest-unreadable check - # fires with an actionable error instead of silently dropping - # the distribution. - return pyproject_candidate + spec = importlib.util.find_spec(module_name) + except (ImportError, ValueError, ModuleNotFoundError): + continue + if spec is None: + continue + + candidate_dirs: list[Path] = [] + if spec.submodule_search_locations: + candidate_dirs.extend(Path(loc) for loc in spec.submodule_search_locations) + elif spec.origin and spec.origin not in {"built-in", "frozen"}: + candidate_dirs.append(Path(spec.origin).parent) + + for package_dir in candidate_dirs: + extension_json = package_dir / "extension.json" + if extension_json.is_file(): + return extension_json + pyproject = package_dir / "pyproject.toml" + if pyproject.is_file(): + try: + if _pyproject_declares_extension(pyproject): + return pyproject + except OSError: + return pyproject return None diff --git a/src/lfx/src/lfx/extension/errors.py b/src/lfx/src/lfx/extension/errors.py index 912e1c9e95..8bfff61dd4 100644 --- a/src/lfx/src/lfx/extension/errors.py +++ b/src/lfx/src/lfx/extension/errors.py @@ -70,7 +70,7 @@ ERROR_CODES: frozenset[str] = frozenset( "inline-path-missing", "inline-path-unreadable", "bundle-json-invalid", - # init / dev CLI codes (LE-1016) + # init / dev CLI codes "extension-target-exists", "extension-target-invalid", "local-extension-missing", @@ -80,14 +80,14 @@ ERROR_CODES: frozenset[str] = frozenset( "migration-table-invalid", "component-not-found-with-hint", "component-name-ambiguous", - # Production install / discovery (LE-1022) + # Production install / discovery "installed-extension-immutable", "seed-directory-immutable", "seed-directory-not-found", "seed-bundle-shadowed", "bundle-shadowed", "duplicate-extension-id", - # Reload-specific codes (LE-1018) + # Reload-specific codes "reload-in-progress", "reload-bundle-not-installed", "reload-bundle-name-mismatch", diff --git a/src/lfx/src/lfx/extension/init_template.py b/src/lfx/src/lfx/extension/init_template.py index 4e47312eec..12ffa7aaae 100644 --- a/src/lfx/src/lfx/extension/init_template.py +++ b/src/lfx/src/lfx/extension/init_template.py @@ -1,4 +1,4 @@ -"""Scaffolder for ``lfx extension init`` (LE-1016). +"""Scaffolder for ``lfx extension init``. Writes the *basic* single-Bundle template at the requested directory: @@ -21,9 +21,9 @@ non-trivial lives here so the CLI is a thin shell. Design constraints honored: - - The generated extension MUST validate clean against the LE-1014 - validator with zero errors. The basic template is exercised by an - AC-level test that runs ``init`` then immediately ``validate``. + - The generated extension MUST validate clean against the manifest + validator with zero errors. The basic template is exercised by a + test that runs ``init`` then immediately ``validate``. - The generated test file MUST be syntactically valid Python and written so a future ``extension test`` runner can collect and execute it without modification. The test file uses ``pytest`` @@ -276,7 +276,7 @@ A Langflow Extension generated by `lfx extension init`. ``` {options.target.name}/ -├── extension.json # v0 manifest (LE-1014) +├── extension.json # v0 manifest ├── README.md ├── components/ │ └── {options.bundle_name}/ diff --git a/src/lfx/src/lfx/extension/loader/_discovery.py b/src/lfx/src/lfx/extension/loader/_discovery.py index 712ceb9498..619c25f003 100644 --- a/src/lfx/src/lfx/extension/loader/_discovery.py +++ b/src/lfx/src/lfx/extension/loader/_discovery.py @@ -97,7 +97,7 @@ def iter_bundle_python_files(bundle_root: Path) -> Iterator[Path]: DEFAULT_MODULE_NAMESPACE: str = "_lfx_ext" """Default top-level package name for bundle modules in ``sys.modules``. -Reload (LE-1018) overrides this with ``__reload_staging__.`` so a +Reload overrides this with ``__reload_staging__.`` so a parallel load lands in an isolated namespace and Stage 3 can swap it in atomically without ever touching the live modules. """ @@ -117,7 +117,7 @@ def module_name_for( the leading underscore-prefixed package keeps these out of the regular import namespace so a bundle named ``json`` doesn't shadow the stdlib. Reload supplies a ``__reload_staging__.`` namespace so Stage 1 lands - in an isolated subtree of ``sys.modules`` (LE-1018). + in an isolated subtree of ``sys.modules``. """ rel = file_path.relative_to(bundle_root).with_suffix("") dotted = ".".join(rel.parts) @@ -153,8 +153,8 @@ def import_bundle_module(module_name: str, file_path: Path) -> tuple[types.Modul # entry under the same synthetic ``_lfx_ext.....`` name # with no cleanup. Consumers holding a previous LoadedComponent.klass # keep the old class object across reloads, so isinstance checks against - # newly-loaded instances will return False. LE-1018 reload owns the - # invalidation: it must scrub registry entries (and the matching + # newly-loaded instances will return False. The reload pipeline owns + # the invalidation: it must scrub registry entries (and the matching # ``_lfx_ext...*`` sys.modules namespace) before calling # back into this loader. Absolute imports only between bundle modules; # relative imports unsupported. diff --git a/src/lfx/src/lfx/extension/loader/_orchestrator.py b/src/lfx/src/lfx/extension/loader/_orchestrator.py index 8bdbf0a7de..de7fab6dfd 100644 --- a/src/lfx/src/lfx/extension/loader/_orchestrator.py +++ b/src/lfx/src/lfx/extension/loader/_orchestrator.py @@ -278,9 +278,9 @@ def load_extension( tree before pip install). module_namespace: Top-level package name used when registering bundle modules in ``sys.modules``. Defaults to ``_lfx_ext`` for normal - loads. Reload (LE-1018) passes ``__reload_staging__.`` so - Stage 1 lands in an isolated namespace; do not override this in - normal application code. + loads. The reload pipeline passes ``__reload_staging__.`` + so Stage 1 lands in an isolated namespace; do not override + this in normal application code. Returns: A :class:`LoadResult`. ``ok`` is False on any structural failure; @@ -291,8 +291,8 @@ def load_extension( Calling this function a second time for the same bundle overwrites the prior bundle's entries in ``sys.modules`` with no cleanup; previously-issued :class:`LoadedComponent` ``klass`` references - remain bound to the old class objects. The reload pipeline (LE-1018) - is responsible for scrubbing registry entries AND the matching + remain bound to the old class objects. The reload pipeline is + responsible for scrubbing registry entries AND the matching ``_lfx_ext...*`` namespace before re-invoking this loader; direct callers should not rely on this function to refresh already-loaded bundles. diff --git a/src/lfx/src/lfx/extension/loader/_types.py b/src/lfx/src/lfx/extension/loader/_types.py index fbd7dd9846..e1beb9a2f2 100644 --- a/src/lfx/src/lfx/extension/loader/_types.py +++ b/src/lfx/src/lfx/extension/loader/_types.py @@ -52,11 +52,11 @@ class LoadedComponent: folders from ``LANGFLOW_COMPONENTS_PATH``, never pip-installed. The reverse (``@official`` with ``distribution=None``) is permitted because ``load_extension`` is used for dev-mode loads against a working tree - *before* the package gets installed (the LE-1016 ``extension dev`` - flow); only ``load_installed_extensions`` is required to carry a - canonical PEP-503 name through. Enforced in :meth:`__post_init__` so - the rule travels with the type when LE-1018 reload and LE-1022 startup - discovery start constructing :class:`LoadedComponent` directly. + *before* the package gets installed (the ``extension dev`` flow); + only ``load_installed_extensions`` is required to carry a canonical + PEP-503 name through. Enforced in :meth:`__post_init__` so the rule + travels with the type when reload and startup discovery construct + :class:`LoadedComponent` directly. """ extension_id: str @@ -116,7 +116,7 @@ class LoadResult: successfully-registered classes from sibling files). Callers that only branch on ``ok`` get strict success; callers that want to surface "partial X of Y loaded" diagnostics should consume ``components`` and - ``errors`` together. The events pipeline (LE-1017) is expected to fan + ``errors`` together. The future events pipeline is expected to fan out as: ``extension_loaded`` when ``ok``, ``extension_error`` when not, plus per-component ``component_registered`` events for everything in ``components`` regardless of ``ok``. diff --git a/src/lfx/src/lfx/extension/migration/__init__.py b/src/lfx/src/lfx/extension/migration/__init__.py index 6756625f20..e62df78a4c 100644 --- a/src/lfx/src/lfx/extension/migration/__init__.py +++ b/src/lfx/src/lfx/extension/migration/__init__.py @@ -17,8 +17,8 @@ Public surface: - :func:`load_migration_table` -- read and validate the canonical JSON. - :func:`migrate_flow_payload` -- rewrite a flow payload in place. - :class:`MigrationTable` / :class:`MigrationEntry` -- Pydantic models. - - :class:`MigrationReport` -- per-node rewrite outcomes; will feed the - ``flow-migrated`` event once :data:`LE-1017` lands. + - :class:`MigrationReport` -- per-node rewrite outcomes; will feed + the ``flow-migrated`` event once the events pipeline lands. """ from lfx.extension.migration.loader import ( diff --git a/src/lfx/src/lfx/extension/migration/rewrite.py b/src/lfx/src/lfx/extension/migration/rewrite.py index 2008fd7983..881557230c 100644 --- a/src/lfx/src/lfx/extension/migration/rewrite.py +++ b/src/lfx/src/lfx/extension/migration/rewrite.py @@ -260,11 +260,11 @@ def _rewrite_one_node( # the ambiguous-bare-names list. A bare class name that exists in # 2+ bundles cannot have a regular auto-rewrite entry (the CI guard # rejects it), so its only surface is the explicit ambiguity - # marker. Surfacing ``component-name-ambiguous`` here -- with the - # candidate targets enumerated -- is the LE-1020 contract: + # marker. Surfacing ``component-name-ambiguous`` here -- with + # the candidate targets enumerated -- is the migration contract: # we will not silently load an ambiguous name into the wrong - # bundle, and we tell the operator exactly which targets they have - # to choose between. + # bundle, and we tell the operator exactly which targets they + # have to choose between. ambig_marker = table.lookup_ambiguous_bare(legacy_value) if ambig_marker is not None: target_list = ", ".join(ambig_marker.candidates) @@ -408,7 +408,7 @@ def migrate_flow_payload( if record.error is not None: report.errors.append(record.error) - # TODO(LE-1017): when the events pipeline (ExtensionEventsService) lands, + # TODO: when the events pipeline (ExtensionEventsService) lands, # emit a single ``flow-migrated`` event per flow per session here when # ``report.any_rewritten`` is True. The event payload should be: # { diff --git a/src/lfx/src/lfx/extension/migration/schema.py b/src/lfx/src/lfx/extension/migration/schema.py index 1a60934f41..1f9ba270cf 100644 --- a/src/lfx/src/lfx/extension/migration/schema.py +++ b/src/lfx/src/lfx/extension/migration/schema.py @@ -13,7 +13,7 @@ legacy reference shapes are mapped: 3. ``bare_class_name`` -- the unqualified class name (``OpenAIEmbeddings``). Added only if the class name is globally unique across every Bundle in this Langflow release; CI guards this with - ``scripts/migrate/check_bare_names.py`` (LE-1023). + ``scripts/migrate/check_bare_names.py``. The table is a single JSON file at a canonical in-repo path. Every Langflow release adds entries; **no entry is ever removed**. CI rejects removals so a diff --git a/src/lfx/src/lfx/extension/registry.py b/src/lfx/src/lfx/extension/registry.py index 90d559318c..775f44f219 100644 --- a/src/lfx/src/lfx/extension/registry.py +++ b/src/lfx/src/lfx/extension/registry.py @@ -1,4 +1,4 @@ -"""Read-only Extension registry for production-install sources (LE-1022). +"""Read-only Extension registry for production-install sources. Where :mod:`lfx.extension.discovery` *finds* manifest-shipping packages and seed directories, this module *registers* them: it owns the in-memory @@ -24,10 +24,9 @@ update) raises :class:`ExtensionImmutableError` carrying a typed ``installed-extension-immutable`` or ``seed-directory-immutable``. Mutation verbs are exposed at the *service* layer here so the invariant -is testable today (LE-1022 acceptance), but the **CLI** uninstall surface -ships in B4 follow-up. Routes that try to mount these as HTTP handlers -are blocked by the router-trust CI guard (LE-1017); see -``langflow-extension-system-design.md`` for the full trust model. +is testable today, but the **CLI** uninstall surface is a follow-up. +Routes that try to mount these as HTTP handlers are blocked by the +router-trust CI guard. """ from __future__ import annotations @@ -55,11 +54,11 @@ if TYPE_CHECKING: class LoadStatus(str, Enum): """Lifecycle state surfaced through ``lfx extension list``. - Discovery alone produces ``DISCOVERED``; the loader (LE-1015) flips + Discovery alone produces ``DISCOVERED``; the component loader flips entries to ``LOADED`` once their components register, and to ``FAILED`` on any per-bundle import error. Keeping the enum here -- rather than in the loader -- means ``extension list`` can render the - full life-cycle without a circular import once both subsystems land. + full life-cycle without a circular import. """ DISCOVERED = "discovered" @@ -276,7 +275,7 @@ class ExtensionRegistry: def mark_loaded(self, extension_id: str) -> Extension: """Flip an entry's :attr:`load_status` to ``LOADED``. - Used by the loader (LE-1015) once a bundle's components have + Used by the component loader once a bundle's components have successfully registered. This is *not* a mutation of the immutability surface -- the install record itself is unchanged; we're recording the side effect of loading the install. @@ -345,7 +344,7 @@ class ExtensionRegistry: Production install in this milestone is ``pip install`` in a Dockerfile, never a runtime call. The router-trust CI guard - (LE-1017) blocks any HTTP handler that tries to mount this verb. + blocks any HTTP handler that tries to mount this verb. """ self._refuse_mutation(extension_id, verb="install") diff --git a/src/lfx/src/lfx/extension/reload.py b/src/lfx/src/lfx/extension/reload.py index 4b0c451d49..78e2f706d3 100644 --- a/src/lfx/src/lfx/extension/reload.py +++ b/src/lfx/src/lfx/extension/reload.py @@ -1,4 +1,4 @@ -"""Atomic-swap Bundle reload pipeline (LE-1018). +"""Atomic-swap Bundle reload pipeline. The :func:`reload_bundle` function is the single entry point for replacing a Bundle's component set in-place under load. It runs five clearly-marked @@ -9,14 +9,14 @@ The five stages --------------- 1. **Stage 1 -- parallel load** into a per-reload staging namespace - (``__reload_staging__.``). Uses the LE-1015 loader with + (``__reload_staging__.``). Uses the component loader with ``module_namespace`` overridden so that the new modules land in ``sys.modules`` without colliding with the live ``_lfx_ext.*`` entries. 2. **Stage 2 -- validate** the staging result. Any error from the loader (manifest invalid, import failure, duplicate class names, etc.) aborts the reload here. The live registry is untouched and a - ``bundle_reload_failed`` event is emitted (currently stubbed; LE-1017). + ``bundle_reload_failed`` event is emitted (currently stubbed). 3. **Stage 3 -- atomic swap** under the registry's write lock. This is the only stage that mutates shared state. Old ``sys.modules`` entries @@ -30,8 +30,7 @@ The five stages 5. **Stage 5 -- emit** a ``bundle_reloaded`` event with the ``components_added`` / ``components_removed`` deltas. Currently a - structured-log shim; LE-1017 will swap the body to the real - ``ExtensionEventsService``. + structured-log shim; a future ``ExtensionEventsService`` will own this. Concurrency invariants ---------------------- @@ -58,7 +57,8 @@ This pipeline only applies to Mode A (in-process Python install). In Mode B/C the bundle code lives in a different container image; reload there means rebuilding the image, not calling this function. The HTTP endpoint and CLI both gate on Mode A; if you find yourself reaching into -this module from a non-Mode-A path, stop and re-read LE-905 first. +this module from a non-Mode-A path, stop and re-read the Bundle +Separation design notes first. """ from __future__ import annotations @@ -586,18 +586,19 @@ def _failure( # --------------------------------------------------------------------------- -# TODO(LE-1017): replace this stub with a call to -# ExtensionEventsService.emit(...) once the events pipeline ticket lands. -# The shape of the payload below is what the events service will consume: -# the ReloadResult is serializable and carries everything needed for the -# bundle_reloaded / bundle_reload_failed discriminants. +# TODO: replace this stub with a call to ExtensionEventsService.emit(...) +# once the events pipeline lands. The shape of the payload below is what +# the events service will consume: the ReloadResult is serializable and +# carries everything needed for the bundle_reloaded / bundle_reload_failed +# discriminants. def _emit_bundle_reload_event(result: ReloadResult) -> None: - """Stub for the LE-1017 events pipeline. + """Stub for the extension events pipeline. - Currently logs a structured event; LE-1017 will swap the body to - ``ExtensionEventsService.emit("bundle_reloaded" | "bundle_reload_failed", payload)``. - Tests can monkey-patch this symbol to capture emissions without - waiting for the events service to land. + Currently logs a structured event; a future ``ExtensionEventsService`` + will swap the body to ``.emit("bundle_reloaded" | + "bundle_reload_failed", payload)``. Tests can monkey-patch this + symbol to capture emissions without waiting for the events service + to land. """ if result.ok: logger.info( diff --git a/src/lfx/src/lfx/graph/graph/base.py b/src/lfx/src/lfx/graph/graph/base.py index 739b112120..6e9e7954c2 100644 --- a/src/lfx/src/lfx/graph/graph/base.py +++ b/src/lfx/src/lfx/graph/graph/base.py @@ -1206,7 +1206,7 @@ class Graph: migration_error.hint, migration_error.message, ) - # TODO(LE-1017): when the extension events pipeline lands, emit a + # TODO: when the extension events pipeline lands, emit a # single ``flow-migrated`` event per flow per session here using # ExtensionEventsService, plus one event per ``ExtensionError`` in # ``migration_report.errors`` so the frontend can surface the diff --git a/src/lfx/src/lfx/interface/components.py b/src/lfx/src/lfx/interface/components.py index 7206627474..7eb388eae4 100644 --- a/src/lfx/src/lfx/interface/components.py +++ b/src/lfx/src/lfx/interface/components.py @@ -707,9 +707,9 @@ def _decorate_template_with_extension( def _emit_extension_diagnostics(results: list[LoadResult]) -> None: """Surface typed errors/warnings from a batch of LoadResults to the logger. - The events pipeline (LE-1017) will replace this with structured emission; - until then we want operators to see what the loader rejected without - silently dropping the typed payload. + The future events pipeline will replace this with structured + emission; until then we want operators to see what the loader + rejected without silently dropping the typed payload. """ for result in results: for err in result.errors: @@ -962,9 +962,9 @@ async def import_extension_components( exc, ) continue - # Register under the namespaced ID per AC: ``ext::@``. - # This is the canonical address saved flows reference after the - # LE-1020 migration table rewrites legacy class-name lookups. + # Register under the namespaced ID: ``ext::@``. + # This is the canonical address saved flows reference after + # the migration table rewrites legacy class-name lookups. bundle_dict[loaded.namespaced_id] = _decorate_template_with_extension( template, extension_id=loaded.extension_id, diff --git a/src/lfx/src/lfx/services/settings/base.py b/src/lfx/src/lfx/services/settings/base.py index 74a18dfb2e..5d88a36510 100644 --- a/src/lfx/src/lfx/services/settings/base.py +++ b/src/lfx/src/lfx/services/settings/base.py @@ -745,7 +745,7 @@ class Settings(BaseSettings): env_value = os.getenv("LANGFLOW_COMPONENTS_PATH") if env_value: logger.debug("Adding LANGFLOW_COMPONENTS_PATH to components_path") - # LE-1015: split on os.pathsep so multi-entry env vars + # Split on os.pathsep so multi-entry env vars # ("/path/A:/path/B" on POSIX, "C:\\a;D:\\b" on Windows) are # parsed as multiple components paths instead of one literal # non-existent path. Empty segments (e.g. trailing pathsep) are diff --git a/src/lfx/tests/integration/extension/test_pilot_arxiv_upgrade.py b/src/lfx/tests/integration/extension/test_pilot_arxiv_upgrade.py index be3c94d30b..2c572298ec 100644 --- a/src/lfx/tests/integration/extension/test_pilot_arxiv_upgrade.py +++ b/src/lfx/tests/integration/extension/test_pilot_arxiv_upgrade.py @@ -140,10 +140,10 @@ def _is_editable_install(dist: importlib_metadata.Distribution) -> bool: def test_lfx_arxiv_ships_manifest() -> None: """``importlib.metadata`` can find ``extension.json`` for the installed dist. - This is the contract LE-1022's :func:`load_installed_extensions` reads - at server startup; if the wheel doesn't include the manifest, the - bundle never registers and the AC ("pip install langflow still pulls - in the pilot bundle as before") fails silently. + This is the contract :func:`load_installed_extensions` reads at + server startup; if the wheel doesn't include the manifest, the bundle + never registers and ``pip install langflow`` silently fails to pull + in the pilot bundle. """ try: dist = importlib_metadata.distribution("lfx-arxiv") diff --git a/src/lfx/tests/integration/extension/test_pilot_duckduckgo_upgrade.py b/src/lfx/tests/integration/extension/test_pilot_duckduckgo_upgrade.py index e5324be244..1ddc546edc 100644 --- a/src/lfx/tests/integration/extension/test_pilot_duckduckgo_upgrade.py +++ b/src/lfx/tests/integration/extension/test_pilot_duckduckgo_upgrade.py @@ -1,4 +1,4 @@ -"""Integration test for the LE-1023 pilot: legacy DuckDuckGo flows upgrade cleanly. +"""Integration test for the DuckDuckGo pilot: legacy flows upgrade cleanly. Verifies the save/upgrade/load contract for flows referencing ``DuckDuckGoSearchComponent`` from the pre-extraction Langflow: @@ -400,10 +400,10 @@ def _is_editable_install(dist: importlib_metadata.Distribution) -> bool: def test_lfx_duckduckgo_ships_manifest() -> None: """``importlib.metadata`` can find ``extension.json`` for the installed dist. - This is the contract LE-1022's :func:`load_installed_extensions` reads - at server startup; if the wheel doesn't include the manifest, the - bundle never registers and the AC ("pip install langflow still pulls - in the pilot bundle as before") fails silently. + This is the contract :func:`load_installed_extensions` reads at + server startup; if the wheel doesn't include the manifest, the bundle + never registers and ``pip install langflow`` silently fails to pull + in the pilot bundle. Editable installs (``pip install -e``) hide package files from ``dist.files`` -- only ``dist-info`` entries appear -- so for editable @@ -431,7 +431,7 @@ def test_lfx_duckduckgo_ships_manifest() -> None: ) else: # Non-editable wheel install: assert dist.files surfaces the manifest - # (the path LE-1022's loader walks at runtime). + # (the path the loader walks at runtime). files = dist.files or [] manifests = [f for f in files if f.parts and f.parts[-1] == "extension.json"] assert manifests, ( diff --git a/src/lfx/tests/integration/extension/test_pip_install_discovery.py b/src/lfx/tests/integration/extension/test_pip_install_discovery.py index d174ac5853..1e68e0f19a 100644 --- a/src/lfx/tests/integration/extension/test_pip_install_discovery.py +++ b/src/lfx/tests/integration/extension/test_pip_install_discovery.py @@ -3,10 +3,9 @@ ``pip install`` three local bundles and verify ``lfx extension list`` shows all 3 at @official; plus a Dockerfile-style seed-directory case. -This is the AC item from LE-1022: - - > Integration test: pip install three local wheel bundles into the - > test environment; extension list shows all 3 at @official. +Acceptance: integration test that pip-installs three local wheel +bundles into the test environment; ``extension list`` shows all 3 at +@official. The test builds three minimal wheel-shaped source trees, runs ``pip install --target `` against each, and points @@ -44,7 +43,7 @@ build-backend = "setuptools.build_meta" [project] name = "{distribution_name}" version = "{version}" -description = "Test bundle for LE-1022 production-install discovery" +description = "Test bundle for production-install discovery" requires-python = ">=3.10" [tool.setuptools] diff --git a/src/lfx/tests/unit/extension/test_cli.py b/src/lfx/tests/unit/extension/test_cli.py index a165861daa..ad1b97c2bb 100644 --- a/src/lfx/tests/unit/extension/test_cli.py +++ b/src/lfx/tests/unit/extension/test_cli.py @@ -104,13 +104,13 @@ def test_extension_app_help_smoke(runner: CliRunner) -> None: assert result.exit_code == 0 assert "validate" in result.stdout assert "schema" in result.stdout - # LE-1016 commands also appear in help. + # Authoring commands also appear in help. assert "init" in result.stdout assert "dev" in result.stdout # --------------------------------------------------------------------------- -# init (LE-1016) +# init # --------------------------------------------------------------------------- @@ -180,7 +180,7 @@ def test_init_accepts_explicit_id_and_name(runner: CliRunner, tmp_path: Path) -> # --------------------------------------------------------------------------- -# dev (LE-1016) +# dev # --------------------------------------------------------------------------- @@ -305,20 +305,50 @@ def test_dev_launch_env_respects_author_reload_off_path() -> None: # --------------------------------------------------------------------------- -# extension reload (LE-1018) -- argument validation +# extension reload -- argument validation # --------------------------------------------------------------------------- -def test_reload_requires_explicit_bundle(runner: CliRunner) -> None: - """The CLI must not silently default --bundle to the extension id. - - The conventional install shape is ``lfx-`` ext_id with bundle - name ````; defaulting bundle to the extension id would POST - to ``/extensions/lfx-foo/bundles/lfx-foo/reload`` which always 404s. - Until the LE-1019 list endpoint can resolve the single bundle for an - extension, --bundle is mandatory. - """ - result = runner.invoke(app, ["extension", "reload", "lfx-pilot"]) +def test_reload_with_neither_id_nor_all_exits_2(runner: CliRunner) -> None: + """No positional id AND no ``--all`` is a usage error.""" + result = runner.invoke(app, ["extension", "reload"]) assert result.exit_code == 2 msg = result.stderr or result.stdout + # The error message should mention both alternatives so the user + # knows what to do next. + assert "--all" in msg + assert "extension id" in msg + + +def test_reload_all_rejects_extra_args(runner: CliRunner) -> None: + """``--all`` is mutually exclusive with an explicit id / bundle name.""" + result = runner.invoke(app, ["extension", "reload", "--all", "lfx-pilot"]) + assert result.exit_code == 2 + msg = result.stderr or result.stdout + assert "--all" in msg + + +def test_reload_id_not_in_local_discovery_errors_clean(runner: CliRunner, monkeypatch: pytest.MonkeyPatch) -> None: + """Error cleanly when the extension is not locally installed. + + With --bundle omitted, the CLI exits 1 with a typed message rather + than POSTing with a guessed bundle name. + """ + # Force discovery to return nothing. + from lfx.cli import _extension_commands as commands + + monkeypatch.setattr( + "lfx.extension.discover_all_extensions", + lambda: ([], []), + ) + # Defensive: prevent the module-cached reference if any. + monkeypatch.setattr( + commands, + "_post_reload", + lambda **_kwargs: pytest.fail("should not POST when discovery fails"), + ) + result = runner.invoke(app, ["extension", "reload", "lfx-not-installed"]) + assert result.exit_code == 1 + msg = result.stderr or result.stdout + assert "lfx-not-installed" in msg assert "--bundle" in msg diff --git a/src/lfx/tests/unit/extension/test_cli_list.py b/src/lfx/tests/unit/extension/test_cli_list.py index 6869e1305e..53a168deab 100644 --- a/src/lfx/tests/unit/extension/test_cli_list.py +++ b/src/lfx/tests/unit/extension/test_cli_list.py @@ -1,4 +1,4 @@ -"""End-to-end CLI tests for ``lfx extension list`` (LE-1022). +"""End-to-end CLI tests for ``lfx extension list``. The list command reads from the live importlib metadata + the seed-directory environment. We override the seed directory through the diff --git a/src/lfx/tests/unit/extension/test_components_path_settings.py b/src/lfx/tests/unit/extension/test_components_path_settings.py index 2f4e67f7bb..a135ccac67 100644 --- a/src/lfx/tests/unit/extension/test_components_path_settings.py +++ b/src/lfx/tests/unit/extension/test_components_path_settings.py @@ -1,9 +1,9 @@ """Tests for the LANGFLOW_COMPONENTS_PATH pathsep-split behavior in settings. -LE-1015 requires that ``LANGFLOW_COMPONENTS_PATH=/path/A:/path/B`` is parsed -as two component paths rather than treated as one literal non-existent path. -This test exercises the validator directly so it works without spinning up -the full settings service. +The extension loader requires that ``LANGFLOW_COMPONENTS_PATH=/path/A:/path/B`` +is parsed as two component paths rather than treated as one literal +non-existent path. This test exercises the validator directly so it works +without spinning up the full settings service. """ from __future__ import annotations diff --git a/src/lfx/tests/unit/extension/test_dev_registry.py b/src/lfx/tests/unit/extension/test_dev_registry.py index 15d8d86811..3ce5c4cd4b 100644 --- a/src/lfx/tests/unit/extension/test_dev_registry.py +++ b/src/lfx/tests/unit/extension/test_dev_registry.py @@ -1,4 +1,4 @@ -"""Tests for ``lfx.extension.dev_registry`` (LE-1016). +"""Tests for ``lfx.extension.dev_registry``. Coverage: - register / list / unregister round-trip diff --git a/src/lfx/tests/unit/extension/test_discovery.py b/src/lfx/tests/unit/extension/test_discovery.py index 4973b98629..1cc48c7355 100644 --- a/src/lfx/tests/unit/extension/test_discovery.py +++ b/src/lfx/tests/unit/extension/test_discovery.py @@ -1,6 +1,6 @@ -"""Discovery tests for installed-distribution + seed-directory sources (LE-1022). +"""Discovery tests for installed-distribution + seed-directory sources. -Two integration pieces (per the LE-1022 acceptance criteria): +Two integration pieces: * Three "installed" distributions surfaced via a fake ``importlib.metadata.distributions()`` iterator -- mirrors the @@ -275,6 +275,181 @@ def test_canonicalize_distribution_normalizes_per_pep503() -> None: assert canonicalize_distribution("lfx--OpenAI") == "lfx-openai" +# --------------------------------------------------------------------------- +# Editable install entry-point fallback +# --------------------------------------------------------------------------- + + +class _EditableDistribution(importlib_metadata.Distribution): + """In-memory editable Distribution stub. + + Mirrors what ``uv pip install -e`` / ``pip install -e`` produce: the + distribution's ``files`` list contains only ``dist-info/`` entries + (the actual source tree is reached via a ``.pth`` file rather than + listed in RECORD), and the ``langflow.extensions`` entry-point is + what the manifest discovery has to lean on. + """ + + def __init__(self, *, name: str, module_name: str) -> None: + self._name = name + self._module_name = module_name + + @property + def files(self) -> list[importlib_metadata.PackagePath]: # type: ignore[override] + # Editable installs only surface dist-info entries -- no + # ``extension.json`` and no source ``pyproject.toml``. + slug = self._name.replace("-", "_") + return [ + importlib_metadata.PackagePath(f"{slug}-0.1.0.dist-info/METADATA"), + importlib_metadata.PackagePath(f"{slug}-0.1.0.dist-info/RECORD"), + importlib_metadata.PackagePath(f"_editable_impl_{slug}.pth"), + ] + + def locate_file(self, path: object) -> Path: # type: ignore[override] + return Path(str(path)) + + def read_text(self, filename: str) -> str | None: # type: ignore[override] + if filename in {"METADATA", "PKG-INFO"}: + return f"Metadata-Version: 2.1\nName: {self._name}\nVersion: 1.0.0\n" + return None + + @property + def metadata(self) -> object: # type: ignore[override] + class _Stub(dict): + pass + + return _Stub({"Name": self._name}) + + @property + def entry_points(self) -> list[importlib_metadata.EntryPoint]: # type: ignore[override] + return [ + importlib_metadata.EntryPoint( + name=self._name, + value=self._module_name, + group="langflow.extensions", + ) + ] + + +def _make_editable_package( + site_root: Path, + *, + module_name: str, + manifest: dict[str, object], +) -> Path: + """Stand up a real importable package on disk with an ``extension.json``.""" + pkg_dir = site_root / module_name + bundle_name = manifest["bundles"][0]["name"] # type: ignore[index] + (pkg_dir / bundle_name).mkdir(parents=True) + (pkg_dir / "__init__.py").write_text("", encoding="utf-8") + (pkg_dir / "extension.json").write_text(json.dumps(manifest), encoding="utf-8") + return pkg_dir + + +def test_discover_installed_falls_back_to_entry_point_for_editable_install( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Editable installs are discovered via the ``langflow.extensions`` entry-point. + + Reproduces the editable-install bug: ``dist.files`` only carries + ``dist-info/`` entries, so the file-walk path finds no manifest. The + entry-point fallback resolves the package directory via + :func:`importlib.util.find_spec` and locates ``extension.json`` there. + """ + site = tmp_path / "src" + site.mkdir() + _make_editable_package( + site, + module_name="lfx_editable_bundle", + manifest=_manifest("lfx-editable-bundle", "bundle"), + ) + + monkeypatch.syspath_prepend(str(site)) + # Drop any cached spec from a previous test invocation. + import sys + + sys.modules.pop("lfx_editable_bundle", None) + + dist = _EditableDistribution( + name="lfx-editable-bundle", + module_name="lfx_editable_bundle", + ) + + extensions, errors = discover_installed_extensions(distributions=[dist]) + + assert errors == [] + assert len(extensions) == 1 + ext = extensions[0] + assert ext.extension_id == "lfx-editable-bundle" + assert ext.source_kind == "installed" + assert ext.bundle_name == "bundle" + assert ext.manifest.kind == "extension.json" + assert ext.extension_root == site / "lfx_editable_bundle" + + +def test_discover_installed_entry_point_fallback_handles_missing_module( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An entry-point pointing at an unimportable module yields no result, no crash.""" + # No package on sys.path corresponds to ``lfx_nonexistent``. + monkeypatch.syspath_prepend(str(tmp_path)) + import sys + + sys.modules.pop("lfx_nonexistent_bundle", None) + + dist = _EditableDistribution( + name="lfx-nonexistent-bundle", + module_name="lfx_nonexistent_bundle", + ) + + extensions, errors = discover_installed_extensions(distributions=[dist]) + + # Unresolvable entry-point is treated as "no manifest here", same as a + # regular non-extension package -- no error, no record. + assert extensions == [] + assert errors == [] + + +def test_discover_installed_entry_point_fallback_skipped_when_files_have_manifest( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """When ``dist.files`` already exposes the manifest, the entry-point path is unused. + + Guards the wheel-install codepath (the common case) from being + accidentally rerouted through ``find_spec`` -- which would otherwise + re-import every bundle package on every startup. + """ + site = tmp_path / "site-packages/lfx_wheelish" + _write_extension_json(site, _manifest("lfx-wheelish", "wheelish")) + monkeypatch.syspath_prepend(str(tmp_path / "site-packages")) + + # Sentinel: if find_spec is consulted, we'd notice. + import importlib.util as _importlib_util + + calls = {"n": 0} + real_find_spec = _importlib_util.find_spec + + def _tracking_find_spec(name: str, package: str | None = None) -> object: + calls["n"] += 1 + return real_find_spec(name, package) + + monkeypatch.setattr(_importlib_util, "find_spec", _tracking_find_spec) + + dist = _FakeDistribution( + name="lfx-wheelish", + root=site, + manifest_relative="extension.json", + ) + extensions, errors = discover_installed_extensions(distributions=[dist]) + + assert errors == [] + assert len(extensions) == 1 + assert calls["n"] == 0, "Wheel-install path should not consult find_spec" + + # --------------------------------------------------------------------------- # Seed-directory discovery # --------------------------------------------------------------------------- @@ -421,9 +596,9 @@ def test_discover_all_emits_seed_bundle_shadowed_when_ids_collide( ) -> None: """A seed bundle with the same id as an installed one is flagged, not silently dropped. - Installed wins by precedence (LE-1022's documented contract); the - operator still gets a typed ``seed-bundle-shadowed`` error so the - shadow is visible instead of disappearing into discovery debug logs. + Installed wins by precedence (documented contract); the operator + still gets a typed ``seed-bundle-shadowed`` error so the shadow is + visible instead of disappearing into discovery debug logs. """ # ``fake_installed_distributions`` ships ``lfx-openai``; the seed dir # below carries the same id from a different on-disk source. diff --git a/src/lfx/tests/unit/extension/test_init_template.py b/src/lfx/tests/unit/extension/test_init_template.py index e7107a6936..52ee7eb58e 100644 --- a/src/lfx/tests/unit/extension/test_init_template.py +++ b/src/lfx/tests/unit/extension/test_init_template.py @@ -1,8 +1,8 @@ -"""Tests for ``lfx.extension.init_template`` (LE-1016). +"""Tests for ``lfx.extension.init_template``. Coverage: - Basic template renders a manifest that round-trips through the - LE-1014 validator with zero errors (AC #1). + manifest validator with zero errors (AC #1). - Generated test file is a syntactically valid pytest module that imports + executes (AC #2). - Unknown ``--template`` value (e.g. ``full``) returns a typed diff --git a/src/lfx/tests/unit/extension/test_registry.py b/src/lfx/tests/unit/extension/test_registry.py index 86a71022e9..b40a1f7032 100644 --- a/src/lfx/tests/unit/extension/test_registry.py +++ b/src/lfx/tests/unit/extension/test_registry.py @@ -1,4 +1,4 @@ -"""Service-layer tests for the read-only Extension registry (LE-1022). +"""Service-layer tests for the read-only Extension registry. The acceptance criterion this file covers: @@ -167,7 +167,7 @@ def test_duplicate_extension_id_raises_typed_error(tmp_path: Path) -> None: # --------------------------------------------------------------------------- -# Immutability invariant (the core LE-1022 acceptance test) +# Immutability invariant # --------------------------------------------------------------------------- _MUTATION_VERBS = ("uninstall", "disable", "enable", "install", "update_entry") @@ -186,9 +186,8 @@ def _invoke_mutation_verb(registry: ExtensionRegistry, verb: str, extension_id: def test_installed_extension_is_immutable(tmp_path: Path, verb: str) -> None: """Every mutation verb on an installed Extension raises immutability. - Drives the LE-1022 acceptance: the invariant is observable today via - the service surface, even though uninstall doesn't ship as a CLI verb - until B4. + The invariant is observable via the service surface, even though + uninstall doesn't ship as a CLI verb yet. """ discovered = _make_discovered( extension_id="lfx-openai", diff --git a/src/lfx/tests/unit/extension/test_reload.py b/src/lfx/tests/unit/extension/test_reload.py index c114827e3e..45b81c923f 100644 --- a/src/lfx/tests/unit/extension/test_reload.py +++ b/src/lfx/tests/unit/extension/test_reload.py @@ -1,6 +1,6 @@ -"""Unit tests for the LE-1018 atomic-swap reload pipeline. +"""Unit tests for the atomic-swap reload pipeline. -Covers the AC items from the ticket: +Covers the AC items: * rename a component, reload, old name gone, new name present; * broken bundle (import error in staging) leaves the live registry diff --git a/src/lfx/tests/unit/extension/test_reload_cli.py b/src/lfx/tests/unit/extension/test_reload_cli.py index 73db3ff950..730966f4f9 100644 --- a/src/lfx/tests/unit/extension/test_reload_cli.py +++ b/src/lfx/tests/unit/extension/test_reload_cli.py @@ -1,4 +1,4 @@ -"""Tests for the LE-1018 reload CLI: HTTP client + ``lfx extension reload``.""" +"""Tests for the reload CLI: HTTP client + ``lfx extension reload``.""" from __future__ import annotations diff --git a/src/lfx/tests/unit/inputs/test_model_input_fixes.py b/src/lfx/tests/unit/inputs/test_model_input_fixes.py index deb4dce3c5..384019560f 100644 --- a/src/lfx/tests/unit/inputs/test_model_input_fixes.py +++ b/src/lfx/tests/unit/inputs/test_model_input_fixes.py @@ -180,7 +180,7 @@ class TestModelInputRefreshButton: class TestModelInputEmbeddingType: - """Test that ModelInput correctly handles embedding model_type (fix for LE-278). + """Test that ModelInput correctly handles embedding model_type. input_types should always be set based on model_type: - "embedding" -> ["Embeddings"] diff --git a/src/lfx/tests/unit/test_flow_builder.py b/src/lfx/tests/unit/test_flow_builder.py index 9b122dfebf..7865cd792c 100644 --- a/src/lfx/tests/unit/test_flow_builder.py +++ b/src/lfx/tests/unit/test_flow_builder.py @@ -337,8 +337,8 @@ class TestConnect: def test_add_connection_is_idempotent(self): """Repeating add_connection for the same pair must not duplicate the edge. - Regression for LE-866: connecting components via batch (or after the - edge was already created in the UI) appended a second edge with the + Regression: connecting components via batch (or after the edge + was already created in the UI) appended a second edge with the same connection, double-wiring the flow at runtime. """ flow = _fresh_flow() @@ -352,10 +352,11 @@ class TestConnect: def test_add_connection_dedupes_against_ui_saved_edges(self): """Re-adding connections to a real UI-saved flow must not grow the edge list. - Regression for LE-866 against an actual UI-exported fixture. UI-saved - edges from older Langflow versions use the `xy-edge__` id prefix - instead of `reactflow__edge-`, so dedup must be structural (source, - target, handle name, handle fieldName) rather than by edge id. + Regression against an actual UI-exported fixture. UI-saved edges + from older Langflow versions use the `xy-edge__` id prefix + instead of `reactflow__edge-`, so dedup must be structural + (source, target, handle name, handle fieldName) rather than by + edge id. """ fixture = Path(__file__).parent.parent / "data" / "MemoryChatbotNoLLM.json" flow = json.loads(fixture.read_text())