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,113 @@
|
|
|
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 path-template router small enough to read in one sitting.
|
|
14
|
+
|
|
15
|
+
Exists so the route table is data rather than decorators: the same table
|
|
16
|
+
drives our own ASGI and WSGI callables and can be walked by an adapter
|
|
17
|
+
that registers the routes in a host framework's router instead.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
from collections.abc import Callable
|
|
24
|
+
from collections.abc import Iterable
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from dataclasses import field
|
|
27
|
+
from enum import Enum
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from taskflow_meter.api.http import MeterRequest
|
|
31
|
+
|
|
32
|
+
#: A handler takes the request and returns a response, or a stream.
|
|
33
|
+
Handler = Callable[[MeterRequest], Any]
|
|
34
|
+
|
|
35
|
+
_PARAM = re.compile(r"\{([a-zA-Z_][a-zA-Z0-9_]*)\}")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, slots=True)
|
|
39
|
+
class Route:
|
|
40
|
+
"""One method and path template, bound to a handler."""
|
|
41
|
+
|
|
42
|
+
method: str
|
|
43
|
+
template: str
|
|
44
|
+
handler: Handler
|
|
45
|
+
name: str
|
|
46
|
+
|
|
47
|
+
def compile(self) -> re.Pattern[str]:
|
|
48
|
+
"""Build the matcher for this template.
|
|
49
|
+
|
|
50
|
+
The literal spans are escaped and the parameters are not, which
|
|
51
|
+
is why this walks the template instead of substituting into an
|
|
52
|
+
already-escaped string -- ``re.escape`` escapes the braces too,
|
|
53
|
+
leaving nothing for a substitution to find.
|
|
54
|
+
"""
|
|
55
|
+
parts: list[str] = []
|
|
56
|
+
cursor = 0
|
|
57
|
+
for found in _PARAM.finditer(self.template):
|
|
58
|
+
parts.append(re.escape(self.template[cursor : found.start()]))
|
|
59
|
+
parts.append(f"(?P<{found.group(1)}>[^/]+)")
|
|
60
|
+
cursor = found.end()
|
|
61
|
+
parts.append(re.escape(self.template[cursor:]))
|
|
62
|
+
return re.compile(f"^{''.join(parts)}$")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Outcome(Enum):
|
|
66
|
+
"""Why a match did or did not happen."""
|
|
67
|
+
|
|
68
|
+
MATCHED = "matched"
|
|
69
|
+
NOT_FOUND = "not_found"
|
|
70
|
+
METHOD_NOT_ALLOWED = "method_not_allowed"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True, slots=True)
|
|
74
|
+
class Match:
|
|
75
|
+
outcome: Outcome
|
|
76
|
+
route: Route | None = None
|
|
77
|
+
params: dict[str, str] = field(default_factory=dict)
|
|
78
|
+
allowed: tuple[str, ...] = ()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class Router:
|
|
82
|
+
"""Matches a method and path against a fixed table of routes."""
|
|
83
|
+
|
|
84
|
+
def __init__(self, routes: Iterable[Route]) -> None:
|
|
85
|
+
self.routes = tuple(routes)
|
|
86
|
+
self._compiled = tuple(
|
|
87
|
+
(route, route.compile()) for route in self.routes
|
|
88
|
+
)
|
|
89
|
+
self._by_name = {route.name: route for route in self.routes}
|
|
90
|
+
|
|
91
|
+
def match(self, method: str, path: str) -> Match:
|
|
92
|
+
allowed: list[str] = []
|
|
93
|
+
for route, pattern in self._compiled:
|
|
94
|
+
found = pattern.match(path)
|
|
95
|
+
if found is None:
|
|
96
|
+
continue
|
|
97
|
+
if route.method == method:
|
|
98
|
+
return Match(
|
|
99
|
+
Outcome.MATCHED, route=route, params=found.groupdict()
|
|
100
|
+
)
|
|
101
|
+
allowed.append(route.method)
|
|
102
|
+
|
|
103
|
+
if allowed:
|
|
104
|
+
# The path exists, the verb does not. Saying so beats a 404
|
|
105
|
+
# that sends a client hunting for a typo in the URL.
|
|
106
|
+
return Match(
|
|
107
|
+
Outcome.METHOD_NOT_ALLOWED,
|
|
108
|
+
allowed=tuple(sorted(set(allowed))),
|
|
109
|
+
)
|
|
110
|
+
return Match(Outcome.NOT_FOUND)
|
|
111
|
+
|
|
112
|
+
def template_for(self, name: str) -> str:
|
|
113
|
+
return self._by_name[name].template
|
|
@@ -0,0 +1,53 @@
|
|
|
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
|
+
"""Path templates, in one place so links and routes cannot drift apart.
|
|
14
|
+
|
|
15
|
+
The serialisers build links from the same constants the router matches
|
|
16
|
+
on, so a renamed route cannot leave the payloads pointing somewhere that
|
|
17
|
+
no longer exists.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from collections.abc import Iterable
|
|
23
|
+
from typing import TYPE_CHECKING
|
|
24
|
+
|
|
25
|
+
from taskflow_meter.api.router import Route
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
# Imported for typing only: service imports serializers, which
|
|
29
|
+
# imports these templates, so a real import would close the loop.
|
|
30
|
+
from taskflow_meter.api.service import MeterService
|
|
31
|
+
|
|
32
|
+
HEALTH = "/healthz"
|
|
33
|
+
FLOWS = "/api/v1/flows"
|
|
34
|
+
FLOW = "/api/v1/flows/{run_id}"
|
|
35
|
+
ATOMS = "/api/v1/flows/{run_id}/atoms"
|
|
36
|
+
EVENTS = "/api/v1/flows/{run_id}/events"
|
|
37
|
+
STREAM = "/api/v1/flows/{run_id}/stream"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def build_routes(service: MeterService) -> tuple[Route, ...]:
|
|
41
|
+
"""Bind the templates to a service's handlers."""
|
|
42
|
+
return (
|
|
43
|
+
Route("GET", HEALTH, service.health, name="health"),
|
|
44
|
+
Route("GET", FLOWS, service.list_flows, name="flows"),
|
|
45
|
+
Route("GET", FLOW, service.get_flow, name="flow"),
|
|
46
|
+
Route("GET", ATOMS, service.get_atoms, name="atoms"),
|
|
47
|
+
Route("GET", EVENTS, service.get_events, name="events"),
|
|
48
|
+
Route("GET", STREAM, service.stream, name="stream"),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def templates() -> Iterable[str]:
|
|
53
|
+
return (HEALTH, FLOWS, FLOW, ATOMS, EVENTS, STREAM)
|
|
@@ -0,0 +1,137 @@
|
|
|
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
|
+
"""Snapshots and events, rendered as JSON-ready dictionaries.
|
|
14
|
+
|
|
15
|
+
Every payload carries the links that reach the rest of the API, built
|
|
16
|
+
from the request's mount prefix rather than a hard-coded path, so the
|
|
17
|
+
same handler is correct at ``/`` and at ``/deep/prefix``.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from taskflow_meter.api import routes
|
|
25
|
+
from taskflow_meter.api.http import MeterRequest
|
|
26
|
+
from taskflow_meter.datasource.base import EventPage
|
|
27
|
+
from taskflow_meter.datasource.base import FlowPage
|
|
28
|
+
from taskflow_meter.events import Event
|
|
29
|
+
from taskflow_meter.models import AtomSnapshot
|
|
30
|
+
from taskflow_meter.models import FlowSnapshot
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def atom(snapshot: AtomSnapshot) -> dict[str, Any]:
|
|
34
|
+
return {
|
|
35
|
+
"name": snapshot.name,
|
|
36
|
+
"uuid": snapshot.uuid,
|
|
37
|
+
"type": snapshot.atom_type,
|
|
38
|
+
"state": snapshot.state,
|
|
39
|
+
"intention": snapshot.intention,
|
|
40
|
+
"progress": snapshot.progress,
|
|
41
|
+
"progress_details": snapshot.progress_details,
|
|
42
|
+
"completion": snapshot.completion,
|
|
43
|
+
"finished": snapshot.is_finished,
|
|
44
|
+
"running": snapshot.is_running,
|
|
45
|
+
"has_result": snapshot.has_result,
|
|
46
|
+
"failure": snapshot.failure,
|
|
47
|
+
"revert_failure": snapshot.revert_failure,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def flow(
|
|
52
|
+
snapshot: FlowSnapshot,
|
|
53
|
+
request: MeterRequest,
|
|
54
|
+
*,
|
|
55
|
+
with_atoms: bool = False,
|
|
56
|
+
with_events: bool = True,
|
|
57
|
+
) -> dict[str, Any]:
|
|
58
|
+
"""Render a flow.
|
|
59
|
+
|
|
60
|
+
``with_events`` is false when the datasource keeps no history, so
|
|
61
|
+
the payload does not advertise a stream that would answer every
|
|
62
|
+
request with silence.
|
|
63
|
+
"""
|
|
64
|
+
run_id = snapshot.run_id
|
|
65
|
+
links = {
|
|
66
|
+
"self": request.url(routes.FLOW.format(run_id=run_id)),
|
|
67
|
+
"atoms": request.url(routes.ATOMS.format(run_id=run_id)),
|
|
68
|
+
}
|
|
69
|
+
if with_events:
|
|
70
|
+
links["events"] = request.url(routes.EVENTS.format(run_id=run_id))
|
|
71
|
+
links["stream"] = request.url(routes.STREAM.format(run_id=run_id))
|
|
72
|
+
|
|
73
|
+
payload: dict[str, Any] = {
|
|
74
|
+
"run_id": run_id,
|
|
75
|
+
"name": snapshot.name,
|
|
76
|
+
"state": snapshot.state,
|
|
77
|
+
"book_id": snapshot.book_id,
|
|
78
|
+
"book_name": snapshot.book_name,
|
|
79
|
+
"observed_at": snapshot.observed_at,
|
|
80
|
+
"finished": snapshot.is_finished,
|
|
81
|
+
# An unweighted mean of the atoms: taskflow offers nothing to
|
|
82
|
+
# weight them by, so this indicates rather than estimates.
|
|
83
|
+
"completion": snapshot.completion,
|
|
84
|
+
"atom_count": len(snapshot.atoms),
|
|
85
|
+
# What it is doing right now: a list, because parallel flows run
|
|
86
|
+
# several at once and a flow between atoms is running none.
|
|
87
|
+
#
|
|
88
|
+
# Not named `atom`: this module has a function by that name, and
|
|
89
|
+
# under PEP 709 comprehension inlining (broken in CPython 3.12.0
|
|
90
|
+
# and 3.12.1) the loop target shadows it for the whole function.
|
|
91
|
+
"running_atoms": [running.name for running in snapshot.running_atoms],
|
|
92
|
+
"state_counts": snapshot.state_counts,
|
|
93
|
+
"meta": snapshot.meta,
|
|
94
|
+
"links": links,
|
|
95
|
+
}
|
|
96
|
+
if with_atoms:
|
|
97
|
+
payload["atoms"] = [
|
|
98
|
+
atom(snapshot.atoms[name]) for name in snapshot.atom_names
|
|
99
|
+
]
|
|
100
|
+
return payload
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def flow_page(
|
|
104
|
+
page: FlowPage, request: MeterRequest, *, with_events: bool = True
|
|
105
|
+
) -> dict[str, Any]:
|
|
106
|
+
links = {"self": request.url(routes.FLOWS)}
|
|
107
|
+
if page.next_marker is not None:
|
|
108
|
+
links["next"] = request.url(routes.FLOWS, marker=page.next_marker)
|
|
109
|
+
return {
|
|
110
|
+
"flows": [
|
|
111
|
+
flow(item, request, with_events=with_events) for item in page.items
|
|
112
|
+
],
|
|
113
|
+
"next_marker": page.next_marker,
|
|
114
|
+
"links": links,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def event(item: Event) -> dict[str, Any]:
|
|
119
|
+
return item.to_dict()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def event_page(
|
|
123
|
+
page: EventPage, run_id: str, request: MeterRequest
|
|
124
|
+
) -> dict[str, Any]:
|
|
125
|
+
template = routes.EVENTS.format(run_id=run_id)
|
|
126
|
+
return {
|
|
127
|
+
"events": [event(item) for item in page.events],
|
|
128
|
+
"next_seq": page.next_seq,
|
|
129
|
+
"oldest_seq": page.oldest_seq,
|
|
130
|
+
# True means the caller's next expected event was already
|
|
131
|
+
# evicted: there is a hole, and the snapshot must be re-read.
|
|
132
|
+
"truncated": page.truncated,
|
|
133
|
+
"links": {
|
|
134
|
+
"self": request.url(template),
|
|
135
|
+
"next": request.url(template, since_seq=page.next_seq),
|
|
136
|
+
},
|
|
137
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
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
|
+
"""Every query the API can answer, with no HTTP machinery attached.
|
|
14
|
+
|
|
15
|
+
This is the source of truth. The ASGI and WSGI callables are adapters
|
|
16
|
+
over it, and a host framework's own router can call the same handlers,
|
|
17
|
+
so the three can never disagree about what an endpoint returns.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from taskflow_meter import __version__
|
|
23
|
+
from taskflow_meter.api import serializers
|
|
24
|
+
from taskflow_meter.api.http import BadRequestError
|
|
25
|
+
from taskflow_meter.api.http import MeterRequest
|
|
26
|
+
from taskflow_meter.api.http import MeterResponse
|
|
27
|
+
from taskflow_meter.api.http import NotFoundError
|
|
28
|
+
from taskflow_meter.api.http import UnsupportedError
|
|
29
|
+
from taskflow_meter.api.sse import EventCursor
|
|
30
|
+
from taskflow_meter.api.sse import StreamResponse
|
|
31
|
+
from taskflow_meter.datasource.base import UnknownMarkerError
|
|
32
|
+
from taskflow_meter.meter import Meter
|
|
33
|
+
from taskflow_meter.models import FlowSnapshot
|
|
34
|
+
|
|
35
|
+
#: Refuse to serve more than this in one page, however much is asked
|
|
36
|
+
#: for: an unbounded limit is a full table scan someone can request.
|
|
37
|
+
MAX_LIMIT = 500
|
|
38
|
+
|
|
39
|
+
DEFAULT_FLOW_LIMIT = 50
|
|
40
|
+
DEFAULT_EVENT_LIMIT = 200
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class MeterService:
|
|
44
|
+
"""Answers requests from a :class:`~taskflow_meter.meter.Meter`."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, meter: Meter, *, max_limit: int = MAX_LIMIT) -> None:
|
|
47
|
+
self.meter = meter
|
|
48
|
+
self.max_limit = max_limit
|
|
49
|
+
|
|
50
|
+
# -- endpoints -------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
def health(
|
|
53
|
+
self,
|
|
54
|
+
request: MeterRequest, # noqa: ARG002 - every handler takes one
|
|
55
|
+
) -> MeterResponse:
|
|
56
|
+
poller = self.meter.poller
|
|
57
|
+
return MeterResponse.json(
|
|
58
|
+
{
|
|
59
|
+
"status": "ok",
|
|
60
|
+
"version": __version__,
|
|
61
|
+
"running": self.meter.running,
|
|
62
|
+
"supports_events": self.meter.supports_events,
|
|
63
|
+
"poller": None
|
|
64
|
+
if poller is None
|
|
65
|
+
else {
|
|
66
|
+
"polls": poller.stats.polls,
|
|
67
|
+
"events": poller.stats.events,
|
|
68
|
+
"errors": poller.stats.errors,
|
|
69
|
+
"flows_seen": poller.stats.flows_seen,
|
|
70
|
+
"last_error": poller.stats.last_error,
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def list_flows(self, request: MeterRequest) -> MeterResponse:
|
|
76
|
+
limit = self._limit(request, DEFAULT_FLOW_LIMIT)
|
|
77
|
+
try:
|
|
78
|
+
page = self.meter.list_flows(
|
|
79
|
+
state=request.get("state"),
|
|
80
|
+
book_id=request.get("book_id"),
|
|
81
|
+
limit=limit,
|
|
82
|
+
marker=request.get("marker"),
|
|
83
|
+
)
|
|
84
|
+
except UnknownMarkerError as exc:
|
|
85
|
+
# The run it named expired between pages. A 400 tells the
|
|
86
|
+
# client to restart the walk; silently starting over would
|
|
87
|
+
# loop it through the first page forever.
|
|
88
|
+
raise BadRequestError(str(exc)) from exc
|
|
89
|
+
|
|
90
|
+
return MeterResponse.json(
|
|
91
|
+
serializers.flow_page(
|
|
92
|
+
page, request, with_events=self.meter.supports_events
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def get_flow(self, request: MeterRequest) -> MeterResponse:
|
|
97
|
+
run_id = request.param("run_id")
|
|
98
|
+
snapshot = self._flow_or_404(run_id)
|
|
99
|
+
return MeterResponse.json(
|
|
100
|
+
serializers.flow(
|
|
101
|
+
snapshot,
|
|
102
|
+
request,
|
|
103
|
+
with_atoms=True,
|
|
104
|
+
with_events=self.meter.supports_events,
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def get_atoms(self, request: MeterRequest) -> MeterResponse:
|
|
109
|
+
run_id = request.param("run_id")
|
|
110
|
+
atoms = self.meter.get_atoms(run_id)
|
|
111
|
+
if atoms is None:
|
|
112
|
+
raise NotFoundError(f"no flow with run id {run_id!r}")
|
|
113
|
+
return MeterResponse.json(
|
|
114
|
+
{
|
|
115
|
+
"atoms": [serializers.atom(item) for item in atoms],
|
|
116
|
+
"links": {"self": request.url(request.path)},
|
|
117
|
+
}
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def get_events(self, request: MeterRequest) -> MeterResponse:
|
|
121
|
+
run_id = request.param("run_id")
|
|
122
|
+
self._require_events()
|
|
123
|
+
self._flow_or_404(run_id)
|
|
124
|
+
page = self.meter.events_since(
|
|
125
|
+
run_id,
|
|
126
|
+
since_seq=request.get_int("since_seq", 0),
|
|
127
|
+
limit=self._limit(request, DEFAULT_EVENT_LIMIT),
|
|
128
|
+
)
|
|
129
|
+
return MeterResponse.json(
|
|
130
|
+
serializers.event_page(page, run_id, request)
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def stream(self, request: MeterRequest) -> StreamResponse:
|
|
134
|
+
run_id = request.param("run_id")
|
|
135
|
+
self._require_events()
|
|
136
|
+
self._flow_or_404(run_id)
|
|
137
|
+
return StreamResponse(
|
|
138
|
+
cursor=EventCursor(
|
|
139
|
+
reader=self.meter.reader,
|
|
140
|
+
run_id=run_id,
|
|
141
|
+
since_seq=self._resume_point(request),
|
|
142
|
+
batch_limit=self._limit(request, DEFAULT_EVENT_LIMIT),
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# -- shared checks ---------------------------------------------------
|
|
147
|
+
|
|
148
|
+
def _flow_or_404(self, run_id: str) -> FlowSnapshot:
|
|
149
|
+
snapshot = self.meter.get_flow(run_id)
|
|
150
|
+
if snapshot is None:
|
|
151
|
+
raise NotFoundError(f"no flow with run id {run_id!r}")
|
|
152
|
+
return snapshot
|
|
153
|
+
|
|
154
|
+
def _require_events(self) -> None:
|
|
155
|
+
if not self.meter.supports_events:
|
|
156
|
+
msg = (
|
|
157
|
+
"this datasource keeps current state, not a history; "
|
|
158
|
+
"pair it with a poller feeding a writable datasource "
|
|
159
|
+
"to stream events"
|
|
160
|
+
)
|
|
161
|
+
raise UnsupportedError(msg)
|
|
162
|
+
|
|
163
|
+
def _limit(self, request: MeterRequest, default: int) -> int:
|
|
164
|
+
limit = request.get_int("limit", default)
|
|
165
|
+
if limit < 1:
|
|
166
|
+
msg = f"limit must be at least 1, got {limit}"
|
|
167
|
+
raise BadRequestError(msg)
|
|
168
|
+
return min(limit, self.max_limit)
|
|
169
|
+
|
|
170
|
+
def _resume_point(self, request: MeterRequest) -> int:
|
|
171
|
+
"""Where to resume a stream from.
|
|
172
|
+
|
|
173
|
+
``Last-Event-ID`` is what a browser's EventSource sends by itself
|
|
174
|
+
on reconnect, so honouring it is what makes a dropped connection
|
|
175
|
+
recoverable rather than a hole in the client's history. An
|
|
176
|
+
explicit query parameter wins, for clients driving it by hand.
|
|
177
|
+
"""
|
|
178
|
+
explicit = request.get("since_seq")
|
|
179
|
+
if explicit is not None:
|
|
180
|
+
return request.get_int("since_seq", 0)
|
|
181
|
+
|
|
182
|
+
header = request.headers.get("last-event-id")
|
|
183
|
+
if header is None:
|
|
184
|
+
return 0
|
|
185
|
+
try:
|
|
186
|
+
return int(header)
|
|
187
|
+
except ValueError:
|
|
188
|
+
msg = f"Last-Event-ID must be an integer, got {header!r}"
|
|
189
|
+
raise BadRequestError(msg) from None
|