From 061a51359c2f792ddf160ff9bfef94c6439f768e Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Mon, 19 Jan 2026 08:23:20 +0900 Subject: [PATCH 01/11] 2026-01-19 08:23:20 (Mon) > DW-Mac > derekwan From 4b77aa50eb6d69a19bbdcac04f674386a2268513 Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Mon, 19 Jan 2026 08:23:34 +0900 Subject: [PATCH 02/11] 2026-01-19 08:23:34 (Mon) > DW-Mac > derekwan --- .bumpversion.toml | 2 +- pyproject.toml | 4 ++-- src/utilities/__init__.py | 2 +- src/utilities/subprocess.py | 8 ++++++++ uv.lock | 4 ++-- 5 files changed, 14 insertions(+), 6 deletions(-) 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..96b69d9c3 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.0", "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/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/subprocess.py b/src/utilities/subprocess.py index 8a24b7a13..96c03d138 100644 --- a/src/utilities/subprocess.py +++ b/src/utilities/subprocess.py @@ -1745,6 +1745,14 @@ def uv_index_cmd(*, index: MaybeSequenceStr | None = None) -> list[str]: ## +def uv_pip_list_cmd(*, index: MaybeSequenceStr | None = None) -> list[str]: + """Generate the `--index` command if necessary.""" + return [] if index is None else ["--index", ",".join(always_iterable(index))] + + +## + + 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 [] diff --git a/uv.lock b/uv.lock index f48ae994e..501082942 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.0" }, { name = "pyright", specifier = ">=1.1.408" }, { name = "pytest-cov", specifier = ">=7.0.0" }, { name = "pytest-timeout", specifier = ">=2.4.0" }, From 83d5564b4eb36b4c643e3e204ef273f903eb6ddc Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Mon, 19 Jan 2026 08:29:29 +0900 Subject: [PATCH 03/11] 2026-01-19 08:29:29 (Mon) > DW-Mac > derekwan --- src/tests/test_subprocess.py | 17 +++++++++++++++++ src/utilities/subprocess.py | 32 +++++++++++++++++++++++++++++--- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/tests/test_subprocess.py b/src/tests/test_subprocess.py index 57d8ebc86..d1c7cb5f6 100644 --- a/src/tests/test_subprocess.py +++ b/src/tests/test_subprocess.py @@ -1759,6 +1759,23 @@ def test_multiple(self) -> None: assert result == expected +class TestUvPipListCmd: + def test_none(self) -> None: + result = uv_index_cmd() + expected = [] + assert result == expected + + def test_single(self) -> None: + result = uv_index_cmd(index="index") + expected = ["--index", "index"] + assert result == expected + + def test_multiple(self) -> None: + result = uv_index_cmd(index=["index1", "index2"]) + expected = ["--index", "index1,index2"] + assert result == expected + + class TestUvNativeTLSCmd: def test_none(self) -> None: result = uv_native_tls_cmd() diff --git a/src/utilities/subprocess.py b/src/utilities/subprocess.py index 96c03d138..84199c294 100644 --- a/src/utilities/subprocess.py +++ b/src/utilities/subprocess.py @@ -1745,9 +1745,34 @@ def uv_index_cmd(*, index: MaybeSequenceStr | None = None) -> list[str]: ## -def uv_pip_list_cmd(*, index: MaybeSequenceStr | None = None) -> list[str]: - """Generate the `--index` command if necessary.""" - return [] if index is None else ["--index", ",".join(always_iterable(index))] +type _UVPipListFormat = Literal["columns", "freeze", "json"] + + +def uv_pip_list_cmd( + *, + editable: bool = False, + exclude_editable: bool = False, + format_: _UVPipListFormat = "json", + 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), + ] ## @@ -2424,6 +2449,7 @@ def yield_ssh_temp_dir( "useradd", "useradd_cmd", "uv_native_tls_cmd", + "uv_pip_list_cmd", "uv_run", "uv_run_cmd", "uv_tool_install", From f3692616953a0b5ec3839cad1ea5ed491c0aeadb Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Mon, 19 Jan 2026 08:31:32 +0900 Subject: [PATCH 04/11] 2026-01-19 08:31:32 (Mon) > DW-Mac > derekwan --- src/tests/test_subprocess.py | 72 +++++++++++++++++++++++++++++++----- src/utilities/subprocess.py | 2 +- 2 files changed, 64 insertions(+), 10 deletions(-) diff --git a/src/tests/test_subprocess.py b/src/tests/test_subprocess.py index d1c7cb5f6..814062eee 100644 --- a/src/tests/test_subprocess.py +++ b/src/tests/test_subprocess.py @@ -93,6 +93,7 @@ useradd_cmd, uv_index_cmd, uv_native_tls_cmd, + uv_pip_list_cmd, uv_run_cmd, uv_tool_install_cmd, uv_tool_run_cmd, @@ -1760,19 +1761,72 @@ def test_multiple(self) -> None: class TestUvPipListCmd: - def test_none(self) -> None: - result = uv_index_cmd() - expected = [] + def test_main(self) -> None: + result = uv_pip_list_cmd() + expected = [ + "uv", + "pip", + "list", + "--format", + "columns", + "--strict", + "--managed-python", + ] assert result == expected - def test_single(self) -> None: - result = uv_index_cmd(index="index") - expected = ["--index", "index"] + 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_multiple(self) -> None: - result = uv_index_cmd(index=["index1", "index2"]) - expected = ["--index", "index1,index2"] + 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 diff --git a/src/utilities/subprocess.py b/src/utilities/subprocess.py index 84199c294..573978afc 100644 --- a/src/utilities/subprocess.py +++ b/src/utilities/subprocess.py @@ -1752,7 +1752,7 @@ def uv_pip_list_cmd( *, editable: bool = False, exclude_editable: bool = False, - format_: _UVPipListFormat = "json", + format_: _UVPipListFormat = "columns", outdated: bool = False, index: MaybeSequenceStr | None = None, native_tls: bool = False, From 43dba980d27d2447b76f7d151bca3be7d0b14ff0 Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Mon, 19 Jan 2026 08:48:19 +0900 Subject: [PATCH 05/11] 2026-01-19 08:48:19 (Mon) > DW-Mac > derekwan --- src/tests/test_subprocess.py | 80 +++++++++++++++++++++++++- src/tests/test_version.py | 106 +++++++++++++++++++++++++++++++++-- src/utilities/libcst.py | 2 +- src/utilities/subprocess.py | 79 +++++++++++++++++++++++++- src/utilities/version.py | 4 +- 5 files changed, 261 insertions(+), 10 deletions(-) diff --git a/src/tests/test_subprocess.py b/src/tests/test_subprocess.py index 814062eee..27dcc1e2a 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 ( @@ -33,6 +33,7 @@ RsyncCmdSourcesNotFoundError, _rsync_many_prepare, _ssh_is_strict_checking_error, + _UvPipListOutput, append_text, apt_install_cmd, apt_remove_cmd, @@ -93,9 +94,11 @@ 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, uv_tool_run_cmd, uv_with_cmd, yield_git_repo, @@ -103,6 +106,7 @@ ) from utilities.tempfile import TemporaryDirectory, TemporaryFile from utilities.text import strip_and_dedent, unique_str +from utilities.typing import is_sequence_of if TYPE_CHECKING: from pytest import CaptureFixture @@ -1760,6 +1764,80 @@ 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(1, rel=0.1) + assert is_sequence_of(result, _UvPipListOutput) + + 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 TestUvPipListCmd: def test_main(self) -> None: result = uv_pip_list_cmd() diff --git a/src/tests/test_version.py b/src/tests/test_version.py index 07d280419..52c139a12 100644 --- a/src/tests/test_version.py +++ b/src/tests/test_version.py @@ -1,15 +1,32 @@ from __future__ import annotations +from typing import TYPE_CHECKING + +from hypothesis import HealthCheck, Phase, given, reproduce_failure, settings +from pytest import RaisesGroup, approx, fixture, mark, param, raises, skip + +from utilities.contextvars import set_global_breakpoint + +if TYPE_CHECKING: + from pytest_benchmark.fixture import BenchmarkFixture + from pytest_lazy_fixtures import lf + from pytest_regressions.dataframe_regression import DataFrameRegressionFixture + + from re import search from typing import TYPE_CHECKING -from hypothesis import given from hypothesis.strategies import booleans, integers, none -from pytest import raises -from utilities.hypothesis import sentinels, text_ascii, version3s +from utilities.hypothesis import sentinels, text_ascii, version2s, version3s from utilities.version import ( + Version2, Version3, + _Version2EmptySuffixError, + _Version2NegativeMajorVersionError, + _Version2NegativeMinorVersionError, + _Version2ParseError, + _Version2ZeroError, _Version3EmptySuffixError, _Version3NegativeMajorVersionError, _Version3NegativeMinorVersionError, @@ -23,7 +40,88 @@ from utilities.constants import Sentinel -class TestVersion: +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/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 573978afc..70904c5ff 100644 --- a/src/utilities/subprocess.py +++ b/src/utilities/subprocess.py @@ -1,7 +1,9 @@ from __future__ import annotations +import json import shutil import sys +from contextlib import suppress from dataclasses import dataclass from io import StringIO from itertools import chain, repeat @@ -31,6 +33,13 @@ from utilities.tempfile import TemporaryDirectory from utilities.text import strip_and_dedent from utilities.time import sleep +from utilities.version import ( + Version2, + Version2Error, + Version3, + _Version2ParseError, + _Version3ParseError, +) if TYPE_CHECKING: from collections.abc import Callable, Iterator @@ -1745,14 +1754,80 @@ def uv_index_cmd(*, index: MaybeSequenceStr | None = None) -> list[str]: ## -type _UVPipListFormat = Literal["columns", "freeze", "json"] +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, + format_: _UvPipListFormat = "columns", + outdated: bool = False, + index: MaybeSequenceStr | None = None, + native_tls: bool = False, +) -> list[_UvPipListOutput]: + """List packages installed in an environment.""" + text = run( + *uv_pip_list_cmd( + editable=editable, + exclude_editable=exclude_editable, + format_="json", + index=index, + native_tls=native_tls, + ), + return_=True, + ) + dicts = json.loads(text) + outputs: list[_UvPipListOutput] = [] + for dict_ in dicts: + try: + version = Version2.parse(dict_["version"]) + except _Version2ParseError: + try: + version = Version3.parse(dict_["version"]) + except _Version3ParseError: + raise _UvPipListError(data=dict_) + try: + location = Path(dict_["editable_project_location"]) + except KeyError: + location = None + outputs.append( + _UvPipListOutput( + name=dict_["name"], version=version, editable_project_location=location + ) + ) + assert 0, dicts_[:3] + 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_pip_list_cmd( *, editable: bool = False, exclude_editable: bool = False, - format_: _UVPipListFormat = "columns", + format_: _UvPipListFormat = "columns", outdated: bool = False, index: MaybeSequenceStr | None = None, native_tls: bool = False, diff --git a/src/utilities/version.py b/src/utilities/version.py index a34c8061e..94e9fc111 100644 --- a/src/utilities/version.py +++ b/src/utilities/version.py @@ -17,7 +17,7 @@ ## -_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 +26,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: From 7eff3b0688227a68fc59ae0739c0cfae3ec74703 Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Mon, 19 Jan 2026 08:55:33 +0900 Subject: [PATCH 06/11] 2026-01-19 08:55:33 (Mon) > DW-Mac > derekwan --- src/tests/test_version.py | 20 +++++++++++++++++++ src/utilities/subprocess.py | 38 ++++++++++++++++++++++++------------- src/utilities/version.py | 26 +++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 13 deletions(-) diff --git a/src/tests/test_version.py b/src/tests/test_version.py index 52c139a12..82dad28d2 100644 --- a/src/tests/test_version.py +++ b/src/tests/test_version.py @@ -20,7 +20,9 @@ from utilities.hypothesis import sentinels, text_ascii, version2s, version3s from utilities.version import ( + ParseVersion2Or3Error, Version2, + Version2Or3, Version3, _Version2EmptySuffixError, _Version2NegativeMajorVersionError, @@ -33,6 +35,7 @@ _Version3NegativePatchVersionError, _Version3ParseError, _Version3ZeroError, + parse_version_2_or_3, to_version3, ) @@ -40,6 +43,23 @@ from utilities.constants import Sentinel +class TestParseVersion2Or3Error: + @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: diff --git a/src/utilities/subprocess.py b/src/utilities/subprocess.py index 70904c5ff..3ec86b5dc 100644 --- a/src/utilities/subprocess.py +++ b/src/utilities/subprocess.py @@ -26,7 +26,7 @@ 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 @@ -1776,19 +1776,26 @@ def uv_pip_list( native_tls: bool = False, ) -> list[_UvPipListOutput]: """List packages installed in an environment.""" - text = run( - *uv_pip_list_cmd( + cmds_base, cmds_outdated = [ + uv_pip_list_cmd( editable=editable, exclude_editable=exclude_editable, format_="json", + outdated=outdated, index=index, native_tls=native_tls, - ), - return_=True, - ) - dicts = json.loads(text) + ) + 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] + ] outputs: list[_UvPipListOutput] = [] - for dict_ in dicts: + for dict_ in dicts_base: + name = dict_["name"] try: version = Version2.parse(dict_["version"]) except _Version2ParseError: @@ -1800,12 +1807,17 @@ def uv_pip_list( location = Path(dict_["editable_project_location"]) except KeyError: location = None - outputs.append( - _UvPipListOutput( - name=dict_["name"], version=version, editable_project_location=location - ) + try: + latest = one(d for d in dicts_outdated if d["name"] == name) + except OneEmptyError: + latest_version = latest_filetype = None + else: + z + output_i = _UvPipListOutput( + name=dict_["name"], version=version, editable_project_location=location ) - assert 0, dicts_[:3] + outputs.append(output_i) + assert 0, outputs[:3] args: list[str] = ["uv", "pip", "list"] if editable: args.append("--editable") diff --git a/src/utilities/version.py b/src/utilities/version.py index 94e9fc111..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,6 +12,7 @@ type Version2Like = MaybeStr[Version2] type Version3Like = MaybeStr[Version3] +type Version2Or3 = Version2 | Version3 type MaybeCallableVersion3Like = MaybeCallable[Version3Like] @@ -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", ] From 2f434a1b5531e0bc6e1dc514d38ccf678ed0a197 Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Mon, 19 Jan 2026 09:07:51 +0900 Subject: [PATCH 07/11] 2026-01-19 09:07:51 (Mon) > DW-Mac > derekwan --- src/tests/test_subprocess.py | 107 ++++++++++++++++------------------- src/utilities/subprocess.py | 98 +++++++++++++++++--------------- 2 files changed, 101 insertions(+), 104 deletions(-) diff --git a/src/tests/test_subprocess.py b/src/tests/test_subprocess.py index 27dcc1e2a..c1f5cf78e 100644 --- a/src/tests/test_subprocess.py +++ b/src/tests/test_subprocess.py @@ -15,6 +15,7 @@ EFFECTIVE_USER_NAME, HOME, MINUTE, + PWD, SECOND, ) from utilities.iterables import one @@ -33,6 +34,9 @@ RsyncCmdSourcesNotFoundError, _rsync_many_prepare, _ssh_is_strict_checking_error, + _uv_pip_list_assemble_output, + _UvPipListBaseError, + _UvPipListOutdatedError, _UvPipListOutput, append_text, apt_install_cmd, @@ -98,7 +102,6 @@ uv_pip_list_cmd, uv_run_cmd, uv_tool_install_cmd, - uv_tool_run, uv_tool_run_cmd, uv_with_cmd, yield_git_repo, @@ -107,6 +110,7 @@ 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 @@ -1768,74 +1772,61 @@ class TestUvPipList: @skipif_ci def test_main(self) -> None: result = uv_pip_list() - assert len(result) == approx(1, rel=0.1) + assert len(result) == approx(159, rel=0.1) assert is_sequence_of(result, _UvPipListOutput) - expected = [ - "uv", - "pip", - "list", - "--format", - "columns", - "--strict", - "--managed-python", - ] + +class TestUvPipListAsembleOutput: + 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: - result = uv_pip_list_cmd(editable=True) - expected = [ - "uv", - "pip", - "list", - "--editable", - "--format", - "columns", - "--strict", - "--managed-python", - ] + 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_exclude_editable(self) -> None: - result = uv_pip_list_cmd(exclude_editable=True) - expected = [ - "uv", - "pip", - "list", - "--exclude-editable", - "--format", - "columns", - "--strict", - "--managed-python", + 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_format_(self) -> None: - result = uv_pip_list_cmd(format_="json") - expected = [ - "uv", - "pip", - "list", - "--format", - "json", - "--strict", - "--managed-python", - ] - 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_outdated(self) -> None: - result = uv_pip_list_cmd(outdated=True) - expected = [ - "uv", - "pip", - "list", - "--format", - "columns", - "--outdated", - "--strict", - "--managed-python", - ] - assert result == expected + 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: diff --git a/src/utilities/subprocess.py b/src/utilities/subprocess.py index 3ec86b5dc..8b51844e9 100644 --- a/src/utilities/subprocess.py +++ b/src/utilities/subprocess.py @@ -3,7 +3,6 @@ import json import shutil import sys -from contextlib import suppress from dataclasses import dataclass from io import StringIO from itertools import chain, repeat @@ -34,15 +33,14 @@ from utilities.text import strip_and_dedent from utilities.time import sleep from utilities.version import ( + ParseVersion2Or3Error, Version2, - Version2Error, Version3, - _Version2ParseError, - _Version3ParseError, + 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 ( @@ -1770,8 +1768,6 @@ def uv_pip_list( *, editable: bool = False, exclude_editable: bool = False, - format_: _UvPipListFormat = "columns", - outdated: bool = False, index: MaybeSequenceStr | None = None, native_tls: bool = False, ) -> list[_UvPipListOutput]: @@ -1793,46 +1789,54 @@ def uv_pip_list( dicts_base, dicts_outdated = [ json.loads(text) for text in [text_base, text_outdated] ] - outputs: list[_UvPipListOutput] = [] - for dict_ in dicts_base: - name = dict_["name"] - try: - version = Version2.parse(dict_["version"]) - except _Version2ParseError: - try: - version = Version3.parse(dict_["version"]) - except _Version3ParseError: - raise _UvPipListError(data=dict_) - try: - location = Path(dict_["editable_project_location"]) - except KeyError: - location = None + 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 = one(d for d in dicts_outdated if d["name"] == name) - except OneEmptyError: - latest_version = latest_filetype = None - else: - z - output_i = _UvPipListOutput( - name=dict_["name"], version=version, editable_project_location=location - ) - outputs.append(output_i) - assert 0, outputs[:3] - 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), - ] + 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( @@ -2471,6 +2475,7 @@ def yield_ssh_temp_dir( "RsyncCmdError", "RsyncCmdNoSourcesError", "RsyncCmdSourcesNotFoundError", + "UvPipListError", "append_text", "apt_install", "apt_install_cmd", @@ -2536,6 +2541,7 @@ def yield_ssh_temp_dir( "useradd", "useradd_cmd", "uv_native_tls_cmd", + "uv_pip_list", "uv_pip_list_cmd", "uv_run", "uv_run_cmd", From 8fef94ea5e5db0701448ca78eeb601d91b92301f Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Mon, 19 Jan 2026 09:08:33 +0900 Subject: [PATCH 08/11] 2026-01-19 09:08:33 (Mon) > DW-Mac > derekwan --- pyproject.toml | 2 +- src/tests/test_version.py | 15 ++------------- uv.lock | 2 +- 3 files changed, 4 insertions(+), 15 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 96b69d9c3..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.184.0", + "dycw-utilities[test]>=0.184.1", "pyright>=1.1.408", "pytest-cov>=7.0.0", "pytest-timeout>=2.4.0", diff --git a/src/tests/test_version.py b/src/tests/test_version.py index 82dad28d2..ff6480fa7 100644 --- a/src/tests/test_version.py +++ b/src/tests/test_version.py @@ -1,22 +1,11 @@ from __future__ import annotations -from typing import TYPE_CHECKING - -from hypothesis import HealthCheck, Phase, given, reproduce_failure, settings -from pytest import RaisesGroup, approx, fixture, mark, param, raises, skip - -from utilities.contextvars import set_global_breakpoint - -if TYPE_CHECKING: - from pytest_benchmark.fixture import BenchmarkFixture - from pytest_lazy_fixtures import lf - from pytest_regressions.dataframe_regression import DataFrameRegressionFixture - - from re import search from typing import TYPE_CHECKING +from hypothesis import given from hypothesis.strategies import booleans, integers, none +from pytest import mark, param, raises from utilities.hypothesis import sentinels, text_ascii, version2s, version3s from utilities.version import ( diff --git a/uv.lock b/uv.lock index 501082942..0755e73db 100644 --- a/uv.lock +++ b/uv.lock @@ -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.184.0" }, + { 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" }, From d005b38a80a085a3e8e0de6c9dc0dc064a34b09a Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Mon, 19 Jan 2026 09:09:07 +0900 Subject: [PATCH 09/11] 2026-01-19 09:09:07 (Mon) > DW-Mac > derekwan --- src/tests/test_subprocess.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tests/test_subprocess.py b/src/tests/test_subprocess.py index c1f5cf78e..cc841d631 100644 --- a/src/tests/test_subprocess.py +++ b/src/tests/test_subprocess.py @@ -1776,7 +1776,7 @@ def test_main(self) -> None: assert is_sequence_of(result, _UvPipListOutput) -class TestUvPipListAsembleOutput: +class TestUvPipListAssembleOutput: def test_main(self) -> None: dict_ = {"name": "name", "version": "0.0.1"} outdated = [] From af735ab17dc78415b5307961882ab6f0e44352fa Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Mon, 19 Jan 2026 09:09:25 +0900 Subject: [PATCH 10/11] 2026-01-19 09:09:25 (Mon) > DW-Mac > derekwan --- src/tests/test_subprocess.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tests/test_subprocess.py b/src/tests/test_subprocess.py index cc841d631..fbf673a03 100644 --- a/src/tests/test_subprocess.py +++ b/src/tests/test_subprocess.py @@ -1871,7 +1871,7 @@ def test_exclude_editable(self) -> None: ] assert result == expected - def test_format_(self) -> None: + def test_format(self) -> None: result = uv_pip_list_cmd(format_="json") expected = [ "uv", From 0b677dcf0dd83c2ca332411229cab9e501d9553c Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Mon, 19 Jan 2026 09:09:42 +0900 Subject: [PATCH 11/11] 2026-01-19 09:09:42 (Mon) > DW-Mac > derekwan --- src/tests/test_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tests/test_version.py b/src/tests/test_version.py index ff6480fa7..e5bc6a46e 100644 --- a/src/tests/test_version.py +++ b/src/tests/test_version.py @@ -32,7 +32,7 @@ from utilities.constants import Sentinel -class TestParseVersion2Or3Error: +class TestParseVersion2Or3: @mark.parametrize( ("text", "expected"), [param("0.1", Version2(0, 1)), param("0.0.1", Version3(0, 0, 1))],