Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Strict typing #28

Merged
merged 2 commits into from
Sep 27, 2024
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
8 changes: 4 additions & 4 deletions conftest.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import sys
import re
import sys

import jaraco.functools


def pytest_configure():
def pytest_configure() -> None:
patch_for_issue_12()


def patch_for_issue_12(): # pragma: nocover
def patch_for_issue_12() -> None: # pragma: nocover
"""
Issue #12 revealed that Python 3.7.3 had a subtle
change in the C implementation of functools that
Expand All @@ -21,4 +21,4 @@ def patch_for_issue_12(): # pragma: nocover
if sys.version_info[:3] != affected_ver:
return
mc = jaraco.functools.method_cache
mc.__doc__ = re.sub(r'^(\s+)75', r'\g<1>76', mc.__doc__, flags=re.M)
mc.__doc__ = re.sub(r'^(\s+)75', r'\g<1>76', mc.__doc__ or "", flags=re.M)
2 changes: 1 addition & 1 deletion mypy.ini
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[mypy]
# Is the project well-typed?
strict = False
strict = True

# Early opt-in even when strict = False
warn_unused_ignores = True
Expand Down
4 changes: 0 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,3 @@ type = [


[tool.setuptools_scm]


[tool.pytest-enabler.mypy]
# Disabled due to jaraco/skeleton#143
74 changes: 39 additions & 35 deletions test_functools.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,29 @@
import os
import itertools
import time
from __future__ import annotations

import copy
import random
import functools
import itertools
import os
import platform
import random
import time
from typing import Literal, TypeVar
from unittest import mock

import pytest

from jaraco.classes import properties
from jaraco.functools import Throttler, method_cache, retry, retry_call

from jaraco.functools import Throttler, method_cache, retry_call, retry
_T = TypeVar("_T")


class TestThrottler:
@pytest.mark.xfail(
os.environ.get('GITHUB_ACTIONS') # type: ignore
and platform.system() in ('Darwin', 'Windows'),
'GITHUB_ACTIONS' in os.environ and platform.system() in ('Darwin', 'Windows'),
reason="Performance is heavily throttled on Github Actions Mac/Windows runs",
)
def test_function_throttled(self):
def test_function_throttled(self) -> None:
"""
Ensure the throttler actually throttles calls.
"""
Expand All @@ -43,7 +47,7 @@ def test_function_throttled(self):
limited_next(counter)
assert 28 <= next(counter) <= 32

def test_reconstruct_unwraps(self):
def test_reconstruct_unwraps(self) -> None:
"""
The throttler should be re-usable - if one wants to throttle a
function that's aready throttled, the original function should be
Expand All @@ -54,10 +58,10 @@ def test_reconstruct_unwraps(self):
assert wrapped_again.func is next
assert wrapped_again.max_rate == 60

def test_throttled_method(self):
def test_throttled_method(self) -> None:
class ThrottledMethodClass:
@Throttler
def echo(self, arg):
def echo(self, arg: _T) -> _T:
return arg

tmc = ThrottledMethodClass()
Expand All @@ -68,7 +72,7 @@ class TestMethodCache:
bad_vers = '(3, 5, 0) <= sys.version_info < (3, 5, 2)'

@pytest.mark.skipif(bad_vers, reason="https://bugs.python.org/issue25447")
def test_deepcopy(self):
def test_deepcopy(self) -> None:
"""
A deepcopy of an object with a method cache should still
succeed.
Expand All @@ -78,7 +82,7 @@ class ClassUnderTest:
calls = 0

@method_cache
def method(self, value):
def method(self, value: _T) -> _T:
self.calls += 1
return value

Expand All @@ -87,7 +91,7 @@ def method(self, value):
ob.method(1)
copy.deepcopy(ob)

def test_special_methods(self):
def test_special_methods(self) -> None:
"""
Test method_cache with __getitem__ and __getattr__.
"""
Expand All @@ -97,12 +101,12 @@ class ClassUnderTest:
getattr_calls = 0

@method_cache
def __getitem__(self, item):
def __getitem__(self, item: _T) -> _T:
self.getitem_calls += 1
return item

@method_cache
def __getattr__(self, name):
def __getattr__(self, name: _T) -> _T:
self.getattr_calls += 1
return name

Expand All @@ -113,27 +117,27 @@ def __getattr__(self, name):
assert ob.getitem_calls == 1

# __getattr__
ob.one + ob.one
ob.one + ob.one # type: ignore[operator] # Using ParamSpec on methods is still limited
assert ob.getattr_calls == 1

@pytest.mark.xfail(reason="can't replace property with cache; #6")
def test_property(self):
def test_property(self) -> None:
"""
Can a method_cache decorated method also be a property?
"""

class ClassUnderTest:
@property
@method_cache
def mything(self): # pragma: nocover
def mything(self) -> float: # pragma: nocover
return random.random()

ob = ClassUnderTest()

assert ob.mything == ob.mything

@pytest.mark.xfail(reason="can't replace property with cache; #6")
def test_non_data_property(self):
def test_non_data_property(self) -> None:
"""
A non-data property also does not work because the property
gets replaced with a method.
Expand All @@ -142,7 +146,7 @@ def test_non_data_property(self):
class ClassUnderTest:
@properties.NonDataProperty
@method_cache
def mything(self):
def mything(self) -> float:
return random.random()

ob = ClassUnderTest()
Expand All @@ -151,17 +155,17 @@ def mything(self):


class TestRetry:
def attempt(self, arg=None):
def attempt(self, arg: mock.Mock | None = None) -> Literal['Success']:
if next(self.fails_left):
raise ValueError("Failed!")
if arg:
arg.touch()
return "Success"

def set_to_fail(self, times):
def set_to_fail(self, times: int) -> None:
self.fails_left = itertools.count(times, -1)

def test_set_to_fail(self):
def test_set_to_fail(self) -> None:
"""
Test this test's internal failure mechanism.
"""
Expand All @@ -172,12 +176,12 @@ def test_set_to_fail(self):
self.attempt()
assert self.attempt() == 'Success'

def test_retry_call_succeeds(self):
def test_retry_call_succeeds(self) -> None:
self.set_to_fail(times=2)
res = retry_call(self.attempt, retries=2, trap=ValueError)
assert res == "Success"

def test_retry_call_fails(self):
def test_retry_call_fails(self) -> None:
"""
Failing more than the number of retries should
raise the underlying error.
Expand All @@ -187,56 +191,56 @@ def test_retry_call_fails(self):
retry_call(self.attempt, retries=2, trap=ValueError)
assert str(res.value) == 'Failed!'

def test_retry_multiple_exceptions(self):
def test_retry_multiple_exceptions(self) -> None:
self.set_to_fail(times=2)
errors = ValueError, NameError
res = retry_call(self.attempt, retries=2, trap=errors)
assert res == "Success"

def test_retry_exception_superclass(self):
def test_retry_exception_superclass(self) -> None:
self.set_to_fail(times=2)
res = retry_call(self.attempt, retries=2, trap=Exception)
assert res == "Success"

def test_default_traps_nothing(self):
def test_default_traps_nothing(self) -> None:
self.set_to_fail(times=1)
with pytest.raises(ValueError):
retry_call(self.attempt, retries=1)

def test_default_does_not_retry(self):
def test_default_does_not_retry(self) -> None:
self.set_to_fail(times=1)
with pytest.raises(ValueError):
retry_call(self.attempt, trap=Exception)

def test_cleanup_called_on_exception(self):
def test_cleanup_called_on_exception(self) -> None:
calls = random.randint(1, 10)
cleanup = mock.Mock()
self.set_to_fail(times=calls)
retry_call(self.attempt, retries=calls, cleanup=cleanup, trap=Exception)
assert cleanup.call_count == calls
cleanup.assert_called_with()

def test_infinite_retries(self):
def test_infinite_retries(self) -> None:
self.set_to_fail(times=999)
cleanup = mock.Mock()
retry_call(self.attempt, retries=float('inf'), cleanup=cleanup, trap=Exception)
assert cleanup.call_count == 999

def test_with_arg(self):
def test_with_arg(self) -> None:
self.set_to_fail(times=0)
arg = mock.Mock()
bound = functools.partial(self.attempt, arg)
res = retry_call(bound)
assert res == 'Success'
assert arg.touch.called

def test_decorator(self):
def test_decorator(self) -> None:
self.set_to_fail(times=1)
attempt = retry(retries=1, trap=Exception)(self.attempt)
res = attempt()
assert res == "Success"

def test_decorator_with_arg(self):
def test_decorator_with_arg(self) -> None:
self.set_to_fail(times=0)
attempt = retry()(self.attempt)
arg = mock.Mock()
Expand Down
Loading