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,232 @@
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 datasource: state and a bounded event history, in RAM.
14
+
15
+ Useful on its own for embedded monitoring of a single process, and it is
16
+ the reference implementation the other datasources are checked against --
17
+ folding an event stream into it must reproduce the snapshots the stream was
18
+ derived from.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+ import threading
25
+ from collections import deque
26
+ from dataclasses import replace
27
+
28
+ from taskflow_meter.datasource.base import DEFAULT_EVENT_LIMIT
29
+ from taskflow_meter.datasource.base import DEFAULT_FLOW_LIMIT
30
+ from taskflow_meter.datasource.base import EventPage
31
+ from taskflow_meter.datasource.base import FlowPage
32
+ from taskflow_meter.datasource.base import UnknownMarkerError
33
+ from taskflow_meter.datasource.base import WritableDataSource
34
+ from taskflow_meter.events import Event
35
+ from taskflow_meter.fold import contiguous_from
36
+ from taskflow_meter.fold import flow_from_event
37
+ from taskflow_meter.fold import fold
38
+ from taskflow_meter.models import FlowSnapshot
39
+
40
+ LOG = logging.getLogger(__name__)
41
+
42
+ #: Events retained per run before the oldest are dropped.
43
+ DEFAULT_MAX_EVENTS_PER_RUN = 1000
44
+
45
+
46
+ class _Run:
47
+ """Stored state for one flow run."""
48
+
49
+ __slots__ = ("events", "flow", "highest_seq", "oldest_seq")
50
+
51
+ def __init__(self, flow: FlowSnapshot, max_events: int) -> None:
52
+ self.flow = flow
53
+ self.events: deque[Event] = deque(maxlen=max_events)
54
+ self.oldest_seq: int | None = None
55
+ self.highest_seq = 0
56
+
57
+
58
+ class MemoryDataSource(WritableDataSource):
59
+ """Keeps every run it is told about until told to forget it."""
60
+
61
+ name = "memory"
62
+
63
+ def __init__(
64
+ self,
65
+ *,
66
+ max_events_per_run: int = DEFAULT_MAX_EVENTS_PER_RUN,
67
+ max_runs: int | None = None,
68
+ ) -> None:
69
+ if max_events_per_run < 1:
70
+ msg = "max_events_per_run must be at least 1"
71
+ raise ValueError(msg)
72
+ if max_runs is not None and max_runs < 1:
73
+ msg = "max_runs must be at least 1 when set"
74
+ raise ValueError(msg)
75
+ self._max_events_per_run = max_events_per_run
76
+ self._max_runs = max_runs
77
+ # Producers write from a poller or executor thread while the API
78
+ # reads from its own; every access below holds this.
79
+ self._lock = threading.RLock()
80
+ self._runs: dict[str, _Run] = {}
81
+
82
+ @property
83
+ def max_events_per_run(self) -> int:
84
+ """How much history each run keeps before the oldest is dropped."""
85
+ return self._max_events_per_run
86
+
87
+ # -- writing ---------------------------------------------------------
88
+
89
+ def apply(self, event: Event) -> None:
90
+ with self._lock:
91
+ run = self._runs.get(event.run_id)
92
+ if run is None:
93
+ run = _Run(flow_from_event(event), self._max_events_per_run)
94
+ self._runs[event.run_id] = run
95
+ self._evict_if_needed()
96
+
97
+ run.flow = fold(run.flow, event)
98
+ self._record(run, event)
99
+
100
+ def _record(self, run: _Run, event: Event) -> None:
101
+ if len(run.events) == self._max_events_per_run:
102
+ # The deque drops the oldest for us; say which one went, so a
103
+ # gap reported later by events_since() can be explained.
104
+ LOG.debug(
105
+ "dropping event %d for run %s: history is full at %d",
106
+ run.events[0].seq,
107
+ event.run_id,
108
+ self._max_events_per_run,
109
+ )
110
+ run.events.append(event)
111
+ run.oldest_seq = run.events[0].seq
112
+ run.highest_seq = max(run.highest_seq, event.seq)
113
+
114
+ def _evict_if_needed(self) -> None:
115
+ if self._max_runs is None or len(self._runs) <= self._max_runs:
116
+ return
117
+ # Oldest observation first, so a long-finished run goes before a
118
+ # live one. Never silent: an operator needs to know the window is
119
+ # too small for their flow volume.
120
+ victim = min(self._runs.values(), key=lambda run: run.flow.observed_at)
121
+ del self._runs[victim.flow.run_id]
122
+ LOG.warning(
123
+ "evicted run %s: at the %d run limit",
124
+ victim.flow.run_id,
125
+ self._max_runs,
126
+ )
127
+
128
+ def forget(self, run_id: str) -> bool:
129
+ """Drop a run entirely. Returns whether it was there."""
130
+ with self._lock:
131
+ return self._runs.pop(run_id, None) is not None
132
+
133
+ # -- reading ---------------------------------------------------------
134
+
135
+ def get_flow(self, run_id: str) -> FlowSnapshot | None:
136
+ with self._lock:
137
+ run = self._runs.get(run_id)
138
+ return _detach(run.flow) if run is not None else None
139
+
140
+ def list_flows(
141
+ self,
142
+ *,
143
+ state: str | None = None,
144
+ book_id: str | None = None,
145
+ limit: int = DEFAULT_FLOW_LIMIT,
146
+ marker: str | None = None,
147
+ ) -> FlowPage:
148
+ if limit < 1:
149
+ msg = "limit must be at least 1"
150
+ raise ValueError(msg)
151
+
152
+ with self._lock:
153
+ flows = [
154
+ _detach(run.flow)
155
+ for run in self._runs.values()
156
+ if (state is None or run.flow.state == state)
157
+ and (book_id is None or run.flow.book_id == book_id)
158
+ ]
159
+
160
+ # Newest observation first; run_id breaks ties so that paging over
161
+ # flows observed in the same instant cannot repeat or skip one.
162
+ flows.sort(key=lambda flow: (-flow.observed_at, flow.run_id))
163
+
164
+ start = 0
165
+ if marker is not None:
166
+ positions = {
167
+ flow.run_id: index for index, flow in enumerate(flows)
168
+ }
169
+ if marker not in positions:
170
+ msg = f"unknown paging marker: {marker!r}"
171
+ raise UnknownMarkerError(msg)
172
+ start = positions[marker] + 1
173
+
174
+ window = flows[start : start + limit]
175
+ more = len(flows) > start + limit
176
+ return FlowPage(
177
+ items=tuple(window),
178
+ next_marker=window[-1].run_id if more and window else None,
179
+ )
180
+
181
+ def events_since(
182
+ self,
183
+ run_id: str,
184
+ *,
185
+ since_seq: int = 0,
186
+ limit: int = DEFAULT_EVENT_LIMIT,
187
+ ) -> EventPage:
188
+ """Return the contiguous run of events after ``since_seq``.
189
+
190
+ Contiguous, and in sequence order, because concurrent producers
191
+ do not arrive in it: two threads under the parallel engine each
192
+ allocate a number and then enqueue, and the enqueues can
193
+ invert. Returning event 8 while 7 is still in flight would
194
+ advance the caller past 7 for good, so anything beyond a gap is
195
+ held back until the gap fills.
196
+ """
197
+ if limit < 1:
198
+ msg = "limit must be at least 1"
199
+ raise ValueError(msg)
200
+
201
+ with self._lock:
202
+ run = self._runs.get(run_id)
203
+ if run is None:
204
+ return EventPage(next_seq=since_seq)
205
+ stored = sorted(run.events, key=lambda event: event.seq)
206
+
207
+ oldest = stored[0].seq if stored else None
208
+ # A hole exists when the caller's next expected event has
209
+ # already been evicted.
210
+ truncated = oldest is not None and since_seq + 1 < oldest
211
+ expected = (
212
+ oldest if truncated and oldest is not None else since_seq + 1
213
+ )
214
+
215
+ selected = contiguous_from(stored, expected, limit)
216
+
217
+ return EventPage(
218
+ events=tuple(selected),
219
+ next_seq=selected[-1].seq if selected else since_seq,
220
+ oldest_seq=oldest,
221
+ truncated=truncated,
222
+ )
223
+
224
+
225
+ def _detach(flow: FlowSnapshot) -> FlowSnapshot:
226
+ """Copy a snapshot on its way out.
227
+
228
+ The dataclass is frozen but its ``atoms`` mapping is not, so handing
229
+ out the stored object would let a reader mutate what the datasource
230
+ believes. A monitoring read must never be able to do that.
231
+ """
232
+ return replace(flow, atoms=dict(flow.atoms))
@@ -0,0 +1,311 @@
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
+ """Read taskflow's own persistence layer, and write nothing back.
14
+
15
+ This is the datasource that makes an existing deployment observable
16
+ without touching its flow code. Everything it reports was put there by
17
+ taskflow itself: states via the engine, and per-atom progress via
18
+ ``storage.set_task_progress()``, which write-throughs to the backend on
19
+ every ``update_progress()`` call.
20
+
21
+ Two limits are inherent rather than incidental, and callers are better
22
+ off knowing them than discovering them:
23
+
24
+ * **No timestamps.** Only the logbook carries ``created_at`` and
25
+ ``updated_at``; flow and atom details carry none. Snapshots are
26
+ therefore stamped with our own observation time.
27
+ * **No topology.** Atoms are persisted, edges are not, so nothing here
28
+ can describe the graph. That needs the in-process listener.
29
+
30
+ Listing is a full scan: taskflow's connection API offers no filtering or
31
+ paging, so filters are applied after reading. Put a cache in front of it
32
+ before pointing a busy API at a large logbook.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import contextlib
38
+ import logging
39
+ import threading
40
+ import time
41
+ from collections.abc import Callable
42
+ from collections.abc import Iterator
43
+ from typing import Any
44
+
45
+ from taskflow import exceptions as tf_exc
46
+ from taskflow.persistence import backends as tf_backends
47
+ from taskflow.persistence import models as tf_models
48
+
49
+ from taskflow_meter.datasource.base import DEFAULT_EVENT_LIMIT
50
+ from taskflow_meter.datasource.base import DEFAULT_FLOW_LIMIT
51
+ from taskflow_meter.datasource.base import DataSource
52
+ from taskflow_meter.datasource.base import EventPage
53
+ from taskflow_meter.datasource.base import FlowPage
54
+ from taskflow_meter.datasource.base import UnknownMarkerError
55
+ from taskflow_meter.models import RETRY
56
+ from taskflow_meter.models import TASK
57
+ from taskflow_meter.models import AtomSnapshot
58
+ from taskflow_meter.models import FlowSnapshot
59
+
60
+ LOG = logging.getLogger(__name__)
61
+
62
+ #: taskflow's name for a task detail, as reported by ``atom_detail_type``.
63
+ _TASK_DETAIL = "TASK_DETAIL"
64
+
65
+ #: Where ``storage.set_task_progress`` files what it records.
66
+ _META_PROGRESS = "progress"
67
+ _META_PROGRESS_DETAILS = "progress_details"
68
+
69
+
70
+ class PersistenceDataSource(DataSource):
71
+ """A read-only view of a taskflow persistence backend."""
72
+
73
+ name = "persistence"
74
+
75
+ #: Persistence stores state, not history. Pair this source with a
76
+ #: Poller feeding a writable datasource to get an event stream.
77
+ supports_events = False
78
+
79
+ def __init__(
80
+ self,
81
+ backend: Any = None,
82
+ *,
83
+ conf: dict[str, Any] | None = None,
84
+ clock: Callable[[], float] = time.time,
85
+ ) -> None:
86
+ """Wrap an existing ``backend``, or build one from ``conf``.
87
+
88
+ A backend passed in is borrowed and never closed -- it usually
89
+ belongs to the application being monitored. One built from
90
+ ``conf`` is ours, and :meth:`stop` closes it.
91
+ """
92
+ if (backend is None) == (conf is None):
93
+ msg = "pass exactly one of backend or conf"
94
+ raise ValueError(msg)
95
+ self._backend = backend
96
+ self._conf = conf
97
+ self._owns_backend = backend is None
98
+ self._clock = clock
99
+ self._lock = threading.Lock()
100
+
101
+ def start(self) -> None:
102
+ with self._lock:
103
+ if self._backend is None and self._conf is not None:
104
+ self._backend = tf_backends.fetch(self._conf)
105
+
106
+ def stop(self) -> None:
107
+ with self._lock:
108
+ if self._owns_backend and self._backend is not None:
109
+ self._backend.close()
110
+ self._backend = None
111
+
112
+ @contextlib.contextmanager
113
+ def _connection(self) -> Iterator[Any]:
114
+ """Borrow a connection, closing it however we leave."""
115
+ with self._lock:
116
+ backend = self._backend
117
+ if backend is None:
118
+ self.start()
119
+ with self._lock:
120
+ backend = self._backend
121
+ if backend is None:
122
+ msg = "datasource has no backend"
123
+ raise RuntimeError(msg)
124
+ connection = backend.get_connection()
125
+ try:
126
+ yield connection
127
+ finally:
128
+ with contextlib.suppress(Exception):
129
+ connection.close()
130
+
131
+ # -- reading ---------------------------------------------------------
132
+
133
+ def list_flows(
134
+ self,
135
+ *,
136
+ state: str | None = None,
137
+ book_id: str | None = None,
138
+ limit: int = DEFAULT_FLOW_LIMIT,
139
+ marker: str | None = None,
140
+ ) -> FlowPage:
141
+ if limit < 1:
142
+ msg = "limit must be at least 1"
143
+ raise ValueError(msg)
144
+
145
+ flows = [
146
+ flow
147
+ for flow in self._scan()
148
+ if (state is None or flow.state == state)
149
+ and (book_id is None or flow.book_id == book_id)
150
+ ]
151
+ # Flow details carry no timestamp of their own, so ordering falls
152
+ # back to the owning book's creation time, then the run id to keep
153
+ # paging stable.
154
+ flows.sort(key=lambda flow: (-flow.observed_at, flow.run_id))
155
+
156
+ start = 0
157
+ if marker is not None:
158
+ positions = {flow.run_id: i for i, flow in enumerate(flows)}
159
+ if marker not in positions:
160
+ msg = f"unknown paging marker: {marker!r}"
161
+ raise UnknownMarkerError(msg)
162
+ start = positions[marker] + 1
163
+
164
+ window = flows[start : start + limit]
165
+ more = len(flows) > start + limit
166
+ return FlowPage(
167
+ items=tuple(window),
168
+ next_marker=window[-1].run_id if more and window else None,
169
+ )
170
+
171
+ def get_flow(self, run_id: str) -> FlowSnapshot | None:
172
+ """Return one flow.
173
+
174
+ Reads the flow detail directly, then scans the logbooks only to
175
+ attach the owning book's identity -- taskflow's connection API
176
+ offers no way to go from a flow back to its book.
177
+ """
178
+ with self._connection() as conn:
179
+ try:
180
+ detail = conn.get_flow_details(run_id, lazy=False)
181
+ except tf_exc.NotFound:
182
+ return None
183
+ book_id, book_name, created = self._find_book(conn, run_id)
184
+ return self._flow_snapshot(detail, book_id, book_name, created)
185
+
186
+ def get_atoms(self, run_id: str) -> tuple[AtomSnapshot, ...] | None:
187
+ """Return a flow's atoms without paying for the book lookup.
188
+
189
+ The existence check is not redundant: ``get_atoms_for_flow``
190
+ answers an unknown flow with an empty list, which would otherwise
191
+ be indistinguishable from a flow that has no atoms yet -- a 404
192
+ reported as an empty collection.
193
+ """
194
+ with self._connection() as conn:
195
+ try:
196
+ conn.get_flow_details(run_id, lazy=True)
197
+ details = list(conn.get_atoms_for_flow(run_id))
198
+ except tf_exc.NotFound:
199
+ return None
200
+ atoms = [_atom_snapshot(detail) for detail in details]
201
+ return tuple(sorted(atoms, key=lambda atom: atom.name))
202
+
203
+ def events_since(
204
+ self,
205
+ run_id: str, # noqa: ARG002 - part of the DataSource contract
206
+ *,
207
+ since_seq: int = 0,
208
+ limit: int = DEFAULT_EVENT_LIMIT, # noqa: ARG002 - ditto
209
+ ) -> EventPage:
210
+ """Always empty: taskflow persists state, never a history.
211
+
212
+ Reported through :attr:`supports_events` so an API can decline to
213
+ advertise a stream it cannot serve, rather than handing clients an
214
+ empty one that is indistinguishable from silence.
215
+ """
216
+ return EventPage(next_seq=since_seq)
217
+
218
+ # -- internals -------------------------------------------------------
219
+
220
+ def _scan(self) -> list[FlowSnapshot]:
221
+ """Walk every logbook. See the module docstring on cost."""
222
+ snapshots: list[FlowSnapshot] = []
223
+ with self._connection() as conn:
224
+ for book in conn.get_logbooks(lazy=True):
225
+ created = _epoch(book.created_at)
226
+ for detail in conn.get_flows_for_book(book.uuid):
227
+ snapshots.append(
228
+ self._flow_snapshot(
229
+ detail, book.uuid, book.name, created
230
+ )
231
+ )
232
+ return snapshots
233
+
234
+ def _find_book(
235
+ self, conn: Any, run_id: str
236
+ ) -> tuple[str | None, str | None, float | None]:
237
+ for book in conn.get_logbooks(lazy=True):
238
+ for detail in conn.get_flows_for_book(book.uuid):
239
+ if detail.uuid == run_id:
240
+ return book.uuid, book.name, _epoch(book.created_at)
241
+ return None, None, None
242
+
243
+ def _flow_snapshot(
244
+ self,
245
+ detail: Any,
246
+ book_id: str | None,
247
+ book_name: str | None,
248
+ book_created_at: float | None,
249
+ ) -> FlowSnapshot:
250
+ atoms = {
251
+ atom.name: atom for atom in (_atom_snapshot(ad) for ad in detail)
252
+ }
253
+ meta = dict(detail.meta or {})
254
+ if book_created_at is not None:
255
+ # The only real timestamp taskflow gives us. Kept in meta
256
+ # rather than passed off as an observation time.
257
+ meta.setdefault("book_created_at", book_created_at)
258
+ return FlowSnapshot(
259
+ run_id=detail.uuid,
260
+ name=detail.name,
261
+ state=detail.state,
262
+ book_id=book_id,
263
+ book_name=book_name,
264
+ observed_at=self._clock(),
265
+ meta=meta,
266
+ atoms=atoms,
267
+ )
268
+
269
+
270
+ def _atom_snapshot(detail: Any) -> AtomSnapshot:
271
+ meta = dict(detail.meta or {})
272
+ return AtomSnapshot(
273
+ name=detail.name,
274
+ uuid=detail.uuid,
275
+ atom_type=(
276
+ TASK
277
+ if tf_models.atom_detail_type(detail) == _TASK_DETAIL
278
+ else RETRY
279
+ ),
280
+ state=detail.state,
281
+ intention=detail.intention,
282
+ progress=float(meta.get(_META_PROGRESS) or 0.0),
283
+ progress_details=meta.get(_META_PROGRESS_DETAILS),
284
+ failure=_failure_dict(detail.failure),
285
+ revert_failure=_failure_dict(getattr(detail, "revert_failure", None)),
286
+ # Carrying results would mean copying arbitrary application data
287
+ # into a monitoring path, so only their presence is reported. A
288
+ # task that returns None is indistinguishable from one that
289
+ # returned nothing, which is a trade we accept.
290
+ has_result=detail.results is not None,
291
+ meta=meta,
292
+ )
293
+
294
+
295
+ def _failure_dict(failure: Any) -> dict[str, Any] | None:
296
+ """Render a taskflow Failure, tolerating anything that is not one."""
297
+ if failure is None:
298
+ return None
299
+ try:
300
+ return dict(failure.to_dict())
301
+ except Exception:
302
+ LOG.warning("could not serialise a failure", exc_info=True)
303
+ return {"unserialisable": repr(failure)}
304
+
305
+
306
+ def _epoch(when: Any) -> float | None:
307
+ """Convert a datetime to a float, or give up quietly."""
308
+ try:
309
+ return float(when.timestamp())
310
+ except (AttributeError, TypeError, ValueError, OSError):
311
+ return None
@@ -0,0 +1,21 @@
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 datasource with a schema of its own."""
14
+
15
+ from __future__ import annotations
16
+
17
+ from taskflow_meter.datasource.sqlalchemy.models import metadata
18
+ from taskflow_meter.datasource.sqlalchemy.source import SQLADataSource
19
+ from taskflow_meter.datasource.sqlalchemy.source import upgrade
20
+
21
+ __all__ = ["SQLADataSource", "metadata", "upgrade"]
@@ -0,0 +1,68 @@
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
+ """Alembic environment for the meter's own schema.
14
+
15
+ Driven programmatically by :func:`taskflow_meter.datasource.sqlalchemy
16
+ .upgrade`, so the URL arrives through the config rather than from an
17
+ ``alembic.ini`` a deployment would have to carry.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from typing import Any
23
+
24
+ from alembic import context
25
+ from sqlalchemy import engine_from_config
26
+ from sqlalchemy import pool
27
+
28
+ from taskflow_meter.datasource.sqlalchemy.models import metadata
29
+
30
+ config = context.config
31
+ target_metadata = metadata
32
+
33
+
34
+ def run_migrations_offline() -> None:
35
+ context.configure(
36
+ url=config.get_main_option("sqlalchemy.url"),
37
+ target_metadata=target_metadata,
38
+ literal_binds=True,
39
+ dialect_opts={"paramstyle": "named"},
40
+ )
41
+ with context.begin_transaction():
42
+ context.run_migrations()
43
+
44
+
45
+ def run_migrations_online() -> None:
46
+ connectable = config.attributes.get("connection", None)
47
+ if connectable is None:
48
+ connectable = engine_from_config(
49
+ config.get_section(config.config_ini_section, {}),
50
+ prefix="sqlalchemy.",
51
+ poolclass=pool.NullPool,
52
+ )
53
+ with connectable.connect() as connection:
54
+ _run(connection)
55
+ return
56
+ _run(connectable)
57
+
58
+
59
+ def _run(connection: Any) -> None:
60
+ context.configure(connection=connection, target_metadata=target_metadata)
61
+ with context.begin_transaction():
62
+ context.run_migrations()
63
+
64
+
65
+ if context.is_offline_mode():
66
+ run_migrations_offline()
67
+ else:
68
+ run_migrations_online()
@@ -0,0 +1,25 @@
1
+ """${message}
2
+
3
+ Revision ID: ${up_revision}
4
+ Revises: ${down_revision | comma,n}
5
+ Create Date: ${create_date}
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from alembic import op
11
+ import sqlalchemy as sa
12
+ ${imports if imports else ""}
13
+
14
+ revision = ${repr(up_revision)}
15
+ down_revision = ${repr(down_revision)}
16
+ branch_labels = ${repr(branch_labels)}
17
+ depends_on = ${repr(depends_on)}
18
+
19
+
20
+ def upgrade() -> None:
21
+ ${upgrades if upgrades else "pass"}
22
+
23
+
24
+ def downgrade() -> None:
25
+ ${downgrades if downgrades else "pass"}