python-corekit 0.1.0__py3-none-any.whl → 0.2.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 (64) hide show
  1. corekit/api/__init__.py +18 -3
  2. corekit/api/application.py +237 -0
  3. corekit/api/lifespan.py +210 -0
  4. corekit/api/middleware.py +93 -0
  5. corekit/api/routers.py +109 -1
  6. corekit/concurrency/worker.py +65 -65
  7. corekit/config/settings.py +3 -3
  8. corekit/connections/sql/__init__.py +31 -3
  9. corekit/connections/sql/connection.py +19 -0
  10. corekit/connections/sql/migration/__init__.py +5 -5
  11. corekit/connections/sql/migration/base.py +3 -3
  12. corekit/connections/sql/migration/operations.py +66 -42
  13. corekit/connections/sql/migration/registry.py +2 -2
  14. corekit/connections/sql/operations/__init__.py +24 -0
  15. corekit/connections/sql/operations/base.py +102 -0
  16. corekit/connections/sql/operations/statements.py +150 -0
  17. corekit/connections/sql/query.py +4 -62
  18. corekit/connections/sql/table.py +30 -4
  19. corekit/constants.py +45 -45
  20. corekit/crypto/constants.py +4 -4
  21. corekit/data/__init__.py +8 -0
  22. corekit/data/expressions/__init__.py +10 -2
  23. corekit/data/expressions/comparison.py +184 -104
  24. corekit/data/expressions/expression.py +103 -98
  25. corekit/data/expressions/operator.py +54 -0
  26. corekit/data/expressions/target.py +21 -0
  27. corekit/data/record.py +147 -147
  28. corekit/data/stats.py +159 -157
  29. corekit/decorators/__init__.py +2 -2
  30. corekit/decorators/exception_handling.py +2 -1
  31. corekit/etl/connection.py +44 -44
  32. corekit/events/websocket.py +3 -2
  33. corekit/exceptions/__init__.py +18 -0
  34. corekit/http/__init__.py +13 -0
  35. corekit/jobs/__init__.py +26 -0
  36. corekit/jobs/registry.py +87 -0
  37. corekit/jobs/runner.py +69 -0
  38. corekit/jobs/task.py +152 -0
  39. corekit/observability/__init__.py +5 -3
  40. corekit/observability/request_context.py +135 -0
  41. corekit/registry/__init__.py +11 -6
  42. corekit/registry/ordered.py +86 -0
  43. corekit/schemas/__init__.py +10 -0
  44. corekit/schemas/enum.py +49 -49
  45. corekit/schemas/models/arbitrary.py +11 -11
  46. corekit/schemas/pydantic/fields.py +35 -35
  47. corekit/schemas/types.py +40 -40
  48. corekit/serialization/__init__.py +22 -0
  49. corekit/serialization/serializer.py +1 -1
  50. corekit/utils/__init__.py +59 -5
  51. corekit/utils/coercion.py +118 -0
  52. corekit/utils/collections.py +115 -0
  53. corekit/utils/ids.py +61 -5
  54. corekit/utils/payload.py +100 -0
  55. corekit/utils/raise_exc.py +8 -8
  56. corekit/utils/text.py +56 -0
  57. corekit/utils/time.py +74 -21
  58. corekit/utils/validators.py +15 -15
  59. corekit/utils/void.py +8 -8
  60. {python_corekit-0.1.0.dist-info → python_corekit-0.2.0.dist-info}/METADATA +105 -100
  61. {python_corekit-0.1.0.dist-info → python_corekit-0.2.0.dist-info}/RECORD +64 -46
  62. {python_corekit-0.1.0.dist-info → python_corekit-0.2.0.dist-info}/WHEEL +0 -0
  63. {python_corekit-0.1.0.dist-info → python_corekit-0.2.0.dist-info}/licenses/LICENSE +0 -0
  64. {python_corekit-0.1.0.dist-info → python_corekit-0.2.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,237 @@
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.staticfiles import StaticFiles
30
+ from starlette.middleware.trustedhost import TrustedHostMiddleware
31
+
32
+ from corekit.api.middleware import MiddlewareStack
33
+ from corekit.api.routers import CatchAllRouter, SimpleRouter, router_registry
34
+ from corekit.observability.loggable import Loggable
35
+
36
+ __all__ = ["Application"]
37
+
38
+
39
+ class Application(FastAPI, Loggable):
40
+ """
41
+ ``FastAPI`` plus router discovery, middleware helpers and static mounting.
42
+ """
43
+
44
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
45
+ """
46
+ Accepts every ``FastAPI`` argument and forwards it unchanged.
47
+ """
48
+ FastAPI.__init__(self, *args, **kwargs)
49
+ Loggable.__init__(self)
50
+
51
+ # ─── Routers ──────────────────────────────────────────────────────────
52
+
53
+ def discover_routers(self, package: ModuleType) -> list[SimpleRouter]:
54
+ """
55
+ Import every module under ``package`` and mount the routers it defines.
56
+
57
+ Importing a module is what constructs its routers, so discovery is a
58
+ walk over ``package`` that lets each one register itself. A router that
59
+ was included into another router during that walk is skipped -- its
60
+ parent already owns it -- and any ``CatchAllRouter`` is mounted last.
61
+
62
+ Args:
63
+ package: The imported package to search, e.g. ``app.backend.routers``.
64
+ Passing the module itself rather than a string keeps the
65
+ reference something an IDE can follow.
66
+
67
+ Returns:
68
+ The routers that were mounted, in mount order.
69
+
70
+ Raises:
71
+ ValueError: If the walk finds no routers. Calling this method is an
72
+ explicit statement that routers live in ``package``; finding
73
+ none there means the wrong package was passed, and an app that
74
+ silently serves nothing is worse than one that refuses to start.
75
+ """
76
+ name = package.__name__
77
+ self.info(f"Discovering routers in {name}")
78
+
79
+ self._warn_on_unimportable_dirs(package)
80
+
81
+ before = len(router_registry)
82
+ scanned = self._import_submodules(package)
83
+ discovered = len(router_registry) - before
84
+
85
+ mountable = router_registry.unclaimed
86
+ if not mountable:
87
+ raise ValueError(
88
+ f"No routers found in '{name}' (scanned {scanned} modules). "
89
+ f"Check that the package is correct and that its modules build "
90
+ f"a SimpleRouter or SmartRouter at import time."
91
+ )
92
+
93
+ claimed = discovered - len(mountable)
94
+ self.info(f" scanned {scanned} modules, found {discovered} routers")
95
+ if claimed > 0:
96
+ plural = "s" if claimed != 1 else ""
97
+ self.info(f" skipped {claimed} sub-router{plural} (already included by a parent)")
98
+
99
+ for router in mountable:
100
+ self._mount_router(router)
101
+
102
+ self.info(f"Mounted {len(mountable)} routers")
103
+ return mountable
104
+
105
+ def _mount_router(self, router: SimpleRouter) -> None:
106
+ """
107
+ Include one router and log what it contributes.
108
+ """
109
+ prefix = router.prefix or "/"
110
+ kind = type(router).__name__
111
+ detail = ""
112
+ if router.dependencies:
113
+ names = [getattr(d.dependency, "__name__", "?") for d in router.dependencies]
114
+ detail = f" deps={names}"
115
+ if isinstance(router, CatchAllRouter):
116
+ detail += " (last)"
117
+
118
+ router.include(self)
119
+ self.info(f" + {prefix:<24} {router._origin_module:<40} {kind}{detail}")
120
+
121
+ def _warn_on_unimportable_dirs(self, package: ModuleType) -> None:
122
+ """
123
+ Warn about directories the walk will silently skip.
124
+
125
+ ``pkgutil.walk_packages`` only descends into directories that are
126
+ packages, so one missing ``__init__.py`` hides every router beneath it
127
+ with no error. An explicit import still works, which is why this can sit
128
+ unnoticed until discovery is the thing doing the importing.
129
+ """
130
+ for root in package.__path__:
131
+ for child in sorted(Path(root).iterdir()):
132
+ if not child.is_dir() or child.name == "__pycache__":
133
+ continue
134
+ if (child / "__init__.py").exists():
135
+ continue
136
+ if not any(child.rglob("*.py")):
137
+ continue
138
+ self.warning(
139
+ f" ! {child} has .py files but no __init__.py, so it is not a package "
140
+ f"and everything under it will be skipped"
141
+ )
142
+
143
+ @staticmethod
144
+ def _import_submodules(package: ModuleType) -> int:
145
+ """
146
+ Import every submodule of ``package`` recursively, returning the count.
147
+ """
148
+ count = 0
149
+ for info in pkgutil.walk_packages(package.__path__, package.__name__ + "."):
150
+ importlib.import_module(info.name)
151
+ count += 1
152
+ return count
153
+
154
+ # ─── Middleware ───────────────────────────────────────────────────────
155
+ #
156
+ # Middleware is an onion: add_middleware prepends, so the last one added
157
+ # runs outermost. That ordering is a security property -- proxy headers
158
+ # must be trusted before a host check reads them -- so these helpers cut
159
+ # the boilerplate without ever choosing the order for you.
160
+ #
161
+ # Only middleware that ships with FastAPI or Starlette gets a helper here.
162
+ # ProxyHeadersMiddleware belongs to uvicorn, which corekit does not depend
163
+ # on, so add it directly:
164
+ #
165
+ # from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
166
+ # app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="*")
167
+
168
+ def add_cors_middleware(
169
+ self,
170
+ allow_origins: Sequence[str],
171
+ allow_credentials: bool = True,
172
+ allow_methods: Sequence[str] = ("*",),
173
+ allow_headers: Sequence[str] = ("*",),
174
+ **kwargs: Any,
175
+ ) -> None:
176
+ """
177
+ Add CORS. ``allow_origins`` is required: browsers reject a wildcard
178
+ combined with credentials, so there is no safe default to pick.
179
+ """
180
+ self.add_middleware(
181
+ CORSMiddleware,
182
+ allow_origins=list(allow_origins),
183
+ allow_credentials=allow_credentials,
184
+ allow_methods=list(allow_methods),
185
+ allow_headers=list(allow_headers),
186
+ **kwargs,
187
+ )
188
+ self.info(f" + CORSMiddleware(allow_origins={list(allow_origins)}, allow_credentials={allow_credentials})")
189
+
190
+ def add_trusted_host_middleware(self, allowed_hosts: Sequence[str], **kwargs: Any) -> None:
191
+ """
192
+ Reject requests whose Host header is not in ``allowed_hosts``.
193
+ """
194
+ self.add_middleware(TrustedHostMiddleware, allowed_hosts=list(allowed_hosts), **kwargs)
195
+ self.info(f" + TrustedHostMiddleware(allowed_hosts={list(allowed_hosts)})")
196
+
197
+ def add_middleware_stack(self, stack: "MiddlewareStack") -> None:
198
+ """
199
+ Install a declared stack, outermost layer first.
200
+
201
+ ``add_middleware`` prepends, so adding in declaration order would build
202
+ the onion inside out. The layers are applied in reverse here, which is
203
+ what lets the stack be *written* in the order a request meets them --
204
+ the order you need to read to check that, say, proxy headers are
205
+ trusted before a host check consults them.
206
+ """
207
+ if not len(stack):
208
+ self.warning(" ! middleware stack is empty; nothing installed")
209
+ return
210
+
211
+ self.info(f"Installing {len(stack)} middleware layers (outermost first)")
212
+ for layer in reversed(stack.layers):
213
+ self.add_middleware(layer.middleware_class, **layer.options)
214
+ for position, layer in enumerate(stack.layers, start=1):
215
+ self.info(f" {position}. {layer.describe()}")
216
+
217
+ # ─── Static files ─────────────────────────────────────────────────────
218
+
219
+ def mount_static(self, url_path: str, directory: str | Path, name: str | None = None, **kwargs: Any) -> bool:
220
+ """
221
+ Serve ``directory`` at ``url_path`` if it exists.
222
+
223
+ A missing directory is normal, not an error: a front end that has not
224
+ been built yet is the usual case in development, where a dev server
225
+ serves those assets instead.
226
+
227
+ Returns:
228
+ True if the directory existed and was mounted.
229
+ """
230
+ path = Path(directory)
231
+ if not path.is_dir():
232
+ self.info(f" - static {url_path} not mounted ({path} does not exist)")
233
+ return False
234
+
235
+ self.mount(url_path, StaticFiles(directory=str(path), **kwargs), name=name or url_path.strip("/"))
236
+ self.info(f" + static {url_path:<24} -> {path}")
237
+ return True
@@ -0,0 +1,210 @@
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: Free-form labels, for callers that group or filter steps.
112
+ always_shutdown: Call ``shutdown`` even when ``startup`` raised.
113
+ For a startup that acquires something before it can fail -- a
114
+ connection opened, then a handshake that throws. The hook then
115
+ has to cope with partial state, which is why it is not default.
116
+ """
117
+ self._steps.append(
118
+ LifespanStep(
119
+ name=name,
120
+ startup=startup,
121
+ shutdown=shutdown,
122
+ tags=tags,
123
+ always_shutdown=always_shutdown,
124
+ )
125
+ )
126
+ return self
127
+
128
+ def __len__(self) -> int:
129
+ return len(self._steps)
130
+
131
+ def __iter__(self) -> Any:
132
+ return iter(self._steps)
133
+
134
+ @property
135
+ def steps(self) -> list[LifespanStep]:
136
+ """The registered steps, in the order they will start."""
137
+ return list(self._steps)
138
+
139
+ @asynccontextmanager
140
+ async def __call__(self, app: Any = None) -> AsyncIterator[None]:
141
+ """
142
+ Run every startup, yield to the application, then run every shutdown.
143
+
144
+ Args:
145
+ app: The application, passed by FastAPI and not otherwise used.
146
+ """
147
+ async with AsyncExitStack() as stack:
148
+ for step in self._steps:
149
+ await self._enter(stack, step)
150
+ self.info(f"Startup complete ({len(self._steps)} steps)")
151
+ yield
152
+
153
+ async def _enter(self, stack: AsyncExitStack, step: LifespanStep) -> None:
154
+ """
155
+ Run one step's startup, having first armed its shutdown for the unwind.
156
+
157
+ The callback is pushed *before* the startup runs, because a startup that
158
+ throws halfway has already left the exit stack behind it -- pushing
159
+ afterwards would mean a failed boot never unwinds at all.
160
+
161
+ Whether that armed callback actually calls the hook is decided later, by
162
+ ``needs_shutdown``. So the default is the safe one in both directions: a
163
+ shutdown hook is never handed a step that did not finish starting, and
164
+ the steps that did finish are always torn down.
165
+ """
166
+ stack.push_async_callback(self._run_shutdown, step)
167
+
168
+ if step.startup is not None:
169
+ self.info(f" ^ {step.name}")
170
+ try:
171
+ await self._maybe_await(step.startup)
172
+ except Exception:
173
+ self.exception(f"Startup step '{step.name}' failed; shutting down the steps that started")
174
+ raise
175
+
176
+ step.started = True
177
+
178
+ async def _run_shutdown(self, step: LifespanStep) -> None:
179
+ """
180
+ Run one step's shutdown, if it should run, logging rather than raising.
181
+
182
+ Skipped for a step whose startup did not complete, so a hook can assume
183
+ the state its own startup builds. A step that acquires something before
184
+ it can fail sets ``always_shutdown`` and takes on that check itself.
185
+
186
+ Failures are logged and swallowed: a shutdown that raised would abandon
187
+ every step below it, so one noisy subsystem cannot stop the rest from
188
+ closing cleanly.
189
+ """
190
+ if not step.needs_shutdown:
191
+ if step.shutdown is not None:
192
+ self.warning(f" - {step.name} (skipped: startup did not complete)")
193
+ return
194
+
195
+ self.info(f" v {step.name}")
196
+ try:
197
+ await self._maybe_await(step.shutdown)
198
+ except Exception:
199
+ self.exception(f"Shutdown step '{step.name}' failed; continuing with the rest")
200
+
201
+ @staticmethod
202
+ async def _maybe_await(hook: Hook | None) -> None:
203
+ """
204
+ Call ``hook``, awaiting it if it returns an awaitable.
205
+ """
206
+ if hook is None:
207
+ return
208
+ result = hook()
209
+ if inspect.isawaitable(result):
210
+ 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]})"