Skip to content

Commit f6fe095

Browse files
Add generator modifier to mark_as_sync and mark_as_async.
The calling convention markers now accept an optional generator keyword argument. Tri-state: None (default, auto), True, False. Auto preserves generator-ness across the sync/async flip -- an async generator marked sync becomes a sync generator, a sync generator marked async becomes an async generator. Explicit True or False force the corresponding bit. Both markers now always clear CO_ITERABLE_COROUTINE (the legacy @types.coroutine flag), which is incompatible with either assertion. Tests added cover the generator parameter combinations, descriptor binding, and stacking of the markers over with_signature. The marker and signature decorators touch disjoint bits of co_flags so they compose cleanly in either stacking order. Docs in bundled.rst extended with a generator subsection under the markers section and a new subsection under with_signature explaining how to combine the two.
1 parent 9ee898a commit f6fe095

6 files changed

Lines changed: 506 additions & 30 deletions

File tree

docs/bundled.rst

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,60 @@ changes what ``iscoroutinefunction()`` reports. The marker wrappers are for
468468
annotating stacks whose effective convention has already been established by
469469
other decorators.
470470

471+
Marking generator convention
472+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
473+
474+
Both ``mark_as_sync`` and ``mark_as_async`` accept an optional
475+
``generator`` keyword to control the reported generator-ness of the
476+
wrapper. This is the modifier on top of the primary sync/async axis, and
477+
lets the markers address all four realistic callable kinds: plain
478+
function, sync generator, coroutine function, and async generator.
479+
480+
The parameter is tri-state:
481+
482+
- ``None`` (default): auto. Preserve generator-ness from the input. For
483+
``mark_as_sync``, an async generator input becomes a sync generator;
484+
other inputs keep their ``CO_GENERATOR`` bit as-is. For
485+
``mark_as_async``, any generator input (sync or async) becomes an
486+
async generator; non-generator input becomes a coroutine function.
487+
- ``True``: force generator reporting on. ``mark_as_sync(generator=True)``
488+
sets ``CO_GENERATOR``; ``mark_as_async(generator=True)`` sets
489+
``CO_ASYNC_GENERATOR`` (and clears ``CO_COROUTINE`` since the two
490+
are mutually exclusive at the CPython code-object level).
491+
- ``False``: force generator reporting off. ``mark_as_sync(generator=False)``
492+
clears ``CO_GENERATOR``; ``mark_as_async(generator=False)`` sets
493+
``CO_COROUTINE`` and clears ``CO_ASYNC_GENERATOR`` and ``CO_GENERATOR``.
494+
495+
::
496+
497+
# An upstream decorator collects items from an async generator into
498+
# a list and returns it synchronously. Mark the resulting callable
499+
# as a plain sync function (default auto would flip
500+
# CO_ASYNC_GENERATOR into CO_GENERATOR and the wrapper would report
501+
# as a sync generator, which is wrong here -- the real return is a
502+
# list).
503+
504+
@wrapt.mark_as_sync(generator=False)
505+
@collect_async_generator_to_list
506+
async def stream(...):
507+
yield ...
508+
509+
::
510+
511+
# A sync generator is being exposed through an adapter that wraps
512+
# each yielded item in an async future. Mark it as an async
513+
# generator so consumers using ``async for`` see the expected
514+
# introspection.
515+
516+
@wrapt.mark_as_async(generator=True)
517+
@async_wrap_yielded_items
518+
def produce(...):
519+
yield ...
520+
521+
Both markers always clear ``CO_ITERABLE_COROUTINE`` (the legacy
522+
``@types.coroutine`` flag), regardless of ``generator``, since that
523+
flag's meaning is incompatible with either marker's assertion.
524+
471525
Bridging between conventions
472526
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
473527

@@ -701,3 +755,49 @@ chain.
701755
...
702756

703757
# inspect.signature(function) still reports the prototype's signature.
758+
759+
Combining with calling-convention markers
760+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
761+
762+
``with_signature`` controls *what the arguments look like*, but it does
763+
not assert anything about the *calling convention* of the resulting
764+
wrapper -- whether it is a coroutine function, a generator, an async
765+
generator, or a plain sync function. Those concerns are expressed by
766+
``mark_as_sync`` and ``mark_as_async``, described in the "Calling
767+
Convention Markers and Adapters" section above. The two concerns are
768+
deliberately orthogonal: ``with_signature`` only modifies the
769+
argument-related ``co_flags`` bits (``CO_VARARGS`` and
770+
``CO_VARKEYWORDS``, derived from the signature), while the markers only
771+
modify the calling-convention bits (``CO_COROUTINE``,
772+
``CO_ASYNC_GENERATOR``, ``CO_GENERATOR``, ``CO_ITERABLE_COROUTINE``).
773+
774+
Because the two decorators touch disjoint bits, they compose cleanly
775+
when stacked. The conventional ordering is the marker on top, the
776+
signature override underneath, but both orders produce the same
777+
result:
778+
779+
::
780+
781+
@wrapt.mark_as_sync
782+
@wrapt.with_signature(prototype=_prototype)
783+
async def real(*args, **kwargs):
784+
# inspect.iscoroutinefunction(real) -> False (from mark_as_sync)
785+
# inspect.signature(real) -> prototype's signature
786+
...
787+
788+
When the underlying callable is a generator of some kind and the
789+
surrounding stack has changed that convention, the ``generator``
790+
keyword on the markers is the right modifier:
791+
792+
::
793+
794+
@wrapt.mark_as_async(generator=True)
795+
@wrapt.with_signature(prototype=_prototype)
796+
def real(*args, **kwargs):
797+
# inspect.isasyncgenfunction(real) -> True
798+
# inspect.signature(real) -> prototype's signature
799+
yield ...
800+
801+
Use ``with_signature`` alone when only the signature needs correcting;
802+
stack with a marker when the calling convention also needs to be
803+
asserted independently of the wrapped function's own declaration.

docs/changes.rst

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -78,11 +78,15 @@ their help is much appreciated.
7878
``inspect.iscoroutinefunction()`` reports the intended convention,
7979
letting ``synchronized`` auto-select the correct sync or async wrapping
8080
behaviour even when an upstream decorator has changed the effective
81-
calling convention. ``async_to_sync`` runs an async callable to
82-
completion via ``asyncio.run()``, and ``sync_to_async`` dispatches a
83-
sync callable onto the default executor via ``loop.run_in_executor()``;
84-
both self-mark so they integrate with ``synchronized`` without needing
85-
an additional marker decorator. The naming of ``async_to_sync`` and
81+
calling convention. Both markers take an optional ``generator`` keyword
82+
(tri-state: ``None`` / ``True`` / ``False``) controlling the reported
83+
generator bit, so all four callable kinds (plain function, sync
84+
generator, coroutine function, async generator) can be asserted.
85+
``async_to_sync`` runs an async callable to completion via
86+
``asyncio.run()``, and ``sync_to_async`` dispatches a sync callable
87+
onto the default executor via ``loop.run_in_executor()``; both
88+
self-mark so they integrate with ``synchronized`` without needing an
89+
additional marker decorator. The naming of ``async_to_sync`` and
8690
``sync_to_async`` follows the convention used by ``asgiref``. See the
8791
"Calling Convention Markers and Adapters" section of :doc:`bundled` for
8892
details.

src/wrapt/__init__.pyi

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -396,8 +396,18 @@ if sys.version_info >= (3, 10):
396396

397397
# mark_as_sync(), mark_as_async(), async_to_sync(), sync_to_async()
398398

399-
def mark_as_sync(wrapped: Callable[P, R]) -> Callable[P, R]: ...
400-
def mark_as_async(wrapped: Callable[P, R]) -> Callable[P, R]: ...
399+
@overload
400+
def mark_as_sync(wrapped: Callable[P, R], /) -> Callable[P, R]: ...
401+
@overload
402+
def mark_as_sync(
403+
*, generator: bool | None = None
404+
) -> Callable[[Callable[P, R]], Callable[P, R]]: ...
405+
@overload
406+
def mark_as_async(wrapped: Callable[P, R], /) -> Callable[P, R]: ...
407+
@overload
408+
def mark_as_async(
409+
*, generator: bool | None = None
410+
) -> Callable[[Callable[P, R]], Callable[P, R]]: ...
401411
def async_to_sync(wrapped: Callable[P, R]) -> Callable[P, R]: ...
402412
def sync_to_async(wrapped: Callable[P, R]) -> Callable[P, R]: ...
403413

src/wrapt/synchronization.py

Lines changed: 122 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,13 @@
99
import asyncio
1010
import sys
1111
from functools import partial
12-
from inspect import CO_ASYNC_GENERATOR, CO_COROUTINE, iscoroutinefunction
12+
from inspect import (
13+
CO_ASYNC_GENERATOR,
14+
CO_COROUTINE,
15+
CO_GENERATOR,
16+
CO_ITERABLE_COROUTINE,
17+
iscoroutinefunction,
18+
)
1319
from threading import Lock, RLock
1420

1521
from .__wrapt__ import BoundFunctionWrapper, CallableObjectProxy, FunctionWrapper
@@ -23,21 +29,40 @@
2329
# inner decorator that invokes an async def via asyncio.run()).
2430

2531

26-
_CO_SYNC_MASK = ~(CO_COROUTINE | CO_ASYNC_GENERATOR)
27-
28-
2932
class _SyncCodeProxy(CallableObjectProxy):
3033

34+
def __init__(self, wrapped, generator=None):
35+
super().__init__(wrapped)
36+
self._self_generator = generator
37+
3138
@property
3239
def co_flags(self):
33-
return self.__wrapped__.co_flags & _CO_SYNC_MASK
40+
original = self.__wrapped__.co_flags
41+
# Strip async-axis and iterable-coroutine bits; sync means neither
42+
# coroutine function nor async generator nor types.coroutine-style.
43+
flags = original & ~(CO_COROUTINE | CO_ASYNC_GENERATOR | CO_ITERABLE_COROUTINE)
44+
if self._self_generator is True:
45+
flags |= CO_GENERATOR
46+
elif self._self_generator is False:
47+
flags &= ~CO_GENERATOR
48+
else:
49+
# Auto: if input was an async generator, preserve generator-ness
50+
# on the sync side by setting CO_GENERATOR. Otherwise leave
51+
# CO_GENERATOR as-is (already copied from the wrapped flags).
52+
if original & CO_ASYNC_GENERATOR:
53+
flags |= CO_GENERATOR
54+
return flags
3455

3556

3657
class _SyncFunctionSurrogate(CallableObjectProxy):
3758

59+
def __init__(self, wrapped, generator=None):
60+
super().__init__(wrapped)
61+
self._self_generator = generator
62+
3863
@property
3964
def __code__(self):
40-
return _SyncCodeProxy(self.__wrapped__.__code__)
65+
return _SyncCodeProxy(self.__wrapped__.__code__, self._self_generator)
4166

4267

4368
class _BoundSyncFunctionWrapper(BoundFunctionWrapper):
@@ -48,77 +73,151 @@ def __init__(self, *args, **kwargs):
4873

4974
@property
5075
def __func__(self):
51-
return _SyncFunctionSurrogate(self.__wrapped__.__func__)
76+
return _SyncFunctionSurrogate(
77+
self.__wrapped__.__func__, self._self_parent._self_generator
78+
)
5279

5380

5481
class _SyncFunctionWrapper(FunctionWrapper):
5582

5683
__bound_function_wrapper__ = _BoundSyncFunctionWrapper
5784

58-
def __init__(self, *args, **kwargs):
59-
super().__init__(*args, **kwargs)
85+
def __init__(self, wrapped, wrapper, generator=None):
86+
super().__init__(wrapped, wrapper)
6087
self._self_is_not_coroutine = True
88+
self._self_generator = generator
6189

6290
@property
6391
def __code__(self):
64-
return _SyncCodeProxy(self.__wrapped__.__code__)
92+
return _SyncCodeProxy(self.__wrapped__.__code__, self._self_generator)
6593

6694

6795
class _AsyncCodeProxy(CallableObjectProxy):
6896

97+
def __init__(self, wrapped, generator=None):
98+
super().__init__(wrapped)
99+
self._self_generator = generator
100+
69101
@property
70102
def co_flags(self):
71-
return (self.__wrapped__.co_flags & ~CO_ASYNC_GENERATOR) | CO_COROUTINE
103+
original = self.__wrapped__.co_flags
104+
# Strip all four convention bits; we reassert the right ones below.
105+
flags = original & ~(
106+
CO_GENERATOR | CO_COROUTINE | CO_ITERABLE_COROUTINE | CO_ASYNC_GENERATOR
107+
)
108+
if self._self_generator is True:
109+
flags |= CO_ASYNC_GENERATOR
110+
elif self._self_generator is False:
111+
flags |= CO_COROUTINE
112+
else:
113+
# Auto: if input was a generator (sync or async), produce an
114+
# async generator; otherwise produce a coroutine function.
115+
if original & (CO_GENERATOR | CO_ASYNC_GENERATOR):
116+
flags |= CO_ASYNC_GENERATOR
117+
else:
118+
flags |= CO_COROUTINE
119+
return flags
72120

73121

74122
class _AsyncFunctionSurrogate(CallableObjectProxy):
75123

124+
def __init__(self, wrapped, generator=None):
125+
super().__init__(wrapped)
126+
self._self_generator = generator
127+
76128
@property
77129
def __code__(self):
78-
return _AsyncCodeProxy(self.__wrapped__.__code__)
130+
return _AsyncCodeProxy(self.__wrapped__.__code__, self._self_generator)
79131

80132

81133
class _BoundAsyncFunctionWrapper(BoundFunctionWrapper):
82134

83135
@property
84136
def __func__(self):
85-
return _AsyncFunctionSurrogate(self.__wrapped__.__func__)
137+
return _AsyncFunctionSurrogate(
138+
self.__wrapped__.__func__, self._self_parent._self_generator
139+
)
86140

87141

88142
class _AsyncFunctionWrapper(FunctionWrapper):
89143

90144
__bound_function_wrapper__ = _BoundAsyncFunctionWrapper
91145

146+
def __init__(self, wrapped, wrapper, generator=None):
147+
super().__init__(wrapped, wrapper)
148+
self._self_generator = generator
149+
92150
@property
93151
def __code__(self):
94-
return _AsyncCodeProxy(self.__wrapped__.__code__)
152+
return _AsyncCodeProxy(self.__wrapped__.__code__, self._self_generator)
95153

96154

97-
def mark_as_sync(wrapped):
155+
def mark_as_sync(wrapped=None, /, *, generator=None):
98156
"""Mark a callable as synchronous from the perspective of calling
99157
convention detection. The returned wrapper is a pass-through that
100158
reports `inspect.iscoroutinefunction()` as False regardless of
101159
whether the underlying callable is declared `async def`. Useful
102160
when a stacked decorator has already collapsed an async function
103-
into a synchronous one (for example by using `asyncio.run()`)."""
161+
into a synchronous one (for example by using `asyncio.run()`).
104162
105-
def wrapper(wrapped, instance, args, kwargs):
106-
return wrapped(*args, **kwargs)
163+
The `generator` keyword toggles the sync generator bit
164+
(`CO_GENERATOR`) on the resulting wrapper. Tri-state:
107165
108-
return _SyncFunctionWrapper(wrapped, wrapper)
166+
- `None` (default): auto. Preserve generator-ness from the input --
167+
if the input was an async generator, the wrapper reports as a sync
168+
generator; otherwise CO_GENERATOR is copied through unchanged.
169+
- `True`: force CO_GENERATOR on. Wrapper reports as a sync generator.
170+
- `False`: force CO_GENERATOR off. Wrapper reports as a plain sync
171+
function even if the input had CO_GENERATOR set.
172+
173+
Regardless of `generator`, CO_COROUTINE, CO_ASYNC_GENERATOR, and
174+
CO_ITERABLE_COROUTINE are all cleared (sync means none of those).
175+
"""
176+
177+
def _decorator(wrapped):
178+
def _wrapper(wrapped, instance, args, kwargs):
179+
return wrapped(*args, **kwargs)
109180

181+
return _SyncFunctionWrapper(wrapped, _wrapper, generator=generator)
110182

111-
def mark_as_async(wrapped):
183+
if wrapped is None:
184+
return _decorator
185+
return _decorator(wrapped)
186+
187+
188+
def mark_as_async(wrapped=None, /, *, generator=None):
112189
"""Mark a callable as asynchronous from the perspective of calling
113190
convention detection. The returned wrapper reports
114191
`inspect.iscoroutinefunction()` as True regardless of whether the
115192
underlying callable is declared `async def`. Useful when a stacked
116-
decorator returns a coroutine from a plain `def` wrapper."""
193+
decorator returns a coroutine from a plain `def` wrapper.
194+
195+
The `generator` keyword chooses between coroutine function and
196+
async generator reporting. Tri-state:
197+
198+
- `None` (default): auto. If the input was a sync or async
199+
generator, the wrapper reports as an async generator
200+
(`CO_ASYNC_GENERATOR`); otherwise it reports as a coroutine
201+
function (`CO_COROUTINE`).
202+
- `True`: force async generator reporting (`CO_ASYNC_GENERATOR` set,
203+
`CO_COROUTINE` cleared). These two flags are mutually exclusive at
204+
the CPython code-object level.
205+
- `False`: force coroutine function reporting (`CO_COROUTINE` set,
206+
`CO_ASYNC_GENERATOR` cleared).
207+
208+
CO_GENERATOR and CO_ITERABLE_COROUTINE are always cleared (the
209+
async path does not use either).
210+
"""
117211

118-
async def wrapper(wrapped, instance, args, kwargs):
212+
async def _wrapper(wrapped, instance, args, kwargs):
119213
return wrapped(*args, **kwargs)
120214

121-
return _AsyncFunctionWrapper(wrapped, wrapper)
215+
def _decorator(wrapped):
216+
return _AsyncFunctionWrapper(wrapped, _wrapper, generator=generator)
217+
218+
if wrapped is None:
219+
return _decorator
220+
return _decorator(wrapped)
122221

123222

124223
def async_to_sync(wrapped):

0 commit comments

Comments
 (0)