From 896056ee53475c92719ae995344acac7881809d7 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 11 Jan 2022 07:01:18 -0600 Subject: [PATCH 1/6] Custom Version implementation --- src/libtmux/_compat.py | 104 +++++++++++++++++++++++++++++++++++++++++ tests/test_version.py | 43 +++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 tests/test_version.py 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/tests/test_version.py b/tests/test_version.py new file mode 100644 index 000000000..8250d019e --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,43 @@ +import operator +from contextlib import nullcontext as does_not_raise + +import pytest + +from libtmux._compat import LooseVersion + + +@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): + assert LooseVersion(version) + + +@pytest.mark.parametrize( + "version,op,versionb,raises", + [ + ["1", operator.eq, "1", False], + ["1", operator.eq, "1.0", False], + ["1", operator.eq, "1.0.0", False], + ["1", operator.gt, "1.0.0a", False], + ["1", operator.gt, "1.0.0b", False], + ["1", operator.lt, "1.0.0p1", False], + ["1", operator.lt, "1.0.0-openbsd", False], + ["1", operator.lt, "1", AssertionError], + ["1", operator.lt, "1", AssertionError], + ], +) +def test_version_compare(version, op, versionb, raises): + raises_ctx = pytest.raises(raises) if raises else does_not_raise() + with raises_ctx: + assert op(LooseVersion(version), LooseVersion(versionb)) From ac1f16a7f9f548c58e428035678c9c7ace6f02be Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 10 Dec 2022 08:48:37 -0600 Subject: [PATCH 2/6] test(common): Use LooseVersion from compat --- tests/test_common.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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, From 7864b10f6981bcaaae8cc285adf08ccf633acd3c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 10 Dec 2022 10:56:25 -0600 Subject: [PATCH 3/6] chore(test_version): Updates for mypy --- tests/test_version.py | 55 ++++++++++++++++++++++++++++++++----------- 1 file changed, 41 insertions(+), 14 deletions(-) diff --git a/tests/test_version.py b/tests/test_version.py index 8250d019e..dc4af269d 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -1,10 +1,20 @@ 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", @@ -19,25 +29,42 @@ "1.0.0-next.1", ], ) -def test_version(version): +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( - "version,op,versionb,raises", + VersionCompareFixture._fields, [ - ["1", operator.eq, "1", False], - ["1", operator.eq, "1.0", False], - ["1", operator.eq, "1.0.0", False], - ["1", operator.gt, "1.0.0a", False], - ["1", operator.gt, "1.0.0b", False], - ["1", operator.lt, "1.0.0p1", False], - ["1", operator.lt, "1.0.0-openbsd", False], - ["1", operator.lt, "1", AssertionError], - ["1", operator.lt, "1", AssertionError], + 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(version, op, versionb, raises): - raises_ctx = pytest.raises(raises) if raises else does_not_raise() +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(version), LooseVersion(versionb)) + assert op(LooseVersion(a), LooseVersion(b)) From 887086e0ee7515057e13b6e649fc5448469d58c1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 10 Dec 2022 13:26:51 -0600 Subject: [PATCH 4/6] chore(common): Use compat LooseVersion --- src/libtmux/common.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 From 094e624538c63b7af919e6aa75815c1abf006866 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 10 Dec 2022 14:00:53 -0600 Subject: [PATCH 5/6] ci: Remove pytest filterwarnings --- setup.cfg | 1 - 1 file changed, 1 deletion(-) 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 From 68443d6ac54f274a24ab19d190afba4674441d21 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 10 Dec 2022 14:19:06 -0600 Subject: [PATCH 6/6] docs(CHANGES): Note update --- CHANGES | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) 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