Skip to content

Commit 7748b6c

Browse files
authored
Add support for async generators to "@logger.catch" (#1303)
The implementation is ugly because of Python 3.5 required compatibility ("yield" inside "async def" would generate "SyntaxError"). Also, note that currently async generator do not support arbitrary return values, but this might change (there is an opened PR at the time of writing).
1 parent 7b6f6e3 commit 7748b6c

File tree

9 files changed

+221
-3
lines changed

9 files changed

+221
-3
lines changed

CHANGELOG.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
- Update the default log format to include the timezone offset since it produces less ambiguous logs (`#856 <https://github.com/Delgan/loguru/pull/856>`_, thanks `@tim-x-y-z <https://github.com/tim-x-y-z>`_).
88
- Honor the ``NO_COLOR`` environment variable to disable color output by default if ``colorize`` is not provided (`#1178 <https://github.com/Delgan/loguru/issues/1178>`_).
99
- Add requirement for ``TERM`` environment variable not to be ``"dumb"`` to enable colorization (`#1287 <https://github.com/Delgan/loguru/pull/1287>`_, thanks `@snosov1 <https://github.com/snosov1>`_).
10+
- Make ``logger.catch()`` compatible with asynchronous generators (`#1302 <https://github.com/Delgan/loguru/issues/1302>`_).
11+
1012

1113
`0.7.3`_ (2024-12-06)
1214
=====================

loguru/_logger.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,10 +114,16 @@
114114
from ._simple_sinks import AsyncSink, CallableSink, StandardSink, StreamSink
115115

116116
if sys.version_info >= (3, 6):
117+
from collections.abc import AsyncGenerator
118+
from inspect import isasyncgenfunction
117119
from os import PathLike
120+
118121
else:
119122
from pathlib import PurePath as PathLike
120123

124+
def isasyncgenfunction(func):
125+
return False
126+
121127

122128
Level = namedtuple("Level", ["name", "no", "color", "icon"]) # noqa: PYI024
123129

@@ -1293,6 +1299,30 @@ def catch_wrapper(*args, **kwargs):
12931299
return (yield from function(*args, **kwargs))
12941300
return default
12951301

1302+
elif isasyncgenfunction(function):
1303+
1304+
class AsyncGenCatchWrapper(AsyncGenerator):
1305+
1306+
def __init__(self, gen):
1307+
self._gen = gen
1308+
1309+
async def asend(self, value):
1310+
with catcher:
1311+
try:
1312+
return await self._gen.asend(value)
1313+
except StopAsyncIteration:
1314+
pass
1315+
except:
1316+
raise
1317+
raise StopAsyncIteration
1318+
1319+
async def athrow(self, *args, **kwargs):
1320+
return await self._gen.athrow(*args, **kwargs)
1321+
1322+
def catch_wrapper(*args, **kwargs):
1323+
gen = function(*args, **kwargs)
1324+
return AsyncGenCatchWrapper(gen)
1325+
12961326
else:
12971327

12981328
def catch_wrapper(*args, **kwargs):

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,9 @@ convention = "numpy"
133133
[tool.typos.default]
134134
extend-ignore-re = ["(?Rm)^.*# spellchecker: disable-line$"]
135135

136+
[tool.typos.default.extend-identifiers]
137+
asend = "asend"
138+
136139
[tool.typos.files]
137140
extend-exclude = [
138141
"tests/exceptions/output/**", # False positive due to ansi sequences.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Done
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
2+
Traceback (most recent call last):
3+
File "tests/exceptions/source/modern/exception_formatting_async_generator.py", line 20, in <module>
4+
f.send(None)
5+
File "tests/exceptions/source/modern/exception_formatting_async_generator.py", line 14, in foo
6+
yield a / b
7+
ZeroDivisionError: division by zero
8+
9+
Traceback (most recent call last):
10+
11+
File "tests/exceptions/source/modern/exception_formatting_async_generator.py", line 20, in <module>
12+
f.send(None)
13+
│ └ <method 'send' of 'coroutine' objects>
14+
└ <coroutine object Logger.catch.<locals>.Catcher.__call__.<locals>.AsyncGenCatchWrapper.asend at 0xDEADBEEF>
15+
16+
File "tests/exceptions/source/modern/exception_formatting_async_generator.py", line 14, in foo
17+
yield a / b
18+
│ └ 0
19+
└ 1
20+
21+
ZeroDivisionError: division by zero
22+
23+
Traceback (most recent call last):
24+
> File "tests/exceptions/source/modern/exception_formatting_async_generator.py", line 20, in <module>
25+
f.send(None)
26+
File "tests/exceptions/source/modern/exception_formatting_async_generator.py", line 14, in foo
27+
yield a / b
28+
ZeroDivisionError: division by zero
29+
30+
Traceback (most recent call last):
31+
32+
> File "tests/exceptions/source/modern/exception_formatting_async_generator.py", line 20, in <module>
33+
f.send(None)
34+
│ └ <method 'send' of 'coroutine' objects>
35+
└ <coroutine object Logger.catch.<locals>.Catcher.__call__.<locals>.AsyncGenCatchWrapper.asend at 0xDEADBEEF>
36+
37+
File "tests/exceptions/source/modern/exception_formatting_async_generator.py", line 14, in foo
38+
yield a / b
39+
│ └ 0
40+
└ 1
41+
42+
ZeroDivisionError: division by zero
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
from loguru import logger
2+
import asyncio
3+
import sys
4+
5+
logger.remove()
6+
7+
# We're truly only testing whether the tests succeed, we do not care about the formatting.
8+
# These should be regular Pytest test cases, but that is not possible because the syntax is not valid in Python 3.5.
9+
logger.add(lambda m: None, format="", diagnose=True, backtrace=True, colorize=True)
10+
11+
def test_decorate_async_generator():
12+
@logger.catch(reraise=True)
13+
async def generator(x, y):
14+
yield x
15+
yield y
16+
17+
async def coro():
18+
out = []
19+
async for val in generator(1, 2):
20+
out.append(val)
21+
return out
22+
23+
res = asyncio.run(coro())
24+
assert res == [1, 2]
25+
26+
27+
def test_decorate_async_generator_with_error():
28+
@logger.catch(reraise=False)
29+
async def generator(x, y):
30+
yield x
31+
yield y
32+
raise ValueError
33+
34+
async def coro():
35+
out = []
36+
async for val in generator(1, 2):
37+
out.append(val)
38+
return out
39+
40+
res = asyncio.run(coro())
41+
assert res == [1, 2]
42+
43+
def test_decorate_async_generator_with_error_reraised():
44+
@logger.catch(reraise=True)
45+
async def generator(x, y):
46+
yield x
47+
yield y
48+
raise ValueError
49+
50+
async def coro():
51+
out = []
52+
try:
53+
async for val in generator(1, 2):
54+
out.append(val)
55+
except ValueError:
56+
pass
57+
else:
58+
raise AssertionError("ValueError not raised")
59+
return out
60+
61+
res = asyncio.run(coro())
62+
assert res == [1, 2]
63+
64+
65+
def test_decorate_async_generator_then_async_send():
66+
@logger.catch
67+
async def generator(x, y):
68+
yield x
69+
yield y
70+
71+
async def coro():
72+
gen = generator(1, 2)
73+
await gen.asend(None)
74+
await gen.asend(None)
75+
try:
76+
await gen.asend(None)
77+
except StopAsyncIteration:
78+
pass
79+
else:
80+
raise AssertionError("StopAsyncIteration not raised")
81+
82+
asyncio.run(coro())
83+
84+
85+
def test_decorate_async_generator_then_async_throw():
86+
@logger.catch
87+
async def generator(x, y):
88+
yield x
89+
yield y
90+
91+
async def coro():
92+
gen = generator(1, 2)
93+
await gen.asend(None)
94+
try:
95+
await gen.athrow(ValueError)
96+
except ValueError:
97+
pass
98+
else:
99+
raise AssertionError("ValueError not raised")
100+
101+
asyncio.run(coro())
102+
103+
104+
test_decorate_async_generator()
105+
test_decorate_async_generator_with_error()
106+
test_decorate_async_generator_with_error_reraised()
107+
test_decorate_async_generator_then_async_send()
108+
test_decorate_async_generator_then_async_throw()
109+
110+
logger.add(sys.stderr, format="{message}")
111+
logger.info("Done")
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import sys
2+
3+
from loguru import logger
4+
5+
logger.remove()
6+
logger.add(sys.stderr, format="", diagnose=False, backtrace=False, colorize=False)
7+
logger.add(sys.stderr, format="", diagnose=True, backtrace=False, colorize=False)
8+
logger.add(sys.stderr, format="", diagnose=False, backtrace=True, colorize=False)
9+
logger.add(sys.stderr, format="", diagnose=True, backtrace=True, colorize=False)
10+
11+
12+
@logger.catch
13+
async def foo(a, b):
14+
yield a / b
15+
16+
17+
f = foo(1, 0).asend(None)
18+
19+
try:
20+
f.send(None)
21+
except StopAsyncIteration:
22+
pass

tests/test_exceptions_catch.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -407,9 +407,9 @@ def foo(x, y, z):
407407
def test_decorate_generator_with_error():
408408
@logger.catch
409409
def foo():
410-
for i in range(3):
411-
1 / (2 - i)
412-
yield i
410+
yield 0
411+
yield 1
412+
raise ValueError
413413

414414
assert list(foo()) == [0, 1]
415415

tests/test_exceptions_formatting.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,16 +23,21 @@ def normalize(exception):
2323

2424
def fix_filepath(match):
2525
filepath = match.group(1)
26+
27+
# Pattern to check if the filepath contains ANSI escape codes.
2628
pattern = (
2729
r'((?:\x1b\[[0-9]*m)+)([^"]+?)((?:\x1b\[[0-9]*m)+)([^"]+?)((?:\x1b\[[0-9]*m)+)'
2830
)
31+
2932
match = re.match(pattern, filepath)
3033
start_directory = os.path.dirname(os.path.dirname(__file__))
3134
if match:
35+
# Simplify the path while preserving the color highlighting of the file basename.
3236
groups = list(match.groups())
3337
groups[1] = os.path.relpath(os.path.abspath(groups[1]), start_directory) + "/"
3438
relpath = "".join(groups)
3539
else:
40+
# We can straightforwardly convert from absolute to relative path.
3641
relpath = os.path.relpath(os.path.abspath(filepath), start_directory)
3742
return 'File "%s"' % relpath.replace("\\", "/")
3843

@@ -241,6 +246,8 @@ def test_exception_others(filename):
241246
("filename", "minimum_python_version"),
242247
[
243248
("type_hints", (3, 6)),
249+
("exception_formatting_async_generator", (3, 6)),
250+
("decorate_async_generator", (3, 7)),
244251
("positional_only_argument", (3, 8)),
245252
("walrus_operator", (3, 8)),
246253
("match_statement", (3, 10)),

0 commit comments

Comments
 (0)