From 7c8c5f82a444081089bbb01418cda8aae858eb30 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 8 Nov 2025 05:58:22 -0600 Subject: [PATCH 1/7] 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. --- scripts/runtime_dep_smoketest.py | 202 +++++++++++++++++++++++++++++++ src/vcspull/cli/add.py | 9 +- tests/cli/test_add.py | 4 +- tests/test_config_writer.py | 4 +- 4 files changed, 211 insertions(+), 8 deletions(-) create mode 100755 scripts/runtime_dep_smoketest.py diff --git a/scripts/runtime_dep_smoketest.py b/scripts/runtime_dep_smoketest.py new file mode 100755 index 00000000..cae6fab2 --- /dev/null +++ b/scripts/runtime_dep_smoketest.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +"""Runtime dependency smoke test for vcspull. + +This script attempts to import every module within the ``vcspull`` package and +invokes each CLI sub-command with ``--help``. It is intended to run inside an +environment that only has the package's runtime dependencies installed to catch +missing dependency declarations (for example, ``typing_extensions``). +""" + +from __future__ import annotations + +import argparse +import importlib +import pkgutil +import subprocess + +ModuleName = str + + +def parse_args() -> argparse.Namespace: + """Return parsed CLI arguments for the smoke test runner.""" + parser = argparse.ArgumentParser( + description=( + "Probe vcspull's runtime dependencies by importing all modules " + "and exercising CLI entry points." + ), + ) + parser.add_argument( + "--package", + default="vcspull", + help="Root package name to inspect (defaults to vcspull).", + ) + parser.add_argument( + "--cli-module", + default="vcspull.cli", + help="Module that exposes the create_parser helper for CLI discovery.", + ) + parser.add_argument( + "--cli-executable", + default="vcspull", + help="Console script to run for CLI smoke checks.", + ) + parser.add_argument( + "--cli-probe-arg", + action="append", + dest="cli_probe_args", + default=None, + help=( + "Additional argument(s) appended after each CLI sub-command; " + "may be repeated. Defaults to --help." + ), + ) + parser.add_argument( + "--skip-imports", + action="store_true", + help="Skip module import validation.", + ) + parser.add_argument( + "--skip-cli", + action="store_true", + help="Skip CLI command execution.", + ) + parser.add_argument( + "--verbose", + action="store_true", + help="Print verbose output for each check.", + ) + return parser.parse_args() + + +def discover_modules(package_name: str) -> list[ModuleName]: + """Return a sorted list of module names within *package_name*.""" + package = importlib.import_module(package_name) + module_names: set[str] = {package_name} + package_path = getattr(package, "__path__", None) + if package_path is None: + return sorted(module_names) + module_names.update( + module_info.name + for module_info in pkgutil.walk_packages( + package_path, + prefix=f"{package_name}.", + ) + ) + return sorted(module_names) + + +def import_all_modules(module_names: list[ModuleName], verbose: bool) -> list[str]: + """Attempt to import each module and return a list of failure messages.""" + failures: list[str] = [] + for module_name in module_names: + if verbose: + pass + try: + importlib.import_module(module_name) + except Exception as exc: # pragma: no cover - reporting only + detail = f"{module_name}: {exc!r}" + failures.append(detail) + return failures + + +def _find_cli_subcommands( + cli_module_name: str, +) -> tuple[list[str], argparse.ArgumentParser]: + """Return CLI sub-command names via vcspull.cli.create_parser.""" + cli_module = importlib.import_module(cli_module_name) + try: + parser = cli_module.create_parser() + except AttributeError as exc: # pragma: no cover - defensive + msg = f"{cli_module_name} does not expose create_parser()" + raise RuntimeError(msg) from exc + + commands: set[str] = set() + for action in parser._actions: # pragma: no branch - argparse internals + if isinstance(action, argparse._SubParsersAction): + commands.update(action.choices.keys()) + return sorted(commands), parser + + +def run_cli_command( + executable: str, + args: list[str], + *, + verbose: bool, +) -> tuple[int, str, str]: + """Execute CLI command and capture its result.""" + command = [executable, *args] + if verbose: + pass + result = subprocess.run( + command, + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + if result.stdout: + pass + if result.stderr: + pass + return result.returncode, result.stdout, result.stderr + + +def exercise_cli( + executable: str, + cli_module_name: str, + probe_args: list[str], + verbose: bool, +) -> list[str]: + """Run base CLI plus every sub-command, returning failure messages.""" + failures: list[str] = [] + subcommands, _parser = _find_cli_subcommands(cli_module_name) + + # Always test the base command with --help to verify the entry point. + base_exit, _, _ = run_cli_command(executable, ["--help"], verbose=verbose) + if base_exit != 0: + failures.append(f"{executable} --help (exit code {base_exit})") + + for subcommand in subcommands: + exit_code, _, _ = run_cli_command( + executable, + [subcommand, *probe_args], + verbose=verbose, + ) + if exit_code != 0: + probe_display = " ".join(probe_args) + failures.append( + f"{executable} {subcommand} {probe_display} (exit code {exit_code})", + ) + return failures + + +def main() -> int: + """Entry point for the runtime dependency smoke test.""" + args = parse_args() + cli_probe_args = args.cli_probe_args or ["--help"] + failures: list[str] = [] + + if not args.skip_imports: + modules = discover_modules(args.package) + failures.extend(import_all_modules(modules, args.verbose)) + + if not args.skip_cli: + failures.extend( + exercise_cli( + args.cli_executable, + args.cli_module, + cli_probe_args, + args.verbose, + ), + ) + + if failures: + for _failure in failures: + pass + return 1 + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/vcspull/cli/add.py b/src/vcspull/cli/add.py index 4a938c2c..fdf7303a 100644 --- a/src/vcspull/cli/add.py +++ b/src/vcspull/cli/add.py @@ -463,12 +463,11 @@ def _ensure_workspace_label_for_merge( relabelled = True else: config_data.setdefault("./", {}) + elif existing_label is None: + workspace_label = preferred_label + config_data.setdefault(workspace_label, {}) else: - if existing_label is None: - workspace_label = preferred_label - config_data.setdefault(workspace_label, {}) - else: - workspace_label = existing_label + workspace_label = existing_label if workspace_label not in config_data: config_data[workspace_label] = {} diff --git a/tests/cli/test_add.py b/tests/cli/test_add.py index c09c9271..f9ad539a 100644 --- a/tests/cli/test_add.py +++ b/tests/cli/test_add.py @@ -727,7 +727,7 @@ def test_handle_add_command_path_mode( { "test_id": test_id, "log": normalized_log.replace("", "~/.vcspull.yaml"), - } + }, ) else: snapshot.assert_match({"test_id": test_id, "log": normalized_log}) @@ -885,7 +885,7 @@ class NoMergePreservationFixture(t.NamedTuple): repo: git+https://github.com/Stiivi/bubbles.git cubes: repo: git+https://github.com/Stiivi/cubes.git - """ + """, ), expected_original_repos=( "Flexget", diff --git a/tests/test_config_writer.py b/tests/test_config_writer.py index 6afb2471..fe4f547b 100644 --- a/tests/test_config_writer.py +++ b/tests/test_config_writer.py @@ -2,7 +2,6 @@ from __future__ import annotations -import pathlib import textwrap import typing as t @@ -10,6 +9,9 @@ from vcspull.config import save_config_yaml_with_items +if t.TYPE_CHECKING: + import pathlib + FixtureEntry = tuple[str, dict[str, t.Any]] From a3ad5887fe5d02b7954e7985cadc7e6f5f39ba33 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 8 Nov 2025 05:58:27 -0600 Subject: [PATCH 2/7] scripts/test_runtime_dep_smoketest.py(test[smoke]): add pytest wrapper why: allow CI to invoke runtime dependency smoke test via pytest marker. what: - add isolated test harness that shells out with uvx to run the script. - echo stdout/stderr when the subprocess fails for easier debugging. --- scripts/test_runtime_dep_smoketest.py | 49 +++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 scripts/test_runtime_dep_smoketest.py diff --git a/scripts/test_runtime_dep_smoketest.py b/scripts/test_runtime_dep_smoketest.py new file mode 100644 index 00000000..f60097f0 --- /dev/null +++ b/scripts/test_runtime_dep_smoketest.py @@ -0,0 +1,49 @@ +"""Tests for the runtime dependency smoke test script. + +These tests are intentionally isolated behind the +``scripts__runtime_dep_smoketest`` marker so they only run when explicitly +requested, e.g. ``pytest -m scripts__runtime_dep_smoketest``. +""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.scripts__runtime_dep_smoketest + + +def test_runtime_smoke_test_script() -> None: + """Run ``scripts/runtime_dep_smoketest.py`` in a clean uvx environment.""" + uvx = shutil.which("uvx") + if uvx is None: + pytest.skip("uvx is required to run the runtime dependency smoke test") + + repo_root = Path(__file__).resolve().parents[1] + script_path = repo_root / "scripts" / "runtime_dep_smoketest.py" + + result = subprocess.run( + [ + uvx, + "--isolated", + "--reinstall", + "--from", + str(repo_root), + "python", + str(script_path), + ], + capture_output=True, + text=True, + cwd=str(repo_root), + check=False, + ) + + if result.returncode != 0: + sys.stdout.write(result.stdout) + sys.stderr.write(result.stderr) + + assert result.returncode == 0, "runtime dependency smoke test failed" From 311c12b4e5dce50f8730a84ddab6c61e71c5b4de Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 8 Nov 2025 05:58:32 -0600 Subject: [PATCH 3/7] conftest.py(test[markers]): skip runtime smoke tests by default why: runtime smoke test should only run when explicitly requested because it spawns uvx and needs network access. what: - add pytest_collection_modifyitems hook that skips items with scripts__runtime_dep_smoketest marker unless -m is passed. --- conftest.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/conftest.py b/conftest.py index 36e8cd3e..12c4266d 100644 --- a/conftest.py +++ b/conftest.py @@ -96,3 +96,21 @@ def clean() -> None: request.addfinalizer(clean) return path + + +def pytest_collection_modifyitems( + config: pytest.Config, + items: list[pytest.Item], +) -> None: + """Skip runtime smoke tests unless explicitly requested via ``-m``.""" + marker_name = "scripts__runtime_dep_smoketest" + markexpr = getattr(config.option, "markexpr", "") or "" + if marker_name in markexpr: + return + + skip_marker = pytest.mark.skip( + reason=f"pass -m {marker_name} to run runtime dependency smoke tests", + ) + for item in items: + if marker_name in item.keywords: + item.add_marker(skip_marker) From 20946cdda726f5e802ecdd082012a76659129233 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 8 Nov 2025 05:58:38 -0600 Subject: [PATCH 4/7] pyproject.toml(chore[test]): register scripts smoke marker why: ensure pytest discovers the new scripts tests and documents the opt-in marker. what: - add scripts directory to pytest testpaths so the new test file is collected. - declare scripts__runtime_dep_smoketest marker description. --- pyproject.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index f994719b..5c7f4300 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -246,6 +246,10 @@ testpaths = [ "src/vcspull", "tests", "docs", + "scripts", +] +markers = [ + "scripts__runtime_dep_smoketest: run runtime dependency smoke tests", ] filterwarnings = [ "ignore:The frontend.Option(Parser)? class.*:DeprecationWarning::", From 128ab20ebf3948fe8b9fa304cddf49f6cbf8bdda Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 8 Nov 2025 05:58:42 -0600 Subject: [PATCH 5/7] docs/developing.md(docs[testing]): document runtime smoke test why: contributors need guidance on exercising the isolated runtime dependency smoke test. what: - add section showing how to run the script via uvx and via the pytest marker. - call out that the check relies on network access. --- docs/developing.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/developing.md b/docs/developing.md index 91c3df56..ff04b3c7 100644 --- a/docs/developing.md +++ b/docs/developing.md @@ -64,6 +64,24 @@ or: $ make test ``` +#### Runtime dependency smoke test + +Verify that the published wheel runs without dev/test extras: + +```console +$ uvx --isolated --reinstall --from . python scripts/runtime_dep_smoketest.py +``` + +The script imports every ``vcspull`` module and exercises each CLI sub-command +with ``--help``. There is also a pytest wrapper guarded by a dedicated marker: + +```console +$ uv run pytest -m scripts__runtime_dep_smoketest scripts/test_runtime_dep_smoketest.py +``` + +These checks are network-dependent because they rely on ``uvx`` to build the +package in an isolated environment. + #### pytest options `PYTEST_ADDOPTS` can be set in the commands below. For more From ccd2260e6d1e192bd175f0087e2423e4c83cbf93 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 8 Nov 2025 05:58:48 -0600 Subject: [PATCH 6/7] src/vcspull/types.py(refactor[typing]): remove typing_extensions dependency why: keep runtime installs free of typing_extensions while still supporting optional fields. what: - split ConfigDict into required/optional TypedDict mixins instead of relying on NotRequired. - drop the typing_extensions import entirely. --- src/vcspull/types.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/vcspull/types.py b/src/vcspull/types.py index 6d8674eb..ff192a54 100644 --- a/src/vcspull/types.py +++ b/src/vcspull/types.py @@ -31,9 +31,7 @@ import pathlib import typing as t -from typing import TypeAlias - -from typing_extensions import NotRequired, TypedDict +from typing import TypeAlias, TypedDict if t.TYPE_CHECKING: from libvcs._internal.types import StrPath, VCSLiteral @@ -54,16 +52,25 @@ class RawConfigDict(t.TypedDict): RawConfig = dict[str, RawConfigDir] -class ConfigDict(TypedDict): - """Configuration map for vcspull after shorthands and variables resolved.""" +class _ConfigDictRequired(TypedDict): + """Required fields for resolved vcspull configuration entries.""" vcs: VCSLiteral | None name: str path: pathlib.Path url: str workspace_root: str - remotes: NotRequired[GitSyncRemoteDict | None] - shell_command_after: NotRequired[list[str] | None] + + +class _ConfigDictOptional(TypedDict, total=False): + """Optional fields for resolved vcspull configuration entries.""" + + remotes: GitSyncRemoteDict | None + shell_command_after: list[str] | None + + +class ConfigDict(_ConfigDictRequired, _ConfigDictOptional): + """Configuration map for vcspull after shorthands and variables resolved.""" ConfigDir = dict[str, ConfigDict] From 27abf1c9d9985c8425fcb4de3e1eb753e541f9ab Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 8 Nov 2025 06:00:36 -0600 Subject: [PATCH 7/7] .github/workflows/tests.yml(ci[smoke]): run runtime smoke test why: ensure CI verifies vcspull installs without dev/test dependencies by running the new smoke script. what: - add step that invokes uvx in an isolated environment to run scripts/runtime_dep_smoketest.py. --- .github/workflows/tests.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 57183ca4..c0d97753 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -45,6 +45,9 @@ jobs: COV_CORE_CONFIG: pyproject.toml COV_CORE_DATAFILE: .coverage.eager + - name: Runtime dependency smoke test + run: uvx --isolated --reinstall --from . python scripts/runtime_dep_smoketest.py + - uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }}