miniject 0.1.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.
- miniject-0.1.0/.github/workflows/ci.yml +35 -0
- miniject-0.1.0/.gitignore +8 -0
- miniject-0.1.0/LICENSE +21 -0
- miniject-0.1.0/PKG-INFO +21 -0
- miniject-0.1.0/README.md +204 -0
- miniject-0.1.0/pyproject.toml +63 -0
- miniject-0.1.0/src/miniject/__init__.py +11 -0
- miniject-0.1.0/src/miniject/_container.py +354 -0
- miniject-0.1.0/src/miniject/py.typed +0 -0
- miniject-0.1.0/tests/test_container.py +424 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: ["master"]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
checks:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
fail-fast: false
|
|
13
|
+
matrix:
|
|
14
|
+
python-version: ["3.11", "3.12", "3.13"]
|
|
15
|
+
|
|
16
|
+
steps:
|
|
17
|
+
- name: Check out repository
|
|
18
|
+
uses: actions/checkout@v6
|
|
19
|
+
|
|
20
|
+
- name: Set up Python
|
|
21
|
+
uses: actions/setup-python@v6
|
|
22
|
+
with:
|
|
23
|
+
python-version: ${{ matrix.python-version }}
|
|
24
|
+
|
|
25
|
+
- name: Install dependencies
|
|
26
|
+
run: python -m pip install -e ".[dev]"
|
|
27
|
+
|
|
28
|
+
- name: Run Ruff
|
|
29
|
+
run: ruff check
|
|
30
|
+
|
|
31
|
+
- name: Run Pyright
|
|
32
|
+
run: python -m pyright --pythonpath "$(command -v python)"
|
|
33
|
+
|
|
34
|
+
- name: Run pytest
|
|
35
|
+
run: PYTHONPATH=src python -m pytest -q
|
miniject-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dstarman
|
|
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.
|
miniject-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: miniject
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Lightweight dependency injection container with auto-wiring and scoped child containers.
|
|
5
|
+
Project-URL: Repository, https://github.com/danielstarman/miniject
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: auto-wiring,container,dependency-injection,di,ioc
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: pyright; extra == 'dev'
|
|
20
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
21
|
+
Requires-Dist: ruff; extra == 'dev'
|
miniject-0.1.0/README.md
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# miniject
|
|
2
|
+
|
|
3
|
+
Lightweight dependency injection container for Python. Auto-wires constructor
|
|
4
|
+
dependencies from type hints, supports singleton and transient scopes, and
|
|
5
|
+
provides scoped child containers for testing and per-context overrides.
|
|
6
|
+
|
|
7
|
+
**Small codebase. Zero dependencies. Fully typed.**
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install miniject
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Quick start
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from miniject import Container
|
|
19
|
+
|
|
20
|
+
class Database:
|
|
21
|
+
def __init__(self, url: str = "sqlite:///:memory:") -> None:
|
|
22
|
+
self.url = url
|
|
23
|
+
|
|
24
|
+
class UserRepo:
|
|
25
|
+
def __init__(self, database: Database) -> None:
|
|
26
|
+
self.database = database
|
|
27
|
+
|
|
28
|
+
class UserService:
|
|
29
|
+
def __init__(self, repo: UserRepo) -> None:
|
|
30
|
+
self.repo = repo
|
|
31
|
+
|
|
32
|
+
container = Container()
|
|
33
|
+
container.bind(Database, instance=Database("postgres://localhost/mydb"))
|
|
34
|
+
container.bind(UserRepo) # auto-wired from type hints
|
|
35
|
+
container.bind(UserService) # resolves UserRepo → Database automatically
|
|
36
|
+
|
|
37
|
+
service = container.resolve(UserService)
|
|
38
|
+
assert service.repo.database.url == "postgres://localhost/mydb"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## API
|
|
42
|
+
|
|
43
|
+
### `Container()`
|
|
44
|
+
|
|
45
|
+
Create a new container.
|
|
46
|
+
|
|
47
|
+
### `container.bind(service, *, factory=..., instance=..., singleton=...)`
|
|
48
|
+
|
|
49
|
+
Register a service type. Four modes:
|
|
50
|
+
|
|
51
|
+
| Call | Behavior |
|
|
52
|
+
|------|----------|
|
|
53
|
+
| `bind(SomeType)` | Auto-wire from `__init__` type hints (transient) |
|
|
54
|
+
| `bind(SomeType, instance=obj)` | Singleton by instance |
|
|
55
|
+
| `bind(SomeType, factory=fn)` | Custom factory (transient) |
|
|
56
|
+
| `bind(SomeType, factory=fn, singleton=True)` | Custom factory (singleton, shared by child scopes) |
|
|
57
|
+
|
|
58
|
+
**Auto-wiring** inspects constructor parameters via `typing.get_type_hints()`
|
|
59
|
+
and resolves each typed parameter from the container. Parameters with default
|
|
60
|
+
values are left to Python when no binding exists. Nullable dependencies such as
|
|
61
|
+
`Database | None = None` are supported: if `Database` is bound it is injected,
|
|
62
|
+
otherwise Python keeps the default `None`.
|
|
63
|
+
|
|
64
|
+
Common scalar builtins like `int`, `str`, `float`, `bool`, and `bytes` are not
|
|
65
|
+
supported as DI keys. Prefer typed value objects or explicit factories for
|
|
66
|
+
scalar configuration values.
|
|
67
|
+
|
|
68
|
+
Type hints must be importable at runtime. If `get_type_hints()` cannot resolve
|
|
69
|
+
an annotation, miniject raises `ResolutionError` instead of silently skipping
|
|
70
|
+
injection. `Annotated[...]` is intentionally unsupported. miniject does not
|
|
71
|
+
provide qualifier-style multiple bindings for the same base type. If two
|
|
72
|
+
dependencies mean different things, model them as different types. If the
|
|
73
|
+
distinction is construction logic, use an explicit factory.
|
|
74
|
+
|
|
75
|
+
## Design Philosophy
|
|
76
|
+
|
|
77
|
+
miniject prefers minimal container magic:
|
|
78
|
+
|
|
79
|
+
- constructors should describe semantic dependencies, not container selection rules
|
|
80
|
+
- composition roots and factories should own non-trivial wiring decisions
|
|
81
|
+
- if two dependencies mean different things, they should usually be different types
|
|
82
|
+
- if construction depends on runtime policy, use an explicit factory rather than metadata
|
|
83
|
+
|
|
84
|
+
### `container.resolve(service, **overrides)`
|
|
85
|
+
|
|
86
|
+
Resolve a service, recursively auto-wiring all dependencies. Keyword
|
|
87
|
+
`overrides` are passed directly to the factory/constructor, bypassing the
|
|
88
|
+
container for those parameters.
|
|
89
|
+
|
|
90
|
+
Raises `ResolutionError` on missing bindings or circular dependencies, with
|
|
91
|
+
a full dependency chain in the message.
|
|
92
|
+
|
|
93
|
+
### `container.scope()`
|
|
94
|
+
|
|
95
|
+
Create a child container that inherits all parent bindings. Overrides in the
|
|
96
|
+
child do not affect the parent.
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
parent = Container()
|
|
100
|
+
parent.bind(Database, instance=production_db)
|
|
101
|
+
parent.bind(UserRepo)
|
|
102
|
+
|
|
103
|
+
child = parent.scope()
|
|
104
|
+
child.bind(Database, instance=test_db) # override in child only
|
|
105
|
+
|
|
106
|
+
child.resolve(UserRepo).database # → test_db
|
|
107
|
+
parent.resolve(UserRepo).database # → production_db
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Singletons defined in the parent remain shared when resolved through a child
|
|
111
|
+
scope. If a child re-binds a service, that override is isolated to the child.
|
|
112
|
+
|
|
113
|
+
Use cases:
|
|
114
|
+
- **Testing** — swap specific dependencies without rebuilding the whole graph
|
|
115
|
+
- **Per-request isolation** — override config for a specific context
|
|
116
|
+
|
|
117
|
+
### `ResolutionError`
|
|
118
|
+
|
|
119
|
+
Raised when resolution fails. The message includes the full dependency chain:
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
ResolutionError: Cannot resolve UserRepo: missing binding for parameter 'database'
|
|
123
|
+
(type=Database) (UserService -> UserRepo)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Circular dependencies are detected and reported:
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
ResolutionError: Circular dependency: A -> B -> A
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Composition root pattern
|
|
133
|
+
|
|
134
|
+
Only composition roots (startup code, CLI entrypoints, test fixtures) should
|
|
135
|
+
call `container.resolve()`. All other code receives dependencies via constructor
|
|
136
|
+
injection:
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
# src/myapp/container.py — composition root
|
|
140
|
+
from miniject import Container
|
|
141
|
+
|
|
142
|
+
def create_container(config: Config) -> Container:
|
|
143
|
+
c = Container()
|
|
144
|
+
c.bind(Config, instance=config)
|
|
145
|
+
c.bind(Database, factory=lambda: Database(config.db_url), singleton=True)
|
|
146
|
+
c.bind(UserRepo)
|
|
147
|
+
c.bind(UserService)
|
|
148
|
+
return c
|
|
149
|
+
|
|
150
|
+
# src/myapp/services.py — normal code, no container import
|
|
151
|
+
class UserService:
|
|
152
|
+
def __init__(self, repo: UserRepo) -> None:
|
|
153
|
+
self.repo = repo
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Thread safety
|
|
157
|
+
|
|
158
|
+
miniject is designed for the **composition-root-at-startup** pattern: build
|
|
159
|
+
and populate the container at application start, then share it for resolution.
|
|
160
|
+
Concurrent `resolve()` calls are safe after configuration is complete, and
|
|
161
|
+
singleton factories are initialized at most once per owning container.
|
|
162
|
+
|
|
163
|
+
Rebinding services on a container that is already being shared across threads is
|
|
164
|
+
not supported. If you need runtime reconfiguration, build a new container or a
|
|
165
|
+
child scope instead of mutating a shared container in place.
|
|
166
|
+
|
|
167
|
+
## When to use miniject
|
|
168
|
+
|
|
169
|
+
miniject is a good fit when you want:
|
|
170
|
+
|
|
171
|
+
- constructor injection from type hints
|
|
172
|
+
- a tiny composition-root container with very little magic
|
|
173
|
+
- child scopes for tests and context-specific overrides
|
|
174
|
+
- explicit failure when runtime annotations are not actually resolvable
|
|
175
|
+
|
|
176
|
+
miniject is probably **not** the right fit when you need:
|
|
177
|
+
|
|
178
|
+
- async/resource lifecycle management
|
|
179
|
+
- framework integration or function/method wiring
|
|
180
|
+
- multiple qualified bindings for the same base type
|
|
181
|
+
- extensive provider types, configuration loaders, or container metaprogramming
|
|
182
|
+
|
|
183
|
+
## Comparison
|
|
184
|
+
|
|
185
|
+
miniject is intentionally narrower than larger Python DI frameworks.
|
|
186
|
+
|
|
187
|
+
- Compared with `dependency-injector`, miniject is much smaller and easier to
|
|
188
|
+
hold in your head, but it does not try to compete with provider graphs,
|
|
189
|
+
configuration providers, wiring, async resources, or broader framework
|
|
190
|
+
integrations.
|
|
191
|
+
- Compared with `lagom`, miniject is more opinionated and lower-surface-area.
|
|
192
|
+
Lagom supports async usage, richer integrations, and more advanced type-driven
|
|
193
|
+
behavior. miniject aims to stay focused on composition-root constructor
|
|
194
|
+
injection.
|
|
195
|
+
- Compared with `punq`, miniject lives in a more similar simplicity tier. The
|
|
196
|
+
main differences are miniject's scoped child containers, circular dependency
|
|
197
|
+
detection, and stricter stance on runtime-resolvable type hints.
|
|
198
|
+
|
|
199
|
+
The goal is not to be the most powerful DI library. The goal is to be a small,
|
|
200
|
+
predictable one that stays useful without turning into a framework.
|
|
201
|
+
|
|
202
|
+
## License
|
|
203
|
+
|
|
204
|
+
MIT
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "miniject"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Lightweight dependency injection container with auto-wiring and scoped child containers."
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
keywords = ["dependency-injection", "di", "ioc", "container", "auto-wiring"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 3 - Alpha",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.11",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
"Typing :: Typed",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Repository = "https://github.com/danielstarman/miniject"
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
dev = ["pytest", "pyright", "ruff"]
|
|
28
|
+
|
|
29
|
+
[tool.hatch.build.targets.wheel]
|
|
30
|
+
packages = ["src/miniject"]
|
|
31
|
+
|
|
32
|
+
[tool.pytest.ini_options]
|
|
33
|
+
testpaths = ["tests"]
|
|
34
|
+
pythonpath = ["src"]
|
|
35
|
+
|
|
36
|
+
[tool.ruff]
|
|
37
|
+
target-version = "py311"
|
|
38
|
+
line-length = 100
|
|
39
|
+
src = ["src", "tests"]
|
|
40
|
+
|
|
41
|
+
[tool.ruff.lint]
|
|
42
|
+
select = ["ALL"]
|
|
43
|
+
ignore = [
|
|
44
|
+
"ANN401", # Any is appropriate at runtime introspection boundaries
|
|
45
|
+
"D", # pydocstyle (docstrings covered manually)
|
|
46
|
+
"EM102", # inline exception messages are fine for a small library
|
|
47
|
+
"SLF001", # container internals may coordinate through private helpers
|
|
48
|
+
"TC003", # keep runtime-visible imports for type-hint introspection
|
|
49
|
+
"TRY003", # small project; dedicated exception-message helpers add noise
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
[tool.ruff.lint.per-file-ignores]
|
|
53
|
+
"tests/*.py" = ["ANN001", "PLR2004", "S101"]
|
|
54
|
+
|
|
55
|
+
[tool.pyright]
|
|
56
|
+
include = ["src", "tests"]
|
|
57
|
+
extraPaths = ["src"]
|
|
58
|
+
typeCheckingMode = "strict"
|
|
59
|
+
|
|
60
|
+
[[tool.pyright.executionEnvironments]]
|
|
61
|
+
root = "tests"
|
|
62
|
+
reportMissingParameterType = "none"
|
|
63
|
+
reportUnknownParameterType = "none"
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Lightweight dependency injection container.
|
|
2
|
+
|
|
3
|
+
Provides constructor-based auto-wiring, singleton/transient scopes, and scoped
|
|
4
|
+
child containers for testing and experiment overrides. Only composition roots
|
|
5
|
+
should call ``.resolve()``; all other code receives dependencies via constructor
|
|
6
|
+
injection.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from miniject._container import Container, ResolutionError
|
|
10
|
+
|
|
11
|
+
__all__ = ["Container", "ResolutionError"]
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
"""Lightweight dependency injection container.
|
|
2
|
+
|
|
3
|
+
Provides constructor-based auto-wiring, singleton/transient scopes, and scoped
|
|
4
|
+
child containers for testing and experiment overrides. Only composition roots
|
|
5
|
+
should call ``.resolve()``; all other code receives dependencies via constructor
|
|
6
|
+
injection.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import inspect
|
|
12
|
+
import types
|
|
13
|
+
import typing
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from threading import RLock
|
|
17
|
+
from typing import Any, TypeVar, cast
|
|
18
|
+
|
|
19
|
+
_T = TypeVar("_T")
|
|
20
|
+
|
|
21
|
+
_EMPTY: object = object()
|
|
22
|
+
_DISALLOWED_AUTO_INJECT_TYPES: frozenset[type] = frozenset({bool, bytes, float, int, str})
|
|
23
|
+
_NONE_TYPE: type[None] = type(None)
|
|
24
|
+
_UNION_TYPES: tuple[object, ...] = (typing.Union, types.UnionType)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ResolutionError(Exception):
|
|
28
|
+
"""Raised when a dependency cannot be resolved."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class _ResolvedParamType:
|
|
33
|
+
"""Normalized parameter type metadata used during dependency resolution."""
|
|
34
|
+
|
|
35
|
+
binding_key: type | None
|
|
36
|
+
display_name: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _Binding:
|
|
40
|
+
"""Internal registration record."""
|
|
41
|
+
|
|
42
|
+
__slots__ = ("factory", "instance", "singleton")
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
*,
|
|
47
|
+
factory: Callable[..., Any] | None = None,
|
|
48
|
+
instance: object = _EMPTY,
|
|
49
|
+
singleton: bool = False,
|
|
50
|
+
) -> None:
|
|
51
|
+
self.factory = factory
|
|
52
|
+
self.instance = instance
|
|
53
|
+
self.singleton = singleton
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Container:
|
|
57
|
+
"""Minimal DI container with auto-wiring and scoped child containers.
|
|
58
|
+
|
|
59
|
+
API::
|
|
60
|
+
|
|
61
|
+
container = Container()
|
|
62
|
+
container.bind(Database, instance=db) # singleton by instance
|
|
63
|
+
container.bind(TradeRepository) # auto-wired transient
|
|
64
|
+
container.bind(BalanceTracker, factory=fn, singleton=True)
|
|
65
|
+
|
|
66
|
+
db = container.resolve(Database)
|
|
67
|
+
|
|
68
|
+
scoped = container.scope() # child container
|
|
69
|
+
scoped.bind(FooParams, instance=modified) # override in child
|
|
70
|
+
strat = scoped.resolve(MyStrategy) # parent unaffected
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(self, *, _parent: Container | None = None) -> None:
|
|
74
|
+
self._bindings: dict[type, _Binding] = {}
|
|
75
|
+
self._singletons: dict[type, object] = {}
|
|
76
|
+
self._parent = _parent
|
|
77
|
+
self._lock = RLock()
|
|
78
|
+
|
|
79
|
+
def bind(
|
|
80
|
+
self,
|
|
81
|
+
service: type[_T],
|
|
82
|
+
*,
|
|
83
|
+
factory: Callable[..., _T] | None = None,
|
|
84
|
+
instance: _T | object = _EMPTY,
|
|
85
|
+
singleton: bool = False,
|
|
86
|
+
) -> None:
|
|
87
|
+
"""Register a service type.
|
|
88
|
+
|
|
89
|
+
* ``bind(SomeType)`` — auto-wire from ``__init__`` type hints (transient)
|
|
90
|
+
* ``bind(SomeType, instance=obj)`` — singleton by instance
|
|
91
|
+
* ``bind(SomeType, factory=fn)`` — custom factory (transient by default)
|
|
92
|
+
* ``bind(SomeType, factory=fn, singleton=True)`` — factory singleton
|
|
93
|
+
"""
|
|
94
|
+
_validate_service_type(service)
|
|
95
|
+
if instance is not _EMPTY:
|
|
96
|
+
self._bindings[service] = _Binding(instance=instance)
|
|
97
|
+
self._singletons[service] = instance
|
|
98
|
+
elif factory is not None:
|
|
99
|
+
self._bindings[service] = _Binding(factory=factory, singleton=singleton)
|
|
100
|
+
else:
|
|
101
|
+
self._bindings[service] = _Binding(factory=service, singleton=singleton)
|
|
102
|
+
|
|
103
|
+
def resolve(self, service: type[_T], **overrides: Any) -> _T:
|
|
104
|
+
"""Resolve a service, auto-wiring constructor dependencies.
|
|
105
|
+
|
|
106
|
+
Raises :class:`ResolutionError` on missing bindings or circular deps.
|
|
107
|
+
"""
|
|
108
|
+
return self._resolve(service, _stack=(), **overrides)
|
|
109
|
+
|
|
110
|
+
def scope(self) -> Container:
|
|
111
|
+
"""Create a child container inheriting all parent bindings.
|
|
112
|
+
|
|
113
|
+
Overrides in the child do **not** affect the parent.
|
|
114
|
+
"""
|
|
115
|
+
return Container(_parent=self)
|
|
116
|
+
|
|
117
|
+
# ── internals ────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
def _resolve(self, service: type[_T], *, _stack: tuple[type, ...], **overrides: Any) -> _T:
|
|
120
|
+
# Check for circular dependency
|
|
121
|
+
if service in _stack:
|
|
122
|
+
chain = " -> ".join(t.__name__ for t in (*_stack, service))
|
|
123
|
+
raise ResolutionError(f"Circular dependency: {chain}")
|
|
124
|
+
|
|
125
|
+
# Find binding owner (local then parent chain)
|
|
126
|
+
binding_owner_and_binding = self._find_binding_owner(service)
|
|
127
|
+
if binding_owner_and_binding is None:
|
|
128
|
+
chain = " -> ".join(t.__name__ for t in (*_stack, service))
|
|
129
|
+
raise ResolutionError(f"Cannot resolve {service.__name__}: no binding ({chain})")
|
|
130
|
+
binding_owner, binding = binding_owner_and_binding
|
|
131
|
+
|
|
132
|
+
# Singleton factories should be shared and initialized safely where the
|
|
133
|
+
# binding is defined.
|
|
134
|
+
if binding.singleton:
|
|
135
|
+
return cast(
|
|
136
|
+
"_T",
|
|
137
|
+
binding_owner._resolve_singleton(
|
|
138
|
+
service,
|
|
139
|
+
binding,
|
|
140
|
+
requester=self,
|
|
141
|
+
stack=(*_stack, service),
|
|
142
|
+
overrides=overrides,
|
|
143
|
+
),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# Instance binding (already stored)
|
|
147
|
+
if binding.instance is not _EMPTY:
|
|
148
|
+
return cast("_T", binding.instance)
|
|
149
|
+
|
|
150
|
+
# Factory binding
|
|
151
|
+
factory = _require_factory(binding)
|
|
152
|
+
instance = self._invoke_factory(factory, _stack=(*_stack, service), **overrides)
|
|
153
|
+
|
|
154
|
+
return cast("_T", instance)
|
|
155
|
+
|
|
156
|
+
def _find_binding(self, service: type) -> _Binding | None:
|
|
157
|
+
binding_owner_and_binding = self._find_binding_owner(service)
|
|
158
|
+
if binding_owner_and_binding is None:
|
|
159
|
+
return None
|
|
160
|
+
_, binding = binding_owner_and_binding
|
|
161
|
+
return binding
|
|
162
|
+
|
|
163
|
+
def _find_binding_owner(self, service: type) -> tuple[Container, _Binding] | None:
|
|
164
|
+
if service in self._bindings:
|
|
165
|
+
return self, self._bindings[service]
|
|
166
|
+
if self._parent is not None:
|
|
167
|
+
return self._parent._find_binding_owner(service)
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
def _resolve_singleton(
|
|
171
|
+
self,
|
|
172
|
+
service: type,
|
|
173
|
+
binding: _Binding,
|
|
174
|
+
*,
|
|
175
|
+
requester: Container,
|
|
176
|
+
stack: tuple[type, ...],
|
|
177
|
+
overrides: dict[str, Any],
|
|
178
|
+
) -> object:
|
|
179
|
+
with self._lock:
|
|
180
|
+
existing = self._singletons.get(service, _EMPTY)
|
|
181
|
+
if existing is not _EMPTY:
|
|
182
|
+
return existing
|
|
183
|
+
|
|
184
|
+
if binding.instance is not _EMPTY:
|
|
185
|
+
self._singletons[service] = binding.instance
|
|
186
|
+
return binding.instance
|
|
187
|
+
|
|
188
|
+
factory = _require_factory(binding)
|
|
189
|
+
instance = requester._invoke_factory(
|
|
190
|
+
factory,
|
|
191
|
+
_stack=stack,
|
|
192
|
+
**overrides,
|
|
193
|
+
)
|
|
194
|
+
self._singletons[service] = instance
|
|
195
|
+
return instance
|
|
196
|
+
|
|
197
|
+
def _invoke_factory(
|
|
198
|
+
self, factory: Callable[..., Any], *, _stack: tuple[type, ...], **overrides: Any,
|
|
199
|
+
) -> Any:
|
|
200
|
+
"""Call a factory, resolving its parameters from the container."""
|
|
201
|
+
sig_and_hints = _introspect_factory(factory)
|
|
202
|
+
if sig_and_hints is None:
|
|
203
|
+
return factory()
|
|
204
|
+
sig, hints = sig_and_hints
|
|
205
|
+
|
|
206
|
+
kwargs: dict[str, Any] = {}
|
|
207
|
+
for param_name, param in sig.parameters.items():
|
|
208
|
+
if param_name == "self":
|
|
209
|
+
continue
|
|
210
|
+
if param_name in overrides:
|
|
211
|
+
kwargs[param_name] = overrides[param_name]
|
|
212
|
+
continue
|
|
213
|
+
|
|
214
|
+
param_type = hints.get(param_name)
|
|
215
|
+
resolved_type = _resolve_param_type(
|
|
216
|
+
param_type,
|
|
217
|
+
factory_name=_callable_name(factory),
|
|
218
|
+
param_name=param_name,
|
|
219
|
+
)
|
|
220
|
+
if resolved_type.binding_key is not None:
|
|
221
|
+
binding = self._find_binding(resolved_type.binding_key)
|
|
222
|
+
if binding is not None:
|
|
223
|
+
kwargs[param_name] = self._resolve(resolved_type.binding_key, _stack=_stack)
|
|
224
|
+
continue
|
|
225
|
+
|
|
226
|
+
# No binding found — use default if available
|
|
227
|
+
if param.default is not inspect.Parameter.empty:
|
|
228
|
+
continue # let Python's default apply
|
|
229
|
+
if param.kind in (
|
|
230
|
+
inspect.Parameter.VAR_POSITIONAL,
|
|
231
|
+
inspect.Parameter.VAR_KEYWORD,
|
|
232
|
+
):
|
|
233
|
+
continue
|
|
234
|
+
|
|
235
|
+
# Required param with no binding and no default
|
|
236
|
+
chain = " -> ".join(t.__name__ for t in _stack)
|
|
237
|
+
raise ResolutionError(
|
|
238
|
+
f"Cannot resolve {factory.__name__}: "
|
|
239
|
+
f"missing binding for parameter '{param_name}' "
|
|
240
|
+
f"(type={resolved_type.display_name}) "
|
|
241
|
+
f"({chain})",
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
return factory(**kwargs)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _introspect_factory(
|
|
248
|
+
factory: Callable[..., Any],
|
|
249
|
+
) -> tuple[inspect.Signature, dict[str, Any]] | None:
|
|
250
|
+
"""Extract signature and type hints for a factory, or None if not introspectable."""
|
|
251
|
+
try:
|
|
252
|
+
sig = inspect.signature(factory)
|
|
253
|
+
except (ValueError, TypeError):
|
|
254
|
+
return None
|
|
255
|
+
hint_target = factory.__init__ if isinstance(factory, type) else factory
|
|
256
|
+
hints = _get_type_hints_or_raise(hint_target, factory_name=_callable_name(factory))
|
|
257
|
+
return sig, hints
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _get_type_hints_or_raise(
|
|
261
|
+
fn: Callable[..., Any],
|
|
262
|
+
*,
|
|
263
|
+
factory_name: str,
|
|
264
|
+
) -> dict[str, Any]:
|
|
265
|
+
"""Get runtime-resolvable type hints for a factory or constructor."""
|
|
266
|
+
try:
|
|
267
|
+
return typing.get_type_hints(fn, include_extras=True)
|
|
268
|
+
except (AttributeError, NameError, TypeError, ValueError) as exc:
|
|
269
|
+
target_name = _callable_name(fn)
|
|
270
|
+
raise ResolutionError(
|
|
271
|
+
f"Cannot resolve {factory_name}: failed to evaluate type hints for "
|
|
272
|
+
f"{target_name}; make annotations importable at runtime or use an explicit "
|
|
273
|
+
f"factory ({exc.__class__.__name__}: {exc})",
|
|
274
|
+
) from exc
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _resolve_param_type(
|
|
278
|
+
param_type: Any,
|
|
279
|
+
*,
|
|
280
|
+
factory_name: str,
|
|
281
|
+
param_name: str,
|
|
282
|
+
) -> _ResolvedParamType:
|
|
283
|
+
"""Normalize a parameter annotation into a DI binding key and semantics."""
|
|
284
|
+
if param_type is None:
|
|
285
|
+
return _ResolvedParamType(binding_key=None, display_name="?")
|
|
286
|
+
|
|
287
|
+
origin = typing.get_origin(param_type)
|
|
288
|
+
if origin is typing.Annotated:
|
|
289
|
+
raise ResolutionError(
|
|
290
|
+
f"Cannot resolve {factory_name}: parameter '{param_name}' uses Annotated[...] "
|
|
291
|
+
"which miniject does not support; use an explicit factory instead",
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
if origin in _UNION_TYPES:
|
|
295
|
+
args = typing.get_args(param_type)
|
|
296
|
+
non_none_args = tuple(arg for arg in args if arg is not _NONE_TYPE)
|
|
297
|
+
if len(non_none_args) == 1 and len(non_none_args) != len(args):
|
|
298
|
+
inner = non_none_args[0]
|
|
299
|
+
inner_origin = typing.get_origin(inner)
|
|
300
|
+
if inner_origin is typing.Annotated:
|
|
301
|
+
raise ResolutionError(
|
|
302
|
+
f"Cannot resolve {factory_name}: parameter '{param_name}' uses "
|
|
303
|
+
"Annotated[...] which miniject does not support; use an explicit "
|
|
304
|
+
"factory instead",
|
|
305
|
+
)
|
|
306
|
+
binding_key = inner if _is_auto_injectable_type(inner) else None
|
|
307
|
+
return _ResolvedParamType(
|
|
308
|
+
binding_key=binding_key,
|
|
309
|
+
display_name=_format_type_name(param_type),
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
binding_key = param_type if _is_auto_injectable_type(param_type) else None
|
|
313
|
+
return _ResolvedParamType(
|
|
314
|
+
binding_key=binding_key,
|
|
315
|
+
display_name=_format_type_name(param_type),
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _format_type_name(param_type: Any) -> str:
|
|
320
|
+
"""Render a readable type name for resolution errors."""
|
|
321
|
+
if param_type is None:
|
|
322
|
+
return "?"
|
|
323
|
+
if isinstance(param_type, type):
|
|
324
|
+
return param_type.__name__
|
|
325
|
+
return str(param_type).replace("typing.", "")
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _is_auto_injectable_type(param_type: Any) -> bool:
|
|
329
|
+
"""Return whether a type hint should be used as a DI lookup key."""
|
|
330
|
+
return isinstance(param_type, type) and param_type not in _DISALLOWED_AUTO_INJECT_TYPES
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _callable_name(fn: Callable[..., Any]) -> str:
|
|
334
|
+
"""Best-effort human-readable callable name for diagnostics."""
|
|
335
|
+
return getattr(fn, "__name__", fn.__class__.__name__)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _require_factory(binding: _Binding) -> Callable[..., Any]:
|
|
339
|
+
"""Return a binding factory or raise if the binding is malformed."""
|
|
340
|
+
factory = binding.factory
|
|
341
|
+
if factory is None:
|
|
342
|
+
msg = "Binding is missing a factory"
|
|
343
|
+
raise ResolutionError(msg)
|
|
344
|
+
return factory
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _validate_service_type(service: type[Any]) -> None:
|
|
348
|
+
"""Reject unsupported DI keys up front."""
|
|
349
|
+
if service in _DISALLOWED_AUTO_INJECT_TYPES:
|
|
350
|
+
msg = (
|
|
351
|
+
f"Cannot bind {service.__name__}: scalar builtins are not supported as DI keys; "
|
|
352
|
+
"use a typed value object or an explicit factory instead"
|
|
353
|
+
)
|
|
354
|
+
raise TypeError(msg)
|
|
File without changes
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
"""Tests for the DI container."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
6
|
+
from threading import Barrier, Lock
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from miniject import Container, ResolutionError
|
|
12
|
+
|
|
13
|
+
# ── Test fixtures ────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _Database:
|
|
17
|
+
def __init__(self, path: str = ":memory:") -> None:
|
|
18
|
+
self.path = path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _Repo:
|
|
22
|
+
def __init__(self, database: _Database) -> None:
|
|
23
|
+
self.database = database
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _Service:
|
|
27
|
+
def __init__(self, repo: _Repo, database: _Database) -> None:
|
|
28
|
+
self.repo = repo
|
|
29
|
+
self.database = database
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _ServiceWithDefault:
|
|
33
|
+
def __init__(self, database: _Database, timeout: int = 30) -> None:
|
|
34
|
+
self.database = database
|
|
35
|
+
self.timeout = timeout
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _TimeoutSettings:
|
|
39
|
+
def __init__(self, seconds: int) -> None:
|
|
40
|
+
self.seconds = seconds
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _ServiceWithSettings:
|
|
44
|
+
def __init__(self, database: _Database, settings: _TimeoutSettings) -> None:
|
|
45
|
+
self.database = database
|
|
46
|
+
self.settings = settings
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class _ServiceWithOptionalDatabaseDefault:
|
|
50
|
+
def __init__(self, database: _Database | None = None) -> None:
|
|
51
|
+
self.database = database
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class _ServiceWithOptionalDatabaseRequired:
|
|
55
|
+
def __init__(self, database: _Database | None) -> None:
|
|
56
|
+
self.database = database
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class _AnnotatedService:
|
|
60
|
+
def __init__(self, database: Annotated[_Database, "primary"]) -> None:
|
|
61
|
+
self.database = database
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class _MissingRuntimeDependency:
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class _MissingRuntimeHintService:
|
|
69
|
+
def __init__(self, database: _MissingRuntimeDependency) -> None:
|
|
70
|
+
self.database = database
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class _UntypedService:
|
|
74
|
+
def __init__(self, dependency) -> None:
|
|
75
|
+
self.dependency = dependency
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class _CircularA:
|
|
79
|
+
def __init__(self, b: _CircularB) -> None:
|
|
80
|
+
self.b = b
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class _CircularB:
|
|
84
|
+
def __init__(self, a: _CircularA) -> None:
|
|
85
|
+
self.a = a
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ── bind + resolve: instance ─────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_bind_instance_and_resolve() -> None:
|
|
92
|
+
c = Container()
|
|
93
|
+
db = _Database("test.db")
|
|
94
|
+
c.bind(_Database, instance=db)
|
|
95
|
+
|
|
96
|
+
resolved = c.resolve(_Database)
|
|
97
|
+
|
|
98
|
+
assert resolved is db
|
|
99
|
+
assert resolved.path == "test.db"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_instance_is_singleton() -> None:
|
|
103
|
+
c = Container()
|
|
104
|
+
db = _Database()
|
|
105
|
+
c.bind(_Database, instance=db)
|
|
106
|
+
|
|
107
|
+
assert c.resolve(_Database) is c.resolve(_Database)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ── bind + resolve: auto-wired ───────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_auto_wire_transient() -> None:
|
|
114
|
+
c = Container()
|
|
115
|
+
c.bind(_Database, instance=_Database())
|
|
116
|
+
c.bind(_Repo)
|
|
117
|
+
|
|
118
|
+
repo = c.resolve(_Repo)
|
|
119
|
+
|
|
120
|
+
assert isinstance(repo, _Repo)
|
|
121
|
+
assert isinstance(repo.database, _Database)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_auto_wire_transient_creates_new_each_time() -> None:
|
|
125
|
+
c = Container()
|
|
126
|
+
c.bind(_Database, instance=_Database())
|
|
127
|
+
c.bind(_Repo)
|
|
128
|
+
|
|
129
|
+
r1 = c.resolve(_Repo)
|
|
130
|
+
r2 = c.resolve(_Repo)
|
|
131
|
+
|
|
132
|
+
assert r1 is not r2
|
|
133
|
+
assert r1.database is r2.database # shared singleton Database
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_auto_wire_deep_chain() -> None:
|
|
137
|
+
c = Container()
|
|
138
|
+
c.bind(_Database, instance=_Database())
|
|
139
|
+
c.bind(_Repo)
|
|
140
|
+
c.bind(_Service)
|
|
141
|
+
|
|
142
|
+
svc = c.resolve(_Service)
|
|
143
|
+
|
|
144
|
+
assert isinstance(svc.repo, _Repo)
|
|
145
|
+
assert isinstance(svc.database, _Database)
|
|
146
|
+
assert svc.repo.database is svc.database # same Database singleton
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ── bind + resolve: factory ──────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_factory_transient() -> None:
|
|
153
|
+
c = Container()
|
|
154
|
+
c.bind(_Database, factory=lambda: _Database("/custom"))
|
|
155
|
+
|
|
156
|
+
db = c.resolve(_Database)
|
|
157
|
+
|
|
158
|
+
assert db.path == "/custom"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_factory_singleton() -> None:
|
|
162
|
+
c = Container()
|
|
163
|
+
c.bind(_Database, factory=lambda: _Database("/singleton"), singleton=True)
|
|
164
|
+
|
|
165
|
+
d1 = c.resolve(_Database)
|
|
166
|
+
d2 = c.resolve(_Database)
|
|
167
|
+
|
|
168
|
+
assert d1 is d2
|
|
169
|
+
assert d1.path == "/singleton"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _repo_factory(database: _Database) -> _Repo:
|
|
173
|
+
return _Repo(database)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_factory_with_deps() -> None:
|
|
177
|
+
c = Container()
|
|
178
|
+
c.bind(_Database, instance=_Database())
|
|
179
|
+
c.bind(_Repo, factory=_repo_factory)
|
|
180
|
+
|
|
181
|
+
repo = c.resolve(_Repo)
|
|
182
|
+
|
|
183
|
+
assert isinstance(repo.database, _Database)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ── scope (child containers) ─────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def test_scope_inherits_parent_bindings() -> None:
|
|
190
|
+
parent = Container()
|
|
191
|
+
parent.bind(_Database, instance=_Database("/parent"))
|
|
192
|
+
parent.bind(_Repo)
|
|
193
|
+
|
|
194
|
+
child = parent.scope()
|
|
195
|
+
repo = child.resolve(_Repo)
|
|
196
|
+
|
|
197
|
+
assert repo.database.path == "/parent"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def test_scope_override_does_not_affect_parent() -> None:
|
|
201
|
+
parent = Container()
|
|
202
|
+
parent.bind(_Database, instance=_Database("/parent"))
|
|
203
|
+
|
|
204
|
+
child = parent.scope()
|
|
205
|
+
child.bind(_Database, instance=_Database("/child"))
|
|
206
|
+
|
|
207
|
+
assert parent.resolve(_Database).path == "/parent"
|
|
208
|
+
assert child.resolve(_Database).path == "/child"
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def test_scope_override_propagates_to_auto_wiring() -> None:
|
|
212
|
+
parent = Container()
|
|
213
|
+
parent.bind(_Database, instance=_Database("/parent"))
|
|
214
|
+
parent.bind(_Repo)
|
|
215
|
+
|
|
216
|
+
child = parent.scope()
|
|
217
|
+
child.bind(_Database, instance=_Database("/child"))
|
|
218
|
+
|
|
219
|
+
parent_repo = parent.resolve(_Repo)
|
|
220
|
+
child_repo = child.resolve(_Repo)
|
|
221
|
+
|
|
222
|
+
assert parent_repo.database.path == "/parent"
|
|
223
|
+
assert child_repo.database.path == "/child"
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def test_parent_singleton_factory_is_shared_with_children() -> None:
|
|
227
|
+
parent = Container()
|
|
228
|
+
parent.bind(_Database, factory=lambda: _Database("/shared"), singleton=True)
|
|
229
|
+
|
|
230
|
+
child = parent.scope()
|
|
231
|
+
|
|
232
|
+
parent_db = parent.resolve(_Database)
|
|
233
|
+
child_db = child.resolve(_Database)
|
|
234
|
+
|
|
235
|
+
assert parent_db is child_db
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def test_parent_singleton_dependency_is_shared_when_resolved_through_child() -> None:
|
|
239
|
+
parent = Container()
|
|
240
|
+
parent.bind(_Database, factory=lambda: _Database("/shared"), singleton=True)
|
|
241
|
+
parent.bind(_Repo)
|
|
242
|
+
|
|
243
|
+
child = parent.scope()
|
|
244
|
+
|
|
245
|
+
parent_repo = parent.resolve(_Repo)
|
|
246
|
+
child_repo = child.resolve(_Repo)
|
|
247
|
+
|
|
248
|
+
assert parent_repo.database is child_repo.database
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def test_singleton_factory_is_initialized_once_under_concurrent_resolution() -> None:
|
|
252
|
+
start_barrier = Barrier(8)
|
|
253
|
+
call_count = 0
|
|
254
|
+
count_lock = Lock()
|
|
255
|
+
|
|
256
|
+
def _factory() -> _Database:
|
|
257
|
+
nonlocal call_count
|
|
258
|
+
with count_lock:
|
|
259
|
+
call_count += 1
|
|
260
|
+
return _Database("/shared")
|
|
261
|
+
|
|
262
|
+
c = Container()
|
|
263
|
+
c.bind(_Database, factory=_factory, singleton=True)
|
|
264
|
+
|
|
265
|
+
def _resolve(_: int) -> _Database:
|
|
266
|
+
start_barrier.wait()
|
|
267
|
+
return c.resolve(_Database)
|
|
268
|
+
|
|
269
|
+
with ThreadPoolExecutor(max_workers=8) as pool:
|
|
270
|
+
results = list(pool.map(_resolve, range(8)))
|
|
271
|
+
|
|
272
|
+
assert all(result is results[0] for result in results)
|
|
273
|
+
assert call_count == 1
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def test_parent_singleton_is_initialized_once_when_children_resolve_concurrently() -> None:
|
|
277
|
+
start_barrier = Barrier(8)
|
|
278
|
+
call_count = 0
|
|
279
|
+
count_lock = Lock()
|
|
280
|
+
|
|
281
|
+
def _factory() -> _Database:
|
|
282
|
+
nonlocal call_count
|
|
283
|
+
with count_lock:
|
|
284
|
+
call_count += 1
|
|
285
|
+
return _Database("/shared")
|
|
286
|
+
|
|
287
|
+
parent = Container()
|
|
288
|
+
parent.bind(_Database, factory=_factory, singleton=True)
|
|
289
|
+
children = [parent.scope() for _ in range(8)]
|
|
290
|
+
|
|
291
|
+
def _resolve(child: Container) -> _Database:
|
|
292
|
+
start_barrier.wait()
|
|
293
|
+
return child.resolve(_Database)
|
|
294
|
+
|
|
295
|
+
with ThreadPoolExecutor(max_workers=8) as pool:
|
|
296
|
+
results = list(pool.map(_resolve, children))
|
|
297
|
+
|
|
298
|
+
assert all(result is results[0] for result in results)
|
|
299
|
+
assert call_count == 1
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# ── default parameter behavior ───────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def test_default_used_when_no_binding() -> None:
|
|
306
|
+
c = Container()
|
|
307
|
+
c.bind(_Database, instance=_Database())
|
|
308
|
+
c.bind(_ServiceWithDefault)
|
|
309
|
+
|
|
310
|
+
svc = c.resolve(_ServiceWithDefault)
|
|
311
|
+
|
|
312
|
+
assert svc.timeout == 30 # default preserved
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def test_scalar_bindings_are_rejected() -> None:
|
|
316
|
+
c = Container()
|
|
317
|
+
|
|
318
|
+
with pytest.raises(TypeError, match="scalar builtins"):
|
|
319
|
+
c.bind(int, instance=99)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def test_typed_value_object_binding_is_injected() -> None:
|
|
323
|
+
c = Container()
|
|
324
|
+
settings = _TimeoutSettings(99)
|
|
325
|
+
c.bind(_Database, instance=_Database())
|
|
326
|
+
c.bind(_TimeoutSettings, instance=settings)
|
|
327
|
+
c.bind(_ServiceWithSettings)
|
|
328
|
+
|
|
329
|
+
svc = c.resolve(_ServiceWithSettings)
|
|
330
|
+
|
|
331
|
+
assert svc.settings is settings
|
|
332
|
+
assert svc.settings.seconds == 99
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def test_optional_binding_overrides_none_default() -> None:
|
|
336
|
+
c = Container()
|
|
337
|
+
db = _Database("/bound")
|
|
338
|
+
c.bind(_Database, instance=db)
|
|
339
|
+
c.bind(_ServiceWithOptionalDatabaseDefault)
|
|
340
|
+
|
|
341
|
+
svc = c.resolve(_ServiceWithOptionalDatabaseDefault)
|
|
342
|
+
|
|
343
|
+
assert svc.database is db
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def test_optional_binding_uses_none_default_when_unbound() -> None:
|
|
347
|
+
c = Container()
|
|
348
|
+
c.bind(_ServiceWithOptionalDatabaseDefault)
|
|
349
|
+
|
|
350
|
+
svc = c.resolve(_ServiceWithOptionalDatabaseDefault)
|
|
351
|
+
|
|
352
|
+
assert svc.database is None
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def test_optional_binding_without_default_still_requires_binding() -> None:
|
|
356
|
+
c = Container()
|
|
357
|
+
c.bind(_ServiceWithOptionalDatabaseRequired)
|
|
358
|
+
|
|
359
|
+
with pytest.raises(ResolutionError, match="missing binding for parameter 'database'"):
|
|
360
|
+
c.resolve(_ServiceWithOptionalDatabaseRequired)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
# ── error handling ───────────────────────────────────────────────────
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def test_bind_none_instance() -> None:
|
|
367
|
+
"""None is a valid instance value — it should not fall through to auto-wiring."""
|
|
368
|
+
c = Container()
|
|
369
|
+
c.bind(type(None), instance=None)
|
|
370
|
+
|
|
371
|
+
assert c.resolve(type(None)) is None
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def test_missing_binding_raises_resolution_error() -> None:
|
|
375
|
+
c = Container()
|
|
376
|
+
|
|
377
|
+
with pytest.raises(ResolutionError, match="no binding"):
|
|
378
|
+
c.resolve(_Database)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def test_missing_dep_shows_chain() -> None:
|
|
382
|
+
c = Container()
|
|
383
|
+
c.bind(_Repo) # needs _Database but it's not registered
|
|
384
|
+
|
|
385
|
+
with pytest.raises(ResolutionError, match="_Database"):
|
|
386
|
+
c.resolve(_Repo)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def test_annotated_dependency_requires_explicit_factory() -> None:
|
|
390
|
+
c = Container()
|
|
391
|
+
c.bind(_Database, instance=_Database())
|
|
392
|
+
c.bind(_AnnotatedService)
|
|
393
|
+
|
|
394
|
+
with pytest.raises(ResolutionError, match="Annotated"):
|
|
395
|
+
c.resolve(_AnnotatedService)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def test_unresolvable_runtime_type_hints_raise_resolution_error() -> None:
|
|
399
|
+
c = Container()
|
|
400
|
+
c.bind(_MissingRuntimeHintService)
|
|
401
|
+
|
|
402
|
+
original = globals().pop("_MissingRuntimeDependency")
|
|
403
|
+
try:
|
|
404
|
+
with pytest.raises(ResolutionError, match="failed to evaluate type hints"):
|
|
405
|
+
c.resolve(_MissingRuntimeHintService)
|
|
406
|
+
finally:
|
|
407
|
+
globals()["_MissingRuntimeDependency"] = original
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def test_untyped_required_param_shows_unknown_type() -> None:
|
|
411
|
+
c = Container()
|
|
412
|
+
c.bind(_UntypedService)
|
|
413
|
+
|
|
414
|
+
with pytest.raises(ResolutionError, match=r"type=\?"):
|
|
415
|
+
c.resolve(_UntypedService)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def test_circular_dependency_detected() -> None:
|
|
419
|
+
c = Container()
|
|
420
|
+
c.bind(_CircularA)
|
|
421
|
+
c.bind(_CircularB)
|
|
422
|
+
|
|
423
|
+
with pytest.raises(ResolutionError, match="Circular dependency"):
|
|
424
|
+
c.resolve(_CircularA)
|