Skip to content

Commit 3902b44

Browse files
authored
Merge pull request #38 from xpodev/copilot/add-async-function-extensions
Add async function extensions for coroutine objects
2 parents f60e3fb + 9f41bc5 commit 3902b44

9 files changed

Lines changed: 245 additions & 17 deletions

File tree

.github/workflows/python-test-linux.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ jobs:
4242
pip install dist/*.whl
4343
- name: Test with pytest
4444
run: |
45-
python -m pip install typing-extensions
45+
python -m pip install typing-extensions pytest-asyncio
4646
pytest
4747
4848
build:
@@ -74,6 +74,6 @@ jobs:
7474
pip install dist/*.whl
7575
- name: Test with pytest
7676
run: |
77-
python -m pip install typing-extensions
77+
python -m pip install typing-extensions pytest-asyncio
7878
pytest
7979

.github/workflows/python-test-macos.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ jobs:
4242
pip install dist/*.whl
4343
- name: Test with pytest
4444
run: |
45-
python -m pip install typing-extensions
45+
python -m pip install typing-extensions pytest-asyncio
4646
pytest
4747
4848
build:
@@ -74,6 +74,6 @@ jobs:
7474
pip install dist/*.whl
7575
- name: Test with pytest
7676
run: |
77-
python -m pip install typing-extensions
77+
python -m pip install typing-extensions pytest-asyncio
7878
pytest
7979

.github/workflows/python-test-windows.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,6 @@ jobs:
4949
}
5050
- name: Test with pytest
5151
run: |
52-
python -m pip install typing-extensions
52+
python -m pip install typing-extensions pytest-asyncio
5353
pytest
5454

README.md

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -47,15 +47,16 @@ list_ext.extend()
4747
Currently, we provide the following extensions:
4848

4949

50-
| file | extended types |
51-
|:---------------:|:----------------------------------:|
52-
| dict_ext.py | dict_keys, dict_values, dict_items |
53-
| float_ext.py | float |
54-
| function_ext.py | FunctionType, LambdaType |
55-
| int_ext.py | int |
56-
| list_ext.py | list |
57-
| seq_ext.py | map, filter, range, zip |
58-
| str_ext.py | str |
50+
| file | extended types |
51+
|:----------------:|:----------------------------------:|
52+
| coroutine_ext.py | coroutine (async functions) |
53+
| dict_ext.py | dict_keys, dict_values, dict_items |
54+
| float_ext.py | float |
55+
| function_ext.py | FunctionType, LambdaType |
56+
| int_ext.py | int |
57+
| list_ext.py | list |
58+
| seq_ext.py | map, filter, range, zip |
59+
| str_ext.py | str |
5960

6061

6162

@@ -214,6 +215,36 @@ list.last(self: List[T]) -> T, raise IndexError
214215
```
215216
Returns the last element in the list, or raises `IndexError` if the list is empty.
216217

218+
```py
219+
coroutine.then(self: Awaitable[T], fn: Callable[[T], Awaitable[U] | U]) -> Awaitable[U]
220+
```
221+
Maps the result of the awaitable via an optionally async function. If the function is async, it is awaited in the context of the wrapped awaitable.
222+
223+
Example:
224+
```py
225+
async def get_value():
226+
return 10
227+
228+
result = await get_value().then(lambda x: x * 2) # result is 20
229+
```
230+
231+
```py
232+
coroutine.catch(self: Awaitable[T], fn: Callable[[E], Awaitable[U] | U], *, exception: type[E] = Exception) -> Awaitable[T | U]
233+
```
234+
Catches an exception of the given type and calls the passed function with the caught exception.
235+
236+
If no exception was raised inside the wrapped awaitable, the function will not be called.
237+
The passed function can optionally return a value to be returned in case of an error.
238+
The passed function can be either sync or async. If it's async, it is awaited in the context of the wrapped awaitable.
239+
240+
Example:
241+
```py
242+
async def might_fail():
243+
raise ValueError("error")
244+
245+
result = await might_fail().catch(lambda e: "default", exception=ValueError) # result is "default"
246+
```
247+
217248
```py
218249
float.round(self: float) -> int
219250
```

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,4 @@ dev = [
2323
"meson-python>=0.17.1",
2424
"ninja>=1.11.1.4",
2525
]
26-
test = ["pytest>=7.4.4", "typing-extensions>=4.7.1"]
26+
test = ["pytest>=7.4.4", "pytest-asyncio>=0.21.0", "typing-extensions>=4.7.1"]
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
from inspect import iscoroutine
2+
from typing import Awaitable, Callable, Type, TypeVar, Union
3+
4+
from ..extension_utils import extend_type_with, extension
5+
6+
7+
__all__ = [
8+
"extend",
9+
"CoroutineExtension"
10+
]
11+
12+
13+
_T = TypeVar("_T")
14+
_U = TypeVar("_U")
15+
_E = TypeVar("_E", bound=BaseException)
16+
17+
18+
class CoroutineExtension:
19+
"""
20+
A class that contains methods to extend coroutine objects (async functions).
21+
"""
22+
23+
@extension
24+
def then(self: Awaitable[_T], fn: Callable[[_T], Union[Awaitable[_U], _U]]) -> Awaitable[_U]:
25+
"""
26+
Maps the result of the awaitable via an optionally async function.
27+
28+
If the function is async, it is awaited in the context of the wrapped awaitable.
29+
30+
Args:
31+
fn: A function that takes the result of the awaitable and returns a value or awaitable.
32+
33+
Returns:
34+
An awaitable that resolves to the result of the function.
35+
"""
36+
async def _then():
37+
result = fn(await self)
38+
if iscoroutine(result):
39+
return await result
40+
return result
41+
42+
return _then()
43+
44+
@extension
45+
def catch(
46+
self: Awaitable[_T],
47+
fn: Callable[[_E], Union[Awaitable[_U], _U]],
48+
*,
49+
exception: Type[_E] = Exception
50+
) -> Awaitable[Union[_T, _U]]:
51+
"""
52+
Catches an exception of the given type and calls the passed function with the caught exception.
53+
54+
If no exception was raised inside the wrapped awaitable, the function will not be called.
55+
The passed function can optionally return a value to be returned in case of an error.
56+
The passed function can be either sync or async. If it's async, it is awaited.
57+
58+
Args:
59+
fn: A function that takes the exception and returns a value or awaitable.
60+
exception: The type of exception to catch (default: Exception).
61+
62+
Returns:
63+
An awaitable that resolves to the original result or the result of the error handler.
64+
"""
65+
async def _catch():
66+
try:
67+
return await self
68+
except exception as e:
69+
result = fn(e)
70+
if iscoroutine(result):
71+
return await result
72+
return result
73+
74+
return _catch()
75+
76+
77+
def extend():
78+
"""
79+
Applies the coroutine extensions to coroutine objects.
80+
"""
81+
# Get the coroutine type by creating a coroutine and getting its type
82+
async def _dummy():
83+
pass
84+
85+
coro = _dummy()
86+
coroutine_type = type(coro)
87+
extend_type_with(coroutine_type, CoroutineExtension)
88+
89+
# Close the coroutine to avoid warnings
90+
coro.close()

src/extype/builtin_extensions/extend_all.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
function_ext,
77
dict_ext,
88
str_ext,
9+
coroutine_ext,
910
)
1011

1112
for ext in [
@@ -16,5 +17,6 @@
1617
function_ext,
1718
dict_ext,
1819
str_ext,
20+
coroutine_ext,
1921
]:
2022
ext.extend()

src/extype/builtin_extensions/meson.build

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ python_sources = [
77
'int_ext.py',
88
'list_ext.py',
99
'seq_ext.py',
10-
'str_ext.py'
10+
'str_ext.py',
11+
'coroutine_ext.py'
1112
]
1213

1314

tests/test_builtin_extensions.py

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import pytest
2-
from extype.builtin_extensions import extend_all
2+
from extype.builtin_extensions import extend_all # noqa: F401
33

44

55
# dict keys extension tests
@@ -322,3 +322,107 @@ def test_str_to_float():
322322

323323
###################################################
324324

325+
326+
# coroutine extensions tests
327+
328+
329+
@pytest.mark.asyncio
330+
async def test_coroutine_then_sync():
331+
async def foo():
332+
return 10
333+
334+
result = await foo().then(lambda x: x + 5)
335+
assert result == 15
336+
337+
338+
@pytest.mark.asyncio
339+
async def test_coroutine_then_async():
340+
async def foo():
341+
return 10
342+
343+
async def add_five(x):
344+
return x + 5
345+
346+
result = await foo().then(add_five)
347+
assert result == 15
348+
349+
350+
@pytest.mark.asyncio
351+
async def test_coroutine_then_chaining():
352+
async def foo():
353+
return 10
354+
355+
async def add_five(x):
356+
return x + 5
357+
358+
result = await foo().then(lambda x: x * 2).then(add_five).then(lambda x: x - 3)
359+
assert result == 22 # (10 * 2) + 5 - 3 = 22
360+
361+
362+
@pytest.mark.asyncio
363+
async def test_coroutine_catch_no_exception():
364+
async def foo():
365+
return 42
366+
367+
result = await foo().catch(lambda e: 0)
368+
assert result == 42
369+
370+
371+
@pytest.mark.asyncio
372+
async def test_coroutine_catch_with_exception():
373+
async def foo():
374+
raise ValueError("test error")
375+
376+
result = await foo().catch(lambda e: 100, exception=ValueError)
377+
assert result == 100
378+
379+
380+
@pytest.mark.asyncio
381+
async def test_coroutine_catch_async_handler():
382+
async def foo():
383+
raise ValueError("test error")
384+
385+
async def handle_error(e):
386+
return 200
387+
388+
result = await foo().catch(handle_error, exception=ValueError)
389+
assert result == 200
390+
391+
392+
@pytest.mark.asyncio
393+
async def test_coroutine_catch_wrong_exception_type():
394+
async def foo():
395+
raise ValueError("test error")
396+
397+
with pytest.raises(ValueError):
398+
await foo().catch(lambda e: 0, exception=TypeError)
399+
400+
401+
@pytest.mark.asyncio
402+
async def test_coroutine_catch_default_exception():
403+
async def foo():
404+
raise RuntimeError("test error")
405+
406+
result = await foo().catch(lambda e: 300)
407+
assert result == 300
408+
409+
410+
@pytest.mark.asyncio
411+
async def test_coroutine_then_and_catch_combined():
412+
async def foo():
413+
return 10
414+
415+
result = await foo().then(lambda x: x * 2).catch(lambda e: 0)
416+
assert result == 20
417+
418+
419+
@pytest.mark.asyncio
420+
async def test_coroutine_catch_and_then_combined():
421+
async def foo():
422+
raise ValueError("error")
423+
424+
result = await foo().catch(lambda e: 50, exception=ValueError).then(lambda x: x + 10)
425+
assert result == 60
426+
427+
428+
###################################################

0 commit comments

Comments
 (0)