miniject 0.1.0__py3-none-any.whl
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/__init__.py +11 -0
- miniject/_container.py +354 -0
- miniject/py.typed +0 -0
- miniject-0.1.0.dist-info/METADATA +21 -0
- miniject-0.1.0.dist-info/RECORD +7 -0
- miniject-0.1.0.dist-info/WHEEL +4 -0
- miniject-0.1.0.dist-info/licenses/LICENSE +21 -0
miniject/__init__.py
ADDED
|
@@ -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"]
|
miniject/_container.py
ADDED
|
@@ -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)
|
miniject/py.typed
ADDED
|
File without changes
|
|
@@ -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,7 @@
|
|
|
1
|
+
miniject/__init__.py,sha256=A62uIzhi2kon4_1ig4vLSQwKAUXSDyRoKdpC5pZajeg,416
|
|
2
|
+
miniject/_container.py,sha256=g21mD63QA1EsEWYFI1Yw_rEHzrMcceVbL-nvMHOgDCY,13207
|
|
3
|
+
miniject/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
miniject-0.1.0.dist-info/METADATA,sha256=By4nOSrqcx-s8lmx68Lqf95J1AfePaE2SALdqvNa1g0,848
|
|
5
|
+
miniject-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
6
|
+
miniject-0.1.0.dist-info/licenses/LICENSE,sha256=qA6OKCSvrEXsWU_L0GgFU4w05Nuujoc6zEutEij_jHw,1086
|
|
7
|
+
miniject-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|