ets-labs/python-dependency-injector

Context Local Scope-Level Resource/Singleton/...

Opened this issue · 2 comments

XKTZ commented

Hi!

I would like to manage a scope-managed resource with a possible fastapi request. If I write something like:

class MyService:
    def __init__(self, session: Callable[[], Awaitable[int]]) -> None:
        self._session = session

    async def dosth(self):
        print(f"Session ID: {await self._session()}")


"""
I expect
async def sess():
    print("I am starting")
    yield uuid.uuid4().int
    print("I am ending")
"""


async def sess():
    print("I am starting")
    return uuid.uuid4().int


class Container(DeclarativeContainer):
    _sess = providers.Factory(sess)
    _ctx = providers.ContextLocalSingleton(_sess)

    my_service = providers.Resource(MyService, session=_ctx.provider)


container = Container()
app = FastAPI(lifespan=Lifespan(container=container))

@app.get("/")
@inject
async def index(
    service: Annotated[MyService, Depends(Provide[Container.my_service])],
):
    await service.dosth()
    await service.dosth()
    return 1

container.wire(modules=[__name__])

client = TestClient(app)

client.get("/")
client.get("/")

print("I am ok")

The code is maximum I would be able to go for now - the desired behaviour is: the two service.dosth calls print same ID in one session, but print different ID in two sessions. So without considering the lifetime the thing is already doable.
However, if we add an extra constraint to the code, which is in the comment section, that we also hope we are able to commit the stuff at the end (with/without a contextmanager, just like waht the Resource do), it seems become difficult. Because it seems ContextLocal seems not being able to catch & handle an AsyncContextManager / Generator. The demand of this seems to be general, where the user may need a yield a database session every request:

async def get_session(engine):
    async with AsyncSession(engine) as session():
        async with session.begin():
            yield session

I have tried a few possible ways:

  • Simply use a Factory won't give same ID even in one session, and Factory seems can't catch a correct contextmanager either
  • Use Resource can work but Resource is only for global scope, not request level scope. So it can't work nicely in my scenario
  • __del__ might also works, but it is fully give the choice to python's GC

May I ask if there are other ways possibly can solve this problem in the scenario? Thanks a lot!

I think FastAPI Depends() handle context-manager well.

If that is not enough for you.
There is way to wire Resource function scope.
https://python-dependency-injector.ets-labs.org/providers/resource.html#resources-wiring-and-per-function-execution-scope

And here is code you expected,

import uuid
from typing import Annotated, Awaitable, Callable

from dependency_injector import containers, providers
from dependency_injector.containers import DeclarativeContainer
from dependency_injector.wiring import Closing, Provide, inject
from fastapi import Depends, FastAPI
from starlette.testclient import TestClient
from starlette.types import Lifespan


class MyService:
    def __init__(self, session: Callable[[], Awaitable[int]]) -> None:
        self._session = session

    async def dosth(self):
        print(f"Session ID: {await self._session()}")


"""
I expect
# async def sess():
#     print("I am starting")
#     yield uuid.uuid4().int
#     print("I am ending")
"""


async def expect_sess():
    print("I am starting")
    yield uuid.uuid4().int
    print("I am ending")


class Container(DeclarativeContainer):
    _sess = providers.Resource(expect_sess)
    my_service = providers.Singleton(MyService, session=_sess.provider)


container = Container()
app = FastAPI()


@app.get("/")
@inject
async def index(
    service: Annotated[MyService, Depends(Closing[Provide[Container.my_service]])],
):
    await service.dosth()
    await service.dosth()
    return 1


container.wire(modules=[__name__])

client = TestClient(app)

client.get("/")
client.get("/")

print("I am ok")


>>>
I am starting
Session ID: 256923325444126301778075785945325532268
Session ID: 256923325444126301778075785945325532268
I am ending
I am starting
Session ID: 321693372074142523556488565648631552765
Session ID: 321693372074142523556488565648631552765
I am ending
I am ok

@rumbarum
If a second request comes before the first one has finished, it will share the same session.
Closing doesn’t fit ASGI frameworks – I think this should be mentioned in the docs.

I have my solution here #728 (comment)