Skip to content

Commit 7e4d3ce

Browse files
abrichrclaude
andauthored
fix: serve Namespace and lazy-import bugs; add CLI smoke and seam tests (#1002)
Follow-up to #999. CI previously ran zero pytest (ruff plus two import one-liners), never installed openadapt-ml, and could not see lazy imports inside command bodies. This adds the missing layers and fixes what they immediately caught: New tests: - test_cli_smoke.py: renders --help across the whole Click tree; contract tests that monkeypatch openadapt-ml entry points and verify cli.py calls them with kwargs that exist; an AST check that every args.<attr> cmd_serve reads is provided by cli.py's Namespace; and a regression test that internal ImportErrors surface the real error instead of "openadapt-ml not installed" - test_import_integrity.py: AST-walks every `from openadapt... import` and `from openadapt_ml... import` (including inside function bodies) and verifies the names exist; also checks kwargs against internal function signatures Latent bugs the new tests caught against openadapt-ml 0.16.0, fixed: - cli.py serve omitted `open` from the Namespace, but cmd_serve reads args.open at the end of its body (AttributeError after serving). cli.py's own browser-opening timer is replaced by passing open through, since cmd_serve handles it natively - openadapt/__init__.py lazy __getattr__ imported QwenVLAdapter from the openadapt_ml package root (exports nothing) instead of models.qwen_vl, and imported a Trainer that does not exist anywhere; `openadapt.train` returned None instead of raising AttributeError CI: - main.yml now installs openadapt-ml and runs pytest, so the seam that broke in #999 is exercised on every PR - release-and-publish.yml files a GitHub issue if the release workflow fails, so PyPI cannot silently go stale again Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 6b3c073 commit 7e4d3ce

6 files changed

Lines changed: 514 additions & 14 deletions

File tree

.github/workflows/main.yml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,11 @@ jobs:
2929
- name: Install dependencies
3030
run: |
3131
python -m pip install --upgrade pip
32-
pip install -e ".[dev]"
32+
# openadapt-ml is installed explicitly so the cross-package
33+
# seam tests (tests/test_import_integrity.py and the cmd_serve
34+
# contract in tests/test_cli_smoke.py) run instead of skipping.
35+
# Issue #999 shipped because this seam was never tested in CI.
36+
pip install -e ".[dev]" openadapt-ml
3337
3438
- name: Lint with ruff
3539
run: |
@@ -40,3 +44,6 @@ jobs:
4044
run: |
4145
python -c "from openadapt.cli import main; print('CLI import OK')"
4246
python -c "from openadapt.version import __version__; print(f'Version: {__version__}')"
47+
48+
- name: Run tests
49+
run: pytest tests/ -v

.github/workflows/release-and-publish.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,21 @@ jobs:
5454
poetry config pypi-token.pypi $PYPI_TOKEN
5555
poetry build
5656
poetry publish --no-interaction --skip-existing
57+
58+
# Releases in openadapt-ml failed silently for 3 months (Mar-Jun
59+
# 2026) while PyPI went stale; see issue #999. Fail loudly instead.
60+
- name: File issue on release failure
61+
if: failure()
62+
env:
63+
GH_TOKEN: ${{ secrets.ADMIN_TOKEN }}
64+
run: |
65+
TITLE="Release workflow failed on main"
66+
BODY="The release workflow failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
67+
68+
Until this is fixed, merged fix/feat commits are NOT being published to PyPI, and users install stale versions."
69+
EXISTING=$(gh issue list --repo "${{ github.repository }}" --state open --search "in:title \"$TITLE\"" --json number --jq '.[0].number // empty')
70+
if [ -n "$EXISTING" ]; then
71+
gh issue comment "$EXISTING" --repo "${{ github.repository }}" --body "$BODY"
72+
else
73+
gh issue create --repo "${{ github.repository }}" --title "$TITLE" --body "$BODY"
74+
fi

openadapt/__init__.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,10 @@ def __getattr__(name: str):
5959
return locals()[name]
6060

6161
# ML package (heavy - only import if explicitly requested)
62-
if name in ("QwenVLAdapter", "train", "Trainer"):
63-
from openadapt_ml import QwenVLAdapter # noqa: F401
64-
from openadapt_ml.training import Trainer # noqa: F401
62+
if name == "QwenVLAdapter":
63+
from openadapt_ml.models.qwen_vl import QwenVLAdapter # noqa: F401
6564

66-
return locals().get(name)
65+
return QwenVLAdapter
6766

6867
# Grounding package (optional)
6968
if name in ("Grounder", "OmniGrounder", "GeminiGrounder"):
@@ -115,7 +114,6 @@ def __getattr__(name: str):
115114
"HTMLBuilder",
116115
# From ml
117116
"QwenVLAdapter",
118-
"Trainer",
119117
# From grounding (optional)
120118
"Grounder",
121119
"OmniGrounder",

openadapt/cli.py

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -441,14 +441,6 @@ def serve(port: int, output: str, open: bool):
441441
# so --output is honored.
442442
oa_local.TRAINING_OUTPUT = Path(output)
443443

444-
if open:
445-
import threading
446-
import webbrowser
447-
448-
threading.Timer(
449-
1.0, webbrowser.open, args=(f"http://localhost:{port}",)
450-
).start()
451-
452444
sys.exit(
453445
oa_local.cmd_serve(
454446
argparse.Namespace(
@@ -457,6 +449,7 @@ def serve(port: int, output: str, open: bool):
457449
no_regenerate=False,
458450
start_page=None,
459451
quiet=False,
452+
open=open,
460453
)
461454
)
462455
)

tests/test_cli_smoke.py

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
"""CLI smoke and seam-contract tests.
2+
3+
Issue #999: `openadapt serve` and `openadapt train start` were broken
4+
for months while CI stayed green, because cli.py's imports of
5+
openadapt-ml only execute inside command bodies and the broad
6+
`except ImportError` handlers reported every failure as
7+
"openadapt-ml not installed".
8+
9+
Three layers of defense here:
10+
11+
1. test_every_command_help — walks the whole Click tree and renders
12+
--help for every command, so any module-level wiring error fails CI.
13+
2. Contract tests — monkeypatch the openadapt-ml entry points and
14+
assert cli.py calls them the way they're actually shaped today.
15+
3. test_cmd_serve_reads_only_provided_args — parses the installed
16+
openadapt-ml's cmd_serve and asserts every `args.<attr>` it reads is
17+
provided by cli.py's Namespace, so the seam can't drift silently in
18+
either direction.
19+
20+
The openadapt-ml-dependent tests skip when it isn't installed; CI
21+
installs it so they always run there.
22+
"""
23+
24+
from __future__ import annotations
25+
26+
import ast
27+
import sys
28+
from pathlib import Path
29+
30+
import click
31+
import pytest
32+
from click.testing import CliRunner
33+
34+
from openadapt.cli import main as cli_main
35+
36+
# Namespace attributes cli.py's serve command provides to cmd_serve.
37+
# Keep in sync with openadapt/cli.py::serve.
38+
SERVE_NAMESPACE_ATTRS = {
39+
"port",
40+
"benchmark",
41+
"no_regenerate",
42+
"start_page",
43+
"quiet",
44+
"open",
45+
}
46+
47+
48+
def _iter_commands(group, prefix=()):
49+
yield prefix, group
50+
if isinstance(group, click.Group):
51+
for name, cmd in group.commands.items():
52+
yield from _iter_commands(cmd, prefix + (name,))
53+
54+
55+
def test_every_command_help():
56+
"""Render --help for every command in the tree."""
57+
runner = CliRunner()
58+
failures = []
59+
for path, _cmd in _iter_commands(cli_main):
60+
args = list(path) + ["--help"]
61+
result = runner.invoke(cli_main, args)
62+
if result.exit_code != 0:
63+
failures.append(f"{' '.join(args)!r} exited {result.exit_code}")
64+
assert not failures, "Commands whose --help failed:\n " + "\n ".join(failures)
65+
66+
67+
def test_version_command():
68+
runner = CliRunner()
69+
result = runner.invoke(cli_main, ["version"])
70+
assert result.exit_code == 0
71+
72+
73+
# ---------------------------------------------------------------------------
74+
# Seam contracts with openadapt-ml
75+
# ---------------------------------------------------------------------------
76+
77+
78+
def _require_openadapt_ml():
79+
return pytest.importorskip("openadapt_ml", reason="openadapt-ml not installed")
80+
81+
82+
def test_train_start_calls_real_entry_point(monkeypatch, tmp_path):
83+
"""`openadapt train start` must call scripts.train.main with kwargs
84+
that exist in its signature."""
85+
_require_openadapt_ml()
86+
import inspect
87+
88+
from openadapt_ml.scripts import train as train_module
89+
90+
real_params = set(inspect.signature(train_module.main).parameters)
91+
calls = []
92+
93+
def fake_main(**kwargs):
94+
unknown = set(kwargs) - real_params
95+
assert not unknown, (
96+
f"cli.py passes kwargs {sorted(unknown)} that "
97+
f"openadapt_ml.scripts.train.main does not accept "
98+
f"(it takes {sorted(real_params)})"
99+
)
100+
calls.append(kwargs)
101+
102+
monkeypatch.setattr(train_module, "main", fake_main)
103+
104+
capture_dir = tmp_path / "my-capture"
105+
capture_dir.mkdir()
106+
config = tmp_path / "config.yaml"
107+
config.write_text("model:\n name: test\n")
108+
109+
runner = CliRunner()
110+
result = runner.invoke(
111+
cli_main,
112+
[
113+
"train",
114+
"start",
115+
"--capture",
116+
str(capture_dir),
117+
"--config",
118+
str(config),
119+
"--no-open",
120+
],
121+
)
122+
assert result.exit_code == 0, result.output
123+
assert len(calls) == 1
124+
kwargs = calls[0]
125+
assert kwargs["config_path"] == str(config)
126+
assert kwargs["capture_path"] == str(capture_dir)
127+
assert kwargs["open_dashboard"] is False
128+
129+
130+
def test_serve_calls_cmd_serve_with_expected_namespace(monkeypatch):
131+
"""`openadapt serve` must call cmd_serve with the agreed Namespace."""
132+
_require_openadapt_ml()
133+
from openadapt_ml.cloud import local as oa_local
134+
135+
received = []
136+
137+
def fake_cmd_serve(args):
138+
received.append(args)
139+
return 0
140+
141+
monkeypatch.setattr(oa_local, "cmd_serve", fake_cmd_serve)
142+
143+
runner = CliRunner()
144+
result = runner.invoke(cli_main, ["serve", "--port", "8123", "--no-open"])
145+
assert result.exit_code == 0, result.output
146+
assert len(received) == 1
147+
ns = received[0]
148+
assert ns.port == 8123
149+
assert ns.open is False # --no-open passes through to cmd_serve
150+
for attr in SERVE_NAMESPACE_ATTRS:
151+
assert hasattr(ns, attr), f"Namespace missing {attr}"
152+
153+
154+
def test_serve_honors_output_directory(monkeypatch, tmp_path):
155+
"""--output must repoint openadapt-ml's TRAINING_OUTPUT."""
156+
_require_openadapt_ml()
157+
from openadapt_ml.cloud import local as oa_local
158+
159+
monkeypatch.setattr(oa_local, "cmd_serve", lambda args: 0)
160+
161+
runner = CliRunner()
162+
out = tmp_path / "runs"
163+
result = runner.invoke(cli_main, ["serve", "--output", str(out), "--no-open"])
164+
assert result.exit_code == 0, result.output
165+
assert Path(oa_local.TRAINING_OUTPUT) == out
166+
167+
168+
def test_cmd_serve_reads_only_provided_args():
169+
"""Every `args.<attr>` cmd_serve reads must be in cli.py's Namespace.
170+
171+
This is the direction the contract can silently drift: openadapt-ml
172+
adds a new required Namespace attribute and cli.py doesn't provide
173+
it. Parse the installed cmd_serve and check.
174+
"""
175+
ml = _require_openadapt_ml()
176+
local_path = Path(next(iter(ml.__path__))) / "cloud" / "local.py"
177+
tree = ast.parse(local_path.read_text(encoding="utf-8"))
178+
cmd_serve = next(
179+
(
180+
node
181+
for node in tree.body
182+
if isinstance(node, ast.FunctionDef) and node.name == "cmd_serve"
183+
),
184+
None,
185+
)
186+
assert cmd_serve is not None, "cmd_serve not found in openadapt-ml"
187+
188+
args_param = cmd_serve.args.args[0].arg
189+
read_attrs = {
190+
node.attr
191+
for node in ast.walk(cmd_serve)
192+
if isinstance(node, ast.Attribute)
193+
and isinstance(node.value, ast.Name)
194+
and node.value.id == args_param
195+
}
196+
missing = read_attrs - SERVE_NAMESPACE_ATTRS
197+
assert not missing, (
198+
f"openadapt-ml's cmd_serve reads args attributes "
199+
f"{sorted(missing)} that openadapt's serve command does not "
200+
f"provide; update openadapt/cli.py (and SERVE_NAMESPACE_ATTRS)"
201+
)
202+
203+
204+
def test_import_error_messages_not_masked(monkeypatch):
205+
"""Internal ImportErrors must surface the real error, not claim
206+
openadapt-ml isn't installed."""
207+
_require_openadapt_ml()
208+
209+
import builtins
210+
211+
real_import = builtins.__import__
212+
213+
def broken_import(name, *args, **kwargs):
214+
if name == "openadapt_ml.cloud" or name.startswith("openadapt_ml.cloud."):
215+
raise ImportError(
216+
"cannot import name 'definitely_phantom' from "
217+
"'openadapt_ml.cloud.local'"
218+
)
219+
return real_import(name, *args, **kwargs)
220+
221+
monkeypatch.setattr(builtins, "__import__", broken_import)
222+
monkeypatch.delitem(sys.modules, "openadapt_ml.cloud.local", raising=False)
223+
monkeypatch.delitem(sys.modules, "openadapt_ml.cloud", raising=False)
224+
225+
runner = CliRunner()
226+
result = runner.invoke(cli_main, ["serve", "--no-open"])
227+
assert result.exit_code != 0
228+
assert "definitely_phantom" in result.output, (
229+
"The underlying ImportError must appear in the CLI output; "
230+
f"got: {result.output}"
231+
)

0 commit comments

Comments
 (0)