nuke-di 1.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,15 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ build/
5
+ dist/
6
+ *.egg-info/
7
+ .pytest_cache/
8
+ .mypy_cache/
9
+ .ruff_cache/
10
+ .coverage
11
+ coverage.xml
12
+ htmlcov/
13
+ .DS_Store
14
+ .idea/
15
+ .vscode/
@@ -0,0 +1,25 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
+ and the project adheres to [Semantic Versioning](https://semver.org/).
6
+
7
+ ## [1.0.0] - 2026-10-08
8
+
9
+ First public release, extracted from the `nuke.di` package of the nuke framework.
10
+
11
+ ### Added
12
+
13
+ - `Client` / `NotSingletonClient` base classes with async `connect()` / `disconnect()` hooks.
14
+ - `Dependencies` container and the global `DI` instance: `resolve`, `inject`, `mock`, `flush`,
15
+ `connect` / `disconnect` and `async with` support.
16
+ - `client_dataclass` decorator.
17
+ - `CONNECT_TIMEOUT_SECONDS` setting and `DependenciesSettings`.
18
+
19
+ ### Changed compared to `nuke.di`
20
+
21
+ - No runtime dependencies: settings no longer use `pydantic-settings`, logging uses the standard
22
+ `logging` module under the `nuke_di` logger.
23
+ - Clients no longer get a per-class `_logger` attribute.
24
+
25
+ [1.0.0]: https://github.com/troyan-dy/nuke-di/releases/tag/v1.0.0
nuke_di-1.0.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dmitriy Troyan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
nuke_di-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,237 @@
1
+ Metadata-Version: 2.5
2
+ Name: nuke-di
3
+ Version: 1.0.0
4
+ Summary: Simplest type-hint based dependency injection for async Python projects
5
+ Project-URL: Homepage, https://github.com/troyan-dy/nuke-di
6
+ Project-URL: Repository, https://github.com/troyan-dy/nuke-di
7
+ Project-URL: Issues, https://github.com/troyan-dy/nuke-di/issues
8
+ Project-URL: Changelog, https://github.com/troyan-dy/nuke-di/blob/master/CHANGELOG.md
9
+ Author: Dmitriy Troyan
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: asyncio,dependency-injection,di,ioc,type-hints
13
+ Classifier: Development Status :: 5 - Production/Stable
14
+ Classifier: Framework :: AsyncIO
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Programming Language :: Python :: 3.14
23
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
24
+ Classifier: Typing :: Typed
25
+ Requires-Python: >=3.11
26
+ Description-Content-Type: text/markdown
27
+
28
+ # nuke-di
29
+
30
+ [![PyPI](https://img.shields.io/pypi/v/nuke-di)](https://pypi.org/project/nuke-di/)
31
+ [![Python](https://img.shields.io/pypi/pyversions/nuke-di)](https://pypi.org/project/nuke-di/)
32
+ [![CI](https://github.com/troyan-dy/nuke-di/actions/workflows/ci.yml/badge.svg)](https://github.com/troyan-dy/nuke-di/actions/workflows/ci.yml)
33
+ [![License](https://img.shields.io/pypi/l/nuke-di)](LICENSE)
34
+
35
+ The simplest dependency injection for async Python projects.
36
+
37
+ Dependencies are declared with plain type hints. `nuke-di` builds the dependency tree,
38
+ creates every client once and drives its async lifecycle: `connect()` on startup and
39
+ `disconnect()` on shutdown, in reverse order.
40
+
41
+ It was extracted from the DI layer of a production Python microservice framework
42
+ and has no runtime dependencies.
43
+
44
+ ## Installation
45
+
46
+ ```bash
47
+ pip install nuke-di
48
+ ```
49
+
50
+ Requires Python 3.11+.
51
+
52
+ ## Quick start
53
+
54
+ ```python
55
+ import asyncio
56
+
57
+ from nuke_di import DI, Client
58
+
59
+
60
+ class Database(Client):
61
+ async def connect(self) -> None:
62
+ print("database: connected")
63
+
64
+ async def disconnect(self) -> None:
65
+ print("database: disconnected")
66
+
67
+ async def fetch_user(self, user_id: int) -> str:
68
+ return f"user-{user_id}"
69
+
70
+
71
+ class UserService(Client):
72
+ def __init__(self, db: Database) -> None:
73
+ self._db = db
74
+
75
+ async def greet(self, user_id: int) -> str:
76
+ return f"Hello, {await self._db.fetch_user(user_id)}!"
77
+
78
+
79
+ async def handler(user_id: int, users: UserService) -> str:
80
+ return await users.greet(user_id)
81
+
82
+
83
+ async def main() -> None:
84
+ injected = DI.inject(handler) # resolves UserService -> Database
85
+
86
+ async with DI: # connect() every client, disconnect() on exit
87
+ print(await injected(42))
88
+
89
+
90
+ asyncio.run(main())
91
+ ```
92
+
93
+ ```text
94
+ database: connected
95
+ Hello, user-42!
96
+ database: disconnected
97
+ ```
98
+
99
+ ## Concepts
100
+
101
+ ### Clients
102
+
103
+ Every dependency is a subclass of one of two base classes:
104
+
105
+ | Base class | Instances |
106
+ |----------------------|----------------------------------------------------|
107
+ | `Client` | Singleton: one instance per `Dependencies` |
108
+ | `NotSingletonClient` | A new instance for every consumer that declares it |
109
+
110
+ Override the async `connect()` / `disconnect()` methods to open and release resources
111
+ such as connection pools:
112
+
113
+ ```python
114
+ class Redis(Client):
115
+ def __init__(self) -> None:
116
+ self._pool: Pool | None = None
117
+
118
+ async def connect(self) -> None:
119
+ self._pool = await create_pool()
120
+
121
+ async def disconnect(self) -> None:
122
+ if self._pool is not None:
123
+ await self._pool.close()
124
+ self._pool = None
125
+ ```
126
+
127
+ ### Composition
128
+
129
+ A client declares its own dependencies in `__init__`. Only arguments annotated with a
130
+ client type are injected; resolution is recursive.
131
+
132
+ ```python
133
+ class BusinessLogic(Client):
134
+ def __init__(self, pg: Postgres, grpc: GrpcClient) -> None:
135
+ self._pg = pg
136
+ self._grpc = grpc
137
+ ```
138
+
139
+ ### Container
140
+
141
+ `Dependencies` is the container. `DI` is a ready-to-use global instance; create your own
142
+ when you need isolation, e.g. in tests.
143
+
144
+ | Method | Description |
145
+ |----------------------|-------------------------------------------------------------------------|
146
+ | `resolve(cls)` | Build `cls` and its dependency tree. Idempotent for `Client`. |
147
+ | `inject(func)` | Return `functools.partial(func, ...)` with client arguments bound. |
148
+ | `connect()` | Call `connect()` on every resolved client, in resolution order. |
149
+ | `disconnect()` | Call `disconnect()` in reverse order, then `flush()` the container. |
150
+ | `async with` | `connect()` on enter, `disconnect()` on exit. |
151
+ | `mock(cls, new=None)`| Register a replacement for `cls` (an autospec mock by default). |
152
+ | `flush()` | Forget every resolved client. |
153
+
154
+ `resolve`, `inject`, `mock` and `flush` only work while the container is disconnected:
155
+ the whole tree is built before startup.
156
+
157
+ A failing `disconnect()` is logged and does not stop the other clients from shutting down.
158
+
159
+ ### Dataclass clients
160
+
161
+ `client_dataclass` turns a class into a `Client` and a dataclass at once, so the fields
162
+ become the injected dependencies:
163
+
164
+ ```python
165
+ from nuke_di import client_dataclass
166
+
167
+
168
+ @client_dataclass(frozen=True)
169
+ class Checkout:
170
+ pg: Postgres
171
+ payments: PaymentsClient
172
+ ```
173
+
174
+ It accepts the same keyword arguments as `dataclasses.dataclass`.
175
+
176
+ ## Testing
177
+
178
+ Register mocks before the tree is resolved; every consumer then receives the mock.
179
+
180
+ ```python
181
+ from unittest.mock import call
182
+
183
+ from nuke_di import Dependencies
184
+
185
+
186
+ async def test_greet() -> None:
187
+ deps = Dependencies()
188
+ db = deps.mock(Database)
189
+ db.fetch_user.return_value = "alice"
190
+
191
+ users = deps.resolve(UserService)
192
+ async with deps:
193
+ assert await users.greet(1) == "Hello, alice!"
194
+
195
+ assert db.fetch_user.await_args_list == [call(1)]
196
+ ```
197
+
198
+ ## Configuration
199
+
200
+ | Environment variable | Default | Description |
201
+ |---------------------------|---------|----------------------------------------------------|
202
+ | `CONNECT_TIMEOUT_SECONDS` | `30` | Timeout for a single client's `connect()`, seconds |
203
+
204
+ The value is read when a `Dependencies` instance is created. You can also pass it explicitly:
205
+
206
+ ```python
207
+ from nuke_di import Dependencies, DependenciesSettings
208
+
209
+ deps = Dependencies(settings=DependenciesSettings(connect_timeout=5))
210
+ ```
211
+
212
+ ## Errors
213
+
214
+ | Exception | Raised when |
215
+ |-----------------------------|-----------------------------------------------------------|
216
+ | `InitializeDependencyError` | A client's `__init__` raised |
217
+ | `ConnectError` | A client's `connect()` raised, or the container state is wrong (e.g. resolving after connect) |
218
+ | `ConnectTimeoutError` | A client's `connect()` exceeded `CONNECT_TIMEOUT_SECONDS` |
219
+
220
+ `InitializeDependencyError` and `ConnectError` derive from `SystemExit`: an application
221
+ whose dependencies cannot start is expected to stop. Catch them explicitly if you need
222
+ different behavior; the original exception is available as `__cause__`.
223
+
224
+ `nuke-di` logs through the standard `logging` module under the `nuke_di` logger.
225
+
226
+ ## Development
227
+
228
+ ```bash
229
+ uv sync
230
+ uv run pytest --cov
231
+ uv run ruff check . && uv run ruff format --check .
232
+ uv run mypy
233
+ ```
234
+
235
+ ## License
236
+
237
+ [MIT](LICENSE)
@@ -0,0 +1,210 @@
1
+ # nuke-di
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/nuke-di)](https://pypi.org/project/nuke-di/)
4
+ [![Python](https://img.shields.io/pypi/pyversions/nuke-di)](https://pypi.org/project/nuke-di/)
5
+ [![CI](https://github.com/troyan-dy/nuke-di/actions/workflows/ci.yml/badge.svg)](https://github.com/troyan-dy/nuke-di/actions/workflows/ci.yml)
6
+ [![License](https://img.shields.io/pypi/l/nuke-di)](LICENSE)
7
+
8
+ The simplest dependency injection for async Python projects.
9
+
10
+ Dependencies are declared with plain type hints. `nuke-di` builds the dependency tree,
11
+ creates every client once and drives its async lifecycle: `connect()` on startup and
12
+ `disconnect()` on shutdown, in reverse order.
13
+
14
+ It was extracted from the DI layer of a production Python microservice framework
15
+ and has no runtime dependencies.
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ pip install nuke-di
21
+ ```
22
+
23
+ Requires Python 3.11+.
24
+
25
+ ## Quick start
26
+
27
+ ```python
28
+ import asyncio
29
+
30
+ from nuke_di import DI, Client
31
+
32
+
33
+ class Database(Client):
34
+ async def connect(self) -> None:
35
+ print("database: connected")
36
+
37
+ async def disconnect(self) -> None:
38
+ print("database: disconnected")
39
+
40
+ async def fetch_user(self, user_id: int) -> str:
41
+ return f"user-{user_id}"
42
+
43
+
44
+ class UserService(Client):
45
+ def __init__(self, db: Database) -> None:
46
+ self._db = db
47
+
48
+ async def greet(self, user_id: int) -> str:
49
+ return f"Hello, {await self._db.fetch_user(user_id)}!"
50
+
51
+
52
+ async def handler(user_id: int, users: UserService) -> str:
53
+ return await users.greet(user_id)
54
+
55
+
56
+ async def main() -> None:
57
+ injected = DI.inject(handler) # resolves UserService -> Database
58
+
59
+ async with DI: # connect() every client, disconnect() on exit
60
+ print(await injected(42))
61
+
62
+
63
+ asyncio.run(main())
64
+ ```
65
+
66
+ ```text
67
+ database: connected
68
+ Hello, user-42!
69
+ database: disconnected
70
+ ```
71
+
72
+ ## Concepts
73
+
74
+ ### Clients
75
+
76
+ Every dependency is a subclass of one of two base classes:
77
+
78
+ | Base class | Instances |
79
+ |----------------------|----------------------------------------------------|
80
+ | `Client` | Singleton: one instance per `Dependencies` |
81
+ | `NotSingletonClient` | A new instance for every consumer that declares it |
82
+
83
+ Override the async `connect()` / `disconnect()` methods to open and release resources
84
+ such as connection pools:
85
+
86
+ ```python
87
+ class Redis(Client):
88
+ def __init__(self) -> None:
89
+ self._pool: Pool | None = None
90
+
91
+ async def connect(self) -> None:
92
+ self._pool = await create_pool()
93
+
94
+ async def disconnect(self) -> None:
95
+ if self._pool is not None:
96
+ await self._pool.close()
97
+ self._pool = None
98
+ ```
99
+
100
+ ### Composition
101
+
102
+ A client declares its own dependencies in `__init__`. Only arguments annotated with a
103
+ client type are injected; resolution is recursive.
104
+
105
+ ```python
106
+ class BusinessLogic(Client):
107
+ def __init__(self, pg: Postgres, grpc: GrpcClient) -> None:
108
+ self._pg = pg
109
+ self._grpc = grpc
110
+ ```
111
+
112
+ ### Container
113
+
114
+ `Dependencies` is the container. `DI` is a ready-to-use global instance; create your own
115
+ when you need isolation, e.g. in tests.
116
+
117
+ | Method | Description |
118
+ |----------------------|-------------------------------------------------------------------------|
119
+ | `resolve(cls)` | Build `cls` and its dependency tree. Idempotent for `Client`. |
120
+ | `inject(func)` | Return `functools.partial(func, ...)` with client arguments bound. |
121
+ | `connect()` | Call `connect()` on every resolved client, in resolution order. |
122
+ | `disconnect()` | Call `disconnect()` in reverse order, then `flush()` the container. |
123
+ | `async with` | `connect()` on enter, `disconnect()` on exit. |
124
+ | `mock(cls, new=None)`| Register a replacement for `cls` (an autospec mock by default). |
125
+ | `flush()` | Forget every resolved client. |
126
+
127
+ `resolve`, `inject`, `mock` and `flush` only work while the container is disconnected:
128
+ the whole tree is built before startup.
129
+
130
+ A failing `disconnect()` is logged and does not stop the other clients from shutting down.
131
+
132
+ ### Dataclass clients
133
+
134
+ `client_dataclass` turns a class into a `Client` and a dataclass at once, so the fields
135
+ become the injected dependencies:
136
+
137
+ ```python
138
+ from nuke_di import client_dataclass
139
+
140
+
141
+ @client_dataclass(frozen=True)
142
+ class Checkout:
143
+ pg: Postgres
144
+ payments: PaymentsClient
145
+ ```
146
+
147
+ It accepts the same keyword arguments as `dataclasses.dataclass`.
148
+
149
+ ## Testing
150
+
151
+ Register mocks before the tree is resolved; every consumer then receives the mock.
152
+
153
+ ```python
154
+ from unittest.mock import call
155
+
156
+ from nuke_di import Dependencies
157
+
158
+
159
+ async def test_greet() -> None:
160
+ deps = Dependencies()
161
+ db = deps.mock(Database)
162
+ db.fetch_user.return_value = "alice"
163
+
164
+ users = deps.resolve(UserService)
165
+ async with deps:
166
+ assert await users.greet(1) == "Hello, alice!"
167
+
168
+ assert db.fetch_user.await_args_list == [call(1)]
169
+ ```
170
+
171
+ ## Configuration
172
+
173
+ | Environment variable | Default | Description |
174
+ |---------------------------|---------|----------------------------------------------------|
175
+ | `CONNECT_TIMEOUT_SECONDS` | `30` | Timeout for a single client's `connect()`, seconds |
176
+
177
+ The value is read when a `Dependencies` instance is created. You can also pass it explicitly:
178
+
179
+ ```python
180
+ from nuke_di import Dependencies, DependenciesSettings
181
+
182
+ deps = Dependencies(settings=DependenciesSettings(connect_timeout=5))
183
+ ```
184
+
185
+ ## Errors
186
+
187
+ | Exception | Raised when |
188
+ |-----------------------------|-----------------------------------------------------------|
189
+ | `InitializeDependencyError` | A client's `__init__` raised |
190
+ | `ConnectError` | A client's `connect()` raised, or the container state is wrong (e.g. resolving after connect) |
191
+ | `ConnectTimeoutError` | A client's `connect()` exceeded `CONNECT_TIMEOUT_SECONDS` |
192
+
193
+ `InitializeDependencyError` and `ConnectError` derive from `SystemExit`: an application
194
+ whose dependencies cannot start is expected to stop. Catch them explicitly if you need
195
+ different behavior; the original exception is available as `__cause__`.
196
+
197
+ `nuke-di` logs through the standard `logging` module under the `nuke_di` logger.
198
+
199
+ ## Development
200
+
201
+ ```bash
202
+ uv sync
203
+ uv run pytest --cov
204
+ uv run ruff check . && uv run ruff format --check .
205
+ uv run mypy
206
+ ```
207
+
208
+ ## License
209
+
210
+ [MIT](LICENSE)
@@ -0,0 +1,75 @@
1
+ [project]
2
+ name = "nuke-di"
3
+ version = "1.0.0"
4
+ description = "Simplest type-hint based dependency injection for async Python projects"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [{ name = "Dmitriy Troyan" }]
9
+ requires-python = ">=3.11"
10
+ dependencies = []
11
+ keywords = ["di", "dependency-injection", "asyncio", "ioc", "type-hints"]
12
+ classifiers = [
13
+ "Development Status :: 5 - Production/Stable",
14
+ "Framework :: AsyncIO",
15
+ "Intended Audience :: Developers",
16
+ "Operating System :: OS Independent",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3 :: Only",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Programming Language :: Python :: 3.14",
23
+ "Topic :: Software Development :: Libraries :: Python Modules",
24
+ "Typing :: Typed",
25
+ ]
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/troyan-dy/nuke-di"
29
+ Repository = "https://github.com/troyan-dy/nuke-di"
30
+ Issues = "https://github.com/troyan-dy/nuke-di/issues"
31
+ Changelog = "https://github.com/troyan-dy/nuke-di/blob/master/CHANGELOG.md"
32
+
33
+ [dependency-groups]
34
+ dev = [
35
+ "mypy>=1.13",
36
+ "pytest>=8.3",
37
+ "pytest-asyncio>=0.24",
38
+ "pytest-cov>=6.0",
39
+ "ruff>=0.8",
40
+ ]
41
+
42
+ [build-system]
43
+ requires = ["hatchling>=1.27"]
44
+ build-backend = "hatchling.build"
45
+
46
+ [tool.hatch.build.targets.sdist]
47
+ include = ["src", "tests", "CHANGELOG.md", "README.md", "LICENSE"]
48
+
49
+ [tool.hatch.build.targets.wheel]
50
+ packages = ["src/nuke_di"]
51
+
52
+ [tool.ruff]
53
+ line-length = 120
54
+ target-version = "py311"
55
+
56
+ [tool.ruff.lint]
57
+ select = ["E", "W", "F", "I", "N", "B", "A", "UP", "ASYNC", "S", "RUF"]
58
+ ignore = ["S101"]
59
+
60
+ [tool.mypy]
61
+ files = ["src", "tests"]
62
+ strict = true
63
+ disallow_any_generics = false
64
+
65
+ [tool.pytest.ini_options]
66
+ asyncio_mode = "auto"
67
+ asyncio_default_fixture_loop_scope = "function"
68
+ testpaths = ["tests"]
69
+
70
+ [tool.coverage.run]
71
+ source = ["nuke_di"]
72
+ branch = true
73
+
74
+ [tool.coverage.report]
75
+ show_missing = true
@@ -0,0 +1,18 @@
1
+ from nuke_di.core import DI, Dependencies
2
+ from nuke_di.dataclass import client_dataclass
3
+ from nuke_di.errors import ConnectError, ConnectTimeoutError, InitializeDependencyError, InvalidSignatureError
4
+ from nuke_di.options import DependenciesSettings
5
+ from nuke_di.types import Client, NotSingletonClient
6
+
7
+ __all__ = (
8
+ "DI",
9
+ "Client",
10
+ "ConnectError",
11
+ "ConnectTimeoutError",
12
+ "Dependencies",
13
+ "DependenciesSettings",
14
+ "InitializeDependencyError",
15
+ "InvalidSignatureError",
16
+ "NotSingletonClient",
17
+ "client_dataclass",
18
+ )
@@ -0,0 +1,148 @@
1
+ import asyncio
2
+ import inspect
3
+ import logging
4
+ from collections import OrderedDict
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass, field
7
+ from functools import partial
8
+ from typing import Any, TypeVar, cast, get_type_hints
9
+ from unittest.mock import create_autospec
10
+
11
+ from nuke_di.errors import ConnectError, ConnectTimeoutError, InitializeDependencyError, InvalidSignatureError
12
+ from nuke_di.options import DependenciesSettings
13
+ from nuke_di.types import Client, NotSingletonClient
14
+ from nuke_di.utils import isa, select_values, sname, walk_values
15
+
16
+ logger = logging.getLogger(__name__)
17
+ isclient = isa(Client)
18
+ isnotsingleton = isa(NotSingletonClient)
19
+
20
+ CT = TypeVar("CT", bound=NotSingletonClient)
21
+
22
+
23
+ @dataclass(repr=False)
24
+ class Dependencies:
25
+ clients: OrderedDict[type[NotSingletonClient], NotSingletonClient] = field(default_factory=OrderedDict)
26
+ connect_clients: list[NotSingletonClient] = field(default_factory=list)
27
+ settings: DependenciesSettings = field(default_factory=DependenciesSettings)
28
+ connected: bool = field(default=False, init=False)
29
+
30
+ async def __aenter__(self) -> None:
31
+ await self.connect()
32
+
33
+ async def __aexit__(self, *args: Any) -> None:
34
+ await self.disconnect()
35
+
36
+ def flush(self) -> None:
37
+ if self.connected is True:
38
+ raise ConnectError("already connected")
39
+
40
+ self.clients = OrderedDict()
41
+ self.connect_clients = []
42
+
43
+ async def connect(self) -> None:
44
+ if self.connected is True:
45
+ raise ConnectError("already connected")
46
+
47
+ self.connected = True
48
+
49
+ for client in self.connect_clients:
50
+ name = sname(client)
51
+ logger.debug("Connecting client %s", name)
52
+ try:
53
+ await asyncio.wait_for(client.connect(), timeout=self.settings.connect_timeout)
54
+
55
+ except TimeoutError as exc:
56
+ logger.exception("Timeout occurred connecting client %s", name)
57
+ raise ConnectTimeoutError(f"Timeout occurred connecting client {name}") from exc
58
+
59
+ except Exception as e:
60
+ logger.exception("Error occurred connecting client %s", name)
61
+ raise ConnectError(f"Error occurred connecting client {name}") from e
62
+
63
+ async def disconnect(self) -> None:
64
+ if self.connected is False:
65
+ raise ConnectError("already disconnected")
66
+
67
+ self.connected = False
68
+
69
+ for client in reversed(self.connect_clients):
70
+ name = sname(client)
71
+ logger.debug("Disconnecting client %s", name)
72
+ try:
73
+ await client.disconnect()
74
+ except Exception:
75
+ # The client failed, but the rest still have to be stopped
76
+ logger.exception("Failed to disconnect client %s", name)
77
+
78
+ self.flush()
79
+
80
+ def resolve(self, cls: type[CT]) -> CT:
81
+ """
82
+ Idempotent operation.
83
+ """
84
+ if self.connected is True:
85
+ raise ConnectError("already connected")
86
+
87
+ inst = self.clients.get(cls)
88
+ if inst is not None:
89
+ return cast(CT, inst)
90
+
91
+ name = sname(cls)
92
+ logger.debug('Resolving dependency "%s"', name)
93
+
94
+ # A client may appear in the class annotations but not in __init__,
95
+ # which means it is not _our_ dependency
96
+ type_hints = get_type_hints(cls.__init__)
97
+ type_hints.pop("self", None)
98
+
99
+ deps = select_values(isnotsingleton, type_hints)
100
+
101
+ init = dict(walk_values(self.resolve, deps))
102
+ try:
103
+ inst = cls(**init)
104
+ except Exception as e:
105
+ logger.exception("Error occurred during initialize client %s", name)
106
+ raise InitializeDependencyError(f"Error occurred during initialize client {name}") from e
107
+
108
+ if isclient(cls):
109
+ self.clients[cls] = inst
110
+
111
+ self.connect_clients.append(inst)
112
+ return inst
113
+
114
+ def inject(self, func: Callable) -> Callable:
115
+ """
116
+ Bind the dependencies from the signature of `func`.
117
+
118
+ note: `func` may be a function or a class.
119
+ """
120
+ if self.connected is True:
121
+ raise ConnectError("already connected")
122
+
123
+ signature = self._inspect(func)
124
+ return partial(func, **signature)
125
+
126
+ def mock(self, cls: type[CT], new: CT | None = None) -> CT:
127
+ if self.connected is True:
128
+ raise ConnectError("already connected")
129
+ return self.clients.setdefault(cls, new or create_autospec(cls)) # type: ignore[return-value]
130
+
131
+ def _inspect(self, func: Callable) -> dict[str, NotSingletonClient]:
132
+ logger.debug('Parsing signature of func "%s"', sname(func))
133
+ signature: dict[str, NotSingletonClient] = {}
134
+
135
+ sig: dict[str, Any] = get_type_hints(func)
136
+ sig.pop("return", None)
137
+
138
+ for key, value in sig.items():
139
+ if value is inspect.Parameter.empty:
140
+ raise InvalidSignatureError("Arguments without type hints are restricted")
141
+
142
+ if isnotsingleton(value):
143
+ signature[key] = self.resolve(value)
144
+
145
+ return signature
146
+
147
+
148
+ DI = Dependencies()
@@ -0,0 +1,32 @@
1
+ import inspect
2
+ from collections.abc import Callable
3
+ from dataclasses import dataclass
4
+ from typing import Any, TypeVar, dataclass_transform
5
+
6
+ from nuke_di.types import Client, NotSingletonClient
7
+
8
+ C = TypeVar("C")
9
+ CT = TypeVar("CT", bound=NotSingletonClient)
10
+
11
+
12
+ @dataclass_transform()
13
+ def client_dataclass(_cls: type | None = None, **dataclass_kwargs: Any) -> Callable[[type[C]], type[CT]]:
14
+ """
15
+ Decorator that makes a class a `Client` subclass and a dataclass.
16
+
17
+ Accepts the same keyword arguments as `dataclasses.dataclass`.
18
+ """
19
+
20
+ def decorator(cls: type[C]) -> type[CT]:
21
+ if not issubclass(cls, Client):
22
+ attrs = {name: value for name, value in cls.__dict__.items() if name not in {"__dict__", "__weakref__"}}
23
+ new_cls = type(cls.__name__, (cls, Client), attrs)
24
+ else:
25
+ new_cls = cls
26
+
27
+ return dataclass(**dataclass_kwargs)(new_cls)
28
+
29
+ if inspect.isclass(_cls):
30
+ return decorator(_cls)
31
+
32
+ return decorator
@@ -0,0 +1,26 @@
1
+ class InvalidSignatureError(TypeError):
2
+ """
3
+ A signature contains an argument without a type hint.
4
+ """
5
+
6
+
7
+ class ConnectError(SystemExit):
8
+ """
9
+ An error occurred while connecting clients.
10
+
11
+ It is a `SystemExit` on purpose: a client that failed to start should stop the application.
12
+ """
13
+
14
+
15
+ class ConnectTimeoutError(ConnectError):
16
+ """
17
+ A client did not finish connecting in time.
18
+
19
+ The timeout is set by the `CONNECT_TIMEOUT_SECONDS` environment variable.
20
+ """
21
+
22
+
23
+ class InitializeDependencyError(SystemExit):
24
+ """
25
+ A dependency raised an exception during initialization.
26
+ """
@@ -0,0 +1,14 @@
1
+ import os
2
+ from dataclasses import dataclass, field
3
+
4
+ CONNECT_TIMEOUT_ENV = "CONNECT_TIMEOUT_SECONDS"
5
+ DEFAULT_CONNECT_TIMEOUT = 30.0
6
+
7
+
8
+ def _connect_timeout_from_env() -> float:
9
+ return float(os.environ.get(CONNECT_TIMEOUT_ENV, DEFAULT_CONNECT_TIMEOUT))
10
+
11
+
12
+ @dataclass
13
+ class DependenciesSettings:
14
+ connect_timeout: float = field(default_factory=_connect_timeout_from_env)
File without changes
@@ -0,0 +1,24 @@
1
+ class NotSingletonClient:
2
+ """
3
+ Base class for dependencies managed by `Dependencies`.
4
+
5
+ A new instance is created for every consumer that declares it.
6
+ """
7
+
8
+ async def connect(self) -> None:
9
+ """
10
+ Override this method to run an action after `__init__`.
11
+ """
12
+
13
+ async def disconnect(self) -> None:
14
+ """
15
+ Override this method to run an action before the object is dropped.
16
+ """
17
+
18
+
19
+ class Client(NotSingletonClient):
20
+ """
21
+ Singleton client.
22
+
23
+ It is cached on resolution, so every consumer receives the same instance.
24
+ """
@@ -0,0 +1,28 @@
1
+ import inspect
2
+ from collections.abc import Callable, Iterable, Iterator
3
+ from typing import Any
4
+
5
+
6
+ def iteritems(col: Any) -> Any:
7
+ if isinstance(col, dict):
8
+ return col.items()
9
+ return col
10
+
11
+
12
+ def select_values(pred: Callable[[Any], bool], col: Iterable) -> Iterator:
13
+ return (i for i in iteritems(col) if pred(i[1]))
14
+
15
+
16
+ def walk_values(prim: Callable[[Any], Any], col: Iterable) -> Iterator:
17
+ return ((k, prim(v)) for k, v in iteritems(col))
18
+
19
+
20
+ def sname(obj: Any) -> str:
21
+ try:
22
+ return obj.__name__ # type: ignore[no-any-return]
23
+ except AttributeError:
24
+ return sname(obj.__class__)
25
+
26
+
27
+ def isa(*types: type) -> Callable[[Any], bool]:
28
+ return lambda o: inspect.isclass(o) and issubclass(o, types)
File without changes
@@ -0,0 +1,65 @@
1
+ from dataclasses import FrozenInstanceError, is_dataclass
2
+
3
+ import pytest
4
+
5
+ from nuke_di import Client, Dependencies, NotSingletonClient, client_dataclass
6
+
7
+
8
+ @client_dataclass
9
+ class A:
10
+ pass
11
+
12
+
13
+ class B(Client):
14
+ pass
15
+
16
+
17
+ class C(NotSingletonClient):
18
+ pass
19
+
20
+
21
+ @client_dataclass(frozen=True)
22
+ class MyClient:
23
+ b: B
24
+ c: C
25
+ a: A
26
+
27
+ def get_a(self) -> A:
28
+ return self.a
29
+
30
+ def get_b(self) -> B:
31
+ return self.b
32
+
33
+ def get_c(self) -> C:
34
+ return self.c
35
+
36
+
37
+ @pytest.fixture
38
+ def my_client() -> MyClient:
39
+ di = Dependencies()
40
+ return di.resolve(MyClient) # type: ignore[type-var]
41
+
42
+
43
+ def test_methods(my_client: MyClient) -> None:
44
+ assert type(my_client.get_b()) is B
45
+ assert type(my_client.get_c()) is C
46
+ assert type(my_client.get_a()) is A
47
+
48
+
49
+ def test_is_dataclass(my_client: MyClient) -> None:
50
+ assert is_dataclass(my_client)
51
+
52
+
53
+ def test_dataclass_init(my_client: MyClient) -> None:
54
+ assert type(my_client.b) is B
55
+ assert type(my_client.c) is C
56
+ assert type(my_client.a) is A
57
+
58
+
59
+ def test_frozen(my_client: MyClient) -> None:
60
+ with pytest.raises(FrozenInstanceError):
61
+ my_client.a = 9090 # type: ignore[assignment,misc]
62
+
63
+
64
+ def test_client_inheritance(my_client: MyClient) -> None:
65
+ assert getattr(my_client, "connect", False)
@@ -0,0 +1,108 @@
1
+ import asyncio
2
+
3
+ import pytest
4
+
5
+ from nuke_di import (
6
+ Client,
7
+ ConnectError,
8
+ ConnectTimeoutError,
9
+ Dependencies,
10
+ DependenciesSettings,
11
+ InitializeDependencyError,
12
+ )
13
+
14
+
15
+ class SlowClient(Client):
16
+ async def connect(self) -> None:
17
+ await asyncio.sleep(1)
18
+
19
+
20
+ class BrokenConnectClient(Client):
21
+ async def connect(self) -> None:
22
+ raise RuntimeError("boom")
23
+
24
+
25
+ class BrokenInitClient(Client):
26
+ def __init__(self) -> None:
27
+ raise RuntimeError("boom")
28
+
29
+
30
+ class BrokenDisconnectClient(Client):
31
+ async def disconnect(self) -> None:
32
+ raise RuntimeError("boom")
33
+
34
+
35
+ class TrackedClient(Client):
36
+ disconnected = False
37
+
38
+ async def disconnect(self) -> None:
39
+ TrackedClient.disconnected = True
40
+
41
+
42
+ async def test_connect_timeout() -> None:
43
+ dep = Dependencies(settings=DependenciesSettings(connect_timeout=0.01))
44
+ dep.resolve(SlowClient)
45
+
46
+ with pytest.raises(ConnectTimeoutError):
47
+ await dep.connect()
48
+
49
+
50
+ async def test_connect_error() -> None:
51
+ dep = Dependencies()
52
+ dep.resolve(BrokenConnectClient)
53
+
54
+ with pytest.raises(ConnectError) as exc_info:
55
+ await dep.connect()
56
+
57
+ assert not isinstance(exc_info.value, ConnectTimeoutError)
58
+ assert isinstance(exc_info.value.__cause__, RuntimeError)
59
+
60
+
61
+ def test_initialize_error() -> None:
62
+ dep = Dependencies()
63
+
64
+ with pytest.raises(InitializeDependencyError):
65
+ dep.resolve(BrokenInitClient)
66
+
67
+
68
+ async def test_disconnect_continues_after_failure() -> None:
69
+ dep = Dependencies()
70
+ # disconnect goes in reverse order, so the broken client is stopped first
71
+ dep.resolve(TrackedClient)
72
+ dep.resolve(BrokenDisconnectClient)
73
+
74
+ async with dep:
75
+ pass
76
+
77
+ assert TrackedClient.disconnected
78
+ assert dep.connect_clients == []
79
+
80
+
81
+ async def test_connected_state_guards() -> None:
82
+ dep = Dependencies()
83
+
84
+ with pytest.raises(ConnectError):
85
+ await dep.disconnect()
86
+
87
+ async with dep:
88
+ with pytest.raises(ConnectError):
89
+ await dep.connect()
90
+ with pytest.raises(ConnectError):
91
+ dep.resolve(TrackedClient)
92
+ with pytest.raises(ConnectError):
93
+ dep.inject(test_initialize_error)
94
+ with pytest.raises(ConnectError):
95
+ dep.mock(TrackedClient)
96
+
97
+
98
+ def test_settings_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
99
+ monkeypatch.setenv("CONNECT_TIMEOUT_SECONDS", "5")
100
+
101
+ assert DependenciesSettings().connect_timeout == 5
102
+ assert Dependencies().settings.connect_timeout == 5
103
+
104
+
105
+ def test_settings_default(monkeypatch: pytest.MonkeyPatch) -> None:
106
+ monkeypatch.delenv("CONNECT_TIMEOUT_SECONDS", raising=False)
107
+
108
+ assert DependenciesSettings().connect_timeout == 30
@@ -0,0 +1,57 @@
1
+ import pytest
2
+
3
+ from nuke_di import Client, Dependencies, NotSingletonClient
4
+ from nuke_di.errors import ConnectError
5
+
6
+
7
+ class InnerDeps(NotSingletonClient):
8
+ pass
9
+
10
+
11
+ class PublicClient(Client):
12
+ def __init__(self, inner: InnerDeps):
13
+ self.inner = inner
14
+
15
+ async def add(self, a: int, b: int) -> int:
16
+ return a + b
17
+
18
+
19
+ class NewPublicClient(PublicClient):
20
+ pass
21
+
22
+
23
+ async def test_cached_client() -> None:
24
+ dep = Dependencies()
25
+
26
+ client_1 = dep.resolve(PublicClient)
27
+ client_2 = dep.resolve(PublicClient)
28
+
29
+ assert client_1 is client_2
30
+
31
+
32
+ async def test_not_singleton_client() -> None:
33
+ dep = Dependencies()
34
+
35
+ client_1 = dep.resolve(PublicClient)
36
+ client_2 = dep.resolve(NewPublicClient)
37
+
38
+ assert client_1.inner is not client_2.inner
39
+
40
+
41
+ async def test_flush() -> None:
42
+ dep = Dependencies()
43
+
44
+ client_1 = dep.resolve(PublicClient)
45
+ dep.flush()
46
+ client_2 = dep.resolve(PublicClient)
47
+
48
+ assert client_1 is not client_2
49
+
50
+
51
+ async def test_flush_on_connected() -> None:
52
+ dep = Dependencies()
53
+ dep.resolve(PublicClient)
54
+
55
+ async with dep:
56
+ with pytest.raises(ConnectError):
57
+ dep.flush()
@@ -0,0 +1,28 @@
1
+ from nuke_di import Client, Dependencies, NotSingletonClient
2
+
3
+
4
+ class InnerDeps(NotSingletonClient):
5
+ def number(self) -> int:
6
+ return 1
7
+
8
+
9
+ class PublicClient(Client):
10
+ def __init__(self, inner: InnerDeps):
11
+ self.inner = inner
12
+
13
+ async def add(self, a: int, b: int) -> int:
14
+ return a + b
15
+
16
+
17
+ async def any_func(a: int, adder: PublicClient, inner: InnerDeps) -> int:
18
+ assert adder.inner is not inner
19
+
20
+ return await adder.add(a, inner.number())
21
+
22
+
23
+ async def test_inject() -> None:
24
+ dep = Dependencies()
25
+ new_func = dep.inject(any_func)
26
+
27
+ async with dep:
28
+ assert (await new_func(1)) == 2
@@ -0,0 +1,57 @@
1
+ from collections.abc import AsyncGenerator
2
+ from unittest.mock import call
3
+
4
+ import pytest
5
+
6
+ from nuke_di import DI, Client, Dependencies
7
+
8
+
9
+ class InnerDeps(Client):
10
+ pass
11
+
12
+
13
+ class PublicClient(Client):
14
+ def __init__(self, inner: InnerDeps):
15
+ self.inner = inner
16
+
17
+ async def add(self, a: int, b: int) -> int:
18
+ return a + b
19
+
20
+
21
+ async def test_cached_client() -> None:
22
+ dep = Dependencies()
23
+
24
+ mocked_client = dep.mock(PublicClient)
25
+
26
+ client_1 = dep.resolve(PublicClient)
27
+
28
+ async with dep:
29
+ await client_1.add(1, 2)
30
+
31
+ assert mocked_client.add.await_args_list == [call(1, 2)] # type: ignore
32
+
33
+
34
+ @pytest.fixture()
35
+ async def app_like_fixture() -> AsyncGenerator[Dependencies, None]:
36
+ DI.resolve(PublicClient)
37
+ async with DI:
38
+ yield DI
39
+
40
+
41
+ @pytest.fixture
42
+ async def mock_client() -> PublicClient:
43
+ """
44
+ Example of a fixture that mocks a dependency.
45
+
46
+ This fixture must come first in the test arguments or via @pytest.mark.usefixtures,
47
+ so that the mock is registered before the dependency tree is resolved.
48
+ """
49
+ return DI.mock(PublicClient)
50
+
51
+
52
+ async def test_with_fixture(mock_client: PublicClient, app_like_fixture: Dependencies) -> None:
53
+ cli: PublicClient = app_like_fixture.clients[PublicClient] # type: ignore
54
+
55
+ await cli.add(1, 2)
56
+
57
+ assert mock_client.add.await_args_list == [call(1, 2)] # type: ignore
@@ -0,0 +1,81 @@
1
+ from unittest.mock import AsyncMock, call
2
+
3
+ from nuke_di import Client, Dependencies, NotSingletonClient
4
+
5
+
6
+ class InnerDeps(NotSingletonClient):
7
+ connect = AsyncMock()
8
+ disconnect = AsyncMock()
9
+
10
+
11
+ class PublicClient(Client):
12
+ connect = AsyncMock()
13
+ disconnect = AsyncMock()
14
+
15
+ def __init__(self, inner: InnerDeps):
16
+ self.inner = inner
17
+
18
+
19
+ class NewPublicClient(PublicClient):
20
+ connect = AsyncMock()
21
+ disconnect = AsyncMock()
22
+
23
+
24
+ async def test_connecting() -> None:
25
+ dep = Dependencies()
26
+ cli = dep.resolve(PublicClient)
27
+ new_cli = dep.resolve(NewPublicClient)
28
+
29
+ assert isinstance(cli, PublicClient)
30
+ assert isinstance(new_cli, NewPublicClient)
31
+ # resolving, not connecting
32
+ assert cli.connect.await_args_list == []
33
+ assert cli.disconnect.await_args_list == []
34
+
35
+ assert new_cli.connect.await_args_list == []
36
+ assert new_cli.disconnect.await_args_list == []
37
+
38
+ assert cli.inner.connect.await_args_list == []
39
+ assert cli.inner.disconnect.await_args_list == []
40
+
41
+ async with dep:
42
+ # connecting
43
+ assert cli.connect.await_args_list == [call()]
44
+ assert cli.disconnect.await_args_list == []
45
+
46
+ assert new_cli.connect.await_args_list == [call()]
47
+ assert new_cli.disconnect.await_args_list == []
48
+
49
+ assert cli.inner.connect.await_args_list == [call(), call()]
50
+ assert cli.inner.disconnect.await_args_list == []
51
+
52
+ assert cli.inner is not new_cli.inner
53
+
54
+ # disconnecting
55
+ assert cli.connect.await_args_list == [call()]
56
+ assert cli.disconnect.await_args_list == [call()]
57
+
58
+ assert new_cli.connect.await_args_list == [call()]
59
+ assert new_cli.disconnect.await_args_list == [call()]
60
+
61
+ assert cli.inner.connect.await_args_list == [call(), call()]
62
+ assert cli.inner.disconnect.await_args_list == [call(), call()]
63
+
64
+
65
+ class TypedSelfClient(Client):
66
+ connect = AsyncMock()
67
+ disconnect = AsyncMock()
68
+
69
+ def __init__(self: "TypedSelfClient") -> None:
70
+ pass
71
+
72
+
73
+ async def test_typed_self() -> None:
74
+ dep = Dependencies()
75
+ cli = dep.resolve(TypedSelfClient)
76
+
77
+ async with dep:
78
+ pass
79
+
80
+ assert cli.connect.await_args_list == [call()]
81
+ assert cli.disconnect.await_args_list == [call()]