modern-di-aiogram 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,62 @@
1
+ Metadata-Version: 2.4
2
+ Name: modern-di-aiogram
3
+ Version: 2.0.0
4
+ Summary: modern-di integration for aiogram
5
+ Keywords: dependency-injection,di,ioc-container,modern-di,aiogram,telegram,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: aiogram>=3.2,<4
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-aiogram
24
+ Project-URL: Issues, https://github.com/modern-python/modern-di-aiogram/issues
25
+ Project-URL: Changelog, https://github.com/modern-python/modern-di-aiogram/releases
26
+ Description-Content-Type: text/markdown
27
+
28
+ # modern-di-aiogram
29
+
30
+ [Modern-DI](https://github.com/modern-python/modern-di) integration for [aiogram](https://docs.aiogram.dev) 3.x.
31
+
32
+ ## Quickstart
33
+
34
+ ```python
35
+ import typing
36
+
37
+ from aiogram import Bot, Dispatcher
38
+ from aiogram.types import Message
39
+ from modern_di import Container, Group, Scope, providers
40
+ from modern_di_aiogram import FromDI, inject, setup_di
41
+
42
+
43
+ class Settings:
44
+ def __init__(self) -> None:
45
+ self.greeting = "hello"
46
+
47
+
48
+ class Dependencies(Group):
49
+ settings = providers.Factory(scope=Scope.APP, creator=Settings)
50
+
51
+
52
+ dispatcher = Dispatcher()
53
+ setup_di(dispatcher, Container(groups=[Dependencies], validate=True))
54
+
55
+
56
+ @dispatcher.message()
57
+ @inject
58
+ async def greet(message: Message, settings: typing.Annotated[Settings, FromDI(Dependencies.settings)]) -> None:
59
+ await message.answer(f"{settings.greeting}, {message.from_user.first_name}")
60
+ ```
61
+
62
+ See the [documentation](https://modern-di.modern-python.org) for the full guide, including `auto_inject`.
@@ -0,0 +1,35 @@
1
+ # modern-di-aiogram
2
+
3
+ [Modern-DI](https://github.com/modern-python/modern-di) integration for [aiogram](https://docs.aiogram.dev) 3.x.
4
+
5
+ ## Quickstart
6
+
7
+ ```python
8
+ import typing
9
+
10
+ from aiogram import Bot, Dispatcher
11
+ from aiogram.types import Message
12
+ from modern_di import Container, Group, Scope, providers
13
+ from modern_di_aiogram import FromDI, inject, setup_di
14
+
15
+
16
+ class Settings:
17
+ def __init__(self) -> None:
18
+ self.greeting = "hello"
19
+
20
+
21
+ class Dependencies(Group):
22
+ settings = providers.Factory(scope=Scope.APP, creator=Settings)
23
+
24
+
25
+ dispatcher = Dispatcher()
26
+ setup_di(dispatcher, Container(groups=[Dependencies], validate=True))
27
+
28
+
29
+ @dispatcher.message()
30
+ @inject
31
+ async def greet(message: Message, settings: typing.Annotated[Settings, FromDI(Dependencies.settings)]) -> None:
32
+ await message.answer(f"{settings.greeting}, {message.from_user.first_name}")
33
+ ```
34
+
35
+ See the [documentation](https://modern-di.modern-python.org) for the full guide, including `auto_inject`.
@@ -0,0 +1,18 @@
1
+ from modern_di_aiogram.main import (
2
+ FromDI,
3
+ aiogram_event_provider,
4
+ aiogram_update_provider,
5
+ fetch_di_container,
6
+ inject,
7
+ setup_di,
8
+ )
9
+
10
+
11
+ __all__ = [
12
+ "FromDI",
13
+ "aiogram_event_provider",
14
+ "aiogram_update_provider",
15
+ "fetch_di_container",
16
+ "inject",
17
+ "setup_di",
18
+ ]
@@ -0,0 +1,131 @@
1
+ import dataclasses
2
+ import functools
3
+ import inspect
4
+ import typing
5
+
6
+ import aiogram
7
+ from aiogram import BaseMiddleware, Dispatcher
8
+ from aiogram.dispatcher.event.handler import HandlerObject
9
+ from aiogram.types import TelegramObject, Update
10
+ from modern_di import Container, Scope, providers
11
+
12
+
13
+ aiogram_update_provider = providers.ContextProvider(Update, scope=Scope.REQUEST)
14
+ aiogram_event_provider = providers.ContextProvider(TelegramObject, scope=Scope.REQUEST)
15
+ _CONNECTION_PROVIDERS = (aiogram_update_provider, aiogram_event_provider)
16
+
17
+ # Root container in the dispatcher's workflow-data; per-update child in the
18
+ # per-update ``data`` dict (also the keyword-only param name ``inject`` adds).
19
+ # Named constants keep writer and reader in provable agreement.
20
+ _ROOT_CONTAINER_KEY = "modern_di_root_container"
21
+ _CHILD_CONTAINER_KEY = "modern_di_container"
22
+
23
+
24
+ class _DiMiddleware(BaseMiddleware):
25
+ def __init__(self, container: Container) -> None:
26
+ self.container = container
27
+
28
+ async def __call__(
29
+ self,
30
+ handler: typing.Callable[[TelegramObject, dict[str, typing.Any]], typing.Awaitable[typing.Any]],
31
+ event: TelegramObject,
32
+ data: dict[str, typing.Any],
33
+ ) -> typing.Any: # noqa: ANN401
34
+ child_container = self.container.build_child_container(
35
+ scope=Scope.REQUEST,
36
+ context={Update: event, TelegramObject: typing.cast("Update", event).event},
37
+ )
38
+ data[_CHILD_CONTAINER_KEY] = child_container
39
+ try:
40
+ return await handler(event, data)
41
+ finally:
42
+ await child_container.close_async()
43
+
44
+
45
+ def setup_di(dispatcher: Dispatcher, container: Container, *, auto_inject: bool = False) -> Container:
46
+ dispatcher[_ROOT_CONTAINER_KEY] = container
47
+ container.add_providers(*_CONNECTION_PROVIDERS)
48
+ dispatcher.startup.register(container.open)
49
+ dispatcher.shutdown.register(container.close_async)
50
+ dispatcher.update.outer_middleware(_DiMiddleware(container))
51
+ if auto_inject:
52
+ dispatcher.startup.register(functools.partial(_inject_router, dispatcher))
53
+ return container
54
+
55
+
56
+ def fetch_di_container(dispatcher: Dispatcher) -> Container:
57
+ return typing.cast(Container, dispatcher[_ROOT_CONTAINER_KEY])
58
+
59
+
60
+ T = typing.TypeVar("T")
61
+ T_co = typing.TypeVar("T_co", covariant=True)
62
+
63
+
64
+ @dataclasses.dataclass(slots=True, frozen=True)
65
+ class _FromDI(typing.Generic[T_co]):
66
+ dependency: providers.AbstractProvider[T_co] | type[T_co]
67
+
68
+
69
+ def FromDI(dependency: providers.AbstractProvider[T] | type[T]) -> T: # noqa: N802
70
+ return typing.cast(T, _FromDI(dependency))
71
+
72
+
73
+ def _parse_inject_params(func: typing.Callable[..., typing.Any]) -> dict[str, _FromDI[typing.Any]]:
74
+ hints = typing.get_type_hints(func, include_extras=True)
75
+ di_params: dict[str, _FromDI[typing.Any]] = {}
76
+ for name, hint in hints.items():
77
+ if name == "return":
78
+ continue
79
+ if typing.get_origin(hint) is typing.Annotated:
80
+ for meta in typing.get_args(hint)[1:]:
81
+ if isinstance(meta, _FromDI):
82
+ di_params[name] = meta
83
+ break
84
+ return di_params
85
+
86
+
87
+ def inject(func: typing.Callable[..., typing.Awaitable[T]]) -> typing.Callable[..., typing.Awaitable[T]]:
88
+ di_params = _parse_inject_params(func)
89
+ if not di_params:
90
+ func.__modern_di_injected__ = True # ty: ignore[unresolved-attribute]
91
+ return func
92
+
93
+ original_signature = inspect.signature(func)
94
+ visible_params = [p for name, p in original_signature.parameters.items() if name not in di_params]
95
+ container_param_injected = _CHILD_CONTAINER_KEY not in original_signature.parameters
96
+ if container_param_injected:
97
+ visible_params.append(
98
+ inspect.Parameter(_CHILD_CONTAINER_KEY, kind=inspect.Parameter.KEYWORD_ONLY, annotation=Container),
99
+ )
100
+
101
+ async def wrapper(*args: typing.Any, **kwargs: typing.Any) -> T: # noqa: ANN401
102
+ container: Container = (
103
+ kwargs.pop(_CHILD_CONTAINER_KEY) if container_param_injected else kwargs[_CHILD_CONTAINER_KEY]
104
+ )
105
+ resolved = {name: container.resolve_dependency(marker.dependency) for name, marker in di_params.items()}
106
+ return await func(*args, **kwargs, **resolved)
107
+
108
+ # NOT functools.wraps: aiogram unwraps __wrapped__ and would defeat __signature__.
109
+ wrapper.__name__ = func.__name__ # ty: ignore[unresolved-attribute]
110
+ wrapper.__qualname__ = func.__qualname__ # ty: ignore[unresolved-attribute]
111
+ wrapper.__doc__ = func.__doc__
112
+ wrapper.__module__ = func.__module__
113
+ wrapper.__signature__ = original_signature.replace(parameters=visible_params) # ty: ignore[unresolved-attribute]
114
+ wrapper.__modern_di_injected__ = True # ty: ignore[unresolved-attribute]
115
+ return wrapper
116
+
117
+
118
+ def _inject_router(router: aiogram.Router) -> None:
119
+ for sub_router in router.chain_tail:
120
+ for observer in sub_router.observers.values():
121
+ if observer.event_name == "update":
122
+ continue
123
+ for handler in observer.handlers:
124
+ if not getattr(handler.callback, "__modern_di_injected__", False):
125
+ wrapped = HandlerObject(
126
+ callback=inject(handler.callback),
127
+ filters=handler.filters,
128
+ flags=handler.flags,
129
+ )
130
+ handler.callback = wrapped.callback
131
+ handler.params = wrapped.params
File without changes
@@ -0,0 +1,84 @@
1
+ [project]
2
+ name = "modern-di-aiogram"
3
+ description = "modern-di integration for aiogram"
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", "aiogram", "telegram", "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 = ["aiogram>=3.2,<4", "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-aiogram"
27
+ Issues = "https://github.com/modern-python/modern-di-aiogram/issues"
28
+ Changelog = "https://github.com/modern-python/modern-di-aiogram/releases"
29
+
30
+ [dependency-groups]
31
+ dev = [
32
+ "pytest",
33
+ "pytest-cov",
34
+ "pytest-asyncio",
35
+ ]
36
+ lint = [
37
+ "ty",
38
+ "ruff",
39
+ "eof-fixer",
40
+ "typing-extensions",
41
+ ]
42
+
43
+ [build-system]
44
+ requires = ["uv_build>=0.11,<1.0"]
45
+ build-backend = "uv_build"
46
+
47
+ [tool.uv.build-backend]
48
+ module-name = "modern_di_aiogram"
49
+ module-root = ""
50
+
51
+ [tool.ruff]
52
+ fix = false
53
+ unsafe-fixes = true
54
+ line-length = 120
55
+ target-version = "py310"
56
+
57
+ [tool.ruff.format]
58
+ docstring-code-format = true
59
+
60
+ [tool.ruff.lint]
61
+ select = ["ALL"]
62
+ ignore = [
63
+ "D1", # allow missing docstrings
64
+ "S101", # allow asserts
65
+ "TCH", # ignore flake8-type-checking
66
+ "FBT", # allow boolean args
67
+ "D203", # "one-blank-line-before-class" conflicting with D211
68
+ "D213", # "multi-line-summary-second-line" conflicting with D212
69
+ "COM812", # flake8-commas "Trailing comma missing"
70
+ "ISC001", # flake8-implicit-str-concat
71
+ "G004", # allow f-strings in logging
72
+ ]
73
+ isort.lines-after-imports = 2
74
+ isort.no-lines-before = ["standard-library", "local-folder"]
75
+
76
+ [tool.pytest.ini_options]
77
+ addopts = ""
78
+ asyncio_mode = "auto"
79
+ asyncio_default_fixture_loop_scope = "function"
80
+
81
+ [tool.coverage.report]
82
+ exclude_also = [
83
+ "if typing.TYPE_CHECKING:",
84
+ ]