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,204 @@
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
+ """Carry events over a message broker, using kombu.
14
+
15
+ kombu rather than a raw AMQP client because taskflow already depends on
16
+ it for its worker-based engine, so a deployment running those flows has
17
+ it already.
18
+
19
+ This is the transport for the collector deployment: the processes
20
+ running flows publish, one collector consumes and writes to a shared
21
+ datasource, and the API workers read that with ``poll = false``.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+ from collections.abc import Callable
28
+ from collections.abc import Sequence
29
+ from typing import Any
30
+
31
+ import kombu
32
+
33
+ from taskflow_meter.events import Event
34
+ from taskflow_meter.transports.base import Publisher
35
+ from taskflow_meter.transports.base import Subscriber
36
+
37
+ LOG = logging.getLogger(__name__)
38
+
39
+ DEFAULT_EXCHANGE = "taskflow-meter"
40
+ DEFAULT_QUEUE = "taskflow-meter-events"
41
+ DEFAULT_ROUTING_KEY = "events"
42
+ DEFAULT_TIMEOUT = 1.0
43
+
44
+
45
+ def build_exchange(name: str = DEFAULT_EXCHANGE) -> kombu.Exchange:
46
+ return kombu.Exchange(name, type="direct", durable=True)
47
+
48
+
49
+ def build_queue(
50
+ name: str = DEFAULT_QUEUE,
51
+ *,
52
+ exchange: str = DEFAULT_EXCHANGE,
53
+ routing_key: str = DEFAULT_ROUTING_KEY,
54
+ ) -> kombu.Queue:
55
+ """Durable, so events survive a collector restart.
56
+
57
+ A monitoring queue that discards what it could not deliver is a
58
+ monitoring queue that lies about the gap.
59
+ """
60
+ return kombu.Queue(
61
+ name,
62
+ exchange=build_exchange(exchange),
63
+ routing_key=routing_key,
64
+ durable=True,
65
+ )
66
+
67
+
68
+ class AMQPTransport(Publisher):
69
+ """Publishes batches to an exchange."""
70
+
71
+ name = "amqp"
72
+
73
+ def __init__(
74
+ self,
75
+ url: str,
76
+ *,
77
+ exchange: str = DEFAULT_EXCHANGE,
78
+ queue: str = DEFAULT_QUEUE,
79
+ routing_key: str = DEFAULT_ROUTING_KEY,
80
+ connection: kombu.Connection | None = None,
81
+ ) -> None:
82
+ self.url = url
83
+ self.routing_key = routing_key
84
+ self._exchange = build_exchange(exchange)
85
+ self._queue = build_queue(
86
+ queue, exchange=exchange, routing_key=routing_key
87
+ )
88
+ self._connection = connection
89
+ self._owns_connection = connection is None
90
+
91
+ @property
92
+ def connection(self) -> kombu.Connection:
93
+ if self._connection is None:
94
+ self._connection = kombu.Connection(self.url)
95
+ return self._connection
96
+
97
+ def stop(self) -> None:
98
+ if self._owns_connection and self._connection is not None:
99
+ self._connection.release()
100
+ self._connection = None
101
+
102
+ def publish(self, events: Sequence[Event]) -> None:
103
+ payload = {"events": [event.to_dict() for event in events]}
104
+ producer = self.connection.Producer(serializer="json")
105
+ producer.publish(
106
+ payload,
107
+ exchange=self._exchange,
108
+ routing_key=self.routing_key,
109
+ # Declared on every publish: the exchange and queue may not
110
+ # exist yet if a flow starts before the collector does, and
111
+ # events published into nothing are simply lost.
112
+ declare=[self._queue],
113
+ retry=True,
114
+ )
115
+
116
+
117
+ class AMQPSubscriber(Subscriber):
118
+ """Consumes batches from a queue."""
119
+
120
+ name = "amqp"
121
+
122
+ def __init__(
123
+ self,
124
+ url: str,
125
+ *,
126
+ exchange: str = DEFAULT_EXCHANGE,
127
+ queue: str = DEFAULT_QUEUE,
128
+ routing_key: str = DEFAULT_ROUTING_KEY,
129
+ connection: kombu.Connection | None = None,
130
+ ) -> None:
131
+ self.url = url
132
+ self._queue = build_queue(
133
+ queue, exchange=exchange, routing_key=routing_key
134
+ )
135
+ self._connection = connection
136
+ self._owns_connection = connection is None
137
+
138
+ @property
139
+ def connection(self) -> kombu.Connection:
140
+ if self._connection is None:
141
+ self._connection = kombu.Connection(self.url)
142
+ return self._connection
143
+
144
+ def stop(self) -> None:
145
+ if self._owns_connection and self._connection is not None:
146
+ self._connection.release()
147
+ self._connection = None
148
+
149
+ def consume(
150
+ self,
151
+ handler: Callable[[Sequence[Event]], None],
152
+ *,
153
+ timeout: float | None = DEFAULT_TIMEOUT,
154
+ ) -> int:
155
+ """Deliver what has arrived, and return how many events."""
156
+ received = 0
157
+ give_up = False
158
+
159
+ def on_message(body: Any, message: Any) -> None:
160
+ nonlocal received, give_up
161
+ try:
162
+ events = _decode(body)
163
+ except Exception:
164
+ # A message we cannot read will never become readable.
165
+ # Rejecting it beats redelivering it forever.
166
+ LOG.exception("discarding an unreadable message")
167
+ message.reject()
168
+ return
169
+ try:
170
+ handler(events)
171
+ except Exception:
172
+ # Leave it on the queue: the handler may recover, and
173
+ # dropping monitoring data silently is the one outcome
174
+ # worse than redelivering it. Then stop draining --
175
+ # a requeued message is redelivered immediately, so
176
+ # carrying on would spin on it until the handler
177
+ # recovered, which it cannot while we never return.
178
+ LOG.exception("handler failed; leaving the message")
179
+ message.requeue()
180
+ give_up = True
181
+ return
182
+ received += len(events)
183
+ message.ack()
184
+
185
+ with self.connection.Consumer(
186
+ [self._queue], callbacks=[on_message], accept=["json"]
187
+ ):
188
+ while not give_up:
189
+ try:
190
+ self.connection.drain_events(timeout=timeout)
191
+ except TimeoutError:
192
+ break
193
+ except OSError: # pragma: no cover - broker went away
194
+ LOG.exception("lost the broker connection")
195
+ break
196
+ return received
197
+
198
+
199
+ def _decode(body: Any) -> list[Event]:
200
+ if isinstance(body, str):
201
+ import json
202
+
203
+ body = json.loads(body)
204
+ return [Event.from_dict(item) for item in body["events"]]
@@ -0,0 +1,105 @@
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
+ """Where events go once they leave the process that produced them.
14
+
15
+ A publisher is handed batches, never single events: the pipeline drains
16
+ its queue in one go, so a transport that can amortise a round trip gets
17
+ the chance to.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import abc
23
+ from collections.abc import Callable
24
+ from collections.abc import Sequence
25
+ from types import TracebackType
26
+ from typing import Self
27
+
28
+ from taskflow_meter.events import Event
29
+
30
+
31
+ class Publisher(abc.ABC):
32
+ """Sends events somewhere.
33
+
34
+ Implementations may raise: the pipeline that drives them counts and
35
+ logs failures rather than letting one reach the flow being watched.
36
+ """
37
+
38
+ #: Stevedore plugin name, set by subclasses.
39
+ name: str = ""
40
+
41
+ def start(self) -> None: # noqa: B027 - optional hook, not abstract
42
+ """Acquire whatever the transport needs. Idempotent."""
43
+
44
+ def stop(self) -> None: # noqa: B027 - optional hook, not abstract
45
+ """Release it again. Idempotent, and safe to call unstarted."""
46
+
47
+ @abc.abstractmethod
48
+ def publish(self, events: Sequence[Event]) -> None:
49
+ """Send a batch. Never called with an empty one."""
50
+
51
+ def __enter__(self) -> Self:
52
+ self.start()
53
+ return self
54
+
55
+ def __exit__(
56
+ self,
57
+ exc_type: type[BaseException] | None,
58
+ exc: BaseException | None,
59
+ tb: TracebackType | None,
60
+ ) -> None:
61
+ self.stop()
62
+
63
+
64
+ class Subscriber(abc.ABC):
65
+ """Receives events somebody else published.
66
+
67
+ The collector's end of a transport: one process consumes what the
68
+ flows emitted and writes it to a datasource that any number of API
69
+ workers read.
70
+ """
71
+
72
+ #: Stevedore plugin name, set by subclasses.
73
+ name: str = ""
74
+
75
+ def start(self) -> None: # noqa: B027 - optional hook, not abstract
76
+ """Acquire whatever the transport needs. Idempotent."""
77
+
78
+ def stop(self) -> None: # noqa: B027 - optional hook, not abstract
79
+ """Release it again. Idempotent, and safe to call unstarted."""
80
+
81
+ @abc.abstractmethod
82
+ def consume(
83
+ self,
84
+ handler: Callable[[Sequence[Event]], None],
85
+ *,
86
+ timeout: float | None = None,
87
+ ) -> int:
88
+ """Deliver whatever has arrived, and return how many events.
89
+
90
+ Returns when nothing more arrives within ``timeout``, so the
91
+ caller owns the loop -- and can shut it down between batches
92
+ rather than being trapped inside one.
93
+ """
94
+
95
+ def __enter__(self) -> Self:
96
+ self.start()
97
+ return self
98
+
99
+ def __exit__(
100
+ self,
101
+ exc_type: type[BaseException] | None,
102
+ exc: BaseException | None,
103
+ tb: TracebackType | None,
104
+ ) -> None:
105
+ self.stop()
@@ -0,0 +1,97 @@
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
+ """Post batches of events to a webhook.
14
+
15
+ Uses :mod:`urllib.request` rather than a HTTP client library: this is
16
+ one POST of a JSON array, and a monitoring sidecar is a poor reason to
17
+ put another dependency into somebody's service.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import logging
24
+ import time
25
+ import urllib.error
26
+ import urllib.request
27
+ from collections.abc import Sequence
28
+
29
+ from taskflow_meter.events import Event
30
+ from taskflow_meter.transports.base import Publisher
31
+
32
+ LOG = logging.getLogger(__name__)
33
+
34
+ DEFAULT_TIMEOUT = 5.0
35
+ DEFAULT_RETRIES = 2
36
+ DEFAULT_BACKOFF = 0.2
37
+
38
+ #: Statuses worth trying again. A 4xx means the request was wrong and
39
+ #: will be just as wrong the second time.
40
+ RETRYABLE_STATUSES = frozenset({408, 425, 429, 500, 502, 503, 504})
41
+
42
+
43
+ class HTTPTransport(Publisher):
44
+ """POSTs ``{"events": [...]}`` to a URL."""
45
+
46
+ name = "http"
47
+
48
+ def __init__(
49
+ self,
50
+ url: str,
51
+ *,
52
+ timeout: float = DEFAULT_TIMEOUT,
53
+ retries: int = DEFAULT_RETRIES,
54
+ backoff: float = DEFAULT_BACKOFF,
55
+ headers: dict[str, str] | None = None,
56
+ ) -> None:
57
+ if retries < 0:
58
+ msg = "retries cannot be negative"
59
+ raise ValueError(msg)
60
+ self.url = url
61
+ self.timeout = timeout
62
+ self.retries = retries
63
+ self.backoff = backoff
64
+ self.headers = {"content-type": "application/json", **(headers or {})}
65
+
66
+ def publish(self, events: Sequence[Event]) -> None:
67
+ body = json.dumps(
68
+ {"events": [event.to_dict() for event in events]},
69
+ separators=(",", ":"),
70
+ ).encode()
71
+
72
+ last: Exception | None = None
73
+ for attempt in range(self.retries + 1):
74
+ try:
75
+ self._post(body)
76
+ except urllib.error.HTTPError as exc:
77
+ last = exc
78
+ if exc.code not in RETRYABLE_STATUSES:
79
+ # Retrying a rejected request just rejects it again.
80
+ raise
81
+ except OSError as exc:
82
+ last = exc
83
+ else:
84
+ return
85
+
86
+ if attempt < self.retries:
87
+ time.sleep(self.backoff * (2**attempt))
88
+
89
+ assert last is not None
90
+ raise last
91
+
92
+ def _post(self, body: bytes) -> None:
93
+ request = urllib.request.Request(
94
+ self.url, data=body, headers=self.headers, method="POST"
95
+ )
96
+ with urllib.request.urlopen(request, timeout=self.timeout) as response:
97
+ LOG.debug("published to %s: %s", self.url, response.status)
@@ -0,0 +1,83 @@
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
+ """In-process transports: keep the events, or fold them into a store."""
14
+
15
+ from __future__ import annotations
16
+
17
+ import threading
18
+ from collections import deque
19
+ from collections.abc import Sequence
20
+
21
+ from taskflow_meter.datasource.base import WritableDataSource
22
+ from taskflow_meter.events import Event
23
+ from taskflow_meter.transports.base import Publisher
24
+
25
+ #: Events retained before the oldest are dropped.
26
+ DEFAULT_MAX_EVENTS = 10000
27
+
28
+
29
+ class MemoryTransport(Publisher):
30
+ """Holds events in a bounded buffer for something else to drain.
31
+
32
+ Useful for tests and for handing a batch to another thread in the
33
+ same process. Bounded because an undrained buffer is a leak with a
34
+ slower fuse than a crash.
35
+ """
36
+
37
+ name = "memory"
38
+
39
+ def __init__(self, *, max_events: int = DEFAULT_MAX_EVENTS) -> None:
40
+ if max_events < 1:
41
+ msg = "max_events must be at least 1"
42
+ raise ValueError(msg)
43
+ self._events: deque[Event] = deque(maxlen=max_events)
44
+ self._lock = threading.Lock()
45
+ self.dropped = 0
46
+
47
+ def publish(self, events: Sequence[Event]) -> None:
48
+ with self._lock:
49
+ room = self._events.maxlen or 0
50
+ overflow = max(0, len(self._events) + len(events) - room)
51
+ self.dropped += overflow
52
+ self._events.extend(events)
53
+
54
+ def drain(self) -> tuple[Event, ...]:
55
+ """Take everything buffered so far."""
56
+ with self._lock:
57
+ taken = tuple(self._events)
58
+ self._events.clear()
59
+ return taken
60
+
61
+ def peek(self) -> tuple[Event, ...]:
62
+ with self._lock:
63
+ return tuple(self._events)
64
+
65
+ def __len__(self) -> int:
66
+ with self._lock:
67
+ return len(self._events)
68
+
69
+
70
+ class DataSourcePublisher(Publisher):
71
+ """Folds events straight into a writable datasource.
72
+
73
+ The whole in-process path: a listener produces events and the API
74
+ can serve them from the same process, with nothing on the wire.
75
+ """
76
+
77
+ name = "datasource"
78
+
79
+ def __init__(self, sink: WritableDataSource) -> None:
80
+ self.sink = sink
81
+
82
+ def publish(self, events: Sequence[Event]) -> None:
83
+ self.sink.apply_many(events)