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 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 eeb5f8417..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", ] @@ -75,7 +77,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 a11d46398..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 @@ -929,3 +932,54 @@ 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 + + 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.set_defaults(func=_main_compare) + compare.add_argument("version1", type=Version, help="First version to compare") + compare.add_argument( + "operator", + choices=_COMPARE_OPERATIONS.keys(), + help="Comparison operator", + ) + compare.add_argument("version2", type=Version, help="Second version to compare") + + args = parser.parse_args() + + 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