pydepi 0.1.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.
- pydepi-0.1.0/PKG-INFO +90 -0
- pydepi-0.1.0/README.md +59 -0
- pydepi-0.1.0/depi/__init__.py +72 -0
- pydepi-0.1.0/depi/context.py +91 -0
- pydepi-0.1.0/depi/exceptions.py +125 -0
- pydepi-0.1.0/depi/integration.py +173 -0
- pydepi-0.1.0/depi/services.py +808 -0
- pydepi-0.1.0/pydepi.egg-info/PKG-INFO +90 -0
- pydepi-0.1.0/pydepi.egg-info/SOURCES.txt +12 -0
- pydepi-0.1.0/pydepi.egg-info/dependency_links.txt +1 -0
- pydepi-0.1.0/pydepi.egg-info/requires.txt +15 -0
- pydepi-0.1.0/pydepi.egg-info/top_level.txt +1 -0
- pydepi-0.1.0/pyproject.toml +51 -0
- pydepi-0.1.0/setup.cfg +4 -0
pydepi-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pydepi
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A .NET-inspired, type-hint driven dependency injection container for Python
|
|
5
|
+
Author: Dan Leonard
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/danleonard-nj/depi
|
|
8
|
+
Project-URL: Repository, https://github.com/danleonard-nj/depi
|
|
9
|
+
Project-URL: Issues, https://github.com/danleonard-nj/depi/issues
|
|
10
|
+
Keywords: dependency-injection,di,ioc,inversion-of-control,container
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Provides-Extra: flask
|
|
22
|
+
Requires-Dist: pydepi-flask; extra == "flask"
|
|
23
|
+
Provides-Extra: quart
|
|
24
|
+
Requires-Dist: pydepi-quart; extra == "quart"
|
|
25
|
+
Provides-Extra: fastapi
|
|
26
|
+
Requires-Dist: pydepi-fastapi; extra == "fastapi"
|
|
27
|
+
Provides-Extra: django
|
|
28
|
+
Requires-Dist: pydepi-django; extra == "django"
|
|
29
|
+
Provides-Extra: all
|
|
30
|
+
Requires-Dist: pydepi[django,fastapi,flask,quart]; extra == "all"
|
|
31
|
+
|
|
32
|
+
# pydepi
|
|
33
|
+
|
|
34
|
+
A .NET-inspired, type-hint driven dependency injection container for Python.
|
|
35
|
+
|
|
36
|
+
`pydepi` resolves dependency graphs from constructor type annotations. It has **no dependencies** and knows nothing about the web — framework support ships as separate packages, so installing the container never drags a web framework into your environment.
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install pydepi
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from depi import ServiceCollection
|
|
44
|
+
|
|
45
|
+
class Config:
|
|
46
|
+
def __init__(self):
|
|
47
|
+
self.dsn = 'postgres://localhost/app'
|
|
48
|
+
|
|
49
|
+
class Database:
|
|
50
|
+
def __init__(self, config: Config): # resolved from the annotation
|
|
51
|
+
self.dsn = config.dsn
|
|
52
|
+
|
|
53
|
+
services = ServiceCollection()
|
|
54
|
+
services.add_singleton(Config)
|
|
55
|
+
services.add_scoped(Database)
|
|
56
|
+
|
|
57
|
+
provider = services.build_provider()
|
|
58
|
+
|
|
59
|
+
with provider.create_scope() as scope:
|
|
60
|
+
db = scope.resolve(Database)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Lifetimes
|
|
64
|
+
|
|
65
|
+
- **Transient** – a new instance on every resolution
|
|
66
|
+
- **Singleton** – one instance for the life of the provider
|
|
67
|
+
- **Scoped** – one instance per scope, typically per HTTP request
|
|
68
|
+
|
|
69
|
+
Scopes dispose their instances on exit, and `async with` awaits async cleanup first.
|
|
70
|
+
|
|
71
|
+
## Framework integrations
|
|
72
|
+
|
|
73
|
+
Each is a separate distribution depending on this one:
|
|
74
|
+
|
|
75
|
+
| Package | Import |
|
|
76
|
+
| ---------------- | -------------- |
|
|
77
|
+
| `pydepi-flask` | `depi_flask` |
|
|
78
|
+
| `pydepi-quart` | `depi_quart` |
|
|
79
|
+
| `pydepi-fastapi` | `depi_fastapi` |
|
|
80
|
+
| `pydepi-django` | `depi_django` |
|
|
81
|
+
|
|
82
|
+
Install one by name — `pip install pydepi-flask`. The extra `pydepi[flask]` works too, but the
|
|
83
|
+
distribution name is the more accurate form: these are separate packages, versioned and released
|
|
84
|
+
independently of core, not optional features of it.
|
|
85
|
+
|
|
86
|
+
Full documentation, including the integration guide, factories, and the async API, is in the [project README](https://github.com/danleonard-nj/depi#readme).
|
|
87
|
+
|
|
88
|
+
## License
|
|
89
|
+
|
|
90
|
+
MIT
|
pydepi-0.1.0/README.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# pydepi
|
|
2
|
+
|
|
3
|
+
A .NET-inspired, type-hint driven dependency injection container for Python.
|
|
4
|
+
|
|
5
|
+
`pydepi` resolves dependency graphs from constructor type annotations. It has **no dependencies** and knows nothing about the web — framework support ships as separate packages, so installing the container never drags a web framework into your environment.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install pydepi
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from depi import ServiceCollection
|
|
13
|
+
|
|
14
|
+
class Config:
|
|
15
|
+
def __init__(self):
|
|
16
|
+
self.dsn = 'postgres://localhost/app'
|
|
17
|
+
|
|
18
|
+
class Database:
|
|
19
|
+
def __init__(self, config: Config): # resolved from the annotation
|
|
20
|
+
self.dsn = config.dsn
|
|
21
|
+
|
|
22
|
+
services = ServiceCollection()
|
|
23
|
+
services.add_singleton(Config)
|
|
24
|
+
services.add_scoped(Database)
|
|
25
|
+
|
|
26
|
+
provider = services.build_provider()
|
|
27
|
+
|
|
28
|
+
with provider.create_scope() as scope:
|
|
29
|
+
db = scope.resolve(Database)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Lifetimes
|
|
33
|
+
|
|
34
|
+
- **Transient** – a new instance on every resolution
|
|
35
|
+
- **Singleton** – one instance for the life of the provider
|
|
36
|
+
- **Scoped** – one instance per scope, typically per HTTP request
|
|
37
|
+
|
|
38
|
+
Scopes dispose their instances on exit, and `async with` awaits async cleanup first.
|
|
39
|
+
|
|
40
|
+
## Framework integrations
|
|
41
|
+
|
|
42
|
+
Each is a separate distribution depending on this one:
|
|
43
|
+
|
|
44
|
+
| Package | Import |
|
|
45
|
+
| ---------------- | -------------- |
|
|
46
|
+
| `pydepi-flask` | `depi_flask` |
|
|
47
|
+
| `pydepi-quart` | `depi_quart` |
|
|
48
|
+
| `pydepi-fastapi` | `depi_fastapi` |
|
|
49
|
+
| `pydepi-django` | `depi_django` |
|
|
50
|
+
|
|
51
|
+
Install one by name — `pip install pydepi-flask`. The extra `pydepi[flask]` works too, but the
|
|
52
|
+
distribution name is the more accurate form: these are separate packages, versioned and released
|
|
53
|
+
independently of core, not optional features of it.
|
|
54
|
+
|
|
55
|
+
Full documentation, including the integration guide, factories, and the async API, is in the [project README](https://github.com/danleonard-nj/depi#readme).
|
|
56
|
+
|
|
57
|
+
## License
|
|
58
|
+
|
|
59
|
+
MIT
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""
|
|
2
|
+
depi - a type-hint driven dependency injection container for Python.
|
|
3
|
+
|
|
4
|
+
Core is dependency-free and framework-agnostic. Web framework support ships as
|
|
5
|
+
separate distributions, each with its own top-level module, so importing
|
|
6
|
+
``depi`` never pulls in a web framework:
|
|
7
|
+
|
|
8
|
+
pip install pydepi[flask] # or: pip install pydepi-flask
|
|
9
|
+
|
|
10
|
+
from depi_flask import FlaskInjector
|
|
11
|
+
|
|
12
|
+
Adapters build on :mod:`depi.context` and :mod:`depi.integration`.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from .context import (
|
|
16
|
+
current_scope,
|
|
17
|
+
get_current_scope,
|
|
18
|
+
reset_current_scope,
|
|
19
|
+
set_current_scope,
|
|
20
|
+
use_scope,
|
|
21
|
+
)
|
|
22
|
+
from .exceptions import (
|
|
23
|
+
AsyncFactoryError,
|
|
24
|
+
CircularDependencyError,
|
|
25
|
+
DepiError,
|
|
26
|
+
InvalidLifetimeError,
|
|
27
|
+
MissingAnnotationError,
|
|
28
|
+
NoActiveScopeError,
|
|
29
|
+
RegistrationError,
|
|
30
|
+
ResolutionError,
|
|
31
|
+
ScopeRequiredError,
|
|
32
|
+
UnknownLifetimeError,
|
|
33
|
+
UnregisteredDependencyError,
|
|
34
|
+
)
|
|
35
|
+
from .services import (
|
|
36
|
+
ConstructorDependency,
|
|
37
|
+
DependencyRegistration,
|
|
38
|
+
Lifetime,
|
|
39
|
+
ServiceCollection,
|
|
40
|
+
ServiceProvider,
|
|
41
|
+
ServiceScope,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
# Container
|
|
46
|
+
'ServiceCollection',
|
|
47
|
+
'ServiceProvider',
|
|
48
|
+
'ServiceScope',
|
|
49
|
+
'Lifetime',
|
|
50
|
+
'ConstructorDependency',
|
|
51
|
+
'DependencyRegistration',
|
|
52
|
+
|
|
53
|
+
# Ambient scope
|
|
54
|
+
'current_scope',
|
|
55
|
+
'get_current_scope',
|
|
56
|
+
'set_current_scope',
|
|
57
|
+
'reset_current_scope',
|
|
58
|
+
'use_scope',
|
|
59
|
+
|
|
60
|
+
# Errors
|
|
61
|
+
'DepiError',
|
|
62
|
+
'RegistrationError',
|
|
63
|
+
'MissingAnnotationError',
|
|
64
|
+
'CircularDependencyError',
|
|
65
|
+
'InvalidLifetimeError',
|
|
66
|
+
'UnknownLifetimeError',
|
|
67
|
+
'ResolutionError',
|
|
68
|
+
'UnregisteredDependencyError',
|
|
69
|
+
'ScopeRequiredError',
|
|
70
|
+
'AsyncFactoryError',
|
|
71
|
+
'NoActiveScopeError',
|
|
72
|
+
]
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Ambient scope tracking.
|
|
3
|
+
|
|
4
|
+
Framework integrations bind a :class:`~depi.services.ServiceScope` to the
|
|
5
|
+
current request or task context so view functions can reach it without it being
|
|
6
|
+
threaded through every call.
|
|
7
|
+
|
|
8
|
+
This lives in core rather than in an integration on purpose: every integration
|
|
9
|
+
needs the *same* contextvar. If each one owned a private one, a scope opened by
|
|
10
|
+
the Flask integration would be invisible to anything reading through another,
|
|
11
|
+
and nested/mixed stacks (an ASGI app mounting a WSGI app, say) would silently
|
|
12
|
+
resolve against the wrong scope.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from contextlib import contextmanager
|
|
16
|
+
from contextvars import ContextVar, Token
|
|
17
|
+
from typing import TYPE_CHECKING, Iterator, Optional
|
|
18
|
+
|
|
19
|
+
# Defined in exceptions.py so the whole hierarchy lives in one place; re-exported
|
|
20
|
+
# here because this is where it is raised and where callers expect to find it.
|
|
21
|
+
from .exceptions import NoActiveScopeError
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from .services import ServiceScope
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
'NoActiveScopeError',
|
|
28
|
+
'current_scope',
|
|
29
|
+
'get_current_scope',
|
|
30
|
+
'set_current_scope',
|
|
31
|
+
'reset_current_scope',
|
|
32
|
+
'use_scope',
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
_current_scope: ContextVar[Optional['ServiceScope']] = ContextVar(
|
|
37
|
+
'depi_current_scope', default=None
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_current_scope() -> Optional['ServiceScope']:
|
|
42
|
+
"""Return the scope bound to the current context, or None if there is none."""
|
|
43
|
+
return _current_scope.get()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def current_scope() -> 'ServiceScope':
|
|
47
|
+
"""
|
|
48
|
+
Return the scope bound to the current context.
|
|
49
|
+
|
|
50
|
+
Raises:
|
|
51
|
+
NoActiveScopeError: if no scope is bound.
|
|
52
|
+
"""
|
|
53
|
+
scope = _current_scope.get()
|
|
54
|
+
if scope is None:
|
|
55
|
+
raise NoActiveScopeError(
|
|
56
|
+
"No active depi scope. Ensure the integration's setup(app) ran and that "
|
|
57
|
+
"this code runs inside a request handled by its middleware."
|
|
58
|
+
)
|
|
59
|
+
return scope
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def set_current_scope(scope: Optional['ServiceScope']) -> Token:
|
|
63
|
+
"""
|
|
64
|
+
Bind a scope to the current context.
|
|
65
|
+
|
|
66
|
+
Returns the token needed to restore the previous value; pass it to
|
|
67
|
+
:func:`reset_current_scope`. Prefer :func:`use_scope` where the bind and
|
|
68
|
+
restore happen in the same frame.
|
|
69
|
+
"""
|
|
70
|
+
return _current_scope.set(scope)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def reset_current_scope(token: Token) -> None:
|
|
74
|
+
"""Restore the scope that was bound before ``token`` was issued."""
|
|
75
|
+
_current_scope.reset(token)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@contextmanager
|
|
79
|
+
def use_scope(scope: 'ServiceScope') -> Iterator['ServiceScope']:
|
|
80
|
+
"""
|
|
81
|
+
Bind ``scope`` for the duration of the block, then restore the previous one.
|
|
82
|
+
|
|
83
|
+
Note this only binds the scope; it does not dispose it. Disposal stays with
|
|
84
|
+
whoever created the scope, since integrations differ on when it is safe
|
|
85
|
+
(Flask at teardown_request, ASGI after the response body is sent).
|
|
86
|
+
"""
|
|
87
|
+
token = _current_scope.set(scope)
|
|
88
|
+
try:
|
|
89
|
+
yield scope
|
|
90
|
+
finally:
|
|
91
|
+
_current_scope.reset(token)
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Exception hierarchy.
|
|
3
|
+
|
|
4
|
+
Every error depi raises derives from :class:`DepiError`, so an application can
|
|
5
|
+
catch depi's failures without catching everything. The classes are grouped by
|
|
6
|
+
*when* the failure happens, because that maps to who can fix it:
|
|
7
|
+
|
|
8
|
+
- :class:`RegistrationError` -- the container was described wrongly. Raised while
|
|
9
|
+
registering services or building a provider, i.e. at startup, before traffic.
|
|
10
|
+
- :class:`ResolutionError` -- the container was asked for something it could not
|
|
11
|
+
produce. Raised at resolve time.
|
|
12
|
+
|
|
13
|
+
These live in their own module so both :mod:`depi.services` and
|
|
14
|
+
:mod:`depi.context` can import them without a cycle.
|
|
15
|
+
|
|
16
|
+
Backwards compatibility: depi previously raised bare ``Exception`` and, for the
|
|
17
|
+
async-factory guard, ``RuntimeError``. Every class here still derives from the
|
|
18
|
+
type it used to be, so existing ``except Exception`` and ``except RuntimeError``
|
|
19
|
+
handlers keep working unchanged.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
'DepiError',
|
|
24
|
+
'RegistrationError',
|
|
25
|
+
'MissingAnnotationError',
|
|
26
|
+
'CircularDependencyError',
|
|
27
|
+
'InvalidLifetimeError',
|
|
28
|
+
'UnknownLifetimeError',
|
|
29
|
+
'ResolutionError',
|
|
30
|
+
'UnregisteredDependencyError',
|
|
31
|
+
'ScopeRequiredError',
|
|
32
|
+
'AsyncFactoryError',
|
|
33
|
+
'NoActiveScopeError',
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class DepiError(Exception):
|
|
38
|
+
"""Base class for every error raised by depi."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# --------------------------------------------------------------------------
|
|
42
|
+
# Registration / build time: the container was described wrongly.
|
|
43
|
+
# --------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
class RegistrationError(DepiError):
|
|
46
|
+
"""Raised while registering services or building a provider."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class MissingAnnotationError(RegistrationError):
|
|
50
|
+
"""
|
|
51
|
+
A constructor parameter has no type annotation.
|
|
52
|
+
|
|
53
|
+
depi resolves by annotation, so an unannotated parameter cannot be resolved.
|
|
54
|
+
Raised at registration rather than at resolution, so it surfaces at startup.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class CircularDependencyError(RegistrationError):
|
|
59
|
+
"""
|
|
60
|
+
A dependency cycle was found while building the provider.
|
|
61
|
+
|
|
62
|
+
Detected by static analysis at build time, so a cycle cannot reach
|
|
63
|
+
production as a recursion error at request time.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class InvalidLifetimeError(RegistrationError):
|
|
68
|
+
"""
|
|
69
|
+
Two registrations combine in a way that breaks one of their lifetimes.
|
|
70
|
+
|
|
71
|
+
In practice this is a singleton depending on a transient or scoped service:
|
|
72
|
+
the dependency would be constructed exactly once, inside the singleton, and
|
|
73
|
+
would silently stop behaving like a transient or a scoped service.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class UnknownLifetimeError(RegistrationError):
|
|
78
|
+
"""A registration carries a lifetime depi does not recognise."""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# --------------------------------------------------------------------------
|
|
82
|
+
# Resolution time: the container could not produce what was asked for.
|
|
83
|
+
# --------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
class ResolutionError(DepiError):
|
|
86
|
+
"""Raised while resolving a service."""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class UnregisteredDependencyError(ResolutionError):
|
|
90
|
+
"""
|
|
91
|
+
No registration exists for the requested type.
|
|
92
|
+
|
|
93
|
+
Either the service was never registered, or it was registered under a
|
|
94
|
+
different type -- an interface rather than the implementation, typically.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class ScopeRequiredError(ResolutionError):
|
|
99
|
+
"""
|
|
100
|
+
A scoped service was resolved without a scope.
|
|
101
|
+
|
|
102
|
+
Call ``provider.create_scope()``, or resolve from the scope a framework
|
|
103
|
+
integration opened for the current request.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class AsyncFactoryError(ResolutionError, RuntimeError):
|
|
108
|
+
"""
|
|
109
|
+
An async factory was resolved through the synchronous API.
|
|
110
|
+
|
|
111
|
+
Also derives from RuntimeError, which is what this used to be, so existing
|
|
112
|
+
``except RuntimeError`` handlers still catch it.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class NoActiveScopeError(DepiError, RuntimeError):
|
|
117
|
+
"""
|
|
118
|
+
A request scope was needed but none is bound to the current context.
|
|
119
|
+
|
|
120
|
+
Raised by :func:`depi.context.current_scope` when no integration has opened
|
|
121
|
+
a scope -- typically because ``setup(app)`` never ran, or because the code
|
|
122
|
+
is executing outside a request.
|
|
123
|
+
|
|
124
|
+
Also derives from RuntimeError for backwards compatibility.
|
|
125
|
+
"""
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The contract every depi framework integration is built on.
|
|
3
|
+
|
|
4
|
+
This lives in core, and stays dependency-free: adapters ship as separate
|
|
5
|
+
distributions (pydepi-flask, pydepi-quart, ...) and all build against this.
|
|
6
|
+
|
|
7
|
+
Integrations are deliberately thin: they open a :class:`ServiceScope` per
|
|
8
|
+
request, bind it to the ambient context (see :mod:`depi.context`), dispose it
|
|
9
|
+
when the request ends, and offer a decorator to hand that scope to a view
|
|
10
|
+
function. Everything above that -- authentication, response shaping, blueprint
|
|
11
|
+
conventions -- belongs to the application, not to depi.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import inspect
|
|
15
|
+
from abc import ABC, abstractmethod
|
|
16
|
+
from functools import wraps
|
|
17
|
+
from typing import TYPE_CHECKING, Any, Callable, Dict
|
|
18
|
+
|
|
19
|
+
from .context import current_scope
|
|
20
|
+
from .services import get_signature
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from .services import ServiceProvider, ServiceScope
|
|
24
|
+
|
|
25
|
+
__all__ = ['BaseInjector', 'injectable_parameters']
|
|
26
|
+
|
|
27
|
+
_VARIADIC = (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def injectable_parameters(
|
|
31
|
+
fn: Callable,
|
|
32
|
+
provider: 'ServiceProvider'
|
|
33
|
+
) -> Dict[str, type]:
|
|
34
|
+
"""
|
|
35
|
+
Map parameter name -> registered type for the parameters depi should supply.
|
|
36
|
+
|
|
37
|
+
Parameters the provider does not know about are left alone, because the web
|
|
38
|
+
framework almost certainly owns them (URL converters, query arguments). That
|
|
39
|
+
is why autowire cannot fail fast on an unresolvable annotation: from here,
|
|
40
|
+
"unregistered" and "supplied by the framework" look identical.
|
|
41
|
+
|
|
42
|
+
Called once at decoration time, never per request.
|
|
43
|
+
"""
|
|
44
|
+
try:
|
|
45
|
+
parameters = get_signature(fn).parameters
|
|
46
|
+
except Exception:
|
|
47
|
+
# Deliberately broad. Signatures are evaluated with eval_str=True, and
|
|
48
|
+
# under `from __future__ import annotations` every annotation is a
|
|
49
|
+
# string, so this can fail in many ways: an unimportable forward
|
|
50
|
+
# reference (NameError), a malformed one (SyntaxError), a dotted name
|
|
51
|
+
# whose attribute is missing (AttributeError), a builtin with no
|
|
52
|
+
# retrievable signature (ValueError).
|
|
53
|
+
#
|
|
54
|
+
# None of those are depi's to diagnose, and all of them happen at
|
|
55
|
+
# decoration time -- at import, before the app can even start. Claiming
|
|
56
|
+
# nothing lets the framework supply the parameter and report the real
|
|
57
|
+
# problem itself.
|
|
58
|
+
return {}
|
|
59
|
+
|
|
60
|
+
injectable = {}
|
|
61
|
+
for name, param in parameters.items():
|
|
62
|
+
if param.kind in _VARIADIC:
|
|
63
|
+
continue
|
|
64
|
+
annotation = param.annotation
|
|
65
|
+
if annotation is inspect.Parameter.empty:
|
|
66
|
+
continue
|
|
67
|
+
if isinstance(annotation, type) and provider.is_registered(annotation):
|
|
68
|
+
injectable[name] = annotation
|
|
69
|
+
return injectable
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class BaseInjector(ABC):
|
|
73
|
+
"""Base class for framework integrations."""
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
provider: 'ServiceProvider',
|
|
78
|
+
param_name: str = 'provider',
|
|
79
|
+
autowire: bool = False
|
|
80
|
+
):
|
|
81
|
+
"""
|
|
82
|
+
Args:
|
|
83
|
+
provider: the built ServiceProvider to resolve from.
|
|
84
|
+
param_name: the keyword argument the request scope is passed as in
|
|
85
|
+
the default (non-autowire) mode. Name it to taste -- ``container``
|
|
86
|
+
is a common alternative.
|
|
87
|
+
autowire: when True, :meth:`inject` resolves registered types by
|
|
88
|
+
annotation instead of passing the scope. Not supported by every
|
|
89
|
+
integration; FastAPI rejects it outright.
|
|
90
|
+
"""
|
|
91
|
+
self._provider = provider
|
|
92
|
+
self._param_name = param_name
|
|
93
|
+
self._autowire = autowire
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def provider(self) -> 'ServiceProvider':
|
|
97
|
+
"""The provider this injector resolves from."""
|
|
98
|
+
return self._provider
|
|
99
|
+
|
|
100
|
+
def create_scope(self) -> 'ServiceScope':
|
|
101
|
+
"""Create a new, unbound dependency scope."""
|
|
102
|
+
return self._provider.create_scope()
|
|
103
|
+
|
|
104
|
+
def current_scope(self) -> 'ServiceScope':
|
|
105
|
+
"""
|
|
106
|
+
Return the scope bound to the current request context.
|
|
107
|
+
|
|
108
|
+
Raises:
|
|
109
|
+
NoActiveScopeError: if setup(app) never ran, or this is called
|
|
110
|
+
outside a request.
|
|
111
|
+
"""
|
|
112
|
+
return current_scope()
|
|
113
|
+
|
|
114
|
+
@abstractmethod
|
|
115
|
+
def setup(self, app) -> None:
|
|
116
|
+
"""Install per-request scope management onto ``app``."""
|
|
117
|
+
|
|
118
|
+
def _make_inject_wrapper(self, fn: Callable) -> Callable:
|
|
119
|
+
"""
|
|
120
|
+
Build the injecting wrapper for ``fn``.
|
|
121
|
+
|
|
122
|
+
Resolution work that can be done once (signature inspection) is done
|
|
123
|
+
here, at decoration time, so the per-request path stays a dict lookup.
|
|
124
|
+
"""
|
|
125
|
+
param_name = self._param_name
|
|
126
|
+
|
|
127
|
+
if self._autowire:
|
|
128
|
+
targets = injectable_parameters(fn, self._provider)
|
|
129
|
+
|
|
130
|
+
def apply(kwargs: Dict[str, Any]) -> None:
|
|
131
|
+
# The ambient scope is fetched lazily, and only if something is
|
|
132
|
+
# actually missing -- so a view whose services were all passed
|
|
133
|
+
# in can be called straight from a test with no request context.
|
|
134
|
+
scope = None
|
|
135
|
+
for name, _type in targets.items():
|
|
136
|
+
if name in kwargs:
|
|
137
|
+
continue
|
|
138
|
+
if scope is None:
|
|
139
|
+
scope = current_scope()
|
|
140
|
+
kwargs[name] = scope.resolve(_type)
|
|
141
|
+
else:
|
|
142
|
+
def apply(kwargs: Dict[str, Any]) -> None:
|
|
143
|
+
# An explicitly passed scope wins, which is what makes a view
|
|
144
|
+
# callable directly from a test with a hand-built scope.
|
|
145
|
+
if param_name not in kwargs:
|
|
146
|
+
kwargs[param_name] = current_scope()
|
|
147
|
+
|
|
148
|
+
if inspect.iscoroutinefunction(fn):
|
|
149
|
+
@wraps(fn)
|
|
150
|
+
async def async_wrapper(*args, **kwargs):
|
|
151
|
+
apply(kwargs)
|
|
152
|
+
return await fn(*args, **kwargs)
|
|
153
|
+
return async_wrapper
|
|
154
|
+
|
|
155
|
+
@wraps(fn)
|
|
156
|
+
def sync_wrapper(*args, **kwargs):
|
|
157
|
+
apply(kwargs)
|
|
158
|
+
return fn(*args, **kwargs)
|
|
159
|
+
return sync_wrapper
|
|
160
|
+
|
|
161
|
+
def inject(self, fn: Callable) -> Callable:
|
|
162
|
+
"""
|
|
163
|
+
Decorator handing the request scope to ``fn``.
|
|
164
|
+
|
|
165
|
+
Default mode passes the scope as the ``param_name`` keyword argument.
|
|
166
|
+
With ``autowire=True``, parameters annotated with registered types are
|
|
167
|
+
resolved and passed individually instead.
|
|
168
|
+
|
|
169
|
+
Uses functools.wraps, so it composes inside a decorator stack (route
|
|
170
|
+
registration, auth, response handling) without losing the wrapped
|
|
171
|
+
function's identity.
|
|
172
|
+
"""
|
|
173
|
+
return self._make_inject_wrapper(fn)
|