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,140 @@
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 Flask application's own blueprint.
14
+
15
+ Mounting the WSGI callable with ``DispatcherMiddleware`` is simpler and
16
+ works everywhere; reach for this when the routes need to be *inside*
17
+ the host app, so its ``before_request`` hooks, error handlers and
18
+ authentication apply to them::
19
+
20
+ from taskflow_meter.contrib.flask import meter_blueprint
21
+
22
+ app.register_blueprint(meter_blueprint(meter), url_prefix="/taskflow")
23
+
24
+ Flask's rule syntax differs from ours -- ``<run_id>`` rather than
25
+ ``{run_id}`` -- so the templates are translated on the way in, from the
26
+ same table the router matches on.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import re
32
+ from collections.abc import Callable
33
+ from typing import Any
34
+
35
+ import flask
36
+ from flask import Blueprint
37
+ from flask import Response
38
+
39
+ from taskflow_meter.api import routes as route_table
40
+ from taskflow_meter.api.dispatch import Dispatcher
41
+ from taskflow_meter.api.http import MeterRequest
42
+ from taskflow_meter.api.http import MeterResponse
43
+ from taskflow_meter.api.http import mount_prefix
44
+ from taskflow_meter.api.router import Route
45
+ from taskflow_meter.api.service import MeterService
46
+ from taskflow_meter.api.sse import StreamResponse
47
+ from taskflow_meter.api.sse import iter_frames
48
+ from taskflow_meter.meter import Meter
49
+
50
+ DEFAULT_STREAM_INTERVAL = 1.0
51
+ DEFAULT_HEARTBEAT = 15.0
52
+
53
+ _PARAM = re.compile(r"\{([a-zA-Z_][a-zA-Z0-9_]*)\}")
54
+
55
+
56
+ def meter_blueprint(
57
+ meter: Meter,
58
+ *,
59
+ service: MeterService | None = None,
60
+ name: str = "taskflow_meter",
61
+ stream_interval: float = DEFAULT_STREAM_INTERVAL,
62
+ heartbeat: float = DEFAULT_HEARTBEAT,
63
+ ) -> Blueprint:
64
+ """Build a blueprint serving every endpoint the callables serve."""
65
+ resolved = service or MeterService(meter)
66
+ dispatcher = Dispatcher(resolved)
67
+ blueprint = Blueprint(name, __name__)
68
+
69
+ for route in route_table.build_routes(resolved):
70
+ blueprint.add_url_rule(
71
+ to_flask_rule(route.template),
72
+ endpoint=route.name,
73
+ view_func=_view(
74
+ meter, dispatcher, route, stream_interval, heartbeat
75
+ ),
76
+ methods=[route.method],
77
+ )
78
+ return blueprint
79
+
80
+
81
+ def to_flask_rule(template: str) -> str:
82
+ """``/flows/{run_id}`` -> ``/flows/<run_id>``."""
83
+ return _PARAM.sub(r"<\1>", template)
84
+
85
+
86
+ def _view(
87
+ meter: Meter,
88
+ dispatcher: Dispatcher,
89
+ route: Route,
90
+ stream_interval: float,
91
+ heartbeat: float,
92
+ ) -> Callable[..., Response]:
93
+ def view(**params: Any) -> Response:
94
+ # Flask has no lifespan either, so the first request is where a
95
+ # meter nobody started gets started.
96
+ meter.ensure_started()
97
+
98
+ result = dispatcher.run(route, _build_request(route, params))
99
+ if isinstance(result, StreamResponse):
100
+ return Response(
101
+ iter_frames(
102
+ result.cursor,
103
+ interval=stream_interval,
104
+ heartbeat=heartbeat,
105
+ ),
106
+ status=result.status,
107
+ headers=list(result.headers),
108
+ )
109
+ return _response(result)
110
+
111
+ return view
112
+
113
+
114
+ def _build_request(route: Route, params: dict[str, Any]) -> MeterRequest:
115
+ """Translate the active Flask request, recovering the url_prefix.
116
+
117
+ ``request.path`` excludes ``SCRIPT_NAME`` but includes whatever
118
+ prefix the blueprint was registered under, so the prefix is what is
119
+ left when our own template is taken off the end.
120
+ """
121
+ request = flask.request
122
+ text_params = {key: str(value) for key, value in params.items()}
123
+ sub_path = route.template.format(**text_params)
124
+
125
+ return MeterRequest(
126
+ method=request.method,
127
+ path=sub_path,
128
+ prefix=request.script_root + mount_prefix(request.path, sub_path),
129
+ query={key: request.args.getlist(key) for key in request.args},
130
+ headers={key.lower(): value for key, value in request.headers.items()},
131
+ path_params=text_params,
132
+ )
133
+
134
+
135
+ def _response(result: MeterResponse) -> Response:
136
+ return Response(
137
+ result.body,
138
+ status=result.status,
139
+ headers=list(result.headers),
140
+ )
@@ -0,0 +1,96 @@
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
+ """Host the meter in a paste pipeline.
14
+
15
+ A service that composes its WSGI stack from ``api-paste.ini`` wants a
16
+ ``paste.app_factory``. Dispatch a prefix to the app and it is served
17
+ alongside the API it is monitoring, on the same port, behind the same
18
+ middleware::
19
+
20
+ [composite:main]
21
+ use = egg:Paste#urlmap
22
+ /: your_api
23
+ /taskflow-meter: taskflow_meter
24
+
25
+ [app:taskflow_meter]
26
+ paste.app_factory = taskflow_meter.contrib.paste:app_factory
27
+
28
+ Configuration comes from the ``[taskflow_meter]`` group in the
29
+ service's own oslo.config file, so there is no second config file to
30
+ deploy. Anything in the paste stanza
31
+ overrides it, for the deployments that would rather keep it all in
32
+ ``api-paste.ini``::
33
+
34
+ [app:taskflow_meter]
35
+ paste.app_factory = taskflow_meter.contrib.paste:app_factory
36
+ connection = mysql+pymysql://user:pass@host/taskflow
37
+ poll_interval = 5
38
+
39
+ urlmap gives the app a ``SCRIPT_NAME`` of the prefix it was mounted at,
40
+ which is exactly what the WSGI callable builds its links from, so the
41
+ mount point needs no configuring.
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ from typing import Any
47
+
48
+ from oslo_config import cfg
49
+
50
+ from taskflow_meter.api.wsgi import WSGIApp
51
+ from taskflow_meter.conf import GROUP_NAME
52
+ from taskflow_meter.conf import meter_from_config
53
+ from taskflow_meter.conf import register_opts
54
+
55
+ #: Settings a paste stanza may override, and how to read them.
56
+ _OVERRIDES: dict[str, Any] = {
57
+ "connection": str,
58
+ "poll": lambda value: str(value).lower() in {"1", "true", "yes", "on"},
59
+ "poll_interval": float,
60
+ "max_events_per_run": int,
61
+ }
62
+
63
+
64
+ def app_factory(
65
+ global_config: dict[str, Any] | None = None, # noqa: ARG001
66
+ **local_conf: str,
67
+ ) -> WSGIApp:
68
+ """Build the WSGI app. Signature fixed by paste.
69
+
70
+ ``global_config`` is paste's ``[DEFAULT]`` section, which we do not
71
+ read: the service's oslo.config is the source of truth, and the
72
+ paste stanza is the override.
73
+ """
74
+ conf = register_opts()
75
+ apply_overrides(conf, local_conf)
76
+ return WSGIApp(meter_from_config(conf))
77
+
78
+
79
+ def apply_overrides(conf: cfg.ConfigOpts, local_conf: dict[str, str]) -> None:
80
+ """Fold ``api-paste.ini`` values into the config.
81
+
82
+ Paste hands everything over as a string, so each one is coerced by
83
+ the type its option expects. An unusable value is rejected here,
84
+ naming the setting, rather than surfacing later as an odd failure
85
+ from somewhere else.
86
+ """
87
+ for name, coerce in _OVERRIDES.items():
88
+ if name not in local_conf:
89
+ continue
90
+ raw = local_conf[name]
91
+ try:
92
+ value = coerce(raw)
93
+ except (TypeError, ValueError) as exc:
94
+ msg = f"{name}={raw!r} in the paste stanza is not usable"
95
+ raise ValueError(msg) from exc
96
+ conf.set_override(name, value, group=GROUP_NAME)
@@ -0,0 +1,84 @@
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
+ """Host the meter inside a Pecan controller tree.
14
+
15
+ For services built on Pecan, when the meter should live in the
16
+ application's own routing rather than beside it in a paste pipeline::
17
+
18
+ from taskflow_meter.contrib.pecan import MeterController
19
+
20
+
21
+ class RootController:
22
+ v1 = V1Controller()
23
+ taskflow_meter = MeterController()
24
+
25
+ Everything under that path is handed to the WSGI callable. Pecan is
26
+ built on WebOb, so the request already carries the environ the callable
27
+ wants; the only work is moving the part of the path Pecan has already
28
+ consumed out of ``PATH_INFO`` and into ``SCRIPT_NAME``, which is what
29
+ makes the generated links point back at the right place.
30
+
31
+ Mounting a sub-application means the host's own hooks and middleware do
32
+ not run for these requests. For deployments where they must, wire the
33
+ route table into the host's router instead -- ``api.routes.build_routes``
34
+ is that table, and it is data.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ from typing import Any
40
+
41
+ import pecan
42
+ from webob import Request
43
+
44
+ from taskflow_meter.api.http import mount_prefix
45
+ from taskflow_meter.api.wsgi import WSGIApp
46
+ from taskflow_meter.conf import wsgi_app_from_config
47
+
48
+
49
+ class MeterController:
50
+ """A Pecan controller that delegates to the WSGI callable."""
51
+
52
+ def __init__(self, app: WSGIApp | None = None) -> None:
53
+ """Wrap ``app``, or build one from the service's config."""
54
+ self.app = app if app is not None else wsgi_app_from_config()
55
+
56
+ @pecan.expose()
57
+ def index(self) -> Any:
58
+ """The controller's own path, with nothing after it."""
59
+ return self._delegate(())
60
+
61
+ @pecan.expose()
62
+ def _default(self, *remainder: str) -> Any:
63
+ """Everything below it."""
64
+ return self._delegate(remainder)
65
+
66
+ def _delegate(self, remainder: tuple[str, ...]) -> Any:
67
+ request = pecan.request
68
+ environ = dict(request.environ)
69
+ consumed, sub_path = _split(request.path_info, remainder)
70
+ environ["SCRIPT_NAME"] = request.script_name + consumed
71
+ environ["PATH_INFO"] = sub_path
72
+ # Pecan uses a returned WebOb response as-is, so the callable's
73
+ # status, headers and body reach the client untouched.
74
+ return Request(environ).get_response(self.app)
75
+
76
+
77
+ def _split(path_info: str, remainder: tuple[str, ...]) -> tuple[str, str]:
78
+ """Split the path into what Pecan consumed and what is left for us.
79
+
80
+ Pecan does not rewrite ``PATH_INFO`` as it routes, so the mount
81
+ point has to be recovered by taking the remainder off the end.
82
+ """
83
+ sub_path = "/" + "/".join(remainder) if remainder else "/"
84
+ return mount_prefix(path_info, sub_path), sub_path
@@ -0,0 +1,33 @@
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
+ """Datasources: where flow state is read from, and optionally written to."""
14
+
15
+ from __future__ import annotations
16
+
17
+ from taskflow_meter.datasource.base import DataSource
18
+ from taskflow_meter.datasource.base import EventPage
19
+ from taskflow_meter.datasource.base import FlowPage
20
+ from taskflow_meter.datasource.base import UnknownMarkerError
21
+ from taskflow_meter.datasource.base import WritableDataSource
22
+ from taskflow_meter.datasource.memory import MemoryDataSource
23
+ from taskflow_meter.datasource.persistence import PersistenceDataSource
24
+
25
+ __all__ = [
26
+ "DataSource",
27
+ "EventPage",
28
+ "FlowPage",
29
+ "MemoryDataSource",
30
+ "PersistenceDataSource",
31
+ "UnknownMarkerError",
32
+ "WritableDataSource",
33
+ ]
@@ -0,0 +1,154 @@
1
+ # Licensed under the Apache License, Version 2.0 (the "License"); you may
2
+ # not use this file except in compliance with the License. You may obtain
3
+ # a copy of the License at
4
+ #
5
+ # http://www.apache.org/licenses/LICENSE-2.0
6
+ #
7
+ # Unless required by applicable law or agreed to in writing, software
8
+ # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
9
+ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
10
+ # License for the specific language governing permissions and limitations
11
+ # under the License.
12
+
13
+ """The contract every datasource implements.
14
+
15
+ Read and write are separate on purpose. The primary datasource reads
16
+ taskflow's own persistence and can never accept events, while the in-memory
17
+ and SQL ones are fed by a producer; making that a type distinction stops a
18
+ read-only source from being wired up as a sink by mistake.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import abc
24
+ from collections.abc import Iterable
25
+ from dataclasses import dataclass
26
+ from types import TracebackType
27
+ from typing import Self
28
+
29
+ from taskflow_meter.events import Event
30
+ from taskflow_meter.models import AtomSnapshot
31
+ from taskflow_meter.models import FlowSnapshot
32
+
33
+ #: Flows returned by a single unqualified listing.
34
+ DEFAULT_FLOW_LIMIT = 50
35
+
36
+ #: Events returned by a single :meth:`DataSource.events_since` call.
37
+ DEFAULT_EVENT_LIMIT = 500
38
+
39
+
40
+ class UnknownMarkerError(LookupError):
41
+ """A paging marker refers to a flow the datasource cannot place.
42
+
43
+ Usually means the run expired between pages. Raised rather than
44
+ silently restarting from the top, which would make a client loop over
45
+ the same first page forever.
46
+ """
47
+
48
+
49
+ @dataclass(frozen=True, slots=True)
50
+ class FlowPage:
51
+ """One page of flows, newest observation first."""
52
+
53
+ items: tuple[FlowSnapshot, ...] = ()
54
+ next_marker: str | None = None
55
+
56
+ @property
57
+ def has_more(self) -> bool:
58
+ return self.next_marker is not None
59
+
60
+
61
+ @dataclass(frozen=True, slots=True)
62
+ class EventPage:
63
+ """One page of a run's event stream.
64
+
65
+ ``truncated`` is the important field: it says the caller asked for
66
+ events that have already been evicted, so there is a hole between what
67
+ it last saw and what it is being given. A client that cares must
68
+ re-read the snapshot instead of assuming continuity.
69
+ """
70
+
71
+ events: tuple[Event, ...] = ()
72
+ next_seq: int = 0
73
+ oldest_seq: int | None = None
74
+ truncated: bool = False
75
+
76
+
77
+ class DataSource(abc.ABC):
78
+ """Read access to observed flows."""
79
+
80
+ #: Stevedore plugin name, set by subclasses.
81
+ name: str = ""
82
+
83
+ #: Whether :meth:`events_since` can actually return a history. A
84
+ #: source reading a store that only keeps current state sets this
85
+ #: False, so an API can decline to advertise a stream it cannot serve
86
+ #: rather than handing clients an empty one that is indistinguishable
87
+ #: from silence.
88
+ supports_events: bool = True
89
+
90
+ def start(self) -> None: # noqa: B027 - optional hook, not abstract
91
+ """Acquire whatever the source needs. Idempotent.
92
+
93
+ Sources holding nothing (the in-memory one) need not override it.
94
+ """
95
+
96
+ def stop(self) -> None: # noqa: B027 - optional hook, not abstract
97
+ """Release it again. Idempotent, and safe to call unstarted."""
98
+
99
+ def __enter__(self) -> Self:
100
+ self.start()
101
+ return self
102
+
103
+ def __exit__(
104
+ self,
105
+ exc_type: type[BaseException] | None,
106
+ exc: BaseException | None,
107
+ tb: TracebackType | None,
108
+ ) -> None:
109
+ self.stop()
110
+
111
+ @abc.abstractmethod
112
+ def list_flows(
113
+ self,
114
+ *,
115
+ state: str | None = None,
116
+ book_id: str | None = None,
117
+ limit: int = DEFAULT_FLOW_LIMIT,
118
+ marker: str | None = None,
119
+ ) -> FlowPage:
120
+ """Return flows, newest observation first."""
121
+
122
+ @abc.abstractmethod
123
+ def get_flow(self, run_id: str) -> FlowSnapshot | None:
124
+ """Return one flow, or ``None`` if the source has never seen it."""
125
+
126
+ @abc.abstractmethod
127
+ def events_since(
128
+ self,
129
+ run_id: str,
130
+ *,
131
+ since_seq: int = 0,
132
+ limit: int = DEFAULT_EVENT_LIMIT,
133
+ ) -> EventPage:
134
+ """Return events for ``run_id`` with ``seq`` greater than
135
+ ``since_seq``."""
136
+
137
+ def get_atoms(self, run_id: str) -> tuple[AtomSnapshot, ...] | None:
138
+ """Return a flow's atoms in name order, or ``None`` if unknown."""
139
+ flow = self.get_flow(run_id)
140
+ if flow is None:
141
+ return None
142
+ return tuple(flow.atoms[name] for name in flow.atom_names)
143
+
144
+
145
+ class WritableDataSource(DataSource):
146
+ """A datasource that is fed by a producer."""
147
+
148
+ @abc.abstractmethod
149
+ def apply(self, event: Event) -> None:
150
+ """Fold one event into the stored state."""
151
+
152
+ def apply_many(self, events: Iterable[Event]) -> None:
153
+ for event in events:
154
+ self.apply(event)