Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
18 changes: 18 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
18 changes: 18 additions & 0 deletions docs/developing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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::",
Expand Down
202 changes: 202 additions & 0 deletions scripts/runtime_dep_smoketest.py
Original file line number Diff line number Diff line change
@@ -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())
49 changes: 49 additions & 0 deletions scripts/test_runtime_dep_smoketest.py
Original file line number Diff line number Diff line change
@@ -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"
9 changes: 4 additions & 5 deletions src/vcspull/cli/add.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = {}
Expand Down
21 changes: 14 additions & 7 deletions src/vcspull/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]
Expand Down
4 changes: 2 additions & 2 deletions tests/cli/test_add.py
Original file line number Diff line number Diff line change
Expand Up @@ -727,7 +727,7 @@ def test_handle_add_command_path_mode(
{
"test_id": test_id,
"log": normalized_log.replace("<config>", "~/.vcspull.yaml"),
}
},
)
else:
snapshot.assert_match({"test_id": test_id, "log": normalized_log})
Expand Down Expand Up @@ -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",
Expand Down
Loading