Skip to content

Commit 1a206ba

Browse files
committed
feat(procrastinate): ProcrastinateSystemIntegration with auto-track
1 parent c0c9f1a commit 1a206ba

3 files changed

Lines changed: 171 additions & 0 deletions

File tree

taskbadger/procrastinate.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,3 +300,34 @@ def current_task():
300300
if tb_id is None:
301301
return None
302302
return _safe_get_task(tb_id)
303+
304+
305+
def _patch_app_task(app, system):
306+
"""Replace ``app.task`` with a wrapper that instruments newly-registered
307+
tasks under the supplied ``system``. Idempotent — a second call replaces
308+
the wrapper but keeps the same original task method."""
309+
original = getattr(app, "_taskbadger_original_task", None) or app.task
310+
if not getattr(app, "_taskbadger_original_task", None):
311+
app._taskbadger_original_task = original
312+
313+
@functools.wraps(original)
314+
def patched(*args, **kwargs):
315+
task = original(*args, **kwargs)
316+
# ``original`` may return the Task directly or a decorator depending on
317+
# call form. Procrastinate's ``app.task`` always returns a decorator
318+
# when called with arguments and the Task when called bare.
319+
if callable(task) and not hasattr(task, "name"):
320+
# decorator form: wrap the returned decorator
321+
inner_decorator = task
322+
323+
@functools.wraps(inner_decorator)
324+
def outer(func):
325+
t = inner_decorator(func)
326+
_instrument_task(t, system=system)
327+
return t
328+
329+
return outer
330+
_instrument_task(task, system=system)
331+
return task
332+
333+
app.task = patched

taskbadger/systems/procrastinate.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,60 @@
22

33
from __future__ import annotations
44

5+
import re
6+
7+
from taskbadger.procrastinate import _instrument_task, _patch_app_task
58
from taskbadger.systems import System
69

710

811
class ProcrastinateSystemIntegration(System):
912
identifier = "procrastinate"
13+
14+
def __init__(
15+
self,
16+
app,
17+
auto_track_tasks=True,
18+
includes=None,
19+
excludes=None,
20+
record_task_args=False,
21+
):
22+
"""
23+
Args:
24+
app: The ``procrastinate.App`` instance to instrument.
25+
auto_track_tasks: Track all tasks regardless of whether they use
26+
the ``@taskbadger.procrastinate.track`` decorator.
27+
includes: List of task names to include in auto-tracking. Each
28+
entry can be a full name or a regex (matched with
29+
``re.fullmatch``).
30+
excludes: List of task names to exclude. Same semantics as
31+
``includes``. Exclusions take precedence.
32+
record_task_args: Record the task's defer kwargs into the
33+
TaskBadger task's ``data`` under ``procrastinate_task_kwargs``.
34+
"""
35+
self.app = app
36+
self.auto_track_tasks = auto_track_tasks
37+
self.includes = includes
38+
self.excludes = excludes
39+
self.record_task_args = record_task_args
40+
41+
for task in list(app.tasks.values()):
42+
_instrument_task(task, system=self)
43+
_patch_app_task(app, system=self)
44+
45+
def track_task(self, task_name):
46+
if not self.auto_track_tasks:
47+
return False
48+
49+
if self.excludes:
50+
for exclude in self.excludes:
51+
if re.fullmatch(exclude, task_name):
52+
return False
53+
54+
if self.includes:
55+
for include in self.includes:
56+
if re.fullmatch(include, task_name):
57+
break
58+
else:
59+
return False
60+
61+
return True
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
from unittest import mock
2+
3+
import procrastinate
4+
import pytest
5+
from procrastinate import testing
6+
7+
from taskbadger.procrastinate import _INSTRUMENTED_ATTR, TB_TASK_ID_KWARG, _task_cache
8+
from taskbadger.systems.procrastinate import ProcrastinateSystemIntegration
9+
from tests.utils import task_for_test
10+
11+
12+
@pytest.fixture(autouse=True)
13+
def _clear_task_cache():
14+
_task_cache.cache.clear()
15+
yield
16+
_task_cache.cache.clear()
17+
18+
19+
@pytest.fixture
20+
def app():
21+
in_memory = testing.InMemoryConnector()
22+
app = procrastinate.App(connector=in_memory)
23+
with app.open():
24+
yield app
25+
26+
27+
@pytest.mark.parametrize(
28+
("include", "exclude", "expected"),
29+
[
30+
(None, None, True),
31+
(["myapp.tasks.export_data"], None, True),
32+
([".*export_data"], [], True),
33+
([".*export_da"], [], False),
34+
(["myapp.tasks.export_data"], ["myapp.tasks.export_data"], False),
35+
([".*"], ["myapp.tasks.export_data"], False),
36+
([".*"], [".*tasks.*"], False),
37+
],
38+
)
39+
def test_task_name_matching(app, include, exclude, expected):
40+
integration = ProcrastinateSystemIntegration(app=app, includes=include, excludes=exclude)
41+
assert integration.track_task("myapp.tasks.export_data") is expected
42+
43+
44+
def test_auto_track_off_returns_false(app):
45+
integration = ProcrastinateSystemIntegration(app=app, auto_track_tasks=False)
46+
assert integration.track_task("anything") is False
47+
48+
49+
def test_wraps_existing_tasks(app):
50+
@app.task(name="pre_existing")
51+
def pre_existing(a):
52+
return a
53+
54+
assert not getattr(pre_existing, _INSTRUMENTED_ATTR, False)
55+
ProcrastinateSystemIntegration(app=app, auto_track_tasks=True)
56+
assert getattr(pre_existing, _INSTRUMENTED_ATTR) is True
57+
58+
59+
@pytest.mark.usefixtures("_bind_settings")
60+
def test_auto_track_creates_pending(app):
61+
@app.task(name="auto_target")
62+
def auto_target(a):
63+
return a
64+
65+
ProcrastinateSystemIntegration(app=app, auto_track_tasks=True)
66+
67+
tb = task_for_test()
68+
with mock.patch("taskbadger.procrastinate.create_task_safe", return_value=tb) as create:
69+
auto_target.defer(a=1)
70+
71+
create.assert_called_once()
72+
# InMemoryConnector.jobs is a dict keyed by int; kwargs under "args"
73+
jobs = list(app.connector.jobs.values())
74+
assert jobs[0]["args"][TB_TASK_ID_KWARG] == tb.id
75+
76+
77+
@pytest.mark.usefixtures("_bind_settings")
78+
def test_auto_track_excludes_skip(app):
79+
@app.task(name="myapp.cleanup.flush")
80+
def flush():
81+
pass
82+
83+
ProcrastinateSystemIntegration(app=app, auto_track_tasks=True, excludes=[r"myapp\.cleanup\..*"])
84+
85+
with mock.patch("taskbadger.procrastinate.create_task_safe") as create:
86+
flush.defer()
87+
88+
create.assert_not_called()

0 commit comments

Comments
 (0)