Skip to content
Closed
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
6 changes: 6 additions & 0 deletions Doc/library/functools.rst
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ The :mod:`functools` module defines the following functions:

.. versionadded:: 3.9

.. versionchanged:: 3.11
Added support for caching an async function.


.. decorator:: cached_property(func)

Expand Down Expand Up @@ -251,6 +254,9 @@ The :mod:`functools` module defines the following functions:
.. versionadded:: 3.9
Added the function :func:`cache_parameters`

.. versionchanged:: 3.11
Added support for caching an async function.

.. decorator:: total_ordering

Given a class defining one or more rich comparison ordering methods, this
Expand Down
33 changes: 32 additions & 1 deletion Lib/functools.py
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,29 @@ def _make_key(args, kwds, typed,
return key[0]
return _HashedSeq(key)

class _CachedAwaitable:
"""Wrapper to cache when a cache decorator is applied on a coroutine function.

A coroutine shouldn't simply be cached since it can only be awaited once.
This wrapper is cached instead, which awaits the underlying coroutine at
most once and caches the result for subsequent calls.
"""

def __init__(self, coro):
self.coro = coro
self.lock = RLock()
self.cache = _NOT_FOUND

def __await__(self):
if self.cache is not _NOT_FOUND:
return self.cache
with self.lock:
# check if another thread got the result while we awaited lock
if self.cache is not _NOT_FOUND:
return self.cache
self.cache = yield from self.coro.__await__()
return self.cache

def lru_cache(maxsize=128, typed=False):
"""Least-recently-used cache decorator.

Expand Down Expand Up @@ -516,7 +539,15 @@ def lru_cache(maxsize=128, typed=False):
'Expected first argument to be an integer, a callable, or None')

def decorating_function(user_function):
wrapper = _lru_cache_wrapper(user_function, maxsize, typed, _CacheInfo)
import inspect

if inspect.iscoroutinefunction(user_function):
def wrapped_user_function(*args, **kwargs):
return _CachedAwaitable(user_function(*args, **kwargs))
else:
wrapped_user_function = user_function

wrapper = _lru_cache_wrapper(wrapped_user_function, maxsize, typed, _CacheInfo)
wrapper.cache_parameters = lambda : {'maxsize': maxsize, 'typed': typed}
return update_wrapper(wrapper, user_function)

Expand Down
17 changes: 17 additions & 0 deletions Lib/test/support/async_helper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import asyncio
import functools


def async_test(func):
"""Decorator to turn an async function into a test case."""
@functools.wraps(func)
def wrapper(*args, **kwargs):
coro = func(*args, **kwargs)
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
return loop.run_until_complete(coro)
finally:
loop.close()
asyncio.set_event_loop_policy(None)
return wrapper
75 changes: 30 additions & 45 deletions Lib/test/test_contextlib_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,16 @@
from contextlib import (
asynccontextmanager, AbstractAsyncContextManager,
AsyncExitStack, nullcontext, aclosing, contextmanager)
import functools
from test import support
from test.support.async_helper import async_test
import unittest

from test.test_contextlib import TestBaseExitStack


def _async_test(func):
"""Decorator to turn an async function into a test case."""
@functools.wraps(func)
def wrapper(*args, **kwargs):
coro = func(*args, **kwargs)
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
return loop.run_until_complete(coro)
finally:
loop.close()
asyncio.set_event_loop_policy(None)
return wrapper


class TestAbstractAsyncContextManager(unittest.TestCase):

@_async_test
@async_test
async def test_enter(self):
class DefaultEnter(AbstractAsyncContextManager):
async def __aexit__(self, *args):
Expand All @@ -38,7 +23,7 @@ async def __aexit__(self, *args):
async with manager as context:
self.assertIs(manager, context)

@_async_test
@async_test
async def test_async_gen_propagates_generator_exit(self):
# A regression test for https://bugs.python.org/issue33786.

Expand Down Expand Up @@ -95,7 +80,7 @@ class NoneAexit(ManagerFromScratch):

class AsyncContextManagerTestCase(unittest.TestCase):

@_async_test
@async_test
async def test_contextmanager_plain(self):
state = []
@asynccontextmanager
Expand All @@ -109,7 +94,7 @@ async def woohoo():
state.append(x)
self.assertEqual(state, [1, 42, 999])

@_async_test
@async_test
async def test_contextmanager_finally(self):
state = []
@asynccontextmanager
Expand All @@ -127,7 +112,7 @@ async def woohoo():
raise ZeroDivisionError()
self.assertEqual(state, [1, 42, 999])

@_async_test
@async_test
async def test_contextmanager_no_reraise(self):
@asynccontextmanager
async def whee():
Expand All @@ -137,7 +122,7 @@ async def whee():
# Calling __aexit__ should not result in an exception
self.assertFalse(await ctx.__aexit__(TypeError, TypeError("foo"), None))

@_async_test
@async_test
async def test_contextmanager_trap_yield_after_throw(self):
@asynccontextmanager
async def whoo():
Expand All @@ -150,7 +135,7 @@ async def whoo():
with self.assertRaises(RuntimeError):
await ctx.__aexit__(TypeError, TypeError('foo'), None)

@_async_test
@async_test
async def test_contextmanager_trap_no_yield(self):
@asynccontextmanager
async def whoo():
Expand All @@ -160,7 +145,7 @@ async def whoo():
with self.assertRaises(RuntimeError):
await ctx.__aenter__()

@_async_test
@async_test
async def test_contextmanager_trap_second_yield(self):
@asynccontextmanager
async def whoo():
Expand All @@ -171,7 +156,7 @@ async def whoo():
with self.assertRaises(RuntimeError):
await ctx.__aexit__(None, None, None)

@_async_test
@async_test
async def test_contextmanager_non_normalised(self):
@asynccontextmanager
async def whoo():
Expand All @@ -185,7 +170,7 @@ async def whoo():
with self.assertRaises(SyntaxError):
await ctx.__aexit__(RuntimeError, None, None)

@_async_test
@async_test
async def test_contextmanager_except(self):
state = []
@asynccontextmanager
Expand All @@ -203,7 +188,7 @@ async def woohoo():
raise ZeroDivisionError(999)
self.assertEqual(state, [1, 42, 999])

@_async_test
@async_test
async def test_contextmanager_except_stopiter(self):
@asynccontextmanager
async def woohoo():
Expand All @@ -230,7 +215,7 @@ class StopAsyncIterationSubclass(StopAsyncIteration):
else:
self.fail(f'{stop_exc} was suppressed')

@_async_test
@async_test
async def test_contextmanager_wrap_runtimeerror(self):
@asynccontextmanager
async def woohoo():
Expand Down Expand Up @@ -275,14 +260,14 @@ def test_contextmanager_doc_attrib(self):
self.assertEqual(baz.__doc__, "Whee!")

@support.requires_docstrings
@_async_test
@async_test
async def test_instance_docstring_given_cm_docstring(self):
baz = self._create_contextmanager_attribs()(None)
self.assertEqual(baz.__doc__, "Whee!")
async with baz:
pass # suppress warning

@_async_test
@async_test
async def test_keywords(self):
# Ensure no keyword arguments are inhibited
@asynccontextmanager
Expand All @@ -291,7 +276,7 @@ async def woohoo(self, func, args, kwds):
async with woohoo(self=11, func=22, args=33, kwds=44) as target:
self.assertEqual(target, (11, 22, 33, 44))

@_async_test
@async_test
async def test_recursive(self):
depth = 0
ncols = 0
Expand All @@ -318,7 +303,7 @@ async def recursive():
self.assertEqual(ncols, 10)
self.assertEqual(depth, 0)

@_async_test
@async_test
async def test_decorator(self):
entered = False

Expand All @@ -337,7 +322,7 @@ async def test():
await test()
self.assertFalse(entered)

@_async_test
@async_test
async def test_decorator_with_exception(self):
entered = False

Expand All @@ -360,7 +345,7 @@ async def test():
await test()
self.assertFalse(entered)

@_async_test
@async_test
async def test_decorating_method(self):

@asynccontextmanager
Expand Down Expand Up @@ -403,7 +388,7 @@ def test_instance_docs(self):
obj = aclosing(None)
self.assertEqual(obj.__doc__, cm_docstring)

@_async_test
@async_test
async def test_aclosing(self):
state = []
class C:
Expand All @@ -415,7 +400,7 @@ async def aclose(self):
self.assertEqual(x, y)
self.assertEqual(state, [1])

@_async_test
@async_test
async def test_aclosing_error(self):
state = []
class C:
Expand All @@ -429,7 +414,7 @@ async def aclose(self):
1 / 0
self.assertEqual(state, [1])

@_async_test
@async_test
async def test_aclosing_bpo41229(self):
state = []

Expand Down Expand Up @@ -493,7 +478,7 @@ def setUp(self):
self.addCleanup(self.loop.close)
self.addCleanup(asyncio.set_event_loop_policy, None)

@_async_test
@async_test
async def test_async_callback(self):
expected = [
((), {}),
Expand Down Expand Up @@ -536,7 +521,7 @@ async def _exit(*args, **kwds):
stack.push_async_callback(callback=_exit, arg=3)
self.assertEqual(result, [])

@_async_test
@async_test
async def test_async_push(self):
exc_raised = ZeroDivisionError
async def _expect_exc(exc_type, exc, exc_tb):
Expand Down Expand Up @@ -572,7 +557,7 @@ async def __aexit__(self, *exc_details):
self.assertIs(stack._exit_callbacks[-1][1], _expect_exc)
1/0

@_async_test
@async_test
async def test_enter_async_context(self):
class TestCM(object):
async def __aenter__(self):
Expand All @@ -594,7 +579,7 @@ async def _exit():

self.assertEqual(result, [1, 2, 3, 4])

@_async_test
@async_test
async def test_enter_async_context_errors(self):
class LacksEnterAndExit:
pass
Expand All @@ -614,7 +599,7 @@ async def __aenter__(self):
await stack.enter_async_context(LacksExit())
self.assertFalse(stack._exit_callbacks)

@_async_test
@async_test
async def test_async_exit_exception_chaining(self):
# Ensure exception chaining matches the reference behaviour
async def raise_exc(exc):
Expand Down Expand Up @@ -646,7 +631,7 @@ async def suppress_exc(*exc_details):
self.assertIsInstance(inner_exc, ValueError)
self.assertIsInstance(inner_exc.__context__, ZeroDivisionError)

@_async_test
@async_test
async def test_async_exit_exception_explicit_none_context(self):
# Ensure AsyncExitStack chaining matches actual nested `with` statements
# regarding explicit __context__ = None.
Expand Down Expand Up @@ -681,7 +666,7 @@ async def my_cm_with_exit_stack():
else:
self.fail("Expected IndexError, but no exception was raised")

@_async_test
@async_test
async def test_instance_bypass_async(self):
class Example(object): pass
cm = Example()
Expand All @@ -695,7 +680,7 @@ class Example(object): pass


class TestAsyncNullcontext(unittest.TestCase):
@_async_test
@async_test
async def test_async_nullcontext(self):
class C:
pass
Expand Down
Loading