diff --git a/.bumpversion.toml b/.bumpversion.toml index 29dcf9a2d..b809d6541 100644 --- a/.bumpversion.toml +++ b/.bumpversion.toml @@ -1,6 +1,6 @@ [tool.bumpversion] allow_dirty = true - current_version = "0.184.0" + current_version = "0.184.1" [[tool.bumpversion.files]] filename = "pyproject.toml" diff --git a/pyproject.toml b/pyproject.toml index e2c8c141f..c63bad886 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ "coloredlogs>=15.0.1", "coverage-conditional-plugin>=0.9.0", "dycw-pytest-only>=2.1.1", - "dycw-utilities[test]>=0.183.5", + "dycw-utilities[test]>=0.184.1", "pyright>=1.1.408", "pytest-cov>=7.0.0", "pytest-timeout>=2.4.0", @@ -116,7 +116,7 @@ name = "dycw-utilities" readme = "README.md" requires-python = ">= 3.12" - version = "0.184.0" + version = "0.184.1" [project.entry-points.pytest11] pytest-randomly = "utilities.pytest_plugins.pytest_randomly" diff --git a/src/tests/test_subprocess.py b/src/tests/test_subprocess.py index 57d8ebc86..fbf673a03 100644 --- a/src/tests/test_subprocess.py +++ b/src/tests/test_subprocess.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING from uuid import uuid4 -from pytest import LogCaptureFixture, mark, param, raises +from pytest import LogCaptureFixture, approx, mark, param, raises from pytest_lazy_fixtures import lf from utilities.constants import ( @@ -15,6 +15,7 @@ EFFECTIVE_USER_NAME, HOME, MINUTE, + PWD, SECOND, ) from utilities.iterables import one @@ -33,6 +34,10 @@ RsyncCmdSourcesNotFoundError, _rsync_many_prepare, _ssh_is_strict_checking_error, + _uv_pip_list_assemble_output, + _UvPipListBaseError, + _UvPipListOutdatedError, + _UvPipListOutput, append_text, apt_install_cmd, apt_remove_cmd, @@ -93,6 +98,8 @@ useradd_cmd, uv_index_cmd, uv_native_tls_cmd, + uv_pip_list, + uv_pip_list_cmd, uv_run_cmd, uv_tool_install_cmd, uv_tool_run_cmd, @@ -102,6 +109,8 @@ ) from utilities.tempfile import TemporaryDirectory, TemporaryFile from utilities.text import strip_and_dedent, unique_str +from utilities.typing import is_sequence_of +from utilities.version import Version3 if TYPE_CHECKING: from pytest import CaptureFixture @@ -1759,6 +1768,137 @@ def test_multiple(self) -> None: assert result == expected +class TestUvPipList: + @skipif_ci + def test_main(self) -> None: + result = uv_pip_list() + assert len(result) == approx(159, rel=0.1) + assert is_sequence_of(result, _UvPipListOutput) + + +class TestUvPipListAssembleOutput: + def test_main(self) -> None: + dict_ = {"name": "name", "version": "0.0.1"} + outdated = [] + result = _uv_pip_list_assemble_output(dict_, outdated) + expected = _UvPipListOutput(name="name", version=Version3(0, 0, 1)) + assert result == expected + + def test_editable(self) -> None: + dict_ = { + "name": "name", + "version": "0.0.1", + "editable_project_location": str(PWD), + } + outdated = [] + result = _uv_pip_list_assemble_output(dict_, outdated) + expected = _UvPipListOutput( + name="name", version=Version3(0, 0, 1), editable_project_location=PWD + ) + assert result == expected + + def test_outdated(self) -> None: + dict_ = {"name": "name", "version": "0.0.1"} + outdated = [ + { + "name": "name", + "version": "0.0.1", + "latest_version": "0.0.2", + "latest_filetype": "wheel", + } + ] + result = _uv_pip_list_assemble_output(dict_, outdated) + expected = _UvPipListOutput( + name="name", + version=Version3(0, 0, 1), + latest_version=Version3(0, 0, 2), + latest_filetype="wheel", + ) + assert result == expected + + def test_error_base(self) -> None: + dict_ = {"name": "name", "version": "invalid"} + outdated = [] + with raises(_UvPipListBaseError, match=r"Unable to parse version; got .*"): + _ = _uv_pip_list_assemble_output(dict_, outdated) + + def test_error_outdated(self) -> None: + dict_ = {"name": "name", "version": "0.0.1"} + outdated = [{"name": "name", "latest_version": "invalid"}] + with raises(_UvPipListOutdatedError, match=r"Unable to parse version; got .*"): + _ = _uv_pip_list_assemble_output(dict_, outdated) + + +class TestUvPipListCmd: + def test_main(self) -> None: + result = uv_pip_list_cmd() + expected = [ + "uv", + "pip", + "list", + "--format", + "columns", + "--strict", + "--managed-python", + ] + assert result == expected + + def test_editable(self) -> None: + result = uv_pip_list_cmd(editable=True) + expected = [ + "uv", + "pip", + "list", + "--editable", + "--format", + "columns", + "--strict", + "--managed-python", + ] + assert result == expected + + def test_exclude_editable(self) -> None: + result = uv_pip_list_cmd(exclude_editable=True) + expected = [ + "uv", + "pip", + "list", + "--exclude-editable", + "--format", + "columns", + "--strict", + "--managed-python", + ] + assert result == expected + + def test_format(self) -> None: + result = uv_pip_list_cmd(format_="json") + expected = [ + "uv", + "pip", + "list", + "--format", + "json", + "--strict", + "--managed-python", + ] + assert result == expected + + def test_outdated(self) -> None: + result = uv_pip_list_cmd(outdated=True) + expected = [ + "uv", + "pip", + "list", + "--format", + "columns", + "--outdated", + "--strict", + "--managed-python", + ] + assert result == expected + + class TestUvNativeTLSCmd: def test_none(self) -> None: result = uv_native_tls_cmd() diff --git a/src/tests/test_version.py b/src/tests/test_version.py index 07d280419..e5bc6a46e 100644 --- a/src/tests/test_version.py +++ b/src/tests/test_version.py @@ -5,17 +5,26 @@ from hypothesis import given from hypothesis.strategies import booleans, integers, none -from pytest import raises +from pytest import mark, param, raises -from utilities.hypothesis import sentinels, text_ascii, version3s +from utilities.hypothesis import sentinels, text_ascii, version2s, version3s from utilities.version import ( + ParseVersion2Or3Error, + Version2, + Version2Or3, Version3, + _Version2EmptySuffixError, + _Version2NegativeMajorVersionError, + _Version2NegativeMinorVersionError, + _Version2ParseError, + _Version2ZeroError, _Version3EmptySuffixError, _Version3NegativeMajorVersionError, _Version3NegativeMinorVersionError, _Version3NegativePatchVersionError, _Version3ParseError, _Version3ZeroError, + parse_version_2_or_3, to_version3, ) @@ -23,7 +32,105 @@ from utilities.constants import Sentinel -class TestVersion: +class TestParseVersion2Or3: + @mark.parametrize( + ("text", "expected"), + [param("0.1", Version2(0, 1)), param("0.0.1", Version3(0, 0, 1))], + ) + def test_main(self, *, text: str, expected: Version2Or3) -> None: + result = parse_version_2_or_3(text) + assert result == expected + + def test_error(self) -> None: + with raises( + ParseVersion2Or3Error, + match=r"Unable to parse Version2 or Version3; got '.*'", + ): + _ = parse_version_2_or_3("invalid") + + +class TestVersion2: + @given(version=version2s()) + def test_hashable(self, *, version: Version2) -> None: + _ = hash(version) + + @given(version1=version2s(), version2=version2s()) + def test_orderable(self, *, version1: Version2, version2: Version2) -> None: + assert (version1 <= version2) or (version1 >= version2) + + @given(version=version2s()) + def test_parse(self, *, version: Version2) -> None: + parsed = Version2.parse(str(version)) + assert parsed == version + + @given(version=version2s(suffix=booleans())) + def test_repr(self, *, version: Version2) -> None: + result = repr(version) + assert search(r"^\d+.\d+", result) + + @given(version=version2s()) + def test_bump_major(self, *, version: Version2) -> None: + bumped = version.bump_major() + assert version < bumped + assert bumped.major == version.major + 1 + assert bumped.minor == 0 + assert bumped.suffix is None + + @given(version=version2s()) + def test_bump_minor(self, *, version: Version2) -> None: + bumped = version.bump_minor() + assert version < bumped + assert bumped.major == version.major + assert bumped.minor == version.minor + 1 + assert bumped.suffix is None + + @given(version=version2s(), suffix=text_ascii(min_size=1) | none()) + def test_with_suffix(self, *, version: Version2, suffix: str | None) -> None: + new = version.with_suffix(suffix=suffix) + assert new.major == version.major + assert new.minor == version.minor + assert new.suffix == suffix + + @given(version=version2s()) + def test_error_order(self, *, version: Version2) -> None: + with raises(TypeError): + _ = version <= None + + def test_error_zero(self) -> None: + with raises( + _Version2ZeroError, match=r"Version must be greater than zero; got 0\.0" + ): + _ = Version2(0, 0) + + @given(major=integers(max_value=-1)) + def test_error_negative_major_version(self, *, major: int) -> None: + with raises( + _Version2NegativeMajorVersionError, + match=r"Major version must be non-negative; got .*", + ): + _ = Version2(major=major) + + @given(minor=integers(max_value=-1)) + def test_error_negative_minor_version(self, *, minor: int) -> None: + with raises( + _Version2NegativeMinorVersionError, + match=r"Minor version must be non-negative; got .*", + ): + _ = Version2(minor=minor) + + def test_error_empty_suffix(self) -> None: + with raises( + _Version2EmptySuffixError, match=r"Suffix must be non-empty; got .*" + ): + _ = Version2(suffix="") + + @mark.parametrize("text", [param("invalid"), param("0.0.1")]) + def test_error_parse(self, *, text: str) -> None: + with raises(_Version2ParseError, match=r"Unable to parse version; got '.*'"): + _ = Version2.parse(text) + + +class TestVersion3: @given(version=version3s()) def test_hashable(self, *, version: Version3) -> None: _ = hash(version) diff --git a/src/utilities/__init__.py b/src/utilities/__init__.py index 8278ef256..c57282b60 100644 --- a/src/utilities/__init__.py +++ b/src/utilities/__init__.py @@ -1,3 +1,3 @@ from __future__ import annotations -__version__ = "0.184.0" +__version__ = "0.184.1" diff --git a/src/utilities/libcst.py b/src/utilities/libcst.py index 2c1a6388f..659478b0b 100644 --- a/src/utilities/libcst.py +++ b/src/utilities/libcst.py @@ -72,7 +72,7 @@ def __str__(self) -> str: ## -@dataclass(kw_only=True, slots=True) +@dataclass(order=True, unsafe_hash=True, kw_only=True, slots=True) class _ParseImportOutput: module: str name: str | None = None diff --git a/src/utilities/subprocess.py b/src/utilities/subprocess.py index 8a24b7a13..8b51844e9 100644 --- a/src/utilities/subprocess.py +++ b/src/utilities/subprocess.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import shutil import sys from dataclasses import dataclass @@ -24,16 +25,22 @@ from utilities.contextlib import enhanced_context_manager from utilities.errors import ImpossibleCaseError from utilities.functions import in_timedelta -from utilities.iterables import always_iterable +from utilities.iterables import OneEmptyError, always_iterable, one from utilities.logging import to_logger from utilities.pathlib import file_or_dir from utilities.permissions import Permissions, ensure_perms from utilities.tempfile import TemporaryDirectory from utilities.text import strip_and_dedent from utilities.time import sleep +from utilities.version import ( + ParseVersion2Or3Error, + Version2, + Version3, + parse_version_2_or_3, +) if TYPE_CHECKING: - from collections.abc import Callable, Iterator + from collections.abc import Callable, Iterable, Iterator from utilities.permissions import PermissionsLike from utilities.types import ( @@ -1745,6 +1752,123 @@ def uv_index_cmd(*, index: MaybeSequenceStr | None = None) -> list[str]: ## +type _UvPipListFormat = Literal["columns", "freeze", "json"] + + +@dataclass(order=True, unsafe_hash=True, kw_only=True, slots=True) +class _UvPipListOutput: + name: str + version: Version2 | Version3 + editable_project_location: Path | None = None + latest_version: Version2 | Version3 | None = None + latest_filetype: str | None = None + + +def uv_pip_list( + *, + editable: bool = False, + exclude_editable: bool = False, + index: MaybeSequenceStr | None = None, + native_tls: bool = False, +) -> list[_UvPipListOutput]: + """List packages installed in an environment.""" + cmds_base, cmds_outdated = [ + uv_pip_list_cmd( + editable=editable, + exclude_editable=exclude_editable, + format_="json", + outdated=outdated, + index=index, + native_tls=native_tls, + ) + for outdated in [False, True] + ] + text_base, text_outdated = [ + run(*cmds, return_=True) for cmds in [cmds_base, cmds_outdated] + ] + dicts_base, dicts_outdated = [ + json.loads(text) for text in [text_base, text_outdated] + ] + return [_uv_pip_list_assemble_output(d, dicts_outdated) for d in dicts_base] + + +def _uv_pip_list_assemble_output( + dict_: StrMapping, outdated: Iterable[StrMapping], / +) -> _UvPipListOutput: + name = dict_["name"] + try: + version = parse_version_2_or_3(dict_["version"]) + except ParseVersion2Or3Error: + raise _UvPipListBaseError(data=dict_) from None + try: + location = Path(dict_["editable_project_location"]) + except KeyError: + location = None + try: + outdated_i = one(d for d in outdated if d["name"] == name) + except OneEmptyError: + latest_version = latest_filetype = None + else: + try: + latest_version = parse_version_2_or_3(outdated_i["latest_version"]) + except ParseVersion2Or3Error: + raise _UvPipListOutdatedError(data=outdated_i) from None + latest_filetype = outdated_i["latest_filetype"] + return _UvPipListOutput( + name=dict_["name"], + version=version, + editable_project_location=location, + latest_version=latest_version, + latest_filetype=latest_filetype, + ) + + +@dataclass(kw_only=True, slots=True) +class UvPipListError(Exception): + data: StrMapping + + @override + def __str__(self) -> str: + return f"Unable to parse version; got {self.data}" + + +@dataclass(kw_only=True, slots=True) +class _UvPipListBaseError(UvPipListError): ... + + +class _UvPipListOutdatedError(UvPipListError): ... + + +def uv_pip_list_cmd( + *, + editable: bool = False, + exclude_editable: bool = False, + format_: _UvPipListFormat = "columns", + outdated: bool = False, + index: MaybeSequenceStr | None = None, + native_tls: bool = False, +) -> list[str]: + """Command to use 'uv' to list packages installed in an environment.""" + args: list[str] = ["uv", "pip", "list"] + if editable: + args.append("--editable") + if exclude_editable: + args.append("--exclude-editable") + args.extend(["--format", format_]) + if outdated: + args.append("--outdated") + return [ + *args, + "--strict", + *uv_index_cmd(index=index), + MANAGED_PYTHON, + *uv_native_tls_cmd(native_tls=native_tls), + ] + + +## + + def uv_native_tls_cmd(*, native_tls: bool = False) -> list[str]: """Generate the `--native-tls` command if necessary.""" return ["--native-tls"] if native_tls else [] @@ -2351,6 +2475,7 @@ def yield_ssh_temp_dir( "RsyncCmdError", "RsyncCmdNoSourcesError", "RsyncCmdSourcesNotFoundError", + "UvPipListError", "append_text", "apt_install", "apt_install_cmd", @@ -2416,6 +2541,8 @@ def yield_ssh_temp_dir( "useradd", "useradd_cmd", "uv_native_tls_cmd", + "uv_pip_list", + "uv_pip_list_cmd", "uv_run", "uv_run_cmd", "uv_tool_install", diff --git a/src/utilities/version.py b/src/utilities/version.py index a34c8061e..141df7276 100644 --- a/src/utilities/version.py +++ b/src/utilities/version.py @@ -2,6 +2,7 @@ import re from collections.abc import Callable +from contextlib import suppress from dataclasses import dataclass, field, replace from functools import total_ordering from typing import Any, Self, assert_never, overload, override @@ -11,13 +12,14 @@ type Version2Like = MaybeStr[Version2] type Version3Like = MaybeStr[Version3] +type Version2Or3 = Version2 | Version3 type MaybeCallableVersion3Like = MaybeCallable[Version3Like] ## -_PARSE_VERSION2_PATTERN = re.compile(r"^(\d+)\.(\d+)(?:-(\w+))?") +_PARSE_VERSION2_PATTERN = re.compile(r"^(\d+)\.(\d+)(?!\.\d)(?:-(\w+))?") @dataclass(repr=False, frozen=True, slots=True) @@ -26,7 +28,7 @@ class Version2: """A version identifier.""" major: int = 0 - minor: int = 0 + minor: int = 1 suffix: str | None = field(default=None, kw_only=True) def __post_init__(self) -> None: @@ -278,6 +280,27 @@ def __str__(self) -> str: ## +def parse_version_2_or_3(text: str, /) -> Version2Or3: + """Parse a string into a Version2 or Version3 object.""" + with suppress(_Version2ParseError): + return Version2.parse(text) + with suppress(_Version3ParseError): + return Version3.parse(text) + raise ParseVersion2Or3Error(text=text) + + +@dataclass(kw_only=True, slots=True) +class ParseVersion2Or3Error(Exception): + text: str + + @override + def __str__(self) -> str: + return f"Unable to parse Version2 or Version3; got {self.text!r}" + + +## + + @overload def to_version3(version: MaybeCallableVersion3Like, /) -> Version3: ... @overload @@ -302,11 +325,14 @@ def to_version3( ## __all__ = [ "MaybeCallableVersion3Like", + "ParseVersion2Or3Error", "Version2", "Version2Error", "Version2Like", + "Version2Or3", "Version3", "Version3Error", "Version3Like", + "parse_version_2_or_3", "to_version3", ] diff --git a/uv.lock b/uv.lock index f48ae994e..0755e73db 100644 --- a/uv.lock +++ b/uv.lock @@ -625,7 +625,7 @@ wheels = [ [[package]] name = "dycw-utilities" -version = "0.184.0" +version = "0.184.1" source = { editable = "." } dependencies = [ { name = "atomicwrites" }, @@ -944,7 +944,7 @@ dev = [ { name = "coloredlogs", specifier = ">=15.0.1" }, { name = "coverage-conditional-plugin", specifier = ">=0.9.0" }, { name = "dycw-pytest-only", specifier = ">=2.1.1" }, - { name = "dycw-utilities", extras = ["test"], specifier = ">=0.183.5" }, + { name = "dycw-utilities", extras = ["test"], specifier = ">=0.184.1" }, { name = "pyright", specifier = ">=1.1.408" }, { name = "pytest-cov", specifier = ">=7.0.0" }, { name = "pytest-timeout", specifier = ">=2.4.0" },