From 980e3a4dc1e040454649e5684c6dec6036613a47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Gait=C3=A1n?= Date: Tue, 16 Apr 2024 14:53:34 -0300 Subject: [PATCH 01/11] packaging.version as a CLI tool --- src/packaging/version.py | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/packaging/version.py b/src/packaging/version.py index a11d46398..512678cc1 100644 --- a/src/packaging/version.py +++ b/src/packaging/version.py @@ -929,3 +929,43 @@ def _cmpkey( ) return epoch, _release, _pre, _post, _dev, _local + + +if __name__ == "__main__": + import argparse + import sys + + operations = { + "lt": lambda v1, v2: v1 < v2, + "le": lambda v1, v2: v1 <= v2, + "eq": lambda v1, v2: v1 == v2, + "ne": lambda v1, v2: v1 != v2, + "ge": lambda v1, v2: v1 >= v2, + "gt": lambda v1, v2: v1 > v2, + "lt-nl": lambda v1, v2: (v1 < v2) if v1 and v2 else True, + "le-nl": lambda v1, v2: (v1 <= v2) if v1 and v2 else True, + "ge-nl": lambda v1, v2: (v1 >= v2) if v1 and v2 else False, + "gt-nl": lambda v1, v2: (v1 > v2) if v1 and v2 else False, + "<": lambda v1, v2: v1 < v2, + "<<": lambda v1, v2: v1 < v2, + "<=": lambda v1, v2: v1 <= v2, + "=": lambda v1, v2: v1 == v2, + ">=": lambda v1, v2: v1 >= v2, + ">>": lambda v1, v2: v1 > v2, + ">": lambda v1, v2: v1 > v2, + } + + parser = argparse.ArgumentParser(description="Compare two semantic versions.") + parser.add_argument("version1", type=Version, help="First version to compare") + parser.add_argument( + "operator", + type=str, + choices=operations.keys(), + help="Comparison operator", + ) + parser.add_argument("version2", type=Version, help="Second version to compare") + + args = parser.parse_args() + result = operations[args.operator](args.version1, args.version2) + + sys.exit(0 if result else 1) From af6a36598e08f9eef9738955d5705b9396fbca73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Gait=C3=A1n?= Date: Mon, 9 Sep 2024 17:08:44 -0300 Subject: [PATCH 02/11] Update src/packaging/version.py Co-authored-by: Brett Cannon --- src/packaging/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/packaging/version.py b/src/packaging/version.py index 512678cc1..54e550c4a 100644 --- a/src/packaging/version.py +++ b/src/packaging/version.py @@ -968,4 +968,4 @@ def _cmpkey( args = parser.parse_args() result = operations[args.operator](args.version1, args.version2) - sys.exit(0 if result else 1) + sys.exit(not result) From db18c6eac7e1d6e4498821653e990c17cc700595 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Gait=C3=A1n?= Date: Mon, 9 Sep 2024 18:53:53 -0300 Subject: [PATCH 03/11] remove -nl operators --- src/packaging/version.py | 37 +++++++++++++++++-------------------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/src/packaging/version.py b/src/packaging/version.py index 54e550c4a..1103c4bab 100644 --- a/src/packaging/version.py +++ b/src/packaging/version.py @@ -930,31 +930,28 @@ def _cmpkey( return epoch, _release, _pre, _post, _dev, _local - if __name__ == "__main__": import argparse import sys + import operator operations = { - "lt": lambda v1, v2: v1 < v2, - "le": lambda v1, v2: v1 <= v2, - "eq": lambda v1, v2: v1 == v2, - "ne": lambda v1, v2: v1 != v2, - "ge": lambda v1, v2: v1 >= v2, - "gt": lambda v1, v2: v1 > v2, - "lt-nl": lambda v1, v2: (v1 < v2) if v1 and v2 else True, - "le-nl": lambda v1, v2: (v1 <= v2) if v1 and v2 else True, - "ge-nl": lambda v1, v2: (v1 >= v2) if v1 and v2 else False, - "gt-nl": lambda v1, v2: (v1 > v2) if v1 and v2 else False, - "<": lambda v1, v2: v1 < v2, - "<<": lambda v1, v2: v1 < v2, - "<=": lambda v1, v2: v1 <= v2, - "=": lambda v1, v2: v1 == v2, - ">=": lambda v1, v2: v1 >= v2, - ">>": lambda v1, v2: v1 > v2, - ">": lambda v1, v2: v1 > v2, + "lt": operator.lt, + "le": operator.le, + "eq": operator.eq, + "ne": operator.ne, + "ge": operator.ge, + "gt": operator.gt, + "<": operator.lt, + "<<": operator.lt, + "<=": operator.le, + "=": operator.eq, + ">=": operator.ge, + ">>": operator.gt, + ">": operator.gt, } + # Argument parsing parser = argparse.ArgumentParser(description="Compare two semantic versions.") parser.add_argument("version1", type=Version, help="First version to compare") parser.add_argument( @@ -964,8 +961,8 @@ def _cmpkey( help="Comparison operator", ) parser.add_argument("version2", type=Version, help="Second version to compare") - args = parser.parse_args() - result = operations[args.operator](args.version1, args.version2) + result = operations[args.operator](args.version1, args.version2) sys.exit(not result) + From 13afbdc04be8ad3ef0cb492186f3d2879955b77c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Gait=C3=A1n?= Date: Mon, 9 Sep 2024 19:02:35 -0300 Subject: [PATCH 04/11] remove not named ops --- src/packaging/version.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/packaging/version.py b/src/packaging/version.py index 1103c4bab..e3dbb8395 100644 --- a/src/packaging/version.py +++ b/src/packaging/version.py @@ -942,13 +942,6 @@ def _cmpkey( "ne": operator.ne, "ge": operator.ge, "gt": operator.gt, - "<": operator.lt, - "<<": operator.lt, - "<=": operator.le, - "=": operator.eq, - ">=": operator.ge, - ">>": operator.gt, - ">": operator.gt, } # Argument parsing From c2796a8ae58d4e7fa637befd125d43f8dd493841 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Thu, 12 Feb 2026 17:41:54 -0500 Subject: [PATCH 05/11] fix: restore non-textual operators Updated the argument parser description and replaced sys.exit with raise SystemExit. --- src/packaging/version.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/packaging/version.py b/src/packaging/version.py index e3dbb8395..b1e4e2507 100644 --- a/src/packaging/version.py +++ b/src/packaging/version.py @@ -932,7 +932,6 @@ def _cmpkey( if __name__ == "__main__": import argparse - import sys import operator operations = { @@ -942,10 +941,16 @@ def _cmpkey( "ne": operator.ne, "ge": operator.ge, "gt": operator.gt, + "<": operator.lt, + "<=": operator.le, + "==": operator.eq, + "!=": operator.ne, + ">=": operator.ge, + ">": operator.gt, } # Argument parsing - parser = argparse.ArgumentParser(description="Compare two semantic versions.") + parser = argparse.ArgumentParser(description="Compare two semantic versions. Return code is 0 or 1.") parser.add_argument("version1", type=Version, help="First version to compare") parser.add_argument( "operator", @@ -957,5 +962,5 @@ def _cmpkey( args = parser.parse_args() result = operations[args.operator](args.version1, args.version2) - sys.exit(not result) + raise SystemExit(not result) From 30bde3f0a1241b4c3a992b54d9858e667cef5a6b Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Thu, 12 Feb 2026 22:05:20 -0500 Subject: [PATCH 06/11] refactor: put behind a compare subcommand Signed-off-by: Henry Schreiner --- src/packaging/tags.py | 5 ++++- src/packaging/version.py | 25 ++++++++++++++++--------- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/src/packaging/tags.py b/src/packaging/tags.py index e3cc602ec..d8458bfb1 100644 --- a/src/packaging/tags.py +++ b/src/packaging/tags.py @@ -114,7 +114,10 @@ def __str__(self) -> str: return f"{self._interpreter}-{self._abi}-{self._platform}" def __repr__(self) -> str: - return f"<{self} @ {id(self)}>" + return ( + f"{self.__class__.__name__}" + f"({self._interpreter!r}, {self._abi!r}, {self._platform!r})" + ) def __setstate__(self, state: tuple[None, dict[str, Any]]) -> None: # The cached _hash is wrong when unpickling. diff --git a/src/packaging/version.py b/src/packaging/version.py index b1e4e2507..63632798e 100644 --- a/src/packaging/version.py +++ b/src/packaging/version.py @@ -930,6 +930,7 @@ def _cmpkey( return epoch, _release, _pre, _post, _dev, _local + if __name__ == "__main__": import argparse import operator @@ -949,18 +950,24 @@ def _cmpkey( ">": operator.gt, } - # Argument parsing - parser = argparse.ArgumentParser(description="Compare two semantic versions. Return code is 0 or 1.") - parser.add_argument("version1", type=Version, help="First version to compare") - parser.add_argument( + parser = argparse.ArgumentParser(description="Version utilities") + subparsers = parser.add_subparsers(dest="command", required=True) + + compare = subparsers.add_parser( + "compare", + help="Compare two semantic versions.", + description="Compare two semantic versions. Return code is 0 or 1.", + ) + compare.add_argument("version1", type=Version, help="First version to compare") + compare.add_argument( "operator", - type=str, choices=operations.keys(), help="Comparison operator", ) - parser.add_argument("version2", type=Version, help="Second version to compare") - args = parser.parse_args() + compare.add_argument("version2", type=Version, help="Second version to compare") - result = operations[args.operator](args.version1, args.version2) - raise SystemExit(not result) + args = parser.parse_args() + if args.command == "compare": + result = operations[args.operator](args.version1, args.version2) + raise SystemExit(not result) From 1a14401571a0bf0a7cc4dc7e86cd7e393d83838c Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Fri, 13 Feb 2026 12:56:31 -0500 Subject: [PATCH 07/11] tests: add tests for CLI Signed-off-by: Henry Schreiner --- src/packaging/tags.py | 5 +--- src/packaging/version.py | 10 +++++-- tests/test_version_cli.py | 60 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 7 deletions(-) create mode 100644 tests/test_version_cli.py diff --git a/src/packaging/tags.py b/src/packaging/tags.py index d8458bfb1..e3cc602ec 100644 --- a/src/packaging/tags.py +++ b/src/packaging/tags.py @@ -114,10 +114,7 @@ def __str__(self) -> str: return f"{self._interpreter}-{self._abi}-{self._platform}" def __repr__(self) -> str: - return ( - f"{self.__class__.__name__}" - f"({self._interpreter!r}, {self._abi!r}, {self._platform!r})" - ) + return f"<{self} @ {id(self)}>" def __setstate__(self, state: tuple[None, dict[str, Any]]) -> None: # The cached _hash is wrong when unpickling. diff --git a/src/packaging/version.py b/src/packaging/version.py index 63632798e..fa3e23bc1 100644 --- a/src/packaging/version.py +++ b/src/packaging/version.py @@ -931,9 +931,9 @@ def _cmpkey( return epoch, _release, _pre, _post, _dev, _local -if __name__ == "__main__": - import argparse - import operator +def main() -> None: + import argparse # noqa: PLC0415 + import operator # noqa: PLC0415 operations = { "lt": operator.lt, @@ -971,3 +971,7 @@ def _cmpkey( if args.command == "compare": result = operations[args.operator](args.version1, args.version2) raise SystemExit(not result) + + +if __name__ == "__main__": + main() diff --git a/tests/test_version_cli.py b/tests/test_version_cli.py new file mode 100644 index 000000000..e1d7a0c98 --- /dev/null +++ b/tests/test_version_cli.py @@ -0,0 +1,60 @@ +# This file is dual licensed under the terms of the Apache License, Version +# 2.0, and the BSD License. See the LICENSE file in the root of this repository +# for complete details. + +from __future__ import annotations + +import sys + +import pytest + +from packaging.version import main + + +@pytest.mark.parametrize( + ("args", "retcode"), + [ + ("1.2 eq 1.2", 0), + ("1.2 eq 1.2.0", 0), + ("1.2 eq 1.2dev1", 1), + ("1.2 == 1.2", 0), + ("1.2 == 1.2.0", 0), + ("1.2 == 1.2dev1", 1), + ("1.2 ne 1.2.0", 1), + ("1.2 ne 1.2dev1", 0), + ("1.2 != 1.2.0", 1), + ("1.2 != 1.2dev1", 0), + ("1.2 lt 1.2.0", 1), + ("1.2 lt 1.2dev1", 1), + ("1.2 lt 1.3", 0), + ("1.2 < 1.2.0", 1), + ("1.2 < 1.2dev1", 1), + ("1.2 < 1.3", 0), + ("1.2 gt 1.2.0", 1), + ("1.2 gt 1.2dev1", 0), + ("1.2 gt 1.1", 0), + ("1.2 > 1.2.0", 1), + ("1.2 > 1.2dev1", 0), + ("1.2 > 1.1", 0), + ("1.2 le 1.2", 0), + ("1.2 le 1.3", 0), + ("1.2 le 1.1", 1), + ("1.2 <= 1.2", 0), + ("1.2 <= 1.3", 0), + ("1.2 <= 1.1", 1), + ("1.2 ge 1.2", 0), + ("1.2 ge 1.1", 0), + ("1.2 ge 1.3", 1), + ("1.2 >= 1.2", 0), + ("1.2 >= 1.1", 0), + ("1.2 >= 1.3", 1), + ("1.2 foo 1.2", 2), + ("1.2 == unreal", 2), + ], +) +def test_compare(monkeypatch: pytest.MonkeyPatch, args: str, retcode: int) -> None: + monkeypatch.setattr(sys, "argv", ["prog", "compare", *args.split()]) + with pytest.raises(SystemExit) as excinfo: + main() + + assert excinfo.value.code == retcode From a700912aecb1283057dcff71d12c61769006dad8 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Mon, 16 Feb 2026 19:02:02 -0500 Subject: [PATCH 08/11] chore: fix coverage Signed-off-by: Henry Schreiner --- pyproject.toml | 9 +++++++- src/packaging/version.py | 48 +++++++++++++++++++++++----------------- 2 files changed, 36 insertions(+), 21 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index eeb5f8417..320bef4f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,7 +75,14 @@ source_pkgs = ["packaging"] [tool.coverage.report] show_missing = true fail_under = 100 -exclude_also = ["@(abc.)?abstractmethod", "@(abc.)?abstractproperty", "if (typing.)?TYPE_CHECKING:", "@(typing.)?overload", "def __dir__()"] +exclude_also = [ + "@(abc.)?abstractmethod", + "@(abc.)?abstractproperty", + "if (typing.)?TYPE_CHECKING:", + "@(typing.)?overload", + "def __dir__()", + 'if __name__ == "main"', +] [tool.pytest.ini_options] minversion = "6.2" diff --git a/src/packaging/version.py b/src/packaging/version.py index fa3e23bc1..22b596fd9 100644 --- a/src/packaging/version.py +++ b/src/packaging/version.py @@ -9,6 +9,7 @@ from __future__ import annotations +import operator import re import sys import typing @@ -26,6 +27,8 @@ from ._structures import Infinity, InfinityType, NegativeInfinity, NegativeInfinityType if typing.TYPE_CHECKING: + import argparse + from typing_extensions import Self, Unpack if sys.version_info >= (3, 13): # pragma: no cover @@ -931,24 +934,29 @@ def _cmpkey( return epoch, _release, _pre, _post, _dev, _local +_COMPARE_OPERATIONS = { + "lt": operator.lt, + "le": operator.le, + "eq": operator.eq, + "ne": operator.ne, + "ge": operator.ge, + "gt": operator.gt, + "<": operator.lt, + "<=": operator.le, + "==": operator.eq, + "!=": operator.ne, + ">=": operator.ge, + ">": operator.gt, +} + + +def _main_compare(args: argparse.Namespace) -> int: + result = _COMPARE_OPERATIONS[args.operator](args.version1, args.version2) + return not result + + def main() -> None: import argparse # noqa: PLC0415 - import operator # noqa: PLC0415 - - operations = { - "lt": operator.lt, - "le": operator.le, - "eq": operator.eq, - "ne": operator.ne, - "ge": operator.ge, - "gt": operator.gt, - "<": operator.lt, - "<=": operator.le, - "==": operator.eq, - "!=": operator.ne, - ">=": operator.ge, - ">": operator.gt, - } parser = argparse.ArgumentParser(description="Version utilities") subparsers = parser.add_subparsers(dest="command", required=True) @@ -958,19 +966,19 @@ def main() -> None: help="Compare two semantic versions.", description="Compare two semantic versions. Return code is 0 or 1.", ) + compare.set_defaults(func=_main_compare) compare.add_argument("version1", type=Version, help="First version to compare") compare.add_argument( "operator", - choices=operations.keys(), + choices=_COMPARE_OPERATIONS.keys(), help="Comparison operator", ) compare.add_argument("version2", type=Version, help="Second version to compare") args = parser.parse_args() - if args.command == "compare": - result = operations[args.operator](args.version1, args.version2) - raise SystemExit(not result) + result = args.func(args) + raise SystemExit(result) if __name__ == "__main__": From ff4039f34b704fc82424d311bf370ee82077b3c8 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Thu, 19 Feb 2026 17:51:57 -0500 Subject: [PATCH 09/11] Apply suggestion from @henryiii --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 320bef4f7..1f2caff5b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,7 +81,7 @@ exclude_also = [ "if (typing.)?TYPE_CHECKING:", "@(typing.)?overload", "def __dir__()", - 'if __name__ == "main"', + 'if __name__ == "__main__":', ] [tool.pytest.ini_options] From 69ff8dfbe7e0f4c4640e901ad63d550233575f63 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Thu, 26 Feb 2026 17:50:13 -0500 Subject: [PATCH 10/11] docs: add CLI output with color to page Signed-off-by: Henry Schreiner --- docs/conf.py | 13 +++++++++++++ docs/version.rst | 14 ++++++++++++++ pyproject.toml | 4 +++- 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index 9419c4114..de0e3d247 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -17,11 +17,13 @@ # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. extensions = [ + "erbsland.sphinx.ansi", "sphinx.ext.autodoc", "sphinx.ext.doctest", "sphinx.ext.extlinks", "sphinx.ext.intersphinx", "sphinx_toolbox.more_autodoc.autotypeddict", + "sphinxcontrib.programoutput", ] # General information about the project. @@ -104,3 +106,14 @@ "python": ("https://docs.python.org/3/", None), "pypug": ("https://packaging.python.org/", None), } + + +# -- Options for programout ---------------------------------------------------------- +# https://sphinxcontrib-programoutput.readthedocs.io + +programoutput_use_ansi = True + +# Needed to ensure color output +# See https://github.com/OpenNTI/sphinxcontrib-programoutput/issues/77 +os.environ["FORCE_COLOR"] = "1" +os.environ.pop("NO_COLOR", None) diff --git a/docs/version.rst b/docs/version.rst index 2adf336e5..f46eef4f4 100644 --- a/docs/version.rst +++ b/docs/version.rst @@ -52,3 +52,17 @@ Reference .. automodule:: packaging.version :members: :special-members: + + +CLI +--- + +A CLI utility is provided: + +.. program-output:: python -m packaging.version --help + +You can compare two versions: + +.. program-output:: python -m packaging.version compare --help + +.. versionadded:: 26.1 diff --git a/pyproject.toml b/pyproject.toml index 1f2caff5b..5b59da6cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,9 +47,11 @@ test = [ ] dev = [{ include-group = "test" }] docs = [ + "erbsland-sphinx-ansi; python_version>='3.10'", "furo", + "sphinx <9", # required by sphinx-toolbox "sphinx-toolbox", - "typing-extensions>=4.1.0; python_version < '3.9'", + "sphinxcontrib-programoutput >=0.19", ] From 42c3aa1c52880eb8b3d2d071e2932477e2191e39 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Mon, 2 Mar 2026 17:44:26 -0500 Subject: [PATCH 11/11] Update docs Python to 3.14 --- .readthedocs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.readthedocs.yml b/.readthedocs.yml index 57476c1e3..974326d6f 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -8,7 +8,7 @@ version: 2 build: os: ubuntu-24.04 tools: - python: "3.13" + python: "3.14" commands: - asdf plugin add uv - asdf install uv latest