Files
langflow/scripts/migrate/port_bundle.py
Eric Hare ee659ca38c fix(bundles): floor lfx pin at the minor line's .dev0 so nightlies resolve
The first 1.11.0.dev0 nightly failed its "Test Langflow Main CLI" step:
the fork bump's sync_bundle_lfx_pin.py re-synced every bundle floor to
lfx>=1.11.0, and PEP 440 sorts the nightly's 1.11.0.dev0 BELOW 1.11.0,
so the workspace-built bundles (whose metadata shadows the satisfiable
PyPI lfx-arxiv 0.1.1 during `uv pip install dist/*.whl`) reject the
branch's own lfx while langflow-base pins it exactly — unresolvable.

Floor at lfx>=X.Y.0.dev0,<(X+1).0.0 instead: X.Y.0.dev0 is the lowest
version PEP 440 admits in the minor line, so every devN / rcN / final
satisfies the floor while older lines and the next major stay excluded.
This closes the NIGHTLY.md activation-gate hole structurally — future
minor forks re-sync to a floor their own nightlies already satisfy.

- sync_bundle_lfx_pin.py: lfx_floor_spec emits the .dev0 floor
- port_bundle.py: mirrored _current_lfx_floor kept in step
- bundle pyprojects restamped via the script (arxiv/docling/duckduckgo/ibm)
- test_bundle_lfx_pin.py expectations updated (20/20 passing)
- NIGHTLY.md gate section annotated with the post-activation fix

uv.lock is unaffected (workspace lfx is recorded as an editable source
with no specifier — which is also why uv sync/lock passed in the same
job). The release.yml RC floor-relax sed still matches the new form;
now redundant but harmless. No bundle version bump needed: published
0.1.1 floors >=1.10.0.rc0, satisfiable by the whole 1.11 line.
2026-06-09 18:02:43 -07:00

807 lines
30 KiB
Python
Executable File

#!/usr/bin/env python3
"""Mechanical port helper for ``src/lfx/src/lfx/components/<provider>/`` -> ``src/bundles/<provider>/``.
Writes the bundle skeleton documented in ``src/bundles/PORTING.md``,
deletes the in-tree provider directory, and patches the three known
cross-cutting touchpoints (workspace ``pyproject.toml``,
``src/lfx/src/lfx/components/__init__.py``).
What this script does:
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}}}``.
3. Moves every ``*.py`` file from the in-tree provider directory into
the bundle's ``components/<bundle>/`` directory, byte-for-byte.
4. Removes the in-tree directory.
5. Strips the three references in
``src/lfx/src/lfx/components/__init__.py``.
6. 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).
What this script does NOT do (humans-only):
* Run ``uv lock``, ``LFX_DEV=1 scripts/build_component_index.py``,
``ruff``, or any other verification step. See ``src/bundles/PORTING.md``
§ 7 for the manual block.
What this script does on a best-effort basis (review before commit):
* Discovers ``class <Name>(...)`` declarations in each moved file and
wires the corresponding re-exports into both ``__init__.py`` files.
* Renders migration-table JSON entries and an integration-test scaffold
to stdout when ``--migration-release`` is given, so the human edits
become "paste-in" rather than "hand-write".
Run mode:
Default is dry-run (prints the planned changes). Pass ``--apply`` to
actually mutate the tree; the diff should be reviewed in ``git diff``
before committing.
Surface deliberately tiny: every step is one of {mkdir, write_text,
shutil.move, regex substitute}. The script intentionally does NOT depend
on lfx, hatchling, tomllib-as-writer, or any other heavyweight piece;
stdlib only. This is the same constraint
``scripts/migrate/check_bare_names.py`` runs under so the script can run
in any CI checkout.
Usage:
python scripts/migrate/port_bundle.py --bundle arxiv # dry-run
python scripts/migrate/port_bundle.py --bundle arxiv --apply # write
python scripts/migrate/port_bundle.py --bundle arxiv \
--display-name "arXiv Search" \
--migration-release 1.10.0 --apply
"""
from __future__ import annotations
import argparse
import re
import shutil
import sys
from dataclasses import dataclass
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
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", "src/lfx_{bundle}/components/**/*.py"]
[tool.hatch.build.targets.sdist]
include = [
"src/lfx_{bundle}",
"extension.json",
"README.md",
"pyproject.toml",
]
"""
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}
'''
COMPONENTS_INIT_TEMPLATE = '''\
"""Component re-exports for the ``{bundle}`` bundle.
Saved-flow migration entries that target ``lfx.components.{bundle}.<Class>``
resolve through this package, so the moved Component class(es) must be
importable from here by name.
"""
{component_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.
## 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 PortPlan:
bundle: str
display_name: str
in_tree_dir: Path
bundle_dir: Path
component_files: tuple[ComponentFile, ...]
migration_release: str | None
_CLASS_DECL_RE = re.compile(r"^class\s+(\w+)\s*\(", re.MULTILINE)
def _discover_classes(source: str) -> tuple[str, ...]:
"""Return top-level ``class Name(...):`` names in ``source``.
Indented classes (nested inside functions or other classes) are
skipped -- only the top-level Component subclasses participate in the
re-export wiring.
"""
return tuple(_CLASS_DECL_RE.findall(source))
def _validate_candidate(bundle: str, *, display_name: str | None, migration_release: str | None) -> 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 = REPO_ROOT / "src" / "lfx" / "src" / "lfx" / "components" / bundle
if not in_tree.is_dir():
msg = f"In-tree provider directory not found: {in_tree}"
raise SystemExit(msg)
bundle_dir = REPO_ROOT / "src" / "bundles" / bundle
if bundle_dir.exists():
msg = f"Bundle directory already exists: {bundle_dir}. Refusing to overwrite."
raise SystemExit(msg)
deactivated_dup = REPO_ROOT / "src" / "lfx" / "src" / "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] = []
for src in sorted(in_tree.iterdir()):
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:
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()
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,
)
# ---------------------------------------------------------------------------
# Apply
# ---------------------------------------------------------------------------
def _render_reexports(plan: PortPlan) -> tuple[str, str]:
"""Render the two ``__init__.py`` re-export blocks from discovered classes.
Returns ``(package_init_body, components_init_body)``. If no Component
classes were discovered, both bodies fall back to a TODO comment that
instructs the porter to wire the re-exports by hand.
"""
all_classes: list[tuple[str, str]] = [] # (module_stem, ClassName)
for cf in plan.component_files:
if cf.path.name == "__init__.py":
continue
module_stem = cf.path.stem
all_classes.extend((module_stem, cls) for cls in cf.classes)
if not all_classes:
todo = (
"# 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."
)
return todo, todo
# Package-level: ``from lfx_<bundle>.components.<bundle>.<module> import <Class>``.
package_lines = [
f"from lfx_{plan.bundle}.components.{plan.bundle}.{module} import {cls}" for module, cls in all_classes
]
package_all = ", ".join(repr(cls) for _, cls in all_classes)
package_body = "\n".join(package_lines) + f"\n\n__all__ = [{package_all}]"
# Component-package level: relative ``from .<module> import <Class>``.
component_lines = [f"from .{module} import {cls}" for module, cls in all_classes]
component_body = "\n".join(component_lines) + f"\n\n__all__ = [{package_all}]"
return package_body, component_body
def _layout_bundle(plan: PortPlan, *, apply: bool) -> list[str]:
"""Create the bundle directory tree and write the static files."""
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)
package_reexports, component_reexports = _render_reexports(plan)
files = {
plan.bundle_dir / "pyproject.toml": PYPROJECT_TEMPLATE.format(
bundle=plan.bundle, display_name=plan.display_name, lfx_floor=_current_lfx_floor()
),
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=package_reexports,
),
package_dir / "extension.json": EXTENSION_JSON_TEMPLATE.format(
bundle=plan.bundle, display_name=plan.display_name
),
components_dir / "__init__.py": COMPONENTS_INIT_TEMPLATE.format(
bundle=plan.bundle,
component_reexports=component_reexports,
),
}
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 rewritten as the placeholder
# above; preserve its prior re-exports manually after porting.
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))
return actions
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 = REPO_ROOT / "src" / "lfx" / "src" / "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 = (
# Top import block (one `` <bundle>,`` per line).
rf"^ {{8}}{re.escape(plan.bundle)},\n",
# _dynamic_imports table.
rf'^ {{4}}"{re.escape(plan.bundle)}": "__module__",\n',
# __all__ list.
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:
msg = (
f"No occurrences of {plan.bundle!r} found in {target.relative_to(REPO_ROOT)}. "
"The script's regex pattern may be out of date with the file's "
"current shape. Edit by hand and re-run with --skip-init-patch."
)
raise SystemExit(msg)
target.write_text(new_text, encoding="utf-8")
return actions
def _insert_before_marker(text: str, end_marker: str, payload: str, *, what: str) -> str:
"""Insert ``payload`` immediately before the line containing ``end_marker``.
``end_marker`` is a literal substring expected on its own comment line
(e.g. ``# langflow-extensions:bundle-deps-end``). Raises SystemExit
if the marker is absent so the porter notices that the maintenance-
friendly anchors have drifted out of pyproject.toml.
"""
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)
# Rewind to the start of the line so the payload lands above the marker
# with matching indentation.
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]:
"""Add the dep, the workspace source, and the workspace member entry."""
target = REPO_ROOT / "pyproject.toml"
actions = [f"patch {target.relative_to(REPO_ROOT)}"]
if not apply:
return actions
text = target.read_text(encoding="utf-8")
bundle = plan.bundle
if f"lfx-{bundle}" in text:
msg = f"lfx-{bundle} already referenced in {target.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",
)
target.write_text(new_text, encoding="utf-8")
return actions
# ---------------------------------------------------------------------------
# Paste-in scaffolds
# ---------------------------------------------------------------------------
def _render_migration_entries(plan: PortPlan) -> str:
"""Render JSON migration entries for paste-in to ``migration_table.json``.
Emits four entries per discovered class, matching the keys
``lfx.extension.migration.loader`` reads:
* ``bare_class_name`` -- the bare class name a saved flow may have used.
* ``import_path`` -- ``lfx.components.<bundle>.<module>.<Class>``.
* ``import_path`` -- ``lfx.components.<bundle>.<Class>`` (package re-export).
* ``legacy_slot`` -- ``ext:<bundle>:<Class>@official-pre-a`` (the
pre-Phase-A canonical slot, in case any in-flight saved flow
serialized that form before this bundle landed).
Bare-name uniqueness is enforced by ``scripts/migrate/check_bare_names.py``
in CI, so the porter just pastes the block and lets CI catch collisions.
"""
release = plan.migration_release or "X.Y.Z"
entries: list[str] = []
for cf in plan.component_files:
if cf.path.name == "__init__.py":
continue
module_stem = cf.path.stem
for cls in cf.classes:
target = f"ext:{plan.bundle}:{cls}@official"
entries.append(
" {\n"
f' "bare_class_name": "{cls}",\n'
f' "target": "{target}",\n'
f' "added_in": "{release}"\n'
" },\n"
" {\n"
f' "import_path": "lfx.components.{plan.bundle}.{module_stem}.{cls}",\n'
f' "target": "{target}",\n'
f' "added_in": "{release}"\n'
" },\n"
" {\n"
f' "import_path": "lfx.components.{plan.bundle}.{cls}",\n'
f' "target": "{target}",\n'
f' "added_in": "{release}"\n'
" },\n"
" {\n"
f' "legacy_slot": "ext:{plan.bundle}:{cls}@official-pre-a",\n'
f' "target": "{target}",\n'
f' "added_in": "{release}"\n'
" }"
)
return ",\n".join(entries)
def _render_test_scaffold(plan: PortPlan) -> str:
"""Render an integration-test scaffold for ``test_pilot_<bundle>_upgrade.py``.
Structurally mirrors ``test_pilot_duckduckgo_upgrade.py``: a
``migration_table`` fixture, three saved-flow rewrite tests (bare,
full import path, short import path), the bundle-importable check,
and the manifest-shipped check. The porter still owns the
component-specific runtime assertions (build pipeline, output schema)
-- those are too domain-specific to template.
"""
bundle = plan.bundle
first_class = next(
(cls for cf in plan.component_files for cls in cf.classes if cf.path.name != "__init__.py"),
f"{bundle.title()}Component",
)
target = f"ext:{bundle}:{first_class}@official"
module_stem = next(
(cf.path.stem for cf in plan.component_files if cf.classes),
bundle,
)
full_path = f"lfx.components.{bundle}.{module_stem}.{first_class}"
short_path = f"lfx.components.{bundle}.{first_class}"
return f'''"""Integration test: legacy {bundle} flows upgrade cleanly.
Mirrors ``test_pilot_duckduckgo_upgrade.py`` for ``{first_class}``.
"""
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"
EXPECTED_TARGET = "{target}"
@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:
"""Build a minimal saved-flow node skeleton for testing."""
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
def test_legacy_bare_name_flow_upgrades(migration_table) -> None:
"""Pre-Phase-A flow with the bare class name upgrades to the canonical ID."""
from lfx.extension.migration.rewrite import migrate_flow_payload
flow = _saved_flow(_saved_flow_node("{bundle}-1", "{first_class}"))
report = migrate_flow_payload(flow, table=migration_table)
assert report.rewritten_count == 1
assert flow["data"]["nodes"][0]["data"]["type"] == EXPECTED_TARGET
[record] = report.records
assert record.legacy_form_kind == "bare_class_name"
assert record.new_value == EXPECTED_TARGET
@pytest.mark.integration
def test_legacy_import_path_flow_upgrades(migration_table) -> None:
"""Dotted import-path form upgrades to the canonical ID."""
from lfx.extension.migration.rewrite import migrate_flow_payload
flow = _saved_flow(_saved_flow_node("{bundle}-2", "{full_path}"))
report = migrate_flow_payload(flow, table=migration_table)
assert report.rewritten_count == 1
assert flow["data"]["nodes"][0]["data"]["type"] == EXPECTED_TARGET
assert report.records[0].legacy_form_kind == "import_path"
@pytest.mark.integration
def test_short_import_path_flow_upgrades(migration_table) -> None:
"""Package-level import-path form (via ``__init__.py`` re-export) upgrades."""
from lfx.extension.migration.rewrite import migrate_flow_payload
flow = _saved_flow(_saved_flow_node("{bundle}-3", "{short_path}"))
report = migrate_flow_payload(flow, table=migration_table)
assert report.rewritten_count == 1
assert flow["data"]["nodes"][0]["data"]["type"] == EXPECTED_TARGET
@pytest.mark.integration
def test_lfx_{bundle}_distribution_is_importable() -> None:
"""The bundle's package is importable in the development workspace."""
try:
from lfx_{bundle} import {first_class}
except ImportError:
pytest.skip("lfx-{bundle} not installed in this test environment")
assert {first_class}.__name__ == "{first_class}"
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}
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"])
'''
# ---------------------------------------------------------------------------
# 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, "
"and patches the workspace; integration test and migration "
"table edits are left to the human (see src/bundles/PORTING.md)."
)
)
parser.add_argument(
"--bundle",
required=True,
help="Snake-case provider name (matches src/lfx/src/lfx/components/<bundle>/).",
)
parser.add_argument(
"--display-name",
default=None,
help=(
"Human-readable name used in extension.json and README.md "
"(e.g. ``arXiv Search``). Defaults to ``<bundle>.replace('_', ' ').title()``, "
"which is wrong for most providers -- pass this for any bundle "
"whose canonical casing isn't auto-titlecase."
),
)
parser.add_argument(
"--migration-release",
default=None,
help=(
"SemVer release that introduces the bundle (e.g. ``1.10.0``). "
"When given, the script prints paste-in JSON migration entries "
"and an integration-test scaffold alongside the action log."
),
)
parser.add_argument(
"--apply",
action="store_true",
help="Actually mutate the tree. Default: dry-run (print actions only).",
)
args = parser.parse_args()
plan = _validate_candidate(
args.bundle,
display_name=args.display_name,
migration_release=args.migration_release,
)
actions: list[str] = []
actions += _layout_bundle(plan, apply=args.apply)
actions += _delete_in_tree(plan, apply=args.apply)
actions += _patch_components_init(plan, apply=args.apply)
actions += _patch_root_pyproject(plan, apply=args.apply)
mode = "apply" if args.apply else "dry-run"
print(f"port_bundle ({mode}) for bundle {plan.bundle!r}:")
for action in actions:
print(f" {action}")
if args.apply:
b = plan.bundle
print()
print("Done. Manual follow-ups (see src/bundles/PORTING.md § 4-7):")
if plan.migration_release is None:
print(" * Append migration entries (bare/import_path/legacy_slot) to migration_table.json.")
print(f" * Add tests/integration/extension/test_pilot_{b}_upgrade.py.")
print(" Re-run with ``--migration-release X.Y.Z`` to print paste-in scaffolds for both.")
print(" * Run uv lock + LFX_DEV=1 scripts/build_component_index.py + ruff + tests.")
else:
print()
print("Dry-run. Re-invoke with --apply to mutate the tree.")
if plan.migration_release is not None:
print()
print("---- paste into src/lfx/src/lfx/extension/migration/migration_table.json ----")
print(_render_migration_entries(plan))
print(f"---- paste into src/lfx/tests/integration/extension/test_pilot_{plan.bundle}_upgrade.py ----")
print(_render_test_scaffold(plan))
return 0
if __name__ == "__main__":
sys.exit(main())