taskflow-meter 1.0.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 (53) hide show
  1. taskflow_meter/__init__.py +47 -0
  2. taskflow_meter/_version.py +24 -0
  3. taskflow_meter/api/__init__.py +35 -0
  4. taskflow_meter/api/asgi.py +236 -0
  5. taskflow_meter/api/dispatch.py +128 -0
  6. taskflow_meter/api/http.py +210 -0
  7. taskflow_meter/api/router.py +113 -0
  8. taskflow_meter/api/routes.py +53 -0
  9. taskflow_meter/api/serializers.py +137 -0
  10. taskflow_meter/api/service.py +189 -0
  11. taskflow_meter/api/sse.py +222 -0
  12. taskflow_meter/api/wsgi.py +145 -0
  13. taskflow_meter/cli.py +287 -0
  14. taskflow_meter/collect/__init__.py +31 -0
  15. taskflow_meter/collect/attachment.py +208 -0
  16. taskflow_meter/collect/listener.py +161 -0
  17. taskflow_meter/collect/pipeline.py +229 -0
  18. taskflow_meter/collect/progress.py +170 -0
  19. taskflow_meter/conf.py +173 -0
  20. taskflow_meter/contrib/__init__.py +18 -0
  21. taskflow_meter/contrib/django.py +160 -0
  22. taskflow_meter/contrib/fastapi.py +149 -0
  23. taskflow_meter/contrib/flask.py +140 -0
  24. taskflow_meter/contrib/paste.py +96 -0
  25. taskflow_meter/contrib/pecan.py +84 -0
  26. taskflow_meter/datasource/__init__.py +33 -0
  27. taskflow_meter/datasource/base.py +154 -0
  28. taskflow_meter/datasource/memory.py +232 -0
  29. taskflow_meter/datasource/persistence.py +311 -0
  30. taskflow_meter/datasource/sqlalchemy/__init__.py +21 -0
  31. taskflow_meter/datasource/sqlalchemy/migrations/env.py +68 -0
  32. taskflow_meter/datasource/sqlalchemy/migrations/script.py.mako +25 -0
  33. taskflow_meter/datasource/sqlalchemy/migrations/versions/0001_initial.py +71 -0
  34. taskflow_meter/datasource/sqlalchemy/models.py +63 -0
  35. taskflow_meter/datasource/sqlalchemy/source.py +367 -0
  36. taskflow_meter/diff.py +223 -0
  37. taskflow_meter/events.py +129 -0
  38. taskflow_meter/fold.py +137 -0
  39. taskflow_meter/meter.py +255 -0
  40. taskflow_meter/models.py +143 -0
  41. taskflow_meter/poller.py +191 -0
  42. taskflow_meter/py.typed +0 -0
  43. taskflow_meter/states.py +60 -0
  44. taskflow_meter/transports/__init__.py +21 -0
  45. taskflow_meter/transports/amqp.py +204 -0
  46. taskflow_meter/transports/base.py +105 -0
  47. taskflow_meter/transports/http.py +97 -0
  48. taskflow_meter/transports/memory.py +83 -0
  49. taskflow_meter-1.0.0.dist-info/METADATA +258 -0
  50. taskflow_meter-1.0.0.dist-info/RECORD +53 -0
  51. taskflow_meter-1.0.0.dist-info/WHEEL +4 -0
  52. taskflow_meter-1.0.0.dist-info/entry_points.txt +19 -0
  53. taskflow_meter-1.0.0.dist-info/licenses/LICENSE +176 -0
@@ -0,0 +1,47 @@
1
+ # Licensed under the Apache License, Version 2.0 (the "License"); you may
2
+ # not use this file except in compliance with the License. You may obtain
3
+ # a copy of the License at
4
+ #
5
+ # http://www.apache.org/licenses/LICENSE-2.0
6
+ #
7
+ # Unless required by applicable law or agreed to in writing, software
8
+ # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
9
+ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
10
+ # License for the specific language governing permissions and limitations
11
+ # under the License.
12
+
13
+ """Monitoring interfaces for OpenStack TaskFlow flow execution progress.
14
+
15
+ See ``docs/PLAN.md`` for the design this package is being built out against.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ __all__ = ["__version__"]
21
+
22
+
23
+ def _detect_version() -> str:
24
+ """Resolve the distribution version.
25
+
26
+ ``_version.py`` is generated at build time by hatch-vcs and is therefore
27
+ absent from a source checkout that has never been built. Fall back to the
28
+ installed distribution metadata, then to a sentinel, so importing the
29
+ package never fails just because of version plumbing.
30
+ """
31
+ try:
32
+ from taskflow_meter._version import __version__ as version
33
+ except ImportError:
34
+ pass
35
+ else:
36
+ return str(version)
37
+
38
+ from importlib.metadata import PackageNotFoundError
39
+ from importlib.metadata import version as _version
40
+
41
+ try:
42
+ return _version("taskflow-meter")
43
+ except PackageNotFoundError:
44
+ return "0.0.0.dev0"
45
+
46
+
47
+ __version__: str = _detect_version()
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '1.0.0'
22
+ __version_tuple__ = version_tuple = (1, 0, 0)
23
+
24
+ __commit_id__ = commit_id = None
@@ -0,0 +1,35 @@
1
+ # Licensed under the Apache License, Version 2.0 (the "License"); you may
2
+ # not use this file except in compliance with the License. You may obtain
3
+ # a copy of the License at
4
+ #
5
+ # http://www.apache.org/licenses/LICENSE-2.0
6
+ #
7
+ # Unless required by applicable law or agreed to in writing, software
8
+ # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
9
+ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
10
+ # License for the specific language governing permissions and limitations
11
+ # under the License.
12
+
13
+ """The HTTP API: a service, a route table, and adapters over both."""
14
+
15
+ from __future__ import annotations
16
+
17
+ from taskflow_meter.api.asgi import ASGIApp
18
+ from taskflow_meter.api.http import ApiError
19
+ from taskflow_meter.api.http import MeterRequest
20
+ from taskflow_meter.api.http import MeterResponse
21
+ from taskflow_meter.api.router import Route
22
+ from taskflow_meter.api.router import Router
23
+ from taskflow_meter.api.service import MeterService
24
+ from taskflow_meter.api.wsgi import WSGIApp
25
+
26
+ __all__ = [
27
+ "ASGIApp",
28
+ "ApiError",
29
+ "MeterRequest",
30
+ "MeterResponse",
31
+ "MeterService",
32
+ "Route",
33
+ "Router",
34
+ "WSGIApp",
35
+ ]
@@ -0,0 +1,236 @@
1
+ # Licensed under the Apache License, Version 2.0 (the "License"); you may
2
+ # not use this file except in compliance with the License. You may obtain
3
+ # a copy of the License at
4
+ #
5
+ # http://www.apache.org/licenses/LICENSE-2.0
6
+ #
7
+ # Unless required by applicable law or agreed to in writing, software
8
+ # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
9
+ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
10
+ # License for the specific language governing permissions and limitations
11
+ # under the License.
12
+
13
+ """A plain ASGI 3 callable, mountable anywhere.
14
+
15
+ Nothing here is Starlette-aware beyond one rule it also follows: the
16
+ path to route on is ``scope["path"]`` with ``root_path`` removed *only
17
+ when it is genuinely a prefix*. That covers modern Starlette (which
18
+ extends ``root_path`` and leaves ``path`` whole), older versions (which
19
+ stripped ``path`` instead), and a server given ``--root-path`` where the
20
+ prefix never appears in ``path`` at all.
21
+
22
+ Startup is the other thing a mounted app has to get right. **A mounted
23
+ ASGI app never receives the lifespan scope** -- the host router handles
24
+ it at the root and does not forward it -- so the meter is started
25
+ lazily on the first request as well as from lifespan, and neither path
26
+ minds the other having gone first.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import asyncio
32
+ import logging
33
+ from collections.abc import Awaitable
34
+ from collections.abc import Callable
35
+ from collections.abc import Iterable
36
+ from typing import Any
37
+
38
+ from taskflow_meter.api.dispatch import Dispatcher
39
+ from taskflow_meter.api.http import MeterRequest
40
+ from taskflow_meter.api.http import MeterResponse
41
+ from taskflow_meter.api.http import split_path
42
+ from taskflow_meter.api.service import MeterService
43
+ from taskflow_meter.api.sse import StreamResponse
44
+ from taskflow_meter.api.sse import aiter_frames
45
+ from taskflow_meter.meter import Meter
46
+
47
+ LOG = logging.getLogger(__name__)
48
+
49
+ Scope = dict[str, Any]
50
+ Receive = Callable[[], Awaitable[dict[str, Any]]]
51
+ Send = Callable[[dict[str, Any]], Awaitable[None]]
52
+
53
+ #: How often a live stream looks for new events.
54
+ DEFAULT_STREAM_INTERVAL = 1.0
55
+
56
+ #: How long a quiet stream waits before sending a keep-alive comment.
57
+ DEFAULT_HEARTBEAT = 15.0
58
+
59
+ #: Honoured when a reverse proxy strips a prefix the app never sees.
60
+ PREFIX_HEADER = "x-forwarded-prefix"
61
+
62
+
63
+ class ASGIApp:
64
+ """Serves the meter over ASGI, standalone or mounted."""
65
+
66
+ def __init__(
67
+ self,
68
+ meter: Meter,
69
+ *,
70
+ service: MeterService | None = None,
71
+ stream_interval: float = DEFAULT_STREAM_INTERVAL,
72
+ heartbeat: float = DEFAULT_HEARTBEAT,
73
+ ) -> None:
74
+ self.meter = meter
75
+ self.service = service or MeterService(meter)
76
+ self.dispatcher = Dispatcher(self.service)
77
+ self.stream_interval = stream_interval
78
+ self.heartbeat = heartbeat
79
+
80
+ async def __call__(
81
+ self, scope: Scope, receive: Receive, send: Send
82
+ ) -> None:
83
+ kind = scope["type"]
84
+ if kind == "lifespan":
85
+ await self._lifespan(receive, send)
86
+ return
87
+ if kind == "http":
88
+ await self._http(scope, receive, send)
89
+ return
90
+ if kind == "websocket":
91
+ # Declining cleanly beats leaving the handshake hanging.
92
+ await send({"type": "websocket.close", "code": 1000})
93
+ return
94
+ msg = f"unsupported ASGI scope: {kind!r}"
95
+ raise RuntimeError(msg)
96
+
97
+ # -- lifespan --------------------------------------------------------
98
+
99
+ async def _lifespan(self, receive: Receive, send: Send) -> None:
100
+ """Run the meter for as long as the server is up.
101
+
102
+ Only ever reached when this app is the root one; a mount never
103
+ sees these messages.
104
+ """
105
+ while True:
106
+ message = await receive()
107
+ if message["type"] == "lifespan.startup":
108
+ try:
109
+ await asyncio.to_thread(self.meter.start)
110
+ except Exception as exc:
111
+ LOG.exception("meter failed to start")
112
+ await send(
113
+ {
114
+ "type": "lifespan.startup.failed",
115
+ "message": repr(exc),
116
+ }
117
+ )
118
+ return
119
+ await send({"type": "lifespan.startup.complete"})
120
+ elif message["type"] == "lifespan.shutdown":
121
+ try:
122
+ await asyncio.to_thread(self.meter.stop)
123
+ except Exception as exc: # pragma: no cover - defensive
124
+ await send(
125
+ {
126
+ "type": "lifespan.shutdown.failed",
127
+ "message": repr(exc),
128
+ }
129
+ )
130
+ return
131
+ await send({"type": "lifespan.shutdown.complete"})
132
+ return
133
+
134
+ # -- requests --------------------------------------------------------
135
+
136
+ async def _http(self, scope: Scope, receive: Receive, send: Send) -> None:
137
+ # The lazy half of the lifecycle: a mounted app is never told
138
+ # when the server started, so the first request says so.
139
+ await asyncio.to_thread(self.meter.ensure_started)
140
+
141
+ request = build_request(scope)
142
+ result = await asyncio.to_thread(self.dispatcher.dispatch, request)
143
+
144
+ if isinstance(result, StreamResponse):
145
+ await self._stream(result, receive, send)
146
+ return
147
+ await send_response(send, result)
148
+
149
+ # -- streaming -------------------------------------------------------
150
+
151
+ async def _stream(
152
+ self, stream: StreamResponse, receive: Receive, send: Send
153
+ ) -> None:
154
+ await send(
155
+ {
156
+ "type": "http.response.start",
157
+ "status": stream.status,
158
+ "headers": encode_headers(stream.headers),
159
+ }
160
+ )
161
+ cursor = stream.cursor
162
+ disconnected = asyncio.Event()
163
+ watcher = asyncio.create_task(watch_disconnect(receive, disconnected))
164
+ try:
165
+ async for chunk in aiter_frames(
166
+ cursor,
167
+ interval=self.stream_interval,
168
+ heartbeat=self.heartbeat,
169
+ stop=disconnected,
170
+ ):
171
+ await send_chunk(send, chunk)
172
+ finally:
173
+ watcher.cancel()
174
+ if not disconnected.is_set():
175
+ # An empty final chunk is what tells the server the
176
+ # response is over; without it the client waits forever.
177
+ await send({"type": "http.response.body", "body": b""})
178
+
179
+
180
+ async def watch_disconnect(
181
+ receive: Receive, disconnected: asyncio.Event
182
+ ) -> None:
183
+ """Set ``disconnected`` when the client goes away."""
184
+ try:
185
+ while True:
186
+ message = await receive()
187
+ if message["type"] == "http.disconnect":
188
+ disconnected.set()
189
+ return
190
+ except asyncio.CancelledError: # pragma: no cover - shutdown path
191
+ raise
192
+
193
+
194
+ async def send_chunk(send: Send, body: bytes) -> None:
195
+ await send({"type": "http.response.body", "body": body, "more_body": True})
196
+
197
+
198
+ async def send_response(send: Send, response: MeterResponse) -> None:
199
+ await send(
200
+ {
201
+ "type": "http.response.start",
202
+ "status": response.status,
203
+ "headers": encode_headers(response.headers),
204
+ }
205
+ )
206
+ await send({"type": "http.response.body", "body": response.body})
207
+
208
+
209
+ def encode_headers(
210
+ headers: tuple[tuple[str, str], ...],
211
+ ) -> list[tuple[bytes, bytes]]:
212
+ return [(key.encode(), value.encode()) for key, value in headers]
213
+
214
+
215
+ def decode_headers(
216
+ raw: Iterable[tuple[bytes, bytes]] | None,
217
+ ) -> dict[str, str]:
218
+ return {
219
+ key.decode("latin-1").lower(): value.decode("latin-1")
220
+ for key, value in raw or ()
221
+ }
222
+
223
+
224
+ def build_request(scope: Scope) -> MeterRequest:
225
+ """Turn an ASGI scope into a framework-neutral request."""
226
+ root_path = scope.get("root_path", "")
227
+ path = scope.get("path", "/")
228
+ headers = decode_headers(scope.get("headers"))
229
+ query_string = scope.get("query_string", b"").decode("latin-1")
230
+ return MeterRequest.from_query_string(
231
+ query_string,
232
+ method=scope.get("method", "GET"),
233
+ path=split_path(path, root_path),
234
+ prefix=headers.get(PREFIX_HEADER, "") + root_path,
235
+ headers=headers,
236
+ )
@@ -0,0 +1,128 @@
1
+ # Licensed under the Apache License, Version 2.0 (the "License"); you may
2
+ # not use this file except in compliance with the License. You may obtain
3
+ # a copy of the License at
4
+ #
5
+ # http://www.apache.org/licenses/LICENSE-2.0
6
+ #
7
+ # Unless required by applicable law or agreed to in writing, software
8
+ # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
9
+ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
10
+ # License for the specific language governing permissions and limitations
11
+ # under the License.
12
+
13
+ """Routing and error rendering, shared by every adapter.
14
+
15
+ Whatever carries the bytes -- ASGI, WSGI, or a host framework's own
16
+ router -- the decision of which handler runs, what a wrong verb gets
17
+ back, and how an error is rendered belongs here. Two adapters that each
18
+ made those decisions themselves would drift apart, and the difference
19
+ would only show up in whichever one had fewer tests.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from dataclasses import replace
25
+
26
+ from taskflow_meter.api import routes as route_table
27
+ from taskflow_meter.api.http import ApiError
28
+ from taskflow_meter.api.http import MeterRequest
29
+ from taskflow_meter.api.http import MeterResponse
30
+ from taskflow_meter.api.http import MethodNotAllowedError
31
+ from taskflow_meter.api.http import NotFoundError
32
+ from taskflow_meter.api.router import Outcome
33
+ from taskflow_meter.api.router import Route
34
+ from taskflow_meter.api.router import Router
35
+ from taskflow_meter.api.service import MeterService
36
+ from taskflow_meter.api.sse import StreamResponse
37
+
38
+ Result = MeterResponse | StreamResponse
39
+
40
+
41
+ class Dispatcher:
42
+ """Turns a request into a response, or an error into one."""
43
+
44
+ def __init__(
45
+ self, service: MeterService, *, router: Router | None = None
46
+ ) -> None:
47
+ self.service = service
48
+ self.router = router or Router(route_table.build_routes(service))
49
+
50
+ def dispatch(self, request: MeterRequest) -> Result:
51
+ """Route and run, rendering our own errors as JSON.
52
+
53
+ Only :class:`ApiError` is caught. Anything else is a bug, and
54
+ belongs to whoever is hosting us -- swallowing it here would
55
+ turn our crash into a puzzling 500 with no traceback.
56
+ """
57
+ try:
58
+ return self._dispatch(request)
59
+ except ApiError as error:
60
+ return MeterResponse.from_error(error)
61
+
62
+ def run(self, route: Route, request: MeterRequest) -> Result:
63
+ """Invoke a handler the *host* has already matched.
64
+
65
+ The contrib adapters register our routes in the host's router,
66
+ so the host does the matching -- but a 404 for an unknown flow
67
+ is still ours to shape. Without this each adapter would
68
+ re-implement the error rendering, and each would get it subtly
69
+ different.
70
+ """
71
+ try:
72
+ return self._invoke(route, request)
73
+ except ApiError as error:
74
+ return MeterResponse.from_error(error)
75
+
76
+ def _invoke(self, route: Route, request: MeterRequest) -> Result:
77
+ result: Result = route.handler(request)
78
+ if request.method == "HEAD" and isinstance(result, MeterResponse):
79
+ # Same headers, including content-length: a HEAD that
80
+ # reported zero would misdescribe the GET.
81
+ return replace(result, body=b"")
82
+ return result
83
+
84
+ def _dispatch(self, request: MeterRequest) -> Result:
85
+ if request.method == "OPTIONS":
86
+ return self._options(request)
87
+
88
+ # HEAD is a GET whose body is thrown away, which is what makes a
89
+ # health check with curl -I report the truth.
90
+ wanted = "GET" if request.method == "HEAD" else request.method
91
+ match = self.router.match(wanted, request.path)
92
+
93
+ if match.outcome is Outcome.MATCHED:
94
+ assert match.route is not None
95
+ return self._invoke(
96
+ match.route, replace(request, path_params=match.params)
97
+ )
98
+
99
+ if match.outcome is Outcome.METHOD_NOT_ALLOWED:
100
+ raise MethodNotAllowedError(
101
+ f"{request.method} is not allowed here",
102
+ headers=(("allow", self._allow(match.allowed)),),
103
+ )
104
+
105
+ # Unknown paths under our prefix are ours to answer. Raising
106
+ # into the host application would turn a wrong URL into someone
107
+ # else's 500.
108
+ raise NotFoundError(f"no such endpoint: {request.path}")
109
+
110
+ def _options(self, request: MeterRequest) -> MeterResponse:
111
+ allowed: list[str] = []
112
+ for route in self.router.routes:
113
+ if self.router.match(route.method, request.path).outcome is (
114
+ Outcome.MATCHED
115
+ ):
116
+ allowed.append(route.method)
117
+ if not allowed:
118
+ raise NotFoundError(f"no such endpoint: {request.path}")
119
+ return MeterResponse(
120
+ status=204,
121
+ headers=(("allow", self._allow(tuple(allowed))),),
122
+ )
123
+
124
+ @staticmethod
125
+ def _allow(methods: tuple[str, ...]) -> str:
126
+ # HEAD and OPTIONS are handled here rather than by a route, so
127
+ # they would otherwise be missing from what we advertise.
128
+ return ", ".join(sorted({*methods, "HEAD", "OPTIONS"}))
@@ -0,0 +1,210 @@
1
+ # Licensed under the Apache License, Version 2.0 (the "License"); you may
2
+ # not use this file except in compliance with the License. You may obtain
3
+ # a copy of the License at
4
+ #
5
+ # http://www.apache.org/licenses/LICENSE-2.0
6
+ #
7
+ # Unless required by applicable law or agreed to in writing, software
8
+ # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
9
+ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
10
+ # License for the specific language governing permissions and limitations
11
+ # under the License.
12
+
13
+ """Requests and responses, with no web framework in sight.
14
+
15
+ Handlers take a :class:`MeterRequest` and return a :class:`MeterResponse`,
16
+ so the same handler serves an ASGI mount, a WSGI mount, or a host
17
+ framework's own router. Neither adapter is the source of truth.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ from collections.abc import Mapping
24
+ from dataclasses import dataclass
25
+ from dataclasses import field
26
+ from typing import Any
27
+ from urllib.parse import parse_qs
28
+ from urllib.parse import urlencode
29
+
30
+ JSON_CONTENT_TYPE = "application/json"
31
+
32
+
33
+ class ApiError(Exception):
34
+ """An error with an HTTP shape.
35
+
36
+ Raised by handlers and rendered by the adapters. Deliberately not a
37
+ global exception handler: we catch our own and let anything else
38
+ reach the host application, which knows what to do with it.
39
+ """
40
+
41
+ status = 500
42
+ title = "Internal Server Error"
43
+
44
+ def __init__(
45
+ self,
46
+ detail: str,
47
+ *,
48
+ headers: tuple[tuple[str, str], ...] = (),
49
+ ) -> None:
50
+ super().__init__(detail)
51
+ self.detail = detail
52
+ self.headers = headers
53
+
54
+
55
+ class BadRequestError(ApiError):
56
+ status = 400
57
+ title = "Bad Request"
58
+
59
+
60
+ class NotFoundError(ApiError):
61
+ status = 404
62
+ title = "Not Found"
63
+
64
+
65
+ class MethodNotAllowedError(ApiError):
66
+ status = 405
67
+ title = "Method Not Allowed"
68
+
69
+
70
+ class UnsupportedError(ApiError):
71
+ """The datasource in use cannot answer this at all."""
72
+
73
+ status = 501
74
+ title = "Not Implemented"
75
+
76
+
77
+ def split_path(path: str, root_path: str) -> str:
78
+ """Return the path to route on, given a mount prefix.
79
+
80
+ Mirrors what Starlette's own ``get_route_path`` does, and for the
81
+ same reason: since 0.33 ``Mount`` extends ``root_path`` and leaves
82
+ ``scope["path"]`` whole, while older versions stripped ``path``
83
+ instead, and a server given ``--root-path`` may report a prefix that
84
+ never appears in ``path`` at all. Stripping only when the prefix is
85
+ genuinely there covers all three.
86
+ """
87
+ if not root_path or not path.startswith(root_path):
88
+ return path
89
+ return path[len(root_path) :] or "/"
90
+
91
+
92
+ def mount_prefix(full_path: str, sub_path: str) -> str:
93
+ """Return the part of ``full_path`` that precedes ``sub_path``.
94
+
95
+ How an adapter recovers where it was mounted when the host routed by
96
+ consuming path segments rather than by rewriting the path: the
97
+ difference between the two is the prefix. Every generated link is
98
+ built from it, so guessing would produce links to nowhere -- hence
99
+ the empty string when the two do not line up.
100
+ """
101
+ if sub_path in ("", "/"):
102
+ return full_path.rstrip("/")
103
+ if not full_path.endswith(sub_path):
104
+ return ""
105
+ return full_path[: -len(sub_path)]
106
+
107
+
108
+ @dataclass(frozen=True, slots=True)
109
+ class MeterRequest:
110
+ """One inbound request, reduced to what a handler needs."""
111
+
112
+ method: str = "GET"
113
+ path: str = "/"
114
+ #: Where this app is mounted. Every generated link starts here.
115
+ prefix: str = ""
116
+ query: dict[str, list[str]] = field(default_factory=dict)
117
+ headers: dict[str, str] = field(default_factory=dict)
118
+ path_params: dict[str, str] = field(default_factory=dict)
119
+
120
+ @classmethod
121
+ def from_query_string(
122
+ cls, query_string: str, **kwargs: Any
123
+ ) -> MeterRequest:
124
+ return cls(
125
+ query=parse_qs(query_string, keep_blank_values=True), **kwargs
126
+ )
127
+
128
+ def param(self, name: str) -> str:
129
+ try:
130
+ return self.path_params[name]
131
+ except KeyError: # pragma: no cover - a routing bug, not input
132
+ msg = f"no such path parameter: {name}"
133
+ raise LookupError(msg) from None
134
+
135
+ def get(self, name: str, default: str | None = None) -> str | None:
136
+ values = self.query.get(name)
137
+ return values[0] if values else default
138
+
139
+ def get_int(self, name: str, default: int) -> int:
140
+ raw = self.get(name)
141
+ if raw is None or raw == "":
142
+ return default
143
+ try:
144
+ return int(raw)
145
+ except ValueError:
146
+ msg = f"{name} must be an integer, got {raw!r}"
147
+ raise BadRequestError(msg) from None
148
+
149
+ def url(self, path: str, **query: Any) -> str:
150
+ """Build a link back into this app, under its mount prefix."""
151
+ full = f"{self.prefix}{path}"
152
+ pairs = [(k, v) for k, v in query.items() if v is not None]
153
+ return f"{full}?{urlencode(pairs)}" if pairs else full
154
+
155
+
156
+ @dataclass(frozen=True, slots=True)
157
+ class MeterResponse:
158
+ """A complete, already-rendered response."""
159
+
160
+ status: int = 200
161
+ body: bytes = b""
162
+ headers: tuple[tuple[str, str], ...] = ()
163
+
164
+ @classmethod
165
+ def json(
166
+ cls,
167
+ payload: Any,
168
+ *,
169
+ status: int = 200,
170
+ headers: tuple[tuple[str, str], ...] = (),
171
+ ) -> MeterResponse:
172
+ # Separators are pinned so two adapters serving the same handler
173
+ # produce byte-identical output, which is what the conformance
174
+ # suite compares.
175
+ body = json.dumps(payload, separators=(",", ":")).encode()
176
+ return cls(
177
+ status=status,
178
+ body=body,
179
+ headers=(
180
+ ("content-type", JSON_CONTENT_TYPE),
181
+ ("content-length", str(len(body))),
182
+ *headers,
183
+ ),
184
+ )
185
+
186
+ @classmethod
187
+ def from_error(cls, error: ApiError) -> MeterResponse:
188
+ return cls.json(
189
+ {
190
+ "error": {
191
+ "status": error.status,
192
+ "title": error.title,
193
+ "detail": error.detail,
194
+ }
195
+ },
196
+ status=error.status,
197
+ headers=error.headers,
198
+ )
199
+
200
+ def header(self, name: str) -> str | None:
201
+ wanted = name.lower()
202
+ for key, value in self.headers:
203
+ if key.lower() == wanted:
204
+ return value
205
+ return None
206
+
207
+
208
+ def normalise_headers(raw: Mapping[str, str]) -> dict[str, str]:
209
+ """Lowercase header names, so lookups do not depend on the server."""
210
+ return {key.lower(): value for key, value in raw.items()}