Files
langflow/scripts/migrate/port_bundle.py
Eric Hare 7139d07033 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 as 14dfa7f268, 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
2026-06-11 11:30:53 -07:00

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())