Skip to content

Commit 6799d9a

Browse files
snopokeclaude
andcommitted
Fix explicit taskbadger_parent=None and __getattr__ recursion
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 56cab50 commit 6799d9a

3 files changed

Lines changed: 37 additions & 2 deletions

File tree

taskbadger/celery.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -334,8 +334,9 @@ def _maybe_create_task(signal_sender):
334334
"external_id": external_id,
335335
# eager and canvas tasks are created here rather than at publish time, but
336336
# still run inside whatever task invoked them. An explicit
337-
# `taskbadger_parent` wins, as it does at publish time.
338-
"parent": header_kwargs.get("parent") or parent_id(),
337+
# `taskbadger_parent` wins, as it does at publish time — including an
338+
# explicit `None`, which asks for a root task.
339+
"parent": header_kwargs["parent"] if "parent" in header_kwargs else parent_id(),
339340
}
340341
heartbeat_interval, stale_timeout = resolve_heartbeat_options(
341342
header_kwargs.get("heartbeat_interval", getattr(signal_sender, TB_HEARTBEAT_INTERVAL, None)),

taskbadger/sdk.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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):
@@ -606,6 +611,11 @@ def __len__(self):
606611
return len(self._results)
607612

608613
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)
609619
return getattr(self._task_list, item)
610620

611621

tests/test_parents.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
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

@@ -148,6 +149,9 @@ def test_list_tasks_filters_by_parent(httpx_mock):
148149
assert child.parent == "parent_id"
149150
assert list(tasks) == tasks.results
150151
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
151155

152156

153157
@pytest.mark.usefixtures("_bind_settings")
@@ -406,6 +410,26 @@ def test_celery_eager_nests_under_the_running_task():
406410
assert create.call_args.kwargs["parent"] == "root_id"
407411

408412

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+
409433
# --- Procrastinate ------------------------------------------------------------
410434

411435

0 commit comments

Comments
 (0)