diff --git a/src/invisible_playwright/__init__.py b/src/invisible_playwright/__init__.py index 6bae9f3..5ce6fc6 100644 --- a/src/invisible_playwright/__init__.py +++ b/src/invisible_playwright/__init__.py @@ -18,5 +18,13 @@ Quickstart: from .launcher import InvisiblePlaywright from .constants import BINARY_VERSION, FIREFOX_UPSTREAM_VERSION -__version__ = "0.1.0" +from importlib.metadata import PackageNotFoundError, version as _pkg_version + +try: + __version__ = _pkg_version("invisible-playwright") +except PackageNotFoundError: + # Editable / source checkout without an install record: fall back to a + # marker rather than risk shipping a stale hardcoded string. + __version__ = "0.0.0+unknown" + __all__ = ["InvisiblePlaywright", "BINARY_VERSION", "FIREFOX_UPSTREAM_VERSION", "__version__"] diff --git a/src/invisible_playwright/cli.py b/src/invisible_playwright/cli.py index bb1c687..e6057cf 100644 --- a/src/invisible_playwright/cli.py +++ b/src/invisible_playwright/cli.py @@ -44,7 +44,13 @@ def _cmd_clear_cache(_args: argparse.Namespace) -> int: def build_parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser(prog="invisible-playwright", description="invisible_playwright CLI") - sub = p.add_subparsers(dest="cmd", required=True) + # Top-level `--version` / `-V` flag so `python -m invisible_playwright --version` + # works (Python convention), in addition to the existing `version` subcommand. + p.add_argument( + "-V", "--version", action="version", + version=f"invisible_playwright {__version__} (BINARY_VERSION={BINARY_VERSION}, Firefox {FIREFOX_UPSTREAM_VERSION})", + ) + sub = p.add_subparsers(dest="cmd") sub.add_parser("fetch", help="download the patched Firefox binary") sub.add_parser("path", help="print the absolute path to the cached binary") @@ -54,7 +60,15 @@ def build_parser() -> argparse.ArgumentParser: def main(argv: list[str] | None = None) -> int: - args = build_parser().parse_args(argv) + parser = build_parser() + args = parser.parse_args(argv) + if args.cmd is None: + # argparse-conventional: print usage + error message to stderr, exit 2. + # We can't keep `required=True` on the subparsers because that breaks + # the top-level `--version` flag (argparse demands a subcommand even + # when --version is the only token). parser.error() preserves the + # original "no subcommand" exit semantics tests expect. + parser.error("a subcommand is required (try --help, --version, or one of: fetch, path, version, clear-cache)") dispatch = { "fetch": _cmd_fetch, "path": _cmd_path, diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 0000000..7702f7f --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,103 @@ +"""Regression tests for issue #24: CLI version reporting. + +Two distinct symptoms reported by `i43-j`: + 1. `python -m invisible_playwright --version` errored out (only the + `version` subcommand worked). + 2. `python -m invisible_playwright version` printed the literal string + "0.1.0" regardless of the installed version (a stale hardcoded + `__version__` in __init__.py that nobody had remembered to bump). + +These tests pin down both behaviours so the regressions don't sneak back +in via a future copy/paste. +""" +import io +import re +import subprocess +import sys +from contextlib import redirect_stdout + +import pytest + +import invisible_playwright +from invisible_playwright import __version__, cli + + +pytestmark = pytest.mark.unit + + +def test_version_matches_installed_package_metadata(): + """__version__ must come from importlib.metadata, not a hardcoded literal, + so it can never drift from the pyproject.toml `version` field.""" + from importlib.metadata import version as pkg_version + assert __version__ == pkg_version("invisible-playwright") + + +def test_version_is_not_the_stale_010_string(): + """Issue #24 regression: __version__ used to be hardcoded as '0.1.0' + and never updated. If this ever returns to a literal '0.1.0' the + package has been published or shipped with stale metadata.""" + assert __version__ != "0.1.0", ( + "__version__ is the stale hardcoded '0.1.0' string — issue #24 has " + "regressed. Use importlib.metadata to derive it from pyproject.toml." + ) + + +def test_version_subcommand_prints_real_version(): + """`invisible-playwright version` must print the actual installed version, + not the old hardcoded '0.1.0'.""" + buf = io.StringIO() + with redirect_stdout(buf): + rc = cli.main(["version"]) + assert rc == 0 + out = buf.getvalue() + assert f"invisible_playwright {__version__}" in out + assert "0.1.0" not in out or __version__ == "0.1.0" # safety: only allowed if truly 0.1.0 + assert "BINARY_VERSION=" in out + assert "Firefox " in out + + +def test_dash_dash_version_flag_works(): + """Issue #24 reporter: `python -m invisible_playwright --version` used to + error with 'the following arguments are required: cmd' because there was + no top-level --version flag, only the `version` subcommand. Now the + Python convention works too.""" + # argparse's --version action calls sys.exit(0) directly, so use subprocess. + r = subprocess.run( + [sys.executable, "-m", "invisible_playwright", "--version"], + capture_output=True, text=True, timeout=15, + ) + assert r.returncode == 0, f"--version returned {r.returncode}, stderr={r.stderr!r}" + # argparse may emit on stdout or stderr depending on version + combined = r.stdout + r.stderr + assert "invisible_playwright" in combined + assert __version__ in combined + + +def test_no_args_prints_help_not_traceback(): + """`python -m invisible_playwright` with no args should be graceful + (print help, exit non-zero) rather than crashing with a traceback.""" + r = subprocess.run( + [sys.executable, "-m", "invisible_playwright"], + capture_output=True, text=True, timeout=15, + ) + # Either prints help (rc=2) or shows usage. Must NOT contain a traceback. + assert "Traceback" not in (r.stdout + r.stderr) + assert "usage:" in (r.stdout + r.stderr).lower() + + +def test_dash_V_short_flag_works(): + """Alias `-V` for `--version` (Python convention).""" + r = subprocess.run( + [sys.executable, "-m", "invisible_playwright", "-V"], + capture_output=True, text=True, timeout=15, + ) + assert r.returncode == 0 + assert __version__ in (r.stdout + r.stderr) + + +def test_version_matches_semver_shape(): + """Sanity: version should look like a semver (digits.digits.digits) + or a PEP-440 dev marker, not a placeholder string.""" + assert re.match(r"^\d+\.\d+\.\d+", __version__), ( + f"__version__ {__version__!r} doesn't look like a real version" + )