From 6f88324b3e0153f0f4148b7e270838edc89a78a3 Mon Sep 17 00:00:00 2001 From: "John T. Wodder II" Date: Wed, 19 Nov 2025 14:08:13 -0500 Subject: [PATCH 1/5] Drop `click` dependency --- CHANGELOG.md | 1 + docs/changelog.rst | 1 + pyproject.toml | 4 - src/pyversion_info/__main__.py | 427 +++++++++++++++++++-------------- test/test_cmd_cpython.py | 161 +++++++------ test/test_cmd_pypy.py | 260 +++++++++++--------- 6 files changed, 475 insertions(+), 379 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e40119..645965d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ v1.3.0 (in development) ----------------------- - Support Python 3.14 - Drop support for Python 3.8 and 3.9 +- Drop `click` dependency v1.2.4 (2025-09-19) ------------------- diff --git a/docs/changelog.rst b/docs/changelog.rst index 70aae20..7f808e3 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -7,6 +7,7 @@ v1.3.0 (in development) ----------------------- - Support Python 3.14 - Drop support for Python 3.8 and 3.9 +- Drop ``click`` dependency v1.2.4 (2025-09-19) diff --git a/pyproject.toml b/pyproject.toml index 706751a..85c18c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,6 @@ classifiers = [ dependencies = [ "cachecontrol[filecache] >= 0.12, < 0.15", - "click >= 8.0, != 8.2.2", "platformdirs >= 2.1, < 5.0", "pydantic ~= 2.0", "requests ~= 2.20", @@ -67,9 +66,6 @@ include = [ "tox.ini", ] -[tool.hatch.envs.default] -python = "3" - [tool.mypy] allow_incomplete_defs = false allow_untyped_defs = false diff --git a/src/pyversion_info/__main__.py b/src/pyversion_info/__main__.py index 0c2b167..b444386 100644 --- a/src/pyversion_info/__main__.py +++ b/src/pyversion_info/__main__.py @@ -1,10 +1,11 @@ from __future__ import annotations -from collections.abc import Callable +import argparse +from dataclasses import dataclass from datetime import date -from functools import partial, wraps +from functools import partial import json -from typing import Any -import click +import sys +from typing import Any, Protocol from . import ( CPythonVersionInfo, PyPyVersionInfo, @@ -16,199 +17,257 @@ from .util import MajorVersion, MicroVersion, MinorVersion -def map_exc_to_click(func: Callable) -> Callable: - @wraps(func) - def wrapped(*args: Any, **kwargs: Any) -> Any: +class Subcommand(Protocol): + def run(self, vd: VersionDatabase) -> int: ... + + +@dataclass +class Command: + database: str | None + subcommand: Subcommand + + @classmethod + def from_args(cls, argv: list[str] | None = None) -> Command: + parser = argparse.ArgumentParser( + description=( + "Show details about Python versions\n" + "\n" + "Visit for more information." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "-d", + "--database", + metavar="FILE|URL", + help="Fetch version information from the given database", + ) + parser.add_argument( + "-V", "--version", action="version", version=f"%(prog)s {__version__}" + ) + + subparsers = parser.add_subparsers(title="subcommands", dest="subcommand") + + listparser = subparsers.add_parser( + "list", help="List known versions at the given version level" + ) + listparser.add_argument( + "-a", + "--all", + dest="mode", + action="store_const", + const="all", + help="List all known versions", + # This goes on the first option with `dest="mode"`: + default="released", + ) + listparser.add_argument( + "--cpython", + dest="py", + action="store_const", + const="cpython", + help="Show information about CPython versions [default]", + default="cpython", + ) + listparser.add_argument( + "-n", + "--not-eol", + dest="mode", + action="store_const", + const="not-eol", + help="List only versions that are not EOL (supported versions + unreleased versions)", + ) + listparser.add_argument( + "--pypy", + dest="py", + action="store_const", + const="pypy", + help="Show information about PyPy versions", + ) + listparser.add_argument( + "-r", + "--released", + dest="mode", + action="store_const", + const="released", + help="List only released versions [default]", + ) + listparser.add_argument( + "-s", + "--supported", + dest="mode", + action="store_const", + const="supported", + help="List only supported versions", + ) + listparser.add_argument("level", choices=["major", "minor", "micro"]) + + showparser = subparsers.add_parser( + "show", help="Show information about a Python version" + ) + showparser.add_argument( + "--cpython", + dest="py", + action="store_const", + const="cpython", + help="Show information about CPython versions [default]", + default="cpython", + ) + showparser.add_argument( + "-J", "--json", dest="do_json", action="store_true", help="Output JSON" + ) + showparser.add_argument( + "--pypy", + dest="py", + action="store_const", + const="pypy", + help="Show information about PyPy versions", + ) + showparser.add_argument( + "-S", + "--subversions", + choices=["all", "not-eol", "released", "supported"], + help="Which subversions to list [default: released]", + default="released", + ) + showparser.add_argument("version") + + args = parser.parse_args(argv) + subcommand: Subcommand + match args.subcommand: + case "list": + subcommand = ListCommand(level=args.level, mode=args.mode, py=args.py) + case "show": + subcommand = ShowCommand( + version=args.version, + subversions=args.subversions, + do_json=args.do_json, + py=args.py, + ) + case cmd: + raise RuntimeError(f"Internal error: unhandled subcommand: {cmd!r}") + return Command(database=args.database, subcommand=subcommand) + + def run(self) -> int: + if self.database is None: + vd = VersionDatabase.fetch() + elif self.database.lower().startswith(("http://", "https://")): + vd = VersionDatabase.fetch(self.database) + else: + vd = VersionDatabase.parse_file(self.database) try: - return func(*args, **kwargs) + return self.subcommand.run(vd) except ValueError as e: - raise click.UsageError(str(e)) + print(f"pyversion-info: {e}", file=sys.stderr) + return 1 - return wrapped +@dataclass +class ListCommand: + level: str + mode: str + py: str -@click.group(context_settings={"help_option_names": ["-h", "--help"]}) -@click.version_option( - __version__, - "-V", - "--version", - message="%(prog)s %(version)s", -) -@click.option( - "-d", - "--database", - metavar="FILE|URL", - help="Fetch version information from the given database", -) -@click.pass_context -def main(ctx: click.Context, database: str | None) -> None: - """Show details about Python versions""" - if database is None: - ctx.obj = VersionDatabase.fetch() - elif database.lower().startswith(("http://", "https://")): - ctx.obj = VersionDatabase.fetch(database) - else: - ctx.obj = VersionDatabase.parse_file(database) + def run(self, vd: VersionDatabase) -> int: + info = vd.pypy if self.py == "pypy" else vd.cpython + func = { + "major": info.major_versions, + "minor": info.minor_versions, + "micro": info.micro_versions, + }[self.level] + for v in filter_versions(self.mode, info, func()): + print(v) + return 0 -@main.command("list") -@click.option("-a", "--all", "mode", flag_value="all", help="List all known versions") -@click.option( - "--cpython", - "py", - flag_value="cpython", - help="Show information about CPython versions [default]", - default=True, -) -@click.option( - "-n", - "--not-eol", - "mode", - flag_value="not-eol", - help="List only versions that are not EOL (supported versions + unreleased versions)", -) -@click.option( - "--pypy", - "py", - flag_value="pypy", - help="Show information about PyPy versions", -) -@click.option( - "-r", - "--released", - "mode", - flag_value="released", - help="List only released versions [default]", -) -@click.option( - "-s", - "--supported", - "mode", - flag_value="supported", - help="List only supported versions", -) -@click.argument("level", type=click.Choice(["major", "minor", "micro"])) -@click.pass_obj -@map_exc_to_click -def list_cmd(vd: VersionDatabase, level: str, mode: str | None, py: str) -> None: - """List known versions at the given version level""" - if mode is None: - mode = "released" - info = vd.pypy if py == "pypy" else vd.cpython - func = { - "major": info.major_versions, - "minor": info.minor_versions, - "micro": info.micro_versions, - }[level] - for v in filter_versions(mode, info, func()): - print(v) - - -@main.command() -@click.option( - "--cpython", - "py", - flag_value="cpython", - help="Show information about CPython versions [default]", - default=True, -) -@click.option("-J", "--json", "do_json", is_flag=True, help="Output JSON") -@click.option( - "--pypy", - "py", - flag_value="pypy", - help="Show information about PyPy versions", -) -@click.option( - "-S", - "--subversions", - type=click.Choice(["all", "not-eol", "released", "supported"]), - help="Which subversions to list", - default="released", - show_default=True, -) -@click.argument("version") -@click.pass_obj -@map_exc_to_click -def show( - vd: VersionDatabase, version: str, subversions: str, do_json: bool, py: str -) -> None: - """Show information about a Python version""" - info = vd.pypy if py == "pypy" else vd.cpython - v = parse_version(version) - data: list[tuple[str, str, Any]] = [ - ("version", "Version", str(v)), - # ("level", "Level", ---), - ("release_date", "Release-Date", info.release_date(v)), - ("is_released", "Is-Released", info.is_released(v)), - ] - if isinstance(info, CPythonVersionInfo): - data.append(("is_supported", "Is-Supported", info.is_supported(v))) - if isinstance(v, MajorVersion): - data.insert(1, ("level", "Level", "major")) +@dataclass +class ShowCommand: + version: str + subversions: str + do_json: bool + py: str + + def run(self, vd: VersionDatabase) -> int: + info = vd.pypy if self.py == "pypy" else vd.cpython + v = parse_version(self.version) + data: list[tuple[str, str, Any]] = [ + ("version", "Version", str(v)), + # ("level", "Level", ---), + ("release_date", "Release-Date", info.release_date(v)), + ("is_released", "Is-Released", info.is_released(v)), + ] if isinstance(info, CPythonVersionInfo): - data.append(("eol_date", "EOL-Date", info.eol_date(v))) - data.append(("is_eol", "Is-EOL", info.is_eol(v))) - data.append( - ( - "subversions", - "Subversions", - filter_versions(subversions, info, info.subversions(v)), - ) - ) - if isinstance(info, PyPyVersionInfo): + data.append(("is_supported", "Is-Supported", info.is_supported(v))) + if isinstance(v, MajorVersion): + data.insert(1, ("level", "Level", "major")) + if isinstance(info, CPythonVersionInfo): + data.append(("eol_date", "EOL-Date", info.eol_date(v))) + data.append(("is_eol", "Is-EOL", info.is_eol(v))) data.append( ( - "cpython_series", - "CPython-Series", - info.supported_cpython_series( - v, released=subversions == "released" - ), + "subversions", + "Subversions", + filter_versions(self.subversions, info, info.subversions(v)), ) ) - elif isinstance(v, MinorVersion): - data.insert(1, ("level", "Level", "minor")) - if isinstance(info, CPythonVersionInfo): - data.append(("eol_date", "EOL-Date", info.eol_date(v))) - data.append(("is_eol", "Is-EOL", info.is_eol(v))) - data.append( - ( - "subversions", - "Subversions", - filter_versions(subversions, info, info.subversions(v)), - ) - ) - if isinstance(info, PyPyVersionInfo): + if isinstance(info, PyPyVersionInfo): + data.append( + ( + "cpython_series", + "CPython-Series", + info.supported_cpython_series( + v, released=self.subversions == "released" + ), + ) + ) + elif isinstance(v, MinorVersion): + data.insert(1, ("level", "Level", "minor")) + if isinstance(info, CPythonVersionInfo): + data.append(("eol_date", "EOL-Date", info.eol_date(v))) + data.append(("is_eol", "Is-EOL", info.is_eol(v))) data.append( ( - "cpython_series", - "CPython-Series", - info.supported_cpython_series( - v, released=subversions == "released" - ), + "subversions", + "Subversions", + filter_versions(self.subversions, info, info.subversions(v)), ) ) - else: - assert isinstance(v, MicroVersion) - data.insert(1, ("level", "Level", "micro")) - if isinstance(info, CPythonVersionInfo): - data.append(("eol_date", "EOL-Date", info.eol_date(v))) - data.append(("is_eol", "Is-EOL", info.is_eol(v))) - if isinstance(info, PyPyVersionInfo): - data.append(("cpython", "CPython", info.supported_cpython(v))) - if do_json: - print(json.dumps({k: v for k, _, v in data}, indent=4, default=str)) - else: - for _, label, val in data: - if isinstance(val, date): - val = str(val) - elif isinstance(val, bool): - val = "yes" if val else "no" - elif isinstance(val, list): - val = ", ".join(val) - elif val is None: - val = "UNKNOWN" - print(f"{label}: {val}") + if isinstance(info, PyPyVersionInfo): + data.append( + ( + "cpython_series", + "CPython-Series", + info.supported_cpython_series( + v, released=self.subversions == "released" + ), + ) + ) + else: + assert isinstance(v, MicroVersion) + data.insert(1, ("level", "Level", "micro")) + if isinstance(info, CPythonVersionInfo): + data.append(("eol_date", "EOL-Date", info.eol_date(v))) + data.append(("is_eol", "Is-EOL", info.is_eol(v))) + if isinstance(info, PyPyVersionInfo): + data.append(("cpython", "CPython", info.supported_cpython(v))) + if self.do_json: + print(json.dumps({k: v for k, _, v in data}, indent=4, default=str)) + else: + for _, label, val in data: + if isinstance(val, date): + val = str(val) + elif isinstance(val, bool): + val = "yes" if val else "no" + elif isinstance(val, list): + val = ", ".join(val) + elif val is None: + val = "UNKNOWN" + print(f"{label}: {val}") + return 0 + + +def main(argv: list[str] | None = None) -> int: + return Command.from_args(argv).run() def is_not_eol(pyvinfo: CPythonVersionInfo, version: str) -> bool: @@ -226,11 +285,11 @@ def filter_versions(mode: str, info: VersionInfo, versions: list[str]) -> list[s filterer = info.is_released elif mode == "supported": if not isinstance(info, CPythonVersionInfo): - raise click.UsageError("'supported' only applies to CPython versions") + raise ValueError("'supported' only applies to CPython versions") filterer = info.is_supported elif mode == "not-eol": if not isinstance(info, CPythonVersionInfo): - raise click.UsageError("'not-eol' only applies to CPython versions") + raise ValueError("'not-eol' only applies to CPython versions") filterer = partial(is_not_eol, info) else: raise AssertionError(f"Unexpected mode: {mode!r}") # pragma: no cover @@ -238,4 +297,4 @@ def filter_versions(mode: str, info: VersionInfo, versions: list[str]) -> list[s if __name__ == "__main__": - main() # pragma: no cover + sys.exit(main()) # pragma: no cover diff --git a/test/test_cmd_cpython.py b/test/test_cmd_cpython.py index bed4f08..0207ccf 100644 --- a/test/test_cmd_cpython.py +++ b/test/test_cmd_cpython.py @@ -1,9 +1,6 @@ from __future__ import annotations import json from pathlib import Path -from traceback import format_exception -import click -from click.testing import CliRunner, Result import pytest from pytest_mock import MockerFixture from pyversion_info.__main__ import main @@ -22,14 +19,6 @@ def use_fixed_date(mocker: MockerFixture) -> None: # Time is now 2019-04-23T16:46:48-04:00. -def show_result(r: Result) -> str: - if r.exception is not None: - assert isinstance(r.exc_info, tuple) - return "".join(format_exception(*r.exc_info)) - else: - return r.output - - @pytest.mark.parametrize( "mode,versions", [ @@ -39,14 +28,18 @@ def show_result(r: Result) -> str: ("--supported", ["2", "3"]), ], ) -def test_cmd_list_major(mode: str, versions: list[str]) -> None: - r = CliRunner().invoke(main, ["-d", DATA_FILE, "list", mode, "major"]) - assert r.exit_code == 0, show_result(r) - assert r.output == "".join(v + "\n" for v in versions) +def test_cmd_list_major( + capsys: pytest.CaptureFixture[str], mode: str, versions: list[str] +) -> None: + assert main(["-d", DATA_FILE, "list", mode, "major"]) == 0 + out, err = capsys.readouterr() + assert out == "".join(v + "\n" for v in versions) + assert err == "" if mode == "--released": - r = CliRunner().invoke(main, ["-d", DATA_FILE, "list", "major"]) - assert r.exit_code == 0, show_result(r) - assert r.output == "".join(v + "\n" for v in versions) + assert main(["-d", DATA_FILE, "list", "major"]) == 0 + out, err = capsys.readouterr() + assert out == "".join(v + "\n" for v in versions) + assert err == "" @pytest.mark.parametrize( @@ -116,14 +109,17 @@ def test_cmd_list_major(mode: str, versions: list[str]) -> None: ("--supported", ["2.7", "3.5", "3.6", "3.7"]), ], ) -def test_cmd_list_minor(mode: str, versions: list[str]) -> None: - r = CliRunner().invoke(main, ["-d", DATA_FILE, "list", mode, "minor"]) - assert r.exit_code == 0, show_result(r) - assert r.output == "".join(v + "\n" for v in versions) +def test_cmd_list_minor( + capsys: pytest.CaptureFixture[str], mode: str, versions: list[str] +) -> None: + assert main(["-d", DATA_FILE, "list", mode, "minor"]) == 0 + out, err = capsys.readouterr() + assert out == "".join(v + "\n" for v in versions) + assert err == "" if mode == "--released": - r = CliRunner().invoke(main, ["-d", DATA_FILE, "list", "minor"]) - assert r.exit_code == 0, show_result(r) - assert r.output == "".join(v + "\n" for v in versions) + assert main(["-d", DATA_FILE, "list", "minor"]) == 0 + assert out == "".join(v + "\n" for v in versions) + assert err == "" @pytest.mark.parametrize( @@ -511,14 +507,18 @@ def test_cmd_list_minor(mode: str, versions: list[str]) -> None: ), ], ) -def test_cmd_list_micro(mode: str, versions: list[str]) -> None: - r = CliRunner().invoke(main, ["-d", DATA_FILE, "list", mode, "micro"]) - assert r.exit_code == 0, show_result(r) - assert r.output == "".join(v + "\n" for v in versions) +def test_cmd_list_micro( + capsys: pytest.CaptureFixture[str], mode: str, versions: list[str] +) -> None: + assert main(["-d", DATA_FILE, "list", mode, "micro"]) == 0 + out, err = capsys.readouterr() + assert out == "".join(v + "\n" for v in versions) + assert err == "" if mode == "--released": - r = CliRunner().invoke(main, ["-d", DATA_FILE, "list", "micro"]) - assert r.exit_code == 0, show_result(r) - assert r.output == "".join(v + "\n" for v in versions) + assert main(["-d", DATA_FILE, "list", "micro"]) == 0 + out, err = capsys.readouterr() + assert out == "".join(v + "\n" for v in versions) + assert err == "" @pytest.mark.parametrize( @@ -1116,21 +1116,29 @@ def test_cmd_list_micro(mode: str, versions: list[str]) -> None: ), ], ) -def test_show(version: str, subversions: str, data: dict, headers: str) -> None: - r = CliRunner().invoke( - main, ["-d", DATA_FILE, "show", "--subversions", subversions, version] - ) - assert r.exit_code == 0, show_result(r) - assert r.output == headers +def test_show( + capsys: pytest.CaptureFixture[str], + version: str, + subversions: str, + data: dict, + headers: str, +) -> None: + assert main(["-d", DATA_FILE, "show", "--subversions", subversions, version]) == 0 + out, err = capsys.readouterr() + assert out == headers + assert err == "" if subversions == "released": - r = CliRunner().invoke(main, ["-d", DATA_FILE, "show", version]) - assert r.exit_code == 0, show_result(r) - assert r.output == headers - r = CliRunner().invoke( - main, ["-d", DATA_FILE, "show", "--json", "--subversions", subversions, version] + assert main(["-d", DATA_FILE, "show", version]) == 0 + out, err = capsys.readouterr() + assert out == headers + assert err == "" + assert ( + main(["-d", DATA_FILE, "show", "--json", "--subversions", subversions, version]) + == 0 ) - assert r.exit_code == 0, show_result(r) - assert json.loads(r.output) == data + out, err = capsys.readouterr() + assert json.loads(out) == data + assert err == "" @pytest.mark.parametrize( @@ -1223,24 +1231,31 @@ def test_show(version: str, subversions: str, data: dict, headers: str) -> None: ], ) @pytest.mark.parametrize("subversions", ["released", "all", "supported", "not-eol"]) -def test_show_micro(version: str, subversions: str, data: dict, headers: str) -> None: - r = CliRunner().invoke( - main, ["-d", DATA_FILE, "show", "--subversions", subversions, version] - ) - assert r.exit_code == 0, show_result(r) - assert r.output == headers - r = CliRunner().invoke( - main, ["-d", DATA_FILE, "show", "--json", "--subversions", subversions, version] +def test_show_micro( + capsys: pytest.CaptureFixture[str], + version: str, + subversions: str, + data: dict, + headers: str, +) -> None: + assert main(["-d", DATA_FILE, "show", "--subversions", subversions, version]) == 0 + out, err = capsys.readouterr() + assert out == headers + assert err == "" + assert ( + main(["-d", DATA_FILE, "show", "--json", "--subversions", subversions, version]) + == 0 ) - assert r.exit_code == 0, show_result(r) - assert json.loads(r.output) == data + out, err = capsys.readouterr() + assert json.loads(out) == data + assert err == "" -def test_show_recent(mocker: MockerFixture) -> None: +def test_show_recent(capsys: pytest.CaptureFixture[str], mocker: MockerFixture) -> None: mocker.patch("time.time", return_value=1635992101) - r = CliRunner().invoke(main, ["-d", DATA_FILE, "show", "2"]) - assert r.exit_code == 0, show_result(r) - assert r.output == ( + assert main(["-d", DATA_FILE, "show", "2"]) == 0 + out, err = capsys.readouterr() + assert out == ( "Version: 2\n" "Level: major\n" "Release-Date: 2000-10-16\n" @@ -1250,9 +1265,10 @@ def test_show_recent(mocker: MockerFixture) -> None: "Is-EOL: yes\n" "Subversions: 2.0, 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.7\n" ) - r = CliRunner().invoke(main, ["-d", DATA_FILE, "show", "--json", "2"]) - assert r.exit_code == 0, show_result(r) - assert json.loads(r.output) == { + assert err == "" + assert main(["-d", DATA_FILE, "show", "--json", "2"]) == 0 + out, err = capsys.readouterr() + assert json.loads(out) == { "version": "2", "level": "major", "release_date": "2000-10-16", @@ -1262,19 +1278,20 @@ def test_show_recent(mocker: MockerFixture) -> None: "is_eol": True, "subversions": ["2.0", "2.1", "2.2", "2.3", "2.4", "2.5", "2.6", "2.7"], } + assert err == "" @pytest.mark.parametrize("v", ["", "1.2.3.4", "1.2.3rc1", "foobar", "a.b.c"]) -def test_show_invalid_version(v: str) -> None: - r = CliRunner().invoke(main, ["-d", DATA_FILE, "show", v], standalone_mode=False) - assert r.exit_code != 0 - assert isinstance(r.exception, click.UsageError) - assert str(r.exception) == f"Invalid version string: {v!r}" +def test_show_invalid_version(capsys: pytest.CaptureFixture[str], v: str) -> None: + assert main(["-d", DATA_FILE, "show", v]) == 1 + out, err = capsys.readouterr() + assert out == "" + assert err == f"pyversion-info: Invalid version string: {v!r}\n" @pytest.mark.parametrize("v", ["0.8", "2.5.7", "3.9", "3.9.0", "5"]) -def test_show_unknown_version(v: str) -> None: - r = CliRunner().invoke(main, ["-d", DATA_FILE, "show", v], standalone_mode=False) - assert r.exit_code != 0 - assert isinstance(r.exception, click.UsageError) - assert str(r.exception) == f"Unknown version: {v!r}" +def test_show_unknown_version(capsys: pytest.CaptureFixture[str], v: str) -> None: + assert main(["-d", DATA_FILE, "show", v]) == 1 + out, err = capsys.readouterr() + assert out == "" + assert err == f"pyversion-info: Unknown version: {v!r}\n" diff --git a/test/test_cmd_pypy.py b/test/test_cmd_pypy.py index 63bd620..92340d4 100644 --- a/test/test_cmd_pypy.py +++ b/test/test_cmd_pypy.py @@ -1,9 +1,6 @@ from __future__ import annotations import json from pathlib import Path -from traceback import format_exception -import click -from click.testing import CliRunner, Result import pytest from pytest_mock import MockerFixture from pyversion_info.__main__ import main @@ -17,14 +14,6 @@ def use_fixed_date(mocker: MockerFixture) -> None: # Time is now 2021-11-04T02:15:01+00:00. -def show_result(r: Result) -> str: - if r.exception is not None: - assert isinstance(r.exc_info, tuple) - return "".join(format_exception(*r.exc_info)) - else: - return r.output - - @pytest.mark.parametrize( "mode,versions", [ @@ -32,14 +21,18 @@ def show_result(r: Result) -> str: ("--released", ["1", "2", "4", "5", "6", "7"]), ], ) -def test_cmd_list_major(mode: str, versions: list[str]) -> None: - r = CliRunner().invoke(main, ["-d", DATA_FILE, "list", "--pypy", mode, "major"]) - assert r.exit_code == 0, show_result(r) - assert r.output == "".join(v + "\n" for v in versions) +def test_cmd_list_major( + capsys: pytest.CaptureFixture[str], mode: str, versions: list[str] +) -> None: + assert main(["-d", DATA_FILE, "list", "--pypy", mode, "major"]) == 0 + out, err = capsys.readouterr() + assert out == "".join(v + "\n" for v in versions) + assert err == "" if mode == "--released": - r = CliRunner().invoke(main, ["-d", DATA_FILE, "list", "--pypy", "major"]) - assert r.exit_code == 0, show_result(r) - assert r.output == "".join(v + "\n" for v in versions) + assert main(["-d", DATA_FILE, "list", "--pypy", "major"]) == 0 + out, err = capsys.readouterr() + assert out == "".join(v + "\n" for v in versions) + assert err == "" RELEASED_MINOR = [ @@ -79,14 +72,18 @@ def test_cmd_list_major(mode: str, versions: list[str]) -> None: ("--released", RELEASED_MINOR), ], ) -def test_cmd_list_minor(mode: str, versions: list[str]) -> None: - r = CliRunner().invoke(main, ["-d", DATA_FILE, "list", "--pypy", mode, "minor"]) - assert r.exit_code == 0, show_result(r) - assert r.output == "".join(v + "\n" for v in versions) +def test_cmd_list_minor( + capsys: pytest.CaptureFixture[str], mode: str, versions: list[str] +) -> None: + assert main(["-d", DATA_FILE, "list", "--pypy", mode, "minor"]) == 0 + out, err = capsys.readouterr() + assert out == "".join(v + "\n" for v in versions) + assert err == "" if mode == "--released": - r = CliRunner().invoke(main, ["-d", DATA_FILE, "list", "--pypy", "minor"]) - assert r.exit_code == 0, show_result(r) - assert r.output == "".join(v + "\n" for v in versions) + assert main(["-d", DATA_FILE, "list", "--pypy", "minor"]) == 0 + out, err = capsys.readouterr() + assert out == "".join(v + "\n" for v in versions) + assert err == "" RELEASED_MICRO = [ @@ -148,38 +145,44 @@ def test_cmd_list_minor(mode: str, versions: list[str]) -> None: ("--released", RELEASED_MICRO), ], ) -def test_cmd_list_micro(mode: str, versions: list[str]) -> None: - r = CliRunner().invoke(main, ["-d", DATA_FILE, "list", "--pypy", mode, "micro"]) - assert r.exit_code == 0, show_result(r) - assert r.output == "".join(v + "\n" for v in versions) +def test_cmd_list_micro( + capsys: pytest.CaptureFixture[str], mode: str, versions: list[str] +) -> None: + assert main(["-d", DATA_FILE, "list", "--pypy", mode, "micro"]) == 0 + out, err = capsys.readouterr() + assert out == "".join(v + "\n" for v in versions) + assert err == "" if mode == "--released": - r = CliRunner().invoke(main, ["-d", DATA_FILE, "list", "--pypy", "micro"]) - assert r.exit_code == 0, show_result(r) - assert r.output == "".join(v + "\n" for v in versions) + assert main(["-d", DATA_FILE, "list", "--pypy", "micro"]) == 0 + out, err = capsys.readouterr() + assert out == "".join(v + "\n" for v in versions) + assert err == "" @pytest.mark.parametrize("level", ["major", "minor", "micro"]) -def test_cmd_list_not_eol(level: str) -> None: - r = CliRunner().invoke( - main, - ["-d", DATA_FILE, "list", "--pypy", "--not-eol", level], - standalone_mode=False, +def test_cmd_list_not_eol(capsys: pytest.CaptureFixture[str], level: str) -> None: + assert ( + main( + ["-d", DATA_FILE, "list", "--pypy", "--not-eol", level], + ) + == 1 ) - assert r.exit_code != 0 - assert isinstance(r.exception, click.UsageError) - assert str(r.exception) == "'not-eol' only applies to CPython versions" + out, err = capsys.readouterr() + assert out == "" + assert err == "pyversion-info: 'not-eol' only applies to CPython versions\n" @pytest.mark.parametrize("level", ["major", "minor", "micro"]) -def test_cmd_list_supported(level: str) -> None: - r = CliRunner().invoke( - main, - ["-d", DATA_FILE, "list", "--pypy", "--supported", level], - standalone_mode=False, +def test_cmd_list_supported(capsys: pytest.CaptureFixture[str], level: str) -> None: + assert ( + main( + ["-d", DATA_FILE, "list", "--pypy", "--supported", level], + ) + == 1 ) - assert r.exit_code != 0 - assert isinstance(r.exception, click.UsageError) - assert str(r.exception) == "'supported' only applies to CPython versions" + out, err = capsys.readouterr() + assert out == "" + assert err == "pyversion-info: 'supported' only applies to CPython versions\n" @pytest.mark.parametrize( @@ -279,27 +282,38 @@ def test_cmd_list_supported(level: str) -> None: ], ) @pytest.mark.parametrize("subversions", ["released", "all"]) -def test_show(version: str, subversions: str, data: dict, headers: str) -> None: - r = CliRunner().invoke( - main, ["-d", DATA_FILE, "show", "--pypy", "--subversions", subversions, version] +def test_show( + capsys: pytest.CaptureFixture[str], + version: str, + subversions: str, + data: dict, + headers: str, +) -> None: + assert ( + main(["-d", DATA_FILE, "show", "--pypy", "--subversions", subversions, version]) + == 0 ) - assert r.exit_code == 0, show_result(r) - assert r.output == headers - r = CliRunner().invoke( - main, - [ - "-d", - DATA_FILE, - "show", - "--pypy", - "--json", - "--subversions", - subversions, - version, - ], + out, err = capsys.readouterr() + assert out == headers + assert err == "" + assert ( + main( + [ + "-d", + DATA_FILE, + "show", + "--pypy", + "--json", + "--subversions", + subversions, + version, + ] + ) + == 0 ) - assert r.exit_code == 0, show_result(r) - assert json.loads(r.output) == data + out, err = capsys.readouterr() + assert json.loads(out) == data + assert err == "" @pytest.mark.parametrize( @@ -388,73 +402,81 @@ def test_show(version: str, subversions: str, data: dict, headers: str) -> None: ], ) def test_show_not_all_released( - version: str, subversions: str, data: dict, headers: str + capsys: pytest.CaptureFixture[str], + version: str, + subversions: str, + data: dict, + headers: str, ) -> None: - r = CliRunner().invoke( - main, ["-d", DATA_FILE, "show", "--pypy", "--subversions", subversions, version] + assert ( + main(["-d", DATA_FILE, "show", "--pypy", "--subversions", subversions, version]) + == 0 ) - assert r.exit_code == 0, show_result(r) - assert r.output == headers + out, err = capsys.readouterr() + assert out == headers + assert err == "" if subversions == "released": - r = CliRunner().invoke(main, ["-d", DATA_FILE, "show", "--pypy", version]) - assert r.exit_code == 0, show_result(r) - assert r.output == headers - r = CliRunner().invoke( - main, - [ - "-d", - DATA_FILE, - "show", - "--pypy", - "--json", - "--subversions", - subversions, - version, - ], + assert main(["-d", DATA_FILE, "show", "--pypy", version]) == 0 + out, err = capsys.readouterr() + assert out == headers + assert err == "" + assert ( + main( + [ + "-d", + DATA_FILE, + "show", + "--pypy", + "--json", + "--subversions", + subversions, + version, + ] + ) + == 0 ) - assert r.exit_code == 0, show_result(r) - assert json.loads(r.output) == data + out, err = capsys.readouterr() + assert json.loads(out) == data + assert err == "" @pytest.mark.parametrize("v", ["7", "7.3"]) -def test_cmd_show_not_eol(v: str) -> None: - r = CliRunner().invoke( - main, - ["-d", DATA_FILE, "show", "--pypy", "--subversions", "not-eol", v], - standalone_mode=False, +def test_cmd_show_not_eol(capsys: pytest.CaptureFixture[str], v: str) -> None: + assert ( + main( + ["-d", DATA_FILE, "show", "--pypy", "--subversions", "not-eol", v], + ) + == 1 ) - assert r.exit_code != 0 - assert isinstance(r.exception, click.UsageError) - assert str(r.exception) == "'not-eol' only applies to CPython versions" + out, err = capsys.readouterr() + assert out == "" + assert err == "pyversion-info: 'not-eol' only applies to CPython versions\n" @pytest.mark.parametrize("v", ["7", "7.3"]) -def test_cmd_show_supported(v: str) -> None: - r = CliRunner().invoke( - main, - ["-d", DATA_FILE, "show", "--pypy", "--subversions", "supported", v], - standalone_mode=False, +def test_cmd_show_supported(capsys: pytest.CaptureFixture[str], v: str) -> None: + assert ( + main( + ["-d", DATA_FILE, "show", "--pypy", "--subversions", "supported", v], + ) + == 1 ) - assert r.exit_code != 0 - assert isinstance(r.exception, click.UsageError) - assert str(r.exception) == "'supported' only applies to CPython versions" + out, err = capsys.readouterr() + assert out == "" + assert err == "pyversion-info: 'supported' only applies to CPython versions\n" @pytest.mark.parametrize("v", ["", "1.2.3.4", "1.2.3rc1", "foobar", "a.b.c"]) -def test_show_invalid_version(v: str) -> None: - r = CliRunner().invoke( - main, ["-d", DATA_FILE, "show", "--pypy", v], standalone_mode=False - ) - assert r.exit_code != 0 - assert isinstance(r.exception, click.UsageError) - assert str(r.exception) == f"Invalid version string: {v!r}" +def test_show_invalid_version(capsys: pytest.CaptureFixture[str], v: str) -> None: + assert main(["-d", DATA_FILE, "show", "--pypy", v]) == 1 + out, err = capsys.readouterr() + assert out == "" + assert err == f"pyversion-info: Invalid version string: {v!r}\n" @pytest.mark.parametrize("v", ["0.8", "1.5", "3", "7.3.9", "9.0.0"]) -def test_show_unknown_version(v: str) -> None: - r = CliRunner().invoke( - main, ["-d", DATA_FILE, "show", "--pypy", v], standalone_mode=False - ) - assert r.exit_code != 0 - assert isinstance(r.exception, click.UsageError) - assert str(r.exception) == f"Unknown version: {v!r}" +def test_show_unknown_version(capsys: pytest.CaptureFixture[str], v: str) -> None: + assert main(["-d", DATA_FILE, "show", "--pypy", v]) == 1 + out, err = capsys.readouterr() + assert out == "" + assert err == f"pyversion-info: Unknown version: {v!r}\n" From 488bdd381232496aa92bfb56a0e97cb62cbeb49a Mon Sep 17 00:00:00 2001 From: "John T. Wodder II" Date: Wed, 19 Nov 2025 14:24:13 -0500 Subject: [PATCH 2/5] Cut down on some magic strings --- src/pyversion_info/__main__.py | 83 +++++++++++++++++++--------------- 1 file changed, 46 insertions(+), 37 deletions(-) diff --git a/src/pyversion_info/__main__.py b/src/pyversion_info/__main__.py index b444386..e33e375 100644 --- a/src/pyversion_info/__main__.py +++ b/src/pyversion_info/__main__.py @@ -2,6 +2,7 @@ import argparse from dataclasses import dataclass from datetime import date +from enum import Enum from functools import partial import json import sys @@ -56,10 +57,10 @@ def from_args(cls, argv: list[str] | None = None) -> Command: "--all", dest="mode", action="store_const", - const="all", + const=Mode.ALL, help="List all known versions", # This goes on the first option with `dest="mode"`: - default="released", + default=Mode.RELEASED, ) listparser.add_argument( "--cpython", @@ -74,7 +75,7 @@ def from_args(cls, argv: list[str] | None = None) -> Command: "--not-eol", dest="mode", action="store_const", - const="not-eol", + const=Mode.NOT_EOL, help="List only versions that are not EOL (supported versions + unreleased versions)", ) listparser.add_argument( @@ -89,7 +90,7 @@ def from_args(cls, argv: list[str] | None = None) -> Command: "--released", dest="mode", action="store_const", - const="released", + const=Mode.RELEASED, help="List only released versions [default]", ) listparser.add_argument( @@ -97,7 +98,7 @@ def from_args(cls, argv: list[str] | None = None) -> Command: "--supported", dest="mode", action="store_const", - const="supported", + const=Mode.SUPPORTED, help="List only supported versions", ) listparser.add_argument("level", choices=["major", "minor", "micro"]) @@ -113,9 +114,7 @@ def from_args(cls, argv: list[str] | None = None) -> Command: help="Show information about CPython versions [default]", default="cpython", ) - showparser.add_argument( - "-J", "--json", dest="do_json", action="store_true", help="Output JSON" - ) + showparser.add_argument("-J", "--json", action="store_true", help="Output JSON") showparser.add_argument( "--pypy", dest="py", @@ -126,6 +125,7 @@ def from_args(cls, argv: list[str] | None = None) -> Command: showparser.add_argument( "-S", "--subversions", + # Don't convert with `type` here, as that takes effect before `choices` choices=["all", "not-eol", "released", "supported"], help="Which subversions to list [default: released]", default="released", @@ -140,8 +140,8 @@ def from_args(cls, argv: list[str] | None = None) -> Command: case "show": subcommand = ShowCommand( version=args.version, - subversions=args.subversions, - do_json=args.do_json, + subversions=Mode(args.subversions), + json=args.json, py=args.py, ) case cmd: @@ -162,10 +162,35 @@ def run(self) -> int: return 1 +class Mode(Enum): + ALL = "all" + NOT_EOL = "not-eol" + RELEASED = "released" + SUPPORTED = "supported" + + def filter_versions(self, info: VersionInfo, versions: list[str]) -> list[str]: + match self: + case Mode.ALL: + filterer = yes + case Mode.RELEASED: + filterer = info.is_released + case Mode.SUPPORTED: + if not isinstance(info, CPythonVersionInfo): + raise ValueError("'supported' only applies to CPython versions") + filterer = info.is_supported + case Mode.NOT_EOL: + if not isinstance(info, CPythonVersionInfo): + raise ValueError("'not-eol' only applies to CPython versions") + filterer = partial(is_not_eol, info) + case mode: + raise AssertionError(f"Unexpected mode: {mode!r}") # pragma: no cover + return list(filter(filterer, versions)) + + @dataclass class ListCommand: level: str - mode: str + mode: Mode py: str def run(self, vd: VersionDatabase) -> int: @@ -175,7 +200,7 @@ def run(self, vd: VersionDatabase) -> int: "minor": info.minor_versions, "micro": info.micro_versions, }[self.level] - for v in filter_versions(self.mode, info, func()): + for v in self.mode.filter_versions(info, func()): print(v) return 0 @@ -183,8 +208,8 @@ def run(self, vd: VersionDatabase) -> int: @dataclass class ShowCommand: version: str - subversions: str - do_json: bool + subversions: Mode + json: bool py: str def run(self, vd: VersionDatabase) -> int: @@ -207,7 +232,7 @@ def run(self, vd: VersionDatabase) -> int: ( "subversions", "Subversions", - filter_versions(self.subversions, info, info.subversions(v)), + self.subversions.filter_versions(info, info.subversions(v)), ) ) if isinstance(info, PyPyVersionInfo): @@ -216,7 +241,8 @@ def run(self, vd: VersionDatabase) -> int: "cpython_series", "CPython-Series", info.supported_cpython_series( - v, released=self.subversions == "released" + v, + released=self.subversions is Mode.RELEASED, ), ) ) @@ -229,7 +255,7 @@ def run(self, vd: VersionDatabase) -> int: ( "subversions", "Subversions", - filter_versions(self.subversions, info, info.subversions(v)), + self.subversions.filter_versions(info, info.subversions(v)), ) ) if isinstance(info, PyPyVersionInfo): @@ -238,7 +264,8 @@ def run(self, vd: VersionDatabase) -> int: "cpython_series", "CPython-Series", info.supported_cpython_series( - v, released=self.subversions == "released" + v, + released=self.subversions is Mode.RELEASED, ), ) ) @@ -250,7 +277,7 @@ def run(self, vd: VersionDatabase) -> int: data.append(("is_eol", "Is-EOL", info.is_eol(v))) if isinstance(info, PyPyVersionInfo): data.append(("cpython", "CPython", info.supported_cpython(v))) - if self.do_json: + if self.json: print(json.dumps({k: v for k, _, v in data}, indent=4, default=str)) else: for _, label, val in data: @@ -278,23 +305,5 @@ def yes(version: str) -> bool: # noqa: U100 return True -def filter_versions(mode: str, info: VersionInfo, versions: list[str]) -> list[str]: - if mode == "all": - filterer = yes - elif mode == "released": - filterer = info.is_released - elif mode == "supported": - if not isinstance(info, CPythonVersionInfo): - raise ValueError("'supported' only applies to CPython versions") - filterer = info.is_supported - elif mode == "not-eol": - if not isinstance(info, CPythonVersionInfo): - raise ValueError("'not-eol' only applies to CPython versions") - filterer = partial(is_not_eol, info) - else: - raise AssertionError(f"Unexpected mode: {mode!r}") # pragma: no cover - return list(filter(filterer, versions)) - - if __name__ == "__main__": sys.exit(main()) # pragma: no cover From bac62f619d5726d60001c0257f3d6dd32aa8a8ed Mon Sep 17 00:00:00 2001 From: "John T. Wodder II" Date: Wed, 19 Nov 2025 14:26:53 -0500 Subject: [PATCH 3/5] Adjust some error strings --- src/pyversion_info/__main__.py | 4 ++-- test/test_cmd_pypy.py | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/pyversion_info/__main__.py b/src/pyversion_info/__main__.py index e33e375..2190e51 100644 --- a/src/pyversion_info/__main__.py +++ b/src/pyversion_info/__main__.py @@ -176,11 +176,11 @@ def filter_versions(self, info: VersionInfo, versions: list[str]) -> list[str]: filterer = info.is_released case Mode.SUPPORTED: if not isinstance(info, CPythonVersionInfo): - raise ValueError("'supported' only applies to CPython versions") + raise ValueError('"supported" only applies to CPython versions') filterer = info.is_supported case Mode.NOT_EOL: if not isinstance(info, CPythonVersionInfo): - raise ValueError("'not-eol' only applies to CPython versions") + raise ValueError('"not-eol" only applies to CPython versions') filterer = partial(is_not_eol, info) case mode: raise AssertionError(f"Unexpected mode: {mode!r}") # pragma: no cover diff --git a/test/test_cmd_pypy.py b/test/test_cmd_pypy.py index 92340d4..06e218d 100644 --- a/test/test_cmd_pypy.py +++ b/test/test_cmd_pypy.py @@ -169,7 +169,7 @@ def test_cmd_list_not_eol(capsys: pytest.CaptureFixture[str], level: str) -> Non ) out, err = capsys.readouterr() assert out == "" - assert err == "pyversion-info: 'not-eol' only applies to CPython versions\n" + assert err == 'pyversion-info: "not-eol" only applies to CPython versions\n' @pytest.mark.parametrize("level", ["major", "minor", "micro"]) @@ -182,7 +182,7 @@ def test_cmd_list_supported(capsys: pytest.CaptureFixture[str], level: str) -> N ) out, err = capsys.readouterr() assert out == "" - assert err == "pyversion-info: 'supported' only applies to CPython versions\n" + assert err == 'pyversion-info: "supported" only applies to CPython versions\n' @pytest.mark.parametrize( @@ -450,7 +450,7 @@ def test_cmd_show_not_eol(capsys: pytest.CaptureFixture[str], v: str) -> None: ) out, err = capsys.readouterr() assert out == "" - assert err == "pyversion-info: 'not-eol' only applies to CPython versions\n" + assert err == 'pyversion-info: "not-eol" only applies to CPython versions\n' @pytest.mark.parametrize("v", ["7", "7.3"]) @@ -463,7 +463,7 @@ def test_cmd_show_supported(capsys: pytest.CaptureFixture[str], v: str) -> None: ) out, err = capsys.readouterr() assert out == "" - assert err == "pyversion-info: 'supported' only applies to CPython versions\n" + assert err == 'pyversion-info: "supported" only applies to CPython versions\n' @pytest.mark.parametrize("v", ["", "1.2.3.4", "1.2.3rc1", "foobar", "a.b.c"]) From 9659fc3d1f73b26b86f7f5c2850f42d5a54a748c Mon Sep 17 00:00:00 2001 From: "John T. Wodder II" Date: Wed, 19 Nov 2025 14:36:01 -0500 Subject: [PATCH 4/5] Remove more magic strings --- src/pyversion_info/__main__.py | 67 +++++++++++++++++++++++++--------- 1 file changed, 49 insertions(+), 18 deletions(-) diff --git a/src/pyversion_info/__main__.py b/src/pyversion_info/__main__.py index 2190e51..c84b4a3 100644 --- a/src/pyversion_info/__main__.py +++ b/src/pyversion_info/__main__.py @@ -66,9 +66,9 @@ def from_args(cls, argv: list[str] | None = None) -> Command: "--cpython", dest="py", action="store_const", - const="cpython", + const=PyImpl.CPYTHON, help="Show information about CPython versions [default]", - default="cpython", + default=PyImpl.CPYTHON, ) listparser.add_argument( "-n", @@ -82,7 +82,7 @@ def from_args(cls, argv: list[str] | None = None) -> Command: "--pypy", dest="py", action="store_const", - const="pypy", + const=PyImpl.PYPY, help="Show information about PyPy versions", ) listparser.add_argument( @@ -101,6 +101,7 @@ def from_args(cls, argv: list[str] | None = None) -> Command: const=Mode.SUPPORTED, help="List only supported versions", ) + # Don't convert with `type` here, as that takes effect before `choices` listparser.add_argument("level", choices=["major", "minor", "micro"]) showparser = subparsers.add_parser( @@ -110,16 +111,16 @@ def from_args(cls, argv: list[str] | None = None) -> Command: "--cpython", dest="py", action="store_const", - const="cpython", + const=PyImpl.CPYTHON, help="Show information about CPython versions [default]", - default="cpython", + default=PyImpl.CPYTHON, ) showparser.add_argument("-J", "--json", action="store_true", help="Output JSON") showparser.add_argument( "--pypy", dest="py", action="store_const", - const="pypy", + const=PyImpl.PYPY, help="Show information about PyPy versions", ) showparser.add_argument( @@ -136,7 +137,9 @@ def from_args(cls, argv: list[str] | None = None) -> Command: subcommand: Subcommand match args.subcommand: case "list": - subcommand = ListCommand(level=args.level, mode=args.mode, py=args.py) + subcommand = ListCommand( + level=Level(args.level), mode=args.mode, py=args.py + ) case "show": subcommand = ShowCommand( version=args.version, @@ -162,6 +165,22 @@ def run(self) -> int: return 1 +class PyImpl(Enum): + CPYTHON = 0 + PYPY = 1 + + def get_info(self, vd: VersionDatabase) -> VersionInfo: + match self: + case PyImpl.CPYTHON: + return vd.cpython + case PyImpl.PYPY: + return vd.pypy + case _: + raise AssertionError( + f"Unexpected Python implementation: {self!r}" + ) # pragma: no cover + + class Mode(Enum): ALL = "all" NOT_EOL = "not-eol" @@ -187,20 +206,32 @@ def filter_versions(self, info: VersionInfo, versions: list[str]) -> list[str]: return list(filter(filterer, versions)) +class Level(Enum): + MAJOR = "major" + MINOR = "minor" + MICRO = "micro" + + def get_versions(self, info: VersionInfo) -> list[str]: + match self: + case Level.MAJOR: + return info.major_versions() + case Level.MINOR: + return info.minor_versions() + case Level.MICRO: + return info.micro_versions() + case level: + raise AssertionError(f"Unexpected level: {level!r}") # pragma: no cover + + @dataclass class ListCommand: - level: str + level: Level mode: Mode - py: str + py: PyImpl def run(self, vd: VersionDatabase) -> int: - info = vd.pypy if self.py == "pypy" else vd.cpython - func = { - "major": info.major_versions, - "minor": info.minor_versions, - "micro": info.micro_versions, - }[self.level] - for v in self.mode.filter_versions(info, func()): + info = self.py.get_info(vd) + for v in self.mode.filter_versions(info, self.level.get_versions(info)): print(v) return 0 @@ -210,10 +241,10 @@ class ShowCommand: version: str subversions: Mode json: bool - py: str + py: PyImpl def run(self, vd: VersionDatabase) -> int: - info = vd.pypy if self.py == "pypy" else vd.cpython + info = self.py.get_info(vd) v = parse_version(self.version) data: list[tuple[str, str, Any]] = [ ("version", "Version", str(v)), From 36eb8ffd1674cba8b16cf0ee58024e6b2077e892 Mon Sep 17 00:00:00 2001 From: "John T. Wodder II" Date: Wed, 19 Nov 2025 14:40:18 -0500 Subject: [PATCH 5/5] Adjust "no cover" pragmata --- src/pyversion_info/__main__.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/pyversion_info/__main__.py b/src/pyversion_info/__main__.py index c84b4a3..82870d6 100644 --- a/src/pyversion_info/__main__.py +++ b/src/pyversion_info/__main__.py @@ -147,8 +147,8 @@ def from_args(cls, argv: list[str] | None = None) -> Command: json=args.json, py=args.py, ) - case cmd: - raise RuntimeError(f"Internal error: unhandled subcommand: {cmd!r}") + case cmd: # pragma: no cover + raise AssertionError(f"Unexpected subcommand: {cmd!r}") return Command(database=args.database, subcommand=subcommand) def run(self) -> int: @@ -175,10 +175,8 @@ def get_info(self, vd: VersionDatabase) -> VersionInfo: return vd.cpython case PyImpl.PYPY: return vd.pypy - case _: - raise AssertionError( - f"Unexpected Python implementation: {self!r}" - ) # pragma: no cover + case _: # pragma: no cover + raise AssertionError(f"Unexpected Python implementation: {self!r}") class Mode(Enum): @@ -201,8 +199,8 @@ def filter_versions(self, info: VersionInfo, versions: list[str]) -> list[str]: if not isinstance(info, CPythonVersionInfo): raise ValueError('"not-eol" only applies to CPython versions') filterer = partial(is_not_eol, info) - case mode: - raise AssertionError(f"Unexpected mode: {mode!r}") # pragma: no cover + case mode: # pragma: no cover + raise AssertionError(f"Unexpected mode: {mode!r}") return list(filter(filterer, versions)) @@ -219,8 +217,8 @@ def get_versions(self, info: VersionInfo) -> list[str]: return info.minor_versions() case Level.MICRO: return info.micro_versions() - case level: - raise AssertionError(f"Unexpected level: {level!r}") # pragma: no cover + case level: # pragma: no cover + raise AssertionError(f"Unexpected level: {level!r}") @dataclass