Skip to content

Commit 70df366

Browse files
committed
scripts/runtime_dep_smoketest.py(feat[smoke-test]): add runtime dependency smoke test
why: ensure runtime-only installs surface missing deps before release. what: - add typed script that imports all vcspull modules. - probe every CLI sub-command using --help to trigger entry points. - provide flags to skip phases and show verbose output.
1 parent 6e27b7d commit 70df366

File tree

4 files changed

+211
-8
lines changed

4 files changed

+211
-8
lines changed

scripts/runtime_dep_smoketest.py

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
#!/usr/bin/env python3
2+
"""Runtime dependency smoke test for vcspull.
3+
4+
This script attempts to import every module within the ``vcspull`` package and
5+
invokes each CLI sub-command with ``--help``. It is intended to run inside an
6+
environment that only has the package's runtime dependencies installed to catch
7+
missing dependency declarations (for example, ``typing_extensions``).
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import argparse
13+
import importlib
14+
import pkgutil
15+
import subprocess
16+
17+
ModuleName = str
18+
19+
20+
def parse_args() -> argparse.Namespace:
21+
"""Return parsed CLI arguments for the smoke test runner."""
22+
parser = argparse.ArgumentParser(
23+
description=(
24+
"Probe vcspull's runtime dependencies by importing all modules "
25+
"and exercising CLI entry points."
26+
),
27+
)
28+
parser.add_argument(
29+
"--package",
30+
default="vcspull",
31+
help="Root package name to inspect (defaults to vcspull).",
32+
)
33+
parser.add_argument(
34+
"--cli-module",
35+
default="vcspull.cli",
36+
help="Module that exposes the create_parser helper for CLI discovery.",
37+
)
38+
parser.add_argument(
39+
"--cli-executable",
40+
default="vcspull",
41+
help="Console script to run for CLI smoke checks.",
42+
)
43+
parser.add_argument(
44+
"--cli-probe-arg",
45+
action="append",
46+
dest="cli_probe_args",
47+
default=None,
48+
help=(
49+
"Additional argument(s) appended after each CLI sub-command; "
50+
"may be repeated. Defaults to --help."
51+
),
52+
)
53+
parser.add_argument(
54+
"--skip-imports",
55+
action="store_true",
56+
help="Skip module import validation.",
57+
)
58+
parser.add_argument(
59+
"--skip-cli",
60+
action="store_true",
61+
help="Skip CLI command execution.",
62+
)
63+
parser.add_argument(
64+
"--verbose",
65+
action="store_true",
66+
help="Print verbose output for each check.",
67+
)
68+
return parser.parse_args()
69+
70+
71+
def discover_modules(package_name: str) -> list[ModuleName]:
72+
"""Return a sorted list of module names within *package_name*."""
73+
package = importlib.import_module(package_name)
74+
module_names: set[str] = {package_name}
75+
package_path = getattr(package, "__path__", None)
76+
if package_path is None:
77+
return sorted(module_names)
78+
module_names.update(
79+
module_info.name
80+
for module_info in pkgutil.walk_packages(
81+
package_path,
82+
prefix=f"{package_name}.",
83+
)
84+
)
85+
return sorted(module_names)
86+
87+
88+
def import_all_modules(module_names: list[ModuleName], verbose: bool) -> list[str]:
89+
"""Attempt to import each module and return a list of failure messages."""
90+
failures: list[str] = []
91+
for module_name in module_names:
92+
if verbose:
93+
pass
94+
try:
95+
importlib.import_module(module_name)
96+
except Exception as exc: # pragma: no cover - reporting only
97+
detail = f"{module_name}: {exc!r}"
98+
failures.append(detail)
99+
return failures
100+
101+
102+
def _find_cli_subcommands(
103+
cli_module_name: str,
104+
) -> tuple[list[str], argparse.ArgumentParser]:
105+
"""Return CLI sub-command names via vcspull.cli.create_parser."""
106+
cli_module = importlib.import_module(cli_module_name)
107+
try:
108+
parser = cli_module.create_parser()
109+
except AttributeError as exc: # pragma: no cover - defensive
110+
msg = f"{cli_module_name} does not expose create_parser()"
111+
raise RuntimeError(msg) from exc
112+
113+
commands: set[str] = set()
114+
for action in parser._actions: # pragma: no branch - argparse internals
115+
if isinstance(action, argparse._SubParsersAction):
116+
commands.update(action.choices.keys())
117+
return sorted(commands), parser
118+
119+
120+
def run_cli_command(
121+
executable: str,
122+
args: list[str],
123+
*,
124+
verbose: bool,
125+
) -> tuple[int, str, str]:
126+
"""Execute CLI command and capture its result."""
127+
command = [executable, *args]
128+
if verbose:
129+
pass
130+
result = subprocess.run(
131+
command,
132+
capture_output=True,
133+
text=True,
134+
check=False,
135+
)
136+
if result.returncode != 0:
137+
if result.stdout:
138+
pass
139+
if result.stderr:
140+
pass
141+
return result.returncode, result.stdout, result.stderr
142+
143+
144+
def exercise_cli(
145+
executable: str,
146+
cli_module_name: str,
147+
probe_args: list[str],
148+
verbose: bool,
149+
) -> list[str]:
150+
"""Run base CLI plus every sub-command, returning failure messages."""
151+
failures: list[str] = []
152+
subcommands, _parser = _find_cli_subcommands(cli_module_name)
153+
154+
# Always test the base command with --help to verify the entry point.
155+
base_exit, _, _ = run_cli_command(executable, ["--help"], verbose=verbose)
156+
if base_exit != 0:
157+
failures.append(f"{executable} --help (exit code {base_exit})")
158+
159+
for subcommand in subcommands:
160+
exit_code, _, _ = run_cli_command(
161+
executable,
162+
[subcommand, *probe_args],
163+
verbose=verbose,
164+
)
165+
if exit_code != 0:
166+
probe_display = " ".join(probe_args)
167+
failures.append(
168+
f"{executable} {subcommand} {probe_display} (exit code {exit_code})",
169+
)
170+
return failures
171+
172+
173+
def main() -> int:
174+
"""Entry point for the runtime dependency smoke test."""
175+
args = parse_args()
176+
cli_probe_args = args.cli_probe_args or ["--help"]
177+
failures: list[str] = []
178+
179+
if not args.skip_imports:
180+
modules = discover_modules(args.package)
181+
failures.extend(import_all_modules(modules, args.verbose))
182+
183+
if not args.skip_cli:
184+
failures.extend(
185+
exercise_cli(
186+
args.cli_executable,
187+
args.cli_module,
188+
cli_probe_args,
189+
args.verbose,
190+
),
191+
)
192+
193+
if failures:
194+
for _failure in failures:
195+
pass
196+
return 1
197+
198+
return 0
199+
200+
201+
if __name__ == "__main__":
202+
raise SystemExit(main())

src/vcspull/cli/add.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -456,12 +456,11 @@ def _ensure_workspace_label_for_merge(
456456
relabelled = True
457457
else:
458458
config_data.setdefault("./", {})
459+
elif existing_label is None:
460+
workspace_label = preferred_label
461+
config_data.setdefault(workspace_label, {})
459462
else:
460-
if existing_label is None:
461-
workspace_label = preferred_label
462-
config_data.setdefault(workspace_label, {})
463-
else:
464-
workspace_label = existing_label
463+
workspace_label = existing_label
465464

466465
if workspace_label not in config_data:
467466
config_data[workspace_label] = {}

tests/cli/test_add.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -720,7 +720,7 @@ def test_handle_add_command_path_mode(
720720
{
721721
"test_id": test_id,
722722
"log": normalized_log.replace("<config>", "~/.vcspull.yaml"),
723-
}
723+
},
724724
)
725725
else:
726726
snapshot.assert_match({"test_id": test_id, "log": normalized_log})
@@ -844,7 +844,7 @@ class NoMergePreservationFixture(t.NamedTuple):
844844
repo: git+https://github.com/Stiivi/bubbles.git
845845
cubes:
846846
repo: git+https://github.com/Stiivi/cubes.git
847-
"""
847+
""",
848848
),
849849
expected_original_repos=(
850850
"Flexget",

tests/test_config_writer.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,16 @@
22

33
from __future__ import annotations
44

5-
import pathlib
65
import textwrap
76
import typing as t
87

98
import pytest
109

1110
from vcspull.config import save_config_yaml_with_items
1211

12+
if t.TYPE_CHECKING:
13+
import pathlib
14+
1315
FixtureEntry = tuple[str, dict[str, t.Any]]
1416

1517

0 commit comments

Comments
 (0)