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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.