Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
451 changes: 275 additions & 176 deletions README.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions _python_utils_tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Test suite for python_utils."""
12 changes: 12 additions & 0 deletions _python_utils_tests/test_aio.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"""Tests for the async helpers in ``python_utils.aio``."""

import asyncio

import pytest
Expand All @@ -7,9 +9,11 @@

@pytest.mark.asyncio
async def test_acount(monkeypatch: pytest.MonkeyPatch) -> None:
"""Count with a delay between yields until reaching ``stop``."""
sleeps: types.List[float] = []

async def mock_sleep(delay: float) -> None:
"""Record each requested delay instead of sleeping."""
sleeps.append(delay)

monkeypatch.setattr(asyncio, 'sleep', mock_sleep)
Expand All @@ -23,12 +27,16 @@ async def mock_sleep(delay: float) -> None:

@pytest.mark.asyncio
async def test_acontainer() -> None:
"""Collect an async iterable into the requested container."""

async def async_gen() -> types.AsyncIterable[int]:
"""Yield 1, 2, 3 asynchronously."""
yield 1
yield 2
yield 3

async def empty_gen() -> types.AsyncIterable[int]:
"""Yield nothing as an async generator."""
if False:
yield 1

Expand All @@ -52,12 +60,16 @@ async def empty_gen() -> types.AsyncIterable[int]:

@pytest.mark.asyncio
async def test_adict() -> None:
"""Build a dict from an async iterable of key/value pairs."""

async def async_gen() -> types.AsyncIterable[types.Tuple[int, int]]:
"""Yield key/value pairs asynchronously."""
yield 1, 2
yield 3, 4
yield 5, 6

async def empty_gen() -> types.AsyncIterable[types.Tuple[int, int]]:
"""Yield no pairs as an async generator."""
if False:
yield 1, 2

Expand Down
4 changes: 4 additions & 0 deletions _python_utils_tests/test_aliases.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@


def test_aliases_do_not_import_typing_extensions() -> None:
"""Importing ``_aliases`` must not pull in typing_extensions."""
code = (
'import sys, python_utils._aliases\n'
"assert 'typing_extensions' not in sys.modules\n"
Expand All @@ -22,6 +23,7 @@ def test_aliases_do_not_import_typing_extensions() -> None:


def test_aliases_values() -> None:
"""Check the alias values and the ``__all__`` contents."""
from python_utils import _aliases

assert _aliases.Number == (int | float)
Expand All @@ -42,13 +44,15 @@ def test_aliases_values() -> None:


def test_types_reexports_aliases_identically() -> None:
"""Re-export every ``_aliases`` name identically via ``types``."""
from python_utils import _aliases, types

for name in _aliases.__all__:
assert getattr(types, name) is getattr(_aliases, name), name


def test_types_still_exposes_typing_extensions_surface() -> None:
"""The ``types`` facade still re-exports ``Self``."""
# The facade must keep re-exporting typing_extensions (e.g. Self).
# ``hasattr`` (not ``types.Self``) avoids basedpyright's
# reportUnknownMemberType, since the wildcard re-export has no static type.
Expand Down
7 changes: 7 additions & 0 deletions _python_utils_tests/test_containers.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
"""Tests for the container types in ``python_utils.containers``."""

import pytest

from python_utils import containers


def test_unique_list_ignore() -> None:
"""Ignore duplicate appends and block duplicate slice sets."""
a: containers.UniqueList[int] = containers.UniqueList()
a.append(1)
a.append(1)
Expand All @@ -17,6 +20,7 @@ def test_unique_list_ignore() -> None:


def test_unique_list_raise() -> None:
"""Raise on duplicates when ``on_duplicate='raise'``."""
a: containers.UniqueList[int] = containers.UniqueList(
*range(20), on_duplicate='raise'
)
Expand All @@ -32,6 +36,7 @@ def test_unique_list_raise() -> None:


def test_sliceable_deque() -> None:
"""Support indexing and extended slicing on the deque."""
d: containers.SliceableDeque[int] = containers.SliceableDeque(range(10))
assert d[0] == 0
assert d[-1] == 9
Expand All @@ -49,6 +54,7 @@ def test_sliceable_deque() -> None:


def test_sliceable_deque_pop() -> None:
"""Pop by index and raise ``IndexError`` when out of range."""
d: containers.SliceableDeque[int] = containers.SliceableDeque(range(10))

assert d.pop() == 9 == 9
Expand All @@ -65,6 +71,7 @@ def test_sliceable_deque_pop() -> None:


def test_sliceable_deque_eq() -> None:
"""Compare equal to list, tuple, set, and another deque."""
d: containers.SliceableDeque[int] = containers.SliceableDeque([1, 2, 3])
assert d == [1, 2, 3]
assert d == (1, 2, 3)
Expand Down
11 changes: 11 additions & 0 deletions _python_utils_tests/test_decorators.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"""Tests for the decorators in ``python_utils.decorators``."""

import typing
from unittest import mock

Expand All @@ -10,6 +12,7 @@

@pytest.fixture
def random(monkeypatch: pytest.MonkeyPatch) -> mock.MagicMock:
"""Patch ``decorators.random.random`` and return the mock."""
random_mock = mock.MagicMock()
monkeypatch.setattr(
'python_utils.decorators.random.random', random_mock, raising=True
Expand All @@ -18,6 +21,7 @@ def random(monkeypatch: pytest.MonkeyPatch) -> mock.MagicMock:


def test_sample_called(random: mock.MagicMock) -> None:
"""Call the wrapped function when the sampled roll passes."""
demo_function = mock.MagicMock()
decorated = decorators.sample(0.5)(demo_function)
random.return_value = 0.4
Expand All @@ -32,6 +36,7 @@ def test_sample_called(random: mock.MagicMock) -> None:


def test_sample_not_called(random: mock.MagicMock) -> None:
"""Skip the wrapped function when the sampled roll fails."""
demo_function = mock.MagicMock()
decorated = decorators.sample(0.5)(demo_function)
random.return_value = 0.5
Expand All @@ -42,16 +47,21 @@ def test_sample_not_called(random: mock.MagicMock) -> None:


class SomeClass:
"""A sample class with classmethods for wrapping tests."""

@classmethod
def some_classmethod(cls, arg: T) -> T:
"""Return the argument unchanged (generic classmethod)."""
return arg

@classmethod
def some_annotated_classmethod(cls, arg: int) -> int:
"""Return the integer argument unchanged."""
return arg


def test_wraps_classmethod() -> None:
"""Forward calls through a wrapped classmethod."""
some_class = SomeClass()
some_class.some_classmethod = mock.MagicMock() # type: ignore[method-assign]
wrapped_method = decorators.wraps_classmethod(SomeClass.some_classmethod)(
Expand All @@ -62,6 +72,7 @@ def test_wraps_classmethod() -> None:


def test_wraps_annotated_classmethod() -> None:
"""Forward calls through a wrapped annotated classmethod."""
some_class = SomeClass()
some_class.some_annotated_classmethod = mock.MagicMock() # type: ignore[method-assign]
wrapped_method = decorators.wraps_classmethod(
Expand Down
8 changes: 8 additions & 0 deletions _python_utils_tests/test_generators.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"""Tests for the batching helpers in ``python_utils.generators``."""

import asyncio

import pytest
Expand All @@ -8,6 +10,7 @@

@pytest.mark.asyncio
async def test_abatcher() -> None:
"""Group an async count into fixed-size batches."""
async for batch in python_utils.abatcher(python_utils.acount(stop=9), 3):
assert len(batch) == 3

Expand All @@ -17,6 +20,7 @@ async def test_abatcher() -> None:

@pytest.mark.asyncio
async def test_abatcher_timed() -> None:
"""Group async items into batches by time interval."""
batches: types.List[types.List[int]] = []
async for batch in python_utils.abatcher(
python_utils.acount(stop=10, delay=0.08), interval=0.1
Expand All @@ -29,7 +33,10 @@ async def test_abatcher_timed() -> None:

@pytest.mark.asyncio
async def test_abatcher_timed_with_timeout() -> None:
"""Respect timeouts and propagate errors while batching."""

async def generator() -> types.AsyncIterator[int]:
"""Yield items with sleeps to exercise batch timeouts."""
# Test if the timeout is respected
yield 0
yield 1
Expand Down Expand Up @@ -58,6 +65,7 @@ async def generator() -> types.AsyncIterator[int]:


def test_batcher() -> None:
"""Split an iterable into fixed-size batches."""
batch = []
for batch in python_utils.batcher(range(9), 3):
assert len(batch) == 3
Expand Down
9 changes: 9 additions & 0 deletions _python_utils_tests/test_import.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,24 @@
"""Tests for the import helpers in ``python_utils.import_``."""

from python_utils import import_, types


def test_import_globals_relative_import() -> None:
"""Resolve relative imports across several levels."""
for i in range(-1, 5):
relative_import(i)


def relative_import(level: int) -> None:
"""Import ``.formatters`` relatively into a fake module dict."""
locals_: types.Dict[str, types.Any] = {}
globals_ = {'__name__': 'python_utils.import_'}
import_.import_global('.formatters', locals_=locals_, globals_=globals_)
assert 'camel_to_underscore' in globals_


def test_import_globals_without_inspection() -> None:
"""Import a module without inspecting the caller frame."""
locals_: types.Dict[str, types.Any] = {}
globals_: types.Dict[str, types.Any] = {'__name__': __name__}
import_.import_global(
Expand All @@ -23,6 +28,7 @@ def test_import_globals_without_inspection() -> None:


def test_import_globals_single_method() -> None:
"""Import only the single named attribute."""
locals_: types.Dict[str, types.Any] = {}
globals_: types.Dict[str, types.Any] = {'__name__': __name__}
import_.import_global(
Expand All @@ -35,18 +41,21 @@ def test_import_globals_single_method() -> None:


def test_import_globals_with_inspection() -> None:
"""Infer the target globals from the caller frame."""
import_.import_global('python_utils.formatters')
assert 'camel_to_underscore' in globals()


def test_import_globals_missing_module() -> None:
"""Ignore ``ImportError`` for a missing module (locals_)."""
import_.import_global(
'python_utils.spam', exceptions=ImportError, locals_=locals()
)
assert 'camel_to_underscore' in globals()


def test_import_locals_missing_module() -> None:
"""Ignore ``ImportError`` for a missing module (globals_)."""
import_.import_global(
'python_utils.spam', exceptions=ImportError, globals_=globals()
)
Expand Down
6 changes: 6 additions & 0 deletions _python_utils_tests/test_import_footprint.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@


def _modules_after_import(target: str) -> set[str]:
"""Return ``sys.modules`` after importing ``target`` cleanly."""
code = (
f'import sys, {target}\n'
'import json\n'
Expand All @@ -49,12 +50,14 @@ def _modules_after_import(target: str) -> set[str]:

@pytest.mark.parametrize(('target', 'denied'), FOOTPRINT_CASES)
def test_import_footprint(target: str, denied: tuple[str, ...]) -> None:
"""Importing ``target`` must not pull in denied modules."""
present = _modules_after_import(target)
leaked = [m for m in denied if m in present]
assert not leaked, f'{target} eagerly imported {leaked}'


def test_bare_import_module_count_under_budget() -> None:
"""Keep a bare import under the module-count budget."""
# Coarse bloat tripwire (denylist above is the real guard). Cap tightened
# now that __version__ is lazy (importlib.metadata no longer pulled on bare
# import). Bump only if a new Python version legitimately adds startup
Expand All @@ -66,13 +69,15 @@ def test_bare_import_module_count_under_budget() -> None:


def test_bare_import_does_not_pull_importlib_metadata() -> None:
"""A bare import must not import ``importlib.metadata``."""
# __version__ is resolved lazily; bare import must not call
# importlib.metadata.version() (which drags in email/zipfile/json/...).
present = _modules_after_import('python_utils')
assert 'importlib.metadata' not in present


def test_version_resolves_correctly() -> None:
"""Resolve ``__version__`` lazily to a non-empty string."""
import python_utils

assert isinstance(python_utils.__version__, str)
Expand All @@ -92,6 +97,7 @@ def test_version_resolves_correctly() -> None:

@pytest.mark.parametrize(('module', 'name'), PUBLIC_CALLABLES_TO_INTROSPECT)
def test_get_type_hints_still_resolves(module: str, name: str) -> None:
"""Resolve type hints without ``NameError`` for public APIs."""
import importlib
import typing

Expand Down
7 changes: 7 additions & 0 deletions _python_utils_tests/test_lazy_imports.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@


def _run_clean(code: str) -> subprocess.CompletedProcess[str]:
"""Run ``code`` in a fresh interpreter and return the result."""
# Run in a fresh interpreter: the test session itself has long since
# imported asyncio/typing_extensions, so in-process checks are useless.
env = {**os.environ, 'PYTHONPATH': os.pathsep.join(sys.path)}
Expand All @@ -26,6 +27,7 @@ def _run_clean(code: str) -> subprocess.CompletedProcess[str]:


def test_package_lazy_attribute_access() -> None:
"""Resolve submodule and exported names via ``__getattr__``."""
# Submodule access and exported-name access both resolve via __getattr__.
aio = python_utils.aio
assert python_utils.aio is aio # repeated access returns the cached module
Expand All @@ -37,6 +39,7 @@ def test_package_lazy_attribute_access() -> None:


def test_bare_import_stays_light() -> None:
"""Keep a bare import free of asyncio and typing_extensions."""
# Importing the package must not eagerly pull in heavy/optional deps.
result = _run_clean(
'import sys, python_utils\n'
Expand All @@ -48,6 +51,7 @@ def test_bare_import_stays_light() -> None:


def test_importing_time_submodule_avoids_asyncio() -> None:
"""Import ``python_utils.time`` without importing asyncio."""
# Importing python_utils.time for its synchronous helpers must not import
# asyncio; the async helpers import it lazily inside their own bodies.
result = _run_clean(
Expand All @@ -59,6 +63,7 @@ def test_importing_time_submodule_avoids_asyncio() -> None:


def test_first_access_caches_into_module_dict() -> None:
"""Cache the first lazy access into the module dict."""
# PEP 562 __getattr__ runs once: the resolved object is cached in the
# module namespace so subsequent lookups skip __getattr__ entirely.
module = python_utils.time
Expand All @@ -69,6 +74,7 @@ def test_first_access_caches_into_module_dict() -> None:


def test_dir_lists_lazy_submodules() -> None:
"""List lazy submodules and ``__all__`` names via ``dir``."""
# Lazy submodules that are not in __all__ (e.g. ``containers`` and
# ``exceptions``) must still be discoverable via ``dir``; tools such as
# ``import_global`` intersect requested names with ``dir(module)``.
Expand All @@ -79,6 +85,7 @@ def test_dir_lists_lazy_submodules() -> None:

@pytest.mark.asyncio
async def test_aio_timeout_generator_default_iterable() -> None:
"""Default the iterable to ``aio.acount`` when omitted."""
# With no iterable the generator defaults to ``aio.acount`` -- exercising
# the lazy ``aio``/``asyncio`` import and the None-resolution branch.
count = 0
Expand Down
Loading
Loading