fastapi-injected 0.3.4__tar.gz → 0.3.5__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.

Potentially problematic release.


This version of fastapi-injected might be problematic. Click here for more details.

Files changed (65) hide show
  1. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/PKG-INFO +3 -3
  2. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/README.md +2 -2
  3. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/fastapi_injected/_bind.py +3 -2
  4. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/fastapi_injected/_cache.py +17 -5
  5. fastapi_injected-0.3.5/fastapi_injected/_calls.py +192 -0
  6. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/fastapi_injected/_given.py +3 -2
  7. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/fastapi_injected/deps.py +49 -22
  8. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/fastapi_injected/resolve.py +7 -11
  9. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/pyproject.toml +1 -1
  10. fastapi_injected-0.3.5/tests/test_calls.py +117 -0
  11. fastapi_injected-0.3.5/tests/test_dependant_cache.py +249 -0
  12. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/uv.lock +30 -30
  13. fastapi_injected-0.3.4/tests/test_dependant_cache.py +0 -92
  14. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/.github/dependabot.yml +0 -0
  15. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/.github/workflows/automerge.yml +0 -0
  16. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/.github/workflows/lint.yml +0 -0
  17. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/.github/workflows/publish.yml +0 -0
  18. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/.github/workflows/test.yml +0 -0
  19. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/.gitignore +0 -0
  20. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/.pre-commit-config.yaml +0 -0
  21. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/LICENSE +0 -0
  22. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/fastapi_injected/__init__.py +0 -0
  23. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/fastapi_injected/_dataclass.py +0 -0
  24. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/fastapi_injected/_deps_tp.py +0 -0
  25. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/fastapi_injected/_fastapi_lifecycle.py +0 -0
  26. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/fastapi_injected/_injected.py +0 -0
  27. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/fastapi_injected/_overrides.py +0 -0
  28. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/fastapi_injected/_rlock.py +0 -0
  29. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/fastapi_injected/inject.py +0 -0
  30. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/fastapi_injected/overrides.py +0 -0
  31. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/fastapi_injected/scope.py +0 -0
  32. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/fastapi_injected/sign.py +0 -0
  33. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/fastapi_injected/types.py +0 -0
  34. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/tests/__init__.py +0 -0
  35. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/tests/_typing/__init__.py +0 -0
  36. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/tests/_typing/annotations.py +0 -0
  37. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/tests/_typing/arg.py +0 -0
  38. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/tests/_typing/bind.py +0 -0
  39. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/tests/_typing/dataclass.py +0 -0
  40. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/tests/_typing/deps.py +0 -0
  41. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/tests/_typing/errors.py +0 -0
  42. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/tests/_typing/given.py +0 -0
  43. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/tests/_typing/inject.py +0 -0
  44. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/tests/_typing/injected.py +0 -0
  45. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/tests/_typing/integration.py +0 -0
  46. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/tests/_typing/markers.py +0 -0
  47. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/tests/_typing/overrides.py +0 -0
  48. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/tests/_typing/resolve.py +0 -0
  49. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/tests/_typing/scope.py +0 -0
  50. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/tests/deps.py +0 -0
  51. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/tests/ext/__init__.py +0 -0
  52. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/tests/ext/test_pydantic_ai.py +0 -0
  53. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/tests/test_bind.py +0 -0
  54. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/tests/test_cache.py +0 -0
  55. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/tests/test_concurrency.py +0 -0
  56. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/tests/test_dataclass.py +0 -0
  57. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/tests/test_errors.py +0 -0
  58. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/tests/test_fastapi.py +0 -0
  59. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/tests/test_given.py +0 -0
  60. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/tests/test_inject.py +0 -0
  61. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/tests/test_injected.py +0 -0
  62. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/tests/test_overrides.py +0 -0
  63. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/tests/test_resolve.py +0 -0
  64. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/tests/test_rlock.py +0 -0
  65. {fastapi_injected-0.3.4 → fastapi_injected-0.3.5}/tests/test_scope.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: fastapi-injected
3
- Version: 0.3.4
3
+ Version: 0.3.5
4
4
  Summary: Yet another library to reuse fastapi dependency injection
5
5
  Project-URL: Repository, https://github.com/uriyyo/fastapi-injected
6
6
  Author-email: Yurii Karabas <1998uriyyo@gmail.com>
@@ -164,7 +164,7 @@ async with push_inject_scope(app=app):
164
164
 
165
165
  Without it, reading `request.app` raises a `KeyError` naming what is missing rather than a bare `'app'`.
166
166
 
167
- Analysing a dependency is the expensive part of resolving one, so the result is cached — keyed by the dependency itself, not by whatever object carried it, so nothing that only passed through is kept alive. `clear_dependant_cache()` drops it, for long-lived processes and test suites that want the memory back.
167
+ Analysing a dependency is the expensive part of resolving one, so the result is cached — keyed by the dependency itself, not by whatever object carried it, so nothing that only passed through is kept alive. Only dependencies written in source code are cached this way: functions and classes, and annotations built from them. One made at runtime — `Given(...)`, a `MakeInjected` or `bind_deps` result, an object that brings its own `Depends` — is analysed again on every resolve, and FastAPI never gets to keep it: what it carries is released with the resolve instead of living as long as the process. `clear_dependant_cache()` drops the cache, for long-lived processes and test suites that want the memory back.
168
168
 
169
169
  ### Overriding dependencies
170
170
 
@@ -227,7 +227,7 @@ async with push_inject_scope({Session: fake}) as scope:
227
227
 
228
228
  ### Dependencies that are objects
229
229
 
230
- Dependants are cached by the callable that resolves them, so a dependency that is an object — a class holding configuration, a parametrized resolver — has to be hashable to get there. A plain dataclass is not, and a frozen one still refuses as soon as it holds a list or a dict.
230
+ Resolved values are cached in their scope by the callable that resolves them, so a dependency that is an object — a class holding configuration, a parametrized resolver — has to be hashable to get there. A plain dataclass is not, and a frozen one still refuses as soon as it holds a list or a dict.
231
231
 
232
232
  `MakeDataclass` is a base class that makes its subclasses dataclasses with a hash that always answers: by fields when they can be hashed, by identity when they cannot.
233
233
 
@@ -143,7 +143,7 @@ async with push_inject_scope(app=app):
143
143
 
144
144
  Without it, reading `request.app` raises a `KeyError` naming what is missing rather than a bare `'app'`.
145
145
 
146
- Analysing a dependency is the expensive part of resolving one, so the result is cached — keyed by the dependency itself, not by whatever object carried it, so nothing that only passed through is kept alive. `clear_dependant_cache()` drops it, for long-lived processes and test suites that want the memory back.
146
+ Analysing a dependency is the expensive part of resolving one, so the result is cached — keyed by the dependency itself, not by whatever object carried it, so nothing that only passed through is kept alive. Only dependencies written in source code are cached this way: functions and classes, and annotations built from them. One made at runtime — `Given(...)`, a `MakeInjected` or `bind_deps` result, an object that brings its own `Depends` — is analysed again on every resolve, and FastAPI never gets to keep it: what it carries is released with the resolve instead of living as long as the process. `clear_dependant_cache()` drops the cache, for long-lived processes and test suites that want the memory back.
147
147
 
148
148
  ### Overriding dependencies
149
149
 
@@ -206,7 +206,7 @@ async with push_inject_scope({Session: fake}) as scope:
206
206
 
207
207
  ### Dependencies that are objects
208
208
 
209
- Dependants are cached by the callable that resolves them, so a dependency that is an object — a class holding configuration, a parametrized resolver — has to be hashable to get there. A plain dataclass is not, and a frozen one still refuses as soon as it holds a list or a dict.
209
+ Resolved values are cached in their scope by the callable that resolves them, so a dependency that is an object — a class holding configuration, a parametrized resolver — has to be hashable to get there. A plain dataclass is not, and a frozen one still refuses as soon as it holds a list or a dict.
210
210
 
211
211
  `MakeDataclass` is a base class that makes its subclasses dataclasses with a hash that always answers: by fields when they can be hashed, by identity when they cannot.
212
212
 
@@ -6,6 +6,7 @@ from typing import Annotated, Any, Concatenate, get_origin, overload
6
6
  from fastapi.dependencies.utils import get_typed_signature
7
7
  from fastapi.params import Depends
8
8
 
9
+ from ._calls import annotated
9
10
  from ._deps_tp import unwrap_tp
10
11
  from .sign import update_func_sign
11
12
  from .types import AsyncFunc, DepOf, Func
@@ -39,12 +40,12 @@ def _is_annotation(tp: Any, /) -> bool:
39
40
 
40
41
  def _dep_annotation(dep: DepOf[Any] | Callable[..., Any], /) -> Any:
41
42
  if isinstance(dep, Depends):
42
- return Annotated[Any, dep]
43
+ return annotated(Any, dep)
43
44
 
44
45
  if _is_annotation(dep) or not callable(dep):
45
46
  return dep
46
47
 
47
- return Annotated[Any, Depends(dep)]
48
+ return annotated(Any, Depends(dep))
48
49
 
49
50
 
50
51
  def signature_with_deps(
@@ -5,6 +5,7 @@ from typing import Any
5
5
  from fastapi.dependencies.models import Dependant
6
6
  from fastapi.types import DependencyCacheKey
7
7
 
8
+ from ._calls import unwrap_proxy
8
9
  from ._dataclass import MakeDataclass
9
10
  from .types import DependencyCache
10
11
 
@@ -16,13 +17,14 @@ def overridden_calls(dependant: Dependant, overrides: Collection[Any], /) -> fro
16
17
  overridden: set[Any] = set()
17
18
 
18
19
  def _visit(dep: Dependant, /) -> bool:
19
- is_overridden = dep.call is not None and dep.call in overrides
20
+ call = unwrap_proxy(dep.call)
21
+ is_overridden = call is not None and call in overrides
20
22
 
21
23
  for sub_dep in dep.dependencies:
22
24
  is_overridden = _visit(sub_dep) or is_overridden
23
25
 
24
- if is_overridden and dep.call is not None:
25
- overridden.add(dep.call)
26
+ if is_overridden and call is not None:
27
+ overridden.add(call)
26
28
 
27
29
  return is_overridden
28
30
 
@@ -30,6 +32,14 @@ def overridden_calls(dependant: Dependant, overrides: Collection[Any], /) -> fro
30
32
  return frozenset(overridden)
31
33
 
32
34
 
35
+ def _cache_key(key: DependencyCacheKey, /) -> DependencyCacheKey:
36
+ # kept under the call a proxy stands for: the proxy is released once its resolve is
37
+ # done, the entry has to stay reachable for the rest of the scope
38
+ call, *rest = key
39
+
40
+ return (unwrap_proxy(call), *rest) # type: ignore[ty:invalid-return-type]
41
+
42
+
33
43
  class ScopeCache(MakeDataclass, MutableMapping[DependencyCacheKey, Any]):
34
44
  cache: DependencyCache
35
45
  fallbacks: Sequence[tuple[Mapping[DependencyCacheKey, Any], frozenset[Any]]] = field(default_factory=tuple)
@@ -42,6 +52,8 @@ class ScopeCache(MakeDataclass, MutableMapping[DependencyCacheKey, Any]):
42
52
  yield cache
43
53
 
44
54
  def __getitem__(self, key: DependencyCacheKey) -> Any:
55
+ key = _cache_key(key)
56
+
45
57
  try:
46
58
  return self.cache[key]
47
59
  except KeyError:
@@ -56,10 +68,10 @@ class ScopeCache(MakeDataclass, MutableMapping[DependencyCacheKey, Any]):
56
68
  raise KeyError(key)
57
69
 
58
70
  def __setitem__(self, key: DependencyCacheKey, value: Any) -> None:
59
- self.cache[key] = value
71
+ self.cache[_cache_key(key)] = value
60
72
 
61
73
  def __delitem__(self, key: DependencyCacheKey) -> None:
62
- del self.cache[key]
74
+ del self.cache[_cache_key(key)]
63
75
 
64
76
  def __iter__(self) -> Iterator[DependencyCacheKey]:
65
77
  seen = set(self.cache)
@@ -0,0 +1,192 @@
1
+ import dataclasses
2
+ import inspect
3
+ import typing
4
+ from collections.abc import Callable, Iterator, Mapping
5
+ from typing import Any, get_args, get_origin
6
+
7
+ from fastapi.dependencies.utils import get_typed_signature
8
+ from fastapi.params import Depends
9
+
10
+ from ._deps_tp import unwrap_tp
11
+
12
+
13
+ def annotated(tp: Any, /, *metadata: Any) -> Any:
14
+ # `Annotated[...]` goes through typing's own cache, which holds on to the last
15
+ # aliases it built - and with them whatever value a runtime marker carries
16
+ return typing._AnnotatedAlias(tp, metadata) # type: ignore[ty:unresolved-attribute] # noqa: SLF001
17
+
18
+
19
+ def is_static_call(call: Any, /) -> bool:
20
+ # what source code wrote down lives as long as the process anyway, so caching
21
+ # by it keeps nothing extra alive - an instance or a closure may carry request data
22
+ if inspect.isclass(call):
23
+ return True
24
+
25
+ # a builtin bound to an instance, like `cache.get`, carries that instance along
26
+ if inspect.isbuiltin(call):
27
+ return call.__self__ is None or inspect.ismodule(call.__self__)
28
+
29
+ # `__qualname__` is what `functools.wraps` copied over, the code knows where it was defined
30
+ if inspect.isfunction(call):
31
+ return "<locals>" not in call.__code__.co_qualname
32
+
33
+ return False
34
+
35
+
36
+ def _depends_metadata(annotation: Any, /) -> tuple[Any, list[Any]] | None:
37
+ annotation = unwrap_tp(annotation)
38
+
39
+ if get_origin(annotation) is not typing.Annotated:
40
+ return None
41
+
42
+ tp, *metadata = get_args(annotation)
43
+
44
+ return tp, metadata
45
+
46
+
47
+ def is_static_annotation(annotation: Any, /) -> bool:
48
+ match _depends_metadata(annotation):
49
+ case (tp, metadata):
50
+ return all(
51
+ is_static_call(tp if meta.dependency is None else meta.dependency)
52
+ for meta in metadata
53
+ if isinstance(meta, Depends)
54
+ )
55
+ case _:
56
+ return False
57
+
58
+
59
+ class CallProxy:
60
+ # what FastAPI is handed in place of a call made at runtime: its classification
61
+ # caches keep every call they are shown, and once released this is all they keep
62
+ __slots__ = ("_hash", "_proxies", "_signature", "call")
63
+
64
+ def __init__(self, call: Callable[..., Any], proxies: list["CallProxy"], /) -> None:
65
+ self.call: Callable[..., Any] | None = call
66
+ self._proxies = proxies
67
+ self._signature: inspect.Signature | None = None
68
+
69
+ try:
70
+ self._hash = hash(call)
71
+ except TypeError:
72
+ self._hash = id(call)
73
+
74
+ proxies.append(self)
75
+
76
+ @property
77
+ def __wrapped__(self) -> Callable[..., Any]:
78
+ # FastAPI unwraps a call to tell a coroutine from a generator, so it sees the
79
+ # real one - a released proxy has nothing left to show
80
+ if self.call is None:
81
+ raise AttributeError("__wrapped__")
82
+
83
+ return self.call
84
+
85
+ @property
86
+ def __signature__(self) -> inspect.Signature:
87
+ # FastAPI builds the dependencies of this call from its signature, so whatever
88
+ # was made at runtime among them is handed over behind a proxy as well
89
+ if self._signature is None:
90
+ sign = get_typed_signature(self.__wrapped__)
91
+ self._signature = sign.replace(
92
+ parameters=[self._detach_param(param) for param in sign.parameters.values()],
93
+ )
94
+
95
+ return self._signature
96
+
97
+ def __call__(self, *args: Any, **kwargs: Any) -> Any:
98
+ return self.__wrapped__(*args, **kwargs)
99
+
100
+ def __hash__(self) -> int:
101
+ return self._hash
102
+
103
+ def __eq__(self, other: object) -> bool:
104
+ # equal to the call it stands for, so it finds the same cache entries and overrides
105
+ if self.call is None:
106
+ return other is self
107
+
108
+ return self.call == unwrap_proxy(other)
109
+
110
+ def release(self) -> None:
111
+ self.call = None
112
+ self._signature = None
113
+ self._proxies = []
114
+
115
+ def _detach_depends(self, depends: Depends, /) -> Depends:
116
+ if depends.dependency is None or is_static_call(depends.dependency):
117
+ return depends
118
+
119
+ return dataclasses.replace(depends, dependency=CallProxy(depends.dependency, self._proxies))
120
+
121
+ def _detach_param(self, param: inspect.Parameter, /) -> inspect.Parameter:
122
+ if isinstance(param.default, Depends):
123
+ param = param.replace(default=self._detach_depends(param.default))
124
+
125
+ match _depends_metadata(param.annotation):
126
+ case (tp, metadata):
127
+ detached = [self._detach_depends(meta) if isinstance(meta, Depends) else meta for meta in metadata]
128
+
129
+ if any(new is not old for new, old in zip(detached, metadata, strict=True)):
130
+ param = param.replace(annotation=annotated(tp, *detached))
131
+
132
+ return param
133
+
134
+
135
+ class DetachedOverrides(Mapping[Any, Any]):
136
+ # an override replaces a call inside FastAPI, past where a proxy could be put on it,
137
+ # so the overrides of a resolve hand out what they hold behind one
138
+ __slots__ = ("_handed", "_overrides", "_proxies")
139
+
140
+ def __init__(self, overrides: Mapping[Any, Any], proxies: list[CallProxy], /) -> None:
141
+ self._overrides = overrides
142
+ self._proxies = proxies
143
+ self._handed: dict[int, CallProxy] = {}
144
+
145
+ @property
146
+ def dependency_overrides(self) -> Mapping[Any, Any]:
147
+ # FastAPI asks a provider for its overrides, and this is both
148
+ return self
149
+
150
+ def __getitem__(self, key: Any) -> Any:
151
+ override = self._overrides[key]
152
+
153
+ if is_static_call(override):
154
+ return override
155
+
156
+ if (proxy := self._handed.get(id(override))) is None:
157
+ proxy = self._handed[id(override)] = CallProxy(override, self._proxies)
158
+
159
+ return proxy
160
+
161
+ def __iter__(self) -> Iterator[Any]:
162
+ return iter(self._overrides)
163
+
164
+ def __len__(self) -> int:
165
+ return len(self._overrides)
166
+
167
+ def __bool__(self) -> bool:
168
+ # FastAPI takes the override path only when there are some, and asks at every step
169
+ return bool(self._overrides)
170
+
171
+
172
+ def release(proxies: list[CallProxy], /) -> None:
173
+ for proxy in proxies:
174
+ proxy.release()
175
+
176
+
177
+ def unwrap_proxy(call: Any, /) -> Any:
178
+ if isinstance(call, CallProxy) and call.call is not None:
179
+ return call.call
180
+
181
+ return call
182
+
183
+
184
+ __all__ = [
185
+ "CallProxy",
186
+ "DetachedOverrides",
187
+ "annotated",
188
+ "is_static_annotation",
189
+ "is_static_call",
190
+ "release",
191
+ "unwrap_proxy",
192
+ ]
@@ -1,7 +1,8 @@
1
- from typing import Annotated, Any, cast
1
+ from typing import Any, cast
2
2
 
3
3
  from fastapi import Depends
4
4
 
5
+ from ._calls import annotated
5
6
  from ._dataclass import MakeDataclass
6
7
  from .types import DepOf
7
8
 
@@ -16,7 +17,7 @@ class _Constant[R](MakeDataclass):
16
17
  def Given[R](value: R, /) -> DepOf[R]: # noqa: N802
17
18
  # a dependency that was already resolved by whoever built it - constants that hold
18
19
  # the same value are the same dependency, so they are cached and overridden alike
19
- return cast("DepOf[R]", Annotated[Any, Depends(_Constant(value))])
20
+ return cast("DepOf[R]", annotated(Any, Depends(_Constant(value))))
20
21
 
21
22
 
22
23
  __all__ = [
@@ -1,14 +1,15 @@
1
1
  import inspect
2
- from collections.abc import Callable
3
- from contextlib import AsyncExitStack
2
+ from collections.abc import Callable, Iterator
3
+ from contextlib import AsyncExitStack, contextmanager
4
4
  from functools import lru_cache, wraps
5
- from typing import Annotated, Any, Literal, Protocol, cast, overload, runtime_checkable
5
+ from typing import Any, Literal, Protocol, cast, overload, runtime_checkable
6
6
 
7
7
  from fastapi import Depends, params
8
8
  from fastapi.dependencies.models import Dependant
9
9
  from fastapi.dependencies.utils import get_dependant, get_typed_signature, solve_dependencies
10
10
  from fastapi.exceptions import RequestValidationError
11
11
 
12
+ from ._calls import CallProxy, DetachedOverrides, annotated, is_static_annotation, release
12
13
  from ._deps_tp import is_dep, unwrap_tp
13
14
  from .scope import InjectScope
14
15
  from .sign import prepare_sign, update_func_sign
@@ -55,25 +56,41 @@ def create_dependant[**P, R](func: Callable[P, Coro[R]], /) -> Dependant:
55
56
  )
56
57
 
57
58
 
58
- def create_single_dependant[**P, R](
59
+ @contextmanager
60
+ def single_dependant[**P, R](
59
61
  func: Callable[P, R] | HasDependsHook[P, R],
60
62
  /,
61
63
  *,
62
64
  path: str | None = None,
63
- ) -> Dependant:
65
+ ) -> Iterator[Dependant]:
64
66
  match func:
65
67
  case _ if is_dep(func):
66
68
  annotation = unwrap_tp(func)
67
69
  case HasDependsHook():
68
- annotation = Annotated[Any, func.__get_depends__()]
70
+ annotation = annotated(Any, func.__get_depends__())
69
71
  case _:
70
- annotation = Annotated[Any, Depends(func)]
72
+ annotation = annotated(Any, Depends(func))
71
73
 
72
- return _create_annotation_dependant(annotation, path=path)
74
+ if is_static_annotation(annotation):
75
+ yield _create_annotation_dependant(annotation, path=path)
76
+ return
73
77
 
78
+ # a dependency made at runtime is analysed again on every resolve, and FastAPI only
79
+ # ever sees its calls behind proxies released here - caching either would keep
80
+ # whatever it carries alive past the request
81
+ proxies: list[CallProxy] = []
82
+ dependant = get_dependant(
83
+ path=path or "",
84
+ call=CallProxy(_value_factory(annotation), proxies),
85
+ )
74
86
 
75
- @lru_cache(maxsize=1024)
76
- def _create_annotation_dependant(annotation: Any, /, *, path: str | None = None) -> Dependant:
87
+ try:
88
+ yield dependant
89
+ finally:
90
+ release(proxies)
91
+
92
+
93
+ def _value_factory(annotation: Any, /) -> Callable[..., Coro[Any]]:
77
94
  async def _factory(__value__: Any) -> Any:
78
95
  return __value__
79
96
 
@@ -88,9 +105,14 @@ def _create_annotation_dependant(annotation: Any, /, *, path: str | None = None)
88
105
  return_annotation=Any,
89
106
  )
90
107
 
108
+ return _factory
109
+
110
+
111
+ @lru_cache(maxsize=1024)
112
+ def _create_annotation_dependant(annotation: Any, /, *, path: str | None = None) -> Dependant:
91
113
  return get_dependant(
92
114
  path=path or "",
93
- call=_factory,
115
+ call=_value_factory(annotation),
94
116
  )
95
117
 
96
118
 
@@ -122,16 +144,21 @@ async def resolve_dependencies(
122
144
  *,
123
145
  single: bool = False,
124
146
  ) -> dict[str, Any]:
125
- async with scope.lock:
126
- solved = await solve_dependencies(
127
- request=scope.bound_request,
128
- dependant=dependant,
129
- dependency_cache=cast("DependencyCache", scope.cache_for(dependant)),
130
- dependency_overrides_provider=scope,
131
- # this parameter is deprecated and not used
132
- async_exit_stack=cast(AsyncExitStack, None),
133
- embed_body_fields=False,
134
- )
147
+ proxies: list[CallProxy] = []
148
+
149
+ try:
150
+ async with scope.lock:
151
+ solved = await solve_dependencies(
152
+ request=scope.bound_request,
153
+ dependant=dependant,
154
+ dependency_cache=cast("DependencyCache", scope.cache_for(dependant)),
155
+ dependency_overrides_provider=DetachedOverrides(scope.dependency_overrides, proxies),
156
+ # this parameter is deprecated and not used
157
+ async_exit_stack=cast(AsyncExitStack, None),
158
+ embed_body_fields=False,
159
+ )
160
+ finally:
161
+ release(proxies)
135
162
 
136
163
  if solved.errors:
137
164
  raise DependencyResolutionError(solved.errors)
@@ -151,6 +178,6 @@ __all__ = [
151
178
  "MissedDependencyError",
152
179
  "clear_dependant_cache",
153
180
  "create_dependant",
154
- "create_single_dependant",
155
181
  "resolve_dependencies",
182
+ "single_dependant",
156
183
  ]
@@ -3,7 +3,7 @@ from typing import Any, overload
3
3
 
4
4
  from typing_extensions import TypeForm
5
5
 
6
- from .deps import HasDependsHook, create_single_dependant, resolve_dependencies
6
+ from .deps import HasDependsHook, resolve_dependencies, single_dependant
7
7
  from .scope import inside_inject_scope
8
8
  from .types import DepDecl, DepShape
9
9
 
@@ -50,16 +50,12 @@ async def resolve(
50
50
  async with inside_inject_scope(
51
51
  new_scope=new_scope,
52
52
  ) as inject_scope:
53
- dependant = create_single_dependant(
54
- tp,
55
- path=inject_scope.path_format,
56
- )
57
-
58
- return await resolve_dependencies(
59
- dependant,
60
- inject_scope,
61
- single=True,
62
- )
53
+ with single_dependant(tp, path=inject_scope.path_format) as dependant:
54
+ return await resolve_dependencies(
55
+ dependant,
56
+ inject_scope,
57
+ single=True,
58
+ )
63
59
 
64
60
 
65
61
  __all__ = [
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fastapi-injected"
3
- version = "0.3.4"
3
+ version = "0.3.5"
4
4
  description = "Yet another library to reuse fastapi dependency injection"
5
5
  authors = [
6
6
  { name = "Yurii Karabas", email = "1998uriyyo@gmail.com" },
@@ -0,0 +1,117 @@
1
+ import inspect
2
+ from functools import wraps
3
+ from typing import Annotated, Any
4
+
5
+ import pytest
6
+ from fastapi import Depends
7
+
8
+ from fastapi_injected import push_inject_scope, resolve
9
+ from fastapi_injected._calls import CallProxy, DetachedOverrides, is_static_annotation, is_static_call
10
+
11
+ pytestmark = pytest.mark.asyncio
12
+
13
+
14
+ def module_level() -> int:
15
+ return 1
16
+
17
+
18
+ def _closure() -> Any:
19
+ def inner() -> int: # pragma: no cover
20
+ return 1
21
+
22
+ return inner
23
+
24
+
25
+ def _wrapped() -> Any:
26
+ @wraps(module_level)
27
+ def wrapper() -> int: # pragma: no cover
28
+ return 1
29
+
30
+ return wrapper
31
+
32
+
33
+ class Unhashable:
34
+ __hash__ = None # type: ignore[assignment]
35
+
36
+ def __call__(self) -> int:
37
+ return 1
38
+
39
+
40
+ @pytest.mark.parametrize(
41
+ ("call", "expected"),
42
+ [
43
+ pytest.param(module_level, True, id="function"),
44
+ pytest.param(Unhashable, True, id="class"),
45
+ pytest.param(len, True, id="builtin"),
46
+ pytest.param(_closure(), False, id="closure"),
47
+ # `functools.wraps` copies the name of a function written at module level
48
+ pytest.param(_wrapped(), False, id="wrapped-closure"),
49
+ pytest.param({}.get, False, id="bound-builtin"),
50
+ pytest.param(Unhashable(), False, id="instance"),
51
+ ],
52
+ )
53
+ async def test_what_counts_as_written_in_source(call: Any, expected: bool):
54
+ assert is_static_call(call) is expected
55
+
56
+
57
+ async def test_an_annotation_without_metadata_is_not_static():
58
+ assert not is_static_annotation(int)
59
+
60
+
61
+ async def test_a_released_proxy_lets_go_of_its_call():
62
+ proxy = CallProxy(module_level, [])
63
+ proxy.release()
64
+
65
+ assert proxy.call is None
66
+ assert not hasattr(proxy, "__wrapped__")
67
+ assert proxy == proxy # noqa: PLR0124
68
+ assert proxy != module_level
69
+
70
+
71
+ async def test_a_proxy_of_an_unhashable_call_is_hashed_by_identity():
72
+ call = Unhashable()
73
+ proxy = CallProxy(call, [])
74
+
75
+ assert hash(proxy) == id(call)
76
+ assert proxy() == 1
77
+
78
+
79
+ class One:
80
+ def __call__(self) -> int:
81
+ return 1
82
+
83
+
84
+ class Defaulted:
85
+ # a dependency that is an object, written as a default rather than in the annotation
86
+ def __init__(self, value: int) -> None:
87
+ self.value = value
88
+
89
+ async def __call__(self, one: int = Depends(One())) -> int:
90
+ return self.value + one
91
+
92
+
93
+ async def test_a_dependency_written_as_a_default_is_detached_too():
94
+ async with push_inject_scope():
95
+ assert await resolve(Defaulted(41)) == 42
96
+
97
+
98
+ async def test_a_proxy_shows_fastapi_the_signature_it_stands_for():
99
+ async def call(value: Annotated[int, Depends(module_level)]) -> int: # pragma: no cover
100
+ return value
101
+
102
+ # FastAPI reads the parameters only, its own typed signature drops the return annotation
103
+ assert inspect.signature(CallProxy(call, [])).parameters == inspect.signature(call).parameters
104
+
105
+
106
+ async def test_detached_overrides_hand_out_what_was_made_at_runtime_behind_a_proxy():
107
+ value = One()
108
+ proxies: list[CallProxy] = []
109
+ overrides = DetachedOverrides({module_level: value, One: module_level}, proxies)
110
+
111
+ assert overrides.dependency_overrides is overrides
112
+ assert (list(overrides), len(overrides), bool(overrides)) == ([module_level, One], 2, True)
113
+ assert overrides[One] is module_level # written in source, nothing to detach
114
+ assert overrides[module_level] is overrides[module_level]
115
+ assert overrides[module_level] == value
116
+ assert proxies == [value]
117
+ assert not DetachedOverrides({}, [])