diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 52e9bf7..24bdc63 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -21,3 +21,8 @@ jobs: run: just publish modern-di-fastapi env: PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} + + - if: startsWith(github.ref_name, 'litestar') + run: just publish modern-di-litestar + env: + PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} diff --git a/.github/workflows/test-core.yml b/.github/workflows/test-core.yml index f1b3335..4372262 100644 --- a/.github/workflows/test-core.yml +++ b/.github/workflows/test-core.yml @@ -32,4 +32,6 @@ jobs: - uses: extractions/setup-just@v2 - uses: astral-sh/setup-uv@v3 - run: uv python install ${{ matrix.python-version }} - - run: just install test + - run: | + just install-ci modern-di + just test-core diff --git a/.github/workflows/test-fastapi.yml b/.github/workflows/test-fastapi.yml index 620febc..42af76b 100644 --- a/.github/workflows/test-fastapi.yml +++ b/.github/workflows/test-fastapi.yml @@ -32,4 +32,6 @@ jobs: - uses: extractions/setup-just@v2 - uses: astral-sh/setup-uv@v3 - run: uv python install ${{ matrix.python-version }} - - run: just install test-fastapi + - run: | + just install-ci modern-di-fastapi + just test-fastapi diff --git a/.github/workflows/test-litestar.yml b/.github/workflows/test-litestar.yml new file mode 100644 index 0000000..6a20e48 --- /dev/null +++ b/.github/workflows/test-litestar.yml @@ -0,0 +1,36 @@ +name: test litestar + +on: + push: + branches: + - main + paths: + - 'packages/modern-di-litestar/**' + - '.github/workflows/test-litestar.yml' + pull_request: + paths: + - 'packages/modern-di-litestar/**' + - '.github/workflows/test-litestar.yml' + +concurrency: + group: ${{ github.head_ref || github.run_id }} fastapi + cancel-in-progress: false + +jobs: + pytest: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: + - "3.10" + - "3.11" + - "3.12" + steps: + - uses: actions/checkout@v4 + - uses: extractions/setup-just@v2 + - uses: astral-sh/setup-uv@v3 + - run: uv python install ${{ matrix.python-version }} + - run: | + just install-ci modern-di-litestar + just test-litestar diff --git a/Justfile b/Justfile index 7bcfec8..120b589 100644 --- a/Justfile +++ b/Justfile @@ -4,6 +4,10 @@ install: uv lock --upgrade uv sync --all-extras --all-packages --frozen +install-ci package: + uv lock --upgrade + uv sync --all-extras --package {{ package }} --frozen + lint: uv run ruff format . uv run ruff check . --fix @@ -23,6 +27,9 @@ test-core *args: test-fastapi *args: uv run --directory=packages/modern-di-fastapi pytest {{ args }} +test-litestar *args: + uv run --directory=packages/modern-di-litestar pytest {{ args }} + publish package: rm -rf dist uv build --package {{ package }} diff --git a/packages/modern-di-fastapi/pyproject.toml b/packages/modern-di-fastapi/pyproject.toml index 2e41d0a..1d77ce9 100644 --- a/packages/modern-di-fastapi/pyproject.toml +++ b/packages/modern-di-fastapi/pyproject.toml @@ -22,7 +22,16 @@ repository = "https://github.com/modern-python/modern-di" docs = "https://modern-di.readthedocs.io" [dependency-groups] -dev = ["httpx", "asgi-lifespan"] +dev = [ + "pytest", + "pytest-cov", + "pytest-asyncio", + "ruff", + "mypy", + "typing-extensions", + "httpx", + "asgi-lifespan", +] [build-system] requires = ["hatchling", "hatch-vcs"] @@ -39,6 +48,7 @@ include = ["modern_di_fastapi"] [tool.pytest.ini_options] addopts = "--cov=. --cov-report term-missing" asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" [tool.coverage.report] exclude_also = ["if typing.TYPE_CHECKING:"] diff --git a/packages/modern-di-litestar/README.md b/packages/modern-di-litestar/README.md new file mode 100644 index 0000000..c06d2ed --- /dev/null +++ b/packages/modern-di-litestar/README.md @@ -0,0 +1,6 @@ +"Modern-DI-LiteStar" +== + +Integration of [Modern-DI](https://github.com/modern-python/modern-di) to LiteStar + +📚 [Documentation](https://modern-di.readthedocs.io) diff --git a/packages/modern-di-litestar/modern_di_litestar/__init__.py b/packages/modern-di-litestar/modern_di_litestar/__init__.py new file mode 100644 index 0000000..6280bf1 --- /dev/null +++ b/packages/modern-di-litestar/modern_di_litestar/__init__.py @@ -0,0 +1,8 @@ +from modern_di_litestar.main import FromDI, fetch_di_container, setup_di + + +__all__ = [ + "FromDI", + "fetch_di_container", + "setup_di", +] diff --git a/packages/modern-di-litestar/modern_di_litestar/main.py b/packages/modern-di-litestar/modern_di_litestar/main.py new file mode 100644 index 0000000..ccb509b --- /dev/null +++ b/packages/modern-di-litestar/modern_di_litestar/main.py @@ -0,0 +1,55 @@ +import dataclasses +import enum +import typing + +import litestar +from litestar.di import Provide +from litestar.enums import ScopeType +from litestar.types import ASGIApp, Receive, Scope, Send +from modern_di import Container, providers +from modern_di import Scope as DIScope + + +T_co = typing.TypeVar("T_co", covariant=True) + + +def setup_di(app: litestar.Litestar, scope: enum.IntEnum = DIScope.APP) -> Container: + app.asgi_handler = make_add_request_container_middleware( + app.asgi_handler, + ) + app.state.di_container = Container(scope=scope) + return app.state.di_container + + +def fetch_di_container(app: litestar.Litestar) -> Container: + return typing.cast(Container, app.state.di_container) + + +def make_add_request_container_middleware(app: ASGIApp) -> ASGIApp: + async def middleware(scope: Scope, receive: Receive, send: Send) -> None: + if scope.get("type") != ScopeType.HTTP: + await app(scope, receive, send) + return + + request: litestar.Request[typing.Any, typing.Any, typing.Any] = litestar.Request(scope) + di_container = fetch_di_container(request.app) + + async with di_container.build_child_container( + scope=DIScope.REQUEST, context={"request": request} + ) as request_container: + request.state.di_container = request_container + await app(scope, receive, send) + + return middleware + + +@dataclasses.dataclass(slots=True, frozen=True) +class Dependency(typing.Generic[T_co]): + dependency: providers.AbstractProvider[T_co] + + async def __call__(self, request: litestar.Request[typing.Any, typing.Any, typing.Any]) -> T_co: + return await self.dependency.async_resolve(request.state.di_container) + + +def FromDI(dependency: providers.AbstractProvider[T_co], *, use_cache: bool = True) -> Provide: # noqa: N802 + return Provide(dependency=Dependency(dependency), use_cache=use_cache) diff --git a/packages/modern-di-litestar/modern_di_litestar/py.typed b/packages/modern-di-litestar/modern_di_litestar/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/packages/modern-di-litestar/pyproject.toml b/packages/modern-di-litestar/pyproject.toml new file mode 100644 index 0000000..ec1bf0c --- /dev/null +++ b/packages/modern-di-litestar/pyproject.toml @@ -0,0 +1,53 @@ +[project] +name = "modern-di-litestar" +description = "Modern-DI integration for LiteStar" +authors = [{ name = "Artur Shiriev", email = "me@shiriev.ru" }] +requires-python = ">=3.10,<4" +license = "MIT" +readme = "README.md" +keywords = ["DI", "dependency injector", "ioc-container", "LiteStar", "python"] +classifiers = [ + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Typing :: Typed", + "Topic :: Software Development :: Libraries", +] +dependencies = ["litestar", "modern-di"] +dynamic = ["version"] + +[project.urls] +repository = "https://github.com/modern-python/modern-di" +docs = "https://modern-di.readthedocs.io" + +[dependency-groups] +dev = [ + "pytest", + "pytest-cov", + "pytest-asyncio", + "ruff", + "mypy", + "typing-extensions", + "httpx", + "asgi-lifespan", +] + +[build-system] +requires = ["hatchling", "hatch-vcs"] +build-backend = "hatchling.build" + +[tool.hatch.version] +source = "vcs" +raw-options.root = "../.." +fallback-version = "0" + +[tool.hatch.build] +include = ["modern_di_litestar"] + +[tool.pytest.ini_options] +addopts = "--cov=. --cov-report term-missing" +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" + +[tool.coverage.report] +exclude_also = ["if typing.TYPE_CHECKING:"] diff --git a/packages/modern-di-litestar/tests_litestar/__init__.py b/packages/modern-di-litestar/tests_litestar/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/modern-di-litestar/tests_litestar/conftest.py b/packages/modern-di-litestar/tests_litestar/conftest.py new file mode 100644 index 0000000..128d3c2 --- /dev/null +++ b/packages/modern-di-litestar/tests_litestar/conftest.py @@ -0,0 +1,28 @@ +import contextlib +import typing + +import litestar +import pytest +from asgi_lifespan import LifespanManager +from litestar.testing import TestClient +from modern_di_litestar import setup_di + + +@contextlib.asynccontextmanager +async def lifespan(app_: litestar.Litestar) -> typing.AsyncIterator[None]: + container = setup_di(app_) + async with container: + yield + + +@pytest.fixture +async def app() -> typing.AsyncIterator[litestar.Litestar]: + app_ = litestar.Litestar(lifespan=[lifespan], debug=True) + async with LifespanManager(app_): # type: ignore[arg-type] + yield app_ + + +@pytest.fixture +def client(app: litestar.Litestar) -> typing.Iterator[TestClient[litestar.Litestar]]: + with TestClient(app=app) as client: + yield client diff --git a/packages/modern-di-litestar/tests_litestar/dependencies.py b/packages/modern-di-litestar/tests_litestar/dependencies.py new file mode 100644 index 0000000..8f31071 --- /dev/null +++ b/packages/modern-di-litestar/tests_litestar/dependencies.py @@ -0,0 +1,11 @@ +import dataclasses + + +@dataclasses.dataclass(kw_only=True, slots=True) +class SimpleCreator: + dep1: str + + +@dataclasses.dataclass(kw_only=True, slots=True) +class DependentCreator: + dep1: SimpleCreator diff --git a/packages/modern-di-litestar/tests_litestar/test_routes.py b/packages/modern-di-litestar/tests_litestar/test_routes.py new file mode 100644 index 0000000..d0b4d2d --- /dev/null +++ b/packages/modern-di-litestar/tests_litestar/test_routes.py @@ -0,0 +1,63 @@ +import typing + +import litestar +from litestar import status_codes +from litestar.testing import TestClient +from modern_di import Scope, providers +from modern_di_litestar import FromDI + +from tests_litestar.dependencies import DependentCreator, SimpleCreator + + +def context_adapter_function(*, request: litestar.Request[typing.Any, typing.Any, typing.Any], **_: object) -> str: + return request.method + + +app_factory = providers.Factory(Scope.APP, SimpleCreator, dep1="original") +request_factory = providers.Factory(Scope.REQUEST, DependentCreator, dep1=app_factory.cast) +action_factory = providers.Factory(Scope.ACTION, DependentCreator, dep1=app_factory.cast) +context_adapter = providers.ContextAdapter(Scope.REQUEST, context_adapter_function) + + +def test_factories(client: TestClient[litestar.Litestar], app: litestar.Litestar) -> None: + @litestar.get( + "/", + dependencies={"app_factory_instance": FromDI(app_factory), "request_factory_instance": FromDI(request_factory)}, + ) + async def read_root(app_factory_instance: SimpleCreator, request_factory_instance: DependentCreator) -> None: + assert isinstance(app_factory_instance, SimpleCreator) + assert isinstance(request_factory_instance, DependentCreator) + assert request_factory_instance.dep1 is not app_factory_instance + + app.register(read_root) + + response = client.get("/") + assert response.status_code == status_codes.HTTP_200_OK, response.text + assert response.json() is None + + +def test_context_adapter(client: TestClient[litestar.Litestar], app: litestar.Litestar) -> None: + @litestar.get("/", dependencies={"method": FromDI(context_adapter)}) + async def read_root(method: str) -> None: + assert method == "GET" + + app.register(read_root) + + response = client.get("/") + assert response.status_code == status_codes.HTTP_200_OK + assert response.json() is None + + +def test_factories_action_scope(client: TestClient[litestar.Litestar], app: litestar.Litestar) -> None: + @litestar.get("/") + async def read_root(request: litestar.Request[typing.Any, typing.Any, typing.Any]) -> None: + request_container = request.state.di_container + with request_container.build_child_container() as action_container: + action_factory_instance = action_factory.sync_resolve(action_container) + assert isinstance(action_factory_instance, DependentCreator) + + app.register(read_root) + + response = client.get("/") + assert response.status_code == status_codes.HTTP_200_OK + assert response.json() is None diff --git a/packages/modern-di-litestar/tests_litestar/test_websockets.py b/packages/modern-di-litestar/tests_litestar/test_websockets.py new file mode 100644 index 0000000..5448b5d --- /dev/null +++ b/packages/modern-di-litestar/tests_litestar/test_websockets.py @@ -0,0 +1,12 @@ +import litestar +from litestar.testing import TestClient + + +async def test_websocket_not_supported(client: TestClient[litestar.Litestar], app: litestar.Litestar) -> None: + async def websocket_handler(data: str) -> None: + pass + + app.register(litestar.websocket_listener("/ws")(websocket_handler)) + + with client.websocket_connect("/ws") as websocket: + websocket.send("test") diff --git a/packages/modern-di/pyproject.toml b/packages/modern-di/pyproject.toml index 5d288e7..d79dea7 100644 --- a/packages/modern-di/pyproject.toml +++ b/packages/modern-di/pyproject.toml @@ -19,6 +19,16 @@ dynamic = ["version", "readme"] repository = "https://github.com/modern-python/modern-di" docs = "https://modern-di.readthedocs.io" +[dependency-groups] +dev = [ + "pytest", + "pytest-cov", + "pytest-asyncio", + "ruff", + "mypy", + "typing-extensions", +] + [build-system] requires = ["hatchling", "hatch-vcs", "hatch-fancy-pypi-readme"] build-backend = "hatchling.build" @@ -40,6 +50,7 @@ path = "../../README.md" [tool.pytest.ini_options] addopts = "--cov=. --cov-report term-missing" asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" [tool.coverage.report] exclude_also = ["if typing.TYPE_CHECKING:"] diff --git a/pyproject.toml b/pyproject.toml index 1bf4c5f..0ebff4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,7 @@ isort.no-lines-before = ["standard-library", "local-folder"] [tool.pytest.ini_options] addopts = "--cov=. --cov-report term-missing" asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" [tool.coverage.report] exclude_also = ["if typing.TYPE_CHECKING:"]