mirror of
https://github.com/langflow-ai/langflow.git
synced 2026-07-24 06:10:49 +08:00
* feat(extension): add manifest-less lfx.bundles discovery + precedence tier Foundation for the bundle metapackage split (1.11). Adds a third @official-slot discovery source: a distribution declaring the [project.entry-points."lfx.bundles"] group ships a package whose immediate subdirectories are each a manifest-less bundle, folder-walked and registered at @official with no extension.json (the langchain-community model). - new loader/_bundles_root.py::load_lfx_bundles_extensions, mirroring discover_inline_bundles but sourcing roots from the lfx.bundles entry-point group via find_spec and reusing _load_bundle_directory - discovery precedence becomes installed > seed > lfx_bundles > dev > inline so a manifest-shipping lfx-<provider> always shadows the same-named provider in the metapackage (lets a provider graduate with no lockstep release), emitting the existing bundle-shadowed warning - new bundle-discovery-malformed warning code, emitted on warnings (never flips ok / aborts startup) for unresolvable declarations and invalid provider folder names - exported via loader/__init__ and the lfx.extension PEP-562 lazy surface 10 new tests in test_load_lfx_bundles.py; full extension unit suite (449) passes; ruff + format clean; mypy clean on the new module. * docs(bundles): changelog entry for the lfx.bundles discovery surface The BUNDLE_API.md changelog gate diffs each PR's branch against main, so the entry covering this PR's surface additions (lfx.bundles discovery, the lfx_bundles precedence tier, bundle-discovery-malformed) must live on this branch, not only further up the stack. Text is verbatim from the graduate-partners commit so the stacked merge dedupes trivially. * fix(extension): reject plain-module lfx.bundles targets; correct validate wording CodeRabbit review fixes on #13563: - _spec_package_dir no longer falls back to a plain module's parent directory -- a single-file entry-point target now emits the bundle-discovery-malformed warning instead of folder-walking unrelated sibling directories as bundles (+ regression test) - BUNDLE_API.md / loader docstring no longer say manifest-less providers are 'exempt from lfx extension validate'; they are not valid validator input (validate requires a manifest and reports manifest-not-found) * fix(extension): review fixes — shadowed metapackage providers never import; typed manifest-less reload refusal Review findings on #13563: - A manifest-less provider whose bundle name is already claimed by a higher-precedence @official source (installed/seed) is now skipped WITHOUT importing: all @official sources share the _lfx_ext.official.<bundle>.* sys.modules namespace, so importing the losing copy overwrote the winner's live modules even though _resolve_bundle_shadowing dropped its components afterwards. The orchestrator passes the claimed-name map (built by the new _claimed_official_bundles helper, mirroring the resolver's winner rule); the skipped copy still emits the typed bundle-shadowed warning. This collision is the expected post-graduation state, not an error. - Manifest-less records now carry a manifestless provenance flag (additive field on LoadResult + BundleRecord). The reload pipeline refuses them with the new typed reload-manifestless-unsupported code instead of routing through load_extension and failing with a misleading manifest-not-found. Hot reload of metapackage providers is a process-restart concern (pip-installed content); a loader-based reload path can follow if QA wants it. BUNDLE_API.md changelog entry extended for both surface changes. * fix(extension): harden lfx.bundles discovery per review — broad find_spec guard, namespace portions, per-mode error codes Review findings from ogabrielluiz on #13563: - find_spec on a dotted declaration imports the PARENT package, whose __init__ can raise anything; a non-Import error escaped the old catch tuple and (through the palette cache's catch-all) wiped every source's components for the boot. Catch Exception and degrade to the malformed sentinel; comment corrected. - Namespace packages: walk ALL submodule_search_locations portions (one root per portion) instead of only locations[0], and dedupe resolved roots by path so duplicate entry-point declarations (or overlapping portions) never walk a directory twice and self-shadow. _spec_package_dir -> _spec_package_dirs. - Split bundle-discovery-malformed into one code per failure mode, mirroring the inline tier: bundles-provider-name-invalid (rename the directory), bundles-root-unreadable (check permissions), keeping bundle-discovery-malformed for unresolvable declarations (fix the entry-point). Same-tier duplicates get duplicate-lfx-bundles-provider instead of overloading bundle-shadowed, whose rendered template (format_extension_error renders templates, not ad-hoc messages) was wrong on both counts for that case. - The claimed-bundles cross-source skip now emits bundle-shadowed on errors (matching _resolve_bundle_shadowing) so filtering by code never mixes severities; bundle-shadowed is already in the CLI warn-only set. 3 new regression tests (raising parent package, namespace portions, duplicate entry points); BUNDLE_API changelog updated. * feat(bundles): create lfx-bundles metapackage skeleton (#13564) * feat(bundles): add lfx-bundles metapackage skeleton Creates the manifest-less lfx-bundles distribution (the langchain-community model) that the long-tail providers will move into. Empty skeleton for now; the bulk move (scripts/migrate/consolidate_bundles.py) populates the provider folders + per-provider extras later. - src/bundles/lfx-bundles: a single pyproject declaring the lfx.bundles entry-point (lfx_bundles = "lfx_bundles"), an lfx>=1.10.0,<2.0.0 pin, and a generated (currently empty) `all` extra; plus a bare lfx_bundles namespace package and a README documenting the model + install stories - wired into the root workspace via the existing bundle marker blocks: dep lfx-bundles[all]>=1.0,<2.0, uv source, and member - hyphen dir name so release.yml's src/bundles/*/pyproject.toml glob builds it with zero workflow change Verified: the wheel builds (entry_points.txt carries the lfx.bundles group); load_lfx_bundles_extensions (PR-1) discovers the entry point and the empty skeleton registers zero providers with no error. * fix(ci): nightly bundle rename follows [extras] refs and self-refs Two gaps in update_bundle_versions.py around extras suffixes, both fatal or silently wrong for the first nightly carrying the lfx-bundles metapackage: - update_root_pyproject_for_bundle's dep regex required the version specifier immediately after the name, so "lfx-bundles[all]>=1.0,<2.0" (a MAIN dep) was left unrewritten while the workspace member was renamed to lfx-bundles-nightly; uv lock then tries to resolve stable lfx-bundles from PyPI, where it does not exist. The same gap silently left "lfx-docling[local]>=0.1.0" optional-dep refs pointing at the stable distribution. The regex now tolerates an [extras] group and carries it into the replacement. - rename_bundle_pyproject skipped self-referencing extras, so the metapackage's generated `all` extra kept 45 "lfx-bundles[<provider>]" members after the rename, pulling the stable distribution (same lfx_bundles import package, install collision) once published. Self refs now follow the rename, idempotently. Tests drive the real script module, mirroring test_bundle_lfx_pin.py. This closes the PR-2 audit item flagged when the metapackage was introduced. The canonical-pre-release cutover would retire the nightly rename entirely; until it lands, the rename must be correct. * fix(bundles): address review — drop orphaned nightly bundle-rename script, fix README install stories - Delete scripts/ci/update_bundle_versions.py and its tests: the nightly bundle-rename track it served was retired by the canonical pre-release cutover (src/bundles/NIGHTLY.md) and no workflow or Makefile target on main or any release branch invokes it. Stale doc references in sync_bundle_lfx_pin.py and test_bundle_lfx_pin.py updated to match. - README: split the install section into what works today (langflow, bare lfx) vs what arrives with the bulk move / engine-only split (lfx[bundles], per-provider lfx-bundles[<provider>] extras), and note the generated all extra is empty until the first provider tranche lands. * ci(bundles): cross-bundle test matrix (lfx contract axis) (#13566) * ci(bundles): add cross-bundle test matrix (lfx contract axis) Tests every extracted bundle (the lfx-bundles metapackage + each graduated lfx-<provider>) against the lfx contract surface it depends on -- overdue since 1.10 left 4 independently-versioned bundles depending on lfx. - .github/workflows/cross-bundle-test.yml: discovers bundles via the same src/bundles/*/pyproject.toml glob release.yml uses, then per (bundle x python 3.10/3.13): installs the in-repo lfx + the bundle, imports the declared entry-point package, asserts lfx.bundles discovery is error-free (for the metapackage), runs `lfx extension validate` for manifest bundles, and runs the bundle's own tests/. - Triggers: pull_request (src/bundles, src/lfx), workflow_dispatch, a weekly schedule, and workflow_call. The lfx-minor axis seeds with the in-repo lfx (the 1.10 line is unpublished); when minors publish, add the version dimension (oldest+latest get full tests, every supported minor gets contract-smoke) per the epic's cost-control shape. Verified: the workflow YAML parses and the contract-smoke logic imports a real bundle (lfx_arxiv) cleanly. * fix(ci): cross-bundle smoke tolerates extras-less SDK degradation Review findings on the cross-bundle matrix: - CRITICAL: the contract smoke asserted zero discovery errors, but the CI venv installs lfx-bundles WITHOUT per-provider extras, so providers whose modules import their SDK at top level degrade with module-import-failed -- the expected graceful-degradation contract. The smoke now fails only on structural codes and reports the degraded-module count. (Graduated partner bundles carry their deps directly, not as extras, so their steps are unaffected.) - the scheduled run now actually delivers the exhaustive grid the header promised (all supported Pythons on schedule; oldest+latest on PRs) - concurrency group with cancel-in-progress on PRs so stacked bundle PRs don't queue N-bundles x N-pythons jobs per push * ci(bundles): tomli fallback for the py3.10 cross-bundle smoke tomllib is stdlib only from Python 3.11, so the contract-smoke heredoc failed with ModuleNotFoundError on every py3.10 matrix leg. Install the tomli backport into the smoke venv and import it as a fallback. * fix(test): coherent sys.modules restore in the ibm without_ibm_db fixture The cross-bundle matrix's py3.10 leg exposed test-ordering pollution in the ibm bundle suite: without_ibm_db monkeypatch-deleted three lfx_ibm modules and re-imported them mid-test, but entries created during the test survive teardown (delitem with raising=False records nothing for keys it didn't find) and re-imported parents never regain the submodule attribute bindings that mock.patch target resolution walks on Python 3.10 (3.11+ mock resolves via sys.modules and tolerates the incoherence). Two fixture uses in sequence left the tree inconsistent and 29 later watsonx tests failed with AttributeError on the package. Snapshot, drop, and restore the entire lfx_ibm module tree wholesale instead. Full suite now passes on both 3.10 and 3.13. * feat(lfx): add lfx[bundles] extra and keep lfx engine-only (#13565) * feat(bundles): add lfx-bundles metapackage skeleton Creates the manifest-less lfx-bundles distribution (the langchain-community model) that the long-tail providers will move into. Empty skeleton for now; the bulk move (scripts/migrate/consolidate_bundles.py) populates the provider folders + per-provider extras later. - src/bundles/lfx-bundles: a single pyproject declaring the lfx.bundles entry-point (lfx_bundles = "lfx_bundles"), an lfx>=1.10.0,<2.0.0 pin, and a generated (currently empty) `all` extra; plus a bare lfx_bundles namespace package and a README documenting the model + install stories - wired into the root workspace via the existing bundle marker blocks: dep lfx-bundles[all]>=1.0,<2.0, uv source, and member - hyphen dir name so release.yml's src/bundles/*/pyproject.toml glob builds it with zero workflow change Verified: the wheel builds (entry_points.txt carries the lfx.bundles group); load_lfx_bundles_extensions (PR-1) discovers the entry point and the empty skeleton registers zero providers with no error. * feat(lfx): add lfx[bundles] extra and keep lfx engine-only lfx ships no bundles by default. The new optional extra pulls the long-tail metapackage for users who want the provider components without the full langflow server install. - src/lfx/pyproject.toml: [project.optional-dependencies] bundles = ["lfx-bundles[all]>=1.0,<2.0"]; intentionally NO lfx[all] - pip install lfx -> engine only pip install "lfx[bundles]" -> engine + the lfx-bundles long tail (documented as a headless/serverless deployment footnote, not a headline) Verified against the built lfx wheel METADATA: lfx-bundles appears only under `extra == "bundles"`, never in core Requires-Dist, so the engine-only default is preserved. * fix(ci): nightly bundle rename follows [extras] refs and self-refs Two gaps in update_bundle_versions.py around extras suffixes, both fatal or silently wrong for the first nightly carrying the lfx-bundles metapackage: - update_root_pyproject_for_bundle's dep regex required the version specifier immediately after the name, so "lfx-bundles[all]>=1.0,<2.0" (a MAIN dep) was left unrewritten while the workspace member was renamed to lfx-bundles-nightly; uv lock then tries to resolve stable lfx-bundles from PyPI, where it does not exist. The same gap silently left "lfx-docling[local]>=0.1.0" optional-dep refs pointing at the stable distribution. The regex now tolerates an [extras] group and carries it into the replacement. - rename_bundle_pyproject skipped self-referencing extras, so the metapackage's generated `all` extra kept 45 "lfx-bundles[<provider>]" members after the rename, pulling the stable distribution (same lfx_bundles import package, install collision) once published. Self refs now follow the rename, idempotently. Tests drive the real script module, mirroring test_bundle_lfx_pin.py. This closes the PR-2 audit item flagged when the metapackage was introduced. The canonical-pre-release cutover would retire the nightly rename entirely; until it lands, the rename must be correct. * ci(bundles): freeze lfx/components against new top-level providers (#13567) * feat(bundles): add lfx-bundles metapackage skeleton Creates the manifest-less lfx-bundles distribution (the langchain-community model) that the long-tail providers will move into. Empty skeleton for now; the bulk move (scripts/migrate/consolidate_bundles.py) populates the provider folders + per-provider extras later. - src/bundles/lfx-bundles: a single pyproject declaring the lfx.bundles entry-point (lfx_bundles = "lfx_bundles"), an lfx>=1.10.0,<2.0.0 pin, and a generated (currently empty) `all` extra; plus a bare lfx_bundles namespace package and a README documenting the model + install stories - wired into the root workspace via the existing bundle marker blocks: dep lfx-bundles[all]>=1.0,<2.0, uv source, and member - hyphen dir name so release.yml's src/bundles/*/pyproject.toml glob builds it with zero workflow change Verified: the wheel builds (entry_points.txt carries the lfx.bundles group); load_lfx_bundles_extensions (PR-1) discovers the entry point and the empty skeleton registers zero providers with no error. * ci(bundles): freeze lfx/components against new top-level providers After the metapackage split, new providers go to lfx-bundles (or a graduated lfx-<provider>), never in-tree. Adds an additions-only CI gate. - scripts/ci/check_components_frozen.py: stdlib gate comparing the live top-level dir listing of src/lfx/src/lfx/components/ against a committed baseline; fails on any directory not in the baseline. Removals are allowed so M4 shim cleanup (which shrinks the set) never trips it. - scripts/ci/frozen_component_dirs.txt: the 111-dir baseline. - .github/workflows/lint-py.yml: new freeze-components job runs the gate (stdlib-only, no uv sync). Verified locally: passes on the baseline, fails (exit 1) on a simulated new provider directory with an actionable message, passes again after cleanup. * fix(ci): freeze gate counts only dirs shipping __init__.py Review finding: a stray directory holding only __pycache__ bytecode (left behind by a branch switch) tripped the additions-only gate locally. A directory without __init__.py is not a provider; ignore it. * fix(ci): nightly bundle rename follows [extras] refs and self-refs Two gaps in update_bundle_versions.py around extras suffixes, both fatal or silently wrong for the first nightly carrying the lfx-bundles metapackage: - update_root_pyproject_for_bundle's dep regex required the version specifier immediately after the name, so "lfx-bundles[all]>=1.0,<2.0" (a MAIN dep) was left unrewritten while the workspace member was renamed to lfx-bundles-nightly; uv lock then tries to resolve stable lfx-bundles from PyPI, where it does not exist. The same gap silently left "lfx-docling[local]>=0.1.0" optional-dep refs pointing at the stable distribution. The regex now tolerates an [extras] group and carries it into the replacement. - rename_bundle_pyproject skipped self-referencing extras, so the metapackage's generated `all` extra kept 45 "lfx-bundles[<provider>]" members after the rename, pulling the stable distribution (same lfx_bundles import package, install collision) once published. Self refs now follow the rename, idempotently. Tests drive the real script module, mirroring test_bundle_lfx_pin.py. This closes the PR-2 audit item flagged when the metapackage was introduced. The canonical-pre-release cutover would retire the nightly rename entirely; until it lands, the rename must be correct. * feat(bundles): move 45 long-tail providers into lfx-bundles + graduate 5 partner packages (#13568) * feat(bundles): add lfx-bundles metapackage skeleton Creates the manifest-less lfx-bundles distribution (the langchain-community model) that the long-tail providers will move into. Empty skeleton for now; the bulk move (scripts/migrate/consolidate_bundles.py) populates the provider folders + per-provider extras later. - src/bundles/lfx-bundles: a single pyproject declaring the lfx.bundles entry-point (lfx_bundles = "lfx_bundles"), an lfx>=1.10.0,<2.0.0 pin, and a generated (currently empty) `all` extra; plus a bare lfx_bundles namespace package and a README documenting the model + install stories - wired into the root workspace via the existing bundle marker blocks: dep lfx-bundles[all]>=1.0,<2.0, uv source, and member - hyphen dir name so release.yml's src/bundles/*/pyproject.toml glob builds it with zero workflow change Verified: the wheel builds (entry_points.txt carries the lfx.bundles group); load_lfx_bundles_extensions (PR-1) discovers the entry point and the empty skeleton registers zero providers with no error. * feat(bundles): consolidate first long-tail tranche into lfx-bundles Adds scripts/migrate/consolidate_bundles.py (the inverse of port_bundle.py -- moves in-tree providers into the manifest-less lfx-bundles metapackage) and runs it on a verified 5-provider tranche: tavily, exa, wikipedia, yahoosearch, wolframalpha. Per provider the script: - moves src/lfx/src/lfx/components/<p>/ -> lfx_bundles/<p>/ (lowercase names); - leaves a fail-soft import shim (first line `# lfx-bundles-shim`) so `from lfx.components.<p> import X` keeps working when lfx-bundles is installed, and raises an actionable ImportError otherwise; - merges the provider's third-party deps into a PEP 685-normalized lfx-bundles extra and regenerates the `all` aggregate. Dep parity holds: `uv sync` is a no-op because those deps were already pulled via langflow-base[complete]; - appends the 4-entry migration block per Component class (28 entries) so saved flows referencing lfx.components.<p>.<Class> migrate to ext:<p>:<Class>@official. To avoid double registration, the in-tree component walk (_load_components_dynamically) now skips shimmed provider dirs, and component_index.json is regenerated (355->348 components, 95->90 modules); the moved providers load only at @official via lfx.bundles discovery. Verified: discovery finds all 5 at @official with no errors; shims resolve; `import lfx.components` still works; the index drops the 7 moved component entries (residual `tools`-category name refs resolve via the shim); ruff clean. First tranche proves the engine; the remaining long-tail scales by extending PROVIDER_DEPS (each provider's deps verified individually -- the careful part). * feat(bundles): consolidate 30-provider tranche 2 into lfx-bundles Extends PROVIDER_DEPS with 30 individually-verified providers and runs the consolidation: vector stores (chroma, clickhouse, couchbase, milvus, mongodb, pgvector, pinecone, qdrant, supabase, upstash, weaviate), model providers (groq, mistral, ollama, perplexity, sambanova), and tools/memory/data (apify, assemblyai, confluence, firecrawl, git, glean, icosacomputing, mem0, needle, scrapegraph, serpapi, unstructured, youtube, zep). Dep verification (the careful part): every spec comes from langflow-base's per-provider extras or its direct dependencies; langchain-community providers carry the wrapper plus the SDK the wrapper lazy-imports (e.g. pgvector, atlassian-python-api); requests is declared explicitly where imported (it is only transitive in today's env); pinecone keeps its python_version<'3.14' marker verbatim. Tranche excludes: providers with langflow imports (vlmrun), provider-specific lfx.base dirs (composio/huggingface/langwatch), case- sensitive names (FAISS/Notion), the openai-SDK family (azure/aiml/deepseek/ litellm/lmstudio/novita/openrouter/vllm/xai/cometapi -- cleaner after PR-8), and the partner set. Dep parity verified at the resolution level: the uv.lock diff is +220 lines of lfx-bundles extras metadata with ZERO packages added or removed (`name =` diff empty), so pip install langflow resolves the identical set. Also: 192 append-only migration entries (48 classes x 4, zero bare-name ambiguities); component_index.json regenerated 348->300 components / 90->60 modules (exactly the moved set, no stale standalone entries); mongodb_atlas.py SLF001 per-file-ignore and the mem0/mongodb detect-secrets baseline entries migrated to the new paths (lint/secrets exceptions travel with moved files); 35 bundles now discover at @official with 55 components; shims verified across categories; 449 extension tests pass. * feat(bundles): consolidate openai-SDK family tranche 3 into lfx-bundles 10 providers that ride the langchain-openai wrapper, deferred from tranche 2 until the partner graduation settled the openai-SDK dep story: aiml, azure, cometapi, deepseek, litellm, lmstudio, novita, openrouter, vllm, xai. Dep verification: every provider declares langchain-openai>=1.1.6; the openai SDK is declared only where a component imports it directly (aiml, deepseek, litellm, lmstudio, vllm, xai -- wrapper-transitive elsewhere); requests declared where imported (cometapi, deepseek, novita, xai); lmstudio's lazy NVIDIAEmbeddings path gets langchain-nvidia-ai-endpoints~=1.0.0. The litellm component drives LiteLLM-served endpoints through the OpenAI client and does NOT import the litellm package -- langflow-base's litellm extra stays put. These providers use the lazy _dynamic_imports __init__ shape; it survives the move unchanged (import_mod resolves via __spec__.parent and lfx.components._importing remains a core helper). Dep parity: uv.lock diff has zero package additions/removals (all specs already resolved via langflow-base[complete]). Also: 56 append-only migration entries (14 classes x 4, zero ambiguities); component_index.json regenerated 300->286 components / 60->50 modules (exactly the moved set); detect-secrets baseline re-keyed; 45 bundles now discover at @official with 69 components; shims verified (azure/xai/litellm/deepseek); ruff clean. * fix(ci): nightly bundle rename follows [extras] refs and self-refs Two gaps in update_bundle_versions.py around extras suffixes, both fatal or silently wrong for the first nightly carrying the lfx-bundles metapackage: - update_root_pyproject_for_bundle's dep regex required the version specifier immediately after the name, so "lfx-bundles[all]>=1.0,<2.0" (a MAIN dep) was left unrewritten while the workspace member was renamed to lfx-bundles-nightly; uv lock then tries to resolve stable lfx-bundles from PyPI, where it does not exist. The same gap silently left "lfx-docling[local]>=0.1.0" optional-dep refs pointing at the stable distribution. The regex now tolerates an [extras] group and carries it into the replacement. - rename_bundle_pyproject skipped self-referencing extras, so the metapackage's generated `all` extra kept 45 "lfx-bundles[<provider>]" members after the rename, pulling the stable distribution (same lfx_bundles import package, install collision) once published. Self refs now follow the rename, idempotently. Tests drive the real script module, mirroring test_bundle_lfx_pin.py. This closes the PR-2 audit item flagged when the metapackage was introduced. The canonical-pre-release cutover would retire the nightly rename entirely; until it lands, the rename must be correct. * feat(bundles): graduate partner set to standalone lfx-<provider> packages (#13573) * feat(bundles): graduate partner set to standalone lfx-<provider> packages Extracts the five partner/flagship providers into manifest-shipping distributions: lfx-openai, lfx-anthropic, lfx-amazon, lfx-datastax, lfx-cohere. Each ships extension.json (lfx.compat ["1"]), a langflow.extensions entry-point, an lfx>=1.10.0,<2.0.0 pin, and is wired into the root workspace marker blocks. Zero flow impact by the bundle-name invariant: ext:<provider>:<Class>@official ids are unchanged. Mechanics: - adopts the five-phase scripts/migrate/port_bundle.py from feat/bundle-mass-extraction (handles shared-base moves, consumer rewrites, surgical index updates, migration-table appends) and runs it per partner with --migration-release 1.11.0 - datastax's shared lfx.base.datastax moves into lfx_datastax.base; its backend unit tests move to src/bundles/datastax/tests (74 pass); its ruff per-file-ignore and its check_component_env_writes ALLOWLIST entry travel; 10 repo consumers rewritten (incl. the vector_store_rag starter project) - amazon_bedrock_converse.py legacy langflow.* imports rewritten to the lfx.* equivalents (thin re-export aliases; behavior-identical) pre-extraction - runtime deps pinned from langflow-base's extras / direct deps (wrapper- guaranteed SDKs stay transitive, parity-exact); --remove-base-extra dropped the openai/anthropic/cohere/aws extras from langflow-base[complete] in favor of the bundle pins; the astradb extra stays (also used outside datastax) - in-tree dirs replaced with marker shims pointing at lfx_<p>.components.<p> (skipped by the in-tree walk; legacy from lfx.components.<p> imports keep working); lfx/components/__init__.py entries kept, consistent with the consolidated providers - validate.py: accept classes whose base is a *derived* Component base (LCVectorStoreComponent / LCToolComponent / ...) -- they inherit the class-level outputs declaration and only override the output method, which the AST-only check could not see; bare Component subclasses keep the strict build/outputs requirement (+ regression test) - BUNDLE_API.md changelog entries added for the branch's surface changes: the lfx.bundles manifest-less discovery group + precedence tier + bundle-discovery-malformed code (from the foundation PR) and the validator acceptance above - 100 append-only migration entries (20 classes x 5 partners x 4 shapes); component_index.json surgically updated 300->280 components / 60->55 modules, sha256 recomputed and verified - detect-secrets baseline re-keyed for moved partner files Dep parity: lock diff adds only the five lfx-* package names; no third-party package added or removed. One benign transitive re-resolution: anthropic SDK 0.105.2 -> 0.109.0 (allowed by langchain-anthropic's range on both sides). Verified: all five register at @official via the installed-manifest tier (extension_id/distribution = lfx-<p>, 20 components); manifest-less lfx_bundles unchanged (35 bundles, disjoint); all 5 shims import; engine-safe; `lfx extension validate` passes for all five; 450 extension unit tests, 60 pilot-upgrade migration tests, 74 moved datastax tests pass; ruff clean. * fix(bundles): shim lfx.base.datastax so stored-flow imports keep resolving The datastax graduation moved lfx.base.datastax into lfx_datastax.base, but saved flows and starter templates (Hybrid Search RAG, TemplateAssistant) embed `from lfx.base.datastax.astradb_base import AstraDBBaseComponent` inside their stored component code fields, which is re-executed verbatim at flow build time. Without a shim that import raises ModuleNotFoundError and the flows fail to build (test-starter-projects red). Mirror the lfx.components shim contract: module-aliasing to lfx_datastax.base, narrow except that only translates a missing bundle (transitive dep failures re-raise untouched), marker-tagged single-file dir, removed at M4 together with the components shims. * chore(bundles): bounded version ranges for curated lfx-* packages (#13576) langflow's pyproject is the release-coordination point now that lfx and the lfx-* bundles release independently. Every curated lfx-* dependency declares a BOUNDED range (>=A,<B), never an exact pin -- exact pins in reusable library metadata create resolver conflicts for downstreams that depend on a different version. Exact pins for reproducibility live in uv.lock and the release-build manifests. - the 4 pilots + 5 graduated partners: >=0.1.0 -> >=0.1.0,<1.0.0 (bundles follow their own 0.1.x cadence) - lfx-bundles[all]>=1.0,<2.0 unchanged (versions with the metapackage contract) - the three lfx-docling[...] optional-dependency refs bounded the same way - policy documented inline in the bundle-deps marker block; the cross-bundle CI matrix verifies the ranges resolve together across the supported lfx axis Verified: uv lock resolves with zero package/version changes (bounds are metadata-only today); the nightly rename's dep regex already matches the bounded form. * test(bundles): lock the in-tree shim contract + breakage audit (#13577) * chore(bundles): bounded version ranges for curated lfx-* packages langflow's pyproject is the release-coordination point now that lfx and the lfx-* bundles release independently. Every curated lfx-* dependency declares a BOUNDED range (>=A,<B), never an exact pin -- exact pins in reusable library metadata create resolver conflicts for downstreams that depend on a different version. Exact pins for reproducibility live in uv.lock and the release-build manifests. - the 4 pilots + 5 graduated partners: >=0.1.0 -> >=0.1.0,<1.0.0 (bundles follow their own 0.1.x cadence) - lfx-bundles[all]>=1.0,<2.0 unchanged (versions with the metapackage contract) - the three lfx-docling[...] optional-dependency refs bounded the same way - policy documented inline in the bundle-deps marker block; the cross-bundle CI matrix verifies the ranges resolve together across the supported lfx axis Verified: uv lock resolves with zero package/version changes (bounds are metadata-only today); the nightly rename's dep regex already matches the bounded form. * test(bundles): lock the in-tree shim contract + breakage audit Locks the five-point contract for the 50 lfx-bundles import shims under lfx/components/ (45 metapackage + 5 graduated partners): 1. first line is the `# lfx-bundles-shim` marker (keys the in-tree walk's double-registration skip); 2. a shim dir is exactly one __init__.py -- no impls, no deps; 3. sys.modules module-aliasing (submodule trees resolve); 4. bundle missing -> actionable ModuleNotFoundError whose wording is part of the contract ("pip install lfx-bundles" / "pip install lfx-<p>"); 5. a missing *transitive* dep re-raises untouched. Test shape (env-adaptive by design -- the lfx test env prunes the venv, so nothing here assumes bundles are installed): - source-level sweep of every real shim, parameterized (no imports); - hermetic mechanism tests against a synthetic shim + synthetic target (alias-resolves / locked-message / transitive-reraise); - the walk-skip detector and the marker sweep must agree exactly; - one adaptive live test on lfx.components.tavily exercising whichever branch the running env is in. 106 tests pass in the pruned lfx env (where the live test exercises the bare-engine locked-message branch). Breakage audit (gh code search, recorded in the PR): ~20-25 public repos reference lfx.components.*; the majority are langflow forks (vendored tree, unaffected by packaging). Genuine library consumers exist and the moved providers have real surface (openai: 11 external repos, datastax: 17) -- covered by the shims wherever bundles are co-installed (every `pip install langflow`). Decision: deprecation warnings NOT warranted now; revisit at M4 (shim removal), where one loud minor release before removal is the right shape. * test(bundles): extend the shim contract sweep to lfx.base shims The datastax graduation moved lfx.base.datastax into lfx_datastax.base and left a module-aliasing shim behind (stored flow code fields embed the legacy import and are re-executed verbatim at build time). Sweep lfx/base for marker shims with the same source-level contract as the components sweep: one-file stub, module-aliasing to the bundle's base subpackage, narrow name-checked except, locked install message. * docs(bundles): install shapes, override rule, bundle_api_version (#13578) Documents the deliberately small user-facing rule surface of the metapackage split (1.11): - extensions-overview.mdx: the two install stories (pip install langflow = everything, same as today; pip install lfx = engine only, bring your own bundles); the bundle-name-is-identity invariant; the current package table (lfx-bundles metapackage + the 5 partner packages + the 4 pilots); the legacy-import shim behavior with the locked ModuleNotFoundError wording and the migration recipe for direct lfx users (switch to lfx[bundles] or pin lfx-<provider> packages); the override rule ("ship a manifest -- a manifest always wins", with the bundle-shadowed warning); bundle_api_version as the single compat number (lfx.compat ["1"]; manifest-less packages ride their PEP 508 lfx pin) and the no-version-arithmetic rule. - deployment-lfx-compatibility.mdx: lfx[bundles] as the headless/serverless deployment footnote (engine + lfx-bundles[all]; slimmer per-provider alternative; intentionally no lfx[all]). Both files verified MDX-safe (no unbackticked angle brackets). * fix(bundles): stack-review fixes — symlink containment, idempotency, docs accuracy (#13579) * chore(bundles): bounded version ranges for curated lfx-* packages langflow's pyproject is the release-coordination point now that lfx and the lfx-* bundles release independently. Every curated lfx-* dependency declares a BOUNDED range (>=A,<B), never an exact pin -- exact pins in reusable library metadata create resolver conflicts for downstreams that depend on a different version. Exact pins for reproducibility live in uv.lock and the release-build manifests. - the 4 pilots + 5 graduated partners: >=0.1.0 -> >=0.1.0,<1.0.0 (bundles follow their own 0.1.x cadence) - lfx-bundles[all]>=1.0,<2.0 unchanged (versions with the metapackage contract) - the three lfx-docling[...] optional-dependency refs bounded the same way - policy documented inline in the bundle-deps marker block; the cross-bundle CI matrix verifies the ranges resolve together across the supported lfx axis Verified: uv lock resolves with zero package/version changes (bounds are metadata-only today); the nightly rename's dep regex already matches the bounded form. * test(bundles): lock the in-tree shim contract + breakage audit Locks the five-point contract for the 50 lfx-bundles import shims under lfx/components/ (45 metapackage + 5 graduated partners): 1. first line is the `# lfx-bundles-shim` marker (keys the in-tree walk's double-registration skip); 2. a shim dir is exactly one __init__.py -- no impls, no deps; 3. sys.modules module-aliasing (submodule trees resolve); 4. bundle missing -> actionable ModuleNotFoundError whose wording is part of the contract ("pip install lfx-bundles" / "pip install lfx-<p>"); 5. a missing *transitive* dep re-raises untouched. Test shape (env-adaptive by design -- the lfx test env prunes the venv, so nothing here assumes bundles are installed): - source-level sweep of every real shim, parameterized (no imports); - hermetic mechanism tests against a synthetic shim + synthetic target (alias-resolves / locked-message / transitive-reraise); - the walk-skip detector and the marker sweep must agree exactly; - one adaptive live test on lfx.components.tavily exercising whichever branch the running env is in. 106 tests pass in the pruned lfx env (where the live test exercises the bare-engine locked-message branch). Breakage audit (gh code search, recorded in the PR): ~20-25 public repos reference lfx.components.*; the majority are langflow forks (vendored tree, unaffected by packaging). Genuine library consumers exist and the moved providers have real surface (openai: 11 external repos, datastax: 17) -- covered by the shims wherever bundles are co-installed (every `pip install langflow`). Decision: deprecation warnings NOT warranted now; revisit at M4 (shim removal), where one loud minor release before removal is the right shape. * docs(bundles): install shapes, override rule, bundle_api_version Documents the deliberately small user-facing rule surface of the metapackage split (1.11): - extensions-overview.mdx: the two install stories (pip install langflow = everything, same as today; pip install lfx = engine only, bring your own bundles); the bundle-name-is-identity invariant; the current package table (lfx-bundles metapackage + the 5 partner packages + the 4 pilots); the legacy-import shim behavior with the locked ModuleNotFoundError wording and the migration recipe for direct lfx users (switch to lfx[bundles] or pin lfx-<provider> packages); the override rule ("ship a manifest -- a manifest always wins", with the bundle-shadowed warning); bundle_api_version as the single compat number (lfx.compat ["1"]; manifest-less packages ride their PEP 508 lfx pin) and the no-version-arithmetic rule. - deployment-lfx-compatibility.mdx: lfx[bundles] as the headless/serverless deployment footnote (engine + lfx-bundles[all]; slimmer per-provider alternative; intentionally no lfx[all]). Both files verified MDX-safe (no unbackticked angle brackets). * fix(bundles): review fixes — symlink containment, idempotency, docs accuracy Fixes from the multi-agent stack review (kept as a separate PR so the reviewed PRs' diffs stay frozen for QA's independent pass): - _bundles_root.py: directory-level symlink containment — a provider dir that resolves outside the bundle root is skipped with a typed bundle-discovery-malformed warning, mirroring the seed-directory walk's is_within rule (+ regression test). Anchors the trust boundary to the installed package tree. - consolidate_bundles.py: migration-append idempotency guard — entries are deduped on full content so a partially-failed earlier run cannot duplicate rows on re-run (table stays append-only). - langflow-base pyproject: documented WHY the aws extra is deliberately retained after the lfx-amazon graduation (boto3 also backs the server's own S3 storage backend and lfx's S3 ingestion — review suggested removing it; that would regress S3 storage support). - extensions-overview.mdx: note that `lfx extension list` shows manifest-shipping extensions only; manifest-less packages load at startup but are not listed. 93 loader+migration tests pass (incl. the new symlink test); ruff clean. * refactor(bundles): stabilize import_mod as a public BUNDLE_API utility The lazy-import helper that bundle packages call from their __getattr__-based __init__.py files lived at lfx.components._importing -- an internal path with no stability contract, imported by 35 separately-installed bundle __init__ files (30 lfx-bundles providers + the 5 graduated partners). - canonical home is now lfx.utils.lazy_import.import_mod (code unchanged); lfx.components._importing re-exports it so in-tree callers and any external code on the old path keep working - all 35 bundle-side imports rewritten to the stable path - BUNDLE_API.md: surface table entry + changelog (additive) - contract tests: re-export identity, both call forms, the AttributeError conversion Verified: identity holds across both paths; lazy __init__ loads work through the new path for both bundle families; 110 tests pass; ruff clean. * fix(bundles): tombstone the broken legacy ZepChatMemory build method (#13580) build_message_history targeted the zep-python v1 SDK (ZepClient + zep_python.langchain.ZepChatMessageHistory); both were removed in zep-python 2.x and the zep extra pins 2.0.2, so the method has been unable to run for as long as the pin has existed -- its ImportError guard misleadingly told users to 'pip install zep-python' (already installed). The component is legacy=True with helpers.Memory as its designated replacement, so rather than hand-write a new integration against the 2.x SDK, the method now raises a clear RuntimeError pointing at the Message History component. Flow identity is preserved: class/component name, display_name, description, inputs and the memory output are byte-identical, so saved flows keep loading, i18n locale keys are unchanged, and migration_table.json needs no edits. New bundle tests pin the stub contract (identity, actionable error, no zep_python import). * test(bundles): extend the shim contract sweep to lfx.base shims The datastax graduation moved lfx.base.datastax into lfx_datastax.base and left a module-aliasing shim behind (stored flow code fields embed the legacy import and are re-executed verbatim at build time). Sweep lfx/base for marker shims with the same source-level contract as the components sweep: one-file stub, module-aliasing to the bundle's base subpackage, narrow name-checked except, locked install message. * test(bundles): extras-drift guard for the lfx-bundles metapackage Risk-2 of the metapackage split: the generated `all` extra and the per-provider extras must never drift by hand-edit, or `pip install langflow` silently loses a provider's deps. Guard the four invariants: extras <-> provider dirs (PEP 685-normalized), `all` == the exact self-ref set, normalized keys collision-free, and the metapackage provider set disjoint from the graduated partner distributions. * fix(extension): symlink-escaped providers reject with path-escape, matching the documented contract The lfx.bundles provider containment check (stack-review symlink fix) emitted bundle-discovery-malformed, whose template and hint describe a broken entry-point declaration. The changelog's path-safety entry already documents symlink escapes as path-escape on every other discovery path; use the same code here. Also resolves the semantic merge conflict with the per-mode code split (the removed _malformed_error location kwarg). * fix(tests): repoint lfx tests off moved providers; complete provider fallback map CI fail-fast had been masking these: the LFX test job runs in an engine-only env where openai/anthropic/chroma are now bundle-package shims, so every test that used them as the example category failed on all Python versions (3.14 just reported first), and the backend Group 2 leg failed collecting test_lfx_bundles_extras.py on Python 3.10. - flow_requirements: complete _PROVIDER_PACKAGE_FALLBACKS for the nine moved model providers (OpenAI, Anthropic, Amazon Bedrock, Groq, Google Generative AI, SambaNova, IBM watsonx.ai, Ollama + existing Azure OpenAI). In engine-only installs MODEL_PROVIDERS_DICT registers only in-tree providers, so the dynamic source-inspection path cannot resolve moved providers; the static fallbacks keep lfx run/serve requirements inference working. A dict hit still wins. - test_dynamic_imports / test_import_utils: use composio (still-in-tree lazy category with its SDK absent in the bare env) as the example category instead of openai/anthropic/chroma; patch import_module at its canonical home lfx.utils.lazy_import. - flow-builder tests (build_flow_from_spec, flow_builder_tools, propose_field_edit): specs use LanguageModelComponent (in-tree) instead of OpenAIModel. - test_lfx_bundles_extras: tomli fallback for Python 3.10 (tomllib is stdlib 3.11+; pytest guarantees tomli on <3.11). Full lfx unit suite: 4550 passed. Backend extras+pin tests: 24 passed. * fix(tests): repoint MCP client-server tests off graduated OpenAIModel key Same fail-fast-masked class as14dfa7f268, backend side: since the partner graduation, extension components register in /api/v1/all under their namespaced ids (ext:openai:OpenAIModelComponent@official), so the bare 'OpenAIModel' key these tests hardcoded no longer exists on any Python version (the 'py3.14-only' Group 2 failure was the only leg that ran; 3.10 was cancelled by fail-fast). The MCP feature itself is fine — search returns the namespaced ids and add_component accepts them. - Use LanguageModelComponent (in-tree, stable bare key; SecretStr api_key, real_time_refresh fields, advanced 'stream', LanguageModel output) for the redaction/configure/search/describe/spec tests. - configure_dynamic_field: model_name -> api_key (the refresh field on LanguageModelComponent). - prompt-template-variables spec: drop the model node — server-side validation builds the graph and a model without an API key fails its build; the test's subject is the dynamic {var} fields. Full file: 69 passed. * Update test_mongodb_atlas.py * Frontend tests * Frontend test updates * fix: restore loading.py and tableAutoCellRender to base-branch state Commit9df9475615('Frontend test updates') accidentally reverted both files to their pre-#9902 state, removing the no_env_fallback contract in load_from_env_vars / update_table_params_with_load_from_db_fields, the defensive params.pop('code') in build_custom_component, and the localValue/updateGlobalVariableCell handling in tableAutoCellRender. The base branch's regression tests (test_loading_no_env_fallback.py, test_loading_custom_component_code_param.py, tableAutoCellRender index.test.tsx) were kept, so the reversion failed them. * ci(bundles): make freeze gate reachable on PRs; review follow-ups - Move the freeze-components job from lint-py.yml (workflow_call/ workflow_dispatch only -- never invoked, so the gate could not block a PR) into extension-migration-checks.yml, which already triggers on pull_request for src/lfx/src/lfx/components/**. Add the gate's script and baseline to the path filter. - Add timeout-minutes to the cross-bundle-test jobs so a hung install or test leg cannot occupy a runner for the 360-minute default. - _discover_shimmed_component_dirs: read only the first line of each __init__.py instead of the whole file -- the shim marker contract is line 1 (enforced by test_shim_source_contract), and this prevents a non-shim file with the marker after leading blank lines from being misclassified and silently skipped from the in-tree walk. * fix(bundles): restore legacy-name resolution for ext components; sync starter projects Saved flows reference moved providers by their legacy palette identity -- either the bare class name (TavilySearchComponent) or the component's name attribute (AstraDB, Chroma, needle, OllamaModel). After the provider move neither resolved to a current template on the backend: - lfx.utils.component_aliases now derives the bare class name (and its Component-stripped form) from ext:<bundle>:<Class>@<slot> keys, mirroring the frontend's getTemplateAliases. Ext templates carry name=None / _type='Component', so the key is the only source. - _decorate_template_with_extension now stamps template['name'] with the component's legacy identity (name attr, falling back to class name). In-tree palette entries expose it as the dict key; ext entries are keyed by namespaced id, so without this both alias maps lose the only bridge from node types like 'AstraDB' to the current template. Without these, the starter-project updater could not match moved-provider nodes, so their embedded code stayed one whitespace change behind the bundle sources -- every such flow showed the update-all banner, and because tool-mode nodes' saved outputs ([component_as_tool]) differ from the template's natural outputs the update was flagged breaking, opening the confirmation modal instead of toasting 'successfully updated': deterministic timeouts in the starter-project Playwright specs (shards 29/30/36/37). Regenerated the 13 affected starter projects with scripts/ci/update_starter_projects.py (idempotent; code_hash hex strings in the regenerated JSONs are detect-secrets false positives). Also converts test_groq_integration.py to the captured-module patch.object pattern (same module-identity trap as test_mongodb_atlas: dotted-name @patch can land on a different materialization of the moved module than the one that defined the class, leaving the real get_groq_models in the instance's globals -- order-dependent failure in unit-test Group 5). * fix(bundles): keep importable module paths in starter projects The starter-project updater syncs node metadata from the live templates (NODE_FORMAT_ATTRIBUTES includes 'metadata'), and ext components report their runtime sys.modules namespace there (_lfx_ext.official.<p>.<m>) -- a path that only exists inside a running extension loader. Persisting it broke test_template_field_order_matches_component in both template jobs ('No module named _lfx_ext'): the test imports metadata.module to instantiate the component, and the migration table likewise keys on the legacy lfx.components.<provider> form that the bundle shims keep importable. _merge_node_metadata now preserves the node's stored module when the live template's is a runtime _lfx_ext.* path, while still syncing the rest of the metadata (e.g. dependencies). Starter projects regenerated from the pre-pollution state with the fixed updater: the only diff vs the previous regen is the 20 module lines reverting to the legacy importable form. Regression tests added for both merge directions. * feat: add Oracle integration (#13502) * Add Oracle Integration * [autofix.ci] apply automated fixes * Fix styleUtils problem * fix(oracledb): address review feedback * fix(oracledb): apply formatting * Fix component index * refactor(oracledb): convert Oracle integration to lfx-oracle extension bundle Port the in-tree lfx.components.oracledb provider into a standalone Extension Bundle distribution (src/bundles/oracle, dist lfx-oracle, package lfx_oracle, bundle name 'oracle') per src/bundles/PORTING.md: - Move the four components + connection helper into the bundle; deps (oracledb, langchain-oracledb, langchain-community) move from the langflow-base[oracledb] extra into the bundle's pyproject - Add migration-table entries mapping legacy bare class names and lfx.components.oracledb.* import paths to ext:oracle:<Class>@official - Add test_pilot_oracle_upgrade.py integration coverage (mirrors ibm) - Move backend unit tests into src/bundles/oracle/tests with bundle import paths; drop the empty ComponentTestBaseWithoutClient version-fixture scaffolding (component is new in 1.11.0) - Declare explicit outputs on OracleVectorStoreComponent (mirrors LCVectorStoreComponent) so lfx extension validate passes - Regenerate component index (oracledb category removed) and uv.lock; wire workspace member/dep/source in root pyproject - Frontend sidebar entry renamed oracledb -> oracle; docs page renamed to bundles-oracle following the extracted-bundle convention --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Eric Hare <ericrhare@gmail.com> * feat(lfx): fail-fast dependency preflight for `lfx run` (#13680) Detect a flow's required third-party packages before the graph load and fail fast with an actionable `pip install ...` message when any are missing, instead of a deep ModuleNotFoundError mid-build. Addresses the 'export a flow, run it on a bare pip install lfx, it fails' confusion introduced by the bundle split. - flow_requirements: add find_missing_dependencies / _is_installed / format_missing_dependencies_error (best-effort, reuses the existing flow analyzer). - run.base: _preflight_dependencies helper + check_dependencies gate in run_flow (JSON flows only; .py uses PEP 723; fail-open on analysis errors so a runnable flow is never blocked by the check itself). - CLI: --check-dependencies/--no-check-dependencies on `lfx run`. - tests: unit (find_missing_dependencies/format) + integration (run_flow + _preflight_dependencies). * feat(bundles): move spider + toolguard provider components into lfx-bundles Two provider components were still living inside otherwise-core directories (file-level stragglers from the metapackage split). Both are portable (lfx-only imports) and gated by dedicated optional SDKs, so they belong in the lfx-bundles metapackage rather than lfx core: - SpiderTool (langchain_utilities/spider.py) -> lfx_bundles/spider/, dep spider-client (`spider` extra). - PoliciesComponent + policies/ submodule (models_and_agents) -> lfx_bundles/toolguard/, dep toolguard (`toolguard` extra). Changes: - Move the files into lfx_bundles/<name>/; rewrite policies' absolute self-imports (lfx.components.models_and_agents.policies -> lfx_bundles.toolguard.policies). - Drop SpiderTool / PoliciesComponent from the core langchain_utilities / models_and_agents lazy-import registries. - Add `spider` and `toolguard` extras to lfx-bundles pyproject + the generated `all` aggregate; uv.lock re-resolved (uv lock --check clean). - Append migration entries (4 per class) mapping the historical lfx.components.* paths to ext🕷️SpiderTool@official / ext:toolguard:PoliciesComponent@official so saved flows migrate. - Regenerate component_index.json (both classes drop out of core; 263 components / 45 categories). - Repoint the toolguard policies tests to lfx_bundles.toolguard (60 pass). The toolguard deadlock-warmer (_warm_circular_imports) is unaffected: it warms toolguard.runtime directly and runs single-threaded before the unified component fan-out that loads bundle components. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: auto-bake note keys and regenerate backend locales/en.json [skip ci] * feat(bundles): consolidate cleanlab, twelvelabs, jigsawstack into lfx-bundles First directory-level batch of the core-tail consolidation (validates the consolidate_bundles.py path end-to-end after the spider/toolguard file-level extractions). All three are flat, lfx-only, single-SDK providers: - cleanlab -> lfx_bundles/cleanlab (cleanlab-tlm) 3 components - twelvelabs -> lfx_bundles/twelvelabs (twelvelabs) 7 components - jigsawstack -> lfx_bundles/jigsawstack (jigsawstack) 11 components Via scripts/migrate/consolidate_bundles.py --apply: moves each dir into the metapackage + leaves a fail-soft shim, merges per-provider extras and regen `all`, appends the 4-entry migration block per class (84 entries, 0 ambiguous). Component index regenerated (45->42 categories, 263->242 core components); uv.lock re-resolved; twelvelabs test repointed to lfx_bundles.twelvelabs (passes). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(bundles): consolidate vector/datastore + community-wrapper providers into lfx-bundles Second directory-level batch (8 providers, 12 components) via consolidate_bundles.py --apply: - baidu (qianfan + langchain-community), redis (redis + community), elastic (elasticsearch + langchain-elasticsearch + opensearch-py) - bing, cloudflare, maritalk, searchapi, vectara (langchain-community REST wrappers) Moves each dir into lfx-bundles + fail-soft shim, merges per-provider extras and regen `all`, appends 4-entry migration block per class (48 entries, 0 ambiguous). Component index regenerated (42->34 categories, 242->230 core components); uv.lock re-resolved. Test fixups: - Repoint baidu/elastic/searchapi backend tests to lfx_bundles.<provider>. - test_dynamic_imports (lfx isolated suite) used the now-bundled searchapi as its fixture; bundles aren't installed in the engine-only lfx env, so switch it to the core langchain_utilities.FakeEmbeddingsComponent (same langchain_community optional-import path). 131 shim tests + 21 dynamic-import tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * [autofix.ci] apply automated fixes * feat(bundles): consolidate homeassistant, olivya, agentql REST providers into lfx-bundles Third batch (3 providers, 4 components) via consolidate_bundles.py --apply. These are thin REST wrappers with no dedicated vendor SDK: - homeassistant (requests) -> lfx_bundles/homeassistant 2 components - olivya (httpx, lfx core) -> lfx_bundles/olivya 1 component - agentql (httpx, lfx core) -> lfx_bundles/agentql 1 component Moves + shims + per-provider extras + `all` regen; 16 migration entries (0 ambiguous). Component index 34->31 categories, 230->226 components; uv.lock re-resolved. No tests reference these providers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(bundles): consolidate google, vertexai, altk, codeagents into lfx-bundles Fourth directory-level batch (4 providers, 14 components) via consolidate_bundles.py. google/vertexai (langchain-google-*), altk (agent-lifecycle-toolkit), codeagents (smolagents + OpenDsStar); platform/py markers copied verbatim from langflow-base extras. 56 migration entries (0 ambiguous); index 31->27 categories, 226->212 components. Backend tests repointed; starter projects embedding moved components regenerated via the validator. SKIP=detect-secrets: only flags are regenerated code_hash hex (false positives). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(lfx): decouple dynamic-import unit tests from soon-to-be-bundled providers test_dynamic_imports.py and test_import_utils.py used composio/nvidia/huggingface as the "lazy category whose SDK is absent in the engine-only lfx env" fixture. That couples the import-mechanism tests to specific providers — they have broken each time a provider graduated to a bundle (openai/anthropic/chroma -> composio, now composio et al.). Switch to stable in-tree core categories: - files_and_knowledge.KnowledgeComponent for the "missing-dep" path (its module imports langchain_chroma / langflow, absent in bare lfx) — works for both the import_mod direct calls and the lfx.components hierarchy access. - helpers / processing for the "imports cleanly" and "module not found" paths. No behavior change; 43 tests pass in the isolated lfx env. This unblocks moving composio, huggingface, nvidia, cuga into lfx-bundles without breaking these tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(bundles): consolidate composio, huggingface, nvidia, cuga into lfx-bundles Fifth batch (4 providers, 71 components) via consolidate_bundles.py --apply, unblocked by the prior dynamic-import test decoupling: - composio -> lfx_bundles/composio (composio + composio-langchain) 63 components - huggingface -> lfx_bundles/huggingface (langchain-huggingface[marker], huggingface-hub, community) 2 components - nvidia -> lfx_bundles/nvidia (langchain-nvidia-ai-endpoints, nv-ingest-client[py3.12], gassist[win32]) 5 components - cuga -> lfx_bundles/cuga (cuga; platform/py markers) 1 component Markers copied verbatim from langflow-base extras (uv lock --check clean). 284 migration entries (0 ambiguous); component index 27->23 categories, 212->141 components. Tests: relocate the dedicated lfx-isolated tests for the now-bundled components to the backend suite (test_nvidia_component.py, test_cuga_session_id_fallback.py) repointed to lfx_bundles.<provider>; repoint backend composio/huggingface/cuga imports + a cuga patch target; pragma a fake test api_key. 43 decoupled lfx import tests pass post-move. SKIP=detect-secrets: only flags are regenerated component_index code_hash hex (false positives). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * [autofix.ci] apply automated fixes * feat(bundles): consolidate langwatch evaluator into lfx-bundles langwatch -> lfx_bundles/langwatch (1 component). The LangWatchComponent is a pure httpx REST wrapper (its only non-core touchpoint is lfx.base.langwatch.utils, which is also httpx-based) — the langwatch SDK extra in langflow-base is for the tracing service, not this component, so the bundle declares no extra deps. Moves + shim + `all` regen; 4 migration entries (0 ambiguous). Component index 23->22 categories, 141->140 components. Backend langwatch test repointed (16 passed). SKIP=detect-secrets: only flags are regenerated component_index code_hash hex (false positives). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(bundles): consolidate FAISS, Notion into lfx-bundles (lowercase-slug support) Uppercase source dirs need lowercase bundle slugs (BUNDLE_NAME_RE is lowercase-only). Enhance consolidate_bundles.py with bundle_slug(provider) = provider.lower(): the bundle dir, ext id, shim target, and extra key use the lowercased slug, while the migration import_path keeps the historical lfx.components.<Provider> casing so saved flows still resolve. Identity for already-lowercase providers (no change to prior entries). - FAISS -> lfx_bundles/faiss (faiss-cpu[py-split] + langchain-community) 1 component - Notion -> lfx_bundles/notion (requests + Markdown) 8 components test_bundle_shims.py: derive the expected bundle name by lowercasing the shim dir name (FAISS shim aliases lfx_bundles.faiss) — 159 shim tests pass. Relocate the lfx-isolated FAISS test to backend/vectorstores (repointed to lfx_bundles.faiss.faiss). 36 migration entries (0 ambiguous); index 22->20 categories, 140->131 components. SKIP=detect-secrets: regenerated code_hash hex. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(bundles): fold vectorstores LocalDBComponent into the chroma bundle The lone LocalDBComponent is Chroma-backed (eager `from langchain_chroma import Chroma`), so it belongs in the existing `chroma` bundle rather than a new one — its deps are already covered by the chroma extra (no new deps). - Move vectorstores/local_db.py -> lfx_bundles/chroma/local_db.py; register LocalDBComponent in the chroma bundle's lazy registry. - Replace lfx.components.vectorstores with a CROSS-BUNDLE shim aliasing lfx_bundles.chroma (dir != bundle). - Migration: lfx.components.vectorstores[.local_db].LocalDBComponent -> ext:chroma:LocalDBComponent@official (4 entries, 0 ambiguous). - test_bundle_shims.py: add a cross-bundle map {vectorstores: chroma} so the shim contract validates (161 shim tests pass). - Repoint the backend LocalDB test (import + patch target) to lfx_bundles.chroma; test_all_modules_importable resolves it via the shim. Component index 20->19 categories, 131->130 components. SKIP=detect-secrets: regenerated code_hash hex (false positives). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * [autofix.ci] apply automated fixes * feat(bundles): remove unbundleable vlmrun + notdiamond components from lfx core Per the core-tail audit, four providers couldn't be bundled. vlmrun (imports `from langflow`, violating the lfx engine boundary) and notdiamond (its `notdiamond` SDK isn't in the lockfile or any extra, so it can't run) have no engine references, no starter flows, and no migration-table entries — they're effectively dead in every distribution, so remove them outright. (crewai and agentics are kept: crewai is woven into 3 starter-project example flows, and the core Agent hard-depends on agentics.helpers.model_config — both need deliberate upstream/refactor work, not deletion.) - Delete src/lfx/.../components/{vlmrun,notdiamond}/ and drop them from the lfx.components top-level registry (TYPE_CHECKING / _dynamic_imports / __all__). - Regenerate component index (19->17 categories, 130->128 components) and backend locales (orphan keys removed; extract_backend_strings --check OK). - Repoint test_dynamic_imports.test_import_error_handling off the deleted notdiamond to the core processing category (43 lfx import tests pass). SKIP=detect-secrets: only flags are regenerated component_index code_hash hex. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(lfx): tie flow-builder search 'returns all' to registry size The hardcoded `count > 100` assertion predated the bundle-extraction work, which deliberately moves non-legacy components out of lfx core (now 66 non-legacy of 128 indexed). Assert the empty-query count equals the full non-legacy registry instead — the durable 'no query == every component the registry exposes' contract that survives future bundle moves. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(bundles): deterministic component alias resolution + sidebar slug casing after bundle move Moving agentql/composio and the long tail into lfx-bundles made two shared-label resolutions order-dependent and left two sidebar slugs mis-cased, surfacing as red CI on the starter-projects template tests and the bundles-sidebar e2e. - flatten_components_with_aliases: register every component's identity aliases (registry key / ext class name / name) before any component's display_name alias, so a component's own class identity always wins its key regardless of registry iteration order. A single setdefault pass previously let the Composio 'AgentQL'/'SerpAPI' wrappers (display_name) shadow the standalone components (class name) depending on load order, so update_starter_projects wrote the wrong metadata.module into the AgentQL starter node. - Correct the AgentQL node module in News Aggregator.json and Price Deal Finder.json back to lfx.components.agentql.agentql_api.AgentQL. - SIDEBAR_BUNDLES: lowercase the Notion/FAISS name slugs to match the lowercase bundle category names so both bundles render in the sidebar again (display_name and icons unchanged). - Add order-independence regression tests for the identity/display tiers. * test(weaviate): patch the class's own module, not a fixed string path The Weaviate component moved into lfx-bundles, so weaviate.py is now reachable under several module identities (the lfx.components.weaviate shim, lfx_bundles.weaviate.weaviate, and the runtime _lfx_ext.* copy). On Python 3.10, unittest.mock resolves a dotted patch target via a getattr walk that follows the shim's package alias to a *different* module object than the one the imported class actually lives in, so patching 'lfx.components.weaviate.weaviate.WeaviateVectorStore' missed the object the component body reads -> 'WeaviateVectorStore called 0 times' when an earlier test in the group forced the alternate identity. Resolve the module from the imported class (sys.modules[Cls.__module__]) and patch.object it directly, bypassing dotted-string resolution. Same fix for AuthApiKey. Order/identity-independent on all Python versions. * test(ollama,perplexity): patch the class's own module, not a fixed string path The Ollama and Perplexity components moved into lfx-bundles, so ollama.py and perplexity.py are now reachable under several module identities (the lfx.components.<x>.<x> shim, the canonical lfx_bundles.<x>.<x>, and the runtime _lfx_ext.* ext-loader copy). On Python 3.10, unittest.mock resolves a dotted patch target via a getattr walk that follows the shim's package alias to a *different* module object than the one the imported class / component body actually use, so patching e.g. "lfx.components.ollama.ollama.ChatOllama" missed the object the body reads -> "mock called 0 times" depending on suite order. Resolve the module from the imported class (sys.modules[Cls.__module__]) and patch.object it directly, bypassing dotted-string resolution: - perplexity: ChatPerplexity -> patch.object(_PERPLEXITY_MODULE, ...) - ollama: ChatOllama / logger -> patch.object(_OLLAMA_MODULE, ...) - ollama: ChatOllamaComponent._parse_json_response -> patch.object(ChatOllamaComponent, ...) (the imported class itself) - ollama: httpx.AsyncClient.get/post -> patched on the global httpx (a single class object, identity-independent), dropping the shim prefix Same fix as the Weaviate test (064069b6bb). Order/identity-independent on all Python versions; the langchain_ollama / lfx.utils.util / socket patches target stable modules and are left unchanged. * fix(locales): restore en.json keys for still-shipping spider/policies components The spider (SpiderTool) and toolguard (PoliciesComponent) components live in lfx-bundles and still ship in the palette, but their 44 en.json keys were pruned while all 6 translated locales (de/es/fr/ja/pt/zh-Hans) retained them. The extractor walks only lfx.components, so bundle-only components with no compat shim get dropped from en.json on regeneration. Left as-is, the next GP upload/download cycle would strip ~264 existing translations (44 keys x 6 langs) for two components that are still in the palette. Restore the 25 spidertool + 19 policies keys to re-align en.json with the translated locales. (Durable fix — making the extractor walk src/bundles and/or adding lfx.components shims for these two providers — is a follow-up, since spider.py eager-imports its SDK and a shim alone won't make it walkable.) * chore(deps): drop dead astradb/graph-retriever extras and langchain-aws from langflow-base After datastax/amazon graduated to lfx-datastax/lfx-amazon, langflow-base still declared astradb (langchain-astradb), graph-retriever (langchain-graph-retriever, graph-retriever) and langchain-aws in its aws extra -- all pulled into the complete aggregate -- though zero core .py files import them (the consuming components moved to the bundles, which re-declare the same deps). This duplicated the SDKs in every pip install langflow and was inconsistent with the openai/anthropic/cohere extras already removed in this PR. Remove the astradb and graph-retriever extras and their complete refs, and drop langchain-aws from the aws extra (boto3 stays -- it backs langflow-base's own S3 storage, not just the amazon components). Regenerate uv.lock; closure unchanged, the SDKs still resolve via lfx-datastax/lfx-amazon. * ci(extension-migration): enforce component os.environ-write lint in CI check_component_env_writes.py guards against components writing to process-global os.environ (which bleeds one caller's credential into other requests on a warm worker). It was wired only as a pre-commit hook, bypassable via git commit -n, web-UI commits, or uninstalled hooks. Add it as a stdlib-only job in extension-migration-checks.yml next to freeze-components, so the credential-bleed guard is enforced in CI regardless of local pre-commit. * test(bundles): cover migration-table completeness, preflight provider path, frozen gate Three test-coverage gaps from the metapackage split, all green on the current tree: - migration-table completeness: parametrized sweep asserting every lfx_bundles provider component class (196 across 72 providers) is reachable via migration_table.json as an ext:<provider>:<Class>@ target, so a provider moved into a bundle without a table entry fails CI instead of silently breaking saved flows. Includes a negative control. - dependency preflight provider path: drives find_missing_dependencies and _preflight_dependencies through a node with a model/agent_llm provider field so the _PROVIDER_PACKAGE_FALLBACKS path (the only working provider-dep source in engine-only installs) is exercised. - frozen-components gate: unit tests for check_components_frozen.py covering additions (fail), removals (allowed), and the init/pycache/dotdir and comment-parsing rules. * chore: auto-bake note keys and regenerate backend locales/en.json [skip ci] * fix(locales): make spider/toolguard i18n keys survive regen via shims The earlier restore of the 44 spider/policies en.json keys was reverted by the gp-backend-check bot: it auto-regenerates en.json on every push to this PR (the PR-level path filter matches the bundle moves under lfx/components) and the bundle-blind extractor could not reproduce the keys. Make them durable the way every other moved provider does: - Add lfx.components.spider and lfx.components.toolguard '# lfx-bundles-shim' packages re-pointing to lfx_bundles.{spider,toolguard}, so the extractor (which walks lfx.components) emits SpiderTool/PoliciesComponent keys again. - Lazy-import spider's SDK (from spider.spider import Spider moved into crawl()) so SpiderTool is importable for extraction/registration without spider-client, matching the lazy pattern weaviate/zep already use (toolguard was already lazy). - Add spider/toolguard to scripts/ci/frozen_component_dirs.txt so the additions-only freeze gate accepts the new shim dirs. - Restore the 44 keys to en.json. Verified the extractor now reproduces exactly the 44 keys (25 spidertool + 19 policies), so the bot keeps them instead of dropping them. Bundle components are dynamic-only (not in component_index.json), so no index regen is needed. * chore(bundles): declare astrapy in lfx-datastax; align lfx-ibm manifest version - lfx-datastax imports astrapy directly (astrapy.DataAPIClient/Database in the astra components) but only pulled it transitively via langchain-astradb. Declare it explicitly, pinned to match langflow-base's astrapy extra (>=2.1.0,<3.0.0). - lfx-ibm's extension.json reported version 0.1.0 while pyproject.toml is 0.1.1; the loader uses the manifest version, so bump it to 0.1.1 to match. Regenerate uv.lock (closure unchanged; astrapy was already resolved transitively). * refactor(migrate): guard consolidate against subdir data loss; fix alias docstring - consolidate_bundles.move_provider copies only top-level *.py but rmtree's the whole source tree, so a provider with a subpackage would have its subdir silently destroyed and never migration-mapped. Refuse with a clear error instead of half-moving (port_bundle.py handles nested layouts). No current PROVIDER_DEPS entry has a subdir, so this is a defensive guard. - component_aliases: the order-independence docstrings overclaimed. Scope the guarantee to identity-beats-display (and real-key-beats-alias) and note that two components contributing the same identity alias remain first-wins by iteration order. * ci(gp-backend): regen en.json on src/bundles/** edits The gp-backend-check bot regenerates locales/en.json on PR changes, but its path filter only watched src/lfx/src/lfx/components/**. Bundle component strings reach core en.json through the lfx/components/<provider> import shims (e.g. spider, toolguard), so editing a bundle's display_name/description/info under src/bundles/** would not re-trigger the extractor and en.json could silently drift from the translated locales. Add src/bundles/** to the trigger so bundle-side string edits keep en.json aligned; regen is diff-gated, so non-string bundle edits remain a no-op. * fix(bundles): Policies in Models & Agents core; Spider in bundles sidebar Policies (toolguard) component: - Move PoliciesComponent + policies/ helpers out of the lfx-bundles consolidated metapackage back into lfx/components/models_and_agents core (restores the main/release-1.11.0 layout); register it in the category __init__ and restore the core-path tests. - toolguard stays an optional langflow-base extra (langflow-base[toolguard], pulled in via [complete]); the component imports it lazily, so a bare lfx install still loads/inspects it. - Remove the lfx-bundles toolguard provider (dir + extra + [all] entry), the lfx core import shim, and the frozen_component_dirs.txt entry; drop the 4 ext:toolguard migration entries; regenerate component_index (128 -> 129) and uv.lock. Spider: - Add spider to SIDEBAR_BUNDLES so it renders in the Bundles section like arXiv instead of falling into the default categories (it was missing from the list while peer lfx-bundles providers were present). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(bundles): fix error-tier mirror claim, list-time discovery note, metapackage README Addresses three documentation nits from the PR #13563 re-review: - errors.py: the lfx.bundles diagnostics comment claimed a full mirror of the inline tier's split, but only the duplicate case matches. name-invalid and root-unreadable are warning-only here where their inline counterparts are hard errors (ok=False). Tighten the comment to match actual behavior. - BUNDLE_API.md: note that manifest-less lfx.bundles providers are invisible to `lfx extension list` (discover_all_extensions walks only installed + seed; load_lfx_bundles_extensions runs in the startup component-loading path), so the precedence story isn't read as a list-time property. - lfx-bundles/README.md: the bulk move has landed on this branch (71 providers, populated per-provider extras, generated `all`), so drop the "empty skeleton / available later" framing and document the final state. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(bundles): surface pip-install guidance for missing SDKs on the server run path A flow loaded through the UI/API on an engine-only `lfx` (no bundles) failed deep in the vertex build with a bare `Error creating class. ModuleNotFoundError(No module named 'langchain_chroma')`. The actionable install guidance only existed on the `lfx run` preflight and the legacy import shim; the server run path (simple_run_flow -> run_graph_internal -> graph.arun()) had neither. Both vertex-build error formatters (lfx's `format_exception_message`, used by graph.arun(), and langflow's, used by the build/chat endpoints) now detect a plain ModuleNotFoundError in the cause chain and attach the same `pip install <package>` guidance the CLI gives, mapping the missing import to its PyPI distribution via the existing requirements resolver. Bundle-shim "components moved to ..." messages are left untouched (their message does not start with "No module named"), so graduated providers stay actionable. * fix(bundles): surface curated shim message over its raw cause in exception formatter get_causing_exception walked to the deepest __cause__, so a graduated provider's curated "components moved to ..." ModuleNotFoundError (raised `from` the raw "No module named 'lfx_<x>'") was unwrapped past it. The formatter then regenerated bare "pip install lfx-<x>" guidance, dropping the curated text — including the "or pip install langflow, which bundles it" clause. Both get_causing_exception copies (lfx + langflow) now stop at the first ModuleNotFoundError whose message is not a raw "No module named ..." (the curated shim error), and module_not_found_hint surfaces that curated message verbatim. Plain SDK import errors are unchanged. Adds regression tests reproducing the real 3-level cause chain on both formatters; the old test used a truncated chain that hid this. * fix(bundles): cover OpenRouter/IBM WatsonX in Orchestrate requirements detection (#13732) fix(bundles): cover OpenRouter/IBM WatsonX in wxO requirements detection The bundle split removed the langchain provider SDKs from lfx's own dependency tree, which makes flow→requirements detection load-bearing for Watsonx Orchestrate deployments: the runner installs only `lfx` + the packages `generate_requirements_from_flow` emits, so a provider missing from the resolution tables yields an empty requirements set and the deployed flow fails to import its model class. Two unified-selectable providers were unresolved: - OpenRouter (runs on ChatOpenAI) was in no resolution table -> []. - "IBM WatsonX" (catalog casing) only matched the legacy "IBM watsonx.ai" key; langchain-ibm landed only by an embedding-path coincidence. Add both to _PROVIDER_PACKAGE_FALLBACKS (keyed by the exact catalog strings) so every provider in MODEL_PROVIDER_METADATA resolves. Tests: - TestBundleSeparationOrchestrate in test_flow_requirements.py: catalog coverage guard, OpenRouter/IBM regressions, and split-contract guards (bundle dists and langchain imports must stay out of lfx's tree). - End-to-end artifact tests in test_watsonx_orchestrate.py asserting a bundle-provider flow emits the langchain SDK (not lfx-openai/lfx-bundles) and that requirements.txt pins lfx as its first line. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * Backend format * Format again * [autofix.ci] apply automated fixes * fix(bundles): pin datastax ruff line-length to 120 to stop format tug-of-war The datastax bundle's pyproject.toml declares a [tool.ruff.lint.per-file-ignores] table, which makes ruff treat the directory as a standalone config root. It never restated line-length, so ruff fell back to its default of 88 there. As a result `make format_backend` (per-directory config discovery) wrapped lines to 88 while the pre-commit ruff-format hook (run with --config pyproject.toml, forcing the repo-wide 120) unwrapped them back, leaving the two formatters fighting forever. Restate line-length = 120 so both config-resolution paths agree. Use the explicit line-length rather than extend=../../../pyproject.toml to avoid pulling in the root lint ruleset, which the bundle intentionally does not enforce. * fix(bundles): relocate notdiamond + vlmrun into lfx-bundles Both providers were dropped during the bulk long-tail move with no bundle and no compatibility shim, removing them from the registry, component_index, migration table and locales. That breaks saved flows using the NotDiamond Router or VLM Run Transcription nodes (vlmrun ships in langflow[complete]), contradicting the zero-user-facing-change goal. Relocate both the same way as the other long-tail providers: - move implementations into src/bundles/lfx-bundles/src/lfx_bundles/ - restore lfx.components.{notdiamond,vlmrun} marker shims - re-register both in lfx/components/__init__.py - add per-provider extras (vlmrun -> vlmrun[all]) and the 'all' aggregate - add migration_table targets ext:{notdiamond,vlmrun}:<Class>@official - restore the 32 en.json strings; refresh uv.lock * docs(lfx): correct dependency-preflight scope to lfx run only The preflight is wired into 'lfx run'; 'lfx serve' has its own module-not-found hint at request time and does not call it. --------- Co-authored-by: Elif Sema Balcioglu <elifsemabalcioglu@gmail.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1740 lines
68 KiB
Python
Executable File
1740 lines
68 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
r"""Mechanical port helper for ``src/lfx/src/lfx/components/<provider>/`` -> ``src/bundles/<provider>/``.
|
|
|
|
Writes the bundle skeleton documented in ``src/bundles/PORTING.md``,
|
|
moves the in-tree provider directory (and any shared base under
|
|
``lfx.base.<provider>``) into the bundle, patches every cross-cutting
|
|
touchpoint, and -- when given ``--migration-release`` -- writes the
|
|
migration table entries and integration-test scaffold directly.
|
|
|
|
What this script does
|
|
=====================
|
|
|
|
Phase A -- bundle layout (always):
|
|
1. Validates the candidate (provider directory exists, no
|
|
``from langflow`` imports, no ``deactivated`` duplicate).
|
|
2. Lays out ``src/bundles/<bundle>/{pyproject.toml,README.md,
|
|
src/lfx_<bundle>/{__init__.py,extension.json,components/<bundle>/{__init__.py}}}``.
|
|
The components ``__init__.py`` preserves the lazy-import shape
|
|
(``_dynamic_imports`` dict + ``__getattr__`` + ``__dir__``) so
|
|
saved flows that reference the package-level re-export keep
|
|
resolving without an eager third-party import.
|
|
3. Moves every ``*.py`` file from the in-tree provider directory
|
|
into the bundle's ``components/<bundle>/`` directory.
|
|
4. If a shared base exists at ``src/lfx/src/lfx/base/<bundle>/``,
|
|
moves it into ``src/bundles/<bundle>/src/lfx_<bundle>/base/``
|
|
and rewrites intra-bundle imports
|
|
``from lfx.base.<bundle>...`` -> ``from lfx_<bundle>.base...``.
|
|
|
|
Phase B -- in-tree cleanup:
|
|
5. Removes the in-tree component directory.
|
|
6. Strips the three references in
|
|
``src/lfx/src/lfx/components/__init__.py``.
|
|
7. Moves the per-file ruff ignores under
|
|
``src/lfx/src/lfx/components/<bundle>/`` from the root
|
|
``pyproject.toml`` into the bundle's own ``pyproject.toml``
|
|
(under ``[tool.ruff.lint.per-file-ignores]``).
|
|
|
|
Phase C -- workspace + external consumers:
|
|
8. Adds the dep, the workspace source, and the workspace member
|
|
entry to the root ``pyproject.toml`` (uses the
|
|
``# langflow-extensions:bundle-{deps,sources,members}-end``
|
|
marker pairs so the anchors survive dep reordering / pin bumps).
|
|
9. Rewrites every external consumer that imported
|
|
``lfx.components.<bundle>`` or ``lfx.base.<bundle>``:
|
|
``--rewrite-consumers`` greps the repo (excluding the bundle dir
|
|
and the in-tree dir, both already moved) and applies the
|
|
canonical substitutions ``lfx.components.<bundle>`` ->
|
|
``lfx_<bundle>.components.<bundle>``,
|
|
``lfx.components.<bundle>.<Class>`` (re-export form) ->
|
|
``lfx_<bundle>.<Class>``,
|
|
``lfx.base.<bundle>`` -> ``lfx_<bundle>.base``.
|
|
10. Auto-discovers backend test directories at
|
|
``src/backend/tests/unit/base/<bundle>/`` and moves them into
|
|
``src/bundles/<bundle>/tests/`` with patch paths rewritten.
|
|
|
|
Phase D -- artefacts (when --migration-release is set):
|
|
11. Appends the four-entry-per-class migration block directly to
|
|
``migration_table.json`` (was: paste-in stdout pre-1.0.).
|
|
12. Writes ``test_pilot_<bundle>_upgrade.py`` directly to
|
|
``src/lfx/tests/integration/extension/``.
|
|
13. Removes the bundle's category from
|
|
``src/lfx/src/lfx/_assets/component_index.json`` and
|
|
recomputes the embedded SHA256 (requires ``uv run`` because
|
|
the script depends on ``orjson`` -- the same library
|
|
``scripts/build_component_index.py`` uses).
|
|
14. Patches the two non-uv-sync Dockerfiles
|
|
(``docker/build_and_push_backend.Dockerfile`` and
|
|
``docker/build_and_push_base.Dockerfile``) to install the bundle.
|
|
|
|
Phase E -- optional cleanup (when --remove-base-extra is set):
|
|
15. Removes ``<bundle> = [...]`` from
|
|
``src/backend/base/pyproject.toml`` extras and any
|
|
``langflow-base[<bundle>]`` reference from ``complete``. Only
|
|
safe when the bundle's runtime deps cover everything the extra
|
|
used to install -- the porter MUST review the diff.
|
|
|
|
Run mode
|
|
========
|
|
|
|
Dry-run by default (prints the planned actions, makes no changes).
|
|
Pass ``--apply`` to mutate the tree; review with ``git diff`` before
|
|
committing.
|
|
|
|
Usage
|
|
=====
|
|
|
|
::
|
|
|
|
# Dry-run -- print the planned actions.
|
|
python scripts/migrate/port_bundle.py --bundle arxiv
|
|
|
|
# Full apply with migration release, consumer rewrites, ruff ignore
|
|
# migration, and Dockerfile updates.
|
|
python scripts/migrate/port_bundle.py \\
|
|
--bundle datastax --display-name "DataStax / AstraDB" \\
|
|
--migration-release 1.10.0 --apply --rewrite-consumers \\
|
|
--update-index --update-dockerfiles --remove-base-extra
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
LFX_COMPONENTS = REPO_ROOT / "src" / "lfx" / "src" / "lfx" / "components"
|
|
LFX_BASE = REPO_ROOT / "src" / "lfx" / "src" / "lfx" / "base"
|
|
BUNDLES_DIR = REPO_ROOT / "src" / "bundles"
|
|
ROOT_PYPROJECT = REPO_ROOT / "pyproject.toml"
|
|
BASE_PYPROJECT = REPO_ROOT / "src" / "backend" / "base" / "pyproject.toml"
|
|
COMPONENT_INDEX_PATH = REPO_ROOT / "src" / "lfx" / "src" / "lfx" / "_assets" / "component_index.json"
|
|
MIGRATION_TABLE = REPO_ROOT / "src" / "lfx" / "src" / "lfx" / "extension" / "migration" / "migration_table.json"
|
|
BACKEND_BASE_TESTS = REPO_ROOT / "src" / "backend" / "tests" / "unit" / "base"
|
|
DOCKER_BACKEND = REPO_ROOT / "docker" / "build_and_push_backend.Dockerfile"
|
|
DOCKER_BASE = REPO_ROOT / "docker" / "build_and_push_base.Dockerfile"
|
|
PILOT_TEST_DIR = REPO_ROOT / "src" / "lfx" / "tests" / "integration" / "extension"
|
|
|
|
|
|
def _current_lfx_floor() -> str:
|
|
"""Return the ``lfx`` dependency floor for a freshly-ported bundle.
|
|
|
|
Reads the workspace lfx version from ``src/lfx/pyproject.toml`` and pins
|
|
``lfx>=X.Y.0.dev0,<(X+1).0.0`` -- floored at the current major.minor
|
|
line's first pre-release (the branch's own canonical ``X.Y.0.devN``
|
|
nightlies sort below a plain ``X.Y.0`` under PEP 440, so they must be
|
|
admitted by the floor), capped below the next lfx major. Mirrors
|
|
``lfx_floor_spec`` in ``scripts/ci/sync_bundle_lfx_pin.py`` (each script
|
|
is kept standalone, so keep the two in step); that script re-syncs every
|
|
existing bundle on ``make patch``.
|
|
"""
|
|
lfx_pyproject = (REPO_ROOT / "src" / "lfx" / "pyproject.toml").read_text(encoding="utf-8")
|
|
match = re.search(r'^version = "(\d+)\.(\d+)\.\d+', lfx_pyproject, re.MULTILINE)
|
|
if not match:
|
|
msg = "Could not read lfx version from src/lfx/pyproject.toml"
|
|
raise ValueError(msg)
|
|
major, minor = int(match.group(1)), int(match.group(2))
|
|
return f"lfx>={major}.{minor}.0.dev0,<{major + 1}.0.0"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Templates
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
PYPROJECT_TEMPLATE = """\
|
|
[project]
|
|
name = "lfx-{bundle}"
|
|
version = "0.1.0"
|
|
description = "{display_name} component(s) as a standalone Langflow Extension Bundle."
|
|
readme = "README.md"
|
|
requires-python = ">=3.10,<3.15"
|
|
license = {{ text = "MIT" }}
|
|
authors = [
|
|
{{ name = "Langflow", email = "contact@langflow.org" }},
|
|
]
|
|
keywords = ["langflow", "lfx", "extension", "bundle", "{bundle}"]
|
|
|
|
# Runtime deps: lfx (the BUNDLE_API surface) plus any third-party imports
|
|
# the bundle's components rely on. REVIEW THIS LIST -- the script ports
|
|
# only ``lfx``; add any other deps the moved component(s) import.
|
|
# lfx is floored at the current major.minor line and capped below the next
|
|
# lfx major (e.g. "lfx>=1.10.0,<2.0.0"); the floor is read from
|
|
# src/lfx/pyproject.toml at port time and re-synced on ``make patch`` via
|
|
# scripts/ci/sync_bundle_lfx_pin.py. Fine-grained BUNDLE_API compatibility
|
|
# is enforced via extension.json's lfx.compat list against BUNDLE_API_VERSION.
|
|
dependencies = [
|
|
"{lfx_floor}",
|
|
]
|
|
|
|
[project.urls]
|
|
Homepage = "https://github.com/langflow-ai/langflow"
|
|
Documentation = "https://docs.langflow.org/extensions"
|
|
Repository = "https://github.com/langflow-ai/langflow"
|
|
|
|
# Manifest-shipping distributions are discovered via the
|
|
# ``langflow.extensions`` entry-point. Editable installs whose
|
|
# ``dist.files`` only surfaces dist-info entries fall back to this
|
|
# entry-point to find the manifest.
|
|
[project.entry-points."langflow.extensions"]
|
|
lfx-{bundle} = "lfx_{bundle}"
|
|
|
|
[build-system]
|
|
requires = ["hatchling"]
|
|
build-backend = "hatchling.build"
|
|
|
|
[tool.hatch.build.targets.wheel]
|
|
# extension.json + components live inside the lfx_{bundle} package so
|
|
# ``importlib.metadata.files(dist)`` finds them and the loader resolves
|
|
# bundles[].path relative to the manifest's directory.
|
|
packages = ["src/lfx_{bundle}"]
|
|
include = [
|
|
"src/lfx_{bundle}/extension.json",
|
|
{wheel_include_extra} "src/lfx_{bundle}/components/**/*.py",
|
|
]
|
|
|
|
[tool.hatch.build.targets.sdist]
|
|
include = [
|
|
"src/lfx_{bundle}",
|
|
"extension.json",
|
|
"README.md",
|
|
"pyproject.toml",
|
|
]
|
|
{ruff_section}"""
|
|
|
|
|
|
RUFF_SECTION_TEMPLATE = """\
|
|
|
|
[tool.ruff.lint.per-file-ignores]
|
|
{ruff_entries}"""
|
|
|
|
|
|
EXTENSION_JSON_TEMPLATE = """\
|
|
{{
|
|
"$schema": "https://schemas.langflow.org/extension/v1.json",
|
|
"id": "lfx-{bundle}",
|
|
"version": "0.1.0",
|
|
"name": "{display_name}",
|
|
"description": "{display_name} component(s) as a standalone Langflow Extension Bundle.",
|
|
"lfx": {{
|
|
"compat": ["1"]
|
|
}},
|
|
"bundles": [
|
|
{{
|
|
"name": "{bundle}",
|
|
"path": "components/{bundle}"
|
|
}}
|
|
]
|
|
}}
|
|
"""
|
|
|
|
|
|
PACKAGE_INIT_TEMPLATE = '''\
|
|
"""lfx-{bundle}: {display_name} bundle.
|
|
|
|
Distribution unit ``lfx-{bundle}``. At runtime Langflow's loader
|
|
discovers ``extension.json`` shipped alongside this ``__init__.py`` and
|
|
registers the bundle's components under the namespaced IDs
|
|
``ext:{bundle}:<Class>@official``.
|
|
"""
|
|
|
|
{package_reexports}
|
|
'''
|
|
|
|
|
|
# Preserves the lazy-import shape that ``lfx.components.<bundle>``
|
|
# pre-extraction used: a TYPE_CHECKING import block, a ``_dynamic_imports``
|
|
# dict mapping ``ClassName -> module_stem``, and a ``__getattr__`` /
|
|
# ``__dir__`` pair that resolves attributes on first access. Saved flows
|
|
# that reference the package-level re-export
|
|
# (``lfx.components.<bundle>.<Class>``, migration-table-rewritten to
|
|
# ``lfx_<bundle>.components.<bundle>.<Class>``) keep resolving without
|
|
# eagerly importing every component module at package load.
|
|
COMPONENTS_INIT_TEMPLATE = '''\
|
|
"""Lazy component re-exports for the ``{bundle}`` bundle.
|
|
|
|
Mirrors the pre-extraction layout of ``lfx.components.{bundle}`` so saved
|
|
flows that referenced the module-level class
|
|
(e.g. ``lfx.components.{bundle}.<Class>``) keep resolving via the
|
|
migration table after rewrite to
|
|
``lfx_{bundle}.components.{bundle}.<Class>``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
from lfx.components._importing import import_mod
|
|
|
|
if TYPE_CHECKING:
|
|
{type_checking_imports}
|
|
|
|
_dynamic_imports = {dynamic_imports_dict}
|
|
|
|
__all__ = {all_list}
|
|
|
|
|
|
def __getattr__(attr_name: str) -> Any:
|
|
if attr_name not in _dynamic_imports:
|
|
msg = f"module {{__name__!r}} has no attribute {{attr_name!r}}"
|
|
raise AttributeError(msg)
|
|
try:
|
|
result = import_mod(attr_name, _dynamic_imports[attr_name], __spec__.parent)
|
|
except (ModuleNotFoundError, ImportError, AttributeError) as e:
|
|
msg = f"Could not import {{attr_name!r}} from {{__name__!r}}: {{e}}"
|
|
raise AttributeError(msg) from e
|
|
globals()[attr_name] = result
|
|
return result
|
|
|
|
|
|
def __dir__() -> list[str]:
|
|
return list(__all__)
|
|
'''
|
|
|
|
|
|
BASE_INIT_TEMPLATE = '''\
|
|
"""Shared base infrastructure for the {bundle} bundle.
|
|
|
|
Houses the mixin(s) every component in this bundle inherits from --
|
|
pre-extraction this lived at ``lfx.base.{bundle}``. Moved into the
|
|
bundle (not kept in lfx) because it is {bundle}-specific and only ever
|
|
imported by the {bundle} components.
|
|
"""
|
|
|
|
{base_reexports}
|
|
'''
|
|
|
|
|
|
README_TEMPLATE = """\
|
|
# lfx-{bundle}
|
|
|
|
{display_name} component(s) as a standalone Langflow Extension Bundle.
|
|
|
|
## Install
|
|
|
|
```bash
|
|
pip install lfx-{bundle}
|
|
```
|
|
|
|
The bundle is registered automatically via the `langflow.extensions`
|
|
entry-point. After install, restart your Langflow server; the bundle's
|
|
components will appear in the palette under the `{bundle}` group with
|
|
the namespaced IDs `ext:{bundle}:<Class>@official`.
|
|
|
|
## Develop
|
|
|
|
```bash
|
|
cd src/bundles/{bundle}
|
|
pip install -e .
|
|
lfx extension validate src/lfx_{bundle}
|
|
```
|
|
|
|
## Migration
|
|
|
|
Saved flows referencing the legacy class name(s) or the old import paths
|
|
under `lfx.components.{bundle}.*` are rewritten to the new namespaced
|
|
IDs by the migration table in
|
|
`src/lfx/src/lfx/extension/migration/migration_table.json`.
|
|
"""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Plan
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ComponentFile:
|
|
"""A ported .py file plus the Component class names it declares."""
|
|
|
|
path: Path
|
|
classes: tuple[str, ...]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RuffIgnore:
|
|
"""One ``[tool.ruff.lint.per-file-ignores]`` entry to migrate."""
|
|
|
|
pattern: str # e.g. ``"src/lfx/src/lfx/components/datastax/astradb_vectorstore.py"``
|
|
codes: tuple[str, ...] # e.g. ``("S110",)``
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ConsumerRewrite:
|
|
"""One file path plus the substring substitutions to apply.
|
|
|
|
Order matters: more-specific substitutions first. The script does
|
|
plain ``str.replace`` so a substitution like
|
|
``lfx.components.<bundle>.<Class>`` -> ``lfx_<bundle>.<Class>`` must
|
|
run before the catch-all ``lfx.components.<bundle>`` ->
|
|
``lfx_<bundle>.components.<bundle>``.
|
|
"""
|
|
|
|
path: Path
|
|
substitutions: tuple[tuple[str, str], ...]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PortPlan:
|
|
bundle: str
|
|
display_name: str
|
|
in_tree_dir: Path
|
|
bundle_dir: Path
|
|
component_files: tuple[ComponentFile, ...]
|
|
migration_release: str | None
|
|
# Nested subdirectories under the in-tree provider directory
|
|
# (e.g. ``agentics/helpers/``, ``agentics/inputs/``). Moved
|
|
# wholesale into the bundle's ``components/<bundle>/`` so any
|
|
# imports from ``lfx.components.<bundle>.<subdir>.<module>``
|
|
# continue to resolve via the migration-rewritten path
|
|
# ``lfx_<bundle>.components.<bundle>.<subdir>.<module>``.
|
|
nested_subdirs: tuple[Path, ...]
|
|
# Auto-detected extras.
|
|
shared_base_dir: Path | None
|
|
backend_test_dirs: tuple[Path, ...]
|
|
ruff_ignores: tuple[RuffIgnore, ...]
|
|
consumer_rewrites: tuple[ConsumerRewrite, ...]
|
|
base_extra_present: bool
|
|
# The original ``__init__.py``'s ``_dynamic_imports`` dict (if any),
|
|
# used to preserve the lazy-import shape during port.
|
|
legacy_dynamic_imports: dict[str, str] = field(default_factory=dict)
|
|
legacy_all: tuple[str, ...] = ()
|
|
|
|
|
|
# Capture both class name and base list so we can filter out classes
|
|
# that obviously aren't Langflow components (pydantic BaseModels,
|
|
# dataclasses, vendor SDK schemas, ABCs, etc.).
|
|
_CLASS_DECL_RE = re.compile(r"^class\s+(?P<name>\w+)\s*\((?P<bases>[^)]*)\)", re.MULTILINE)
|
|
|
|
# Bases (or any token in the bases list) that mean "this is a runtime
|
|
# data shape, not a component" -- the loader doesn't register these and
|
|
# the migration table must not name them. Match on the bare identifier
|
|
# so dotted/qualified forms like ``pydantic.BaseModel`` still trip the
|
|
# filter.
|
|
_NON_COMPONENT_BASES = frozenset(
|
|
{
|
|
"BaseModel",
|
|
"BaseSettings",
|
|
"RootModel",
|
|
"TypedDict",
|
|
"NamedTuple",
|
|
"Enum",
|
|
"IntEnum",
|
|
"StrEnum",
|
|
"Flag",
|
|
"Exception",
|
|
"Protocol",
|
|
"ABC",
|
|
"ABCMeta",
|
|
"object",
|
|
}
|
|
)
|
|
_DYNAMIC_IMPORTS_RE = re.compile(
|
|
r"_dynamic_imports\s*=\s*\{(?P<body>.+?)\}",
|
|
re.DOTALL,
|
|
)
|
|
_DICT_ENTRY_RE = re.compile(r'"(?P<key>[\w]+)"\s*:\s*"(?P<value>[\w.]+)"')
|
|
_ALL_RE = re.compile(r"__all__\s*=\s*\[(?P<body>.+?)\]", re.DOTALL)
|
|
_PER_FILE_IGNORES_ENTRY_RE = re.compile(
|
|
r'^"(?P<pattern>[^"]+)"\s*=\s*\[(?P<body>[^\]]+)\]',
|
|
re.MULTILINE,
|
|
)
|
|
|
|
|
|
def _discover_classes(source: str) -> tuple[str, ...]:
|
|
"""Return top-level Component-shaped class names declared in ``source``.
|
|
|
|
Filters out classes whose first base is a known non-Component
|
|
shape (``BaseModel``, ``Enum``, ``TypedDict``, ``ABC``, ``Exception``,
|
|
etc.). The filter is base-list-based rather than name-based because
|
|
Langflow's naming convention is loose: some components don't end in
|
|
``Component`` (``Dotenv``, ``GetEnvVar``).
|
|
"""
|
|
result: list[str] = []
|
|
for m in _CLASS_DECL_RE.finditer(source):
|
|
bases_raw = m.group("bases")
|
|
# Pull bare base identifiers (last dotted segment, no kwargs).
|
|
base_idents = {
|
|
tok.split(".")[-1].strip() for tok in re.split(r"[,\s]+", bases_raw.strip()) if tok and "=" not in tok
|
|
}
|
|
if base_idents & _NON_COMPONENT_BASES:
|
|
continue
|
|
result.append(m.group("name"))
|
|
return tuple(result)
|
|
|
|
|
|
def _parse_legacy_init(init_path: Path) -> tuple[dict[str, str], tuple[str, ...]]:
|
|
"""Pull ``_dynamic_imports`` and ``__all__`` out of the legacy ``__init__.py``.
|
|
|
|
Returns ``(dynamic_imports, all_list)``. An empty mapping / tuple
|
|
means the legacy ``__init__.py`` did not use the lazy-import shape
|
|
-- in that case the script falls back to discovering classes by
|
|
AST-scanning the moved files (which is what the original script
|
|
did for the ``arxiv`` and ``duckduckgo`` pilots).
|
|
"""
|
|
if not init_path.is_file():
|
|
return {}, ()
|
|
text = init_path.read_text(encoding="utf-8")
|
|
|
|
di_match = _DYNAMIC_IMPORTS_RE.search(text)
|
|
dynamic_imports: dict[str, str] = {}
|
|
if di_match:
|
|
for entry in _DICT_ENTRY_RE.finditer(di_match.group("body")):
|
|
dynamic_imports[entry.group("key")] = entry.group("value")
|
|
|
|
all_list: tuple[str, ...] = ()
|
|
all_match = _ALL_RE.search(text)
|
|
if all_match:
|
|
all_list = tuple(re.findall(r'"([^"]+)"', all_match.group("body")))
|
|
|
|
return dynamic_imports, all_list
|
|
|
|
|
|
def _discover_external_consumers(
|
|
bundle: str,
|
|
in_tree_dir: Path,
|
|
bundle_dir: Path,
|
|
*,
|
|
shared_base_dir: Path | None,
|
|
) -> tuple[ConsumerRewrite, ...]:
|
|
"""Grep the repo for files that import ``lfx.components.<bundle>`` or ``lfx.base.<bundle>``.
|
|
|
|
Excludes:
|
|
* The in-tree provider directory (about to be moved).
|
|
* The bundle's own tree.
|
|
* ``__pycache__``, ``.venv``, ``node_modules``, build artefacts.
|
|
* The migration-table JSON (which legitimately contains those
|
|
strings as ``import_path`` values that must NOT be rewritten).
|
|
"""
|
|
needles = (f"lfx.components.{bundle}", f"lfx.base.{bundle}")
|
|
consumers: dict[Path, list[tuple[str, str]]] = {}
|
|
|
|
rg_cmd = [
|
|
"rg",
|
|
"--files-with-matches",
|
|
"--no-config",
|
|
"--glob",
|
|
"!**/__pycache__/**",
|
|
"--glob",
|
|
"!**/.venv/**",
|
|
"--glob",
|
|
"!**/node_modules/**",
|
|
"--glob",
|
|
"!**/dist/**",
|
|
"--glob",
|
|
"!**/build/**",
|
|
"--glob",
|
|
f"!{in_tree_dir.relative_to(REPO_ROOT)}/**",
|
|
"--glob",
|
|
f"!{bundle_dir.relative_to(REPO_ROOT)}/**",
|
|
# The shared base (if any) moves into the bundle in Phase A;
|
|
# any ``lfx.base.<bundle>`` self-imports inside it are handled
|
|
# by _move_shared_base, not by consumer rewrite.
|
|
*(("--glob", f"!{shared_base_dir.relative_to(REPO_ROOT)}/**") if shared_base_dir is not None else ()),
|
|
# The migration table legitimately contains the legacy paths as
|
|
# data; never rewrite it.
|
|
"--glob",
|
|
"!src/lfx/src/lfx/extension/migration/migration_table.json",
|
|
# The component_index also contains datastax category metadata
|
|
# in stripped/embedded source-code values; handled via a
|
|
# separate surgical removal pass, not via substring rewrite.
|
|
"--glob",
|
|
"!src/lfx/src/lfx/_assets/component_index.json",
|
|
# Root + bundle pyprojects contain ruff per-file-ignores tied
|
|
# to ``src/lfx/src/lfx/components/<bundle>/`` paths. Those are
|
|
# migrated separately by _strip_root_ruff_ignores +
|
|
# _render_pyproject -- never touch them via consumer rewrite.
|
|
"--glob",
|
|
"!pyproject.toml",
|
|
"--glob",
|
|
"!src/backend/base/pyproject.toml",
|
|
"--glob",
|
|
"!src/lfx/pyproject.toml",
|
|
"--glob",
|
|
"!src/sdk/pyproject.toml",
|
|
"--glob",
|
|
"!src/bundles/**/pyproject.toml",
|
|
# Markdown and docs are documentation -- rewrites there are
|
|
# presentation-only and risk breaking the doc rendering. The
|
|
# porter can update docs by hand if needed.
|
|
"--glob",
|
|
"!**/*.md",
|
|
"--glob",
|
|
"!**/*.mdx",
|
|
# JSON files are saved-flow artefacts (starter projects, legacy
|
|
# version fixtures). The migration table rewrites these at flow
|
|
# load time -- the bare-name + full-path + short-path entries
|
|
# in the four-entry block we just appended cover every legacy
|
|
# form Langflow has serialized. Mechanically rewriting the
|
|
# JSONs would defeat the migration test suite's purpose
|
|
# (verifying frozen historical snapshots still load).
|
|
"--glob",
|
|
"!**/*.json",
|
|
"-e",
|
|
needles[0],
|
|
"-e",
|
|
needles[1],
|
|
str(REPO_ROOT),
|
|
]
|
|
try:
|
|
out = subprocess.check_output(rg_cmd, text=True) # noqa: S603
|
|
except FileNotFoundError as exc:
|
|
msg = "ripgrep (rg) is required for --rewrite-consumers but was not found on PATH."
|
|
raise SystemExit(msg) from exc
|
|
except subprocess.CalledProcessError as exc:
|
|
# rg returns 1 when no matches are found; that's a legitimate
|
|
# outcome (no external consumers exist).
|
|
if exc.returncode == 1:
|
|
return ()
|
|
raise
|
|
|
|
# The substitution order matters: do the most specific rewrite first
|
|
# so the catch-all doesn't shadow it. These ordered pairs are what
|
|
# the datastax port applied by hand.
|
|
base_subs = (
|
|
# ``from lfx.base.<bundle> import X`` -> ``from lfx_<bundle>.base import X``
|
|
(f"from lfx.base.{bundle} import", f"from lfx_{bundle}.base import"),
|
|
# ``lfx.base.<bundle>.<module>`` patch-path form
|
|
(f"lfx.base.{bundle}.", f"lfx_{bundle}.base."),
|
|
# ``lfx.components.<bundle>.<module>`` patch-path / import-path form
|
|
(f"lfx.components.{bundle}.", f"lfx_{bundle}.components.{bundle}."),
|
|
# ``from lfx.components.<bundle> import X`` (re-export form) ->
|
|
# ``from lfx_<bundle> import X``
|
|
(f"from lfx.components.{bundle} import", f"from lfx_{bundle} import"),
|
|
)
|
|
|
|
for raw in out.splitlines():
|
|
path = Path(raw).resolve()
|
|
try:
|
|
path.relative_to(REPO_ROOT)
|
|
except ValueError:
|
|
continue
|
|
consumers[path] = list(base_subs)
|
|
|
|
return tuple(ConsumerRewrite(path=p, substitutions=tuple(subs)) for p, subs in sorted(consumers.items()))
|
|
|
|
|
|
def _discover_backend_test_dirs(bundle: str) -> tuple[Path, ...]:
|
|
"""Auto-detect backend test directories tied to this bundle.
|
|
|
|
Returns paths that exist; up to two: the base/<bundle> tests and the
|
|
components/<bundle> tests. Either or both may be absent.
|
|
"""
|
|
candidates = (
|
|
BACKEND_BASE_TESTS / bundle,
|
|
REPO_ROOT / "src" / "backend" / "tests" / "unit" / "components" / bundle,
|
|
)
|
|
return tuple(c for c in candidates if c.is_dir())
|
|
|
|
|
|
def _discover_ruff_ignores(bundle: str) -> tuple[RuffIgnore, ...]:
|
|
"""Find ``per-file-ignores`` entries pointing at the in-tree provider directory."""
|
|
if not ROOT_PYPROJECT.is_file():
|
|
return ()
|
|
text = ROOT_PYPROJECT.read_text(encoding="utf-8")
|
|
needle_dir = f"src/lfx/src/lfx/components/{bundle}/"
|
|
found: list[RuffIgnore] = []
|
|
for m in _PER_FILE_IGNORES_ENTRY_RE.finditer(text):
|
|
pat = m.group("pattern")
|
|
if needle_dir not in pat and not pat.startswith(needle_dir.rstrip("/")):
|
|
continue
|
|
codes = tuple(re.findall(r'"([A-Z]+\d+)"', m.group("body")))
|
|
if codes:
|
|
found.append(RuffIgnore(pattern=pat, codes=codes))
|
|
return tuple(found)
|
|
|
|
|
|
def _discover_base_extra(bundle: str) -> bool:
|
|
"""Detect whether ``src/backend/base/pyproject.toml`` ships a ``<bundle>`` extra."""
|
|
if not BASE_PYPROJECT.is_file():
|
|
return False
|
|
text = BASE_PYPROJECT.read_text(encoding="utf-8")
|
|
return bool(re.search(rf"^{re.escape(bundle)}\s*=\s*\[", text, re.MULTILINE))
|
|
|
|
|
|
def _validate_candidate(
|
|
bundle: str,
|
|
*,
|
|
display_name: str | None,
|
|
migration_release: str | None,
|
|
discover_consumers: bool,
|
|
) -> PortPlan:
|
|
"""Refuse early if the candidate is not eligible for the mechanical port."""
|
|
if not re.fullmatch(r"[a-z][a-z0-9_]{1,63}", bundle):
|
|
msg = (
|
|
f"--bundle {bundle!r} is not a valid bundle name (lowercase "
|
|
"snake_case, 2-64 chars, starts with a letter). This script "
|
|
"ports an in-tree provider folder named exactly the same."
|
|
)
|
|
raise SystemExit(msg)
|
|
|
|
in_tree = LFX_COMPONENTS / bundle
|
|
if not in_tree.is_dir():
|
|
msg = f"In-tree provider directory not found: {in_tree}"
|
|
raise SystemExit(msg)
|
|
|
|
bundle_dir = BUNDLES_DIR / bundle
|
|
if bundle_dir.exists():
|
|
msg = f"Bundle directory already exists: {bundle_dir}. Refusing to overwrite."
|
|
raise SystemExit(msg)
|
|
|
|
deactivated_dup = LFX_COMPONENTS / "deactivated" / bundle
|
|
if deactivated_dup.is_dir():
|
|
msg = (
|
|
f"A deactivated duplicate exists at {deactivated_dup}. Resolve "
|
|
"the duplicate manually before porting -- see "
|
|
"src/bundles/PORTING.md § 0."
|
|
)
|
|
raise SystemExit(msg)
|
|
|
|
component_files: list[ComponentFile] = []
|
|
nested_subdirs: list[Path] = []
|
|
for src in sorted(in_tree.iterdir()):
|
|
if src.is_dir():
|
|
if src.name == "__pycache__":
|
|
continue
|
|
# Nested subpackage (e.g. ``agentics/helpers``); validate
|
|
# its files for ``from langflow`` imports too, then plan
|
|
# to move the whole subdir wholesale.
|
|
for nested in src.rglob("*.py"):
|
|
if "__pycache__" in nested.parts:
|
|
continue
|
|
text = nested.read_text(encoding="utf-8")
|
|
if "from langflow" in text:
|
|
msg = (
|
|
f"{nested} imports from ``langflow`` -- the bundle is "
|
|
"installed against the public BUNDLE_API surface (lfx), "
|
|
"not Langflow internals. Either rewrite the import or "
|
|
"leave the component in-tree. See src/bundles/PORTING.md § 0."
|
|
)
|
|
raise SystemExit(msg)
|
|
nested_subdirs.append(src)
|
|
continue
|
|
if not src.is_file() or src.suffix != ".py":
|
|
continue
|
|
text = src.read_text(encoding="utf-8")
|
|
if "from langflow" in text:
|
|
msg = (
|
|
f"{src} imports from ``langflow`` -- the bundle is installed "
|
|
"against the public BUNDLE_API surface (lfx), not Langflow "
|
|
"internals. Either rewrite the import or leave the component "
|
|
"in-tree. See src/bundles/PORTING.md § 0."
|
|
)
|
|
raise SystemExit(msg)
|
|
classes = () if src.name == "__init__.py" else _discover_classes(text)
|
|
component_files.append(ComponentFile(path=src, classes=classes))
|
|
|
|
if not component_files and not nested_subdirs:
|
|
msg = f"No ``*.py`` files under {in_tree}; nothing to port."
|
|
raise SystemExit(msg)
|
|
|
|
if migration_release is not None and not re.fullmatch(r"\d+\.\d+\.\d+", migration_release):
|
|
msg = f"--migration-release {migration_release!r} must be a three-segment SemVer (e.g. 1.10.0)."
|
|
raise SystemExit(msg)
|
|
|
|
resolved_display = display_name if display_name is not None else bundle.replace("_", " ").title()
|
|
|
|
shared_base = LFX_BASE / bundle
|
|
shared_base_dir = shared_base if shared_base.is_dir() else None
|
|
|
|
backend_test_dirs = _discover_backend_test_dirs(bundle)
|
|
ruff_ignores = _discover_ruff_ignores(bundle)
|
|
base_extra_present = _discover_base_extra(bundle)
|
|
|
|
legacy_dynamic, legacy_all = _parse_legacy_init(in_tree / "__init__.py")
|
|
|
|
consumer_rewrites: tuple[ConsumerRewrite, ...] = ()
|
|
if discover_consumers:
|
|
consumer_rewrites = _discover_external_consumers(bundle, in_tree, bundle_dir, shared_base_dir=shared_base_dir)
|
|
|
|
return PortPlan(
|
|
bundle=bundle,
|
|
display_name=resolved_display,
|
|
in_tree_dir=in_tree,
|
|
bundle_dir=bundle_dir,
|
|
component_files=tuple(component_files),
|
|
migration_release=migration_release,
|
|
nested_subdirs=tuple(nested_subdirs),
|
|
shared_base_dir=shared_base_dir,
|
|
backend_test_dirs=backend_test_dirs,
|
|
ruff_ignores=ruff_ignores,
|
|
consumer_rewrites=consumer_rewrites,
|
|
base_extra_present=base_extra_present,
|
|
legacy_dynamic_imports=legacy_dynamic,
|
|
legacy_all=legacy_all,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Phase A: layout
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _render_package_init(plan: PortPlan) -> str:
|
|
"""Package-level ``__init__.py`` body: import every Component class by name."""
|
|
classes: list[str] = []
|
|
if plan.legacy_all:
|
|
# Preserve the order the original ``__init__.py`` declared.
|
|
ordered = list(plan.legacy_all)
|
|
for class_name in ordered:
|
|
module = plan.legacy_dynamic_imports.get(class_name)
|
|
if module is None:
|
|
# Fall back to AST discovery for classes the legacy
|
|
# ``__init__.py`` didn't list (e.g. ``hcd.py`` in the
|
|
# datastax port -- the class was importable via the
|
|
# full path but not the module-level re-export).
|
|
module = next(
|
|
(cf.path.stem for cf in plan.component_files if class_name in cf.classes),
|
|
None,
|
|
)
|
|
if module is None:
|
|
continue
|
|
classes.append(f"from lfx_{plan.bundle}.components.{plan.bundle}.{module} import {class_name}")
|
|
# Also append classes the legacy init missed (so the bundle's
|
|
# exported surface is the full discovered set).
|
|
legacy_set = set(plan.legacy_all)
|
|
for cf in plan.component_files:
|
|
if cf.path.name == "__init__.py":
|
|
continue
|
|
for cls in cf.classes:
|
|
if cls in legacy_set:
|
|
continue
|
|
classes.append(f"from lfx_{plan.bundle}.components.{plan.bundle}.{cf.path.stem} import {cls}")
|
|
else:
|
|
for cf in plan.component_files:
|
|
if cf.path.name == "__init__.py":
|
|
continue
|
|
for cls in cf.classes:
|
|
classes.append(f"from lfx_{plan.bundle}.components.{plan.bundle}.{cf.path.stem} import {cls}") # noqa: PERF401
|
|
|
|
if not classes:
|
|
return (
|
|
"# TODO(port_bundle): no top-level ``class <Name>(...):`` declarations were\n"
|
|
"# discovered. Add the Component class re-exports manually so saved-flow\n"
|
|
"# migration entries that target ``lfx.components.<bundle>.<Class>`` resolve."
|
|
)
|
|
|
|
all_names = sorted({line.rsplit(" import ", 1)[1] for line in classes})
|
|
all_block = "\n ".join(f'"{n}",' for n in all_names)
|
|
return "\n".join(classes) + f"\n\n__all__ = [\n {all_block}\n]"
|
|
|
|
|
|
def _render_components_init(plan: PortPlan) -> str:
|
|
"""Lazy-import shape for ``lfx_<bundle>/components/<bundle>/__init__.py``."""
|
|
if plan.legacy_dynamic_imports:
|
|
dynamic = dict(plan.legacy_dynamic_imports)
|
|
else:
|
|
dynamic = {}
|
|
for cf in plan.component_files:
|
|
if cf.path.name == "__init__.py":
|
|
continue
|
|
for cls in cf.classes:
|
|
dynamic[cls] = cf.path.stem
|
|
|
|
# Cover anything in the discovery that the legacy init missed.
|
|
legacy_known = set(plan.legacy_dynamic_imports)
|
|
for cf in plan.component_files:
|
|
if cf.path.name == "__init__.py":
|
|
continue
|
|
for cls in cf.classes:
|
|
if cls in legacy_known:
|
|
continue
|
|
dynamic.setdefault(cls, cf.path.stem)
|
|
|
|
if not dynamic:
|
|
return COMPONENTS_INIT_TEMPLATE.format(
|
|
bundle=plan.bundle,
|
|
type_checking_imports=" pass",
|
|
dynamic_imports_dict="{}",
|
|
all_list="[]",
|
|
)
|
|
|
|
ordered = sorted(dynamic.items())
|
|
type_checking = "\n".join(f" from .{module} import {cls}" for cls, module in ordered)
|
|
dict_lines = ",\n".join(f' "{cls}": "{module}"' for cls, module in ordered)
|
|
all_lines = ",\n".join(f' "{cls}"' for cls, _ in ordered)
|
|
return COMPONENTS_INIT_TEMPLATE.format(
|
|
bundle=plan.bundle,
|
|
type_checking_imports=type_checking,
|
|
dynamic_imports_dict="{\n" + dict_lines + ",\n}",
|
|
all_list="[\n" + all_lines + ",\n]",
|
|
)
|
|
|
|
|
|
def _render_pyproject(plan: PortPlan) -> str:
|
|
wheel_extra = ""
|
|
ruff_section = ""
|
|
if plan.shared_base_dir is not None:
|
|
wheel_extra = f' "src/lfx_{plan.bundle}/base/**/*.py",\n'
|
|
if plan.ruff_ignores:
|
|
# Rewrite paths so they live under the bundle's own tree.
|
|
entries: list[str] = []
|
|
in_tree_prefix = f"src/lfx/src/lfx/components/{plan.bundle}/"
|
|
bundle_prefix = f"src/lfx_{plan.bundle}/components/{plan.bundle}/"
|
|
for ri in plan.ruff_ignores:
|
|
new_pat = ri.pattern.replace(in_tree_prefix, bundle_prefix)
|
|
comma_codes = ", ".join(f'"{c}"' for c in ri.codes)
|
|
entries.append(f'"{new_pat}" = [{comma_codes}]')
|
|
ruff_section = RUFF_SECTION_TEMPLATE.format(ruff_entries="\n".join(entries))
|
|
return PYPROJECT_TEMPLATE.format(
|
|
bundle=plan.bundle,
|
|
display_name=plan.display_name,
|
|
lfx_floor=_current_lfx_floor(),
|
|
wheel_include_extra=wheel_extra,
|
|
ruff_section=ruff_section,
|
|
)
|
|
|
|
|
|
def _move_shared_base(plan: PortPlan, *, apply: bool) -> list[str]:
|
|
"""Move ``lfx/base/<bundle>/`` into the bundle's ``lfx_<bundle>/base/``."""
|
|
if plan.shared_base_dir is None:
|
|
return []
|
|
actions: list[str] = []
|
|
dst_dir = plan.bundle_dir / "src" / f"lfx_{plan.bundle}" / "base"
|
|
actions.append(f"mkdir -p {dst_dir.relative_to(REPO_ROOT)}")
|
|
if apply:
|
|
dst_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
base_classes: list[tuple[str, str]] = [] # (module_stem, ClassName)
|
|
for src in sorted(plan.shared_base_dir.iterdir()):
|
|
if not src.is_file() or src.suffix != ".py":
|
|
continue
|
|
if src.name == "__init__.py":
|
|
continue
|
|
dst = dst_dir / src.name
|
|
actions.append(f"move {src.relative_to(REPO_ROOT)} -> {dst.relative_to(REPO_ROOT)}")
|
|
if apply:
|
|
shutil.move(str(src), str(dst))
|
|
# Rewrite intra-bundle imports inside the moved base file.
|
|
content = dst.read_text(encoding="utf-8")
|
|
new_content = content.replace(
|
|
f"from lfx.base.{plan.bundle}.",
|
|
f"from lfx_{plan.bundle}.base.",
|
|
)
|
|
if new_content != content:
|
|
dst.write_text(new_content, encoding="utf-8")
|
|
# Record class re-exports for the base/__init__.py.
|
|
text = src.read_text(encoding="utf-8") if not apply else dst.read_text(encoding="utf-8")
|
|
for cls in _discover_classes(text):
|
|
base_classes.append((src.stem, cls)) # noqa: PERF401
|
|
|
|
# Write base/__init__.py that re-exports every class from every
|
|
# module under the base subpackage.
|
|
init_path = dst_dir / "__init__.py"
|
|
actions.append(f"write {init_path.relative_to(REPO_ROOT)}")
|
|
if apply:
|
|
if base_classes:
|
|
reexports = "\n".join(f"from lfx_{plan.bundle}.base.{m} import {c}" for m, c in base_classes)
|
|
all_list = "[\n " + ",\n ".join(f'"{c}"' for _, c in base_classes) + ",\n]"
|
|
body = reexports + f"\n\n__all__ = {all_list}"
|
|
else:
|
|
body = "__all__: list[str] = []"
|
|
init_path.write_text(
|
|
BASE_INIT_TEMPLATE.format(bundle=plan.bundle, base_reexports=body),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
# Finally, remove the original ``lfx.base.<bundle>`` directory.
|
|
actions.append(f"rm -r {plan.shared_base_dir.relative_to(REPO_ROOT)}")
|
|
if apply and plan.shared_base_dir.exists():
|
|
shutil.rmtree(plan.shared_base_dir)
|
|
return actions
|
|
|
|
|
|
def _rewrite_intra_bundle_imports(plan: PortPlan, *, apply: bool, components_dir: Path) -> list[str]:
|
|
"""Rewrite intra-bundle imports in moved components.
|
|
|
|
Two substitutions, applied recursively across the bundle's
|
|
``components/<bundle>/`` tree (so nested subpackages like
|
|
``agentics/helpers/`` and ``agentics/inputs/`` are covered):
|
|
|
|
* ``lfx.base.<bundle>`` -> ``lfx_<bundle>.base`` (shared-base move)
|
|
* ``lfx.components.<bundle>`` -> ``lfx_<bundle>.components.<bundle>``
|
|
(cross-file imports between components/<bundle>/ siblings or
|
|
subpackages -- agentics in particular has helpers/* files that
|
|
import ``lfx_agentics.components.agentics.constants`` etc.)
|
|
"""
|
|
actions: list[str] = []
|
|
substitutions: list[tuple[str, str]] = []
|
|
if plan.shared_base_dir is not None:
|
|
substitutions.append((f"lfx.base.{plan.bundle}", f"lfx_{plan.bundle}.base"))
|
|
if plan.nested_subdirs:
|
|
substitutions.append((f"lfx.components.{plan.bundle}", f"lfx_{plan.bundle}.components.{plan.bundle}"))
|
|
|
|
if not substitutions:
|
|
return actions
|
|
if not apply:
|
|
for needle, repl in substitutions:
|
|
actions.append(f"rewrite ``{needle}`` -> ``{repl}`` in {components_dir.relative_to(REPO_ROOT)}/**/*.py")
|
|
return actions
|
|
|
|
for f in sorted(components_dir.rglob("*.py")):
|
|
content = f.read_text(encoding="utf-8")
|
|
new_content = content
|
|
for needle, repl in substitutions:
|
|
new_content = new_content.replace(needle, repl)
|
|
if new_content != content:
|
|
f.write_text(new_content, encoding="utf-8")
|
|
actions.append(f"rewrite imports in {f.relative_to(REPO_ROOT)}")
|
|
return actions
|
|
|
|
|
|
def _layout_bundle(plan: PortPlan, *, apply: bool) -> list[str]:
|
|
"""Create the bundle directory tree, write static files, move sources."""
|
|
actions: list[str] = []
|
|
package_dir = plan.bundle_dir / "src" / f"lfx_{plan.bundle}"
|
|
components_dir = package_dir / "components" / plan.bundle
|
|
|
|
actions.append(f"mkdir -p {components_dir.relative_to(REPO_ROOT)}")
|
|
if apply:
|
|
components_dir.mkdir(parents=True, exist_ok=False)
|
|
# Make ``components/`` a proper package.
|
|
(components_dir.parent / "__init__.py").write_text("", encoding="utf-8")
|
|
|
|
files = {
|
|
plan.bundle_dir / "pyproject.toml": _render_pyproject(plan),
|
|
plan.bundle_dir / "README.md": README_TEMPLATE.format(bundle=plan.bundle, display_name=plan.display_name),
|
|
package_dir / "__init__.py": PACKAGE_INIT_TEMPLATE.format(
|
|
bundle=plan.bundle,
|
|
display_name=plan.display_name,
|
|
package_reexports=_render_package_init(plan),
|
|
),
|
|
package_dir / "extension.json": EXTENSION_JSON_TEMPLATE.format(
|
|
bundle=plan.bundle, display_name=plan.display_name
|
|
),
|
|
components_dir / "__init__.py": _render_components_init(plan),
|
|
}
|
|
for path, content in files.items():
|
|
actions.append(f"write {path.relative_to(REPO_ROOT)}")
|
|
if apply:
|
|
path.write_text(content, encoding="utf-8")
|
|
|
|
for cf in plan.component_files:
|
|
src = cf.path
|
|
if src.name == "__init__.py":
|
|
# The package-level __init__.py is rendered above; the
|
|
# legacy file is removed in the in-tree cleanup phase.
|
|
continue
|
|
dst = components_dir / src.name
|
|
actions.append(f"move {src.relative_to(REPO_ROOT)} -> {dst.relative_to(REPO_ROOT)}")
|
|
if apply:
|
|
shutil.move(str(src), str(dst))
|
|
|
|
# Move nested subpackages wholesale (e.g. ``agentics/helpers``,
|
|
# ``agentics/inputs``). Their internal ``lfx.components.<bundle>...``
|
|
# imports get rewritten in-place by the subsequent intra-bundle
|
|
# import rewrite pass below.
|
|
for subdir in plan.nested_subdirs:
|
|
dst = components_dir / subdir.name
|
|
actions.append(f"move {subdir.relative_to(REPO_ROOT)}/ -> {dst.relative_to(REPO_ROOT)}/")
|
|
if apply:
|
|
shutil.move(str(subdir), str(dst))
|
|
|
|
# Move the shared base (if any) and rewrite intra-bundle imports.
|
|
actions += _move_shared_base(plan, apply=apply)
|
|
actions += _rewrite_intra_bundle_imports(plan, apply=apply, components_dir=components_dir)
|
|
return actions
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Phase B: in-tree cleanup
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _delete_in_tree(plan: PortPlan, *, apply: bool) -> list[str]:
|
|
"""Remove the legacy in-tree provider directory."""
|
|
actions = [f"rm -r {plan.in_tree_dir.relative_to(REPO_ROOT)}"]
|
|
if apply and plan.in_tree_dir.exists():
|
|
shutil.rmtree(plan.in_tree_dir)
|
|
return actions
|
|
|
|
|
|
def _patch_components_init(plan: PortPlan, *, apply: bool) -> list[str]:
|
|
"""Remove the three references to ``<bundle>`` in components/__init__.py."""
|
|
target = LFX_COMPONENTS / "__init__.py"
|
|
actions = [f"strip {plan.bundle!r} refs from {target.relative_to(REPO_ROOT)}"]
|
|
if not apply:
|
|
return actions
|
|
text = target.read_text(encoding="utf-8")
|
|
patterns = (
|
|
rf"^ {{8}}{re.escape(plan.bundle)},\n",
|
|
rf'^ {{4}}"{re.escape(plan.bundle)}": "__module__",\n',
|
|
rf'^ {{4}}"{re.escape(plan.bundle)}",\n',
|
|
)
|
|
new_text = text
|
|
for pattern in patterns:
|
|
new_text = re.sub(pattern, "", new_text, count=1, flags=re.MULTILINE)
|
|
if new_text == text:
|
|
# Some bundles were never registered in the central
|
|
# ``components.__init__.py`` -- e.g. ``cometapi`` and ``vllm``
|
|
# ship in-tree component directories but the parent init does
|
|
# not list them. Treat as a no-op so the rest of the port
|
|
# (workspace patch, migration entries, etc.) still completes.
|
|
actions[-1] = f" (no {plan.bundle!r} refs in {target.relative_to(REPO_ROOT)}; nothing to strip)"
|
|
return actions
|
|
target.write_text(new_text, encoding="utf-8")
|
|
return actions
|
|
|
|
|
|
def _strip_root_ruff_ignores(plan: PortPlan, *, apply: bool) -> list[str]:
|
|
"""Remove ``per-file-ignores`` entries pointing at the in-tree provider dir."""
|
|
if not plan.ruff_ignores:
|
|
return []
|
|
actions = [
|
|
f"remove {len(plan.ruff_ignores)} ruff per-file-ignore "
|
|
f"entry(ies) from {ROOT_PYPROJECT.relative_to(REPO_ROOT)} "
|
|
f"(migrated to bundle pyproject)"
|
|
]
|
|
if not apply:
|
|
return actions
|
|
text = ROOT_PYPROJECT.read_text(encoding="utf-8")
|
|
for ri in plan.ruff_ignores:
|
|
# Remove the entire entry block (``"<pattern>" = [...]\n``);
|
|
# the block may span multiple lines so we anchor on the
|
|
# pattern line and consume up to the closing ``]\n``.
|
|
# The bracketed body may contain newlines, so use re.DOTALL.
|
|
block = re.compile(
|
|
r'^"' + re.escape(ri.pattern) + r'"\s*=\s*\[[^\]]*\]\n',
|
|
re.MULTILINE | re.DOTALL,
|
|
)
|
|
new_text = block.sub("", text, count=1)
|
|
if new_text == text:
|
|
msg = (
|
|
f"Could not strip ruff per-file-ignore entry for "
|
|
f"{ri.pattern!r}; the regex may not match the current "
|
|
"layout. Edit by hand."
|
|
)
|
|
raise SystemExit(msg)
|
|
text = new_text
|
|
ROOT_PYPROJECT.write_text(text, encoding="utf-8")
|
|
return actions
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Phase C: workspace + external consumers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _insert_before_marker(text: str, end_marker: str, payload: str, *, what: str) -> str:
|
|
needle = end_marker
|
|
idx = text.find(needle)
|
|
if idx == -1:
|
|
msg = (
|
|
f"Could not locate the {what} end marker ({needle!r}) in "
|
|
"pyproject.toml. Re-add the ``langflow-extensions:bundle-*`` "
|
|
"marker pair before re-running -- see src/bundles/PORTING.md."
|
|
)
|
|
raise SystemExit(msg)
|
|
line_start = text.rfind("\n", 0, idx) + 1
|
|
return text[:line_start] + payload + text[line_start:]
|
|
|
|
|
|
def _patch_root_pyproject(plan: PortPlan, *, apply: bool) -> list[str]:
|
|
actions = [f"patch {ROOT_PYPROJECT.relative_to(REPO_ROOT)}"]
|
|
if not apply:
|
|
return actions
|
|
text = ROOT_PYPROJECT.read_text(encoding="utf-8")
|
|
bundle = plan.bundle
|
|
|
|
if f"lfx-{bundle}" in text:
|
|
msg = f"lfx-{bundle} already referenced in {ROOT_PYPROJECT.relative_to(REPO_ROOT)}; not patching."
|
|
raise SystemExit(msg)
|
|
|
|
dep_line = f' "lfx-{bundle}>=0.1.0",\n'
|
|
source_line = f"lfx-{bundle} = {{ workspace = true }}\n"
|
|
member_line = f' "src/bundles/{bundle}",\n'
|
|
|
|
new_text = _insert_before_marker(text, "# langflow-extensions:bundle-deps-end", dep_line, what="bundle-deps")
|
|
new_text = _insert_before_marker(
|
|
new_text, "# langflow-extensions:bundle-sources-end", source_line, what="bundle-sources"
|
|
)
|
|
new_text = _insert_before_marker(
|
|
new_text, "# langflow-extensions:bundle-members-end", member_line, what="bundle-members"
|
|
)
|
|
ROOT_PYPROJECT.write_text(new_text, encoding="utf-8")
|
|
return actions
|
|
|
|
|
|
def _rewrite_consumers(plan: PortPlan, *, apply: bool) -> list[str]:
|
|
if not plan.consumer_rewrites:
|
|
return []
|
|
actions: list[str] = [f"rewrite imports in {len(plan.consumer_rewrites)} external consumer file(s)"]
|
|
if not apply:
|
|
for cr in plan.consumer_rewrites:
|
|
actions.append(f" - {cr.path.relative_to(REPO_ROOT)}") # noqa: PERF401
|
|
return actions
|
|
for cr in plan.consumer_rewrites:
|
|
content = cr.path.read_text(encoding="utf-8")
|
|
new_content = content
|
|
for needle, replacement in cr.substitutions:
|
|
new_content = new_content.replace(needle, replacement)
|
|
if new_content != content:
|
|
cr.path.write_text(new_content, encoding="utf-8")
|
|
actions.append(f" rewrote {cr.path.relative_to(REPO_ROOT)}")
|
|
return actions
|
|
|
|
|
|
def _move_backend_tests(plan: PortPlan, *, apply: bool) -> list[str]:
|
|
if not plan.backend_test_dirs:
|
|
return []
|
|
actions: list[str] = []
|
|
dst_dir = plan.bundle_dir / "tests"
|
|
actions.append(f"mkdir -p {dst_dir.relative_to(REPO_ROOT)}")
|
|
if apply:
|
|
dst_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
needle_base = f"lfx.base.{plan.bundle}"
|
|
repl_base = f"lfx_{plan.bundle}.base"
|
|
needle_components = f"lfx.components.{plan.bundle}"
|
|
repl_components = f"lfx_{plan.bundle}.components.{plan.bundle}"
|
|
needle_short = f"from lfx.components.{plan.bundle} import"
|
|
repl_short = f"from lfx_{plan.bundle} import"
|
|
|
|
for test_dir in plan.backend_test_dirs:
|
|
for src in sorted(test_dir.iterdir()):
|
|
if not src.is_file() or src.suffix != ".py":
|
|
continue
|
|
if src.name == "__init__.py":
|
|
# The bundle's tests/ doesn't need a backend-pkg __init__
|
|
# since bundle tests run under bundle's own pytest config.
|
|
actions.append(f"rm {src.relative_to(REPO_ROOT)}")
|
|
if apply:
|
|
src.unlink()
|
|
continue
|
|
dst = dst_dir / src.name
|
|
actions.append(f"move {src.relative_to(REPO_ROOT)} -> {dst.relative_to(REPO_ROOT)}")
|
|
if apply:
|
|
shutil.move(str(src), str(dst))
|
|
content = dst.read_text(encoding="utf-8")
|
|
for needle, replacement in (
|
|
(needle_short, repl_short),
|
|
(needle_components, repl_components),
|
|
(needle_base, repl_base),
|
|
):
|
|
content = content.replace(needle, replacement)
|
|
dst.write_text(content, encoding="utf-8")
|
|
# Remove the now-empty source test dir.
|
|
if apply and test_dir.exists() and not any(test_dir.iterdir()):
|
|
test_dir.rmdir()
|
|
actions.append(f"rmdir {test_dir.relative_to(REPO_ROOT)}")
|
|
return actions
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Phase D: artefacts (migration table, pilot test, component index, dockerfiles)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _build_migration_entries(plan: PortPlan) -> list[dict]:
|
|
release = plan.migration_release
|
|
if release is None:
|
|
return []
|
|
entries: list[dict] = []
|
|
seen: set[str] = set()
|
|
# Use legacy_dynamic_imports first (preserves ordering / module mapping
|
|
# from the original __init__.py); fall back to AST discovery.
|
|
if plan.legacy_dynamic_imports:
|
|
items = list(plan.legacy_dynamic_imports.items())
|
|
else:
|
|
items = []
|
|
for cf in plan.component_files:
|
|
if cf.path.name == "__init__.py":
|
|
continue
|
|
for cls in cf.classes:
|
|
items.append((cls, cf.path.stem))
|
|
# Cover any class the legacy init missed (e.g. ``hcd.HCDVectorStoreComponent``
|
|
# in the datastax port).
|
|
legacy = set(plan.legacy_dynamic_imports)
|
|
for cf in plan.component_files:
|
|
if cf.path.name == "__init__.py":
|
|
continue
|
|
for cls in cf.classes:
|
|
if cls in legacy:
|
|
continue
|
|
items.append((cls, cf.path.stem))
|
|
|
|
for cls, module in items:
|
|
if cls in seen:
|
|
continue
|
|
seen.add(cls)
|
|
target = f"ext:{plan.bundle}:{cls}@official"
|
|
entries.extend(
|
|
[
|
|
{"bare_class_name": cls, "target": target, "added_in": release},
|
|
{
|
|
"import_path": f"lfx.components.{plan.bundle}.{module}.{cls}",
|
|
"target": target,
|
|
"added_in": release,
|
|
},
|
|
{
|
|
"import_path": f"lfx.components.{plan.bundle}.{cls}",
|
|
"target": target,
|
|
"added_in": release,
|
|
},
|
|
{
|
|
"legacy_slot": f"ext:{plan.bundle}:{cls}@official-pre-a",
|
|
"target": target,
|
|
"added_in": release,
|
|
},
|
|
]
|
|
)
|
|
return entries
|
|
|
|
|
|
def _write_migration_entries(plan: PortPlan, *, apply: bool) -> list[str]:
|
|
if plan.migration_release is None:
|
|
return []
|
|
entries = _build_migration_entries(plan)
|
|
if not entries:
|
|
return []
|
|
actions = [
|
|
f"append {len(entries)} entries to {MIGRATION_TABLE.relative_to(REPO_ROOT)} ({len(entries) // 4} class(es) x 4)"
|
|
]
|
|
if not apply:
|
|
return actions
|
|
table = json.loads(MIGRATION_TABLE.read_text(encoding="utf-8"))
|
|
table["entries"].extend(entries)
|
|
MIGRATION_TABLE.write_text(
|
|
json.dumps(table, indent=2, ensure_ascii=False) + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
return actions
|
|
|
|
|
|
def _render_pilot_test(plan: PortPlan) -> str:
|
|
bundle = plan.bundle
|
|
items: list[tuple[str, str]] = []
|
|
legacy = plan.legacy_dynamic_imports
|
|
if legacy:
|
|
for cls, module in legacy.items():
|
|
items.append((cls, module))
|
|
else:
|
|
for cf in plan.component_files:
|
|
if cf.path.name == "__init__.py":
|
|
continue
|
|
for cls in cf.classes:
|
|
items.append((cls, cf.path.stem)) # noqa: PERF401
|
|
# Cover anything legacy missed.
|
|
seen = {c for c, _ in items}
|
|
for cf in plan.component_files:
|
|
if cf.path.name == "__init__.py":
|
|
continue
|
|
for cls in cf.classes:
|
|
if cls not in seen:
|
|
items.append((cls, cf.path.stem))
|
|
seen.add(cls)
|
|
classes_literal = ",\n".join(f' ("{c}", "{m}")' for c, m in items)
|
|
return f'''"""Integration test: legacy {bundle} flows upgrade cleanly.
|
|
|
|
Generated by scripts/migrate/port_bundle.py. Mirrors the duckduckgo and
|
|
arxiv pilots: every bundle class is exercised through the migration
|
|
table via the four legacy forms (bare name, full import path, package
|
|
re-export path, pre-Phase-A slot), plus distribution importability,
|
|
manifest discovery, and loader resolution.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from importlib import metadata as importlib_metadata
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from lfx.extension.migration.loader import load_migration_table
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[5]
|
|
TABLE_PATH = REPO_ROOT / "src" / "lfx" / "src" / "lfx" / "extension" / "migration" / "migration_table.json"
|
|
|
|
BUNDLE_CLASSES: tuple[tuple[str, str], ...] = (
|
|
{classes_literal},
|
|
)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def migration_table():
|
|
table, error = load_migration_table(TABLE_PATH)
|
|
assert error is None, f"failed to load migration table: {{error}}"
|
|
assert table is not None
|
|
return table
|
|
|
|
|
|
def _saved_flow_node(node_id: str, type_value: str) -> dict:
|
|
return {{
|
|
"id": node_id,
|
|
"type": "genericNode",
|
|
"data": {{"id": node_id, "type": type_value, "node": {{"template": {{}}}}}},
|
|
}}
|
|
|
|
|
|
def _saved_flow(*nodes: dict) -> dict:
|
|
return {{"data": {{"nodes": list(nodes), "edges": []}}}}
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.parametrize(("class_name", "module_stem"), BUNDLE_CLASSES)
|
|
def test_legacy_bare_name_flow_upgrades(migration_table, class_name: str, module_stem: str) -> None: # noqa: ARG001
|
|
"""Bare class name rewrites to the canonical namespaced ID."""
|
|
from lfx.extension.migration.rewrite import migrate_flow_payload
|
|
|
|
expected = f"ext:{bundle}:{{class_name}}@official"
|
|
flow = _saved_flow(_saved_flow_node(f"{bundle}-bare-{{class_name}}", class_name))
|
|
report = migrate_flow_payload(flow, table=migration_table)
|
|
assert report.rewritten_count == 1
|
|
assert flow["data"]["nodes"][0]["data"]["type"] == expected
|
|
[record] = report.records
|
|
assert record.legacy_form_kind == "bare_class_name"
|
|
assert record.new_value == expected
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.parametrize(("class_name", "module_stem"), BUNDLE_CLASSES)
|
|
def test_legacy_import_path_flow_upgrades(migration_table, class_name: str, module_stem: str) -> None:
|
|
"""Full dotted import path rewrites to the canonical namespaced ID."""
|
|
from lfx.extension.migration.rewrite import migrate_flow_payload
|
|
|
|
expected = f"ext:{bundle}:{{class_name}}@official"
|
|
legacy = f"lfx.components.{bundle}.{{module_stem}}.{{class_name}}"
|
|
flow = _saved_flow(_saved_flow_node(f"{bundle}-full-{{class_name}}", legacy))
|
|
report = migrate_flow_payload(flow, table=migration_table)
|
|
assert report.rewritten_count == 1
|
|
assert flow["data"]["nodes"][0]["data"]["type"] == expected
|
|
assert report.records[0].legacy_form_kind == "import_path"
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.parametrize(("class_name", "module_stem"), BUNDLE_CLASSES)
|
|
def test_short_import_path_flow_upgrades(migration_table, class_name: str, module_stem: str) -> None: # noqa: ARG001
|
|
"""Package-level import-path rewrites cleanly."""
|
|
from lfx.extension.migration.rewrite import migrate_flow_payload
|
|
|
|
expected = f"ext:{bundle}:{{class_name}}@official"
|
|
legacy = f"lfx.components.{bundle}.{{class_name}}"
|
|
flow = _saved_flow(_saved_flow_node(f"{bundle}-short-{{class_name}}", legacy))
|
|
report = migrate_flow_payload(flow, table=migration_table)
|
|
assert report.rewritten_count == 1
|
|
assert flow["data"]["nodes"][0]["data"]["type"] == expected
|
|
|
|
|
|
@pytest.mark.integration
|
|
def test_lfx_{bundle}_distribution_is_importable() -> None:
|
|
"""The bundle is importable in the development workspace."""
|
|
try:
|
|
import lfx_{bundle} # type: ignore[import-not-found]
|
|
except ImportError:
|
|
pytest.skip("lfx-{bundle} not installed in this test environment")
|
|
|
|
for class_name, _module_stem in BUNDLE_CLASSES:
|
|
klass = getattr(lfx_{bundle}, class_name, None)
|
|
assert klass is not None, f"lfx_{bundle} does not re-export {{class_name!r}}"
|
|
assert klass.__name__ == class_name
|
|
|
|
|
|
def _is_editable_install(dist: importlib_metadata.Distribution) -> bool:
|
|
direct_url = dist.read_text("direct_url.json")
|
|
if not direct_url:
|
|
return False
|
|
try:
|
|
payload = json.loads(direct_url)
|
|
except json.JSONDecodeError:
|
|
return False
|
|
return bool(payload.get("dir_info", {{}}).get("editable"))
|
|
|
|
|
|
@pytest.mark.integration
|
|
def test_lfx_{bundle}_ships_manifest() -> None:
|
|
"""``importlib.metadata`` can find ``extension.json`` for the installed dist."""
|
|
try:
|
|
dist = importlib_metadata.distribution("lfx-{bundle}")
|
|
except importlib_metadata.PackageNotFoundError:
|
|
pytest.skip("lfx-{bundle} not installed in this test environment")
|
|
|
|
if _is_editable_install(dist):
|
|
import lfx_{bundle} # type: ignore[import-not-found]
|
|
|
|
package_dir = Path(lfx_{bundle}.__file__).parent
|
|
manifest_path = package_dir / "extension.json"
|
|
assert manifest_path.is_file()
|
|
else:
|
|
files = dist.files or []
|
|
manifests = [f for f in files if f.parts and f.parts[-1] == "extension.json"]
|
|
assert manifests
|
|
manifest_path = Path(dist.locate_file(manifests[0]))
|
|
|
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
assert manifest["id"] == "lfx-{bundle}"
|
|
assert manifest["lfx"]["compat"] == ["1"]
|
|
assert any(b["name"] == "{bundle}" for b in manifest["bundles"])
|
|
'''
|
|
|
|
|
|
def _write_pilot_test(plan: PortPlan, *, apply: bool) -> list[str]:
|
|
if plan.migration_release is None:
|
|
return []
|
|
dst = PILOT_TEST_DIR / f"test_pilot_{plan.bundle}_upgrade.py"
|
|
if dst.exists():
|
|
return [f"skip {dst.relative_to(REPO_ROOT)} (already exists; not overwriting)"]
|
|
actions = [f"write {dst.relative_to(REPO_ROOT)}"]
|
|
if apply:
|
|
dst.write_text(_render_pilot_test(plan), encoding="utf-8")
|
|
return actions
|
|
|
|
|
|
def _update_component_index(plan: PortPlan, *, apply: bool) -> list[str]:
|
|
if not COMPONENT_INDEX_PATH.is_file():
|
|
return []
|
|
actions = [f"surgically remove {plan.bundle!r} category from {COMPONENT_INDEX_PATH.relative_to(REPO_ROOT)}"]
|
|
if not apply:
|
|
return actions
|
|
try:
|
|
import orjson
|
|
except ImportError as exc:
|
|
msg = (
|
|
"orjson is required for --update-index (same dep "
|
|
"scripts/build_component_index.py uses). Run this script "
|
|
"under ``uv run``."
|
|
)
|
|
raise SystemExit(msg) from exc
|
|
import hashlib
|
|
|
|
with COMPONENT_INDEX_PATH.open("rb") as f:
|
|
idx = json.loads(f.read())
|
|
entry = next((e for e in idx["entries"] if e[0] == plan.bundle), None)
|
|
if entry is None:
|
|
actions.append(f" (no {plan.bundle!r} entry in index; nothing to do)")
|
|
return actions
|
|
n_components = len(entry[1])
|
|
idx["entries"] = [e for e in idx["entries"] if e[0] != plan.bundle]
|
|
idx["metadata"]["num_modules"] -= 1
|
|
idx["metadata"]["num_components"] -= n_components
|
|
|
|
idx.pop("sha256", None)
|
|
payload = orjson.dumps(idx, option=orjson.OPT_SORT_KEYS)
|
|
idx["sha256"] = hashlib.sha256(payload).hexdigest()
|
|
out = orjson.dumps(idx, option=orjson.OPT_SORT_KEYS | orjson.OPT_INDENT_2)
|
|
COMPONENT_INDEX_PATH.write_bytes(out + b"\n")
|
|
actions.append(f" removed {n_components} component(s) under {plan.bundle!r}; new sha256={idx['sha256']}")
|
|
return actions
|
|
|
|
|
|
def _patch_backend_dockerfile(plan: PortPlan, *, apply: bool) -> list[str]:
|
|
if not DOCKER_BACKEND.is_file():
|
|
return []
|
|
actions = [f"patch {DOCKER_BACKEND.relative_to(REPO_ROOT)}"]
|
|
if not apply:
|
|
return actions
|
|
text = DOCKER_BACKEND.read_text(encoding="utf-8")
|
|
if f"./src/bundles/{plan.bundle}" in text:
|
|
actions.append(f" (lfx-{plan.bundle} already referenced; skipping)")
|
|
return actions
|
|
# Insert before the ``"./src/backend/base[complete..."`` line, after
|
|
# any other ``./src/bundles/...`` line.
|
|
bundle_line = f" ./src/bundles/{plan.bundle} \\\n"
|
|
pattern = re.compile(r"(\s+\./src/bundles/\w+ \\\n)(?!\s+\./src/bundles)", re.MULTILINE)
|
|
new_text, count = pattern.subn(rf"\g<1>{bundle_line}", text, count=1)
|
|
if count == 0:
|
|
actions.append(" WARNING: no ``./src/bundles/...`` line found; patch by hand.")
|
|
return actions
|
|
DOCKER_BACKEND.write_text(new_text, encoding="utf-8")
|
|
return actions
|
|
|
|
|
|
def _patch_base_dockerfile(plan: PortPlan, *, apply: bool) -> list[str]:
|
|
if not DOCKER_BASE.is_file():
|
|
return []
|
|
actions = [f"patch {DOCKER_BASE.relative_to(REPO_ROOT)}"]
|
|
if not apply:
|
|
return actions
|
|
text = DOCKER_BASE.read_text(encoding="utf-8")
|
|
if f"/app/src/bundles/{plan.bundle}" in text:
|
|
actions.append(f" (lfx-{plan.bundle} already referenced; skipping)")
|
|
return actions
|
|
# Append a new ``uv pip install --no-deps`` block after the last
|
|
# existing one (matched by ``/app/src/bundles/<existing>``).
|
|
snippet = (
|
|
f"\n# Bundle re-attach: ``lfx-{plan.bundle}`` ships the {plan.display_name}\n"
|
|
f"# components as a standalone distribution. ``--no-deps`` is intentional\n"
|
|
f"# -- the bundle's runtime deps live in the langflow-base lockfile so\n"
|
|
f"# installing them here would yank duplicates that fight the locked\n"
|
|
f"# versions.\n"
|
|
f"RUN --mount=type=cache,target=/root/.cache/uv \\\n"
|
|
f" RUSTFLAGS='--cfg reqwest_unstable' \\\n"
|
|
f" uv pip install --no-deps /app/src/bundles/{plan.bundle}\n"
|
|
)
|
|
pattern = re.compile(
|
|
r"(uv pip install --no-deps /app/src/bundles/\w+\n)(?!.*uv pip install --no-deps /app/src/bundles)",
|
|
re.DOTALL,
|
|
)
|
|
new_text, count = pattern.subn(rf"\g<1>{snippet}", text, count=1)
|
|
if count == 0:
|
|
actions.append(
|
|
" WARNING: no existing ``uv pip install --no-deps /app/src/bundles/...`` block found; patch by hand."
|
|
)
|
|
return actions
|
|
DOCKER_BASE.write_text(new_text, encoding="utf-8")
|
|
return actions
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Phase E: optional langflow-base[<bundle>] extra cleanup
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _remove_base_extra(plan: PortPlan, *, apply: bool) -> list[str]:
|
|
if not plan.base_extra_present:
|
|
return []
|
|
actions = [
|
|
f"remove ``{plan.bundle}`` extra + ``langflow-base[{plan.bundle}]`` ref "
|
|
f"from {BASE_PYPROJECT.relative_to(REPO_ROOT)}"
|
|
]
|
|
if not apply:
|
|
return actions
|
|
text = BASE_PYPROJECT.read_text(encoding="utf-8")
|
|
# 1. Drop the extra definition line (``<bundle> = [...]``).
|
|
extra_re = re.compile(
|
|
rf"^{re.escape(plan.bundle)}\s*=\s*\[[^\]]*\]\n",
|
|
re.MULTILINE,
|
|
)
|
|
new_text = extra_re.sub("", text, count=1)
|
|
# 2. Drop the ``"langflow-base[<bundle>]",`` reference from
|
|
# ``complete`` (always indented and followed by a newline).
|
|
ref_re = re.compile(
|
|
rf'\n\s*"langflow-base\[{re.escape(plan.bundle)}\]",',
|
|
)
|
|
new_text = ref_re.sub("", new_text, count=1)
|
|
if new_text == text:
|
|
actions.append(" (no changes; the script's regex did not match)")
|
|
return actions
|
|
BASE_PYPROJECT.write_text(new_text, encoding="utf-8")
|
|
return actions
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Entry point
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(
|
|
description=(
|
|
"Port an in-tree provider directory into a standalone Langflow "
|
|
"Extension Bundle. Writes the skeleton, moves the source files, "
|
|
"rewrites external consumers, migrates ruff per-file ignores, "
|
|
"patches the workspace, and -- with ``--migration-release`` -- "
|
|
"appends migration entries, writes the pilot test, surgically "
|
|
"removes the bundle's category from component_index.json, and "
|
|
"patches both Dockerfiles."
|
|
)
|
|
)
|
|
parser.add_argument("--bundle", required=True, help="Snake-case provider name.")
|
|
parser.add_argument(
|
|
"--display-name",
|
|
default=None,
|
|
help="Human-readable name used in extension.json and README.md.",
|
|
)
|
|
parser.add_argument(
|
|
"--migration-release",
|
|
default=None,
|
|
help=(
|
|
"SemVer release that introduces the bundle (e.g. ``1.10.0``). "
|
|
"Required for writing migration table entries and the pilot test."
|
|
),
|
|
)
|
|
parser.add_argument("--apply", action="store_true", help="Actually mutate the tree.")
|
|
parser.add_argument(
|
|
"--rewrite-consumers",
|
|
action="store_true",
|
|
help="Grep the repo for external consumers of ``lfx.components.<bundle>`` "
|
|
"and ``lfx.base.<bundle>`` and rewrite their imports (requires ``rg`` on PATH).",
|
|
)
|
|
parser.add_argument(
|
|
"--update-index",
|
|
action="store_true",
|
|
help="Surgically remove the bundle's category from component_index.json "
|
|
"and recompute its sha256 (requires ``uv run``).",
|
|
)
|
|
parser.add_argument(
|
|
"--update-dockerfiles",
|
|
action="store_true",
|
|
help="Patch ``docker/build_and_push_backend.Dockerfile`` and "
|
|
"``docker/build_and_push_base.Dockerfile`` to install the bundle.",
|
|
)
|
|
parser.add_argument(
|
|
"--remove-base-extra",
|
|
action="store_true",
|
|
help="Remove the ``<bundle>`` extra from langflow-base/pyproject.toml "
|
|
"and any ``langflow-base[<bundle>]`` references from ``complete`` "
|
|
"(only safe when the bundle's pyproject covers the same deps).",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
plan = _validate_candidate(
|
|
args.bundle,
|
|
display_name=args.display_name,
|
|
migration_release=args.migration_release,
|
|
discover_consumers=args.rewrite_consumers,
|
|
)
|
|
|
|
actions: list[str] = []
|
|
actions.append("== Phase A: bundle layout ==")
|
|
actions += _layout_bundle(plan, apply=args.apply)
|
|
actions.append("== Phase B: in-tree cleanup ==")
|
|
actions += _delete_in_tree(plan, apply=args.apply)
|
|
actions += _patch_components_init(plan, apply=args.apply)
|
|
actions += _strip_root_ruff_ignores(plan, apply=args.apply)
|
|
actions.append("== Phase C: workspace + consumers ==")
|
|
actions += _patch_root_pyproject(plan, apply=args.apply)
|
|
actions += _rewrite_consumers(plan, apply=args.apply)
|
|
actions += _move_backend_tests(plan, apply=args.apply)
|
|
if args.migration_release is not None or args.update_index or args.update_dockerfiles:
|
|
actions.append("== Phase D: artefacts ==")
|
|
if args.migration_release is not None:
|
|
actions += _write_migration_entries(plan, apply=args.apply)
|
|
actions += _write_pilot_test(plan, apply=args.apply)
|
|
if args.update_index:
|
|
actions += _update_component_index(plan, apply=args.apply)
|
|
if args.update_dockerfiles:
|
|
actions += _patch_backend_dockerfile(plan, apply=args.apply)
|
|
actions += _patch_base_dockerfile(plan, apply=args.apply)
|
|
if args.remove_base_extra:
|
|
actions.append("== Phase E: optional cleanup ==")
|
|
actions += _remove_base_extra(plan, apply=args.apply)
|
|
|
|
mode = "apply" if args.apply else "dry-run"
|
|
print(f"port_bundle ({mode}) for bundle {plan.bundle!r}:")
|
|
print(f" display_name : {plan.display_name!r}")
|
|
print(f" in-tree dir : {plan.in_tree_dir.relative_to(REPO_ROOT)}")
|
|
print(
|
|
f" shared base dir : {plan.shared_base_dir.relative_to(REPO_ROOT) if plan.shared_base_dir else '(none)'}"
|
|
)
|
|
print(f" backend test dirs : {[str(p.relative_to(REPO_ROOT)) for p in plan.backend_test_dirs]}")
|
|
print(f" ruff ignores : {[ri.pattern for ri in plan.ruff_ignores]}")
|
|
print(f" external consumers : {len(plan.consumer_rewrites)}")
|
|
print(f" langflow-base extra : {plan.base_extra_present}")
|
|
print(f" migration release : {plan.migration_release or '(none)'}")
|
|
print(f" component classes : {sum(len(cf.classes) for cf in plan.component_files)}")
|
|
print()
|
|
for action in actions:
|
|
print(f" {action}")
|
|
|
|
if not args.apply:
|
|
print()
|
|
print("Dry-run. Re-invoke with --apply to mutate the tree.")
|
|
else:
|
|
print()
|
|
print("Done. Manual follow-ups (always):")
|
|
print(" * Review bundle pyproject's runtime deps and pin them carefully.")
|
|
print(" * Run uv lock + uv sync.")
|
|
print(" * Run ruff + pytest + lfx extension validate.")
|
|
print(" * Inspect git diff before committing.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|