dinkleberg 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.
dinkleberg/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from .dependency import Dependency
2
+ from .dependency_configurator import DependencyConfigurator
3
+ from .dependency_scope import DependencyScope
4
+
5
+ __all__ = ['DependencyScope', 'DependencyConfigurator', 'Dependency']
@@ -0,0 +1,2 @@
1
+ class Dependency:
2
+ pass
@@ -0,0 +1,286 @@
1
+ import asyncio
2
+ import inspect
3
+ import logging
4
+ from inspect import Signature
5
+ from types import MappingProxyType
6
+ from typing import AsyncGenerator, Callable, overload, get_type_hints, Mapping, get_origin
7
+
8
+ from .dependency import Dependency
9
+ from .dependency_scope import DependencyScope
10
+ from .descriptor import Descriptor, Lifetime
11
+ from .typing import get_static_params, get_public_methods
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ # noinspection PyShadowingBuiltins
17
+ class DependencyConfigurator(DependencyScope):
18
+ def __init__(self, parent: 'DependencyConfigurator' = None) -> None:
19
+ super().__init__()
20
+ self._parent = parent
21
+ self._descriptors: dict[type, Descriptor] = {}
22
+ self._singleton_instances = {}
23
+ self._scoped_instances = {}
24
+ self._active_generators = []
25
+ self._scopes = []
26
+ self._closed = False
27
+
28
+ async def close(self) -> None:
29
+ if self._closed:
30
+ return
31
+ self._closed = True
32
+
33
+ exceptions = []
34
+
35
+ # TODO close generators in reverse order of creation (LIFO)
36
+ for generator in self._active_generators:
37
+ try:
38
+ await generator.__anext__()
39
+ raise RuntimeError('Generator did not stop after yielding a single value.')
40
+ except StopAsyncIteration:
41
+ pass
42
+ except Exception as e:
43
+ exceptions.append(e)
44
+
45
+ for scope in self._scopes:
46
+ try:
47
+ await scope.close()
48
+ except Exception as e:
49
+ exceptions.append(e)
50
+
51
+ self._singleton_instances.clear()
52
+ self._active_generators.clear()
53
+ self._scoped_instances.clear()
54
+ self._descriptors.clear()
55
+ self._scopes.clear()
56
+
57
+ if exceptions:
58
+ raise ExceptionGroup('Errors occurred during closing DependencyConfigurator', exceptions)
59
+
60
+ def _add(self, lifetime: Lifetime, *, t: type = None, generator: Callable[..., AsyncGenerator] = None,
61
+ callable: Callable = None):
62
+ if generator is None and callable is None:
63
+ raise ValueError('Invalid dependency configuration.')
64
+ if t is None:
65
+ t = self._infer_type(generator=generator, callable=callable)
66
+ self._descriptors[t] = Descriptor(generator=generator, callable=callable, lifetime=lifetime)
67
+
68
+ @staticmethod
69
+ def _infer_type(*, generator: Callable[..., AsyncGenerator], callable: Callable) -> type:
70
+ # noinspection PyBroadException
71
+ try:
72
+ hints = get_type_hints(generator or callable)
73
+ return_hint = hints.get('return')
74
+
75
+ if not return_hint:
76
+ pass
77
+
78
+ # This looks for the generic arguments of the return type
79
+ # e.g., if hint is AsyncGenerator[User, None], it extracts User
80
+ if hasattr(return_hint, '__args__'):
81
+ return return_hint.__args__[0]
82
+ return return_hint
83
+ except Exception:
84
+ pass
85
+ raise ValueError('Could not infer type from generator. Please provide the type explicitly.')
86
+
87
+ def _raise_if_closed(self):
88
+ if self._closed:
89
+ raise RuntimeError('DependencyConfigurator is already closed.')
90
+
91
+ def scope(self) -> 'DependencyConfigurator':
92
+ self._raise_if_closed()
93
+ scope = DependencyConfigurator(self)
94
+ scope._descriptors = self._descriptors.copy()
95
+ self._scopes.append(scope)
96
+ return scope
97
+
98
+ def _lookup_singleton(self, t: type):
99
+ if t in self._singleton_instances:
100
+ return self._singleton_instances[t]
101
+ if self._parent:
102
+ return self._parent._lookup_singleton(t)
103
+ return None
104
+
105
+ # TODO circular dependency detection
106
+ async def resolve[T](self, t: type[T], **kwargs) -> T:
107
+ self._raise_if_closed()
108
+
109
+ if t == DependencyScope:
110
+ return self
111
+
112
+ singleton = self._lookup_singleton(t)
113
+ if singleton is not None:
114
+ return singleton
115
+ if t in self._scoped_instances:
116
+ return self._scoped_instances[t]
117
+
118
+ if t in self._descriptors:
119
+ descriptor = self._descriptors[t]
120
+ lifetime = descriptor['lifetime']
121
+ if lifetime == 'singleton' and self._parent:
122
+ # we need to resolve singleton from the root scope
123
+ return await self._parent.resolve(t, **kwargs)
124
+
125
+ is_generator = descriptor['generator'] is not None
126
+ factory = descriptor['generator'] or descriptor['callable']
127
+ deps = await self._resolve_deps(factory)
128
+ else:
129
+ origin = get_origin(t)
130
+ if origin is not None:
131
+ raise ValueError(f'Cannot resolve generic type {t} without explicit registration.')
132
+
133
+ is_generator = False
134
+ lifetime = 'transient'
135
+ factory = t
136
+ deps = await self._resolve_deps(t.__init__)
137
+
138
+ if is_generator:
139
+ generator = factory(**deps, **kwargs)
140
+ try:
141
+ instance = await generator.__anext__()
142
+ except StopAsyncIteration:
143
+ raise RuntimeError(f'Generator {t} did not yield any value.')
144
+
145
+ self._active_generators.append(generator)
146
+ else:
147
+ instance = factory(**deps, **kwargs)
148
+
149
+ self._wrap_instance(instance)
150
+
151
+ if lifetime == 'singleton':
152
+ self._singleton_instances[t] = instance
153
+ elif lifetime == 'scoped':
154
+ self._scoped_instances[t] = instance
155
+
156
+ return instance
157
+
158
+ async def _resolve_deps(self, func: Callable) -> dict:
159
+ params = get_static_params(func)
160
+ tasks = []
161
+ names = []
162
+
163
+ for param in params:
164
+ if not param.annotation or param.annotation is inspect.Parameter.empty:
165
+ continue
166
+
167
+ # TODO handle more complex cases (e.g., Union, Optional, etc.)
168
+ # TODO handle native types (int, str, etc.)
169
+ names.append(param.name)
170
+ tasks.append(self.resolve(param.annotation))
171
+
172
+ results = await asyncio.gather(*tasks)
173
+ return dict(zip(names, results))
174
+
175
+ async def _resolve_kwargs(self, signature: Signature, name: str, args: tuple, kwargs: dict,
176
+ dep_params: Mapping[str, inspect.Parameter]) -> dict:
177
+ bound_args = signature.bind_partial(*args, **kwargs)
178
+ actual_kwargs = kwargs.copy()
179
+
180
+ params_to_resolve = []
181
+ for p_name, p_obj in dep_params.items():
182
+ if p_name not in bound_args.arguments:
183
+ if p_obj.annotation is inspect.Parameter.empty:
184
+ raise TypeError(f'Parameter "{p_name}" in {name} is marked as a Dependency but lacks a type hint.')
185
+ params_to_resolve.append((p_name, p_obj.annotation))
186
+
187
+ if not params_to_resolve:
188
+ return actual_kwargs
189
+
190
+ names, types = zip(*params_to_resolve)
191
+ resolved_values = await asyncio.gather(*(self.resolve(t, **kwargs) for t in types))
192
+
193
+ actual_kwargs.update(dict(zip(names, resolved_values)))
194
+
195
+ return actual_kwargs
196
+
197
+ # TODO handle __slots__
198
+ def _wrap_instance(self, instance):
199
+ if getattr(instance, '__di_wrapped__', False):
200
+ return
201
+
202
+ methods = get_public_methods(instance)
203
+ for name, value in methods:
204
+ signature = inspect.signature(value)
205
+
206
+ dep_params = MappingProxyType({
207
+ param_name: param
208
+ for param_name, param in signature.parameters.items()
209
+ if isinstance(param.default, Dependency)
210
+ })
211
+ if not dep_params:
212
+ continue
213
+
214
+ instance_method = getattr(instance, name)
215
+ if asyncio.iscoroutinefunction(instance_method):
216
+ async def wrapped_method(*args, __m=instance_method, __s=signature, __n=name, __d=dep_params, **kwargs):
217
+ new_kwargs = await self._resolve_kwargs(__s, __n, args, kwargs, __d)
218
+ return await __m(*args, **new_kwargs)
219
+
220
+ setattr(instance, name, wrapped_method)
221
+ else:
222
+ raise NotImplementedError('Synchronous methods with Dependency() defaults are not supported.')
223
+
224
+ try:
225
+ setattr(instance, '__di_wrapped__', True)
226
+ except (AttributeError, TypeError):
227
+ # Some objects (like those with __slots__) might not allow new attributes
228
+ pass
229
+
230
+ @overload
231
+ def add_singleton[I](self, *, instance: I):
232
+ ...
233
+
234
+ @overload
235
+ def add_singleton[T, I](self, *, t: type[T], instance: I):
236
+ ...
237
+
238
+ @overload
239
+ def add_singleton[I](self, *, callable: Callable[..., I]):
240
+ ...
241
+
242
+ @overload
243
+ def add_singleton[T, I](self, *, t: type[T], callable: Callable[..., I]):
244
+ ...
245
+
246
+ @overload
247
+ def add_singleton[I](self, *, generator: Callable[..., AsyncGenerator[I]]):
248
+ ...
249
+
250
+ @overload
251
+ def add_singleton[T, I](self, *, t: type[T], generator: Callable[..., AsyncGenerator[I]]):
252
+ ...
253
+
254
+ def add_singleton[T, I](self, *, t: type[T] = None, generator: Callable[..., AsyncGenerator[I]] = None,
255
+ callable: Callable[..., I] = None, instance: I = None):
256
+ self._raise_if_closed()
257
+ if instance is None:
258
+ self._add('singleton', t=t, generator=generator, callable=callable)
259
+ return
260
+ elif t is None:
261
+ t = type(instance)
262
+
263
+ self._wrap_instance(instance)
264
+
265
+ self._singleton_instances[t] = instance
266
+
267
+ @overload
268
+ def add_scoped[I](self, *, callable: Callable[..., I]):
269
+ ...
270
+
271
+ @overload
272
+ def add_scoped[T, I](self, *, t: type[T], callable: Callable[..., I]):
273
+ ...
274
+
275
+ @overload
276
+ def add_scoped[I](self, *, generator: Callable[..., AsyncGenerator[I]]):
277
+ ...
278
+
279
+ @overload
280
+ def add_scoped[T, I](self, *, t: type[T], generator: Callable[..., AsyncGenerator[I]]):
281
+ ...
282
+
283
+ def add_scoped[T, I](self, *, t: type[T] = None, generator: Callable[..., AsyncGenerator[I]] = None,
284
+ callable: Callable[..., I] = None):
285
+ self._raise_if_closed()
286
+ self._add('scoped', t=t, generator=generator, callable=callable)
@@ -0,0 +1,15 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+
4
+ class DependencyScope(ABC):
5
+ @abstractmethod
6
+ async def resolve[T](self, t: type[T], **kwargs) -> T:
7
+ pass
8
+
9
+ @abstractmethod
10
+ async def close(self) -> None:
11
+ pass
12
+
13
+ @abstractmethod
14
+ def scope(self) -> 'DependencyScope':
15
+ pass
@@ -0,0 +1,9 @@
1
+ from typing import Optional, TypedDict, Callable, AsyncGenerator, Literal, Union
2
+
3
+ Lifetime = Union[Literal['singleton'], Literal['scoped'], Literal['transient']]
4
+
5
+
6
+ class Descriptor(TypedDict):
7
+ generator: Optional[Callable[..., AsyncGenerator]]
8
+ callable: Optional[Callable]
9
+ lifetime: Lifetime
dinkleberg/typing.py ADDED
@@ -0,0 +1,26 @@
1
+ import inspect
2
+ from inspect import Parameter, signature
3
+ from typing import Callable
4
+
5
+
6
+ def get_static_params(func: Callable) -> list[Parameter]:
7
+ sig = signature(func)
8
+
9
+ params = list(sig.parameters.values())
10
+
11
+ if params and params[0].name in ('self', 'cls'):
12
+ params = params[1:]
13
+
14
+ return params
15
+
16
+
17
+ def get_public_methods(obj: object) -> list[tuple[str, Callable]]:
18
+ methods = []
19
+
20
+ for name in dir(obj.__class__):
21
+ attr = getattr(obj.__class__, name)
22
+
23
+ if inspect.isroutine(attr) and not isinstance(attr, property) and not name.startswith('_'):
24
+ methods.append((name, attr))
25
+
26
+ return methods
@@ -0,0 +1,34 @@
1
+ Metadata-Version: 2.4
2
+ Name: dinkleberg
3
+ Version: 0.1.0
4
+ Summary: Your friendly neighbour when it comes to dependency management.
5
+ Project-URL: Homepage, https://github.com/DavidVollmers/dinkleberg
6
+ Project-URL: Documentation, https://github.com/DavidVollmers/dinkleberg/blob/main/libs/dinkleberg/README.md
7
+ Project-URL: Repository, https://github.com/DavidVollmers/dinkleberg.git
8
+ Project-URL: Issues, https://github.com/DavidVollmers/dinkleberg/issues
9
+ Project-URL: Changelog, https://github.com/DavidVollmers/dinkleberg/blob/main/CHANGELOG.md
10
+ Keywords: automation,dependency,package-management
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Requires-Python: >=3.13
19
+ Description-Content-Type: text/markdown
20
+
21
+ # dinkleberg
22
+
23
+ > "And this is where I'd put my working dependencies... IF I HAD ANY!"
24
+
25
+ **dinkleberg** is a lightweight Python utility designed to make dependency management less of a neighborhood feud. Built
26
+ to work seamlessly in any project, it ensures your environment stays green—unlike the guy's next door.
27
+
28
+ ## Installation
29
+
30
+ ```bash
31
+ pip install dinkleberg
32
+
33
+ uv add dinkleberg
34
+ ```
@@ -0,0 +1,9 @@
1
+ dinkleberg/__init__.py,sha256=HGu8mjYt-Fa0y9_aMtJZFYo3s9Yb0q5dmbMKtHrAV_k,217
2
+ dinkleberg/dependency.py,sha256=YcGvuvhoBRxShgSRaznKyZg49GCgOWy_L8HD0sXAMYo,29
3
+ dinkleberg/dependency_configurator.py,sha256=IeGSUUbaaxbcb1I6NcIqWyqGs0iWygVzjXzsj0VmOGg,10473
4
+ dinkleberg/dependency_scope.py,sha256=sufXjQ6F62ZfohItUnrV7Fvh8gSSc7a6orO6PXu19Vw,318
5
+ dinkleberg/descriptor.py,sha256=mVI00K1M2m4Rl5ANatGMeB_HpzeFONoULwg0UmNxfQ0,313
6
+ dinkleberg/typing.py,sha256=9AGDNz_aQ8p7ToyJwkj2fg92o0L3-zXwZBEmoTNS1to,670
7
+ dinkleberg-0.1.0.dist-info/METADATA,sha256=vU4SQ-0fafwgtOETUAtr_-zuPTEJX4Haesa4i8OT8L8,1396
8
+ dinkleberg-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
9
+ dinkleberg-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any