Skip to content

Commit c98d1f7

Browse files
authored
Merge branch 'master' into chore-replace-pre-commit-with-prek
2 parents 2d25caa + 953d20b commit c98d1f7

8 files changed

Lines changed: 98 additions & 14 deletions

File tree

‎docs/.vuepress/config.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export default defineUserConfig({
3333
sidebar: "structure",
3434

3535
pure: true,
36+
focus: false,
3637

3738
markdown: {
3839
tabs: true,
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import asyncio
2+
3+
from taskiq_dependencies import Depends
4+
5+
from taskiq.depends.progress_tracker import ProgressTracker, TaskState
6+
7+
8+
async def my_task(progress: ProgressTracker[str] = Depends()) -> None:
9+
for i in range(10):
10+
await asyncio.sleep(1)
11+
await progress.set_progress(TaskState.STARTED, meta=f"{(i + 1) * 10}%")
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import asyncio
2+
from typing import Annotated
3+
4+
from taskiq_dependencies import Depends
5+
6+
from taskiq.depends.progress_tracker import ProgressTracker, TaskState
7+
8+
9+
async def my_task(progress: Annotated[ProgressTracker[str], Depends()]) -> None:
10+
for i in range(10):
11+
await asyncio.sleep(1)
12+
await progress.set_progress(TaskState.STARTED, meta=f"{(i + 1) * 10}%")

‎docs/guide/state-and-deps.md‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,48 @@ taskiq worker my_file:broker --no-propagate-errors
319319

320320
In this case, no exception will ever going to be propagated to any dependency.
321321

322+
## Progress tracking
323+
324+
Sometimes a task runs for a long time and you want to report how far it has progressed, so other parts of your system
325+
(e.g. a web handler polling for status) can display it.
326+
327+
Taskiq provides a `ProgressTracker` dependency for this. It's not a method on `Context`, it's a dependency, just like
328+
anything else built with `taskiq-dependencies`. It grabs the current `task_id` from the context for you and stores
329+
progress using your broker's result backend.
330+
331+
::: tabs
332+
333+
@tab Annotated 3.10+
334+
335+
@[code python](../examples/state/progress_tracker_annot.py)
336+
337+
@tab default values
338+
339+
@[code python](../examples/state/progress_tracker.py)
340+
341+
:::
342+
343+
You can read the progress back from anywhere that has access to the `AsyncTaskiqTask` returned by `kiq`:
344+
345+
```python
346+
task = await my_task.kiq()
347+
348+
progress = await task.get_progress()
349+
if progress is not None:
350+
print(progress.state, progress.meta)
351+
```
352+
353+
`state` can be one of the `TaskState` enum values (`STARTED`, `SUCCESS`, `FAILURE`, `RETRY`) or any custom string.
354+
`meta` is generic (`ProgressTracker[MetaType]`) and can be any value your result backend can serialize, such as a plain
355+
string, a `dict`, or a pydantic model. If you call `set_progress` without `meta`, the previously stored `meta` value is
356+
preserved, which is handy when you only want to update `state`.
357+
358+
::: warning important note
359+
360+
`set_progress`/`get_progress` are no-ops by default on `AsyncResultBackend`. Make sure the result backend you use
361+
actually implements progress storage (`InMemoryBroker`'s built-in backend does), before relying on this in production.
362+
363+
:::
322364

323365
## Generics
324366

‎taskiq/cli/scheduler/run.py‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from datetime import datetime, timedelta, timezone
55
from logging import basicConfig, getLogger
66
from typing import Any, TypeAlias
7-
from zoneinfo import ZoneInfo
7+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
88

99
import pycron
1010

@@ -99,7 +99,13 @@ def is_cron_task_now(
9999
# If timezone was specified as string we convert it timezone
100100
# offset and then apply.
101101
elif offset and isinstance(offset, str):
102-
now = now.astimezone(ZoneInfo(offset))
102+
try:
103+
now = now.astimezone(ZoneInfo(offset))
104+
# ZoneInfoNotFoundError for unknown keys, ModuleNotFoundError
105+
# for systems without timezone data available (e.g. missing
106+
# tzdata on Windows).
107+
except (ZoneInfoNotFoundError, ModuleNotFoundError) as e:
108+
raise CronValueError(e) from e
103109

104110
try:
105111
return pycron.is_now(cron_value, now)

‎tests/abc/test_broker.py‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,6 @@ async def test_task() -> None: ...
8282
assert test_task.labels == old_labels
8383

8484

85-
@pytest.mark.anyio
8685
@pytest.mark.parametrize(
8786
("is_worker_process", "startup", "shutdown"),
8887
[
@@ -120,7 +119,6 @@ async def track_shutdown(state: TaskiqState) -> None:
120119
assert shutdown_called is True
121120

122121

123-
@pytest.mark.anyio
124122
@pytest.mark.parametrize(
125123
("is_worker_process", "startup", "shutdown"),
126124
[

‎tests/cli/scheduler/test_is_cron_task_now.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,3 +75,16 @@ def test_is_cron_task_now(
7575
def test_is_cron_task_now_invalid_cron() -> None:
7676
with pytest.raises(CronValueError):
7777
is_cron_task_now("invalid cron", datetime.now())
78+
79+
80+
@pytest.mark.parametrize(
81+
"offset",
82+
["UTC+3", "Europa/Madrid"],
83+
)
84+
def test_is_cron_task_now_invalid_offset_string(offset: str) -> None:
85+
with pytest.raises(CronValueError):
86+
is_cron_task_now(
87+
cron_value="* * * * *",
88+
now=datetime.now(timezone.utc),
89+
offset=offset,
90+
)

‎tests/cli/scheduler/test_send_with_timeout.py‎

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from typing import Any
44

55
import pytest
6+
from typing_extensions import override
67

78
from taskiq.abc.schedule_source import ScheduleSource
89
from taskiq.brokers.inmemory_broker import InMemoryBroker
@@ -30,7 +31,8 @@ def __init__(self, hang_seconds: float = 60.0) -> None:
3031
self.hang_seconds = hang_seconds
3132
self.on_ready_calls = 0
3233

33-
async def on_ready( # type: ignore[override]
34+
@override
35+
async def on_ready(
3436
self,
3537
source: ScheduleSource,
3638
task: ScheduledTask,
@@ -50,7 +52,6 @@ def _task() -> ScheduledTask:
5052
)
5153

5254

53-
@pytest.mark.anyio
5455
async def test_send_with_timeout_returns_on_timeout_without_raising(
5556
caplog: pytest.LogCaptureFixture,
5657
) -> None:
@@ -87,7 +88,6 @@ async def test_send_with_timeout_returns_on_timeout_without_raising(
8788
assert "dummy" in msg
8889

8990

90-
@pytest.mark.anyio
9191
async def test_send_with_timeout_does_not_log_on_success(
9292
caplog: pytest.LogCaptureFixture,
9393
) -> None:
@@ -99,7 +99,8 @@ def __init__(self) -> None:
9999
super().__init__(broker=InMemoryBroker(), sources=[_StubSource()])
100100
self.calls = 0
101101

102-
async def on_ready( # type: ignore[override]
102+
@override
103+
async def on_ready(
103104
self,
104105
source: ScheduleSource,
105106
task: ScheduledTask,
@@ -121,7 +122,6 @@ async def on_ready( # type: ignore[override]
121122
assert warnings == []
122123

123124

124-
@pytest.mark.anyio
125125
async def test_send_with_timeout_propagates_non_timeout_exceptions() -> None:
126126
"""Errors inside on_ready that AREN'T a timeout must still propagate.
127127
@@ -134,7 +134,8 @@ class _BoomScheduler(TaskiqScheduler):
134134
def __init__(self) -> None:
135135
super().__init__(broker=InMemoryBroker(), sources=[_StubSource()])
136136

137-
async def on_ready( # type: ignore[override]
137+
@override
138+
async def on_ready(
138139
self,
139140
source: ScheduleSource,
140141
task: ScheduledTask,
@@ -145,7 +146,6 @@ async def on_ready( # type: ignore[override]
145146
await send_with_timeout(_BoomScheduler(), _StubSource(), _task(), timeout=5.0)
146147

147148

148-
@pytest.mark.anyio
149149
async def test_send_with_timeout_cancels_inner_send() -> None:
150150
"""The inner ``send`` coroutine must actually be cancelled on timeout.
151151
@@ -158,7 +158,8 @@ class _CancelObservingScheduler(TaskiqScheduler):
158158
def __init__(self) -> None:
159159
super().__init__(broker=InMemoryBroker(), sources=[_StubSource()])
160160

161-
async def on_ready( # type: ignore[override]
161+
@override
162+
async def on_ready(
162163
self,
163164
source: ScheduleSource,
164165
task: ScheduledTask,
@@ -179,7 +180,6 @@ async def on_ready( # type: ignore[override]
179180
await asyncio.wait_for(cancelled.wait(), timeout=2.0)
180181

181182

182-
@pytest.mark.anyio
183183
async def test_plain_send_still_works_unchanged() -> None:
184184
"""The original ``send`` function must remain unchanged in behavior.
185185
@@ -191,7 +191,8 @@ def __init__(self) -> None:
191191
super().__init__(broker=InMemoryBroker(), sources=[_StubSource()])
192192
self.calls: list[Any] = []
193193

194-
async def on_ready( # type: ignore[override]
194+
@override
195+
async def on_ready(
195196
self,
196197
source: ScheduleSource,
197198
task: ScheduledTask,

0 commit comments

Comments
 (0)