modern-di-grpc 2.0.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,32 @@
1
+ Metadata-Version: 2.4
2
+ Name: modern-di-grpc
3
+ Version: 2.0.0
4
+ Summary: modern-di integration for gRPC
5
+ Keywords: dependency-injection,di,ioc-container,modern-di,grpc,grpcio,python
6
+ Author: Artur Shiriev
7
+ Author-email: Artur Shiriev <me@shiriev.ru>
8
+ License-Expression: MIT
9
+ Classifier: Development Status :: 5 - Production/Stable
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Typing :: Typed
17
+ Classifier: Topic :: Software Development :: Libraries
18
+ Requires-Dist: grpcio>=1.48,<2
19
+ Requires-Dist: modern-di>=2.25,<3
20
+ Requires-Python: >=3.10, <4
21
+ Project-URL: Homepage, https://modern-di.modern-python.org
22
+ Project-URL: Documentation, https://modern-di.modern-python.org
23
+ Project-URL: Repository, https://github.com/modern-python/modern-di-grpc
24
+ Project-URL: Issues, https://github.com/modern-python/modern-di-grpc/issues
25
+ Project-URL: Changelog, https://github.com/modern-python/modern-di-grpc/releases
26
+ Description-Content-Type: text/markdown
27
+
28
+ # modern-di-grpc
29
+
30
+ [Modern-DI](https://github.com/modern-python/modern-di) integration for [gRPC](https://grpc.io) (`grpcio`).
31
+
32
+ See the [documentation](https://modern-di.modern-python.org).
@@ -0,0 +1,5 @@
1
+ # modern-di-grpc
2
+
3
+ [Modern-DI](https://github.com/modern-python/modern-di) integration for [gRPC](https://grpc.io) (`grpcio`).
4
+
5
+ See the [documentation](https://modern-di.modern-python.org).
@@ -0,0 +1,18 @@
1
+ from modern_di_grpc.main import (
2
+ DIAioInterceptor,
3
+ DIInterceptor,
4
+ FromDI,
5
+ fetch_di_container,
6
+ grpc_context_provider,
7
+ inject,
8
+ )
9
+
10
+
11
+ __all__ = [
12
+ "DIAioInterceptor",
13
+ "DIInterceptor",
14
+ "FromDI",
15
+ "fetch_di_container",
16
+ "grpc_context_provider",
17
+ "inject",
18
+ ]
@@ -0,0 +1,238 @@
1
+ """modern-di integration for gRPC (grpcio)."""
2
+
3
+ import contextvars
4
+ import dataclasses
5
+ import functools
6
+ import inspect
7
+ import typing
8
+
9
+ import grpc
10
+ import grpc.aio
11
+ from grpc import ServicerContext
12
+ from modern_di import Container, Scope, providers
13
+
14
+
15
+ grpc_context_provider = providers.ContextProvider(ServicerContext, scope=Scope.REQUEST)
16
+
17
+ _request_container: contextvars.ContextVar[Container] = contextvars.ContextVar("modern_di_request_container")
18
+
19
+
20
+ def _build_child(container: Container, context: ServicerContext) -> Container:
21
+ child = container.build_child_container(scope=Scope.REQUEST)
22
+ child.set_context(ServicerContext, context)
23
+ return child
24
+
25
+
26
+ def fetch_di_container() -> Container:
27
+ """Return the current RPC's child container. Raises ``LookupError`` outside an intercepted RPC."""
28
+ return _request_container.get()
29
+
30
+
31
+ def _ensure_context_provider(container: Container) -> None:
32
+ # Register grpc_context_provider once (idempotent) so ServicerContext injects out of the box.
33
+ if container.providers_registry.find_provider(ServicerContext) is None:
34
+ container.add_providers(grpc_context_provider)
35
+
36
+
37
+ T = typing.TypeVar("T")
38
+ T_co = typing.TypeVar("T_co", covariant=True)
39
+
40
+
41
+ @dataclasses.dataclass(slots=True, frozen=True)
42
+ class _FromDI(typing.Generic[T_co]):
43
+ dependency: "providers.AbstractProvider[T_co] | type[T_co]"
44
+
45
+
46
+ def FromDI(dependency: "providers.AbstractProvider[T] | type[T]") -> T: # noqa: N802
47
+ """Mark a servicer-method parameter for DI injection via ``Annotated[T, FromDI]``."""
48
+ return typing.cast(T, _FromDI(dependency))
49
+
50
+
51
+ def _parse_inject_params(func: typing.Callable[..., typing.Any]) -> dict[str, _FromDI[typing.Any]]:
52
+ hints = typing.get_type_hints(func, include_extras=True)
53
+ di_params: dict[str, _FromDI[typing.Any]] = {}
54
+ for name, hint in hints.items():
55
+ if name == "return":
56
+ continue
57
+ if typing.get_origin(hint) is typing.Annotated:
58
+ for meta in typing.get_args(hint)[1:]:
59
+ if isinstance(meta, _FromDI):
60
+ di_params[name] = meta
61
+ break
62
+ return di_params
63
+
64
+
65
+ def _resolve(di_params: dict[str, _FromDI[typing.Any]]) -> dict[str, typing.Any]:
66
+ container = _request_container.get()
67
+ return {name: container.resolve_dependency(marker.dependency) for name, marker in di_params.items()}
68
+
69
+
70
+ def inject(func: typing.Callable[..., typing.Any]) -> typing.Callable[..., typing.Any]:
71
+ """Resolve ``FromDI`` params of a gRPC servicer method from the current RPC's child container.
72
+
73
+ Produces a sync, async, or async-generator wrapper matching *func*. gRPC always calls a
74
+ behavior as ``(request, context)`` positionally, so resolved params are appended as keywords
75
+ (no bind-by-name needed). A method with no ``FromDI`` parameter is returned unchanged.
76
+ """
77
+ di_params = _parse_inject_params(func)
78
+ if not di_params:
79
+ return func
80
+
81
+ if inspect.isasyncgenfunction(func):
82
+
83
+ @functools.wraps(func)
84
+ async def async_gen_wrapper(
85
+ *args: typing.Any, # noqa: ANN401
86
+ **kwargs: typing.Any, # noqa: ANN401
87
+ ) -> typing.AsyncIterator[typing.Any]:
88
+ async for item in func(*args, **kwargs, **_resolve(di_params)):
89
+ yield item
90
+
91
+ return async_gen_wrapper
92
+
93
+ if inspect.iscoroutinefunction(func):
94
+
95
+ @functools.wraps(func)
96
+ async def async_wrapper(*args: typing.Any, **kwargs: typing.Any) -> typing.Any: # noqa: ANN401
97
+ return await func(*args, **kwargs, **_resolve(di_params))
98
+
99
+ return async_wrapper
100
+
101
+ @functools.wraps(func)
102
+ def sync_wrapper(*args: typing.Any, **kwargs: typing.Any) -> typing.Any: # noqa: ANN401
103
+ return func(*args, **kwargs, **_resolve(di_params))
104
+
105
+ return sync_wrapper
106
+
107
+
108
+ def _rewrap(
109
+ handler: grpc.RpcMethodHandler,
110
+ unary_builder: typing.Callable[[typing.Callable[..., typing.Any]], typing.Callable[..., typing.Any]],
111
+ stream_builder: typing.Callable[[typing.Callable[..., typing.Any]], typing.Callable[..., typing.Any]],
112
+ ) -> grpc.RpcMethodHandler:
113
+ # grpc.RpcMethodHandler is an abc.ABC documenting these attributes only in its docstring (no annotated
114
+ # fields), so ty cannot resolve them on the concrete instances the runtime actually hands back.
115
+ deserializer = handler.request_deserializer # ty: ignore[unresolved-attribute]
116
+ serializer = handler.response_serializer # ty: ignore[unresolved-attribute]
117
+ if unary_unary := handler.unary_unary: # ty: ignore[unresolved-attribute]
118
+ return grpc.unary_unary_rpc_method_handler(unary_builder(unary_unary), deserializer, serializer)
119
+ if unary_stream := handler.unary_stream: # ty: ignore[unresolved-attribute]
120
+ return grpc.unary_stream_rpc_method_handler(stream_builder(unary_stream), deserializer, serializer)
121
+ if stream_unary := handler.stream_unary: # ty: ignore[unresolved-attribute]
122
+ return grpc.stream_unary_rpc_method_handler(unary_builder(stream_unary), deserializer, serializer)
123
+ if stream_stream := handler.stream_stream: # ty: ignore[unresolved-attribute]
124
+ return grpc.stream_stream_rpc_method_handler(stream_builder(stream_stream), deserializer, serializer)
125
+ return handler # pragma: no cover (every RpcMethodHandler sets exactly one behavior)
126
+
127
+
128
+ def _wrap_unary_sync(
129
+ behavior: typing.Callable[..., typing.Any], container: Container
130
+ ) -> typing.Callable[..., typing.Any]:
131
+ def wrapper(request_or_iterator: typing.Any, context: ServicerContext) -> typing.Any: # noqa: ANN401
132
+ child = _build_child(container, context)
133
+ token = _request_container.set(child)
134
+ try:
135
+ return behavior(request_or_iterator, context)
136
+ finally:
137
+ try:
138
+ child.close_sync()
139
+ finally:
140
+ _request_container.reset(token)
141
+
142
+ return wrapper
143
+
144
+
145
+ def _wrap_stream_sync(
146
+ behavior: typing.Callable[..., typing.Any], container: Container
147
+ ) -> typing.Callable[..., typing.Any]:
148
+ def wrapper(request_or_iterator: typing.Any, context: ServicerContext) -> typing.Iterator[typing.Any]: # noqa: ANN401
149
+ child = _build_child(container, context)
150
+ token = _request_container.set(child)
151
+ try:
152
+ yield from behavior(request_or_iterator, context)
153
+ finally:
154
+ try:
155
+ child.close_sync()
156
+ finally:
157
+ _request_container.reset(token)
158
+
159
+ return wrapper
160
+
161
+
162
+ class DIInterceptor(grpc.ServerInterceptor):
163
+ """Server interceptor that opens a ``Scope.REQUEST`` child container per RPC (sync server)."""
164
+
165
+ def __init__(self, container: Container) -> None:
166
+ self._container = container
167
+ _ensure_context_provider(container)
168
+
169
+ def intercept_service(
170
+ self,
171
+ continuation: typing.Callable[[grpc.HandlerCallDetails], grpc.RpcMethodHandler],
172
+ handler_call_details: grpc.HandlerCallDetails,
173
+ ) -> grpc.RpcMethodHandler:
174
+ handler = continuation(handler_call_details)
175
+ if handler is None:
176
+ return handler
177
+ return _rewrap(
178
+ handler,
179
+ lambda behavior: _wrap_unary_sync(behavior, self._container),
180
+ lambda behavior: _wrap_stream_sync(behavior, self._container),
181
+ )
182
+
183
+
184
+ def _wrap_unary_aio(
185
+ behavior: typing.Callable[..., typing.Any], container: Container
186
+ ) -> typing.Callable[..., typing.Any]:
187
+ async def wrapper(request_or_iterator: typing.Any, context: ServicerContext) -> typing.Any: # noqa: ANN401
188
+ child = _build_child(container, context)
189
+ token = _request_container.set(child)
190
+ try:
191
+ return await behavior(request_or_iterator, context)
192
+ finally:
193
+ try:
194
+ await child.close_async()
195
+ finally:
196
+ _request_container.reset(token)
197
+
198
+ return wrapper
199
+
200
+
201
+ def _wrap_stream_aio(
202
+ behavior: typing.Callable[..., typing.Any], container: Container
203
+ ) -> typing.Callable[..., typing.Any]:
204
+ async def wrapper(request_or_iterator: typing.Any, context: ServicerContext) -> typing.AsyncIterator[typing.Any]: # noqa: ANN401
205
+ child = _build_child(container, context)
206
+ token = _request_container.set(child)
207
+ try:
208
+ async for item in behavior(request_or_iterator, context):
209
+ yield item
210
+ finally:
211
+ try:
212
+ await child.close_async()
213
+ finally:
214
+ _request_container.reset(token)
215
+
216
+ return wrapper
217
+
218
+
219
+ class DIAioInterceptor(grpc.aio.ServerInterceptor):
220
+ """Server interceptor that opens a ``Scope.REQUEST`` child container per RPC (async server)."""
221
+
222
+ def __init__(self, container: Container) -> None:
223
+ self._container = container
224
+ _ensure_context_provider(container)
225
+
226
+ async def intercept_service(
227
+ self,
228
+ continuation: typing.Callable[[grpc.HandlerCallDetails], typing.Awaitable[grpc.RpcMethodHandler]],
229
+ handler_call_details: grpc.HandlerCallDetails,
230
+ ) -> grpc.RpcMethodHandler:
231
+ handler = await continuation(handler_call_details)
232
+ if handler is None:
233
+ return handler
234
+ return _rewrap(
235
+ handler,
236
+ lambda behavior: _wrap_unary_aio(behavior, self._container),
237
+ lambda behavior: _wrap_stream_aio(behavior, self._container),
238
+ )
File without changes
@@ -0,0 +1,73 @@
1
+ [project]
2
+ name = "modern-di-grpc"
3
+ description = "modern-di integration for gRPC"
4
+ authors = [{ name = "Artur Shiriev", email = "me@shiriev.ru" }]
5
+ requires-python = ">=3.10,<4"
6
+ license = "MIT"
7
+ readme = "README.md"
8
+ keywords = ["dependency-injection", "di", "ioc-container", "modern-di", "grpc", "grpcio", "python"]
9
+ classifiers = [
10
+ "Development Status :: 5 - Production/Stable",
11
+ "Intended Audience :: Developers",
12
+ "Programming Language :: Python :: 3.10",
13
+ "Programming Language :: Python :: 3.11",
14
+ "Programming Language :: Python :: 3.12",
15
+ "Programming Language :: Python :: 3.13",
16
+ "Programming Language :: Python :: 3.14",
17
+ "Typing :: Typed",
18
+ "Topic :: Software Development :: Libraries",
19
+ ]
20
+ dependencies = ["grpcio>=1.48,<2", "modern-di>=2.25,<3"]
21
+ version = "2.0.0"
22
+
23
+ [project.urls]
24
+ Homepage = "https://modern-di.modern-python.org"
25
+ Documentation = "https://modern-di.modern-python.org"
26
+ Repository = "https://github.com/modern-python/modern-di-grpc"
27
+ Issues = "https://github.com/modern-python/modern-di-grpc/issues"
28
+ Changelog = "https://github.com/modern-python/modern-di-grpc/releases"
29
+
30
+ [dependency-groups]
31
+ dev = ["pytest", "pytest-cov", "pytest-asyncio", "grpcio-tools", "protobuf"]
32
+ lint = ["ty", "ruff", "eof-fixer", "typing-extensions"]
33
+
34
+ [build-system]
35
+ requires = ["uv_build>=0.11,<1.0"]
36
+ build-backend = "uv_build"
37
+
38
+ [tool.uv.build-backend]
39
+ module-name = "modern_di_grpc"
40
+ module-root = ""
41
+
42
+ [tool.ruff]
43
+ fix = false
44
+ unsafe-fixes = true
45
+ line-length = 120
46
+ target-version = "py310"
47
+ extend-exclude = ["tests/protos"]
48
+
49
+ [tool.ruff.format]
50
+ docstring-code-format = true
51
+
52
+ [tool.ruff.lint]
53
+ select = ["ALL"]
54
+ ignore = ["D1", "S101", "TCH", "FBT", "D203", "D213", "COM812", "ISC001", "G004"]
55
+ isort.lines-after-imports = 2
56
+ isort.no-lines-before = ["standard-library", "local-folder"]
57
+
58
+ [tool.ruff.lint.extend-per-file-ignores]
59
+ "tests/*.py" = ["PLR2004", "SLF001"]
60
+
61
+ [tool.ty.src]
62
+ exclude = ["tests/protos"]
63
+
64
+ [tool.pytest.ini_options]
65
+ addopts = ""
66
+ asyncio_mode = "auto"
67
+ asyncio_default_fixture_loop_scope = "function"
68
+
69
+ [tool.coverage.run]
70
+ omit = ["tests/protos/*"]
71
+
72
+ [tool.coverage.report]
73
+ exclude_also = ["if typing.TYPE_CHECKING:"]