diff --git a/.claude/hooks/user_prompt_router.py b/.claude/hooks/user_prompt_router.py index 2631197..6dddd92 100644 --- a/.claude/hooks/user_prompt_router.py +++ b/.claude/hooks/user_prompt_router.py @@ -51,7 +51,7 @@ def main() -> None: if issue is None: continue - labels = ", ".join(l.get("name", "") for l in (issue.get("labels") or [])) + labels = ", ".join(lbl.get("name", "") for lbl in (issue.get("labels") or [])) body = (issue.get("body") or "").strip() if len(body) > 1500: body = body[:1500] + "\n…(truncated)" diff --git a/CHANGELOG.md b/CHANGELOG.md index f886b18..58ab7b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- `ossmate doctor` subcommand — runs 6 environment diagnostics (Python version, `ossmate` install, `gh` CLI + auth, MCP server reachability via `python -m ossmate_mcp --selftest`, project root detection, `.ossmate/` artifact dir writability) and reports ✓/⚠/✗ with remediation hints. Exits 0 when healthy, 1 on any hard failure. Supports `--json` for CI use. Motivated by Phase 9 onboarding-friction review + ### Fixed - `ossmate version` and `ossmate_mcp.__version__` resolved to the hardcoded literal `"0.0.1"` instead of the installed package version. Both `__init__.py` modules now read from `importlib.metadata.version()` so they always agree with what `pip show` reports. New invariant test [tests/test_versioning.py](tests/test_versioning.py)::`test_init_modules_dont_hardcode_versions` forbids the hardcoded form going forward — caught the day after v0.1.0 shipped to PyPI diff --git a/README.en.md b/README.en.md index c26e4e9..c6cf9a4 100644 --- a/README.en.md +++ b/README.en.md @@ -71,6 +71,8 @@ ossmate triage-pr 1234 --dry-run # print the rendered prompt + ClaudeAgentOpt The CLI loads the same `.claude/commands/*.md` skill bodies the plugin uses — write a skill once, get a slash command and a CLI subcommand for free. +If your first command fails, run `ossmate doctor` to diagnose the environment (Python, `gh` CLI auth, MCP server, `.claude/` / `.ossmate/` directories). Pass `--json` for CI-friendly output. + ### C. From source (development) ```bash diff --git a/README.md b/README.md index 6cf379c..dc11bc5 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,8 @@ ossmate triage-pr 1234 --dry-run # 렌더링된 prompt + ClaudeAgentOptions CLI는 플러그인이 사용하는 동일한 `.claude/commands/*.md` 스킬 본문을 로드합니다 — 스킬을 한 번 작성하면 슬래시 명령과 CLI 서브커맨드가 자동으로 함께 생깁니다. +설치 후 첫 명령이 실패한다면 `ossmate doctor`로 환경(Python, `gh` CLI 인증, MCP 서버, `.claude/`·`.ossmate/` 디렉터리)을 먼저 진단하세요. `--json` 플래그로 CI에서도 사용 가능합니다. + ### C. 소스에서 (개발용) ```bash diff --git a/cli/ossmate/pyproject.toml b/cli/ossmate/pyproject.toml index fba6aa4..2eab2be 100644 --- a/cli/ossmate/pyproject.toml +++ b/cli/ossmate/pyproject.toml @@ -55,6 +55,11 @@ target-version = "py311" [tool.ruff.lint] select = ["E", "F", "I", "N", "UP", "B", "SIM"] +[tool.ruff.lint.per-file-ignores] +# Typer's idiom calls `_common_cwd()` etc. in default values; a module-level +# singleton wouldn't work because Typer re-parses OptionInfo at call time. +"src/ossmate/cli.py" = ["B008"] + [tool.mypy] python_version = "3.11" strict = true diff --git a/cli/ossmate/src/ossmate/__init__.py b/cli/ossmate/src/ossmate/__init__.py index e829409..1800877 100644 --- a/cli/ossmate/src/ossmate/__init__.py +++ b/cli/ossmate/src/ossmate/__init__.py @@ -1,6 +1,7 @@ """Ossmate — Claude-powered co-maintainer CLI.""" -from importlib.metadata import PackageNotFoundError, version as _pkg_version +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as _pkg_version try: __version__ = _pkg_version("ossmate") diff --git a/cli/ossmate/src/ossmate/cli.py b/cli/ossmate/src/ossmate/cli.py index 15ce001..5333f63 100644 --- a/cli/ossmate/src/ossmate/cli.py +++ b/cli/ossmate/src/ossmate/cli.py @@ -19,7 +19,6 @@ from .prompts import SkillNotFoundError, load_skill from .tools.repo import ProjectRootNotFoundError, find_project_root - app = typer.Typer( name="ossmate", help="OSS Maintainer's co-pilot — runs the same workflows as the Claude Code " @@ -120,7 +119,10 @@ def release_notes( _dispatch("release-notes", args, dry_run=dry_run, cwd=cwd, model=model) -@app.command("stale-sweep", help="Find issues older than N days and propose nudge / close / wontfix.") +@app.command( + "stale-sweep", + help="Find issues older than N days and propose nudge / close / wontfix.", +) def stale_sweep( days: int = typer.Option(60, "--days", help="Inactivity threshold in days."), label: str | None = typer.Option(None, "--label", help="Restrict to a single label."), @@ -134,7 +136,10 @@ def stale_sweep( _dispatch("stale-sweep", args, dry_run=dry_run, cwd=cwd, model=model) -@app.command("onboard-contributor", help="Draft a warm welcome comment for a first-time contributor.") +@app.command( + "onboard-contributor", + help="Draft a warm welcome comment for a first-time contributor.", +) def onboard_contributor( number: str = typer.Argument(..., help="PR or issue number."), dry_run: bool = _common_dry_run(), @@ -178,7 +183,10 @@ def security_review_pr( _dispatch("security-review-pr", args, dry_run=dry_run, cwd=cwd, model=model) -@app.command("changelog-bump", help="Inspect Conventional Commits and propose the next semver bump.") +@app.command( + "changelog-bump", + help="Inspect Conventional Commits and propose the next semver bump.", +) def changelog_bump( since: str | None = typer.Option(None, "--since", help="Ref or ISO date to diff from."), dry_run: bool = _common_dry_run(), @@ -196,6 +204,26 @@ def version_cmd() -> None: sys.stdout.write(f"ossmate {__version__}\n") +@app.command("doctor", help="Run diagnostic checks for the Ossmate environment.") +def doctor( + json_output: bool = typer.Option( + False, + "--json", + help="Emit machine-readable JSON instead of pretty output.", + ), + cwd: Path | None = _common_cwd(), +) -> None: + from .diagnostics import render_json, render_pretty, run_all + + results = run_all(cwd) + if json_output: + sys.stdout.write(render_json(results) + "\n") + else: + render_pretty(results) + if any(r.status == "fail" for r in results): + raise typer.Exit(1) + + def main() -> None: for stream in (sys.stdout, sys.stderr): reconfigure = getattr(stream, "reconfigure", None) diff --git a/cli/ossmate/src/ossmate/diagnostics.py b/cli/ossmate/src/ossmate/diagnostics.py new file mode 100644 index 0000000..68f3eac --- /dev/null +++ b/cli/ossmate/src/ossmate/diagnostics.py @@ -0,0 +1,211 @@ +"""Ossmate environment diagnostics — 6 checks that self-diagnose ~80% of +first-run failures (missing `gh`, unauthed `gh`, missing `.claude/`, broken +MCP install, `.ossmate/` permission issues). + +Each check is a pure `(project_root: Path | None) -> CheckResult` function so +it can be unit-tested in isolation. `run_all` composes them in a fixed order. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +import sys +from dataclasses import asdict, dataclass +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as _pkg_version +from pathlib import Path +from typing import Literal + +from .tools.repo import ProjectRootNotFoundError, find_project_root + +Status = Literal["ok", "warn", "fail"] + + +@dataclass(frozen=True) +class CheckResult: + name: str + status: Status + detail: str + hint: str = "" + + +MIN_PYTHON = (3, 11) +MCP_SELFTEST_TIMEOUT_S = 10 +GH_AUTH_TIMEOUT_S = 5 + + +def check_python(_project_root: Path | None) -> CheckResult: + v = sys.version_info + detail = f"{v.major}.{v.minor}.{v.micro}" + if (v.major, v.minor) >= MIN_PYTHON: + return CheckResult("python", "ok", detail) + return CheckResult( + "python", + "fail", + detail, + hint=f"Ossmate requires Python {MIN_PYTHON[0]}.{MIN_PYTHON[1]}+", + ) + + +def check_ossmate(_project_root: Path | None) -> CheckResult: + try: + v = _pkg_version("ossmate") + except PackageNotFoundError: + return CheckResult( + "ossmate", + "fail", + "not installed", + hint="Install with `pipx install ossmate`", + ) + return CheckResult("ossmate", "ok", v) + + +def check_gh(_project_root: Path | None) -> CheckResult: + gh_path = shutil.which("gh") + if gh_path is None: + return CheckResult( + "gh cli", + "warn", + "not found", + hint=( + "Install from https://cli.github.com/ — Ossmate falls back to " + "PyGithub but `gh` speeds up PR/issue commands" + ), + ) + try: + result = subprocess.run( + [gh_path, "auth", "status"], + capture_output=True, + encoding="utf-8", + errors="replace", + timeout=GH_AUTH_TIMEOUT_S, + check=False, + ) + except (subprocess.TimeoutExpired, OSError) as exc: + return CheckResult( + "gh cli", + "warn", + f"auth probe failed ({type(exc).__name__})", + hint="Run `gh auth login` and retry", + ) + if result.returncode == 0: + return CheckResult("gh cli", "ok", "authenticated") + return CheckResult( + "gh cli", + "warn", + "not authenticated", + hint="Run `gh auth login` to enable PR/issue commands", + ) + + +def check_mcp_server(_project_root: Path | None) -> CheckResult: + try: + result = subprocess.run( + [sys.executable, "-X", "utf8", "-m", "ossmate_mcp", "--selftest"], + capture_output=True, + encoding="utf-8", + errors="replace", + timeout=MCP_SELFTEST_TIMEOUT_S, + check=False, + ) + except subprocess.TimeoutExpired: + return CheckResult( + "mcp server", + "fail", + f"selftest timed out after {MCP_SELFTEST_TIMEOUT_S}s", + hint="Reinstall with `pipx install ossmate-mcp`", + ) + except OSError as exc: + return CheckResult( + "mcp server", + "fail", + f"could not spawn Python ({exc})", + hint="Reinstall with `pipx install ossmate-mcp`", + ) + if result.returncode != 0: + stderr_tail = (result.stderr or "").strip().splitlines()[-1:] or [""] + return CheckResult( + "mcp server", + "fail", + f"selftest exit code {result.returncode}: {stderr_tail[0]}", + hint="Reinstall with `pipx install ossmate-mcp`", + ) + first_line = (result.stdout or "").strip().splitlines()[:1] or [""] + return CheckResult("mcp server", "ok", first_line[0] or "selftest ok") + + +def check_project_root(project_root: Path | None) -> CheckResult: + try: + root = find_project_root(project_root) + except ProjectRootNotFoundError: + return CheckResult( + "project root", + "warn", + "no `.claude/commands/` above cwd", + hint="Run from inside a repo with `.claude/commands/`", + ) + return CheckResult("project root", "ok", str(root)) + + +def check_ossmate_writable(project_root: Path | None) -> CheckResult: + try: + root = find_project_root(project_root) + except ProjectRootNotFoundError: + return CheckResult( + ".ossmate writable", + "warn", + "skipped — no project root", + hint="This check runs once `project root` resolves", + ) + artifacts = root / ".ossmate" + probe = artifacts / ".write-probe" + try: + artifacts.mkdir(parents=True, exist_ok=True) + probe.write_text("ok", encoding="utf-8") + probe.unlink() + except OSError as exc: + return CheckResult( + ".ossmate writable", + "fail", + f"{artifacts}: {exc.strerror or exc}", + hint="Check filesystem permissions on the project root", + ) + return CheckResult(".ossmate writable", "ok", str(artifacts)) + + +# Ordered so output reads top-down: runtime → package → external → server → repo. +_CHECKS = ( + check_python, + check_ossmate, + check_gh, + check_mcp_server, + check_project_root, + check_ossmate_writable, +) + + +def run_all(project_root: Path | None) -> list[CheckResult]: + return [c(project_root) for c in _CHECKS] + + +def render_pretty(results: list[CheckResult]) -> None: + from rich.console import Console + + console = Console() + glyph = { + "ok": "[green]✓[/green]", + "warn": "[yellow]⚠[/yellow]", + "fail": "[red]✗[/red]", + } + for r in results: + console.print(f"{glyph[r.status]} {r.name:<18} {r.detail}") + if r.hint and r.status != "ok": + console.print(f" [dim]→ {r.hint}[/dim]") + + +def render_json(results: list[CheckResult]) -> str: + return json.dumps( + {"checks": [asdict(r) for r in results]}, indent=2, ensure_ascii=False + ) diff --git a/cli/ossmate/src/ossmate/prompts.py b/cli/ossmate/src/ossmate/prompts.py index e80e4e3..95bc3ac 100644 --- a/cli/ossmate/src/ossmate/prompts.py +++ b/cli/ossmate/src/ossmate/prompts.py @@ -12,7 +12,6 @@ from dataclasses import dataclass, field from pathlib import Path - _FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n(.*)$", re.DOTALL) _KV_RE = re.compile(r"^([A-Za-z][\w-]*)\s*:\s*(.*)$") _PLACEHOLDER_RE = re.compile(r"\$(\d+|ARGUMENTS)") diff --git a/mcp/ossmate_mcp/src/ossmate_mcp/__init__.py b/mcp/ossmate_mcp/src/ossmate_mcp/__init__.py index bcbe141..5fa746c 100644 --- a/mcp/ossmate_mcp/src/ossmate_mcp/__init__.py +++ b/mcp/ossmate_mcp/src/ossmate_mcp/__init__.py @@ -1,6 +1,7 @@ """Ossmate MCP server — OSS maintainer tools exposed via Model Context Protocol.""" -from importlib.metadata import PackageNotFoundError, version as _pkg_version +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as _pkg_version try: __version__ = _pkg_version("ossmate-mcp") diff --git a/mcp/ossmate_mcp/src/ossmate_mcp/tools/github.py b/mcp/ossmate_mcp/src/ossmate_mcp/tools/github.py index 6b0831a..d6146cd 100644 --- a/mcp/ossmate_mcp/src/ossmate_mcp/tools/github.py +++ b/mcp/ossmate_mcp/src/ossmate_mcp/tools/github.py @@ -17,7 +17,7 @@ import os import shutil import subprocess -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import Any from mcp.server.fastmcp import FastMCP @@ -190,7 +190,7 @@ def list_stale_issues( ]) if data is None: return {"error": "gh_issue_list_failed"} - cutoff = datetime.now(tz=timezone.utc).timestamp() - days * 86400 + cutoff = datetime.now(tz=UTC).timestamp() - days * 86400 stale = [] for issue in data: updated = issue.get("updatedAt") @@ -202,7 +202,7 @@ def list_stale_issues( continue if ts <= cutoff: issue["age_days"] = int( - (datetime.now(tz=timezone.utc).timestamp() - ts) / 86400 + (datetime.now(tz=UTC).timestamp() - ts) / 86400 ) stale.append(issue) return {"count": len(stale), "threshold_days": days, "issues": stale} diff --git a/tests/test_cli.py b/tests/test_cli.py index d338216..0656236 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -186,8 +186,8 @@ def test_every_skill_has_a_subcommand(self): def test_no_orphan_subcommands(self): """A subcommand without a matching skill file is dead code.""" skill_names = {p.stem for p in COMMANDS_DIR.glob("*.md")} - # `version` is the one CLI-only command we expect. - cli_only_allowlist = {"version"} + # `version` and `doctor` are CLI-only commands with no slash equivalent. + cli_only_allowlist = {"version", "doctor"} registered = _registered_subcommands() - cli_only_allowlist orphans = registered - skill_names assert not orphans, ( diff --git a/tests/test_doctor.py b/tests/test_doctor.py new file mode 100644 index 0000000..f01eb71 --- /dev/null +++ b/tests/test_doctor.py @@ -0,0 +1,175 @@ +"""Phase 10 contract tests for `ossmate doctor`. + +Hermetic — each check function is a pure `(project_root) -> CheckResult`, so +tests exercise them directly with `monkeypatch`/`tmp_path` instead of spawning +a real subprocess (except `test_mcp_selftest_check_ok_via_subprocess`, which +intentionally spawns `python -m ossmate_mcp --selftest` to prove end-to-end +reachability). +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +from ossmate import cli as cli_module +from ossmate import diagnostics as diag + +REPO_ROOT = Path(__file__).resolve().parent.parent + + +class TestCheckPython: + def test_passes_on_311_plus(self): + r = diag.check_python(None) + assert r.status == "ok" + assert r.name == "python" + assert sys.version.split()[0].startswith(r.detail) + + +class TestCheckGhCli: + def test_warns_when_absent(self, monkeypatch): + monkeypatch.setattr(diag.shutil, "which", lambda _: None) + r = diag.check_gh(None) + assert r.status == "warn" + assert "not found" in r.detail + assert "cli.github.com" in r.hint + + def test_warns_when_present_but_unauthed(self, monkeypatch): + monkeypatch.setattr(diag.shutil, "which", lambda _: "/fake/gh") + + def fake_run(*_a, **_kw): + return subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="not logged in") + + monkeypatch.setattr(diag.subprocess, "run", fake_run) + r = diag.check_gh(None) + assert r.status == "warn" + assert "not authenticated" in r.detail + assert "gh auth login" in r.hint + + def test_ok_when_authed(self, monkeypatch): + monkeypatch.setattr(diag.shutil, "which", lambda _: "/fake/gh") + + def fake_run(*_a, **_kw): + return subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="") + + monkeypatch.setattr(diag.subprocess, "run", fake_run) + r = diag.check_gh(None) + assert r.status == "ok" + + +class TestCheckMcpServer: + def test_ok_via_subprocess(self): + """Actually spawn `python -m ossmate_mcp --selftest` — integration-style.""" + r = diag.check_mcp_server(None) + assert r.status == "ok", f"selftest failed: {r.detail}" + assert "tool" in r.detail.lower() or r.detail == "selftest ok" + + def test_fail_on_timeout(self, monkeypatch): + def boom(*_a, **_kw): + raise subprocess.TimeoutExpired(cmd="python", timeout=diag.MCP_SELFTEST_TIMEOUT_S) + + monkeypatch.setattr(diag.subprocess, "run", boom) + r = diag.check_mcp_server(None) + assert r.status == "fail" + assert "timed out" in r.detail + + +class TestCheckProjectRoot: + def test_ok_in_repo(self): + r = diag.check_project_root(REPO_ROOT) + assert r.status == "ok" + assert Path(r.detail).resolve() == REPO_ROOT.resolve() + + def test_warns_outside_repo(self, tmp_path: Path): + r = diag.check_project_root(tmp_path) + assert r.status == "warn" + assert ".claude/commands" in r.detail + + +class TestCheckOssmateWritable: + def test_creates_dir_under_repo_root(self, tmp_path: Path): + # Fake a project root: drop a `.claude/commands/` marker inside tmp_path. + (tmp_path / ".claude" / "commands").mkdir(parents=True) + r = diag.check_ossmate_writable(tmp_path) + assert r.status == "ok" + assert (tmp_path / ".ossmate").is_dir() + assert not (tmp_path / ".ossmate" / ".write-probe").exists(), "probe file was not cleaned up" + + def test_warns_when_no_project_root(self, tmp_path: Path): + r = diag.check_ossmate_writable(tmp_path) + assert r.status == "warn" + assert "skipped" in r.detail + + +class TestRunAll: + def test_returns_six_results_in_fixed_order(self): + results = diag.run_all(REPO_ROOT) + assert [r.name for r in results] == [ + "python", + "ossmate", + "gh cli", + "mcp server", + "project root", + ".ossmate writable", + ] + + +class TestRenderJson: + def test_schema_round_trips(self): + results = diag.run_all(REPO_ROOT) + payload = json.loads(diag.render_json(results)) + assert "checks" in payload + assert len(payload["checks"]) == 6 + for entry in payload["checks"]: + assert set(entry.keys()) == {"name", "status", "detail", "hint"} + assert entry["status"] in {"ok", "warn", "fail"} + + +class TestDoctorCli: + def test_help_lists_doctor(self): + typer_testing = pytest.importorskip("typer.testing") + runner = typer_testing.CliRunner() + result = runner.invoke(cli_module.app, ["doctor", "--help"]) + assert result.exit_code == 0 + # rich's help table wraps/styles flag names across ANSI sequences, so + # match on stable prose rather than `--json`; that flag is exercised + # end-to-end in `test_json_output_schema_via_clirunner` below. + assert "diagnostic checks" in result.output.lower() + + def test_json_output_schema_via_clirunner(self): + typer_testing = pytest.importorskip("typer.testing") + runner = typer_testing.CliRunner() + result = runner.invoke( + cli_module.app, + ["doctor", "--json", "--cwd", str(REPO_ROOT)], + ) + # exit_code may be 0 or 1 depending on `gh` presence; both are valid here. + assert result.exit_code in (0, 1), result.output + payload = json.loads(result.output) + assert len(payload["checks"]) == 6 + + def test_exits_zero_in_this_repo_except_for_gh(self): + """Core success criterion: everything but `gh cli` must pass in CI.""" + typer_testing = pytest.importorskip("typer.testing") + runner = typer_testing.CliRunner() + result = runner.invoke( + cli_module.app, + ["doctor", "--json", "--cwd", str(REPO_ROOT)], + ) + payload = json.loads(result.output) + fails = [c for c in payload["checks"] if c["status"] == "fail"] + assert not fails, ( + f"hard checks failed in repo CI: {fails} — " + "python / ossmate / mcp server / .ossmate writable must all pass" + ) + # gh may or may not be installed, but everything else must be `ok`. + for c in payload["checks"]: + if c["name"] == "gh cli": + continue + assert c["status"] == "ok", ( + f"expected `{c['name']}` to be ok in this repo, got {c['status']}: {c['detail']}" + ) diff --git a/tests/test_mcp_tools.py b/tests/test_mcp_tools.py index ba56408..6e4d703 100644 --- a/tests/test_mcp_tools.py +++ b/tests/test_mcp_tools.py @@ -12,7 +12,6 @@ import sys from pathlib import Path -import pytest # Helpers under test. from ossmate_mcp.tools import changelog as changelog_mod diff --git a/tests/test_plugin_manifest.py b/tests/test_plugin_manifest.py index 3f42fcc..d22d7c9 100644 --- a/tests/test_plugin_manifest.py +++ b/tests/test_plugin_manifest.py @@ -26,7 +26,6 @@ import re from pathlib import Path -import pytest REPO_ROOT = Path(__file__).resolve().parent.parent PLUGIN_DIR = REPO_ROOT / ".claude-plugin" @@ -170,7 +169,10 @@ def test_uses_plugin_root_not_project_dir(self): def test_referenced_hook_scripts_exist(self): """A typo in a path silently disables the hook — prevent that.""" text = PLUGIN_HOOKS.read_text(encoding="utf-8") - for match in re.finditer(r"\$\{CLAUDE_PLUGIN_ROOT\}([^\"]+)", text): + # Exclude both `"` and `\` from the capture: the JSON-escaped `\"` + # that terminates the command string otherwise leaves a trailing `\` + # on Linux/macOS where pathlib treats it as a literal filename char. + for match in re.finditer(r"\$\{CLAUDE_PLUGIN_ROOT\}([^\"\\]+)", text): rel = match.group(1).lstrip("/") target = REPO_ROOT / rel assert target.exists(), ( diff --git a/tests/test_versioning.py b/tests/test_versioning.py index 5d52d9a..198a665 100644 --- a/tests/test_versioning.py +++ b/tests/test_versioning.py @@ -60,7 +60,6 @@ def test_script_is_importable(self, bump): def test_semver_regex_rejects_garbage(self, bump): """The bump script must refuse non-semver versions to keep PyPI happy.""" - import argparse with pytest.raises(SystemExit): bump.bump("v1.2.3") # leading v