taskflow-meter 1.1.0__tar.gz → 1.3.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 (116) hide show
  1. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/PKG-INFO +16 -9
  2. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/README.md +9 -3
  3. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/pyproject.toml +8 -7
  4. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/taskflow_meter/_version.py +2 -2
  5. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/taskflow_meter/api/sse.py +4 -1
  6. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/taskflow_meter/collect/listener.py +20 -1
  7. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/taskflow_meter/datasource/base.py +4 -2
  8. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/taskflow_meter/datasource/persistence.py +5 -13
  9. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/taskflow_meter/datasource/sqlalchemy/__init__.py +2 -1
  10. taskflow_meter-1.3.0/taskflow_meter/datasource/sqlalchemy/migrations/env.py +115 -0
  11. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/taskflow_meter/datasource/sqlalchemy/source.py +66 -4
  12. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/taskflow_meter/events.py +21 -3
  13. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/taskflow_meter/fold.py +6 -1
  14. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/taskflow_meter/meter.py +4 -2
  15. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/taskflow_meter/models.py +20 -0
  16. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/taskflow_meter/transports/base.py +6 -3
  17. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/tests/functional/test_migrations.py +73 -5
  18. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/tests/integration/test_attach.py +47 -0
  19. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/tests/unit/datasource/sqlalchemy/test_source.py +20 -0
  20. taskflow_meter-1.1.0/taskflow_meter/datasource/sqlalchemy/migrations/env.py +0 -68
  21. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/.gitignore +0 -0
  22. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/LICENSE +0 -0
  23. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/docs/design.md +0 -0
  24. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/docs/guide.md +0 -0
  25. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/docs/releasing.md +0 -0
  26. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/taskflow_meter/__init__.py +0 -0
  27. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/taskflow_meter/api/__init__.py +0 -0
  28. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/taskflow_meter/api/asgi.py +0 -0
  29. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/taskflow_meter/api/dispatch.py +0 -0
  30. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/taskflow_meter/api/http.py +0 -0
  31. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/taskflow_meter/api/router.py +0 -0
  32. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/taskflow_meter/api/routes.py +0 -0
  33. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/taskflow_meter/api/serializers.py +0 -0
  34. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/taskflow_meter/api/service.py +0 -0
  35. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/taskflow_meter/api/wsgi.py +0 -0
  36. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/taskflow_meter/cli.py +0 -0
  37. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/taskflow_meter/collect/__init__.py +0 -0
  38. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/taskflow_meter/collect/attachment.py +0 -0
  39. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/taskflow_meter/collect/pipeline.py +0 -0
  40. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/taskflow_meter/collect/progress.py +0 -0
  41. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/taskflow_meter/conf.py +0 -0
  42. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/taskflow_meter/contrib/__init__.py +0 -0
  43. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/taskflow_meter/contrib/django.py +0 -0
  44. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/taskflow_meter/contrib/fastapi.py +0 -0
  45. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/taskflow_meter/contrib/flask.py +0 -0
  46. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/taskflow_meter/contrib/paste.py +0 -0
  47. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/taskflow_meter/contrib/pecan.py +0 -0
  48. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/taskflow_meter/datasource/__init__.py +0 -0
  49. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/taskflow_meter/datasource/memory.py +0 -0
  50. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/taskflow_meter/datasource/sqlalchemy/migrations/script.py.mako +0 -0
  51. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/taskflow_meter/datasource/sqlalchemy/migrations/versions/0001_initial.py +0 -0
  52. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/taskflow_meter/datasource/sqlalchemy/models.py +0 -0
  53. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/taskflow_meter/diff.py +0 -0
  54. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/taskflow_meter/poller.py +0 -0
  55. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/taskflow_meter/py.typed +0 -0
  56. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/taskflow_meter/states.py +0 -0
  57. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/taskflow_meter/transports/__init__.py +0 -0
  58. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/taskflow_meter/transports/amqp.py +0 -0
  59. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/taskflow_meter/transports/http.py +0 -0
  60. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/taskflow_meter/transports/memory.py +0 -0
  61. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/taskflow_meter/transports/oslo_messaging.py +0 -0
  62. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/tests/__init__.py +0 -0
  63. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/tests/asgi_client.py +0 -0
  64. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/tests/conformance/__init__.py +0 -0
  65. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/tests/conformance/test_hosts.py +0 -0
  66. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/tests/conformance/test_parity.py +0 -0
  67. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/tests/conftest.py +0 -0
  68. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/tests/functional/__init__.py +0 -0
  69. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/tests/functional/test_cross_process.py +0 -0
  70. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/tests/functional/test_examples.py +0 -0
  71. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/tests/functional/test_packaging.py +0 -0
  72. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/tests/functional/test_serve.py +0 -0
  73. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/tests/functional/test_tooling.py +0 -0
  74. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/tests/hosts.py +0 -0
  75. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/tests/integration/__init__.py +0 -0
  76. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/tests/integration/test_collector.py +0 -0
  77. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/tests/unit/__init__.py +0 -0
  78. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/tests/unit/api/__init__.py +0 -0
  79. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/tests/unit/api/test_asgi.py +0 -0
  80. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/tests/unit/api/test_http.py +0 -0
  81. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/tests/unit/api/test_router.py +0 -0
  82. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/tests/unit/api/test_routes.py +0 -0
  83. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/tests/unit/api/test_serializers.py +0 -0
  84. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/tests/unit/api/test_service.py +0 -0
  85. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/tests/unit/api/test_sse.py +0 -0
  86. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/tests/unit/api/test_wsgi.py +0 -0
  87. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/tests/unit/collect/__init__.py +0 -0
  88. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/tests/unit/collect/test_attachment.py +0 -0
  89. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/tests/unit/collect/test_listener.py +0 -0
  90. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/tests/unit/collect/test_pipeline.py +0 -0
  91. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/tests/unit/collect/test_progress.py +0 -0
  92. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/tests/unit/contrib/__init__.py +0 -0
  93. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/tests/unit/contrib/test_django.py +0 -0
  94. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/tests/unit/contrib/test_fastapi.py +0 -0
  95. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/tests/unit/contrib/test_flask.py +0 -0
  96. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/tests/unit/contrib/test_paste.py +0 -0
  97. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/tests/unit/contrib/test_pecan.py +0 -0
  98. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/tests/unit/datasource/__init__.py +0 -0
  99. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/tests/unit/datasource/sqlalchemy/__init__.py +0 -0
  100. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/tests/unit/datasource/test_base.py +0 -0
  101. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/tests/unit/datasource/test_memory.py +0 -0
  102. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/tests/unit/datasource/test_persistence.py +0 -0
  103. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/tests/unit/test_cli.py +0 -0
  104. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/tests/unit/test_conf.py +0 -0
  105. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/tests/unit/test_diff.py +0 -0
  106. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/tests/unit/test_events.py +0 -0
  107. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/tests/unit/test_meter.py +0 -0
  108. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/tests/unit/test_models.py +0 -0
  109. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/tests/unit/test_poller.py +0 -0
  110. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/tests/unit/test_states.py +0 -0
  111. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/tests/unit/transports/__init__.py +0 -0
  112. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/tests/unit/transports/test_amqp.py +0 -0
  113. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/tests/unit/transports/test_http.py +0 -0
  114. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/tests/unit/transports/test_memory.py +0 -0
  115. {taskflow_meter-1.1.0 → taskflow_meter-1.3.0}/tests/unit/transports/test_oslo_messaging.py +0 -0
  116. {taskflow_meter-1.1.0 → taskflow_meter-1.3.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.3.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.3.0'
22
+ __version_tuple__ = version_tuple = (1, 3, 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:
@@ -16,6 +16,7 @@ from __future__ import annotations
16
16
 
17
17
  from taskflow_meter.datasource.sqlalchemy.models import metadata
18
18
  from taskflow_meter.datasource.sqlalchemy.source import SQLADataSource
19
+ from taskflow_meter.datasource.sqlalchemy.source import alembic_config
19
20
  from taskflow_meter.datasource.sqlalchemy.source import upgrade
20
21
 
21
- __all__ = ["SQLADataSource", "metadata", "upgrade"]
22
+ __all__ = ["SQLADataSource", "alembic_config", "metadata", "upgrade"]
@@ -0,0 +1,115 @@
1
+ # Licensed under the Apache License, Version 2.0 (the "License"); you may
2
+ # not use this file except in compliance with the License. You may obtain
3
+ # a copy of the License at
4
+ #
5
+ # http://www.apache.org/licenses/LICENSE-2.0
6
+ #
7
+ # Unless required by applicable law or agreed to in writing, software
8
+ # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
9
+ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
10
+ # License for the specific language governing permissions and limitations
11
+ # under the License.
12
+
13
+ """Alembic environment for the meter's own schema.
14
+
15
+ Driven programmatically by :func:`taskflow_meter.datasource.sqlalchemy
16
+ .upgrade`, so the connection arrives through the config rather than from
17
+ an ``alembic.ini`` a deployment would have to carry.
18
+
19
+ Everything variable is read from ``config.attributes``, never from a
20
+ main option: alembic passes main options through configparser, which
21
+ treats ``%`` as an interpolation and rejects a percent-encoded password.
22
+ See :func:`~taskflow_meter.datasource.sqlalchemy.source.alembic_config`.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from typing import Any
28
+
29
+ import sqlalchemy as sa
30
+ from alembic import context
31
+ from sqlalchemy import pool
32
+
33
+ from taskflow_meter.datasource.sqlalchemy.models import metadata
34
+
35
+ config = context.config
36
+ target_metadata = metadata
37
+
38
+ #: Default name of alembic's own bookkeeping table. Overridable,
39
+ #: because this schema may share a database with a host service that has
40
+ #: an alembic tree of its own -- and two trees writing one
41
+ #: ``alembic_version`` row each mistake the other's revision for a
42
+ #: missing one.
43
+ DEFAULT_VERSION_TABLE = "alembic_version"
44
+
45
+
46
+ def _version_table() -> str:
47
+ return config.attributes.get("version_table") or DEFAULT_VERSION_TABLE
48
+
49
+
50
+ def _url() -> str | None:
51
+ """Where to connect, when no live connection was handed over.
52
+
53
+ Falls back to the main option so that running these migrations from
54
+ a hand-written ``alembic.ini`` still works. Escaping ``%`` is the
55
+ ini author's problem there, and ``%%`` is the documented convention
56
+ for it.
57
+ """
58
+ url = config.attributes.get("url")
59
+ if url is not None:
60
+ return str(url)
61
+ return config.get_main_option("sqlalchemy.url", None)
62
+
63
+
64
+ def run_migrations_offline() -> None:
65
+ context.configure(
66
+ url=_url(),
67
+ target_metadata=target_metadata,
68
+ literal_binds=True,
69
+ dialect_opts={"paramstyle": "named"},
70
+ version_table=_version_table(),
71
+ )
72
+ with context.begin_transaction():
73
+ context.run_migrations()
74
+
75
+
76
+ def run_migrations_online() -> None:
77
+ connection = config.attributes.get("connection")
78
+ if connection is not None:
79
+ # The usual path: somebody else owns the connection and the
80
+ # transaction around it.
81
+ _run(connection)
82
+ return
83
+
84
+ url = _url()
85
+ if url is None:
86
+ # Reached only by a config built by hand that set neither.
87
+ # Saying so beats whatever SQLAlchemy makes of ``None``.
88
+ msg = (
89
+ "no connection and no url to migrate: build the config with "
90
+ "taskflow_meter.datasource.sqlalchemy.alembic_config()"
91
+ )
92
+ raise RuntimeError(msg)
93
+
94
+ engine = sa.create_engine(url, poolclass=pool.NullPool)
95
+ try:
96
+ with engine.connect() as owned:
97
+ _run(owned)
98
+ finally:
99
+ engine.dispose()
100
+
101
+
102
+ def _run(connection: Any) -> None:
103
+ context.configure(
104
+ connection=connection,
105
+ target_metadata=target_metadata,
106
+ version_table=_version_table(),
107
+ )
108
+ with context.begin_transaction():
109
+ context.run_migrations()
110
+
111
+
112
+ if context.is_offline_mode():
113
+ run_migrations_offline()
114
+ else:
115
+ run_migrations_online()
@@ -30,10 +30,14 @@ from collections.abc import Iterable
30
30
  from collections.abc import Iterator
31
31
  from dataclasses import asdict
32
32
  from pathlib import Path
33
+ from typing import TYPE_CHECKING
33
34
  from typing import Any
34
35
 
35
36
  import sqlalchemy as sa
36
37
 
38
+ if TYPE_CHECKING:
39
+ from alembic.config import Config
40
+
37
41
  from taskflow_meter.datasource.base import DEFAULT_EVENT_LIMIT
38
42
  from taskflow_meter.datasource.base import DEFAULT_FLOW_LIMIT
39
43
  from taskflow_meter.datasource.base import EventPage
@@ -352,16 +356,74 @@ def _event_from_row(row: Any) -> Event:
352
356
  )
353
357
 
354
358
 
355
- def upgrade(url: str, revision: str = "head") -> None:
359
+ def upgrade(
360
+ url: str,
361
+ revision: str = "head",
362
+ *,
363
+ version_table: str | None = None,
364
+ ) -> None:
356
365
  """Bring a database up to date, the way a deployment should.
357
366
 
358
367
  Imported lazily: alembic is only needed by whoever runs migrations,
359
368
  not by every process that reads the results.
369
+
370
+ ``version_table`` renames alembic's bookkeeping table, which matters
371
+ when this schema shares a database with a host service that migrates
372
+ its own: both would otherwise keep their revision in
373
+ ``alembic_version``, and each would read the other's as a revision it
374
+ has never heard of. Pass the same name on every upgrade of a given
375
+ database -- changing it later looks exactly like an unmigrated one.
360
376
  """
361
377
  from alembic import command
378
+
379
+ engine = sa.create_engine(url)
380
+ try:
381
+ # One live connection, handed over in ``attributes``. The URL is
382
+ # parsed by SQLAlchemy, which understands URLs, and never by
383
+ # configparser, which does not.
384
+ with engine.begin() as connection:
385
+ config = alembic_config(
386
+ connection=connection, version_table=version_table
387
+ )
388
+ command.upgrade(config, revision)
389
+ finally:
390
+ engine.dispose()
391
+
392
+
393
+ def alembic_config(
394
+ *,
395
+ url: str | None = None,
396
+ connection: sa.Connection | None = None,
397
+ version_table: str | None = None,
398
+ ) -> Config:
399
+ """Build the config this package's migrations run under.
400
+
401
+ Public because ``upgrade`` is not the only thing an operator runs:
402
+ ``downgrade``, ``stamp`` and ``--sql`` mode all need a config, and
403
+ building one by hand is how the escaping rule below gets forgotten.
404
+
405
+ Everything variable travels in ``config.attributes`` rather than as
406
+ a main option, because alembic hands main options to configparser,
407
+ which reads ``%`` as the start of an interpolation and rejects
408
+ anything that is not one. Escaping it to ``%%`` does work, but
409
+ only for whoever remembers; keeping the value out of configparser
410
+ altogether cannot be forgotten.
411
+
412
+ Pass *connection* for a live migration, or *url* for ``--sql`` mode,
413
+ which has no connection to offer.
414
+ """
362
415
  from alembic.config import Config
363
416
 
364
417
  config = Config()
365
- config.set_main_option("script_location", str(MIGRATIONS))
366
- config.set_main_option("sqlalchemy.url", url)
367
- command.upgrade(config, revision)
418
+ # script_location has to be a main option -- alembic reads it from
419
+ # there to find the scripts. It is a path inside this package
420
+ # rather than anything a caller supplies, but an install prefix
421
+ # containing "%" is unusual rather than impossible, so it gets the
422
+ # escaping that the URL no longer needs.
423
+ config.set_main_option(
424
+ "script_location", str(MIGRATIONS).replace("%", "%%")
425
+ )
426
+ config.attributes["url"] = url
427
+ config.attributes["connection"] = connection
428
+ config.attributes["version_table"] = version_table
429
+ return config
@@ -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