mirror of
https://github.com/langflow-ai/langflow.git
synced 2026-07-23 17:06:46 +08:00
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 <ext_id>`: `--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 <Kaushik.debojit@gmail.com>
This commit is contained in:
@ -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 <ext_id>`` 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.
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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.<provider>`, 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/<provider>/` 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/<provider>/` 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:
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 --
|
||||
|
||||
@ -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
|
||||
|
||||
@ -5759,4 +5759,4 @@
|
||||
"tags": [
|
||||
"agents"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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]",
|
||||
|
||||
@ -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}"
|
||||
|
||||
@ -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"
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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.<provider>` 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.<provider>`
|
||||
into a separate distribution. The bundle ships a single component,
|
||||
`DuckDuckGoSearchComponent`, which performs DuckDuckGo web searches
|
||||
via `langchain-community`.
|
||||
|
||||
## Install
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -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.<provider>`` into a standalone Bundle.
|
||||
The first provider extracted from ``lfx.components.<provider>`` into
|
||||
a standalone Bundle.
|
||||
"""
|
||||
|
||||
from lfx_duckduckgo.components.duckduckgo.duck_duck_go_search_run import (
|
||||
|
||||
@ -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"]
|
||||
},
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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-<provider>' "
|
||||
"with bundle name '<provider>', 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 <name>; conventional shape is "
|
||||
"lfx-<provider> ext_id with <provider> 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 <name> 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 <name> 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 <target>`` followed immediately by ``validate <target>``
|
||||
passes with zero errors.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 <path>``, 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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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}/
|
||||
|
||||
@ -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__.<reload_id>`` so a
|
||||
Reload overrides this with ``__reload_staging__.<reload_id>`` 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__.<id>`` 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.<slot>.<bundle>...`` 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.<slot>.<bundle>.*`` sys.modules namespace) before calling
|
||||
# back into this loader. Absolute imports only between bundle modules;
|
||||
# relative imports unsupported.
|
||||
|
||||
@ -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__.<id>`` so
|
||||
Stage 1 lands in an isolated namespace; do not override this in
|
||||
normal application code.
|
||||
loads. The reload pipeline passes ``__reload_staging__.<id>``
|
||||
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.<slot>.<bundle>.*`` namespace before re-invoking this
|
||||
loader; direct callers should not rely on this function to refresh
|
||||
already-loaded bundles.
|
||||
|
||||
@ -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``.
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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:
|
||||
# {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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__.<reload_id>``). Uses the LE-1015 loader with
|
||||
(``__reload_staging__.<reload_id>``). 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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:<bundle>:<Class>@<slot>``.
|
||||
# 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:<bundle>:<Class>@<slot>``.
|
||||
# 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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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, (
|
||||
|
||||
@ -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 <isolated dir>`` 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]
|
||||
|
||||
@ -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-<provider>`` ext_id with bundle
|
||||
name ``<provider>``; 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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""Tests for ``lfx.extension.dev_registry`` (LE-1016).
|
||||
"""Tests for ``lfx.extension.dev_registry``.
|
||||
|
||||
Coverage:
|
||||
- register / list / unregister round-trip
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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())
|
||||
|
||||
Reference in New Issue
Block a user