diff --git a/CHANGES b/CHANGES index df5f059fb..e83a9565f 100644 --- a/CHANGES +++ b/CHANGES @@ -14,6 +14,27 @@ $ pip install --user --upgrade --pre libtmux +### Breaking changes + +- Fix `distutils` warning, vendorize `LegacyVersion` (#351) + + Removal of reliancy on `distutils.version.LooseVersion`, which does not + support `tmux(1)` versions like `3.1a`. + + Fixes warning: + + > DeprecationWarning: distutils Version classes are deprecated. Use + > packaging.version instead. + + The temporary workaround, before 0.16.0 (assuming _setup.cfg_): + + ```ini + [tool:pytest] + filterwarnings = + ignore:.* Use packaging.version.*:DeprecationWarning:: + ignore:The frontend.Option(Parser)? class.*:DeprecationWarning:: + ``` + ### Features - `Window.split_window()` and `Session.new_window()` now support an optional diff --git a/setup.cfg b/setup.cfg index 0620db0c5..882dc98ee 100644 --- a/setup.cfg +++ b/setup.cfg @@ -18,7 +18,6 @@ line_length = 88 [tool:pytest] filterwarnings = - ignore:.* Use packaging.version.*:DeprecationWarning:: ignore:The frontend.Option(Parser)? class.*:DeprecationWarning:: addopts = --tb=short --no-header --showlocals --doctest-docutils-modules --reruns 2 -p no:doctest doctest_optionflags = ELLIPSIS NORMALIZE_WHITESPACE diff --git a/src/libtmux/_compat.py b/src/libtmux/_compat.py index d7b6bb274..e96b69f0a 100644 --- a/src/libtmux/_compat.py +++ b/src/libtmux/_compat.py @@ -1,4 +1,5 @@ # flake8: NOQA +import functools import sys import types import typing as t @@ -31,3 +32,106 @@ def str_from_console(s: t.Union[str, bytes]) -> str: return str(s) except UnicodeDecodeError: return str(s, encoding="utf_8") if isinstance(s, bytes) else s + + +import re +from typing import Iterator, List, Tuple + +from packaging.version import Version + +### +### Legacy support for LooseVersion / LegacyVersion, e.g. 2.4-openbsd +### https://github.com/pypa/packaging/blob/21.3/packaging/version.py#L106-L115 +### License: BSD, Accessed: Jan 14th, 2022 +### + +LegacyCmpKey = Tuple[int, Tuple[str, ...]] + +_legacy_version_component_re = re.compile(r"(\d+ | [a-z]+ | \.| -)", re.VERBOSE) +_legacy_version_replacement_map = { + "pre": "c", + "preview": "c", + "-": "final-", + "rc": "c", + "dev": "@", +} + + +def _parse_version_parts(s: str) -> Iterator[str]: + for part in _legacy_version_component_re.split(s): + part = _legacy_version_replacement_map.get(part, part) + + if not part or part == ".": + continue + + if part[:1] in "0123456789": + # pad for numeric comparison + yield part.zfill(8) + else: + yield "*" + part + + # ensure that alpha/beta/candidate are before final + yield "*final" + + +def _legacy_cmpkey(version: str) -> LegacyCmpKey: + # We hardcode an epoch of -1 here. A PEP 440 version can only have a epoch + # greater than or equal to 0. This will effectively put the LegacyVersion, + # which uses the defacto standard originally implemented by setuptools, + # as before all PEP 440 versions. + epoch = -1 + + # This scheme is taken from pkg_resources.parse_version setuptools prior to + # it's adoption of the packaging library. + parts: List[str] = [] + for part in _parse_version_parts(version.lower()): + if part.startswith("*"): + # remove "-" before a prerelease tag + if part < "*final": + while parts and parts[-1] == "*final-": + parts.pop() + + # remove trailing zeros from each series of numeric parts + while parts and parts[-1] == "00000000": + parts.pop() + + parts.append(part) + + return epoch, tuple(parts) + + +@functools.total_ordering +class LegacyVersion: + _key: LegacyCmpKey + + def __hash__(self) -> int: + return hash(self._key) + + def __init__(self, version: object) -> None: + self._version = str(version) + self._key = _legacy_cmpkey(self._version) + + def __str__(self) -> str: + return self._version + + def __lt__(self, other: object) -> bool: + if isinstance(other, str): + other = LegacyVersion(other) + if not isinstance(other, LegacyVersion): + return NotImplemented + + return self._key < other._key + + def __eq__(self, other: object) -> bool: + if isinstance(other, str): + other = LegacyVersion(other) + if not isinstance(other, LegacyVersion): + return NotImplemented + + return self._key == other._key + + def __repr__(self) -> str: + return "".format(repr(str(self))) + + +LooseVersion = LegacyVersion diff --git a/src/libtmux/common.py b/src/libtmux/common.py index 430053963..eda8ea5aa 100644 --- a/src/libtmux/common.py +++ b/src/libtmux/common.py @@ -11,11 +11,10 @@ import subprocess import sys import typing as t -from distutils.version import LooseVersion from typing import Dict, Generic, KeysView, List, Optional, TypeVar, Union, overload from . import exc -from ._compat import console_to_str, str_from_console +from ._compat import LooseVersion, console_to_str, str_from_console if t.TYPE_CHECKING: from typing_extensions import Literal diff --git a/tests/test_common.py b/tests/test_common.py index d09d21e84..da9bcdeea 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -3,12 +3,12 @@ import re import sys import typing as t -from distutils.version import LooseVersion from typing import Optional import pytest import libtmux +from libtmux._compat import LooseVersion from libtmux.common import ( TMUX_MAX_VERSION, TMUX_MIN_VERSION, diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 000000000..dc4af269d --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,70 @@ +import operator +import typing as t +from contextlib import nullcontext as does_not_raise + +import pytest + +from libtmux._compat import LooseVersion + +if t.TYPE_CHECKING: + from _pytest.python_api import RaisesContext + from typing_extensions import TypeAlias + + VersionCompareOp: TypeAlias = t.Callable[ + [t.Any, t.Any], + bool, + ] + + +@pytest.mark.parametrize( + "version", + [ + "1", + "1.0", + "1.0.0", + "1.0.0b", + "1.0.0b1", + "1.0.0b-openbsd", + "1.0.0-next", + "1.0.0-next.1", + ], +) +def test_version(version: str) -> None: + assert LooseVersion(version) + + +class VersionCompareFixture(t.NamedTuple): + a: object + op: "VersionCompareOp" + b: object + raises: t.Union[t.Type[Exception], bool] + + +@pytest.mark.parametrize( + VersionCompareFixture._fields, + [ + VersionCompareFixture(a="1", op=operator.eq, b="1", raises=False), + VersionCompareFixture(a="1", op=operator.eq, b="1.0", raises=False), + VersionCompareFixture(a="1", op=operator.eq, b="1.0.0", raises=False), + VersionCompareFixture(a="1", op=operator.gt, b="1.0.0a", raises=False), + VersionCompareFixture(a="1", op=operator.gt, b="1.0.0b", raises=False), + VersionCompareFixture(a="1", op=operator.lt, b="1.0.0p1", raises=False), + VersionCompareFixture(a="1", op=operator.lt, b="1.0.0-openbsd", raises=False), + VersionCompareFixture(a="1", op=operator.lt, b="1", raises=AssertionError), + VersionCompareFixture(a="1", op=operator.lt, b="1", raises=AssertionError), + VersionCompareFixture(a="1.0.0c", op=operator.gt, b="1.0.0b", raises=False), + ], +) +def test_version_compare( + a: str, + op: "VersionCompareOp", + b: str, + raises: t.Union[t.Type[Exception], bool], +) -> None: + raises_ctx: "RaisesContext[Exception]" = ( + pytest.raises(t.cast(t.Type[Exception], raises)) + if raises + else t.cast("RaisesContext[Exception]", does_not_raise()) + ) + with raises_ctx: + assert op(LooseVersion(a), LooseVersion(b))