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.
- nuke_di-1.0.0/.gitignore +15 -0
- nuke_di-1.0.0/CHANGELOG.md +25 -0
- nuke_di-1.0.0/LICENSE +21 -0
- nuke_di-1.0.0/PKG-INFO +237 -0
- nuke_di-1.0.0/README.md +210 -0
- nuke_di-1.0.0/pyproject.toml +75 -0
- nuke_di-1.0.0/src/nuke_di/__init__.py +18 -0
- nuke_di-1.0.0/src/nuke_di/core.py +148 -0
- nuke_di-1.0.0/src/nuke_di/dataclass.py +32 -0
- nuke_di-1.0.0/src/nuke_di/errors.py +26 -0
- nuke_di-1.0.0/src/nuke_di/options.py +14 -0
- nuke_di-1.0.0/src/nuke_di/py.typed +0 -0
- nuke_di-1.0.0/src/nuke_di/types.py +24 -0
- nuke_di-1.0.0/src/nuke_di/utils.py +28 -0
- nuke_di-1.0.0/tests/__init__.py +0 -0
- nuke_di-1.0.0/tests/test_client_dataclass.py +65 -0
- nuke_di-1.0.0/tests/test_errors.py +108 -0
- nuke_di-1.0.0/tests/test_flush.py +57 -0
- nuke_di-1.0.0/tests/test_inject.py +28 -0
- nuke_di-1.0.0/tests/test_mock.py +57 -0
- nuke_di-1.0.0/tests/test_resolve.py +81 -0
nuke_di-1.0.0/.gitignore
ADDED
|
@@ -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
|
+
[](https://pypi.org/project/nuke-di/)
|
|
31
|
+
[](https://pypi.org/project/nuke-di/)
|
|
32
|
+
[](https://github.com/troyan-dy/nuke-di/actions/workflows/ci.yml)
|
|
33
|
+
[](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)
|
nuke_di-1.0.0/README.md
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# nuke-di
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/nuke-di/)
|
|
4
|
+
[](https://pypi.org/project/nuke-di/)
|
|
5
|
+
[](https://github.com/troyan-dy/nuke-di/actions/workflows/ci.yml)
|
|
6
|
+
[](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()]
|