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.
@@ -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
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .venv/
7
+ .pytest_cache/
8
+ .ruff_cache/
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.
@@ -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'
@@ -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)