Skip to content

asyncio: #8 consider contextvars #9

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 3 commits into from
Jun 1, 2024
Merged

Conversation

delfick
Copy link
Owner

@delfick delfick commented Oct 16, 2021

Make it so everything gets executed in the same asyncio context

I've never worked with contextvars before and what I did here was very non obvious.

So before I write more tests and docs, can you give this a shot please @andredias ?

Thanks.

@wraps(original)
def run_fixture(*args, **kwargs):
try:
ctx.run(lambda: None)
Copy link
Owner Author

Choose a reason for hiding this comment

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

there seems no other way of knowing if the context is already active then to use it and see if it makes RuntimError happen



def converted_async_test(test_tasks, func, timeout, *args, **kwargs):
"""Used to replace async tests"""
__tracebackhide__ = True

info = {}
loop = asyncio.get_event_loop()
loop = asyncio.get_event_loop_policy().get_event_loop()
Copy link
Owner Author

Choose a reason for hiding this comment

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

python3.10 change. Apparently asyncio.get_event_loop() becomes equivalent to asyncio.get_running_loop() and these sync functions where I get the loop don't necessarily have one setup already it seems


def pytest_configure(self, config):
"""Register our timeout marker which is used to signify async timeouts"""
config.addinivalue_line(
"markers", "async_timeout(length): mark async test to have a timeout"
)

@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_sessionstart(self, session):
Copy link
Owner Author

Choose a reason for hiding this comment

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

it hurts so much that I can't do a run_until_complete with a particular context.

setup.py Outdated
@@ -14,7 +14,7 @@
, version = VERSION
, packages = find_packages(include="alt_pytest_asyncio.*", exclude=["tests*"])

, python_requires = ">= 3.5"
, python_requires = ">= 3.7"
Copy link
Owner Author

Choose a reason for hiding this comment

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

python3.6 is nearly end of life, and the contextvars is new since 3.7

@delfick delfick changed the title asyncio: consider contextvars asyncio: #8 consider contextvars Oct 16, 2021
@delfick
Copy link
Owner Author

delfick commented Oct 16, 2021

originally I tried to make it so that each test got their own context, but it's too difficult to know what should stick around from fixtures of different scopes so I've made one context for everything.

@andredias
Copy link
Contributor

Thanks! Please, give me a couple of days.

@delfick
Copy link
Owner Author

delfick commented Oct 17, 2021

cool. No rush :)

@delfick delfick force-pushed the f-asyncio/consider-contextvars branch 2 times, most recently from 07432ae to 6bc310a Compare October 24, 2021 23:01
@delfick
Copy link
Owner Author

delfick commented Oct 6, 2022

did you still need this @andredias ?

@andredias
Copy link
Contributor

andredias commented Oct 6, 2022 via email

@delfick
Copy link
Owner Author

delfick commented Oct 7, 2022

mmkay, I'm gonna close this then. If you (or anyone else!) wants this, add a comment and I'll make it work again :)

@delfick delfick closed this Oct 7, 2022
@delfick delfick reopened this May 26, 2024
@delfick delfick force-pushed the f-asyncio/consider-contextvars branch 2 times, most recently from 0b83dba to 433cd43 Compare May 26, 2024 06:01
@delfick delfick force-pushed the f-asyncio/consider-contextvars branch 2 times, most recently from e0d8c92 to 246518c Compare May 26, 2024 06:30
Make it so everything gets executed in the same asyncio context
@delfick delfick force-pushed the f-asyncio/consider-contextvars branch from 246518c to c3c6112 Compare May 26, 2024 06:40
@delfick delfick force-pushed the f-asyncio/consider-contextvars branch from c3c6112 to c585125 Compare May 26, 2024 06:43
@delfick delfick marked this pull request as ready for review May 26, 2024 06:47
@andredias
Copy link
Contributor

It works for the simplest example I gave before. Unfortunately, when it gets a bit more complicated, it fails:

from collections.abc import AsyncIterable
from pathlib import Path

from databases import Database
from pytest import fixture
from sqlalchemy import text


@fixture(scope="session")
async def db() -> AsyncIterable[Database]:
    db: Database = Database("sqlite:///example.db")
    await db.connect()
    query = """
create table produto (
    id integer primary key,
    name text not null,
    email text not null,
    unique(email)
)
"""
    await db.execute(query)
    try:
        yield db
    finally:
        await db.disconnect()
        Path("exammple.db").unlink()


@fixture
async def trans(db: Database) -> AsyncIterable[Database]:
    async with db.transaction(force_rollback=True):
        yield db


async def test_db(trans: Database) -> None:
    query = """insert into produto (id, name, email) values (1, 'Fulano', '[email protected]')"""
    await trans.execute(text(query))

The error is:

$ pytest 
=================================== test session starts ===================================
platform linux -- Python 3.12.3, pytest-8.2.1, pluggy-1.5.0
rootdir: /tmp/test_alt
plugins: alt-pytest-asyncio-0.7.2
collected 1 item                                                                          

tests/test_database.py .E                                                           [100%]

========================================= ERRORS ==========================================
______________________________ ERROR at teardown of test_db _______________________________
  + Exception Group Traceback (most recent call last):
  |   File "/tmp/test_alt/.venv/lib/python3.12/site-packages/_pytest/runner.py", line 341, in from_call
  |     result: Optional[TResult] = func()
  |                                 ^^^^^^
  |   File "/tmp/test_alt/.venv/lib/python3.12/site-packages/_pytest/runner.py", line 241, in <lambda>
  |     lambda: runtest_hook(item=item, **kwds), when=when, reraise=reraise
  |             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  |   File "/tmp/test_alt/.venv/lib/python3.12/site-packages/pluggy/_hooks.py", line 513, in __call__
  |     return self._hookexec(self.name, self._hookimpls.copy(), kwargs, firstresult)
  |            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  |   File "/tmp/test_alt/.venv/lib/python3.12/site-packages/pluggy/_manager.py", line 120, in _hookexec
  |     return self._inner_hookexec(hook_name, methods, kwargs, firstresult)
  |            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  |   File "/tmp/test_alt/.venv/lib/python3.12/site-packages/pluggy/_callers.py", line 139, in _multicall
  |     raise exception.with_traceback(exception.__traceback__)
  |   File "/tmp/test_alt/.venv/lib/python3.12/site-packages/pluggy/_callers.py", line 122, in _multicall
  |     teardown.throw(exception)  # type: ignore[union-attr]
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^
  |   File "/tmp/test_alt/.venv/lib/python3.12/site-packages/_pytest/threadexception.py", line 92, in pytest_runtest_teardown
  |     yield from thread_exception_runtest_hook()
  |   File "/tmp/test_alt/.venv/lib/python3.12/site-packages/_pytest/threadexception.py", line 63, in thread_exception_runtest_hook
  |     yield
  |   File "/tmp/test_alt/.venv/lib/python3.12/site-packages/pluggy/_callers.py", line 122, in _multicall
  |     teardown.throw(exception)  # type: ignore[union-attr]
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^
  |   File "/tmp/test_alt/.venv/lib/python3.12/site-packages/_pytest/unraisableexception.py", line 95, in pytest_runtest_teardown
  |     yield from unraisable_exception_runtest_hook()
  |   File "/tmp/test_alt/.venv/lib/python3.12/site-packages/_pytest/unraisableexception.py", line 65, in unraisable_exception_runtest_hook
  |     yield
  |   File "/tmp/test_alt/.venv/lib/python3.12/site-packages/pluggy/_callers.py", line 122, in _multicall
  |     teardown.throw(exception)  # type: ignore[union-attr]
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^
  |   File "/tmp/test_alt/.venv/lib/python3.12/site-packages/_pytest/logging.py", line 857, in pytest_runtest_teardown
  |     yield from self._runtest_for(item, "teardown")
  |   File "/tmp/test_alt/.venv/lib/python3.12/site-packages/_pytest/logging.py", line 833, in _runtest_for
  |     yield
  |   File "/tmp/test_alt/.venv/lib/python3.12/site-packages/pluggy/_callers.py", line 122, in _multicall
  |     teardown.throw(exception)  # type: ignore[union-attr]
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^
  |   File "/tmp/test_alt/.venv/lib/python3.12/site-packages/_pytest/capture.py", line 883, in pytest_runtest_teardown
  |     return (yield)
  |             ^^^^^
  |   File "/tmp/test_alt/.venv/lib/python3.12/site-packages/pluggy/_callers.py", line 103, in _multicall
  |     res = hook_impl.function(*args)
  |           ^^^^^^^^^^^^^^^^^^^^^^^^^
  |   File "/tmp/test_alt/.venv/lib/python3.12/site-packages/_pytest/runner.py", line 188, in pytest_runtest_teardown
  |     item.session._setupstate.teardown_exact(nextitem)
  |   File "/tmp/test_alt/.venv/lib/python3.12/site-packages/_pytest/runner.py", line 559, in teardown_exact
  |     raise BaseExceptionGroup("errors during test teardown", exceptions[::-1])
  | ExceptionGroup: errors during test teardown (2 sub-exceptions)
  +-+---------------- 1 ----------------
    | Traceback (most recent call last):
    |   File "/tmp/test_alt/.venv/lib/python3.12/site-packages/_pytest/runner.py", line 546, in teardown_exact
    |     fin()
    |   File "/tmp/test_alt/.venv/lib/python3.12/site-packages/_pytest/fixtures.py", line 1020, in finish
    |     raise exceptions[0]
    |   File "/tmp/test_alt/.venv/lib/python3.12/site-packages/_pytest/fixtures.py", line 1009, in finish
    |     fin()
    |   File "/tmp/test_alt/.venv/lib/python3.12/site-packages/alt_pytest_asyncio/async_converters.py", line 269, in finalizer
    |     _run_and_raise(ctx, loop, info, generator, async_finalizer())
    |   File "/tmp/test_alt/.venv/lib/python3.12/site-packages/alt_pytest_asyncio/async_converters.py", line 161, in _run_and_raise
    |     _raise_maybe(func, info)
    |   File "/tmp/test_alt/.venv/lib/python3.12/site-packages/alt_pytest_asyncio/async_converters.py", line 143, in _raise_maybe
    |     raise_error()
    |   File "/tmp/test_alt/.venv/lib/python3.12/site-packages/alt_pytest_asyncio/async_converters.py", line 138, in raise_error
    |     raise info["e"]
    |   File "/tmp/test_alt/.venv/lib/python3.12/site-packages/alt_pytest_asyncio/async_converters.py", line 310, in async_runner
    |     return await func(*args, **kwargs)
    |            ^^^^^^^^^^^^^^^^^^^^^^^^^^^
    |   File "/tmp/test_alt/tests/test_database.py", line 26, in db
    |     Path("exammple.db").unlink()
    |   File "/home/andre/.pyenv/versions/3.12.3/lib/python3.12/pathlib.py", line 1342, in unlink
    |     os.unlink(self)
    | FileNotFoundError: [Errno 2] No such file or directory: 'exammple.db'
    +---------------- 2 ----------------
    | Traceback (most recent call last):
    |   File "/tmp/test_alt/.venv/lib/python3.12/site-packages/_pytest/runner.py", line 546, in teardown_exact
    |     fin()
    |   File "/tmp/test_alt/.venv/lib/python3.12/site-packages/_pytest/fixtures.py", line 1020, in finish
    |     raise exceptions[0]
    |   File "/tmp/test_alt/.venv/lib/python3.12/site-packages/_pytest/fixtures.py", line 1009, in finish
    |     fin()
    |   File "/tmp/test_alt/.venv/lib/python3.12/site-packages/alt_pytest_asyncio/async_converters.py", line 269, in finalizer
    |     _run_and_raise(ctx, loop, info, generator, async_finalizer())
    |   File "/tmp/test_alt/.venv/lib/python3.12/site-packages/alt_pytest_asyncio/async_converters.py", line 161, in _run_and_raise
    |     _raise_maybe(func, info)
    |   File "/tmp/test_alt/.venv/lib/python3.12/site-packages/alt_pytest_asyncio/async_converters.py", line 143, in _raise_maybe
    |     raise_error()
    |   File "/tmp/test_alt/.venv/lib/python3.12/site-packages/alt_pytest_asyncio/async_converters.py", line 138, in raise_error
    |     raise info["e"]
    |   File "/tmp/test_alt/.venv/lib/python3.12/site-packages/alt_pytest_asyncio/async_converters.py", line 310, in async_runner
    |     return await func(*args, **kwargs)
    |            ^^^^^^^^^^^^^^^^^^^^^^^^^^^
    |   File "/tmp/test_alt/tests/test_database.py", line 31, in trans
    |     async with db.transaction(force_rollback=True):
    |   File "/tmp/test_alt/.venv/lib/python3.12/site-packages/databases/core.py", line 426, in __aexit__
    |     await self.rollback()
    |   File "/tmp/test_alt/.venv/lib/python3.12/site-packages/databases/core.py", line 471, in rollback
    |     assert self._connection._transaction_stack[-1] is self
    |            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^
    | IndexError: list index out of range
    +------------------------------------

Isn't creating a backport for Runner to run on older versions feasible? It feels like the best solution.

@delfick
Copy link
Owner Author

delfick commented May 27, 2024

@andredias ok, so the first problem is your test has a typo and says "exammple.db" instead of "example.db"

The second problem is that when you let it work out which connection to use, it'll use one per asyncio.Task, which doesn't work because we create new asyncio.Tasks all the time in this plugin.

So you want something more like

from collections.abc import AsyncIterable
from pathlib import Path

from databases import Database
from databases.core import Connection
from pytest import fixture
from sqlalchemy import text


@fixture(scope="session")
async def db() -> AsyncIterable[Database]:
    db: Database = Database("sqlite:///example.db")
    await db.connect()
    query = """
create table if not exists produto (
    id integer primary key,
    name text not null,
    email text not null,
    unique(email)
) 
"""
    await db.execute(query)
    try:
        yield db
    finally:
        await db.disconnect()
        Path("example.db").unlink(missing_ok=True)


@fixture
async def nondurable_conn(db: Database) -> AsyncIterable[Connection]:
    async with db.connection() as connection:
        async with connection.transaction(force_rollback=True):
            yield connection


async def test_db(nondurable_conn: Database) -> None:
    query = """insert into produto (id, name, email) values (1, 'Fulano', '[email protected]')"""
    await nondurable_conn.execute(text(query))

@andredias
Copy link
Contributor

andredias commented May 30, 2024 via email

@delfick
Copy link
Owner Author

delfick commented May 30, 2024

You're right: I could pass a connection to other functions that need it as
we usually do with SQLAlchemy. However, one of the features I like most in
encode/databases is not doing that as it relies on contextvars to get the
right one

Well, the contextvars part works fine. The part that doesn't work is that it caches which connection is available based on the current asyncio.Task. And fundamentally the way this plugin works is by creating new asyncio.Task objects whenever it runs a test or fixture.

That keeps interfaces much simpler.

unsolicited advice, but certainly my default opinion would be that for anything more than a random script, you would find that convenience very limiting in the future in a way that would be very difficult to reverse.

@andredias
Copy link
Contributor

andredias commented May 31, 2024 via email

@delfick
Copy link
Owner Author

delfick commented Jun 1, 2024

well, this PR makes the library 3.11+ so I get rid of the problem of needing at least python 3.11

As I said I definitely don't have time to use asyncio.Runners. Also I don't think that will solve your problem. This PR supports contextvars fine, and I'm gonna merge it now and release a new version.

The problem is this code https://github.com/encode/databases/blob/0.9.0/databases/core.py#L89. It is able to find the connections it stores, but it's finding them based on the current task. And the way this plugin runs everything as async is by running each function as it's own asyncio.Task (necessary for the timeout stuff and error handling)

@delfick delfick merged commit 04ff9ca into main Jun 1, 2024
8 checks passed
@delfick delfick deleted the f-asyncio/consider-contextvars branch June 1, 2024 07:17
@delfick
Copy link
Owner Author

delfick commented Jun 1, 2024

I've released version 0.8.0.

Please don't be afraid to keep seeking assistance :)

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.

2 participants