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,170 @@
|
|
|
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
|
+
"""Capture the progress a task reports about itself.
|
|
14
|
+
|
|
15
|
+
The gap a listener cannot close. When a task calls
|
|
16
|
+
``self.update_progress(0.4)``, taskflow's ``TaskAction`` writes the
|
|
17
|
+
number to storage and **never re-emits it on the engine's atom
|
|
18
|
+
notifier** -- so a listener sees state transitions and nothing in
|
|
19
|
+
between.
|
|
20
|
+
|
|
21
|
+
The number is published on the *task's own* notifier, one per atom, so
|
|
22
|
+
this walks the compiled graph and registers there. Registering is
|
|
23
|
+
symmetrical: whatever is bound is unbound again, because leaving a
|
|
24
|
+
callback on somebody's task object outlives the monitoring that wanted
|
|
25
|
+
it.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import logging
|
|
31
|
+
import time
|
|
32
|
+
from collections.abc import Callable
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
from taskflow import task as tf_task
|
|
36
|
+
from taskflow.engines.action_engine import compiler as tf_compiler
|
|
37
|
+
|
|
38
|
+
from taskflow_meter.events import Event
|
|
39
|
+
from taskflow_meter.events import EventKind
|
|
40
|
+
from taskflow_meter.events import SequenceAllocator
|
|
41
|
+
from taskflow_meter.models import TASK
|
|
42
|
+
|
|
43
|
+
LOG = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
Emit = Callable[[Event], Any]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ProgressTap:
|
|
49
|
+
"""Registers for ``EVENT_UPDATE_PROGRESS`` on every task in a flow."""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
engine: Any,
|
|
54
|
+
emit: Emit,
|
|
55
|
+
*,
|
|
56
|
+
allocator: SequenceAllocator | None = None,
|
|
57
|
+
clock: Callable[[], float] = time.time,
|
|
58
|
+
book_id: str | None = None,
|
|
59
|
+
) -> None:
|
|
60
|
+
self._engine = engine
|
|
61
|
+
self._emit = emit
|
|
62
|
+
self._allocator = allocator or SequenceAllocator()
|
|
63
|
+
self._clock = clock
|
|
64
|
+
self._book_id = book_id
|
|
65
|
+
self._bound: list[tuple[Any, Callable[..., None]]] = []
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def bound(self) -> int:
|
|
69
|
+
"""How many tasks are currently being listened to."""
|
|
70
|
+
return len(self._bound)
|
|
71
|
+
|
|
72
|
+
def register(self) -> None:
|
|
73
|
+
"""Bind to every task in the flow. Idempotent."""
|
|
74
|
+
if self._bound:
|
|
75
|
+
return
|
|
76
|
+
for atom in atoms_of(self._engine):
|
|
77
|
+
if not isinstance(atom, tf_task.Task):
|
|
78
|
+
# Only tasks report progress; a retry controller has no
|
|
79
|
+
# notifier to bind to.
|
|
80
|
+
continue
|
|
81
|
+
if not atom.notifier.can_be_registered(
|
|
82
|
+
tf_task.EVENT_UPDATE_PROGRESS
|
|
83
|
+
):
|
|
84
|
+
LOG.debug(
|
|
85
|
+
"task %r does not accept progress listeners", atom.name
|
|
86
|
+
)
|
|
87
|
+
continue
|
|
88
|
+
callback = self._callback_for(atom)
|
|
89
|
+
atom.notifier.register(tf_task.EVENT_UPDATE_PROGRESS, callback)
|
|
90
|
+
self._bound.append((atom, callback))
|
|
91
|
+
|
|
92
|
+
def deregister(self) -> None:
|
|
93
|
+
"""Unbind everything. Idempotent, and never raises."""
|
|
94
|
+
while self._bound:
|
|
95
|
+
atom, callback = self._bound.pop()
|
|
96
|
+
try:
|
|
97
|
+
atom.notifier.deregister(
|
|
98
|
+
tf_task.EVENT_UPDATE_PROGRESS, callback
|
|
99
|
+
)
|
|
100
|
+
except Exception:
|
|
101
|
+
LOG.warning(
|
|
102
|
+
"could not deregister from task %r",
|
|
103
|
+
getattr(atom, "name", atom),
|
|
104
|
+
exc_info=True,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
def __enter__(self) -> ProgressTap:
|
|
108
|
+
self.register()
|
|
109
|
+
return self
|
|
110
|
+
|
|
111
|
+
def __exit__(self, *exc: object) -> None:
|
|
112
|
+
self.deregister()
|
|
113
|
+
|
|
114
|
+
def _callback_for(self, atom: Any) -> Callable[..., None]:
|
|
115
|
+
def on_progress(
|
|
116
|
+
event_type: str, # noqa: ARG001 - taskflow's callback shape
|
|
117
|
+
details: Any,
|
|
118
|
+
) -> None:
|
|
119
|
+
# Runs on the thread executing the task. Nothing may
|
|
120
|
+
# escape: a monitoring bug must not fail somebody's task.
|
|
121
|
+
try:
|
|
122
|
+
self._emit(self._build(atom, details))
|
|
123
|
+
except Exception:
|
|
124
|
+
LOG.exception("could not emit progress for task %r", atom.name)
|
|
125
|
+
|
|
126
|
+
return on_progress
|
|
127
|
+
|
|
128
|
+
def _build(self, atom: Any, details: Any) -> Event:
|
|
129
|
+
payload = dict(details or {})
|
|
130
|
+
progress = payload.pop("progress", None)
|
|
131
|
+
run_id = str(self._engine.storage.flow_uuid)
|
|
132
|
+
return Event(
|
|
133
|
+
run_id=run_id,
|
|
134
|
+
seq=self._allocator.allocate(run_id),
|
|
135
|
+
ts=self._clock(),
|
|
136
|
+
kind=EventKind.ATOM_PROGRESS,
|
|
137
|
+
book_id=self._book_id,
|
|
138
|
+
atom_name=atom.name,
|
|
139
|
+
atom_uuid=_atom_uuid(self._engine, atom.name),
|
|
140
|
+
atom_type=TASK,
|
|
141
|
+
progress=float(progress) if progress is not None else None,
|
|
142
|
+
details={"progress_details": payload} if payload else {},
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def atoms_of(engine: Any) -> list[Any]:
|
|
147
|
+
"""Every atom in the flow, from the compiled graph.
|
|
148
|
+
|
|
149
|
+
Compiling here rather than waiting for ``run()`` is what lets the
|
|
150
|
+
tap bind before the first task starts -- and it is idempotent, so
|
|
151
|
+
an engine that was already compiled is untouched.
|
|
152
|
+
"""
|
|
153
|
+
engine.compile()
|
|
154
|
+
compilation = engine.compilation
|
|
155
|
+
if compilation is None: # pragma: no cover - compile() just ran
|
|
156
|
+
return []
|
|
157
|
+
graph = compilation.execution_graph
|
|
158
|
+
return [
|
|
159
|
+
node
|
|
160
|
+
for node, data in graph.nodes(data=True)
|
|
161
|
+
if data.get("kind") in tf_compiler.ATOMS
|
|
162
|
+
]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _atom_uuid(engine: Any, name: str) -> str | None:
|
|
166
|
+
try:
|
|
167
|
+
return str(engine.storage.get_atom_uuid(name))
|
|
168
|
+
except Exception:
|
|
169
|
+
# Before the flow is prepared the atom may have no detail yet.
|
|
170
|
+
return None
|
taskflow_meter/conf.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
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
|
+
"""Configuration, read from the host service's own oslo.config.
|
|
14
|
+
|
|
15
|
+
The point is that an operator adds the meter to their service's
|
|
16
|
+
pipeline and it works, without writing a second config file. If
|
|
17
|
+
``[taskflow_meter] connection`` is unset we fall back to ``[database]
|
|
18
|
+
connection`` -- the option those services already set -- so the common
|
|
19
|
+
case needs no configuration at all.
|
|
20
|
+
|
|
21
|
+
That fallback is a convenience, not a guarantee: a deployment whose
|
|
22
|
+
taskflow logbooks live somewhere other than the service's main database
|
|
23
|
+
has to say so. It is spelled out in the option help rather than left
|
|
24
|
+
for someone to discover from an empty flow list.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from oslo_config import cfg
|
|
32
|
+
|
|
33
|
+
from taskflow_meter.api.wsgi import WSGIApp
|
|
34
|
+
from taskflow_meter.datasource.persistence import PersistenceDataSource
|
|
35
|
+
from taskflow_meter.meter import Meter
|
|
36
|
+
from taskflow_meter.poller import DEFAULT_INTERVAL
|
|
37
|
+
|
|
38
|
+
GROUP_NAME = "taskflow_meter"
|
|
39
|
+
|
|
40
|
+
OPT_GROUP = cfg.OptGroup(
|
|
41
|
+
name=GROUP_NAME,
|
|
42
|
+
title="TaskFlow monitoring",
|
|
43
|
+
help="Read-only monitoring of taskflow flow execution progress.",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
OPTS = [
|
|
47
|
+
cfg.StrOpt(
|
|
48
|
+
"connection",
|
|
49
|
+
secret=True,
|
|
50
|
+
help=(
|
|
51
|
+
"Connection string for the taskflow persistence backend, in "
|
|
52
|
+
"the form the flows themselves use. Defaults to [database] "
|
|
53
|
+
"connection, which is right when taskflow shares this "
|
|
54
|
+
"service's database and wrong when its logbooks live "
|
|
55
|
+
"elsewhere -- set this explicitly if they do."
|
|
56
|
+
),
|
|
57
|
+
),
|
|
58
|
+
cfg.BoolOpt(
|
|
59
|
+
"poll",
|
|
60
|
+
default=True,
|
|
61
|
+
help=(
|
|
62
|
+
"Poll the backend and keep an event history. Turn this off "
|
|
63
|
+
"in API workers when a separate collector process keeps the "
|
|
64
|
+
"store warm, so N workers do not become N pollers on the "
|
|
65
|
+
"same database. With it off, the event and stream endpoints "
|
|
66
|
+
"report 501."
|
|
67
|
+
),
|
|
68
|
+
),
|
|
69
|
+
cfg.FloatOpt(
|
|
70
|
+
"poll_interval",
|
|
71
|
+
default=DEFAULT_INTERVAL,
|
|
72
|
+
min=0.05,
|
|
73
|
+
help=(
|
|
74
|
+
"Seconds between polls. This is also the resolution of what "
|
|
75
|
+
"can be observed: a state a flow entered and left within one "
|
|
76
|
+
"interval was never visible, and no amount of polling "
|
|
77
|
+
"afterwards recovers it."
|
|
78
|
+
),
|
|
79
|
+
),
|
|
80
|
+
cfg.IntOpt(
|
|
81
|
+
"max_events_per_run",
|
|
82
|
+
default=1000,
|
|
83
|
+
min=1,
|
|
84
|
+
help=(
|
|
85
|
+
"Events retained per flow before the oldest are dropped. A "
|
|
86
|
+
"client that falls behind further than this is told its "
|
|
87
|
+
"history has a hole rather than being handed a stream that "
|
|
88
|
+
"silently skips events."
|
|
89
|
+
),
|
|
90
|
+
),
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def register_opts(conf: cfg.ConfigOpts | None = None) -> cfg.ConfigOpts:
|
|
95
|
+
"""Register our options on ``conf``. Safe to call repeatedly.
|
|
96
|
+
|
|
97
|
+
Registered by group *name*, not by passing :data:`OPT_GROUP`.
|
|
98
|
+
oslo.config keeps each option's state -- including overrides -- on
|
|
99
|
+
the OptGroup object, so handing the same one to two ConfigOpts makes
|
|
100
|
+
them share values. Nothing notices while there is only the global
|
|
101
|
+
CONF, and everything notices the moment there is a second one.
|
|
102
|
+
"""
|
|
103
|
+
target = cfg.CONF if conf is None else conf
|
|
104
|
+
target.register_opts(OPTS, group=GROUP_NAME)
|
|
105
|
+
return target
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def list_opts() -> list[tuple[cfg.OptGroup, list[cfg.Opt]]]:
|
|
109
|
+
"""Entry point for ``oslo-config-generator``.
|
|
110
|
+
|
|
111
|
+
Returns the group object so the generated sample carries its title
|
|
112
|
+
and help; registration deliberately does not.
|
|
113
|
+
"""
|
|
114
|
+
return [(OPT_GROUP, OPTS)]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def resolve_connection(conf: cfg.ConfigOpts) -> str:
|
|
118
|
+
"""Find the taskflow connection, or explain what is missing."""
|
|
119
|
+
configured = conf[GROUP_NAME].connection
|
|
120
|
+
if configured:
|
|
121
|
+
return str(configured)
|
|
122
|
+
|
|
123
|
+
inherited = _host_database_connection(conf)
|
|
124
|
+
if inherited:
|
|
125
|
+
return inherited
|
|
126
|
+
|
|
127
|
+
msg = (
|
|
128
|
+
"no taskflow persistence connection configured: set "
|
|
129
|
+
f"[{GROUP_NAME}] connection, or run inside a service that sets "
|
|
130
|
+
"[database] connection"
|
|
131
|
+
)
|
|
132
|
+
raise cfg.RequiredOptError("connection", GROUP_NAME) from ValueError(msg)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _host_database_connection(conf: cfg.ConfigOpts) -> str | None:
|
|
136
|
+
"""Read ``[database] connection`` if the host service has one.
|
|
137
|
+
|
|
138
|
+
Read rather than imported: oslo.db is not our dependency, and the
|
|
139
|
+
services that set this option have already registered it.
|
|
140
|
+
"""
|
|
141
|
+
try:
|
|
142
|
+
return str(conf.database.connection) or None
|
|
143
|
+
except (cfg.NoSuchOptError, cfg.NoSuchGroupError, AttributeError):
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def meter_from_config(conf: cfg.ConfigOpts | None = None) -> Meter:
|
|
148
|
+
"""Build a meter from configuration alone."""
|
|
149
|
+
target = register_opts(conf)
|
|
150
|
+
settings = target[GROUP_NAME]
|
|
151
|
+
source = PersistenceDataSource(
|
|
152
|
+
conf={"connection": resolve_connection(target)}
|
|
153
|
+
)
|
|
154
|
+
store = None
|
|
155
|
+
if settings.poll:
|
|
156
|
+
from taskflow_meter.datasource.memory import MemoryDataSource
|
|
157
|
+
|
|
158
|
+
store = MemoryDataSource(
|
|
159
|
+
max_events_per_run=settings.max_events_per_run
|
|
160
|
+
)
|
|
161
|
+
return Meter(
|
|
162
|
+
source,
|
|
163
|
+
store=store,
|
|
164
|
+
poll=settings.poll,
|
|
165
|
+
interval=settings.poll_interval,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def wsgi_app_from_config(
|
|
170
|
+
conf: cfg.ConfigOpts | None = None, **overrides: Any
|
|
171
|
+
) -> WSGIApp:
|
|
172
|
+
"""Build the WSGI callable a paste pipeline or Pecan tree can host."""
|
|
173
|
+
return WSGIApp(meter_from_config(conf), **overrides)
|
|
@@ -0,0 +1,18 @@
|
|
|
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
|
+
"""Adapters for hosting the meter inside somebody else's application.
|
|
14
|
+
|
|
15
|
+
Nothing here is imported by the package itself, and none of it adds a
|
|
16
|
+
runtime dependency: a deployment that hosts the meter in Pecan already
|
|
17
|
+
has Pecan.
|
|
18
|
+
"""
|
|
@@ -0,0 +1,160 @@
|
|
|
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
|
+
"""Register the meter's routes in a Django project's URL conf.
|
|
14
|
+
|
|
15
|
+
Mounting the WSGI callable beside Django works too; reach for this when
|
|
16
|
+
the routes need to be *inside* the project, so its middleware,
|
|
17
|
+
authentication and permission decorators apply to them::
|
|
18
|
+
|
|
19
|
+
from django.urls import include, path
|
|
20
|
+
from taskflow_meter.contrib.django import meter_urlpatterns
|
|
21
|
+
|
|
22
|
+
urlpatterns = [
|
|
23
|
+
path("taskflow/", include(meter_urlpatterns(meter))),
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
Django's converter syntax differs from ours -- ``<str:run_id>`` rather
|
|
27
|
+
than ``{run_id}`` -- so the templates are translated on the way in,
|
|
28
|
+
from the same table the router matches on.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import re
|
|
34
|
+
from collections.abc import Callable
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
from django.http import HttpRequest
|
|
38
|
+
from django.http import HttpResponse
|
|
39
|
+
from django.http import StreamingHttpResponse
|
|
40
|
+
from django.urls import URLPattern
|
|
41
|
+
from django.urls import path as django_path
|
|
42
|
+
|
|
43
|
+
from taskflow_meter.api import routes as route_table
|
|
44
|
+
from taskflow_meter.api.dispatch import Dispatcher
|
|
45
|
+
from taskflow_meter.api.http import MeterRequest
|
|
46
|
+
from taskflow_meter.api.http import MeterResponse
|
|
47
|
+
from taskflow_meter.api.http import mount_prefix
|
|
48
|
+
from taskflow_meter.api.router import Route
|
|
49
|
+
from taskflow_meter.api.service import MeterService
|
|
50
|
+
from taskflow_meter.api.sse import StreamResponse
|
|
51
|
+
from taskflow_meter.api.sse import iter_frames
|
|
52
|
+
from taskflow_meter.meter import Meter
|
|
53
|
+
|
|
54
|
+
DEFAULT_STREAM_INTERVAL = 1.0
|
|
55
|
+
DEFAULT_HEARTBEAT = 15.0
|
|
56
|
+
|
|
57
|
+
_PARAM = re.compile(r"\{([a-zA-Z_][a-zA-Z0-9_]*)\}")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def meter_urlpatterns(
|
|
61
|
+
meter: Meter,
|
|
62
|
+
*,
|
|
63
|
+
service: MeterService | None = None,
|
|
64
|
+
stream_interval: float = DEFAULT_STREAM_INTERVAL,
|
|
65
|
+
heartbeat: float = DEFAULT_HEARTBEAT,
|
|
66
|
+
) -> list[URLPattern]:
|
|
67
|
+
"""Build URL patterns serving every endpoint the callables serve."""
|
|
68
|
+
resolved = service or MeterService(meter)
|
|
69
|
+
dispatcher = Dispatcher(resolved)
|
|
70
|
+
|
|
71
|
+
return [
|
|
72
|
+
django_path(
|
|
73
|
+
to_django_route(route.template),
|
|
74
|
+
_view(meter, dispatcher, route, stream_interval, heartbeat),
|
|
75
|
+
name=route.name,
|
|
76
|
+
)
|
|
77
|
+
for route in route_table.build_routes(resolved)
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def to_django_route(template: str) -> str:
|
|
82
|
+
"""``/flows/{run_id}`` -> ``flows/<str:run_id>``.
|
|
83
|
+
|
|
84
|
+
Django patterns are relative to wherever they are included, so the
|
|
85
|
+
leading slash goes too.
|
|
86
|
+
"""
|
|
87
|
+
return _PARAM.sub(r"<str:\1>", template).lstrip("/")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _view(
|
|
91
|
+
meter: Meter,
|
|
92
|
+
dispatcher: Dispatcher,
|
|
93
|
+
route: Route,
|
|
94
|
+
stream_interval: float,
|
|
95
|
+
heartbeat: float,
|
|
96
|
+
) -> Callable[..., HttpResponse | StreamingHttpResponse]:
|
|
97
|
+
def view(
|
|
98
|
+
request: HttpRequest, **params: Any
|
|
99
|
+
) -> HttpResponse | StreamingHttpResponse:
|
|
100
|
+
meter.ensure_started()
|
|
101
|
+
|
|
102
|
+
result = dispatcher.run(route, _build_request(request, route, params))
|
|
103
|
+
if isinstance(result, StreamResponse):
|
|
104
|
+
streaming = StreamingHttpResponse(
|
|
105
|
+
iter_frames(
|
|
106
|
+
result.cursor,
|
|
107
|
+
interval=stream_interval,
|
|
108
|
+
heartbeat=heartbeat,
|
|
109
|
+
),
|
|
110
|
+
status=result.status,
|
|
111
|
+
)
|
|
112
|
+
_apply_headers(streaming, result.headers)
|
|
113
|
+
return streaming
|
|
114
|
+
return _response(result)
|
|
115
|
+
|
|
116
|
+
return view
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _build_request(
|
|
120
|
+
request: HttpRequest, route: Route, params: dict[str, Any]
|
|
121
|
+
) -> MeterRequest:
|
|
122
|
+
"""Translate a Django request, recovering where it was included.
|
|
123
|
+
|
|
124
|
+
``path_info`` excludes ``SCRIPT_NAME`` but includes the prefix the
|
|
125
|
+
patterns were included under, so the prefix is what is left when
|
|
126
|
+
our own template is taken off the end.
|
|
127
|
+
"""
|
|
128
|
+
text_params = {key: str(value) for key, value in params.items()}
|
|
129
|
+
sub_path = route.template.format(**text_params)
|
|
130
|
+
script_name = request.META.get("SCRIPT_NAME", "")
|
|
131
|
+
|
|
132
|
+
return MeterRequest(
|
|
133
|
+
method=request.method or "GET",
|
|
134
|
+
path=sub_path,
|
|
135
|
+
prefix=script_name + mount_prefix(request.path_info, sub_path),
|
|
136
|
+
query=dict(request.GET.lists()),
|
|
137
|
+
headers={key.lower(): value for key, value in request.headers.items()},
|
|
138
|
+
path_params=text_params,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _response(result: MeterResponse) -> HttpResponse:
|
|
143
|
+
response = HttpResponse(result.body, status=result.status)
|
|
144
|
+
_apply_headers(response, result.headers)
|
|
145
|
+
return response
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _apply_headers(
|
|
149
|
+
response: HttpResponse | StreamingHttpResponse,
|
|
150
|
+
headers: tuple[tuple[str, str], ...],
|
|
151
|
+
) -> None:
|
|
152
|
+
"""Copy our headers onto a Django response.
|
|
153
|
+
|
|
154
|
+
Content-Length included: Django does not compute one itself, and
|
|
155
|
+
its header mapping overwrites rather than appends, so there is no
|
|
156
|
+
duplicate for a later middleware to trip over. A streaming
|
|
157
|
+
response never carries one to begin with.
|
|
158
|
+
"""
|
|
159
|
+
for key, value in headers:
|
|
160
|
+
response[key] = value
|
|
@@ -0,0 +1,149 @@
|
|
|
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
|
+
"""Register the meter's routes in a FastAPI application's own router.
|
|
14
|
+
|
|
15
|
+
Mounting the raw ASGI callable is simpler and works everywhere, so
|
|
16
|
+
reach for this when the routes need to be *inside* the host app: its
|
|
17
|
+
authentication dependencies, its middleware, its exception handlers and
|
|
18
|
+
its OpenAPI schema all apply to a router, and none of them apply to a
|
|
19
|
+
mount::
|
|
20
|
+
|
|
21
|
+
from taskflow_meter.contrib.fastapi import meter_router
|
|
22
|
+
|
|
23
|
+
app.include_router(
|
|
24
|
+
meter_router(meter),
|
|
25
|
+
prefix="/taskflow",
|
|
26
|
+
dependencies=[Depends(require_admin)],
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
Our path templates already use FastAPI's ``{name}`` syntax, so they are
|
|
30
|
+
registered verbatim and the host's ``prefix`` is recovered from the
|
|
31
|
+
request rather than configured twice.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import asyncio
|
|
37
|
+
from collections.abc import Awaitable
|
|
38
|
+
from collections.abc import Callable
|
|
39
|
+
from collections.abc import Sequence
|
|
40
|
+
|
|
41
|
+
from fastapi import APIRouter
|
|
42
|
+
from fastapi import Request
|
|
43
|
+
from fastapi import Response
|
|
44
|
+
from fastapi.responses import StreamingResponse
|
|
45
|
+
|
|
46
|
+
from taskflow_meter.api import routes as route_table
|
|
47
|
+
from taskflow_meter.api.dispatch import Dispatcher
|
|
48
|
+
from taskflow_meter.api.http import MeterRequest
|
|
49
|
+
from taskflow_meter.api.http import MeterResponse
|
|
50
|
+
from taskflow_meter.api.http import mount_prefix
|
|
51
|
+
from taskflow_meter.api.http import split_path
|
|
52
|
+
from taskflow_meter.api.router import Route
|
|
53
|
+
from taskflow_meter.api.service import MeterService
|
|
54
|
+
from taskflow_meter.api.sse import StreamResponse
|
|
55
|
+
from taskflow_meter.api.sse import aiter_frames
|
|
56
|
+
from taskflow_meter.meter import Meter
|
|
57
|
+
|
|
58
|
+
DEFAULT_STREAM_INTERVAL = 1.0
|
|
59
|
+
DEFAULT_HEARTBEAT = 15.0
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def meter_router(
|
|
63
|
+
meter: Meter,
|
|
64
|
+
*,
|
|
65
|
+
service: MeterService | None = None,
|
|
66
|
+
stream_interval: float = DEFAULT_STREAM_INTERVAL,
|
|
67
|
+
heartbeat: float = DEFAULT_HEARTBEAT,
|
|
68
|
+
tags: Sequence[str] | None = None,
|
|
69
|
+
) -> APIRouter:
|
|
70
|
+
"""Build a router serving every endpoint the callables serve."""
|
|
71
|
+
resolved = service or MeterService(meter)
|
|
72
|
+
dispatcher = Dispatcher(resolved)
|
|
73
|
+
router = APIRouter(tags=list(tags or ["taskflow-meter"]))
|
|
74
|
+
|
|
75
|
+
for route in route_table.build_routes(resolved):
|
|
76
|
+
router.add_api_route(
|
|
77
|
+
route.template,
|
|
78
|
+
_endpoint(meter, dispatcher, route, stream_interval, heartbeat),
|
|
79
|
+
methods=[route.method],
|
|
80
|
+
name=route.name,
|
|
81
|
+
# The payloads are plain dictionaries built by our own
|
|
82
|
+
# serialisers; letting FastAPI infer a response model would
|
|
83
|
+
# only give it something to validate them against twice.
|
|
84
|
+
response_class=Response,
|
|
85
|
+
)
|
|
86
|
+
return router
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _endpoint(
|
|
90
|
+
meter: Meter,
|
|
91
|
+
dispatcher: Dispatcher,
|
|
92
|
+
route: Route,
|
|
93
|
+
stream_interval: float,
|
|
94
|
+
heartbeat: float,
|
|
95
|
+
) -> Callable[[Request], Awaitable[Response]]:
|
|
96
|
+
async def endpoint(request: Request) -> Response:
|
|
97
|
+
# A mounted app is never told the server started, and an
|
|
98
|
+
# included router is no different.
|
|
99
|
+
await asyncio.to_thread(meter.ensure_started)
|
|
100
|
+
|
|
101
|
+
meter_request = _build_request(request, route)
|
|
102
|
+
result = await asyncio.to_thread(dispatcher.run, route, meter_request)
|
|
103
|
+
|
|
104
|
+
if isinstance(result, StreamResponse):
|
|
105
|
+
return StreamingResponse(
|
|
106
|
+
aiter_frames(
|
|
107
|
+
result.cursor,
|
|
108
|
+
interval=stream_interval,
|
|
109
|
+
heartbeat=heartbeat,
|
|
110
|
+
),
|
|
111
|
+
status_code=result.status,
|
|
112
|
+
headers=dict(result.headers),
|
|
113
|
+
)
|
|
114
|
+
return _response(result)
|
|
115
|
+
|
|
116
|
+
return endpoint
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _build_request(request: Request, route: Route) -> MeterRequest:
|
|
120
|
+
"""Translate a Starlette request, recovering the router's prefix.
|
|
121
|
+
|
|
122
|
+
``include_router(prefix=...)`` puts the prefix in the path rather
|
|
123
|
+
than in ``root_path``, so it is found by taking our own template off
|
|
124
|
+
the end of what arrived.
|
|
125
|
+
"""
|
|
126
|
+
params = {key: str(value) for key, value in request.path_params.items()}
|
|
127
|
+
root_path = request.scope.get("root_path", "")
|
|
128
|
+
path = split_path(request.scope.get("path", "/"), root_path)
|
|
129
|
+
sub_path = route.template.format(**params)
|
|
130
|
+
|
|
131
|
+
return MeterRequest(
|
|
132
|
+
method=request.method,
|
|
133
|
+
path=sub_path,
|
|
134
|
+
prefix=root_path + mount_prefix(path, sub_path),
|
|
135
|
+
query={
|
|
136
|
+
key: request.query_params.getlist(key)
|
|
137
|
+
for key in request.query_params
|
|
138
|
+
},
|
|
139
|
+
headers={key.lower(): value for key, value in request.headers.items()},
|
|
140
|
+
path_params=params,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _response(result: MeterResponse) -> Response:
|
|
145
|
+
return Response(
|
|
146
|
+
content=result.body,
|
|
147
|
+
status_code=result.status,
|
|
148
|
+
headers=dict(result.headers),
|
|
149
|
+
)
|