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,222 @@
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
+ """Server-sent events: framing, and the cursor that drives a stream.
14
+
15
+ The framing is shared by both adapters. The cursor is deliberately
16
+ synchronous and pull-based -- it answers "what is new since the last
17
+ call?" -- so the ASGI adapter can drive it from an event loop and the
18
+ WSGI one from a thread without either owning the logic.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import asyncio
24
+ import contextlib
25
+ import json
26
+ import time
27
+ from collections.abc import AsyncIterator
28
+ from collections.abc import Iterator
29
+ from dataclasses import dataclass
30
+ from dataclasses import field
31
+
32
+ from taskflow_meter.api import serializers
33
+ from taskflow_meter.datasource.base import DataSource
34
+ from taskflow_meter.events import Event
35
+
36
+ #: Note the absence of ``connection: keep-alive``. It is a hop-by-hop
37
+ #: header, which PEP 3333 forbids an application from emitting -- and
38
+ #: wsgiref rightly refuses to send. Connection persistence is the
39
+ #: server's business, not ours.
40
+ SSE_HEADERS: tuple[tuple[str, str], ...] = (
41
+ ("content-type", "text/event-stream; charset=utf-8"),
42
+ ("cache-control", "no-cache"),
43
+ # nginx buffers proxied responses by default, which turns a live
44
+ # stream into one long silence followed by everything at once.
45
+ ("x-accel-buffering", "no"),
46
+ )
47
+
48
+ #: Sent once, telling a browser how long to wait before reconnecting.
49
+ DEFAULT_RETRY_MS = 3000
50
+
51
+
52
+ def frame(
53
+ data: str,
54
+ *,
55
+ event: str | None = None,
56
+ event_id: int | str | None = None,
57
+ retry: int | None = None,
58
+ ) -> bytes:
59
+ """Render one SSE frame.
60
+
61
+ Multi-line data is split across ``data:`` lines, as the protocol
62
+ requires -- a raw newline would end the frame early and truncate
63
+ whatever followed it.
64
+ """
65
+ lines: list[str] = []
66
+ if event is not None:
67
+ lines.append(f"event: {event}")
68
+ if event_id is not None:
69
+ lines.append(f"id: {event_id}")
70
+ if retry is not None:
71
+ lines.append(f"retry: {retry}")
72
+ lines.extend(f"data: {line}" for line in data.split("\n"))
73
+ return ("\n".join(lines) + "\n\n").encode()
74
+
75
+
76
+ def comment(text: str = "") -> bytes:
77
+ """A frame clients ignore, used to keep the connection alive."""
78
+ return f": {text}\n\n".encode()
79
+
80
+
81
+ @dataclass(slots=True)
82
+ class EventCursor:
83
+ """Tracks one client's position in one run's event stream."""
84
+
85
+ reader: DataSource
86
+ run_id: str
87
+ since_seq: int = 0
88
+ batch_limit: int = 200
89
+ #: Set once the flow has finished and its events have been drained.
90
+ complete: bool = field(default=False, init=False)
91
+
92
+ def opening(self) -> bytes:
93
+ """The first bytes on the wire.
94
+
95
+ A retry hint plus a comment, so proxies that wait for output
96
+ before forwarding headers let the response through immediately.
97
+ """
98
+ return frame("", retry=DEFAULT_RETRY_MS) + comment("stream open")
99
+
100
+ def poll(self) -> list[bytes]:
101
+ """Return frames for everything new since the last call."""
102
+ page = self.reader.events_since(
103
+ self.run_id, since_seq=self.since_seq, limit=self.batch_limit
104
+ )
105
+ frames: list[bytes] = []
106
+
107
+ if page.truncated:
108
+ # The client's next event was evicted before it got here.
109
+ # Telling it beats letting it stitch a gap into a history it
110
+ # believes is continuous.
111
+ frames.append(
112
+ frame(
113
+ json.dumps(
114
+ {
115
+ "reason": "events_evicted",
116
+ "resume_from": page.oldest_seq,
117
+ },
118
+ separators=(",", ":"),
119
+ ),
120
+ event="gap",
121
+ )
122
+ )
123
+
124
+ for item in page.events:
125
+ frames.append(self._event_frame(item))
126
+ self.since_seq = page.next_seq
127
+
128
+ if not page.events and self._flow_finished():
129
+ frames.append(frame("{}", event="end"))
130
+ self.complete = True
131
+ return frames
132
+
133
+ def heartbeat(self) -> bytes:
134
+ return comment("keep-alive")
135
+
136
+ def _event_frame(self, item: Event) -> bytes:
137
+ return frame(
138
+ json.dumps(serializers.event(item), separators=(",", ":")),
139
+ event=str(item.kind),
140
+ event_id=item.seq,
141
+ )
142
+
143
+ def _flow_finished(self) -> bool:
144
+ snapshot = self.reader.get_flow(self.run_id)
145
+ return snapshot is not None and snapshot.is_finished
146
+
147
+
148
+ @dataclass(frozen=True, slots=True)
149
+ class StreamResponse:
150
+ """A response an adapter drives, rather than one it just writes.
151
+
152
+ Carries the headers and the cursor; how the polling loop is run is
153
+ the adapter's business, because an event loop and a worker thread
154
+ want to do it differently.
155
+ """
156
+
157
+ cursor: EventCursor
158
+ status: int = 200
159
+ headers: tuple[tuple[str, str], ...] = SSE_HEADERS
160
+
161
+
162
+ def iter_frames(
163
+ cursor: EventCursor,
164
+ *,
165
+ interval: float,
166
+ heartbeat: float,
167
+ ) -> Iterator[bytes]:
168
+ """Drive a cursor synchronously, for WSGI and its descendants.
169
+
170
+ A generator, so the server closing the iterable lands a
171
+ ``GeneratorExit`` here and the loop stops rather than polling a
172
+ datasource nobody is listening to.
173
+ """
174
+ quiet = 0.0
175
+ yield cursor.opening()
176
+ while True:
177
+ frames = cursor.poll()
178
+ yield from frames
179
+ if cursor.complete:
180
+ return
181
+ quiet = 0.0 if frames else quiet + interval
182
+ if quiet >= heartbeat:
183
+ yield cursor.heartbeat()
184
+ quiet = 0.0
185
+ time.sleep(interval)
186
+
187
+
188
+ async def aiter_frames(
189
+ cursor: EventCursor,
190
+ *,
191
+ interval: float,
192
+ heartbeat: float,
193
+ stop: asyncio.Event | None = None,
194
+ ) -> AsyncIterator[bytes]:
195
+ """Drive a cursor from an event loop, for ASGI and its descendants.
196
+
197
+ The polling itself is handed to a thread: a datasource read is
198
+ blocking, and doing it inline would stall every other request the
199
+ loop is serving.
200
+ """
201
+ quiet = 0.0
202
+ yield cursor.opening()
203
+ while stop is None or not stop.is_set():
204
+ frames = await asyncio.to_thread(cursor.poll)
205
+ for chunk in frames:
206
+ if stop is not None and stop.is_set():
207
+ return
208
+ yield chunk
209
+ if cursor.complete:
210
+ return
211
+
212
+ quiet = 0.0 if frames else quiet + interval
213
+ if quiet >= heartbeat:
214
+ yield cursor.heartbeat()
215
+ quiet = 0.0
216
+
217
+ if stop is None:
218
+ await asyncio.sleep(interval)
219
+ else:
220
+ # Sleep, but wake immediately if the client leaves.
221
+ with contextlib.suppress(TimeoutError):
222
+ await asyncio.wait_for(stop.wait(), interval)
@@ -0,0 +1,145 @@
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 WSGI callable, mountable anywhere.
14
+
15
+ The same service and dispatcher the ASGI app uses, so the two cannot
16
+ answer the same request differently -- a parity suite compares them byte
17
+ for byte.
18
+
19
+ Mount handling is simpler here than under ASGI: WSGI has always split
20
+ ``SCRIPT_NAME`` from ``PATH_INFO``, so there is one convention rather
21
+ than three. Streaming is the harder half. A synchronous worker holds
22
+ one thread for as long as an SSE response stays open, so a deployment
23
+ serving streams from this callable wants gevent or eventlet workers --
24
+ or should let clients poll ``/events?since_seq=`` instead, which costs
25
+ nothing while idle.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import logging
31
+ from collections.abc import Callable
32
+ from collections.abc import Iterable
33
+ from collections.abc import Iterator
34
+ from http import HTTPStatus
35
+ from typing import Any
36
+
37
+ from taskflow_meter.api.dispatch import Dispatcher
38
+ from taskflow_meter.api.http import MeterRequest
39
+ from taskflow_meter.api.service import MeterService
40
+ from taskflow_meter.api.sse import StreamResponse
41
+ from taskflow_meter.api.sse import iter_frames
42
+ from taskflow_meter.meter import Meter
43
+
44
+ LOG = logging.getLogger(__name__)
45
+
46
+ Environ = dict[str, Any]
47
+ StartResponse = Callable[[str, list[tuple[str, str]]], Any]
48
+
49
+ DEFAULT_STREAM_INTERVAL = 1.0
50
+ DEFAULT_HEARTBEAT = 15.0
51
+
52
+ #: Honoured when a reverse proxy strips a prefix the app never sees.
53
+ PREFIX_HEADER = "HTTP_X_FORWARDED_PREFIX"
54
+
55
+
56
+ class WSGIApp:
57
+ """Serves the meter over WSGI, standalone or mounted."""
58
+
59
+ def __init__(
60
+ self,
61
+ meter: Meter,
62
+ *,
63
+ service: MeterService | None = None,
64
+ stream_interval: float = DEFAULT_STREAM_INTERVAL,
65
+ heartbeat: float = DEFAULT_HEARTBEAT,
66
+ ) -> None:
67
+ self.meter = meter
68
+ self.service = service or MeterService(meter)
69
+ self.dispatcher = Dispatcher(self.service)
70
+ self.stream_interval = stream_interval
71
+ self.heartbeat = heartbeat
72
+
73
+ def __call__(
74
+ self, environ: Environ, start_response: StartResponse
75
+ ) -> Iterable[bytes]:
76
+ # WSGI has no lifespan at all, so the first request is the only
77
+ # place to notice that nobody started the meter.
78
+ self.meter.ensure_started()
79
+
80
+ result = self.dispatcher.dispatch(build_request(environ))
81
+ if isinstance(result, StreamResponse):
82
+ return self._stream(result, start_response)
83
+
84
+ start_response(status_line(result.status), list(result.headers))
85
+ return [result.body]
86
+
87
+ def _stream(
88
+ self, stream: StreamResponse, start_response: StartResponse
89
+ ) -> Iterator[bytes]:
90
+ start_response(status_line(stream.status), list(stream.headers))
91
+ return self._frames(stream)
92
+
93
+ def _frames(self, stream: StreamResponse) -> Iterator[bytes]:
94
+ """Yield frames until the flow ends or the client goes away."""
95
+ try:
96
+ yield from iter_frames(
97
+ stream.cursor,
98
+ interval=self.stream_interval,
99
+ heartbeat=self.heartbeat,
100
+ )
101
+ except GeneratorExit:
102
+ LOG.debug(
103
+ "client closed the stream for run %s", stream.cursor.run_id
104
+ )
105
+ raise
106
+
107
+
108
+ def status_line(status: int) -> str:
109
+ """Render ``200`` as ``"200 OK"``, which is what WSGI expects."""
110
+ try:
111
+ return f"{status} {HTTPStatus(status).phrase}"
112
+ except ValueError: # pragma: no cover - defensive
113
+ return f"{status} Unknown"
114
+
115
+
116
+ def build_request(environ: Environ) -> MeterRequest:
117
+ """Turn a WSGI environ into a framework-neutral request."""
118
+ # The server split these for us; PATH_INFO is empty when the request
119
+ # lands exactly on the mount point.
120
+ script_name = environ.get("SCRIPT_NAME", "")
121
+ path = environ.get("PATH_INFO", "") or "/"
122
+ return MeterRequest.from_query_string(
123
+ environ.get("QUERY_STRING", ""),
124
+ method=environ.get("REQUEST_METHOD", "GET"),
125
+ path=path,
126
+ prefix=environ.get(PREFIX_HEADER, "") + script_name,
127
+ headers=request_headers(environ),
128
+ )
129
+
130
+
131
+ def request_headers(environ: Environ) -> dict[str, str]:
132
+ """Recover header names from the CGI-style keys WSGI hands over."""
133
+ headers = {
134
+ key[5:].replace("_", "-").lower(): value
135
+ for key, value in environ.items()
136
+ if key.startswith("HTTP_")
137
+ }
138
+ for key, name in (
139
+ ("CONTENT_TYPE", "content-type"),
140
+ ("CONTENT_LENGTH", "content-length"),
141
+ ):
142
+ # These two lose their HTTP_ prefix on the way into WSGI.
143
+ if key in environ:
144
+ headers[name] = environ[key]
145
+ return headers
taskflow_meter/cli.py ADDED
@@ -0,0 +1,287 @@
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
+ """Command line entry point for ``taskflow-meter``.
14
+
15
+ Three commands, which between them cover both deployments:
16
+
17
+ ``serve``
18
+ The API. Runs on :mod:`wsgiref`, which is in the standard library,
19
+ so a monitoring server costs no dependency at all. It is a
20
+ development server: fine for a laptop, a container sidecar or a
21
+ look at a staging logbook, but a real deployment should put the
22
+ same callable behind gunicorn or mount it in a service it runs.
23
+
24
+ ``collect``
25
+ The other half of a multi-process deployment: consume events from a
26
+ broker and write them to a shared store, so the flows publish once
27
+ and any number of API workers read without polling anything.
28
+
29
+ ``upgrade``
30
+ Bring that store's schema up to date.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import argparse
36
+ import logging
37
+ import socketserver
38
+ from collections.abc import Sequence
39
+ from typing import Any
40
+ from wsgiref.simple_server import WSGIRequestHandler
41
+ from wsgiref.simple_server import WSGIServer
42
+ from wsgiref.simple_server import make_server
43
+
44
+ from taskflow_meter import __version__
45
+ from taskflow_meter.api.wsgi import WSGIApp
46
+ from taskflow_meter.datasource.persistence import PersistenceDataSource
47
+ from taskflow_meter.meter import Meter
48
+ from taskflow_meter.poller import DEFAULT_INTERVAL
49
+
50
+ LOG = logging.getLogger(__name__)
51
+
52
+ #: Localhost by default: a monitoring API exposes flow names, atom
53
+ #: names and failure detail, none of which belongs on 0.0.0.0 by
54
+ #: accident.
55
+ DEFAULT_HOST = "127.0.0.1"
56
+ DEFAULT_PORT = 8080
57
+
58
+
59
+ class ThreadingWSGIServer(socketserver.ThreadingMixIn, WSGIServer):
60
+ """One thread per connection.
61
+
62
+ The single-threaded default would let one open SSE stream block
63
+ every other request for as long as it stayed connected.
64
+ """
65
+
66
+ daemon_threads = True
67
+
68
+
69
+ def build_parser() -> argparse.ArgumentParser:
70
+ parser = argparse.ArgumentParser(
71
+ prog="taskflow-meter",
72
+ description="Monitor OpenStack TaskFlow flow execution progress.",
73
+ )
74
+ parser.add_argument(
75
+ "--version",
76
+ action="version",
77
+ version=f"%(prog)s {__version__}",
78
+ )
79
+ subcommands = parser.add_subparsers(dest="command")
80
+
81
+ serve = subcommands.add_parser(
82
+ "serve",
83
+ help="serve the monitoring API over HTTP",
84
+ description=(
85
+ "Read a taskflow persistence backend and serve the "
86
+ "monitoring API. A development server; put the WSGI "
87
+ "callable behind a real one for anything else."
88
+ ),
89
+ )
90
+ serve.add_argument(
91
+ "--connection",
92
+ metavar="URL",
93
+ help=(
94
+ "taskflow persistence connection, as the flows themselves "
95
+ "use it (for example sqlite:///taskflow.db)"
96
+ ),
97
+ )
98
+ serve.add_argument("--host", default=DEFAULT_HOST)
99
+ serve.add_argument("--port", type=int, default=DEFAULT_PORT)
100
+ serve.add_argument(
101
+ "--interval",
102
+ type=float,
103
+ default=DEFAULT_INTERVAL,
104
+ help="seconds between polls of the backend",
105
+ )
106
+ serve.add_argument(
107
+ "--no-poll",
108
+ action="store_true",
109
+ help=(
110
+ "read the backend directly instead of polling it; no event "
111
+ "history, and the stream endpoints report 501"
112
+ ),
113
+ )
114
+ serve.add_argument(
115
+ "--store-url",
116
+ metavar="URL",
117
+ help=(
118
+ "read the meter's own database instead, as filled by a "
119
+ "collect process; implies no polling"
120
+ ),
121
+ )
122
+ serve.set_defaults(handler=serve_command)
123
+
124
+ collect = subcommands.add_parser(
125
+ "collect",
126
+ help="consume events from a broker into the meter's database",
127
+ description=(
128
+ "The collector half of a multi-process deployment. Flows "
129
+ "publish events to a broker; this writes them to a store "
130
+ "the API workers read."
131
+ ),
132
+ )
133
+ collect.add_argument(
134
+ "--amqp-url",
135
+ required=True,
136
+ metavar="URL",
137
+ help="broker to consume from, for example amqp://host//",
138
+ )
139
+ collect.add_argument(
140
+ "--store-url",
141
+ required=True,
142
+ metavar="URL",
143
+ help="the meter's own database to write to",
144
+ )
145
+ collect.add_argument(
146
+ "--create-schema",
147
+ action="store_true",
148
+ help="create the tables if missing, instead of migrating",
149
+ )
150
+ collect.add_argument(
151
+ "--once",
152
+ action="store_true",
153
+ help="drain whatever is queued and exit, rather than looping",
154
+ )
155
+ collect.add_argument(
156
+ "--timeout",
157
+ type=float,
158
+ default=1.0,
159
+ help="seconds to wait for a batch before looking again",
160
+ )
161
+ collect.set_defaults(handler=collect_command)
162
+
163
+ upgrade = subcommands.add_parser(
164
+ "upgrade",
165
+ help="bring the meter's own database up to date",
166
+ )
167
+ upgrade.add_argument("--store-url", required=True, metavar="URL")
168
+ upgrade.set_defaults(handler=upgrade_command)
169
+ return parser
170
+
171
+
172
+ def build_app(
173
+ connection: str | None = None,
174
+ *,
175
+ poll: bool = True,
176
+ interval: float = DEFAULT_INTERVAL,
177
+ store_url: str | None = None,
178
+ ) -> WSGIApp:
179
+ """Build the callable for whichever deployment was asked for.
180
+
181
+ With ``store_url`` the meter reads a store a collector fills, and
182
+ polls nothing. Otherwise it watches a taskflow backend directly.
183
+ """
184
+ if store_url is not None:
185
+ return WSGIApp(Meter(build_store(store_url), poll=False))
186
+ if connection is None:
187
+ msg = "pass --connection or --store-url"
188
+ raise ValueError(msg)
189
+ source = PersistenceDataSource(conf={"connection": connection})
190
+ return WSGIApp(Meter(source, poll=poll, interval=interval))
191
+
192
+
193
+ def build_store(url: str, *, create_schema: bool = False) -> Any:
194
+ """Open the meter's own database.
195
+
196
+ Imported here rather than at module scope: SQLAlchemy is an extra,
197
+ and `taskflow-meter serve` against a taskflow backend must not
198
+ require it.
199
+ """
200
+ from taskflow_meter.datasource.sqlalchemy import SQLADataSource
201
+
202
+ return SQLADataSource(url, create_schema=create_schema)
203
+
204
+
205
+ def serve_command(args: argparse.Namespace) -> int:
206
+ logging.basicConfig(
207
+ level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s"
208
+ )
209
+ app = build_app(
210
+ args.connection,
211
+ poll=not args.no_poll,
212
+ interval=args.interval,
213
+ store_url=args.store_url,
214
+ )
215
+ with (
216
+ app.meter,
217
+ make_server(
218
+ args.host,
219
+ args.port,
220
+ app,
221
+ server_class=ThreadingWSGIServer,
222
+ handler_class=WSGIRequestHandler,
223
+ ) as server,
224
+ ):
225
+ host, port = server.server_address[:2]
226
+ LOG.info("serving on http://%s:%s", host, port)
227
+ try:
228
+ server.serve_forever()
229
+ except KeyboardInterrupt:
230
+ LOG.info("shutting down")
231
+ return 0
232
+
233
+
234
+ def collect_command(args: argparse.Namespace) -> int:
235
+ """Consume from the broker until interrupted."""
236
+ logging.basicConfig(
237
+ level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s"
238
+ )
239
+ from taskflow_meter.transports.amqp import AMQPSubscriber
240
+
241
+ store = build_store(args.store_url, create_schema=args.create_schema)
242
+ subscriber = AMQPSubscriber(args.amqp_url)
243
+ total = 0
244
+
245
+ with store, subscriber:
246
+ LOG.info("collecting from %s", args.amqp_url)
247
+ try:
248
+ while True:
249
+ received = subscriber.consume(
250
+ store.apply_many, timeout=args.timeout
251
+ )
252
+ total += received
253
+ if received:
254
+ LOG.info("stored %d events (%d total)", received, total)
255
+ if args.once:
256
+ break
257
+ except KeyboardInterrupt:
258
+ LOG.info("shutting down after %d events", total)
259
+ return 0
260
+
261
+
262
+ def upgrade_command(args: argparse.Namespace) -> int:
263
+ """Run the migrations against the meter's own database."""
264
+ logging.basicConfig(level=logging.INFO)
265
+ from taskflow_meter.datasource.sqlalchemy import upgrade
266
+
267
+ upgrade(args.store_url)
268
+ LOG.info("schema is up to date")
269
+ return 0
270
+
271
+
272
+ def main(argv: Sequence[str] | None = None) -> int:
273
+ parser = build_parser()
274
+ args = parser.parse_args(argv)
275
+ handler = getattr(args, "handler", None)
276
+ if handler is None:
277
+ parser.print_help()
278
+ return 0
279
+ if args.command == "serve" and not (args.connection or args.store_url):
280
+ # Neither source given: a usage error, not a traceback.
281
+ parser.error("serve needs --connection or --store-url")
282
+ result: int = handler(args)
283
+ return result
284
+
285
+
286
+ if __name__ == "__main__": # pragma: no cover
287
+ raise SystemExit(main())
@@ -0,0 +1,31 @@
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 emit side: watch an engine as it runs."""
14
+
15
+ from __future__ import annotations
16
+
17
+ from taskflow_meter.collect.attachment import Attachment
18
+ from taskflow_meter.collect.attachment import attach
19
+ from taskflow_meter.collect.attachment import describe_graph
20
+ from taskflow_meter.collect.listener import MeterListener
21
+ from taskflow_meter.collect.pipeline import EventPipeline
22
+ from taskflow_meter.collect.progress import ProgressTap
23
+
24
+ __all__ = [
25
+ "Attachment",
26
+ "EventPipeline",
27
+ "MeterListener",
28
+ "ProgressTap",
29
+ "attach",
30
+ "describe_graph",
31
+ ]