fastapi-singleton 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.
@@ -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,229 @@
1
+ <div align="center">
2
+ <h1><code>fastapi-singleton</code></h1>
3
+ <p align="center"><i>
4
+ Application-scoped dependencies for <code>fastapi</code>
5
+ </i></p>
6
+ <img width="256px" src=".github/assets/three-card-trickster-768.png">
7
+ <div align="center">
8
+ <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>
9
+ <a href="https://pypi.python.org/pypi/fastapi-singleton"><img src="https://img.shields.io/pypi/v/fastapi-singleton.svg"></a>
10
+ <a href="https://github.com/alxwrd/fastapi-singleton/blob/main/LICENCE"><img src="https://img.shields.io/pypi/l/fastapi-singleton.svg?"></a>
11
+ </div>
12
+
13
+ Every dependency resolved through FastAPI's `Depends` is request-scoped:
14
+ created on each request and discarded once the response is sent. That's the
15
+ right default for most things, but it's the wrong default for connection
16
+ pools, HTTP clients, and anything else that's expensive to create and safe to
17
+ share.
18
+
19
+ `fastapi-singleton` gives you a `@singleton` decorator that turns any
20
+ dependency, function or class, into one shared instance per process, with
21
+ proper startup and shutdown hooks wired into FastAPI's `lifespan`, instead of
22
+ leaving it to whatever a `SIGTERM` does to a `@lru_cache`d object.
23
+ </div>
24
+
25
+ ## Example
26
+
27
+ ```python
28
+ from typing import Annotated
29
+
30
+ from fastapi import Depends, FastAPI
31
+ from fastapi_singleton import singleton, lifespan
32
+
33
+
34
+ @singleton
35
+ class Settings:
36
+ def __init__(self):
37
+ self.dsn = "postgresql://localhost/app"
38
+
39
+
40
+ @singleton
41
+ async def get_pool(settings: Annotated[Settings, Depends(Settings)]):
42
+ pool = await create_pool(settings.dsn)
43
+ yield pool
44
+ await pool.close()
45
+
46
+
47
+ @get_pool.before_start
48
+ def log_pool_starting():
49
+ logger.info("opening connection pool")
50
+
51
+
52
+ @get_pool.after_end
53
+ def log_pool_closed():
54
+ logger.info("connection pool closed")
55
+
56
+
57
+ app = FastAPI(lifespan=lifespan)
58
+
59
+
60
+ @app.get("/users/{user_id}")
61
+ def read_user(pool: Annotated[Pool, Depends(get_pool)], user_id: int):
62
+ return pool.fetch_user(user_id)
63
+ ```
64
+
65
+ ```plain
66
+ $ uvicorn app:app
67
+
68
+ INFO: opening connection pool
69
+ INFO: Application startup complete.
70
+ ...
71
+ INFO: Shutting down
72
+ INFO: connection pool closed
73
+ INFO: Application shutdown complete.
74
+ ```
75
+
76
+ ## Installation
77
+
78
+ ```shell
79
+ uv add fastapi-singleton
80
+ ```
81
+
82
+ ## Defining a singleton
83
+
84
+ `@singleton` wraps a function or a class so that it's only ever called once
85
+ per process; every dependant that resolves it via `Depends` receives the
86
+ exact same instance, the same guarantee `@lru_cache(maxsize=1)` gives you,
87
+ but tracked in a registry so its lifecycle can be managed instead of left to
88
+ the garbage collector.
89
+
90
+ ```python
91
+ @singleton
92
+ def get_other():
93
+ return Other()
94
+ ```
95
+
96
+ Singletons can depend on other singletons the same way any FastAPI
97
+ dependency does, by declaring them with `Depends` in the constructor or
98
+ function signature:
99
+
100
+ ```python
101
+ @singleton
102
+ class Connection:
103
+ def __init__(self, other: Annotated[Other, Depends(get_other)]):
104
+ self.other = other
105
+ ```
106
+
107
+ A class singleton's `__init__` is the constructor, plain and simple -
108
+ `Depends(Connection)` calls it exactly once, the same way any FastAPI
109
+ class-based dependency works. `__init__` can never be `async def` in
110
+ Python, so a class singleton can't do real async setup itself - if you need
111
+ that (an async connection pool, an `await`-based client, anything with
112
+ teardown), write it as a function singleton instead and have your class
113
+ depend on it, the same way `Connection` depends on `get_other` above.
114
+
115
+ A singleton can't depend on a regular, request-scoped dependency - there's
116
+ no request to resolve it from when the singleton is constructed eagerly at
117
+ startup, or directly in plain Python. `@singleton`-ing something that
118
+ depends on non-singleton `Depends(...)` raises an error rather than silently
119
+ resolving it once and reusing stale data on every later request.
120
+
121
+ A singleton is also constructed exactly once: calling it again with the
122
+ same arguments it was first constructed with is a no-op (this is what lets
123
+ FastAPI re-resolve a singleton's own `Depends`-declared dependencies on
124
+ every request without recreating anything), but calling it again with
125
+ genuinely different arguments raises rather than silently ignoring them.
126
+
127
+ ## Teardown with generators
128
+
129
+ If a singleton needs to release what it acquired, write it as a generator,
130
+ exactly like a request-scoped `yield` dependency in FastAPI. The code before
131
+ `yield` runs once, on creation; the code after `yield` runs once, on
132
+ shutdown.
133
+
134
+ ```python
135
+ @singleton
136
+ def get_other():
137
+ other = Other()
138
+ yield other
139
+ other.close()
140
+ ```
141
+
142
+ ## Lifecycle hooks
143
+
144
+ Sometimes the setup or teardown you need isn't part of constructing the
145
+ resource itself, things like metrics, logging, or cache warming. Each
146
+ singleton exposes hooks you can register without touching its body:
147
+
148
+ ```python
149
+ @Connection.before_start
150
+ def before_start():
151
+ ... # runs immediately before Connection is constructed
152
+
153
+
154
+ @get_other.before_end
155
+ def before_end():
156
+ ... # runs immediately before get_other's teardown executes
157
+
158
+
159
+ @get_other.after_end
160
+ def after_end():
161
+ ... # runs immediately after get_other's teardown completes
162
+ ```
163
+
164
+ | Hook | Runs |
165
+ |---|---|
166
+ | `before_start` | Immediately before the singleton is constructed |
167
+ | `before_end` | Immediately before the singleton's teardown executes |
168
+ | `after_end` | Immediately after the singleton's teardown completes |
169
+
170
+ A singleton can register any number of hooks for each event; they run in
171
+ registration order.
172
+
173
+ ## Wiring up the lifespan
174
+
175
+ Singletons are created lazily by default, on first resolution, the same as
176
+ `@lru_cache`. To get deterministic startup and shutdown instead, pass
177
+ `fastapi_singleton.lifespan` to your `FastAPI` app:
178
+
179
+ ```python
180
+ from fastapi_singleton import lifespan
181
+
182
+ app = FastAPI(lifespan=lifespan)
183
+ ```
184
+
185
+ On startup, every registered singleton is constructed eagerly, in dependency
186
+ order, so a connection pool is open and ready before the app accepts its
187
+ first request. On shutdown, each singleton is torn down in reverse order,
188
+ running any `before_end` hooks, its own post-`yield` teardown, then any
189
+ `after_end` hooks, like a stack of context managers being unwound.
190
+
191
+ If you already have a `lifespan` of your own, compose them:
192
+
193
+ ```python
194
+ from contextlib import asynccontextmanager
195
+
196
+ from fastapi_singleton import lifespan as singleton_lifespan
197
+
198
+
199
+ @asynccontextmanager
200
+ async def lifespan(app: FastAPI):
201
+ async with singleton_lifespan(app):
202
+ # your own startup
203
+ yield
204
+ # your own shutdown
205
+
206
+
207
+ app = FastAPI(lifespan=lifespan)
208
+ ```
209
+
210
+ Without the lifespan wired up, singletons still work, lazily, on first call,
211
+ like a plain `@lru_cache`, but nothing guarantees their teardown code runs;
212
+ register the lifespan whenever a singleton's cleanup actually matters.
213
+
214
+ ## One process, one app
215
+
216
+ Singletons live in a process-global registry, the same way `@lru_cache`d
217
+ state does. That makes `fastapi-singleton` a fit for one `FastAPI` app per
218
+ process; running two `FastAPI(lifespan=lifespan)` apps side by side in the
219
+ same process means they'd share singleton state, including teardown. If
220
+ you're testing code that uses singletons, reset the registry between tests:
221
+
222
+ ```python
223
+ from fastapi_singleton import reset
224
+
225
+
226
+ @pytest.fixture(autouse=True)
227
+ def reset_singletons():
228
+ reset()
229
+ ```
@@ -0,0 +1,61 @@
1
+ [project]
2
+ name = "fastapi-singleton"
3
+ version = "0.1.0"
4
+ description = "Application-scoped dependencies for FastAPI"
5
+ license = "MIT"
6
+ readme = "README.md"
7
+ authors = [
8
+ { name = "Alex Ward", email = "alxwrd@googlemail.com" }
9
+ ]
10
+ classifiers = [
11
+ "Intended Audience :: Developers",
12
+ "Operating System :: OS Independent",
13
+ "License :: OSI Approved :: MIT License",
14
+ "Programming Language :: Python",
15
+ "Programming Language :: Python :: 3.13",
16
+ "Programming Language :: Python :: 3.14",
17
+ "Programming Language :: Python :: 3 :: Only",
18
+ "Framework :: FastAPI",
19
+ ]
20
+ requires-python = ">=3.13"
21
+ dependencies = [
22
+ "fastapi>=0.115",
23
+ ]
24
+
25
+ [project.urls]
26
+ Repository = "https://github.com/alxwrd/fastapi-singleton"
27
+ Releases = "https://github.com/alxwrd/fastapi-singleton/releases"
28
+
29
+ [build-system]
30
+ requires = ["uv_build>=0.10.0,<0.11.0"]
31
+ build-backend = "uv_build"
32
+
33
+ [dependency-groups]
34
+ dev = [
35
+ "poethepoet>=0.42.1",
36
+ "ruff>=0.15.18",
37
+ "ty>=0.0.51",
38
+ "pytest>=8.0",
39
+ "pytest-asyncio>=0.24",
40
+ "httpx>=0.27",
41
+ "uvicorn>=0.30",
42
+ ]
43
+
44
+ [tool.poe.tasks]
45
+ _format = "ruff format"
46
+ _sort = "ruff check --select I --fix"
47
+ format = ["_format", "_sort"]
48
+ lint = "ruff check"
49
+ check = "ty check"
50
+ test = "pytest"
51
+ all = ["format", "lint", "check", "test"]
52
+ _check-format = "ruff format --check"
53
+ _check-sort = "ruff check --select I"
54
+ ci = ["_check-format", "_check-sort", "lint", "check", "test"]
55
+
56
+ [tool.poe.tasks.example]
57
+ cmd = "uvicorn example.main:app --reload"
58
+ help = "Run the example app from README.md's ConnectionPool walkthrough"
59
+
60
+ [tool.pytest.ini_options]
61
+ asyncio_mode = "auto"
@@ -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