Skip to content

gh-120284: Enhance asyncio.Runner's run() method to accept more object types #120566

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 17 commits into from
Sep 26, 2024

Conversation

ronf
Copy link
Contributor

@ronf ronf commented Jun 15, 2024

This commit makes asyncio.Runner's run() method accept the same object types as asyncio.run_until_complete(). Instead of accepting only a coroutine, run() will now accept an awaitable, coroutine, asyncio.Future, or asyncio.Task.

@ghost
Copy link

ghost commented Jun 15, 2024

All commit authors signed the Contributor License Agreement.
CLA signed

@bedevere-app
Copy link

bedevere-app bot commented Jun 15, 2024

Most changes to Python require a NEWS entry. Add one using the blurb_it web app or the blurb command-line tool.

If this change has little impact on Python users, wait for a maintainer to apply the skip news label instead.

@ronf ronf force-pushed the asyncio-runner-enhancement branch from f41dea9 to e6f4092 Compare June 15, 2024 20:56
@bedevere-app
Copy link

bedevere-app bot commented Jun 15, 2024

Most changes to Python require a NEWS entry. Add one using the blurb_it web app or the blurb command-line tool.

If this change has little impact on Python users, wait for a maintainer to apply the skip news label instead.

@ronf
Copy link
Contributor Author

ronf commented Jun 15, 2024

Let me know if you'd like me to create a NEWS entry for this.

@gvanrossum
Copy link
Member

Yes please

@@ -97,7 +103,21 @@ def run(self, coro, *, context=None):

if context is None:
context = self._context
task = self._loop.create_task(coro, context=context)

if futures.isfuture(coro):
Copy link
Contributor

Choose a reason for hiding this comment

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

You replace most of this with call to ensure_future.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That's what I did originally, but it caused problems with some of the unit tests. In particular, ensure_future() had a call to get_event_loop() in it which caused a unit test to fail. Also, in my original change, I needed to modify ensure_future() to take a context argument and that changed a public API, which caused other associated unit test issues. More importantly, ensure_future() also had code which called close() on the coro or future, which isn't wanted here.

After trying out various alternatives, I settled on just taking the few lines from ensure_future() that were needed and modifying them to include only what this new code required. It was a smaller change, and in my opinion lower risk and more readable.

Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe extract it to a new function? I really don't want to repeat the logic of awaitable trick here.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'll take another look at this tonight.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ok - I figured out a way to leverage the code in ensure_future() for this - see commit 4ed7357.

One thing I'm not thrilled about here is that this adds a new optional keyword argument to ensure_future(), which is a public API. I could move the entire logic of ensure_future() into a new function instead to avoid changing its signature, but that's kind of ugly in a different way.

One other thing to note is that this now raises a TypeError instead of a ValueError when a caller passes in a coro which is not one of the expected types, since that's what ensure_future() raised.

@ronf ronf force-pushed the asyncio-runner-enhancement branch from 4ed7357 to a882241 Compare June 22, 2024 00:51
Copy link
Contributor

@willingc willingc left a comment

Choose a reason for hiding this comment

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

Thanks for the PR.

Copy link
Contributor

@Jason-Y-Z Jason-Y-Z left a comment

Choose a reason for hiding this comment

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

Thanks for the PR! Overall logic makes sense to me, left a small comment

Copy link
Member

@picnixz picnixz left a comment

Choose a reason for hiding this comment

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

Some nitpicks (feel free to argue).

Comment on lines 52 to 55
An optional keyword-only *context* argument allows specifying a
custom :class:`contextvars.Context` to use when creating a new task.

Copy link
Member

@picnixz picnixz Aug 12, 2024

Choose a reason for hiding this comment

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

Maybe mention that the current context is used if none is specified (I think the other functions/methods that support contextvars do it).

Copy link
Contributor Author

@ronf ronf Aug 14, 2024

Choose a reason for hiding this comment

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

From what I can tell, ensure_future() sets no context at all if you pass in None to it. It calls create_task() on the current event loop with no context argument in that case. What happens from there would depend on the event loop being used.

I do see code in the Task() constructor which calls contextvars.copy_context() if you pass None in as a context and that's what event loops usually call. I'm hesitant to claim that behavior at the ensure_future() level, though, given the multiple layers in between and the possibility for the event loops to do something different.

Looking at Runner.run(), it passes in the runner's default context if no context is specified when Runner.run() is called, and it is documented as such. That default context seems to be set to a copy of the context active at the time the Runner object is first used (during its lazy init). That then gets passed explicitly to ensure_future() if no context is passed into Runner.run().

Note that the runner's default context may not always be the same as the context which is active at the time Runner.run() is called.

Copy link
Member

Choose a reason for hiding this comment

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

You're entirely right. I incorrectly assumed that it would be like Runner.run() but that's not what the code actually does. Maybe we could expand the docs by saying:

-custom :class:`contextvars.Context` to use when creating a new task.
+custom :class:`contextvars.Context` to use when creating a new task
+via :meth:`loop.create_task <asyncio.loop.create_task>`.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I don't think the loop version of the create_task() method is a public function. There's only asyncio.create_task() (and more recently TaskGroup.create_task), but those are not used by Runner.

Copy link
Member

Choose a reason for hiding this comment

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

Then I really wonder how we can teach the None argument in that case. I don't have any good suggestion but we can also decide to be deliberately imprecise... I'll leave it to another reviewer :/

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I looked this over again, and it turns out that the same indirection happening with ensure_future() calling into the internal create_task() on the event loop also happens in asyncio.create_task(), and yet it documents context as:

   An optional keyword-only *context* argument allows specifying a
   custom :class:`contextvars.Context` for the *coro* to run in.
   The current context copy is created when no *context* is provided.

Given that both of these are likely to call the constructor for Task which does the actual copy and documents that:

   An optional keyword-only *context* argument allows specifying a
   custom :class:`contextvars.Context` for the *coro* to run in.
   If no *context* is provided, the Task copies the current context
   and later runs its coroutine in the copied context.

So, perhaps it's ok after all to document context in ensure_future() as something like:

   An optional keyword-only *context* argument allows specifying a
   custom :class:`contextvars.Context` to use when creating a new task.
   A copy of the current context is used when no *context* is provided.

There's a chance that a custom event loop could fail to pass the context through properly and change this behavior but that's fairly unlikely (and might in fact be considered a bug).

@@ -64,6 +68,8 @@ Future Functions
Deprecation warning is emitted if *obj* is not a Future-like object
and *loop* is not specified and there is no running event loop.

.. versionchanged:: 3.12
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
.. versionchanged:: 3.12
.. versionchanged:: 3.13

1st1
1st1 previously requested changes Sep 12, 2024
Copy link
Member

@1st1 1st1 left a comment

Choose a reason for hiding this comment

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

The proposed implementation makes ensure_future unpredictable so I suggest taking a different route.

@@ -720,7 +720,7 @@ async def sleep(delay, result=None):
h.cancel()


def ensure_future(coro_or_future, *, loop=None):
def ensure_future(coro_or_future, *, loop=None, context=None):
Copy link
Member

@1st1 1st1 Sep 12, 2024

Choose a reason for hiding this comment

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

Sadly this change is a no-go.

If coro_or_future is a future or task, the context argument would be ignored, breaking the future user assumption that the context they pass would actually be applied.

I suggest a different approach to this PR:

Runner.run(o) should check if o is a coroutine -- if it is, keep things as is. If not, it should wrap it in an ad-hoc "proxy" coroutine async def proxy(o=o): return await o and pass proxy to create_task().

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I have made the requested changes; please review again.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

(apologies Yury for misspelling your first name in the last commit!)

@bedevere-app
Copy link

bedevere-app bot commented Sep 12, 2024

A Python core developer has requested some changes be made to your pull request before we can consider merging it. If you could please address their requests along with any other requests in other reviews from core developers that would be appreciated.

Once you have made the requested changes, please leave a comment on this pull request containing the phrase I have made the requested changes; please review again. I will then notify any core developers who have left a review that you're ready for them to take another look at this pull request.

This commit makes asyncio.Runner's run() method accept the same object
types as asyncio.run_until_complete(). Instead of accepting only a
coroutine, run() will now accept an awaitable, coroutine,
asyncio.Future, or asyncio.Task.
@ronf ronf force-pushed the asyncio-runner-enhancement branch from 1ad56be to 6b34b62 Compare September 14, 2024 17:20
This version has reverted the change in ensure_future, and only
changes Runner.run().
Copy link
Contributor

@kumaraditya303 kumaraditya303 left a comment

Choose a reason for hiding this comment

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

Looks fine but the docs also need to be updated for this.

This commit updates the documentation for Runner.run() and
asyncio.run() in asyncio-runner.rst. In the process, I removed
documentation previously added in runners.py.
@ronf
Copy link
Contributor Author

ronf commented Sep 21, 2024

I've updated the documentation in asyncio-runner.rst for Runner.run() and asyncio.run(). In the process, I removed documentation I had previously added in runners.py (before realizing the docs weren't inline).

@kumaraditya303 kumaraditya303 merged commit 1229cb8 into python:main Sep 26, 2024
35 of 36 checks passed
@ronf ronf deleted the asyncio-runner-enhancement branch September 28, 2024 00:07
@ronf
Copy link
Contributor Author

ronf commented Sep 28, 2024

Thanks very much!

@1st1
Copy link
Member

1st1 commented Oct 2, 2024

@kumaraditya303 Kumar, please don't "resolve" my requests like this. If I review something I usually want to see the final thing too.

CleanShot 2024-10-02 at 15 01 17@2x

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

9 participants