Skip to content
56 changes: 56 additions & 0 deletions _python_utils_tests/clock.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""A fake clock for the tests and doctests of ``python_utils.time``."""

import typing

import pytest

import python_utils.time

#: The doctest that sleeps on the clock, by the name pytest gives it.
TIMEOUT_GENERATOR_DOCTEST: str = 'python_utils.time.timeout_generator'


class FakeClock:
"""
A clock that only moves when something sleeps on it.

``time.sleep`` promises to sleep at least as long as requested. A busy
machine sleeps tens of milliseconds longer, and that changes how many
items ``timeout_generator`` yields before its timeout. On this clock the
number of items depends on the arguments alone.

Attributes:
now (float): The current time in seconds.
sleeps (list[float]): Every requested sleep, in order.
"""

def __init__(self) -> None:
"""Start at zero without any recorded sleeps."""
self.now: float = 0.0
self.sleeps: list[float] = []

def perf_counter(self) -> float:
"""Return the current time, like ``time.perf_counter``."""
return self.now

def sleep(self, seconds: float) -> None:
"""Record the sleep and move the clock forward, without waiting."""
self.sleeps.append(seconds)
self.now += seconds


@pytest.fixture
def fake_clock(monkeypatch: pytest.MonkeyPatch) -> FakeClock:
"""Replace the ``time`` module inside ``python_utils.time``."""
clock: FakeClock = FakeClock()
monkeypatch.setattr(python_utils.time, 'time', clock)
return clock


@pytest.fixture(autouse=True)
def fake_clock_in_doctest(request: pytest.FixtureRequest) -> None:
"""Run the ``timeout_generator`` doctest on the fake clock."""
# pytest leaves `FixtureRequest.node` without a type.
node: pytest.Item = typing.cast(pytest.Item, request.node)
if node.name == TIMEOUT_GENERATOR_DOCTEST:
request.getfixturevalue('fake_clock')
37 changes: 37 additions & 0 deletions _python_utils_tests/test_aio.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,43 @@ async def mock_sleep(delay: float) -> None:
assert sum(sleeps) == 4


@pytest.mark.asyncio
@pytest.mark.parametrize(
'start,step,stop,expected',
[
(10, -2, 0, [10, 8, 6, 4, 2]),
(0, -1, -3, [0, -1, -2]),
(1.5, -0.5, 0, [1.5, 1.0, 0.5]),
# Nothing to count when `start` is already past `stop`, in either
# direction. This is what `range` does as well.
(0, -1, 5, []),
(5, 1, 3, []),
(0, 2, 5, [0, 2, 4]),
],
)
async def test_acount_stop_follows_step_direction(
start: float, step: float, stop: float, expected: types.List[float]
) -> None:
"""Count down to a lower ``stop`` when the step is negative."""
limit: int = len(expected) + 5
items: types.List[float] = []
item: float
# `acount` is annotated as a plain iterator, closing takes a generator.
counter: types.AsyncGenerator[float, None] = types.cast(
types.AsyncGenerator[float, None],
aio.acount(start=start, step=step, stop=stop),
)
async for item in counter:
items.append(item)
# A counter that misses its `stop` never ends by itself.
if len(items) == limit:
break

await counter.aclose()

assert items == expected


@pytest.mark.asyncio
async def test_acontainer() -> None:
"""Collect an async iterable into the requested container."""
Expand Down
96 changes: 96 additions & 0 deletions _python_utils_tests/test_decorators.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Tests for the decorators in ``python_utils.decorators``."""

import inspect
import logging
import typing
from unittest import mock

Expand Down Expand Up @@ -46,6 +48,50 @@ def test_sample_not_called(random: mock.MagicMock) -> None:
assert demo_function.call_count == 0


def test_sample_leaves_root_logger_alone(
random: mock.MagicMock, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Skip a call without configuring the root logger."""
root: logging.Logger = logging.getLogger()
handlers: list[logging.Handler] = []
# A root logger without handlers is what an application starts with.
# `logging.debug()` installs a handler on it, a module logger does not.
monkeypatch.setattr(root, 'handlers', handlers)
random.return_value = 1.0

decorators.sample(0.5)(mock.MagicMock())()

assert handlers == []


def test_sample_logs_on_module_logger(
random: mock.MagicMock, caplog: pytest.LogCaptureFixture
) -> None:
"""Report a skipped call on the logger of the module."""
random.return_value = 1.0

with caplog.at_level(logging.DEBUG):
decorators.sample(0.5)(mock.MagicMock())()

names: list[str] = [record.name for record in caplog.records]
assert names == ['python_utils.decorators']
assert 'Skipped execution' in caplog.records[0].getMessage()


def test_listify_keeps_metadata() -> None:
"""Keep the name, docstring and signature of the decorated function."""

@decorators.listify(collection=list)
def numbers(count: int = 3) -> typing.Iterator[int]:
"""Yield ``count`` numbers."""
yield from range(count)

assert numbers() == [0, 1, 2]
assert numbers.__name__ == 'numbers'
assert numbers.__doc__ == 'Yield ``count`` numbers.'
assert list(inspect.signature(numbers).parameters) == ['count']


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

Expand Down Expand Up @@ -80,3 +126,53 @@ def test_wraps_annotated_classmethod() -> None:
)(some_class.some_annotated_classmethod)
wrapped_method(123)
some_class.some_annotated_classmethod.assert_called_with(123)


def test_wraps_classmethod_leaves_wrapped_annotations_alone() -> None:
"""Drop ``self`` for the wrapper without touching the wrapped method."""

def wrapped(self: SomeClass, arg: int) -> int:
"""Return the argument unchanged, as a regular method would."""
return arg

def wrapper(cls: type[SomeClass], arg: int) -> int:
"""Return the argument unchanged, as a classmethod would."""
return arg

result: typing.Callable[..., int] = decorators.wraps_classmethod(wrapped)(
wrapper
)

assert wrapped.__annotations__ == {
'self': SomeClass,
'arg': int,
'return': int,
}
assert result.__annotations__ == {'arg': int, 'return': int}
assert result.__annotations__ is not wrapped.__annotations__


def test_wraps_classmethod_keeps_wrapper_annotations() -> None:
"""Keep the wrapper's annotations if the wrapped method has none."""

def wrapped(self: SomeClass, arg: int) -> int:
"""Return the argument unchanged, as a regular method would."""
return arg

def wrapper(cls: type[SomeClass], arg: int) -> int:
"""Return the argument unchanged, as a classmethod would."""
return arg

# To the interpreter this is a method that was written without
# annotations. The type checkers still get to see them.
wrapped.__annotations__ = {}

result: typing.Callable[..., int] = decorators.wraps_classmethod(wrapped)(
wrapper
)

assert result.__annotations__ == {
'cls': type[SomeClass],
'arg': int,
'return': int,
}
Loading
Loading