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