classification
Title: add contextlib.AsyncExitStack
Type: enhancement Stage: resolved
Components: Library (Lib) Versions: Python 3.7
process
Status: closed Resolution: fixed
Dependencies: Superseder:
Assigned To: Nosy List: Ilya.Kulakov, cheryl.sabella, giampaolo.rodola, ncoghlan, njs, privatwolke, thehesiod, veky, yselivanov
Priority: normal Keywords: patch

Created on 2017-01-17 22:49 by thehesiod, last changed 2018-01-25 20:52 by yselivanov. This issue is now closed.

Pull Requests
URL Status Linked Edit
PR 4790 merged Ilya.Kulakov, 2017-12-11 05:50
Messages (21)
msg285687 - (view) Author: Alexander Mohr (thehesiod) * Date: 2017-01-17 22:49
ExitStack is a really useful class and would be a great to have an async version.  I've gone ahead and created an implementation based on the existing Python 3.5.2 implementation.  Let me know what you guys think. I think it would be possible to combine most of the two classes together if you guys think it would be useful.  Let me know if I can/should create a github PR and where to do that.
msg286380 - (view) Author: Alexander Mohr (thehesiod) * Date: 2017-01-27 18:07
created gist: https://gist.github.com/thehesiod/b8442ed50e27a23524435a22f10c04a0

I've now updated the imple to support both __aenter__/__aexit__ and __enter__/__exit__ so I don't need two ExitStacks
msg286411 - (view) Author: Vedran Čačić (veky) * Date: 2017-01-28 14:16
An example from "real life": I recently needed this when implementing a demo of dining philosophers using asyncio (when the order of locks should depend on comparison of fork ids, not be static). Of course, it wasn't hard to implement it directly, but still, it would be nice if I had it in stdlib.

Also, yes, it would probably be nicer if there was only one ExitStack with push_async and similar methods.
msg288735 - (view) Author: Nick Coghlan (ncoghlan) * (Python committer) Date: 2017-03-01 05:01
I quite like the idea of enhancing the existing ExitStack to also handle asynchronous operations rather than having completely independent implementations. However, a separate object may end up being simpler in practice as it allows developers to more explicitly declare their intent of writing async-friendly code.

Sketching out what a combined implementation based on the current ExitStack might look like:

- add suitable `__aenter__` and `__aexit__` implementations
- add `enter_context_async` (async def, called with await)
- add `push_aexit` (normal def, but given function is called with await)
- add `coroutine_callback` (normal def, but given coroutine is called with await)
- add `close_async` (async def, called with await)
- add a new internal state flag `_allow_async`. This would default to True, but be set to False when the synchronous `__enter__` is called. When it's false, attempting to register an async callback would raise RuntimeError. `__enter__` would also need to check any previously register contexts and callbacks, and complain if any of them require the use of `Await`
- keep track of which callbacks should be invoked as synchronous calls and which should be called via await
- update `close` to raise RuntimeError if it's asked to clean up asynchronous resources

That's really quite messy and hard to explain, and makes async code look quite different from the corresponding synchronous code.

The repeated checks of `self._allow_async` and `iscoroutinefunction` would also slow down existing synchronous code for no specific end user benefit.

By contrast, a dedicated AsyncExitStack object can:

- assume everything passed to it is a coroutine or an async context manager by default
- always require close() to be called via await
- either add synchronous variants of the default-to-async methods (`enter_context_sync`, `push_sync`, `callback_sync`), or else make them auto-adapt to handle both synchronous and asynchronous inputs
- rather than using `iscoroutinefunction`, instead always invoke callbacks with await, and wrap synchronous callbacks supplied using the above methods in simple one-shot iterators
msg288744 - (view) Author: Alexander Mohr (thehesiod) * Date: 2017-03-01 08:37
Thanks for the feedback Nick! If I get a chance I'll see about refactoring my gist into a base class and two sub-classes with the async supporting non-async but not vice-versa.  I think it will be cleaner.  Sorry I didn't spend too much effort on the existing gist as I tried quickly layering on async support to move on to my primary task.  After doing that I noticed that the code could use some refactoring, but only after taking the time deconstructing the impl to understand how the code works.
msg288761 - (view) Author: Yury Selivanov (yselivanov) * (Python committer) Date: 2017-03-01 15:45
Alexander, 

You don't need asyncio.isocoroutinefunction. Please use inspect.iscoroutinefunction and inspect.iscoroutine.

Nick,

I'm +1 to have a separate class AsyncExitStack.

> - assume everything passed to it is a coroutine or an async context manager by default

I would add a separate set of methods prefixed with 'a' to handle async context managers.

> - always require close() to be called via await

I'd only have one coroutine 'aclose()' that would internally close sync and async context managers in the right order.

> - either add synchronous variants of the default-to-async methods (`enter_context_sync`, `push_sync`, `callback_sync`), or else make them auto-adapt to handle both synchronous and asynchronous inputs

I'd use the 'a' prefix. We use it already to name magic methods: __aenter__, __aexit__, __aiter__.

> - rather than using `iscoroutinefunction`, instead always invoke callbacks with await, and wrap synchronous callbacks supplied using the above methods in simple one-shot iterators

I'd favour a more explicit approach: separate methods for handling sync and async.

Also, speaking about asyncio dependancy -- we shouldn't have it. Using asyncio.iscoroutinefunction is unnecessary, inspect.iscoroutinefunction should be used instead.
msg288762 - (view) Author: Yury Selivanov (yselivanov) * (Python committer) Date: 2017-03-01 16:06
Looking at the code: we don't need to use iscoroutinefunction at all.  If we had separate methods for sync and async context managers, you can store the flag if a function is async or sync along with it.

So _exit_callbacks should store tuples `(is_async, cb)`, instead of just `cb`.

asyncio.iscoroutinefunction differs from inspect.iscoroutinefunction a little bit to support asyncio-specific debug coroutine wrapper functions.  We should avoid using any version of iscoroutinefunction here.
msg290674 - (view) Author: Alexander Mohr (thehesiod) * Date: 2017-03-28 01:42
ok I've updated the gist with a base class and sync + async sub-classes.  The way it worked out I think is nice because we can have the same method names across both sync+async.  Let me know what you guys think!  btw, it seems the test_dont_reraise_RuntimeError test hangs even with the release version.
msg293138 - (view) Author: Yury Selivanov (yselivanov) * (Python committer) Date: 2017-05-05 19:40
Overall, the approach looks fine. Alexander, do you want to make a PR to start working on adding this to 3.7?
msg300572 - (view) Author: Ilya Kulakov (Ilya.Kulakov) * Date: 2017-08-19 08:15
I'm not sure about type() to get a class object and calling __aenter__, __aexit__ through it: that makes it hard to mock these classes as Mock's spec= relies on __class__ and type() seem to ignore it (learned it a hard way.

Yury, I could take a second look and try to change this into a patch if that's OK.
msg300586 - (view) Author: Yury Selivanov (yselivanov) * (Python committer) Date: 2017-08-19 16:44
> I'm not sure about type() to get a class object and calling __aenter__, __aexit__ through it: that makes it hard to mock these classes as Mock's spec= relies on __class__ and type() seem to ignore it (learned it a hard way.

Looking up __dunder__ methods on the class is how it should be done as that's how the rest of Python works.  And that's how ExitStack is currently implemented for synchronous "with" blocks.  We won't be able to change this behaviour even if we wanted to, so it stays.

Here's an example:

    class Foo:
        def __init__(self):
            self.__aenter__ = ...
            self.__aexit__ = ...

If we implement AsyncExitStack to lookup __anter__ directly on the object, then the above Foo class would be supported by it, but at the same time rejected by the 'async with' statement.

> Yury, I could take a second look and try to change this into a patch if that's OK.

By all means you can submit a PR!
msg300588 - (view) Author: Ilya Kulakov (Ilya.Kulakov) * Date: 2017-08-19 17:02
> but at the same time rejected by the 'async with' statement.

Perhaps unittest.mock (or type) needs to be adjusted to allow mocking via spec= without subclassing?

> By all means you can submit a PR!

I'll take a look then.
msg300589 - (view) Author: Yury Selivanov (yselivanov) * (Python committer) Date: 2017-08-19 17:04
>> but at the same time rejected by the 'async with' statement.

> Perhaps unittest.mock (or type) needs to be adjusted to allow mocking via spec= without subclassing?

Maybe. You should try to find discussions around this topic on python mailing lists and this tracker. If you find nothing then feel free to open an issue and add Michael Foord to nosy list.
msg300595 - (view) Author: Nick Coghlan (ncoghlan) * (Python committer) Date: 2017-08-20 04:07
While it *may* be possible to do something simpler for test purposes where performance isn't a major concern, fully supporting type() level mocking basically requires bringing the equivalent of wrapt object proxies into the standard library: https://wrapt.readthedocs.io/en/latest/wrappers.html#object-proxy

I actually think we *should* do that, and occasionally bug Graham Dumpleton about it, but while he's not opposed to the idea, it's also something that would take quite a bit of work (since odd edge cases that are tolerable in an opt-in third party module would probably need to be addressed for a standard library version).

For test cases like AsyncExitStack though, we instead just use custom type definitions, rather than the unittest.mock module.

Autospec'ed mocks are most attractive when we're dealing with object interfaces that are subject to a high rate of churn (since they make the tests more self-adjusting), and that isn't the case here: Python's syntactic support protocols rarely change, and when they do, preserving backwards compatibility with existing classes is typically a key requirement.
msg301004 - (view) Author: Alexander Mohr (thehesiod) * Date: 2017-08-30 00:05
let me know if I need to do anything
msg307970 - (view) Author: Cheryl Sabella (cheryl.sabella) * (Python triager) Date: 2017-12-10 18:54
Alexander,

Did you want to submit a PR for this?
msg308014 - (view) Author: Ilya Kulakov (Ilya.Kulakov) * Date: 2017-12-11 06:00
Charyl, I made the PR.

Where is the AbstractAsyncContextManager? I see that typing.py references it, but there is no actual implementation.
msg308046 - (view) Author: Cheryl Sabella (cheryl.sabella) * (Python triager) Date: 2017-12-11 14:48
Looks like AbstractAsyncContextManager is defined in issue #30241.
msg310353 - (view) Author: Nick Coghlan (ncoghlan) * (Python committer) Date: 2018-01-20 15:43
Explicitly noting some API design decisions from the review of https://github.com/python/cpython/pull/4790:

1. AsyncExitStack will define __aenter__ and __aexit__ but *not* __enter__ and __exit. This makes it unambiguous which form of with statement you need to be using for a given stack.
2. AsyncExitStack will use the same methods to add synchronous context managers as ExitStack does
3. The only common method with clearly distinct external behaviour will be stack.close(), which will be a coroutine in the AsyncExitStack case (the other common methods will all still be synchronous in AsyncExitStack)
4. AsyncExitStack will gain 3 new methods for working with async context managers:

- enter_async_context(cm): coroutine to enter an async context manager and adds its __aexit__ to the context stack if __aenter__ succeeds
- push_async_exit(cm_or_exit): push an exit callback directly onto the stack. The argument must either be an object implementing __aexit__, or else a callback with the same signature as __aexit__ (use stack.push() to register a synchronous CM or exit callback)
- push_async_callback(callback): push the given callback onto the stack. The callback should return an awaitable (use stack.callback() to register synchronous callbacks)
msg310705 - (view) Author: Yury Selivanov (yselivanov) * (Python committer) Date: 2018-01-25 20:51
New changeset 1aa094f74039cd20fdc7df56c68f6848c18ce4dd by Yury Selivanov (Ilya Kulakov) in branch 'master':
bpo-29302: Implement contextlib.AsyncExitStack. (#4790)
https://github.com/python/cpython/commit/1aa094f74039cd20fdc7df56c68f6848c18ce4dd
msg310706 - (view) Author: Yury Selivanov (yselivanov) * (Python committer) Date: 2018-01-25 20:52
Thank you Alexander and Ilya!
History
Date User Action Args
2018-01-25 20:52:36yselivanovsetstatus: open -> closed
resolution: fixed
messages: + msg310706

stage: patch review -> resolved
2018-01-25 20:51:26yselivanovsetmessages: + msg310705
2018-01-20 15:43:42ncoghlansetmessages: + msg310353
2017-12-11 14:48:58cheryl.sabellasetmessages: + msg308046
2017-12-11 06:00:17Ilya.Kulakovsetmessages: + msg308014
2017-12-11 05:50:27Ilya.Kulakovsetkeywords: + patch
stage: needs patch -> patch review
pull_requests: + pull_request4690
2017-12-10 18:55:22cheryl.sabellasetstage: needs patch
2017-12-10 18:54:34cheryl.sabellasetnosy: + cheryl.sabella
messages: + msg307970
2017-11-23 21:30:50privatwolkesetnosy: + privatwolke
2017-09-05 07:38:11njssetnosy: + njs
2017-08-30 00:05:04thehesiodsetmessages: + msg301004
2017-08-20 04:07:37ncoghlansetmessages: + msg300595
2017-08-19 17:04:50yselivanovsetmessages: + msg300589
2017-08-19 17:02:00Ilya.Kulakovsetmessages: + msg300588
2017-08-19 16:44:25yselivanovsetmessages: + msg300586
2017-08-19 08:15:23Ilya.Kulakovsetnosy: + Ilya.Kulakov
messages: + msg300572
2017-05-05 19:40:11yselivanovsetmessages: + msg293138
versions: - Python 3.6
2017-03-28 01:42:32thehesiodsetmessages: + msg290674
2017-03-01 20:41:21vstinnersetnosy: - vstinner
2017-03-01 17:42:03gvanrossumsetnosy: - gvanrossum
2017-03-01 16:06:16yselivanovsetmessages: + msg288762
2017-03-01 15:45:26yselivanovsetmessages: + msg288761
2017-03-01 08:37:34thehesiodsetmessages: + msg288744
2017-03-01 05:01:55ncoghlansetmessages: + msg288735
2017-03-01 04:31:59ncoghlansetnosy: + gvanrossum, ncoghlan, vstinner, giampaolo.rodola, yselivanov
2017-01-28 14:16:22vekysetnosy: + veky
messages: + msg286411
2017-01-27 18:07:53thehesiodsetmessages: + msg286380
2017-01-27 18:07:04thehesiodsetfiles: - exit_stack.py
2017-01-17 22:49:42thehesiodcreate