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

.. versionadded:: 3.8

.. versionchanged:: 3.11
Added support for caching an async function's return value.

.. function:: cmp_to_key(func)

Expand Down
28 changes: 28 additions & 0 deletions Lib/functools.py
Original file line number Diff line number Diff line change
Expand Up @@ -963,6 +963,30 @@ def __isabstractmethod__(self):
_NOT_FOUND = object()


class _CachedAwaitable:
"""Wrapper to return if @cached_proeprty is used on a coroutine function.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

spelling: cached_property


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 cache the result for subsequent calls.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"and caches"

"""

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


class cached_property:
def __init__(self, func):
self.func = func
Expand All @@ -980,6 +1004,8 @@ def __set_name__(self, owner, name):
)

def __get__(self, instance, owner=None):
import inspect

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please move the import just before usage, when the attribute name is not in cache yet.
The import instruction is not for free, it requires a lock at least IIRC.
The move allows returning cached results as fast as possible.


if instance is None:
return self
if self.attrname is None:
Expand All @@ -1000,6 +1026,8 @@ def __get__(self, instance, owner=None):
val = cache.get(self.attrname, _NOT_FOUND)
if val is _NOT_FOUND:
val = self.func(instance)
if inspect.iscoroutinefunction(self.func):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

inspect.isawaitable(val) check covers more cases, please use it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would change the behaviour of

class MyAwaitable:
    def __await__(self):
        print("called")

class Foo:
    @cached_property
    def get_awaitable(self):
        return MyAwaitable()

Currently this would cache the MyAwaitable object and calls __await__. If we use isawaitable here, MyAwaitable would be wrapped and only awaited once.

IMO iscoroutinefunction is more correct here.

val = _CachedAwaitable(val)
try:
cache[self.attrname] = val
except TypeError:
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
16 changes: 16 additions & 0 deletions Lib/test/test_functools.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

from test.support import import_helper
from test.support import threading_helper
from test.support.async_helper import async_test
from test.support.script_helper import assert_python_ok

import functools
Expand Down Expand Up @@ -2909,6 +2910,15 @@ def cost(self):
raise RuntimeError('never called, slots not supported')


class CachedCostItemAsync:
_cost = 1

@py_functools.cached_property
async def cost(self):
self._cost += 1
return self._cost


class TestCachedProperty(unittest.TestCase):
def test_cached(self):
item = CachedCostItem()
Expand Down Expand Up @@ -3022,6 +3032,12 @@ def test_access_from_class(self):
def test_doc(self):
self.assertEqual(CachedCostItem.cost.__doc__, "The cost of the item.")

@async_test
async def test_async(self):
item = CachedCostItemAsync()
self.assertEqual(await item.cost, 2)
self.assertEqual(await item.cost, 2) # not 3


if __name__ == '__main__':
unittest.main()
Loading