fix: __version__ comes from package metadata; add --version flag (#24)

Two bugs reported in #24:

1. `python -m invisible_playwright version` printed the literal "0.1.0"
   regardless of the installed version. Root cause: __version__ in
   __init__.py was hardcoded and never bumped when the package version
   moved past 0.1.0. Fix: read from importlib.metadata so __version__
   stays in lockstep with pyproject.toml's `version` field by construction.

2. `python -m invisible_playwright --version` errored with "the
   following arguments are required: cmd". Root cause: the parser had
   `required=True` on its subparsers and no top-level --version flag.
   Fix: add a top-level `--version`/`-V` flag using argparse's standard
   version action, drop `required=True`, and reroute the "no subcommand"
   case through parser.error() so the existing test_no_subcommand_errors
   contract is preserved.

7 new unit tests pin both behaviours so they can't regress silently.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
feder-cr 2026-05-27 00:18:03 -07:00
parent f208f5262c
commit 66c6b09821
3 changed files with 128 additions and 3 deletions

View file

@ -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__"]

View file

@ -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,

103
tests/test_version.py Normal file
View file

@ -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"
)