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.
- taskflow_meter/__init__.py +47 -0
- taskflow_meter/_version.py +24 -0
- taskflow_meter/api/__init__.py +35 -0
- taskflow_meter/api/asgi.py +236 -0
- taskflow_meter/api/dispatch.py +128 -0
- taskflow_meter/api/http.py +210 -0
- taskflow_meter/api/router.py +113 -0
- taskflow_meter/api/routes.py +53 -0
- taskflow_meter/api/serializers.py +137 -0
- taskflow_meter/api/service.py +189 -0
- taskflow_meter/api/sse.py +222 -0
- taskflow_meter/api/wsgi.py +145 -0
- taskflow_meter/cli.py +287 -0
- taskflow_meter/collect/__init__.py +31 -0
- taskflow_meter/collect/attachment.py +208 -0
- taskflow_meter/collect/listener.py +161 -0
- taskflow_meter/collect/pipeline.py +229 -0
- taskflow_meter/collect/progress.py +170 -0
- taskflow_meter/conf.py +173 -0
- taskflow_meter/contrib/__init__.py +18 -0
- taskflow_meter/contrib/django.py +160 -0
- taskflow_meter/contrib/fastapi.py +149 -0
- taskflow_meter/contrib/flask.py +140 -0
- taskflow_meter/contrib/paste.py +96 -0
- taskflow_meter/contrib/pecan.py +84 -0
- taskflow_meter/datasource/__init__.py +33 -0
- taskflow_meter/datasource/base.py +154 -0
- taskflow_meter/datasource/memory.py +232 -0
- taskflow_meter/datasource/persistence.py +311 -0
- taskflow_meter/datasource/sqlalchemy/__init__.py +21 -0
- taskflow_meter/datasource/sqlalchemy/migrations/env.py +68 -0
- taskflow_meter/datasource/sqlalchemy/migrations/script.py.mako +25 -0
- taskflow_meter/datasource/sqlalchemy/migrations/versions/0001_initial.py +71 -0
- taskflow_meter/datasource/sqlalchemy/models.py +63 -0
- taskflow_meter/datasource/sqlalchemy/source.py +367 -0
- taskflow_meter/diff.py +223 -0
- taskflow_meter/events.py +129 -0
- taskflow_meter/fold.py +137 -0
- taskflow_meter/meter.py +255 -0
- taskflow_meter/models.py +143 -0
- taskflow_meter/poller.py +191 -0
- taskflow_meter/py.typed +0 -0
- taskflow_meter/states.py +60 -0
- taskflow_meter/transports/__init__.py +21 -0
- taskflow_meter/transports/amqp.py +204 -0
- taskflow_meter/transports/base.py +105 -0
- taskflow_meter/transports/http.py +97 -0
- taskflow_meter/transports/memory.py +83 -0
- taskflow_meter-1.0.0.dist-info/METADATA +258 -0
- taskflow_meter-1.0.0.dist-info/RECORD +53 -0
- taskflow_meter-1.0.0.dist-info/WHEEL +4 -0
- taskflow_meter-1.0.0.dist-info/entry_points.txt +19 -0
- 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()}
|