Skip to content

Commit 32966fc

Browse files
committed
Fixed several typing issues and added more thorough tests
1 parent 4deb4d5 commit 32966fc

12 files changed

Lines changed: 333 additions & 61 deletions

File tree

_python_utils_tests/test_aio.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22
import pytest
33
import asyncio
44

5+
56
from python_utils import types
6-
from python_utils.aio import acount
7+
from python_utils.aio import acount, acontainer
78

89

910
@pytest.mark.asyncio
@@ -20,3 +21,16 @@ async def mock_sleep(delay: float):
2021

2122
assert len(sleeps) == 4
2223
assert sum(sleeps) == 4
24+
25+
26+
@pytest.mark.asyncio
27+
async def test_acontainer():
28+
async def async_gen():
29+
yield 1
30+
yield 2
31+
yield 3
32+
33+
assert await acontainer(async_gen) == [1, 2, 3]
34+
assert await acontainer(async_gen()) == [1, 2, 3]
35+
assert await acontainer(async_gen, set) == {1, 2, 3}
36+
assert await acontainer(async_gen(), set) == {1, 2, 3}

_python_utils_tests/test_containers.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
from python_utils import containers
44

55

6-
def test_unique_list_ignore():
7-
a = containers.UniqueList()
6+
def test_unique_list_ignore() -> None:
7+
a: containers.UniqueList[int] = containers.UniqueList()
88
a.append(1)
99
a.append(1)
1010
assert a == [1]
@@ -16,8 +16,10 @@ def test_unique_list_ignore():
1616
a[3] = 5
1717

1818

19-
def test_unique_list_raise():
20-
a = containers.UniqueList(*range(20), on_duplicate='raise')
19+
def test_unique_list_raise() -> None:
20+
a: containers.UniqueList[int] = containers.UniqueList(
21+
*range(20), on_duplicate='raise'
22+
)
2123
with pytest.raises(ValueError):
2224
a[10:20:2] = [1, 2, 3, 4, 5]
2325

_python_utils_tests/test_decorators.py

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,37 +2,69 @@
22

33
import pytest
44

5-
from python_utils.decorators import sample
5+
from python_utils.decorators import sample, wraps_classmethod
66

77

88
@pytest.fixture
9-
def random(monkeypatch):
9+
def random(monkeypatch: pytest.MonkeyPatch) -> MagicMock:
1010
mock = MagicMock()
1111
monkeypatch.setattr(
12-
"python_utils.decorators.random.random", mock, raising=True
12+
'python_utils.decorators.random.random', mock, raising=True
1313
)
1414
return mock
1515

1616

17-
def test_sample_called(random):
17+
def test_sample_called(random: MagicMock):
1818
demo_function = MagicMock()
1919
decorated = sample(0.5)(demo_function)
2020
random.return_value = 0.4
2121
decorated()
2222
random.return_value = 0.0
2323
decorated()
2424
args = [1, 2]
25-
kwargs = {"1": 1, "2": 2}
25+
kwargs = {'1': 1, '2': 2}
2626
decorated(*args, **kwargs)
2727
demo_function.assert_called_with(*args, **kwargs)
2828
assert demo_function.call_count == 3
2929

3030

31-
def test_sample_not_called(random):
31+
def test_sample_not_called(random: MagicMock):
3232
demo_function = MagicMock()
3333
decorated = sample(0.5)(demo_function)
3434
random.return_value = 0.5
3535
decorated()
3636
random.return_value = 1.0
3737
decorated()
3838
assert demo_function.call_count == 0
39+
40+
41+
class SomeClass:
42+
@classmethod
43+
def some_classmethod(cls, arg): # type: ignore
44+
return arg # type: ignore
45+
46+
@classmethod
47+
def some_annotated_classmethod(cls, arg: int) -> int:
48+
return arg
49+
50+
51+
def test_wraps_classmethod(): # type: ignore
52+
some_class = SomeClass()
53+
some_class.some_classmethod = MagicMock()
54+
wrapped_method = wraps_classmethod( # type: ignore
55+
SomeClass.some_classmethod # type: ignore
56+
)( # type: ignore
57+
some_class.some_classmethod # type: ignore
58+
)
59+
wrapped_method(123)
60+
some_class.some_classmethod.assert_called_with(123) # type: ignore
61+
62+
63+
def test_wraps_classmethod(): # type: ignore
64+
some_class = SomeClass()
65+
some_class.some_annotated_classmethod = MagicMock()
66+
wrapped_method = wraps_classmethod(SomeClass.some_annotated_classmethod)(
67+
some_class.some_annotated_classmethod
68+
)
69+
wrapped_method(123) # type: ignore
70+
some_class.some_annotated_classmethod.assert_called_with(123)

_python_utils_tests/test_generators.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import pytest
44

55
import python_utils
6+
from python_utils import types
67

78

89
@pytest.mark.asyncio
@@ -16,7 +17,7 @@ async def test_abatcher():
1617

1718
@pytest.mark.asyncio
1819
async def test_abatcher_timed():
19-
batches = []
20+
batches: types.List[types.List[int]] = []
2021
async for batch in python_utils.abatcher(
2122
python_utils.acount(stop=10, delay=0.08), interval=0.1
2223
):

_python_utils_tests/test_import.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
1-
from python_utils import import_
1+
from python_utils import import_, types
22

33

44
def test_import_globals_relative_import():
55
for i in range(-1, 5):
66
relative_import(i)
77

88

9-
def relative_import(level):
10-
locals_ = {}
9+
def relative_import(level: int):
10+
locals_: types.Dict[str, types.Any] = {}
1111
globals_ = {'__name__': 'python_utils.import_'}
1212
import_.import_global('.formatters', locals_=locals_, globals_=globals_)
1313
assert 'camel_to_underscore' in globals_

_python_utils_tests/test_logger.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,6 @@ class MyClass(Logurud):
1515
my_class.info('info')
1616
my_class.warning('warning')
1717
my_class.error('error')
18+
my_class.critical('critical')
1819
my_class.exception('exception')
1920
my_class.log(0, 'log')

_python_utils_tests/test_time.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import pytest
66

77
import python_utils
8+
from python_utils import types
89

910

1011
@pytest.mark.parametrize(
@@ -25,7 +26,12 @@
2526
)
2627
@pytest.mark.asyncio
2728
async def test_aio_timeout_generator(
28-
timeout, interval, interval_multiplier, maximum_interval, iterable, result
29+
timeout: float,
30+
interval: float,
31+
interval_multiplier: float,
32+
maximum_interval: float,
33+
iterable: types.AsyncIterable[types.Any],
34+
result: int,
2935
):
3036
i = None
3137
async for i in python_utils.aio_timeout_generator(
@@ -40,21 +46,30 @@ async def test_aio_timeout_generator(
4046
'timeout,interval,interval_multiplier,maximum_interval,iterable,result',
4147
[
4248
(0.01, 0.006, 0.5, 0.01, 'abc', 'c'),
43-
(0.01, 0.006, 0.5, 0.01, itertools.count, 2),
49+
(0.01, 0.006, 0.5, 0.01, itertools.count, 2), # type: ignore
4450
(0.01, 0.006, 0.5, 0.01, itertools.count(), 2),
4551
(0.01, 0.006, 1.0, None, 'abc', 'c'),
4652
(
4753
timedelta(seconds=0.01),
4854
timedelta(seconds=0.006),
4955
2.0,
5056
timedelta(seconds=0.01),
51-
itertools.count,
57+
itertools.count, # type: ignore
5258
2,
5359
),
5460
],
5561
)
5662
def test_timeout_generator(
57-
timeout, interval, interval_multiplier, maximum_interval, iterable, result
63+
timeout: float,
64+
interval: float,
65+
interval_multiplier: float,
66+
maximum_interval: float,
67+
iterable: types.Union[
68+
str,
69+
types.Iterable[types.Any],
70+
types.Callable[..., types.Iterable[types.Any]],
71+
],
72+
result: int,
5873
):
5974
i = None
6075
for i in python_utils.timeout_generator(

pyproject.toml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ target-version = ['py37', 'py38', 'py39', 'py310', 'py311']
44
skip-string-normalization = true
55

66
[tool.pyright]
7-
include = ['python_utils']
8-
strict = ['python_utils', '_python_utils_tests/test_aio.py']
7+
# include = ['python_utils']
8+
include = ['python_utils', '_python_utils_tests']
9+
strict = ['python_utils', '_python_utils_tests']
910
# The terminal file is very OS specific and dependent on imports so we're skipping it from type checking
1011
ignore = ['python_utils/terminal.py']
11-
pythonVersion = '3.8'
12+
pythonVersion = '3.8'

python_utils/aio.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from . import types
99

1010
_N = types.TypeVar('_N', int, float)
11+
_T = types.TypeVar('_T')
1112

1213

1314
async def acount(
@@ -21,5 +22,33 @@ async def acount(
2122
if stop is not None and item >= stop:
2223
break
2324

24-
yield types.cast(_N, item)
25+
yield item
2526
await asyncio.sleep(delay)
27+
28+
29+
async def acontainer(
30+
iterable: types.Union[
31+
types.AsyncIterable[_T],
32+
types.Callable[..., types.AsyncIterable[_T]],
33+
],
34+
container: types.Callable[[types.Iterable[_T]], types.Iterable[_T]] = list,
35+
) -> types.Iterable[_T]:
36+
'''
37+
Asyncio version of list()/set()/tuple()/etc() using an async for loop
38+
39+
So instead of doing `[item async for item in iterable]` you can do
40+
`await acontainer(iterable)`.
41+
42+
'''
43+
iterable_: types.AsyncIterable[_T]
44+
if callable(iterable):
45+
iterable_ = iterable()
46+
else:
47+
iterable_ = iterable
48+
49+
item: _T
50+
items: types.List[_T] = []
51+
async for item in iterable_:
52+
items.append(item)
53+
54+
return container(items)

python_utils/decorators.py

Lines changed: 60 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33
import random
44
from . import types
55

6-
T = types.TypeVar('T')
7-
TC = types.TypeVar('TC', bound=types.Container[types.Any])
8-
P = types.ParamSpec('P')
6+
_T = types.TypeVar('_T')
7+
_TC = types.TypeVar('_TC', bound=types.Container[types.Any])
8+
_P = types.ParamSpec('_P')
9+
_S = types.TypeVar('_S', covariant=True)
910

1011

1112
def set_attributes(**kwargs: types.Any) -> types.Callable[..., types.Any]:
@@ -33,8 +34,8 @@ def set_attributes(**kwargs: types.Any) -> types.Callable[..., types.Any]:
3334
'''
3435

3536
def _set_attributes(
36-
function: types.Callable[P, T]
37-
) -> types.Callable[P, T]:
37+
function: types.Callable[_P, _T]
38+
) -> types.Callable[_P, _T]:
3839
for key, value in kwargs.items():
3940
setattr(function, key, value)
4041
return function
@@ -43,11 +44,13 @@ def _set_attributes(
4344

4445

4546
def listify(
46-
collection: types.Callable[[types.Iterable[T]], TC] = list, # type: ignore
47+
collection: types.Callable[
48+
[types.Iterable[_T]], _TC
49+
] = list, # type: ignore
4750
allow_empty: bool = True,
4851
) -> types.Callable[
49-
[types.Callable[..., types.Optional[types.Iterable[T]]]],
50-
types.Callable[..., TC],
52+
[types.Callable[..., types.Optional[types.Iterable[_T]]]],
53+
types.Callable[..., _TC],
5154
]:
5255
'''
5356
Convert any generator to a list or other type of collection.
@@ -96,10 +99,10 @@ def listify(
9699
'''
97100

98101
def _listify(
99-
function: types.Callable[..., types.Optional[types.Iterable[T]]]
100-
) -> types.Callable[..., TC]:
101-
def __listify(*args: types.Any, **kwargs: types.Any) -> TC:
102-
result: types.Optional[types.Iterable[T]] = function(
102+
function: types.Callable[..., types.Optional[types.Iterable[_T]]]
103+
) -> types.Callable[..., _TC]:
104+
def __listify(*args: types.Any, **kwargs: types.Any) -> _TC:
105+
result: types.Optional[types.Iterable[_T]] = function(
103106
*args, **kwargs
104107
)
105108
if result is None:
@@ -134,10 +137,12 @@ def sample(sample_rate: float):
134137
'''
135138

136139
def _sample(
137-
function: types.Callable[P, T]
138-
) -> types.Callable[P, types.Optional[T]]:
140+
function: types.Callable[_P, _T]
141+
) -> types.Callable[_P, types.Optional[_T]]:
139142
@functools.wraps(function)
140-
def __sample(*args: P.args, **kwargs: P.kwargs) -> types.Optional[T]:
143+
def __sample(
144+
*args: _P.args, **kwargs: _P.kwargs
145+
) -> types.Optional[_T]:
141146
if random.random() < sample_rate:
142147
return function(*args, **kwargs)
143148
else:
@@ -152,3 +157,43 @@ def __sample(*args: P.args, **kwargs: P.kwargs) -> types.Optional[T]:
152157
return __sample
153158

154159
return _sample
160+
161+
162+
def wraps_classmethod(
163+
wrapped: types.Callable[types.Concatenate[_S, _P], _T],
164+
) -> types.Callable[
165+
[
166+
types.Callable[types.Concatenate[types.Any, _P], _T],
167+
],
168+
types.Callable[types.Concatenate[types.Type[_S], _P], _T],
169+
]:
170+
'''
171+
Like `functools.wraps`, but for wrapping classmethods with the type info
172+
from a regular method
173+
'''
174+
175+
def _wraps_classmethod(
176+
wrapper: types.Callable[types.Concatenate[types.Any, _P], _T],
177+
) -> types.Callable[types.Concatenate[types.Type[_S], _P], _T]:
178+
try: # pragma: no cover
179+
wrapper = functools.update_wrapper(
180+
wrapper,
181+
wrapped,
182+
assigned=tuple(
183+
a
184+
for a in functools.WRAPPER_ASSIGNMENTS
185+
if a != '__annotations__'
186+
),
187+
)
188+
except AttributeError:
189+
# For some reason `functools.update_wrapper` fails on some test
190+
# runs but not while running actual code
191+
pass
192+
193+
if annotations := getattr(wrapped, '__annotations__', {}):
194+
annotations.pop('self', None)
195+
wrapper.__annotations__ = annotations
196+
197+
return wrapper
198+
199+
return _wraps_classmethod

0 commit comments

Comments
 (0)