fastapi-injected 0.2.2__tar.gz → 0.3.0__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.
Files changed (70) hide show
  1. {fastapi_injected-0.2.2 → fastapi_injected-0.3.0}/.pre-commit-config.yaml +1 -1
  2. fastapi_injected-0.3.0/PKG-INFO +371 -0
  3. fastapi_injected-0.3.0/README.md +350 -0
  4. fastapi_injected-0.3.0/fastapi_injected/__init__.py +57 -0
  5. fastapi_injected-0.3.0/fastapi_injected/_bind.py +180 -0
  6. fastapi_injected-0.3.0/fastapi_injected/_cache.py +83 -0
  7. fastapi_injected-0.3.0/fastapi_injected/_dataclass.py +58 -0
  8. {fastapi_injected-0.2.2 → fastapi_injected-0.3.0}/fastapi_injected/_deps_tp.py +9 -0
  9. {fastapi_injected-0.2.2 → fastapi_injected-0.3.0}/fastapi_injected/_fastapi_lifecycle.py +16 -12
  10. fastapi_injected-0.3.0/fastapi_injected/_given.py +24 -0
  11. fastapi_injected-0.3.0/fastapi_injected/_injected.py +32 -0
  12. fastapi_injected-0.3.0/fastapi_injected/_overrides.py +79 -0
  13. fastapi_injected-0.3.0/fastapi_injected/_rlock.py +63 -0
  14. {fastapi_injected-0.2.2 → fastapi_injected-0.3.0}/fastapi_injected/deps.py +49 -50
  15. {fastapi_injected-0.2.2 → fastapi_injected-0.3.0}/fastapi_injected/inject.py +3 -3
  16. fastapi_injected-0.3.0/fastapi_injected/overrides.py +36 -0
  17. {fastapi_injected-0.2.2 → fastapi_injected-0.3.0}/fastapi_injected/resolve.py +14 -3
  18. fastapi_injected-0.3.0/fastapi_injected/scope.py +275 -0
  19. {fastapi_injected-0.2.2 → fastapi_injected-0.3.0}/fastapi_injected/sign.py +30 -13
  20. {fastapi_injected-0.2.2 → fastapi_injected-0.3.0}/fastapi_injected/types.py +29 -2
  21. {fastapi_injected-0.2.2 → fastapi_injected-0.3.0}/pyproject.toml +1 -1
  22. fastapi_injected-0.3.0/tests/_typing/annotations.py +24 -0
  23. fastapi_injected-0.3.0/tests/_typing/arg.py +60 -0
  24. fastapi_injected-0.3.0/tests/_typing/bind.py +105 -0
  25. fastapi_injected-0.3.0/tests/_typing/dataclass.py +37 -0
  26. fastapi_injected-0.3.0/tests/_typing/deps.py +76 -0
  27. fastapi_injected-0.3.0/tests/_typing/errors.py +28 -0
  28. fastapi_injected-0.3.0/tests/_typing/given.py +33 -0
  29. fastapi_injected-0.3.0/tests/_typing/inject.py +70 -0
  30. fastapi_injected-0.3.0/tests/_typing/injected.py +51 -0
  31. fastapi_injected-0.3.0/tests/_typing/integration.py +37 -0
  32. fastapi_injected-0.3.0/tests/_typing/markers.py +42 -0
  33. fastapi_injected-0.3.0/tests/_typing/overrides.py +62 -0
  34. fastapi_injected-0.3.0/tests/_typing/resolve.py +90 -0
  35. fastapi_injected-0.3.0/tests/_typing/scope.py +54 -0
  36. {fastapi_injected-0.2.2 → fastapi_injected-0.3.0}/tests/deps.py +6 -0
  37. fastapi_injected-0.3.0/tests/ext/__init__.py +0 -0
  38. fastapi_injected-0.3.0/tests/test_bind.py +121 -0
  39. fastapi_injected-0.3.0/tests/test_cache.py +66 -0
  40. fastapi_injected-0.3.0/tests/test_concurrency.py +79 -0
  41. fastapi_injected-0.3.0/tests/test_dataclass.py +71 -0
  42. fastapi_injected-0.3.0/tests/test_dependant_cache.py +92 -0
  43. fastapi_injected-0.3.0/tests/test_errors.py +58 -0
  44. fastapi_injected-0.3.0/tests/test_fastapi.py +213 -0
  45. fastapi_injected-0.3.0/tests/test_given.py +66 -0
  46. fastapi_injected-0.3.0/tests/test_inject.py +195 -0
  47. fastapi_injected-0.3.0/tests/test_injected.py +96 -0
  48. {fastapi_injected-0.2.2 → fastapi_injected-0.3.0}/tests/test_overrides.py +30 -17
  49. {fastapi_injected-0.2.2 → fastapi_injected-0.3.0}/tests/test_resolve.py +57 -0
  50. fastapi_injected-0.3.0/tests/test_rlock.py +107 -0
  51. fastapi_injected-0.3.0/tests/test_scope.py +50 -0
  52. {fastapi_injected-0.2.2 → fastapi_injected-0.3.0}/uv.lock +1 -1
  53. fastapi_injected-0.2.2/PKG-INFO +0 -196
  54. fastapi_injected-0.2.2/README.md +0 -175
  55. fastapi_injected-0.2.2/fastapi_injected/__init__.py +0 -24
  56. fastapi_injected-0.2.2/fastapi_injected/overrides.py +0 -122
  57. fastapi_injected-0.2.2/fastapi_injected/scope.py +0 -117
  58. fastapi_injected-0.2.2/tests/test_fastapi.py +0 -121
  59. fastapi_injected-0.2.2/tests/test_inject.py +0 -101
  60. fastapi_injected-0.2.2/tests/test_typing.py +0 -33
  61. {fastapi_injected-0.2.2 → fastapi_injected-0.3.0}/.github/dependabot.yml +0 -0
  62. {fastapi_injected-0.2.2 → fastapi_injected-0.3.0}/.github/workflows/automerge.yml +0 -0
  63. {fastapi_injected-0.2.2 → fastapi_injected-0.3.0}/.github/workflows/lint.yml +0 -0
  64. {fastapi_injected-0.2.2 → fastapi_injected-0.3.0}/.github/workflows/publish.yml +0 -0
  65. {fastapi_injected-0.2.2 → fastapi_injected-0.3.0}/.github/workflows/test.yml +0 -0
  66. {fastapi_injected-0.2.2 → fastapi_injected-0.3.0}/.gitignore +0 -0
  67. {fastapi_injected-0.2.2 → fastapi_injected-0.3.0}/LICENSE +0 -0
  68. {fastapi_injected-0.2.2 → fastapi_injected-0.3.0}/tests/__init__.py +0 -0
  69. {fastapi_injected-0.2.2/tests/ext → fastapi_injected-0.3.0/tests/_typing}/__init__.py +0 -0
  70. {fastapi_injected-0.2.2 → fastapi_injected-0.3.0}/tests/ext/test_pydantic_ai.py +0 -0
@@ -23,4 +23,4 @@ repos:
23
23
  language: python
24
24
  name: ty
25
25
  pass_filenames: false
26
- entry: uv run ty check fastapi_injected tests/test_typing.py --error-on-warning
26
+ entry: uv run ty check fastapi_injected tests/_typing --error-on-warning
@@ -0,0 +1,371 @@
1
+ Metadata-Version: 2.5
2
+ Name: fastapi-injected
3
+ Version: 0.3.0
4
+ Summary: Yet another library to reuse fastapi dependency injection
5
+ Project-URL: Repository, https://github.com/uriyyo/fastapi-injected
6
+ Author-email: Yurii Karabas <1998uriyyo@gmail.com>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Programming Language :: Python
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Requires-Python: >=3.12
17
+ Requires-Dist: fastapi>=0.139.2
18
+ Requires-Dist: typing-extensions>=4.16.0
19
+ Requires-Dist: typing-inspection>=0.4.4
20
+ Description-Content-Type: text/markdown
21
+
22
+ # fastapi-injected
23
+
24
+ Yet another attempt to reuse FastAPI's dependency injection outside of request handlers.
25
+
26
+ This is an opinionated library: it takes the DI machinery you already know from FastAPI (`Depends`, generator dependencies with teardown, dependency caching) and makes it usable in plain async functions — background jobs, CLI commands, workers, scripts — without a `Request` in sight.
27
+
28
+ ## Installation
29
+
30
+ ```sh
31
+ pip install fastapi-injected
32
+ ```
33
+
34
+ Requires Python 3.12+.
35
+
36
+ ## Usage
37
+
38
+ Declare dependencies as regular classes and annotate fields with `Dep[...]`:
39
+
40
+ ```python
41
+ from dataclasses import dataclass
42
+ from typing import AsyncIterator
43
+
44
+ from fastapi_injected import Dep, DepFactory, Injected, inject
45
+
46
+
47
+ @dataclass
48
+ class Session:
49
+ closed: bool = False
50
+
51
+
52
+ async def session_dep() -> AsyncIterator[Session]:
53
+ session = Session()
54
+ try:
55
+ yield session
56
+ finally:
57
+ session.closed = True
58
+
59
+
60
+ @dataclass
61
+ class Repository:
62
+ session: DepFactory[Session, session_dep]
63
+
64
+
65
+ @dataclass
66
+ class Service:
67
+ repo: Dep[Repository]
68
+
69
+
70
+ @inject
71
+ async def handler(*, service: Dep[Service] = Injected) -> None:
72
+ ... # service is built and injected, session is closed on exit
73
+
74
+
75
+ await handler()
76
+ ```
77
+
78
+ - `Dep[T]` — resolve `T` by calling it, same as FastAPI's `Annotated[T, Depends()]`.
79
+ - `DepFactory[T, factory]` — resolve `T` via a factory, same as `Annotated[T, Depends(factory)]`. Generator factories get proper teardown.
80
+ - `Arg[T]` — keep a parameter out of the dependency graph even though its annotation is a dependency, so the caller supplies it. See [Caller-supplied arguments](#caller-supplied-arguments).
81
+ - `Injected` — a sentinel default that exists purely to make type checkers happy: without it they would complain about a missing argument at call sites. At runtime the parameter is always filled in by `@inject`.
82
+
83
+ Injected parameters mix freely with regular ones — pass your own arguments as usual and the rest is injected:
84
+
85
+ ```python
86
+ @inject
87
+ async def add(a: int, b: int, *, service: Dep[Service] = Injected) -> int:
88
+ ...
89
+
90
+
91
+ result = await add(1, 2)
92
+ ```
93
+
94
+ ### Caller-supplied arguments
95
+
96
+ A parameter is injected only if it is written as a dependency — `Dep[...]`, `DepFactory[...]`, an `Annotated[..., Depends(...)]` of your own, or a `Depends(...)` default. Everything else is left for the caller, annotation untouched: it is never handed to pydantic, so any type works with no marker at all.
97
+
98
+ ```python
99
+ class Connection: # not a pydantic-friendly type, and it does not have to be
100
+ ...
101
+
102
+
103
+ @inject
104
+ async def handler(conn: Connection, *, service: Dep[Service] = Injected) -> None:
105
+ ...
106
+
107
+
108
+ await handler(connection)
109
+ ```
110
+
111
+ `Arg[...]` is for the opposite case — a parameter that *is* spelled as a dependency, in a function that wants it passed in instead:
112
+
113
+ ```python
114
+ from fastapi_injected import Arg
115
+
116
+ type ServiceDep = Annotated[Service, Depends(get_service)]
117
+
118
+
119
+ @inject
120
+ async def handler(service: Arg[ServiceDep], *, session: Dep[Session] = Injected) -> None:
121
+ ...
122
+
123
+
124
+ await handler(service)
125
+ ```
126
+
127
+ `Arg[T]` is a no-op for type checkers — the parameter stays typed as `T` — and at runtime it tells `@inject` to keep this parameter out of the dependency graph.
128
+
129
+ Writing `Injected` as the default of a parameter that is not a dependency raises `NotADependencyError` at decoration time, since nothing would ever fill it in.
130
+
131
+ ### Resolving a type directly
132
+
133
+ No decorator needed — resolve a dependency graph on demand:
134
+
135
+ ```python
136
+ from fastapi_injected import resolve
137
+
138
+ service = await resolve(Service)
139
+ ```
140
+
141
+ Like `@inject`, `resolve` accepts `new_scope=True` to force a fresh scope instead of reusing the surrounding one.
142
+
143
+ ### Scopes and caching
144
+
145
+ By default every call to an injected function gets its own scope: dependencies are built, cached within the call, and torn down when it returns. Wrap several calls in `push_inject_scope()` to share one cache (and defer teardown to the end of the scope):
146
+
147
+ ```python
148
+ from fastapi_injected import push_inject_scope
149
+
150
+ async with push_inject_scope():
151
+ a = await handler() # dependencies built here
152
+ b = await handler() # same instances reused
153
+ # generator dependencies are torn down here
154
+ ```
155
+
156
+ Use `@inject(new_scope=True)` to opt a function out of the surrounding scope and always get fresh dependencies. It builds them again, but stays in the request they are being built for, so request-bound dependencies keep resolving.
157
+
158
+ Outside of a request there is no request to resolve against, so the scope makes one up. It answers what a dependency usually reads — `method`, `url`, `headers`, `path_params`, `client`, `state` — with the values of a request nobody sent. The application is the one thing it cannot invent: pass it when a dependency reaches for `request.app`:
159
+
160
+ ```python
161
+ async with push_inject_scope(app=app):
162
+ await handler() # `request.app.state` resolves as it does in a route
163
+ ```
164
+
165
+ Without it, reading `request.app` raises a `KeyError` naming what is missing rather than a bare `'app'`.
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.
168
+
169
+ ### Overriding dependencies
170
+
171
+ `push_overrides` swaps dependencies out for the duration of a `with` block — handy in tests, or anywhere you need to run the same code against a different implementation:
172
+
173
+ ```python
174
+ from fastapi_injected import push_overrides
175
+
176
+ with push_overrides({Session: Session(closed=True)}):
177
+ await handler() # gets the override instead of the real dependency
178
+ ```
179
+
180
+ A key can be the dependency itself, or the annotation you wrote in the signature — `Dep[Session]` and `DepFactory[Session, session_dep]` both work and are normalized to the same underlying dependency:
181
+
182
+ ```python
183
+ with push_overrides({DepFactory[Session, session_dep]: my_session}):
184
+ ...
185
+ ```
186
+
187
+ Values are used as-is, but two wrappers make the intent explicit and cover the ambiguous cases:
188
+
189
+ - `ValueOverride(value)` — always inject `value`, even when it is itself callable.
190
+ - `FactoryOverride(factory)` — call `factory` to produce the value. Sync, async, and generator factories are all supported, with the same teardown semantics as regular dependencies.
191
+
192
+ ```python
193
+ from fastapi_injected import FactoryOverride, ValueOverride
194
+
195
+ with push_overrides(
196
+ {
197
+ Session: ValueOverride(fake_session),
198
+ Repository: FactoryOverride(lambda: FakeRepository()),
199
+ },
200
+ ):
201
+ ...
202
+ ```
203
+
204
+ Overrides apply to the whole graph, not just top-level parameters — overriding a nested dependency changes what its dependents receive. Nested `push_overrides` blocks merge, with the innermost one winning.
205
+
206
+ An override block is a scope of its own: it caches what it builds, and that cache goes away with the block. The surrounding scope keeps what it had resolved before, and it stays visible — except for the overridden dependencies and anything built from them, which are resolved again under the override:
207
+
208
+ ```python
209
+ async with push_inject_scope():
210
+ real = await handler() # dependencies cached here
211
+
212
+ with push_overrides({Session: fake}):
213
+ await handler() # rebuilt with `fake`, everything else reused from the cache
214
+
215
+ await handler() # back to the cached, real dependencies
216
+ ```
217
+
218
+ Overrides can also be handed to the scope directly, which is the same thing in one call:
219
+
220
+ ```python
221
+ async with push_inject_scope({Session: fake}) as scope:
222
+ await handler()
223
+
224
+ with scope.override({Session: other}): # nest freely
225
+ await handler()
226
+ ```
227
+
228
+ ### Dependencies that are objects
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.
231
+
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
+
234
+ ```python
235
+ from dataclasses import field
236
+ from fastapi_injected import MakeDataclass, resolve
237
+
238
+
239
+ class Settings(MakeDataclass):
240
+ hosts: list[str] = field(default_factory=list)
241
+
242
+ def __call__(self) -> list[str]:
243
+ return self.hosts
244
+
245
+
246
+ await resolve(Settings(["a", "b"])) # a plain dataclass would raise TypeError here
247
+ ```
248
+
249
+ Every `dataclasses.dataclass` option is accepted as a class keyword and passed straight through:
250
+
251
+ ```python
252
+ class Config(MakeDataclass, frozen=True, kw_only=True):
253
+ retries: int = 3
254
+ ```
255
+
256
+ ### When resolution fails
257
+
258
+ A dependency can fail to resolve for the same reasons it would in a route — a missing header, a query parameter that does not validate. `DependencyResolutionError` carries those errors in the shape pydantic produced them, and turns into the response FastAPI would have returned:
259
+
260
+ ```python
261
+ from fastapi_injected import DependencyResolutionError
262
+
263
+ try:
264
+ await handler()
265
+ except DependencyResolutionError as exc:
266
+ exc.errors # [{'type': 'missing', 'loc': ('header', 'x-trace'), ...}]
267
+ raise exc.as_validation_error() from exc # a RequestValidationError, so a 422
268
+ ```
269
+
270
+ It is a `ValueError`, which is what resolution used to raise, so code catching that keeps working.
271
+
272
+ ### Dependencies decided at runtime
273
+
274
+ A dependency is usually written as an annotation, but sometimes it is a value a caller already has, or one picked while the program runs. `Given` turns a value into a dependency that resolves to it:
275
+
276
+ ```python
277
+ from fastapi_injected import Given, resolve
278
+
279
+ doc = Doc(title="readme")
280
+
281
+ await resolve(Given(doc)) # the doc itself
282
+ ```
283
+
284
+ Constants holding equal values are the same dependency, so they share a cache entry and can be overridden like any other. What `Given` returns is a marker held as a value — `DepOf[R]`, as opposed to `Dep[R]`, which is the same marker in annotation position.
285
+
286
+ `bind_deps` binds such markers to the leading parameters of a function, which is how a dependency graph gets built from markers that were not known when the function was written:
287
+
288
+ ```python
289
+ from fastapi_injected import bind_deps
290
+
291
+ async def describe(doc: Doc, role: Role) -> str:
292
+ return f"{doc.title}:{role.name}"
293
+
294
+
295
+ bound = bind_deps(describe, Given(doc), RoleDep)
296
+
297
+ await resolve(bound) # "readme:admin", with `role` resolved as usual
298
+ ```
299
+
300
+ What a parameter is bound to can be written any way a dependency can be: a marker like `Given(...)`, `Dep[T]` or `DepFactory[T, factory]`, a `Depends(...)`, or the callable itself — the same things `resolve` accepts.
301
+
302
+ Binding takes the parameters away, and a type checker sees what is left: `bind_deps(describe, Given(doc))` is a function of `(role)`, and binding both leaves one of no arguments at all. The bound function is a dependency like any other — resolve it, `@inject` it, override what it depends on. Binding more markers than the function has parameters is a `TypeError` rather than a silent truncation.
303
+
304
+ ### Dependencies as objects
305
+
306
+ When the markers belong together, `MakeInjected` makes a dataclass of them: fields annotated with `DepOf` hold the markers, and the call receives what they resolve to.
307
+
308
+ ```python
309
+ from fastapi_injected import Given, MakeInjected
310
+ from fastapi_injected.types import DepOf
311
+
312
+
313
+ class IsOwner(MakeInjected):
314
+ doc: DepOf[Doc]
315
+ role: DepOf[Role]
316
+ loud: bool = False # a plain field stays a plain field
317
+
318
+ async def __call__(self, doc: Doc, role: Role) -> bool:
319
+ return doc.owner == role.name
320
+
321
+
322
+ await resolve(IsOwner(doc=Given(doc), role=RoleDep))
323
+ ```
324
+
325
+ It is a `MakeDataclass`, so instances built from equal markers are equal — and therefore one dependency, resolved once per scope and overridable as a whole. For callables that are not dataclasses, the two halves of the binding are available on their own: `signature_with_deps(func, deps)` builds the signature, and `remap_dep_args` turns the arguments it names back into positional ones.
326
+
327
+ ### Inspecting annotations
328
+
329
+ A few helpers are exported for code that needs to reason about `Dep[...]` annotations — building override maps, custom decorators, and the like:
330
+
331
+ - `is_dep(tp)` — whether `tp` is a `Dep`/`DepFactory` annotation.
332
+ - `unwrap_dep_tp(tp)` — the annotated type (`Any` for a bare `Dep`).
333
+ - `unwrap_dep_dependency(tp)` — the callable that resolves it: the factory for `DepFactory[T, factory]`, the type itself for `Dep[T]`.
334
+
335
+ ### FastAPI request integration
336
+
337
+ Inside a FastAPI app, `@inject`-ed functions and `resolve` can share the request's own dependency cache — the same instances FastAPI built for the handler. Register `init_inject_scope` as a dependency:
338
+
339
+ ```python
340
+ from fastapi import Depends, FastAPI
341
+ from fastapi_injected import Dep, init_inject_scope, resolve
342
+
343
+ app = FastAPI(dependencies=[Depends(init_inject_scope)])
344
+
345
+
346
+ @app.get("/")
347
+ async def route(service: Dep[Service]) -> str:
348
+ same = await resolve(Service) # same instance as `service`
349
+ ...
350
+ ```
351
+
352
+ Anything called from the handler — including `@inject`-ed helpers — resolves against the request's cache, so a per-request dependency like a DB session stays a single instance for the whole request.
353
+
354
+ ## What is public
355
+
356
+ Everything the package supports is importable from `fastapi_injected` itself, and that is the surface a release keeps:
357
+
358
+ | | |
359
+ | --- | --- |
360
+ | Markers | `Dep`, `DepFactory`, `DepOf`, `Arg`, `Given`, `Injected` |
361
+ | Resolving | `inject`, `resolve`, `bind_deps`, `signature_with_deps`, `remap_dep_args`, `clear_dependant_cache` |
362
+ | Scopes and overrides | `InjectScope`, `push_inject_scope`, `inside_inject_scope`, `push_overrides`, `Overrides`, `OverridesProvider`, `ValueOverride`, `FactoryOverride` |
363
+ | Building on top | `MakeDataclass`, `MakeInjected`, `HasDependsHook`, `ArgMarker`, `is_arg`, `is_dep`, `unwrap_dep_tp`, `unwrap_dep_dependency` |
364
+ | FastAPI integration | `add_injected_scope`, `init_inject_scope` |
365
+ | Errors | `DependencyResolutionError`, `MissedDependencyError`, `MissingDependencyCacheError`, `NotADependencyError`, `UnboundDepArgsError`, `UnboundScopeError` |
366
+
367
+ `fastapi_injected.types` holds the typing vocabulary the signatures are written in — `DepReturn`, `DepShape`, `DepDecl`, `AsyncFunc`, `Coro` and friends — and is public too. Anything else, including every module whose name starts with an underscore, is machinery that can change in a patch release.
368
+
369
+ ## License
370
+
371
+ MIT