oapi-gen-dishka 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,148 @@
1
+ Metadata-Version: 2.4
2
+ Name: oapi-gen-dishka
3
+ Version: 0.1.0
4
+ Summary: Request-scoped Dishka injection for oapi-gen handlers
5
+ Requires-Python: >=3.12
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: dishka<2,>=1.10.1
8
+ Requires-Dist: starlette<2,>=1.0
9
+
10
+ # oapi-gen-dishka
11
+
12
+ Inject Dishka dependencies into the async methods called by an `oapi-gen` router.
13
+ Generated code stays unchanged. The integration reuses Dishka's native Starlette
14
+ request container, so operation handlers, security handlers, and ordinary Starlette
15
+ endpoints share the same `Scope.REQUEST` instances.
16
+
17
+ This directory is an independently buildable package. Install it into your
18
+ application from a local checkout:
19
+
20
+ ```bash
21
+ uv add /path/to/oapi-gen/integrations/dishka
22
+ ```
23
+
24
+ ## Usage
25
+
26
+ The example uses the repository's complete Cats specification. Generate it into
27
+ your application's package:
28
+
29
+ ```bash
30
+ oapi-gen generate tests/fixtures/cats.openapi.yaml --output app/http/generated
31
+ ```
32
+
33
+ ```python
34
+ from contextlib import asynccontextmanager
35
+ from uuid import UUID, uuid4
36
+
37
+ from dishka import FromDishka, Provider, Scope, make_async_container, provide
38
+ from starlette.applications import Starlette
39
+ from oapi_gen_dishka import inject, setup_dishka
40
+
41
+ from app.http.generated import Handlers, contracts, create_router, models
42
+
43
+
44
+ class CatsRepository:
45
+ def __init__(self):
46
+ self.cats: dict[UUID, models.Cat] = {}
47
+
48
+
49
+ class CatsController:
50
+ @inject
51
+ async def list_cats(
52
+ self,
53
+ request: contracts.ListCatsRequest,
54
+ *,
55
+ repository: FromDishka[CatsRepository],
56
+ ) -> contracts.ListCatsResponse:
57
+ cats = list(repository.cats.values())
58
+ return contracts.ListCatsResponse200(body=cats[: request.limit])
59
+
60
+ @inject
61
+ async def create_cat(
62
+ self,
63
+ request: contracts.CreateCatRequest,
64
+ *,
65
+ repository: FromDishka[CatsRepository],
66
+ ) -> contracts.CreateCatResponse:
67
+ cat = models.Cat(id=uuid4(), name=request.body.name)
68
+ repository.cats[cat.id] = cat
69
+ return contracts.CreateCatResponse201()
70
+
71
+ @inject
72
+ async def show_cat_by_id(
73
+ self,
74
+ request: contracts.ShowCatByIdRequest,
75
+ *,
76
+ repository: FromDishka[CatsRepository],
77
+ ) -> contracts.ShowCatByIdResponse:
78
+ cat = repository.cats.get(request.cat_id)
79
+ if cat is None:
80
+ return contracts.ShowCatByIdResponse404(
81
+ body=models.HttpError(message="Cat not found"),
82
+ )
83
+ return contracts.ShowCatByIdResponse200(body=cat)
84
+
85
+
86
+ class AppProvider(Provider):
87
+ # This in-memory example keeps cats between requests in one process.
88
+ repository = provide(CatsRepository, scope=Scope.APP)
89
+
90
+
91
+ container = make_async_container(AppProvider())
92
+
93
+
94
+ @asynccontextmanager
95
+ async def lifespan(app: Starlette):
96
+ try:
97
+ yield
98
+ finally:
99
+ await container.close()
100
+
101
+
102
+ router = create_router(Handlers(cats=CatsController()), prefix="/api")
103
+ app = Starlette(lifespan=lifespan, routes=router.routes)
104
+ setup_dishka(container, app)
105
+ ```
106
+
107
+ Use `Scope.REQUEST` for services or database sessions that must be recreated for
108
+ each request. Dishka resolves dependencies from the normal provider graph and
109
+ finalizes generator providers at the end of the request, including on exceptions.
110
+ Add Dishka's `StarletteProvider()` from `dishka.integrations.starlette` when a
111
+ provider needs `starlette.requests.Request`.
112
+
113
+ `@inject` also works on generated security handler methods. Use
114
+ `dishka.integrations.starlette.inject` on ordinary Starlette endpoints; both
115
+ decorators resolve from the same container after the single setup call above.
116
+
117
+ ## Boundaries
118
+
119
+ - Async HTTP handlers only. WebSockets, sync containers, and background work
120
+ outside the request lifetime are not supported by this decorator.
121
+ - Call this package's `setup_dishka` once, before startup, **instead of** calling
122
+ `dishka.integrations.starlette.setup_dishka` separately.
123
+ - The controller instance passed to `Handlers` is shared. Keep request-specific
124
+ state in local variables and injected dependencies, not on `self`.
125
+ - Providers must register dependencies; `FromDishka[T]` selects the registered
126
+ type and does not register it automatically. Dishka components are supported.
127
+ - Runtime signatures omit injected parameters and retain the remaining
128
+ annotations. The decorator preserves the static response type but uses
129
+ `Callable[..., ...]` for arguments, since Python typing cannot remove arbitrary
130
+ `FromDishka` parameters. Annotate controller references with the generated
131
+ protocol when calling them directly to check request argument types:
132
+
133
+ ```python
134
+ controller: contracts.CatsApi = CatsController()
135
+ ```
136
+
137
+ ## Development
138
+
139
+ ```bash
140
+ uv sync --project integrations/dishka
141
+ uv run --project integrations/dishka pytest integrations/dishka/tests
142
+ uv run --project integrations/dishka ruff check integrations/dishka
143
+ uv run --project integrations/dishka basedpyright --project integrations/dishka
144
+ uv build integrations/dishka --out-dir integrations/dishka/dist
145
+ ```
146
+
147
+ The local development dependency on `oapi-gen` is used only for integration tests;
148
+ the published wheel does not depend on the generator at runtime.
@@ -0,0 +1,139 @@
1
+ # oapi-gen-dishka
2
+
3
+ Inject Dishka dependencies into the async methods called by an `oapi-gen` router.
4
+ Generated code stays unchanged. The integration reuses Dishka's native Starlette
5
+ request container, so operation handlers, security handlers, and ordinary Starlette
6
+ endpoints share the same `Scope.REQUEST` instances.
7
+
8
+ This directory is an independently buildable package. Install it into your
9
+ application from a local checkout:
10
+
11
+ ```bash
12
+ uv add /path/to/oapi-gen/integrations/dishka
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ The example uses the repository's complete Cats specification. Generate it into
18
+ your application's package:
19
+
20
+ ```bash
21
+ oapi-gen generate tests/fixtures/cats.openapi.yaml --output app/http/generated
22
+ ```
23
+
24
+ ```python
25
+ from contextlib import asynccontextmanager
26
+ from uuid import UUID, uuid4
27
+
28
+ from dishka import FromDishka, Provider, Scope, make_async_container, provide
29
+ from starlette.applications import Starlette
30
+ from oapi_gen_dishka import inject, setup_dishka
31
+
32
+ from app.http.generated import Handlers, contracts, create_router, models
33
+
34
+
35
+ class CatsRepository:
36
+ def __init__(self):
37
+ self.cats: dict[UUID, models.Cat] = {}
38
+
39
+
40
+ class CatsController:
41
+ @inject
42
+ async def list_cats(
43
+ self,
44
+ request: contracts.ListCatsRequest,
45
+ *,
46
+ repository: FromDishka[CatsRepository],
47
+ ) -> contracts.ListCatsResponse:
48
+ cats = list(repository.cats.values())
49
+ return contracts.ListCatsResponse200(body=cats[: request.limit])
50
+
51
+ @inject
52
+ async def create_cat(
53
+ self,
54
+ request: contracts.CreateCatRequest,
55
+ *,
56
+ repository: FromDishka[CatsRepository],
57
+ ) -> contracts.CreateCatResponse:
58
+ cat = models.Cat(id=uuid4(), name=request.body.name)
59
+ repository.cats[cat.id] = cat
60
+ return contracts.CreateCatResponse201()
61
+
62
+ @inject
63
+ async def show_cat_by_id(
64
+ self,
65
+ request: contracts.ShowCatByIdRequest,
66
+ *,
67
+ repository: FromDishka[CatsRepository],
68
+ ) -> contracts.ShowCatByIdResponse:
69
+ cat = repository.cats.get(request.cat_id)
70
+ if cat is None:
71
+ return contracts.ShowCatByIdResponse404(
72
+ body=models.HttpError(message="Cat not found"),
73
+ )
74
+ return contracts.ShowCatByIdResponse200(body=cat)
75
+
76
+
77
+ class AppProvider(Provider):
78
+ # This in-memory example keeps cats between requests in one process.
79
+ repository = provide(CatsRepository, scope=Scope.APP)
80
+
81
+
82
+ container = make_async_container(AppProvider())
83
+
84
+
85
+ @asynccontextmanager
86
+ async def lifespan(app: Starlette):
87
+ try:
88
+ yield
89
+ finally:
90
+ await container.close()
91
+
92
+
93
+ router = create_router(Handlers(cats=CatsController()), prefix="/api")
94
+ app = Starlette(lifespan=lifespan, routes=router.routes)
95
+ setup_dishka(container, app)
96
+ ```
97
+
98
+ Use `Scope.REQUEST` for services or database sessions that must be recreated for
99
+ each request. Dishka resolves dependencies from the normal provider graph and
100
+ finalizes generator providers at the end of the request, including on exceptions.
101
+ Add Dishka's `StarletteProvider()` from `dishka.integrations.starlette` when a
102
+ provider needs `starlette.requests.Request`.
103
+
104
+ `@inject` also works on generated security handler methods. Use
105
+ `dishka.integrations.starlette.inject` on ordinary Starlette endpoints; both
106
+ decorators resolve from the same container after the single setup call above.
107
+
108
+ ## Boundaries
109
+
110
+ - Async HTTP handlers only. WebSockets, sync containers, and background work
111
+ outside the request lifetime are not supported by this decorator.
112
+ - Call this package's `setup_dishka` once, before startup, **instead of** calling
113
+ `dishka.integrations.starlette.setup_dishka` separately.
114
+ - The controller instance passed to `Handlers` is shared. Keep request-specific
115
+ state in local variables and injected dependencies, not on `self`.
116
+ - Providers must register dependencies; `FromDishka[T]` selects the registered
117
+ type and does not register it automatically. Dishka components are supported.
118
+ - Runtime signatures omit injected parameters and retain the remaining
119
+ annotations. The decorator preserves the static response type but uses
120
+ `Callable[..., ...]` for arguments, since Python typing cannot remove arbitrary
121
+ `FromDishka` parameters. Annotate controller references with the generated
122
+ protocol when calling them directly to check request argument types:
123
+
124
+ ```python
125
+ controller: contracts.CatsApi = CatsController()
126
+ ```
127
+
128
+ ## Development
129
+
130
+ ```bash
131
+ uv sync --project integrations/dishka
132
+ uv run --project integrations/dishka pytest integrations/dishka/tests
133
+ uv run --project integrations/dishka ruff check integrations/dishka
134
+ uv run --project integrations/dishka basedpyright --project integrations/dishka
135
+ uv build integrations/dishka --out-dir integrations/dishka/dist
136
+ ```
137
+
138
+ The local development dependency on `oapi-gen` is used only for integration tests;
139
+ the published wheel does not depend on the generator at runtime.
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "oapi-gen-dishka"
7
+ version = "0.1.0"
8
+ description = "Request-scoped Dishka injection for oapi-gen handlers"
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ dependencies = ["dishka>=1.10.1,<2", "starlette>=1.0,<2"]
12
+
13
+ [dependency-groups]
14
+ dev = [
15
+ "basedpyright==1.39.10",
16
+ "httpx==0.28.1",
17
+ "oapi-gen",
18
+ "pytest==9.1.1",
19
+ "ruff==0.16.6",
20
+ ]
21
+
22
+ [tool.uv.sources]
23
+ oapi-gen = { path = "../.." }
24
+
25
+ [tool.setuptools.packages.find]
26
+ where = ["src"]
27
+
28
+ [tool.setuptools.package-data]
29
+ oapi_gen_dishka = ["py.typed"]
30
+
31
+ [tool.pytest.ini_options]
32
+ testpaths = ["tests"]
33
+ addopts = "-q"
34
+
35
+ [tool.basedpyright]
36
+ include = ["src"]
37
+ pythonVersion = "3.12"
38
+ typeCheckingMode = "standard"
39
+ reportMissingTypeStubs = false
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ """Dishka injection for the ordinary async methods called by generated routers."""
2
+
3
+ from ._integration import inject, setup_dishka
4
+
5
+ __all__ = ["inject", "setup_dishka"]
@@ -0,0 +1,77 @@
1
+ """Expose the native Dishka HTTP container to nested handler calls."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from contextvars import ContextVar
7
+ from inspect import iscoroutinefunction
8
+ from typing import Any, cast
9
+
10
+ from dishka import AsyncContainer
11
+ from dishka.integrations.base import wrap_injection
12
+ from dishka.integrations.starlette import ContainerMiddleware
13
+ from starlette.applications import Starlette
14
+ from starlette.types import ASGIApp, Receive, Scope, Send
15
+
16
+ _request_container: ContextVar[AsyncContainer] = ContextVar("oapi_gen_dishka_request_container")
17
+
18
+
19
+ def _get_container(args: tuple[Any, ...], kwargs: dict[str, Any]) -> AsyncContainer:
20
+ try:
21
+ return _request_container.get()
22
+ except LookupError:
23
+ raise RuntimeError(
24
+ "An injected oapi-gen handler must run inside an HTTP request configured with "
25
+ "oapi_gen_dishka.setup_dishka(container, app)."
26
+ ) from None
27
+
28
+
29
+ def inject[Result](
30
+ func: Callable[..., Result],
31
+ ) -> Callable[..., Result]:
32
+ """Inject FromDishka parameters using the active HTTP request's container.
33
+
34
+ Runtime parameter annotations and the response type are preserved. Static
35
+ typing allows arbitrary call arguments because injected parameters are removed;
36
+ use the generated handler protocol for a strictly typed calling interface.
37
+ """
38
+ if not iscoroutinefunction(func):
39
+ raise TypeError("oapi_gen_dishka.inject requires an async function or method")
40
+ wrapped = wrap_injection(func=func, is_async=True, container_getter=_get_container)
41
+ return cast(Callable[..., Result], wrapped)
42
+
43
+
44
+ class _RequestContextMiddleware:
45
+ def __init__(self, app: ASGIApp) -> None:
46
+ self.app = app
47
+
48
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
49
+ if scope["type"] != "http":
50
+ await self.app(scope, receive, send)
51
+ return
52
+
53
+ token = _request_container.set(scope["state"]["dishka_container"])
54
+ try:
55
+ await self.app(scope, receive, send)
56
+ finally:
57
+ _request_container.reset(token)
58
+
59
+
60
+ def setup_dishka(container: AsyncContainer, app: Starlette) -> None:
61
+ """Install native Dishka scope management and expose its container to handlers.
62
+
63
+ Call once, before app startup, to install the request container and handler context.
64
+ The application lifespan remains responsible for closing the APP container.
65
+ """
66
+ if not isinstance(container, AsyncContainer):
67
+ raise TypeError("oapi_gen_dishka requires an AsyncContainer")
68
+ if getattr(app.state, "dishka_container", None) is not None:
69
+ raise RuntimeError(
70
+ "Dishka is already configured for this app. Call oapi_gen_dishka.setup_dishka "
71
+ "once, instead of the framework's native Dishka setup."
72
+ )
73
+
74
+ # Starlette prepends middleware: native Dishka must run before this bridge.
75
+ app.add_middleware(_RequestContextMiddleware)
76
+ app.add_middleware(ContainerMiddleware)
77
+ app.state.dishka_container = container
File without changes
@@ -0,0 +1,148 @@
1
+ Metadata-Version: 2.4
2
+ Name: oapi-gen-dishka
3
+ Version: 0.1.0
4
+ Summary: Request-scoped Dishka injection for oapi-gen handlers
5
+ Requires-Python: >=3.12
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: dishka<2,>=1.10.1
8
+ Requires-Dist: starlette<2,>=1.0
9
+
10
+ # oapi-gen-dishka
11
+
12
+ Inject Dishka dependencies into the async methods called by an `oapi-gen` router.
13
+ Generated code stays unchanged. The integration reuses Dishka's native Starlette
14
+ request container, so operation handlers, security handlers, and ordinary Starlette
15
+ endpoints share the same `Scope.REQUEST` instances.
16
+
17
+ This directory is an independently buildable package. Install it into your
18
+ application from a local checkout:
19
+
20
+ ```bash
21
+ uv add /path/to/oapi-gen/integrations/dishka
22
+ ```
23
+
24
+ ## Usage
25
+
26
+ The example uses the repository's complete Cats specification. Generate it into
27
+ your application's package:
28
+
29
+ ```bash
30
+ oapi-gen generate tests/fixtures/cats.openapi.yaml --output app/http/generated
31
+ ```
32
+
33
+ ```python
34
+ from contextlib import asynccontextmanager
35
+ from uuid import UUID, uuid4
36
+
37
+ from dishka import FromDishka, Provider, Scope, make_async_container, provide
38
+ from starlette.applications import Starlette
39
+ from oapi_gen_dishka import inject, setup_dishka
40
+
41
+ from app.http.generated import Handlers, contracts, create_router, models
42
+
43
+
44
+ class CatsRepository:
45
+ def __init__(self):
46
+ self.cats: dict[UUID, models.Cat] = {}
47
+
48
+
49
+ class CatsController:
50
+ @inject
51
+ async def list_cats(
52
+ self,
53
+ request: contracts.ListCatsRequest,
54
+ *,
55
+ repository: FromDishka[CatsRepository],
56
+ ) -> contracts.ListCatsResponse:
57
+ cats = list(repository.cats.values())
58
+ return contracts.ListCatsResponse200(body=cats[: request.limit])
59
+
60
+ @inject
61
+ async def create_cat(
62
+ self,
63
+ request: contracts.CreateCatRequest,
64
+ *,
65
+ repository: FromDishka[CatsRepository],
66
+ ) -> contracts.CreateCatResponse:
67
+ cat = models.Cat(id=uuid4(), name=request.body.name)
68
+ repository.cats[cat.id] = cat
69
+ return contracts.CreateCatResponse201()
70
+
71
+ @inject
72
+ async def show_cat_by_id(
73
+ self,
74
+ request: contracts.ShowCatByIdRequest,
75
+ *,
76
+ repository: FromDishka[CatsRepository],
77
+ ) -> contracts.ShowCatByIdResponse:
78
+ cat = repository.cats.get(request.cat_id)
79
+ if cat is None:
80
+ return contracts.ShowCatByIdResponse404(
81
+ body=models.HttpError(message="Cat not found"),
82
+ )
83
+ return contracts.ShowCatByIdResponse200(body=cat)
84
+
85
+
86
+ class AppProvider(Provider):
87
+ # This in-memory example keeps cats between requests in one process.
88
+ repository = provide(CatsRepository, scope=Scope.APP)
89
+
90
+
91
+ container = make_async_container(AppProvider())
92
+
93
+
94
+ @asynccontextmanager
95
+ async def lifespan(app: Starlette):
96
+ try:
97
+ yield
98
+ finally:
99
+ await container.close()
100
+
101
+
102
+ router = create_router(Handlers(cats=CatsController()), prefix="/api")
103
+ app = Starlette(lifespan=lifespan, routes=router.routes)
104
+ setup_dishka(container, app)
105
+ ```
106
+
107
+ Use `Scope.REQUEST` for services or database sessions that must be recreated for
108
+ each request. Dishka resolves dependencies from the normal provider graph and
109
+ finalizes generator providers at the end of the request, including on exceptions.
110
+ Add Dishka's `StarletteProvider()` from `dishka.integrations.starlette` when a
111
+ provider needs `starlette.requests.Request`.
112
+
113
+ `@inject` also works on generated security handler methods. Use
114
+ `dishka.integrations.starlette.inject` on ordinary Starlette endpoints; both
115
+ decorators resolve from the same container after the single setup call above.
116
+
117
+ ## Boundaries
118
+
119
+ - Async HTTP handlers only. WebSockets, sync containers, and background work
120
+ outside the request lifetime are not supported by this decorator.
121
+ - Call this package's `setup_dishka` once, before startup, **instead of** calling
122
+ `dishka.integrations.starlette.setup_dishka` separately.
123
+ - The controller instance passed to `Handlers` is shared. Keep request-specific
124
+ state in local variables and injected dependencies, not on `self`.
125
+ - Providers must register dependencies; `FromDishka[T]` selects the registered
126
+ type and does not register it automatically. Dishka components are supported.
127
+ - Runtime signatures omit injected parameters and retain the remaining
128
+ annotations. The decorator preserves the static response type but uses
129
+ `Callable[..., ...]` for arguments, since Python typing cannot remove arbitrary
130
+ `FromDishka` parameters. Annotate controller references with the generated
131
+ protocol when calling them directly to check request argument types:
132
+
133
+ ```python
134
+ controller: contracts.CatsApi = CatsController()
135
+ ```
136
+
137
+ ## Development
138
+
139
+ ```bash
140
+ uv sync --project integrations/dishka
141
+ uv run --project integrations/dishka pytest integrations/dishka/tests
142
+ uv run --project integrations/dishka ruff check integrations/dishka
143
+ uv run --project integrations/dishka basedpyright --project integrations/dishka
144
+ uv build integrations/dishka --out-dir integrations/dishka/dist
145
+ ```
146
+
147
+ The local development dependency on `oapi-gen` is used only for integration tests;
148
+ the published wheel does not depend on the generator at runtime.
@@ -0,0 +1,12 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/oapi_gen_dishka/__init__.py
4
+ src/oapi_gen_dishka/_integration.py
5
+ src/oapi_gen_dishka/py.typed
6
+ src/oapi_gen_dishka.egg-info/PKG-INFO
7
+ src/oapi_gen_dishka.egg-info/SOURCES.txt
8
+ src/oapi_gen_dishka.egg-info/dependency_links.txt
9
+ src/oapi_gen_dishka.egg-info/requires.txt
10
+ src/oapi_gen_dishka.egg-info/top_level.txt
11
+ tests/test_integration.py
12
+ tests/test_starlette.py
@@ -0,0 +1,2 @@
1
+ dishka<2,>=1.10.1
2
+ starlette<2,>=1.0
@@ -0,0 +1 @@
1
+ oapi_gen_dishka
@@ -0,0 +1,416 @@
1
+ import asyncio
2
+ import importlib
3
+ import inspect
4
+ import json
5
+ import subprocess
6
+ import sys
7
+ from collections.abc import AsyncIterator
8
+ from contextlib import asynccontextmanager
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Annotated
12
+ from uuid import uuid4
13
+
14
+ import httpx
15
+ import pytest
16
+ from dishka import FromComponent, FromDishka, Provider, Scope, make_async_container, provide
17
+ from dishka.integrations.starlette import StarletteProvider
18
+ from dishka.integrations.starlette import inject as inject_starlette
19
+ from dishka.integrations.starlette import setup_dishka as setup_starlette_dishka
20
+ from oapi_gen import generate_package
21
+ from oapi_gen_dishka import inject, setup_dishka
22
+ from starlette.applications import Starlette
23
+ from starlette.requests import Request
24
+ from starlette.responses import JSONResponse
25
+ from starlette.routing import Route
26
+ from starlette.testclient import TestClient
27
+
28
+
29
+ @dataclass
30
+ class RequestResource:
31
+ name: str
32
+ id: str
33
+ native_dependency_seen: bool = False
34
+ closed: bool = False
35
+
36
+
37
+ class ResourceProvider(Provider):
38
+ def __init__(self):
39
+ super().__init__()
40
+ self.resources: list[RequestResource] = []
41
+
42
+ @provide(scope=Scope.REQUEST)
43
+ async def resource(self, request: Request) -> AsyncIterator[RequestResource]:
44
+ resource = RequestResource(name=request.headers.get("X-Request", "normal"), id=uuid4().hex)
45
+ self.resources.append(resource)
46
+ try:
47
+ yield resource
48
+ finally:
49
+ resource.closed = True
50
+
51
+
52
+ @pytest.fixture(scope="module")
53
+ def generated(tmp_path_factory):
54
+ root = tmp_path_factory.mktemp("generated")
55
+ spec = {
56
+ "openapi": "3.1.0",
57
+ "info": {"title": "Dishka integration", "version": "1"},
58
+ "paths": {
59
+ "/items": {
60
+ "get": {
61
+ "operationId": "listItems",
62
+ "x-handler-group": "Items",
63
+ "security": [{"bearerAuth": []}],
64
+ "parameters": [
65
+ {"name": "fail", "in": "query", "schema": {"type": "boolean"}},
66
+ ],
67
+ "responses": {
68
+ "200": {
69
+ "description": "Resource id",
70
+ "content": {"application/json": {"schema": {"type": "string"}}},
71
+ },
72
+ },
73
+ },
74
+ },
75
+ },
76
+ "components": {"securitySchemes": {"bearerAuth": {"type": "http", "scheme": "bearer"}}},
77
+ }
78
+ source = root / "spec.json"
79
+ source.write_text(json.dumps(spec))
80
+ name = "generated_dishka_test_api"
81
+ generate_package(source, root / name)
82
+ sys.path.insert(0, str(root))
83
+ try:
84
+ yield importlib.import_module(name)
85
+ finally:
86
+ sys.path.remove(str(root))
87
+ for key in list(sys.modules):
88
+ if key == name or key.startswith(f"{name}."):
89
+ del sys.modules[key]
90
+
91
+
92
+ @inject
93
+ async def outside_probe(*, resource: FromDishka[RequestResource]) -> RequestResource:
94
+ return resource
95
+
96
+
97
+ def build_app(generated, started=None):
98
+ contracts = generated.contracts
99
+ provider = ResourceProvider()
100
+ container = make_async_container(provider, StarletteProvider())
101
+
102
+ class Controller:
103
+ @inject
104
+ async def list_items(
105
+ self,
106
+ request: contracts.ListItemsRequest,
107
+ *,
108
+ resource: FromDishka[RequestResource],
109
+ ) -> contracts.ListItemsResponse:
110
+ assert request.security_context is resource
111
+ assert resource.native_dependency_seen
112
+ assert not resource.closed
113
+ if request.fail:
114
+ raise ValueError("handler failed")
115
+ if resource.name == "cancel":
116
+ started.set()
117
+ await asyncio.Event().wait()
118
+ await asyncio.sleep(0)
119
+ # Nested injection must still see this request's resource after yielding.
120
+ assert await outside_probe() is resource
121
+ return contracts.ListItemsResponse200(body=f"{resource.name}:{resource.id}")
122
+
123
+ class Security:
124
+ @inject
125
+ async def handle_bearer_auth(
126
+ self,
127
+ context: object | None,
128
+ operation_id: str,
129
+ credential: contracts.BearerAuthSecurity,
130
+ *,
131
+ resource: FromDishka[RequestResource],
132
+ ) -> object:
133
+ assert operation_id == "listItems"
134
+ assert context is None
135
+ if credential.token != "accepted":
136
+ raise contracts.SecurityRejected("denied")
137
+ return resource
138
+
139
+ @asynccontextmanager
140
+ async def lifespan(app):
141
+ try:
142
+ yield
143
+ finally:
144
+ await container.close()
145
+
146
+ router = generated.create_router(generated.Handlers(items=Controller()), security=Security())
147
+ generated_endpoint = router.routes[0].endpoint
148
+
149
+ @inject_starlette
150
+ async def native_endpoint(request: Request, resource: FromDishka[RequestResource]):
151
+ resource.native_dependency_seen = True
152
+ return await generated_endpoint(request)
153
+
154
+ app = Starlette(
155
+ lifespan=lifespan,
156
+ routes=[Route("/items", native_endpoint), *router.routes[1:]],
157
+ )
158
+ setup_dishka(container, app)
159
+ return app, container, provider, Controller
160
+
161
+
162
+ def test_generated_handlers_share_native_scope_with_security_and_dependencies(generated):
163
+ app, _, provider, controller = build_app(generated)
164
+ with TestClient(app) as client:
165
+ first = client.get("/items", headers={"Authorization": "Bearer accepted"})
166
+ second = client.get("/items", headers={"Authorization": "Bearer accepted"})
167
+ assert first.status_code == second.status_code == 200
168
+ assert first.json() != second.json()
169
+ assert len(provider.resources) == 2
170
+ assert all(resource.closed for resource in provider.resources)
171
+ operation = client.get("/openapi.json").json()["paths"]["/items"]["get"]
172
+ assert [parameter["name"] for parameter in operation["parameters"]] == ["fail"]
173
+
174
+ signature = inspect.signature(controller().list_items)
175
+ assert list(signature.parameters) == ["request"]
176
+ assert signature.parameters["request"].annotation is generated.contracts.ListItemsRequest
177
+ assert signature.return_annotation is generated.contracts.ListItemsResponse
178
+
179
+
180
+ def test_parallel_http_requests_are_isolated(generated):
181
+ async def scenario():
182
+ app, container, provider, _ = build_app(generated)
183
+ try:
184
+ async with httpx.AsyncClient(
185
+ transport=httpx.ASGITransport(app=app),
186
+ base_url="http://test",
187
+ ) as client:
188
+ responses = await asyncio.gather(
189
+ *(
190
+ client.get(
191
+ "/items",
192
+ headers={"Authorization": "Bearer accepted", "X-Request": str(index)},
193
+ )
194
+ for index in range(10)
195
+ )
196
+ )
197
+ assert all(response.status_code == 200 for response in responses)
198
+ assert [response.json().split(":")[0] for response in responses] == list(
199
+ map(str, range(10))
200
+ )
201
+ assert len({response.json().split(":")[1] for response in responses}) == 10
202
+ assert len(provider.resources) == 10
203
+ assert all(resource.closed for resource in provider.resources)
204
+ with pytest.raises(RuntimeError, match="inside an HTTP request"):
205
+ await outside_probe()
206
+ finally:
207
+ await container.close()
208
+
209
+ asyncio.run(scenario())
210
+
211
+
212
+ def test_failures_finalize_dependencies_and_reset_context(generated):
213
+ async def scenario():
214
+ app, container, provider, _ = build_app(generated)
215
+ try:
216
+ async with httpx.AsyncClient(
217
+ transport=httpx.ASGITransport(app=app),
218
+ base_url="http://test",
219
+ ) as client:
220
+ with pytest.raises(ValueError, match="handler failed"):
221
+ await client.get(
222
+ "/items?fail=true", headers={"Authorization": "Bearer accepted"}
223
+ )
224
+ assert provider.resources[-1].closed
225
+ with pytest.raises(RuntimeError, match="inside an HTTP request"):
226
+ await outside_probe()
227
+ denied = await client.get("/items", headers={"Authorization": "Bearer denied"})
228
+ assert denied.status_code == 401
229
+ assert provider.resources[-1].closed
230
+ success = await client.get("/items", headers={"Authorization": "Bearer accepted"})
231
+ assert success.status_code == 200
232
+ assert all(resource.closed for resource in provider.resources)
233
+ with pytest.raises(RuntimeError, match="inside an HTTP request"):
234
+ await outside_probe()
235
+ finally:
236
+ await container.close()
237
+
238
+ asyncio.run(scenario())
239
+
240
+
241
+ def test_cancellation_finalizes_request_resources(generated):
242
+ async def scenario():
243
+ started = asyncio.Event()
244
+ app, container, provider, _ = build_app(generated, started)
245
+ try:
246
+ async with httpx.AsyncClient(
247
+ transport=httpx.ASGITransport(app=app),
248
+ base_url="http://test",
249
+ ) as client:
250
+ task = asyncio.create_task(
251
+ client.get(
252
+ "/items",
253
+ headers={"Authorization": "Bearer accepted", "X-Request": "cancel"},
254
+ )
255
+ )
256
+ try:
257
+ await asyncio.wait_for(started.wait(), timeout=5)
258
+ finally:
259
+ task.cancel()
260
+ with pytest.raises(asyncio.CancelledError):
261
+ await task
262
+ assert len(provider.resources) == 1
263
+ assert provider.resources[0].closed
264
+ with pytest.raises(RuntimeError, match="inside an HTTP request"):
265
+ await outside_probe()
266
+ finally:
267
+ await container.close()
268
+
269
+ asyncio.run(scenario())
270
+
271
+
272
+ def test_dishka_components_are_supported():
273
+ class ComponentProvider(Provider):
274
+ @provide(scope=Scope.REQUEST)
275
+ def resource(self) -> RequestResource:
276
+ return RequestResource(name="component", id="component-id")
277
+
278
+ @inject
279
+ async def handler(
280
+ *,
281
+ resource: Annotated[RequestResource, FromComponent("other")],
282
+ ) -> str:
283
+ return resource.name
284
+
285
+ container = make_async_container(ComponentProvider().to_component("other"))
286
+
287
+ @asynccontextmanager
288
+ async def lifespan(app):
289
+ try:
290
+ yield
291
+ finally:
292
+ await container.close()
293
+
294
+ async def endpoint(request: Request):
295
+ return JSONResponse(await handler())
296
+
297
+ app = Starlette(lifespan=lifespan, routes=[Route("/", endpoint)])
298
+
299
+ setup_dishka(container, app)
300
+ with TestClient(app) as client:
301
+ assert client.get("/").json() == "component"
302
+
303
+
304
+ @pytest.mark.parametrize("native_first", [False, True])
305
+ def test_setup_rejects_duplicate_scope_management(native_first):
306
+ async def scenario():
307
+ container = make_async_container(Provider())
308
+ app = Starlette()
309
+ try:
310
+ (setup_starlette_dishka if native_first else setup_dishka)(container, app)
311
+ middleware_count = len(app.user_middleware)
312
+ with pytest.raises(RuntimeError, match="already configured"):
313
+ setup_dishka(container, app)
314
+ assert len(app.user_middleware) == middleware_count
315
+ finally:
316
+ await container.close()
317
+
318
+ asyncio.run(scenario())
319
+
320
+
321
+ def test_inject_rejects_sync_functions():
322
+ with pytest.raises(TypeError, match="async function or method"):
323
+ inject(lambda: None)
324
+
325
+
326
+ def test_decorated_handlers_implement_generated_protocol(generated, tmp_path):
327
+ code = """
328
+ from dishka import FromDishka
329
+ from oapi_gen_dishka import inject
330
+ from generated_dishka_test_api import contracts
331
+
332
+ class Controller:
333
+ @inject
334
+ async def list_items(
335
+ self,
336
+ request: contracts.ListItemsRequest,
337
+ *,
338
+ dependency: FromDishka[object],
339
+ ) -> contracts.ListItemsResponse:
340
+ return contracts.ListItemsResponse200(body=str(request.fail))
341
+
342
+ handler: contracts.ItemsApi = Controller()
343
+ handlers = contracts.Handlers(items=Controller())
344
+
345
+ async def call() -> contracts.ListItemsResponse:
346
+ return await handler.list_items(
347
+ request=contracts.ListItemsRequest(fail=None, security_context=None),
348
+ )
349
+ """
350
+ source = tmp_path / "example.py"
351
+ source.write_text(code)
352
+ config = tmp_path / "pyrightconfig.json"
353
+ config.write_text(
354
+ json.dumps(
355
+ {
356
+ "include": ["example.py"],
357
+ "pythonVersion": "3.12",
358
+ "typeCheckingMode": "standard",
359
+ "extraPaths": [
360
+ str(Path(generated.__file__).parents[1]),
361
+ str(Path(__file__).resolve().parents[1] / "src"),
362
+ ],
363
+ }
364
+ )
365
+ )
366
+ command = [
367
+ sys.executable,
368
+ "-m",
369
+ "basedpyright",
370
+ "--project",
371
+ str(config),
372
+ "--pythonpath",
373
+ sys.executable,
374
+ "--outputjson",
375
+ ]
376
+ result = subprocess.run(command, capture_output=True, text=True, check=False)
377
+ assert result.returncode == 0, result.stdout + result.stderr
378
+
379
+ source.write_text(
380
+ code + '\nasync def invalid() -> None:\n await handler.list_items("wrong")\n'
381
+ )
382
+ result = subprocess.run(command, capture_output=True, text=True, check=False)
383
+ assert result.returncode == 1, result.stdout + result.stderr
384
+ diagnostics = json.loads(result.stdout)["generalDiagnostics"]
385
+ assert [diagnostic["rule"] for diagnostic in diagnostics] == ["reportArgumentType"]
386
+
387
+
388
+ def test_readme_example_runs_with_complete_generated_api(tmp_path, monkeypatch):
389
+ integration_root = Path(__file__).resolve().parents[1]
390
+ repo_root = integration_root.parents[1]
391
+ example = (
392
+ integration_root.joinpath("README.md")
393
+ .read_text()
394
+ .split("```python\n", 1)[1]
395
+ .split("```", 1)[0]
396
+ )
397
+ generate_package(
398
+ repo_root / "tests/fixtures/cats.openapi.yaml", tmp_path / "app/http/generated"
399
+ )
400
+ monkeypatch.syspath_prepend(str(tmp_path))
401
+ try:
402
+ namespace = {}
403
+ exec(compile(example, "README.md", "exec"), namespace)
404
+ with TestClient(namespace["app"]) as client:
405
+ assert client.get("/api/cats").json() == []
406
+ assert client.post("/api/cats", json={"name": "Mittens"}).status_code == 201
407
+ cats = client.get("/api/cats?limit=1").json()
408
+ assert len(cats) == 1
409
+ assert cats[0]["name"] == "Mittens"
410
+ assert client.get(f"/api/cats/{cats[0]['id']}").json() == cats[0]
411
+ assert client.get(f"/api/cats/{uuid4()}").status_code == 404
412
+ assert client.get("/api/cats?limit=0").status_code == 422
413
+ finally:
414
+ for name in list(sys.modules):
415
+ if name == "app" or name.startswith("app."):
416
+ del sys.modules[name]
@@ -0,0 +1,101 @@
1
+ import asyncio
2
+ import importlib
3
+ import json
4
+ from collections.abc import AsyncIterator
5
+ from pathlib import Path
6
+
7
+ import httpx
8
+ from dishka import FromDishka, Provider, Scope, make_async_container, provide
9
+ from dishka.integrations.starlette import StarletteProvider
10
+ from oapi_gen import generate_package
11
+ from oapi_gen_dishka import inject, setup_dishka
12
+ from starlette.applications import Starlette
13
+ from starlette.requests import Request
14
+
15
+
16
+ def test_starlette_scopes_are_shared_isolated_and_closed(tmp_path: Path, monkeypatch):
17
+ specification = {
18
+ "openapi": "3.1.0",
19
+ "info": {"title": "Scope test", "version": "1"},
20
+ "paths": {
21
+ "/value": {
22
+ "get": {
23
+ "operationId": "test",
24
+ "security": [{"key": []}],
25
+ "responses": {
26
+ "200": {
27
+ "description": "value",
28
+ "content": {"application/json": {"schema": {"type": "string"}}},
29
+ }
30
+ },
31
+ }
32
+ }
33
+ },
34
+ "components": {
35
+ "securitySchemes": {"key": {"type": "apiKey", "in": "header", "name": "X-Key"}}
36
+ },
37
+ }
38
+ source = tmp_path / "spec.json"
39
+ source.write_text(json.dumps(specification))
40
+ generate_package(source, tmp_path / "generated_starlette_dishka")
41
+ monkeypatch.syspath_prepend(str(tmp_path))
42
+ generated = importlib.import_module("generated_starlette_dishka")
43
+ contracts = importlib.import_module("generated_starlette_dishka.contracts")
44
+ resources = []
45
+
46
+ class Resource:
47
+ def __init__(self, name):
48
+ self.name = name
49
+ self.closed = False
50
+
51
+ class Services(Provider):
52
+ @provide(scope=Scope.REQUEST)
53
+ async def resource(self, request: Request) -> AsyncIterator[Resource]:
54
+ resource = Resource(request.headers["X-Key"])
55
+ resources.append(resource)
56
+ try:
57
+ yield resource
58
+ finally:
59
+ resource.closed = True
60
+
61
+ class Security:
62
+ @inject
63
+ async def handle_key(
64
+ self, context, operation_id, credential, resource: FromDishka[Resource]
65
+ ):
66
+ assert resource.name == credential.api_key
67
+ return resource
68
+
69
+ class Controller:
70
+ @inject
71
+ async def test(self, request, resource: FromDishka[Resource]):
72
+ await asyncio.sleep(0)
73
+ assert request.security_context is resource
74
+ assert not resource.closed
75
+ if resource.name == "fail":
76
+ raise ValueError("failed")
77
+ return contracts.TestResponse200(body=resource.name)
78
+
79
+ async def scenario():
80
+ container = make_async_container(Services(), StarletteProvider())
81
+ router = generated.create_router(
82
+ generated.Handlers(default=Controller()), security=Security()
83
+ )
84
+ app = Starlette(routes=router.routes)
85
+ setup_dishka(container, app)
86
+ try:
87
+ async with httpx.AsyncClient(
88
+ transport=httpx.ASGITransport(app, raise_app_exceptions=False),
89
+ base_url="http://test",
90
+ ) as client:
91
+ responses = await asyncio.gather(
92
+ *[client.get("/value", headers={"X-Key": str(i)}) for i in range(10)]
93
+ )
94
+ assert [response.json() for response in responses] == [str(i) for i in range(10)]
95
+ assert (await client.get("/value", headers={"X-Key": "fail"})).status_code == 500
96
+ assert len(resources) == 11
97
+ assert all(resource.closed for resource in resources)
98
+ finally:
99
+ await container.close()
100
+
101
+ asyncio.run(scenario())