mirror of
https://github.com/langflow-ai/langflow.git
synced 2026-07-24 08:23:43 +08:00
fix: More review comments addressed
This commit is contained in:
@ -30,6 +30,12 @@ RUN apt-get update \
|
||||
COPY ./src/backend ./src/backend
|
||||
COPY ./src/lfx ./src/lfx
|
||||
COPY ./src/sdk ./src/sdk
|
||||
# Workspace bundles (LE-1023 pilot+): each Bundle is shipped as a
|
||||
# separate distribution that langflow-base depends on by name (e.g.
|
||||
# ``lfx-duckduckgo``). Without copying the source tree, the install
|
||||
# below cannot resolve the path-based bundle deps and ends up with a
|
||||
# Langflow image missing components that previously lived in lfx.
|
||||
COPY ./src/bundles ./src/bundles
|
||||
|
||||
# Create venv and install langflow-base with dependencies
|
||||
# Using uv pip instead of uv sync to avoid workspace complexities
|
||||
@ -37,9 +43,15 @@ RUN uv venv /app/.venv
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
ENV VIRTUAL_ENV="/app/.venv"
|
||||
|
||||
# Install langflow-base with all extras except dev (which includes Playwright)
|
||||
# Install langflow-base with all extras except dev (which includes Playwright).
|
||||
# Each pilot-extracted bundle is installed alongside so the runtime image
|
||||
# keeps shipping the same component set users had before LE-1023.
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv pip install ./src/sdk ./src/lfx "./src/backend/base[complete,postgresql]"
|
||||
uv pip install \
|
||||
./src/sdk \
|
||||
./src/lfx \
|
||||
./src/bundles/duckduckgo \
|
||||
"./src/backend/base[complete,postgresql]"
|
||||
|
||||
################################
|
||||
# RUNTIME
|
||||
|
||||
@ -79,6 +79,19 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
RUSTFLAGS='--cfg reqwest_unstable' \
|
||||
uv sync --frozen --no-dev --no-editable --extra postgresql
|
||||
|
||||
# Pilot Bundle re-attach (LE-1023): ``langflow-base`` no longer pulls in
|
||||
# DuckDuckGo (it moved to the standalone ``lfx-duckduckgo`` distribution
|
||||
# whose pyproject lives at ``src/bundles/duckduckgo``). The base image
|
||||
# was the user-facing path for that component before the move; install
|
||||
# every extracted bundle so the runtime image keeps the same component
|
||||
# set. ``--no-deps`` is intentional: the bundle's runtime deps are
|
||||
# already in the langflow-base lockfile (lfx, langchain-community,
|
||||
# ddgs); avoid pulling a parallel ``lfx`` install.
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
RUSTFLAGS='--cfg reqwest_unstable' \
|
||||
uv pip install --no-deps /app/src/bundles/duckduckgo \
|
||||
&& uv pip install ddgs
|
||||
|
||||
################################
|
||||
# RUNTIME
|
||||
# Setup user, utilities and copy the virtual environment only
|
||||
|
||||
@ -14,6 +14,7 @@ from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from lfx.extension.bundle_registry import get_default_registry
|
||||
from lfx.extension.errors import ExtensionError
|
||||
from lfx.extension.reload import ReloadInProgressError, reload_bundle
|
||||
from lfx.log.logger import logger
|
||||
from lfx.services.deps import get_settings_service
|
||||
@ -23,6 +24,18 @@ from langflow.services.auth.utils import get_current_active_user
|
||||
router = APIRouter(prefix="/extensions", tags=["Extensions"])
|
||||
|
||||
|
||||
def _typed_http_exception(*, status_code: int, error: ExtensionError) -> HTTPException:
|
||||
"""Wrap an :class:`ExtensionError` in a FastAPI HTTPException.
|
||||
|
||||
The ``detail`` body is the full ``{code, message, location, content,
|
||||
hint, ref_url}`` envelope, matching the typed-error contract documented
|
||||
in the extensions error guide. Returning anything narrower here would
|
||||
drop the fix hint and the docs link from the client surface, which the
|
||||
palette + CLI consumers depend on.
|
||||
"""
|
||||
return HTTPException(status_code=status_code, detail=error.to_dict())
|
||||
|
||||
|
||||
def _require_extension_reload_enabled() -> None:
|
||||
"""Per-request guard for the Mode A reload route.
|
||||
|
||||
@ -31,20 +44,27 @@ def _require_extension_reload_enabled() -> None:
|
||||
to happen after the env file is loaded. Operators on Mode B/C
|
||||
deployments leave ``LANGFLOW_ENABLE_EXTENSION_RELOAD`` unset; the
|
||||
guard returns 404 so the route is indistinguishable from "not
|
||||
mounted" for those operators.
|
||||
mounted" for those operators, with the typed error body so the
|
||||
client renders the same envelope shape it does for any other
|
||||
reload error.
|
||||
"""
|
||||
settings = get_settings_service().settings
|
||||
if not getattr(settings, "enable_extension_reload", False):
|
||||
raise HTTPException(
|
||||
raise _typed_http_exception(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={
|
||||
"code": "extension-reload-disabled",
|
||||
"message": (
|
||||
"Extension reload is disabled. Set "
|
||||
"LANGFLOW_ENABLE_EXTENSION_RELOAD=true to enable it on a "
|
||||
"local-development install (Mode A)."
|
||||
error=ExtensionError(
|
||||
code="extension-reload-disabled",
|
||||
message=(
|
||||
"Extension reload is disabled on this server. "
|
||||
"Set LANGFLOW_ENABLE_EXTENSION_RELOAD=true to enable it on "
|
||||
"a local-development install (Mode A)."
|
||||
),
|
||||
},
|
||||
hint=(
|
||||
"Local development: ``lfx extension dev`` sets the flag "
|
||||
"for you. Self-hosted: export LANGFLOW_ENABLE_EXTENSION_RELOAD=true "
|
||||
"or include it in your --env-file."
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@ -75,14 +95,22 @@ async def reload_extension_bundle(extension_id: str, bundle_name: str) -> dict:
|
||||
if record is not None and record.extension_id != extension_id:
|
||||
# Bundle name is registered but under a different extension -- treat as
|
||||
# a 404 so a typo in the URL doesn't mutate someone else's bundle.
|
||||
raise HTTPException(
|
||||
raise _typed_http_exception(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={
|
||||
"code": "reload-bundle-not-installed",
|
||||
"message": (
|
||||
f"Bundle {bundle_name!r} is registered to extension {record.extension_id!r}, not {extension_id!r}."
|
||||
error=ExtensionError(
|
||||
code="reload-bundle-not-installed",
|
||||
message=(
|
||||
f"Bundle {bundle_name!r} is registered to extension "
|
||||
f"{record.extension_id!r}, not {extension_id!r}."
|
||||
),
|
||||
},
|
||||
location=f"{extension_id}/{bundle_name}",
|
||||
content=bundle_name,
|
||||
hint=(
|
||||
"Use the bundle's actual extension id (typically the pip "
|
||||
"distribution name); ``GET /api/v1/extensions`` will list "
|
||||
"the registered (extension, bundle) pairs."
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
@ -90,13 +118,18 @@ async def reload_extension_bundle(extension_id: str, bundle_name: str) -> dict:
|
||||
except ReloadInProgressError as exc:
|
||||
# 409 is the conventional "in-progress / conflicting state" code.
|
||||
logger.info("extension reload-in-progress collision for %s", exc.bundle)
|
||||
raise HTTPException(
|
||||
raise _typed_http_exception(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail={
|
||||
"code": "reload-in-progress",
|
||||
"message": str(exc),
|
||||
"bundle": exc.bundle,
|
||||
},
|
||||
error=ExtensionError(
|
||||
code="reload-in-progress",
|
||||
message=str(exc),
|
||||
location=exc.bundle,
|
||||
content=exc.bundle,
|
||||
hint=(
|
||||
"Wait for the in-flight reload to finish before retrying; "
|
||||
"another tab or worker is already swapping this bundle."
|
||||
),
|
||||
),
|
||||
) from exc
|
||||
|
||||
return result.to_dict()
|
||||
|
||||
@ -380,6 +380,13 @@ class BaseConfigResponse(BaseModel):
|
||||
voice_mode_available: bool
|
||||
frontend_timeout: int
|
||||
mcp_base_url: str
|
||||
# Mode A only: gates the palette Bundle-header Reload action. Surfaced
|
||||
# at runtime so the packaged frontend (built once with the env var
|
||||
# default) can still light up the button when an operator turns the
|
||||
# backend reload route on -- the build-time Vite flag gates first-paint,
|
||||
# but ``lfx extension dev`` and ``--env-file LANGFLOW_ENABLE_EXTENSION_RELOAD=true``
|
||||
# opt in after the build is frozen, so the UI consults this field too.
|
||||
enable_extension_reload: bool
|
||||
|
||||
|
||||
class PublicConfigResponse(BaseConfigResponse):
|
||||
@ -409,6 +416,7 @@ class PublicConfigResponse(BaseConfigResponse):
|
||||
voice_mode_available=settings.voice_mode_available,
|
||||
frontend_timeout=settings.frontend_timeout,
|
||||
mcp_base_url=settings.mcp_base_url,
|
||||
enable_extension_reload=settings.enable_extension_reload,
|
||||
allow_custom_components=settings.allow_custom_components,
|
||||
)
|
||||
|
||||
@ -463,6 +471,7 @@ class ConfigResponse(BaseConfigResponse):
|
||||
event_delivery=settings.event_delivery,
|
||||
voice_mode_available=settings.voice_mode_available,
|
||||
mcp_base_url=settings.mcp_base_url,
|
||||
enable_extension_reload=settings.enable_extension_reload,
|
||||
webhook_auth_enable=auth_settings.WEBHOOK_AUTH_ENABLE,
|
||||
default_folder_name=DEFAULT_FOLDER_NAME,
|
||||
hide_getting_started_progress=os.getenv("HIDE_GETTING_STARTED_PROGRESS", "").lower() == "true",
|
||||
|
||||
@ -26,7 +26,13 @@ def _make_settings(*, enable: bool):
|
||||
|
||||
|
||||
def test_guard_raises_404_when_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Guard must return 404 with the typed code when the flag is off."""
|
||||
"""Guard must return 404 with the FULL typed-error envelope when the flag is off.
|
||||
|
||||
The body must include ``code``, ``message``, ``hint``, and ``ref_url``
|
||||
fields so palette / CLI consumers render the same shape they do for
|
||||
every other reload error. An ad-hoc ``{code, message}`` body would
|
||||
drop the hint that tells the user how to enable the flag.
|
||||
"""
|
||||
monkeypatch.setattr(
|
||||
"langflow.api.v1.extensions.get_settings_service",
|
||||
lambda: _make_settings(enable=False),
|
||||
@ -36,8 +42,14 @@ def test_guard_raises_404_when_disabled(monkeypatch: pytest.MonkeyPatch) -> None
|
||||
_require_extension_reload_enabled()
|
||||
|
||||
assert exc.value.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert exc.value.detail["code"] == "extension-reload-disabled"
|
||||
assert "LANGFLOW_ENABLE_EXTENSION_RELOAD" in exc.value.detail["message"]
|
||||
detail = exc.value.detail
|
||||
assert isinstance(detail, dict)
|
||||
# Full ExtensionError envelope -- code + hint + docs link must all
|
||||
# survive the trip through HTTPException.
|
||||
assert detail["code"] == "extension-reload-disabled"
|
||||
assert "LANGFLOW_ENABLE_EXTENSION_RELOAD" in detail["message"]
|
||||
assert detail["hint"]
|
||||
assert detail["ref_url"].endswith("#extension-reload-disabled")
|
||||
|
||||
|
||||
def test_guard_passes_when_enabled(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
|
||||
41
src/frontend/package-lock.json
generated
41
src/frontend/package-lock.json
generated
@ -217,7 +217,6 @@
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@ -1083,7 +1082,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@chakra-ui/styled-system/-/styled-system-2.9.2.tgz",
|
||||
"integrity": "sha512-To/Z92oHpIE+4nk11uVMWqo2GGRS86coeMmjxtpnErmWRdLcp1WVCVRAvn+ZwpLiNR+reWFr2FFqJRsREuZdAg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@chakra-ui/shared-utils": "2.0.5",
|
||||
"csstype": "^3.1.2",
|
||||
@ -1095,7 +1093,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@chakra-ui/system/-/system-2.6.2.tgz",
|
||||
"integrity": "sha512-EGtpoEjLrUu4W1fHD+a62XR+hzC5YfsWm+6lO0Kybcga3yYEij9beegO0jZgug27V+Rf7vns95VPVP6mFd/DEQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@chakra-ui/color-mode": "2.2.0",
|
||||
"@chakra-ui/object-utils": "2.1.0",
|
||||
@ -1202,7 +1199,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz",
|
||||
"integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.23.0",
|
||||
@ -1239,7 +1235,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz",
|
||||
"integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@marijn/find-cluster-break": "^1.0.0"
|
||||
}
|
||||
@ -1249,7 +1244,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.41.1.tgz",
|
||||
"integrity": "sha512-ToDnWKbBnke+ZLrP6vgTTDScGi5H37YYuZGniQaBzxMVdtCxMrslsmtnOvbPZk4RX9bvkQqnWR/WS/35tJA0qg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"crelt": "^1.0.6",
|
||||
@ -1345,7 +1339,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@ -1369,7 +1362,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@ -6015,7 +6007,6 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@swc/counter": "^0.1.3",
|
||||
"@swc/types": "^0.1.26"
|
||||
@ -6422,7 +6413,6 @@
|
||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
@ -7134,7 +7124,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@ -7145,7 +7134,6 @@
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
@ -7161,8 +7149,7 @@
|
||||
"version": "1.15.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/sortablejs/-/sortablejs-1.15.9.tgz",
|
||||
"integrity": "sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/stack-utils": {
|
||||
"version": "2.0.3",
|
||||
@ -7794,7 +7781,6 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@ -8295,7 +8281,6 @@
|
||||
"integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"bare-abort-controller": "*"
|
||||
},
|
||||
@ -8564,7 +8549,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.12",
|
||||
"caniuse-lite": "^1.0.30001782",
|
||||
@ -8957,7 +8941,6 @@
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"anymatch": "~3.1.2",
|
||||
"braces": "~3.0.2",
|
||||
@ -9398,7 +9381,6 @@
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
@ -9975,7 +9957,6 @@
|
||||
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
@ -10254,7 +10235,6 @@
|
||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "^2.0.0",
|
||||
"body-parser": "^2.2.1",
|
||||
@ -11387,7 +11367,6 @@
|
||||
"integrity": "sha512-jN0ZewiNAWSe5khM3EyCmBb250+b40wWbwNILNfEvq84VREWwOIkuUsFONk/3i3nqkz7Oe1PcpM2mwQEK2L9Kg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
@ -11540,7 +11519,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.2"
|
||||
},
|
||||
@ -12100,7 +12078,6 @@
|
||||
"integrity": "sha512-AkXIIFcaazymvey2i/+F94XRnM6TsVLZDhBMLsd1Sf/W0wzsvvpjeyUrCZD6HGG4SDYPgDJDBKeiJTBb10WzMg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jest/core": "30.3.0",
|
||||
"@jest/types": "30.3.0",
|
||||
@ -13687,7 +13664,6 @@
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
|
||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
@ -13737,7 +13713,6 @@
|
||||
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cssstyle": "^4.2.1",
|
||||
"data-urls": "^5.0.0",
|
||||
@ -13777,7 +13752,6 @@
|
||||
"resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz",
|
||||
"integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10.16.0"
|
||||
}
|
||||
@ -16273,7 +16247,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@ -16696,7 +16669,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
|
||||
"integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@ -16790,7 +16762,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
|
||||
"integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@ -16821,7 +16792,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.74.0.tgz",
|
||||
"integrity": "sha512-yR6wHr99p9wFv686jhRWVSFhUvDvNbdUf2dKlbno8/VKOCuoNobDGC6S+M2dua9A9Yo8vpcrp8assIYbsZCQ9g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
@ -17515,7 +17485,6 @@
|
||||
"integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
@ -18034,8 +18003,7 @@
|
||||
"version": "1.15.7",
|
||||
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.7.tgz",
|
||||
"integrity": "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.5.7",
|
||||
@ -18214,7 +18182,6 @@
|
||||
"integrity": "sha512-vbSz7g/1rGMC1uAULqMZjALkIuLu2QABqfhRYhyr/11kzyesi+vAmwyJLukZP1FfecxGOgMwOh6GS0YsGpHAvQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@storybook/global": "^5.0.0",
|
||||
"@storybook/icons": "^2.0.1",
|
||||
@ -18589,7 +18556,6 @@
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
||||
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"arg": "^5.0.2",
|
||||
@ -19186,7 +19152,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@ -19700,7 +19665,6 @@
|
||||
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@ -20685,7 +20649,6 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@ -21,6 +21,10 @@ interface BaseConfig {
|
||||
voice_mode_available: boolean;
|
||||
allow_custom_components: boolean;
|
||||
mcp_base_url: string;
|
||||
// Mode A only: backend's ``LANGFLOW_ENABLE_EXTENSION_RELOAD`` mirrored
|
||||
// through to the frontend so a packaged build can light up the palette
|
||||
// Reload button without a rebuild. See utilityStore.enableExtensionReload.
|
||||
enable_extension_reload: boolean;
|
||||
}
|
||||
|
||||
// Public config = base config (unauthenticated users get only base fields)
|
||||
@ -84,6 +88,9 @@ export const useGetConfig: useQueryFunctionType<
|
||||
(state) => state.setAllowCustomComponents,
|
||||
);
|
||||
const setMcpBaseUrl = useUtilityStore((state) => state.setMcpBaseUrl);
|
||||
const setEnableExtensionReload = useUtilityStore(
|
||||
(state) => state.setEnableExtensionReload,
|
||||
);
|
||||
|
||||
const { query } = UseRequestProcessor();
|
||||
|
||||
@ -107,6 +114,7 @@ export const useGetConfig: useQueryFunctionType<
|
||||
const allowCustomComponents = data.allow_custom_components ?? true;
|
||||
setAllowCustomComponents(allowCustomComponents);
|
||||
setMcpBaseUrl(data.mcp_base_url ?? "");
|
||||
setEnableExtensionReload(Boolean(data.enable_extension_reload));
|
||||
recomputeComponentsToUpdateIfNeeded();
|
||||
|
||||
// Set authenticated-only fields if present (full config)
|
||||
|
||||
@ -29,6 +29,17 @@ jest.mock("@/stores/alertStore", () => ({
|
||||
selector({ setSuccessData, setErrorData, setNoticeData }),
|
||||
}));
|
||||
|
||||
// The runtime gate (mirrored from /config) must be on for the rendered-UI
|
||||
// tests; the off-path is exercised separately below.
|
||||
interface UtilitySlice {
|
||||
enableExtensionReload: boolean;
|
||||
}
|
||||
let runtimeReloadEnabled = true;
|
||||
jest.mock("@/stores/utilityStore", () => ({
|
||||
useUtilityStore: (selector: (state: UtilitySlice) => unknown) =>
|
||||
selector({ enableExtensionReload: runtimeReloadEnabled }),
|
||||
}));
|
||||
|
||||
// Captured between tests so we can poke the latest onSuccess / onError
|
||||
// directly without round-tripping through MSW.
|
||||
type ReloadVars = { extensionId: string; bundleName: string };
|
||||
@ -175,6 +186,13 @@ describe("BundleHeaderActions", () => {
|
||||
setNoticeData.mockReset();
|
||||
lastOptions = {};
|
||||
pending = false;
|
||||
runtimeReloadEnabled = true;
|
||||
});
|
||||
|
||||
it("renders nothing when the runtime backend flag is off", () => {
|
||||
runtimeReloadEnabled = false;
|
||||
const { container } = render(<BundleHeaderActions {...baseProps} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("renders nothing when extensionId is missing", () => {
|
||||
|
||||
@ -14,6 +14,7 @@ import type {
|
||||
import { useReloadBundle } from "@/controllers/API/queries/extensions";
|
||||
import { ENABLE_EXTENSION_RELOAD } from "@/customization/feature-flags";
|
||||
import useAlertStore from "@/stores/alertStore";
|
||||
import { useUtilityStore } from "@/stores/utilityStore";
|
||||
|
||||
const RELOAD_VALUE = "reload" as const;
|
||||
|
||||
@ -153,9 +154,15 @@ const BundleHeaderActionsInner = ({
|
||||
[],
|
||||
);
|
||||
|
||||
// Defense-in-depth gate: parents already check both flags but a future
|
||||
// caller that bypasses BundleItem / CategoryDisclosure must not surface
|
||||
// the action against a backend that has reload disabled.
|
||||
const enableReloadRuntime = useUtilityStore(
|
||||
(state) => state.enableExtensionReload,
|
||||
);
|
||||
const visible = useMemo(
|
||||
() => ENABLE_EXTENSION_RELOAD && Boolean(extensionId),
|
||||
[extensionId],
|
||||
() => ENABLE_EXTENSION_RELOAD && enableReloadRuntime && Boolean(extensionId),
|
||||
[enableReloadRuntime, extensionId],
|
||||
);
|
||||
|
||||
if (!visible) {
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
} from "@/components/ui/disclosure";
|
||||
import { SidebarMenuButton, SidebarMenuItem } from "@/components/ui/sidebar";
|
||||
import { ENABLE_EXTENSION_RELOAD } from "@/customization/feature-flags";
|
||||
import { useUtilityStore } from "@/stores/utilityStore";
|
||||
import { deriveBundleExtensionId } from "../helpers/derive-bundle-extension-id";
|
||||
import type { BundleItemProps } from "../types";
|
||||
import BundleHeaderActions from "./bundleHeaderActions";
|
||||
@ -45,12 +46,21 @@ export const BundleItem = memo(
|
||||
[item.extension_id, item.name, dataFilter],
|
||||
);
|
||||
|
||||
// The actions menu is only meaningful when both the feature flag is
|
||||
// on AND the bundle was loaded from a manifest-shipping Extension; the
|
||||
// BundleHeaderActions component itself handles those checks but we
|
||||
// also use the same predicate here to avoid registering a context-menu
|
||||
// capture handler that has nothing to show.
|
||||
const showActions = Boolean(ENABLE_EXTENSION_RELOAD && extensionId);
|
||||
// The actions menu is only meaningful when:
|
||||
// * the build-time kill switch is on (corporate Mode B/C builds can
|
||||
// dead-code-eliminate the UI by setting it to false),
|
||||
// * the backend has the reload route enabled (runtime mirror of
|
||||
// ``LANGFLOW_ENABLE_EXTENSION_RELOAD`` from ``/config``), AND
|
||||
// * the bundle was loaded from a manifest-shipping Extension (we
|
||||
// have an extensionId to send to the reload endpoint).
|
||||
// BundleHeaderActions itself re-checks; the predicate here also gates
|
||||
// the context-menu capture handler.
|
||||
const enableReloadRuntime = useUtilityStore(
|
||||
(state) => state.enableExtensionReload,
|
||||
);
|
||||
const showActions = Boolean(
|
||||
ENABLE_EXTENSION_RELOAD && enableReloadRuntime && extensionId,
|
||||
);
|
||||
|
||||
// Right-click on the Bundle header opens the same overflow menu as
|
||||
// the kebab icon to the right of the chevron. Implemented by
|
||||
|
||||
@ -9,6 +9,7 @@ import {
|
||||
} from "@/components/ui/disclosure";
|
||||
import { SidebarMenuButton, SidebarMenuItem } from "@/components/ui/sidebar";
|
||||
import { ENABLE_EXTENSION_RELOAD } from "@/customization/feature-flags";
|
||||
import { useUtilityStore } from "@/stores/utilityStore";
|
||||
import type { APIClassType } from "@/types/api";
|
||||
import { deriveBundleExtensionId } from "../helpers/derive-bundle-extension-id";
|
||||
import BundleHeaderActions from "./bundleHeaderActions";
|
||||
@ -68,7 +69,14 @@ export const CategoryDisclosure = memo(function CategoryDisclosure({
|
||||
() => deriveBundleExtensionId(item.name, dataFilter),
|
||||
[item.name, dataFilter],
|
||||
);
|
||||
const showActions = Boolean(ENABLE_EXTENSION_RELOAD && extensionId);
|
||||
// Mirror the BundleItem gate: build-time kill switch AND runtime
|
||||
// backend-reload flag (from /config) AND a derivable extension id.
|
||||
const enableReloadRuntime = useUtilityStore(
|
||||
(state) => state.enableExtensionReload,
|
||||
);
|
||||
const showActions = Boolean(
|
||||
ENABLE_EXTENSION_RELOAD && enableReloadRuntime && extensionId,
|
||||
);
|
||||
const handleContextMenu = useCallback(
|
||||
(event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!showActions) return;
|
||||
|
||||
@ -66,4 +66,10 @@ export const useUtilityStore = create<UtilityStoreType>((set, get) => ({
|
||||
set({ allowCustomComponents }),
|
||||
mcpBaseUrl: "",
|
||||
setMcpBaseUrl: (mcpBaseUrl: string) => set({ mcpBaseUrl }),
|
||||
// Default ``false`` so a misconfigured store (no ``/config`` reply yet)
|
||||
// matches the backend default of "reload disabled". The /config query
|
||||
// overwrites this on first load.
|
||||
enableExtensionReload: false,
|
||||
setEnableExtensionReload: (enableExtensionReload: boolean) =>
|
||||
set({ enableExtensionReload }),
|
||||
}));
|
||||
|
||||
@ -40,4 +40,17 @@ export type UtilityStoreType = {
|
||||
setAllowCustomComponents: (allowCustomComponents: boolean) => void;
|
||||
mcpBaseUrl: string;
|
||||
setMcpBaseUrl: (mcpBaseUrl: string) => void;
|
||||
/**
|
||||
* Mode A only: gates the palette Bundle-header Reload action at runtime.
|
||||
* Sourced from the backend ``/config`` response (mirrors
|
||||
* ``settings.enable_extension_reload``) so a ``langflow run`` started
|
||||
* with ``LANGFLOW_ENABLE_EXTENSION_RELOAD=true`` (or via ``--env-file``,
|
||||
* or by ``lfx extension dev``) lights up the Reload button without a
|
||||
* frontend rebuild. The build-time Vite flag (ENABLE_EXTENSION_RELOAD)
|
||||
* still gates first paint; the UI consults BOTH so Mode B/C deployments
|
||||
* with the build flag off keep the button hidden even if a misconfigured
|
||||
* backend turns it on.
|
||||
*/
|
||||
enableExtensionReload: boolean;
|
||||
setEnableExtensionReload: (enableExtensionReload: boolean) => void;
|
||||
};
|
||||
|
||||
@ -57,15 +57,18 @@ export default defineConfig(({ mode }) => {
|
||||
"import.meta.env.LANGFLOW_MCP_COMPOSER_ENABLED": JSON.stringify(
|
||||
envLangflow.LANGFLOW_MCP_COMPOSER_ENABLED ?? "true",
|
||||
),
|
||||
// Mode A only: gates the palette Bundle-header Reload action.
|
||||
// ``feature-flags.ts`` reads this via ``import.meta.env``; without an
|
||||
// entry here Vite never substitutes the value at build time, so the
|
||||
// flag stays undefined for both ``vite dev`` and ``vite build`` and
|
||||
// the Reload UI never appears even when extension templates carry
|
||||
// the metadata. Default off so production builds keep parity with
|
||||
// the backend's default-off ``LANGFLOW_ENABLE_EXTENSION_RELOAD``.
|
||||
// Compile-time hard kill switch for the palette Bundle-header
|
||||
// Reload action. The actual user-facing gate is the runtime
|
||||
// ``enable_extension_reload`` flag served from ``/config`` (mirrors
|
||||
// ``LANGFLOW_ENABLE_EXTENSION_RELOAD``), so a packaged frontend
|
||||
// built once can still light up the button when an operator opts
|
||||
// the backend in via ``--env-file`` or ``lfx extension dev``.
|
||||
// Default ``true`` here means the bundle SHIPS the UI; corporate
|
||||
// Mode B/C builds that want to drop the code entirely can set
|
||||
// ``LANGFLOW_EXTENSION_RELOAD_ENABLED=false`` in ``.env`` to dead-code-
|
||||
// eliminate the Reload UI at build time.
|
||||
"import.meta.env.LANGFLOW_EXTENSION_RELOAD_ENABLED": JSON.stringify(
|
||||
envLangflow.LANGFLOW_EXTENSION_RELOAD_ENABLED ?? "false",
|
||||
envLangflow.LANGFLOW_EXTENSION_RELOAD_ENABLED ?? "true",
|
||||
),
|
||||
},
|
||||
plugins: [
|
||||
|
||||
@ -620,22 +620,15 @@ def _build_dev_launch_env(base_env: os._Environ[str] | dict[str, str]) -> dict[s
|
||||
- ``LANGFLOW_LAZY_LOAD_COMPONENTS=false`` (always, overriding the
|
||||
author's shell): dev components must appear in the palette
|
||||
eagerly so the AC's 5-second budget holds.
|
||||
- ``LANGFLOW_ENABLE_EXTENSION_RELOAD=true`` (setdefault): the
|
||||
backend route only mounts a usable handler when this is set;
|
||||
``setdefault`` so an author intentionally testing the off path
|
||||
can pre-export ``=false``.
|
||||
- ``LANGFLOW_EXTENSION_RELOAD_ENABLED=true`` (setdefault): the
|
||||
frontend palette gate (``ENABLE_EXTENSION_RELOAD`` in
|
||||
``feature-flags.ts``) is wired through ``import.meta.env`` and
|
||||
read by Vite at build time; setting it here only affects a
|
||||
``vite dev`` server launched from the same shell. Listed for
|
||||
parity with the backend flag so an author who runs both halves
|
||||
locally gets the Reload button without hand-editing ``.env``.
|
||||
- ``LANGFLOW_ENABLE_EXTENSION_RELOAD=true`` (setdefault): turns on
|
||||
the in-process Bundle reload route AND the ``/config`` flag the
|
||||
packaged frontend reads at runtime to surface the palette
|
||||
Reload button. ``setdefault`` so an author intentionally
|
||||
testing the off path can pre-export ``=false``.
|
||||
"""
|
||||
env = dict(base_env)
|
||||
env["LANGFLOW_LAZY_LOAD_COMPONENTS"] = "false"
|
||||
env.setdefault("LANGFLOW_ENABLE_EXTENSION_RELOAD", "true")
|
||||
env.setdefault("LANGFLOW_EXTENSION_RELOAD_ENABLED", "true")
|
||||
return env
|
||||
|
||||
|
||||
|
||||
@ -87,6 +87,10 @@ ERROR_CODES: frozenset[str] = frozenset(
|
||||
"reload-bundle-not-installed",
|
||||
"reload-bundle-name-mismatch",
|
||||
"reload-source-missing",
|
||||
# HTTP route gate (Mode A only): the runtime guard that hides the
|
||||
# reload route on Mode B/C deployments returns this code so the
|
||||
# client gets the same typed envelope as every other reload error.
|
||||
"extension-reload-disabled",
|
||||
}
|
||||
)
|
||||
|
||||
@ -261,6 +265,10 @@ _BRANCH_TEMPLATES: dict[str, str] = {
|
||||
"reload-source-missing": (
|
||||
"Reload source path {content!r} for bundle {location!r} does not exist or is not a directory."
|
||||
),
|
||||
"extension-reload-disabled": (
|
||||
"Extension reload is disabled on this server. "
|
||||
"Set LANGFLOW_ENABLE_EXTENSION_RELOAD=true to enable it on a local-development install (Mode A)."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -53,37 +53,157 @@ def _distribution_manifest_path(dist: importlib_metadata.Distribution) -> Path |
|
||||
|
||||
extension.json wins on collision; pyproject.toml is accepted only when
|
||||
it declares ``[tool.langflow.extension]``.
|
||||
|
||||
Editable installs (``pip install -e``, ``uv pip install --editable``)
|
||||
typically expose only dist-info files via ``dist.files`` -- the source
|
||||
tree lives outside the site-packages and is reached at import time via
|
||||
a ``.pth`` shim. When the dist-info pass finds no manifest, fall back
|
||||
to ``direct_url.json`` (PEP 610) to locate the editable project root
|
||||
and look for ``extension.json`` / ``pyproject.toml`` there. Without
|
||||
this fallback, ``lfx-duckduckgo`` and friends installed via ``uv sync``
|
||||
workspace links never reach :func:`load_installed_extensions`.
|
||||
"""
|
||||
files = dist.files
|
||||
if files 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):
|
||||
# ``locate_file`` may raise on unusual setups (e.g. namespace
|
||||
# packages without a concrete root); treat as "not found"
|
||||
# without breaking the rest of the scan.
|
||||
if files is not None:
|
||||
for relative in files:
|
||||
if not relative.parts:
|
||||
continue
|
||||
if located.is_file():
|
||||
# extension.json wins outright -- skip any pyproject seen later.
|
||||
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
|
||||
last = relative.parts[-1]
|
||||
if last == "extension.json":
|
||||
try:
|
||||
located = Path(dist.locate_file(relative))
|
||||
except (OSError, ValueError):
|
||||
# ``locate_file`` may raise on unusual setups (e.g. namespace
|
||||
# packages without a concrete root); treat as "not found"
|
||||
# without breaking the rest of the scan.
|
||||
continue
|
||||
if located.is_file():
|
||||
# extension.json wins outright -- skip any pyproject seen later.
|
||||
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 and _pyproject_has_extension_section(pyproject_candidate):
|
||||
return pyproject_candidate
|
||||
|
||||
# Fallback: editable install. PEP 610 ``direct_url.json`` records the
|
||||
# source URL; for editable installs the URL is a ``file://`` path to the
|
||||
# project root, which is where the manifest lives.
|
||||
return _editable_manifest_path(dist)
|
||||
|
||||
|
||||
def _editable_manifest_path(dist: importlib_metadata.Distribution) -> Path | None:
|
||||
"""Resolve the manifest of an editable install whose ``dist.files`` is dist-info-only.
|
||||
|
||||
Tries two fallbacks in order:
|
||||
|
||||
1. The ``langflow.extensions`` entry-point group. When a bundle
|
||||
declares ``[project.entry-points."langflow.extensions"] foo = "lfx_foo"``
|
||||
the entry-point's value is the dotted package path that ships
|
||||
``extension.json``; importing that package gives us the source
|
||||
directory regardless of how the dist was installed.
|
||||
|
||||
2. PEP 610 ``direct_url.json`` for ``editable=true`` distributions.
|
||||
The recorded URL points at the project root; we look for
|
||||
``extension.json`` (or a ``[tool.langflow.extension]`` pyproject)
|
||||
directly there.
|
||||
|
||||
Returns ``None`` if neither path yields a manifest. Both paths are
|
||||
necessary because (a) installed wheels list the package's
|
||||
``extension.json`` in ``dist.files`` so they don't reach this fallback,
|
||||
and (b) editable installs that use ``langflow.extensions`` entry-points
|
||||
point at the package, while editable installs that don't may still
|
||||
have a top-level manifest.
|
||||
"""
|
||||
manifest = _manifest_via_entry_point(dist)
|
||||
if manifest is not None:
|
||||
return manifest
|
||||
return _manifest_via_direct_url(dist)
|
||||
|
||||
|
||||
def _manifest_via_entry_point(dist: importlib_metadata.Distribution) -> Path | None:
|
||||
"""Find a manifest via this distribution's ``langflow.extensions`` entry-point.
|
||||
|
||||
Locates the package directory via ``importlib.util.find_spec`` rather
|
||||
than importing the package, so a manifest-discovery pass at startup
|
||||
does not trigger arbitrary side-effects from a bundle's ``__init__``.
|
||||
"""
|
||||
import importlib.util
|
||||
|
||||
try:
|
||||
eps = dist.entry_points
|
||||
except (OSError, AttributeError, TypeError):
|
||||
return None
|
||||
if eps is None:
|
||||
return None
|
||||
|
||||
candidate_modules: list[str] = []
|
||||
for ep in eps:
|
||||
if getattr(ep, "group", None) == "langflow.extensions":
|
||||
value = (getattr(ep, "value", "") or "").split(":", 1)[0].strip()
|
||||
if value:
|
||||
candidate_modules.append(value)
|
||||
|
||||
for module_name in candidate_modules:
|
||||
try:
|
||||
spec = importlib.util.find_spec(module_name)
|
||||
except (ImportError, ValueError, ModuleNotFoundError):
|
||||
continue
|
||||
if spec is None or spec.origin is None:
|
||||
continue
|
||||
package_dir = Path(spec.origin).parent
|
||||
manifest = package_dir / "extension.json"
|
||||
if manifest.is_file():
|
||||
return manifest
|
||||
return None
|
||||
|
||||
|
||||
def _manifest_via_direct_url(dist: importlib_metadata.Distribution) -> Path | None:
|
||||
"""Resolve the manifest from PEP 610 ``direct_url.json`` for editable installs."""
|
||||
import json
|
||||
from urllib.parse import urlparse
|
||||
|
||||
try:
|
||||
raw = dist.read_text("direct_url.json")
|
||||
except (OSError, FileNotFoundError, AttributeError):
|
||||
# ``AttributeError`` covers test doubles that don't implement the
|
||||
# full ``Distribution`` interface; ``OSError`` covers the production
|
||||
# case where the file is missing or unreadable.
|
||||
return None
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
if not payload.get("dir_info", {}).get("editable"):
|
||||
return None
|
||||
|
||||
url = payload.get("url")
|
||||
if not isinstance(url, str):
|
||||
return None
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme != "file":
|
||||
return None
|
||||
project_root = Path(parsed.path)
|
||||
if not project_root.is_dir():
|
||||
return None
|
||||
|
||||
manifest = project_root / "extension.json"
|
||||
if manifest.is_file():
|
||||
return manifest
|
||||
|
||||
pyproject = project_root / "pyproject.toml"
|
||||
if pyproject.is_file() and _pyproject_has_extension_section(pyproject):
|
||||
return pyproject
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@ -15,6 +15,11 @@
|
||||
"import_path": "lfx.components.duckduckgo.DuckDuckGoSearchComponent",
|
||||
"target": "ext:duckduckgo:DuckDuckGoSearchComponent@official",
|
||||
"added_in": "1.10.0"
|
||||
},
|
||||
{
|
||||
"legacy_slot": "ext:duckduckgo:DuckDuckGoSearchComponent@official-pre-a",
|
||||
"target": "ext:duckduckgo:DuckDuckGoSearchComponent@official",
|
||||
"added_in": "1.10.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -108,6 +108,70 @@ def test_no_installed_extensions_returns_empty(tmp_path: Path) -> None:
|
||||
assert results == []
|
||||
|
||||
|
||||
def test_editable_install_discovered_via_direct_url(tmp_path: Path) -> None:
|
||||
"""Editable installs (``pip install -e``) typically expose only dist-info files.
|
||||
|
||||
The dist-info pass finds no ``extension.json``; the loader must fall
|
||||
back to PEP 610 ``direct_url.json`` (``editable=true``) which records
|
||||
the source path. Without this fallback, an ``lfx-duckduckgo`` linked
|
||||
via ``uv sync`` workspace member never reaches
|
||||
:func:`load_installed_extensions` -- the dogfood / dev case skipped
|
||||
the bundle entirely.
|
||||
"""
|
||||
# Lay out a real extension on disk to be the "editable source".
|
||||
project_root = tmp_path / "src" / "bundles" / "duckduckgo"
|
||||
bundle_dir = project_root / "components"
|
||||
bundle_dir.mkdir(parents=True)
|
||||
(project_root / "extension.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"id": "lfx-pilot",
|
||||
"version": "1.0.0",
|
||||
"name": "lfx-pilot",
|
||||
"lfx": {"compat": ["1"]},
|
||||
"bundles": [{"name": "pilot", "path": "components"}],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(bundle_dir / "thing.py").write_text(component_source(), encoding="utf-8")
|
||||
|
||||
class _EditableDist(FakeDist):
|
||||
"""FakeDist that exposes the same surface as ``importlib.metadata.Distribution``.
|
||||
|
||||
Editable installs leave ``files`` listing only dist-info entries;
|
||||
the manifest is reachable only via ``read_text("direct_url.json")``.
|
||||
"""
|
||||
|
||||
def __init__(self, name: str, project_root: Path) -> None:
|
||||
super().__init__(name=name, root=tmp_path, files=[Path("dist-info") / "METADATA"])
|
||||
self._direct_url = json.dumps(
|
||||
{
|
||||
"url": project_root.resolve().as_uri(),
|
||||
"dir_info": {"editable": True},
|
||||
}
|
||||
)
|
||||
|
||||
def read_text(self, name: str) -> str | None:
|
||||
if name == "direct_url.json":
|
||||
return self._direct_url
|
||||
return None
|
||||
|
||||
@property
|
||||
def entry_points(self):
|
||||
return []
|
||||
|
||||
dist = _EditableDist("lfx-pilot", project_root)
|
||||
|
||||
results = load_installed_extensions(distributions=[dist])
|
||||
assert len(results) == 1
|
||||
result = results[0]
|
||||
assert result.ok, [e.code for e in result.errors]
|
||||
assert result.distribution == "lfx-pilot"
|
||||
assert result.source_path == project_root.resolve()
|
||||
assert result.components, "Editable bundle's components must load"
|
||||
|
||||
|
||||
def test_results_are_lexicographically_ordered(tmp_path: Path) -> None:
|
||||
"""Order is deterministic so events / logging are reproducible."""
|
||||
for name in ("lfx-zeta", "lfx-alpha", "lfx-mike"):
|
||||
|
||||
@ -264,11 +264,12 @@ def test_dev_refuses_non_directory(
|
||||
def test_dev_launch_env_enables_reload_and_eager_loading() -> None:
|
||||
"""The launched langflow inherits flags that make 'edit -> Reload' work.
|
||||
|
||||
Without ``LANGFLOW_ENABLE_EXTENSION_RELOAD=true`` the backend route is
|
||||
not registered as a usable handler and the documented author loop
|
||||
404s. Without ``LANGFLOW_LAZY_LOAD_COMPONENTS=false`` dev components
|
||||
miss the palette's 5-second budget. Both must be set unconditionally
|
||||
or via setdefault per the helper's contract.
|
||||
Without ``LANGFLOW_ENABLE_EXTENSION_RELOAD=true`` the backend reload
|
||||
handler returns 404 (the runtime guard reads
|
||||
``settings.enable_extension_reload``) AND the ``/config`` payload
|
||||
reports the flag as off, which keeps the packaged frontend from
|
||||
showing the Reload button. Without ``LANGFLOW_LAZY_LOAD_COMPONENTS=false``
|
||||
dev components miss the palette's 5-second budget.
|
||||
"""
|
||||
from lfx.cli._extension_commands import _build_dev_launch_env
|
||||
|
||||
@ -276,7 +277,6 @@ def test_dev_launch_env_enables_reload_and_eager_loading() -> None:
|
||||
|
||||
assert env["LANGFLOW_LAZY_LOAD_COMPONENTS"] == "false"
|
||||
assert env["LANGFLOW_ENABLE_EXTENSION_RELOAD"] == "true"
|
||||
assert env["LANGFLOW_EXTENSION_RELOAD_ENABLED"] == "true"
|
||||
|
||||
|
||||
def test_dev_launch_env_overrides_author_lazy_loading() -> None:
|
||||
@ -295,19 +295,13 @@ def test_dev_launch_env_overrides_author_lazy_loading() -> None:
|
||||
def test_dev_launch_env_respects_author_reload_off_path() -> None:
|
||||
"""An author testing the off path can pre-export the disable flag.
|
||||
|
||||
The reload flags use setdefault so an explicit ``=false`` exported
|
||||
The reload flag uses setdefault so an explicit ``=false`` exported
|
||||
in the shell survives the helper's defaulting.
|
||||
"""
|
||||
from lfx.cli._extension_commands import _build_dev_launch_env
|
||||
|
||||
env = _build_dev_launch_env(
|
||||
{
|
||||
"LANGFLOW_ENABLE_EXTENSION_RELOAD": "false",
|
||||
"LANGFLOW_EXTENSION_RELOAD_ENABLED": "false",
|
||||
}
|
||||
)
|
||||
env = _build_dev_launch_env({"LANGFLOW_ENABLE_EXTENSION_RELOAD": "false"})
|
||||
assert env["LANGFLOW_ENABLE_EXTENSION_RELOAD"] == "false"
|
||||
assert env["LANGFLOW_EXTENSION_RELOAD_ENABLED"] == "false"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@ -100,6 +100,7 @@ _FIRST_LINE_EXPECTATIONS: dict[str, str] = {
|
||||
"reload-bundle-not-installed": "error[reload-bundle-not-installed]: Cannot reload bundle 'content': it is not registered. Install the extension first or pass an explicit source path.", # noqa: E501
|
||||
"reload-bundle-name-mismatch": "error[reload-bundle-name-mismatch]: Reload source at loc declares bundle name 'content', which does not match the registered bundle being reloaded.", # noqa: E501
|
||||
"reload-source-missing": "error[reload-source-missing]: Reload source path 'content' for bundle 'loc' does not exist or is not a directory.", # noqa: E501
|
||||
"extension-reload-disabled": "error[extension-reload-disabled]: Extension reload is disabled on this server. Set LANGFLOW_ENABLE_EXTENSION_RELOAD=true to enable it on a local-development install (Mode A).", # noqa: E501
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user