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')
39 changes: 39 additions & 0 deletions _python_utils_tests/test_converters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""Tests for the conversion helpers in ``python_utils.converters``."""

import re

import pytest

from python_utils import converters


@pytest.mark.parametrize('regexp', [r'\d+', re.compile(r'\d+')])
def test_to_int_regexp_without_group(regexp: re.Pattern[str] | str) -> None:
"""Use the whole match when the pattern has no capture group."""
assert converters.to_int('abc123', regexp=regexp) == 123


@pytest.mark.parametrize('regexp', [r'\d+\.\d+', re.compile(r'\d+\.\d+')])
def test_to_float_regexp_without_group(
regexp: re.Pattern[str] | str,
) -> None:
"""Use the whole match when the pattern has no capture group."""
assert converters.to_float('abc1.5', regexp=regexp) == 1.5


def test_regexp_with_groups_keeps_its_group() -> None:
"""Keep the last group for ``to_int`` and the first for ``to_float``."""
assert converters.to_int('a1b2', regexp=r'(\d)\D(\d)') == 2
assert converters.to_float('a1b2', regexp=r'(\d)\D(\d)') == 1.0


@pytest.mark.parametrize('value', [0.0001, 1e-9, 2**-11])
def test_scale_1024_small_number(value: float) -> None:
"""Never scale a number below one up to a negative power."""
assert converters.scale_1024(value, 9) == (value, 0)


@pytest.mark.parametrize('n_prefixes', [0, -1])
def test_scale_1024_without_prefixes(n_prefixes: int) -> None:
"""Never return a negative power when there are no prefixes."""
assert converters.scale_1024(2048, n_prefixes) == (2048.0, 0)
80 changes: 80 additions & 0 deletions _python_utils_tests/test_formatters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""Tests for the formatting helpers in ``python_utils.formatters``."""

import datetime
import typing

import pytest

from python_utils import formatters


@pytest.mark.parametrize(
('days', 'expected'),
[
(30, '1 month ago'),
(35, '1 month and 5 days ago'),
(60, '2 months ago'),
(365, '1 year ago'),
(730, '2 years ago'),
],
)
def test_timesince_units_do_not_overlap(days: int, expected: str) -> None:
"""Take the weeks and days from what the larger units left over."""
delta: datetime.timedelta = datetime.timedelta(days=days)
assert formatters.timesince(delta) == expected


@pytest.mark.parametrize(
('delta', 'expected'),
[
(datetime.timedelta(seconds=-1), '1 second ago'),
(datetime.timedelta(seconds=-61), '1 minute and 1 second ago'),
(datetime.timedelta(days=-400), '1 year and 1 month ago'),
],
)
def test_timesince_negative_timedelta(
delta: datetime.timedelta, expected: str
) -> None:
"""Describe a negative timedelta by its size, like a datetime."""
assert formatters.timesince(delta) == expected


@pytest.mark.parametrize(
'timezone',
[
datetime.timezone.utc,
datetime.timezone(datetime.timedelta(hours=5, minutes=30)),
],
)
def test_timesince_aware_datetime(timezone: datetime.timezone) -> None:
"""Compare a timezone-aware datetime with an aware current time."""
# Only the two largest units are shown, so the time this test takes
# cannot change the result.
age: datetime.timedelta = datetime.timedelta(hours=1, minutes=30)
moment: datetime.datetime = datetime.datetime.now(timezone) - age
assert formatters.timesince(moment) == '1 hour and 30 minutes ago'


@pytest.mark.parametrize(
('name', 'expected'),
[
('SPAM_EGGS', 'spam_eggs'),
('HTTP_OK', 'http_ok'),
('MAX_SIZE_LIMIT', 'max_size_limit'),
('HTML5Parser', 'html5_parser'),
('toUTF8String', 'to_utf8_string'),
('ABCD_e', 'abcd_e'),
],
)
def test_camel_to_underscore_keeps_acronyms_whole(
name: str, expected: str
) -> None:
"""Leave an acronym whole when an underscore or a digit follows it."""
assert formatters.camel_to_underscore(name) == expected


def test_timesince_date_raises_type_error() -> None:
"""Keep raising ``TypeError`` for a date, which has no time."""
today: typing.Any = datetime.date.today()
with pytest.raises(TypeError):
formatters.timesince(today)
143 changes: 143 additions & 0 deletions _python_utils_tests/test_import.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,44 @@
"""Tests for the import helpers in ``python_utils.import_``."""

import collections.abc
import pathlib
import sys

import pytest

from python_utils import import_, types

#: Top-level name of the package that the ``nested_module`` fixture creates.
NESTED_PACKAGE: str = 'python_utils_spam'


@pytest.fixture
def nested_module(
tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
) -> collections.abc.Iterator[str]:
"""Create ``python_utils_spam.eggs.bacon`` and yield its dotted name."""
package: pathlib.Path = tmp_path / NESTED_PACKAGE
subpackage: pathlib.Path = package / 'eggs'
subpackage.mkdir(parents=True)
(package / '__init__.py').write_text('', encoding='utf-8')
(subpackage / '__init__.py').write_text('', encoding='utf-8')
(subpackage / 'bacon.py').write_text("ham = 'ham'\n", encoding='utf-8')
(subpackage / 'needy.py').write_text(
'import python_utils_missing_dependency\n', encoding='utf-8'
)
(subpackage / 'broken.py').write_text(
"raise AttributeError('broken on purpose')\n", encoding='utf-8'
)
monkeypatch.setattr(sys, 'path', [str(tmp_path), *sys.path])

yield f'{NESTED_PACKAGE}.eggs.bacon'

imported: list[str] = [
name for name in sys.modules if name.split('.')[0] == NESTED_PACKAGE
]
for name in imported:
del sys.modules[name]


def test_import_globals_relative_import() -> None:
"""Resolve relative imports across several levels."""
Expand Down Expand Up @@ -60,3 +97,109 @@ def test_import_locals_missing_module() -> None:
'python_utils.spam', exceptions=ImportError, globals_=globals()
)
assert 'camel_to_underscore' in globals()


def test_import_global_nested_module(nested_module: str) -> None:
"""Import a dotted name whose last module nothing imported before."""
locals_: types.Dict[str, types.Any] = {}
globals_: types.Dict[str, types.Any] = {'__name__': __name__}
assert nested_module not in sys.modules
import_.import_global(nested_module, locals_=locals_, globals_=globals_)
assert globals_['ham'] == 'ham'


def test_import_global_missing_nested_module(nested_module: str) -> None:
"""Keep the ``ImportError`` for a nested module that does not exist."""
locals_: types.Dict[str, types.Any] = {}
globals_: types.Dict[str, types.Any] = {'__name__': __name__}
error: types.Any = import_.import_global(
f'{nested_module}.sausage',
exceptions=ImportError,
locals_=locals_,
globals_=globals_,
)
assert type(error) is ImportError
assert str(error) == f'No module named {nested_module}.sausage'


def test_import_global_attribute_of_non_module() -> None:
"""Report a path through an object that is not a module as missing."""
locals_: types.Dict[str, types.Any] = {}
globals_: types.Dict[str, types.Any] = {'__name__': __name__}
error: types.Any = import_.import_global(
'os.environ.spam',
exceptions=ImportError,
locals_=locals_,
globals_=globals_,
)
assert type(error) is ImportError
assert str(error) == 'No module named os.environ.spam'


def test_import_global_nested_module_missing_dependency(
nested_module: str,
) -> None:
"""Show the missing dependency of a nested module that does exist."""
locals_: types.Dict[str, types.Any] = {}
globals_: types.Dict[str, types.Any] = {'__name__': __name__}
with pytest.raises(ModuleNotFoundError) as raised:
import_.import_global(
f'{NESTED_PACKAGE}.eggs.needy', locals_=locals_, globals_=globals_
)

assert raised.value.name == 'python_utils_missing_dependency'


def test_import_global_nested_module_import_error(nested_module: str) -> None:
"""Show the error that a nested module raises while it is imported."""
locals_: types.Dict[str, types.Any] = {}
globals_: types.Dict[str, types.Any] = {'__name__': __name__}
with pytest.raises(AttributeError, match='broken on purpose'):
import_.import_global(
f'{NESTED_PACKAGE}.eggs.broken', locals_=locals_, globals_=globals_
)


def test_import_global_looks_an_attribute_up_once(
nested_module: str, tmp_path: pathlib.Path
) -> None:
"""Ask a module for an attribute once, also when it computes it."""
lazy: pathlib.Path = tmp_path / NESTED_PACKAGE / 'eggs' / 'lazy.py'
lazy.write_text(
'calls = []\n\n\n'
'def __getattr__(name):\n'
" if name == 'computed':\n"
' calls.append(name)\n'
' return calls\n'
' raise AttributeError(name)\n',
encoding='utf-8',
)
locals_: types.Dict[str, types.Any] = {}
globals_: types.Dict[str, types.Any] = {'__name__': __name__}
import_.import_global(
f'{NESTED_PACKAGE}.eggs.lazy.computed',
locals_=locals_,
globals_=globals_,
)

assert sys.modules[f'{NESTED_PACKAGE}.eggs.lazy'].calls == ['computed']


def test_import_global_missing_below_detached_module(
nested_module: str, tmp_path: pathlib.Path
) -> None:
"""Name the requested module when a module has another name itself."""
detached: pathlib.Path = tmp_path / NESTED_PACKAGE / 'eggs' / 'detached.py'
detached.write_text(
"import types\n\nspam = types.ModuleType('python_utils_detached')\n",
encoding='utf-8',
)
name: str = f'{NESTED_PACKAGE}.eggs.detached.spam.sausage'
locals_: types.Dict[str, types.Any] = {}
globals_: types.Dict[str, types.Any] = {'__name__': __name__}
error: types.Any = import_.import_global(
name, exceptions=ImportError, locals_=locals_, globals_=globals_
)

assert type(error) is ImportError
assert str(error) == f'No module named {name}'
6 changes: 5 additions & 1 deletion _python_utils_tests/test_lazy_imports.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import pytest

import python_utils
from _python_utils_tests import clock


def _run_clean(code: str) -> subprocess.CompletedProcess[str]:
Expand Down Expand Up @@ -94,10 +95,13 @@ def test_star_import_resolves_all_names() -> None:


@pytest.mark.asyncio
async def test_aio_timeout_generator_default_iterable() -> None:
async def test_aio_timeout_generator_default_iterable(
fake_clock: clock.FakeClock,
) -> 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.
# The fake clock stands still, so the timeout cannot end the loop early.
count = 0
generator: collections.abc.AsyncGenerator[object, None] = (
python_utils.aio_timeout_generator(timeout=0.05, interval=0.0)
Expand Down
Loading
Loading