Skip to content
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

Unexpected ContextVar handling for lifespan context managers #3781

Open
1 of 4 tasks
rmorshea opened this issue Oct 9, 2024 · 6 comments
Open
1 of 4 tasks

Unexpected ContextVar handling for lifespan context managers #3781

rmorshea opened this issue Oct 9, 2024 · 6 comments
Labels
Bug 🐛 This is something that is not working as expected

Comments

@rmorshea
Copy link

rmorshea commented Oct 9, 2024

Description

A ContextVar set within a lifespan context manager is not available inside request handlers. By contrast, doing the same thing via an application level dependency does appear set in the require handler.

MCVE

import asyncio
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from contextvars import ContextVar

from litestar import Litestar
from litestar import get
from litestar.testing import AsyncTestClient

VAR = ContextVar[int]("VAR", default=0)


@asynccontextmanager
async def set_var() -> AsyncIterator[None]:
    token = VAR.set(1)
    try:
        yield
    finally:
        VAR.reset(token)


@get("/example")
async def example() -> int:
    return VAR.get()


app = Litestar([example], lifespan=[set_var()])


async def test() -> None:
    async with AsyncTestClient(app) as client:
        response = await client.get("/example")
        assert (value := response.json()) == 1, f"Expected 1, got {value}"


if __name__ == "__main__":
    asyncio.run(test())

Steps to reproduce

Run the example above. Either via python example.py or uvicorn example:app and check the /example route.

We should expect the response to be 1 since that's what's set during the lifespan context manager, but it's always 0.

If you instead swap the lifespan context manager for a dependency it works as expected:

import asyncio
from collections.abc import AsyncIterator
from contextvars import ContextVar

from litestar import Litestar
from litestar import get
from litestar.testing import AsyncTestClient

VAR = ContextVar[int]("VAR", default=0)


async def set_var() -> AsyncIterator[None]:
    token = VAR.set(1)
    try:
        yield
    finally:
        VAR.reset(token)


@get("/example")
async def example(set_var: None) -> int:
    return VAR.get()


app = Litestar([example], dependencies={"set_var": set_var})


async def test() -> None:
    async with AsyncTestClient(app) as client:
        response = await client.get("/example")
        assert (value := response.json()) == 1, f"Expected 1, got {value}"


if __name__ == "__main__":
    asyncio.run(test())

Litestar Version

2.12.1

Platform

  • Linux
  • Mac
  • Windows
  • Other (Please specify in the description above)

Note

While we are open for sponsoring on GitHub Sponsors and
OpenCollective, we also utilize Polar.sh to engage in pledge-based sponsorship.

Check out all issues funded or available for funding on our Polar.sh dashboard

  • If you would like to see an issue prioritized, make a pledge towards it!
  • We receive the pledge once the issue is completed & verified
  • This, along with engagement in the community, helps us know which features are a priority to our users.
Fund with Polar
@rmorshea rmorshea added the Bug 🐛 This is something that is not working as expected label Oct 9, 2024
@rmorshea rmorshea changed the title Unexpected ContextVar handling with AsyncTestClient Unexpected ContextVar handling for lifespan context managers Oct 9, 2024
@euri10
Copy link
Contributor

euri10 commented Oct 9, 2024

vey interesting, I played a litttle with it, changing lifespan to the old way with on_startup / on_sutdown and the result is even weirder.

I didn't go through all this thread but that seems related pytest-dev/pytest-asyncio#127

note on my "tests" I didnt use pytest-asyncio but anyio

@rmorshea
Copy link
Author

rmorshea commented Oct 9, 2024

It looks like this is expected behavior in Starlette/FastAPI. The response there is pretty turse so it's hard to see exactly why.

@rmorshea
Copy link
Author

rmorshea commented Oct 9, 2024

If this turns out to be a fundamental limitation it might be nice to support dependencies that have side effects but don't actually return a value. I could imagine something similar to Pytest's auto use fixtures:

VAR = ContextVar("VAR", default=0)


async def set_var() -> AsyncIterator[None]:
    token = VAR.set(1)
    try:
        yield
    finally:
        VAR.reset(token)


@get("/", dependencies={"set_var": Provide(set_var, always=True)})
async def get_thing() -> None:
    assert VAR.get() == 1

@euri10
Copy link
Contributor

euri10 commented Oct 9, 2024

yep, pytest-asyncio might add another layer of complexity but this fails without regardless so the issue lies elsewhere,

now adding some goold old-style prints to this:

from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from contextvars import ContextVar

from litestar import Litestar
from litestar import get



VAR = ContextVar("VAR", default=0)
print(f"1: {VAR}")


@asynccontextmanager
async def set_var() -> AsyncIterator[None]:
    token = VAR.set(1)
    try:
        yield
    # except Exception as exc:
    #     print(exc)
    finally:
        print("Finally")
        VAR.reset(token)


@get("/example")
async def example() -> int:
    print(VAR)
    return VAR.get()


app = Litestar([example], lifespan=[set_var()], debug=True)

if __name__ == "__main__":
    # asyncio.run(test())
    print(f"1: {VAR}")
    import uvicorn
    uvicorn.run("cv:app", reload=True)

I get this log, note that there is something going on if I understand it clearly with the way the var is created / initialized as the handler seems to not use the same var that was created in lifespan

/home/lotso/PycharmProjects/abdul/.venv/bin/python /home/lotso/PycharmProjects/abdul/cv.py 
1: <ContextVar name='VAR' default=0 at 0x7fb24500be20>
1: <ContextVar name='VAR' default=0 at 0x7fb24500be20>
INFO:     Will watch for changes in these directories: ['/home/lotso/PycharmProjects/abdul']
INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO:     Started reloader process [436898] using WatchFiles
1: <ContextVar name='VAR' default=0 at 0x7fabc7fedfd0>
1: <ContextVar name='VAR' default=0 at 0x7fabc57972e0>
INFO:     Started server process [436904]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO - 2024-10-09 08:08:12,131 - watchfiles.main - main - 3 changes detected
<ContextVar name='VAR' default=0 at 0x7fabc57972e0>
INFO:     127.0.0.1:57262 - "GET /example HTTP/1.1" 200 OK

@rmorshea
Copy link
Author

rmorshea commented Oct 9, 2024

Here's a longer running issue: encode/uvicorn#1151

@rmorshea
Copy link
Author

rmorshea commented Oct 11, 2024

For additional context (😉) here's related Pytest issue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Bug 🐛 This is something that is not working as expected
Projects
None yet
Development

No branches or pull requests

2 participants