diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6752a4415..297bac571 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 --no-cache --from . python scripts/runtime_dep_smoketest.py + - uses: codecov/codecov-action@v6 with: token: ${{ secrets.CODECOV_TOKEN }} diff --git a/CHANGES b/CHANGES index 7a6028071..ca3eee2c2 100644 --- a/CHANGES +++ b/CHANGES @@ -48,6 +48,13 @@ branch or checking out a branch locally, and shows the remote branches Git reported. This turns a low-level checkout failure into guidance for repairing the repository configuration. +#### Runtime installs stay lean (#486) + +vcspull now checks the same runtime-only install path users get from package +tools, instead of relying only on the development test environment. That keeps +production installs free of dev-only typing dependencies while still exercising +the import and CLI help paths before changes ship. + ### Documentation - Command examples are re-captured from real runs; the sync and status diff --git a/conftest.py b/conftest.py index 22fdb77f2..9a5b07cb7 100644 --- a/conftest.py +++ b/conftest.py @@ -102,3 +102,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) diff --git a/docs/project/contributing.md b/docs/project/contributing.md index 177fba597..4a8214b03 100644 --- a/docs/project/contributing.md +++ b/docs/project/contributing.md @@ -64,6 +64,30 @@ or: $ just test ``` +#### Runtime dependency smoke test + +Verify that the published wheel runs without dev/test extras: + +```console +$ uvx \ + --isolated \ + --no-cache \ + --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 diff --git a/pyproject.toml b/pyproject.toml index c9d4d54fa..f6acef409 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -267,6 +267,10 @@ testpaths = [ "src/vcspull", "tests", "docs/_ext", + "scripts", +] +markers = [ + "scripts__runtime_dep_smoketest: run runtime dependency smoke tests", ] filterwarnings = [ "ignore:The frontend.Option(Parser)? class.*:DeprecationWarning::", diff --git a/scripts/runtime_dep_smoketest.py b/scripts/runtime_dep_smoketest.py new file mode 100755 index 000000000..2f037fe9f --- /dev/null +++ b/scripts/runtime_dep_smoketest.py @@ -0,0 +1,277 @@ +#!/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 +import sys + +ModuleName = str + + +def parse_args() -> argparse.Namespace: + """Return parsed CLI arguments for the smoke test runner. + + Examples + -------- + >>> import unittest.mock + >>> with unittest.mock.patch( + ... "sys.argv", + ... ["runtime_dep_smoketest.py", "--skip-cli"], + ... ): + ... parse_args().skip_cli + True + """ + 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*. + + Examples + -------- + >>> discover_modules("argparse") + ['argparse'] + """ + 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. + + Examples + -------- + >>> import_all_modules(["argparse"], verbose=False) + [] + """ + failures: list[str] = [] + for module_name in module_names: + if verbose: + sys.stdout.write(f"import {module_name}\n") + 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. + + Examples + -------- + >>> commands, _ = _find_cli_subcommands("vcspull.cli") + >>> "sync" in commands + True + """ + 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. + + Examples + -------- + >>> exit_code, stdout, stderr = run_cli_command( + ... sys.executable, + ... ["-c", "print('ok')"], + ... verbose=False, + ... ) + >>> exit_code + 0 + >>> stdout.strip() + 'ok' + >>> stderr + '' + """ + command = [executable, *args] + if verbose: + sys.stdout.write(f"run {' '.join(command)}\n") + result = subprocess.run( + command, + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + if result.stdout: + sys.stdout.write(result.stdout) + if result.stderr: + sys.stderr.write(result.stderr) + 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. + + Examples + -------- + >>> exercise_cli("vcspull", "vcspull.cli", ["--help"], verbose=False) + [] + """ + 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. + + Examples + -------- + >>> import unittest.mock + >>> with unittest.mock.patch( + ... "sys.argv", + ... [ + ... "runtime_dep_smoketest.py", + ... "--package", + ... "argparse", + ... "--skip-cli", + ... ], + ... ): + ... main() + 0 + """ + args = parse_args() + cli_probe_args = args.cli_probe_args or ["--help"] + failures: list[str] = [] + + if not args.skip_imports: + try: + modules = discover_modules(args.package) + except Exception as exc: # pragma: no cover - reporting only + failures.append(f"{args.package}: {exc!r}") + else: + failures.extend(import_all_modules(modules, args.verbose)) + + if not args.skip_cli: + try: + failures.extend( + exercise_cli( + args.cli_executable, + args.cli_module, + cli_probe_args, + args.verbose, + ), + ) + except Exception as exc: # pragma: no cover - reporting only + failures.append(f"{args.cli_module}: {exc!r}") + + if failures: + for failure in failures: + sys.stderr.write(f"{failure}\n") + return 1 + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test_runtime_dep_smoketest.py b/scripts/test_runtime_dep_smoketest.py new file mode 100644 index 000000000..769c84536 --- /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 pathlib +import shutil +import subprocess +import sys + +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 = pathlib.Path(__file__).resolve().parents[1] + script_path = repo_root / "scripts" / "runtime_dep_smoketest.py" + + result = subprocess.run( + [ + uvx, + "--isolated", + "--no-cache", + "--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" diff --git a/src/vcspull/types.py b/src/vcspull/types.py index 292eb1b8d..5b9506ca4 100644 --- a/src/vcspull/types.py +++ b/src/vcspull/types.py @@ -31,16 +31,14 @@ 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 from libvcs.sync.git import GitSyncRemoteDict -class WorktreeConfigDict(TypedDict): +class _WorktreeConfigDictRequired(TypedDict): """Configuration for a single git worktree. Worktrees allow checking out multiple branches/tags/commits of a repository @@ -66,25 +64,36 @@ class WorktreeConfigDict(TypedDict): dir: str """Path for the worktree (relative to workspace root or absolute).""" - tag: NotRequired[str | None] + +class _WorktreeConfigDictOptional(TypedDict, total=False): + """Optional configuration for a single git worktree.""" + + tag: str | None """Tag to checkout (creates detached HEAD).""" - branch: NotRequired[str | None] + branch: str | None """Branch to checkout (can be updated/pulled).""" - commit: NotRequired[str | None] + commit: str | None """Commit SHA to checkout (creates detached HEAD).""" - detach: NotRequired[bool | None] + detach: bool | None """Force detached HEAD. Default: True for tag/commit, False for branch.""" - lock: NotRequired[bool | None] + lock: bool | None """Lock the worktree to prevent accidental removal.""" - lock_reason: NotRequired[str | None] + lock_reason: str | None """Reason for locking. If provided, implies lock=True.""" +class WorktreeConfigDict( + _WorktreeConfigDictRequired, + _WorktreeConfigDictOptional, +): + """Configuration for a single git worktree.""" + + RepoPinDict = TypedDict( "RepoPinDict", { @@ -160,20 +169,20 @@ class RepoOptionsDict(TypedDict, total=False): allow_overwrite: false """ - rev: NotRequired[str] + rev: str """Commit, tag, or branch to check out on sync (libvcs ``rev``). Distinct from ``pin``, which guards config mutation rather than pinning a git ref. """ - shallow: NotRequired[bool] + shallow: bool """If ``True``, clone with ``--depth 1`` on sync (libvcs ``git_shallow``). Sugar for ``depth: 1``; ``depth`` wins when both are set. """ - depth: NotRequired[int] + depth: int """Clone with history truncated to ``depth`` commits (libvcs ``depth``). Takes precedence over ``shallow``. @@ -195,7 +204,7 @@ class RepoOptionsDict(TypedDict, total=False): """Human-readable reason shown in log output when an op is skipped due to pin.""" -class RepoEntryDict(TypedDict): +class _RepoEntryDictRequired(TypedDict): """Raw per-repository entry as written to .vcspull.yaml. Examples @@ -223,22 +232,30 @@ class RepoEntryDict(TypedDict): repo: str """VCS URL in vcspull format, e.g. ``git+git@github.com:user/repo.git``.""" - rev: NotRequired[str] + +class _RepoEntryDictOptional(TypedDict, total=False): + """Optional raw per-repository entry fields.""" + + rev: str """Deprecated top-level form of ``options.rev``; still read, with a warning. Run ``vcspull migrate`` to relocate it under ``options:``. """ - shallow: NotRequired[bool] + shallow: bool """Deprecated top-level form of ``options.shallow``; still read, with a warning. Run ``vcspull migrate`` to relocate it under ``options:``. """ - options: NotRequired[RepoOptionsDict] + options: RepoOptionsDict """Sync tuning (``rev``/``shallow``/``depth``) plus mutation policy.""" +class RepoEntryDict(_RepoEntryDictRequired, _RepoEntryDictOptional): + """Raw per-repository entry as written to .vcspull.yaml.""" + + class RawConfigDict(t.TypedDict): """Configuration dictionary without any type marshalling or variable resolution.""" @@ -253,21 +270,30 @@ 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 - rev: NotRequired[str | None] - shallow: NotRequired[bool | None] - depth: NotRequired[int | None] - remotes: NotRequired[GitSyncRemoteDict | None] - shell_command_after: NotRequired[list[str] | None] - worktrees: NotRequired[list[WorktreeConfigDict] | None] - options: NotRequired[RepoOptionsDict] + + +class _ConfigDictOptional(TypedDict, total=False): + """Optional fields for resolved vcspull configuration entries.""" + + rev: str | None + shallow: bool | None + depth: int | None + remotes: GitSyncRemoteDict | None + shell_command_after: list[str] | None + worktrees: list[WorktreeConfigDict] | None + options: RepoOptionsDict + + +class ConfigDict(_ConfigDictRequired, _ConfigDictOptional): + """Configuration map for vcspull after shorthands and variables resolved.""" ConfigDir = dict[str, ConfigDict]