Skip to content

Commit ee95ec2

Browse files
authored
Allow to add tags on registration (#102)
* Allow to add tags on registration * Fix log config
1 parent df5e923 commit ee95ec2

11 files changed

Lines changed: 310 additions & 209 deletions

File tree

docs/release-notes.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,19 @@
11
# Release Notes
22

3+
## v2.4.2
4+
5+
Tasks can now be tagged at registration time.
6+
7+
- The task registration methods accept an optional `tags` argument. When
8+
provided, the extra tags are merged into each task's own tags as it is
9+
registered — applied across `register_task`, `register_from_module`, and
10+
`register_from_dict`.
11+
([#102](https://github.com/quantmind/aio-fluid/pull/102))
12+
- Bumped `python-json-logger` to `>= 4.1.0` and switched JSON logging to the
13+
new `pythonjsonlogger.json` formatter path (the old `pythonjsonlogger.jsonlogger`
14+
module is deprecated).
15+
([#102](https://github.com/quantmind/aio-fluid/pull/102))
16+
317
## v2.4.1
418

519
Task history can now be filtered by task tags.

docs/tutorials/task_app.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,15 @@ if __name__ == "__main__":
2828
asyncio.run(consumer.run())
2929
```
3030

31+
Pass `tags` to [register_from_module][fluid.scheduler.TaskManager.register_from_module]
32+
(or [register_from_dict][fluid.scheduler.TaskManager.register_from_dict] /
33+
[register_task][fluid.scheduler.TaskManager.register_task]) to add extra tags to
34+
every registered task on top of the tags already declared on each task:
35+
36+
```python
37+
consumer.register_from_module(task_module_a, tags=["module-a"])
38+
```
39+
3140
## FastAPI Integration
3241

3342
A [TaskManager][fluid.scheduler.TaskManager] can be integrated with FastAPI so that

docs/tutorials/task_k8s.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# K8s Jobs
22

3-
When the [TaskConsumer][fluid.scheduler.TaskConsumer] runs inside a Kubernetes cluster, [CPU bound tasks](/tutorials/task_queue/#cpu-bound-tasks) can be dispatched as [Kubernetes Jobs](https://kubernetes.io/docs/concepts/workloads/controllers/job/) instead of local subprocesses.
3+
When the [TaskConsumer][fluid.scheduler.TaskConsumer] runs inside a Kubernetes cluster, [CPU bound tasks](task_queue.md#cpu-bound-tasks) can be dispatched as [Kubernetes Jobs](https://kubernetes.io/docs/concepts/workloads/controllers/job/) instead of local subprocesses.
44
This offloads heavy computation to dedicated pods and keeps the consumer event loop free.
55

66
## How it works

fluid/scheduler/consumer.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from functools import partial
88
from time import monotonic
99
from types import ModuleType
10-
from typing import Any, Awaitable, Callable, Self
10+
from typing import Any, Awaitable, Callable, Self, Sequence
1111

1212
from starlette.datastructures import State
1313
from typing_extensions import Annotated, Doc
@@ -136,8 +136,17 @@ def type(self) -> str:
136136
"""The type of the task manager"""
137137
return snake_case(self.__class__.__name__)
138138

139-
def register_task(self, task: Annotated[Task, Doc("Task to register")]) -> None:
139+
def register_task(
140+
self,
141+
task: Annotated[Task, Doc("Task to register")],
142+
tags: Annotated[
143+
Sequence[str] | None,
144+
Doc("Extra tags to add to the task before registering it"),
145+
] = None,
146+
) -> None:
140147
"""Register a task with the task manager"""
148+
if tags:
149+
task = task._replace(tags=task.tags | frozenset(tags))
141150
self.broker.register_task(task)
142151

143152
async def execute(
@@ -308,13 +317,17 @@ def register_from_module(
308317
"- can contain any object, only instances of Task are registered"
309318
),
310319
],
320+
tags: Annotated[
321+
Sequence[str] | None,
322+
Doc("Extra tags to add to every registered task"),
323+
] = None,
311324
) -> None:
312325
"""Register tasks from a python module"""
313326
for name in dir(module):
314327
if name.startswith("_"):
315328
continue
316329
if isinstance(obj := getattr(module, name), Task):
317-
self.register_task(obj)
330+
self.register_task(obj, tags=tags)
318331

319332
def register_from_dict(
320333
self,
@@ -325,13 +338,17 @@ def register_from_dict(
325338
"- can contain any object, only instances of Task are registered"
326339
),
327340
],
341+
tags: Annotated[
342+
Sequence[str] | None,
343+
Doc("Extra tags to add to every registered task"),
344+
] = None,
328345
) -> None:
329346
"""Register tasks from a python dictionary"""
330347
for name, obj in data.items():
331348
if name.startswith("_"):
332349
continue
333350
if isinstance(obj, Task):
334-
self.register_task(obj)
351+
self.register_task(obj, tags=tags)
335352

336353
def register_async_handler(
337354
self,

fluid/utils/log.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,11 @@ def _log_config(
5959
)
6060
log_formatters.update(
6161
json={
62-
"()": "pythonjsonlogger.jsonlogger.JsonFormatter",
62+
"()": "pythonjsonlogger.json.JsonFormatter",
6363
"format": log_format,
6464
},
6565
nicejson={
66-
"()": "pythonjsonlogger.jsonlogger.JsonFormatter",
66+
"()": "pythonjsonlogger.json.JsonFormatter",
6767
"format": log_format,
6868
"json_indent": 2,
6969
},

fluid/utils/worker.py

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -477,16 +477,9 @@ class Workers(Worker):
477477
starts each child worker and monitors their health — if any child stops
478478
unexpectedly the whole group is gracefully stopped.
479479
480-
On shutdown all child workers are stopped concurrently via
481-
[_wait_for_workers][fluid.utils.worker.Workers._wait_for_workers].
480+
On shutdown all child workers are stopped concurrently.
482481
Workers that do not exit within `stopping_grace_period` seconds are
483482
force-cancelled.
484-
485-
!!! note "Shutdown ordering"
486-
By default all child workers are shut down concurrently. If a subclass
487-
needs a specific shutdown order (e.g. an event dispatcher that must
488-
outlive the workers that produce events), override
489-
[_wait_for_workers][fluid.utils.worker.Workers._wait_for_workers].
490483
"""
491484

492485
def __init__(

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ site_name: Aio Fluid
22
site_url: https://fluid.quantmind.com
33
repo_name: quantmind/aio-fluid
44
repo_url: https://github.com/quantmind/aio-fluid
5+
strict: true
56
theme:
67
name: material
78
palette:

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "aio-fluid"
3-
version = "2.4.1"
3+
version = "2.4.2"
44
description = "Tools for backend python services"
55
authors = [
66
{ name = "Luca Sbardella", email = "luca@quantmind.com" },
@@ -71,7 +71,7 @@ k8s = [
7171
"python-slugify >= 8.0.4",
7272
]
7373
log = [
74-
"python-json-logger >= 3.2.1",
74+
"python-json-logger >= 4.1.0",
7575
]
7676

7777
[dependency-groups]

tests/scheduler/test_config.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,14 @@ def test_register_from_module() -> None:
5050
assert task_manager.registry["dummy"]
5151

5252

53+
def test_register_from_module_with_tags() -> None:
54+
task_manager = TaskManager()
55+
task_manager.register_from_module(example_tasks, tags=["extra", "module"])
56+
assert task_manager.registry
57+
dummy = task_manager.registry["dummy"]
58+
assert {"extra", "module"} <= dummy.tags
59+
60+
5361
def test_cpu_bount_params() -> None:
5462
cpu_bound = example_tasks.cpu_bound
5563
assert cpu_bound.params_model is example_tasks.Sleep

tests/utils/test_log.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import json
2+
import logging
3+
import warnings
4+
5+
import pytest
6+
7+
from fluid.utils import log
8+
9+
pytestmark = pytest.mark.asyncio(loop_scope="module")
10+
11+
12+
async def test_get_level_num_from_int() -> None:
13+
assert log.get_level_num(logging.DEBUG) == logging.DEBUG
14+
15+
16+
async def test_get_level_num_from_string() -> None:
17+
assert log.get_level_num("info") == logging.INFO
18+
assert log.get_level_num("WARNING") == logging.WARNING
19+
20+
21+
async def test_config_plain() -> None:
22+
cfg = log.config(log_handler="plain", level="INFO")
23+
assert cfg["handlers"]["plain"]["formatter"] == "plain"
24+
assert cfg["loggers"]["fluid"]["level"] == logging.INFO
25+
26+
27+
async def test_config_other_level_floored_by_level() -> None:
28+
cfg = log.config(level="ERROR", other_level=logging.WARNING)
29+
# other loggers cannot be more verbose than the app level
30+
assert cfg["root"]["level"] == logging.ERROR
31+
32+
33+
async def test_config_app_names() -> None:
34+
cfg = log.config(app_names=["myapp"], level="INFO")
35+
# the configured app and the default app name are both present
36+
assert "myapp" in cfg["loggers"]
37+
assert "fluid" in cfg["loggers"]
38+
39+
40+
async def test_config_extra_formatters() -> None:
41+
cfg = log.config(formatters={"custom": {"format": "%(message)s"}})
42+
assert "custom" in cfg["formatters"]
43+
44+
45+
@pytest.mark.parametrize("handler", ["json", "nicejson"])
46+
async def test_config_json_handlers(
47+
handler: str, capsys: pytest.CaptureFixture
48+
) -> None:
49+
with warnings.catch_warnings():
50+
warnings.simplefilter("error", DeprecationWarning)
51+
log.config(log_handler=handler, level="INFO")
52+
logging.getLogger("fluid").info("hello %s", handler)
53+
captured = capsys.readouterr()
54+
record = json.loads(captured.err)
55+
assert record["message"] == f"hello {handler}"
56+
assert record["levelname"] == "INFO"
57+
58+
59+
async def test_config_json_formatter_uses_new_path() -> None:
60+
cfg = log.config(log_handler="json")
61+
assert cfg["formatters"]["json"]["()"] == "pythonjsonlogger.json.JsonFormatter"

0 commit comments

Comments
 (0)