taskflow-meter 1.1.0__tar.gz → 1.2.0__tar.gz

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 (115) hide show
  1. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/PKG-INFO +16 -9
  2. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/README.md +9 -3
  3. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/pyproject.toml +8 -7
  4. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/taskflow_meter/_version.py +2 -2
  5. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/taskflow_meter/api/sse.py +4 -1
  6. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/taskflow_meter/collect/listener.py +20 -1
  7. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/taskflow_meter/datasource/base.py +4 -2
  8. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/taskflow_meter/datasource/persistence.py +5 -13
  9. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/taskflow_meter/datasource/sqlalchemy/migrations/env.py +17 -1
  10. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/taskflow_meter/datasource/sqlalchemy/source.py +15 -1
  11. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/taskflow_meter/events.py +21 -3
  12. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/taskflow_meter/fold.py +6 -1
  13. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/taskflow_meter/meter.py +4 -2
  14. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/taskflow_meter/models.py +20 -0
  15. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/taskflow_meter/transports/base.py +6 -3
  16. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/tests/integration/test_attach.py +47 -0
  17. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/tests/unit/datasource/sqlalchemy/test_source.py +20 -0
  18. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/.gitignore +0 -0
  19. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/LICENSE +0 -0
  20. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/docs/design.md +0 -0
  21. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/docs/guide.md +0 -0
  22. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/docs/releasing.md +0 -0
  23. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/taskflow_meter/__init__.py +0 -0
  24. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/taskflow_meter/api/__init__.py +0 -0
  25. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/taskflow_meter/api/asgi.py +0 -0
  26. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/taskflow_meter/api/dispatch.py +0 -0
  27. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/taskflow_meter/api/http.py +0 -0
  28. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/taskflow_meter/api/router.py +0 -0
  29. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/taskflow_meter/api/routes.py +0 -0
  30. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/taskflow_meter/api/serializers.py +0 -0
  31. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/taskflow_meter/api/service.py +0 -0
  32. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/taskflow_meter/api/wsgi.py +0 -0
  33. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/taskflow_meter/cli.py +0 -0
  34. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/taskflow_meter/collect/__init__.py +0 -0
  35. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/taskflow_meter/collect/attachment.py +0 -0
  36. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/taskflow_meter/collect/pipeline.py +0 -0
  37. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/taskflow_meter/collect/progress.py +0 -0
  38. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/taskflow_meter/conf.py +0 -0
  39. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/taskflow_meter/contrib/__init__.py +0 -0
  40. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/taskflow_meter/contrib/django.py +0 -0
  41. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/taskflow_meter/contrib/fastapi.py +0 -0
  42. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/taskflow_meter/contrib/flask.py +0 -0
  43. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/taskflow_meter/contrib/paste.py +0 -0
  44. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/taskflow_meter/contrib/pecan.py +0 -0
  45. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/taskflow_meter/datasource/__init__.py +0 -0
  46. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/taskflow_meter/datasource/memory.py +0 -0
  47. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/taskflow_meter/datasource/sqlalchemy/__init__.py +0 -0
  48. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/taskflow_meter/datasource/sqlalchemy/migrations/script.py.mako +0 -0
  49. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/taskflow_meter/datasource/sqlalchemy/migrations/versions/0001_initial.py +0 -0
  50. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/taskflow_meter/datasource/sqlalchemy/models.py +0 -0
  51. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/taskflow_meter/diff.py +0 -0
  52. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/taskflow_meter/poller.py +0 -0
  53. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/taskflow_meter/py.typed +0 -0
  54. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/taskflow_meter/states.py +0 -0
  55. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/taskflow_meter/transports/__init__.py +0 -0
  56. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/taskflow_meter/transports/amqp.py +0 -0
  57. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/taskflow_meter/transports/http.py +0 -0
  58. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/taskflow_meter/transports/memory.py +0 -0
  59. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/taskflow_meter/transports/oslo_messaging.py +0 -0
  60. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/tests/__init__.py +0 -0
  61. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/tests/asgi_client.py +0 -0
  62. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/tests/conformance/__init__.py +0 -0
  63. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/tests/conformance/test_hosts.py +0 -0
  64. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/tests/conformance/test_parity.py +0 -0
  65. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/tests/conftest.py +0 -0
  66. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/tests/functional/__init__.py +0 -0
  67. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/tests/functional/test_cross_process.py +0 -0
  68. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/tests/functional/test_examples.py +0 -0
  69. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/tests/functional/test_migrations.py +0 -0
  70. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/tests/functional/test_packaging.py +0 -0
  71. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/tests/functional/test_serve.py +0 -0
  72. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/tests/functional/test_tooling.py +0 -0
  73. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/tests/hosts.py +0 -0
  74. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/tests/integration/__init__.py +0 -0
  75. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/tests/integration/test_collector.py +0 -0
  76. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/tests/unit/__init__.py +0 -0
  77. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/tests/unit/api/__init__.py +0 -0
  78. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/tests/unit/api/test_asgi.py +0 -0
  79. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/tests/unit/api/test_http.py +0 -0
  80. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/tests/unit/api/test_router.py +0 -0
  81. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/tests/unit/api/test_routes.py +0 -0
  82. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/tests/unit/api/test_serializers.py +0 -0
  83. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/tests/unit/api/test_service.py +0 -0
  84. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/tests/unit/api/test_sse.py +0 -0
  85. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/tests/unit/api/test_wsgi.py +0 -0
  86. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/tests/unit/collect/__init__.py +0 -0
  87. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/tests/unit/collect/test_attachment.py +0 -0
  88. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/tests/unit/collect/test_listener.py +0 -0
  89. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/tests/unit/collect/test_pipeline.py +0 -0
  90. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/tests/unit/collect/test_progress.py +0 -0
  91. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/tests/unit/contrib/__init__.py +0 -0
  92. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/tests/unit/contrib/test_django.py +0 -0
  93. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/tests/unit/contrib/test_fastapi.py +0 -0
  94. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/tests/unit/contrib/test_flask.py +0 -0
  95. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/tests/unit/contrib/test_paste.py +0 -0
  96. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/tests/unit/contrib/test_pecan.py +0 -0
  97. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/tests/unit/datasource/__init__.py +0 -0
  98. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/tests/unit/datasource/sqlalchemy/__init__.py +0 -0
  99. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/tests/unit/datasource/test_base.py +0 -0
  100. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/tests/unit/datasource/test_memory.py +0 -0
  101. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/tests/unit/datasource/test_persistence.py +0 -0
  102. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/tests/unit/test_cli.py +0 -0
  103. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/tests/unit/test_conf.py +0 -0
  104. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/tests/unit/test_diff.py +0 -0
  105. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/tests/unit/test_events.py +0 -0
  106. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/tests/unit/test_meter.py +0 -0
  107. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/tests/unit/test_models.py +0 -0
  108. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/tests/unit/test_poller.py +0 -0
  109. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/tests/unit/test_states.py +0 -0
  110. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/tests/unit/transports/__init__.py +0 -0
  111. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/tests/unit/transports/test_amqp.py +0 -0
  112. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/tests/unit/transports/test_http.py +0 -0
  113. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/tests/unit/transports/test_memory.py +0 -0
  114. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/tests/unit/transports/test_oslo_messaging.py +0 -0
  115. {taskflow_meter-1.1.0 → taskflow_meter-1.2.0}/tests/wsgi_client.py +0 -0
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: taskflow-meter
3
- Version: 1.1.0
3
+ Version: 1.2.0
4
4
  Summary: Monitoring interfaces (ASGI, WSGI, datasource) for OpenStack TaskFlow flow execution progress
5
- Project-URL: Homepage, https://github.com/daipham3213/taskflow-meter
6
- Project-URL: Source, https://github.com/daipham3213/taskflow-meter
7
- Project-URL: Issues, https://github.com/daipham3213/taskflow-meter/issues
8
- Project-URL: Changelog, https://github.com/daipham3213/taskflow-meter/releases
5
+ Project-URL: Homepage, https://github.com/daipham3213/taskflow.meter
6
+ Project-URL: Source, https://github.com/daipham3213/taskflow.meter
7
+ Project-URL: Issues, https://github.com/daipham3213/taskflow.meter/issues
8
+ Project-URL: Changelog, https://github.com/daipham3213/taskflow.meter/releases
9
9
  Author-email: Pham Le Gia Dai <daipham.3213@gmail.com>
10
10
  License-Expression: Apache-2.0
11
11
  License-File: LICENSE
@@ -15,13 +15,14 @@ Classifier: Intended Audience :: Developers
15
15
  Classifier: Intended Audience :: System Administrators
16
16
  Classifier: Operating System :: OS Independent
17
17
  Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.10
18
19
  Classifier: Programming Language :: Python :: 3.11
19
20
  Classifier: Programming Language :: Python :: 3.12
20
21
  Classifier: Programming Language :: Python :: 3.13
21
22
  Classifier: Programming Language :: Python :: 3.14
22
23
  Classifier: Topic :: System :: Monitoring
23
24
  Classifier: Typing :: Typed
24
- Requires-Python: >=3.11
25
+ Requires-Python: >=3.10
25
26
  Requires-Dist: oslo-config>=6.9.0
26
27
  Requires-Dist: taskflow>=4.2.0
27
28
  Provides-Extra: all
@@ -40,6 +41,12 @@ Description-Content-Type: text/markdown
40
41
 
41
42
  # taskflow-meter
42
43
 
44
+ [![ci](https://github.com/daipham3213/taskflow.meter/actions/workflows/ci.yml/badge.svg)](https://github.com/daipham3213/taskflow.meter/actions/workflows/ci.yml)
45
+ [![conformance](https://github.com/daipham3213/taskflow.meter/actions/workflows/conformance.yml/badge.svg)](https://github.com/daipham3213/taskflow.meter/actions/workflows/conformance.yml)
46
+ [![PyPI](https://img.shields.io/pypi/v/taskflow-meter.svg)](https://pypi.org/project/taskflow-meter/)
47
+ [![Python versions](https://img.shields.io/pypi/pyversions/taskflow-meter.svg)](https://pypi.org/project/taskflow-meter/)
48
+ [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE)
49
+
43
50
  Monitoring interfaces — ASGI, WSGI, datasources, transports — for observing
44
51
  [OpenStack TaskFlow](https://opendev.org/openstack/taskflow) flow execution
45
52
  progress.
@@ -200,7 +207,7 @@ serving an empty stream that cannot be told apart from silence.
200
207
 
201
208
  ## Requirements
202
209
 
203
- - Python 3.11+
210
+ - Python 3.10+
204
211
  - taskflow 4.2.0+
205
212
  - oslo.config 6.9.0+
206
213
 
@@ -263,8 +270,8 @@ CI also runs the suite with every declared dependency floor installed exactly,
263
270
  which is the only job that checks those floors are real. To reproduce it:
264
271
 
265
272
  ```bash
266
- uv lock --python 3.11 --resolution lowest-direct
267
- uv sync --python 3.11 --group dev --all-extras --resolution lowest-direct
273
+ uv lock --python 3.10 --resolution lowest-direct
274
+ uv sync --python 3.10 --group dev --all-extras --resolution lowest-direct
268
275
  uv run --frozen --no-sync pytest
269
276
  ```
270
277
 
@@ -1,5 +1,11 @@
1
1
  # taskflow-meter
2
2
 
3
+ [![ci](https://github.com/daipham3213/taskflow.meter/actions/workflows/ci.yml/badge.svg)](https://github.com/daipham3213/taskflow.meter/actions/workflows/ci.yml)
4
+ [![conformance](https://github.com/daipham3213/taskflow.meter/actions/workflows/conformance.yml/badge.svg)](https://github.com/daipham3213/taskflow.meter/actions/workflows/conformance.yml)
5
+ [![PyPI](https://img.shields.io/pypi/v/taskflow-meter.svg)](https://pypi.org/project/taskflow-meter/)
6
+ [![Python versions](https://img.shields.io/pypi/pyversions/taskflow-meter.svg)](https://pypi.org/project/taskflow-meter/)
7
+ [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE)
8
+
3
9
  Monitoring interfaces — ASGI, WSGI, datasources, transports — for observing
4
10
  [OpenStack TaskFlow](https://opendev.org/openstack/taskflow) flow execution
5
11
  progress.
@@ -160,7 +166,7 @@ serving an empty stream that cannot be told apart from silence.
160
166
 
161
167
  ## Requirements
162
168
 
163
- - Python 3.11+
169
+ - Python 3.10+
164
170
  - taskflow 4.2.0+
165
171
  - oslo.config 6.9.0+
166
172
 
@@ -223,8 +229,8 @@ CI also runs the suite with every declared dependency floor installed exactly,
223
229
  which is the only job that checks those floors are real. To reproduce it:
224
230
 
225
231
  ```bash
226
- uv lock --python 3.11 --resolution lowest-direct
227
- uv sync --python 3.11 --group dev --all-extras --resolution lowest-direct
232
+ uv lock --python 3.10 --resolution lowest-direct
233
+ uv sync --python 3.10 --group dev --all-extras --resolution lowest-direct
228
234
  uv run --frozen --no-sync pytest
229
235
  ```
230
236
 
@@ -7,7 +7,7 @@ name = "taskflow-meter"
7
7
  dynamic = ["version"]
8
8
  description = "Monitoring interfaces (ASGI, WSGI, datasource) for OpenStack TaskFlow flow execution progress"
9
9
  readme = "README.md"
10
- requires-python = ">=3.11"
10
+ requires-python = ">=3.10"
11
11
  license = "Apache-2.0"
12
12
  license-files = ["LICENSE"]
13
13
  authors = [
@@ -20,6 +20,7 @@ classifiers = [
20
20
  "Intended Audience :: System Administrators",
21
21
  "Operating System :: OS Independent",
22
22
  "Programming Language :: Python :: 3",
23
+ "Programming Language :: Python :: 3.10",
23
24
  "Programming Language :: Python :: 3.11",
24
25
  "Programming Language :: Python :: 3.12",
25
26
  "Programming Language :: Python :: 3.13",
@@ -51,10 +52,10 @@ all = [
51
52
  taskflow-meter = "taskflow_meter.cli:main"
52
53
 
53
54
  [project.urls]
54
- Homepage = "https://github.com/daipham3213/taskflow-meter"
55
- Source = "https://github.com/daipham3213/taskflow-meter"
56
- Issues = "https://github.com/daipham3213/taskflow-meter/issues"
57
- Changelog = "https://github.com/daipham3213/taskflow-meter/releases"
55
+ Homepage = "https://github.com/daipham3213/taskflow.meter"
56
+ Source = "https://github.com/daipham3213/taskflow.meter"
57
+ Issues = "https://github.com/daipham3213/taskflow.meter/issues"
58
+ Changelog = "https://github.com/daipham3213/taskflow.meter/releases"
58
59
 
59
60
  # Plugin entry points are declared as their modules land -- one pointing at a
60
61
  # module that does not exist yet breaks discovery for the whole group.
@@ -139,7 +140,7 @@ include = [
139
140
 
140
141
  [tool.ruff]
141
142
  line-length = 79
142
- target-version = "py311"
143
+ target-version = "py310"
143
144
  src = ["."]
144
145
 
145
146
  [tool.ruff.lint]
@@ -174,7 +175,7 @@ force-single-line = true
174
175
  docstring-code-format = true
175
176
 
176
177
  [tool.mypy]
177
- python_version = "3.11"
178
+ python_version = "3.10"
178
179
  strict = true
179
180
  warn_unreachable = true
180
181
  files = ["taskflow_meter", "tests", "tools", "examples"]
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '1.1.0'
22
- __version_tuple__ = version_tuple = (1, 1, 0)
21
+ __version__ = version = '1.2.0'
22
+ __version_tuple__ = version_tuple = (1, 2, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -218,5 +218,8 @@ async def aiter_frames(
218
218
  await asyncio.sleep(interval)
219
219
  else:
220
220
  # Sleep, but wake immediately if the client leaves.
221
- with contextlib.suppress(TimeoutError):
221
+ # asyncio.TimeoutError, spelled out: it is the builtin from
222
+ # 3.11 on, but a class of its own before that, so the bare
223
+ # name catches nothing on 3.10 and the sleep raises.
224
+ with contextlib.suppress(asyncio.TimeoutError):
222
225
  await asyncio.wait_for(stop.wait(), interval)
@@ -28,8 +28,11 @@ import time
28
28
  from collections.abc import Callable
29
29
  from typing import Any
30
30
 
31
+ from taskflow import states
31
32
  from taskflow.listeners import base as tf_listeners
33
+ from taskflow.types import failure as tf_failure
32
34
 
35
+ from taskflow_meter import models
33
36
  from taskflow_meter.events import Event
34
37
  from taskflow_meter.events import EventKind
35
38
  from taskflow_meter.events import SequenceAllocator
@@ -100,9 +103,25 @@ class MeterListener(tf_listeners.Listener):
100
103
  ) -> None:
101
104
  extra: dict[str, Any] = {}
102
105
  if "result" in details:
106
+ result = details["result"]
103
107
  # Results are arbitrary application objects and are not
104
108
  # ours to copy around; their presence is the useful part.
105
- extra["has_result"] = details["result"] is not None
109
+ extra["has_result"] = result is not None
110
+ # Except when the result *is* the failure, which is the one
111
+ # thing anybody watching a flow needs the detail of. Rendered
112
+ # in the same shape the persistence datasource reports, so a
113
+ # client cannot tell which producer it is reading.
114
+ if isinstance(result, tf_failure.Failure):
115
+ # Which one it is follows from the state, not from the
116
+ # intention: taskflow does not put an intention on these
117
+ # notifications, and only a revert that itself failed
118
+ # reaches REVERT_FAILURE.
119
+ key = (
120
+ "revert_failure"
121
+ if state == states.REVERT_FAILURE
122
+ else "failure"
123
+ )
124
+ extra[key] = models.failure_dict(result)
106
125
  self._safely(
107
126
  EventKind.ATOM_STATE,
108
127
  state=state,
@@ -24,12 +24,14 @@ import abc
24
24
  from collections.abc import Iterable
25
25
  from dataclasses import dataclass
26
26
  from types import TracebackType
27
- from typing import Self
27
+ from typing import TypeVar
28
28
 
29
29
  from taskflow_meter.events import Event
30
30
  from taskflow_meter.models import AtomSnapshot
31
31
  from taskflow_meter.models import FlowSnapshot
32
32
 
33
+ _D = TypeVar("_D", bound="DataSource")
34
+
33
35
  #: Flows returned by a single unqualified listing.
34
36
  DEFAULT_FLOW_LIMIT = 50
35
37
 
@@ -96,7 +98,7 @@ class DataSource(abc.ABC):
96
98
  def stop(self) -> None: # noqa: B027 - optional hook, not abstract
97
99
  """Release it again. Idempotent, and safe to call unstarted."""
98
100
 
99
- def __enter__(self) -> Self:
101
+ def __enter__(self: _D) -> _D:
100
102
  self.start()
101
103
  return self
102
104
 
@@ -46,6 +46,7 @@ from taskflow import exceptions as tf_exc
46
46
  from taskflow.persistence import backends as tf_backends
47
47
  from taskflow.persistence import models as tf_models
48
48
 
49
+ from taskflow_meter import models
49
50
  from taskflow_meter.datasource.base import DEFAULT_EVENT_LIMIT
50
51
  from taskflow_meter.datasource.base import DEFAULT_FLOW_LIMIT
51
52
  from taskflow_meter.datasource.base import DataSource
@@ -281,8 +282,10 @@ def _atom_snapshot(detail: Any) -> AtomSnapshot:
281
282
  intention=detail.intention,
282
283
  progress=float(meta.get(_META_PROGRESS) or 0.0),
283
284
  progress_details=meta.get(_META_PROGRESS_DETAILS),
284
- failure=_failure_dict(detail.failure),
285
- revert_failure=_failure_dict(getattr(detail, "revert_failure", None)),
285
+ failure=models.failure_dict(detail.failure),
286
+ revert_failure=models.failure_dict(
287
+ getattr(detail, "revert_failure", None)
288
+ ),
286
289
  # Carrying results would mean copying arbitrary application data
287
290
  # into a monitoring path, so only their presence is reported. A
288
291
  # task that returns None is indistinguishable from one that
@@ -292,17 +295,6 @@ def _atom_snapshot(detail: Any) -> AtomSnapshot:
292
295
  )
293
296
 
294
297
 
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
298
  def _epoch(when: Any) -> float | None:
307
299
  """Convert a datetime to a float, or give up quietly."""
308
300
  try:
@@ -30,6 +30,17 @@ from taskflow_meter.datasource.sqlalchemy.models import metadata
30
30
  config = context.config
31
31
  target_metadata = metadata
32
32
 
33
+ #: Default name of alembic's own bookkeeping table. Overridable,
34
+ #: because this schema may share a database with a host service that has
35
+ #: an alembic tree of its own -- and two trees writing one
36
+ #: ``alembic_version`` row each mistake the other's revision for a
37
+ #: missing one.
38
+ DEFAULT_VERSION_TABLE = "alembic_version"
39
+
40
+
41
+ def _version_table() -> str:
42
+ return config.get_main_option("version_table", DEFAULT_VERSION_TABLE)
43
+
33
44
 
34
45
  def run_migrations_offline() -> None:
35
46
  context.configure(
@@ -37,6 +48,7 @@ def run_migrations_offline() -> None:
37
48
  target_metadata=target_metadata,
38
49
  literal_binds=True,
39
50
  dialect_opts={"paramstyle": "named"},
51
+ version_table=_version_table(),
40
52
  )
41
53
  with context.begin_transaction():
42
54
  context.run_migrations()
@@ -57,7 +69,11 @@ def run_migrations_online() -> None:
57
69
 
58
70
 
59
71
  def _run(connection: Any) -> None:
60
- context.configure(connection=connection, target_metadata=target_metadata)
72
+ context.configure(
73
+ connection=connection,
74
+ target_metadata=target_metadata,
75
+ version_table=_version_table(),
76
+ )
61
77
  with context.begin_transaction():
62
78
  context.run_migrations()
63
79
 
@@ -352,11 +352,23 @@ def _event_from_row(row: Any) -> Event:
352
352
  )
353
353
 
354
354
 
355
- def upgrade(url: str, revision: str = "head") -> None:
355
+ def upgrade(
356
+ url: str,
357
+ revision: str = "head",
358
+ *,
359
+ version_table: str | None = None,
360
+ ) -> None:
356
361
  """Bring a database up to date, the way a deployment should.
357
362
 
358
363
  Imported lazily: alembic is only needed by whoever runs migrations,
359
364
  not by every process that reads the results.
365
+
366
+ ``version_table`` renames alembic's bookkeeping table, which matters
367
+ when this schema shares a database with a host service that migrates
368
+ its own: both would otherwise keep their revision in
369
+ ``alembic_version``, and each would read the other's as a revision it
370
+ has never heard of. Pass the same name on every upgrade of a given
371
+ database -- changing it later looks exactly like an unmigrated one.
360
372
  """
361
373
  from alembic import command
362
374
  from alembic.config import Config
@@ -364,4 +376,6 @@ def upgrade(url: str, revision: str = "head") -> None:
364
376
  config = Config()
365
377
  config.set_main_option("script_location", str(MIGRATIONS))
366
378
  config.set_main_option("sqlalchemy.url", url)
379
+ if version_table is not None:
380
+ config.set_main_option("version_table", version_table)
367
381
  command.upgrade(config, revision)
@@ -19,14 +19,32 @@ datasources, the API and any transport only ever handle one vocabulary.
19
19
 
20
20
  from __future__ import annotations
21
21
 
22
+ import sys
22
23
  import threading
23
24
  from collections.abc import Mapping
24
25
  from dataclasses import asdict
25
26
  from dataclasses import dataclass
26
27
  from dataclasses import field
27
- from enum import StrEnum
28
28
  from typing import Any
29
- from typing import Self
29
+ from typing import TypeVar
30
+
31
+ if sys.version_info >= (3, 11):
32
+ from enum import StrEnum
33
+ else:
34
+ from enum import Enum
35
+
36
+ class StrEnum(str, Enum):
37
+ """As much of 3.11's ``StrEnum`` as this module relies on.
38
+
39
+ A plain ``str, Enum`` mixin renders as ``EventKind.FLOW_STATE``
40
+ under ``str()``, and the value is what goes on the wire.
41
+ """
42
+
43
+ def __str__(self) -> str:
44
+ return str.__str__(self)
45
+
46
+
47
+ _E = TypeVar("_E", bound="Event")
30
48
 
31
49
 
32
50
  class EventKind(StrEnum):
@@ -77,7 +95,7 @@ class Event:
77
95
  return data
78
96
 
79
97
  @classmethod
80
- def from_dict(cls, data: Mapping[str, Any]) -> Self:
98
+ def from_dict(cls: type[_E], data: Mapping[str, Any]) -> _E:
81
99
  """Rebuild from :meth:`to_dict` output.
82
100
 
83
101
  Unknown keys are rejected rather than dropped -- a transport
@@ -55,7 +55,12 @@ def fold(flow: FlowSnapshot, event: Event) -> FlowSnapshot:
55
55
  flow = replace(flow, observed_at=max(flow.observed_at, event.ts))
56
56
 
57
57
  if event.kind is EventKind.FLOW_STATE:
58
- return replace(flow, state=event.state)
58
+ # The name arrives on these events too, and has to be taken from
59
+ # one of them when the run was seeded by something else: the
60
+ # in-process producer emits the graph before anything runs, and a
61
+ # structure event carries no name to seed from.
62
+ name = _optional_str(event.details.get("flow_name")) or flow.name
63
+ return replace(flow, state=event.state, name=name)
59
64
 
60
65
  if event.kind in (EventKind.ATOM_STATE, EventKind.ATOM_PROGRESS):
61
66
  if event.atom_name is None:
@@ -42,7 +42,7 @@ import logging
42
42
  import threading
43
43
  from collections.abc import AsyncIterator
44
44
  from types import TracebackType
45
- from typing import Self
45
+ from typing import TypeVar
46
46
 
47
47
  from taskflow_meter.datasource.base import DEFAULT_EVENT_LIMIT
48
48
  from taskflow_meter.datasource.base import DEFAULT_FLOW_LIMIT
@@ -58,6 +58,8 @@ from taskflow_meter.poller import Poller
58
58
 
59
59
  LOG = logging.getLogger(__name__)
60
60
 
61
+ _M = TypeVar("_M", bound="Meter")
62
+
61
63
 
62
64
  class Meter:
63
65
  """Owns a source, a store, a poller, and their lifecycle."""
@@ -180,7 +182,7 @@ class Meter:
180
182
  self._holders = 0
181
183
  self._stop_locked()
182
184
 
183
- def __enter__(self) -> Self:
185
+ def __enter__(self: _M) -> _M:
184
186
  self.start()
185
187
  return self
186
188
 
@@ -20,12 +20,15 @@ producer it is talking to.
20
20
 
21
21
  from __future__ import annotations
22
22
 
23
+ import logging
23
24
  from dataclasses import dataclass
24
25
  from dataclasses import field
25
26
  from typing import Any
26
27
 
27
28
  from taskflow_meter import states
28
29
 
30
+ LOG = logging.getLogger(__name__)
31
+
29
32
  TASK = "task"
30
33
  RETRY = "retry"
31
34
 
@@ -141,3 +144,20 @@ class FlowSnapshot:
141
144
 
142
145
  def _clamp(value: float, low: float = 0.0, high: float = 1.0) -> float:
143
146
  return max(low, min(high, value))
147
+
148
+
149
+ def failure_dict(failure: Any) -> dict[str, Any] | None:
150
+ """Render a taskflow ``Failure``, tolerating anything that is not one.
151
+
152
+ Lives here because both producers report failures and a client must
153
+ not have to tell which one it is reading: the persistence datasource
154
+ renders what taskflow stored, the listener renders what taskflow
155
+ handed it as it happened, and this is the one shape either way.
156
+ """
157
+ if failure is None:
158
+ return None
159
+ try:
160
+ return dict(failure.to_dict())
161
+ except Exception:
162
+ LOG.warning("could not serialise a failure", exc_info=True)
163
+ return {"unserialisable": repr(failure)}
@@ -25,10 +25,13 @@ from collections.abc import Mapping
25
25
  from collections.abc import Sequence
26
26
  from types import TracebackType
27
27
  from typing import Any
28
- from typing import Self
28
+ from typing import TypeVar
29
29
 
30
30
  from taskflow_meter.events import Event
31
31
 
32
+ _P = TypeVar("_P", bound="Publisher")
33
+ _S = TypeVar("_S", bound="Subscriber")
34
+
32
35
 
33
36
  def to_payload(events: Sequence[Event]) -> dict[str, Any]:
34
37
  """The envelope every transport that leaves the process sends.
@@ -64,7 +67,7 @@ class Publisher(abc.ABC):
64
67
  def publish(self, events: Sequence[Event]) -> None:
65
68
  """Send a batch. Never called with an empty one."""
66
69
 
67
- def __enter__(self) -> Self:
70
+ def __enter__(self: _P) -> _P:
68
71
  self.start()
69
72
  return self
70
73
 
@@ -108,7 +111,7 @@ class Subscriber(abc.ABC):
108
111
  rather than being trapped inside one.
109
112
  """
110
113
 
111
- def __enter__(self) -> Self:
114
+ def __enter__(self: _S) -> _S:
112
115
  self.start()
113
116
  return self
114
117
 
@@ -327,3 +327,50 @@ def test_nothing_is_left_registered_on_the_tasks() -> None:
327
327
 
328
328
  assert seen == []
329
329
  assert threading.active_count() <= before
330
+
331
+
332
+ @pytest.mark.integration
333
+ def test_the_flow_keeps_its_name_behind_the_structure_event() -> None:
334
+ # The graph is emitted before anything runs, so it is the event a run
335
+ # gets seeded from -- and it carries no flow name. The name arrives on
336
+ # every state event after it, and has to be taken from one of those or
337
+ # a client is told the flow is called "".
338
+ flow = linear_flow.Flow("named-flow").add(ProgressingTask("only"))
339
+ engine = engines.load(flow)
340
+
341
+ with attach(engine) as watched:
342
+ engine.run()
343
+ assert watched.flush(5.0)
344
+ assert watched.store is not None
345
+ snapshot = watched.store.get_flow(watched.run_id)
346
+
347
+ assert snapshot is not None
348
+ assert snapshot.name == "named-flow"
349
+
350
+
351
+ @pytest.mark.integration
352
+ def test_a_failed_task_reports_why_it_failed() -> None:
353
+ # The state says a task failed; the failure says what of. Reading
354
+ # persistence has always reported this, and watching the engine has
355
+ # the failure in hand as it happens.
356
+ class Exploding(task.Task):
357
+ def execute(self) -> None:
358
+ raise ValueError("no appliance answered")
359
+
360
+ flow = linear_flow.Flow("demo").add(Exploding("boom"))
361
+ engine = engines.load(flow)
362
+
363
+ with attach(engine) as watched:
364
+ with pytest.raises(ValueError, match="no appliance answered"):
365
+ engine.run()
366
+ assert watched.flush(5.0)
367
+ assert watched.store is not None
368
+ atoms = watched.store.get_atoms(watched.run_id)
369
+
370
+ assert atoms is not None
371
+ (atom,) = atoms
372
+ assert atom.failure is not None
373
+ assert "ValueError" in atom.failure["exc_type_names"]
374
+ assert "no appliance answered" in atom.failure["exception_str"]
375
+ # And the same shape the persistence datasource reports.
376
+ assert set(atom.failure) >= {"exc_type_names", "exception_str"}
@@ -374,6 +374,26 @@ def test_upgrade_is_idempotent(tmp_path: Path) -> None:
374
374
  upgrade(url)
375
375
 
376
376
 
377
+ def test_upgrade_can_be_told_where_to_keep_its_revision(
378
+ tmp_path: Path,
379
+ ) -> None:
380
+ # Sharing a database with a host service that migrates its own schema
381
+ # means sharing ``alembic_version`` unless this is passed, and each
382
+ # tree then reads the other's revision as one it has never heard of.
383
+ url = f"sqlite:///{tmp_path / 'shared.db'}"
384
+ upgrade(url, version_table="taskflow_meter_version")
385
+
386
+ store = SQLADataSource(url)
387
+ with store.engine.begin() as conn:
388
+ tables = set(sa.inspect(conn).get_table_names())
389
+
390
+ assert "taskflow_meter_version" in tables
391
+ assert "alembic_version" not in tables
392
+
393
+ # And it stays idempotent against the table it was given.
394
+ upgrade(url, version_table="taskflow_meter_version")
395
+
396
+
377
397
  def test_the_source_can_be_stopped_and_reused(
378
398
  tmp_path: Path,
379
399
  ) -> None:
File without changes