python-corekit 0.1.1__py3-none-any.whl → 0.3.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.
- corekit/api/__init__.py +18 -3
- corekit/api/application.py +275 -0
- corekit/api/lifespan.py +233 -0
- corekit/api/middleware.py +93 -0
- corekit/api/routers.py +109 -1
- corekit/concurrency/__init__.py +2 -2
- corekit/concurrency/decorators.py +32 -5
- corekit/concurrency/thread_local.py +2 -2
- corekit/concurrency/worker.py +74 -65
- corekit/config/loader.py +42 -5
- corekit/config/settings.py +11 -1
- corekit/connections/__init__.py +7 -1
- corekit/connections/connectable.py +45 -4
- corekit/connections/redis/connection.py +53 -10
- corekit/connections/sql/__init__.py +33 -4
- corekit/connections/sql/connection.py +56 -3
- corekit/connections/sql/fields/__init__.py +2 -2
- corekit/connections/sql/fields/jsonb.py +13 -6
- corekit/connections/sql/migration/__init__.py +9 -5
- corekit/connections/sql/migration/base.py +3 -3
- corekit/connections/sql/migration/operations.py +135 -44
- corekit/connections/sql/migration/registry.py +2 -2
- corekit/connections/sql/operations/__init__.py +24 -0
- corekit/connections/sql/operations/base.py +111 -0
- corekit/connections/sql/operations/statements.py +170 -0
- corekit/connections/sql/query.py +4 -62
- corekit/connections/sql/table.py +33 -29
- corekit/crypto/__init__.py +3 -1
- corekit/crypto/constants.py +2 -2
- corekit/crypto/hasher.py +9 -4
- corekit/data/__init__.py +8 -0
- corekit/data/dataset.py +8 -2
- corekit/data/expressions/__init__.py +10 -2
- corekit/data/expressions/comparison.py +142 -123
- corekit/data/expressions/expression.py +71 -98
- corekit/data/expressions/operator.py +39 -0
- corekit/data/expressions/target.py +21 -0
- corekit/data/record.py +147 -147
- corekit/data/stats.py +162 -157
- corekit/decorators/__init__.py +2 -2
- corekit/decorators/exception_handling.py +38 -9
- corekit/docker/watchdog.py +50 -31
- corekit/etl/__init__.py +2 -1
- corekit/etl/connection.py +46 -44
- corekit/etl/extract/extractor.py +6 -13
- corekit/etl/orchestrator.py +19 -2
- corekit/etl/schemas.py +2 -2
- corekit/etl/transform/transformer.py +4 -1
- corekit/events/publisher.py +1 -1
- corekit/events/reader.py +26 -21
- corekit/events/sse.py +4 -1
- corekit/events/websocket.py +27 -13
- corekit/exceptions/__init__.py +33 -0
- corekit/exceptions/base.py +139 -10
- corekit/exceptions/enum.py +17 -0
- corekit/exceptions/types.py +6 -6
- corekit/files/__init__.py +2 -4
- corekit/files/base.py +15 -2
- corekit/files/enum.py +0 -5
- corekit/files/json.py +16 -2
- corekit/http/__init__.py +51 -0
- corekit/http/api.py +24 -0
- corekit/http/client.py +100 -73
- corekit/http/exceptions.py +140 -0
- corekit/http/response.py +50 -1
- corekit/http/status.py +89 -0
- corekit/jobs/__init__.py +26 -0
- corekit/jobs/registry.py +87 -0
- corekit/jobs/runner.py +80 -0
- corekit/jobs/task.py +173 -0
- corekit/log_monitor/models.py +8 -2
- corekit/log_monitor/service.py +77 -38
- corekit/notifications/base.py +18 -10
- corekit/observability/__init__.py +12 -3
- corekit/observability/benchmarkable.py +23 -5
- corekit/observability/loggable.py +21 -0
- corekit/observability/request_context.py +188 -0
- corekit/observability/timing/timer.py +4 -2
- corekit/registry/__init__.py +12 -7
- corekit/registry/ordered.py +86 -0
- corekit/registry/registry.py +55 -14
- corekit/schemas/__init__.py +10 -0
- corekit/schemas/enum.py +70 -49
- corekit/schemas/models/arbitrary.py +11 -11
- corekit/schemas/pydantic/fields.py +35 -35
- corekit/schemas/types.py +45 -40
- corekit/serialization/__init__.py +24 -0
- corekit/serialization/pickle_file.py +61 -0
- corekit/serialization/serializable.py +22 -2
- corekit/serialization/serializer.py +10 -3
- corekit/utils/__init__.py +59 -5
- corekit/utils/coercion.py +118 -0
- corekit/utils/collections.py +124 -0
- corekit/utils/ids.py +61 -5
- corekit/utils/payload.py +112 -0
- corekit/utils/raise_exc.py +8 -8
- corekit/utils/text.py +56 -0
- corekit/utils/time.py +74 -21
- corekit/utils/validators.py +15 -15
- corekit/utils/void.py +8 -8
- {python_corekit-0.1.1.dist-info → python_corekit-0.3.0.dist-info}/METADATA +103 -97
- python_corekit-0.3.0.dist-info/RECORD +145 -0
- corekit/constants.py +0 -45
- corekit/exceptions/http/exceptions.py +0 -37
- corekit/files/pickle.py +0 -12
- python_corekit-0.1.1.dist-info/RECORD +0 -125
- {python_corekit-0.1.1.dist-info → python_corekit-0.3.0.dist-info}/WHEEL +0 -0
- {python_corekit-0.1.1.dist-info → python_corekit-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {python_corekit-0.1.1.dist-info → python_corekit-0.3.0.dist-info}/top_level.txt +0 -0
corekit/api/__init__.py
CHANGED
|
@@ -1,9 +1,24 @@
|
|
|
1
1
|
"""
|
|
2
|
-
FastAPI building blocks: handlers, routers and responses.
|
|
2
|
+
FastAPI building blocks: an application, its lifespan, handlers, routers and responses.
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
+
from corekit.api.application import Application
|
|
5
6
|
from corekit.api.handler import BaseHandler
|
|
7
|
+
from corekit.api.lifespan import Lifespan, LifespanStep
|
|
8
|
+
from corekit.api.middleware import MiddlewareLayer, MiddlewareStack
|
|
6
9
|
from corekit.api.responses import SSEResponse
|
|
7
|
-
from corekit.api.routers import SimpleRouter, SmartRouter
|
|
10
|
+
from corekit.api.routers import CatchAllRouter, SimpleRouter, SmartRouter, router_registry
|
|
8
11
|
|
|
9
|
-
__all__ = [
|
|
12
|
+
__all__ = [
|
|
13
|
+
"Application",
|
|
14
|
+
"BaseHandler",
|
|
15
|
+
"CatchAllRouter",
|
|
16
|
+
"Lifespan",
|
|
17
|
+
"LifespanStep",
|
|
18
|
+
"MiddlewareLayer",
|
|
19
|
+
"MiddlewareStack",
|
|
20
|
+
"SSEResponse",
|
|
21
|
+
"SimpleRouter",
|
|
22
|
+
"SmartRouter",
|
|
23
|
+
"router_registry",
|
|
24
|
+
]
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A FastAPI application that can find its own routers.
|
|
3
|
+
|
|
4
|
+
``Application`` is a plain ``FastAPI`` subclass: every constructor argument,
|
|
5
|
+
including ``lifespan``, is passed straight through and behaves exactly as it
|
|
6
|
+
does upstream. What it adds is a short, explicit set of assembly steps::
|
|
7
|
+
|
|
8
|
+
from corekit.api import Application
|
|
9
|
+
from app.backend import routers
|
|
10
|
+
|
|
11
|
+
app = Application(lifespan=lifespan)
|
|
12
|
+
app.discover_routers(routers)
|
|
13
|
+
app.add_cors_middleware(allow_origins=ORIGINS, allow_credentials=True)
|
|
14
|
+
app.mount_static("/assets", "frontend/dist/assets")
|
|
15
|
+
|
|
16
|
+
Nothing happens that is not written down. No middleware is installed by
|
|
17
|
+
default -- middleware order is a security property, so it stays the caller's
|
|
18
|
+
decision -- and every step logs what it did.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import importlib
|
|
22
|
+
import pkgutil
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from types import ModuleType
|
|
25
|
+
from typing import Any, Sequence
|
|
26
|
+
|
|
27
|
+
from fastapi import FastAPI
|
|
28
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
29
|
+
from fastapi.responses import JSONResponse
|
|
30
|
+
from fastapi.staticfiles import StaticFiles
|
|
31
|
+
from starlette.middleware.trustedhost import TrustedHostMiddleware
|
|
32
|
+
|
|
33
|
+
from corekit.api.middleware import MiddlewareStack
|
|
34
|
+
from corekit.api.routers import CatchAllRouter, SimpleRouter, router_registry
|
|
35
|
+
from corekit.exceptions import CoreHTTPException
|
|
36
|
+
from corekit.observability.loggable import Loggable
|
|
37
|
+
|
|
38
|
+
__all__ = ["Application"]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _origin_in_package(router: SimpleRouter, package_name: str) -> bool:
|
|
42
|
+
"""
|
|
43
|
+
Whether this router was constructed by ``package_name`` or a submodule.
|
|
44
|
+
"""
|
|
45
|
+
origin = router._origin_module
|
|
46
|
+
return origin == package_name or origin.startswith(f"{package_name}.")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
async def _core_http_exception_handler(request: Any, exc: CoreHTTPException) -> JSONResponse:
|
|
50
|
+
"""
|
|
51
|
+
The same body FastAPI returns for ``HTTPException``.
|
|
52
|
+
"""
|
|
53
|
+
headers = getattr(exc, "headers", None)
|
|
54
|
+
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}, headers=headers)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Application(FastAPI, Loggable):
|
|
58
|
+
"""
|
|
59
|
+
``FastAPI`` plus router discovery, middleware helpers and static mounting.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
63
|
+
"""
|
|
64
|
+
Accepts every ``FastAPI`` argument and forwards it unchanged.
|
|
65
|
+
"""
|
|
66
|
+
FastAPI.__init__(self, *args, **kwargs)
|
|
67
|
+
Loggable.__init__(self)
|
|
68
|
+
self._register_http_exception_handler()
|
|
69
|
+
|
|
70
|
+
# ─── Routers ──────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
def discover_routers(self, package: ModuleType) -> list[SimpleRouter]:
|
|
73
|
+
"""
|
|
74
|
+
Import every module under ``package`` and mount the routers it defines.
|
|
75
|
+
|
|
76
|
+
Importing a module is what constructs its routers, so discovery is a
|
|
77
|
+
walk over ``package`` that lets each one register itself. Only routers
|
|
78
|
+
constructed inside that package are mounted: the registry is
|
|
79
|
+
process-wide, and an unclaimed router from some other import must not
|
|
80
|
+
ride along. A router that was included into another router is skipped
|
|
81
|
+
-- its parent already owns it -- and any ``CatchAllRouter`` is mounted
|
|
82
|
+
last.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
package: The imported package to search, e.g. ``app.backend.routers``.
|
|
86
|
+
Passing the module itself rather than a string keeps the
|
|
87
|
+
reference something an IDE can follow.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
The routers that were mounted, in mount order.
|
|
91
|
+
|
|
92
|
+
Raises:
|
|
93
|
+
ValueError: If the walk finds no routers. Calling this method is an
|
|
94
|
+
explicit statement that routers live in ``package``; finding
|
|
95
|
+
none there means the wrong package was passed, and an app that
|
|
96
|
+
silently serves nothing is worse than one that refuses to start.
|
|
97
|
+
"""
|
|
98
|
+
name = package.__name__
|
|
99
|
+
self.info(f"Discovering routers in {name}")
|
|
100
|
+
|
|
101
|
+
self._warn_on_unimportable_dirs(package)
|
|
102
|
+
|
|
103
|
+
scanned = self._import_submodules(package)
|
|
104
|
+
# unclaimed is every top-level router in the process. Mounting that
|
|
105
|
+
# list would include routers built by some other package that happens
|
|
106
|
+
# to be imported. Origin is where the instance was constructed, which
|
|
107
|
+
# is the scanned package for a router that belongs here.
|
|
108
|
+
mountable = [router for router in router_registry.unclaimed if _origin_in_package(router, name)]
|
|
109
|
+
if not mountable:
|
|
110
|
+
raise ValueError(
|
|
111
|
+
f"No routers found in '{name}' (scanned {scanned} modules). "
|
|
112
|
+
f"Check that the package is correct and that its modules build "
|
|
113
|
+
f"a SimpleRouter or SmartRouter at import time."
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
in_package = [router for router in router_registry if _origin_in_package(router, name)]
|
|
117
|
+
claimed = len(in_package) - len(mountable)
|
|
118
|
+
self.info(f" scanned {scanned} modules, found {len(in_package)} routers")
|
|
119
|
+
if claimed > 0:
|
|
120
|
+
plural = "s" if claimed != 1 else ""
|
|
121
|
+
self.info(f" skipped {claimed} sub-router{plural} (already included by a parent)")
|
|
122
|
+
|
|
123
|
+
for router in mountable:
|
|
124
|
+
self._mount_router(router)
|
|
125
|
+
|
|
126
|
+
self.info(f"Mounted {len(mountable)} routers")
|
|
127
|
+
return mountable
|
|
128
|
+
|
|
129
|
+
def _mount_router(self, router: SimpleRouter) -> None:
|
|
130
|
+
"""
|
|
131
|
+
Include one router and log what it contributes.
|
|
132
|
+
"""
|
|
133
|
+
prefix = router.prefix or "/"
|
|
134
|
+
kind = type(router).__name__
|
|
135
|
+
detail = ""
|
|
136
|
+
if router.dependencies:
|
|
137
|
+
names = [getattr(d.dependency, "__name__", "?") for d in router.dependencies]
|
|
138
|
+
detail = f" deps={names}"
|
|
139
|
+
if isinstance(router, CatchAllRouter):
|
|
140
|
+
detail += " (last)"
|
|
141
|
+
|
|
142
|
+
router.include(self)
|
|
143
|
+
self.info(f" + {prefix:<24} {router._origin_module:<40} {kind}{detail}")
|
|
144
|
+
|
|
145
|
+
def _warn_on_unimportable_dirs(self, package: ModuleType) -> None:
|
|
146
|
+
"""
|
|
147
|
+
Warn about directories the walk will silently skip.
|
|
148
|
+
|
|
149
|
+
``pkgutil.walk_packages`` only descends into directories that are
|
|
150
|
+
packages, so one missing ``__init__.py`` hides every router beneath it
|
|
151
|
+
with no error. An explicit import still works, which is why this can sit
|
|
152
|
+
unnoticed until discovery is the thing doing the importing.
|
|
153
|
+
"""
|
|
154
|
+
for root in package.__path__:
|
|
155
|
+
for child in sorted(Path(root).iterdir()):
|
|
156
|
+
if not child.is_dir() or child.name == "__pycache__":
|
|
157
|
+
continue
|
|
158
|
+
if (child / "__init__.py").exists():
|
|
159
|
+
continue
|
|
160
|
+
if not any(child.rglob("*.py")):
|
|
161
|
+
continue
|
|
162
|
+
self.warning(
|
|
163
|
+
f" ! {child} has .py files but no __init__.py, so it is not a package "
|
|
164
|
+
f"and everything under it will be skipped"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
@staticmethod
|
|
168
|
+
def _import_submodules(package: ModuleType) -> int:
|
|
169
|
+
"""
|
|
170
|
+
Import every submodule of ``package`` recursively, returning the count.
|
|
171
|
+
"""
|
|
172
|
+
count = 0
|
|
173
|
+
for info in pkgutil.walk_packages(package.__path__, package.__name__ + "."):
|
|
174
|
+
importlib.import_module(info.name)
|
|
175
|
+
count += 1
|
|
176
|
+
return count
|
|
177
|
+
|
|
178
|
+
# ─── Middleware ───────────────────────────────────────────────────────
|
|
179
|
+
#
|
|
180
|
+
# Middleware is an onion: add_middleware prepends, so the last one added
|
|
181
|
+
# runs outermost. That ordering is a security property -- proxy headers
|
|
182
|
+
# must be trusted before a host check reads them -- so these helpers cut
|
|
183
|
+
# the boilerplate without ever choosing the order for you.
|
|
184
|
+
#
|
|
185
|
+
# Only middleware that ships with FastAPI or Starlette gets a helper here.
|
|
186
|
+
# ProxyHeadersMiddleware belongs to uvicorn, which corekit does not depend
|
|
187
|
+
# on, so add it directly:
|
|
188
|
+
#
|
|
189
|
+
# from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
|
|
190
|
+
# app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="*")
|
|
191
|
+
|
|
192
|
+
def add_cors_middleware(
|
|
193
|
+
self,
|
|
194
|
+
allow_origins: Sequence[str],
|
|
195
|
+
allow_credentials: bool = True,
|
|
196
|
+
allow_methods: Sequence[str] = ("*",),
|
|
197
|
+
allow_headers: Sequence[str] = ("*",),
|
|
198
|
+
**kwargs: Any,
|
|
199
|
+
) -> None:
|
|
200
|
+
"""
|
|
201
|
+
Add CORS. ``allow_origins`` is required: browsers reject a wildcard
|
|
202
|
+
combined with credentials, so there is no safe default to pick.
|
|
203
|
+
"""
|
|
204
|
+
self.add_middleware(
|
|
205
|
+
CORSMiddleware,
|
|
206
|
+
allow_origins=list(allow_origins),
|
|
207
|
+
allow_credentials=allow_credentials,
|
|
208
|
+
allow_methods=list(allow_methods),
|
|
209
|
+
allow_headers=list(allow_headers),
|
|
210
|
+
**kwargs,
|
|
211
|
+
)
|
|
212
|
+
self.info(f" + CORSMiddleware(allow_origins={list(allow_origins)}, allow_credentials={allow_credentials})")
|
|
213
|
+
|
|
214
|
+
def add_trusted_host_middleware(self, allowed_hosts: Sequence[str], **kwargs: Any) -> None:
|
|
215
|
+
"""
|
|
216
|
+
Reject requests whose Host header is not in ``allowed_hosts``.
|
|
217
|
+
"""
|
|
218
|
+
self.add_middleware(TrustedHostMiddleware, allowed_hosts=list(allowed_hosts), **kwargs)
|
|
219
|
+
self.info(f" + TrustedHostMiddleware(allowed_hosts={list(allowed_hosts)})")
|
|
220
|
+
|
|
221
|
+
def add_middleware_stack(self, stack: "MiddlewareStack") -> None:
|
|
222
|
+
"""
|
|
223
|
+
Install a declared stack, outermost layer first.
|
|
224
|
+
|
|
225
|
+
``add_middleware`` prepends, so adding in declaration order would build
|
|
226
|
+
the onion inside out. The layers are applied in reverse here, which is
|
|
227
|
+
what lets the stack be *written* in the order a request meets them --
|
|
228
|
+
the order you need to read to check that, say, proxy headers are
|
|
229
|
+
trusted before a host check consults them.
|
|
230
|
+
"""
|
|
231
|
+
if not len(stack):
|
|
232
|
+
self.warning(" ! middleware stack is empty; nothing installed")
|
|
233
|
+
return
|
|
234
|
+
|
|
235
|
+
self.info(f"Installing {len(stack)} middleware layers (outermost first)")
|
|
236
|
+
for layer in reversed(stack.layers):
|
|
237
|
+
self.add_middleware(layer.middleware_class, **layer.options)
|
|
238
|
+
for position, layer in enumerate(stack.layers, start=1):
|
|
239
|
+
self.info(f" {position}. {layer.describe()}")
|
|
240
|
+
|
|
241
|
+
# ─── Static files ─────────────────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
def mount_static(self, url_path: str, directory: str | Path, name: str | None = None, **kwargs: Any) -> bool:
|
|
244
|
+
"""
|
|
245
|
+
Serve ``directory`` at ``url_path`` if it exists.
|
|
246
|
+
|
|
247
|
+
A missing directory is normal, not an error: a front end that has not
|
|
248
|
+
been built yet is the usual case in development, where a dev server
|
|
249
|
+
serves those assets instead.
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
True if the directory existed and was mounted.
|
|
253
|
+
"""
|
|
254
|
+
path = Path(directory)
|
|
255
|
+
if not path.is_dir():
|
|
256
|
+
self.info(f" - static {url_path} not mounted ({path} does not exist)")
|
|
257
|
+
return False
|
|
258
|
+
|
|
259
|
+
self.mount(url_path, StaticFiles(directory=str(path), **kwargs), name=name or url_path.strip("/"))
|
|
260
|
+
self.info(f" + static {url_path:<24} -> {path}")
|
|
261
|
+
return True
|
|
262
|
+
|
|
263
|
+
def _register_http_exception_handler(self) -> None:
|
|
264
|
+
"""
|
|
265
|
+
Handle the library's HTTP exceptions the same way FastAPI handles its own.
|
|
266
|
+
|
|
267
|
+
``CoreHTTPException`` is already an ``HTTPException``, so Starlette
|
|
268
|
+
would answer it. Registering the type explicitly keeps that response
|
|
269
|
+
-- ``{"detail": ...}`` only, never ``message`` or ``error`` -- even
|
|
270
|
+
if a later change in lookup order would otherwise miss the subclass.
|
|
271
|
+
A handler the caller already installed is left in place.
|
|
272
|
+
"""
|
|
273
|
+
if CoreHTTPException in self.exception_handlers:
|
|
274
|
+
return
|
|
275
|
+
self.add_exception_handler(CoreHTTPException, _core_http_exception_handler)
|
corekit/api/lifespan.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Startup and shutdown steps, collected into one object.
|
|
3
|
+
|
|
4
|
+
``Lifespan`` holds a list of steps and hands FastAPI a single lifespan. Each
|
|
5
|
+
step contributes startup work, shutdown work, or both::
|
|
6
|
+
|
|
7
|
+
lifespan = Lifespan()
|
|
8
|
+
lifespan.add("yap", startup=yap_manager.startup, shutdown=yap_manager.shutdown)
|
|
9
|
+
lifespan.add("rq", startup=schedule_all_tasks)
|
|
10
|
+
|
|
11
|
+
app = Application(lifespan=lifespan)
|
|
12
|
+
|
|
13
|
+
Steps start in the order added and shut down in reverse, the way nested
|
|
14
|
+
``with`` blocks unwind. If one fails on the way up, the steps that already
|
|
15
|
+
started are still torn down -- so a half-built application never leaks the
|
|
16
|
+
resources it did manage to acquire.
|
|
17
|
+
|
|
18
|
+
Two guarantees hold for a shutdown hook, so that writing one needs no
|
|
19
|
+
defensive boilerplate:
|
|
20
|
+
|
|
21
|
+
* It is called only if its own startup completed. A step that failed on the
|
|
22
|
+
way up does not have its shutdown run, so the hook may assume the state its
|
|
23
|
+
startup builds. Where that is wrong -- a startup that acquires something
|
|
24
|
+
before it can fail -- pass ``always_shutdown=True`` and handle partial state.
|
|
25
|
+
* A hook that raises cannot stop the unwind. The failure is logged and the
|
|
26
|
+
remaining steps still shut down.
|
|
27
|
+
|
|
28
|
+
Sync and async callables both work; a sync one is called directly, which is
|
|
29
|
+
fine for the fast bookkeeping that startup usually is. Anything slow enough to
|
|
30
|
+
block the event loop should be async, or should hand off to a thread itself.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
import inspect
|
|
34
|
+
from contextlib import AsyncExitStack, asynccontextmanager
|
|
35
|
+
from dataclasses import dataclass, field
|
|
36
|
+
from typing import Any, AsyncIterator, Awaitable, Callable
|
|
37
|
+
|
|
38
|
+
from corekit.observability.loggable import Loggable
|
|
39
|
+
|
|
40
|
+
__all__ = ["Lifespan", "LifespanStep"]
|
|
41
|
+
|
|
42
|
+
Hook = Callable[[], Any | Awaitable[Any]]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class LifespanStep:
|
|
47
|
+
"""
|
|
48
|
+
One named unit of startup and/or shutdown work.
|
|
49
|
+
|
|
50
|
+
``started`` records whether this step's startup ran to completion. It is
|
|
51
|
+
what lets the unwind tell a fully-started step from one that failed partway,
|
|
52
|
+
and it is set by ``Lifespan``, not by the caller.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
name: str
|
|
56
|
+
startup: Hook | None = None
|
|
57
|
+
shutdown: Hook | None = None
|
|
58
|
+
tags: tuple[str, ...] = field(default_factory=tuple)
|
|
59
|
+
always_shutdown: bool = False
|
|
60
|
+
started: bool = field(default=False, init=False)
|
|
61
|
+
|
|
62
|
+
def __post_init__(self) -> None:
|
|
63
|
+
if self.startup is None and self.shutdown is None:
|
|
64
|
+
raise ValueError(f"Lifespan step '{self.name}' does nothing: give it a startup, a shutdown, or both.")
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def needs_shutdown(self) -> bool:
|
|
68
|
+
"""
|
|
69
|
+
Whether the unwind should call this step's shutdown.
|
|
70
|
+
|
|
71
|
+
A step with no startup has nothing to half-do, so its shutdown always
|
|
72
|
+
runs. Otherwise the shutdown runs only if the startup completed --
|
|
73
|
+
unless the step opted into ``always_shutdown``, which is for a startup
|
|
74
|
+
that acquires something before it can fail.
|
|
75
|
+
"""
|
|
76
|
+
if self.shutdown is None:
|
|
77
|
+
return False
|
|
78
|
+
if self.startup is None or self.always_shutdown:
|
|
79
|
+
return True
|
|
80
|
+
return self.started
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class Lifespan(Loggable):
|
|
84
|
+
"""
|
|
85
|
+
A composable application lifespan.
|
|
86
|
+
|
|
87
|
+
Pass the instance straight to ``Application(lifespan=...)``: ``__call__`` is
|
|
88
|
+
the async context manager FastAPI expects, so no wrapper is needed.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def __init__(self) -> None:
|
|
92
|
+
super().__init__()
|
|
93
|
+
self._steps: list[LifespanStep] = []
|
|
94
|
+
|
|
95
|
+
def add(
|
|
96
|
+
self,
|
|
97
|
+
name: str,
|
|
98
|
+
startup: Hook | None = None,
|
|
99
|
+
shutdown: Hook | None = None,
|
|
100
|
+
tags: tuple[str, ...] = (),
|
|
101
|
+
always_shutdown: bool = False,
|
|
102
|
+
) -> "Lifespan":
|
|
103
|
+
"""
|
|
104
|
+
Append a step. Returns self, so calls can be chained.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
name: Shown in logs and in errors. Make it recognisable at 3am.
|
|
108
|
+
startup: Called on the way up, in the order steps were added.
|
|
109
|
+
shutdown: Called on the way down, in reverse order. Called only if
|
|
110
|
+
``startup`` completed, so it can assume what ``startup`` builds.
|
|
111
|
+
tags: Labels used by ``for_tags``. Untagged steps always run; a
|
|
112
|
+
tagged step runs only when one of its tags is selected. With
|
|
113
|
+
no filter, every step runs.
|
|
114
|
+
always_shutdown: Call ``shutdown`` even when ``startup`` raised.
|
|
115
|
+
For a startup that acquires something before it can fail -- a
|
|
116
|
+
connection opened, then a handshake that throws. The hook then
|
|
117
|
+
has to cope with partial state, which is why it is not default.
|
|
118
|
+
"""
|
|
119
|
+
self._steps.append(
|
|
120
|
+
LifespanStep(
|
|
121
|
+
name=name,
|
|
122
|
+
startup=startup,
|
|
123
|
+
shutdown=shutdown,
|
|
124
|
+
tags=tags,
|
|
125
|
+
always_shutdown=always_shutdown,
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
return self
|
|
129
|
+
|
|
130
|
+
def __len__(self) -> int:
|
|
131
|
+
return len(self._steps)
|
|
132
|
+
|
|
133
|
+
def __iter__(self) -> Any:
|
|
134
|
+
return iter(self._steps)
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def steps(self) -> list[LifespanStep]:
|
|
138
|
+
"""The registered steps, in the order they will start."""
|
|
139
|
+
return list(self._steps)
|
|
140
|
+
|
|
141
|
+
def for_tags(self, *tags: str) -> "Lifespan":
|
|
142
|
+
"""
|
|
143
|
+
A lifespan that runs the steps matching ``tags``, plus untagged ones.
|
|
144
|
+
|
|
145
|
+
An untagged step is ordinary startup and always runs. A tagged step
|
|
146
|
+
runs only when the caller asked for one of its tags, so a label is a
|
|
147
|
+
filter rather than decoration. No tags means every step, which is
|
|
148
|
+
what ``__call__`` does.
|
|
149
|
+
"""
|
|
150
|
+
if not tags:
|
|
151
|
+
return self
|
|
152
|
+
|
|
153
|
+
wanted = set(tags)
|
|
154
|
+
selected = Lifespan()
|
|
155
|
+
for step in self._steps:
|
|
156
|
+
if not step.tags or wanted.intersection(step.tags):
|
|
157
|
+
selected._steps.append(step)
|
|
158
|
+
return selected
|
|
159
|
+
|
|
160
|
+
@asynccontextmanager
|
|
161
|
+
async def __call__(self, app: Any = None) -> AsyncIterator[None]:
|
|
162
|
+
"""
|
|
163
|
+
Run every startup, yield to the application, then run every shutdown.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
app: The application, passed by FastAPI and not otherwise used.
|
|
167
|
+
"""
|
|
168
|
+
async with AsyncExitStack() as stack:
|
|
169
|
+
for step in self._steps:
|
|
170
|
+
await self._enter(stack, step)
|
|
171
|
+
self.info(f"Startup complete ({len(self._steps)} steps)")
|
|
172
|
+
yield
|
|
173
|
+
|
|
174
|
+
async def _enter(self, stack: AsyncExitStack, step: LifespanStep) -> None:
|
|
175
|
+
"""
|
|
176
|
+
Run one step's startup, having first armed its shutdown for the unwind.
|
|
177
|
+
|
|
178
|
+
The callback is pushed *before* the startup runs, because a startup that
|
|
179
|
+
throws halfway has already left the exit stack behind it -- pushing
|
|
180
|
+
afterwards would mean a failed boot never unwinds at all.
|
|
181
|
+
|
|
182
|
+
Whether that armed callback actually calls the hook is decided later, by
|
|
183
|
+
``needs_shutdown``. So the default is the safe one in both directions: a
|
|
184
|
+
shutdown hook is never handed a step that did not finish starting, and
|
|
185
|
+
the steps that did finish are always torn down.
|
|
186
|
+
"""
|
|
187
|
+
stack.push_async_callback(self._run_shutdown, step)
|
|
188
|
+
|
|
189
|
+
if step.startup is not None:
|
|
190
|
+
label = step.name if not step.tags else f"{step.name} [{', '.join(step.tags)}]"
|
|
191
|
+
self.info(f" ^ {label}")
|
|
192
|
+
try:
|
|
193
|
+
await self._maybe_await(step.startup)
|
|
194
|
+
except Exception:
|
|
195
|
+
self.exception(f"Startup step '{step.name}' failed; shutting down the steps that started")
|
|
196
|
+
raise
|
|
197
|
+
|
|
198
|
+
step.started = True
|
|
199
|
+
|
|
200
|
+
async def _run_shutdown(self, step: LifespanStep) -> None:
|
|
201
|
+
"""
|
|
202
|
+
Run one step's shutdown, if it should run, logging rather than raising.
|
|
203
|
+
|
|
204
|
+
Skipped for a step whose startup did not complete, so a hook can assume
|
|
205
|
+
the state its own startup builds. A step that acquires something before
|
|
206
|
+
it can fail sets ``always_shutdown`` and takes on that check itself.
|
|
207
|
+
|
|
208
|
+
Failures are logged and swallowed: a shutdown that raised would abandon
|
|
209
|
+
every step below it, so one noisy subsystem cannot stop the rest from
|
|
210
|
+
closing cleanly.
|
|
211
|
+
"""
|
|
212
|
+
if not step.needs_shutdown:
|
|
213
|
+
if step.shutdown is not None:
|
|
214
|
+
self.warning(f" - {step.name} (skipped: startup did not complete)")
|
|
215
|
+
return
|
|
216
|
+
|
|
217
|
+
label = step.name if not step.tags else f"{step.name} [{', '.join(step.tags)}]"
|
|
218
|
+
self.info(f" v {label}")
|
|
219
|
+
try:
|
|
220
|
+
await self._maybe_await(step.shutdown)
|
|
221
|
+
except Exception:
|
|
222
|
+
self.exception(f"Shutdown step '{step.name}' failed; continuing with the rest")
|
|
223
|
+
|
|
224
|
+
@staticmethod
|
|
225
|
+
async def _maybe_await(hook: Hook | None) -> None:
|
|
226
|
+
"""
|
|
227
|
+
Call ``hook``, awaiting it if it returns an awaitable.
|
|
228
|
+
"""
|
|
229
|
+
if hook is None:
|
|
230
|
+
return
|
|
231
|
+
result = hook()
|
|
232
|
+
if inspect.isawaitable(result):
|
|
233
|
+
await result
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Declared middleware stacks.
|
|
3
|
+
|
|
4
|
+
Middleware is an onion, and the order of the layers is a security property: a
|
|
5
|
+
host check that reads a client address before the proxy-header layer has
|
|
6
|
+
rewritten it is checking the proxy, not the client. So middleware is *not*
|
|
7
|
+
auto-discovered the way routers are. A router registers itself at import and
|
|
8
|
+
order between routers does not matter; if import order decided your security
|
|
9
|
+
layering, moving an import could silently open a hole.
|
|
10
|
+
|
|
11
|
+
Instead, a stack is declared in one place, outermost first -- the order a
|
|
12
|
+
request actually meets them::
|
|
13
|
+
|
|
14
|
+
stack = (
|
|
15
|
+
MiddlewareStack()
|
|
16
|
+
.add(ProxyHeadersMiddleware, trusted_hosts="*")
|
|
17
|
+
.add(TrustedHostMiddleware, allowed_hosts=HOSTS)
|
|
18
|
+
.add(CORSMiddleware, allow_origins=ORIGINS, allow_credentials=True)
|
|
19
|
+
)
|
|
20
|
+
app.add_middleware_stack(stack)
|
|
21
|
+
|
|
22
|
+
Reading top to bottom gives the order a request travels, which is the property
|
|
23
|
+
you need to check when reviewing it.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from corekit.registry import OrderedRegistry
|
|
30
|
+
|
|
31
|
+
__all__ = ["MiddlewareLayer", "MiddlewareStack"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class MiddlewareLayer:
|
|
36
|
+
"""
|
|
37
|
+
One middleware class and the keyword arguments it is built with.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
middleware_class: type
|
|
41
|
+
options: dict[str, Any] = field(default_factory=dict)
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def name(self) -> str:
|
|
45
|
+
"""
|
|
46
|
+
The middleware class name, for logging.
|
|
47
|
+
"""
|
|
48
|
+
return self.middleware_class.__name__
|
|
49
|
+
|
|
50
|
+
def describe(self) -> str:
|
|
51
|
+
"""
|
|
52
|
+
Render the layer as ``Name(key=value, ...)`` for a log line.
|
|
53
|
+
"""
|
|
54
|
+
if not self.options:
|
|
55
|
+
return f"{self.name}()"
|
|
56
|
+
rendered = ", ".join(f"{key}={value!r}" for key, value in self.options.items())
|
|
57
|
+
return f"{self.name}({rendered})"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class MiddlewareStack(OrderedRegistry[MiddlewareLayer]):
|
|
61
|
+
"""
|
|
62
|
+
An ordered set of middleware layers, declared outermost first.
|
|
63
|
+
|
|
64
|
+
Outermost first means the order a request meets them on the way in, and the
|
|
65
|
+
reverse of the order a response passes them on the way out. This is the
|
|
66
|
+
opposite of Starlette's ``add_middleware``, which prepends -- so the same
|
|
67
|
+
stack written for that API reads backwards. Declaring it in request order
|
|
68
|
+
is what makes a review of the ordering possible.
|
|
69
|
+
|
|
70
|
+
Ordering with duplicates allowed is exactly what ``OrderedRegistry``
|
|
71
|
+
provides, so the collection behaviour is inherited; ``add`` is the
|
|
72
|
+
middleware-shaped way to build an entry.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def add(self, middleware_class: type, **options: Any) -> "MiddlewareStack":
|
|
76
|
+
"""
|
|
77
|
+
Append a layer, inside every layer added before it. Chainable.
|
|
78
|
+
"""
|
|
79
|
+
self.register(MiddlewareLayer(middleware_class, options))
|
|
80
|
+
return self
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def layers(self) -> list[MiddlewareLayer]:
|
|
84
|
+
"""
|
|
85
|
+
The layers, outermost first. Reads better than ``entries`` at a call site.
|
|
86
|
+
"""
|
|
87
|
+
return self.entries
|
|
88
|
+
|
|
89
|
+
def __repr__(self) -> str:
|
|
90
|
+
"""
|
|
91
|
+
Return a formal representation naming the layers in order.
|
|
92
|
+
"""
|
|
93
|
+
return f"{type(self).__name__}({[layer.name for layer in self]})"
|