Skip to content

Commit bba4eb8

Browse files
committed
feat: composable type injection
1 parent dee23e7 commit bba4eb8

File tree

6 files changed

+192
-19
lines changed

6 files changed

+192
-19
lines changed

linkd/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"""A powerful async-only dependency injection framework for Python."""
2222

2323
from linkd import ext
24+
from linkd.compose import *
2425
from linkd.conditions import *
2526
from linkd.container import *
2627
from linkd.context import *
@@ -35,6 +36,7 @@
3536
"INJECTED",
3637
"AutoInjecting",
3738
"CircularDependencyException",
39+
"Compose",
3840
"Container",
3941
"ContainerClosedException",
4042
"Context",

linkd/compose.py

+106
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
# -*- coding: utf-8 -*-
2+
# Copyright (c) 2025-present tandemdude
3+
#
4+
# Permission is hereby granted, free of charge, to any person obtaining a copy
5+
# of this software and associated documentation files (the "Software"), to deal
6+
# in the Software without restriction, including without limitation the rights
7+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8+
# copies of the Software, and to permit persons to whom the Software is
9+
# furnished to do so, subject to the following conditions:
10+
#
11+
# The above copyright notice and this permission notice shall be included in all
12+
# copies or substantial portions of the Software.
13+
#
14+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20+
# SOFTWARE.
21+
from __future__ import annotations
22+
23+
__all__ = ["Compose"]
24+
25+
import inspect
26+
import textwrap
27+
import typing as t
28+
29+
if t.TYPE_CHECKING:
30+
import typing_extensions as t_ex
31+
from typing_extensions import dataclass_transform
32+
else:
33+
dataclass_transform = lambda: lambda x: x # noqa: E731
34+
35+
_ACTUAL_ATTR: t.Final[str] = "__linkd_actual__"
36+
_DEPS_ATTR: t.Final[str] = "__linkd_deps__"
37+
38+
39+
def _is_compose_class(item: t.Any) -> t_ex.TypeIs[type[Compose]]: # type: ignore[reportUnusedFunction]
40+
return hasattr(item, _ACTUAL_ATTR) and inspect.isclass(item)
41+
42+
43+
class ComposeMeta(type):
44+
"""Metaclass handling code generation for user-defined :obj:`~Compose` subclasses."""
45+
46+
@staticmethod
47+
def _codegen_compose_cls(name: str, attrs: dict[str, t.Any]) -> type[t.Any]:
48+
joined_names = ",".join(attrs)
49+
joined_quoted_names = ",".join(f'"{n}"' for n in attrs) + ("," if len(attrs) == 1 else "")
50+
51+
lines = [
52+
f"class {name}:",
53+
f" __slots__ = ({joined_quoted_names})",
54+
f" def __init__(self,{joined_names}):",
55+
*(textwrap.indent(f"self.{n} = {n}", " " * 8) for n in attrs),
56+
]
57+
58+
exec("\n".join(lines), {}, (generated_locals := {}))
59+
return t.cast("type[t.Any]", generated_locals[name])
60+
61+
def __new__(cls, name: str, bases: tuple[type[t.Any]], attrs: dict[str, t.Any], **kwargs: t.Any) -> type[t.Any]:
62+
if attrs["__module__"] == "linkd.compose" and attrs["__qualname__"] == "Compose":
63+
return super().__new__(cls, name, bases, attrs)
64+
65+
generated = cls._codegen_compose_cls(name, attrs["__annotations__"])
66+
setattr(generated, _ACTUAL_ATTR, super().__new__(cls, name, bases, attrs))
67+
68+
return generated
69+
70+
71+
@dataclass_transform()
72+
class Compose(metaclass=ComposeMeta):
73+
"""
74+
Class allowing for "composing" of multiple dependencies into a single object, to help declutter
75+
the signatures of functions that require a large number of dependencies.
76+
77+
If you have ever used msgspec or pydantic this will feel familiar. To use this feature, simply create a
78+
subclass of this class and define fields for the dependencies you wish to use.
79+
80+
.. code-block:: python
81+
82+
class ComposedDependencies(linkd.Compose):
83+
foo: FooDep
84+
bar: BarDep
85+
baz: BazDep
86+
87+
Then, in place of specifying ``foo``, ``bar`` and ``baz`` within the function signature, you can request
88+
a single object of type ``ComposedDependencies``.
89+
90+
.. code-block:: python
91+
92+
async def function(deps: ComposedDependencies):
93+
...
94+
95+
Linkd will automatically try to create an instance of your composed class with all the dependencies that
96+
it requires. As with defining dependencies within the function signature, you can use fallback and `If` or `Try`
97+
syntax within the composed class field annotations.
98+
99+
.. warning::
100+
None of the fields may contain a dependency on a composed class.
101+
102+
.. warning::
103+
Composed classes cannot have any defined methods, they will be erased at runtime.
104+
"""
105+
106+
__slots__ = ()

linkd/conditions.py

+11-1
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import types
2727
import typing as t
2828

29+
from linkd import compose
2930
from linkd import exceptions
3031
from linkd import utils as di_utils
3132

@@ -47,7 +48,10 @@ class BaseCondition(abc.ABC):
4748

4849
def __init__(self, inner: type[t.Any] | types.UnionType | tuple[t.Any, ...] | None) -> None:
4950
if isinstance(inner, tuple) or inner is None or t.get_origin(inner) in (types.UnionType, t.Union):
50-
raise SyntaxError("{self.__class__.__name__!r} can only be parameterized by concrete types")
51+
raise SyntaxError(f"{self.__class__.__name__!r} can only be parameterized by concrete types")
52+
53+
if compose._is_compose_class(inner):
54+
raise ValueError(f"{self.__class__.__name__!r} cannot be parameterized by composed types")
5155

5256
self.inner: type[t.Any] = inner # type: ignore[reportAttributeAccessIssue]
5357
self.inner_id: str = di_utils.get_dependency_id(inner)
@@ -206,6 +210,9 @@ def create(cls, expr: t.Any, /) -> DependencyExpression[t.Any]:
206210
Returns:
207211
The created dependency expression.
208212
"""
213+
if compose._is_compose_class(expr):
214+
raise ValueError("cannot create a dependency expression from a composed type")
215+
209216
requested_dependencies: list[BaseCondition] = []
210217
required: bool = True
211218

@@ -216,6 +223,9 @@ def create(cls, expr: t.Any, /) -> DependencyExpression[t.Any]:
216223
args = expr.order
217224

218225
for arg in args:
226+
if compose._is_compose_class(arg):
227+
raise ValueError("composed types cannot be used within dependency expressions")
228+
219229
if arg is types.NoneType or arg is None:
220230
required = False
221231
continue

linkd/solver.py

+64-18
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
from collections.abc import Mapping
3939
from collections.abc import Sequence
4040

41+
from linkd import compose
4142
from linkd import conditions
4243
from linkd import container
4344
from linkd import context as context_
@@ -58,12 +59,14 @@
5859
R = t.TypeVar("R")
5960
T = t.TypeVar("T")
6061
AsyncFnT = t.TypeVar("AsyncFnT", bound=t.Callable[..., Coroutine[t.Any, t.Any, t.Any]])
62+
DependencyExprOrComposed: t.TypeAlias = t.Union[conditions.DependencyExpression[t.Any], type[compose.Compose]]
63+
64+
LOGGER = logging.getLogger(__name__)
6165

6266
DI_ENABLED: t.Final[bool] = os.environ.get("LINKD_DI_DISABLED", "false").lower() != "true"
6367
DI_CONTAINER: contextvars.ContextVar[container.Container | None] = contextvars.ContextVar(
6468
"linkd_container", default=None
6569
)
66-
LOGGER = logging.getLogger(__name__)
6770

6871
INJECTED: t.Final[t.Any] = utils.Marker("INJECTED")
6972
"""
@@ -288,17 +291,39 @@ async def close(self) -> None:
288291
CANNOT_INJECT: t.Final[t.Any] = utils.Marker("CANNOT_INJECT")
289292

290293

291-
def _parse_injectable_params(func: Callable[..., t.Any]) -> tuple[list[tuple[str, t.Any]], dict[str, t.Any]]:
292-
positional_or_keyword_params: list[tuple[str, t.Any]] = []
293-
keyword_only_params: dict[str, t.Any] = {}
294+
def _parse_composed_dependencies(cls: type[compose.Compose]) -> dict[str, conditions.DependencyExpression[t.Any]]:
295+
if (existing := getattr(cls, compose._DEPS_ATTR, None)) is not None:
296+
return existing
297+
298+
actual_class = getattr(cls, compose._ACTUAL_ATTR, None)
299+
if actual_class is None:
300+
raise TypeError(f"class {cls} is not a composed dependency")
301+
302+
actual_class = t.cast("type[t.Any]", actual_class)
303+
hints = t.get_type_hints(
304+
actual_class, localns={m: sys.modules[m] for m in utils.ANNOTATION_PARSE_LOCAL_INCLUDE_MODULES}
305+
)
306+
return {
307+
name: conditions.DependencyExpression.create(annotation)
308+
for name, annotation in hints.items()
309+
if name in getattr(cls, "__slots__")
310+
}
311+
312+
313+
def _parse_injectable_params(
314+
func: Callable[..., t.Any],
315+
) -> tuple[list[tuple[str, DependencyExprOrComposed]], dict[str, DependencyExprOrComposed]]:
316+
positional_or_keyword_params: list[tuple[str, DependencyExprOrComposed]] = []
317+
keyword_only_params: dict[str, DependencyExprOrComposed] = {}
294318

295319
parameters = inspect.signature(
296320
func, locals={m: sys.modules[m] for m in utils.ANNOTATION_PARSE_LOCAL_INCLUDE_MODULES}, eval_str=True
297321
).parameters
298322
for parameter in parameters.values():
323+
annotation = parameter.annotation
299324
if (
300325
# If the parameter has no annotation
301-
parameter.annotation is inspect.Parameter.empty
326+
annotation is inspect.Parameter.empty
302327
# If the parameter is not positional-or-keyword or keyword-only
303328
or parameter.kind
304329
in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD)
@@ -309,12 +334,17 @@ def _parse_injectable_params(func: Callable[..., t.Any]) -> tuple[list[tuple[str
309334
positional_or_keyword_params.append((parameter.name, CANNOT_INJECT))
310335
continue
311336

312-
expr = conditions.DependencyExpression.create(parameter.annotation)
337+
if compose._is_compose_class(annotation):
338+
setattr(annotation, compose._DEPS_ATTR, _parse_composed_dependencies(annotation))
339+
340+
item = (
341+
annotation if compose._is_compose_class(annotation) else conditions.DependencyExpression.create(annotation)
342+
)
313343
if parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD:
314-
positional_or_keyword_params.append((parameter.name, expr))
344+
positional_or_keyword_params.append((parameter.name, item))
315345
else:
316346
# It has to be a keyword-only parameter
317-
keyword_only_params[parameter.name] = expr
347+
keyword_only_params[parameter.name] = item
318348

319349
return positional_or_keyword_params, keyword_only_params
320350

@@ -378,7 +408,7 @@ def _codegen_dependency_func(
378408
) -> DependencyResolverFunctionT:
379409
pos_or_kw, kw_only = _parse_injectable_params(self._func)
380410

381-
exec_globals: dict[str, conditions.DependencyExpression[t.Any]] = {}
411+
exec_globals: dict[str, DependencyExprOrComposed] = {}
382412

383413
def gen_random_name() -> str:
384414
while True:
@@ -389,30 +419,46 @@ def gen_random_name() -> str:
389419
# this can never happen but pycharm is being stupid
390420
return ""
391421

422+
def resolver(dependency: DependencyExprOrComposed, refname: str) -> t.Any:
423+
if not compose._is_compose_class(dependency):
424+
return f"await {refname}.resolve(container)"
425+
426+
init_params: list[str] = []
427+
subdeps = t.cast(
428+
"dict[str, conditions.DependencyExpression[t.Any]]", getattr(dependency, compose._DEPS_ATTR)
429+
)
430+
for subdep_name, subdep in subdeps.items():
431+
exec_globals[ident := gen_random_name()] = subdep
432+
init_params.append(f"{subdep_name}=await {ident}.resolve(container)")
433+
434+
return f"{refname}({','.join(init_params)})"
435+
392436
fn_lines = ["arglen = len(args)", "new_kwargs = {}; new_kwargs.update(kwargs)"]
393437

394438
for i, tup in enumerate(pos_or_kw):
395-
name, type_expr = tup
396-
if type_expr is CANNOT_INJECT:
439+
name, dep = tup
440+
if dep is CANNOT_INJECT:
397441
continue
398442

399-
exec_globals[n := gen_random_name()] = type_expr
443+
exec_globals[n := gen_random_name()] = dep
444+
400445
fn_lines.append(
401-
f"if '{name}' not in new_kwargs and arglen < ({i + 1} - offset): new_kwargs['{name}'] = await {n}.resolve(container)" # noqa: E501
446+
f"if '{name}' not in new_kwargs and arglen < ({i + 1} - offset): new_kwargs['{name}'] = {resolver(dep, n)}" # noqa: E501
402447
)
403448

404-
for name, type_expr in kw_only.items():
405-
if type_expr is CANNOT_INJECT:
449+
for name, dep in kw_only.items():
450+
if dep is CANNOT_INJECT:
406451
continue
407452

408-
exec_globals[n := gen_random_name()] = type_expr
409-
fn_lines.append(f"if '{name}' not in new_kwargs: new_kwargs['{name}'] = await {n}.resolve(container)")
453+
exec_globals[n := gen_random_name()] = dep
454+
fn_lines.append(f"if '{name}' not in new_kwargs: new_kwargs['{name}'] = {resolver(dep, n)}")
410455

411456
fn_lines.append("return new_kwargs")
412457

413-
fn = "async def resolve_dependencies(container, offset, args, kwargs):\n" + "\n".join(
458+
fn = "async def resolve_dependencies(container,offset,args,kwargs):\n" + "\n".join(
414459
textwrap.indent(line, " ") for line in fn_lines
415460
)
461+
416462
exec(fn, exec_globals, (generated_locals := {}))
417463
return generated_locals["resolve_dependencies"] # type: ignore[reportReturnType]
418464

pyproject.toml

+5
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,11 @@ convention = "google"
154154
strict-imports = true
155155
require-superclass = true
156156
require-subclass = true
157+
exclude-classes = """
158+
(
159+
^linkd\\.compose:ComposeMeta$
160+
)
161+
"""
157162

158163
[tool.pyright]
159164
include = ["linkd", "examples", "tests"]

tests/test_solver.py

+4
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,15 @@ def m(foo: str, bar: int = linkd.INJECTED, *, baz: float, bork: bool = linkd.INJ
6969
pos, kw = _parse_injectable_params(m)
7070

7171
assert len(pos) == 2
72+
assert isinstance(pos[0][1], linkd.DependencyExpression)
7273
assert pos[0][1]._order[0].inner is str and pos[0][1]._required
74+
assert isinstance(pos[1][1], linkd.DependencyExpression)
7375
assert pos[1][1]._order[0].inner is int and pos[1][1]._required
7476

7577
assert len(kw) == 2
78+
assert isinstance(kw["baz"], linkd.DependencyExpression)
7679
assert kw["baz"]._order[0].inner is float and kw["baz"]._required
80+
assert isinstance(kw["bork"], linkd.DependencyExpression)
7781
assert kw["bork"]._order[0].inner is bool and kw["bork"]._required
7882

7983

0 commit comments

Comments
 (0)