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,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