modern-di-celery 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,61 @@
1
+ Metadata-Version: 2.4
2
+ Name: modern-di-celery
3
+ Version: 2.0.0
4
+ Summary: modern-di integration for Celery
5
+ Keywords: dependency-injection,di,ioc-container,modern-di,celery,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: celery>=5,<6
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-celery
24
+ Project-URL: Issues, https://github.com/modern-python/modern-di-celery/issues
25
+ Project-URL: Changelog, https://github.com/modern-python/modern-di-celery/releases
26
+ Description-Content-Type: text/markdown
27
+
28
+ # modern-di-celery
29
+
30
+ [Modern-DI](https://github.com/modern-python/modern-di) integration for [Celery](https://docs.celeryq.dev) 5.x.
31
+
32
+ ## Quickstart
33
+
34
+ ```python
35
+ import typing
36
+
37
+ from celery import Celery
38
+ from modern_di import Container, Group, Scope, providers
39
+ from modern_di_celery import DITask, FromDI, setup_di
40
+
41
+
42
+ class Settings:
43
+ def __init__(self) -> None:
44
+ self.greeting = "hello"
45
+
46
+
47
+ class Dependencies(Group):
48
+ settings = providers.Factory(scope=Scope.APP, creator=Settings)
49
+
50
+
51
+ app = Celery("myapp", broker="redis://localhost", task_cls=DITask)
52
+ setup_di(app, Container(groups=[Dependencies], validate=True))
53
+
54
+
55
+ @app.task
56
+ def greet(name: str, settings: typing.Annotated[Settings, FromDI(Dependencies.settings)]) -> str:
57
+ return f"{settings.greeting}, {name}"
58
+ ```
59
+
60
+ With `task_cls=DITask` every task is injected; alternatively decorate individual
61
+ tasks with `@inject`. See the [documentation](https://modern-di.modern-python.org).
@@ -0,0 +1,34 @@
1
+ # modern-di-celery
2
+
3
+ [Modern-DI](https://github.com/modern-python/modern-di) integration for [Celery](https://docs.celeryq.dev) 5.x.
4
+
5
+ ## Quickstart
6
+
7
+ ```python
8
+ import typing
9
+
10
+ from celery import Celery
11
+ from modern_di import Container, Group, Scope, providers
12
+ from modern_di_celery import DITask, FromDI, setup_di
13
+
14
+
15
+ class Settings:
16
+ def __init__(self) -> None:
17
+ self.greeting = "hello"
18
+
19
+
20
+ class Dependencies(Group):
21
+ settings = providers.Factory(scope=Scope.APP, creator=Settings)
22
+
23
+
24
+ app = Celery("myapp", broker="redis://localhost", task_cls=DITask)
25
+ setup_di(app, Container(groups=[Dependencies], validate=True))
26
+
27
+
28
+ @app.task
29
+ def greet(name: str, settings: typing.Annotated[Settings, FromDI(Dependencies.settings)]) -> str:
30
+ return f"{settings.greeting}, {name}"
31
+ ```
32
+
33
+ With `task_cls=DITask` every task is injected; alternatively decorate individual
34
+ tasks with `@inject`. See the [documentation](https://modern-di.modern-python.org).
@@ -0,0 +1,10 @@
1
+ from modern_di_celery.main import DITask, FromDI, fetch_di_container, inject, setup_di
2
+
3
+
4
+ __all__ = [
5
+ "DITask",
6
+ "FromDI",
7
+ "fetch_di_container",
8
+ "inject",
9
+ "setup_di",
10
+ ]
@@ -0,0 +1,99 @@
1
+ import dataclasses
2
+ import inspect
3
+ import typing
4
+
5
+ from celery import Celery, Task, current_app, signals
6
+ from celery.utils.functional import head_from_fun
7
+ from modern_di import Container, Scope, providers
8
+
9
+
10
+ # The root container lives on ``app.conf`` under this named key; read back with
11
+ # ``current_app.conf`` inside ``inject``. A named constant keeps writer and
12
+ # reader in provable agreement.
13
+ _ROOT_CONTAINER_KEY = "modern_di_container"
14
+
15
+
16
+ def setup_di(app: Celery, container: Container) -> Container:
17
+ app.conf[_ROOT_CONTAINER_KEY] = container
18
+
19
+ def _open_container(**_: typing.Any) -> None: # noqa: ANN401
20
+ container.open()
21
+
22
+ def _close_container(**_: typing.Any) -> None: # noqa: ANN401
23
+ container.close_sync()
24
+
25
+ # weak=False is required: Celery signals default to weak refs, which would
26
+ # garbage-collect these handlers before a worker process ever fires them.
27
+ signals.worker_process_init.connect(_open_container, weak=False)
28
+ signals.worker_process_shutdown.connect(_close_container, weak=False)
29
+ return container
30
+
31
+
32
+ def fetch_di_container(app: Celery) -> Container:
33
+ return typing.cast(Container, app.conf[_ROOT_CONTAINER_KEY])
34
+
35
+
36
+ T = typing.TypeVar("T")
37
+ T_co = typing.TypeVar("T_co", covariant=True)
38
+
39
+
40
+ @dataclasses.dataclass(slots=True, frozen=True)
41
+ class _FromDI(typing.Generic[T_co]):
42
+ dependency: providers.AbstractProvider[T_co] | type[T_co]
43
+
44
+
45
+ def FromDI(dependency: providers.AbstractProvider[T] | type[T]) -> T: # noqa: N802
46
+ return typing.cast(T, _FromDI(dependency))
47
+
48
+
49
+ def _parse_inject_params(func: typing.Callable[..., typing.Any]) -> dict[str, _FromDI[typing.Any]]:
50
+ hints = typing.get_type_hints(func, include_extras=True)
51
+ di_params: dict[str, _FromDI[typing.Any]] = {}
52
+ for name, hint in hints.items():
53
+ if name == "return":
54
+ continue
55
+ if typing.get_origin(hint) is typing.Annotated:
56
+ for meta in typing.get_args(hint)[1:]:
57
+ if isinstance(meta, _FromDI):
58
+ di_params[name] = meta
59
+ break
60
+ return di_params
61
+
62
+
63
+ def inject(func: typing.Callable[..., T]) -> typing.Callable[..., T]:
64
+ di_params = _parse_inject_params(func)
65
+ if not di_params:
66
+ func.__modern_di_injected__ = True # ty: ignore[unresolved-attribute]
67
+ return func
68
+
69
+ signature = inspect.signature(func)
70
+ visible_params = [p for name, p in signature.parameters.items() if name not in di_params]
71
+ visible_signature = signature.replace(parameters=visible_params)
72
+
73
+ def wrapper(*args: typing.Any, **kwargs: typing.Any) -> T: # noqa: ANN401
74
+ container = fetch_di_container(typing.cast(Celery, current_app)).build_child_container(scope=Scope.REQUEST)
75
+ try:
76
+ resolved = {name: container.resolve_dependency(marker.dependency) for name, marker in di_params.items()}
77
+ bound = visible_signature.bind(*args, **kwargs)
78
+ bound.apply_defaults()
79
+ return func(**bound.arguments, **resolved)
80
+ finally:
81
+ container.close_sync()
82
+
83
+ # NOT functools.wraps — keep Celery's arg-binding reading the stripped signature.
84
+ wrapper.__name__ = func.__name__ # ty: ignore[unresolved-attribute]
85
+ wrapper.__qualname__ = func.__qualname__ # ty: ignore[unresolved-attribute]
86
+ wrapper.__doc__ = func.__doc__
87
+ wrapper.__module__ = func.__module__
88
+ wrapper.__signature__ = visible_signature # ty: ignore[unresolved-attribute]
89
+ wrapper.__modern_di_injected__ = True # ty: ignore[unresolved-attribute]
90
+ return wrapper
91
+
92
+
93
+ class DITask(Task):
94
+ def __init__(self) -> None:
95
+ super().__init__()
96
+ if not getattr(self.run, "__modern_di_injected__", False):
97
+ injected = inject(self.run)
98
+ self.run = injected # ty: ignore[invalid-assignment]
99
+ self.__header__ = head_from_fun(injected)
File without changes
@@ -0,0 +1,81 @@
1
+ [project]
2
+ name = "modern-di-celery"
3
+ description = "modern-di integration for Celery"
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", "celery", "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 = ["celery>=5,<6", "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-celery"
27
+ Issues = "https://github.com/modern-python/modern-di-celery/issues"
28
+ Changelog = "https://github.com/modern-python/modern-di-celery/releases"
29
+
30
+ [dependency-groups]
31
+ dev = [
32
+ "pytest",
33
+ "pytest-cov",
34
+ ]
35
+ lint = [
36
+ "ty",
37
+ "ruff",
38
+ "eof-fixer",
39
+ "typing-extensions",
40
+ ]
41
+
42
+ [build-system]
43
+ requires = ["uv_build>=0.11,<1.0"]
44
+ build-backend = "uv_build"
45
+
46
+ [tool.uv.build-backend]
47
+ module-name = "modern_di_celery"
48
+ module-root = ""
49
+
50
+ [tool.ruff]
51
+ fix = false
52
+ unsafe-fixes = true
53
+ line-length = 120
54
+ target-version = "py310"
55
+
56
+ [tool.ruff.format]
57
+ docstring-code-format = true
58
+
59
+ [tool.ruff.lint]
60
+ select = ["ALL"]
61
+ ignore = [
62
+ "D1", # allow missing docstrings
63
+ "S101", # allow asserts
64
+ "TCH", # ignore flake8-type-checking
65
+ "FBT", # allow boolean args
66
+ "D203", # "one-blank-line-before-class" conflicting with D211
67
+ "D213", # "multi-line-summary-second-line" conflicting with D212
68
+ "COM812", # flake8-commas "Trailing comma missing"
69
+ "ISC001", # flake8-implicit-str-concat
70
+ "G004", # allow f-strings in logging
71
+ ]
72
+ isort.lines-after-imports = 2
73
+ isort.no-lines-before = ["standard-library", "local-folder"]
74
+
75
+ [tool.pytest.ini_options]
76
+ addopts = ""
77
+
78
+ [tool.coverage.report]
79
+ exclude_also = [
80
+ "if typing.TYPE_CHECKING:",
81
+ ]