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.
Files changed (109) hide show
  1. corekit/api/__init__.py +18 -3
  2. corekit/api/application.py +275 -0
  3. corekit/api/lifespan.py +233 -0
  4. corekit/api/middleware.py +93 -0
  5. corekit/api/routers.py +109 -1
  6. corekit/concurrency/__init__.py +2 -2
  7. corekit/concurrency/decorators.py +32 -5
  8. corekit/concurrency/thread_local.py +2 -2
  9. corekit/concurrency/worker.py +74 -65
  10. corekit/config/loader.py +42 -5
  11. corekit/config/settings.py +11 -1
  12. corekit/connections/__init__.py +7 -1
  13. corekit/connections/connectable.py +45 -4
  14. corekit/connections/redis/connection.py +53 -10
  15. corekit/connections/sql/__init__.py +33 -4
  16. corekit/connections/sql/connection.py +56 -3
  17. corekit/connections/sql/fields/__init__.py +2 -2
  18. corekit/connections/sql/fields/jsonb.py +13 -6
  19. corekit/connections/sql/migration/__init__.py +9 -5
  20. corekit/connections/sql/migration/base.py +3 -3
  21. corekit/connections/sql/migration/operations.py +135 -44
  22. corekit/connections/sql/migration/registry.py +2 -2
  23. corekit/connections/sql/operations/__init__.py +24 -0
  24. corekit/connections/sql/operations/base.py +111 -0
  25. corekit/connections/sql/operations/statements.py +170 -0
  26. corekit/connections/sql/query.py +4 -62
  27. corekit/connections/sql/table.py +33 -29
  28. corekit/crypto/__init__.py +3 -1
  29. corekit/crypto/constants.py +2 -2
  30. corekit/crypto/hasher.py +9 -4
  31. corekit/data/__init__.py +8 -0
  32. corekit/data/dataset.py +8 -2
  33. corekit/data/expressions/__init__.py +10 -2
  34. corekit/data/expressions/comparison.py +142 -123
  35. corekit/data/expressions/expression.py +71 -98
  36. corekit/data/expressions/operator.py +39 -0
  37. corekit/data/expressions/target.py +21 -0
  38. corekit/data/record.py +147 -147
  39. corekit/data/stats.py +162 -157
  40. corekit/decorators/__init__.py +2 -2
  41. corekit/decorators/exception_handling.py +38 -9
  42. corekit/docker/watchdog.py +50 -31
  43. corekit/etl/__init__.py +2 -1
  44. corekit/etl/connection.py +46 -44
  45. corekit/etl/extract/extractor.py +6 -13
  46. corekit/etl/orchestrator.py +19 -2
  47. corekit/etl/schemas.py +2 -2
  48. corekit/etl/transform/transformer.py +4 -1
  49. corekit/events/publisher.py +1 -1
  50. corekit/events/reader.py +26 -21
  51. corekit/events/sse.py +4 -1
  52. corekit/events/websocket.py +27 -13
  53. corekit/exceptions/__init__.py +33 -0
  54. corekit/exceptions/base.py +139 -10
  55. corekit/exceptions/enum.py +17 -0
  56. corekit/exceptions/types.py +6 -6
  57. corekit/files/__init__.py +2 -4
  58. corekit/files/base.py +15 -2
  59. corekit/files/enum.py +0 -5
  60. corekit/files/json.py +16 -2
  61. corekit/http/__init__.py +51 -0
  62. corekit/http/api.py +24 -0
  63. corekit/http/client.py +100 -73
  64. corekit/http/exceptions.py +140 -0
  65. corekit/http/response.py +50 -1
  66. corekit/http/status.py +89 -0
  67. corekit/jobs/__init__.py +26 -0
  68. corekit/jobs/registry.py +87 -0
  69. corekit/jobs/runner.py +80 -0
  70. corekit/jobs/task.py +173 -0
  71. corekit/log_monitor/models.py +8 -2
  72. corekit/log_monitor/service.py +77 -38
  73. corekit/notifications/base.py +18 -10
  74. corekit/observability/__init__.py +12 -3
  75. corekit/observability/benchmarkable.py +23 -5
  76. corekit/observability/loggable.py +21 -0
  77. corekit/observability/request_context.py +188 -0
  78. corekit/observability/timing/timer.py +4 -2
  79. corekit/registry/__init__.py +12 -7
  80. corekit/registry/ordered.py +86 -0
  81. corekit/registry/registry.py +55 -14
  82. corekit/schemas/__init__.py +10 -0
  83. corekit/schemas/enum.py +70 -49
  84. corekit/schemas/models/arbitrary.py +11 -11
  85. corekit/schemas/pydantic/fields.py +35 -35
  86. corekit/schemas/types.py +45 -40
  87. corekit/serialization/__init__.py +24 -0
  88. corekit/serialization/pickle_file.py +61 -0
  89. corekit/serialization/serializable.py +22 -2
  90. corekit/serialization/serializer.py +10 -3
  91. corekit/utils/__init__.py +59 -5
  92. corekit/utils/coercion.py +118 -0
  93. corekit/utils/collections.py +124 -0
  94. corekit/utils/ids.py +61 -5
  95. corekit/utils/payload.py +112 -0
  96. corekit/utils/raise_exc.py +8 -8
  97. corekit/utils/text.py +56 -0
  98. corekit/utils/time.py +74 -21
  99. corekit/utils/validators.py +15 -15
  100. corekit/utils/void.py +8 -8
  101. {python_corekit-0.1.1.dist-info → python_corekit-0.3.0.dist-info}/METADATA +103 -97
  102. python_corekit-0.3.0.dist-info/RECORD +145 -0
  103. corekit/constants.py +0 -45
  104. corekit/exceptions/http/exceptions.py +0 -37
  105. corekit/files/pickle.py +0 -12
  106. python_corekit-0.1.1.dist-info/RECORD +0 -125
  107. {python_corekit-0.1.1.dist-info → python_corekit-0.3.0.dist-info}/WHEEL +0 -0
  108. {python_corekit-0.1.1.dist-info → python_corekit-0.3.0.dist-info}/licenses/LICENSE +0 -0
  109. {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__ = ["BaseHandler", "SSEResponse", "SimpleRouter", "SmartRouter"]
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)
@@ -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]})"