mirror of
https://github.com/langflow-ai/langflow.git
synced 2026-07-24 18:31:48 +08:00
* 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
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())
|