fastapi-singleton 0.1.0__py3-none-any.whl
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.
- fastapi_singleton/__init__.py +29 -0
- fastapi_singleton/_class.py +105 -0
- fastapi_singleton/_function.py +163 -0
- fastapi_singleton/_hooks.py +43 -0
- fastapi_singleton/_lifespan.py +46 -0
- fastapi_singleton/_provider.py +72 -0
- fastapi_singleton/_registry.py +53 -0
- fastapi_singleton/_signature.py +167 -0
- fastapi_singleton/py.typed +0 -0
- fastapi_singleton-0.1.0.dist-info/METADATA +250 -0
- fastapi_singleton-0.1.0.dist-info/RECORD +12 -0
- fastapi_singleton-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Application-scoped dependencies for FastAPI.
|
|
2
|
+
|
|
3
|
+
See README.md for the full guide. Public API:
|
|
4
|
+
|
|
5
|
+
from fastapi_singleton import singleton, lifespan
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import inspect
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from ._class import make_class_singleton
|
|
12
|
+
from ._function import make_function_singleton
|
|
13
|
+
from ._lifespan import lifespan
|
|
14
|
+
from ._registry import reset
|
|
15
|
+
from ._signature import UsageError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def singleton(obj: Any) -> Any:
|
|
19
|
+
if inspect.isclass(obj):
|
|
20
|
+
return make_class_singleton(obj)
|
|
21
|
+
return make_function_singleton(obj)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"singleton",
|
|
26
|
+
"lifespan",
|
|
27
|
+
"reset",
|
|
28
|
+
"UsageError",
|
|
29
|
+
]
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""@singleton for classes.
|
|
2
|
+
|
|
3
|
+
Classes are always a sync, `__init__`-only "value singleton": `__init__`
|
|
4
|
+
can never be `async def` in Python, so there's no mechanism by which a
|
|
5
|
+
class-based dependency could do real async resource setup (`await
|
|
6
|
+
asyncpg.create_pool(...)` and friends) - FastAPI itself never awaits a
|
|
7
|
+
class's constructor either, for the same reason. If a singleton needs async
|
|
8
|
+
construction or generator-based teardown, write it as a function (see
|
|
9
|
+
_function.py); a class singleton can still depend on one via `Depends` in
|
|
10
|
+
its `__init__` the same way any other dependency does.
|
|
11
|
+
|
|
12
|
+
Because `@singleton class Foo` only ever constructs via `__init__`, calling
|
|
13
|
+
it is exactly "call the constructor" - the same mental model as a plain
|
|
14
|
+
FastAPI class-based dependency (`Depends(Foo)`), just memoized.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import inspect
|
|
18
|
+
import threading
|
|
19
|
+
import time
|
|
20
|
+
from collections.abc import Callable
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from . import _hooks, _registry, _signature
|
|
24
|
+
|
|
25
|
+
_UNSET = object()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _ClassSingleton:
|
|
29
|
+
def __init__(self, cls: type) -> None:
|
|
30
|
+
self._cls = cls
|
|
31
|
+
self._hooks = _hooks.HookRegistry()
|
|
32
|
+
self.__name__ = cls.__name__
|
|
33
|
+
self.__doc__ = cls.__doc__
|
|
34
|
+
self.__module__ = cls.__module__
|
|
35
|
+
init_signature = inspect.signature(cls.__init__)
|
|
36
|
+
params = [p for name, p in init_signature.parameters.items() if name != "self"]
|
|
37
|
+
self.__signature__ = init_signature.replace(parameters=params)
|
|
38
|
+
setattr(self, _registry.MARKER, True)
|
|
39
|
+
self._created: float | None = None
|
|
40
|
+
self._torn_down = False
|
|
41
|
+
self._value: Any = _UNSET
|
|
42
|
+
self._construction_args: tuple[Any, ...] = ()
|
|
43
|
+
self._construction_kwargs: dict[str, Any] = {}
|
|
44
|
+
self._lock = threading.Lock()
|
|
45
|
+
|
|
46
|
+
def _existing(self, args: tuple[Any, ...], kwargs: dict[str, Any]) -> Any:
|
|
47
|
+
if not self._created:
|
|
48
|
+
return _UNSET
|
|
49
|
+
_signature.check_no_conflict(
|
|
50
|
+
repr(self),
|
|
51
|
+
(self._construction_args, self._construction_kwargs),
|
|
52
|
+
(args, kwargs),
|
|
53
|
+
)
|
|
54
|
+
return self._value
|
|
55
|
+
|
|
56
|
+
def __call__(self, *args: Any, **kwargs: Any) -> Any:
|
|
57
|
+
existing = self._existing(args, kwargs)
|
|
58
|
+
if existing is not _UNSET:
|
|
59
|
+
return existing
|
|
60
|
+
with _signature.guard_against_cycles(self):
|
|
61
|
+
with self._lock:
|
|
62
|
+
existing = self._existing(args, kwargs)
|
|
63
|
+
if existing is not _UNSET:
|
|
64
|
+
return existing
|
|
65
|
+
if not args and not kwargs:
|
|
66
|
+
kwargs = _signature.self_resolve_kwargs(self._cls.__init__)
|
|
67
|
+
_hooks.run_sync(self._hooks.before_start)
|
|
68
|
+
self._value = self._cls(*args, **kwargs)
|
|
69
|
+
self._created = time.time()
|
|
70
|
+
self._construction_args = args
|
|
71
|
+
self._construction_kwargs = kwargs
|
|
72
|
+
return self._value
|
|
73
|
+
|
|
74
|
+
def teardown(self) -> None:
|
|
75
|
+
if not self._created or self._torn_down:
|
|
76
|
+
return
|
|
77
|
+
self._torn_down = True
|
|
78
|
+
_hooks.run_sync(self._hooks.before_end)
|
|
79
|
+
_hooks.run_sync(self._hooks.after_end)
|
|
80
|
+
|
|
81
|
+
def before_start(self, hook: Callable[[], Any]) -> Callable[[], Any]:
|
|
82
|
+
self._hooks.before_start.append(hook)
|
|
83
|
+
return hook
|
|
84
|
+
|
|
85
|
+
def before_end(self, hook: Callable[[], Any]) -> Callable[[], Any]:
|
|
86
|
+
self._hooks.before_end.append(hook)
|
|
87
|
+
return hook
|
|
88
|
+
|
|
89
|
+
def after_end(self, hook: Callable[[], Any]) -> Callable[[], Any]:
|
|
90
|
+
self._hooks.after_end.append(hook)
|
|
91
|
+
return hook
|
|
92
|
+
|
|
93
|
+
def _reset(self) -> None:
|
|
94
|
+
self._created = None
|
|
95
|
+
self._torn_down = False
|
|
96
|
+
self._value = _UNSET
|
|
97
|
+
self._construction_args = ()
|
|
98
|
+
self._construction_kwargs = {}
|
|
99
|
+
self._lock = threading.Lock()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def make_class_singleton(cls: type) -> _ClassSingleton:
|
|
103
|
+
instance = _ClassSingleton(cls)
|
|
104
|
+
_registry.register(instance)
|
|
105
|
+
return instance
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""@singleton for plain functions.
|
|
2
|
+
|
|
3
|
+
A sync and an async wrapper class exist separately, rather than one wrapper
|
|
4
|
+
that internally awaits, because FastAPI decides how to invoke a dependency
|
|
5
|
+
(directly await it, or run it in a threadpool) based on whether the
|
|
6
|
+
callable itself is `async def` - so the wrapper's own sync/async-ness must
|
|
7
|
+
match the underlying provider's, not just delegate to it.
|
|
8
|
+
|
|
9
|
+
Deliberately does NOT use functools.wraps/update_wrapper on the raw
|
|
10
|
+
provider: that would set __wrapped__, and FastAPI's own generator detection
|
|
11
|
+
(fastapi.dependencies.models.Dependant.is_gen_callable) calls
|
|
12
|
+
inspect.unwrap() on whatever it's given, which would find the original
|
|
13
|
+
generator/async-generator function and misclassify our cached-value wrapper
|
|
14
|
+
as a live per-request resource. Identity (__name__/__doc__/__signature__) is
|
|
15
|
+
copied by hand instead.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import inspect
|
|
20
|
+
import threading
|
|
21
|
+
import time
|
|
22
|
+
from collections.abc import Callable
|
|
23
|
+
from typing import Any, Generic, TypeVar
|
|
24
|
+
|
|
25
|
+
from . import _hooks, _registry, _signature
|
|
26
|
+
from ._provider import Provider
|
|
27
|
+
|
|
28
|
+
_UNSET = object()
|
|
29
|
+
|
|
30
|
+
_LockT = TypeVar("_LockT", threading.Lock, asyncio.Lock)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _BaseFunctionSingleton(Generic[_LockT]):
|
|
34
|
+
LOCK_METHOD: type[_LockT]
|
|
35
|
+
_lock: _LockT
|
|
36
|
+
|
|
37
|
+
def __init__(self, fn: Callable[..., Any], provider: Provider) -> None:
|
|
38
|
+
self._fn = fn
|
|
39
|
+
self._reset()
|
|
40
|
+
self._construction_kwargs: dict[str, Any] = {}
|
|
41
|
+
self._hooks = _hooks.HookRegistry()
|
|
42
|
+
self.__name__ = getattr(fn, "__name__", "singleton")
|
|
43
|
+
self.__doc__ = fn.__doc__
|
|
44
|
+
self.__module__ = fn.__module__
|
|
45
|
+
self.__signature__ = inspect.signature(fn)
|
|
46
|
+
setattr(self, _registry.MARKER, True)
|
|
47
|
+
|
|
48
|
+
def before_start(self, hook: Callable[[], Any]) -> Callable[[], Any]:
|
|
49
|
+
self._hooks.before_start.append(hook)
|
|
50
|
+
return hook
|
|
51
|
+
|
|
52
|
+
def before_end(self, hook: Callable[[], Any]) -> Callable[[], Any]:
|
|
53
|
+
self._hooks.before_end.append(hook)
|
|
54
|
+
return hook
|
|
55
|
+
|
|
56
|
+
def after_end(self, hook: Callable[[], Any]) -> Callable[[], Any]:
|
|
57
|
+
self._hooks.after_end.append(hook)
|
|
58
|
+
return hook
|
|
59
|
+
|
|
60
|
+
def _reset(self) -> None:
|
|
61
|
+
self._provider = Provider(self._fn)
|
|
62
|
+
self._created = None
|
|
63
|
+
self._torn_down = False
|
|
64
|
+
self._before_end_done = False
|
|
65
|
+
self._value = _UNSET
|
|
66
|
+
self._construction_kwargs = {}
|
|
67
|
+
self._lock = self.LOCK_METHOD()
|
|
68
|
+
|
|
69
|
+
def _existing(self, kwargs: dict[str, Any]) -> Any:
|
|
70
|
+
"""Returns the cached value if already created, else _UNSET.
|
|
71
|
+
|
|
72
|
+
Shared between the sync and async fast-path/locked-path checks,
|
|
73
|
+
which are otherwise identical aside from await placement.
|
|
74
|
+
"""
|
|
75
|
+
if self._torn_down:
|
|
76
|
+
raise _signature.UsageError(
|
|
77
|
+
f"{self!r} was already torn down and cannot be called again."
|
|
78
|
+
)
|
|
79
|
+
if not self._created:
|
|
80
|
+
return _UNSET
|
|
81
|
+
_signature.check_no_conflict(
|
|
82
|
+
repr(self), ((), self._construction_kwargs), ((), kwargs)
|
|
83
|
+
)
|
|
84
|
+
return self._value
|
|
85
|
+
|
|
86
|
+
def _commit(self, value: Any, kwargs: dict[str, Any]) -> Any:
|
|
87
|
+
self._value = value
|
|
88
|
+
self._created = time.time()
|
|
89
|
+
self._construction_kwargs = kwargs
|
|
90
|
+
return value
|
|
91
|
+
|
|
92
|
+
def _should_teardown(self) -> bool:
|
|
93
|
+
return bool(self._created) and not self._torn_down
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class SyncFunctionSingleton(_BaseFunctionSingleton[threading.Lock]):
|
|
97
|
+
LOCK_METHOD = threading.Lock
|
|
98
|
+
|
|
99
|
+
def __call__(self, **kwargs: Any) -> Any:
|
|
100
|
+
existing = self._existing(kwargs)
|
|
101
|
+
if existing is not _UNSET:
|
|
102
|
+
return existing
|
|
103
|
+
with _signature.guard_against_cycles(self):
|
|
104
|
+
with self._lock:
|
|
105
|
+
existing = self._existing(kwargs)
|
|
106
|
+
if existing is not _UNSET:
|
|
107
|
+
return existing
|
|
108
|
+
if not kwargs:
|
|
109
|
+
kwargs = _signature.self_resolve_kwargs(self._fn)
|
|
110
|
+
_hooks.run_sync(self._hooks.before_start)
|
|
111
|
+
value = self._provider.create(**kwargs)
|
|
112
|
+
return self._commit(value, kwargs)
|
|
113
|
+
|
|
114
|
+
def teardown(self) -> None:
|
|
115
|
+
if not self._should_teardown():
|
|
116
|
+
return
|
|
117
|
+
if not self._before_end_done:
|
|
118
|
+
self._before_end_done = True
|
|
119
|
+
_hooks.run_sync(self._hooks.before_end)
|
|
120
|
+
self._provider.teardown()
|
|
121
|
+
_hooks.run_sync(self._hooks.after_end)
|
|
122
|
+
self._torn_down = True
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class AsyncFunctionSingleton(_BaseFunctionSingleton[asyncio.Lock]):
|
|
126
|
+
LOCK_METHOD = asyncio.Lock
|
|
127
|
+
|
|
128
|
+
async def __call__(self, **kwargs: Any) -> Any:
|
|
129
|
+
existing = self._existing(kwargs)
|
|
130
|
+
if existing is not _UNSET:
|
|
131
|
+
return existing
|
|
132
|
+
with _signature.guard_against_cycles(self):
|
|
133
|
+
async with self._lock:
|
|
134
|
+
existing = self._existing(kwargs)
|
|
135
|
+
if existing is not _UNSET:
|
|
136
|
+
return existing
|
|
137
|
+
if not kwargs:
|
|
138
|
+
kwargs = await _signature.async_self_resolve_kwargs(self._fn)
|
|
139
|
+
await _hooks.run_async(self._hooks.before_start)
|
|
140
|
+
value = await self._provider.create(**kwargs)
|
|
141
|
+
return self._commit(value, kwargs)
|
|
142
|
+
|
|
143
|
+
async def teardown(self) -> None:
|
|
144
|
+
if not self._should_teardown():
|
|
145
|
+
return
|
|
146
|
+
if not self._before_end_done:
|
|
147
|
+
self._before_end_done = True
|
|
148
|
+
await _hooks.run_async(self._hooks.before_end)
|
|
149
|
+
result = self._provider.teardown()
|
|
150
|
+
if inspect.isawaitable(result):
|
|
151
|
+
await result
|
|
152
|
+
await _hooks.run_async(self._hooks.after_end)
|
|
153
|
+
self._torn_down = True
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def make_function_singleton(
|
|
157
|
+
fn: Callable[..., Any],
|
|
158
|
+
) -> SyncFunctionSingleton | AsyncFunctionSingleton:
|
|
159
|
+
provider = Provider(fn)
|
|
160
|
+
cls = AsyncFunctionSingleton if provider.is_async else SyncFunctionSingleton
|
|
161
|
+
instance = cls(fn, provider)
|
|
162
|
+
_registry.register(instance)
|
|
163
|
+
return instance
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""before_start/before_end/after_end lifecycle hooks.
|
|
2
|
+
|
|
3
|
+
Hooks run in registration order. A hook may be sync or async, but firing an
|
|
4
|
+
async hook requires an await-capable path (an async provider, or the
|
|
5
|
+
lifespan context manager) - firing one from a purely sync path raises a
|
|
6
|
+
clear error rather than silently dropping it or blocking on the coroutine.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import inspect
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AsyncHookError(RuntimeError):
|
|
15
|
+
"""Raised when an async hook fires on a purely sync lifecycle path."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class HookRegistry:
|
|
19
|
+
def __init__(self) -> None:
|
|
20
|
+
self.before_start: list[Callable[[], Any]] = []
|
|
21
|
+
self.before_end: list[Callable[[], Any]] = []
|
|
22
|
+
self.after_end: list[Callable[[], Any]] = []
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def run_sync(hooks: list[Callable[[], Any]]) -> None:
|
|
26
|
+
for hook in hooks:
|
|
27
|
+
result = hook()
|
|
28
|
+
if inspect.isawaitable(result):
|
|
29
|
+
close = getattr(result, "close", None)
|
|
30
|
+
if close is not None:
|
|
31
|
+
close()
|
|
32
|
+
raise AsyncHookError(
|
|
33
|
+
f"{hook!r} is an async hook but fired on a sync singleton "
|
|
34
|
+
"lifecycle path. Use fastapi_singleton.lifespan, or make "
|
|
35
|
+
"the singleton's provider async, to run async hooks."
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def run_async(hooks: list[Callable[[], Any]]) -> None:
|
|
40
|
+
for hook in hooks:
|
|
41
|
+
result = hook()
|
|
42
|
+
if inspect.isawaitable(result):
|
|
43
|
+
await result
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""The `lifespan` async context manager: eager startup, reverse-order
|
|
2
|
+
teardown.
|
|
3
|
+
|
|
4
|
+
No explicit topological sort is needed: calling every registered singleton
|
|
5
|
+
once is enough, because self-resolution (see _signature.py) recursively
|
|
6
|
+
constructs a singleton's own dependencies before it finishes constructing
|
|
7
|
+
itself. Each singleton's `_created` timestamp is therefore set in a valid
|
|
8
|
+
dependency order (deps before dependents) for free, and sorting by it -
|
|
9
|
+
see registry.creation_order() - then reversing is a valid teardown order,
|
|
10
|
+
exactly like unwinding a stack of context managers.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import inspect
|
|
14
|
+
from collections.abc import AsyncIterator
|
|
15
|
+
from contextlib import asynccontextmanager
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from . import _registry
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def _teardown_all() -> None:
|
|
22
|
+
for singleton in reversed(_registry.creation_order()):
|
|
23
|
+
result = singleton.teardown()
|
|
24
|
+
if inspect.isawaitable(result):
|
|
25
|
+
await result
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@asynccontextmanager
|
|
29
|
+
async def lifespan(app: Any) -> AsyncIterator[None]:
|
|
30
|
+
try:
|
|
31
|
+
for singleton in _registry.all_singletons():
|
|
32
|
+
if singleton._created:
|
|
33
|
+
continue
|
|
34
|
+
result = singleton()
|
|
35
|
+
if inspect.isawaitable(result):
|
|
36
|
+
await result
|
|
37
|
+
except BaseException:
|
|
38
|
+
# A later singleton failing to construct must not leak whatever
|
|
39
|
+
# earlier singletons already acquired - tear down everything that
|
|
40
|
+
# did get created before re-raising.
|
|
41
|
+
await _teardown_all()
|
|
42
|
+
raise
|
|
43
|
+
try:
|
|
44
|
+
yield
|
|
45
|
+
finally:
|
|
46
|
+
await _teardown_all()
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Normalizes the four sync/async x plain/generator dependency shapes that
|
|
2
|
+
FastAPI itself supports into a single create-once/teardown-once interface.
|
|
3
|
+
|
|
4
|
+
Detection runs on the raw function the user wrote, never on a wrapper we
|
|
5
|
+
hand to FastAPI - see _function.py and _class.py for why that distinction
|
|
6
|
+
matters.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import inspect
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
_UNSET = object()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MultipleYieldError(RuntimeError):
|
|
17
|
+
"""Raised when a generator-based provider yields more than once."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Provider:
|
|
21
|
+
"""Drives a single sync/async, plain/generator callable exactly once."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, fn: Callable[..., Any]) -> None:
|
|
24
|
+
self._fn = fn
|
|
25
|
+
self._is_gen = inspect.isgeneratorfunction(fn)
|
|
26
|
+
self._is_async_gen = inspect.isasyncgenfunction(fn)
|
|
27
|
+
self._is_coroutine = inspect.iscoroutinefunction(fn)
|
|
28
|
+
self.is_async = self._is_async_gen or self._is_coroutine
|
|
29
|
+
self._generator: Any = _UNSET
|
|
30
|
+
|
|
31
|
+
def create(self, **kwargs: Any) -> Any:
|
|
32
|
+
if self._is_async_gen:
|
|
33
|
+
return self._acreate(**kwargs)
|
|
34
|
+
if self._is_coroutine:
|
|
35
|
+
return self._fn(**kwargs)
|
|
36
|
+
if self._is_gen:
|
|
37
|
+
generator = self._fn(**kwargs)
|
|
38
|
+
value = next(generator)
|
|
39
|
+
self._generator = generator
|
|
40
|
+
return value
|
|
41
|
+
return self._fn(**kwargs)
|
|
42
|
+
|
|
43
|
+
async def _acreate(self, **kwargs: Any) -> Any:
|
|
44
|
+
generator = self._fn(**kwargs)
|
|
45
|
+
value = await anext(generator)
|
|
46
|
+
self._generator = generator
|
|
47
|
+
return value
|
|
48
|
+
|
|
49
|
+
def teardown(self) -> Any:
|
|
50
|
+
if self._generator is _UNSET:
|
|
51
|
+
return None
|
|
52
|
+
if self._is_async_gen:
|
|
53
|
+
return self._ateardown()
|
|
54
|
+
generator = self._generator
|
|
55
|
+
sentinel = _UNSET
|
|
56
|
+
result = next(generator, sentinel)
|
|
57
|
+
if result is not sentinel:
|
|
58
|
+
raise MultipleYieldError(
|
|
59
|
+
f"{self._fn!r} yielded more than once; "
|
|
60
|
+
"singleton providers must yield exactly once"
|
|
61
|
+
)
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
async def _ateardown(self) -> None:
|
|
65
|
+
generator = self._generator
|
|
66
|
+
sentinel = _UNSET
|
|
67
|
+
result = await anext(generator, sentinel)
|
|
68
|
+
if result is not sentinel:
|
|
69
|
+
raise MultipleYieldError(
|
|
70
|
+
f"{self._fn!r} yielded more than once; "
|
|
71
|
+
"singleton providers must yield exactly once"
|
|
72
|
+
)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Process-global registry of every @singleton-wrapped object.
|
|
2
|
+
|
|
3
|
+
This package is single-FastAPI-app-per-process by design: singleton state
|
|
4
|
+
lives at module scope, the same way @lru_cache(maxsize=1) does. reset() is
|
|
5
|
+
provided for tests, where leaking state across test functions would
|
|
6
|
+
otherwise be a footgun.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Any, Protocol, runtime_checkable
|
|
10
|
+
|
|
11
|
+
#: marker attribute used by is_singleton() to recognize anything @singleton
|
|
12
|
+
#: produces, including objects (like _InstanceProxy) that don't themselves
|
|
13
|
+
#: own a create/teardown lifecycle but stand in for one.
|
|
14
|
+
MARKER = "__fastapi_singleton__"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@runtime_checkable
|
|
18
|
+
class _Lifecycle(Protocol):
|
|
19
|
+
#: unix timestamp set when the singleton is created, None until then.
|
|
20
|
+
#: Doubles as the creation-order sort key, so no separate list of
|
|
21
|
+
#: created singletons needs to be maintained.
|
|
22
|
+
_created: float | None
|
|
23
|
+
|
|
24
|
+
def __call__(self, *args: Any, **kwargs: Any) -> Any: ...
|
|
25
|
+
def teardown(self) -> Any: ...
|
|
26
|
+
def _reset(self) -> None: ...
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_singletons: list[_Lifecycle] = []
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def register(singleton: _Lifecycle) -> None:
|
|
33
|
+
_singletons.append(singleton)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def all_singletons() -> tuple[_Lifecycle, ...]:
|
|
37
|
+
return tuple(_singletons)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def creation_order() -> tuple[_Lifecycle, ...]:
|
|
41
|
+
created = [s for s in _singletons if s._created is not None]
|
|
42
|
+
created.sort(key=lambda s: s._created)
|
|
43
|
+
return tuple(created)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def is_singleton(obj: Any) -> bool:
|
|
47
|
+
return getattr(obj, MARKER, False) is True
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def reset() -> None:
|
|
51
|
+
for singleton in _singletons:
|
|
52
|
+
singleton._reset()
|
|
53
|
+
_singletons.clear()
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Depends()-aware signature introspection and self-resolution.
|
|
2
|
+
|
|
3
|
+
Mirrors fastapi.dependencies.utils.analyze_param's two ways of finding a
|
|
4
|
+
Depends() marker on a parameter (bare default, or Annotated[...] metadata),
|
|
5
|
+
so @singleton-decorated callables can be resolved both by FastAPI itself
|
|
6
|
+
(during a real request) and by our own code (direct calls in plain Python,
|
|
7
|
+
and the eager lifespan startup walk).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
import contextvars
|
|
12
|
+
import inspect
|
|
13
|
+
import math
|
|
14
|
+
import typing
|
|
15
|
+
from collections.abc import Callable, Iterator
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from fastapi.params import Depends
|
|
19
|
+
|
|
20
|
+
from . import _registry
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class UsageError(RuntimeError):
|
|
24
|
+
"""Raised when a singleton's dependency graph can't be resolved."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
#: ids of singletons currently under construction on this thread/task, used
|
|
28
|
+
#: to detect a singleton depending on itself, directly or transitively.
|
|
29
|
+
#: contextvars rather than a plain set: each thread gets its own context by
|
|
30
|
+
#: default, and the value propagates correctly across awaits within a single
|
|
31
|
+
#: asyncio task, so concurrent unrelated constructions never collide.
|
|
32
|
+
_constructing: contextvars.ContextVar[frozenset[int]] = contextvars.ContextVar(
|
|
33
|
+
"_constructing", default=frozenset()
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@contextlib.contextmanager
|
|
38
|
+
def guard_against_cycles(singleton: Any) -> Iterator[None]:
|
|
39
|
+
"""Raises UsageError if `singleton` is already being constructed further
|
|
40
|
+
up the current call stack, instead of letting construction proceed into
|
|
41
|
+
a re-acquire of its own non-reentrant lock, which would deadlock rather
|
|
42
|
+
than fail."""
|
|
43
|
+
current = _constructing.get()
|
|
44
|
+
key = id(singleton)
|
|
45
|
+
if key in current:
|
|
46
|
+
raise UsageError(
|
|
47
|
+
f"{singleton!r} depends on itself, directly or transitively. "
|
|
48
|
+
"A singleton's dependency graph must be acyclic."
|
|
49
|
+
)
|
|
50
|
+
token = _constructing.set(current | {key})
|
|
51
|
+
try:
|
|
52
|
+
yield
|
|
53
|
+
finally:
|
|
54
|
+
_constructing.reset(token)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _values_equal(a: Any, b: Any) -> bool:
|
|
58
|
+
"""Like `==`, but treats two NaN floats as equal to each other.
|
|
59
|
+
|
|
60
|
+
Plain `==` follows IEEE 754, where NaN is never equal to anything,
|
|
61
|
+
including another NaN - so a repeat call with a semantically-unchanged
|
|
62
|
+
NaN-valued argument would otherwise look like a conflicting argument."""
|
|
63
|
+
if (
|
|
64
|
+
isinstance(a, float)
|
|
65
|
+
and isinstance(b, float)
|
|
66
|
+
and math.isnan(a)
|
|
67
|
+
and math.isnan(b)
|
|
68
|
+
):
|
|
69
|
+
return True
|
|
70
|
+
if isinstance(a, dict) and isinstance(b, dict):
|
|
71
|
+
return a.keys() == b.keys() and all(_values_equal(a[key], b[key]) for key in a)
|
|
72
|
+
if (
|
|
73
|
+
isinstance(a, (list, tuple))
|
|
74
|
+
and isinstance(b, (list, tuple))
|
|
75
|
+
and len(a) == len(b)
|
|
76
|
+
):
|
|
77
|
+
return all(_values_equal(x, y) for x, y in zip(a, b))
|
|
78
|
+
return a == b
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def check_no_conflict(
|
|
82
|
+
name: str,
|
|
83
|
+
original: tuple[tuple[Any, ...], dict[str, Any]],
|
|
84
|
+
attempted: tuple[tuple[Any, ...], dict[str, Any]],
|
|
85
|
+
) -> None:
|
|
86
|
+
"""A singleton is constructed once; calling it again with the same
|
|
87
|
+
resolved args (e.g. FastAPI re-passing an already-cached nested
|
|
88
|
+
singleton dependency on every request) is a no-op, but calling it again
|
|
89
|
+
with genuinely different args is almost certainly a bug, not something
|
|
90
|
+
to silently ignore."""
|
|
91
|
+
attempted_args, attempted_kwargs = attempted
|
|
92
|
+
if not attempted_args and not attempted_kwargs:
|
|
93
|
+
return
|
|
94
|
+
if _values_equal(attempted, original):
|
|
95
|
+
return
|
|
96
|
+
raise UsageError(
|
|
97
|
+
f"{name} was already constructed with {original!r}; called again "
|
|
98
|
+
f"with different arguments {attempted!r}. A singleton is "
|
|
99
|
+
"constructed exactly once - if you need different configurations, "
|
|
100
|
+
"use separate singletons."
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def depends_params(fn: Callable[..., Any]) -> dict[str, Callable[..., Any]]:
|
|
105
|
+
signature = inspect.signature(fn)
|
|
106
|
+
try:
|
|
107
|
+
hints = typing.get_type_hints(fn, include_extras=True)
|
|
108
|
+
except NameError:
|
|
109
|
+
hints = {}
|
|
110
|
+
found: dict[str, Callable[..., Any]] = {}
|
|
111
|
+
for name, param in signature.parameters.items():
|
|
112
|
+
if name == "self":
|
|
113
|
+
continue
|
|
114
|
+
depends = None
|
|
115
|
+
if isinstance(param.default, Depends):
|
|
116
|
+
depends = param.default
|
|
117
|
+
else:
|
|
118
|
+
annotation = hints.get(name, param.annotation)
|
|
119
|
+
if typing.get_origin(annotation) is typing.Annotated:
|
|
120
|
+
for meta in typing.get_args(annotation)[1:]:
|
|
121
|
+
if isinstance(meta, Depends):
|
|
122
|
+
depends = meta
|
|
123
|
+
if depends is None:
|
|
124
|
+
continue
|
|
125
|
+
target = depends.dependency
|
|
126
|
+
if target is None:
|
|
127
|
+
target = hints.get(name, param.annotation)
|
|
128
|
+
found[name] = target
|
|
129
|
+
return found
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _check_target(fn: Callable[..., Any], target: Callable[..., Any]) -> None:
|
|
133
|
+
if not _registry.is_singleton(target):
|
|
134
|
+
raise UsageError(
|
|
135
|
+
f"{fn!r} depends on {target!r} via Depends(), but {target!r} "
|
|
136
|
+
"is not @singleton-wrapped. Singletons can only depend on "
|
|
137
|
+
"other singletons, never on request-scoped data."
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def self_resolve_kwargs(fn: Callable[..., Any]) -> dict[str, Any]:
|
|
142
|
+
"""Sync resolution: raises if a dependency turns out to be async."""
|
|
143
|
+
kwargs: dict[str, Any] = {}
|
|
144
|
+
for name, target in depends_params(fn).items():
|
|
145
|
+
_check_target(fn, target)
|
|
146
|
+
result = target()
|
|
147
|
+
if inspect.isawaitable(result):
|
|
148
|
+
result.close()
|
|
149
|
+
raise UsageError(
|
|
150
|
+
f"{fn!r} depends on {target!r}, which is async. Resolve it "
|
|
151
|
+
"from an async context instead - make this singleton's own "
|
|
152
|
+
"provider `async def`, or rely on fastapi_singleton.lifespan "
|
|
153
|
+
"for eager startup, rather than calling it directly."
|
|
154
|
+
)
|
|
155
|
+
kwargs[name] = result
|
|
156
|
+
return kwargs
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
async def async_self_resolve_kwargs(fn: Callable[..., Any]) -> dict[str, Any]:
|
|
160
|
+
kwargs: dict[str, Any] = {}
|
|
161
|
+
for name, target in depends_params(fn).items():
|
|
162
|
+
_check_target(fn, target)
|
|
163
|
+
result = target()
|
|
164
|
+
if inspect.isawaitable(result):
|
|
165
|
+
result = await result
|
|
166
|
+
kwargs[name] = result
|
|
167
|
+
return kwargs
|
|
File without changes
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fastapi-singleton
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Application-scoped dependencies for FastAPI
|
|
5
|
+
Author: Alex Ward
|
|
6
|
+
Author-email: Alex Ward <alxwrd@googlemail.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Framework :: FastAPI
|
|
16
|
+
Requires-Dist: fastapi>=0.115
|
|
17
|
+
Requires-Python: >=3.13
|
|
18
|
+
Project-URL: Repository, https://github.com/alxwrd/fastapi-singleton
|
|
19
|
+
Project-URL: Releases, https://github.com/alxwrd/fastapi-singleton/releases
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
<div align="center">
|
|
23
|
+
<h1><code>fastapi-singleton</code></h1>
|
|
24
|
+
<p align="center"><i>
|
|
25
|
+
Application-scoped dependencies for <code>fastapi</code>
|
|
26
|
+
</i></p>
|
|
27
|
+
<img width="256px" src=".github/assets/three-card-trickster-768.png">
|
|
28
|
+
<div align="center">
|
|
29
|
+
<a href="https://github.com/alxwrd/fastapi-singleton/actions/workflows/test.yml"><img src="https://img.shields.io/github/actions/workflow/status/alxwrd/fastapi-singleton/test.yml?branch=main&label=main"></a>
|
|
30
|
+
<a href="https://pypi.python.org/pypi/fastapi-singleton"><img src="https://img.shields.io/pypi/v/fastapi-singleton.svg"></a>
|
|
31
|
+
<a href="https://github.com/alxwrd/fastapi-singleton/blob/main/LICENCE"><img src="https://img.shields.io/pypi/l/fastapi-singleton.svg?"></a>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
Every dependency resolved through FastAPI's `Depends` is request-scoped:
|
|
35
|
+
created on each request and discarded once the response is sent. That's the
|
|
36
|
+
right default for most things, but it's the wrong default for connection
|
|
37
|
+
pools, HTTP clients, and anything else that's expensive to create and safe to
|
|
38
|
+
share.
|
|
39
|
+
|
|
40
|
+
`fastapi-singleton` gives you a `@singleton` decorator that turns any
|
|
41
|
+
dependency, function or class, into one shared instance per process, with
|
|
42
|
+
proper startup and shutdown hooks wired into FastAPI's `lifespan`, instead of
|
|
43
|
+
leaving it to whatever a `SIGTERM` does to a `@lru_cache`d object.
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
## Example
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from typing import Annotated
|
|
50
|
+
|
|
51
|
+
from fastapi import Depends, FastAPI
|
|
52
|
+
from fastapi_singleton import singleton, lifespan
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@singleton
|
|
56
|
+
class Settings:
|
|
57
|
+
def __init__(self):
|
|
58
|
+
self.dsn = "postgresql://localhost/app"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@singleton
|
|
62
|
+
async def get_pool(settings: Annotated[Settings, Depends(Settings)]):
|
|
63
|
+
pool = await create_pool(settings.dsn)
|
|
64
|
+
yield pool
|
|
65
|
+
await pool.close()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@get_pool.before_start
|
|
69
|
+
def log_pool_starting():
|
|
70
|
+
logger.info("opening connection pool")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@get_pool.after_end
|
|
74
|
+
def log_pool_closed():
|
|
75
|
+
logger.info("connection pool closed")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
app = FastAPI(lifespan=lifespan)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@app.get("/users/{user_id}")
|
|
82
|
+
def read_user(pool: Annotated[Pool, Depends(get_pool)], user_id: int):
|
|
83
|
+
return pool.fetch_user(user_id)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
```plain
|
|
87
|
+
$ uvicorn app:app
|
|
88
|
+
|
|
89
|
+
INFO: opening connection pool
|
|
90
|
+
INFO: Application startup complete.
|
|
91
|
+
...
|
|
92
|
+
INFO: Shutting down
|
|
93
|
+
INFO: connection pool closed
|
|
94
|
+
INFO: Application shutdown complete.
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Installation
|
|
98
|
+
|
|
99
|
+
```shell
|
|
100
|
+
uv add fastapi-singleton
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Defining a singleton
|
|
104
|
+
|
|
105
|
+
`@singleton` wraps a function or a class so that it's only ever called once
|
|
106
|
+
per process; every dependant that resolves it via `Depends` receives the
|
|
107
|
+
exact same instance, the same guarantee `@lru_cache(maxsize=1)` gives you,
|
|
108
|
+
but tracked in a registry so its lifecycle can be managed instead of left to
|
|
109
|
+
the garbage collector.
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
@singleton
|
|
113
|
+
def get_other():
|
|
114
|
+
return Other()
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Singletons can depend on other singletons the same way any FastAPI
|
|
118
|
+
dependency does, by declaring them with `Depends` in the constructor or
|
|
119
|
+
function signature:
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
@singleton
|
|
123
|
+
class Connection:
|
|
124
|
+
def __init__(self, other: Annotated[Other, Depends(get_other)]):
|
|
125
|
+
self.other = other
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
A class singleton's `__init__` is the constructor, plain and simple -
|
|
129
|
+
`Depends(Connection)` calls it exactly once, the same way any FastAPI
|
|
130
|
+
class-based dependency works. `__init__` can never be `async def` in
|
|
131
|
+
Python, so a class singleton can't do real async setup itself - if you need
|
|
132
|
+
that (an async connection pool, an `await`-based client, anything with
|
|
133
|
+
teardown), write it as a function singleton instead and have your class
|
|
134
|
+
depend on it, the same way `Connection` depends on `get_other` above.
|
|
135
|
+
|
|
136
|
+
A singleton can't depend on a regular, request-scoped dependency - there's
|
|
137
|
+
no request to resolve it from when the singleton is constructed eagerly at
|
|
138
|
+
startup, or directly in plain Python. `@singleton`-ing something that
|
|
139
|
+
depends on non-singleton `Depends(...)` raises an error rather than silently
|
|
140
|
+
resolving it once and reusing stale data on every later request.
|
|
141
|
+
|
|
142
|
+
A singleton is also constructed exactly once: calling it again with the
|
|
143
|
+
same arguments it was first constructed with is a no-op (this is what lets
|
|
144
|
+
FastAPI re-resolve a singleton's own `Depends`-declared dependencies on
|
|
145
|
+
every request without recreating anything), but calling it again with
|
|
146
|
+
genuinely different arguments raises rather than silently ignoring them.
|
|
147
|
+
|
|
148
|
+
## Teardown with generators
|
|
149
|
+
|
|
150
|
+
If a singleton needs to release what it acquired, write it as a generator,
|
|
151
|
+
exactly like a request-scoped `yield` dependency in FastAPI. The code before
|
|
152
|
+
`yield` runs once, on creation; the code after `yield` runs once, on
|
|
153
|
+
shutdown.
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
@singleton
|
|
157
|
+
def get_other():
|
|
158
|
+
other = Other()
|
|
159
|
+
yield other
|
|
160
|
+
other.close()
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## Lifecycle hooks
|
|
164
|
+
|
|
165
|
+
Sometimes the setup or teardown you need isn't part of constructing the
|
|
166
|
+
resource itself, things like metrics, logging, or cache warming. Each
|
|
167
|
+
singleton exposes hooks you can register without touching its body:
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
@Connection.before_start
|
|
171
|
+
def before_start():
|
|
172
|
+
... # runs immediately before Connection is constructed
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@get_other.before_end
|
|
176
|
+
def before_end():
|
|
177
|
+
... # runs immediately before get_other's teardown executes
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@get_other.after_end
|
|
181
|
+
def after_end():
|
|
182
|
+
... # runs immediately after get_other's teardown completes
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
| Hook | Runs |
|
|
186
|
+
|---|---|
|
|
187
|
+
| `before_start` | Immediately before the singleton is constructed |
|
|
188
|
+
| `before_end` | Immediately before the singleton's teardown executes |
|
|
189
|
+
| `after_end` | Immediately after the singleton's teardown completes |
|
|
190
|
+
|
|
191
|
+
A singleton can register any number of hooks for each event; they run in
|
|
192
|
+
registration order.
|
|
193
|
+
|
|
194
|
+
## Wiring up the lifespan
|
|
195
|
+
|
|
196
|
+
Singletons are created lazily by default, on first resolution, the same as
|
|
197
|
+
`@lru_cache`. To get deterministic startup and shutdown instead, pass
|
|
198
|
+
`fastapi_singleton.lifespan` to your `FastAPI` app:
|
|
199
|
+
|
|
200
|
+
```python
|
|
201
|
+
from fastapi_singleton import lifespan
|
|
202
|
+
|
|
203
|
+
app = FastAPI(lifespan=lifespan)
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
On startup, every registered singleton is constructed eagerly, in dependency
|
|
207
|
+
order, so a connection pool is open and ready before the app accepts its
|
|
208
|
+
first request. On shutdown, each singleton is torn down in reverse order,
|
|
209
|
+
running any `before_end` hooks, its own post-`yield` teardown, then any
|
|
210
|
+
`after_end` hooks, like a stack of context managers being unwound.
|
|
211
|
+
|
|
212
|
+
If you already have a `lifespan` of your own, compose them:
|
|
213
|
+
|
|
214
|
+
```python
|
|
215
|
+
from contextlib import asynccontextmanager
|
|
216
|
+
|
|
217
|
+
from fastapi_singleton import lifespan as singleton_lifespan
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@asynccontextmanager
|
|
221
|
+
async def lifespan(app: FastAPI):
|
|
222
|
+
async with singleton_lifespan(app):
|
|
223
|
+
# your own startup
|
|
224
|
+
yield
|
|
225
|
+
# your own shutdown
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
app = FastAPI(lifespan=lifespan)
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Without the lifespan wired up, singletons still work, lazily, on first call,
|
|
232
|
+
like a plain `@lru_cache`, but nothing guarantees their teardown code runs;
|
|
233
|
+
register the lifespan whenever a singleton's cleanup actually matters.
|
|
234
|
+
|
|
235
|
+
## One process, one app
|
|
236
|
+
|
|
237
|
+
Singletons live in a process-global registry, the same way `@lru_cache`d
|
|
238
|
+
state does. That makes `fastapi-singleton` a fit for one `FastAPI` app per
|
|
239
|
+
process; running two `FastAPI(lifespan=lifespan)` apps side by side in the
|
|
240
|
+
same process means they'd share singleton state, including teardown. If
|
|
241
|
+
you're testing code that uses singletons, reset the registry between tests:
|
|
242
|
+
|
|
243
|
+
```python
|
|
244
|
+
from fastapi_singleton import reset
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@pytest.fixture(autouse=True)
|
|
248
|
+
def reset_singletons():
|
|
249
|
+
reset()
|
|
250
|
+
```
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
fastapi_singleton/__init__.py,sha256=g_uLWJ7LTWPT8rMEHudOoHnZHPcVnYXCr4tOOyALll0,602
|
|
2
|
+
fastapi_singleton/_class.py,sha256=X8blAUZe3eMvNmKo4FyV-11MBza-JiW2ibUxkFaClIY,3890
|
|
3
|
+
fastapi_singleton/_function.py,sha256=77Q6sPgTHr-CilUcp2wdSFyirSmGws43LhTdi-_7gtg,5886
|
|
4
|
+
fastapi_singleton/_hooks.py,sha256=SVRsEhxWCfV_l3bblJUPrWplFrIvH2fAHzTWafgIH9A,1453
|
|
5
|
+
fastapi_singleton/_lifespan.py,sha256=8TBPyaAqwvXSMdd5X-QrBWDSDyoiqv7LtONvngi3tMM,1557
|
|
6
|
+
fastapi_singleton/_provider.py,sha256=b_1bim_nrLKxPmDBbsiWVdgN0GnY3g0zBYD1SpNKXaA,2404
|
|
7
|
+
fastapi_singleton/_registry.py,sha256=AgYgcejH7640mOVMZAsGTPOyugwUDaEAV54K7Y7UXmk,1581
|
|
8
|
+
fastapi_singleton/_signature.py,sha256=3kvqWm7pGgzcu_tXOsH-pGP66m46gOsQ1BJiKgUEIi0,6135
|
|
9
|
+
fastapi_singleton/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
fastapi_singleton-0.1.0.dist-info/WHEEL,sha256=jROcLULcdzropX2J55opKw4UHhPFREZax2XzS-Mvpxs,80
|
|
11
|
+
fastapi_singleton-0.1.0.dist-info/METADATA,sha256=krwjk9yz6KrCcirbeEK-Z8lFFBpwi2Ntj8uBDV2u2DQ,8221
|
|
12
|
+
fastapi_singleton-0.1.0.dist-info/RECORD,,
|