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 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)