From 8c3bd5fad6c7f8d53e1659404c43812aaea2a60f Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Sun, 18 Jan 2026 09:15:51 +0900 Subject: [PATCH 01/78] 2026-01-18 09:15:51 (Sun) > DW-Mac > derekwan From cecec705bac4832a2b85d918f2c4b904f5f92473 Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Sun, 18 Jan 2026 09:25:50 +0900 Subject: [PATCH 02/78] 2026-01-18 09:25:50 (Sun) > DW-Mac > derekwan --- .bumpversion.toml | 2 +- pyproject.toml | 2 +- src/tests/test_core.py | 43 +++++++++++++++++++++++++++ src/tests/test_pathlib.py | 36 ---------------------- src/utilities/__init__.py | 2 +- src/utilities/atomicwrites.py | 2 +- src/utilities/compression.py | 2 +- src/utilities/core.py | 56 +++++++++++++++++++++++++++++++++++ src/utilities/pathlib.py | 46 +--------------------------- src/utilities/subprocess.py | 2 +- src/utilities/zipfile.py | 2 +- uv.lock | 2 +- 12 files changed, 108 insertions(+), 89 deletions(-) create mode 100644 src/tests/test_core.py create mode 100644 src/utilities/core.py diff --git a/.bumpversion.toml b/.bumpversion.toml index 0e1d0ad08..17cb78525 100644 --- a/.bumpversion.toml +++ b/.bumpversion.toml @@ -1,6 +1,6 @@ [tool.bumpversion] allow_dirty = true - current_version = "0.184.6" + current_version = "0.183.6" [[tool.bumpversion.files]] filename = "pyproject.toml" diff --git a/pyproject.toml b/pyproject.toml index 569d76dd3..5e597042d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -116,7 +116,7 @@ name = "dycw-utilities" readme = "README.md" requires-python = ">= 3.12" - version = "0.184.6" + version = "0.183.6" [project.entry-points.pytest11] pytest-randomly = "utilities.pytest_plugins.pytest_randomly" diff --git a/src/tests/test_core.py b/src/tests/test_core.py new file mode 100644 index 000000000..77ffce5e0 --- /dev/null +++ b/src/tests/test_core.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from os import mkfifo +from typing import TYPE_CHECKING + +from pytest import raises + +from utilities.core import _FileOrDirMissingError, _FileOrDirTypeError, file_or_dir + +if TYPE_CHECKING: + from pathlib import Path + + +class TestFileOrDir: + def test_file(self, *, tmp_path: Path) -> None: + path = tmp_path / "file.txt" + path.touch() + result = file_or_dir(path) + assert result == "file" + + def test_dir(self, *, tmp_path: Path) -> None: + path = tmp_path / "dir" + path.mkdir() + result = file_or_dir(path) + assert result == "dir" + + def test_empty(self, *, tmp_path: Path) -> None: + path = tmp_path / "non-existent" + result = file_or_dir(path) + assert result is None + + def test_error_missing(self, *, tmp_path: Path) -> None: + path = tmp_path / "non-existent" + with raises(_FileOrDirMissingError, match=r"Path does not exist: '.*'"): + _ = file_or_dir(path, exists=True) + + def test_error_type(self, *, tmp_path: Path) -> None: + path = tmp_path / "fifo" + mkfifo(path) + with raises( + _FileOrDirTypeError, match=r"Path is neither a file nor a directory: '.*'" + ): + _ = file_or_dir(path) diff --git a/src/tests/test_pathlib.py b/src/tests/test_pathlib.py index 0f6c534a7..368bfba8d 100644 --- a/src/tests/test_pathlib.py +++ b/src/tests/test_pathlib.py @@ -1,7 +1,6 @@ from __future__ import annotations from dataclasses import dataclass, field -from os import mkfifo from pathlib import Path from typing import TYPE_CHECKING, Self, assert_never @@ -16,8 +15,6 @@ from utilities.pathlib import ( GetPackageRootError, GetRootError, - _FileOrDirMissingError, - _FileOrDirTypeError, _GetRepoRootNotARepoError, _GetTailDisambiguate, _GetTailEmptyError, @@ -25,7 +22,6 @@ _GetTailNonUniqueError, ensure_suffix, expand_path, - file_or_dir, get_file_group, get_file_owner, get_package_root, @@ -80,38 +76,6 @@ def test_main(self, *, path: Path, expected: Path) -> None: assert result == expected -class TestFileOrDir: - def test_file(self, *, tmp_path: Path) -> None: - path = tmp_path / "file.txt" - path.touch() - result = file_or_dir(path) - assert result == "file" - - def test_dir(self, *, tmp_path: Path) -> None: - path = tmp_path / "dir" - path.mkdir() - result = file_or_dir(path) - assert result == "dir" - - def test_empty(self, *, tmp_path: Path) -> None: - path = tmp_path / "non-existent" - result = file_or_dir(path) - assert result is None - - def test_error_missing(self, *, tmp_path: Path) -> None: - path = tmp_path / "non-existent" - with raises(_FileOrDirMissingError, match=r"Path does not exist: '.*'"): - _ = file_or_dir(path, exists=True) - - def test_error_type(self, *, tmp_path: Path) -> None: - path = tmp_path / "fifo" - mkfifo(path) - with raises( - _FileOrDirTypeError, match=r"Path is neither a file nor a directory: '.*'" - ): - _ = file_or_dir(path) - - class TestFileOwnerAndGroup: def test_owner(self, *, tmp_path: Path) -> None: path = tmp_path.joinpath("file.txt") diff --git a/src/utilities/__init__.py b/src/utilities/__init__.py index 5e57e9b66..0a8eac948 100644 --- a/src/utilities/__init__.py +++ b/src/utilities/__init__.py @@ -1,3 +1,3 @@ from __future__ import annotations -__version__ = "0.184.6" +__version__ = "0.183.6" diff --git a/src/utilities/atomicwrites.py b/src/utilities/atomicwrites.py index 7b6fcf7a2..0d1a32808 100644 --- a/src/utilities/atomicwrites.py +++ b/src/utilities/atomicwrites.py @@ -11,8 +11,8 @@ from atomicwrites import replace_atomic from utilities.contextlib import enhanced_context_manager +from utilities.core import file_or_dir from utilities.iterables import transpose -from utilities.pathlib import file_or_dir from utilities.tempfile import TemporaryDirectory, TemporaryFile if TYPE_CHECKING: diff --git a/src/utilities/compression.py b/src/utilities/compression.py index b183dd6fe..ad75dd62d 100644 --- a/src/utilities/compression.py +++ b/src/utilities/compression.py @@ -7,9 +7,9 @@ from utilities.atomicwrites import writer from utilities.contextlib import enhanced_context_manager +from utilities.core import file_or_dir from utilities.errors import ImpossibleCaseError from utilities.iterables import OneEmptyError, OneNonUniqueError, one -from utilities.pathlib import file_or_dir from utilities.tempfile import TemporaryDirectory, TemporaryFile if TYPE_CHECKING: diff --git a/src/utilities/core.py b/src/utilities/core.py new file mode 100644 index 000000000..34862edb9 --- /dev/null +++ b/src/utilities/core.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Literal, overload, override + +if TYPE_CHECKING: + from utilities.types import FileOrDir, PathLike + + +# pathlib + + +@overload +def file_or_dir(path: PathLike, /, *, exists: Literal[True]) -> FileOrDir: ... +@overload +def file_or_dir(path: PathLike, /, *, exists: bool = False) -> FileOrDir | None: ... +def file_or_dir(path: PathLike, /, *, exists: bool = False) -> FileOrDir | None: + """Classify a path as a file, directory or non-existent.""" + path = Path(path) + match path.exists(), path.is_file(), path.is_dir(), exists: + case True, True, False, _: + return "file" + case True, False, True, _: + return "dir" + case False, False, False, True: + raise _FileOrDirMissingError(path=path) + case False, False, False, False: + return None + case _: + raise _FileOrDirTypeError(path=path) + + +@dataclass(kw_only=True, slots=True) +class FileOrDirError(Exception): + path: Path + + +@dataclass(kw_only=True, slots=True) +class _FileOrDirMissingError(FileOrDirError): + @override + def __str__(self) -> str: + return f"Path does not exist: {str(self.path)!r}" + + +@dataclass(kw_only=True, slots=True) +class _FileOrDirTypeError(FileOrDirError): + @override + def __str__(self) -> str: + return f"Path is neither a file nor a directory: {str(self.path)!r}" + + +# tempfile + + +__all__ = ["FileOrDirError", "file_or_dir"] diff --git a/src/utilities/pathlib.py b/src/utilities/pathlib.py index 8c9c94183..b3ab1266e 100644 --- a/src/utilities/pathlib.py +++ b/src/utilities/pathlib.py @@ -19,7 +19,7 @@ if TYPE_CHECKING: from collections.abc import Iterator, Sequence - from utilities.types import FileOrDir, MaybeCallablePathLike, PathLike + from utilities.types import MaybeCallablePathLike, PathLike def ensure_suffix(path: PathLike, suffix: str, /) -> Path: @@ -51,48 +51,6 @@ def expand_path(path: PathLike, /) -> Path: ## -@overload -def file_or_dir(path: PathLike, /, *, exists: Literal[True]) -> FileOrDir: ... -@overload -def file_or_dir(path: PathLike, /, *, exists: bool = False) -> FileOrDir | None: ... -def file_or_dir(path: PathLike, /, *, exists: bool = False) -> FileOrDir | None: - """Classify a path as a file, directory or non-existent.""" - path = Path(path) - match path.exists(), path.is_file(), path.is_dir(), exists: - case True, True, False, _: - return "file" - case True, False, True, _: - return "dir" - case False, False, False, True: - raise _FileOrDirMissingError(path=path) - case False, False, False, False: - return None - case _: - raise _FileOrDirTypeError(path=path) - - -@dataclass(kw_only=True, slots=True) -class FileOrDirError(Exception): - path: Path - - -@dataclass(kw_only=True, slots=True) -class _FileOrDirMissingError(FileOrDirError): - @override - def __str__(self) -> str: - return f"Path does not exist: {str(self.path)!r}" - - -@dataclass(kw_only=True, slots=True) -class _FileOrDirTypeError(FileOrDirError): - @override - def __str__(self) -> str: - return f"Path is neither a file nor a directory: {str(self.path)!r}" - - -## - - def get_file_group(path: PathLike, /) -> str | None: """Get the group of a file.""" return get_gid_name(to_path(path).stat().st_gid) @@ -375,13 +333,11 @@ def to_path( __all__ = [ - "FileOrDirError", "GetPackageRootError", "GetRepoRootError", "GetTailError", "ensure_suffix", "expand_path", - "file_or_dir", "get_file_group", "get_file_owner", "get_package_root", diff --git a/src/utilities/subprocess.py b/src/utilities/subprocess.py index a4539a26c..a3312bdd8 100644 --- a/src/utilities/subprocess.py +++ b/src/utilities/subprocess.py @@ -24,11 +24,11 @@ ) from utilities.constants import HOME, PWD, SECOND from utilities.contextlib import enhanced_context_manager +from utilities.core import file_or_dir from utilities.errors import ImpossibleCaseError from utilities.functions import in_timedelta 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 diff --git a/src/utilities/zipfile.py b/src/utilities/zipfile.py index 4aa322864..9b8683328 100644 --- a/src/utilities/zipfile.py +++ b/src/utilities/zipfile.py @@ -6,8 +6,8 @@ from utilities.atomicwrites import writer from utilities.contextlib import enhanced_context_manager +from utilities.core import file_or_dir from utilities.iterables import OneEmptyError, OneNonUniqueError, one -from utilities.pathlib import file_or_dir from utilities.tempfile import TemporaryDirectory if TYPE_CHECKING: diff --git a/uv.lock b/uv.lock index 02b83be15..234ac272d 100644 --- a/uv.lock +++ b/uv.lock @@ -625,7 +625,7 @@ wheels = [ [[package]] name = "dycw-utilities" -version = "0.184.6" +version = "0.183.6" source = { editable = "." } dependencies = [ { name = "atomicwrites" }, From 88eb1c4f0f20510ddcc19703ca04959257928a1d Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Sun, 18 Jan 2026 09:28:52 +0900 Subject: [PATCH 03/78] 2026-01-18 09:28:52 (Sun) > DW-Mac > derekwan --- src/tests/conftest.py | 2 +- src/tests/test_core.py | 109 +++++++++++++++++++- src/tests/test_hypothesis.py | 2 +- src/tests/test_pathlib.py | 2 +- src/tests/test_subprocess.py | 2 +- src/tests/test_tempfile.py | 104 ------------------- src/utilities/altair.py | 2 +- src/utilities/atomicwrites.py | 3 +- src/utilities/compression.py | 3 +- src/utilities/core.py | 181 +++++++++++++++++++++++++++++++- src/utilities/hypothesis.py | 4 +- src/utilities/subprocess.py | 3 +- src/utilities/tempfile.py | 187 ---------------------------------- src/utilities/zipfile.py | 3 +- 14 files changed, 295 insertions(+), 312 deletions(-) delete mode 100644 src/tests/test_tempfile.py delete mode 100644 src/utilities/tempfile.py diff --git a/src/tests/conftest.py b/src/tests/conftest.py index 111d2f96d..500e06907 100644 --- a/src/tests/conftest.py +++ b/src/tests/conftest.py @@ -11,9 +11,9 @@ from utilities.asyncio import sleep from utilities.constants import IS_CI, IS_CI_AND_NOT_LINUX, MINUTE from utilities.contextlib import enhanced_context_manager +from utilities.core import TemporaryDirectory, TemporaryFile from utilities.pytest import skipif_ci from utilities.re import ExtractGroupError, extract_group -from utilities.tempfile import TemporaryDirectory, TemporaryFile from utilities.whenever import get_now_local_plain if TYPE_CHECKING: diff --git a/src/tests/test_core.py b/src/tests/test_core.py index 77ffce5e0..c4fbab0cf 100644 --- a/src/tests/test_core.py +++ b/src/tests/test_core.py @@ -1,14 +1,19 @@ from __future__ import annotations from os import mkfifo -from typing import TYPE_CHECKING +from pathlib import Path from pytest import raises -from utilities.core import _FileOrDirMissingError, _FileOrDirTypeError, file_or_dir - -if TYPE_CHECKING: - from pathlib import Path +from utilities.core import ( + TemporaryDirectory, + TemporaryFile, + _FileOrDirMissingError, + _FileOrDirTypeError, + file_or_dir, + yield_temp_dir_at, + yield_temp_file_at, +) class TestFileOrDir: @@ -41,3 +46,97 @@ def test_error_type(self, *, tmp_path: Path) -> None: _FileOrDirTypeError, match=r"Path is neither a file nor a directory: '.*'" ): _ = file_or_dir(path) + + +class TestTemporaryDirectory: + def test_main(self) -> None: + temp_dir = TemporaryDirectory() + path = temp_dir.path + assert isinstance(path, Path) + assert path.is_dir() + assert set(path.iterdir()) == set() + + def test_context_manager(self) -> None: + with TemporaryDirectory() as temp: + assert isinstance(temp, Path) + assert temp.is_dir() + assert set(temp.iterdir()) == set() + assert not temp.exists() + + def test_suffix(self) -> None: + with TemporaryDirectory(suffix="suffix") as temp: + assert temp.name.endswith("suffix") + + def test_prefix(self) -> None: + with TemporaryDirectory(prefix="prefix") as temp: + assert temp.name.startswith("prefix") + + def test_dir(self, *, tmp_path: Path) -> None: + with TemporaryDirectory(dir=tmp_path) as temp: + relative = temp.relative_to(tmp_path) + assert len(relative.parts) == 1 + + +class TestTemporaryFile: + def test_main(self) -> None: + with TemporaryFile() as temp: + assert isinstance(temp, Path) + assert temp.is_file() + _ = temp.write_text("text") + assert temp.read_text() == "text" + assert not temp.exists() + + def test_dir(self, *, tmp_path: Path) -> None: + with TemporaryFile(dir=tmp_path) as temp: + relative = temp.relative_to(tmp_path) + assert len(relative.parts) == 1 + + def test_suffix(self) -> None: + with TemporaryFile(suffix="suffix") as temp: + assert temp.name.endswith("suffix") + + def test_dir_and_suffix(self, *, tmp_path: Path) -> None: + with TemporaryFile(dir=tmp_path, suffix="suffix") as temp: + assert temp.name.endswith("suffix") + + def test_prefix(self) -> None: + with TemporaryFile(prefix="prefix") as temp: + assert temp.name.startswith("prefix") + + def test_dir_and_prefix(self, *, tmp_path: Path) -> None: + with TemporaryFile(dir=tmp_path, prefix="prefix") as temp: + assert temp.name.startswith("prefix") + + def test_name(self) -> None: + with TemporaryFile(name="name") as temp: + assert temp.name == "name" + + def test_dir_and_name(self, *, tmp_path: Path) -> None: + with TemporaryFile(dir=tmp_path, name="name") as temp: + assert temp.name == "name" + + def test_data(self) -> None: + data = b"data" + with TemporaryFile(data=data) as temp: + current = temp.read_bytes() + assert current == data + + def test_text(self) -> None: + text = "text" + with TemporaryFile(text=text) as temp: + current = temp.read_text() + assert current == text + + +class TestYieldTempAt: + def test_dir(self, *, temp_path_not_exist: Path) -> None: + with yield_temp_dir_at(temp_path_not_exist) as temp: + assert temp.is_dir() + assert temp.parent == temp_path_not_exist.parent + assert temp.name.startswith(temp_path_not_exist.name) + + def test_file(self, *, temp_path_not_exist: Path) -> None: + with yield_temp_file_at(temp_path_not_exist) as temp: + assert temp.is_file() + assert temp.parent == temp_path_not_exist.parent + assert temp.name.startswith(temp_path_not_exist.name) diff --git a/src/tests/test_hypothesis.py b/src/tests/test_hypothesis.py index 63532385d..0a0229033 100644 --- a/src/tests/test_hypothesis.py +++ b/src/tests/test_hypothesis.py @@ -145,7 +145,7 @@ from collections.abc import Set as AbstractSet from utilities.constants import Sentinel - from utilities.tempfile import TemporaryDirectory + from utilities.core import TemporaryDirectory from utilities.types import Number diff --git a/src/tests/test_pathlib.py b/src/tests/test_pathlib.py index 368bfba8d..57b8f65ca 100644 --- a/src/tests/test_pathlib.py +++ b/src/tests/test_pathlib.py @@ -10,6 +10,7 @@ from utilities.atomicwrites import copy from utilities.constants import HOME, SYSTEM, Sentinel, sentinel +from utilities.core import TemporaryDirectory from utilities.dataclasses import replace_non_sentinel from utilities.hypothesis import git_repos, pairs, paths, temp_paths from utilities.pathlib import ( @@ -34,7 +35,6 @@ temp_cwd, to_path, ) -from utilities.tempfile import TemporaryDirectory if TYPE_CHECKING: from utilities.types import MaybeCallablePathLike, PathLike diff --git a/src/tests/test_subprocess.py b/src/tests/test_subprocess.py index b07a8c23c..2ab035d1d 100644 --- a/src/tests/test_subprocess.py +++ b/src/tests/test_subprocess.py @@ -18,6 +18,7 @@ PWD, SECOND, ) +from utilities.core import TemporaryDirectory, TemporaryFile from utilities.iterables import one from utilities.pathlib import get_file_group, get_file_owner from utilities.permissions import Permissions @@ -109,7 +110,6 @@ yield_git_repo, yield_ssh_temp_dir, ) -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 diff --git a/src/tests/test_tempfile.py b/src/tests/test_tempfile.py deleted file mode 100644 index 8796ea39c..000000000 --- a/src/tests/test_tempfile.py +++ /dev/null @@ -1,104 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -from utilities.tempfile import ( - TemporaryDirectory, - TemporaryFile, - yield_temp_dir_at, - yield_temp_file_at, -) - - -class TestTemporaryDirectory: - def test_main(self) -> None: - temp_dir = TemporaryDirectory() - path = temp_dir.path - assert isinstance(path, Path) - assert path.is_dir() - assert set(path.iterdir()) == set() - - def test_context_manager(self) -> None: - with TemporaryDirectory() as temp: - assert isinstance(temp, Path) - assert temp.is_dir() - assert set(temp.iterdir()) == set() - assert not temp.exists() - - def test_suffix(self) -> None: - with TemporaryDirectory(suffix="suffix") as temp: - assert temp.name.endswith("suffix") - - def test_prefix(self) -> None: - with TemporaryDirectory(prefix="prefix") as temp: - assert temp.name.startswith("prefix") - - def test_dir(self, *, tmp_path: Path) -> None: - with TemporaryDirectory(dir=tmp_path) as temp: - relative = temp.relative_to(tmp_path) - assert len(relative.parts) == 1 - - -class TestTemporaryFile: - def test_main(self) -> None: - with TemporaryFile() as temp: - assert isinstance(temp, Path) - assert temp.is_file() - _ = temp.write_text("text") - assert temp.read_text() == "text" - assert not temp.exists() - - def test_dir(self, *, tmp_path: Path) -> None: - with TemporaryFile(dir=tmp_path) as temp: - relative = temp.relative_to(tmp_path) - assert len(relative.parts) == 1 - - def test_suffix(self) -> None: - with TemporaryFile(suffix="suffix") as temp: - assert temp.name.endswith("suffix") - - def test_dir_and_suffix(self, *, tmp_path: Path) -> None: - with TemporaryFile(dir=tmp_path, suffix="suffix") as temp: - assert temp.name.endswith("suffix") - - def test_prefix(self) -> None: - with TemporaryFile(prefix="prefix") as temp: - assert temp.name.startswith("prefix") - - def test_dir_and_prefix(self, *, tmp_path: Path) -> None: - with TemporaryFile(dir=tmp_path, prefix="prefix") as temp: - assert temp.name.startswith("prefix") - - def test_name(self) -> None: - with TemporaryFile(name="name") as temp: - assert temp.name == "name" - - def test_dir_and_name(self, *, tmp_path: Path) -> None: - with TemporaryFile(dir=tmp_path, name="name") as temp: - assert temp.name == "name" - - def test_data(self) -> None: - data = b"data" - with TemporaryFile(data=data) as temp: - current = temp.read_bytes() - assert current == data - - def test_text(self) -> None: - text = "text" - with TemporaryFile(text=text) as temp: - current = temp.read_text() - assert current == text - - -class TestYieldTempAt: - def test_dir(self, *, temp_path_not_exist: Path) -> None: - with yield_temp_dir_at(temp_path_not_exist) as temp: - assert temp.is_dir() - assert temp.parent == temp_path_not_exist.parent - assert temp.name.startswith(temp_path_not_exist.name) - - def test_file(self, *, temp_path_not_exist: Path) -> None: - with yield_temp_file_at(temp_path_not_exist) as temp: - assert temp.is_file() - assert temp.parent == temp_path_not_exist.parent - assert temp.name.startswith(temp_path_not_exist.name) diff --git a/src/utilities/altair.py b/src/utilities/altair.py index 5a046c5af..c5c9c3a2e 100644 --- a/src/utilities/altair.py +++ b/src/utilities/altair.py @@ -24,9 +24,9 @@ from altair.utils.schemapi import Undefined from utilities.atomicwrites import writer +from utilities.core import TemporaryDirectory from utilities.functions import ensure_bytes, ensure_number from utilities.iterables import always_iterable -from utilities.tempfile import TemporaryDirectory if TYPE_CHECKING: from polars import DataFrame diff --git a/src/utilities/atomicwrites.py b/src/utilities/atomicwrites.py index 0d1a32808..34da59f45 100644 --- a/src/utilities/atomicwrites.py +++ b/src/utilities/atomicwrites.py @@ -11,9 +11,8 @@ from atomicwrites import replace_atomic from utilities.contextlib import enhanced_context_manager -from utilities.core import file_or_dir +from utilities.core import TemporaryDirectory, TemporaryFile, file_or_dir from utilities.iterables import transpose -from utilities.tempfile import TemporaryDirectory, TemporaryFile if TYPE_CHECKING: from collections.abc import Iterator diff --git a/src/utilities/compression.py b/src/utilities/compression.py index ad75dd62d..f5d4aa08d 100644 --- a/src/utilities/compression.py +++ b/src/utilities/compression.py @@ -7,10 +7,9 @@ from utilities.atomicwrites import writer from utilities.contextlib import enhanced_context_manager -from utilities.core import file_or_dir +from utilities.core import TemporaryDirectory, TemporaryFile, file_or_dir from utilities.errors import ImpossibleCaseError from utilities.iterables import OneEmptyError, OneNonUniqueError, one -from utilities.tempfile import TemporaryDirectory, TemporaryFile if TYPE_CHECKING: from collections.abc import Iterator diff --git a/src/utilities/core.py b/src/utilities/core.py index 34862edb9..a4fb9cdf5 100644 --- a/src/utilities/core.py +++ b/src/utilities/core.py @@ -1,10 +1,18 @@ from __future__ import annotations +import tempfile +from contextlib import contextmanager from dataclasses import dataclass from pathlib import Path +from shutil import move +from tempfile import NamedTemporaryFile as _NamedTemporaryFile from typing import TYPE_CHECKING, Literal, overload, override +from warnings import catch_warnings, filterwarnings if TYPE_CHECKING: + from collections.abc import Iterator + from types import TracebackType + from utilities.types import FileOrDir, PathLike @@ -53,4 +61,175 @@ def __str__(self) -> str: # tempfile -__all__ = ["FileOrDirError", "file_or_dir"] +class TemporaryDirectory: + """Wrapper around `TemporaryDirectory` with a `Path` attribute.""" + + def __init__( + self, + *, + suffix: str | None = None, + prefix: str | None = None, + dir: PathLike | None = None, # noqa: A002 + ignore_cleanup_errors: bool = False, + delete: bool = True, + ) -> None: + super().__init__() + self._temp_dir = _TemporaryDirectoryNoResourceWarning( + suffix=suffix, + prefix=prefix, + dir=dir, + ignore_cleanup_errors=ignore_cleanup_errors, + delete=delete, + ) + self.path = Path(self._temp_dir.name) + + def __enter__(self) -> Path: + return Path(self._temp_dir.__enter__()) + + def __exit__( + self, + exc: type[BaseException] | None, + val: BaseException | None, + tb: TracebackType | None, + ) -> None: + self._temp_dir.__exit__(exc, val, tb) + + +class _TemporaryDirectoryNoResourceWarning(tempfile.TemporaryDirectory): + @classmethod + @override + def _cleanup( # pyright: ignore[reportGeneralTypeIssues] + cls, + name: str, + warn_message: str, + ignore_errors: bool = False, + delete: bool = True, + ) -> None: + with catch_warnings(): + filterwarnings("ignore", category=ResourceWarning) + return super()._cleanup( # pyright: ignore[reportAttributeAccessIssue] + name, warn_message, ignore_errors=ignore_errors, delete=delete + ) + + +## + + +@contextmanager +def TemporaryFile( # noqa: N802 + *, + dir: PathLike | None = None, # noqa: A002 + suffix: str | None = None, + prefix: str | None = None, + ignore_cleanup_errors: bool = False, + delete: bool = True, + name: str | None = None, + data: bytes | None = None, + text: str | None = None, +) -> Iterator[Path]: + """Yield a temporary file.""" + if dir is None: + with ( + TemporaryDirectory( + suffix=suffix, + prefix=prefix, + dir=dir, + ignore_cleanup_errors=ignore_cleanup_errors, + delete=delete, + ) as temp_dir, + _temporary_file_outer( + temp_dir, + suffix=suffix, + prefix=prefix, + delete=delete, + name=name, + data=data, + text=text, + ) as temp, + ): + yield temp + else: + with _temporary_file_outer( + dir, + suffix=suffix, + prefix=prefix, + delete=delete, + name=name, + data=data, + text=text, + ) as temp: + yield temp + + +@contextmanager +def _temporary_file_outer( + path: PathLike, + /, + *, + suffix: str | None = None, + prefix: str | None = None, + delete: bool = True, + name: str | None = None, + data: bytes | None = None, + text: str | None = None, +) -> Iterator[Path]: + with _temporary_file_inner( + path, suffix=suffix, prefix=prefix, delete=delete, name=name + ) as temp: + if data is not None: + _ = temp.write_bytes(data) + if text is not None: + _ = temp.write_text(text) + yield temp + + +@contextmanager +def _temporary_file_inner( + path: PathLike, + /, + *, + suffix: str | None = None, + prefix: str | None = None, + delete: bool = True, + name: str | None = None, +) -> Iterator[Path]: + path = Path(path) + with _NamedTemporaryFile( + suffix=suffix, prefix=prefix, dir=path, delete=delete, delete_on_close=False + ) as temp: + if name is None: + yield path / temp.name + else: + _ = move(path / temp.name, path / name) + yield path / name + + +## + + +@contextmanager +def yield_temp_dir_at(path: PathLike, /) -> Iterator[Path]: + """Yield a temporary dir for a target path.""" + + path = Path(path) + with TemporaryDirectory(suffix=".tmp", prefix=path.name, dir=path.parent) as temp: + yield temp + + +@contextmanager +def yield_temp_file_at(path: PathLike, /) -> Iterator[Path]: + """Yield a temporary file for a target path.""" + + path = Path(path) + with TemporaryFile(dir=path.parent, suffix=".tmp", prefix=path.name) as temp: + yield temp + + +__all__ = [ + "FileOrDirError", + "TemporaryDirectory", + "TemporaryFile", + "file_or_dir", + "yield_temp_dir_at", + "yield_temp_file_at", +] diff --git a/src/utilities/hypothesis.py b/src/utilities/hypothesis.py index 2069505cf..e44bc73ff 100644 --- a/src/utilities/hypothesis.py +++ b/src/utilities/hypothesis.py @@ -85,6 +85,7 @@ sentinel, ) from utilities.contextlib import enhanced_context_manager +from utilities.core import TemporaryDirectory from utilities.functions import ( ensure_int, ensure_str, @@ -96,8 +97,7 @@ from utilities.os import get_env_var from utilities.pathlib import module_path, temp_cwd from utilities.permissions import Permissions -from utilities.tempfile import TemporaryDirectory -from utilities.version import Version2, Version3 +from utilities.version import Version from utilities.whenever import ( DATE_DELTA_PARSABLE_MAX, DATE_DELTA_PARSABLE_MIN, diff --git a/src/utilities/subprocess.py b/src/utilities/subprocess.py index a3312bdd8..274dd3fdd 100644 --- a/src/utilities/subprocess.py +++ b/src/utilities/subprocess.py @@ -24,13 +24,12 @@ ) from utilities.constants import HOME, PWD, SECOND from utilities.contextlib import enhanced_context_manager -from utilities.core import file_or_dir +from utilities.core import TemporaryDirectory, file_or_dir from utilities.errors import ImpossibleCaseError from utilities.functions import in_timedelta from utilities.iterables import OneEmptyError, always_iterable, one from utilities.logging import to_logger 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 ( diff --git a/src/utilities/tempfile.py b/src/utilities/tempfile.py deleted file mode 100644 index 4333f864b..000000000 --- a/src/utilities/tempfile.py +++ /dev/null @@ -1,187 +0,0 @@ -from __future__ import annotations - -import tempfile -from contextlib import contextmanager -from pathlib import Path -from shutil import move -from tempfile import NamedTemporaryFile as _NamedTemporaryFile -from typing import TYPE_CHECKING, override -from warnings import catch_warnings, filterwarnings - -if TYPE_CHECKING: - from collections.abc import Iterator - from types import TracebackType - - from utilities.types import PathLike - - -class TemporaryDirectory: - """Wrapper around `TemporaryDirectory` with a `Path` attribute.""" - - def __init__( - self, - *, - suffix: str | None = None, - prefix: str | None = None, - dir: PathLike | None = None, # noqa: A002 - ignore_cleanup_errors: bool = False, - delete: bool = True, - ) -> None: - super().__init__() - self._temp_dir = _TemporaryDirectoryNoResourceWarning( - suffix=suffix, - prefix=prefix, - dir=dir, - ignore_cleanup_errors=ignore_cleanup_errors, - delete=delete, - ) - self.path = Path(self._temp_dir.name) - - def __enter__(self) -> Path: - return Path(self._temp_dir.__enter__()) - - def __exit__( - self, - exc: type[BaseException] | None, - val: BaseException | None, - tb: TracebackType | None, - ) -> None: - self._temp_dir.__exit__(exc, val, tb) - - -class _TemporaryDirectoryNoResourceWarning(tempfile.TemporaryDirectory): - @classmethod - @override - def _cleanup( # pyright: ignore[reportGeneralTypeIssues] - cls, - name: str, - warn_message: str, - ignore_errors: bool = False, - delete: bool = True, - ) -> None: - with catch_warnings(): - filterwarnings("ignore", category=ResourceWarning) - return super()._cleanup( # pyright: ignore[reportAttributeAccessIssue] - name, warn_message, ignore_errors=ignore_errors, delete=delete - ) - - -## - - -@contextmanager -def TemporaryFile( # noqa: N802 - *, - dir: PathLike | None = None, # noqa: A002 - suffix: str | None = None, - prefix: str | None = None, - ignore_cleanup_errors: bool = False, - delete: bool = True, - name: str | None = None, - data: bytes | None = None, - text: str | None = None, -) -> Iterator[Path]: - """Yield a temporary file.""" - if dir is None: - with ( - TemporaryDirectory( - suffix=suffix, - prefix=prefix, - dir=dir, - ignore_cleanup_errors=ignore_cleanup_errors, - delete=delete, - ) as temp_dir, - _temporary_file_outer( - temp_dir, - suffix=suffix, - prefix=prefix, - delete=delete, - name=name, - data=data, - text=text, - ) as temp, - ): - yield temp - else: - with _temporary_file_outer( - dir, - suffix=suffix, - prefix=prefix, - delete=delete, - name=name, - data=data, - text=text, - ) as temp: - yield temp - - -@contextmanager -def _temporary_file_outer( - path: PathLike, - /, - *, - suffix: str | None = None, - prefix: str | None = None, - delete: bool = True, - name: str | None = None, - data: bytes | None = None, - text: str | None = None, -) -> Iterator[Path]: - with _temporary_file_inner( - path, suffix=suffix, prefix=prefix, delete=delete, name=name - ) as temp: - if data is not None: - _ = temp.write_bytes(data) - if text is not None: - _ = temp.write_text(text) - yield temp - - -@contextmanager -def _temporary_file_inner( - path: PathLike, - /, - *, - suffix: str | None = None, - prefix: str | None = None, - delete: bool = True, - name: str | None = None, -) -> Iterator[Path]: - path = Path(path) - temp = _NamedTemporaryFile( # noqa: SIM115 - suffix=suffix, prefix=prefix, dir=path, delete=delete, delete_on_close=False - ) - if name is None: - yield path / temp.name - else: - _ = move(path / temp.name, path / name) - yield path / name - - -## - - -@contextmanager -def yield_temp_dir_at(path: PathLike, /) -> Iterator[Path]: - """Yield a temporary dir for a target path.""" - - path = Path(path) - with TemporaryDirectory(suffix=".tmp", prefix=path.name, dir=path.parent) as temp: - yield temp - - -@contextmanager -def yield_temp_file_at(path: PathLike, /) -> Iterator[Path]: - """Yield a temporary file for a target path.""" - - path = Path(path) - with TemporaryFile(dir=path.parent, suffix=".tmp", prefix=path.name) as temp: - yield temp - - -__all__ = [ - "TemporaryDirectory", - "TemporaryFile", - "yield_temp_dir_at", - "yield_temp_file_at", -] diff --git a/src/utilities/zipfile.py b/src/utilities/zipfile.py index 9b8683328..2de31e23c 100644 --- a/src/utilities/zipfile.py +++ b/src/utilities/zipfile.py @@ -6,9 +6,8 @@ from utilities.atomicwrites import writer from utilities.contextlib import enhanced_context_manager -from utilities.core import file_or_dir +from utilities.core import TemporaryDirectory, file_or_dir from utilities.iterables import OneEmptyError, OneNonUniqueError, one -from utilities.tempfile import TemporaryDirectory if TYPE_CHECKING: from collections.abc import Iterator From e795ae724bf7cbcb621c2a3398c279c6df1dc71b Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Sun, 18 Jan 2026 09:29:43 +0900 Subject: [PATCH 04/78] 2026-01-18 09:29:43 (Sun) > DW-Mac > derekwan --- src/utilities/core.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/utilities/core.py b/src/utilities/core.py index a4fb9cdf5..72e9f0309 100644 --- a/src/utilities/core.py +++ b/src/utilities/core.py @@ -1,10 +1,10 @@ from __future__ import annotations +import shutil import tempfile from contextlib import contextmanager from dataclasses import dataclass from pathlib import Path -from shutil import move from tempfile import NamedTemporaryFile as _NamedTemporaryFile from typing import TYPE_CHECKING, Literal, overload, override from warnings import catch_warnings, filterwarnings @@ -198,9 +198,9 @@ def _temporary_file_inner( suffix=suffix, prefix=prefix, dir=path, delete=delete, delete_on_close=False ) as temp: if name is None: - yield path / temp.name + yield Path(path, temp.name) else: - _ = move(path / temp.name, path / name) + _ = shutil.move(path / temp.name, path / name) yield path / name From d4701b8d72ada1d48d5d9e0eb8a4974d8376c019 Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Sun, 18 Jan 2026 09:34:47 +0900 Subject: [PATCH 05/78] 2026-01-18 09:34:47 (Sun) > DW-Mac > derekwan --- src/tests/test_core.py | 44 ++++++++++++++ src/tests/test_iterables.py | 93 +----------------------------- src/tests/test_orjson.py | 3 +- src/utilities/altair.py | 3 +- src/utilities/core.py | 21 ++++++- src/utilities/docker.py | 2 +- src/utilities/iterables.py | 69 +--------------------- src/utilities/logging.py | 3 +- src/utilities/numpy.py | 3 +- src/utilities/orjson.py | 9 +-- src/utilities/polars.py | 2 +- src/utilities/postgres.py | 2 +- src/utilities/pottery.py | 2 +- src/utilities/pydantic_settings.py | 2 +- src/utilities/redis.py | 3 +- src/utilities/subprocess.py | 3 +- 16 files changed, 82 insertions(+), 182 deletions(-) diff --git a/src/tests/test_core.py b/src/tests/test_core.py index c4fbab0cf..fdacf2bcd 100644 --- a/src/tests/test_core.py +++ b/src/tests/test_core.py @@ -2,7 +2,10 @@ from os import mkfifo from pathlib import Path +from typing import TYPE_CHECKING +from hypothesis import given +from hypothesis.strategies import binary, dictionaries, integers, lists, text from pytest import raises from utilities.core import ( @@ -10,11 +13,52 @@ TemporaryFile, _FileOrDirMissingError, _FileOrDirTypeError, + always_iterable, file_or_dir, yield_temp_dir_at, yield_temp_file_at, ) +if TYPE_CHECKING: + from collections.abc import Iterator + + +class TestAlwaysIterable: + @given(x=binary()) + def test_bytes(self, *, x: bytes) -> None: + assert list(always_iterable(x)) == [x] + + @given(x=dictionaries(text(), integers())) + def test_dict(self, *, x: dict[str, int]) -> None: + assert list(always_iterable(x)) == list(x) + + @given(x=integers()) + def test_integer(self, *, x: int) -> None: + assert list(always_iterable(x)) == [x] + + @given(x=lists(binary())) + def test_list_of_bytes(self, *, x: list[bytes]) -> None: + assert list(always_iterable(x)) == x + + @given(x=text()) + def test_string(self, *, x: str) -> None: + assert list(always_iterable(x)) == [x] + + @given(x=lists(integers())) + def test_list_of_integers(self, *, x: list[int]) -> None: + assert list(always_iterable(x)) == x + + @given(x=lists(text())) + def test_list_of_strings(self, *, x: list[str]) -> None: + assert list(always_iterable(x)) == x + + def test_generator(self) -> None: + def yield_ints() -> Iterator[int]: + yield 0 + yield 1 + + assert list(always_iterable(yield_ints())) == [0, 1] + class TestFileOrDir: def test_file(self, *, tmp_path: Path) -> None: diff --git a/src/tests/test_iterables.py b/src/tests/test_iterables.py index 48efc65f1..fb2c32522 100644 --- a/src/tests/test_iterables.py +++ b/src/tests/test_iterables.py @@ -13,7 +13,6 @@ from hypothesis import given from hypothesis.strategies import ( DataObject, - binary, booleans, data, dictionaries, @@ -26,7 +25,6 @@ permutations, sampled_from, sets, - text, tuples, ) from pytest import mark, param, raises @@ -50,8 +48,6 @@ EnsureIterableNotStrError, MergeStrMappingsError, OneEmptyError, - OneMaybeEmptyError, - OneMaybeNonUniqueError, OneNonUniqueError, OneStrEmptyError, OneStrNonUniqueError, @@ -67,12 +63,10 @@ _RangePartitionsStopError, _RangePartitionsTotalError, _sort_iterable_cmp_floats, - always_iterable, apply_bijection, apply_to_tuple, apply_to_varargs, chain_mappings, - chain_maybe_iterables, chain_nullable, check_bijection, check_duplicates, @@ -103,7 +97,6 @@ merge_sets, merge_str_mappings, one, - one_maybe, one_str, one_unique, pairwise_tail, @@ -120,46 +113,9 @@ from utilities.typing import is_sequence_of if TYPE_CHECKING: - from collections.abc import Iterable, Iterator, Mapping, Sequence + from collections.abc import Iterable, Mapping, Sequence - from utilities.types import MaybeIterable, StrMapping - - -class TestAlwaysIterable: - @given(x=binary()) - def test_bytes(self, *, x: bytes) -> None: - assert list(always_iterable(x)) == [x] - - @given(x=dictionaries(text(), integers())) - def test_dict(self, *, x: dict[str, int]) -> None: - assert list(always_iterable(x)) == list(x) - - @given(x=integers()) - def test_integer(self, *, x: int) -> None: - assert list(always_iterable(x)) == [x] - - @given(x=lists(binary())) - def test_list_of_bytes(self, *, x: list[bytes]) -> None: - assert list(always_iterable(x)) == x - - @given(x=text()) - def test_string(self, *, x: str) -> None: - assert list(always_iterable(x)) == [x] - - @given(x=lists(integers())) - def test_list_of_integers(self, *, x: list[int]) -> None: - assert list(always_iterable(x)) == x - - @given(x=lists(text())) - def test_list_of_strings(self, *, x: list[str]) -> None: - assert list(always_iterable(x)) == x - - def test_generator(self) -> None: - def yield_ints() -> Iterator[int]: - yield 0 - yield 1 - - assert list(always_iterable(yield_ints())) == [0, 1] + from utilities.types import StrMapping class TestApplyBijection: @@ -220,19 +176,6 @@ def test_main(self, *, mappings: Sequence[Mapping[str, int]], list_: bool) -> No assert set(result) == set(expected) -class TestChainMaybeIterables: - @given(values=lists(integers() | lists(integers()))) - def test_main(self, *, values: list[int | list[int]]) -> None: - result = list(chain_maybe_iterables(*values)) - expected = [] - for val in values: - if isinstance(val, int): - expected.append(val) - else: - expected.extend(v for v in val) - assert result == expected - - class TestChainNullable: @given(values=lists(lists(integers() | none()) | none())) def test_main(self, *, values: list[list[int | None] | None]) -> None: @@ -908,38 +851,6 @@ def test_error_non_unique(self, *, iterable: set[int]) -> None: _ = one(iterable) -class TestOneMaybe: - @mark.parametrize( - "args", - [ - param((None,)), - param(([None],)), - param((None, [])), - param(([None], [])), - param((None, [], [])), - param(([None], [], [])), - ], - ) - def test_main(self, *, args: tuple[MaybeIterable[Any], ...]) -> None: - assert one_maybe(*args) is None - - @mark.parametrize("args", [param([]), param(([], [])), param(([], [], []))]) - def test_error_empty(self, *, args: tuple[MaybeIterable[Any], ...]) -> None: - with raises(OneMaybeEmptyError, match=r"Object\(s\) must not be empty"): - _ = one_maybe(*args) - - @given(iterable=sets(integers(), min_size=2)) - def test_error_non_unique(self, *, iterable: set[int]) -> None: - with raises( - OneMaybeNonUniqueError, - match=re.compile( - r"Object\(s\) .* must contain exactly one item; got .*, .* and perhaps more", - flags=DOTALL, - ), - ): - _ = one_maybe(iterable) - - class TestOneStr: @given(data=data(), text=sampled_from(["a", "b", "c"])) def test_exact_match_case_insensitive(self, *, data: DataObject, text: str) -> None: diff --git a/src/tests/test_orjson.py b/src/tests/test_orjson.py index dc17f9ffb..d7e210174 100644 --- a/src/tests/test_orjson.py +++ b/src/tests/test_orjson.py @@ -60,6 +60,7 @@ Sentinel, sentinel, ) +from utilities.core import always_iterable from utilities.hypothesis import ( date_periods, dates, @@ -72,7 +73,7 @@ zoned_date_time_periods, zoned_date_times, ) -from utilities.iterables import always_iterable, one +from utilities.iterables import one from utilities.logging import get_logging_level_number from utilities.operator import is_equal from utilities.orjson import ( diff --git a/src/utilities/altair.py b/src/utilities/altair.py index c5c9c3a2e..6267333a6 100644 --- a/src/utilities/altair.py +++ b/src/utilities/altair.py @@ -24,9 +24,8 @@ from altair.utils.schemapi import Undefined from utilities.atomicwrites import writer -from utilities.core import TemporaryDirectory +from utilities.core import TemporaryDirectory, always_iterable from utilities.functions import ensure_bytes, ensure_number -from utilities.iterables import always_iterable if TYPE_CHECKING: from polars import DataFrame diff --git a/src/utilities/core.py b/src/utilities/core.py index 72e9f0309..afb02e27e 100644 --- a/src/utilities/core.py +++ b/src/utilities/core.py @@ -6,14 +6,28 @@ from dataclasses import dataclass from pathlib import Path from tempfile import NamedTemporaryFile as _NamedTemporaryFile -from typing import TYPE_CHECKING, Literal, overload, override +from typing import TYPE_CHECKING, Any, Literal, cast, overload, override from warnings import catch_warnings, filterwarnings if TYPE_CHECKING: - from collections.abc import Iterator + from collections.abc import Iterable, Iterator from types import TracebackType - from utilities.types import FileOrDir, PathLike + from utilities.types import FileOrDir, MaybeIterable, PathLike + + +# itertools + + +def always_iterable[T](obj: MaybeIterable[T], /) -> Iterable[T]: + """Typed version of `always_iterable`.""" + obj = cast("Any", obj) + if isinstance(obj, str | bytes): + return cast("list[T]", [obj]) + try: + return iter(cast("Iterable[T]", obj)) + except TypeError: + return cast("list[T]", [obj]) # pathlib @@ -229,6 +243,7 @@ def yield_temp_file_at(path: PathLike, /) -> Iterator[Path]: "FileOrDirError", "TemporaryDirectory", "TemporaryFile", + "always_iterable", "file_or_dir", "yield_temp_dir_at", "yield_temp_file_at", diff --git a/src/utilities/docker.py b/src/utilities/docker.py index 44a249c12..fa74dba54 100644 --- a/src/utilities/docker.py +++ b/src/utilities/docker.py @@ -4,8 +4,8 @@ from typing import TYPE_CHECKING, Literal, overload from utilities.contextlib import enhanced_context_manager +from utilities.core import always_iterable from utilities.errors import ImpossibleCaseError -from utilities.iterables import always_iterable from utilities.logging import to_logger from utilities.subprocess import ( MKTEMP_DIR_CMD, diff --git a/src/utilities/iterables.py b/src/utilities/iterables.py index d0ee5685c..a8aef5498 100644 --- a/src/utilities/iterables.py +++ b/src/utilities/iterables.py @@ -31,6 +31,7 @@ ) from utilities.constants import Sentinel, sentinel +from utilities.core import always_iterable from utilities.errors import ImpossibleCaseError from utilities.functions import is_sentinel from utilities.math import ( @@ -49,23 +50,6 @@ from utilities.types import MaybeIterable, Sign, StrMapping -## - - -def always_iterable[T](obj: MaybeIterable[T], /) -> Iterable[T]: - """Typed version of `always_iterable`.""" - obj = cast("Any", obj) - if isinstance(obj, str | bytes): - return cast("list[T]", [obj]) - try: - return iter(cast("Iterable[T]", obj)) - except TypeError: - return cast("list[T]", [obj]) - - -## - - def apply_bijection[T, U]( func: Callable[[T], U], iterable: Iterable[T], / ) -> Mapping[T, U]: @@ -165,15 +149,6 @@ def _chain_mappings_one[K, V]( ## -def chain_maybe_iterables[T](*maybe_iterables: MaybeIterable[T]) -> Iterable[T]: - """Chain a set of maybe iterables.""" - iterables = map(always_iterable, maybe_iterables) - return chain.from_iterable(iterables) - - -## - - def chain_nullable[T](*maybe_iterables: Iterable[T | None] | None) -> Iterable[T]: """Chain a set of values; ignoring nulls.""" iterables = (mi for mi in maybe_iterables if mi is not None) @@ -971,43 +946,6 @@ def __str__(self) -> str: ## -def one_maybe[T](*objs: MaybeIterable[T]) -> T: - """Return the unique value in a set of values/iterables.""" - try: - return one(chain_maybe_iterables(*objs)) - except OneEmptyError: - raise OneMaybeEmptyError from None - except OneNonUniqueError as error: - raise OneMaybeNonUniqueError( - objs=objs, first=error.first, second=error.second - ) from None - - -@dataclass(kw_only=True, slots=True) -class OneMaybeError(Exception): ... - - -@dataclass(kw_only=True, slots=True) -class OneMaybeEmptyError(OneMaybeError): - @override - def __str__(self) -> str: - return "Object(s) must not be empty" - - -@dataclass(kw_only=True, slots=True) -class OneMaybeNonUniqueError[T](OneMaybeError): - objs: tuple[MaybeIterable[T], ...] - first: T - second: T - - @override - def __str__(self) -> str: - return f"Object(s) {get_repr(self.objs)} must contain exactly one item; got {self.first}, {self.second} and perhaps more" - - -## - - def one_str( iterable: Iterable[str], text: str, @@ -1434,9 +1372,6 @@ def unique_everseen[T]( "MergeStrMappingsError", "OneEmptyError", "OneError", - "OneMaybeEmptyError", - "OneMaybeError", - "OneMaybeNonUniqueError", "OneNonUniqueError", "OneStrEmptyError", "OneStrError", @@ -1452,7 +1387,6 @@ def unique_everseen[T]( "apply_to_tuple", "apply_to_varargs", "chain_mappings", - "chain_maybe_iterables", "chain_nullable", "check_bijection", "check_duplicates", @@ -1482,7 +1416,6 @@ def unique_everseen[T]( "merge_sets", "merge_str_mappings", "one", - "one_maybe", "one_str", "one_unique", "pairwise_tail", diff --git a/src/utilities/logging.py b/src/utilities/logging.py index 620cccdfc..d7ff369fd 100644 --- a/src/utilities/logging.py +++ b/src/utilities/logging.py @@ -37,10 +37,11 @@ from utilities.atomicwrites import move_many from utilities.constants import SECOND, Sentinel, sentinel +from utilities.core import always_iterable from utilities.dataclasses import replace_non_sentinel from utilities.errors import ImpossibleCaseError from utilities.functions import in_seconds -from utilities.iterables import OneEmptyError, always_iterable, one +from utilities.iterables import OneEmptyError, one from utilities.pathlib import ensure_suffix, to_path from utilities.re import ( ExtractGroupError, diff --git a/src/utilities/numpy.py b/src/utilities/numpy.py index 401ad2868..e151b80a9 100644 --- a/src/utilities/numpy.py +++ b/src/utilities/numpy.py @@ -39,7 +39,8 @@ from numpy.random import default_rng from numpy.typing import NDArray -from utilities.iterables import always_iterable, is_iterable_not_str +from utilities.core import always_iterable +from utilities.iterables import is_iterable_not_str if TYPE_CHECKING: from collections.abc import Callable, Iterable diff --git a/src/utilities/orjson.py b/src/utilities/orjson.py index 2b8610b45..29c72cd15 100644 --- a/src/utilities/orjson.py +++ b/src/utilities/orjson.py @@ -38,16 +38,11 @@ from utilities.concurrent import concurrent_map from utilities.constants import LOCAL_TIME_ZONE, MAX_INT64, MIN_INT64 +from utilities.core import always_iterable from utilities.dataclasses import dataclass_to_dict from utilities.functions import ensure_class from utilities.gzip import read_binary -from utilities.iterables import ( - OneEmptyError, - always_iterable, - merge_sets, - one, - one_unique, -) +from utilities.iterables import OneEmptyError, merge_sets, one, one_unique from utilities.json import write_formatted_json from utilities.logging import get_logging_level_number from utilities.types import Dataclass, LogLevel, MaybeIterable, PathLike, StrMapping diff --git a/src/utilities/polars.py b/src/utilities/polars.py index bb5cc42f4..7237d6cb6 100644 --- a/src/utilities/polars.py +++ b/src/utilities/polars.py @@ -55,6 +55,7 @@ import utilities.math from utilities.constants import UTC +from utilities.core import always_iterable from utilities.dataclasses import yield_fields from utilities.errors import ImpossibleCaseError from utilities.functions import get_class_name @@ -65,7 +66,6 @@ CheckSuperMappingError, OneEmptyError, OneNonUniqueError, - always_iterable, check_iterables_equal, check_mappings_equal, check_supermapping, diff --git a/src/utilities/postgres.py b/src/utilities/postgres.py index 6f13881de..9d7683ebf 100644 --- a/src/utilities/postgres.py +++ b/src/utilities/postgres.py @@ -9,8 +9,8 @@ from sqlalchemy.orm import DeclarativeBase from utilities.asyncio import stream_command +from utilities.core import always_iterable from utilities.docker import docker_exec_cmd -from utilities.iterables import always_iterable from utilities.logging import to_logger from utilities.os import temp_environ from utilities.pathlib import ensure_suffix diff --git a/src/utilities/pottery.py b/src/utilities/pottery.py index db3ab56aa..7d1cce79c 100644 --- a/src/utilities/pottery.py +++ b/src/utilities/pottery.py @@ -12,8 +12,8 @@ import utilities.asyncio from utilities.constants import MILLISECOND, SECOND from utilities.contextlib import enhanced_async_context_manager +from utilities.core import always_iterable from utilities.functions import in_seconds -from utilities.iterables import always_iterable if TYPE_CHECKING: from collections.abc import AsyncIterator, Iterable diff --git a/src/utilities/pydantic_settings.py b/src/utilities/pydantic_settings.py index a38eb3211..c4cec2707 100644 --- a/src/utilities/pydantic_settings.py +++ b/src/utilities/pydantic_settings.py @@ -16,8 +16,8 @@ ) from pydantic_settings.sources import DEFAULT_PATH +from utilities.core import always_iterable from utilities.errors import ImpossibleCaseError -from utilities.iterables import always_iterable if TYPE_CHECKING: from collections.abc import Iterator, Sequence diff --git a/src/utilities/redis.py b/src/utilities/redis.py index fe22e0050..baf32414e 100644 --- a/src/utilities/redis.py +++ b/src/utilities/redis.py @@ -24,9 +24,10 @@ from utilities.asyncio import timeout from utilities.constants import MILLISECOND, SECOND from utilities.contextlib import enhanced_async_context_manager +from utilities.core import always_iterable from utilities.errors import ImpossibleCaseError from utilities.functions import ensure_int, identity, in_milli_seconds, in_seconds -from utilities.iterables import always_iterable, one +from utilities.iterables import one from utilities.math import safe_round from utilities.os import is_pytest from utilities.typing import is_instance_gen diff --git a/src/utilities/subprocess.py b/src/utilities/subprocess.py index 274dd3fdd..4569d8a6d 100644 --- a/src/utilities/subprocess.py +++ b/src/utilities/subprocess.py @@ -24,10 +24,9 @@ ) from utilities.constants import HOME, PWD, SECOND from utilities.contextlib import enhanced_context_manager -from utilities.core import TemporaryDirectory, file_or_dir +from utilities.core import TemporaryDirectory, always_iterable, file_or_dir from utilities.errors import ImpossibleCaseError from utilities.functions import in_timedelta -from utilities.iterables import OneEmptyError, always_iterable, one from utilities.logging import to_logger from utilities.permissions import Permissions, ensure_perms from utilities.text import strip_and_dedent From cd3b7b3e851beb96ec872545088565be2df01a0f Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Sun, 18 Jan 2026 09:36:01 +0900 Subject: [PATCH 06/78] 2026-01-18 09:36:01 (Sun) > DW-Mac > derekwan --- src/tests/test_iterables.py | 25 ------------------------- src/utilities/click.py | 4 ++-- src/utilities/iterables.py | 1 - src/utilities/orjson.py | 4 ++-- 4 files changed, 4 insertions(+), 30 deletions(-) diff --git a/src/tests/test_iterables.py b/src/tests/test_iterables.py index fb2c32522..948d9e88a 100644 --- a/src/tests/test_iterables.py +++ b/src/tests/test_iterables.py @@ -51,8 +51,6 @@ OneNonUniqueError, OneStrEmptyError, OneStrNonUniqueError, - OneUniqueEmptyError, - OneUniqueNonUniqueError, ResolveIncludeAndExcludeError, SortIterableError, _ApplyBijectionDuplicateKeysError, @@ -98,7 +96,6 @@ merge_str_mappings, one, one_str, - one_unique, pairwise_tail, product_dicts, range_partitions, @@ -932,28 +929,6 @@ def test_error_head_case_sensitive_non_unique(self) -> None: _ = one_str(["abc", "abd"], "ab", head=True, case_sensitive=True) -class TestOneUnique: - @given(args=sampled_from([([None],), ([None], [None]), ([None], [None], [None])])) - def test_main(self, *, args: tuple[Iterable[Any], ...]) -> None: - assert one_unique(*args) is None - - @given(args=sampled_from([([],), ([], []), ([], [], [])])) - def test_error_empty(self, *, args: tuple[Iterable[Any], ...]) -> None: - with raises(OneUniqueEmptyError, match=r"Iterable\(s\) must not be empty"): - _ = one_unique(*args) - - @given(iterable=sets(integers(), min_size=2)) - def test_error_non_unique(self, *, iterable: set[int]) -> None: - with raises( - OneUniqueNonUniqueError, - match=re.compile( - r"Iterable\(s\) .* must contain exactly one item; got .*, .* and perhaps more", - flags=DOTALL, - ), - ): - _ = one_unique(iterable) - - class TestPairwiseTail: def test_main(self) -> None: iterable = range(5) diff --git a/src/utilities/click.py b/src/utilities/click.py index 184f3e1ca..e4a32751f 100644 --- a/src/utilities/click.py +++ b/src/utilities/click.py @@ -13,7 +13,7 @@ from utilities.enum import EnsureEnumError, ensure_enum from utilities.functions import EnsureStrError, ensure_str, get_class, get_class_name -from utilities.iterables import is_iterable_not_str, one_unique +from utilities.iterables import is_iterable_not_str, one from utilities.parse import ParseObjectError, parse_object from utilities.text import split_str @@ -187,7 +187,7 @@ def __init__( case_sensitive: bool = False, ) -> None: self._members = list(members) - self._enum = one_unique(get_class(e) for e in self._members) + self._enum = one({get_class(e) for e in self._members}) cls = get_class_name(self._enum) self.name = f"enum-partial[{cls}]" self._value = issubclass(self._enum, StrEnum) or value diff --git a/src/utilities/iterables.py b/src/utilities/iterables.py index a8aef5498..068698a4c 100644 --- a/src/utilities/iterables.py +++ b/src/utilities/iterables.py @@ -1417,7 +1417,6 @@ def unique_everseen[T]( "merge_str_mappings", "one", "one_str", - "one_unique", "pairwise_tail", "product_dicts", "range_partitions", diff --git a/src/utilities/orjson.py b/src/utilities/orjson.py index 29c72cd15..1f0599142 100644 --- a/src/utilities/orjson.py +++ b/src/utilities/orjson.py @@ -42,7 +42,7 @@ from utilities.dataclasses import dataclass_to_dict from utilities.functions import ensure_class from utilities.gzip import read_binary -from utilities.iterables import OneEmptyError, merge_sets, one, one_unique +from utilities.iterables import OneEmptyError, merge_sets, one from utilities.json import write_formatted_json from utilities.logging import get_logging_level_number from utilities.types import Dataclass, LogLevel, MaybeIterable, PathLike, StrMapping @@ -954,7 +954,7 @@ def dataframe(self) -> Any: for r in self.records ] if len(records) >= 1: - time_zone = one_unique(ZoneInfo(r.datetime.tz) for r in records) + time_zone = one({ZoneInfo(r.datetime.tz) for r in records}) else: time_zone = LOCAL_TIME_ZONE return DataFrame( From 3dec052193360eaabf11b52dacf4cece5187e81f Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Sun, 18 Jan 2026 09:36:17 +0900 Subject: [PATCH 07/78] 2026-01-18 09:36:17 (Sun) > DW-Mac > derekwan --- src/utilities/iterables.py | 40 -------------------------------------- 1 file changed, 40 deletions(-) diff --git a/src/utilities/iterables.py b/src/utilities/iterables.py index 068698a4c..5824630f6 100644 --- a/src/utilities/iterables.py +++ b/src/utilities/iterables.py @@ -1036,43 +1036,6 @@ def __str__(self) -> str: ## -def one_unique[T: Hashable](*iterables: Iterable[T]) -> T: - """Return the set-unique value in a set of iterables.""" - try: - return one(set(chain(*iterables))) - except OneEmptyError: - raise OneUniqueEmptyError from None - except OneNonUniqueError as error: - raise OneUniqueNonUniqueError( - iterables=iterables, first=error.first, second=error.second - ) from None - - -@dataclass(kw_only=True, slots=True) -class OneUniqueError(Exception): ... - - -@dataclass(kw_only=True, slots=True) -class OneUniqueEmptyError(OneUniqueError): - @override - def __str__(self) -> str: - return "Iterable(s) must not be empty" - - -@dataclass(kw_only=True, slots=True) -class OneUniqueNonUniqueError[THashable](OneUniqueError): - iterables: tuple[MaybeIterable[THashable], ...] - first: THashable - second: THashable - - @override - def __str__(self) -> str: - return f"Iterable(s) {get_repr(self.iterables)} must contain exactly one item; got {self.first}, {self.second} and perhaps more" - - -## - - def pairwise_tail[T](iterable: Iterable[T], /) -> Iterator[tuple[T, T | Sentinel]]: """Return pairwise elements, with the last paired with the sentinel.""" return pairwise(chain(iterable, [sentinel])) @@ -1376,9 +1339,6 @@ def unique_everseen[T]( "OneStrEmptyError", "OneStrError", "OneStrNonUniqueError", - "OneUniqueEmptyError", - "OneUniqueError", - "OneUniqueNonUniqueError", "RangePartitionsError", "ResolveIncludeAndExcludeError", "SortIterableError", From 3a7ab09623757c3a833a6988fb910fa6afd6bbeb Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 14:41:04 +0900 Subject: [PATCH 08/78] 2026-01-21 14:41:04 (Wed) > DW-Mac > derekwan --- .bumpversion.toml | 2 +- pyproject.toml | 2 +- src/utilities/__init__.py | 2 +- src/utilities/hypothesis.py | 1 - 4 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.bumpversion.toml b/.bumpversion.toml index 17cb78525..75c73052d 100644 --- a/.bumpversion.toml +++ b/.bumpversion.toml @@ -1,6 +1,6 @@ [tool.bumpversion] allow_dirty = true - current_version = "0.183.6" + current_version = "0.184.7" [[tool.bumpversion.files]] filename = "pyproject.toml" diff --git a/pyproject.toml b/pyproject.toml index 5e597042d..6e240c2ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -116,7 +116,7 @@ name = "dycw-utilities" readme = "README.md" requires-python = ">= 3.12" - version = "0.183.6" + version = "0.184.7" [project.entry-points.pytest11] pytest-randomly = "utilities.pytest_plugins.pytest_randomly" diff --git a/src/utilities/__init__.py b/src/utilities/__init__.py index 0a8eac948..09a08e7bb 100644 --- a/src/utilities/__init__.py +++ b/src/utilities/__init__.py @@ -1,3 +1,3 @@ from __future__ import annotations -__version__ = "0.183.6" +__version__ = "0.184.7" diff --git a/src/utilities/hypothesis.py b/src/utilities/hypothesis.py index e44bc73ff..ef1e4b7a6 100644 --- a/src/utilities/hypothesis.py +++ b/src/utilities/hypothesis.py @@ -97,7 +97,6 @@ from utilities.os import get_env_var from utilities.pathlib import module_path, temp_cwd from utilities.permissions import Permissions -from utilities.version import Version from utilities.whenever import ( DATE_DELTA_PARSABLE_MAX, DATE_DELTA_PARSABLE_MIN, From fb4be2a1f6dc43d4c674000dd1a8abb33cee7ba7 Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 14:58:09 +0900 Subject: [PATCH 09/78] 2026-01-21 14:58:09 (Wed) > DW-Mac > derekwan --- src/tests/core/__init__.py | 1 + src/tests/core/test_itertools.py | 174 +++++++++++++++++++++++++++++ src/tests/core/test_reprlib.py | 23 ++++ src/tests/test_core.py | 44 -------- src/tests/test_iterables.py | 111 ------------------- src/tests/test_reprlib.py | 18 --- src/utilities/core.py | 184 ++++++++++++++++++++++++++++++- src/utilities/hypothesis.py | 1 + src/utilities/iterables.py | 139 +---------------------- src/utilities/reprlib.py | 35 +----- 10 files changed, 382 insertions(+), 348 deletions(-) create mode 100644 src/tests/core/__init__.py create mode 100644 src/tests/core/test_itertools.py create mode 100644 src/tests/core/test_reprlib.py diff --git a/src/tests/core/__init__.py b/src/tests/core/__init__.py new file mode 100644 index 000000000..9d48db4f9 --- /dev/null +++ b/src/tests/core/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/src/tests/core/test_itertools.py b/src/tests/core/test_itertools.py new file mode 100644 index 000000000..8ddb6d3df --- /dev/null +++ b/src/tests/core/test_itertools.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +import re +from re import DOTALL +from typing import TYPE_CHECKING, Any + +from hypothesis import given +from hypothesis.strategies import ( + DataObject, + binary, + data, + dictionaries, + integers, + lists, + sampled_from, + sets, + text, +) +from pytest import mark, param, raises + +from utilities.core import ( + OneEmptyError, + OneNonUniqueError, + OneStrEmptyError, + OneStrNonUniqueError, + always_iterable, + one, + one_str, +) + +if TYPE_CHECKING: + from collections.abc import Iterable, Iterator + + +class TestAlwaysIterable: + @given(x=binary()) + def test_bytes(self, *, x: bytes) -> None: + assert list(always_iterable(x)) == [x] + + @given(x=dictionaries(text(), integers())) + def test_dict(self, *, x: dict[str, int]) -> None: + assert list(always_iterable(x)) == list(x) + + @given(x=integers()) + def test_integer(self, *, x: int) -> None: + assert list(always_iterable(x)) == [x] + + @given(x=lists(binary())) + def test_list_of_bytes(self, *, x: list[bytes]) -> None: + assert list(always_iterable(x)) == x + + @given(x=text()) + def test_string(self, *, x: str) -> None: + assert list(always_iterable(x)) == [x] + + @given(x=lists(integers())) + def test_list_of_integers(self, *, x: list[int]) -> None: + assert list(always_iterable(x)) == x + + @given(x=lists(text())) + def test_list_of_strings(self, *, x: list[str]) -> None: + assert list(always_iterable(x)) == x + + def test_generator(self) -> None: + def yield_ints() -> Iterator[int]: + yield 0 + yield 1 + + assert list(always_iterable(yield_ints())) == [0, 1] + + +class TestOne: + @mark.parametrize( + "args", [param(([None],)), param(([None], [])), param(([None], [], []))] + ) + def test_main(self, *, args: tuple[Iterable[Any], ...]) -> None: + assert one(*args) is None + + @mark.parametrize("args", [param([]), param(([], [])), param(([], [], []))]) + def test_error_empty(self, *, args: tuple[Iterable[Any], ...]) -> None: + with raises(OneEmptyError, match=r"Iterable\(s\) .* must not be empty"): + _ = one(*args) + + @given(iterable=sets(integers(), min_size=2)) + def test_error_non_unique(self, *, iterable: set[int]) -> None: + with raises( + OneNonUniqueError, + match=re.compile( + r"Iterable\(s\) .* must contain exactly one item; got .*, .* and perhaps more", + flags=DOTALL, + ), + ): + _ = one(iterable) + + +class TestOneStr: + @given(data=data(), text=sampled_from(["a", "b", "c"])) + def test_exact_match_case_insensitive(self, *, data: DataObject, text: str) -> None: + text_use = data.draw(sampled_from([text.lower(), text.upper()])) + assert one_str(["a", "b", "c"], text_use) == text + + @given( + data=data(), case=sampled_from([("ab", "abc"), ("ad", "ade"), ("af", "afg")]) + ) + def test_head_case_insensitive( + self, *, data: DataObject, case: tuple[str, str] + ) -> None: + head, expected = case + head_use = data.draw(sampled_from([head.lower(), head.upper()])) + assert one_str(["abc", "ade", "afg"], head_use, head=True) == expected + + @given(text=sampled_from(["a", "b", "c"])) + def test_exact_match_case_sensitive(self, *, text: str) -> None: + assert one_str(["a", "b", "c"], text, case_sensitive=True) == text + + @given(case=sampled_from([("ab", "abc"), ("ad", "ade"), ("af", "afg")])) + def test_head_case_sensitive(self, *, case: tuple[str, str]) -> None: + head, expected = case + assert ( + one_str(["abc", "ade", "afg"], head, head=True, case_sensitive=True) + == expected + ) + + def test_error_exact_match_case_insensitive_empty_error(self) -> None: + with raises( + OneStrEmptyError, match=r"Iterable .* does not contain 'd' \(modulo case\)" + ): + _ = one_str(["a", "b", "c"], "d") + + def test_error_exact_match_case_insensitive_non_unique_error(self) -> None: + with raises( + OneStrNonUniqueError, + match=r"Iterable .* must contain 'a' exactly once \(modulo case\); got 'a', 'A' and perhaps more", + ): + _ = one_str(["a", "A"], "a") + + def test_error_head_case_insensitive_empty_error(self) -> None: + with raises( + OneStrEmptyError, + match=r"Iterable .* does not contain any string starting with 'ac' \(modulo case\)", + ): + _ = one_str(["abc", "ade", "afg"], "ac", head=True) + + def test_error_head_case_insensitive_non_unique_error(self) -> None: + with raises( + OneStrNonUniqueError, + match=r"Iterable .* must contain exactly one string starting with 'ab' \(modulo case\); got 'abc', 'ABC' and perhaps more", + ): + _ = one_str(["abc", "ABC"], "ab", head=True) + + def test_error_exact_match_case_sensitive_empty_error(self) -> None: + with raises(OneStrEmptyError, match=r"Iterable .* does not contain 'A'"): + _ = one_str(["a", "b", "c"], "A", case_sensitive=True) + + def test_error_exact_match_case_sensitive_non_unique(self) -> None: + with raises( + OneStrNonUniqueError, + match=r"Iterable .* must contain 'a' exactly once; got 'a', 'a' and perhaps more", + ): + _ = one_str(["a", "a"], "a", case_sensitive=True) + + def test_error_head_case_sensitive_empty_error(self) -> None: + with raises( + OneStrEmptyError, + match=r"Iterable .* does not contain any string starting with 'AB'", + ): + _ = one_str(["abc", "ade", "afg"], "AB", head=True, case_sensitive=True) + + def test_error_head_case_sensitive_non_unique(self) -> None: + with raises( + OneStrNonUniqueError, + match=r"Iterable .* must contain exactly one string starting with 'ab'; got 'abc', 'abd' and perhaps more", + ): + _ = one_str(["abc", "abd"], "ab", head=True, case_sensitive=True) diff --git a/src/tests/core/test_reprlib.py b/src/tests/core/test_reprlib.py new file mode 100644 index 000000000..68bda6f8b --- /dev/null +++ b/src/tests/core/test_reprlib.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from typing import Any + +from pytest import mark, param + +from utilities.core import repr_ + + +class TestGetRepr: + @mark.parametrize( + ("obj", "expected"), + [ + param(None, "None"), + param(0, "0"), + param( + list(range(21)), + "[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, ... +1]", + ), + ], + ) + def test_main(self, *, obj: Any, expected: str) -> None: + assert repr_(obj) == expected diff --git a/src/tests/test_core.py b/src/tests/test_core.py index fdacf2bcd..c4fbab0cf 100644 --- a/src/tests/test_core.py +++ b/src/tests/test_core.py @@ -2,10 +2,7 @@ from os import mkfifo from pathlib import Path -from typing import TYPE_CHECKING -from hypothesis import given -from hypothesis.strategies import binary, dictionaries, integers, lists, text from pytest import raises from utilities.core import ( @@ -13,52 +10,11 @@ TemporaryFile, _FileOrDirMissingError, _FileOrDirTypeError, - always_iterable, file_or_dir, yield_temp_dir_at, yield_temp_file_at, ) -if TYPE_CHECKING: - from collections.abc import Iterator - - -class TestAlwaysIterable: - @given(x=binary()) - def test_bytes(self, *, x: bytes) -> None: - assert list(always_iterable(x)) == [x] - - @given(x=dictionaries(text(), integers())) - def test_dict(self, *, x: dict[str, int]) -> None: - assert list(always_iterable(x)) == list(x) - - @given(x=integers()) - def test_integer(self, *, x: int) -> None: - assert list(always_iterable(x)) == [x] - - @given(x=lists(binary())) - def test_list_of_bytes(self, *, x: list[bytes]) -> None: - assert list(always_iterable(x)) == x - - @given(x=text()) - def test_string(self, *, x: str) -> None: - assert list(always_iterable(x)) == [x] - - @given(x=lists(integers())) - def test_list_of_integers(self, *, x: list[int]) -> None: - assert list(always_iterable(x)) == x - - @given(x=lists(text())) - def test_list_of_strings(self, *, x: list[str]) -> None: - assert list(always_iterable(x)) == x - - def test_generator(self) -> None: - def yield_ints() -> Iterator[int]: - yield 0 - yield 1 - - assert list(always_iterable(yield_ints())) == [0, 1] - class TestFileOrDir: def test_file(self, *, tmp_path: Path) -> None: diff --git a/src/tests/test_iterables.py b/src/tests/test_iterables.py index 948d9e88a..ad5a009a0 100644 --- a/src/tests/test_iterables.py +++ b/src/tests/test_iterables.py @@ -47,10 +47,6 @@ EnsureIterableError, EnsureIterableNotStrError, MergeStrMappingsError, - OneEmptyError, - OneNonUniqueError, - OneStrEmptyError, - OneStrNonUniqueError, ResolveIncludeAndExcludeError, SortIterableError, _ApplyBijectionDuplicateKeysError, @@ -94,8 +90,6 @@ merge_mappings, merge_sets, merge_str_mappings, - one, - one_str, pairwise_tail, product_dicts, range_partitions, @@ -824,111 +818,6 @@ def test_error(self) -> None: _ = merge_str_mappings({"x": 1, "X": 2}) -class TestOne: - @mark.parametrize( - "args", [param(([None],)), param(([None], [])), param(([None], [], []))] - ) - def test_main(self, *, args: tuple[Iterable[Any], ...]) -> None: - assert one(*args) is None - - @mark.parametrize("args", [param([]), param(([], [])), param(([], [], []))]) - def test_error_empty(self, *, args: tuple[Iterable[Any], ...]) -> None: - with raises(OneEmptyError, match=r"Iterable\(s\) .* must not be empty"): - _ = one(*args) - - @given(iterable=sets(integers(), min_size=2)) - def test_error_non_unique(self, *, iterable: set[int]) -> None: - with raises( - OneNonUniqueError, - match=re.compile( - r"Iterable\(s\) .* must contain exactly one item; got .*, .* and perhaps more", - flags=DOTALL, - ), - ): - _ = one(iterable) - - -class TestOneStr: - @given(data=data(), text=sampled_from(["a", "b", "c"])) - def test_exact_match_case_insensitive(self, *, data: DataObject, text: str) -> None: - text_use = data.draw(sampled_from([text.lower(), text.upper()])) - assert one_str(["a", "b", "c"], text_use) == text - - @given( - data=data(), case=sampled_from([("ab", "abc"), ("ad", "ade"), ("af", "afg")]) - ) - def test_head_case_insensitive( - self, *, data: DataObject, case: tuple[str, str] - ) -> None: - head, expected = case - head_use = data.draw(sampled_from([head.lower(), head.upper()])) - assert one_str(["abc", "ade", "afg"], head_use, head=True) == expected - - @given(text=sampled_from(["a", "b", "c"])) - def test_exact_match_case_sensitive(self, *, text: str) -> None: - assert one_str(["a", "b", "c"], text, case_sensitive=True) == text - - @given(case=sampled_from([("ab", "abc"), ("ad", "ade"), ("af", "afg")])) - def test_head_case_sensitive(self, *, case: tuple[str, str]) -> None: - head, expected = case - assert ( - one_str(["abc", "ade", "afg"], head, head=True, case_sensitive=True) - == expected - ) - - def test_error_exact_match_case_insensitive_empty_error(self) -> None: - with raises( - OneStrEmptyError, match=r"Iterable .* does not contain 'd' \(modulo case\)" - ): - _ = one_str(["a", "b", "c"], "d") - - def test_error_exact_match_case_insensitive_non_unique_error(self) -> None: - with raises( - OneStrNonUniqueError, - match=r"Iterable .* must contain 'a' exactly once \(modulo case\); got 'a', 'A' and perhaps more", - ): - _ = one_str(["a", "A"], "a") - - def test_error_head_case_insensitive_empty_error(self) -> None: - with raises( - OneStrEmptyError, - match=r"Iterable .* does not contain any string starting with 'ac' \(modulo case\)", - ): - _ = one_str(["abc", "ade", "afg"], "ac", head=True) - - def test_error_head_case_insensitive_non_unique_error(self) -> None: - with raises( - OneStrNonUniqueError, - match=r"Iterable .* must contain exactly one string starting with 'ab' \(modulo case\); got 'abc', 'ABC' and perhaps more", - ): - _ = one_str(["abc", "ABC"], "ab", head=True) - - def test_error_exact_match_case_sensitive_empty_error(self) -> None: - with raises(OneStrEmptyError, match=r"Iterable .* does not contain 'A'"): - _ = one_str(["a", "b", "c"], "A", case_sensitive=True) - - def test_error_exact_match_case_sensitive_non_unique(self) -> None: - with raises( - OneStrNonUniqueError, - match=r"Iterable .* must contain 'a' exactly once; got 'a', 'a' and perhaps more", - ): - _ = one_str(["a", "a"], "a", case_sensitive=True) - - def test_error_head_case_sensitive_empty_error(self) -> None: - with raises( - OneStrEmptyError, - match=r"Iterable .* does not contain any string starting with 'AB'", - ): - _ = one_str(["abc", "ade", "afg"], "AB", head=True, case_sensitive=True) - - def test_error_head_case_sensitive_non_unique(self) -> None: - with raises( - OneStrNonUniqueError, - match=r"Iterable .* must contain exactly one string starting with 'ab'; got 'abc', 'abd' and perhaps more", - ): - _ = one_str(["abc", "abd"], "ab", head=True, case_sensitive=True) - - class TestPairwiseTail: def test_main(self) -> None: iterable = range(5) diff --git a/src/tests/test_reprlib.py b/src/tests/test_reprlib.py index 9cdb73ed7..6e5b3834b 100644 --- a/src/tests/test_reprlib.py +++ b/src/tests/test_reprlib.py @@ -7,7 +7,6 @@ from utilities.reprlib import ( get_call_args_mapping, - get_repr, get_repr_and_class, yield_call_args_repr, yield_mapping_repr, @@ -28,23 +27,6 @@ def test_main(self) -> None: assert mapping == expected -class TestGetRepr: - @given( - case=sampled_from([ - (None, "None"), - (0, "0"), - ( - list(range(21)), - "[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, ... +1]", - ), - ]) - ) - def test_main(self, *, case: tuple[Any, str]) -> None: - obj, expected = case - result = get_repr(obj) - assert result == expected - - class TestGetReprAndClass: @given( case=sampled_from([ diff --git a/src/utilities/core.py b/src/utilities/core.py index afb02e27e..ce5d4b861 100644 --- a/src/utilities/core.py +++ b/src/utilities/core.py @@ -1,14 +1,25 @@ from __future__ import annotations +import reprlib import shutil import tempfile from contextlib import contextmanager from dataclasses import dataclass +from itertools import chain from pathlib import Path from tempfile import NamedTemporaryFile as _NamedTemporaryFile -from typing import TYPE_CHECKING, Any, Literal, cast, overload, override +from typing import TYPE_CHECKING, Any, Literal, assert_never, cast, overload, override from warnings import catch_warnings, filterwarnings +from utilities.constants import ( + RICH_EXPAND_ALL, + RICH_INDENT_SIZE, + RICH_MAX_DEPTH, + RICH_MAX_LENGTH, + RICH_MAX_STRING, + RICH_MAX_WIDTH, +) + if TYPE_CHECKING: from collections.abc import Iterable, Iterator from types import TracebackType @@ -16,7 +27,7 @@ from utilities.types import FileOrDir, MaybeIterable, PathLike -# itertools +#### itertools ############################################################## def always_iterable[T](obj: MaybeIterable[T], /) -> Iterable[T]: @@ -30,7 +41,133 @@ def always_iterable[T](obj: MaybeIterable[T], /) -> Iterable[T]: return cast("list[T]", [obj]) -# pathlib +def one[T](*iterables: Iterable[T]) -> T: + """Return the unique value in a set of iterables.""" + it = chain(*iterables) + try: + first = next(it) + except StopIteration: + raise OneEmptyError(iterables=iterables) from None + try: + second = next(it) + except StopIteration: + return first + raise OneNonUniqueError(iterables=iterables, first=first, second=second) + + +@dataclass(kw_only=True, slots=True) +class OneError[T](Exception): + iterables: tuple[Iterable[T], ...] + + +@dataclass(kw_only=True, slots=True) +class OneEmptyError[T](OneError[T]): + @override + def __str__(self) -> str: + return f"Iterable(s) {repr_(self.iterables)} must not be empty" + + +@dataclass(kw_only=True, slots=True) +class OneNonUniqueError[T](OneError): + first: T + second: T + + @override + def __str__(self) -> str: + return f"Iterable(s) {repr_(self.iterables)} must contain exactly one item; got {self.first}, {self.second} and perhaps more" + + +## + + +def one_str( + iterable: Iterable[str], + text: str, + /, + *, + head: bool = False, + case_sensitive: bool = False, +) -> str: + """Find the unique string in an iterable.""" + as_list = list(iterable) + match head, case_sensitive: + case False, True: + it = (t for t in as_list if t == text) + case False, False: + it = (t for t in as_list if t.lower() == text.lower()) + case True, True: + it = (t for t in as_list if t.startswith(text)) + case True, False: + it = (t for t in as_list if t.lower().startswith(text.lower())) + case never: + assert_never(never) + try: + return one(it) + except OneEmptyError: + raise OneStrEmptyError( + iterable=as_list, text=text, head=head, case_sensitive=case_sensitive + ) from None + except OneNonUniqueError as error: + raise OneStrNonUniqueError( + iterable=as_list, + text=text, + head=head, + case_sensitive=case_sensitive, + first=error.first, + second=error.second, + ) from None + + +@dataclass(kw_only=True, slots=True) +class OneStrError(Exception): + iterable: Iterable[str] + text: str + head: bool = False + case_sensitive: bool = False + + +@dataclass(kw_only=True, slots=True) +class OneStrEmptyError(OneStrError): + @override + def __str__(self) -> str: + head = f"Iterable {repr_(self.iterable)} does not contain" + match self.head, self.case_sensitive: + case False, True: + tail = repr(self.text) + case False, False: + tail = f"{self.text!r} (modulo case)" + case True, True: + tail = f"any string starting with {self.text!r}" + case True, False: + tail = f"any string starting with {self.text!r} (modulo case)" + case never: + assert_never(never) + return f"{head} {tail}" + + +@dataclass(kw_only=True, slots=True) +class OneStrNonUniqueError(OneStrError): + first: str + second: str + + @override + def __str__(self) -> str: + head = f"Iterable {repr_(self.iterable)} must contain" + match self.head, self.case_sensitive: + case False, True: + mid = f"{self.text!r} exactly once" + case False, False: + mid = f"{self.text!r} exactly once (modulo case)" + case True, True: + mid = f"exactly one string starting with {self.text!r}" + case True, False: + mid = f"exactly one string starting with {self.text!r} (modulo case)" + case never: + assert_never(never) + return f"{head} {mid}; got {self.first!r}, {self.second!r} and perhaps more" + + +#### pathlib @overload @@ -72,7 +209,37 @@ def __str__(self) -> str: return f"Path is neither a file nor a directory: {str(self.path)!r}" -# tempfile +#### reprlib ################################################################ + + +def repr_( + obj: Any, + /, + *, + max_width: int = RICH_MAX_WIDTH, + indent_size: int = RICH_INDENT_SIZE, + max_length: int | None = RICH_MAX_LENGTH, + max_string: int | None = RICH_MAX_STRING, + max_depth: int | None = RICH_MAX_DEPTH, + expand_all: bool = RICH_EXPAND_ALL, +) -> str: + """Get the representation of an object.""" + try: + from rich.pretty import pretty_repr + except ModuleNotFoundError: # pragma: no cover + return reprlib.repr(obj) + return pretty_repr( + obj, + max_width=max_width, + indent_size=indent_size, + max_length=max_length, + max_string=max_string, + max_depth=max_depth, + expand_all=expand_all, + ) + + +#### tempfile ############################################################### class TemporaryDirectory: @@ -241,10 +408,19 @@ def yield_temp_file_at(path: PathLike, /) -> Iterator[Path]: __all__ = [ "FileOrDirError", + "OneEmptyError", + "OneError", + "OneNonUniqueError", + "OneStrEmptyError", + "OneStrError", + "OneStrNonUniqueError", "TemporaryDirectory", "TemporaryFile", "always_iterable", "file_or_dir", + "one", + "one_str", + "repr_", "yield_temp_dir_at", "yield_temp_file_at", ] diff --git a/src/utilities/hypothesis.py b/src/utilities/hypothesis.py index ef1e4b7a6..ccc0a7380 100644 --- a/src/utilities/hypothesis.py +++ b/src/utilities/hypothesis.py @@ -97,6 +97,7 @@ from utilities.os import get_env_var from utilities.pathlib import module_path, temp_cwd from utilities.permissions import Permissions +from utilities.version import Version2, Version3 from utilities.whenever import ( DATE_DELTA_PARSABLE_MAX, DATE_DELTA_PARSABLE_MIN, diff --git a/src/utilities/iterables.py b/src/utilities/iterables.py index 5824630f6..9bf5f668b 100644 --- a/src/utilities/iterables.py +++ b/src/utilities/iterables.py @@ -31,7 +31,7 @@ ) from utilities.constants import Sentinel, sentinel -from utilities.core import always_iterable +from utilities.core import OneStrEmptyError, always_iterable, one, one_str from utilities.errors import ImpossibleCaseError from utilities.functions import is_sentinel from utilities.math import ( @@ -907,135 +907,6 @@ def __str__(self) -> str: ## -def one[T](*iterables: Iterable[T]) -> T: - """Return the unique value in a set of iterables.""" - it = chain(*iterables) - try: - first = next(it) - except StopIteration: - raise OneEmptyError(iterables=iterables) from None - try: - second = next(it) - except StopIteration: - return first - raise OneNonUniqueError(iterables=iterables, first=first, second=second) - - -@dataclass(kw_only=True, slots=True) -class OneError[T](Exception): - iterables: tuple[Iterable[T], ...] - - -@dataclass(kw_only=True, slots=True) -class OneEmptyError[T](OneError[T]): - @override - def __str__(self) -> str: - return f"Iterable(s) {get_repr(self.iterables)} must not be empty" - - -@dataclass(kw_only=True, slots=True) -class OneNonUniqueError[T](OneError): - first: T - second: T - - @override - def __str__(self) -> str: - return f"Iterable(s) {get_repr(self.iterables)} must contain exactly one item; got {self.first}, {self.second} and perhaps more" - - -## - - -def one_str( - iterable: Iterable[str], - text: str, - /, - *, - head: bool = False, - case_sensitive: bool = False, -) -> str: - """Find the unique string in an iterable.""" - as_list = list(iterable) - match head, case_sensitive: - case False, True: - it = (t for t in as_list if t == text) - case False, False: - it = (t for t in as_list if t.lower() == text.lower()) - case True, True: - it = (t for t in as_list if t.startswith(text)) - case True, False: - it = (t for t in as_list if t.lower().startswith(text.lower())) - case never: - assert_never(never) - try: - return one(it) - except OneEmptyError: - raise OneStrEmptyError( - iterable=as_list, text=text, head=head, case_sensitive=case_sensitive - ) from None - except OneNonUniqueError as error: - raise OneStrNonUniqueError( - iterable=as_list, - text=text, - head=head, - case_sensitive=case_sensitive, - first=error.first, - second=error.second, - ) from None - - -@dataclass(kw_only=True, slots=True) -class OneStrError(Exception): - iterable: Iterable[str] - text: str - head: bool = False - case_sensitive: bool = False - - -@dataclass(kw_only=True, slots=True) -class OneStrEmptyError(OneStrError): - @override - def __str__(self) -> str: - head = f"Iterable {get_repr(self.iterable)} does not contain" - match self.head, self.case_sensitive: - case False, True: - tail = repr(self.text) - case False, False: - tail = f"{self.text!r} (modulo case)" - case True, True: - tail = f"any string starting with {self.text!r}" - case True, False: - tail = f"any string starting with {self.text!r} (modulo case)" - case never: - assert_never(never) - return f"{head} {tail}" - - -@dataclass(kw_only=True, slots=True) -class OneStrNonUniqueError(OneStrError): - first: str - second: str - - @override - def __str__(self) -> str: - head = f"Iterable {get_repr(self.iterable)} must contain" - match self.head, self.case_sensitive: - case False, True: - mid = f"{self.text!r} exactly once" - case False, False: - mid = f"{self.text!r} exactly once (modulo case)" - case True, True: - mid = f"exactly one string starting with {self.text!r}" - case True, False: - mid = f"exactly one string starting with {self.text!r} (modulo case)" - case never: - assert_never(never) - return f"{head} {mid}; got {self.first!r}, {self.second!r} and perhaps more" - - -## - - def pairwise_tail[T](iterable: Iterable[T], /) -> Iterator[tuple[T, T | Sentinel]]: """Return pairwise elements, with the last paired with the sentinel.""" return pairwise(chain(iterable, [sentinel])) @@ -1333,12 +1204,6 @@ def unique_everseen[T]( "EnsureIterableError", "EnsureIterableNotStrError", "MergeStrMappingsError", - "OneEmptyError", - "OneError", - "OneNonUniqueError", - "OneStrEmptyError", - "OneStrError", - "OneStrNonUniqueError", "RangePartitionsError", "ResolveIncludeAndExcludeError", "SortIterableError", @@ -1375,8 +1240,6 @@ def unique_everseen[T]( "merge_mappings", "merge_sets", "merge_str_mappings", - "one", - "one_str", "pairwise_tail", "product_dicts", "range_partitions", diff --git a/src/utilities/reprlib.py b/src/utilities/reprlib.py index 7a93ac794..e8cb47897 100644 --- a/src/utilities/reprlib.py +++ b/src/utilities/reprlib.py @@ -1,6 +1,5 @@ from __future__ import annotations -import reprlib from functools import partial from typing import TYPE_CHECKING, Any @@ -12,6 +11,7 @@ RICH_MAX_STRING, RICH_MAX_WIDTH, ) +from utilities.core import repr_ if TYPE_CHECKING: from collections.abc import Iterator @@ -32,36 +32,6 @@ def get_call_args_mapping(*args: Any, **kwargs: Any) -> StrMapping: ## -def get_repr( - obj: Any, - /, - *, - max_width: int = RICH_MAX_WIDTH, - indent_size: int = RICH_INDENT_SIZE, - max_length: int | None = RICH_MAX_LENGTH, - max_string: int | None = RICH_MAX_STRING, - max_depth: int | None = RICH_MAX_DEPTH, - expand_all: bool = RICH_EXPAND_ALL, -) -> str: - """Get the representation of an object.""" - try: - from rich.pretty import pretty_repr - except ModuleNotFoundError: # pragma: no cover - return reprlib.repr(obj) - return pretty_repr( - obj, - max_width=max_width, - indent_size=indent_size, - max_length=max_length, - max_string=max_string, - max_depth=max_depth, - expand_all=expand_all, - ) - - -## - - def get_repr_and_class( obj: Any, /, @@ -74,7 +44,7 @@ def get_repr_and_class( expand_all: bool = RICH_EXPAND_ALL, ) -> str: """Get the `reprlib`-representation & class of an object.""" - repr_use = get_repr( + repr_use = repr_( obj, max_width=max_width, indent_size=indent_size, @@ -153,7 +123,6 @@ def yield_mapping_repr( "RICH_MAX_STRING", "RICH_MAX_WIDTH", "get_call_args_mapping", - "get_repr", "get_repr_and_class", "yield_call_args_repr", "yield_mapping_repr", From 0f7a0452d048983b57cda176ca1cb966f5ef6c54 Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 14:58:59 +0900 Subject: [PATCH 10/78] 2026-01-21 14:58:59 (Wed) > DW-Mac > derekwan --- src/tests/core/test_pathlib.py | 43 ++++++++++++++++++++++++++++++++++ src/tests/test_core.py | 38 ------------------------------ src/utilities/core.py | 2 +- 3 files changed, 44 insertions(+), 39 deletions(-) create mode 100644 src/tests/core/test_pathlib.py diff --git a/src/tests/core/test_pathlib.py b/src/tests/core/test_pathlib.py new file mode 100644 index 000000000..77ffce5e0 --- /dev/null +++ b/src/tests/core/test_pathlib.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from os import mkfifo +from typing import TYPE_CHECKING + +from pytest import raises + +from utilities.core import _FileOrDirMissingError, _FileOrDirTypeError, file_or_dir + +if TYPE_CHECKING: + from pathlib import Path + + +class TestFileOrDir: + def test_file(self, *, tmp_path: Path) -> None: + path = tmp_path / "file.txt" + path.touch() + result = file_or_dir(path) + assert result == "file" + + def test_dir(self, *, tmp_path: Path) -> None: + path = tmp_path / "dir" + path.mkdir() + result = file_or_dir(path) + assert result == "dir" + + def test_empty(self, *, tmp_path: Path) -> None: + path = tmp_path / "non-existent" + result = file_or_dir(path) + assert result is None + + def test_error_missing(self, *, tmp_path: Path) -> None: + path = tmp_path / "non-existent" + with raises(_FileOrDirMissingError, match=r"Path does not exist: '.*'"): + _ = file_or_dir(path, exists=True) + + def test_error_type(self, *, tmp_path: Path) -> None: + path = tmp_path / "fifo" + mkfifo(path) + with raises( + _FileOrDirTypeError, match=r"Path is neither a file nor a directory: '.*'" + ): + _ = file_or_dir(path) diff --git a/src/tests/test_core.py b/src/tests/test_core.py index c4fbab0cf..4a303483a 100644 --- a/src/tests/test_core.py +++ b/src/tests/test_core.py @@ -1,53 +1,15 @@ from __future__ import annotations -from os import mkfifo from pathlib import Path -from pytest import raises - from utilities.core import ( TemporaryDirectory, TemporaryFile, - _FileOrDirMissingError, - _FileOrDirTypeError, - file_or_dir, yield_temp_dir_at, yield_temp_file_at, ) -class TestFileOrDir: - def test_file(self, *, tmp_path: Path) -> None: - path = tmp_path / "file.txt" - path.touch() - result = file_or_dir(path) - assert result == "file" - - def test_dir(self, *, tmp_path: Path) -> None: - path = tmp_path / "dir" - path.mkdir() - result = file_or_dir(path) - assert result == "dir" - - def test_empty(self, *, tmp_path: Path) -> None: - path = tmp_path / "non-existent" - result = file_or_dir(path) - assert result is None - - def test_error_missing(self, *, tmp_path: Path) -> None: - path = tmp_path / "non-existent" - with raises(_FileOrDirMissingError, match=r"Path does not exist: '.*'"): - _ = file_or_dir(path, exists=True) - - def test_error_type(self, *, tmp_path: Path) -> None: - path = tmp_path / "fifo" - mkfifo(path) - with raises( - _FileOrDirTypeError, match=r"Path is neither a file nor a directory: '.*'" - ): - _ = file_or_dir(path) - - class TestTemporaryDirectory: def test_main(self) -> None: temp_dir = TemporaryDirectory() diff --git a/src/utilities/core.py b/src/utilities/core.py index ce5d4b861..90d1ab6ad 100644 --- a/src/utilities/core.py +++ b/src/utilities/core.py @@ -167,7 +167,7 @@ def __str__(self) -> str: return f"{head} {mid}; got {self.first!r}, {self.second!r} and perhaps more" -#### pathlib +#### pathlib ################################################# @overload From 133eae9ca79e9e2f3f9d4c8b98043f0afdb73c95 Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 14:59:36 +0900 Subject: [PATCH 11/78] 2026-01-21 14:59:36 (Wed) > DW-Mac > derekwan --- src/tests/{test_core.py => core/test_tempfile.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/tests/{test_core.py => core/test_tempfile.py} (100%) diff --git a/src/tests/test_core.py b/src/tests/core/test_tempfile.py similarity index 100% rename from src/tests/test_core.py rename to src/tests/core/test_tempfile.py From c6a0ff2cb4ec11e4bdce2ef09d95a1836c6b4a4b Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 15:05:29 +0900 Subject: [PATCH 12/78] 2026-01-21 15:05:29 (Wed) > DW-Mac > derekwan --- src/tests/core/test_builtins.py | 75 +++++++++++++++++++ src/tests/core/test_constants.py | 33 ++++++++ src/tests/test_functions.py | 109 +-------------------------- src/utilities/core.py | 98 +++++++++++++++++++++++- src/utilities/functions.py | 124 ++----------------------------- src/utilities/logging.py | 3 +- 6 files changed, 214 insertions(+), 228 deletions(-) create mode 100644 src/tests/core/test_builtins.py create mode 100644 src/tests/core/test_constants.py diff --git a/src/tests/core/test_builtins.py b/src/tests/core/test_builtins.py new file mode 100644 index 000000000..816e25bac --- /dev/null +++ b/src/tests/core/test_builtins.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from itertools import chain +from typing import TYPE_CHECKING + +from hypothesis import given +from hypothesis.strategies import ( + DataObject, + data, + integers, + lists, + none, + permutations, + sampled_from, +) +from pytest import raises + +from utilities.core import ( + MaxNullableError, + MinNullableError, + max_nullable, + min_nullable, +) + +if TYPE_CHECKING: + from collections.abc import Callable, Iterable + + +class TestMinMaxNullable: + @given( + data=data(), + values=lists(integers(), min_size=1), + nones=lists(none()), + case=sampled_from([(min_nullable, min), (max_nullable, max)]), + ) + def test_main( + self, + *, + data: DataObject, + values: list[int], + nones: list[None], + case: tuple[ + Callable[[Iterable[int | None]], int], Callable[[Iterable[int]], int] + ], + ) -> None: + func_nullable, func_builtin = case + values_use = data.draw(permutations(list(chain(values, nones)))) + result = func_nullable(values_use) + expected = func_builtin(values) + assert result == expected + + @given( + nones=lists(none()), + value=integers(), + func=sampled_from([min_nullable, max_nullable]), + ) + def test_default( + self, *, nones: list[None], value: int, func: Callable[..., int] + ) -> None: + result = func(nones, default=value) + assert result == value + + @given(nones=lists(none())) + def test_error_min_nullable(self, *, nones: list[None]) -> None: + with raises( + MinNullableError, match=r"Minimum of an all-None iterable is undefined" + ): + _ = min_nullable(nones) + + @given(nones=lists(none())) + def test_error_max_nullable(self, *, nones: list[None]) -> None: + with raises( + MaxNullableError, match=r"Maximum of an all-None iterable is undefined" + ): + max_nullable(nones) diff --git a/src/tests/core/test_constants.py b/src/tests/core/test_constants.py new file mode 100644 index 000000000..81fb0ffbc --- /dev/null +++ b/src/tests/core/test_constants.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from pytest import mark, param + +from utilities.constants import sentinel +from utilities.core import is_none, is_not_none, is_sentinel + +if TYPE_CHECKING: + from collections.abc import Callable + + +class TestIsNoneAndIsNotNone: + @mark.parametrize( + ("func", "obj", "expected"), + [ + param(is_none, None, True), + param(is_none, 0, False), + param(is_not_none, None, False), + param(is_not_none, 0, True), + ], + ) + def test_main( + self, *, func: Callable[[Any], bool], obj: Any, expected: bool + ) -> None: + assert func(obj) is expected + + +class TestIsSentinel: + @mark.parametrize(("obj", "expected"), [param(None, False), param(sentinel, True)]) + def test_main(self, *, obj: Any, expected: bool) -> None: + assert is_sentinel(obj) is expected diff --git a/src/tests/test_functions.py b/src/tests/test_functions.py index bcf1b7a15..56f890fda 100644 --- a/src/tests/test_functions.py +++ b/src/tests/test_functions.py @@ -3,7 +3,6 @@ import sys from dataclasses import dataclass from functools import cache, cached_property, lru_cache, partial, wraps -from itertools import chain from operator import neg from subprocess import check_output from sys import executable @@ -19,8 +18,6 @@ dictionaries, integers, lists, - none, - permutations, sampled_from, ) from pytest import approx, mark, param, raises @@ -43,9 +40,6 @@ EnsureTimeDeltaError, EnsureTimeError, EnsureZonedDateTimeError, - MaxNullableError, - MinNullableError, - apply_decorators, ensure_bool, ensure_bytes, ensure_class, @@ -70,12 +64,7 @@ in_milli_seconds, in_seconds, in_timedelta, - is_none, - is_not_none, - is_sentinel, map_object, - max_nullable, - min_nullable, not_func, second, yield_object_attributes, @@ -87,38 +76,13 @@ if TYPE_CHECKING: import datetime as dt - from collections.abc import Callable, Iterable + from collections.abc import Callable from whenever import PlainDateTime, TimeDelta, ZonedDateTime from utilities.types import Duration, Number -class TestApplyDecorators: - @given(n=integers()) - def test_main(self, *, n: int) -> None: - counter = 0 - - def negate(x: int, /) -> int: - return -x - - def increment[**P, T](func: Callable[P, T], /) -> Callable[P, T]: - @wraps(func) - def wrapped(*args: P.args, **kwargs: P.kwargs) -> T: - nonlocal counter - counter += 1 - return func(*args, **kwargs) - - return wrapped - - decorated = apply_decorators(negate, increment) - assert counter == 0 - assert negate(n) == -n - assert counter == 0 - assert decorated(n) == -n - assert counter == 1 - - class TestEnsureBool: @given(case=sampled_from([(True, False), (True, True), (None, True)])) def test_main(self, *, case: tuple[bool | None, bool]) -> None: @@ -585,28 +549,6 @@ def test_main(self, *, duration: Duration) -> None: assert in_timedelta(duration) == SECOND -class TestIsNoneAndIsNotNone: - @mark.parametrize( - ("func", "obj", "expected"), - [ - param(is_none, None, True), - param(is_none, 0, False), - param(is_not_none, None, False), - param(is_not_none, 0, True), - ], - ) - def test_main( - self, *, func: Callable[[Any], bool], obj: Any, expected: bool - ) -> None: - assert func(obj) is expected - - -class TestIsSentinel: - @mark.parametrize(("obj", "expected"), [param(None, False), param(sentinel, True)]) - def test_main(self, *, obj: Any, expected: bool) -> None: - assert is_sentinel(obj) is expected - - class TestMapObject: @given(x=integers()) def test_int(self, *, x: int) -> None: @@ -653,55 +595,6 @@ def before(x: Any, /) -> Any: assert result == expected -class TestMinMaxNullable: - @given( - data=data(), - values=lists(integers(), min_size=1), - nones=lists(none()), - case=sampled_from([(min_nullable, min), (max_nullable, max)]), - ) - def test_main( - self, - *, - data: DataObject, - values: list[int], - nones: list[None], - case: tuple[ - Callable[[Iterable[int | None]], int], Callable[[Iterable[int]], int] - ], - ) -> None: - func_nullable, func_builtin = case - values_use = data.draw(permutations(list(chain(values, nones)))) - result = func_nullable(values_use) - expected = func_builtin(values) - assert result == expected - - @given( - nones=lists(none()), - value=integers(), - func=sampled_from([min_nullable, max_nullable]), - ) - def test_default( - self, *, nones: list[None], value: int, func: Callable[..., int] - ) -> None: - result = func(nones, default=value) - assert result == value - - @given(nones=lists(none())) - def test_error_min_nullable(self, *, nones: list[None]) -> None: - with raises( - MinNullableError, match=r"Minimum of an all-None iterable is undefined" - ): - _ = min_nullable(nones) - - @given(nones=lists(none())) - def test_error_max_nullable(self, *, nones: list[None]) -> None: - with raises( - MaxNullableError, match=r"Maximum of an all-None iterable is undefined" - ): - max_nullable(nones) - - class TestNotFunc: @given(x=booleans()) def test_main(self, *, x: bool) -> None: diff --git a/src/utilities/core.py b/src/utilities/core.py index 90d1ab6ad..1254c7541 100644 --- a/src/utilities/core.py +++ b/src/utilities/core.py @@ -11,6 +11,8 @@ from typing import TYPE_CHECKING, Any, Literal, assert_never, cast, overload, override from warnings import catch_warnings, filterwarnings +from typing_extensions import TypeIs + from utilities.constants import ( RICH_EXPAND_ALL, RICH_INDENT_SIZE, @@ -18,7 +20,10 @@ RICH_MAX_LENGTH, RICH_MAX_STRING, RICH_MAX_WIDTH, + Sentinel, + sentinel, ) +from utilities.types import SupportsRichComparison if TYPE_CHECKING: from collections.abc import Iterable, Iterator @@ -27,7 +32,91 @@ from utilities.types import FileOrDir, MaybeIterable, PathLike -#### itertools ############################################################## +#### builtins ################################################################# + + +@overload +def min_nullable[T: SupportsRichComparison]( + iterable: Iterable[T | None], /, *, default: Sentinel = ... +) -> T: ... +@overload +def min_nullable[T: SupportsRichComparison, U]( + iterable: Iterable[T | None], /, *, default: U = ... +) -> T | U: ... +def min_nullable[T: SupportsRichComparison, U]( + iterable: Iterable[T | None], /, *, default: U | Sentinel = sentinel +) -> T | U: + """Compute the minimum of a set of values; ignoring nulls.""" + values = (i for i in iterable if i is not None) + if is_sentinel(default): + try: + return min(values) + except ValueError: + raise MinNullableError(values=values) from None + return min(values, default=default) + + +@dataclass(kw_only=True, slots=True) +class MinNullableError[T: SupportsRichComparison](Exception): + values: Iterable[T] + + @override + def __str__(self) -> str: + return "Minimum of an all-None iterable is undefined" + + +@overload +def max_nullable[T: SupportsRichComparison]( + iterable: Iterable[T | None], /, *, default: Sentinel = ... +) -> T: ... +@overload +def max_nullable[T: SupportsRichComparison, U]( + iterable: Iterable[T | None], /, *, default: U = ... +) -> T | U: ... +def max_nullable[T: SupportsRichComparison, U]( + iterable: Iterable[T | None], /, *, default: U | Sentinel = sentinel +) -> T | U: + """Compute the maximum of a set of values; ignoring nulls.""" + values = (i for i in iterable if i is not None) + if is_sentinel(default): + try: + return max(values) + except ValueError: + raise MaxNullableError(values=values) from None + return max(values, default=default) + + +@dataclass(kw_only=True, slots=True) +class MaxNullableError[TSupportsRichComparison](Exception): + values: Iterable[TSupportsRichComparison] + + @override + def __str__(self) -> str: + return "Maximum of an all-None iterable is undefined" + + +#### constants ################################################################ + + +def is_none(obj: Any, /) -> TypeIs[None]: + """Check if an object is `None`.""" + return obj is None + + +def is_not_none(obj: Any, /) -> bool: + """Check if an object is not `None`.""" + return obj is not None + + +## + + +def is_sentinel(obj: Any, /) -> TypeIs[Sentinel]: + """Check if an object is the sentinel.""" + return obj is sentinel + + +#### itertools ################################################################ def always_iterable[T](obj: MaybeIterable[T], /) -> Iterable[T]: @@ -408,6 +497,8 @@ def yield_temp_file_at(path: PathLike, /) -> Iterator[Path]: __all__ = [ "FileOrDirError", + "MaxNullableError", + "MinNullableError", "OneEmptyError", "OneError", "OneNonUniqueError", @@ -418,6 +509,11 @@ def yield_temp_file_at(path: PathLike, /) -> Iterator[Path]: "TemporaryFile", "always_iterable", "file_or_dir", + "is_none", + "is_not_none", + "is_sentinel", + "max_nullable", + "min_nullable", "one", "one_str", "repr_", diff --git a/src/utilities/functions.py b/src/utilities/functions.py index c2a23b08e..731f67143 100644 --- a/src/utilities/functions.py +++ b/src/utilities/functions.py @@ -1,8 +1,7 @@ from __future__ import annotations -from collections.abc import Callable, Iterable, Iterator from dataclasses import asdict, dataclass -from functools import _lru_cache_wrapper, cached_property, partial, reduce, wraps +from functools import _lru_cache_wrapper, cached_property, partial, wraps from inspect import getattr_static from pathlib import Path from re import findall @@ -16,35 +15,15 @@ ) from typing import TYPE_CHECKING, Any, Literal, assert_never, cast, overload, override -from typing_extensions import TypeIs from whenever import Date, PlainDateTime, Time, TimeDelta, ZonedDateTime -from utilities.constants import SECOND, Sentinel, sentinel -from utilities.reprlib import get_repr, get_repr_and_class -from utilities.types import ( - Dataclass, - Duration, - Number, - SupportsRichComparison, - TypeLike, -) +from utilities.constants import SECOND +from utilities.core import repr_ +from utilities.reprlib import get_repr_and_class +from utilities.types import Dataclass, Duration, Number, TypeLike if TYPE_CHECKING: - from collections.abc import Container - - -def apply_decorators[F1: Callable, F2: Callable]( - func: F1, /, *decorators: Callable[[F2], F2] -) -> F1: - """Apply a set of decorators to a function.""" - return reduce(_apply_decorators_one, decorators, func) - - -def _apply_decorators_one[F: Callable](acc: F, el: Callable[[Any], Any], /) -> F: - return el(acc) - - -## + from collections.abc import Callable, Container, Iterable, Iterator @overload @@ -281,7 +260,7 @@ class EnsureMemberError(Exception): @override def __str__(self) -> str: return _make_error_msg( - self.obj, f"a member of {get_repr(self.container)}", nullable=self.nullable + self.obj, f"a member of {repr_(self.container)}", nullable=self.nullable ) @@ -606,27 +585,6 @@ def in_timedelta(duration: Duration, /) -> TimeDelta: ## -def is_none(obj: Any, /) -> TypeIs[None]: - """Check if an object is `None`.""" - return obj is None - - -def is_not_none(obj: Any, /) -> bool: - """Check if an object is not `None`.""" - return obj is not None - - -## - - -def is_sentinel(obj: Any, /) -> TypeIs[Sentinel]: - """Check if an object is the sentinel.""" - return obj is sentinel - - -## - - def map_object[T]( func: Callable[[Any], Any], obj: T, /, *, before: Callable[[Any], Any] | None = None ) -> T: @@ -649,66 +607,6 @@ def map_object[T]( ## -@overload -def min_nullable[T: SupportsRichComparison]( - iterable: Iterable[T | None], /, *, default: Sentinel = ... -) -> T: ... -@overload -def min_nullable[T: SupportsRichComparison, U]( - iterable: Iterable[T | None], /, *, default: U = ... -) -> T | U: ... -def min_nullable[T: SupportsRichComparison, U]( - iterable: Iterable[T | None], /, *, default: U | Sentinel = sentinel -) -> T | U: - """Compute the minimum of a set of values; ignoring nulls.""" - values = (i for i in iterable if i is not None) - if is_sentinel(default): - try: - return min(values) - except ValueError: - raise MinNullableError(values=values) from None - return min(values, default=default) - - -@dataclass(kw_only=True, slots=True) -class MinNullableError[T: SupportsRichComparison](Exception): - values: Iterable[T] - - @override - def __str__(self) -> str: - return "Minimum of an all-None iterable is undefined" - - -@overload -def max_nullable[T: SupportsRichComparison]( - iterable: Iterable[T | None], /, *, default: Sentinel = ... -) -> T: ... -@overload -def max_nullable[T: SupportsRichComparison, U]( - iterable: Iterable[T | None], /, *, default: U = ... -) -> T | U: ... -def max_nullable[T: SupportsRichComparison, U]( - iterable: Iterable[T | None], /, *, default: U | Sentinel = sentinel -) -> T | U: - """Compute the maximum of a set of values; ignoring nulls.""" - values = (i for i in iterable if i is not None) - if is_sentinel(default): - try: - return max(values) - except ValueError: - raise MaxNullableError(values=values) from None - return max(values, default=default) - - -@dataclass(kw_only=True, slots=True) -class MaxNullableError[TSupportsRichComparison](Exception): - values: Iterable[TSupportsRichComparison] - - @override - def __str__(self) -> str: - return "Maximum of an all-None iterable is undefined" - - ## @@ -808,9 +706,6 @@ def _make_error_msg(obj: Any, desc: str, /, *, nullable: bool = False) -> str: "EnsureTimeDeltaError", "EnsureTimeError", "EnsureZonedDateTimeError", - "MaxNullableError", - "MinNullableError", - "apply_decorators", "ensure_bool", "ensure_bytes", "ensure_class", @@ -835,12 +730,7 @@ def _make_error_msg(obj: Any, desc: str, /, *, nullable: bool = False) -> str: "in_milli_seconds", "in_seconds", "in_timedelta", - "is_none", - "is_not_none", - "is_sentinel", "map_object", - "max_nullable", - "min_nullable", "not_func", "second", "skip_if_optimize", diff --git a/src/utilities/logging.py b/src/utilities/logging.py index d7ff369fd..85eb6b356 100644 --- a/src/utilities/logging.py +++ b/src/utilities/logging.py @@ -37,11 +37,10 @@ from utilities.atomicwrites import move_many from utilities.constants import SECOND, Sentinel, sentinel -from utilities.core import always_iterable +from utilities.core import OneEmptyError, always_iterable, one from utilities.dataclasses import replace_non_sentinel from utilities.errors import ImpossibleCaseError from utilities.functions import in_seconds -from utilities.iterables import OneEmptyError, one from utilities.pathlib import ensure_suffix, to_path from utilities.re import ( ExtractGroupError, From 1293b2ff1e16598f958becc6d55f395adf25a989 Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 15:06:41 +0900 Subject: [PATCH 13/78] 2026-01-21 15:06:41 (Wed) > DW-Mac > derekwan --- pyproject.toml | 1 - src/tests/test_cryptography.py | 38 --------------------- src/utilities/cryptography.py | 41 ---------------------- uv.lock | 62 +--------------------------------- 4 files changed, 1 insertion(+), 141 deletions(-) delete mode 100644 src/tests/test_cryptography.py delete mode 100644 src/utilities/cryptography.py diff --git a/pyproject.toml b/pyproject.toml index 6e240c2ba..f7b86443e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,6 @@ "tzlocal>=5.3.1", "whenever>=0.9.5", ] - cryptography = ["cryptography>=46.0.3"] cvxpy = ["cvxpy>=1.7.5"] dataclasses-test = ["orjson>=3.11.5", "polars>=1.37.1"] dev = [ diff --git a/src/tests/test_cryptography.py b/src/tests/test_cryptography.py deleted file mode 100644 index 094ace9a7..000000000 --- a/src/tests/test_cryptography.py +++ /dev/null @@ -1,38 +0,0 @@ -from __future__ import annotations - -from cryptography.fernet import Fernet -from hypothesis import given -from hypothesis.strategies import text -from pytest import raises - -from utilities.cryptography import ( - _ENV_VAR, - GetFernetError, - decrypt, - encrypt, - get_fernet, -) -from utilities.os import temp_environ - - -class TestEncryptAndDecrypt: - @given(text=text()) - def test_round_trip(self, text: str) -> None: - key = Fernet.generate_key() - with temp_environ({_ENV_VAR: key.decode()}): - assert decrypt(encrypt(text)) == text - - -class TestGetFernet: - def test_main(self) -> None: - key = Fernet.generate_key() - with temp_environ({_ENV_VAR: key.decode()}): - fernet = get_fernet() - assert isinstance(fernet, Fernet) - - def test_error(self) -> None: - with ( - temp_environ({_ENV_VAR: None}), - raises(GetFernetError, match=r"Environment variable 'FERNET_KEY' is None"), - ): - _ = get_fernet() diff --git a/src/utilities/cryptography.py b/src/utilities/cryptography.py deleted file mode 100644 index 0802a2f06..000000000 --- a/src/utilities/cryptography.py +++ /dev/null @@ -1,41 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from os import getenv -from typing import override - -from cryptography.fernet import Fernet - -_ENV_VAR = "FERNET_KEY" - - -def encrypt(text: str, /, *, env_var: str = _ENV_VAR) -> bytes: - """Encrypt a string.""" - return get_fernet(env_var).encrypt(text.encode()) - - -def decrypt(text: bytes, /, *, env_var: str = _ENV_VAR) -> str: - """Encrypt a string.""" - return get_fernet(env_var).decrypt(text).decode() - - -## - - -def get_fernet(env_var: str = _ENV_VAR, /) -> Fernet: - """Get the Fernet key.""" - if (key := getenv(env_var)) is None: - raise GetFernetError(env_var=env_var) - return Fernet(key.encode()) - - -@dataclass(kw_only=True, slots=True) -class GetFernetError(Exception): - env_var: str - - @override - def __str__(self) -> str: - return f"Environment variable {self.env_var!r} is None" - - -__all__ = ["GetFernetError", "decrypt", "encrypt", "get_fernet"] diff --git a/uv.lock b/uv.lock index 234ac272d..3781c8a2c 100644 --- a/uv.lock +++ b/uv.lock @@ -509,62 +509,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/06/83/df10dd1911cb1695274da836e786ade7eaace9ed625b14056eb0bd6117d8/coverage_conditional_plugin-0.9.0-py3-none-any.whl", hash = "sha256:1b37bc469019d2ab5b01f5eee453abe1846b3431e64e209720c2a9ec4afb8130", size = 7317, upload-time = "2023-06-02T10:25:08.177Z" }, ] -[[package]] -name = "cryptography" -version = "46.0.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" }, - { url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" }, - { url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" }, - { url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" }, - { url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" }, - { url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" }, - { url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" }, - { url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" }, - { url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" }, - { url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" }, - { url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" }, - { url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" }, - { url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" }, - { url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" }, - { url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" }, - { url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" }, - { url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" }, - { url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" }, - { url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" }, - { url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" }, - { url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" }, - { url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" }, - { url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" }, - { url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" }, - { url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" }, - { url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" }, - { url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" }, - { url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" }, - { url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" }, - { url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" }, - { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" }, - { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" }, - { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" }, - { url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" }, - { url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" }, - { url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" }, - { url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" }, - { url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" }, - { url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" }, - { url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" }, - { url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" }, - { url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" }, - { url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" }, - { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" }, -] - [[package]] name = "cvxpy" version = "1.7.5" @@ -625,7 +569,7 @@ wheels = [ [[package]] name = "dycw-utilities" -version = "0.183.6" +version = "0.184.7" source = { editable = "." } dependencies = [ { name = "atomicwrites" }, @@ -679,9 +623,6 @@ core = [ { name = "tzlocal" }, { name = "whenever" }, ] -cryptography = [ - { name = "cryptography" }, -] cvxpy = [ { name = "cvxpy" }, ] @@ -934,7 +875,6 @@ core = [ { name = "tzlocal", specifier = ">=5.3.1" }, { name = "whenever", specifier = ">=0.9.5" }, ] -cryptography = [{ name = "cryptography", specifier = ">=46.0.3" }] cvxpy = [{ name = "cvxpy", specifier = ">=1.7.5" }] dataclasses-test = [ { name = "orjson", specifier = ">=3.11.5" }, From 6e4a8ecbaabb48eefc4b8f09d512314018235fad Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 15:08:26 +0900 Subject: [PATCH 14/78] 2026-01-21 15:08:26 (Wed) > DW-Mac > derekwan --- src/tests/test_iterables.py | 12 ----- src/utilities/iterables.py | 94 ++++++++++++++++++------------------- 2 files changed, 45 insertions(+), 61 deletions(-) diff --git a/src/tests/test_iterables.py b/src/tests/test_iterables.py index ad5a009a0..833abe6da 100644 --- a/src/tests/test_iterables.py +++ b/src/tests/test_iterables.py @@ -96,7 +96,6 @@ reduce_mappings, resolve_include_and_exclude, sort_iterable, - sum_mappings, take, transpose, unique_everseen, @@ -1036,17 +1035,6 @@ def test_nan_vs_nan(self) -> None: assert result == 0 -class TestSumMappings: - @given(mappings=lists(dictionaries(text_ascii(), integers()))) - def test_main(self, *, mappings: Sequence[Mapping[str, int]]) -> None: - result = sum_mappings(*mappings) - expected = {} - for mapping in mappings: - for key, value in mapping.items(): - expected[key] = expected.get(key, 0) + value - assert result == expected - - class TestTake: def test_simple(self) -> None: result = take(5, range(10)) diff --git a/src/utilities/iterables.py b/src/utilities/iterables.py index 9bf5f668b..ac211816d 100644 --- a/src/utilities/iterables.py +++ b/src/utilities/iterables.py @@ -18,7 +18,7 @@ from functools import cmp_to_key, partial, reduce from itertools import accumulate, chain, groupby, islice, pairwise, product from math import isnan -from operator import add, or_ +from operator import or_ from typing import ( TYPE_CHECKING, Any, @@ -31,9 +31,15 @@ ) from utilities.constants import Sentinel, sentinel -from utilities.core import OneStrEmptyError, always_iterable, one, one_str +from utilities.core import ( + OneStrEmptyError, + always_iterable, + is_sentinel, + one, + one_str, + repr_, +) from utilities.errors import ImpossibleCaseError -from utilities.functions import is_sentinel from utilities.math import ( _CheckIntegerEqualError, _CheckIntegerEqualOrApproxError, @@ -41,8 +47,7 @@ _CheckIntegerMinError, check_integer, ) -from utilities.reprlib import get_repr -from utilities.types import SupportsAdd, SupportsLT +from utilities.types import SupportsLT if TYPE_CHECKING: from types import NoneType @@ -81,7 +86,7 @@ class ApplyBijectionError[T](Exception): class _ApplyBijectionDuplicateKeysError[T](ApplyBijectionError[T]): @override def __str__(self) -> str: - return f"Keys {get_repr(self.keys)} must not contain duplicates; got {get_repr(self.counts)}" + return f"Keys {repr_(self.keys)} must not contain duplicates; got {repr_(self.counts)}" @dataclass(kw_only=True, slots=True) @@ -90,7 +95,7 @@ class _ApplyBijectionDuplicateValuesError[T, U](ApplyBijectionError[T]): @override def __str__(self) -> str: - return f"Values {get_repr(self.values)} must not contain duplicates; got {get_repr(self.counts)}" + return f"Values {repr_(self.values)} must not contain duplicates; got {repr_(self.counts)}" ## @@ -174,7 +179,7 @@ class CheckBijectionError[THashable](Exception): @override def __str__(self) -> str: - return f"Mapping {get_repr(self.mapping)} must be a bijection; got duplicates {get_repr(self.counts)}" + return f"Mapping {repr_(self.mapping)} must be a bijection; got duplicates {repr_(self.counts)}" ## @@ -194,7 +199,7 @@ class CheckDuplicatesError[THashable](Exception): @override def __str__(self) -> str: - return f"Iterable {get_repr(self.iterable)} must not contain duplicates; got {get_repr(self.counts)}" + return f"Iterable {repr_(self.iterable)} must not contain duplicates; got {repr_(self.counts)}" ## @@ -246,12 +251,12 @@ def __str__(self) -> str: desc = f"{first} and {second}" case _: # pragma: no cover raise ImpossibleCaseError(case=[f"{parts=}"]) - return f"Iterables {get_repr(self.left)} and {get_repr(self.right)} must be equal; {desc}" + return f"Iterables {repr_(self.left)} and {repr_(self.right)} must be equal; {desc}" def _yield_parts(self) -> Iterator[str]: if len(self.errors) >= 1: errors = [(f"{i=}", lv, rv) for i, lv, rv in self.errors] - yield f"differing items were {get_repr(errors)}" + yield f"differing items were {repr_(errors)}" match self.state: case "left_longer": yield "left was longer" @@ -302,7 +307,7 @@ class _CheckLengthEqualError(CheckLengthError): @override def __str__(self) -> str: - return f"Object {get_repr(self.obj)} must have length {self.equal}; got {len(self.obj)}" + return f"Object {repr_(self.obj)} must have length {self.equal}; got {len(self.obj)}" @dataclass(kw_only=True, slots=True) @@ -316,7 +321,7 @@ def __str__(self) -> str: desc = f"approximate length {target} (error {error:%})" case target: desc = f"length {target}" - return f"Object {get_repr(self.obj)} must have {desc}; got {len(self.obj)}" + return f"Object {repr_(self.obj)} must have {desc}; got {len(self.obj)}" @dataclass(kw_only=True, slots=True) @@ -325,7 +330,7 @@ class _CheckLengthMinError(CheckLengthError): @override def __str__(self) -> str: - return f"Object {get_repr(self.obj)} must have minimum length {self.min_}; got {len(self.obj)}" + return f"Object {repr_(self.obj)} must have minimum length {self.min_}; got {len(self.obj)}" @dataclass(kw_only=True, slots=True) @@ -334,7 +339,7 @@ class _CheckLengthMaxError(CheckLengthError): @override def __str__(self) -> str: - return f"Object {get_repr(self.obj)} must have maximum length {self.max_}; got {len(self.obj)}" + return f"Object {repr_(self.obj)} must have maximum length {self.max_}; got {len(self.obj)}" ## @@ -353,7 +358,7 @@ class CheckLengthsEqualError(Exception): @override def __str__(self) -> str: - return f"Sized objects {get_repr(self.left)} and {get_repr(self.right)} must have the same length; got {len(self.left)} and {len(self.right)}" + return f"Sized objects {repr_(self.left)} and {repr_(self.right)} must have the same length; got {len(self.left)} and {len(self.right)}" ## @@ -403,16 +408,18 @@ def __str__(self) -> str: desc = f"{first}, {second} and {third}" case _: # pragma: no cover raise ImpossibleCaseError(case=[f"{parts=}"]) - return f"Mappings {get_repr(self.left)} and {get_repr(self.right)} must be equal; {desc}" + return ( + f"Mappings {repr_(self.left)} and {repr_(self.right)} must be equal; {desc}" + ) def _yield_parts(self) -> Iterator[str]: if len(self.left_extra) >= 1: - yield f"left had extra keys {get_repr(self.left_extra)}" + yield f"left had extra keys {repr_(self.left_extra)}" if len(self.right_extra) >= 1: - yield f"right had extra keys {get_repr(self.right_extra)}" + yield f"right had extra keys {repr_(self.right_extra)}" if len(self.errors) >= 1: errors = [(f"{k=}", lv, rv) for k, lv, rv in self.errors] - yield f"differing values were {get_repr(errors)}" + yield f"differing values were {repr_(errors)}" ## @@ -450,13 +457,13 @@ def __str__(self) -> str: desc = f"{first} and {second}" case _: # pragma: no cover raise ImpossibleCaseError(case=[f"{parts=}"]) - return f"Sets {get_repr(self.left)} and {get_repr(self.right)} must be equal; {desc}" + return f"Sets {repr_(self.left)} and {repr_(self.right)} must be equal; {desc}" def _yield_parts(self) -> Iterator[str]: if len(self.left_extra) >= 1: - yield f"left had extra items {get_repr(self.left_extra)}" + yield f"left had extra items {repr_(self.left_extra)}" if len(self.right_extra) >= 1: - yield f"right had extra items {get_repr(self.right_extra)}" + yield f"right had extra items {repr_(self.right_extra)}" ## @@ -497,14 +504,14 @@ def __str__(self) -> str: desc = f"{first} and {second}" case _: # pragma: no cover raise ImpossibleCaseError(case=[f"{parts=}"]) - return f"Mapping {get_repr(self.left)} must be a submapping of {get_repr(self.right)}; {desc}" + return f"Mapping {repr_(self.left)} must be a submapping of {repr_(self.right)}; {desc}" def _yield_parts(self) -> Iterator[str]: if len(self.extra) >= 1: - yield f"left had extra keys {get_repr(self.extra)}" + yield f"left had extra keys {repr_(self.extra)}" if len(self.errors) >= 1: errors = [(f"{k=}", lv, rv) for k, lv, rv in self.errors] - yield f"differing values were {get_repr(errors)}" + yield f"differing values were {repr_(errors)}" ## @@ -527,7 +534,7 @@ class CheckSubSetError[T](Exception): @override def __str__(self) -> str: - return f"Set {get_repr(self.left)} must be a subset of {get_repr(self.right)}; left had extra items {get_repr(self.extra)}" + return f"Set {repr_(self.left)} must be a subset of {repr_(self.right)}; left had extra items {repr_(self.extra)}" ## @@ -568,14 +575,14 @@ def __str__(self) -> str: desc = f"{first} and {second}" case _: # pragma: no cover raise ImpossibleCaseError(case=[f"{parts=}"]) - return f"Mapping {get_repr(self.left)} must be a supermapping of {get_repr(self.right)}; {desc}" + return f"Mapping {repr_(self.left)} must be a supermapping of {repr_(self.right)}; {desc}" def _yield_parts(self) -> Iterator[str]: if len(self.extra) >= 1: - yield f"right had extra keys {get_repr(self.extra)}" + yield f"right had extra keys {repr_(self.extra)}" if len(self.errors) >= 1: errors = [(f"{k=}", lv, rv) for k, lv, rv in self.errors] - yield f"differing values were {get_repr(errors)}" + yield f"differing values were {repr_(errors)}" ## @@ -598,7 +605,7 @@ class CheckSuperSetError[T](Exception): @override def __str__(self) -> str: - return f"Set {get_repr(self.left)} must be a superset of {get_repr(self.right)}; right had extra items {get_repr(self.extra)}." + return f"Set {repr_(self.left)} must be a superset of {repr_(self.right)}; right had extra items {repr_(self.extra)}." ## @@ -628,7 +635,7 @@ class CheckUniqueModuloCaseError(Exception): class _CheckUniqueModuloCaseDuplicateStringsError(CheckUniqueModuloCaseError): @override def __str__(self) -> str: - return f"Strings {get_repr(self.keys)} must not contain duplicates; got {get_repr(self.counts)}" + return f"Strings {repr_(self.keys)} must not contain duplicates; got {repr_(self.counts)}" @dataclass(kw_only=True, slots=True) @@ -637,7 +644,7 @@ class _CheckUniqueModuloCaseDuplicateLowerCaseStringsError(CheckUniqueModuloCase @override def __str__(self) -> str: - return f"Strings {get_repr(self.values)} must not contain duplicates (modulo case); got {get_repr(self.counts)}" + return f"Strings {repr_(self.values)} must not contain duplicates (modulo case); got {repr_(self.counts)}" ## @@ -682,7 +689,7 @@ class EnsureIterableError(Exception): @override def __str__(self) -> str: - return f"Object {get_repr(self.obj)} must be iterable" + return f"Object {repr_(self.obj)} must be iterable" ## @@ -701,7 +708,7 @@ class EnsureIterableNotStrError(Exception): @override def __str__(self) -> str: - return f"Object {get_repr(self.obj)} must be iterable, but not a string" + return f"Object {repr_(self.obj)} must be iterable, but not a string" ## @@ -901,7 +908,7 @@ class MergeStrMappingsError(Exception): @override def __str__(self) -> str: - return f"Mapping {get_repr(self.mapping)} keys must not contain duplicates (modulo case); got {get_repr(self.counts)}" + return f"Mapping {repr_(self.mapping)} keys must not contain duplicates (modulo case); got {repr_(self.counts)}" ## @@ -1031,7 +1038,7 @@ def __str__(self) -> str: include = list(self.include) exclude = list(self.exclude) overlap = set(include) & set(exclude) - return f"Iterables {get_repr(include)} and {get_repr(exclude)} must not overlap; got {get_repr(overlap)}" + return f"Iterables {repr_(include)} and {repr_(exclude)} must not overlap; got {repr_(overlap)}" ## @@ -1098,7 +1105,7 @@ class SortIterableError(Exception): @override def __str__(self) -> str: - return f"Unable to sort {get_repr(self.x)} and {get_repr(self.y)}" + return f"Unable to sort {repr_(self.x)} and {repr_(self.y)}" def _sort_iterable_cmp_floats(x: float, y: float, /) -> Sign: @@ -1120,16 +1127,6 @@ def _sort_iterable_cmp_floats(x: float, y: float, /) -> Sign: ## -def sum_mappings[K: Hashable, V: SupportsAdd]( - *mappings: Mapping[K, V], -) -> Mapping[K, V]: - """Sum the values of a set of mappings.""" - return reduce_mappings(add, mappings, initial=0) - - -## - - def take[T](n: int, iterable: Iterable[T], /) -> Sequence[T]: """Return first n items of the iterable as a list.""" return list(islice(iterable, n)) @@ -1246,7 +1243,6 @@ def unique_everseen[T]( "reduce_mappings", "resolve_include_and_exclude", "sort_iterable", - "sum_mappings", "take", "transpose", "unique_everseen", From 31212420fcd038b9285573a9208ccc540a8cc91a Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 15:11:46 +0900 Subject: [PATCH 15/78] 2026-01-21 15:11:46 (Wed) > DW-Mac > derekwan --- src/tests/core/test_builtins.py | 34 +++++++++++++++++++++++++++++++-- src/tests/test_functions.py | 30 ----------------------------- src/utilities/core.py | 20 +++++++++++++++++++ src/utilities/functions.py | 22 +-------------------- 4 files changed, 53 insertions(+), 53 deletions(-) diff --git a/src/tests/core/test_builtins.py b/src/tests/core/test_builtins.py index 816e25bac..7e0856260 100644 --- a/src/tests/core/test_builtins.py +++ b/src/tests/core/test_builtins.py @@ -1,7 +1,8 @@ from __future__ import annotations from itertools import chain -from typing import TYPE_CHECKING +from types import NoneType +from typing import TYPE_CHECKING, Any from hypothesis import given from hypothesis.strategies import ( @@ -13,19 +14,48 @@ permutations, sampled_from, ) -from pytest import raises +from pytest import mark, param, raises from utilities.core import ( MaxNullableError, MinNullableError, + get_class, + get_class_name, max_nullable, min_nullable, ) +from utilities.errors import ImpossibleCaseError if TYPE_CHECKING: from collections.abc import Callable, Iterable +class TestGetClass: + @mark.parametrize( + ("obj", "expected"), [param(None, NoneType), param(NoneType, NoneType)] + ) + def test_main(self, *, obj: Any, expected: type[Any]) -> None: + assert get_class(obj) is expected + + +class TestGetClassName: + def test_class(self) -> None: + class Example: ... + + assert get_class_name(Example) == "Example" + + def test_instance(self) -> None: + class Example: ... + + assert get_class_name(Example()) == "Example" + + def test_qual(self) -> None: + assert ( + get_class_name(ImpossibleCaseError, qual=True) + == "utilities.errors.ImpossibleCaseError" + ) + + class TestMinMaxNullable: @given( data=data(), diff --git a/src/tests/test_functions.py b/src/tests/test_functions.py index 56f890fda..2197462cd 100644 --- a/src/tests/test_functions.py +++ b/src/tests/test_functions.py @@ -6,7 +6,6 @@ from operator import neg from subprocess import check_output from sys import executable -from types import NoneType from typing import TYPE_CHECKING, Any, ClassVar, cast from hypothesis import given @@ -23,7 +22,6 @@ from pytest import approx, mark, param, raises from utilities.constants import HOME, MILLISECOND, NOW_UTC, SECOND, ZERO_TIME, sentinel -from utilities.errors import ImpossibleCaseError from utilities.functions import ( EnsureBoolError, EnsureBytesError, @@ -56,8 +54,6 @@ ensure_time_delta, ensure_zoned_date_time, first, - get_class, - get_class_name, get_func_name, get_func_qualname, identity, @@ -386,32 +382,6 @@ def test_main(self, *, x: int, y: int) -> None: assert result == x -class TestGetClass: - @given(case=sampled_from([(None, NoneType), (NoneType, NoneType)])) - def test_main(self, *, case: tuple[Any, type[Any]]) -> None: - obj, expected = case - result = get_class(obj) - assert result is expected - - -class TestGetClassName: - def test_class(self) -> None: - class Example: ... - - assert get_class_name(Example) == "Example" - - def test_instance(self) -> None: - class Example: ... - - assert get_class_name(Example()) == "Example" - - def test_qual(self) -> None: - assert ( - get_class_name(ImpossibleCaseError, qual=True) - == "utilities.errors.ImpossibleCaseError" - ) - - class TestGetFuncNameAndGetFuncQualName: @given( case=sampled_from([ diff --git a/src/utilities/core.py b/src/utilities/core.py index 1254c7541..8c06bfda2 100644 --- a/src/utilities/core.py +++ b/src/utilities/core.py @@ -35,6 +35,24 @@ #### builtins ################################################################# +@overload +def get_class[T](obj: type[T], /) -> type[T]: ... +@overload +def get_class[T](obj: T, /) -> type[T]: ... +def get_class[T](obj: T | type[T], /) -> type[T]: + """Get the class of an object, unless it is already a class.""" + return obj if isinstance(obj, type) else type(obj) + + +def get_class_name(obj: Any, /, *, qual: bool = False) -> str: + """Get the name of the class of an object, unless it is already a class.""" + cls = get_class(obj) + return f"{cls.__module__}.{cls.__qualname__}" if qual else cls.__name__ + + +## + + @overload def min_nullable[T: SupportsRichComparison]( iterable: Iterable[T | None], /, *, default: Sentinel = ... @@ -509,6 +527,8 @@ def yield_temp_file_at(path: PathLike, /) -> Iterator[Path]: "TemporaryFile", "always_iterable", "file_or_dir", + "get_class", + "get_class_name", "is_none", "is_not_none", "is_sentinel", diff --git a/src/utilities/functions.py b/src/utilities/functions.py index 731f67143..98538328a 100644 --- a/src/utilities/functions.py +++ b/src/utilities/functions.py @@ -18,7 +18,7 @@ from whenever import Date, PlainDateTime, Time, TimeDelta, ZonedDateTime from utilities.constants import SECOND -from utilities.core import repr_ +from utilities.core import get_class_name, repr_ from utilities.reprlib import get_repr_and_class from utilities.types import Dataclass, Duration, Number, TypeLike @@ -479,24 +479,6 @@ def first[T](pair: tuple[T, Any], /) -> T: ## -@overload -def get_class[T](obj: type[T], /) -> type[T]: ... -@overload -def get_class[T](obj: T, /) -> type[T]: ... -def get_class[T](obj: T | type[T], /) -> type[T]: - """Get the class of an object, unless it is already a class.""" - return obj if isinstance(obj, type) else type(obj) - - -## - - -def get_class_name(obj: Any, /, *, qual: bool = False) -> str: - """Get the name of the class of an object, unless it is already a class.""" - cls = get_class(obj) - return f"{cls.__module__}.{cls.__qualname__}" if qual else cls.__name__ - - ## @@ -722,8 +704,6 @@ def _make_error_msg(obj: Any, desc: str, /, *, nullable: bool = False) -> str: "ensure_time_delta", "ensure_zoned_date_time", "first", - "get_class", - "get_class_name", "get_func_name", "get_func_qualname", "identity", From 057bebbb4da00a2f114e9dd08c582e326f1c1bd8 Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 15:17:55 +0900 Subject: [PATCH 16/78] 2026-01-21 15:17:55 (Wed) > DW-Mac > derekwan --- src/tests/core/test_pathlib.py | 20 ++++++++++++++----- src/tests/test_errors.py | 35 +--------------------------------- src/tests/test_pathlib.py | 9 --------- src/utilities/asyncio.py | 6 +++--- src/utilities/core.py | 16 ++++++++++++++++ src/utilities/dataclasses.py | 9 +++++---- src/utilities/enum.py | 2 +- src/utilities/errors.py | 16 +--------------- src/utilities/hypothesis.py | 12 ++++++------ src/utilities/operator.py | 4 ++-- src/utilities/parse.py | 2 +- src/utilities/pathlib.py | 19 +----------------- src/utilities/text.py | 4 ++-- 13 files changed, 54 insertions(+), 100 deletions(-) diff --git a/src/tests/core/test_pathlib.py b/src/tests/core/test_pathlib.py index 77ffce5e0..c0fa54027 100644 --- a/src/tests/core/test_pathlib.py +++ b/src/tests/core/test_pathlib.py @@ -1,14 +1,16 @@ from __future__ import annotations from os import mkfifo -from typing import TYPE_CHECKING +from pathlib import Path from pytest import raises -from utilities.core import _FileOrDirMissingError, _FileOrDirTypeError, file_or_dir - -if TYPE_CHECKING: - from pathlib import Path +from utilities.core import ( + _FileOrDirMissingError, + _FileOrDirTypeError, + file_or_dir, + yield_temp_cwd, +) class TestFileOrDir: @@ -41,3 +43,11 @@ def test_error_type(self, *, tmp_path: Path) -> None: _FileOrDirTypeError, match=r"Path is neither a file nor a directory: '.*'" ): _ = file_or_dir(path) + + +class TestYieldTempCwd: + def test_main(self, *, tmp_path: Path) -> None: + assert Path.cwd() != tmp_path + with yield_temp_cwd(tmp_path): + assert Path.cwd() == tmp_path + assert Path.cwd() != tmp_path diff --git a/src/tests/test_errors.py b/src/tests/test_errors.py index 226db9f6c..a98730e1d 100644 --- a/src/tests/test_errors.py +++ b/src/tests/test_errors.py @@ -5,7 +5,7 @@ from pytest import RaisesGroup, raises from utilities.asyncio import sleep -from utilities.errors import ImpossibleCaseError, is_instance_error, repr_error +from utilities.errors import ImpossibleCaseError, repr_error class TestImpossibleCaseError: @@ -15,39 +15,6 @@ def test_main(self) -> None: raise ImpossibleCaseError(case=[f"{x=}"]) -class TestIsInstanceError: - def test_flat(self) -> None: - class CustomError(Exception): ... - - with raises(CustomError) as exc_info: - raise CustomError - - assert is_instance_error(exc_info.value, CustomError) - - async def test_group(self) -> None: - class CustomError(Exception): ... - - async def coroutine() -> None: - await sleep() - raise CustomError - - with RaisesGroup(CustomError) as exc_info: - async with TaskGroup() as tg: - _ = tg.create_task(coroutine()) - - assert is_instance_error(exc_info.value, CustomError) - - def test_false(self) -> None: - class Custom1Error(Exception): ... - - class Custom2Error(Exception): ... - - with raises(Custom1Error) as exc_info: - raise Custom1Error - - assert not is_instance_error(exc_info.value, Custom2Error) - - class TestReprError: def test_class(self) -> None: class CustomError(Exception): ... diff --git a/src/tests/test_pathlib.py b/src/tests/test_pathlib.py index 57b8f65ca..0e8ddea46 100644 --- a/src/tests/test_pathlib.py +++ b/src/tests/test_pathlib.py @@ -32,7 +32,6 @@ is_sub_path, list_dir, module_path, - temp_cwd, to_path, ) @@ -296,14 +295,6 @@ def test_main(self, *, root: PathLike | None, expected: Path) -> None: assert module == expected -class TestTempCwd: - def test_main(self, *, tmp_path: Path) -> None: - assert Path.cwd() != tmp_path - with temp_cwd(tmp_path): - assert Path.cwd() == tmp_path - assert Path.cwd() != tmp_path - - class TestToPath: def test_default(self) -> None: assert to_path() == Path.cwd() diff --git a/src/utilities/asyncio.py b/src/utilities/asyncio.py index c5a5ec94f..a968abf80 100644 --- a/src/utilities/asyncio.py +++ b/src/utilities/asyncio.py @@ -36,9 +36,9 @@ ) from utilities.constants import SYSTEM_RANDOM, Sentinel, sentinel +from utilities.core import repr_ from utilities.functions import ensure_int, ensure_not_none, in_seconds from utilities.os import is_pytest -from utilities.reprlib import get_repr from utilities.shelve import yield_shelf from utilities.text import to_bool from utilities.warnings import suppress_warnings @@ -435,7 +435,7 @@ class OneAsyncError[T](Exception): class OneAsyncEmptyError[T](OneAsyncError[T]): @override def __str__(self) -> str: - return f"Iterable(s) {get_repr(self.iterables)} must not be empty" + return f"Iterable(s) {repr_(self.iterables)} must not be empty" @dataclass(kw_only=True, slots=True) @@ -445,7 +445,7 @@ class OneAsyncNonUniqueError[T](OneAsyncError): @override def __str__(self) -> str: - return f"Iterable(s) {get_repr(self.iterables)} must contain exactly one item; got {self.first}, {self.second} and perhaps more" + return f"Iterable(s) {repr_(self.iterables)} must contain exactly one item; got {self.first}, {self.second} and perhaps more" ## diff --git a/src/utilities/core.py b/src/utilities/core.py index 8c06bfda2..760c9072e 100644 --- a/src/utilities/core.py +++ b/src/utilities/core.py @@ -6,6 +6,7 @@ from contextlib import contextmanager from dataclasses import dataclass from itertools import chain +from os import chdir from pathlib import Path from tempfile import NamedTemporaryFile as _NamedTemporaryFile from typing import TYPE_CHECKING, Any, Literal, assert_never, cast, overload, override @@ -316,6 +317,20 @@ def __str__(self) -> str: return f"Path is neither a file nor a directory: {str(self.path)!r}" +## + + +@contextmanager +def yield_temp_cwd(path: PathLike, /) -> Iterator[None]: + """Context manager with temporary current working directory set.""" + prev = Path.cwd() + chdir(path) + try: + yield + finally: + chdir(prev) + + #### reprlib ################################################################ @@ -537,6 +552,7 @@ def yield_temp_file_at(path: PathLike, /) -> Iterator[Path]: "one", "one_str", "repr_", + "yield_temp_cwd", "yield_temp_dir_at", "yield_temp_file_at", ] diff --git a/src/utilities/dataclasses.py b/src/utilities/dataclasses.py index a4e163454..ba363ee8c 100644 --- a/src/utilities/dataclasses.py +++ b/src/utilities/dataclasses.py @@ -12,14 +12,15 @@ Sentinel, sentinel, ) -from utilities.errors import ImpossibleCaseError -from utilities.functions import get_class_name, is_sentinel -from utilities.iterables import ( +from utilities.core import ( OneStrEmptyError, OneStrNonUniqueError, - cmp_nullable, + get_class_name, + is_sentinel, one_str, ) +from utilities.errors import ImpossibleCaseError +from utilities.iterables import cmp_nullable from utilities.operator import is_equal from utilities.parse import ( _ParseObjectExtraNonUniqueError, diff --git a/src/utilities/enum.py b/src/utilities/enum.py index a3784e990..d2b663e71 100644 --- a/src/utilities/enum.py +++ b/src/utilities/enum.py @@ -4,8 +4,8 @@ from enum import Enum, StrEnum from typing import TYPE_CHECKING, Literal, assert_never, overload, override +from utilities.core import OneStrEmptyError, OneStrNonUniqueError, one_str from utilities.functions import ensure_str -from utilities.iterables import OneStrEmptyError, OneStrNonUniqueError, one_str if TYPE_CHECKING: from utilities.types import EnumLike diff --git a/src/utilities/errors.py b/src/utilities/errors.py index 587e2ee4e..7d44f5bbf 100644 --- a/src/utilities/errors.py +++ b/src/utilities/errors.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, assert_never, override if TYPE_CHECKING: - from utilities.types import ExceptionTypeLike, MaybeType + from utilities.types import MaybeType @dataclass(kw_only=True, slots=True) @@ -21,20 +21,6 @@ def __str__(self) -> str: ## -def is_instance_error( - error: BaseException, class_or_tuple: ExceptionTypeLike[Exception], / -) -> bool: - """Check if an instance relationship holds, allowing for groups.""" - if isinstance(error, class_or_tuple): - return True - if not isinstance(error, BaseExceptionGroup): - return False - return any(is_instance_error(e, class_or_tuple) for e in error.exceptions) - - -## - - def repr_error(error: MaybeType[BaseException], /) -> str: """Get a string representation of an error.""" match error: diff --git a/src/utilities/hypothesis.py b/src/utilities/hypothesis.py index ccc0a7380..fff82e9b5 100644 --- a/src/utilities/hypothesis.py +++ b/src/utilities/hypothesis.py @@ -85,17 +85,17 @@ sentinel, ) from utilities.contextlib import enhanced_context_manager -from utilities.core import TemporaryDirectory -from utilities.functions import ( - ensure_int, - ensure_str, +from utilities.core import ( + TemporaryDirectory, is_sentinel, max_nullable, min_nullable, + yield_temp_cwd, ) +from utilities.functions import ensure_int, ensure_str from utilities.math import is_zero from utilities.os import get_env_var -from utilities.pathlib import module_path, temp_cwd +from utilities.pathlib import module_path from utilities.permissions import Permissions from utilities.version import Version2, Version3 from utilities.whenever import ( @@ -563,7 +563,7 @@ def floats_extra( @composite def git_repos(draw: DrawFn, /) -> Path: path = draw(temp_paths()) - with temp_cwd(path): + with yield_temp_cwd(path): _ = check_call(["git", "init", "-b", "master"]) _ = check_call(["git", "config", "user.name", "User"]) _ = check_call(["git", "config", "user.email", "a@z.com"]) diff --git a/src/utilities/operator.py b/src/utilities/operator.py index 3f4caaac9..70a6b7825 100644 --- a/src/utilities/operator.py +++ b/src/utilities/operator.py @@ -6,8 +6,8 @@ from typing import TYPE_CHECKING, Any, cast, override import utilities.math +from utilities.core import repr_ from utilities.iterables import SortIterableError, sort_iterable -from utilities.reprlib import get_repr from utilities.typing import is_dataclass_instance if TYPE_CHECKING: @@ -109,7 +109,7 @@ class IsEqualError(Exception): @override def __str__(self) -> str: - return f"Unable to sort {get_repr(self.x)} and {get_repr(self.y)}" # pragma: no cover + return f"Unable to sort {repr_(self.x)} and {repr_(self.y)}" # pragma: no cover __all__ = ["IsEqualError", "is_equal"] diff --git a/src/utilities/parse.py b/src/utilities/parse.py index f92248893..081e14e63 100644 --- a/src/utilities/parse.py +++ b/src/utilities/parse.py @@ -28,8 +28,8 @@ Sentinel, SentinelParseError, ) +from utilities.core import OneEmptyError, OneNonUniqueError, one, one_str from utilities.enum import ParseEnumError, parse_enum -from utilities.iterables import OneEmptyError, OneNonUniqueError, one, one_str from utilities.math import ParseNumberError, parse_number from utilities.re import ExtractGroupError, extract_group from utilities.text import ( diff --git a/src/utilities/pathlib.py b/src/utilities/pathlib.py index b3ab1266e..4b023ca6f 100644 --- a/src/utilities/pathlib.py +++ b/src/utilities/pathlib.py @@ -3,7 +3,6 @@ from collections.abc import Callable from dataclasses import dataclass from itertools import chain -from os import chdir from os.path import expandvars from pathlib import Path from re import IGNORECASE, search @@ -11,13 +10,12 @@ from typing import TYPE_CHECKING, Literal, assert_never, overload, override from utilities.constants import Sentinel -from utilities.contextlib import enhanced_context_manager from utilities.errors import ImpossibleCaseError from utilities.grp import get_gid_name from utilities.pwd import get_uid_name if TYPE_CHECKING: - from collections.abc import Iterator, Sequence + from collections.abc import Sequence from utilities.types import MaybeCallablePathLike, PathLike @@ -297,20 +295,6 @@ def list_dir(path: PathLike, /) -> Sequence[Path]: ## -@enhanced_context_manager -def temp_cwd(path: PathLike, /) -> Iterator[None]: - """Context manager with temporary current working directory set.""" - prev = Path.cwd() - chdir(path) - try: - yield - finally: - chdir(prev) - - -## - - @overload def to_path(path: Sentinel, /) -> Sentinel: ... @overload @@ -346,6 +330,5 @@ def to_path( "is_sub_path", "list_dir", "module_path", - "temp_cwd", "to_path", ] diff --git a/src/utilities/text.py b/src/utilities/text.py index 92606c854..d6643ae49 100644 --- a/src/utilities/text.py +++ b/src/utilities/text.py @@ -23,7 +23,7 @@ from utilities.constants import BRACKETS, LIST_SEPARATOR, PAIR_SEPARATOR, Sentinel from utilities.iterables import CheckDuplicatesError, check_duplicates, transpose -from utilities.reprlib import get_repr +from utilities.reprlib import repr_ if TYPE_CHECKING: from collections.abc import Iterable, Mapping, Sequence @@ -212,7 +212,7 @@ class _SplitKeyValuePairsDuplicateKeysError(SplitKeyValuePairsError): @override def __str__(self) -> str: - return f"Unable to split {self.text!r} into a mapping since there are duplicate keys; got {get_repr(self.counts)}" + return f"Unable to split {self.text!r} into a mapping since there are duplicate keys; got {repr_(self.counts)}" ## From 5636a9ae74c68a280facfe896102adb91807c9ce Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 15:21:33 +0900 Subject: [PATCH 17/78] 2026-01-21 15:21:33 (Wed) > DW-Mac > derekwan --- .pre-commit-config.yaml | 2 +- src/tests/test_hypothesis.py | 3 ++- src/utilities/click.py | 3 ++- src/utilities/compression.py | 10 ++++++-- src/utilities/lightweight_charts.py | 6 ++--- src/utilities/orjson.py | 4 ++-- src/utilities/polars.py | 36 +++++++++++++---------------- 7 files changed, 34 insertions(+), 30 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 900ccef3f..7ca17272d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/dycw/pre-commit-hooks - rev: 0.14.108 + rev: 0.14.109 hooks: - id: add-hooks args: diff --git a/src/tests/test_hypothesis.py b/src/tests/test_hypothesis.py index 0a0229033..af1b7486c 100644 --- a/src/tests/test_hypothesis.py +++ b/src/tests/test_hypothesis.py @@ -61,7 +61,8 @@ MIN_UINT32, MIN_UINT64, ) -from utilities.functions import ensure_int, is_sentinel +from utilities.core import is_sentinel +from utilities.functions import ensure_int from utilities.hypothesis import ( _LINUX_DISALLOW_TIME_ZONES, Shape, diff --git a/src/utilities/click.py b/src/utilities/click.py index e4a32751f..464cdfb5f 100644 --- a/src/utilities/click.py +++ b/src/utilities/click.py @@ -11,8 +11,9 @@ from click import Choice, Context, Parameter, ParamType from click.types import IntParamType, StringParamType +from utilities.core import get_class, get_class_name from utilities.enum import EnsureEnumError, ensure_enum -from utilities.functions import EnsureStrError, ensure_str, get_class, get_class_name +from utilities.functions import EnsureStrError, ensure_str from utilities.iterables import is_iterable_not_str, one from utilities.parse import ParseObjectError, parse_object from utilities.text import split_str diff --git a/src/utilities/compression.py b/src/utilities/compression.py index f5d4aa08d..72b519c81 100644 --- a/src/utilities/compression.py +++ b/src/utilities/compression.py @@ -7,9 +7,15 @@ from utilities.atomicwrites import writer from utilities.contextlib import enhanced_context_manager -from utilities.core import TemporaryDirectory, TemporaryFile, file_or_dir +from utilities.core import ( + OneEmptyError, + OneNonUniqueError, + TemporaryDirectory, + TemporaryFile, + file_or_dir, + one, +) from utilities.errors import ImpossibleCaseError -from utilities.iterables import OneEmptyError, OneNonUniqueError, one if TYPE_CHECKING: from collections.abc import Iterator diff --git a/src/utilities/lightweight_charts.py b/src/utilities/lightweight_charts.py index db87d2c53..131fb0682 100644 --- a/src/utilities/lightweight_charts.py +++ b/src/utilities/lightweight_charts.py @@ -5,8 +5,8 @@ from utilities.atomicwrites import writer # pragma: no cover from utilities.contextlib import enhanced_async_context_manager -from utilities.iterables import OneEmptyError, OneNonUniqueError, one -from utilities.reprlib import get_repr +from utilities.core import OneEmptyError, OneNonUniqueError, one, repr_ +from utilities.reprlib import repr_ if TYPE_CHECKING: from collections.abc import AsyncIterator @@ -72,7 +72,7 @@ class _SetDataFrameNonUniqueError(SetDataFrameError): @override def __str__(self) -> str: - return f"{get_repr(self.schema)} must contain exactly 1 date/datetime column; got {self.first!r}, {self.second!r} and perhaps more" + return f"{repr_(self.schema)} must contain exactly 1 date/datetime column; got {self.first!r}, {self.second!r} and perhaps more" ## diff --git a/src/utilities/orjson.py b/src/utilities/orjson.py index 1f0599142..bc18a4dff 100644 --- a/src/utilities/orjson.py +++ b/src/utilities/orjson.py @@ -38,11 +38,11 @@ from utilities.concurrent import concurrent_map from utilities.constants import LOCAL_TIME_ZONE, MAX_INT64, MIN_INT64 -from utilities.core import always_iterable +from utilities.core import OneEmptyError, always_iterable, one from utilities.dataclasses import dataclass_to_dict from utilities.functions import ensure_class from utilities.gzip import read_binary -from utilities.iterables import OneEmptyError, merge_sets, one +from utilities.iterables import merge_sets from utilities.json import write_formatted_json from utilities.logging import get_logging_level_number from utilities.types import Dataclass, LogLevel, MaybeIterable, PathLike, StrMapping diff --git a/src/utilities/polars.py b/src/utilities/polars.py index 7237d6cb6..13b8ed525 100644 --- a/src/utilities/polars.py +++ b/src/utilities/polars.py @@ -55,7 +55,7 @@ import utilities.math from utilities.constants import UTC -from utilities.core import always_iterable +from utilities.core import OneEmptyError, OneNonUniqueError, always_iterable, one from utilities.dataclasses import yield_fields from utilities.errors import ImpossibleCaseError from utilities.functions import get_class_name @@ -64,13 +64,10 @@ CheckIterablesEqualError, CheckMappingsEqualError, CheckSuperMappingError, - OneEmptyError, - OneNonUniqueError, check_iterables_equal, check_mappings_equal, check_supermapping, is_iterable_not_str, - one, resolve_include_and_exclude, ) from utilities.json import write_formatted_json @@ -82,7 +79,6 @@ is_less_than, is_non_negative, ) -from utilities.reprlib import get_repr from utilities.types import MaybeStr, Number, PathLike, StrDict, WeekDay from utilities.typing import ( get_args, @@ -344,7 +340,7 @@ class AppendRowError(Exception): class _AppendRowPredicateError(AppendRowError): @override def __str__(self) -> str: - return f"Predicate failed; got {get_repr(self.row)}" + return f"Predicate failed; got {repr_(self.row)}" @dataclass(kw_only=True, slots=True) @@ -353,7 +349,7 @@ class _AppendRowExtraKeysError(AppendRowError): @override def __str__(self) -> str: - return f"Extra key(s) found; got {get_repr(self.extra)}" + return f"Extra key(s) found; got {repr_(self.extra)}" @dataclass(kw_only=True, slots=True) @@ -362,7 +358,7 @@ class _AppendRowMissingKeysError(AppendRowError): @override def __str__(self) -> str: - return f"Missing key(s) found; got {get_repr(self.missing)}" + return f"Missing key(s) found; got {repr_(self.missing)}" @dataclass(kw_only=True, slots=True) @@ -371,7 +367,7 @@ class _AppendRowNullColumnsError(AppendRowError): @override def __str__(self) -> str: - return f"Null column(s) found; got {get_repr(self.columns)}" + return f"Null column(s) found; got {repr_(self.columns)}" ## @@ -570,7 +566,7 @@ class _CheckPolarsDataFrameColumnsError(CheckPolarsDataFrameError): @override def __str__(self) -> str: - return f"DataFrame must have columns {get_repr(self.columns)}; got {get_repr(self.df.columns)}:\n\n{self.df}" + return f"DataFrame must have columns {repr_(self.columns)}; got {repr_(self.df.columns)}:\n\n{self.df}" def _check_polars_dataframe_dtypes( @@ -588,7 +584,7 @@ class _CheckPolarsDataFrameDTypesError(CheckPolarsDataFrameError): @override def __str__(self) -> str: - return f"DataFrame must have dtypes {get_repr(self.dtypes)}; got {get_repr(self.df.dtypes)}:\n\n{self.df}" + return f"DataFrame must have dtypes {repr_(self.dtypes)}; got {repr_(self.df.dtypes)}:\n\n{self.df}" def _check_polars_dataframe_height( @@ -651,9 +647,9 @@ def __str__(self) -> str: def _yield_parts(self) -> Iterator[str]: if len(self.missing) >= 1: - yield f"missing columns were {get_repr(self.missing)}" + yield f"missing columns were {repr_(self.missing)}" if len(self.failed) >= 1: - yield f"failed predicates were {get_repr(self.failed)}" + yield f"failed predicates were {repr_(self.failed)}" def _check_polars_dataframe_schema_list(df: DataFrame, schema: SchemaDict, /) -> None: @@ -673,7 +669,7 @@ class _CheckPolarsDataFrameSchemaListError(CheckPolarsDataFrameError): @override def __str__(self) -> str: - return f"DataFrame must have schema {get_repr(self.schema)} (ordered); got {get_repr(self.df.schema)}:\n\n{self.df}" + return f"DataFrame must have schema {repr_(self.schema)} (ordered); got {repr_(self.df.schema)}:\n\n{self.df}" def _check_polars_dataframe_schema_set(df: DataFrame, schema: SchemaDict, /) -> None: @@ -689,7 +685,7 @@ class _CheckPolarsDataFrameSchemaSetError(CheckPolarsDataFrameError): @override def __str__(self) -> str: - return f"DataFrame must have schema {get_repr(self.schema)} (unordered); got {get_repr(self.df.schema)}:\n\n{self.df}" + return f"DataFrame must have schema {repr_(self.schema)} (unordered); got {repr_(self.df.schema)}:\n\n{self.df}" def _check_polars_dataframe_schema_subset(df: DataFrame, schema: SchemaDict, /) -> None: @@ -705,7 +701,7 @@ class _CheckPolarsDataFrameSchemaSubsetError(CheckPolarsDataFrameError): @override def __str__(self) -> str: - return f"DataFrame schema must include {get_repr(self.schema)} (unordered); got {get_repr(self.df.schema)}:\n\n{self.df}" + return f"DataFrame schema must include {repr_(self.schema)} (unordered); got {repr_(self.df.schema)}:\n\n{self.df}" def _check_polars_dataframe_shape(df: DataFrame, shape: tuple[int, int], /) -> None: @@ -743,7 +739,7 @@ class _CheckPolarsDataFrameSortedError(CheckPolarsDataFrameError): @override def __str__(self) -> str: - return f"DataFrame must be sorted on {get_repr(self.by)}:\n\n{self.df}" + return f"DataFrame must be sorted on {repr_(self.by)}:\n\n{self.df}" def _check_polars_dataframe_unique( @@ -762,7 +758,7 @@ class _CheckPolarsDataFrameUniqueError(CheckPolarsDataFrameError): @override def __str__(self) -> str: - return f"DataFrame must be unique on {get_repr(self.by)}:\n\n{self.df}" + return f"DataFrame must be unique on {repr_(self.by)}:\n\n{self.df}" def _check_polars_dataframe_width(df: DataFrame, width: int, /) -> None: @@ -1133,7 +1129,7 @@ class _DataClassToDataFrameNonUniqueError(DataClassToDataFrameError): @override def __str__(self) -> str: - return f"Iterable {get_repr(self.objs)} must contain exactly 1 class; got {self.first}, {self.second} and perhaps more" + return f"Iterable {repr_(self.objs)} must contain exactly 1 class; got {self.first}, {self.second} and perhaps more" ## @@ -2666,7 +2662,7 @@ class SelectExactError(Exception): @override def __str__(self) -> str: - return f"All columns must be selected; got {get_repr(self.columns)} remaining" + return f"All columns must be selected; got {repr_(self.columns)} remaining" ## From 673488eefe5d7b4d0f0795c43fd66e7d840ea601 Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 15:22:11 +0900 Subject: [PATCH 18/78] 2026-01-21 15:22:11 (Wed) > DW-Mac > derekwan --- src/utilities/more_itertools.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/utilities/more_itertools.py b/src/utilities/more_itertools.py index d1619bcb9..918c87921 100644 --- a/src/utilities/more_itertools.py +++ b/src/utilities/more_itertools.py @@ -19,9 +19,7 @@ from more_itertools import peekable as _peekable from utilities.constants import Sentinel, sentinel -from utilities.functions import get_class_name, is_sentinel -from utilities.iterables import OneNonUniqueError, one -from utilities.reprlib import get_repr +from utilities.core import OneNonUniqueError, get_class_name, is_sentinel, one, repr_ if TYPE_CHECKING: from collections.abc import Iterable, Iterator, Mapping, Sequence @@ -232,7 +230,7 @@ class BucketMappingError[K: Hashable, V](Exception): @override def __str__(self) -> str: parts = [ - f"{get_repr(key)} (#1: {get_repr(first)}, #2: {get_repr(second)})" + f"{repr_(key)} (#1: {repr_(first)}, #2: {repr_(second)})" for key, (first, second) in self.errors.items() ] desc = ", ".join(parts) From 298cafce63522d47080bedc36998986669c4ba7c Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 15:25:08 +0900 Subject: [PATCH 19/78] 2026-01-21 15:25:08 (Wed) > DW-Mac > derekwan --- src/utilities/packaging.py | 2 +- src/utilities/pqdm.py | 3 ++- src/utilities/pytest_regressions.py | 4 ++-- src/utilities/shellingham.py | 6 +++--- src/utilities/sqlalchemy.py | 14 ++++++-------- 5 files changed, 14 insertions(+), 15 deletions(-) diff --git a/src/utilities/packaging.py b/src/utilities/packaging.py index 43db14385..3aab92a9b 100644 --- a/src/utilities/packaging.py +++ b/src/utilities/packaging.py @@ -8,7 +8,7 @@ from packaging.requirements import _parse_requirement from packaging.specifiers import Specifier, SpecifierSet -from utilities.iterables import OneEmptyError, one +from utilities.core import OneEmptyError, one if TYPE_CHECKING: from packaging._parser import MarkerList diff --git a/src/utilities/pqdm.py b/src/utilities/pqdm.py index f0b285981..d85fb19f0 100644 --- a/src/utilities/pqdm.py +++ b/src/utilities/pqdm.py @@ -7,7 +7,8 @@ from tqdm.auto import tqdm as tqdm_auto from utilities.constants import Sentinel, sentinel -from utilities.functions import get_func_name, is_sentinel +from utilities.core import is_sentinel +from utilities.functions import get_func_name from utilities.iterables import apply_to_varargs from utilities.os import get_cpu_use diff --git a/src/utilities/pytest_regressions.py b/src/utilities/pytest_regressions.py index 884e286d7..420619385 100644 --- a/src/utilities/pytest_regressions.py +++ b/src/utilities/pytest_regressions.py @@ -10,9 +10,9 @@ from pytest_regressions.file_regression import FileRegressionFixture from utilities.atomicwrites import _CopySourceNotFoundError, copy +from utilities.core import repr_ from utilities.functions import ensure_str from utilities.operator import is_equal -from utilities.reprlib import get_repr if TYPE_CHECKING: from polars import DataFrame, Series @@ -96,7 +96,7 @@ class OrjsonRegressionError(Exception): @override def __str__(self) -> str: - return f"Obtained object (at {str(self.path_obtained)!r}) and existing object (at {str(self.path_existing)!r}) differ; got {get_repr(self.obtained)} and {get_repr(self.existing)}" + return f"Obtained object (at {str(self.path_obtained)!r}) and existing object (at {str(self.path_existing)!r}) differ; got {repr_(self.obtained)} and {repr_(self.existing)}" ## diff --git a/src/utilities/shellingham.py b/src/utilities/shellingham.py index c34458461..7bdef260f 100644 --- a/src/utilities/shellingham.py +++ b/src/utilities/shellingham.py @@ -7,7 +7,7 @@ from shellingham import ShellDetectionFailure, detect_shell -from utilities.iterables import OneEmptyError, one +from utilities.core import OneEmptyError, one, repr_ from utilities.typing import get_args type Shell = Literal["bash", "fish", "posix", "sh", "zsh"] @@ -48,7 +48,7 @@ class _GetShellUnsupportedError(Exception): @override def __str__(self) -> str: - return f"Invalid shell; got {self.shell!r}" # pragma: no cover + return f"Invalid shell; got {repr_(self.shell)}" # pragma: no cover @dataclass(kw_only=True, slots=True) @@ -57,7 +57,7 @@ class _GetShellOSError(GetShellError): @override def __str__(self) -> str: - return f"Invalid OS; got {self.name!r}" # pragma: no cover + return f"Invalid OS; got {repr_(self.name)}" # pragma: no cover SHELL = get_shell() diff --git a/src/utilities/sqlalchemy.py b/src/utilities/sqlalchemy.py index 26cd3e38e..eefef330d 100644 --- a/src/utilities/sqlalchemy.py +++ b/src/utilities/sqlalchemy.py @@ -66,12 +66,11 @@ from sqlalchemy.pool import NullPool, Pool import utilities.asyncio -from utilities.functions import ensure_str, get_class_name, yield_object_attributes +from utilities.core import OneEmptyError, OneNonUniqueError, get_class_name, repr_ +from utilities.functions import ensure_str, yield_object_attributes from utilities.iterables import ( CheckLengthError, CheckSubSetError, - OneEmptyError, - OneNonUniqueError, check_length, check_subset, chunked, @@ -80,7 +79,6 @@ one, ) from utilities.os import is_pytest -from utilities.reprlib import get_repr from utilities.text import secret_str, snake_case from utilities.types import ( Duration, @@ -187,7 +185,7 @@ class CheckEngineError(Exception): @override def __str__(self) -> str: - return f"{get_repr(self.engine)} must have {self.expected} table(s); got {len(self.rows)}" + return f"{repr_(self.engine)} must have {self.expected} table(s); got {len(self.rows)}" ## @@ -1103,7 +1101,7 @@ class _MapMappingToTableExtraColumnsError(_MapMappingToTableError): @override def __str__(self) -> str: - return f"Mapping {get_repr(self.mapping)} must be a subset of table columns {get_repr(self.columns)}; got extra {self.extra}" + return f"Mapping {repr_(self.mapping)} must be a subset of table columns {repr_(self.columns)}; got extra {self.extra}" @dataclass(kw_only=True, slots=True) @@ -1112,7 +1110,7 @@ class _MapMappingToTableSnakeMapEmptyError(_MapMappingToTableError): @override def __str__(self) -> str: - return f"Mapping {get_repr(self.mapping)} must be a subset of table columns {get_repr(self.columns)}; cannot find column to map to {self.key!r} modulo snake casing" + return f"Mapping {repr_(self.mapping)} must be a subset of table columns {repr_(self.columns)}; cannot find column to map to {self.key!r} modulo snake casing" @dataclass(kw_only=True, slots=True) @@ -1123,7 +1121,7 @@ class _MapMappingToTableSnakeMapNonUniqueError(_MapMappingToTableError): @override def __str__(self) -> str: - return f"Mapping {get_repr(self.mapping)} must be a subset of table columns {get_repr(self.columns)}; found columns {self.first!r}, {self.second!r} and perhaps more to map to {self.key!r} modulo snake casing" + return f"Mapping {repr_(self.mapping)} must be a subset of table columns {repr_(self.columns)}; found columns {self.first!r}, {self.second!r} and perhaps more to map to {self.key!r} modulo snake casing" ## From 127cf41f21240b87fa95a4f19f25c1136cac670a Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 15:25:31 +0900 Subject: [PATCH 20/78] 2026-01-21 15:25:31 (Wed) > DW-Mac > derekwan --- src/utilities/sqlalchemy_polars.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/src/utilities/sqlalchemy_polars.py b/src/utilities/sqlalchemy_polars.py index d17412b8e..44a9f9bd3 100644 --- a/src/utilities/sqlalchemy_polars.py +++ b/src/utilities/sqlalchemy_polars.py @@ -28,16 +28,10 @@ import utilities.asyncio from utilities.constants import UTC +from utilities.core import OneError, one, repr_ from utilities.functions import identity -from utilities.iterables import ( - CheckDuplicatesError, - OneError, - check_duplicates, - chunked, - one, -) +from utilities.iterables import CheckDuplicatesError, check_duplicates, chunked from utilities.polars import zoned_date_time_dtype -from utilities.reprlib import get_repr from utilities.sqlalchemy import ( CHUNK_SIZE_FRAC, TableOrORMInstOrClass, @@ -168,7 +162,7 @@ class _InsertDataFrameMapDFColumnToTableColumnAndTypeError(Exception): @override def __str__(self) -> str: - return f"Unable to map DataFrame column {self.df_col_name!r} into table schema {get_repr(self.table_schema)} with snake={self.snake}" + return f"Unable to map DataFrame column {self.df_col_name!r} into table schema {repr_(self.table_schema)} with snake={self.snake}" def _insert_dataframe_check_df_and_db_types( From 27b2cf2176c5281c93b8e3f9fa63a9e840309dd8 Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 15:25:45 +0900 Subject: [PATCH 21/78] 2026-01-21 15:25:45 (Wed) > DW-Mac > derekwan --- src/utilities/traceback.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utilities/traceback.py b/src/utilities/traceback.py index 60a4d710e..f37802921 100644 --- a/src/utilities/traceback.py +++ b/src/utilities/traceback.py @@ -23,8 +23,8 @@ RICH_MAX_STRING, RICH_MAX_WIDTH, ) +from utilities.core import OneEmptyError, one from utilities.errors import repr_error -from utilities.iterables import OneEmptyError, one from utilities.pathlib import module_path, to_path from utilities.reprlib import yield_mapping_repr from utilities.text import to_bool From 1d8a747d9040e5b0a7a79dfe7c98799cfbbe1000 Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 15:25:57 +0900 Subject: [PATCH 22/78] 2026-01-21 15:25:57 (Wed) > DW-Mac > derekwan --- src/utilities/zipfile.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/utilities/zipfile.py b/src/utilities/zipfile.py index 2de31e23c..c884b6cdd 100644 --- a/src/utilities/zipfile.py +++ b/src/utilities/zipfile.py @@ -6,8 +6,13 @@ from utilities.atomicwrites import writer from utilities.contextlib import enhanced_context_manager -from utilities.core import TemporaryDirectory, file_or_dir -from utilities.iterables import OneEmptyError, OneNonUniqueError, one +from utilities.core import ( + OneEmptyError, + OneNonUniqueError, + TemporaryDirectory, + file_or_dir, + one, +) if TYPE_CHECKING: from collections.abc import Iterator From fc66f329f648bac1a956a9431dacc7efca850188 Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 15:29:13 +0900 Subject: [PATCH 23/78] 2026-01-21 15:29:13 (Wed) > DW-Mac > derekwan --- src/tests/core/test_contextlib.py | 62 +++++++++++++++++++++++++++++++ src/tests/test_contextlib.py | 60 +----------------------------- src/utilities/contextlib.py | 20 +--------- src/utilities/core.py | 21 +++++++++-- 4 files changed, 83 insertions(+), 80 deletions(-) create mode 100644 src/tests/core/test_contextlib.py diff --git a/src/tests/core/test_contextlib.py b/src/tests/core/test_contextlib.py new file mode 100644 index 000000000..240d58dfb --- /dev/null +++ b/src/tests/core/test_contextlib.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import override + +from pytest import raises + +from utilities.core import suppress_super_object_attribute_error + + +class TestSuppressSuperObjectAttributeError: + def test_main(self) -> None: + inits: list[str] = [] + + @dataclass(kw_only=True) + class A: + def __post_init__(self) -> None: + with suppress_super_object_attribute_error(): + super().__post_init__() # pyright:ignore [reportAttributeAccessIssue] + nonlocal inits + inits.append("A") + + @dataclass(kw_only=True) + class B: ... + + @dataclass(kw_only=True) + class C: + def __post_init__(self) -> None: + with suppress_super_object_attribute_error(): + super().__post_init__() # pyright:ignore [reportAttributeAccessIssue] + nonlocal inits + inits.append("C") + + @dataclass(kw_only=True) + class D: ... + + @dataclass(kw_only=True) + class E(A, B, C, D): + @override + def __post_init__(self) -> None: + super().__post_init__() + nonlocal inits + inits.append("E") + + _ = E() + assert inits == ["C", "A", "E"] + + def test_error(self) -> None: + @dataclass(kw_only=True) + class Parent: + def __post_init__(self) -> None: + with suppress_super_object_attribute_error(): + _ = self.error # pyright:ignore [reportAttributeAccessIssue] + + @dataclass(kw_only=True) + class Child(Parent): + @override + def __post_init__(self) -> None: + super().__post_init__() + + with raises(AttributeError, match=r"'Child' object has no attribute 'error'"): + _ = Child() diff --git a/src/tests/test_contextlib.py b/src/tests/test_contextlib.py index 890f2a268..294e3a840 100644 --- a/src/tests/test_contextlib.py +++ b/src/tests/test_contextlib.py @@ -2,15 +2,14 @@ from asyncio import run from concurrent.futures import ThreadPoolExecutor -from dataclasses import dataclass from inspect import signature from multiprocessing import Process from pathlib import Path -from typing import TYPE_CHECKING, override +from typing import TYPE_CHECKING from hypothesis import given from hypothesis.strategies import booleans -from pytest import mark, param, raises +from pytest import mark, param import utilities.asyncio import utilities.time @@ -18,7 +17,6 @@ from utilities.contextlib import ( enhanced_async_context_manager, enhanced_context_manager, - suppress_super_object_attribute_error, ) from utilities.pytest import skipif_ci @@ -242,57 +240,3 @@ def test_multiprocessing_sigterm( utilities.time.sleep(_DURATION) assert proc.is_alive() assert not marker.exists() - - -class TestSuppressSuperObjectAttributeError: - def test_main(self) -> None: - inits: list[str] = [] - - @dataclass(kw_only=True) - class A: - def __post_init__(self) -> None: - with suppress_super_object_attribute_error(): - super().__post_init__() # pyright:ignore [reportAttributeAccessIssue] - nonlocal inits - inits.append("A") - - @dataclass(kw_only=True) - class B: ... - - @dataclass(kw_only=True) - class C: - def __post_init__(self) -> None: - with suppress_super_object_attribute_error(): - super().__post_init__() # pyright:ignore [reportAttributeAccessIssue] - nonlocal inits - inits.append("C") - - @dataclass(kw_only=True) - class D: ... - - @dataclass(kw_only=True) - class E(A, B, C, D): - @override - def __post_init__(self) -> None: - super().__post_init__() - nonlocal inits - inits.append("E") - - _ = E() - assert inits == ["C", "A", "E"] - - def test_error(self) -> None: - @dataclass(kw_only=True) - class Parent: - def __post_init__(self) -> None: - with suppress_super_object_attribute_error(): - _ = self.error # pyright:ignore [reportAttributeAccessIssue] - - @dataclass(kw_only=True) - class Child(Parent): - @override - def __post_init__(self) -> None: - super().__post_init__() - - with raises(AttributeError, match=r"'Child' object has no attribute 'error'"): - _ = Child() diff --git a/src/utilities/contextlib.py b/src/utilities/contextlib.py index c5c43393c..1045615de 100644 --- a/src/utilities/contextlib.py +++ b/src/utilities/contextlib.py @@ -1,6 +1,5 @@ from __future__ import annotations -import re from asyncio import create_task, get_event_loop from contextlib import ( _AsyncGeneratorContextManager, @@ -237,21 +236,4 @@ def _suppress_signal_error() -> Iterator[None]: ## -_SUPER_OBJECT_HAS_NO_ATTRIBUTE = re.compile(r"'super' object has no attribute '\w+'") - - -@enhanced_context_manager -def suppress_super_object_attribute_error() -> Iterator[None]: - """Suppress the super() attribute error, for mix-ins.""" - try: - yield - except AttributeError as error: - if not _SUPER_OBJECT_HAS_NO_ATTRIBUTE.search(error.args[0]): - raise - - -__all__ = [ - "enhanced_async_context_manager", - "enhanced_context_manager", - "suppress_super_object_attribute_error", -] +__all__ = ["enhanced_async_context_manager", "enhanced_context_manager"] diff --git a/src/utilities/core.py b/src/utilities/core.py index 760c9072e..8acf01243 100644 --- a/src/utilities/core.py +++ b/src/utilities/core.py @@ -8,6 +8,7 @@ from itertools import chain from os import chdir from pathlib import Path +from re import search from tempfile import NamedTemporaryFile as _NamedTemporaryFile from typing import TYPE_CHECKING, Any, Literal, assert_never, cast, overload, override from warnings import catch_warnings, filterwarnings @@ -135,6 +136,19 @@ def is_sentinel(obj: Any, /) -> TypeIs[Sentinel]: return obj is sentinel +#### contextlib ############################################################### + + +@contextmanager +def suppress_super_object_attribute_error() -> Iterator[None]: + """Suppress the super() attribute error, for mix-ins.""" + try: + yield + except AttributeError as error: + if not search(r"'super' object has no attribute '\w+'", error.args[0]): + raise + + #### itertools ################################################################ @@ -275,7 +289,7 @@ def __str__(self) -> str: return f"{head} {mid}; got {self.first!r}, {self.second!r} and perhaps more" -#### pathlib ################################################# +#### pathlib ################################################################## @overload @@ -331,7 +345,7 @@ def yield_temp_cwd(path: PathLike, /) -> Iterator[None]: chdir(prev) -#### reprlib ################################################################ +#### reprlib ################################################################## def repr_( @@ -361,7 +375,7 @@ def repr_( ) -#### tempfile ############################################################### +#### tempfile ################################################################# class TemporaryDirectory: @@ -552,6 +566,7 @@ def yield_temp_file_at(path: PathLike, /) -> Iterator[Path]: "one", "one_str", "repr_", + "suppress_super_object_attribute_error", "yield_temp_cwd", "yield_temp_dir_at", "yield_temp_file_at", From c1b97b18f7d80c737b5796c372e1371baff5c9a1 Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 15:30:01 +0900 Subject: [PATCH 24/78] 2026-01-21 15:30:01 (Wed) > DW-Mac > derekwan --- src/tests/core/test_contextlib.py | 10 +++++----- src/utilities/core.py | 4 ++-- src/utilities/psutil.py | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/tests/core/test_contextlib.py b/src/tests/core/test_contextlib.py index 240d58dfb..d68de48a2 100644 --- a/src/tests/core/test_contextlib.py +++ b/src/tests/core/test_contextlib.py @@ -5,17 +5,17 @@ from pytest import raises -from utilities.core import suppress_super_object_attribute_error +from utilities.core import suppress_super_attribute_error -class TestSuppressSuperObjectAttributeError: +class TestSuppressSuperAttributeError: def test_main(self) -> None: inits: list[str] = [] @dataclass(kw_only=True) class A: def __post_init__(self) -> None: - with suppress_super_object_attribute_error(): + with suppress_super_attribute_error(): super().__post_init__() # pyright:ignore [reportAttributeAccessIssue] nonlocal inits inits.append("A") @@ -26,7 +26,7 @@ class B: ... @dataclass(kw_only=True) class C: def __post_init__(self) -> None: - with suppress_super_object_attribute_error(): + with suppress_super_attribute_error(): super().__post_init__() # pyright:ignore [reportAttributeAccessIssue] nonlocal inits inits.append("C") @@ -49,7 +49,7 @@ def test_error(self) -> None: @dataclass(kw_only=True) class Parent: def __post_init__(self) -> None: - with suppress_super_object_attribute_error(): + with suppress_super_attribute_error(): _ = self.error # pyright:ignore [reportAttributeAccessIssue] @dataclass(kw_only=True) diff --git a/src/utilities/core.py b/src/utilities/core.py index 8acf01243..b8c02c2a5 100644 --- a/src/utilities/core.py +++ b/src/utilities/core.py @@ -140,7 +140,7 @@ def is_sentinel(obj: Any, /) -> TypeIs[Sentinel]: @contextmanager -def suppress_super_object_attribute_error() -> Iterator[None]: +def suppress_super_attribute_error() -> Iterator[None]: """Suppress the super() attribute error, for mix-ins.""" try: yield @@ -566,7 +566,7 @@ def yield_temp_file_at(path: PathLike, /) -> Iterator[Path]: "one", "one_str", "repr_", - "suppress_super_object_attribute_error", + "suppress_super_attribute_error", "yield_temp_cwd", "yield_temp_dir_at", "yield_temp_file_at", diff --git a/src/utilities/psutil.py b/src/utilities/psutil.py index 16f6a8b8a..283f82172 100644 --- a/src/utilities/psutil.py +++ b/src/utilities/psutil.py @@ -6,7 +6,7 @@ from psutil import swap_memory, virtual_memory -from utilities.contextlib import suppress_super_object_attribute_error +from utilities.core import suppress_super_attribute_error from utilities.whenever import get_now if TYPE_CHECKING: @@ -30,7 +30,7 @@ class MemoryUsage: swap_pct: float = field(init=False) def __post_init__(self) -> None: - with suppress_super_object_attribute_error(): + with suppress_super_attribute_error(): super().__post_init__() # pyright: ignore[reportAttributeAccessIssue] self.virtual_used_mb = self._to_mb(self.virtual_used) self.virtual_total_mb = self._to_mb(self.virtual_total) From 25c551d77f6b323510af124261bb9a7b24227d38 Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 15:34:42 +0900 Subject: [PATCH 25/78] 2026-01-21 15:34:42 (Wed) > DW-Mac > derekwan --- src/tests/core/test_os.py | 98 +++++++++++++++++++++++++++++++ src/tests/test_os.py | 112 +----------------------------------- src/tests/test_slack_sdk.py | 7 ++- src/utilities/core.py | 80 +++++++++++++++++++++++++- src/utilities/hypothesis.py | 4 +- src/utilities/os.py | 77 ++----------------------- src/utilities/throttle.py | 4 +- 7 files changed, 192 insertions(+), 190 deletions(-) create mode 100644 src/tests/core/test_os.py diff --git a/src/tests/core/test_os.py b/src/tests/core/test_os.py new file mode 100644 index 000000000..2a48dabae --- /dev/null +++ b/src/tests/core/test_os.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from os import getenv + +from hypothesis import given +from hypothesis.strategies import DataObject, booleans, data, none, sampled_from +from pytest import raises + +from utilities.core import GetEnvError, get_env, temp_environ +from utilities.hypothesis import text_ascii + +text = text_ascii(min_size=1, max_size=10) + + +def _prefix(text: str, /) -> str: + return f"_TEST_OS_{text}" + + +class TestGetEnv: + @given( + key=text.map(_prefix), value=text, default=text | none(), nullable=booleans() + ) + def test_case_sensitive( + self, *, key: str, value: str, default: str | None, nullable: bool + ) -> None: + with temp_environ({key: value}): + result = get_env(key, default=default, nullable=nullable) + assert result == value + + @given( + data=data(), + key=text.map(_prefix), + value=text, + default=text | none(), + nullable=booleans(), + ) + def test_case_insensitive( + self, + *, + data: DataObject, + key: str, + value: str, + default: str | None, + nullable: bool, + ) -> None: + key_use = data.draw(sampled_from([key, key.lower(), key.upper()])) + with temp_environ({key: value}): + result = get_env(key_use, default=default, nullable=nullable) + assert result == value + + @given( + key=text.map(_prefix), + case_sensitive=booleans(), + default=text, + nullable=booleans(), + ) + def test_default( + self, *, key: str, case_sensitive: bool, default: str, nullable: bool + ) -> None: + value = get_env( + key, case_sensitive=case_sensitive, default=default, nullable=nullable + ) + assert value == default + + @given(key=text.map(_prefix), case_sensitive=booleans()) + def test_nullable(self, *, key: str, case_sensitive: bool) -> None: + value = get_env(key, case_sensitive=case_sensitive, nullable=True) + assert value is None + + @given(key=text.map(_prefix), case_sensitive=booleans()) + def test_error(self, *, key: str, case_sensitive: bool) -> None: + with raises(GetEnvError, match=r"No environment variable .*(\(modulo case\))?"): + _ = get_env(key, case_sensitive=case_sensitive) + + +class TestTempEnviron: + @given(key=text.map(_prefix), value=text) + def test_set(self, *, key: str, value: str) -> None: + assert getenv(key) is None + with temp_environ({key: value}): + assert getenv(key) == value + assert getenv(key) is None + + @given(key=text.map(_prefix), prev=text, new=text) + def test_override(self, *, key: str, prev: str, new: str) -> None: + with temp_environ({key: prev}): + assert getenv(key) == prev + with temp_environ({key: new}): + assert getenv(key) == new + assert getenv(key) == prev + + @given(key=text.map(_prefix), value=text) + def test_unset(self, *, key: str, value: str) -> None: + with temp_environ({key: value}): + assert getenv(key) == value + with temp_environ({key: None}): + assert getenv(key) is None + assert getenv(key) == value diff --git a/src/tests/test_os.py b/src/tests/test_os.py index 204c8c6ba..f28b2dd39 100644 --- a/src/tests/test_os.py +++ b/src/tests/test_os.py @@ -1,34 +1,10 @@ from __future__ import annotations -from os import getenv - from hypothesis import given -from hypothesis.strategies import ( - DataObject, - booleans, - data, - integers, - none, - sampled_from, -) +from hypothesis.strategies import integers from pytest import mark, param, raises -from utilities.hypothesis import text_ascii -from utilities.os import ( - GetCPUUseError, - GetEnvVarError, - get_cpu_use, - get_env_var, - is_debug, - is_pytest, - temp_environ, -) - -text = text_ascii(min_size=1, max_size=10) - - -def _prefix(text: str, /) -> str: - return f"_TEST_OS_{text}" +from utilities.os import GetCPUUseError, get_cpu_use, is_debug, is_pytest, temp_environ class TestGetCPUUse: @@ -48,65 +24,6 @@ def test_error(self, *, n: int) -> None: _ = get_cpu_use(n=n) -class TestGetEnvVar: - @given( - key=text.map(_prefix), value=text, default=text | none(), nullable=booleans() - ) - def test_case_sensitive( - self, *, key: str, value: str, default: str | None, nullable: bool - ) -> None: - with temp_environ({key: value}): - result = get_env_var(key, default=default, nullable=nullable) - assert result == value - - @given( - data=data(), - key=text.map(_prefix), - value=text, - default=text | none(), - nullable=booleans(), - ) - def test_case_insensitive( - self, - *, - data: DataObject, - key: str, - value: str, - default: str | None, - nullable: bool, - ) -> None: - key_use = data.draw(sampled_from([key, key.lower(), key.upper()])) - with temp_environ({key: value}): - result = get_env_var(key_use, default=default, nullable=nullable) - assert result == value - - @given( - key=text.map(_prefix), - case_sensitive=booleans(), - default=text, - nullable=booleans(), - ) - def test_default( - self, *, key: str, case_sensitive: bool, default: str, nullable: bool - ) -> None: - value = get_env_var( - key, case_sensitive=case_sensitive, default=default, nullable=nullable - ) - assert value == default - - @given(key=text.map(_prefix), case_sensitive=booleans()) - def test_nullable(self, *, key: str, case_sensitive: bool) -> None: - value = get_env_var(key, case_sensitive=case_sensitive, nullable=True) - assert value is None - - @given(key=text.map(_prefix), case_sensitive=booleans()) - def test_error(self, *, key: str, case_sensitive: bool) -> None: - with raises( - GetEnvVarError, match=r"No environment variable .*(\(modulo case\))?" - ): - _ = get_env_var(key, case_sensitive=case_sensitive) - - class TestIsDebug: @mark.parametrize("env_var", [param("DEBUG"), param("debug")]) def test_main(self, *, env_var: str) -> None: @@ -125,28 +42,3 @@ def test_main(self) -> None: def test_off(self) -> None: with temp_environ(PYTEST_VERSION=None): assert not is_pytest() - - -class TestTempEnviron: - @given(key=text.map(_prefix), value=text) - def test_set(self, *, key: str, value: str) -> None: - assert getenv(key) is None - with temp_environ({key: value}): - assert getenv(key) == value - assert getenv(key) is None - - @given(key=text.map(_prefix), prev=text, new=text) - def test_override(self, *, key: str, prev: str, new: str) -> None: - with temp_environ({key: prev}): - assert getenv(key) == prev - with temp_environ({key: new}): - assert getenv(key) == new - assert getenv(key) == prev - - @given(key=text.map(_prefix), value=text) - def test_unset(self, *, key: str, value: str) -> None: - with temp_environ({key: value}): - assert getenv(key) == value - with temp_environ({key: None}): - assert getenv(key) is None - assert getenv(key) == value diff --git a/src/tests/test_slack_sdk.py b/src/tests/test_slack_sdk.py index f46238ae5..acb1a72aa 100644 --- a/src/tests/test_slack_sdk.py +++ b/src/tests/test_slack_sdk.py @@ -1,11 +1,12 @@ from __future__ import annotations +from os import environ + from aiohttp import InvalidUrlClientError from pytest import mark, raises from slack_sdk.webhook.async_client import AsyncWebhookClient from utilities.constants import MINUTE -from utilities.os import get_env_var from utilities.pytest import throttle_test from utilities.slack_sdk import _get_async_client, send_to_slack, send_to_slack_async @@ -25,10 +26,10 @@ async def test_async(self) -> None: with raises(InvalidUrlClientError, match=r"url"): await send_to_slack_async("url", "message") - @mark.skipif(get_env_var("SLACK", nullable=True) is None, reason="'SLACK' not set") + @mark.skipif("SLACK" not in environ, reason="'SLACK' not set") @throttle_test(duration=5 * MINUTE) async def test_real(self) -> None: - url = get_env_var("SLACK") + url = environ["SLACK"] await send_to_slack_async( url, f"message from {TestSendToSlack.test_real.__qualname__}" ) diff --git a/src/utilities/core.py b/src/utilities/core.py index b8c02c2a5..47820c713 100644 --- a/src/utilities/core.py +++ b/src/utilities/core.py @@ -6,7 +6,7 @@ from contextlib import contextmanager from dataclasses import dataclass from itertools import chain -from os import chdir +from os import chdir, environ from pathlib import Path from re import search from tempfile import NamedTemporaryFile as _NamedTemporaryFile @@ -34,7 +34,9 @@ from utilities.types import FileOrDir, MaybeIterable, PathLike +############################################################################### #### builtins ################################################################# +############################################################################### @overload @@ -115,7 +117,9 @@ def __str__(self) -> str: return "Maximum of an all-None iterable is undefined" +############################################################################### #### constants ################################################################ +############################################################################### def is_none(obj: Any, /) -> TypeIs[None]: @@ -136,7 +140,9 @@ def is_sentinel(obj: Any, /) -> TypeIs[Sentinel]: return obj is sentinel +############################################################################### #### contextlib ############################################################### +############################################################################### @contextmanager @@ -149,7 +155,9 @@ def suppress_super_attribute_error() -> Iterator[None]: raise +############################################################################### #### itertools ################################################################ +############################################################################### def always_iterable[T](obj: MaybeIterable[T], /) -> Iterable[T]: @@ -289,7 +297,71 @@ def __str__(self) -> str: return f"{head} {mid}; got {self.first!r}, {self.second!r} and perhaps more" +############################################################################### +#### os ####################################################################### +############################################################################### + + +@overload +def get_env( + key: str, /, *, case_sensitive: bool = False, default: str, nullable: bool = False +) -> str: ... +@overload +def get_env( + key: str, + /, + *, + case_sensitive: bool = False, + default: None = None, + nullable: Literal[False] = False, +) -> str: ... +@overload +def get_env( + key: str, + /, + *, + case_sensitive: bool = False, + default: str | None = None, + nullable: bool = False, +) -> str | None: ... +def get_env( + key: str, + /, + *, + case_sensitive: bool = False, + default: str | None = None, + nullable: bool = False, +) -> str | None: + """Get an environment variable.""" + try: + key_use = one_str(environ, key, case_sensitive=case_sensitive) + except OneStrEmptyError: + match default, nullable: + case None, False: + raise GetEnvVarError(key=key, case_sensitive=case_sensitive) from None + case None, True: + return None + case str(), _: + return default + case never: + assert_never(never) + return environ[key_use] + + +@dataclass(kw_only=True, slots=True) +class GetEnvVarError(Exception): + key: str + case_sensitive: bool = False + + @override + def __str__(self) -> str: + desc = f"No environment variable {self.key!r}" + return desc if self.case_sensitive else f"{desc} (modulo case)" + + +############################################################################### #### pathlib ################################################################## +############################################################################### @overload @@ -345,7 +417,9 @@ def yield_temp_cwd(path: PathLike, /) -> Iterator[None]: chdir(prev) +############################################################################### #### reprlib ################################################################## +############################################################################### def repr_( @@ -375,7 +449,9 @@ def repr_( ) +############################################################################### #### tempfile ################################################################# +############################################################################### class TemporaryDirectory: @@ -544,6 +620,7 @@ def yield_temp_file_at(path: PathLike, /) -> Iterator[Path]: __all__ = [ "FileOrDirError", + "GetEnvVarError", "MaxNullableError", "MinNullableError", "OneEmptyError", @@ -558,6 +635,7 @@ def yield_temp_file_at(path: PathLike, /) -> Iterator[Path]: "file_or_dir", "get_class", "get_class_name", + "get_env", "is_none", "is_not_none", "is_sentinel", diff --git a/src/utilities/hypothesis.py b/src/utilities/hypothesis.py index fff82e9b5..7ad3c1e88 100644 --- a/src/utilities/hypothesis.py +++ b/src/utilities/hypothesis.py @@ -94,7 +94,7 @@ ) from utilities.functions import ensure_int, ensure_str from utilities.math import is_zero -from utilities.os import get_env_var +from utilities.os import get_env from utilities.pathlib import module_path from utilities.permissions import Permissions from utilities.version import Version2, Version3 @@ -1066,7 +1066,7 @@ def verbosity(self) -> Verbosity: suppress_health_check=suppress_health_check, verbosity=profile.verbosity, ) - profile = get_env_var("HYPOTHESIS_PROFILE", default=Profile.default.name) + profile = get_env("HYPOTHESIS_PROFILE", default=Profile.default.name) settings.load_profile(profile) diff --git a/src/utilities/os.py b/src/utilities/os.py index 3a5d05978..86dc9da19 100644 --- a/src/utilities/os.py +++ b/src/utilities/os.py @@ -3,11 +3,11 @@ from contextlib import suppress from dataclasses import dataclass from os import environ, getenv -from typing import TYPE_CHECKING, Literal, assert_never, overload, override +from typing import TYPE_CHECKING, assert_never, override from utilities.constants import CPU_COUNT from utilities.contextlib import enhanced_context_manager -from utilities.iterables import OneStrEmptyError, one_str +from utilities.core import get_env if TYPE_CHECKING: from collections.abc import Iterator, Mapping @@ -40,69 +40,9 @@ def __str__(self) -> str: ## -@overload -def get_env_var( - key: str, /, *, case_sensitive: bool = False, default: str, nullable: bool = False -) -> str: ... -@overload -def get_env_var( - key: str, - /, - *, - case_sensitive: bool = False, - default: None = None, - nullable: Literal[False] = False, -) -> str: ... -@overload -def get_env_var( - key: str, - /, - *, - case_sensitive: bool = False, - default: str | None = None, - nullable: bool = False, -) -> str | None: ... -def get_env_var( - key: str, - /, - *, - case_sensitive: bool = False, - default: str | None = None, - nullable: bool = False, -) -> str | None: - """Get an environment variable.""" - try: - key_use = one_str(environ, key, case_sensitive=case_sensitive) - except OneStrEmptyError: - match default, nullable: - case None, False: - raise GetEnvVarError(key=key, case_sensitive=case_sensitive) from None - case None, True: - return None - case str(), _: - return default - case never: - assert_never(never) - return environ[key_use] - - -@dataclass(kw_only=True, slots=True) -class GetEnvVarError(Exception): - key: str - case_sensitive: bool = False - - @override - def __str__(self) -> str: - desc = f"No environment variable {self.key!r}" - return desc if self.case_sensitive else f"{desc} (modulo case)" - - -## - - def is_debug() -> bool: """Check if we are in `DEBUG` mode.""" - return get_env_var("DEBUG", nullable=True) is not None + return get_env("DEBUG", nullable=True) is not None ## @@ -110,7 +50,7 @@ def is_debug() -> bool: def is_pytest() -> bool: """Check if `pytest` is running.""" - return get_env_var("PYTEST_VERSION", nullable=True) is not None + return get_env("PYTEST_VERSION", nullable=True) is not None ## @@ -139,11 +79,4 @@ def apply(mapping: Mapping[str, str | None], /) -> None: apply(prev) -__all__ = [ - "GetCPUUseError", - "get_cpu_use", - "get_env_var", - "is_debug", - "is_pytest", - "temp_environ", -] +__all__ = ["GetCPUUseError", "get_cpu_use", "is_debug", "is_pytest", "temp_environ"] diff --git a/src/utilities/throttle.py b/src/utilities/throttle.py index b452aacfe..54e2c4edc 100644 --- a/src/utilities/throttle.py +++ b/src/utilities/throttle.py @@ -11,8 +11,8 @@ from utilities.atomicwrites import writer from utilities.constants import SECOND +from utilities.core import get_env from utilities.functions import in_timedelta -from utilities.os import get_env_var from utilities.pathlib import to_path from utilities.types import Duration, MaybeCallablePathLike, MaybeCoro from utilities.whenever import get_now_local @@ -106,7 +106,7 @@ async def throttle_async_on_try(*args: Any, **kwargs: Any) -> None: def _is_throttle( *, path: MaybeCallablePathLike = Path.cwd, duration: Duration = SECOND ) -> bool: - if get_env_var("THROTTLE", nullable=True): + if get_env("THROTTLE", nullable=True): return False path = to_path(path) if path.is_file(): From 1f8897f443ecb85bd8d1a3edf43b50682d0063c7 Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 15:35:07 +0900 Subject: [PATCH 26/78] 2026-01-21 15:35:07 (Wed) > DW-Mac > derekwan --- src/utilities/core.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/utilities/core.py b/src/utilities/core.py index 47820c713..4713a0d32 100644 --- a/src/utilities/core.py +++ b/src/utilities/core.py @@ -338,7 +338,7 @@ def get_env( except OneStrEmptyError: match default, nullable: case None, False: - raise GetEnvVarError(key=key, case_sensitive=case_sensitive) from None + raise GetEnvError(key=key, case_sensitive=case_sensitive) from None case None, True: return None case str(), _: @@ -349,7 +349,7 @@ def get_env( @dataclass(kw_only=True, slots=True) -class GetEnvVarError(Exception): +class GetEnvError(Exception): key: str case_sensitive: bool = False @@ -620,7 +620,7 @@ def yield_temp_file_at(path: PathLike, /) -> Iterator[Path]: __all__ = [ "FileOrDirError", - "GetEnvVarError", + "GetEnvError", "MaxNullableError", "MinNullableError", "OneEmptyError", From bd824e1e1b3990fca572c238c90551e30d5973dc Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 15:42:18 +0900 Subject: [PATCH 27/78] 2026-01-21 15:42:18 (Wed) > DW-Mac > derekwan --- src/tests/core/test_os.py | 2 +- src/tests/core/test_reprlib.py | 9 ++- src/tests/test_os.py | 14 +++- src/tests/test_pydantic_settings.py | 10 +-- src/tests/test_pydantic_settings_sops.py | 6 +- src/tests/test_string.py | 4 +- src/tests/test_text.py | 8 -- src/tests/test_throttle.py | 4 +- src/utilities/core.py | 94 +++++++++++++++++++----- src/utilities/os.py | 30 +------- src/utilities/polars.py | 2 +- src/utilities/postgres.py | 12 ++- src/utilities/text.py | 9 --- 13 files changed, 117 insertions(+), 87 deletions(-) diff --git a/src/tests/core/test_os.py b/src/tests/core/test_os.py index 2a48dabae..9d5bd605c 100644 --- a/src/tests/core/test_os.py +++ b/src/tests/core/test_os.py @@ -73,7 +73,7 @@ def test_error(self, *, key: str, case_sensitive: bool) -> None: _ = get_env(key, case_sensitive=case_sensitive) -class TestTempEnviron: +class TestYieldTempEnviron: @given(key=text.map(_prefix), value=text) def test_set(self, *, key: str, value: str) -> None: assert getenv(key) is None diff --git a/src/tests/core/test_reprlib.py b/src/tests/core/test_reprlib.py index 68bda6f8b..821585023 100644 --- a/src/tests/core/test_reprlib.py +++ b/src/tests/core/test_reprlib.py @@ -1,10 +1,11 @@ from __future__ import annotations +from pathlib import Path from typing import Any from pytest import mark, param -from utilities.core import repr_ +from utilities.core import repr_, repr_str class TestGetRepr: @@ -21,3 +22,9 @@ class TestGetRepr: ) def test_main(self, *, obj: Any, expected: str) -> None: assert repr_(obj) == expected + + +class TestReprStr: + def test_main(self) -> None: + s = repr_str(Path("path")) + assert s == "'path'" diff --git a/src/tests/test_os.py b/src/tests/test_os.py index f28b2dd39..81926a6c8 100644 --- a/src/tests/test_os.py +++ b/src/tests/test_os.py @@ -4,7 +4,13 @@ from hypothesis.strategies import integers from pytest import mark, param, raises -from utilities.os import GetCPUUseError, get_cpu_use, is_debug, is_pytest, temp_environ +from utilities.os import ( + GetCPUUseError, + get_cpu_use, + is_debug, + is_pytest, + yield_temp_environ, +) class TestGetCPUUse: @@ -27,11 +33,11 @@ def test_error(self, *, n: int) -> None: class TestIsDebug: @mark.parametrize("env_var", [param("DEBUG"), param("debug")]) def test_main(self, *, env_var: str) -> None: - with temp_environ({env_var: "1"}): + with yield_temp_environ({env_var: "1"}): assert is_debug() def test_off(self) -> None: - with temp_environ(DEBUG=None, debug=None): + with yield_temp_environ(DEBUG=None, debug=None): assert not is_debug() @@ -40,5 +46,5 @@ def test_main(self) -> None: assert is_pytest() def test_off(self) -> None: - with temp_environ(PYTEST_VERSION=None): + with yield_temp_environ(PYTEST_VERSION=None): assert not is_pytest() diff --git a/src/tests/test_pydantic_settings.py b/src/tests/test_pydantic_settings.py index c3169e9a7..266521970 100644 --- a/src/tests/test_pydantic_settings.py +++ b/src/tests/test_pydantic_settings.py @@ -10,7 +10,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict from pytest import mark, param -from utilities.os import temp_environ +from utilities.os import yield_temp_environ from utilities.pydantic_settings import ( CustomBaseSettings, HashableBaseSettings, @@ -92,7 +92,7 @@ def test_env_var(self) -> None: class Settings(CustomBaseSettings): x: int - with temp_environ(x="1"): + with yield_temp_environ(x="1"): settings = load_settings(Settings) assert settings.x == 1 @@ -101,7 +101,7 @@ class Settings(CustomBaseSettings): model_config = SettingsConfigDict(env_prefix="test_") x: int - with temp_environ(test_x="1"): + with yield_temp_environ(test_x="1"): settings = load_settings(Settings) assert settings.x == 1 @@ -115,7 +115,7 @@ class Inner(inner_cls): _ = Settings.model_rebuild() - with temp_environ(inner__x="1"): + with yield_temp_environ(inner__x="1"): settings = load_settings(Settings) assert settings.inner.x == 1 @@ -131,7 +131,7 @@ class Inner(inner_cls): x: int _ = Settings.model_rebuild() - with temp_environ(test__inner__x="1"): + with yield_temp_environ(test__inner__x="1"): settings = load_settings(Settings) assert settings.inner.x == 1 diff --git a/src/tests/test_pydantic_settings_sops.py b/src/tests/test_pydantic_settings_sops.py index 0bd4fbbeb..64cf31d27 100644 --- a/src/tests/test_pydantic_settings_sops.py +++ b/src/tests/test_pydantic_settings_sops.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, ClassVar from utilities.iterables import one -from utilities.os import temp_environ +from utilities.os import yield_temp_environ from utilities.pydantic_settings import PathLikeOrWithSection, load_settings from utilities.pydantic_settings_sops import SopsBaseSettings from utilities.pytest import skipif_ci @@ -32,7 +32,7 @@ def test_main(self, *, tmp_path: Path) -> None: public_key = extract_group(pattern, public_line) encrypted_file = tmp_path.joinpath("encrypted.json") with ( - temp_environ(SOPS_AGE_RECIPIENTS=public_key), + yield_temp_environ(SOPS_AGE_RECIPIENTS=public_key), encrypted_file.open(mode="w") as file, ): _ = check_call(["sops", "encrypt", str(unencrypted_file)], stdout=file) @@ -42,7 +42,7 @@ class Settings(SopsBaseSettings): x: int y: int - with temp_environ(SOPS_AGE_KEY_FILE=str(key_file)): + with yield_temp_environ(SOPS_AGE_KEY_FILE=str(key_file)): settings = load_settings(Settings) assert settings.x == 1 assert settings.y == 2 diff --git a/src/tests/test_string.py b/src/tests/test_string.py index 92f590f54..e60411903 100644 --- a/src/tests/test_string.py +++ b/src/tests/test_string.py @@ -4,7 +4,7 @@ from pytest import raises -from utilities.os import temp_environ +from utilities.os import yield_temp_environ from utilities.string import SubstituteError, substitute from utilities.text import strip_and_dedent, unique_str @@ -32,7 +32,7 @@ def test_text(self) -> None: def test_environ(self) -> None: key, value = unique_str(), unique_str() - with temp_environ(TEMPLATE_KEY=key, TEMPLATE_VALUE=value): + with yield_temp_environ(TEMPLATE_KEY=key, TEMPLATE_VALUE=value): result = substitute(self.template, environ=True, key=key, value=value) self._assert_equal(result, key, value) diff --git a/src/tests/test_text.py b/src/tests/test_text.py index a3445e171..d158e1244 100644 --- a/src/tests/test_text.py +++ b/src/tests/test_text.py @@ -1,7 +1,6 @@ from __future__ import annotations from itertools import chain -from pathlib import Path from typing import TYPE_CHECKING, Any from hypothesis import given @@ -35,7 +34,6 @@ pascal_case, prompt_bool, repr_encode, - repr_str, secret_str, snake_case, split_f_str_equals, @@ -202,12 +200,6 @@ def test_main(self) -> None: assert prompt_bool(confirm=True) -class TestReprStr: - def test_main(self) -> None: - s = repr_str(Path("path")) - assert s == "'path'" - - class TestSecretStr: def test_main(self) -> None: s = secret_str("text") diff --git a/src/tests/test_throttle.py b/src/tests/test_throttle.py index f7b8b4de5..ac6c806e1 100644 --- a/src/tests/test_throttle.py +++ b/src/tests/test_throttle.py @@ -8,7 +8,7 @@ import utilities.asyncio import utilities.time from utilities.constants import IS_CI, SECOND -from utilities.os import temp_environ +from utilities.os import yield_temp_environ from utilities.throttle import ( _ThrottleMarkerFileError, _ThrottleParseZonedDateTimeError, @@ -210,7 +210,7 @@ def func() -> None: nonlocal counter counter += 1 - with temp_environ(THROTTLE="1"): + with yield_temp_environ(THROTTLE="1"): for i in range(2): func() assert counter == (i + 1) diff --git a/src/utilities/core.py b/src/utilities/core.py index 4713a0d32..e96a1545e 100644 --- a/src/utilities/core.py +++ b/src/utilities/core.py @@ -3,10 +3,10 @@ import reprlib import shutil import tempfile -from contextlib import contextmanager +from contextlib import contextmanager, suppress from dataclasses import dataclass from itertools import chain -from os import chdir, environ +from os import chdir, environ, getenv from pathlib import Path from re import search from tempfile import NamedTemporaryFile as _NamedTemporaryFile @@ -28,7 +28,7 @@ from utilities.types import SupportsRichComparison if TYPE_CHECKING: - from collections.abc import Iterable, Iterator + from collections.abc import Iterable, Iterator, Mapping from types import TracebackType from utilities.types import FileOrDir, MaybeIterable, PathLike @@ -74,17 +74,17 @@ def min_nullable[T: SupportsRichComparison, U]( try: return min(values) except ValueError: - raise MinNullableError(values=values) from None + raise MinNullableError(iterable=iterable) from None return min(values, default=default) @dataclass(kw_only=True, slots=True) class MinNullableError[T: SupportsRichComparison](Exception): - values: Iterable[T] + iterable: Iterable[T | None] @override def __str__(self) -> str: - return "Minimum of an all-None iterable is undefined" + return f"Minimum of an all-None iterable {repr_(self.iterable)} is undefined" @overload @@ -104,17 +104,17 @@ def max_nullable[T: SupportsRichComparison, U]( try: return max(values) except ValueError: - raise MaxNullableError(values=values) from None + raise MaxNullableError(iterable=iterable) from None return max(values, default=default) @dataclass(kw_only=True, slots=True) -class MaxNullableError[TSupportsRichComparison](Exception): - values: Iterable[TSupportsRichComparison] +class MaxNullableError[T: SupportsRichComparison](Exception): + iterable: Iterable[T | None] @override def __str__(self) -> str: - return "Maximum of an all-None iterable is undefined" + return f"Maximum of an all-None iterable {repr_(self.iterable)} is undefined" ############################################################################### @@ -265,11 +265,11 @@ def __str__(self) -> str: case False, True: tail = repr(self.text) case False, False: - tail = f"{self.text!r} (modulo case)" + tail = f"{repr_(self.text)} (modulo case)" case True, True: - tail = f"any string starting with {self.text!r}" + tail = f"any string starting with {repr_(self.text)}" case True, False: - tail = f"any string starting with {self.text!r} (modulo case)" + tail = f"any string starting with {repr_(self.text)} (modulo case)" case never: assert_never(never) return f"{head} {tail}" @@ -285,16 +285,18 @@ def __str__(self) -> str: head = f"Iterable {repr_(self.iterable)} must contain" match self.head, self.case_sensitive: case False, True: - mid = f"{self.text!r} exactly once" + mid = f"{repr_(self.text)} exactly once" case False, False: - mid = f"{self.text!r} exactly once (modulo case)" + mid = f"{repr_(self.text)} exactly once (modulo case)" case True, True: - mid = f"exactly one string starting with {self.text!r}" + mid = f"exactly one string starting with {repr_(self.text)}" case True, False: - mid = f"exactly one string starting with {self.text!r} (modulo case)" + mid = ( + f"exactly one string starting with {repr_(self.text)} (modulo case)" + ) case never: assert_never(never) - return f"{head} {mid}; got {self.first!r}, {self.second!r} and perhaps more" + return f"{head} {mid}; got {repr_(self.first)}, {repr_(self.second)} and perhaps more" ############################################################################### @@ -355,10 +357,36 @@ class GetEnvError(Exception): @override def __str__(self) -> str: - desc = f"No environment variable {self.key!r}" + desc = f"No environment variable {repr_(self.key)}" return desc if self.case_sensitive else f"{desc} (modulo case)" +## + + +@contextmanager +def yield_temp_environ( + env: Mapping[str, str | None] | None = None, **env_kwargs: str | None +) -> Iterator[None]: + """Context manager with temporary environment variable set.""" + mapping: dict[str, str | None] = ({} if env is None else dict(env)) | env_kwargs + prev = {key: getenv(key) for key in mapping} + _yield_temp_environ_apply(mapping) + try: + yield + finally: + _yield_temp_environ_apply(prev) + + +def _yield_temp_environ_apply(mapping: Mapping[str, str | None], /) -> None: + for key, value in mapping.items(): + if value is None: + with suppress(KeyError): + del environ[key] + else: + environ[key] = value + + ############################################################################### #### pathlib ################################################################## ############################################################################### @@ -449,6 +477,32 @@ def repr_( ) +def repr_str( + obj: Any, + /, + *, + max_width: int = RICH_MAX_WIDTH, + indent_size: int = RICH_INDENT_SIZE, + max_length: int | None = RICH_MAX_LENGTH, + max_string: int | None = RICH_MAX_STRING, + max_depth: int | None = RICH_MAX_DEPTH, + expand_all: bool = RICH_EXPAND_ALL, +) -> str: + """Get the representation of the string of an object.""" + return repr_( + str(obj), + max_width=max_width, + indent_size=indent_size, + max_length=max_length, + max_string=max_string, + max_depth=max_depth, + expand_all=expand_all, + ) + + +## + + ############################################################################### #### tempfile ################################################################# ############################################################################### @@ -644,8 +698,10 @@ def yield_temp_file_at(path: PathLike, /) -> Iterator[Path]: "one", "one_str", "repr_", + "repr_str", "suppress_super_attribute_error", "yield_temp_cwd", "yield_temp_dir_at", + "yield_temp_environ", "yield_temp_file_at", ] diff --git a/src/utilities/os.py b/src/utilities/os.py index 86dc9da19..ade3f49e6 100644 --- a/src/utilities/os.py +++ b/src/utilities/os.py @@ -1,17 +1,12 @@ from __future__ import annotations -from contextlib import suppress from dataclasses import dataclass -from os import environ, getenv from typing import TYPE_CHECKING, assert_never, override from utilities.constants import CPU_COUNT -from utilities.contextlib import enhanced_context_manager from utilities.core import get_env if TYPE_CHECKING: - from collections.abc import Iterator, Mapping - from utilities.types import IntOrAll @@ -56,27 +51,4 @@ def is_pytest() -> bool: ## -@enhanced_context_manager -def temp_environ( - env: Mapping[str, str | None] | None = None, **env_kwargs: str | None -) -> Iterator[None]: - """Context manager with temporary environment variable set.""" - mapping: dict[str, str | None] = ({} if env is None else dict(env)) | env_kwargs - prev = {key: getenv(key) for key in mapping} - - def apply(mapping: Mapping[str, str | None], /) -> None: - for key, value in mapping.items(): - if value is None: - with suppress(KeyError): - del environ[key] - else: - environ[key] = value - - apply(mapping) - try: - yield - finally: - apply(prev) - - -__all__ = ["GetCPUUseError", "get_cpu_use", "is_debug", "is_pytest", "temp_environ"] +__all__ = ["GetCPUUseError", "get_cpu_use", "is_debug", "is_pytest"] diff --git a/src/utilities/polars.py b/src/utilities/polars.py index 13b8ed525..a22d98be9 100644 --- a/src/utilities/polars.py +++ b/src/utilities/polars.py @@ -55,7 +55,7 @@ import utilities.math from utilities.constants import UTC -from utilities.core import OneEmptyError, OneNonUniqueError, always_iterable, one +from utilities.core import OneEmptyError, OneNonUniqueError, always_iterable, one, repr_ from utilities.dataclasses import yield_fields from utilities.errors import ImpossibleCaseError from utilities.functions import get_class_name diff --git a/src/utilities/postgres.py b/src/utilities/postgres.py index 9d7683ebf..39d668249 100644 --- a/src/utilities/postgres.py +++ b/src/utilities/postgres.py @@ -12,7 +12,7 @@ from utilities.core import always_iterable from utilities.docker import docker_exec_cmd from utilities.logging import to_logger -from utilities.os import temp_environ +from utilities.os import yield_temp_environ from utilities.pathlib import ensure_suffix from utilities.sqlalchemy import extract_url, get_table_name from utilities.timer import Timer @@ -82,7 +82,10 @@ async def pg_dump( if logger is not None: to_logger(logger).info("Would run:\n\t%r", str(cmd)) return True - with temp_environ(PGPASSWORD=url.password), Timer() as timer: # pragma: no cover + with ( + yield_temp_environ(PGPASSWORD=url.password), + Timer() as timer, + ): # pragma: no cover try: output = await stream_command(cmd) except KeyboardInterrupt: @@ -237,7 +240,10 @@ async def restore( if logger is not None: to_logger(logger).info("Would run:\n\t%r", str(cmd)) return True - with temp_environ(PGPASSWORD=url.password), Timer() as timer: # pragma: no cover + with ( + yield_temp_environ(PGPASSWORD=url.password), + Timer() as timer, + ): # pragma: no cover try: output = await stream_command(cmd) except KeyboardInterrupt: diff --git a/src/utilities/text.py b/src/utilities/text.py index d6643ae49..0e7539579 100644 --- a/src/utilities/text.py +++ b/src/utilities/text.py @@ -412,14 +412,6 @@ def _escape_separator(*, separator: str = LIST_SEPARATOR) -> str: ## -def repr_str(obj: Any, /) -> str: - """Get the representation of the string of an object.""" - return repr(str(obj)) - - -## - - class secret_str(str): # noqa: N801 """A string with an obfuscated representation.""" @@ -550,7 +542,6 @@ def _kebab_snake_case(text: str, separator: str, /) -> str: "pascal_case", "prompt_bool", "repr_encode", - "repr_str", "secret_str", "snake_case", "split_f_str_equals", From 2d03c416fadffdee86792f35234c01c96a99b604 Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 15:48:44 +0900 Subject: [PATCH 28/78] 2026-01-21 15:48:44 (Wed) > DW-Mac > derekwan --- scripts/run_tests.py | 3 +-- src/tests/core/test_builtins.py | 35 +++++++++++---------------------- src/tests/core/test_os.py | 16 +++++++-------- src/utilities/core.py | 23 +++++++++++++++------- 4 files changed, 36 insertions(+), 41 deletions(-) diff --git a/scripts/run_tests.py b/scripts/run_tests.py index 10055021c..9b5263b93 100755 --- a/scripts/run_tests.py +++ b/scripts/run_tests.py @@ -8,7 +8,6 @@ from typing import TYPE_CHECKING from utilities.logging import basic_config -from utilities.os import temp_environ from utilities.pathlib import get_repo_root from utilities.re import extract_group from utilities.time import sleep @@ -71,7 +70,7 @@ def _run_command(path: Path, /) -> bool: if (test := f"{group}-test") in groups: cmd.append(f"--only-group={test}") cmd.extend(["pytest", "-nauto", str(path)]) - with temp_environ(PYTEST_ADDOPTS=None): + with yield_temp_environ(PYTEST_ADDOPTS=None): try: code = check_call(cmd) except CalledProcessError: diff --git a/src/tests/core/test_builtins.py b/src/tests/core/test_builtins.py index 7e0856260..1add36478 100644 --- a/src/tests/core/test_builtins.py +++ b/src/tests/core/test_builtins.py @@ -79,27 +79,14 @@ def test_main( expected = func_builtin(values) assert result == expected - @given( - nones=lists(none()), - value=integers(), - func=sampled_from([min_nullable, max_nullable]), - ) - def test_default( - self, *, nones: list[None], value: int, func: Callable[..., int] - ) -> None: - result = func(nones, default=value) - assert result == value - - @given(nones=lists(none())) - def test_error_min_nullable(self, *, nones: list[None]) -> None: - with raises( - MinNullableError, match=r"Minimum of an all-None iterable is undefined" - ): - _ = min_nullable(nones) - - @given(nones=lists(none())) - def test_error_max_nullable(self, *, nones: list[None]) -> None: - with raises( - MaxNullableError, match=r"Maximum of an all-None iterable is undefined" - ): - max_nullable(nones) + @mark.parametrize("func", [param(min_nullable), param(max_nullable)]) + def test_default(self, *, func: Callable[..., int]) -> None: + assert func([], default=True) is True + + def test_error_min(self) -> None: + with raises(MinNullableError, match=r"Minimum of \[\] is undefined"): + _ = min_nullable([]) + + def test_error_max(self) -> None: + with raises(MaxNullableError, match=r"Maximum of \[\] is undefined"): + max_nullable([]) diff --git a/src/tests/core/test_os.py b/src/tests/core/test_os.py index 9d5bd605c..46fdec52b 100644 --- a/src/tests/core/test_os.py +++ b/src/tests/core/test_os.py @@ -6,7 +6,7 @@ from hypothesis.strategies import DataObject, booleans, data, none, sampled_from from pytest import raises -from utilities.core import GetEnvError, get_env, temp_environ +from utilities.core import GetEnvError, get_env, yield_temp_environ from utilities.hypothesis import text_ascii text = text_ascii(min_size=1, max_size=10) @@ -23,7 +23,7 @@ class TestGetEnv: def test_case_sensitive( self, *, key: str, value: str, default: str | None, nullable: bool ) -> None: - with temp_environ({key: value}): + with yield_temp_environ({key: value}): result = get_env(key, default=default, nullable=nullable) assert result == value @@ -44,7 +44,7 @@ def test_case_insensitive( nullable: bool, ) -> None: key_use = data.draw(sampled_from([key, key.lower(), key.upper()])) - with temp_environ({key: value}): + with yield_temp_environ({key: value}): result = get_env(key_use, default=default, nullable=nullable) assert result == value @@ -77,22 +77,22 @@ class TestYieldTempEnviron: @given(key=text.map(_prefix), value=text) def test_set(self, *, key: str, value: str) -> None: assert getenv(key) is None - with temp_environ({key: value}): + with yield_temp_environ({key: value}): assert getenv(key) == value assert getenv(key) is None @given(key=text.map(_prefix), prev=text, new=text) def test_override(self, *, key: str, prev: str, new: str) -> None: - with temp_environ({key: prev}): + with yield_temp_environ({key: prev}): assert getenv(key) == prev - with temp_environ({key: new}): + with yield_temp_environ({key: new}): assert getenv(key) == new assert getenv(key) == prev @given(key=text.map(_prefix), value=text) def test_unset(self, *, key: str, value: str) -> None: - with temp_environ({key: value}): + with yield_temp_environ({key: value}): assert getenv(key) == value - with temp_environ({key: None}): + with yield_temp_environ({key: None}): assert getenv(key) is None assert getenv(key) == value diff --git a/src/utilities/core.py b/src/utilities/core.py index e96a1545e..d3e316757 100644 --- a/src/utilities/core.py +++ b/src/utilities/core.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re import reprlib import shutil import tempfile @@ -8,7 +9,6 @@ from itertools import chain from os import chdir, environ, getenv from pathlib import Path -from re import search from tempfile import NamedTemporaryFile as _NamedTemporaryFile from typing import TYPE_CHECKING, Any, Literal, assert_never, cast, overload, override from warnings import catch_warnings, filterwarnings @@ -84,7 +84,7 @@ class MinNullableError[T: SupportsRichComparison](Exception): @override def __str__(self) -> str: - return f"Minimum of an all-None iterable {repr_(self.iterable)} is undefined" + return f"Minimum of {repr_(self.iterable)} is undefined" @overload @@ -114,7 +114,7 @@ class MaxNullableError[T: SupportsRichComparison](Exception): @override def __str__(self) -> str: - return f"Maximum of an all-None iterable {repr_(self.iterable)} is undefined" + return f"Maximum of {repr_(self.iterable)} is undefined" ############################################################################### @@ -151,10 +151,15 @@ def suppress_super_attribute_error() -> Iterator[None]: try: yield except AttributeError as error: - if not search(r"'super' object has no attribute '\w+'", error.args[0]): + if not _suppress_super_attribute_error_pattern.search(error.args[0]): raise +_suppress_super_attribute_error_pattern = re.compile( + r"'super' object has no attribute '\w+'" +) + + ############################################################################### #### itertools ################################################################ ############################################################################### @@ -477,6 +482,9 @@ def repr_( ) +## + + def repr_str( obj: Any, /, @@ -500,9 +508,6 @@ def repr_str( ) -## - - ############################################################################### #### tempfile ################################################################# ############################################################################### @@ -672,6 +677,10 @@ def yield_temp_file_at(path: PathLike, /) -> Iterator[Path]: yield temp +############################################################################### +#### text ##################################################################### +############################################################################### + __all__ = [ "FileOrDirError", "GetEnvError", From 25c0984cdaecc156b8aa89660d8f8bd027568808 Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 15:50:54 +0900 Subject: [PATCH 29/78] 2026-01-21 15:50:54 (Wed) > DW-Mac > derekwan --- scripts/run_tests.py | 1 + src/tests/test_os.py | 9 ++------- src/tests/test_pydantic_settings.py | 2 +- src/tests/test_pydantic_settings_sops.py | 3 +-- src/tests/test_string.py | 2 +- src/utilities/postgres.py | 3 +-- 6 files changed, 7 insertions(+), 13 deletions(-) diff --git a/scripts/run_tests.py b/scripts/run_tests.py index 9b5263b93..e41a59496 100755 --- a/scripts/run_tests.py +++ b/scripts/run_tests.py @@ -7,6 +7,7 @@ from tomllib import TOMLDecodeError, loads from typing import TYPE_CHECKING +from utilities.core import yield_temp_environ from utilities.logging import basic_config from utilities.pathlib import get_repo_root from utilities.re import extract_group diff --git a/src/tests/test_os.py b/src/tests/test_os.py index 81926a6c8..6fe277947 100644 --- a/src/tests/test_os.py +++ b/src/tests/test_os.py @@ -4,13 +4,8 @@ from hypothesis.strategies import integers from pytest import mark, param, raises -from utilities.os import ( - GetCPUUseError, - get_cpu_use, - is_debug, - is_pytest, - yield_temp_environ, -) +from utilities.core import yield_temp_environ +from utilities.os import GetCPUUseError, get_cpu_use, is_debug, is_pytest class TestGetCPUUse: diff --git a/src/tests/test_pydantic_settings.py b/src/tests/test_pydantic_settings.py index 266521970..68f8d17de 100644 --- a/src/tests/test_pydantic_settings.py +++ b/src/tests/test_pydantic_settings.py @@ -10,7 +10,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict from pytest import mark, param -from utilities.os import yield_temp_environ +from utilities.core import yield_temp_environ from utilities.pydantic_settings import ( CustomBaseSettings, HashableBaseSettings, diff --git a/src/tests/test_pydantic_settings_sops.py b/src/tests/test_pydantic_settings_sops.py index 64cf31d27..ebd97621e 100644 --- a/src/tests/test_pydantic_settings_sops.py +++ b/src/tests/test_pydantic_settings_sops.py @@ -5,8 +5,7 @@ from subprocess import check_call from typing import TYPE_CHECKING, ClassVar -from utilities.iterables import one -from utilities.os import yield_temp_environ +from utilities.core import one, yield_temp_environ from utilities.pydantic_settings import PathLikeOrWithSection, load_settings from utilities.pydantic_settings_sops import SopsBaseSettings from utilities.pytest import skipif_ci diff --git a/src/tests/test_string.py b/src/tests/test_string.py index e60411903..c926075e9 100644 --- a/src/tests/test_string.py +++ b/src/tests/test_string.py @@ -4,7 +4,7 @@ from pytest import raises -from utilities.os import yield_temp_environ +from utilities.core import yield_temp_environ from utilities.string import SubstituteError, substitute from utilities.text import strip_and_dedent, unique_str diff --git a/src/utilities/postgres.py b/src/utilities/postgres.py index 39d668249..66ecf16db 100644 --- a/src/utilities/postgres.py +++ b/src/utilities/postgres.py @@ -9,10 +9,9 @@ from sqlalchemy.orm import DeclarativeBase from utilities.asyncio import stream_command -from utilities.core import always_iterable +from utilities.core import always_iterable, yield_temp_environ from utilities.docker import docker_exec_cmd from utilities.logging import to_logger -from utilities.os import yield_temp_environ from utilities.pathlib import ensure_suffix from utilities.sqlalchemy import extract_url, get_table_name from utilities.timer import Timer From 2bad3e90ad146198a6dafd4da53f7a18db7b8581 Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 15:51:20 +0900 Subject: [PATCH 30/78] 2026-01-21 15:51:20 (Wed) > DW-Mac > derekwan --- scripts/run_tests.py | 86 -------------------------------------------- 1 file changed, 86 deletions(-) delete mode 100755 scripts/run_tests.py diff --git a/scripts/run_tests.py b/scripts/run_tests.py deleted file mode 100755 index e41a59496..000000000 --- a/scripts/run_tests.py +++ /dev/null @@ -1,86 +0,0 @@ -#!/usr/bin/env python -from __future__ import annotations - -import datetime as dt -from logging import getLogger -from subprocess import CalledProcessError, check_call -from tomllib import TOMLDecodeError, loads -from typing import TYPE_CHECKING - -from utilities.core import yield_temp_environ -from utilities.logging import basic_config -from utilities.pathlib import get_repo_root -from utilities.re import extract_group -from utilities.time import sleep - -if TYPE_CHECKING: - from pathlib import Path - - -_LOGGER = getLogger(__name__) - - -def main() -> None: - basic_config(obj=_LOGGER) - path = get_repo_root().joinpath("src", "tests") - for path_i in sorted(path.glob("test_*.py")): - _run_test(path_i) - - -def _run_test(path: Path, /) -> None: - group = _get_group(path) - marker = _get_marker(group) - if marker.exists(): - return - _LOGGER.info("Testing %r...", str(path)) - while True: - if _run_command(path): - marker.touch() - return - sleep(1) - - -def _get_group(path: Path, /) -> str: - return extract_group(r"^test_(\w+)$", path.stem).replace("_", "-") - - -def _get_marker(group: str, /) -> Path: - hour = dt.datetime.now(dt.UTC).replace(minute=0, second=0, microsecond=0) - return get_repo_root().joinpath(".pytest_cache", f"{hour:%Y%m%dT%H}-{group}") - - -def _run_command(path: Path, /) -> bool: - cmd: list[str] = [ - "uv", - "run", - "--only-group=core", - "--only-group=hypothesis", - "--only-group=pytest", - "--isolated", - "--managed-python", - ] - text = get_repo_root().joinpath("pyproject.toml").read_text() - try: - loaded = loads(text) - except TOMLDecodeError: - _LOGGER.exception("Invalid TOML document") - return False - groups: list[str] = loaded["dependency-groups"] - if (group := _get_group(path)) in groups: - cmd.append(f"--only-group={group}") - if (test := f"{group}-test") in groups: - cmd.append(f"--only-group={test}") - cmd.extend(["pytest", "-nauto", str(path)]) - with yield_temp_environ(PYTEST_ADDOPTS=None): - try: - code = check_call(cmd) - except CalledProcessError: - return False - if code == 0: - return True - _LOGGER.error("pytest failed") - return False - - -if __name__ == "__main__": - main() From bc5cfaa1d155a7767e8bafb44502a8bf13d1c516 Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 15:53:13 +0900 Subject: [PATCH 31/78] 2026-01-21 15:53:13 (Wed) > DW-Mac > derekwan --- src/tests/test_functions.py | 3 +-- src/tests/test_throttle.py | 2 +- src/utilities/functions.py | 21 --------------------- 3 files changed, 2 insertions(+), 24 deletions(-) diff --git a/src/tests/test_functions.py b/src/tests/test_functions.py index 2197462cd..289e63546 100644 --- a/src/tests/test_functions.py +++ b/src/tests/test_functions.py @@ -55,7 +55,6 @@ ensure_zoned_date_time, first, get_func_name, - get_func_qualname, identity, in_milli_seconds, in_seconds, @@ -382,7 +381,7 @@ def test_main(self, *, x: int, y: int) -> None: assert result == x -class TestGetFuncNameAndGetFuncQualName: +class TestGetFuncName: @given( case=sampled_from([ (identity, "identity", "utilities.functions.identity"), diff --git a/src/tests/test_throttle.py b/src/tests/test_throttle.py index ac6c806e1..fde135393 100644 --- a/src/tests/test_throttle.py +++ b/src/tests/test_throttle.py @@ -8,7 +8,7 @@ import utilities.asyncio import utilities.time from utilities.constants import IS_CI, SECOND -from utilities.os import yield_temp_environ +from utilities.core import yield_temp_environ from utilities.throttle import ( _ThrottleMarkerFileError, _ThrottleParseZonedDateTimeError, diff --git a/src/utilities/functions.py b/src/utilities/functions.py index 98538328a..e3d934ac0 100644 --- a/src/utilities/functions.py +++ b/src/utilities/functions.py @@ -479,9 +479,6 @@ def first[T](pair: tuple[T, Any], /) -> T: ## -## - - def get_func_name(obj: Callable[..., Any], /) -> str: """Get the name of a callable.""" if isinstance(obj, BuiltinFunctionType): @@ -511,24 +508,6 @@ def get_func_name(obj: Callable[..., Any], /) -> str: ## -def get_func_qualname(obj: Callable[..., Any], /) -> str: - """Get the qualified name of a callable.""" - if isinstance( - obj, BuiltinFunctionType | FunctionType | MethodType | _lru_cache_wrapper - ): - return f"{obj.__module__}.{obj.__qualname__}" - if isinstance( - obj, MethodDescriptorType | MethodWrapperType | WrapperDescriptorType - ): - return f"{obj.__objclass__.__module__}.{obj.__qualname__}" - if isinstance(obj, partial): - return get_func_qualname(obj.func) - return f"{obj.__module__}.{get_class_name(obj)}" - - -## - - def identity[T](obj: T, /) -> T: """Return the object itself.""" return obj From 24393cf0a1b21316c87085c3df57445c1f59192b Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 15:53:34 +0900 Subject: [PATCH 32/78] 2026-01-21 15:53:34 (Wed) > DW-Mac > derekwan --- src/utilities/functions.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/utilities/functions.py b/src/utilities/functions.py index e3d934ac0..534fd74b9 100644 --- a/src/utilities/functions.py +++ b/src/utilities/functions.py @@ -684,7 +684,6 @@ def _make_error_msg(obj: Any, desc: str, /, *, nullable: bool = False) -> str: "ensure_zoned_date_time", "first", "get_func_name", - "get_func_qualname", "identity", "in_milli_seconds", "in_seconds", From 9fac4b0b7d7a3727cef3384e70176a549db1e73c Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 15:55:52 +0900 Subject: [PATCH 33/78] 2026-01-21 15:55:52 (Wed) > DW-Mac > derekwan --- src/tests/core/test_builtins.py | 108 +++++++++++++++++++++++++++++++ src/tests/test_functions.py | 110 +------------------------------- src/utilities/core.py | 44 ++++++++++++- src/utilities/functions.py | 43 +------------ 4 files changed, 154 insertions(+), 151 deletions(-) diff --git a/src/tests/core/test_builtins.py b/src/tests/core/test_builtins.py index 1add36478..0e1c96245 100644 --- a/src/tests/core/test_builtins.py +++ b/src/tests/core/test_builtins.py @@ -1,6 +1,10 @@ from __future__ import annotations +import sys +from collections.abc import Callable +from functools import cache, lru_cache, partial, wraps from itertools import chain +from operator import neg from types import NoneType from typing import TYPE_CHECKING, Any @@ -56,6 +60,110 @@ def test_qual(self) -> None: ) +class TestGetFuncName: + @mark.parametrize( + ("func", "expected"), + ([ + param(identity, "identity", "utilities.functions.identity"), + param( + lambda x: x, # pyright: ignore[reportUnknownLambdaType] + "", + "tests.test_functions.TestGetFuncNameAndGetFuncQualName.", + ), + param(len, "len", "builtins.len"), + param(neg, "neg", "_operator.neg"), + param(object.__init__, "object.__init__", "builtins.object.__init__"), + param(object.__str__, "object.__str__", "builtins.object.__str__"), + param(repr, "repr", "builtins.repr"), + param(str, "str", "builtins.str"), + param(str.join, "str.join", "builtins.str.join"), + param(sys.exit, "exit", "sys.exit"), + ]), + ) + def test_main(self, *, func: Callable[..., Any], expected: str) -> None: + assert get_func_name(func) == expected + + def test_cache(self) -> None: + @cache + def cache_func(x: int, /) -> int: + return x + + assert get_func_name(cache_func) == "cache_func" + assert ( + get_func_qualname(cache_func) + == "tests.test_functions.TestGetFuncNameAndGetFuncQualName.test_cache..cache_func" + ) + + def test_decorated(self) -> None: + @wraps(identity) + def wrapped[T](x: T, /) -> T: + return identity(x) + + assert get_func_name(wrapped) == "identity" + assert get_func_qualname(wrapped) == "utilities.functions.identity" + + def test_lru_cache(self) -> None: + @lru_cache + def lru_cache_func(x: int, /) -> int: + return x + + assert get_func_name(lru_cache_func) == "lru_cache_func" + assert ( + get_func_qualname(lru_cache_func) + == "tests.test_functions.TestGetFuncNameAndGetFuncQualName.test_lru_cache..lru_cache_func" + ) + + def test_object(self) -> None: + class Example: + def __call__[T](self, x: T, /) -> T: + return identity(x) + + obj = Example() + assert get_func_name(obj) == "Example" + assert get_func_qualname(obj) == "tests.test_functions.Example" + + def test_obj_method(self) -> None: + class Example: + def obj_method[T](self, x: T) -> T: + return identity(x) + + obj = Example() + assert get_func_name(obj.obj_method) == "Example.obj_method" + assert ( + get_func_qualname(obj.obj_method) + == "tests.test_functions.TestGetFuncNameAndGetFuncQualName.test_obj_method..Example.obj_method" + ) + + def test_obj_classmethod(self) -> None: + class Example: + @classmethod + def obj_classmethod[T](cls: T) -> T: + return identity(cls) + + assert get_func_name(Example.obj_classmethod) == "Example.obj_classmethod" + assert ( + get_func_qualname(Example.obj_classmethod) + == "tests.test_functions.TestGetFuncNameAndGetFuncQualName.test_obj_classmethod..Example.obj_classmethod" + ) + + def test_obj_staticmethod(self) -> None: + class Example: + @staticmethod + def obj_staticmethod[T](x: T) -> T: + return identity(x) + + assert get_func_name(Example.obj_staticmethod) == "Example.obj_staticmethod" + assert ( + get_func_qualname(Example.obj_staticmethod) + == "tests.test_functions.TestGetFuncNameAndGetFuncQualName.test_obj_staticmethod..Example.obj_staticmethod" + ) + + def test_partial(self) -> None: + part = partial(identity) + assert get_func_name(part) == "identity" + assert get_func_qualname(part) == "utilities.functions.identity" + + class TestMinMaxNullable: @given( data=data(), diff --git a/src/tests/test_functions.py b/src/tests/test_functions.py index 289e63546..118fdc801 100644 --- a/src/tests/test_functions.py +++ b/src/tests/test_functions.py @@ -1,8 +1,7 @@ from __future__ import annotations -import sys from dataclasses import dataclass -from functools import cache, cached_property, lru_cache, partial, wraps +from functools import cached_property from operator import neg from subprocess import check_output from sys import executable @@ -54,7 +53,6 @@ ensure_time_delta, ensure_zoned_date_time, first, - get_func_name, identity, in_milli_seconds, in_seconds, @@ -71,7 +69,6 @@ if TYPE_CHECKING: import datetime as dt - from collections.abc import Callable from whenever import PlainDateTime, TimeDelta, ZonedDateTime @@ -381,111 +378,6 @@ def test_main(self, *, x: int, y: int) -> None: assert result == x -class TestGetFuncName: - @given( - case=sampled_from([ - (identity, "identity", "utilities.functions.identity"), - ( - lambda x: x, # pyright: ignore[reportUnknownLambdaType] - "", - "tests.test_functions.TestGetFuncNameAndGetFuncQualName.", - ), - (len, "len", "builtins.len"), - (neg, "neg", "_operator.neg"), - (object.__init__, "object.__init__", "builtins.object.__init__"), - (object.__str__, "object.__str__", "builtins.object.__str__"), - (repr, "repr", "builtins.repr"), - (str, "str", "builtins.str"), - (str.join, "str.join", "builtins.str.join"), - (sys.exit, "exit", "sys.exit"), - ]) - ) - def test_main(self, *, case: tuple[Callable[..., Any], str, str]) -> None: - func, exp_name, exp_qual_name = case - assert get_func_name(func) == exp_name - assert get_func_qualname(func) == exp_qual_name - - def test_cache(self) -> None: - @cache - def cache_func(x: int, /) -> int: - return x - - assert get_func_name(cache_func) == "cache_func" - assert ( - get_func_qualname(cache_func) - == "tests.test_functions.TestGetFuncNameAndGetFuncQualName.test_cache..cache_func" - ) - - def test_decorated(self) -> None: - @wraps(identity) - def wrapped[T](x: T, /) -> T: - return identity(x) - - assert get_func_name(wrapped) == "identity" - assert get_func_qualname(wrapped) == "utilities.functions.identity" - - def test_lru_cache(self) -> None: - @lru_cache - def lru_cache_func(x: int, /) -> int: - return x - - assert get_func_name(lru_cache_func) == "lru_cache_func" - assert ( - get_func_qualname(lru_cache_func) - == "tests.test_functions.TestGetFuncNameAndGetFuncQualName.test_lru_cache..lru_cache_func" - ) - - def test_object(self) -> None: - class Example: - def __call__[T](self, x: T, /) -> T: - return identity(x) - - obj = Example() - assert get_func_name(obj) == "Example" - assert get_func_qualname(obj) == "tests.test_functions.Example" - - def test_obj_method(self) -> None: - class Example: - def obj_method[T](self, x: T) -> T: - return identity(x) - - obj = Example() - assert get_func_name(obj.obj_method) == "Example.obj_method" - assert ( - get_func_qualname(obj.obj_method) - == "tests.test_functions.TestGetFuncNameAndGetFuncQualName.test_obj_method..Example.obj_method" - ) - - def test_obj_classmethod(self) -> None: - class Example: - @classmethod - def obj_classmethod[T](cls: T) -> T: - return identity(cls) - - assert get_func_name(Example.obj_classmethod) == "Example.obj_classmethod" - assert ( - get_func_qualname(Example.obj_classmethod) - == "tests.test_functions.TestGetFuncNameAndGetFuncQualName.test_obj_classmethod..Example.obj_classmethod" - ) - - def test_obj_staticmethod(self) -> None: - class Example: - @staticmethod - def obj_staticmethod[T](x: T) -> T: - return identity(x) - - assert get_func_name(Example.obj_staticmethod) == "Example.obj_staticmethod" - assert ( - get_func_qualname(Example.obj_staticmethod) - == "tests.test_functions.TestGetFuncNameAndGetFuncQualName.test_obj_staticmethod..Example.obj_staticmethod" - ) - - def test_partial(self) -> None: - part = partial(identity) - assert get_func_name(part) == "identity" - assert get_func_qualname(part) == "utilities.functions.identity" - - class TestIdentity: @given(x=integers()) def test_main(self, *, x: int) -> None: diff --git a/src/utilities/core.py b/src/utilities/core.py index d3e316757..9bd813dd7 100644 --- a/src/utilities/core.py +++ b/src/utilities/core.py @@ -4,12 +4,23 @@ import reprlib import shutil import tempfile +from collections.abc import Iterable, Iterator from contextlib import contextmanager, suppress from dataclasses import dataclass +from functools import _lru_cache_wrapper, partial from itertools import chain from os import chdir, environ, getenv from pathlib import Path +from re import findall from tempfile import NamedTemporaryFile as _NamedTemporaryFile +from types import ( + BuiltinFunctionType, + FunctionType, + MethodDescriptorType, + MethodType, + MethodWrapperType, + WrapperDescriptorType, +) from typing import TYPE_CHECKING, Any, Literal, assert_never, cast, overload, override from warnings import catch_warnings, filterwarnings @@ -25,10 +36,11 @@ Sentinel, sentinel, ) +from utilities.core import get_class_name, repr_ from utilities.types import SupportsRichComparison if TYPE_CHECKING: - from collections.abc import Iterable, Iterator, Mapping + from collections.abc import Callable, Iterable, Iterator, Mapping from types import TracebackType from utilities.types import FileOrDir, MaybeIterable, PathLike @@ -57,6 +69,35 @@ def get_class_name(obj: Any, /, *, qual: bool = False) -> str: ## +def get_func_name(obj: Callable[..., Any], /) -> str: + """Get the name of a callable.""" + if isinstance(obj, BuiltinFunctionType): + return obj.__name__ + if isinstance(obj, FunctionType): + name = obj.__name__ + pattern = r"^.+\.([A-Z]\w+\." + name + ")$" + try: + (full_name,) = findall(pattern, obj.__qualname__) + except ValueError: + return name + return full_name + if isinstance(obj, MethodType): + return f"{get_class_name(obj.__self__)}.{obj.__name__}" + if isinstance( + obj, + MethodType | MethodDescriptorType | MethodWrapperType | WrapperDescriptorType, + ): + return obj.__qualname__ + if isinstance(obj, _lru_cache_wrapper): + return cast("Any", obj).__name__ + if isinstance(obj, partial): + return get_func_name(obj.func) + return get_class_name(obj) + + +## + + @overload def min_nullable[T: SupportsRichComparison]( iterable: Iterable[T | None], /, *, default: Sentinel = ... @@ -699,6 +740,7 @@ def yield_temp_file_at(path: PathLike, /) -> Iterator[Path]: "get_class", "get_class_name", "get_env", + "get_func_name", "is_none", "is_not_none", "is_sentinel", diff --git a/src/utilities/functions.py b/src/utilities/functions.py index 534fd74b9..eb0564ce8 100644 --- a/src/utilities/functions.py +++ b/src/utilities/functions.py @@ -1,19 +1,10 @@ from __future__ import annotations from dataclasses import asdict, dataclass -from functools import _lru_cache_wrapper, cached_property, partial, wraps +from functools import cached_property, wraps from inspect import getattr_static from pathlib import Path -from re import findall -from types import ( - BuiltinFunctionType, - FunctionType, - MethodDescriptorType, - MethodType, - MethodWrapperType, - WrapperDescriptorType, -) -from typing import TYPE_CHECKING, Any, Literal, assert_never, cast, overload, override +from typing import TYPE_CHECKING, Any, Literal, assert_never, overload, override from whenever import Date, PlainDateTime, Time, TimeDelta, ZonedDateTime @@ -479,35 +470,6 @@ def first[T](pair: tuple[T, Any], /) -> T: ## -def get_func_name(obj: Callable[..., Any], /) -> str: - """Get the name of a callable.""" - if isinstance(obj, BuiltinFunctionType): - return obj.__name__ - if isinstance(obj, FunctionType): - name = obj.__name__ - pattern = r"^.+\.([A-Z]\w+\." + name + ")$" - try: - (full_name,) = findall(pattern, obj.__qualname__) - except ValueError: - return name - return full_name - if isinstance(obj, MethodType): - return f"{get_class_name(obj.__self__)}.{obj.__name__}" - if isinstance( - obj, - MethodType | MethodDescriptorType | MethodWrapperType | WrapperDescriptorType, - ): - return obj.__qualname__ - if isinstance(obj, _lru_cache_wrapper): - return cast("Any", obj).__name__ - if isinstance(obj, partial): - return get_func_name(obj.func) - return get_class_name(obj) - - -## - - def identity[T](obj: T, /) -> T: """Return the object itself.""" return obj @@ -683,7 +645,6 @@ def _make_error_msg(obj: Any, desc: str, /, *, nullable: bool = False) -> str: "ensure_time_delta", "ensure_zoned_date_time", "first", - "get_func_name", "identity", "in_milli_seconds", "in_seconds", From 752e491d4448663ed115815d7164a75880b3b42b Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 15:57:36 +0900 Subject: [PATCH 34/78] 2026-01-21 15:57:36 (Wed) > DW-Mac > derekwan --- src/tests/core/test_builtins.py | 62 +++++++++------------------------ src/utilities/core.py | 1 - 2 files changed, 17 insertions(+), 46 deletions(-) diff --git a/src/tests/core/test_builtins.py b/src/tests/core/test_builtins.py index 0e1c96245..e5689cdb7 100644 --- a/src/tests/core/test_builtins.py +++ b/src/tests/core/test_builtins.py @@ -25,10 +25,12 @@ MinNullableError, get_class, get_class_name, + get_func_name, max_nullable, min_nullable, ) from utilities.errors import ImpossibleCaseError +from utilities.functions import identity if TYPE_CHECKING: from collections.abc import Callable, Iterable @@ -63,22 +65,18 @@ def test_qual(self) -> None: class TestGetFuncName: @mark.parametrize( ("func", "expected"), - ([ - param(identity, "identity", "utilities.functions.identity"), - param( - lambda x: x, # pyright: ignore[reportUnknownLambdaType] - "", - "tests.test_functions.TestGetFuncNameAndGetFuncQualName.", - ), - param(len, "len", "builtins.len"), - param(neg, "neg", "_operator.neg"), - param(object.__init__, "object.__init__", "builtins.object.__init__"), - param(object.__str__, "object.__str__", "builtins.object.__str__"), - param(repr, "repr", "builtins.repr"), - param(str, "str", "builtins.str"), - param(str.join, "str.join", "builtins.str.join"), - param(sys.exit, "exit", "sys.exit"), - ]), + [ + param(identity, "identity"), + param(lambda x: x, ""), # pyright: ignore[reportUnknownLambdaType] + param(len, "len"), + param(neg, "neg"), + param(object.__init__, "object.__init__"), + param(object.__str__, "object.__str__"), + param(repr, "repr"), + param(str, "str"), + param(str.join, "str.join"), + param(sys.exit, "exit"), + ], ) def test_main(self, *, func: Callable[..., Any], expected: str) -> None: assert get_func_name(func) == expected @@ -89,10 +87,6 @@ def cache_func(x: int, /) -> int: return x assert get_func_name(cache_func) == "cache_func" - assert ( - get_func_qualname(cache_func) - == "tests.test_functions.TestGetFuncNameAndGetFuncQualName.test_cache..cache_func" - ) def test_decorated(self) -> None: @wraps(identity) @@ -100,7 +94,6 @@ def wrapped[T](x: T, /) -> T: return identity(x) assert get_func_name(wrapped) == "identity" - assert get_func_qualname(wrapped) == "utilities.functions.identity" def test_lru_cache(self) -> None: @lru_cache @@ -108,31 +101,20 @@ def lru_cache_func(x: int, /) -> int: return x assert get_func_name(lru_cache_func) == "lru_cache_func" - assert ( - get_func_qualname(lru_cache_func) - == "tests.test_functions.TestGetFuncNameAndGetFuncQualName.test_lru_cache..lru_cache_func" - ) def test_object(self) -> None: class Example: def __call__[T](self, x: T, /) -> T: return identity(x) - obj = Example() - assert get_func_name(obj) == "Example" - assert get_func_qualname(obj) == "tests.test_functions.Example" + assert get_func_name(Example()) == "Example" def test_obj_method(self) -> None: class Example: def obj_method[T](self, x: T) -> T: return identity(x) - obj = Example() - assert get_func_name(obj.obj_method) == "Example.obj_method" - assert ( - get_func_qualname(obj.obj_method) - == "tests.test_functions.TestGetFuncNameAndGetFuncQualName.test_obj_method..Example.obj_method" - ) + assert get_func_name(Example().obj_method) == "Example.obj_method" def test_obj_classmethod(self) -> None: class Example: @@ -141,10 +123,6 @@ def obj_classmethod[T](cls: T) -> T: return identity(cls) assert get_func_name(Example.obj_classmethod) == "Example.obj_classmethod" - assert ( - get_func_qualname(Example.obj_classmethod) - == "tests.test_functions.TestGetFuncNameAndGetFuncQualName.test_obj_classmethod..Example.obj_classmethod" - ) def test_obj_staticmethod(self) -> None: class Example: @@ -153,15 +131,9 @@ def obj_staticmethod[T](x: T) -> T: return identity(x) assert get_func_name(Example.obj_staticmethod) == "Example.obj_staticmethod" - assert ( - get_func_qualname(Example.obj_staticmethod) - == "tests.test_functions.TestGetFuncNameAndGetFuncQualName.test_obj_staticmethod..Example.obj_staticmethod" - ) def test_partial(self) -> None: - part = partial(identity) - assert get_func_name(part) == "identity" - assert get_func_qualname(part) == "utilities.functions.identity" + assert get_func_name(partial(identity)) == "identity" class TestMinMaxNullable: diff --git a/src/utilities/core.py b/src/utilities/core.py index 9bd813dd7..9f10d36a2 100644 --- a/src/utilities/core.py +++ b/src/utilities/core.py @@ -36,7 +36,6 @@ Sentinel, sentinel, ) -from utilities.core import get_class_name, repr_ from utilities.types import SupportsRichComparison if TYPE_CHECKING: From 4a95265c4984b12166c10d24886dd0e4517af2ed Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 15:58:05 +0900 Subject: [PATCH 35/78] 2026-01-21 15:58:05 (Wed) > DW-Mac > derekwan --- src/utilities/pqdm.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/utilities/pqdm.py b/src/utilities/pqdm.py index d85fb19f0..8c6ac0fcc 100644 --- a/src/utilities/pqdm.py +++ b/src/utilities/pqdm.py @@ -7,8 +7,7 @@ from tqdm.auto import tqdm as tqdm_auto from utilities.constants import Sentinel, sentinel -from utilities.core import is_sentinel -from utilities.functions import get_func_name +from utilities.core import get_func_name, is_sentinel from utilities.iterables import apply_to_varargs from utilities.os import get_cpu_use From 88de1798851aa2857d79d6fad063b41256674eca Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 15:58:08 +0900 Subject: [PATCH 36/78] 2026-01-21 15:58:08 (Wed) > DW-Mac > derekwan --- src/utilities/tabulate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utilities/tabulate.py b/src/utilities/tabulate.py index b7a086f60..6975b1b82 100644 --- a/src/utilities/tabulate.py +++ b/src/utilities/tabulate.py @@ -5,7 +5,7 @@ from tabulate import tabulate -from utilities.functions import get_func_name +from utilities.core import get_func_name from utilities.text import split_f_str_equals if TYPE_CHECKING: From 1d511e30ba4ec4acaa62d3a165cceb3c67c3f056 Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 16:01:14 +0900 Subject: [PATCH 37/78] 2026-01-21 16:01:14 (Wed) > DW-Mac > derekwan --- src/tests/core/test_functions.py | 34 ++++++++++++++++++++++++++++++++ src/tests/test_functions.py | 24 ---------------------- src/utilities/core.py | 25 +++++++++++++++++++++++ src/utilities/functions.py | 21 -------------------- 4 files changed, 59 insertions(+), 45 deletions(-) create mode 100644 src/tests/core/test_functions.py diff --git a/src/tests/core/test_functions.py b/src/tests/core/test_functions.py new file mode 100644 index 000000000..24086f444 --- /dev/null +++ b/src/tests/core/test_functions.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from hypothesis import given +from hypothesis.strategies import integers + +from utilities.core import first, identity, last, second + + +class TestFirst: + @given(x=integers(), y=integers()) + def test_main(self, *, x: int, y: int) -> None: + pair = x, y + result = first(pair) + assert result == x + + +class TestIdentity: + @given(x=integers()) + def test_main(self, *, x: int) -> None: + assert identity(x) == x + + +class TestLast: + @given(x=integers(), y=integers()) + def test_main(self, *, x: int, y: int) -> None: + pair = x, y + assert last(pair) == y + + +class TestSecond: + @given(x=integers(), y=integers()) + def test_main(self, *, x: int, y: int) -> None: + pair = x, y + assert second(pair) == y diff --git a/src/tests/test_functions.py b/src/tests/test_functions.py index 118fdc801..4cb5e3b74 100644 --- a/src/tests/test_functions.py +++ b/src/tests/test_functions.py @@ -52,14 +52,11 @@ ensure_time, ensure_time_delta, ensure_zoned_date_time, - first, - identity, in_milli_seconds, in_seconds, in_timedelta, map_object, not_func, - second, yield_object_attributes, yield_object_cached_properties, yield_object_properties, @@ -370,20 +367,6 @@ def test_error(self, *, case: tuple[bool, str]) -> None: _ = ensure_zoned_date_time(sentinel, nullable=nullable) -class TestFirst: - @given(x=integers(), y=integers()) - def test_main(self, *, x: int, y: int) -> None: - pair = x, y - result = first(pair) - assert result == x - - -class TestIdentity: - @given(x=integers()) - def test_main(self, *, x: int) -> None: - assert identity(x) == x - - class TestInMilliSeconds: @mark.parametrize( ("duration", "expected"), @@ -468,13 +451,6 @@ def return_x() -> bool: assert result is expected -class TestSecond: - @given(x=integers(), y=integers()) - def test_main(self, *, x: int, y: int) -> None: - pair = x, y - assert second(pair) == y - - class TestSkipIfOptimize: @mark.parametrize("optimize", [param(True), param(False)]) def test_main(self, *, optimize: bool) -> None: diff --git a/src/utilities/core.py b/src/utilities/core.py index 9f10d36a2..34005fed2 100644 --- a/src/utilities/core.py +++ b/src/utilities/core.py @@ -200,6 +200,31 @@ def suppress_super_attribute_error() -> Iterator[None]: ) +############################################################################### +#### functions ################################################################ +############################################################################### + + +def identity[T](obj: T, /) -> T: + """Return the object itself.""" + return obj + + +def first[T](obj: tuple[T, Any], /) -> T: + """Get the first element in an iterable.""" + return obj[0] + + +def second[T](obj: tuple[Any, T], /) -> T: + """Get the second element in an iterable.""" + return obj[1] + + +def last[T](obj: tuple[T, Any], /) -> T: + """Get the last element in an iterable.""" + return obj[-1] + + ############################################################################### #### itertools ################################################################ ############################################################################### diff --git a/src/utilities/functions.py b/src/utilities/functions.py index eb0564ce8..d82bc1ba3 100644 --- a/src/utilities/functions.py +++ b/src/utilities/functions.py @@ -462,19 +462,9 @@ def __str__(self) -> str: ## -def first[T](pair: tuple[T, Any], /) -> T: - """Get the first element in a pair.""" - return pair[0] - - ## -def identity[T](obj: T, /) -> T: - """Return the object itself.""" - return obj - - ## @@ -530,9 +520,6 @@ def map_object[T]( ## -## - - def not_func[**P](func: Callable[P, bool], /) -> Callable[P, bool]: """Lift a boolean-valued function to return its conjugation.""" @@ -546,14 +533,6 @@ def wrapped(*args: P.args, **kwargs: P.kwargs) -> bool: ## -def second[U](pair: tuple[Any, U], /) -> U: - """Get the second element in a pair.""" - return pair[1] - - -## - - def skip_if_optimize[**P](func: Callable[P, None], /) -> Callable[P, None]: """Skip a function if we are in the optimized mode.""" if __debug__: # pragma: no cover From 96b6510e191a755adb3e902b22f67fa4ed63abcb Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 16:01:37 +0900 Subject: [PATCH 38/78] 2026-01-21 16:01:37 (Wed) > DW-Mac > derekwan --- src/tests/core/test_functions.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/tests/core/test_functions.py b/src/tests/core/test_functions.py index 24086f444..524ae7ef2 100644 --- a/src/tests/core/test_functions.py +++ b/src/tests/core/test_functions.py @@ -9,9 +9,7 @@ class TestFirst: @given(x=integers(), y=integers()) def test_main(self, *, x: int, y: int) -> None: - pair = x, y - result = first(pair) - assert result == x + assert first((x, y)) == x class TestIdentity: @@ -23,12 +21,10 @@ def test_main(self, *, x: int) -> None: class TestLast: @given(x=integers(), y=integers()) def test_main(self, *, x: int, y: int) -> None: - pair = x, y - assert last(pair) == y + assert last((x, y)) == y class TestSecond: @given(x=integers(), y=integers()) def test_main(self, *, x: int, y: int) -> None: - pair = x, y - assert second(pair) == y + assert second((x, y)) == y From 8394350503b8c4f20b0ec829aa1429dc1e887180 Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 16:11:48 +0900 Subject: [PATCH 39/78] 2026-01-21 16:11:48 (Wed) > DW-Mac > derekwan --- src/tests/core/test_builtins.py | 2 +- src/tests/core/test_functions.py | 33 ++++++++++++++++++++++----- src/utilities/core.py | 38 +++++++++++++++++++++++++------- 3 files changed, 58 insertions(+), 15 deletions(-) diff --git a/src/tests/core/test_builtins.py b/src/tests/core/test_builtins.py index e5689cdb7..ab0732879 100644 --- a/src/tests/core/test_builtins.py +++ b/src/tests/core/test_builtins.py @@ -26,11 +26,11 @@ get_class, get_class_name, get_func_name, + identity, max_nullable, min_nullable, ) from utilities.errors import ImpossibleCaseError -from utilities.functions import identity if TYPE_CHECKING: from collections.abc import Callable, Iterable diff --git a/src/tests/core/test_functions.py b/src/tests/core/test_functions.py index 524ae7ef2..660267728 100644 --- a/src/tests/core/test_functions.py +++ b/src/tests/core/test_functions.py @@ -4,12 +4,25 @@ from hypothesis.strategies import integers from utilities.core import first, identity, last, second +from utilities.hypothesis import pairs, quadruples, triples class TestFirst: - @given(x=integers(), y=integers()) - def test_main(self, *, x: int, y: int) -> None: - assert first((x, y)) == x + @given(x=integers()) + def test_main(self, *, x: int) -> None: + assert first((x,)) == x + + @given(x=pairs(integers())) + def test_pair(self, *, x: tuple[int, int]) -> None: + assert first(x) == x[0] + + @given(x=triples(integers())) + def test_triple(self, *, x: tuple[int, int, int]) -> None: + assert first(x) == x[0] + + @given(x=quadruples(integers())) + def test_quadruple(self, *, x: tuple[int, int, int, int]) -> None: + assert first(x) == x[0] class TestIdentity: @@ -25,6 +38,14 @@ def test_main(self, *, x: int, y: int) -> None: class TestSecond: - @given(x=integers(), y=integers()) - def test_main(self, *, x: int, y: int) -> None: - assert second((x, y)) == y + @given(x=pairs(integers())) + def test_pair(self, *, x: tuple[int, int]) -> None: + assert second(x) == x[1] + + @given(x=triples(integers())) + def test_triple(self, *, x: tuple[int, int, int]) -> None: + assert second(x) == x[1] + + @given(x=quadruples(integers())) + def test_quadruple(self, *, x: tuple[int, int, int, int]) -> None: + assert second(x) == x[1] diff --git a/src/utilities/core.py b/src/utilities/core.py index 34005fed2..fe01705f4 100644 --- a/src/utilities/core.py +++ b/src/utilities/core.py @@ -210,18 +210,40 @@ def identity[T](obj: T, /) -> T: return obj -def first[T](obj: tuple[T, Any], /) -> T: - """Get the first element in an iterable.""" - return obj[0] +@overload +def first[T](tup: tuple[T], /) -> T: ... +@overload +def first[T](tup: tuple[T, Any], /) -> T: ... +@overload +def first[T](tup: tuple[T, Any, Any], /) -> T: ... +@overload +def first[T](tup: tuple[T, Any, Any, Any], /) -> T: ... +def first(tup: tuple[Any, ...], /) -> Any: + """Get the first element in a tuple.""" + return tup[0] -def second[T](obj: tuple[Any, T], /) -> T: - """Get the second element in an iterable.""" - return obj[1] +@overload +def second[T](tup: tuple[Any, T], /) -> T: ... +@overload +def second[T](tup: tuple[Any, T, Any], /) -> T: ... +@overload +def second[T](tup: tuple[Any, T, Any, Any], /) -> T: ... +def second(tup: tuple[Any, ...], /) -> Any: + """Get the second element in a tuple.""" + return tup[1] -def last[T](obj: tuple[T, Any], /) -> T: - """Get the last element in an iterable.""" +@overload +def last[T](tup: tuple[T], /) -> T: ... +@overload +def last[T](tup: tuple[Any, T], /) -> T: ... +@overload +def last[T](tup: tuple[Any, Any, T], /) -> T: ... +@overload +def last[T](tup: tuple[Any, Any, Any, T], /) -> T: ... +def last[T](obj: tuple[Any, ...], /) -> Any: + """Get the last element in a tuple.""" return obj[-1] From a4501dcc26f64b72e1099fffc8360d68fd6d2089 Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 16:11:52 +0900 Subject: [PATCH 40/78] 2026-01-21 16:11:52 (Wed) > DW-Mac > derekwan --- src/utilities/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utilities/core.py b/src/utilities/core.py index fe01705f4..69ee538e0 100644 --- a/src/utilities/core.py +++ b/src/utilities/core.py @@ -242,7 +242,7 @@ def last[T](tup: tuple[Any, T], /) -> T: ... def last[T](tup: tuple[Any, Any, T], /) -> T: ... @overload def last[T](tup: tuple[Any, Any, Any, T], /) -> T: ... -def last[T](obj: tuple[Any, ...], /) -> Any: +def last(obj: tuple[Any, ...], /) -> Any: """Get the last element in a tuple.""" return obj[-1] From 82fd0a5b4b419467009826ed9c19771d87594ec3 Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 16:11:56 +0900 Subject: [PATCH 41/78] 2026-01-21 16:11:56 (Wed) > DW-Mac > derekwan --- src/utilities/core.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/utilities/core.py b/src/utilities/core.py index 69ee538e0..de48c1b77 100644 --- a/src/utilities/core.py +++ b/src/utilities/core.py @@ -242,9 +242,9 @@ def last[T](tup: tuple[Any, T], /) -> T: ... def last[T](tup: tuple[Any, Any, T], /) -> T: ... @overload def last[T](tup: tuple[Any, Any, Any, T], /) -> T: ... -def last(obj: tuple[Any, ...], /) -> Any: +def last(objtupuple[Any, ...], /) -> Any: """Get the last element in a tuple.""" - return obj[-1] + return tup[-1] ############################################################################### From df5e552fede314bd70e413266b4b6569d76aaa59 Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 16:11:58 +0900 Subject: [PATCH 42/78] 2026-01-21 16:11:58 (Wed) > DW-Mac > derekwan --- src/utilities/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utilities/core.py b/src/utilities/core.py index de48c1b77..01a7106db 100644 --- a/src/utilities/core.py +++ b/src/utilities/core.py @@ -208,7 +208,7 @@ def suppress_super_attribute_error() -> Iterator[None]: def identity[T](obj: T, /) -> T: """Return the object itself.""" return obj - +## @overload def first[T](tup: tuple[T], /) -> T: ... From 683bcb2a8f152d29f665f2d67460b43fd962e687 Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 16:12:22 +0900 Subject: [PATCH 43/78] 2026-01-21 16:12:22 (Wed) > DW-Mac > derekwan --- src/utilities/core.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/utilities/core.py b/src/utilities/core.py index 01a7106db..8ce5486ae 100644 --- a/src/utilities/core.py +++ b/src/utilities/core.py @@ -205,11 +205,6 @@ def suppress_super_attribute_error() -> Iterator[None]: ############################################################################### -def identity[T](obj: T, /) -> T: - """Return the object itself.""" - return obj -## - @overload def first[T](tup: tuple[T], /) -> T: ... @overload @@ -242,11 +237,19 @@ def last[T](tup: tuple[Any, T], /) -> T: ... def last[T](tup: tuple[Any, Any, T], /) -> T: ... @overload def last[T](tup: tuple[Any, Any, Any, T], /) -> T: ... -def last(objtupuple[Any, ...], /) -> Any: +def last(tup: tuple[Any, ...], /) -> Any: """Get the last element in a tuple.""" return tup[-1] +## + + +def identity[T](obj: T, /) -> T: + """Return the object itself.""" + return obj + + ############################################################################### #### itertools ################################################################ ############################################################################### From 8fab8c5c5cd4a04a13b4f12a3432348dc2ba4f39 Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 16:12:26 +0900 Subject: [PATCH 44/78] 2026-01-21 16:12:26 (Wed) > DW-Mac > derekwan --- src/tests/core/test_functions.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/tests/core/test_functions.py b/src/tests/core/test_functions.py index 660267728..a15d851b6 100644 --- a/src/tests/core/test_functions.py +++ b/src/tests/core/test_functions.py @@ -36,6 +36,18 @@ class TestLast: def test_main(self, *, x: int, y: int) -> None: assert last((x, y)) == y + @given(x=pairs(integers())) + def test_pair(self, *, x: tuple[int, int]) -> None: + assert second(x) == x[1] + + @given(x=triples(integers())) + def test_triple(self, *, x: tuple[int, int, int]) -> None: + assert second(x) == x[1] + + @given(x=quadruples(integers())) + def test_quadruple(self, *, x: tuple[int, int, int, int]) -> None: + assert second(x) == x[1] + class TestSecond: @given(x=pairs(integers())) From 672b805e370c81898504d4355b02929a7778bd2b Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 16:13:00 +0900 Subject: [PATCH 45/78] 2026-01-21 16:13:00 (Wed) > DW-Mac > derekwan --- src/tests/core/test_functions.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/tests/core/test_functions.py b/src/tests/core/test_functions.py index a15d851b6..615a46e85 100644 --- a/src/tests/core/test_functions.py +++ b/src/tests/core/test_functions.py @@ -9,7 +9,7 @@ class TestFirst: @given(x=integers()) - def test_main(self, *, x: int) -> None: + def test_single(self, *, x: int) -> None: assert first((x,)) == x @given(x=pairs(integers())) @@ -32,21 +32,21 @@ def test_main(self, *, x: int) -> None: class TestLast: - @given(x=integers(), y=integers()) - def test_main(self, *, x: int, y: int) -> None: - assert last((x, y)) == y + @given(x=integers()) + def test_single(self, *, x: int) -> None: + assert last((x,)) == x @given(x=pairs(integers())) def test_pair(self, *, x: tuple[int, int]) -> None: - assert second(x) == x[1] + assert last(x) == x[-1] @given(x=triples(integers())) def test_triple(self, *, x: tuple[int, int, int]) -> None: - assert second(x) == x[1] + assert last(x) == x[-1] @given(x=quadruples(integers())) def test_quadruple(self, *, x: tuple[int, int, int, int]) -> None: - assert second(x) == x[1] + assert last(x) == x[-1] class TestSecond: From 61c78ef5c1e008863987cc1d217056b09b16ddb3 Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 16:13:26 +0900 Subject: [PATCH 46/78] 2026-01-21 16:13:26 (Wed) > DW-Mac > derekwan --- src/tests/test_redis.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/tests/test_redis.py b/src/tests/test_redis.py index 3ac434171..216335aba 100644 --- a/src/tests/test_redis.py +++ b/src/tests/test_redis.py @@ -23,9 +23,8 @@ from tests.test_objects.objects import objects from utilities.asyncio import get_items_nowait, sleep from utilities.constants import _SENTINEL_REPR, MICROSECOND, SECOND, Sentinel, sentinel -from utilities.functions import get_class_name, identity +from utilities.core import get_class_name, identity, one from utilities.hypothesis import int64s, pairs, text_ascii -from utilities.iterables import one from utilities.operator import is_equal from utilities.orjson import deserialize, serialize from utilities.pytest import skipif_ci_and_not_linux From 70e020e385560494d08a92793308e97f2ed26465 Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 16:15:01 +0900 Subject: [PATCH 47/78] 2026-01-21 16:15:01 (Wed) > DW-Mac > derekwan --- src/utilities/redis.py | 4 ++-- src/utilities/sqlalchemy_polars.py | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/utilities/redis.py b/src/utilities/redis.py index baf32414e..330d418a0 100644 --- a/src/utilities/redis.py +++ b/src/utilities/redis.py @@ -24,9 +24,9 @@ from utilities.asyncio import timeout from utilities.constants import MILLISECOND, SECOND from utilities.contextlib import enhanced_async_context_manager -from utilities.core import always_iterable +from utilities.core import always_iterable, identity from utilities.errors import ImpossibleCaseError -from utilities.functions import ensure_int, identity, in_milli_seconds, in_seconds +from utilities.functions import ensure_int, in_milli_seconds, in_seconds from utilities.iterables import one from utilities.math import safe_round from utilities.os import is_pytest diff --git a/src/utilities/sqlalchemy_polars.py b/src/utilities/sqlalchemy_polars.py index 44a9f9bd3..368ed2eec 100644 --- a/src/utilities/sqlalchemy_polars.py +++ b/src/utilities/sqlalchemy_polars.py @@ -28,8 +28,7 @@ import utilities.asyncio from utilities.constants import UTC -from utilities.core import OneError, one, repr_ -from utilities.functions import identity +from utilities.core import OneError, identity, one, repr_ from utilities.iterables import CheckDuplicatesError, check_duplicates, chunked from utilities.polars import zoned_date_time_dtype from utilities.sqlalchemy import ( From b00e41b74df0e0b2a8240ca92e692dfde5ef54be Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 16:15:27 +0900 Subject: [PATCH 48/78] 2026-01-21 16:15:27 (Wed) > DW-Mac > derekwan --- src/tests/test_iterables.py | 24 +++------------------- src/utilities/iterables.py | 41 +------------------------------------ 2 files changed, 4 insertions(+), 61 deletions(-) diff --git a/src/tests/test_iterables.py b/src/tests/test_iterables.py index 833abe6da..e47be462f 100644 --- a/src/tests/test_iterables.py +++ b/src/tests/test_iterables.py @@ -6,7 +6,7 @@ from functools import cmp_to_key from itertools import chain, repeat from math import isfinite, isinf, isnan, nan -from operator import add, neg, sub +from operator import neg, sub from re import DOTALL from typing import TYPE_CHECKING, Any, ClassVar @@ -19,7 +19,6 @@ floats, frozensets, integers, - just, lists, none, permutations, @@ -30,8 +29,8 @@ from pytest import mark, param, raises from tests.test_objects.objects import objects -from utilities.constants import Sentinel, sentinel -from utilities.hypothesis import pairs, sentinels, sets_fixed_length, text_ascii +from utilities.constants import sentinel +from utilities.hypothesis import pairs, sets_fixed_length, text_ascii from utilities.iterables import ( CheckBijectionError, CheckDuplicatesError, @@ -93,7 +92,6 @@ pairwise_tail, product_dicts, range_partitions, - reduce_mappings, resolve_include_and_exclude, sort_iterable, take, @@ -900,22 +898,6 @@ def test_error_num_too_high(self) -> None: _ = range_partitions(2, 2, 2) -class TestReduceMappings: - @given( - mappings=lists(dictionaries(text_ascii(), integers())), - initial=just(0) | sentinels(), - ) - def test_main( - self, *, mappings: Sequence[Mapping[str, int]], initial: int | Sentinel - ) -> None: - result = reduce_mappings(add, mappings, initial=initial) - expected = {} - for mapping in mappings: - for key, value in mapping.items(): - expected[key] = expected.get(key, 0) + value - assert result == expected - - class TestResolveIncludeAndExclude: def test_none(self) -> None: include, exclude = resolve_include_and_exclude() diff --git a/src/utilities/iterables.py b/src/utilities/iterables.py index ac211816d..3635e9ba1 100644 --- a/src/utilities/iterables.py +++ b/src/utilities/iterables.py @@ -31,14 +31,7 @@ ) from utilities.constants import Sentinel, sentinel -from utilities.core import ( - OneStrEmptyError, - always_iterable, - is_sentinel, - one, - one_str, - repr_, -) +from utilities.core import OneStrEmptyError, always_iterable, one, one_str, repr_ from utilities.errors import ImpossibleCaseError from utilities.math import ( _CheckIntegerEqualError, @@ -982,37 +975,6 @@ def __str__(self) -> str: ## -@overload -def reduce_mappings[K, V]( - func: Callable[[V, V], V], sequence: Iterable[Mapping[K, V]], / -) -> Mapping[K, V]: ... -@overload -def reduce_mappings[K, V, W]( - func: Callable[[W, V], W], - sequence: Iterable[Mapping[K, V]], - /, - *, - initial: W | Sentinel = sentinel, -) -> Mapping[K, W]: ... -def reduce_mappings[K, V, W]( - func: Callable[[V, V], V] | Callable[[W, V], W], - sequence: Iterable[Mapping[K, V]], - /, - *, - initial: W | Sentinel = sentinel, -) -> Mapping[K, V | W]: - """Reduce a function over the values of a set of mappings.""" - chained = chain_mappings(*sequence) - if is_sentinel(initial): - func2 = cast("Callable[[V, V], V]", func) - return {k: reduce(func2, v) for k, v in chained.items()} - func2 = cast("Callable[[W, V], W]", func) - return {k: reduce(func2, v, initial) for k, v in chained.items()} - - -## - - def resolve_include_and_exclude[T]( *, include: MaybeIterable[T] | None = None, exclude: MaybeIterable[T] | None = None ) -> tuple[set[T] | None, set[T] | None]: @@ -1240,7 +1202,6 @@ def unique_everseen[T]( "pairwise_tail", "product_dicts", "range_partitions", - "reduce_mappings", "resolve_include_and_exclude", "sort_iterable", "take", From a9381aeb317c936f3eb1eea42bfdc5c2d093baac Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 16:23:22 +0900 Subject: [PATCH 49/78] 2026-01-21 16:23:22 (Wed) > DW-Mac > derekwan --- src/tests/core/test_itertools.py | 153 ++++++++++++++++++++++++++++++- src/tests/test_iterables.py | 151 ------------------------------ src/utilities/atomicwrites.py | 3 +- src/utilities/core.py | 81 +++++++++++++++- src/utilities/iterables.py | 72 +-------------- src/utilities/text.py | 4 +- src/utilities/typing.py | 2 +- 7 files changed, 233 insertions(+), 233 deletions(-) diff --git a/src/tests/core/test_itertools.py b/src/tests/core/test_itertools.py index 8ddb6d3df..c0cafcf3a 100644 --- a/src/tests/core/test_itertools.py +++ b/src/tests/core/test_itertools.py @@ -2,7 +2,7 @@ import re from re import DOTALL -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, ClassVar from hypothesis import given from hypothesis.strategies import ( @@ -15,6 +15,7 @@ sampled_from, sets, text, + tuples, ) from pytest import mark, param, raises @@ -24,12 +25,18 @@ OneStrEmptyError, OneStrNonUniqueError, always_iterable, + chunked, one, one_str, + take, + transpose, + unique_everseen, ) +from utilities.hypothesis import text_ascii +from utilities.typing import is_sequence_of if TYPE_CHECKING: - from collections.abc import Iterable, Iterator + from collections.abc import Iterable, Iterator, Sequence class TestAlwaysIterable: @@ -69,6 +76,23 @@ def yield_ints() -> Iterator[int]: assert list(always_iterable(yield_ints())) == [0, 1] +class TestChunked: + @mark.parametrize( + ("iterable", "expected"), + [ + param("ABCDEF", [["A", "B", "C"], ["D", "E", "F"]]), + param("ABCDE", [["A", "B", "C"], ["D", "E"]]), + ], + ) + def test_main( + self, *, iterable: Iterable[str], expected: Sequence[list[str]] + ) -> None: + assert list(chunked(iterable, 3)) == expected + + def test_odd(self) -> None: + assert list(chunked("ABCDE", 3)) == [["A", "B", "C"], ["D", "E"]] + + class TestOne: @mark.parametrize( "args", [param(([None],)), param(([None], [])), param(([None], [], []))] @@ -172,3 +196,128 @@ def test_error_head_case_sensitive_non_unique(self) -> None: match=r"Iterable .* must contain exactly one string starting with 'ab'; got 'abc', 'abd' and perhaps more", ): _ = one_str(["abc", "abd"], "ab", head=True, case_sensitive=True) + + +class TestTake: + def test_simple(self) -> None: + result = take(5, range(10)) + expected = list(range(5)) + assert result == expected + + def test_null(self) -> None: + result = take(0, range(10)) + expected = [] + assert result == expected + + def test_negative(self) -> None: + with raises( + ValueError, + match=r"Indices for islice\(\) must be None or an integer: 0 <= x <= sys.maxsize\.", + ): + _ = take(-3, range(10)) + + def test_too_much(self) -> None: + result = take(10, range(5)) + expected = list(range(5)) + assert result == expected + + +class TestTranspose: + @given(sequence=lists(tuples(integers()), min_size=1)) + def test_singles(self, *, sequence: Sequence[tuple[int]]) -> None: + result = transpose(sequence) + assert isinstance(result, tuple) + for list_i in result: + assert isinstance(list_i, list) + assert len(list_i) == len(sequence) + (first,) = result + assert is_sequence_of(first, int) + zipped = list(zip(*result, strict=True)) + assert zipped == sequence + + @given(sequence=lists(tuples(integers(), text_ascii()), min_size=1)) + def test_pairs(self, *, sequence: Sequence[tuple[int, str]]) -> None: + result = transpose(sequence) + assert isinstance(result, tuple) + for list_i in result: + assert isinstance(list_i, list) + assert len(list_i) == len(sequence) + first, second = result + assert is_sequence_of(first, int) + assert is_sequence_of(second, str) + zipped = list(zip(*result, strict=True)) + assert zipped == sequence + + @given(sequence=lists(tuples(integers(), text_ascii(), integers()), min_size=1)) + def test_triples(self, *, sequence: Sequence[tuple[int, str, int]]) -> None: + result = transpose(sequence) + assert isinstance(result, tuple) + for list_i in result: + assert isinstance(list_i, list) + assert len(list_i) == len(sequence) + first, second, third = result + assert is_sequence_of(first, int) + assert is_sequence_of(second, str) + assert is_sequence_of(third, int) + zipped = list(zip(*result, strict=True)) + assert zipped == sequence + + @given( + sequence=lists( + tuples(integers(), text_ascii(), integers(), text_ascii()), min_size=1 + ) + ) + def test_quadruples(self, *, sequence: Sequence[tuple[int, str, int, str]]) -> None: + result = transpose(sequence) + assert isinstance(result, tuple) + for list_i in result: + assert isinstance(list_i, list) + assert len(list_i) == len(sequence) + first, second, third, fourth = result + assert is_sequence_of(first, int) + assert is_sequence_of(second, str) + assert is_sequence_of(third, int) + assert is_sequence_of(fourth, str) + zipped = list(zip(*result, strict=True)) + assert zipped == sequence + + @given( + sequence=lists( + tuples(integers(), text_ascii(), integers(), text_ascii(), integers()), + min_size=1, + ) + ) + def test_quintuples( + self, *, sequence: Sequence[tuple[int, str, int, str, int]] + ) -> None: + result = transpose(sequence) + assert isinstance(result, tuple) + for list_i in result: + assert isinstance(list_i, list) + assert len(list_i) == len(sequence) + first, second, third, fourth, fifth = result + assert is_sequence_of(first, int) + assert is_sequence_of(second, str) + assert is_sequence_of(third, int) + assert is_sequence_of(fourth, str) + assert is_sequence_of(fifth, int) + zipped = list(zip(*result, strict=True)) + assert zipped == sequence + + +class TestUniqueEverseen: + text: ClassVar[str] = "AAAABBBCCDAABBB" + expected: ClassVar[list[str]] = ["A", "B", "C", "D"] + + def test_main(self) -> None: + result = list(unique_everseen("AAAABBBCCDAABBB")) + assert result == self.expected + + def test_key(self) -> None: + result = list(unique_everseen("ABBCcAD", key=str.lower)) + assert result == self.expected + + def test_non_hashable(self) -> None: + result = list(unique_everseen([[1, 2], [2, 3], [1, 2]])) + expected = [[1, 2], [2, 3]] + assert result == expected diff --git a/src/tests/test_iterables.py b/src/tests/test_iterables.py index e47be462f..285e3c722 100644 --- a/src/tests/test_iterables.py +++ b/src/tests/test_iterables.py @@ -24,7 +24,6 @@ permutations, sampled_from, sets, - tuples, ) from pytest import mark, param, raises @@ -73,7 +72,6 @@ check_supermapping, check_superset, check_unique_modulo_case, - chunked, cmp_nullable, ensure_iterable, ensure_iterable_not_str, @@ -94,11 +92,7 @@ range_partitions, resolve_include_and_exclude, sort_iterable, - take, - transpose, - unique_everseen, ) -from utilities.typing import is_sequence_of if TYPE_CHECKING: from collections.abc import Iterable, Mapping, Sequence @@ -497,26 +491,6 @@ def test_error_duplicate_values(self, *, text: str) -> None: _ = check_unique_modulo_case([text.lower(), text.upper()]) -class TestChunked: - @mark.parametrize( - ("iterable", "expected"), - [ - param("ABCDEF", [["A", "B", "C"], ["D", "E", "F"]]), - param("ABCDE", [["A", "B", "C"], ["D", "E"]]), - ], - ) - def test_main( - self, *, iterable: Iterable[str], expected: Sequence[list[str]] - ) -> None: - result = list(chunked(iterable, 3)) - assert result == expected - - def test_odd(self) -> None: - result = list(chunked("ABCDE", 3)) - expected = [["A", "B", "C"], ["D", "E"]] - assert result == expected - - class TestCmpNullable: @given( data=data(), @@ -1015,128 +989,3 @@ def test_nan_vs_num(self, *, x: float) -> None: def test_nan_vs_nan(self) -> None: result = _sort_iterable_cmp_floats(nan, nan) assert result == 0 - - -class TestTake: - def test_simple(self) -> None: - result = take(5, range(10)) - expected = list(range(5)) - assert result == expected - - def test_null(self) -> None: - result = take(0, range(10)) - expected = [] - assert result == expected - - def test_negative(self) -> None: - with raises( - ValueError, - match=r"Indices for islice\(\) must be None or an integer: 0 <= x <= sys.maxsize\.", - ): - _ = take(-3, range(10)) - - def test_too_much(self) -> None: - result = take(10, range(5)) - expected = list(range(5)) - assert result == expected - - -class TestTranspose: - @given(sequence=lists(tuples(integers()), min_size=1)) - def test_singles(self, *, sequence: Sequence[tuple[int]]) -> None: - result = transpose(sequence) - assert isinstance(result, tuple) - for list_i in result: - assert isinstance(list_i, list) - assert len(list_i) == len(sequence) - (first,) = result - assert is_sequence_of(first, int) - zipped = list(zip(*result, strict=True)) - assert zipped == sequence - - @given(sequence=lists(tuples(integers(), text_ascii()), min_size=1)) - def test_pairs(self, *, sequence: Sequence[tuple[int, str]]) -> None: - result = transpose(sequence) - assert isinstance(result, tuple) - for list_i in result: - assert isinstance(list_i, list) - assert len(list_i) == len(sequence) - first, second = result - assert is_sequence_of(first, int) - assert is_sequence_of(second, str) - zipped = list(zip(*result, strict=True)) - assert zipped == sequence - - @given(sequence=lists(tuples(integers(), text_ascii(), integers()), min_size=1)) - def test_triples(self, *, sequence: Sequence[tuple[int, str, int]]) -> None: - result = transpose(sequence) - assert isinstance(result, tuple) - for list_i in result: - assert isinstance(list_i, list) - assert len(list_i) == len(sequence) - first, second, third = result - assert is_sequence_of(first, int) - assert is_sequence_of(second, str) - assert is_sequence_of(third, int) - zipped = list(zip(*result, strict=True)) - assert zipped == sequence - - @given( - sequence=lists( - tuples(integers(), text_ascii(), integers(), text_ascii()), min_size=1 - ) - ) - def test_quadruples(self, *, sequence: Sequence[tuple[int, str, int, str]]) -> None: - result = transpose(sequence) - assert isinstance(result, tuple) - for list_i in result: - assert isinstance(list_i, list) - assert len(list_i) == len(sequence) - first, second, third, fourth = result - assert is_sequence_of(first, int) - assert is_sequence_of(second, str) - assert is_sequence_of(third, int) - assert is_sequence_of(fourth, str) - zipped = list(zip(*result, strict=True)) - assert zipped == sequence - - @given( - sequence=lists( - tuples(integers(), text_ascii(), integers(), text_ascii(), integers()), - min_size=1, - ) - ) - def test_quintuples( - self, *, sequence: Sequence[tuple[int, str, int, str, int]] - ) -> None: - result = transpose(sequence) - assert isinstance(result, tuple) - for list_i in result: - assert isinstance(list_i, list) - assert len(list_i) == len(sequence) - first, second, third, fourth, fifth = result - assert is_sequence_of(first, int) - assert is_sequence_of(second, str) - assert is_sequence_of(third, int) - assert is_sequence_of(fourth, str) - assert is_sequence_of(fifth, int) - zipped = list(zip(*result, strict=True)) - assert zipped == sequence - - -class TestUniqueEverseen: - text: ClassVar[str] = "AAAABBBCCDAABBB" - expected: ClassVar[list[str]] = ["A", "B", "C", "D"] - - def test_main(self) -> None: - result = list(unique_everseen("AAAABBBCCDAABBB")) - assert result == self.expected - - def test_key(self) -> None: - result = list(unique_everseen("ABBCcAD", key=str.lower)) - assert result == self.expected - - def test_non_hashable(self) -> None: - result = list(unique_everseen([[1, 2], [2, 3], [1, 2]])) - expected = [[1, 2], [2, 3]] - assert result == expected diff --git a/src/utilities/atomicwrites.py b/src/utilities/atomicwrites.py index 34da59f45..cdd670074 100644 --- a/src/utilities/atomicwrites.py +++ b/src/utilities/atomicwrites.py @@ -11,8 +11,7 @@ from atomicwrites import replace_atomic from utilities.contextlib import enhanced_context_manager -from utilities.core import TemporaryDirectory, TemporaryFile, file_or_dir -from utilities.iterables import transpose +from utilities.core import TemporaryDirectory, TemporaryFile, file_or_dir, transpose if TYPE_CHECKING: from collections.abc import Iterator diff --git a/src/utilities/core.py b/src/utilities/core.py index 8ce5486ae..7de4971ae 100644 --- a/src/utilities/core.py +++ b/src/utilities/core.py @@ -8,7 +8,7 @@ from contextlib import contextmanager, suppress from dataclasses import dataclass from functools import _lru_cache_wrapper, partial -from itertools import chain +from itertools import chain, islice from os import chdir, environ, getenv from pathlib import Path from re import findall @@ -39,7 +39,7 @@ from utilities.types import SupportsRichComparison if TYPE_CHECKING: - from collections.abc import Callable, Iterable, Iterator, Mapping + from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence from types import TracebackType from utilities.types import FileOrDir, MaybeIterable, PathLike @@ -266,6 +266,17 @@ def always_iterable[T](obj: MaybeIterable[T], /) -> Iterable[T]: return cast("list[T]", [obj]) +## + + +def chunked[T](iterable: Iterable[T], n: int, /) -> Iterator[Sequence[T]]: + """Break an iterable into lists of length n.""" + return iter(partial(take, n, iter(iterable)), []) + + +## + + def one[T](*iterables: Iterable[T]) -> T: """Return the unique value in a set of iterables.""" it = chain(*iterables) @@ -394,6 +405,64 @@ def __str__(self) -> str: return f"{head} {mid}; got {repr_(self.first)}, {repr_(self.second)} and perhaps more" +## + + +def take[T](n: int, iterable: Iterable[T], /) -> Sequence[T]: + """Return first n items of the iterable as a list.""" + return list(islice(iterable, n)) + + +## + + +@overload +def transpose[T1](iterable: Iterable[tuple[T1]], /) -> tuple[list[T1]]: ... +@overload +def transpose[T1, T2]( + iterable: Iterable[tuple[T1, T2]], / +) -> tuple[list[T1], list[T2]]: ... +@overload +def transpose[T1, T2, T3]( + iterable: Iterable[tuple[T1, T2, T3]], / +) -> tuple[list[T1], list[T2], list[T3]]: ... +@overload +def transpose[T1, T2, T3, T4]( + iterable: Iterable[tuple[T1, T2, T3, T4]], / +) -> tuple[list[T1], list[T2], list[T3], list[T4]]: ... +@overload +def transpose[T1, T2, T3, T4, T5]( + iterable: Iterable[tuple[T1, T2, T3, T4, T5]], / +) -> tuple[list[T1], list[T2], list[T3], list[T4], list[T5]]: ... +def transpose(iterable: Iterable[tuple[Any]]) -> tuple[list[Any], ...]: # pyright: ignore[reportInconsistentOverload] + """Typed verison of `transpose`.""" + return tuple(map(list, zip(*iterable, strict=True))) + + +## + + +def unique_everseen[T]( + iterable: Iterable[T], /, *, key: Callable[[T], Any] | None = None +) -> Iterator[T]: + """Yield unique elements, preserving order.""" + seenset = set() + seenset_add = seenset.add + seenlist = [] + seenlist_add = seenlist.append + use_key = key is not None + for element in iterable: + k = key(element) if use_key else element + try: + if k not in seenset: + seenset_add(k) + yield element + except TypeError: + if k not in seenlist: + seenlist_add(k) + yield element + + ############################################################################### #### os ####################################################################### ############################################################################### @@ -516,14 +585,14 @@ class FileOrDirError(Exception): class _FileOrDirMissingError(FileOrDirError): @override def __str__(self) -> str: - return f"Path does not exist: {str(self.path)!r}" + return f"Path does not exist: {repr_str(self.path)}" @dataclass(kw_only=True, slots=True) class _FileOrDirTypeError(FileOrDirError): @override def __str__(self) -> str: - return f"Path is neither a file nor a directory: {str(self.path)!r}" + return f"Path is neither a file nor a directory: {repr_str(self.path)}" ## @@ -785,6 +854,7 @@ def yield_temp_file_at(path: PathLike, /) -> Iterator[Path]: "TemporaryDirectory", "TemporaryFile", "always_iterable", + "chunked", "file_or_dir", "get_class", "get_class_name", @@ -800,6 +870,9 @@ def yield_temp_file_at(path: PathLike, /) -> Iterator[Path]: "repr_", "repr_str", "suppress_super_attribute_error", + "take", + "transpose", + "unique_everseen", "yield_temp_cwd", "yield_temp_dir_at", "yield_temp_environ", diff --git a/src/utilities/iterables.py b/src/utilities/iterables.py index 3635e9ba1..521f28bc6 100644 --- a/src/utilities/iterables.py +++ b/src/utilities/iterables.py @@ -15,7 +15,7 @@ from contextlib import suppress from dataclasses import dataclass from enum import Enum -from functools import cmp_to_key, partial, reduce +from functools import cmp_to_key, reduce from itertools import accumulate, chain, groupby, islice, pairwise, product from math import isnan from operator import or_ @@ -661,14 +661,6 @@ def cmp_nullable[T: SupportsLT](x: T | None, y: T | None, /) -> Sign: ## -def chunked[T](iterable: Iterable[T], n: int, /) -> Iterator[Sequence[T]]: - """Break an iterable into lists of length n.""" - return iter(partial(take, n, iter(iterable)), []) - - -## - - def ensure_iterable(obj: Any, /) -> Iterable[Any]: """Ensure an object is iterable.""" if is_iterable(obj): @@ -1089,64 +1081,6 @@ def _sort_iterable_cmp_floats(x: float, y: float, /) -> Sign: ## -def take[T](n: int, iterable: Iterable[T], /) -> Sequence[T]: - """Return first n items of the iterable as a list.""" - return list(islice(iterable, n)) - - -## - - -@overload -def transpose[T1](iterable: Iterable[tuple[T1]], /) -> tuple[list[T1]]: ... -@overload -def transpose[T1, T2]( - iterable: Iterable[tuple[T1, T2]], / -) -> tuple[list[T1], list[T2]]: ... -@overload -def transpose[T1, T2, T3]( - iterable: Iterable[tuple[T1, T2, T3]], / -) -> tuple[list[T1], list[T2], list[T3]]: ... -@overload -def transpose[T1, T2, T3, T4]( - iterable: Iterable[tuple[T1, T2, T3, T4]], / -) -> tuple[list[T1], list[T2], list[T3], list[T4]]: ... -@overload -def transpose[T1, T2, T3, T4, T5]( - iterable: Iterable[tuple[T1, T2, T3, T4, T5]], / -) -> tuple[list[T1], list[T2], list[T3], list[T4], list[T5]]: ... -def transpose(iterable: Iterable[tuple[Any]]) -> tuple[list[Any], ...]: # pyright: ignore[reportInconsistentOverload] - """Typed verison of `transpose`.""" - return tuple(map(list, zip(*iterable, strict=True))) - - -## - - -def unique_everseen[T]( - iterable: Iterable[T], /, *, key: Callable[[T], Any] | None = None -) -> Iterator[T]: - """Yield unique elements, preserving order.""" - seenset = set() - seenset_add = seenset.add - seenlist = [] - seenlist_add = seenlist.append - use_key = key is not None - for element in iterable: - k = key(element) if use_key else element - try: - if k not in seenset: - seenset_add(k) - yield element - except TypeError: - if k not in seenlist: - seenlist_add(k) - yield element - - -## - - __all__ = [ "ApplyBijectionError", "CheckBijectionError", @@ -1183,7 +1117,6 @@ def unique_everseen[T]( "check_supermapping", "check_superset", "check_unique_modulo_case", - "chunked", "cmp_nullable", "ensure_iterable", "ensure_iterable_not_str", @@ -1204,7 +1137,4 @@ def unique_everseen[T]( "range_partitions", "resolve_include_and_exclude", "sort_iterable", - "take", - "transpose", - "unique_everseen", ] diff --git a/src/utilities/text.py b/src/utilities/text.py index 0e7539579..ac10a6710 100644 --- a/src/utilities/text.py +++ b/src/utilities/text.py @@ -22,8 +22,8 @@ from uuid import uuid4 from utilities.constants import BRACKETS, LIST_SEPARATOR, PAIR_SEPARATOR, Sentinel -from utilities.iterables import CheckDuplicatesError, check_duplicates, transpose -from utilities.reprlib import repr_ +from utilities.core import repr_, transpose +from utilities.iterables import CheckDuplicatesError, check_duplicates if TYPE_CHECKING: from collections.abc import Iterable, Mapping, Sequence diff --git a/src/utilities/typing.py b/src/utilities/typing.py index cc7bd7a61..b16ab25be 100644 --- a/src/utilities/typing.py +++ b/src/utilities/typing.py @@ -40,7 +40,7 @@ ) from utilities.constants import Sentinel -from utilities.iterables import unique_everseen +from utilities.core import unique_everseen from utilities.types import ( Dataclass, StrDict, From b76b0391e1fcf9ad4313a3b33dcf11c1ff8be980 Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 16:23:48 +0900 Subject: [PATCH 50/78] 2026-01-21 16:23:48 (Wed) > DW-Mac > derekwan --- src/tests/test_concurrent.py | 2 +- src/tests/test_pqdm.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tests/test_concurrent.py b/src/tests/test_concurrent.py index 8b87a1332..b77a730d0 100644 --- a/src/tests/test_concurrent.py +++ b/src/tests/test_concurrent.py @@ -7,8 +7,8 @@ from hypothesis.strategies import integers, lists, sampled_from, tuples from utilities.concurrent import concurrent_map, concurrent_starmap +from utilities.core import transpose from utilities.hypothesis import int32s, pairs, settings_with_reduced_examples -from utilities.iterables import transpose from utilities.types import Parallelism from utilities.typing import get_args diff --git a/src/tests/test_pqdm.py b/src/tests/test_pqdm.py index ec030957e..6b25965b2 100644 --- a/src/tests/test_pqdm.py +++ b/src/tests/test_pqdm.py @@ -10,9 +10,9 @@ from pytest import mark, param from utilities.constants import Sentinel, sentinel +from utilities.core import transpose from utilities.functions import get_class_name from utilities.hypothesis import int32s, pairs, settings_with_reduced_examples -from utilities.iterables import transpose from utilities.pqdm import _get_desc, pqdm_map, pqdm_starmap from utilities.types import Parallelism, StrStrMapping from utilities.typing import get_args From d827b5a058d1cbdda87a694ee2370d92cf0984b6 Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 16:24:32 +0900 Subject: [PATCH 51/78] 2026-01-21 16:24:31 (Wed) > DW-Mac > derekwan --- src/utilities/sqlalchemy.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/utilities/sqlalchemy.py b/src/utilities/sqlalchemy.py index eefef330d..65f417b3a 100644 --- a/src/utilities/sqlalchemy.py +++ b/src/utilities/sqlalchemy.py @@ -66,14 +66,19 @@ from sqlalchemy.pool import NullPool, Pool import utilities.asyncio -from utilities.core import OneEmptyError, OneNonUniqueError, get_class_name, repr_ +from utilities.core import ( + OneEmptyError, + OneNonUniqueError, + chunked, + get_class_name, + repr_, +) from utilities.functions import ensure_str, yield_object_attributes from utilities.iterables import ( CheckLengthError, CheckSubSetError, check_length, check_subset, - chunked, merge_sets, merge_str_mappings, one, From eb00bca602334d0b7fa9332db0ddd6b9162b4959 Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 16:24:57 +0900 Subject: [PATCH 52/78] 2026-01-21 16:24:57 (Wed) > DW-Mac > derekwan --- src/utilities/sqlalchemy_polars.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/utilities/sqlalchemy_polars.py b/src/utilities/sqlalchemy_polars.py index 368ed2eec..9b526e4c9 100644 --- a/src/utilities/sqlalchemy_polars.py +++ b/src/utilities/sqlalchemy_polars.py @@ -28,8 +28,8 @@ import utilities.asyncio from utilities.constants import UTC -from utilities.core import OneError, identity, one, repr_ -from utilities.iterables import CheckDuplicatesError, check_duplicates, chunked +from utilities.core import OneError, chunked, identity, one, repr_ +from utilities.iterables import CheckDuplicatesError, check_duplicates from utilities.polars import zoned_date_time_dtype from utilities.sqlalchemy import ( CHUNK_SIZE_FRAC, From 08bc179f8f22d805d5eb740a567c5902d75ab88c Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 16:25:43 +0900 Subject: [PATCH 53/78] 2026-01-21 16:25:43 (Wed) > DW-Mac > derekwan --- src/tests/test_iterables.py | 9 --------- src/utilities/iterables.py | 12 +----------- 2 files changed, 1 insertion(+), 20 deletions(-) diff --git a/src/tests/test_iterables.py b/src/tests/test_iterables.py index 285e3c722..b7c33d122 100644 --- a/src/tests/test_iterables.py +++ b/src/tests/test_iterables.py @@ -87,7 +87,6 @@ merge_mappings, merge_sets, merge_str_mappings, - pairwise_tail, product_dicts, range_partitions, resolve_include_and_exclude, @@ -789,14 +788,6 @@ def test_error(self) -> None: _ = merge_str_mappings({"x": 1, "X": 2}) -class TestPairwiseTail: - def test_main(self) -> None: - iterable = range(5) - result = list(pairwise_tail(iterable)) - expected = [(0, 1), (1, 2), (2, 3), (3, 4), (4, sentinel)] - assert result == expected - - class TestProductDicts: def test_main(self) -> None: mapping = {"x": [1, 2], "y": [7, 8, 9]} diff --git a/src/utilities/iterables.py b/src/utilities/iterables.py index 521f28bc6..976ece065 100644 --- a/src/utilities/iterables.py +++ b/src/utilities/iterables.py @@ -16,7 +16,7 @@ from dataclasses import dataclass from enum import Enum from functools import cmp_to_key, reduce -from itertools import accumulate, chain, groupby, islice, pairwise, product +from itertools import accumulate, chain, groupby, islice, product from math import isnan from operator import or_ from typing import ( @@ -30,7 +30,6 @@ override, ) -from utilities.constants import Sentinel, sentinel from utilities.core import OneStrEmptyError, always_iterable, one, one_str, repr_ from utilities.errors import ImpossibleCaseError from utilities.math import ( @@ -899,14 +898,6 @@ def __str__(self) -> str: ## -def pairwise_tail[T](iterable: Iterable[T], /) -> Iterator[tuple[T, T | Sentinel]]: - """Return pairwise elements, with the last paired with the sentinel.""" - return pairwise(chain(iterable, [sentinel])) - - -## - - def product_dicts[K, V](mapping: Mapping[K, Iterable[V]], /) -> Iterator[Mapping[K, V]]: """Return the cartesian product of the values in a mapping, as mappings.""" keys = list(mapping) @@ -1132,7 +1123,6 @@ def _sort_iterable_cmp_floats(x: float, y: float, /) -> Sign: "merge_mappings", "merge_sets", "merge_str_mappings", - "pairwise_tail", "product_dicts", "range_partitions", "resolve_include_and_exclude", From f1451feb008eb934bf4e85c1bce8028980b806e9 Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 16:27:04 +0900 Subject: [PATCH 54/78] 2026-01-21 16:27:04 (Wed) > DW-Mac > derekwan --- src/tests/test_iterables.py | 80 ------------------------------------- src/utilities/iterables.py | 62 +--------------------------- 2 files changed, 1 insertion(+), 141 deletions(-) diff --git a/src/tests/test_iterables.py b/src/tests/test_iterables.py index b7c33d122..3f4fb11f3 100644 --- a/src/tests/test_iterables.py +++ b/src/tests/test_iterables.py @@ -51,9 +51,6 @@ _ApplyBijectionDuplicateValuesError, _CheckUniqueModuloCaseDuplicateLowerCaseStringsError, _CheckUniqueModuloCaseDuplicateStringsError, - _RangePartitionsNumError, - _RangePartitionsStopError, - _RangePartitionsTotalError, _sort_iterable_cmp_floats, apply_bijection, apply_to_tuple, @@ -87,8 +84,6 @@ merge_mappings, merge_sets, merge_str_mappings, - product_dicts, - range_partitions, resolve_include_and_exclude, sort_iterable, ) @@ -788,81 +783,6 @@ def test_error(self) -> None: _ = merge_str_mappings({"x": 1, "X": 2}) -class TestProductDicts: - def test_main(self) -> None: - mapping = {"x": [1, 2], "y": [7, 8, 9]} - result = list(product_dicts(mapping)) - expected = [ - {"x": 1, "y": 7}, - {"x": 1, "y": 8}, - {"x": 1, "y": 9}, - {"x": 2, "y": 7}, - {"x": 2, "y": 8}, - {"x": 2, "y": 9}, - ] - assert result == expected - - -class TestRangePartitions: - @given( - case=sampled_from([ - (1, 0, 1, [0]), - (2, 0, 1, [0, 1]), - (2, 0, 2, [0]), - (2, 1, 2, [1]), - (3, 0, 1, [0, 1, 2]), - (3, 0, 2, [0, 1]), - (3, 1, 2, [2]), - (3, 0, 3, [0]), - (3, 1, 3, [1]), - (3, 2, 3, [2]), - (6, 0, 1, [0, 1, 2, 3, 4, 5]), - (6, 0, 2, [0, 1, 2]), - (6, 1, 2, [3, 4, 5]), - (6, 0, 3, [0, 1]), - (6, 1, 3, [2, 3]), - (6, 2, 3, [4, 5]), - (7, 0, 2, [0, 1, 2, 3]), - (7, 1, 2, [4, 5, 6]), - (7, 0, 3, [0, 1, 2]), - (7, 1, 3, [3, 4]), - (7, 2, 3, [5, 6]), - ]) - ) - def test_main(self, *, case: tuple[int, int, int, Sequence[int]]) -> None: - stop, num, total, expected = case - result = list(range_partitions(stop, num, total)) - assert result == expected - - def test_error_stop(self) -> None: - with raises(_RangePartitionsStopError, match=r"'stop' must be positive; got 0"): - _ = range_partitions(0, 0, 0) - - def test_error_total_too_low(self) -> None: - with raises( - _RangePartitionsTotalError, match=r"'total' must be in \[1, 1\]; got 0" - ): - _ = range_partitions(1, 0, 0) - - def test_error_total_too_high(self) -> None: - with raises( - _RangePartitionsTotalError, match=r"'total' must be in \[1, 1\]; got 2" - ): - _ = range_partitions(1, 0, 2) - - def test_error_num_too_low(self) -> None: - with raises( - _RangePartitionsNumError, match=r"'num' must be in \[0, 1\]; got -1" - ): - _ = range_partitions(2, -1, 2) - - def test_error_num_too_high(self) -> None: - with raises( - _RangePartitionsNumError, match=r"'num' must be in \[0, 1\]; got 2" - ): - _ = range_partitions(2, 2, 2) - - class TestResolveIncludeAndExclude: def test_none(self) -> None: include, exclude = resolve_include_and_exclude() diff --git a/src/utilities/iterables.py b/src/utilities/iterables.py index 976ece065..be28eb8ca 100644 --- a/src/utilities/iterables.py +++ b/src/utilities/iterables.py @@ -16,7 +16,7 @@ from dataclasses import dataclass from enum import Enum from functools import cmp_to_key, reduce -from itertools import accumulate, chain, groupby, islice, product +from itertools import accumulate, chain, groupby, islice from math import isnan from operator import or_ from typing import ( @@ -898,66 +898,6 @@ def __str__(self) -> str: ## -def product_dicts[K, V](mapping: Mapping[K, Iterable[V]], /) -> Iterator[Mapping[K, V]]: - """Return the cartesian product of the values in a mapping, as mappings.""" - keys = list(mapping) - for values in product(*mapping.values()): - yield cast("Mapping[K, V]", dict(zip(keys, values, strict=True))) - - -## - - -def range_partitions(stop: int, num: int, total: int, /) -> range: - """Partition a range.""" - if stop <= 0: - raise _RangePartitionsStopError(stop=stop) - if not (1 <= total <= stop): - raise _RangePartitionsTotalError(stop=stop, total=total) - if not (0 <= num < total): - raise _RangePartitionsNumError(num=num, total=total) - q, r = divmod(stop, total) - start = num * q + min(num, r) - end = start + q + (1 if num < r else 0) - return range(start, end) - - -@dataclass(kw_only=True, slots=True) -class RangePartitionsError(Exception): ... - - -@dataclass(kw_only=True, slots=True) -class _RangePartitionsStopError(RangePartitionsError): - stop: int - - @override - def __str__(self) -> str: - return f"'stop' must be positive; got {self.stop}" - - -@dataclass(kw_only=True, slots=True) -class _RangePartitionsTotalError(RangePartitionsError): - stop: int - total: int - - @override - def __str__(self) -> str: - return f"'total' must be in [1, {self.stop}]; got {self.total}" - - -@dataclass(kw_only=True, slots=True) -class _RangePartitionsNumError(RangePartitionsError): - num: int - total: int - - @override - def __str__(self) -> str: - return f"'num' must be in [0, {self.total - 1}]; got {self.num}" - - -## - - def resolve_include_and_exclude[T]( *, include: MaybeIterable[T] | None = None, exclude: MaybeIterable[T] | None = None ) -> tuple[set[T] | None, set[T] | None]: From 5cc5735190f7400c789538e6a5c80a527fd4ce74 Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 16:27:24 +0900 Subject: [PATCH 55/78] 2026-01-21 16:27:24 (Wed) > DW-Mac > derekwan --- src/utilities/iterables.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/utilities/iterables.py b/src/utilities/iterables.py index be28eb8ca..cbf60281e 100644 --- a/src/utilities/iterables.py +++ b/src/utilities/iterables.py @@ -1028,7 +1028,6 @@ def _sort_iterable_cmp_floats(x: float, y: float, /) -> Sign: "EnsureIterableError", "EnsureIterableNotStrError", "MergeStrMappingsError", - "RangePartitionsError", "ResolveIncludeAndExcludeError", "SortIterableError", "always_iterable", @@ -1063,8 +1062,6 @@ def _sort_iterable_cmp_floats(x: float, y: float, /) -> Sign: "merge_mappings", "merge_sets", "merge_str_mappings", - "product_dicts", - "range_partitions", "resolve_include_and_exclude", "sort_iterable", ] From 811306edfcd36029a681b05a425188997d1f54d8 Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 16:27:41 +0900 Subject: [PATCH 56/78] 2026-01-21 16:27:41 (Wed) > DW-Mac > derekwan --- src/tests/test_iterables.py | 30 ------------------------------ src/utilities/iterables.py | 9 --------- 2 files changed, 39 deletions(-) diff --git a/src/tests/test_iterables.py b/src/tests/test_iterables.py index 3f4fb11f3..6ac9e54e9 100644 --- a/src/tests/test_iterables.py +++ b/src/tests/test_iterables.py @@ -2,7 +2,6 @@ import re from dataclasses import dataclass -from enum import Enum, auto from functools import cmp_to_key from itertools import chain, repeat from math import isfinite, isinf, isnan, nan @@ -78,7 +77,6 @@ groupby_lists, hashable_to_iterable, is_iterable, - is_iterable_not_enum, is_iterable_not_str, map_mapping, merge_mappings, @@ -673,34 +671,6 @@ def test_main(self, *, obj: Any, expected: bool) -> None: assert is_iterable(obj) is expected -class TestIsIterableNotEnum: - def test_single(self) -> None: - class Truth(Enum): - true = auto() - false = auto() - - assert not is_iterable_not_enum(Truth) - - def test_union(self) -> None: - class Truth1(Enum): - true = auto() - false = auto() - - class Truth2(Enum): - true = auto() - false = auto() - - assert is_iterable_not_enum((Truth1, Truth2)) - - @mark.parametrize( - ("obj", "expected"), - [param(None, False), param([], True), param((), True), param("", True)], - ) - def test_others(self, *, obj: Any, expected: bool) -> None: - result = is_iterable_not_enum(obj) - assert result is expected - - class TestIsIterableNotStr: @mark.parametrize( ("obj", "expected"), diff --git a/src/utilities/iterables.py b/src/utilities/iterables.py index cbf60281e..127749d2e 100644 --- a/src/utilities/iterables.py +++ b/src/utilities/iterables.py @@ -14,7 +14,6 @@ from collections.abc import Set as AbstractSet from contextlib import suppress from dataclasses import dataclass -from enum import Enum from functools import cmp_to_key, reduce from itertools import accumulate, chain, groupby, islice from math import isnan @@ -817,14 +816,6 @@ def is_iterable(obj: Any, /) -> TypeGuard[Iterable[Any]]: ## -def is_iterable_not_enum(obj: Any, /) -> TypeGuard[Iterable[Any]]: - """Check if an object is iterable, but not an Enum.""" - return is_iterable(obj) and not (isinstance(obj, type) and issubclass(obj, Enum)) - - -## - - def is_iterable_not_str(obj: Any, /) -> TypeGuard[Iterable[Any]]: """Check if an object is iterable, but not a string.""" return is_iterable(obj) and not isinstance(obj, str) From 00a15ed37eccf8053738b7a13a50c74395d61070 Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 16:28:54 +0900 Subject: [PATCH 57/78] 2026-01-21 16:28:54 (Wed) > DW-Mac > derekwan --- src/tests/test_iterables.py | 14 -------------- src/utilities/iterables.py | 10 ---------- 2 files changed, 24 deletions(-) diff --git a/src/tests/test_iterables.py b/src/tests/test_iterables.py index 6ac9e54e9..dd9297893 100644 --- a/src/tests/test_iterables.py +++ b/src/tests/test_iterables.py @@ -75,7 +75,6 @@ expanding_window, filter_include_and_exclude, groupby_lists, - hashable_to_iterable, is_iterable, is_iterable_not_str, map_mapping, @@ -561,19 +560,6 @@ def test_main(self, *, iterable: Iterable[int], expected: list[list[int]]) -> No assert result == expected -class TestHashableToIterable: - def test_none(self) -> None: - result = hashable_to_iterable(None) - expected = None - assert result is expected - - @given(x=lists(integers())) - def test_integers(self, *, x: int) -> None: - result = hashable_to_iterable(x) - expected = (x,) - assert result == expected - - class TestFilterIncludeAndExclude: def test_none(self) -> None: rng = list(range(5)) diff --git a/src/utilities/iterables.py b/src/utilities/iterables.py index 127749d2e..8ecc16d18 100644 --- a/src/utilities/iterables.py +++ b/src/utilities/iterables.py @@ -796,14 +796,6 @@ def groupby_lists[T, U]( ## -def hashable_to_iterable[T: Hashable](obj: T | None, /) -> tuple[T, ...] | None: - """Lift a hashable singleton to an iterable of hashables.""" - return None if obj is None else (obj,) - - -## - - def is_iterable(obj: Any, /) -> TypeGuard[Iterable[Any]]: """Check if an object is iterable.""" try: @@ -1045,9 +1037,7 @@ def _sort_iterable_cmp_floats(x: float, y: float, /) -> Sign: "expanding_window", "filter_include_and_exclude", "groupby_lists", - "hashable_to_iterable", "is_iterable", - "is_iterable_not_enum", "is_iterable_not_str", "map_mapping", "merge_mappings", From 7067c09eba5378a74d9ed0449c963cececc37399 Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 16:29:53 +0900 Subject: [PATCH 58/78] 2026-01-21 16:29:53 (Wed) > DW-Mac > derekwan --- src/tests/test_iterables.py | 32 -------------------------------- src/utilities/iterables.py | 33 +-------------------------------- 2 files changed, 1 insertion(+), 64 deletions(-) diff --git a/src/tests/test_iterables.py b/src/tests/test_iterables.py index dd9297893..b9ac32f73 100644 --- a/src/tests/test_iterables.py +++ b/src/tests/test_iterables.py @@ -42,7 +42,6 @@ CheckSuperMappingError, CheckSuperSetError, EnsureIterableError, - EnsureIterableNotStrError, MergeStrMappingsError, ResolveIncludeAndExcludeError, SortIterableError, @@ -70,9 +69,7 @@ check_unique_modulo_case, cmp_nullable, ensure_iterable, - ensure_iterable_not_str, enumerate_with_edge, - expanding_window, filter_include_and_exclude, groupby_lists, is_iterable, @@ -514,20 +511,6 @@ def test_error(self) -> None: _ = ensure_iterable(None) -class TestEnsureIterableNotStr: - @mark.parametrize("obj", [param([]), param(())]) - def test_main(self, *, obj: Any) -> None: - _ = ensure_iterable_not_str(obj) - - @mark.parametrize("obj", [param(None), param("")]) - def test_error(self, *, obj: Any) -> None: - with raises( - EnsureIterableNotStrError, - match=r"Object .* must be iterable, but not a string", - ): - _ = ensure_iterable_not_str(obj) - - class TestEnumerateWithEdge: def test_main(self) -> None: result = list(enumerate_with_edge(range(100))) @@ -545,21 +528,6 @@ def test_short(self) -> None: assert is_edge -class TestExpandingWindow: - @mark.parametrize( - ("iterable", "expected"), - [ - param( - [1, 2, 3, 4, 5], [[1], [1, 2], [1, 2, 3], [1, 2, 3, 4], [1, 2, 3, 4, 5]] - ), - param([], []), - ], - ) - def test_main(self, *, iterable: Iterable[int], expected: list[list[int]]) -> None: - result = list(expanding_window(iterable)) - assert result == expected - - class TestFilterIncludeAndExclude: def test_none(self) -> None: rng = list(range(5)) diff --git a/src/utilities/iterables.py b/src/utilities/iterables.py index 8ecc16d18..397032a4c 100644 --- a/src/utilities/iterables.py +++ b/src/utilities/iterables.py @@ -15,7 +15,7 @@ from contextlib import suppress from dataclasses import dataclass from functools import cmp_to_key, reduce -from itertools import accumulate, chain, groupby, islice +from itertools import chain, groupby from math import isnan from operator import or_ from typing import ( @@ -678,25 +678,6 @@ def __str__(self) -> str: ## -def ensure_iterable_not_str(obj: Any, /) -> Iterable[Any]: - """Ensure an object is iterable, but not a string.""" - if is_iterable_not_str(obj): - return obj - raise EnsureIterableNotStrError(obj=obj) - - -@dataclass(kw_only=True, slots=True) -class EnsureIterableNotStrError(Exception): - obj: Any - - @override - def __str__(self) -> str: - return f"Object {repr_(self.obj)} must be iterable, but not a string" - - -## - - _EDGE: int = 5 @@ -717,18 +698,6 @@ def enumerate_with_edge[T]( ## -def expanding_window[T](iterable: Iterable[T], /) -> islice[list[T]]: - """Yield an expanding window over an iterable.""" - - def func(acc: Iterable[T], el: T, /) -> list[T]: - return list(chain(acc, [el])) - - return islice(accumulate(iterable, func=func, initial=[]), 1, None) - - -## - - @overload def filter_include_and_exclude[T, U]( iterable: Iterable[T], From e1a069ce3e70d061f6219202bfa02e721750ac82 Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 16:32:41 +0900 Subject: [PATCH 59/78] 2026-01-21 16:32:41 (Wed) > DW-Mac > derekwan --- src/tests/test_iterables.py | 29 +------------------ src/utilities/iterables.py | 58 +------------------------------------ 2 files changed, 2 insertions(+), 85 deletions(-) diff --git a/src/tests/test_iterables.py b/src/tests/test_iterables.py index b9ac32f73..f247c8fa2 100644 --- a/src/tests/test_iterables.py +++ b/src/tests/test_iterables.py @@ -3,7 +3,7 @@ import re from dataclasses import dataclass from functools import cmp_to_key -from itertools import chain, repeat +from itertools import repeat from math import isfinite, isinf, isnan, nan from operator import neg, sub from re import DOTALL @@ -53,8 +53,6 @@ apply_bijection, apply_to_tuple, apply_to_varargs, - chain_mappings, - chain_nullable, check_bijection, check_duplicates, check_iterables_equal, @@ -132,31 +130,6 @@ def test_main(self, *, x: int, y: int) -> None: assert result == expected -class TestChainMappings: - @given(mappings=lists(dictionaries(text_ascii(), integers())), list_=booleans()) - def test_main(self, *, mappings: Sequence[Mapping[str, int]], list_: bool) -> None: - result = chain_mappings(*mappings, list=list_) - expected = {} - for mapping in mappings: - for key, value in mapping.items(): - expected[key] = list(chain(expected.get(key, []), [value])) - if list_: - assert result == expected - else: - assert set(result) == set(expected) - - -class TestChainNullable: - @given(values=lists(lists(integers() | none()) | none())) - def test_main(self, *, values: list[list[int | None] | None]) -> None: - result = list(chain_nullable(*values)) - expected = [] - for val in values: - if val is not None: - expected.extend(v for v in val if v is not None) - assert result == expected - - class TestCheckBijection: @given(data=data(), n=integers(0, 10)) def test_main(self, *, data: DataObject, n: int) -> None: diff --git a/src/utilities/iterables.py b/src/utilities/iterables.py index 397032a4c..5c2fbd9f4 100644 --- a/src/utilities/iterables.py +++ b/src/utilities/iterables.py @@ -1,6 +1,5 @@ from __future__ import annotations -import builtins from collections import Counter from collections.abc import ( Callable, @@ -15,7 +14,7 @@ from contextlib import suppress from dataclasses import dataclass from functools import cmp_to_key, reduce -from itertools import chain, groupby +from itertools import groupby from math import isnan from operator import or_ from typing import ( @@ -97,9 +96,6 @@ def apply_to_tuple[T](func: Callable[..., T], args: tuple[Any, ...], /) -> T: return apply_to_varargs(func, *args) -## - - def apply_to_varargs[T](func: Callable[..., T], *args: Any) -> T: """Apply a function to a variable number of arguments.""" return func(*args) @@ -108,53 +104,6 @@ def apply_to_varargs[T](func: Callable[..., T], *args: Any) -> T: ## -@overload -def chain_mappings[K, V]( - *mappings: Mapping[K, V], list: Literal[True] -) -> Mapping[K, Sequence[V]]: ... -@overload -def chain_mappings[K, V]( - *mappings: Mapping[K, V], list: bool = False -) -> Mapping[K, Iterable[V]]: ... -def chain_mappings[K, V]( - *mappings: Mapping[K, V], - list: bool = False, # noqa: A002 -) -> Mapping[K, Iterable[V]]: - """Chain the values of a set of mappings.""" - try: - first, *rest = mappings - except ValueError: - return {} - initial = {k: [v] for k, v in first.items()} - reduced = reduce(_chain_mappings_one, rest, initial) - if list: - return {k: builtins.list(v) for k, v in reduced.items()} - return reduced - - -def _chain_mappings_one[K, V]( - acc: Mapping[K, Iterable[V]], el: Mapping[K, V], / -) -> Mapping[K, Iterable[V]]: - """Chain the values of a set of mappings.""" - out = dict(acc) - for key, value in el.items(): - out[key] = chain(out.get(key, []), [value]) - return out - - -## - - -def chain_nullable[T](*maybe_iterables: Iterable[T | None] | None) -> Iterable[T]: - """Chain a set of values; ignoring nulls.""" - iterables = (mi for mi in maybe_iterables if mi is not None) - values = ((i for i in it if i is not None) for it in iterables) - return chain.from_iterable(values) - - -## - - def check_bijection(mapping: Mapping[Any, Hashable], /) -> None: """Check if a mapping is a bijection.""" try: @@ -978,7 +927,6 @@ def _sort_iterable_cmp_floats(x: float, y: float, /) -> Sign: "CheckSuperSetError", "CheckUniqueModuloCaseError", "EnsureIterableError", - "EnsureIterableNotStrError", "MergeStrMappingsError", "ResolveIncludeAndExcludeError", "SortIterableError", @@ -986,8 +934,6 @@ def _sort_iterable_cmp_floats(x: float, y: float, /) -> Sign: "apply_bijection", "apply_to_tuple", "apply_to_varargs", - "chain_mappings", - "chain_nullable", "check_bijection", "check_duplicates", "check_iterables_equal", @@ -1001,9 +947,7 @@ def _sort_iterable_cmp_floats(x: float, y: float, /) -> Sign: "check_unique_modulo_case", "cmp_nullable", "ensure_iterable", - "ensure_iterable_not_str", "enumerate_with_edge", - "expanding_window", "filter_include_and_exclude", "groupby_lists", "is_iterable", From eae5b29ac5c258f082f79bd1b83f3843066fabc9 Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 16:33:34 +0900 Subject: [PATCH 60/78] 2026-01-21 16:33:34 (Wed) > DW-Mac > derekwan --- src/utilities/subprocess.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/utilities/subprocess.py b/src/utilities/subprocess.py index 4569d8a6d..6ed369d91 100644 --- a/src/utilities/subprocess.py +++ b/src/utilities/subprocess.py @@ -24,7 +24,13 @@ ) from utilities.constants import HOME, PWD, SECOND from utilities.contextlib import enhanced_context_manager -from utilities.core import TemporaryDirectory, always_iterable, file_or_dir +from utilities.core import ( + OneEmptyError, + TemporaryDirectory, + always_iterable, + file_or_dir, + one, +) from utilities.errors import ImpossibleCaseError from utilities.functions import in_timedelta from utilities.logging import to_logger From 71300f0563fcfaa2833309f245ecdc3db0d21c20 Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 16:34:30 +0900 Subject: [PATCH 61/78] 2026-01-21 16:34:30 (Wed) > DW-Mac > derekwan --- src/tests/test_asyncio.py | 11 ----------- src/utilities/asyncio.py | 17 ----------------- 2 files changed, 28 deletions(-) diff --git a/src/tests/test_asyncio.py b/src/tests/test_asyncio.py index a033ad77f..2fe690eb7 100644 --- a/src/tests/test_asyncio.py +++ b/src/tests/test_asyncio.py @@ -17,7 +17,6 @@ OneAsyncEmptyError, OneAsyncNonUniqueError, chain_async, - get_coroutine_name, get_items, get_items_nowait, one_async, @@ -317,16 +316,6 @@ class CustomError(Exception): ... _ = tg.create_task(sleep(_MULTIPLE_HIGH * _DURATION)) -class TestGetCoroutineName: - def test_main(self) -> None: - async def func() -> None: - await sleep() - - result = get_coroutine_name(func) - expected = "func" - assert result == expected - - class TestGetItems: @given( xs=lists(integers(), min_size=1), diff --git a/src/utilities/asyncio.py b/src/utilities/asyncio.py index a968abf80..be031730e 100644 --- a/src/utilities/asyncio.py +++ b/src/utilities/asyncio.py @@ -41,7 +41,6 @@ from utilities.os import is_pytest from utilities.shelve import yield_shelf from utilities.text import to_bool -from utilities.warnings import suppress_warnings from utilities.whenever import get_now, round_date_or_date_time if TYPE_CHECKING: @@ -67,7 +66,6 @@ from utilities.shelve import _Flag from utilities.types import ( - Coro, Delta, Duration, MaybeCallableBoolLike, @@ -366,20 +364,6 @@ async def iterator() -> AsyncIterator[T]: ## -def get_coroutine_name(func: Callable[[], Coro[Any]], /) -> str: - """Get the name of a coroutine, and then dispose of it gracefully.""" - coro = func() - name = coro.__name__ - with suppress_warnings( - message="coroutine '.*' was never awaited", category=RuntimeWarning - ): - del coro - return name - - -## - - async def get_items[T](queue: Queue[T], /, *, max_size: int | None = None) -> list[T]: """Get items from a queue; if empty then wait.""" try: @@ -601,7 +585,6 @@ async def yield_locked_shelf( "OneAsyncNonUniqueError", "StreamCommandOutput", "chain_async", - "get_coroutine_name", "get_items", "get_items_nowait", "one_async", From bdf120e7066e1497a81c04ccee62bf1e2c6d76c3 Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 16:35:04 +0900 Subject: [PATCH 62/78] 2026-01-21 16:35:04 (Wed) > DW-Mac > derekwan --- src/tests/test_asyncio.py | 22 ---------------------- src/utilities/asyncio.py | 20 -------------------- 2 files changed, 42 deletions(-) diff --git a/src/tests/test_asyncio.py b/src/tests/test_asyncio.py index 2fe690eb7..585585ae1 100644 --- a/src/tests/test_asyncio.py +++ b/src/tests/test_asyncio.py @@ -16,7 +16,6 @@ EnhancedTaskGroup, OneAsyncEmptyError, OneAsyncNonUniqueError, - chain_async, get_items, get_items_nowait, one_async, @@ -195,27 +194,6 @@ def test_values(self, *, dict_: AsyncDict[str, int]) -> None: assert isinstance(value, int) -class TestChainAsync: - @given(n=integers(0, 10)) - async def test_sync(self, *, n: int) -> None: - it = chain_async(range(n)) - result = [x async for x in it] - expected = list(range(n)) - assert result == expected - - @given(n=integers(0, 10)) - async def test_async(self, *, n: int) -> None: - async def range_async(n: int, /) -> AsyncIterator[int]: - await sleep() - for i in range(n): - yield i - - it = chain_async(range_async(n)) - result = [x async for x in it] - expected = list(range(n)) - assert result == expected - - class TestEnhancedTaskGroup: async def test_create_task_context_coroutine(self) -> None: flag: bool = False diff --git a/src/utilities/asyncio.py b/src/utilities/asyncio.py index be031730e..16fb4dd31 100644 --- a/src/utilities/asyncio.py +++ b/src/utilities/asyncio.py @@ -30,7 +30,6 @@ Self, TextIO, assert_never, - cast, overload, override, ) @@ -346,24 +345,6 @@ async def _wrap_with_timeout[T](self, coroutine: _CoroutineLike[T], /) -> T: ## -def chain_async[T](*iterables: Iterable[T] | AsyncIterable[T]) -> AsyncIterator[T]: - """Asynchronous version of `chain`.""" - - async def iterator() -> AsyncIterator[T]: - for it in iterables: - try: - async for item in cast("AsyncIterable[T]", it): - yield item - except TypeError: - for item in cast("Iterable[T]", it): - yield item - - return iterator() - - -## - - async def get_items[T](queue: Queue[T], /, *, max_size: int | None = None) -> list[T]: """Get items from a queue; if empty then wait.""" try: @@ -584,7 +565,6 @@ async def yield_locked_shelf( "OneAsyncError", "OneAsyncNonUniqueError", "StreamCommandOutput", - "chain_async", "get_items", "get_items_nowait", "one_async", From e0e052ef22e96ae8702038a58be146da586ece90 Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 16:38:10 +0900 Subject: [PATCH 63/78] 2026-01-21 16:38:10 (Wed) > DW-Mac > derekwan --- src/tests/test_functions.py | 59 +------------------------------------ src/utilities/functions.py | 31 ++----------------- src/utilities/whenever.py | 1 + 3 files changed, 5 insertions(+), 86 deletions(-) diff --git a/src/tests/test_functions.py b/src/tests/test_functions.py index 4cb5e3b74..3c8dda550 100644 --- a/src/tests/test_functions.py +++ b/src/tests/test_functions.py @@ -2,22 +2,12 @@ from dataclasses import dataclass from functools import cached_property -from operator import neg from subprocess import check_output from sys import executable from typing import TYPE_CHECKING, Any, ClassVar, cast from hypothesis import given -from hypothesis.strategies import ( - DataObject, - booleans, - builds, - data, - dictionaries, - integers, - lists, - sampled_from, -) +from hypothesis.strategies import booleans, integers, sampled_from from pytest import approx, mark, param, raises from utilities.constants import HOME, MILLISECOND, NOW_UTC, SECOND, ZERO_TIME, sentinel @@ -55,7 +45,6 @@ in_milli_seconds, in_seconds, in_timedelta, - map_object, not_func, yield_object_attributes, yield_object_cached_properties, @@ -393,52 +382,6 @@ def test_main(self, *, duration: Duration) -> None: assert in_timedelta(duration) == SECOND -class TestMapObject: - @given(x=integers()) - def test_int(self, *, x: int) -> None: - result = map_object(neg, x) - expected = -x - assert result == expected - - @given(x=dictionaries(integers(), integers())) - def test_dict(self, *, x: dict[int, int]) -> None: - result = map_object(neg, x) - expected = {k: -v for k, v in x.items()} - assert result == expected - - @given(x=lists(integers())) - def test_sequences(self, *, x: list[int]) -> None: - result = map_object(neg, x) - expected = list(map(neg, x)) - assert result == expected - - @given(data=data()) - def test_dataclasses(self, *, data: DataObject) -> None: - @dataclass(kw_only=True, slots=True) - class Example: - x: int = 0 - - obj = data.draw(builds(Example)) - result = map_object(neg, obj) - expected = {"x": -obj.x} - assert result == expected - - @given(x=lists(dictionaries(integers(), integers()))) - def test_nested(self, *, x: list[dict[int, int]]) -> None: - result = map_object(neg, x) - expected = [{k: -v for k, v in x_i.items()} for x_i in x] - assert result == expected - - @given(x=lists(integers())) - def test_before(self, *, x: list[int]) -> None: - def before(x: Any, /) -> Any: - return x + 1 if isinstance(x, int) else x - - result = map_object(neg, x, before=before) - expected = [-(i + 1) for i in x] - assert result == expected - - class TestNotFunc: @given(x=booleans()) def test_main(self, *, x: bool) -> None: diff --git a/src/utilities/functions.py b/src/utilities/functions.py index d82bc1ba3..8d38986c2 100644 --- a/src/utilities/functions.py +++ b/src/utilities/functions.py @@ -1,6 +1,6 @@ from __future__ import annotations -from dataclasses import asdict, dataclass +from dataclasses import dataclass from functools import cached_property, wraps from inspect import getattr_static from pathlib import Path @@ -11,11 +11,12 @@ from utilities.constants import SECOND from utilities.core import get_class_name, repr_ from utilities.reprlib import get_repr_and_class -from utilities.types import Dataclass, Duration, Number, TypeLike if TYPE_CHECKING: from collections.abc import Callable, Container, Iterable, Iterator + from utilities.types import Duration, Number, TypeLike + @overload def ensure_bool(obj: Any, /, *, nullable: bool) -> bool | None: ... @@ -498,28 +499,6 @@ def in_timedelta(duration: Duration, /) -> TimeDelta: ## -def map_object[T]( - func: Callable[[Any], Any], obj: T, /, *, before: Callable[[Any], Any] | None = None -) -> T: - """Map a function over an object, across a variety of structures.""" - if before is not None: - obj = before(obj) - match obj: - case dict(): - return type(obj)({ - k: map_object(func, v, before=before) for k, v in obj.items() - }) - case frozenset() | list() | set() | tuple(): - return type(obj)(map_object(func, i, before=before) for i in obj) - case Dataclass(): - return map_object(func, asdict(obj), before=before) - case _: - return func(obj) - - -## - - def not_func[**P](func: Callable[P, bool], /) -> Callable[P, bool]: """Lift a boolean-valued function to return its conjugation.""" @@ -623,14 +602,10 @@ def _make_error_msg(obj: Any, desc: str, /, *, nullable: bool = False) -> str: "ensure_time", "ensure_time_delta", "ensure_zoned_date_time", - "first", - "identity", "in_milli_seconds", "in_seconds", "in_timedelta", - "map_object", "not_func", - "second", "skip_if_optimize", "yield_object_attributes", "yield_object_cached_properties", diff --git a/src/utilities/whenever.py b/src/utilities/whenever.py index 26e8ba01b..5ee4b3ea7 100644 --- a/src/utilities/whenever.py +++ b/src/utilities/whenever.py @@ -238,6 +238,7 @@ def datetime_utc( month: int, day: int, /, + *, hour: int = 0, minute: int = 0, second: int = 0, From 36c11ab840fe629dec806e904feb1762aab9a832 Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 17:29:52 +0900 Subject: [PATCH 64/78] 2026-01-21 17:29:52 (Wed) > DW-Mac > derekwan --- src/tests/core/test_text.py | 87 +++++++++++++++++++++++++++++++++++++ src/tests/test_text.py | 85 ------------------------------------ src/utilities/core.py | 48 +++++++++++++++++++- src/utilities/functions.py | 6 --- src/utilities/text.py | 49 +-------------------- 5 files changed, 135 insertions(+), 140 deletions(-) create mode 100644 src/tests/core/test_text.py diff --git a/src/tests/core/test_text.py b/src/tests/core/test_text.py new file mode 100644 index 000000000..870a085f1 --- /dev/null +++ b/src/tests/core/test_text.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from pytest import mark, param + +from utilities.core import kebab_case, pascal_case, snake_case + + +class TestPascalSnakeAndKebabCase: + @mark.parametrize( + ("text", "exp_pascal", "exp_snake", "exp_kebab"), + [ + param("API", "API", "api", "api"), + param("APIResponse", "APIResponse", "api_response", "api-response"), + param( + "ApplicationController", + "ApplicationController", + "application_controller", + "application-controller", + ), + param( + "Area51Controller", + "Area51Controller", + "area51_controller", + "area51-controller", + ), + param("FreeBSD", "FreeBSD", "free_bsd", "free-bsd"), + param("HTML", "HTML", "html", "html"), + param("HTMLTidy", "HTMLTidy", "html_tidy", "html-tidy"), + param( + "HTMLTidyGenerator", + "HTMLTidyGenerator", + "html_tidy_generator", + "html-tidy-generator", + ), + param("HTMLVersion", "HTMLVersion", "html_version", "html-version"), + param("NoHTML", "NoHTML", "no_html", "no-html"), + param("One Two", "OneTwo", "one_two", "one-two"), + param("One Two", "OneTwo", "one_two", "one-two"), + param("One Two", "OneTwo", "one_two", "one-two"), + param("OneTwo", "OneTwo", "one_two", "one-two"), + param("One_Two", "OneTwo", "one_two", "one-two"), + param("One__Two", "OneTwo", "one_two", "one-two"), + param("One___Two", "OneTwo", "one_two", "one-two"), + param("Product", "Product", "product", "product"), + param("SpecialGuest", "SpecialGuest", "special_guest", "special-guest"), + param("Text", "Text", "text", "text"), + param("Text123", "Text123", "text123", "text123"), + param( + "Text123Text456", "Text123Text456", "text123_text456", "text123-text456" + ), + param("_APIResponse_", "APIResponse", "_api_response_", "-api-response-"), + param("_API_", "API", "_api_", "-api-"), + param("__APIResponse__", "APIResponse", "_api_response_", "-api-response-"), + param("__API__", "API", "_api_", "-api-"), + param( + "__impliedVolatility_", + "ImpliedVolatility", + "_implied_volatility_", + "-implied-volatility-", + ), + param("_itemID", "ItemID", "_item_id", "-item-id"), + param("_lastPrice__", "LastPrice", "_last_price_", "-last-price-"), + param("_symbol", "Symbol", "_symbol", "-symbol"), + param("aB", "AB", "a_b", "a-b"), + param("changePct", "ChangePct", "change_pct", "change-pct"), + param("changePct_", "ChangePct", "change_pct_", "change-pct-"), + param( + "impliedVolatility", + "ImpliedVolatility", + "implied_volatility", + "implied-volatility", + ), + param("lastPrice", "LastPrice", "last_price", "last-price"), + param("memMB", "MemMB", "mem_mb", "mem-mb"), + param("sizeX", "SizeX", "size_x", "size-x"), + param("symbol", "Symbol", "symbol", "symbol"), + param("testNTest", "TestNTest", "test_n_test", "test-n-test"), + param("text", "Text", "text", "text"), + param("text123", "Text123", "text123", "text123"), + ], + ) + def test_main( + self, *, text: str, exp_pascal: str, exp_snake: str, exp_kebab: str + ) -> None: + assert pascal_case(text) == exp_pascal + assert snake_case(text) == exp_snake + assert kebab_case(text) == exp_kebab diff --git a/src/tests/test_text.py b/src/tests/test_text.py index d158e1244..93fde4758 100644 --- a/src/tests/test_text.py +++ b/src/tests/test_text.py @@ -28,14 +28,11 @@ _SplitStrCountError, _SplitStrOpeningBracketUnmatchedError, join_strs, - kebab_case, parse_bool, parse_none, - pascal_case, prompt_bool, repr_encode, secret_str, - snake_case, split_f_str_equals, split_key_value_pairs, split_str, @@ -113,88 +110,6 @@ def test_main(self, *, n: int) -> None: assert result == expected -class TestPascalSnakeAndKebabCase: - @mark.parametrize( - ("text", "exp_pascal", "exp_snake", "exp_kebab"), - [ - param("API", "API", "api", "api"), - param("APIResponse", "APIResponse", "api_response", "api-response"), - param( - "ApplicationController", - "ApplicationController", - "application_controller", - "application-controller", - ), - param( - "Area51Controller", - "Area51Controller", - "area51_controller", - "area51-controller", - ), - param("FreeBSD", "FreeBSD", "free_bsd", "free-bsd"), - param("HTML", "HTML", "html", "html"), - param("HTMLTidy", "HTMLTidy", "html_tidy", "html-tidy"), - param( - "HTMLTidyGenerator", - "HTMLTidyGenerator", - "html_tidy_generator", - "html-tidy-generator", - ), - param("HTMLVersion", "HTMLVersion", "html_version", "html-version"), - param("NoHTML", "NoHTML", "no_html", "no-html"), - param("One Two", "OneTwo", "one_two", "one-two"), - param("One Two", "OneTwo", "one_two", "one-two"), - param("One Two", "OneTwo", "one_two", "one-two"), - param("OneTwo", "OneTwo", "one_two", "one-two"), - param("One_Two", "OneTwo", "one_two", "one-two"), - param("One__Two", "OneTwo", "one_two", "one-two"), - param("One___Two", "OneTwo", "one_two", "one-two"), - param("Product", "Product", "product", "product"), - param("SpecialGuest", "SpecialGuest", "special_guest", "special-guest"), - param("Text", "Text", "text", "text"), - param("Text123", "Text123", "text123", "text123"), - param( - "Text123Text456", "Text123Text456", "text123_text456", "text123-text456" - ), - param("_APIResponse_", "APIResponse", "_api_response_", "-api-response-"), - param("_API_", "API", "_api_", "-api-"), - param("__APIResponse__", "APIResponse", "_api_response_", "-api-response-"), - param("__API__", "API", "_api_", "-api-"), - param( - "__impliedVolatility_", - "ImpliedVolatility", - "_implied_volatility_", - "-implied-volatility-", - ), - param("_itemID", "ItemID", "_item_id", "-item-id"), - param("_lastPrice__", "LastPrice", "_last_price_", "-last-price-"), - param("_symbol", "Symbol", "_symbol", "-symbol"), - param("aB", "AB", "a_b", "a-b"), - param("changePct", "ChangePct", "change_pct", "change-pct"), - param("changePct_", "ChangePct", "change_pct_", "change-pct-"), - param( - "impliedVolatility", - "ImpliedVolatility", - "implied_volatility", - "implied-volatility", - ), - param("lastPrice", "LastPrice", "last_price", "last-price"), - param("memMB", "MemMB", "mem_mb", "mem-mb"), - param("sizeX", "SizeX", "size_x", "size-x"), - param("symbol", "Symbol", "symbol", "symbol"), - param("testNTest", "TestNTest", "test_n_test", "test-n-test"), - param("text", "Text", "text", "text"), - param("text123", "Text123", "text123", "text123"), - ], - ) - def test_main( - self, *, text: str, exp_pascal: str, exp_snake: str, exp_kebab: str - ) -> None: - assert pascal_case(text) == exp_pascal - assert snake_case(text) == exp_snake - assert kebab_case(text) == exp_kebab - - class TestPromptBool: def test_main(self) -> None: assert prompt_bool(confirm=True) diff --git a/src/utilities/core.py b/src/utilities/core.py index 7de4971ae..0158990d2 100644 --- a/src/utilities/core.py +++ b/src/utilities/core.py @@ -11,7 +11,7 @@ from itertools import chain, islice from os import chdir, environ, getenv from pathlib import Path -from re import findall +from re import VERBOSE, findall from tempfile import NamedTemporaryFile as _NamedTemporaryFile from types import ( BuiltinFunctionType, @@ -840,6 +840,52 @@ def yield_temp_file_at(path: PathLike, /) -> Iterator[Path]: #### text ##################################################################### ############################################################################### + +def kebab_case(text: str, /) -> str: + """Convert text into kebab case.""" + return _kebab_snake_case(text, "-") + + +def snake_case(text: str, /) -> str: + """Convert text into snake case.""" + return _kebab_snake_case(text, "_") + + +def _kebab_snake_case(text: str, separator: str, /) -> str: + """Convert text into kebab/snake case.""" + leading = _kebab_leading_pattern.search(text) is not None + trailing = _kebab_trailing_pattern.search(text) is not None + parts = _kebab_pascal_pattern.findall(text) + parts = (p for p in parts if len(p) >= 1) + parts = chain([""] if leading else [], parts, [""] if trailing else []) + return separator.join(parts).lower() + + +_kebab_leading_pattern = re.compile(r"^_") +_kebab_trailing_pattern = re.compile(r"_$") + + +def pascal_case(text: str, /) -> str: + """Convert text to pascal case.""" + parts = _kebab_pascal_pattern.findall(text) + parts = [p for p in parts if len(p) >= 1] + parts = list(map(_pascal_case_upper_or_title, parts)) + return "".join(parts) + + +def _pascal_case_upper_or_title(text: str, /) -> str: + return text if text.isupper() else text.title() + + +_kebab_pascal_pattern = re.compile( + r""" + [A-Z]+(?=[A-Z][a-z0-9]) | # all caps followed by Upper+lower or digit (API in APIResponse2) + [A-Z]?[a-z]+[0-9]* | # normal words with optional trailing digits (Text123) + [A-Z]+[0-9]* | # consecutive caps with optional trailing digits (ID2) + """, + flags=VERBOSE, +) + __all__ = [ "FileOrDirError", "GetEnvError", diff --git a/src/utilities/functions.py b/src/utilities/functions.py index 8d38986c2..c010f0f6d 100644 --- a/src/utilities/functions.py +++ b/src/utilities/functions.py @@ -463,12 +463,6 @@ def __str__(self) -> str: ## -## - - -## - - def in_milli_seconds(duration: Duration, /) -> float: """Convert a duration to milli-seconds.""" return 1e3 * in_seconds(duration) diff --git a/src/utilities/text.py b/src/utilities/text.py index ac10a6710..9b0d5462a 100644 --- a/src/utilities/text.py +++ b/src/utilities/text.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from itertools import chain from os import getpid -from re import IGNORECASE, VERBOSE, escape, search +from re import IGNORECASE, escape, search from textwrap import dedent from threading import get_ident from time import time_ns @@ -34,11 +34,6 @@ ## -def kebab_case(text: str, /) -> str: - """Convert text into kebab case.""" - return _kebab_snake_case(text, "-") - - ## @@ -82,21 +77,6 @@ def __str__(self) -> str: ## -def pascal_case(text: str, /) -> str: - """Convert text to pascal case.""" - parts = _SPLIT_TEXT.findall(text) - parts = [p for p in parts if len(p) >= 1] - parts = list(map(_pascal_case_one, parts)) - return "".join(parts) - - -def _pascal_case_one(text: str, /) -> str: - return text if text.isupper() else text.title() - - -## - - def prompt_bool(prompt: object = "", /, *, confirm: bool = False) -> bool: """Prompt for a boolean.""" return True if confirm else parse_bool(input(prompt)) @@ -113,14 +93,6 @@ def repr_encode(obj: Any, /) -> bytes: ## -def snake_case(text: str, /) -> str: - """Convert text into snake case.""" - return _kebab_snake_case(text, "_") - - -## - - def split_f_str_equals(text: str, /) -> tuple[str, str]: """Split an `f`-string with `=`.""" first, second = text.split(sep="=", maxsplit=1) @@ -511,25 +483,6 @@ def unique_str() -> str: ## -def _kebab_snake_case(text: str, separator: str, /) -> str: - """Convert text into kebab/snake case.""" - leading = bool(search(r"^_", text)) - trailing = bool(search(r"_$", text)) - parts = _SPLIT_TEXT.findall(text) - parts = (p for p in parts if len(p) >= 1) - parts = chain([""] if leading else [], parts, [""] if trailing else []) - return separator.join(parts).lower() - - -_SPLIT_TEXT = re.compile( - r""" - [A-Z]+(?=[A-Z][a-z0-9]) | # all caps followed by Upper+lower or digit (API in APIResponse2) - [A-Z]?[a-z]+[0-9]* | # normal words with optional trailing digits (Text123) - [A-Z]+[0-9]* | # consecutive caps with optional trailing digits (ID2) - """, - flags=VERBOSE, -) - __all__ = [ "ParseBoolError", "ParseNoneError", From 48a2c87c2cdadcf073c83c926199890bf137e998 Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 17:32:27 +0900 Subject: [PATCH 65/78] 2026-01-21 17:32:27 (Wed) > DW-Mac > derekwan --- src/tests/core/test_text.py | 8 +++++++- src/tests/test_subprocess.py | 4 ++-- src/tests/test_text.py | 16 ---------------- src/utilities/core.py | 21 +++++++++++++++++++-- src/utilities/text.py | 31 ------------------------------- 5 files changed, 28 insertions(+), 52 deletions(-) diff --git a/src/tests/core/test_text.py b/src/tests/core/test_text.py index 870a085f1..9a83bc0e6 100644 --- a/src/tests/core/test_text.py +++ b/src/tests/core/test_text.py @@ -2,7 +2,7 @@ from pytest import mark, param -from utilities.core import kebab_case, pascal_case, snake_case +from utilities.core import kebab_case, pascal_case, snake_case, unique_str class TestPascalSnakeAndKebabCase: @@ -85,3 +85,9 @@ def test_main( assert pascal_case(text) == exp_pascal assert snake_case(text) == exp_snake assert kebab_case(text) == exp_kebab + + +class TestUniqueStrs: + def test_main(self) -> None: + first, second = [unique_str() for _ in range(2)] + assert first != second diff --git a/src/tests/test_subprocess.py b/src/tests/test_subprocess.py index 2ab035d1d..1a0bcb145 100644 --- a/src/tests/test_subprocess.py +++ b/src/tests/test_subprocess.py @@ -18,7 +18,7 @@ PWD, SECOND, ) -from utilities.core import TemporaryDirectory, TemporaryFile +from utilities.core import TemporaryDirectory, TemporaryFile, unique_str from utilities.iterables import one from utilities.pathlib import get_file_group, get_file_owner from utilities.permissions import Permissions @@ -110,7 +110,7 @@ yield_git_repo, yield_ssh_temp_dir, ) -from utilities.text import strip_and_dedent, unique_str +from utilities.text import strip_and_dedent from utilities.typing import is_sequence_of from utilities.version import Version3 diff --git a/src/tests/test_text.py b/src/tests/test_text.py index 93fde4758..d4fd6879c 100644 --- a/src/tests/test_text.py +++ b/src/tests/test_text.py @@ -31,7 +31,6 @@ parse_bool, parse_none, prompt_bool, - repr_encode, secret_str, split_f_str_equals, split_key_value_pairs, @@ -40,7 +39,6 @@ strip_and_dedent, to_bool, to_str, - unique_str, ) if TYPE_CHECKING: @@ -102,14 +100,6 @@ def test_error(self, *, text: str) -> None: _ = parse_none(text) -class TestReprEncode: - @given(n=integers()) - def test_main(self, *, n: int) -> None: - result = repr_encode(n) - expected = repr(n).encode() - assert result == expected - - class TestPromptBool: def test_main(self) -> None: assert prompt_bool(confirm=True) @@ -335,9 +325,3 @@ def test_callable(self, *, text: str) -> None: @given(text=none() | sentinels()) def test_none_or_sentinel(self, *, text: None | Sentinel) -> None: assert to_str(text) is text - - -class TestUniqueStrs: - def test_main(self) -> None: - first, second = [unique_str() for _ in range(2)] - assert first != second diff --git a/src/utilities/core.py b/src/utilities/core.py index 0158990d2..93acef67c 100644 --- a/src/utilities/core.py +++ b/src/utilities/core.py @@ -4,15 +4,17 @@ import reprlib import shutil import tempfile -from collections.abc import Iterable, Iterator +from collections.abc import Callable, Iterable, Iterator from contextlib import contextmanager, suppress from dataclasses import dataclass from functools import _lru_cache_wrapper, partial from itertools import chain, islice -from os import chdir, environ, getenv +from os import chdir, environ, getenv, getpid from pathlib import Path from re import VERBOSE, findall from tempfile import NamedTemporaryFile as _NamedTemporaryFile +from threading import get_ident +from time import time_ns from types import ( BuiltinFunctionType, FunctionType, @@ -22,6 +24,7 @@ WrapperDescriptorType, ) from typing import TYPE_CHECKING, Any, Literal, assert_never, cast, overload, override +from uuid import uuid4 from warnings import catch_warnings, filterwarnings from typing_extensions import TypeIs @@ -886,6 +889,19 @@ def _pascal_case_upper_or_title(text: str, /) -> str: flags=VERBOSE, ) + +## + + +def unique_str() -> str: + """Generate at unique string.""" + now = time_ns() + pid = getpid() + ident = get_ident() + key = str(uuid4()).replace("-", "") + return f"{now}_{pid}_{ident}_{key}" + + __all__ = [ "FileOrDirError", "GetEnvError", @@ -919,6 +935,7 @@ def _pascal_case_upper_or_title(text: str, /) -> str: "take", "transpose", "unique_everseen", + "unique_str", "yield_temp_cwd", "yield_temp_dir_at", "yield_temp_environ", diff --git a/src/utilities/text.py b/src/utilities/text.py index 9b0d5462a..ecad6e365 100644 --- a/src/utilities/text.py +++ b/src/utilities/text.py @@ -5,11 +5,8 @@ from collections.abc import Callable from dataclasses import dataclass from itertools import chain -from os import getpid from re import IGNORECASE, escape, search from textwrap import dedent -from threading import get_ident -from time import time_ns from typing import ( TYPE_CHECKING, Any, @@ -19,7 +16,6 @@ overload, override, ) -from uuid import uuid4 from utilities.constants import BRACKETS, LIST_SEPARATOR, PAIR_SEPARATOR, Sentinel from utilities.core import repr_, transpose @@ -34,9 +30,6 @@ ## -## - - def parse_bool(text: str, /) -> bool: """Parse text into a boolean value.""" if search(r"^(0|False|N|No|Off)$", text, flags=IGNORECASE): @@ -85,14 +78,6 @@ def prompt_bool(prompt: object = "", /, *, confirm: bool = False) -> bool: ## -def repr_encode(obj: Any, /) -> bytes: - """Return the representation of the object encoded as bytes.""" - return repr(obj).encode() - - -## - - def split_f_str_equals(text: str, /) -> tuple[str, str]: """Split an `f`-string with `=`.""" first, second = text.split(sep="=", maxsplit=1) @@ -471,32 +456,16 @@ def to_str(text: MaybeCallableStr | None | Sentinel, /) -> str | None | Sentinel ## -def unique_str() -> str: - """Generate at unique string.""" - now = time_ns() - pid = getpid() - ident = get_ident() - key = str(uuid4()).replace("-", "") - return f"{now}_{pid}_{ident}_{key}" - - -## - - __all__ = [ "ParseBoolError", "ParseNoneError", "SplitKeyValuePairsError", "SplitStrError", "join_strs", - "kebab_case", "parse_bool", "parse_none", - "pascal_case", "prompt_bool", - "repr_encode", "secret_str", - "snake_case", "split_f_str_equals", "split_key_value_pairs", "split_str", From daa250db5d392e36b0d0310d9db743309aae4cd4 Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 17:36:58 +0900 Subject: [PATCH 66/78] 2026-01-21 17:36:58 (Wed) > DW-Mac > derekwan --- src/tests/core/test_text.py | 13 +++++++++++++ src/tests/test_text.py | 13 ------------- src/utilities/core.py | 9 +++++++++ src/utilities/text.py | 7 ------- 4 files changed, 22 insertions(+), 20 deletions(-) diff --git a/src/tests/core/test_text.py b/src/tests/core/test_text.py index 9a83bc0e6..8fe23d013 100644 --- a/src/tests/core/test_text.py +++ b/src/tests/core/test_text.py @@ -3,6 +3,7 @@ from pytest import mark, param from utilities.core import kebab_case, pascal_case, snake_case, unique_str +from utilities.text import strip_and_dedent class TestPascalSnakeAndKebabCase: @@ -87,6 +88,18 @@ def test_main( assert kebab_case(text) == exp_kebab +class TestStripAndDedent: + def test_main(self) -> None: + result = strip_and_dedent( + """ + This is line 1. + This is line 2. + """ + ) + expected = "This is line 1.\nThis is line 2.\n" + assert result == expected + + class TestUniqueStrs: def test_main(self) -> None: first, second = [unique_str() for _ in range(2)] diff --git a/src/tests/test_text.py b/src/tests/test_text.py index d4fd6879c..2b450b3d7 100644 --- a/src/tests/test_text.py +++ b/src/tests/test_text.py @@ -36,7 +36,6 @@ split_key_value_pairs, split_str, str_encode, - strip_and_dedent, to_bool, to_str, ) @@ -287,18 +286,6 @@ def test_main(self, *, n: int) -> None: assert result == expected -class TestStripAndDedent: - @mark.parametrize("trailing", [param(True), param(False)]) - def test_main(self, *, trailing: bool) -> None: - text = """ - This is line 1. - This is line 2. - """ - result = strip_and_dedent(text, trailing=trailing) - expected = "This is line 1.\nThis is line 2." + ("\n" if trailing else "") - assert result == expected - - class TestToBool: @given(bool_=booleans() | none() | sentinels()) def test_bool_none_or_sentinel(self, *, bool_: bool | None | Sentinel) -> None: diff --git a/src/utilities/core.py b/src/utilities/core.py index 93acef67c..540438a06 100644 --- a/src/utilities/core.py +++ b/src/utilities/core.py @@ -13,6 +13,7 @@ from pathlib import Path from re import VERBOSE, findall from tempfile import NamedTemporaryFile as _NamedTemporaryFile +from textwrap import dedent from threading import get_ident from time import time_ns from types import ( @@ -893,6 +894,14 @@ def _pascal_case_upper_or_title(text: str, /) -> str: ## +def strip_and_dedent(text: str, /) -> str: + """Strip and dedent a string.""" + return dedent(text.strip("\n")).strip("\n") + "\n" + + +## + + def unique_str() -> str: """Generate at unique string.""" now = time_ns() diff --git a/src/utilities/text.py b/src/utilities/text.py index ecad6e365..813a20a89 100644 --- a/src/utilities/text.py +++ b/src/utilities/text.py @@ -6,7 +6,6 @@ from dataclasses import dataclass from itertools import chain from re import IGNORECASE, escape, search -from textwrap import dedent from typing import ( TYPE_CHECKING, Any, @@ -403,12 +402,6 @@ def str_encode(obj: Any, /) -> bytes: ## -def strip_and_dedent(text: str, /, *, trailing: bool = False) -> str: - """Strip and dedent a string.""" - result = dedent(text.strip("\n")).strip("\n") - return f"{result}\n" if trailing else result - - ## From 445f1120264e0f116bb53775011e2dcd5503438e Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 17:37:01 +0900 Subject: [PATCH 67/78] 2026-01-21 17:37:01 (Wed) > DW-Mac > derekwan --- src/utilities/core.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/utilities/core.py b/src/utilities/core.py index 540438a06..fe417633b 100644 --- a/src/utilities/core.py +++ b/src/utilities/core.py @@ -940,6 +940,7 @@ def unique_str() -> str: "one_str", "repr_", "repr_str", + "strip_and_dedent", "suppress_super_attribute_error", "take", "transpose", From 4644baa0328ab8315fbf00c3aa62b4c580f591c1 Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 17:39:29 +0900 Subject: [PATCH 68/78] 2026-01-21 17:39:29 (Wed) > DW-Mac > derekwan --- src/tests/core/test_text.py | 26 ++++++++++++++------------ src/tests/test_click.py | 4 ++-- src/tests/test_functions.py | 4 ++-- src/tests/test_jinja2.py | 16 ++++++++-------- src/tests/test_more_itertools.py | 4 ++-- src/tests/test_sqlalchemy.py | 4 ++-- src/tests/test_string.py | 6 +++--- src/tests/test_subprocess.py | 30 +++++++++++++++--------------- src/tests/test_tabulate.py | 8 ++++---- src/utilities/core.py | 6 +++--- src/utilities/subprocess.py | 4 ++-- src/utilities/text.py | 5 +---- 12 files changed, 58 insertions(+), 59 deletions(-) diff --git a/src/tests/core/test_text.py b/src/tests/core/test_text.py index 8fe23d013..be86c64a6 100644 --- a/src/tests/core/test_text.py +++ b/src/tests/core/test_text.py @@ -2,8 +2,7 @@ from pytest import mark, param -from utilities.core import kebab_case, pascal_case, snake_case, unique_str -from utilities.text import strip_and_dedent +from utilities.core import kebab_case, pascal_case, snake_case, strip_dedent, unique_str class TestPascalSnakeAndKebabCase: @@ -88,16 +87,19 @@ def test_main( assert kebab_case(text) == exp_kebab -class TestStripAndDedent: - def test_main(self) -> None: - result = strip_and_dedent( - """ - This is line 1. - This is line 2. - """ - ) - expected = "This is line 1.\nThis is line 2.\n" - assert result == expected +class TestStripDedent: + @mark.parametrize( + "text", + [ + param("text"), + param("\ntext"), + param("text\n"), + param("\ntext\n"), + param("\n\ntext\n\n"), + ], + ) + def test_main(self, *, text: str) -> None: + assert strip_dedent(text) == "text\n" class TestUniqueStrs: diff --git a/src/tests/test_click.py b/src/tests/test_click.py index f8e4fa0a5..deb4c7110 100644 --- a/src/tests/test_click.py +++ b/src/tests/test_click.py @@ -66,7 +66,7 @@ year_months, zoned_date_times, ) -from utilities.text import join_strs, strip_and_dedent +from utilities.text import join_strs, strip_dedent if TYPE_CHECKING: from collections.abc import Callable, Iterable @@ -559,5 +559,5 @@ def cli(*, value: Any) -> None: result = CliRunner().invoke(cli, ["--help"]) assert result.exit_code == 0 - expected = strip_and_dedent(expected, trailing=True) + expected = strip_dedent(expected, trailing=True) assert result.stdout == expected diff --git a/src/tests/test_functions.py b/src/tests/test_functions.py index 3c8dda550..f7e8f9118 100644 --- a/src/tests/test_functions.py +++ b/src/tests/test_functions.py @@ -50,7 +50,7 @@ yield_object_cached_properties, yield_object_properties, ) -from utilities.text import parse_bool, strip_and_dedent +from utilities.text import parse_bool, strip_dedent from utilities.whenever import get_now, get_today if TYPE_CHECKING: @@ -397,7 +397,7 @@ def return_x() -> bool: class TestSkipIfOptimize: @mark.parametrize("optimize", [param(True), param(False)]) def test_main(self, *, optimize: bool) -> None: - code = strip_and_dedent(""" + code = strip_dedent(""" from utilities.functions import skip_if_optimize is_run = False diff --git a/src/tests/test_jinja2.py b/src/tests/test_jinja2.py index 375845f0e..09df91a77 100644 --- a/src/tests/test_jinja2.py +++ b/src/tests/test_jinja2.py @@ -11,7 +11,7 @@ _TemplateJobTargetDoesNotExistError, _TemplateJobTemplateDoesNotExistError, ) -from utilities.text import strip_and_dedent +from utilities.text import strip_dedent if TYPE_CHECKING: from pathlib import Path @@ -21,7 +21,7 @@ class TestEnhancedTemplate: def test_main(self) -> None: env = EnhancedEnvironment( loader=DictLoader({ - "test.j2": strip_and_dedent(""" + "test.j2": strip_dedent(""" text = '{{ text }}' kebab = '{{ text | kebab }}' pascal = '{{ text | pascal }}' @@ -30,7 +30,7 @@ def test_main(self) -> None: }) ) result = env.get_template("test.j2").render(text="multi-word string") - expected = strip_and_dedent(""" + expected = strip_dedent(""" text = 'multi-word string' kebab = 'multi-word-string' pascal = 'MultiWordString' @@ -43,7 +43,7 @@ class TestTemplateJob: def test_main(self, *, tmp_path: Path) -> None: path_template = tmp_path.joinpath("template.j2") _ = path_template.write_text( - strip_and_dedent(""" + strip_dedent(""" text = '{{ text }}' """) ) @@ -51,7 +51,7 @@ def test_main(self, *, tmp_path: Path) -> None: job = TemplateJob( template=path_template, kwargs={"text": "example text"}, target=path_target ) - expected = strip_and_dedent(""" + expected = strip_dedent(""" text = 'example text' """) assert job.rendered == expected @@ -63,7 +63,7 @@ def test_main(self, *, tmp_path: Path) -> None: def test_append(self, *, tmp_path: Path) -> None: path_template = tmp_path.joinpath("template.j2") _ = path_template.write_text( - strip_and_dedent( + strip_dedent( """ new = '{{ text }}' """, @@ -72,7 +72,7 @@ def test_append(self, *, tmp_path: Path) -> None: ) path_target = tmp_path.joinpath("target.txt") _ = path_target.write_text( - strip_and_dedent( + strip_dedent( """ old = 'old text' """, @@ -87,7 +87,7 @@ def test_append(self, *, tmp_path: Path) -> None: ) job.run() assert path_target.exists() - assert path_target.read_text() == strip_and_dedent(""" + assert path_target.read_text() == strip_dedent(""" old = 'old text' new = 'new text' """) diff --git a/src/tests/test_more_itertools.py b/src/tests/test_more_itertools.py index 9a0159047..a3b14da78 100644 --- a/src/tests/test_more_itertools.py +++ b/src/tests/test_more_itertools.py @@ -15,7 +15,7 @@ peekable, yield_splits, ) -from utilities.text import strip_and_dedent +from utilities.text import strip_dedent if TYPE_CHECKING: from collections.abc import Iterable @@ -341,7 +341,7 @@ def test_main( def test_repr(self) -> None: split = Split(head=["a", "b", "c"], tail=["d"]) result = repr(split) - expected = strip_and_dedent( + expected = strip_dedent( """ Split( head= diff --git a/src/tests/test_sqlalchemy.py b/src/tests/test_sqlalchemy.py index 0d3feb86c..d8beb1d29 100644 --- a/src/tests/test_sqlalchemy.py +++ b/src/tests/test_sqlalchemy.py @@ -89,7 +89,7 @@ selectable_to_string, yield_primary_key_columns, ) -from utilities.text import strip_and_dedent +from utilities.text import strip_dedent from utilities.typing import get_args from utilities.whenever import format_compact, get_now_local_plain @@ -1399,7 +1399,7 @@ async def test_main(self, *, test_async_engine: AsyncEngine) -> None: ) sel = select(table).where(table.c.value >= 1) result = selectable_to_string(sel, test_async_engine) - expected = strip_and_dedent( + expected = strip_dedent( """ SELECT example.id_, example.value * FROM example * diff --git a/src/tests/test_string.py b/src/tests/test_string.py index c926075e9..3e196dba4 100644 --- a/src/tests/test_string.py +++ b/src/tests/test_string.py @@ -6,14 +6,14 @@ from utilities.core import yield_temp_environ from utilities.string import SubstituteError, substitute -from utilities.text import strip_and_dedent, unique_str +from utilities.text import strip_dedent, unique_str if TYPE_CHECKING: from pathlib import Path class TestSubstitute: - template: ClassVar[str] = strip_and_dedent(""" + template: ClassVar[str] = strip_dedent(""" This is a template string with: - key = '$TEMPLATE_KEY' - value = '$TEMPLATE_VALUE' @@ -52,7 +52,7 @@ def test_error(self) -> None: _ = substitute(self.template) def _assert_equal(self, text: str, key: str, value: str) -> None: - expected = strip_and_dedent(f""" + expected = strip_dedent(f""" This is a template string with: - key = {key!r} - value = {value!r} diff --git a/src/tests/test_subprocess.py b/src/tests/test_subprocess.py index 1a0bcb145..45ba68892 100644 --- a/src/tests/test_subprocess.py +++ b/src/tests/test_subprocess.py @@ -110,7 +110,7 @@ yield_git_repo, yield_ssh_temp_dir, ) -from utilities.text import strip_and_dedent +from utilities.text import strip_dedent from utilities.typing import is_sequence_of from utilities.version import Version3 @@ -714,7 +714,7 @@ def test_dir_without_trailing_slash( ssh_user, ssh_hostname, *BASH_LS, - input=strip_and_dedent(f""" + input=strip_dedent(f""" if ! [ -d {dest} ]; then exit 1; fi if ! [ -d {dest}/{tmp_path.name} ]; then exit 1; fi if ! [ -f {dest}/{tmp_path.name}/{temp_file.name} ]; then exit 1; fi @@ -732,7 +732,7 @@ def test_dir_with_trailing_slash( ssh_user, ssh_hostname, *BASH_LS, - input=strip_and_dedent(f""" + input=strip_dedent(f""" if ! [ -d {dest} ]; then exit 1; fi if ! [ -f {dest}/{temp_file.name} ]; then exit 1; fi """), @@ -941,7 +941,7 @@ def test_multiple_files( ssh_user, ssh_hostname, *BASH_LS, - input=strip_and_dedent(f""" + input=strip_dedent(f""" if ! [ -f {dest1} ]; then exit 1; fi if ! [ -f {dest2} ]; then exit 1; fi """), @@ -1107,7 +1107,7 @@ def test_env(self, *, capsys: CaptureFixture) -> None: assert cap.err == "" def test_input_bash(self, *, capsys: CaptureFixture) -> None: - input_ = strip_and_dedent(""" + input_ = strip_dedent(""" key=value echo ${key}@stdout echo ${key}@stderr 1>&2 @@ -1119,7 +1119,7 @@ def test_input_bash(self, *, capsys: CaptureFixture) -> None: assert cap.err == "value@stderr\n" def test_input_cat(self, *, capsys: CaptureFixture) -> None: - input_ = strip_and_dedent(""" + input_ = strip_dedent(""" foo bar baz @@ -1131,7 +1131,7 @@ def test_input_cat(self, *, capsys: CaptureFixture) -> None: assert cap.err == "" def test_input_and_return(self, *, capsys: CaptureFixture) -> None: - input_ = strip_and_dedent(""" + input_ = strip_dedent(""" foo bar baz @@ -1336,7 +1336,7 @@ def test_logger(self, *, caplog: LogCaptureFixture) -> None: with raises(CalledProcessError): _ = run("echo stdout; echo stderr 1>&2; exit 1", shell=True, logger=name) # noqa: S604 record = one(r for r in caplog.records if r.name == name) - expected = strip_and_dedent(""" + expected = strip_dedent(""" 'run' failed with: - cmd = echo stdout; echo stderr 1>&2; exit 1 - cmds_or_args = () @@ -1359,7 +1359,7 @@ def test_logger(self, *, caplog: LogCaptureFixture) -> None: def test_logger_and_input(self, *, caplog: LogCaptureFixture) -> None: name = unique_str() - input_ = strip_and_dedent( + input_ = strip_dedent( """ key=value echo ${key}@stdout @@ -1371,7 +1371,7 @@ def test_logger_and_input(self, *, caplog: LogCaptureFixture) -> None: with raises(CalledProcessError): _ = run(*BASH_LS, input=input_, logger=name) record = one(r for r in caplog.records if r.name == name) - expected = strip_and_dedent(""" + expected = strip_dedent(""" 'run' failed with: - cmd = bash - cmds_or_args = ('-ls',) @@ -1397,7 +1397,7 @@ def test_logger_and_input(self, *, caplog: LogCaptureFixture) -> None: assert record.message == expected def _test_retry_cmd(self, path: PathLike, attempts: int, /) -> str: - return strip_and_dedent( + return strip_dedent( f""" count=$(ls -1A "{path}" 2>/dev/null | wc -l) if [ "${{count}}" -lt {attempts} ]; then @@ -1516,13 +1516,13 @@ class TestSSHIsStrictCheckingError: "text", [ param( - strip_and_dedent(""" + strip_dedent(""" No ED25519 host key is known for XXX and you have requested strict checking. Host key verification failed. """) ), param( - strip_and_dedent(""" + strip_dedent(""" @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ @ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ @@ -1780,7 +1780,7 @@ def test_main(self) -> None: class TestUvPipListLoadsOutput: def test_main(self) -> None: - text = strip_and_dedent(""" + text = strip_dedent(""" [{"name":"name","version":"0.0.1"}] """) result = _uv_pip_list_loads(text) @@ -1788,7 +1788,7 @@ def test_main(self) -> None: assert result == expected def test_error(self) -> None: - text = strip_and_dedent(""" + text = strip_dedent(""" [{"name":"name","version":"0.0.1"}] # warning: The package `name` requires `dep>=1.2.3`, but `1.2.2` is installed """) diff --git a/src/tests/test_tabulate.py b/src/tests/test_tabulate.py index 6521063e7..5d142f95e 100644 --- a/src/tests/test_tabulate.py +++ b/src/tests/test_tabulate.py @@ -1,7 +1,7 @@ from __future__ import annotations from utilities.tabulate import func_param_desc, params_table -from utilities.text import strip_and_dedent +from utilities.text import strip_dedent class TestFuncParamDesc: @@ -12,7 +12,7 @@ def test_empty(self) -> None: def func() -> None: ... result = func_param_desc(func, "0.0.1", f"{x=}", f"{y=}") - expected = strip_and_dedent(""" + expected = strip_dedent(""" Running 'func' (version 0.0.1) with: ╭───┬───╮ │ x │ 1 │ @@ -25,7 +25,7 @@ def test_main(self) -> None: x = 1 y = 2 result = params_table(f"{x=}", f"{y=}") - expected = strip_and_dedent(""" + expected = strip_dedent(""" ╭───┬───╮ │ x │ 1 │ │ y │ 2 │ @@ -44,7 +44,7 @@ def test_main(self) -> None: x = 1 y = 2 result = params_table(f"{x=}", f"{y=}") - expected = strip_and_dedent(""" + expected = strip_dedent(""" ╭───┬───╮ │ x │ 1 │ │ y │ 2 │ diff --git a/src/utilities/core.py b/src/utilities/core.py index fe417633b..80620b58e 100644 --- a/src/utilities/core.py +++ b/src/utilities/core.py @@ -894,7 +894,7 @@ def _pascal_case_upper_or_title(text: str, /) -> str: ## -def strip_and_dedent(text: str, /) -> str: +def strip_dedent(text: str, /) -> str: """Strip and dedent a string.""" return dedent(text.strip("\n")).strip("\n") + "\n" @@ -903,7 +903,7 @@ def strip_and_dedent(text: str, /) -> str: def unique_str() -> str: - """Generate at unique string.""" + """Generate a unique string.""" now = time_ns() pid = getpid() ident = get_ident() @@ -940,7 +940,7 @@ def unique_str() -> str: "one_str", "repr_", "repr_str", - "strip_and_dedent", + "strip_dedent", "suppress_super_attribute_error", "take", "transpose", diff --git a/src/utilities/subprocess.py b/src/utilities/subprocess.py index 6ed369d91..b1ba4f390 100644 --- a/src/utilities/subprocess.py +++ b/src/utilities/subprocess.py @@ -35,7 +35,7 @@ from utilities.functions import in_timedelta from utilities.logging import to_logger from utilities.permissions import Permissions, ensure_perms -from utilities.text import strip_and_dedent +from utilities.text import strip_dedent from utilities.time import sleep from utilities.version import ( ParseVersion2Or3Error, @@ -1234,7 +1234,7 @@ def run( else: attempts, duration = retry if logger is not None: - msg = strip_and_dedent(f""" + msg = strip_dedent(f""" 'run' failed with: - cmd = {cmd} - cmds_or_args = {cmds_or_args} diff --git a/src/utilities/text.py b/src/utilities/text.py index 813a20a89..17292a361 100644 --- a/src/utilities/text.py +++ b/src/utilities/text.py @@ -402,9 +402,6 @@ def str_encode(obj: Any, /) -> bytes: ## -## - - @overload def to_bool(bool_: MaybeCallableBoolLike, /) -> bool: ... @overload @@ -463,7 +460,7 @@ def to_str(text: MaybeCallableStr | None | Sentinel, /) -> str | None | Sentinel "split_key_value_pairs", "split_str", "str_encode", - "strip_and_dedent", + "strip_dedent", "to_bool", "to_str", "unique_str", From 936cdb4c9281830fd10be85dc5513a2adf4e8166 Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 17:39:49 +0900 Subject: [PATCH 69/78] 2026-01-21 17:39:49 (Wed) > DW-Mac > derekwan --- src/tests/test_click.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/tests/test_click.py b/src/tests/test_click.py index deb4c7110..fc5043425 100644 --- a/src/tests/test_click.py +++ b/src/tests/test_click.py @@ -53,6 +53,7 @@ YearMonth, ZonedDateTime, ) +from utilities.core import strip_dedent from utilities.hypothesis import ( date_deltas, date_time_deltas, @@ -66,7 +67,7 @@ year_months, zoned_date_times, ) -from utilities.text import join_strs, strip_dedent +from utilities.text import join_strs if TYPE_CHECKING: from collections.abc import Callable, Iterable From 15ae7b5b03bdb1c85c0b1dbf215155bdedc93265 Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 17:40:13 +0900 Subject: [PATCH 70/78] 2026-01-21 17:40:13 (Wed) > DW-Mac > derekwan --- src/tests/test_contextvars.py | 2 +- src/tests/test_docker.py | 2 +- src/tests/test_logging.py | 2 +- src/tests/test_platform.py | 2 +- src/tests/test_pottery.py | 2 +- src/tests/test_redis.py | 3 +-- 6 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/tests/test_contextvars.py b/src/tests/test_contextvars.py index 6b6dd0e6b..6436ab86c 100644 --- a/src/tests/test_contextvars.py +++ b/src/tests/test_contextvars.py @@ -8,7 +8,7 @@ set_global_breakpoint, yield_set_context, ) -from utilities.text import unique_str +from utilities.core import unique_str class TestGlobalBreakpoint: diff --git a/src/tests/test_docker.py b/src/tests/test_docker.py index a4915e213..5790f319d 100644 --- a/src/tests/test_docker.py +++ b/src/tests/test_docker.py @@ -9,6 +9,7 @@ from pytest import CaptureFixture, LogCaptureFixture, mark, param, raises from utilities.constants import MINUTE +from utilities.core import unique_str from utilities.docker import ( _docker_compose_cmd, docker_compose_down_cmd, @@ -23,7 +24,6 @@ from utilities.iterables import one from utilities.pytest import skipif_ci, throttle_test from utilities.subprocess import BASH_LS, touch_cmd -from utilities.text import unique_str if TYPE_CHECKING: from pathlib import Path diff --git a/src/tests/test_logging.py b/src/tests/test_logging.py index 51354d0b5..8d63dd61a 100644 --- a/src/tests/test_logging.py +++ b/src/tests/test_logging.py @@ -12,6 +12,7 @@ from pytest import LogCaptureFixture, mark, param, raises from utilities.constants import SECOND +from utilities.core import unique_str from utilities.hypothesis import pairs, temp_paths, zoned_date_times from utilities.iterables import one from utilities.logging import ( @@ -29,7 +30,6 @@ setup_logging, to_logger, ) -from utilities.text import unique_str from utilities.time import sleep from utilities.types import LogLevel from utilities.typing import get_args diff --git a/src/tests/test_platform.py b/src/tests/test_platform.py index 84acfae23..91c00e099 100644 --- a/src/tests/test_platform.py +++ b/src/tests/test_platform.py @@ -5,9 +5,9 @@ from hypothesis import assume, given +from utilities.core import unique_str from utilities.hypothesis import text_clean from utilities.platform import get_strftime, maybe_lower_case -from utilities.text import unique_str if TYPE_CHECKING: from pathlib import Path diff --git a/src/tests/test_pottery.py b/src/tests/test_pottery.py index e23f4bc26..30936c3c6 100644 --- a/src/tests/test_pottery.py +++ b/src/tests/test_pottery.py @@ -8,13 +8,13 @@ from utilities.asyncio import sleep from utilities.constants import MILLISECOND, SECOND +from utilities.core import unique_str from utilities.pottery import ( _YieldAccessNumLocksError, _YieldAccessUnableToAcquireLockError, extend_lock, yield_access, ) -from utilities.text import unique_str from utilities.timer import Timer if TYPE_CHECKING: diff --git a/src/tests/test_redis.py b/src/tests/test_redis.py index 216335aba..0be6c4ccd 100644 --- a/src/tests/test_redis.py +++ b/src/tests/test_redis.py @@ -23,7 +23,7 @@ from tests.test_objects.objects import objects from utilities.asyncio import get_items_nowait, sleep from utilities.constants import _SENTINEL_REPR, MICROSECOND, SECOND, Sentinel, sentinel -from utilities.core import get_class_name, identity, one +from utilities.core import get_class_name, identity, one, unique_str from utilities.hypothesis import int64s, pairs, text_ascii from utilities.operator import is_equal from utilities.orjson import deserialize, serialize @@ -42,7 +42,6 @@ yield_pubsub, yield_redis, ) -from utilities.text import unique_str if TYPE_CHECKING: from collections.abc import Mapping, Sequence From 2093c91c667bb88c577c6371ff42932e18689a83 Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 17:40:46 +0900 Subject: [PATCH 71/78] 2026-01-21 17:40:46 (Wed) > DW-Mac > derekwan --- src/tests/test_functions.py | 3 ++- src/utilities/jinja2.py | 2 +- src/utilities/subprocess.py | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/tests/test_functions.py b/src/tests/test_functions.py index f7e8f9118..027cbbb0b 100644 --- a/src/tests/test_functions.py +++ b/src/tests/test_functions.py @@ -11,6 +11,7 @@ from pytest import approx, mark, param, raises from utilities.constants import HOME, MILLISECOND, NOW_UTC, SECOND, ZERO_TIME, sentinel +from utilities.core import strip_dedent from utilities.functions import ( EnsureBoolError, EnsureBytesError, @@ -50,7 +51,7 @@ yield_object_cached_properties, yield_object_properties, ) -from utilities.text import parse_bool, strip_dedent +from utilities.text import parse_bool from utilities.whenever import get_now, get_today if TYPE_CHECKING: diff --git a/src/utilities/jinja2.py b/src/utilities/jinja2.py index fc5cbf8b5..3e7a79ce5 100644 --- a/src/utilities/jinja2.py +++ b/src/utilities/jinja2.py @@ -20,7 +20,7 @@ ) from utilities.atomicwrites import writer -from utilities.text import kebab_case, pascal_case, snake_case +from utilities.core import kebab_case, pascal_case, snake_case if TYPE_CHECKING: from collections.abc import Callable, Sequence diff --git a/src/utilities/subprocess.py b/src/utilities/subprocess.py index b1ba4f390..6a9ddee0d 100644 --- a/src/utilities/subprocess.py +++ b/src/utilities/subprocess.py @@ -30,12 +30,12 @@ always_iterable, file_or_dir, one, + strip_dedent, ) from utilities.errors import ImpossibleCaseError from utilities.functions import in_timedelta from utilities.logging import to_logger from utilities.permissions import Permissions, ensure_perms -from utilities.text import strip_dedent from utilities.time import sleep from utilities.version import ( ParseVersion2Or3Error, From 859650e62e55f80ea7eda4574e6a138e69fc596f Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 17:41:06 +0900 Subject: [PATCH 72/78] 2026-01-21 17:41:06 (Wed) > DW-Mac > derekwan --- src/tests/test_jinja2.py | 2 +- src/tests/test_more_itertools.py | 2 +- src/tests/test_sqlalchemy.py | 2 +- src/tests/test_string.py | 3 +-- src/tests/test_subprocess.py | 3 +-- src/tests/test_tabulate.py | 2 +- 6 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/tests/test_jinja2.py b/src/tests/test_jinja2.py index 09df91a77..6269a445c 100644 --- a/src/tests/test_jinja2.py +++ b/src/tests/test_jinja2.py @@ -5,13 +5,13 @@ from jinja2 import DictLoader from pytest import raises +from utilities.core import strip_dedent from utilities.jinja2 import ( EnhancedEnvironment, TemplateJob, _TemplateJobTargetDoesNotExistError, _TemplateJobTemplateDoesNotExistError, ) -from utilities.text import strip_dedent if TYPE_CHECKING: from pathlib import Path diff --git a/src/tests/test_more_itertools.py b/src/tests/test_more_itertools.py index a3b14da78..1cc87788e 100644 --- a/src/tests/test_more_itertools.py +++ b/src/tests/test_more_itertools.py @@ -6,6 +6,7 @@ from pytest import mark, param, raises +from utilities.core import strip_dedent from utilities.more_itertools import ( BucketMappingError, Split, @@ -15,7 +16,6 @@ peekable, yield_splits, ) -from utilities.text import strip_dedent if TYPE_CHECKING: from collections.abc import Iterable diff --git a/src/tests/test_sqlalchemy.py b/src/tests/test_sqlalchemy.py index d8beb1d29..5d2bebffa 100644 --- a/src/tests/test_sqlalchemy.py +++ b/src/tests/test_sqlalchemy.py @@ -30,6 +30,7 @@ ) from utilities.constants import MILLISECOND +from utilities.core import strip_dedent from utilities.hypothesis import int32s, pairs, quadruples, urls from utilities.iterables import one from utilities.modules import is_installed @@ -89,7 +90,6 @@ selectable_to_string, yield_primary_key_columns, ) -from utilities.text import strip_dedent from utilities.typing import get_args from utilities.whenever import format_compact, get_now_local_plain diff --git a/src/tests/test_string.py b/src/tests/test_string.py index 3e196dba4..161884db4 100644 --- a/src/tests/test_string.py +++ b/src/tests/test_string.py @@ -4,9 +4,8 @@ from pytest import raises -from utilities.core import yield_temp_environ +from utilities.core import strip_dedent, unique_str, yield_temp_environ from utilities.string import SubstituteError, substitute -from utilities.text import strip_dedent, unique_str if TYPE_CHECKING: from pathlib import Path diff --git a/src/tests/test_subprocess.py b/src/tests/test_subprocess.py index 45ba68892..68d2d048d 100644 --- a/src/tests/test_subprocess.py +++ b/src/tests/test_subprocess.py @@ -18,7 +18,7 @@ PWD, SECOND, ) -from utilities.core import TemporaryDirectory, TemporaryFile, unique_str +from utilities.core import TemporaryDirectory, TemporaryFile, strip_dedent, unique_str from utilities.iterables import one from utilities.pathlib import get_file_group, get_file_owner from utilities.permissions import Permissions @@ -110,7 +110,6 @@ yield_git_repo, yield_ssh_temp_dir, ) -from utilities.text import strip_dedent from utilities.typing import is_sequence_of from utilities.version import Version3 diff --git a/src/tests/test_tabulate.py b/src/tests/test_tabulate.py index 5d142f95e..8a58a6d9e 100644 --- a/src/tests/test_tabulate.py +++ b/src/tests/test_tabulate.py @@ -1,7 +1,7 @@ from __future__ import annotations +from utilities.core import strip_dedent from utilities.tabulate import func_param_desc, params_table -from utilities.text import strip_dedent class TestFuncParamDesc: From aa2f677f5a87e9f0cca1d915fd4251c2a954fce8 Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 17:41:27 +0900 Subject: [PATCH 73/78] 2026-01-21 17:41:27 (Wed) > DW-Mac > derekwan --- src/utilities/sqlalchemy.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/utilities/sqlalchemy.py b/src/utilities/sqlalchemy.py index 65f417b3a..da0419873 100644 --- a/src/utilities/sqlalchemy.py +++ b/src/utilities/sqlalchemy.py @@ -72,6 +72,7 @@ chunked, get_class_name, repr_, + snake_case, ) from utilities.functions import ensure_str, yield_object_attributes from utilities.iterables import ( @@ -84,7 +85,7 @@ one, ) from utilities.os import is_pytest -from utilities.text import secret_str, snake_case +from utilities.text import secret_str from utilities.types import ( Duration, MaybeIterable, From 617802abf839e713c5e88dcda62f0dd9ba08e0b2 Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 17:41:31 +0900 Subject: [PATCH 74/78] 2026-01-21 17:41:31 (Wed) > DW-Mac > derekwan --- src/utilities/sqlalchemy_polars.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/utilities/sqlalchemy_polars.py b/src/utilities/sqlalchemy_polars.py index 9b526e4c9..3d5bf5315 100644 --- a/src/utilities/sqlalchemy_polars.py +++ b/src/utilities/sqlalchemy_polars.py @@ -28,7 +28,7 @@ import utilities.asyncio from utilities.constants import UTC -from utilities.core import OneError, chunked, identity, one, repr_ +from utilities.core import OneError, chunked, identity, one, repr_, snake_case from utilities.iterables import CheckDuplicatesError, check_duplicates from utilities.polars import zoned_date_time_dtype from utilities.sqlalchemy import ( @@ -39,7 +39,6 @@ get_columns, insert_items, ) -from utilities.text import snake_case from utilities.typing import is_subclass_gen if TYPE_CHECKING: From 39ef97b46ee0f59c988cca2e2530c2cb0f109098 Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 17:41:44 +0900 Subject: [PATCH 75/78] 2026-01-21 17:41:44 (Wed) > DW-Mac > derekwan --- src/utilities/testbook.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utilities/testbook.py b/src/utilities/testbook.py index 50688acf7..52556adab 100644 --- a/src/utilities/testbook.py +++ b/src/utilities/testbook.py @@ -5,8 +5,8 @@ from testbook import testbook +from utilities.core import pascal_case from utilities.pytest import throttle_test -from utilities.text import pascal_case if TYPE_CHECKING: from collections.abc import Callable From 9555a2f68c517941c46bd6b4d4320e96199f95fe Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 18:20:11 +0900 Subject: [PATCH 76/78] 2026-01-21 18:20:11 (Wed) > DW-Mac > derekwan --- src/tests/core/test_text.py | 75 +++++++++++++++++++++++++------- src/tests/test_click.py | 4 +- src/tests/test_functions.py | 4 +- src/tests/test_jinja2.py | 26 +++++------ src/tests/test_more_itertools.py | 4 +- src/tests/test_sqlalchemy.py | 4 +- src/tests/test_string.py | 6 +-- src/tests/test_subprocess.py | 35 ++++++++------- src/tests/test_tabulate.py | 8 ++-- src/utilities/core.py | 25 +++++++++-- src/utilities/subprocess.py | 4 +- 11 files changed, 128 insertions(+), 67 deletions(-) diff --git a/src/tests/core/test_text.py b/src/tests/core/test_text.py index be86c64a6..1c45c6b52 100644 --- a/src/tests/core/test_text.py +++ b/src/tests/core/test_text.py @@ -2,7 +2,65 @@ from pytest import mark, param -from utilities.core import kebab_case, pascal_case, snake_case, strip_dedent, unique_str +from utilities.core import ( + kebab_case, + normalize_multi_line_str, + normalize_str, + pascal_case, + snake_case, + unique_str, +) + + +class TestNormalizeMultiLineStr: + @mark.parametrize( + ("text", "expected"), + [ + param( + """ + text + """, + "text\n", + ), + param( + """ + text + """, + "text\n", + ), + param( + """ + text1 + text2 + """, + "text1\ntext2\n", + ), + param( + """ + text1 + text2 + """, + "text1\ntext2\n", + ), + ], + ) + def test_main(self, *, text: str, expected: str) -> None: + assert normalize_multi_line_str(text) == expected + + +class TestNormalizeStr: + @mark.parametrize( + "text", + [ + param("text"), + param("\ntext"), + param("text\n"), + param("\ntext\n"), + param("\n\ntext\n\n"), + ], + ) + def test_main(self, *, text: str) -> None: + assert normalize_str(text) == "text\n" class TestPascalSnakeAndKebabCase: @@ -87,21 +145,6 @@ def test_main( assert kebab_case(text) == exp_kebab -class TestStripDedent: - @mark.parametrize( - "text", - [ - param("text"), - param("\ntext"), - param("text\n"), - param("\ntext\n"), - param("\n\ntext\n\n"), - ], - ) - def test_main(self, *, text: str) -> None: - assert strip_dedent(text) == "text\n" - - class TestUniqueStrs: def test_main(self) -> None: first, second = [unique_str() for _ in range(2)] diff --git a/src/tests/test_click.py b/src/tests/test_click.py index fc5043425..1fa625b5f 100644 --- a/src/tests/test_click.py +++ b/src/tests/test_click.py @@ -53,7 +53,7 @@ YearMonth, ZonedDateTime, ) -from utilities.core import strip_dedent +from utilities.core import normalize_multi_line_str from utilities.hypothesis import ( date_deltas, date_time_deltas, @@ -560,5 +560,5 @@ def cli(*, value: Any) -> None: result = CliRunner().invoke(cli, ["--help"]) assert result.exit_code == 0 - expected = strip_dedent(expected, trailing=True) + expected = normalize_multi_line_str(expected, trailing=True) assert result.stdout == expected diff --git a/src/tests/test_functions.py b/src/tests/test_functions.py index 027cbbb0b..f8f01114e 100644 --- a/src/tests/test_functions.py +++ b/src/tests/test_functions.py @@ -11,7 +11,7 @@ from pytest import approx, mark, param, raises from utilities.constants import HOME, MILLISECOND, NOW_UTC, SECOND, ZERO_TIME, sentinel -from utilities.core import strip_dedent +from utilities.core import normalize_multi_line_str from utilities.functions import ( EnsureBoolError, EnsureBytesError, @@ -398,7 +398,7 @@ def return_x() -> bool: class TestSkipIfOptimize: @mark.parametrize("optimize", [param(True), param(False)]) def test_main(self, *, optimize: bool) -> None: - code = strip_dedent(""" + code = normalize_multi_line_str(""" from utilities.functions import skip_if_optimize is_run = False diff --git a/src/tests/test_jinja2.py b/src/tests/test_jinja2.py index 6269a445c..ee1063ff4 100644 --- a/src/tests/test_jinja2.py +++ b/src/tests/test_jinja2.py @@ -5,7 +5,7 @@ from jinja2 import DictLoader from pytest import raises -from utilities.core import strip_dedent +from utilities.core import normalize_multi_line_str from utilities.jinja2 import ( EnhancedEnvironment, TemplateJob, @@ -21,7 +21,7 @@ class TestEnhancedTemplate: def test_main(self) -> None: env = EnhancedEnvironment( loader=DictLoader({ - "test.j2": strip_dedent(""" + "test.j2": normalize_multi_line_str(""" text = '{{ text }}' kebab = '{{ text | kebab }}' pascal = '{{ text | pascal }}' @@ -30,7 +30,7 @@ def test_main(self) -> None: }) ) result = env.get_template("test.j2").render(text="multi-word string") - expected = strip_dedent(""" + expected = normalize_multi_line_str(""" text = 'multi-word string' kebab = 'multi-word-string' pascal = 'MultiWordString' @@ -43,7 +43,7 @@ class TestTemplateJob: def test_main(self, *, tmp_path: Path) -> None: path_template = tmp_path.joinpath("template.j2") _ = path_template.write_text( - strip_dedent(""" + normalize_multi_line_str(""" text = '{{ text }}' """) ) @@ -51,7 +51,7 @@ def test_main(self, *, tmp_path: Path) -> None: job = TemplateJob( template=path_template, kwargs={"text": "example text"}, target=path_target ) - expected = strip_dedent(""" + expected = normalize_multi_line_str(""" text = 'example text' """) assert job.rendered == expected @@ -63,21 +63,15 @@ def test_main(self, *, tmp_path: Path) -> None: def test_append(self, *, tmp_path: Path) -> None: path_template = tmp_path.joinpath("template.j2") _ = path_template.write_text( - strip_dedent( - """ + normalize_multi_line_str(""" new = '{{ text }}' - """, - trailing=True, - ) + """) ) path_target = tmp_path.joinpath("target.txt") _ = path_target.write_text( - strip_dedent( - """ + normalize_multi_line_str(""" old = 'old text' - """, - trailing=True, - ) + """) ) job = TemplateJob( template=path_template, @@ -87,7 +81,7 @@ def test_append(self, *, tmp_path: Path) -> None: ) job.run() assert path_target.exists() - assert path_target.read_text() == strip_dedent(""" + assert path_target.read_text() == normalize_multi_line_str(""" old = 'old text' new = 'new text' """) diff --git a/src/tests/test_more_itertools.py b/src/tests/test_more_itertools.py index 1cc87788e..697c806b6 100644 --- a/src/tests/test_more_itertools.py +++ b/src/tests/test_more_itertools.py @@ -6,7 +6,7 @@ from pytest import mark, param, raises -from utilities.core import strip_dedent +from utilities.core import normalize_multi_line_str from utilities.more_itertools import ( BucketMappingError, Split, @@ -341,7 +341,7 @@ def test_main( def test_repr(self) -> None: split = Split(head=["a", "b", "c"], tail=["d"]) result = repr(split) - expected = strip_dedent( + expected = normalize_multi_line_str( """ Split( head= diff --git a/src/tests/test_sqlalchemy.py b/src/tests/test_sqlalchemy.py index 5d2bebffa..309bf5316 100644 --- a/src/tests/test_sqlalchemy.py +++ b/src/tests/test_sqlalchemy.py @@ -30,7 +30,7 @@ ) from utilities.constants import MILLISECOND -from utilities.core import strip_dedent +from utilities.core import normalize_multi_line_str from utilities.hypothesis import int32s, pairs, quadruples, urls from utilities.iterables import one from utilities.modules import is_installed @@ -1399,7 +1399,7 @@ async def test_main(self, *, test_async_engine: AsyncEngine) -> None: ) sel = select(table).where(table.c.value >= 1) result = selectable_to_string(sel, test_async_engine) - expected = strip_dedent( + expected = normalize_multi_line_str( """ SELECT example.id_, example.value * FROM example * diff --git a/src/tests/test_string.py b/src/tests/test_string.py index 161884db4..667dfb79d 100644 --- a/src/tests/test_string.py +++ b/src/tests/test_string.py @@ -4,7 +4,7 @@ from pytest import raises -from utilities.core import strip_dedent, unique_str, yield_temp_environ +from utilities.core import normalize_multi_line_str, unique_str, yield_temp_environ from utilities.string import SubstituteError, substitute if TYPE_CHECKING: @@ -12,7 +12,7 @@ class TestSubstitute: - template: ClassVar[str] = strip_dedent(""" + template: ClassVar[str] = normalize_multi_line_str(""" This is a template string with: - key = '$TEMPLATE_KEY' - value = '$TEMPLATE_VALUE' @@ -51,7 +51,7 @@ def test_error(self) -> None: _ = substitute(self.template) def _assert_equal(self, text: str, key: str, value: str) -> None: - expected = strip_dedent(f""" + expected = normalize_multi_line_str(f""" This is a template string with: - key = {key!r} - value = {value!r} diff --git a/src/tests/test_subprocess.py b/src/tests/test_subprocess.py index 68d2d048d..cfc28e6e9 100644 --- a/src/tests/test_subprocess.py +++ b/src/tests/test_subprocess.py @@ -18,7 +18,12 @@ PWD, SECOND, ) -from utilities.core import TemporaryDirectory, TemporaryFile, strip_dedent, unique_str +from utilities.core import ( + TemporaryDirectory, + TemporaryFile, + normalize_multi_line_str, + unique_str, +) from utilities.iterables import one from utilities.pathlib import get_file_group, get_file_owner from utilities.permissions import Permissions @@ -713,7 +718,7 @@ def test_dir_without_trailing_slash( ssh_user, ssh_hostname, *BASH_LS, - input=strip_dedent(f""" + input=normalize_multi_line_str(f""" if ! [ -d {dest} ]; then exit 1; fi if ! [ -d {dest}/{tmp_path.name} ]; then exit 1; fi if ! [ -f {dest}/{tmp_path.name}/{temp_file.name} ]; then exit 1; fi @@ -731,7 +736,7 @@ def test_dir_with_trailing_slash( ssh_user, ssh_hostname, *BASH_LS, - input=strip_dedent(f""" + input=normalize_multi_line_str(f""" if ! [ -d {dest} ]; then exit 1; fi if ! [ -f {dest}/{temp_file.name} ]; then exit 1; fi """), @@ -940,7 +945,7 @@ def test_multiple_files( ssh_user, ssh_hostname, *BASH_LS, - input=strip_dedent(f""" + input=normalize_multi_line_str(f""" if ! [ -f {dest1} ]; then exit 1; fi if ! [ -f {dest2} ]; then exit 1; fi """), @@ -1106,7 +1111,7 @@ def test_env(self, *, capsys: CaptureFixture) -> None: assert cap.err == "" def test_input_bash(self, *, capsys: CaptureFixture) -> None: - input_ = strip_dedent(""" + input_ = normalize_multi_line_str(""" key=value echo ${key}@stdout echo ${key}@stderr 1>&2 @@ -1118,7 +1123,7 @@ def test_input_bash(self, *, capsys: CaptureFixture) -> None: assert cap.err == "value@stderr\n" def test_input_cat(self, *, capsys: CaptureFixture) -> None: - input_ = strip_dedent(""" + input_ = normalize_multi_line_str(""" foo bar baz @@ -1130,7 +1135,7 @@ def test_input_cat(self, *, capsys: CaptureFixture) -> None: assert cap.err == "" def test_input_and_return(self, *, capsys: CaptureFixture) -> None: - input_ = strip_dedent(""" + input_ = normalize_multi_line_str(""" foo bar baz @@ -1335,7 +1340,7 @@ def test_logger(self, *, caplog: LogCaptureFixture) -> None: with raises(CalledProcessError): _ = run("echo stdout; echo stderr 1>&2; exit 1", shell=True, logger=name) # noqa: S604 record = one(r for r in caplog.records if r.name == name) - expected = strip_dedent(""" + expected = normalize_multi_line_str(""" 'run' failed with: - cmd = echo stdout; echo stderr 1>&2; exit 1 - cmds_or_args = () @@ -1358,7 +1363,7 @@ def test_logger(self, *, caplog: LogCaptureFixture) -> None: def test_logger_and_input(self, *, caplog: LogCaptureFixture) -> None: name = unique_str() - input_ = strip_dedent( + input_ = normalize_multi_line_str( """ key=value echo ${key}@stdout @@ -1370,7 +1375,7 @@ def test_logger_and_input(self, *, caplog: LogCaptureFixture) -> None: with raises(CalledProcessError): _ = run(*BASH_LS, input=input_, logger=name) record = one(r for r in caplog.records if r.name == name) - expected = strip_dedent(""" + expected = normalize_multi_line_str(""" 'run' failed with: - cmd = bash - cmds_or_args = ('-ls',) @@ -1396,7 +1401,7 @@ def test_logger_and_input(self, *, caplog: LogCaptureFixture) -> None: assert record.message == expected def _test_retry_cmd(self, path: PathLike, attempts: int, /) -> str: - return strip_dedent( + return normalize_multi_line_str( f""" count=$(ls -1A "{path}" 2>/dev/null | wc -l) if [ "${{count}}" -lt {attempts} ]; then @@ -1515,13 +1520,13 @@ class TestSSHIsStrictCheckingError: "text", [ param( - strip_dedent(""" + normalize_multi_line_str(""" No ED25519 host key is known for XXX and you have requested strict checking. Host key verification failed. """) ), param( - strip_dedent(""" + normalize_multi_line_str(""" @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ @ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ @@ -1779,7 +1784,7 @@ def test_main(self) -> None: class TestUvPipListLoadsOutput: def test_main(self) -> None: - text = strip_dedent(""" + text = normalize_multi_line_str(""" [{"name":"name","version":"0.0.1"}] """) result = _uv_pip_list_loads(text) @@ -1787,7 +1792,7 @@ def test_main(self) -> None: assert result == expected def test_error(self) -> None: - text = strip_dedent(""" + text = normalize_multi_line_str(""" [{"name":"name","version":"0.0.1"}] # warning: The package `name` requires `dep>=1.2.3`, but `1.2.2` is installed """) diff --git a/src/tests/test_tabulate.py b/src/tests/test_tabulate.py index 8a58a6d9e..c26ea9e3d 100644 --- a/src/tests/test_tabulate.py +++ b/src/tests/test_tabulate.py @@ -1,6 +1,6 @@ from __future__ import annotations -from utilities.core import strip_dedent +from utilities.core import normalize_multi_line_str from utilities.tabulate import func_param_desc, params_table @@ -12,7 +12,7 @@ def test_empty(self) -> None: def func() -> None: ... result = func_param_desc(func, "0.0.1", f"{x=}", f"{y=}") - expected = strip_dedent(""" + expected = normalize_multi_line_str(""" Running 'func' (version 0.0.1) with: ╭───┬───╮ │ x │ 1 │ @@ -25,7 +25,7 @@ def test_main(self) -> None: x = 1 y = 2 result = params_table(f"{x=}", f"{y=}") - expected = strip_dedent(""" + expected = normalize_multi_line_str(""" ╭───┬───╮ │ x │ 1 │ │ y │ 2 │ @@ -44,7 +44,7 @@ def test_main(self) -> None: x = 1 y = 2 result = params_table(f"{x=}", f"{y=}") - expected = strip_dedent(""" + expected = normalize_multi_line_str(""" ╭───┬───╮ │ x │ 1 │ │ y │ 2 │ diff --git a/src/utilities/core.py b/src/utilities/core.py index 80620b58e..012d41602 100644 --- a/src/utilities/core.py +++ b/src/utilities/core.py @@ -894,11 +894,16 @@ def _pascal_case_upper_or_title(text: str, /) -> str: ## -def strip_dedent(text: str, /) -> str: - """Strip and dedent a string.""" +def normalize_multi_line_str(text: str, /) -> str: + """Normalize a multi-line string.""" return dedent(text.strip("\n")).strip("\n") + "\n" +def normalize_str(text: str, /) -> str: + """Normalize a string.""" + return text.strip("\n") + "\n" + + ## @@ -911,6 +916,17 @@ def unique_str() -> str: return f"{now}_{pid}_{ident}_{key}" +############################################################################### +#### writers ################################################################## +############################################################################### + + +def write_text(path: PathLike, text: str, /) -> None: + """Write text to a file.""" + with writer(path, overwrite=overwrite) as temp: + _ = temp.write_text(normalize_str(text)) + + __all__ = [ "FileOrDirError", "GetEnvError", @@ -936,16 +952,19 @@ def unique_str() -> str: "is_sentinel", "max_nullable", "min_nullable", + "normalize_multi_line_str", + "normalize_str", "one", "one_str", "repr_", "repr_str", - "strip_dedent", "suppress_super_attribute_error", "take", "transpose", "unique_everseen", "unique_str", + "write_bytes", + "write_text", "yield_temp_cwd", "yield_temp_dir_at", "yield_temp_environ", diff --git a/src/utilities/subprocess.py b/src/utilities/subprocess.py index 6a9ddee0d..833b57969 100644 --- a/src/utilities/subprocess.py +++ b/src/utilities/subprocess.py @@ -29,8 +29,8 @@ TemporaryDirectory, always_iterable, file_or_dir, + normalize_multi_line_str, one, - strip_dedent, ) from utilities.errors import ImpossibleCaseError from utilities.functions import in_timedelta @@ -1234,7 +1234,7 @@ def run( else: attempts, duration = retry if logger is not None: - msg = strip_dedent(f""" + msg = normalize_multi_line_str(f""" 'run' failed with: - cmd = {cmd} - cmds_or_args = {cmds_or_args} From 55b61efa7f442fba9493e378859c9b817496e4ee Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 20:38:12 +0900 Subject: [PATCH 77/78] 2026-01-21 20:38:12 (Wed) > DW-Mac > derekwan --- src/utilities/core.py | 98 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 94 insertions(+), 4 deletions(-) diff --git a/src/utilities/core.py b/src/utilities/core.py index 012d41602..2bb0da62f 100644 --- a/src/utilities/core.py +++ b/src/utilities/core.py @@ -12,6 +12,7 @@ from os import chdir, environ, getenv, getpid from pathlib import Path from re import VERBOSE, findall +from shutil import rmtree from tempfile import NamedTemporaryFile as _NamedTemporaryFile from textwrap import dedent from threading import get_ident @@ -472,6 +473,95 @@ def unique_everseen[T]( ############################################################################### +def copy(src: PathLike, dest: PathLike, /, *, overwrite: bool = False) -> None: + """Copy a file atomically.""" + src, dest = map(Path, [src, dest]) + _copy_or_move(src, dest, mode="copy", overwrite=overwrite) + + +def move(src: PathLike, dest: PathLike, /, *, overwrite: bool = False) -> None: + """Move a file atomically.""" + src, dest = map(Path, [src, dest]) + _copy_or_move(src, dest, mode="move", overwrite=overwrite) + + +def _copy_or_move( + src: Path, dest: Path, /, *, mode: Literal["copy", "move"], overwrite: bool = False +) -> None: + match file_or_dir(src), file_or_dir(dest), mode, overwrite: + case None, _, _, _: + raise _CopyOrMoveSourceNotFoundError(src=src) + case "file" | "dir", "file" | "dir", _, False: + raise _CopyOrMoveDestinationExistsError(src=src, dest=dest) + case ("file", None, "move", _) | ("file", "file", "move", True): + _copy_or_move__move_file(src, dest) + case ("file", None, "copy", _) | ("file", "file", "copy", True): + _copy_or_move__copy_file(src, dest) + case "file", "dir", "move", True: + rmtree(dest, ignore_errors=True) + _copy_or_move__move_file(src, dest) + case "file", "dir", "copy", True: + rmtree(dest, ignore_errors=True) + _copy_or_move__copy_file(src, dest) + case ("dir", None, "move", _) | ("dir", "dir", "move", True): + _copy_or_move__move_dir(src, dest) + case ("dir", None, "copy", _) | ("dir", "dir", "copy", True): + _copy_or_move__copy_dir(src, dest) + case "dir", "file", "move", True: + dest.unlink(missing_ok=True) + _copy_or_move__move_dir(src, dest) + case "dir", "file", "copy", True: + dest.unlink(missing_ok=True) + _copy_or_move__copy_dir(src, dest) + case never: + assert_never(never) + + +def _copy_or_move__move_file(src: Path, dest: Path, /) -> None: + p + + +## + + +@enhanced_context_manager +def writer( + path: PathLike, /, *, compress: bool = False, overwrite: bool = False +) -> Iterator[Path]: + """Yield a path for atomically writing files to disk.""" + path = Path(path) + parent = path.parent + parent.mkdir(parents=True, exist_ok=True) + name = path.name + with TemporaryDirectory(suffix=".tmp", prefix=name, dir=parent) as temp_dir: + temp_path1 = Path(temp_dir, name) + try: + yield temp_path1 + except KeyboardInterrupt: + rmtree(temp_dir) + else: + if compress: + temp_path2 = Path(temp_dir, f"{name}.gz") + with ( + temp_path1.open("rb") as source, + gzip.open(temp_path2, mode="wb") as dest, + ): + copyfileobj(source, dest) + else: + temp_path2 = temp_path1 + try: + move(temp_path2, path, overwrite=overwrite) + except _MoveSourceNotFoundError as error: + raise _WriterTemporaryPathEmptyError(temp_path=error.src) from None + except _MoveFileExistsError as error: + raise _WriterFileExistsError(destination=error.dest) from None + except _MoveDirectoryExistsError as error: + raise _WriterDirectoryExistsError(destination=error.dest) from None + + +## + + @overload def get_env( key: str, /, *, case_sensitive: bool = False, default: str, nullable: bool = False @@ -789,7 +879,7 @@ def _temporary_file_outer( text: str | None = None, ) -> Iterator[Path]: with _temporary_file_inner( - path, suffix=suffix, prefix=prefix, delete=delete, name=name + Path(path), suffix=suffix, prefix=prefix, delete=delete, name=name ) as temp: if data is not None: _ = temp.write_bytes(data) @@ -800,7 +890,7 @@ def _temporary_file_outer( @contextmanager def _temporary_file_inner( - path: PathLike, + path: Path, /, *, suffix: str | None = None, @@ -808,7 +898,6 @@ def _temporary_file_inner( delete: bool = True, name: str | None = None, ) -> Iterator[Path]: - path = Path(path) with _NamedTemporaryFile( suffix=suffix, prefix=prefix, dir=path, delete=delete, delete_on_close=False ) as temp: @@ -896,7 +985,8 @@ def _pascal_case_upper_or_title(text: str, /) -> str: def normalize_multi_line_str(text: str, /) -> str: """Normalize a multi-line string.""" - return dedent(text.strip("\n")).strip("\n") + "\n" + stripped = text.strip("\n") + return normalize_str(dedent(stripped)) def normalize_str(text: str, /) -> str: From 3bd44f19b9147544b9ce18933f30aadce9504898 Mon Sep 17 00:00:00 2001 From: github-actions-bot Date: Wed, 21 Jan 2026 20:38:31 +0900 Subject: [PATCH 78/78] 2026-01-21 20:38:31 (Wed) > DW-Mac > derekwan --- uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/uv.lock b/uv.lock index 3781c8a2c..b47bbdc85 100644 --- a/uv.lock +++ b/uv.lock @@ -3423,11 +3423,11 @@ wheels = [ [[package]] name = "setuptools" -version = "80.9.0" +version = "80.10.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" } +sdist = { url = "https://files.pythonhosted.org/packages/86/ff/f75651350db3cf2ef767371307eb163f3cc1ac03e16fdf3ac347607f7edb/setuptools-80.10.1.tar.gz", hash = "sha256:bf2e513eb8144c3298a3bd28ab1a5edb739131ec5c22e045ff93cd7f5319703a", size = 1229650, upload-time = "2026-01-21T09:42:03.061Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" }, + { url = "https://files.pythonhosted.org/packages/e0/76/f963c61683a39084aa575f98089253e1e852a4417cb8a3a8a422923a5246/setuptools-80.10.1-py3-none-any.whl", hash = "sha256:fc30c51cbcb8199a219c12cc9c281b5925a4978d212f84229c909636d9f6984e", size = 1099859, upload-time = "2026-01-21T09:42:00.688Z" }, ] [[package]]