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,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:"]
|