Skip to content

Commit 51f8ecf

Browse files
authored
ci(bundles): cross-bundle test matrix (lfx contract axis) (#13566)
* ci(bundles): add cross-bundle test matrix (lfx contract axis) Tests every extracted bundle (the lfx-bundles metapackage + each graduated lfx-<provider>) against the lfx contract surface it depends on -- overdue since 1.10 left 4 independently-versioned bundles depending on lfx. - .github/workflows/cross-bundle-test.yml: discovers bundles via the same src/bundles/*/pyproject.toml glob release.yml uses, then per (bundle x python 3.10/3.13): installs the in-repo lfx + the bundle, imports the declared entry-point package, asserts lfx.bundles discovery is error-free (for the metapackage), runs `lfx extension validate` for manifest bundles, and runs the bundle's own tests/. - Triggers: pull_request (src/bundles, src/lfx), workflow_dispatch, a weekly schedule, and workflow_call. The lfx-minor axis seeds with the in-repo lfx (the 1.10 line is unpublished); when minors publish, add the version dimension (oldest+latest get full tests, every supported minor gets contract-smoke) per the epic's cost-control shape. Verified: the workflow YAML parses and the contract-smoke logic imports a real bundle (lfx_arxiv) cleanly. * fix(ci): cross-bundle smoke tolerates extras-less SDK degradation Review findings on the cross-bundle matrix: - CRITICAL: the contract smoke asserted zero discovery errors, but the CI venv installs lfx-bundles WITHOUT per-provider extras, so providers whose modules import their SDK at top level degrade with module-import-failed -- the expected graceful-degradation contract. The smoke now fails only on structural codes and reports the degraded-module count. (Graduated partner bundles carry their deps directly, not as extras, so their steps are unaffected.) - the scheduled run now actually delivers the exhaustive grid the header promised (all supported Pythons on schedule; oldest+latest on PRs) - concurrency group with cancel-in-progress on PRs so stacked bundle PRs don't queue N-bundles x N-pythons jobs per push * ci(bundles): tomli fallback for the py3.10 cross-bundle smoke tomllib is stdlib only from Python 3.11, so the contract-smoke heredoc failed with ModuleNotFoundError on every py3.10 matrix leg. Install the tomli backport into the smoke venv and import it as a fallback. * fix(test): coherent sys.modules restore in the ibm without_ibm_db fixture The cross-bundle matrix's py3.10 leg exposed test-ordering pollution in the ibm bundle suite: without_ibm_db monkeypatch-deleted three lfx_ibm modules and re-imported them mid-test, but entries created during the test survive teardown (delitem with raising=False records nothing for keys it didn't find) and re-imported parents never regain the submodule attribute bindings that mock.patch target resolution walks on Python 3.10 (3.11+ mock resolves via sys.modules and tolerates the incoherence). Two fixture uses in sequence left the tree inconsistent and 29 later watsonx tests failed with AttributeError on the package. Snapshot, drop, and restore the entire lfx_ibm module tree wholesale instead. Full suite now passes on both 3.10 and 3.13.
1 parent 7cd1673 commit 51f8ecf

2 files changed

Lines changed: 189 additions & 13 deletions

File tree

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
name: Cross-Bundle Test
2+
3+
# Tests every extracted bundle (the lfx-bundles metapackage + each graduated
4+
# lfx-<provider>) against the lfx contract surface it depends on. lfx is the
5+
# PRIMARY axis because the BUNDLE_API contract lives in lfx: a bundle that
6+
# imports cleanly, validates, and passes its tests against a given lfx is
7+
# compatible with that lfx minor.
8+
#
9+
# Cost-control shape (from the bundle-separation epic):
10+
# - contract-smoke (install + import + discover + validate): every bundle,
11+
# on the oldest and latest supported Python.
12+
# - bundle tests (pytest the bundle's own tests/): same matrix.
13+
# - scheduled run: weekly, the exhaustive grid.
14+
#
15+
# The lfx-minor axis is currently a single entry -- the IN-REPO lfx -- because
16+
# the 1.10 line is not yet published to PyPI. When lfx minors publish, add an
17+
# explicit lfx-version matrix dimension here (oldest + latest get the full
18+
# tests; every supported minor gets contract-smoke), and wire langflow RCs as
19+
# the secondary axis via workflow_call from the release pipeline.
20+
21+
on:
22+
workflow_call:
23+
workflow_dispatch:
24+
pull_request:
25+
paths:
26+
- "src/bundles/**"
27+
- "src/lfx/**"
28+
- ".github/workflows/cross-bundle-test.yml"
29+
schedule:
30+
- cron: "0 6 * * 1" # Monday 06:00 UTC -- exhaustive grid
31+
32+
# Stacked bundle PRs would otherwise queue N-bundles x N-pythons jobs per push.
33+
concurrency:
34+
group: cross-bundle-${{ github.workflow }}-${{ github.ref }}
35+
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
36+
37+
jobs:
38+
discover:
39+
name: Discover bundles
40+
runs-on: ubuntu-latest
41+
outputs:
42+
bundles: ${{ steps.find.outputs.bundles }}
43+
steps:
44+
- uses: actions/checkout@v6
45+
with:
46+
persist-credentials: false
47+
- id: find
48+
name: Enumerate src/bundles/*/pyproject.toml
49+
run: |
50+
shopt -s nullglob
51+
dirs=()
52+
for p in src/bundles/*/pyproject.toml; do
53+
dirs+=("$(dirname "$p")")
54+
done
55+
if [ ${#dirs[@]} -eq 0 ]; then
56+
echo "No bundles found under src/bundles/*/" >&2
57+
echo "bundles=[]" >> "$GITHUB_OUTPUT"
58+
exit 0
59+
fi
60+
json=$(printf '%s\n' "${dirs[@]}" | sort | jq -R . | jq -cs .)
61+
echo "bundles=$json" >> "$GITHUB_OUTPUT"
62+
echo "Discovered bundles: $json"
63+
64+
bundle:
65+
name: ${{ matrix.bundle }} (py${{ matrix.python-version }})
66+
needs: discover
67+
if: needs.discover.outputs.bundles != '[]'
68+
runs-on: ubuntu-latest
69+
strategy:
70+
fail-fast: false
71+
matrix:
72+
bundle: ${{ fromJson(needs.discover.outputs.bundles) }}
73+
# PR runs: oldest + latest supported Python (cost-control smoke).
74+
# Scheduled runs: every supported Python (the exhaustive grid).
75+
# The lfx-minor axis collapses to the in-repo lfx until the 1.10 line
76+
# publishes (see header).
77+
python-version: ${{ github.event_name == 'schedule' && fromJson('["3.10", "3.11", "3.12", "3.13", "3.14"]') || fromJson('["3.10", "3.13"]') }}
78+
steps:
79+
- uses: actions/checkout@v6
80+
with:
81+
persist-credentials: false
82+
- uses: astral-sh/setup-uv@v6
83+
with:
84+
enable-cache: true
85+
cache-dependency-glob: "uv.lock"
86+
python-version: ${{ matrix.python-version }}
87+
88+
- name: Install in-repo lfx + the bundle into a clean venv
89+
run: |
90+
uv venv
91+
# The in-repo lfx satisfies the bundle's `lfx>=X,<Y` pin and is the
92+
# contract surface under test; pytest is for the bundle's own tests.
93+
# tomli backports stdlib tomllib for the py3.10 matrix leg.
94+
uv pip install ./src/lfx "./${{ matrix.bundle }}" pytest pytest-asyncio tomli
95+
96+
- name: Contract smoke -- import + discovery
97+
run: |
98+
.venv/bin/python - "${{ matrix.bundle }}" <<'PY'
99+
import importlib
100+
import sys
101+
from pathlib import Path
102+
103+
try:
104+
import tomllib # stdlib from 3.11
105+
except ModuleNotFoundError:
106+
import tomli as tomllib # py3.10 backport, installed above
107+
108+
bundle_dir = Path(sys.argv[1])
109+
meta = tomllib.loads((bundle_dir / "pyproject.toml").read_text())
110+
name = meta["project"]["name"]
111+
eps = meta["project"].get("entry-points", {})
112+
113+
# Manifest bundles declare langflow.extensions; the manifest-less
114+
# metapackage declares lfx.bundles. Either way the declared package
115+
# must import.
116+
modules = list(eps.get("langflow.extensions", {}).values()) or list(eps.get("lfx.bundles", {}).values())
117+
assert modules, f"{name}: no langflow.extensions or lfx.bundles entry-point declared"
118+
for value in modules:
119+
importlib.import_module(value.split(":", 1)[0])
120+
print(f" imported {value}")
121+
122+
# The manifest-less metapackage must be discoverable by the loader.
123+
# This venv installs the bundle WITHOUT its per-provider extras, so
124+
# providers whose modules import their SDK at top level degrade with
125+
# `module-import-failed` -- that is the expected graceful-degradation
126+
# contract, not a failure. Structural errors (bundle-empty,
127+
# path-escape, invalid names, ...) still fail the smoke. A provider
128+
# whose every module failed import also reports no components, which
129+
# is fine here; the bundle's own tests cover behavior with SDKs.
130+
if "lfx.bundles" in eps:
131+
from lfx.extension import load_lfx_bundles_extensions
132+
133+
EXPECTED_WITHOUT_EXTRAS = {"module-import-failed"}
134+
results = load_lfx_bundles_extensions()
135+
bad = [e.code for r in results for e in r.errors if e.code not in EXPECTED_WITHOUT_EXTRAS]
136+
assert not bad, f"{name}: lfx.bundles structural discovery errors: {bad}"
137+
providers = sum(1 for r in results if r.bundle)
138+
degraded = sum(1 for r in results for e in r.errors if e.code in EXPECTED_WITHOUT_EXTRAS)
139+
print(f" lfx.bundles discovery OK ({providers} providers; {degraded} module(s) degraded sans extras)")
140+
print(f"{name}: contract smoke OK")
141+
PY
142+
143+
- name: Validate manifest (manifest-shipping bundles only)
144+
run: |
145+
# Manifest bundles ship extension.json; the manifest-less metapackage
146+
# has none and is exempt from `lfx extension validate`.
147+
manifest=$(ls "${{ matrix.bundle }}"/src/*/extension.json 2>/dev/null | head -1 || true)
148+
if [ -n "$manifest" ]; then
149+
.venv/bin/lfx extension validate "$(dirname "$manifest")"
150+
else
151+
echo "manifest-less bundle; skipping extension validate"
152+
fi
153+
154+
- name: Run the bundle's own tests
155+
run: |
156+
if [ -d "${{ matrix.bundle }}/tests" ]; then
157+
.venv/bin/python -m pytest "${{ matrix.bundle }}/tests" -q
158+
else
159+
echo "no tests/ directory in ${{ matrix.bundle }}; skipping"
160+
fi

src/bundles/ibm/tests/test_optional_dependency.py

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,22 +17,38 @@
1717

1818
import pytest
1919

20-
# Bundle modules whose module-level code must re-run under the simulated
21-
# absence; dropping them from the cache forces a fresh import.
22-
_BUNDLE_MODULES = (
23-
"lfx_ibm.components.ibm",
24-
"lfx_ibm.components.ibm.db2vs",
25-
"lfx_ibm.components.ibm.db2_vector",
26-
)
20+
21+
def _bundle_module_names() -> list[str]:
22+
return [name for name in sys.modules if name == "lfx_ibm" or name.startswith("lfx_ibm.")]
2723

2824

2925
@pytest.fixture
30-
def without_ibm_db(monkeypatch):
31-
"""Make the ibm-db driver look uninstalled (as on linux/aarch64)."""
32-
monkeypatch.setitem(sys.modules, "ibm_db", None)
33-
monkeypatch.setitem(sys.modules, "ibm_db_dbi", None)
34-
for name in _BUNDLE_MODULES:
35-
monkeypatch.delitem(sys.modules, name, raising=False)
26+
def without_ibm_db():
27+
"""Make the ibm-db driver look uninstalled (as on linux/aarch64).
28+
29+
The whole ``lfx_ibm`` module tree is snapshotted, dropped, and restored
30+
wholesale. A partial delete-and-reimport (the previous monkeypatch
31+
approach) leaves ``sys.modules`` and the parent packages' submodule
32+
attribute bindings incoherent for the *next* test: entries created
33+
during the test survive teardown, and a re-imported parent never regains
34+
the submodule attributes that ``mock.patch`` target resolution walks on
35+
Python 3.10 (3.11+ mock resolves via ``sys.modules`` and tolerates it).
36+
"""
37+
saved = {name: sys.modules[name] for name in _bundle_module_names()}
38+
for name in saved:
39+
del sys.modules[name]
40+
sys.modules["ibm_db"] = None
41+
sys.modules["ibm_db_dbi"] = None
42+
try:
43+
yield
44+
finally:
45+
for name in ("ibm_db", "ibm_db_dbi"):
46+
sys.modules.pop(name, None)
47+
# Drop everything imported under the simulated absence, then put the
48+
# original, mutually-consistent module tree back.
49+
for name in _bundle_module_names():
50+
del sys.modules[name]
51+
sys.modules.update(saved)
3652

3753

3854
@pytest.mark.usefixtures("without_ibm_db")

0 commit comments

Comments
 (0)