From 0fce05391f86991b30aff313874b26cb2f1b5f72 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 1/8] 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 e105803f8..57c424596 100644 --- a/src/packaging/version.py +++ b/src/packaging/version.py @@ -1146,3 +1146,43 @@ def _cmpkey( (seg, "") if isinstance(seg, int) else (_LOCAL_STR_RANK, seg) for seg in local ) return epoch, trimmed, suffix, cmp_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 38e8f70bfb9889dd9964fda11571c5fabc3a78b9 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 2/8] 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 57c424596..908b4624c 100644 --- a/src/packaging/version.py +++ b/src/packaging/version.py @@ -1185,4 +1185,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 b7f996561aec89ae34f67e7bae2c7273198709f6 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 3/8] 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 908b4624c..caa9fd1e7 100644 --- a/src/packaging/version.py +++ b/src/packaging/version.py @@ -1147,31 +1147,28 @@ def _cmpkey( ) return epoch, trimmed, suffix, cmp_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( @@ -1181,8 +1178,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 e83ca4e7794dade5969f4df15160104edeab2884 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 4/8] 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 caa9fd1e7..458866719 100644 --- a/src/packaging/version.py +++ b/src/packaging/version.py @@ -1159,13 +1159,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 d7c75eebf33078697e02d5499ebedeec4c90dd31 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Thu, 12 Feb 2026 17:41:54 -0500 Subject: [PATCH 5/8] 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 458866719..ab78d812c 100644 --- a/src/packaging/version.py +++ b/src/packaging/version.py @@ -1149,7 +1149,6 @@ def _cmpkey( if __name__ == "__main__": import argparse - import sys import operator operations = { @@ -1159,10 +1158,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", @@ -1174,5 +1179,5 @@ def _cmpkey( args = parser.parse_args() result = operations[args.operator](args.version1, args.version2) - sys.exit(not result) + raise SystemExit(not result) From b29ac859e7602b1cc1c1ef1c90b4c9ed6ac5e414 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Thu, 12 Feb 2026 22:05:20 -0500 Subject: [PATCH 6/8] 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 99e418373..084071266 100644 --- a/src/packaging/tags.py +++ b/src/packaging/tags.py @@ -143,7 +143,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 ab78d812c..72db36f3e 100644 --- a/src/packaging/version.py +++ b/src/packaging/version.py @@ -1147,6 +1147,7 @@ def _cmpkey( ) return epoch, trimmed, suffix, cmp_local + if __name__ == "__main__": import argparse import operator @@ -1166,18 +1167,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 8be608985092598372e37c4faa7f994f8151a10a Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Fri, 13 Feb 2026 12:56:31 -0500 Subject: [PATCH 7/8] tests: add tests for CLI Signed-off-by: Henry Schreiner chore: fix coverage Signed-off-by: Henry Schreiner Apply suggestion from @henryiii --- pyproject.toml | 1 + src/packaging/tags.py | 5 +--- src/packaging/version.py | 56 ++++++++++++++++++++++-------------- tests/test_version_cli.py | 60 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 96 insertions(+), 26 deletions(-) create mode 100644 tests/test_version_cli.py diff --git a/pyproject.toml b/pyproject.toml index 580d3ddd7..011cf929c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -94,6 +94,7 @@ exclude_also = [ "if (typing.)?TYPE_CHECKING:", "@(typing.)?overload", "def __dir__()", + 'if __name__ == "__main__":', ] [tool.pytest.ini_options] diff --git a/src/packaging/tags.py b/src/packaging/tags.py index 084071266..99e418373 100644 --- a/src/packaging/tags.py +++ b/src/packaging/tags.py @@ -143,10 +143,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 72db36f3e..cfcad07c7 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 @@ -24,6 +25,8 @@ ) if typing.TYPE_CHECKING: + import argparse + from typing_extensions import Self, Unpack if sys.version_info >= (3, 13): # pragma: no cover @@ -1148,24 +1151,29 @@ def _cmpkey( return epoch, trimmed, suffix, cmp_local -if __name__ == "__main__": - import argparse - import operator - - 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, - } +_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 parser = argparse.ArgumentParser(description="Version utilities") subparsers = parser.add_subparsers(dest="command", required=True) @@ -1175,16 +1183,20 @@ def _cmpkey( 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__": + 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 beb2dd52069c87387193f3eea683f2e9805279a3 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Thu, 26 Feb 2026 17:50:13 -0500 Subject: [PATCH 8/8] docs: add CLI output with color to page Signed-off-by: Henry Schreiner --- docs/conf.py | 13 +++++++++++++ docs/version.rst | 14 ++++++++++++++ pyproject.toml | 3 +++ 3 files changed, 30 insertions(+) diff --git a/docs/conf.py b/docs/conf.py index 34a1f4c59..34e9d61ec 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -17,10 +17,12 @@ # 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", + "sphinxcontrib.programoutput", ] # General information about the project. @@ -103,3 +105,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 011cf929c..abe5bb089 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,10 @@ test = [ "tomli_w", ] docs = [ + "erbsland-sphinx-ansi; python_version>='3.10'", "furo", + "sphinx", + "sphinxcontrib-programoutput >=0.19", "typing-extensions>=4.1.0; python_version < '3.9'", ] benchmark = [