Skip to content

Commit 2135f67

Browse files
authored
Merge pull request #61 from taskbadger/sk/list-tasks-wrapper
Return SDK Task objects from list_tasks
2 parents 819f736 + 075c63b commit 2135f67

8 files changed

Lines changed: 265 additions & 14 deletions

File tree

taskbadger/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from .internal.models import StatusEnum
44
from .mug import Badger, Session
55
from .safe_sdk import create_task_safe, update_task_safe
6-
from .sdk import DefaultMergeStrategy, Task, create_task, get_task, init, list_tasks, update_task
6+
from .sdk import DefaultMergeStrategy, Task, TaskList, create_task, get_task, init, list_tasks, update_task
77

88
__all__ = [
99
"track",
@@ -17,6 +17,7 @@
1717
"update_task_safe",
1818
"DefaultMergeStrategy",
1919
"Task",
20+
"TaskList",
2021
"create_task",
2122
"get_task",
2223
"init",

taskbadger/celery.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -324,18 +324,23 @@ def _maybe_create_task(signal_sender):
324324
delivery_info = getattr(signal_sender.request, "delivery_info", None) or {}
325325
queue = delivery_info.get("routing_key")
326326
external_id = signal_sender.request.id
327+
# `before_task_publish` never ran for eager tasks, so their per-call options
328+
# are still sitting in the headers rather than resolved into the message.
329+
# Canvas tasks never have any here: `task_publish_handler` strips TB headers
330+
# off `celery.*` messages before its early return, so nothing reaches the
331+
# worker.
332+
header_kwargs = headers.get(TB_KWARGS_ARG) or {}
327333
create_kwargs = {
328334
"status": StatusEnum.PENDING,
329335
"data": data,
330336
"queue": queue,
331337
"external_id": external_id,
332338
# eager and canvas tasks are created here rather than at publish time, but
333-
# still run inside whatever task invoked them
334-
"parent": parent_id(),
339+
# still run inside whatever task invoked them. For eager tasks an explicit
340+
# `taskbadger_parent` wins, as it does at publish time — including an
341+
# explicit `None`, which asks for a root task.
342+
"parent": header_kwargs["parent"] if "parent" in header_kwargs else parent_id(),
335343
}
336-
# `before_task_publish` never ran for these, so per-call options are still
337-
# sitting in the headers rather than resolved into the message.
338-
header_kwargs = headers.get(TB_KWARGS_ARG) or {}
339344
heartbeat_interval, stale_timeout = resolve_heartbeat_options(
340345
header_kwargs.get("heartbeat_interval", getattr(signal_sender, TB_HEARTBEAT_INTERVAL, None)),
341346
header_kwargs.get("stale_timeout", getattr(signal_sender, TB_STALE_TIMEOUT, None)),

taskbadger/sdk.py

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -302,7 +302,7 @@ def update_task(
302302
return Task(response.parsed)
303303

304304

305-
def list_tasks(page_size: int = None, cursor: str = None, parent: str = None):
305+
def list_tasks(page_size: int = None, cursor: str = None, parent: str = None) -> "TaskList":
306306
"""List tasks.
307307
308308
Arguments:
@@ -314,7 +314,7 @@ def list_tasks(page_size: int = None, cursor: str = None, parent: str = None):
314314
with Session() as client:
315315
response = task_list.sync_detailed(client=client, **kwargs)
316316
_check_response(response)
317-
return response.parsed
317+
return TaskList(response.parsed)
318318

319319

320320
_ACTIONS_DEPRECATED_MESSAGE = (
@@ -554,6 +554,11 @@ def tags(self):
554554
return self._task.tags.to_dict()
555555

556556
def __getattr__(self, item):
557+
if item.startswith("_"):
558+
# don't delegate private / dunder lookups: `copy` and `pickle` probe
559+
# for e.g. `__setstate__` on an instance that has no `_task` yet,
560+
# which would recurse until the stack blows up.
561+
raise AttributeError(item)
557562
return getattr(self._task, item)
558563

559564
def safe_update(self, **kwargs):
@@ -581,6 +586,39 @@ def _check_update_value_interval(self, new_value, value_step: int = None):
581586
return True
582587

583588

589+
class TaskList:
590+
"""A page of tasks as returned by [taskbadger.list_tasks][].
591+
592+
Iterating over a `TaskList` yields [taskbadger.Task][] objects:
593+
594+
for task in taskbadger.list_tasks():
595+
print(task.name)
596+
"""
597+
598+
def __init__(self, task_list):
599+
self._task_list = task_list
600+
self._results = [Task(task) for task in task_list.results]
601+
602+
@property
603+
def results(self) -> list[Task]:
604+
"""The tasks in this page."""
605+
return self._results
606+
607+
def __iter__(self):
608+
return iter(self._results)
609+
610+
def __len__(self):
611+
return len(self._results)
612+
613+
def __getattr__(self, item):
614+
if item.startswith("_"):
615+
# don't delegate private / dunder lookups: `copy` and `pickle` probe
616+
# for e.g. `__setstate__` on an instance that has no `_task_list` yet,
617+
# which would recurse until the stack blows up.
618+
raise AttributeError(item)
619+
return getattr(self._task_list, item)
620+
621+
584622
def _none_to_unset(value):
585623
return UNSET if value is None else value
586624

tasks.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ def tag_release(c: Context):
1313
if bump_key in ("1", "2", "3"):
1414
bump = {"1": "major", "2": "minor", "3": "patch"}[bump_key]
1515
version = _bump_version(bump)
16-
c.run("git add pyproject.toml")
16+
# the lockfile pins the project's own version, so it moves with pyproject
17+
c.run("uv lock")
18+
c.run("git add pyproject.toml uv.lock")
1719
c.run(f"git commit -m 'Bump version to {version}'")
1820

1921
if input(f"\nReady to release version {version}? [y/n]") == "y":

tests/test_celery_system_integration.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,14 @@
1616
from unittest import mock
1717

1818
import pytest
19-
from celery.signals import task_prerun
19+
from celery.signals import (
20+
before_task_publish,
21+
task_failure,
22+
task_postrun,
23+
task_prerun,
24+
task_retry,
25+
task_success,
26+
)
2027

2128
from taskbadger import StatusEnum
2229
from taskbadger.celery import Task
@@ -247,7 +254,22 @@ def _assert_signals(check_is_connected=True):
247254

248255

249256
def _disconnect_signals():
250-
from taskbadger.celery import task_prerun_handler
257+
"""Disconnect every handler the module connected on import.
251258
252-
task_prerun.disconnect(task_prerun_handler)
259+
All of them, not just the one asserted on: re-importing the module connects
260+
a second copy of each handler, and anything left behind keeps firing for the
261+
rest of the session — from a stale module object that `mock.patch` no longer
262+
reaches.
263+
"""
264+
import taskbadger.celery as tb_celery
265+
266+
for signal, handler in (
267+
(before_task_publish, tb_celery.task_publish_handler),
268+
(task_prerun, tb_celery.task_prerun_handler),
269+
(task_postrun, tb_celery.task_postrun_handler),
270+
(task_success, tb_celery.task_success_handler),
271+
(task_failure, tb_celery.task_failure_handler),
272+
(task_retry, tb_celery.task_retry_handler),
273+
):
274+
signal.disconnect(handler)
253275
_assert_signals(check_is_connected=False)

tests/test_cli_list.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import json
2+
import os
3+
from http import HTTPStatus
4+
from unittest import mock
5+
6+
import pytest
7+
from typer.testing import CliRunner
8+
9+
from taskbadger.cli_main import app
10+
from taskbadger.internal.models import PaginatedTaskList
11+
from taskbadger.internal.types import Response
12+
from tests.utils import task_for_test
13+
14+
runner = CliRunner()
15+
16+
NEXT_URL = "https://taskbadger.net/api/org/project/tasks/?cursor=next-token"
17+
18+
19+
@pytest.fixture(autouse=True)
20+
def _mock_env():
21+
with mock.patch.dict(
22+
os.environ,
23+
{
24+
"TASKBADGER_ORG": "org",
25+
"TASKBADGER_PROJECT": "project",
26+
"TASKBADGER_API_KEY": "token",
27+
},
28+
clear=True,
29+
):
30+
yield
31+
32+
33+
def _mock_list(*tasks, next_=None):
34+
page = PaginatedTaskList(results=list(tasks), next_=next_, previous=None)
35+
return Response(HTTPStatus.OK, b"", {}, page)
36+
37+
38+
def test_cli_list_pretty():
39+
with mock.patch("taskbadger.sdk.task_list.sync_detailed") as list_:
40+
# short id so the rich table doesn't truncate it at the default width
41+
task = task_for_test(id="t1")
42+
list_.return_value = _mock_list(task, next_=NEXT_URL)
43+
44+
result = runner.invoke(app, ["list"])
45+
46+
assert result.exit_code == 0, result.output
47+
assert "t1" in result.output
48+
assert task.name in result.output
49+
assert "next-token" in result.output
50+
51+
52+
def test_cli_list_json():
53+
with mock.patch("taskbadger.sdk.task_list.sync_detailed") as list_:
54+
task = task_for_test()
55+
list_.return_value = _mock_list(task, next_=NEXT_URL)
56+
57+
result = runner.invoke(app, ["list", "--format", "json"])
58+
59+
assert result.exit_code == 0, result.output
60+
payload = json.loads(result.output)
61+
assert payload["next_token"] == "next-token"
62+
assert [t["id"] for t in payload["results"]] == [task.id]
63+
64+
65+
def test_cli_list_csv():
66+
with mock.patch("taskbadger.sdk.task_list.sync_detailed") as list_:
67+
task = task_for_test()
68+
list_.return_value = _mock_list(task)
69+
70+
result = runner.invoke(app, ["list", "--format", "csv"])
71+
72+
assert result.exit_code == 0, result.output
73+
assert task.id in result.output
74+
assert "next_token" not in result.output

tests/test_parents.py

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@
55
is attached to the same root rather than to the child.
66
"""
77

8+
import copy
89
import logging
910
from unittest import mock
1011

12+
import celery
1113
import procrastinate
1214
import pytest
1315
from procrastinate import testing
@@ -141,8 +143,15 @@ def test_list_tasks_filters_by_parent(httpx_mock):
141143
json={"next": None, "previous": None, "results": [_json_task_response(parent="parent_id")]},
142144
status_code=200,
143145
)
144-
(child,) = list_tasks(parent="parent_id").results
146+
tasks = list_tasks(parent="parent_id")
147+
(child,) = tasks.results
148+
assert isinstance(child, Task)
145149
assert child.parent == "parent_id"
150+
assert list(tasks) == tasks.results
151+
assert len(tasks) == 1
152+
# a TaskList must survive copy / pickle: both probe for dunders that
153+
# `__getattr__` must not try to delegate
154+
assert len(copy.deepcopy(tasks)) == 1
146155

147156

148157
@pytest.mark.usefixtures("_bind_settings")
@@ -321,6 +330,106 @@ def test_celery_publish_explicit_parent_wins():
321330
assert create.call_args.kwargs["parent"] == "chosen"
322331

323332

333+
def _celery_app(**conf):
334+
"""A standalone app backed by in-memory transports, so no broker is needed."""
335+
app = celery.Celery("test_parents", broker="memory://", backend="cache+memory://", **conf)
336+
337+
@app.task(bind=True, base=taskbadger.celery.Task, name="test_parents.add")
338+
def add(self, a, b):
339+
return a + b
340+
341+
return add
342+
343+
344+
@pytest.mark.usefixtures("_bind_settings")
345+
def test_celery_apply_async_parent():
346+
"""`taskbadger_parent` on `apply_async` reaches the task created at publish time."""
347+
add = _celery_app()
348+
349+
with (
350+
mock.patch("taskbadger.celery.create_task_safe") as create,
351+
mock.patch("taskbadger.sdk.get_task"),
352+
):
353+
create.return_value = task_for_test()
354+
add.apply_async((2, 2), taskbadger_parent="chosen")
355+
356+
assert create.call_args.kwargs["parent"] == "chosen"
357+
358+
359+
@pytest.mark.usefixtures("_bind_settings")
360+
def test_celery_apply_async_parent_beats_the_running_task():
361+
add = _celery_app()
362+
363+
with (
364+
mock.patch("taskbadger.celery.create_task_safe") as create,
365+
mock.patch("taskbadger.sdk.get_task"),
366+
):
367+
create.return_value = task_for_test()
368+
token = enter_task("root_id")
369+
try:
370+
add.apply_async((2, 2), taskbadger_parent="chosen")
371+
finally:
372+
exit_task(token)
373+
374+
assert create.call_args.kwargs["parent"] == "chosen"
375+
376+
377+
@pytest.mark.usefixtures("_bind_settings")
378+
def test_celery_eager_apply_async_parent():
379+
"""Eager tasks are created in `task_prerun` rather than at publish time, but
380+
the explicit parent still has to make it through."""
381+
add = _celery_app(task_always_eager=True, task_eager_propagates=True)
382+
383+
with (
384+
mock.patch("taskbadger.celery.create_task_safe") as create,
385+
mock.patch("taskbadger.celery.update_task_safe"),
386+
mock.patch("taskbadger.sdk.get_task"),
387+
):
388+
create.return_value = task_for_test()
389+
assert add.apply_async((2, 2), taskbadger_parent="chosen").get() == 4
390+
391+
assert create.call_args.kwargs["parent"] == "chosen"
392+
393+
394+
@pytest.mark.usefixtures("_bind_settings")
395+
def test_celery_eager_nests_under_the_running_task():
396+
add = _celery_app(task_always_eager=True, task_eager_propagates=True)
397+
398+
with (
399+
mock.patch("taskbadger.celery.create_task_safe") as create,
400+
mock.patch("taskbadger.celery.update_task_safe"),
401+
mock.patch("taskbadger.sdk.get_task"),
402+
):
403+
create.return_value = task_for_test()
404+
token = enter_task("root_id")
405+
try:
406+
add.apply_async((2, 2))
407+
finally:
408+
exit_task(token)
409+
410+
assert create.call_args.kwargs["parent"] == "root_id"
411+
412+
413+
@pytest.mark.usefixtures("_bind_settings")
414+
def test_celery_eager_explicit_none_parent_makes_a_root_task():
415+
"""`taskbadger_parent=None` asks for a root task, as it does at publish time."""
416+
add = _celery_app(task_always_eager=True, task_eager_propagates=True)
417+
418+
with (
419+
mock.patch("taskbadger.celery.create_task_safe") as create,
420+
mock.patch("taskbadger.celery.update_task_safe"),
421+
mock.patch("taskbadger.sdk.get_task"),
422+
):
423+
create.return_value = task_for_test()
424+
token = enter_task("root_id")
425+
try:
426+
add.apply_async((2, 2), taskbadger_parent=None)
427+
finally:
428+
exit_task(token)
429+
430+
assert create.call_args.kwargs["parent"] is None
431+
432+
324433
# --- Procrastinate ------------------------------------------------------------
325434

326435

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)