taskbadger 2.0.0__tar.gz → 2.1.0a2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. {taskbadger-2.0.0 → taskbadger-2.1.0a2}/PKG-INFO +60 -1
  2. taskbadger-2.1.0a2/README.md +100 -0
  3. {taskbadger-2.0.0 → taskbadger-2.1.0a2}/pyproject.toml +5 -1
  4. taskbadger-2.1.0a2/taskbadger/_integrations.py +110 -0
  5. {taskbadger-2.0.0 → taskbadger-2.1.0a2}/taskbadger/celery.py +7 -62
  6. taskbadger-2.1.0a2/taskbadger/procrastinate.py +335 -0
  7. {taskbadger-2.0.0 → taskbadger-2.1.0a2}/taskbadger/systems/celery.py +9 -27
  8. taskbadger-2.1.0a2/taskbadger/systems/procrastinate.py +51 -0
  9. taskbadger-2.0.0/README.md +0 -43
  10. {taskbadger-2.0.0 → taskbadger-2.1.0a2}/.gitignore +0 -0
  11. {taskbadger-2.0.0 → taskbadger-2.1.0a2}/LICENSE +0 -0
  12. {taskbadger-2.0.0 → taskbadger-2.1.0a2}/taskbadger/__init__.py +0 -0
  13. {taskbadger-2.0.0 → taskbadger-2.1.0a2}/taskbadger/cli/__init__.py +0 -0
  14. {taskbadger-2.0.0 → taskbadger-2.1.0a2}/taskbadger/cli/basics.py +0 -0
  15. {taskbadger-2.0.0 → taskbadger-2.1.0a2}/taskbadger/cli/list_tasks.py +0 -0
  16. {taskbadger-2.0.0 → taskbadger-2.1.0a2}/taskbadger/cli/utils.py +0 -0
  17. {taskbadger-2.0.0 → taskbadger-2.1.0a2}/taskbadger/cli/wrapper.py +0 -0
  18. {taskbadger-2.0.0 → taskbadger-2.1.0a2}/taskbadger/cli_main.py +0 -0
  19. {taskbadger-2.0.0 → taskbadger-2.1.0a2}/taskbadger/config.py +0 -0
  20. {taskbadger-2.0.0 → taskbadger-2.1.0a2}/taskbadger/decorators.py +0 -0
  21. {taskbadger-2.0.0 → taskbadger-2.1.0a2}/taskbadger/exceptions.py +0 -0
  22. {taskbadger-2.0.0 → taskbadger-2.1.0a2}/taskbadger/integrations.py +0 -0
  23. {taskbadger-2.0.0 → taskbadger-2.1.0a2}/taskbadger/internal/__init__.py +0 -0
  24. {taskbadger-2.0.0 → taskbadger-2.1.0a2}/taskbadger/internal/api/__init__.py +0 -0
  25. {taskbadger-2.0.0 → taskbadger-2.1.0a2}/taskbadger/internal/api/action_endpoints/__init__.py +0 -0
  26. {taskbadger-2.0.0 → taskbadger-2.1.0a2}/taskbadger/internal/api/action_endpoints/action_cancel.py +0 -0
  27. {taskbadger-2.0.0 → taskbadger-2.1.0a2}/taskbadger/internal/api/action_endpoints/action_create.py +0 -0
  28. {taskbadger-2.0.0 → taskbadger-2.1.0a2}/taskbadger/internal/api/action_endpoints/action_get.py +0 -0
  29. {taskbadger-2.0.0 → taskbadger-2.1.0a2}/taskbadger/internal/api/action_endpoints/action_list.py +0 -0
  30. {taskbadger-2.0.0 → taskbadger-2.1.0a2}/taskbadger/internal/api/action_endpoints/action_partial_update.py +0 -0
  31. {taskbadger-2.0.0 → taskbadger-2.1.0a2}/taskbadger/internal/api/action_endpoints/action_update.py +0 -0
  32. {taskbadger-2.0.0 → taskbadger-2.1.0a2}/taskbadger/internal/api/task_endpoints/__init__.py +0 -0
  33. {taskbadger-2.0.0 → taskbadger-2.1.0a2}/taskbadger/internal/api/task_endpoints/task_cancel.py +0 -0
  34. {taskbadger-2.0.0 → taskbadger-2.1.0a2}/taskbadger/internal/api/task_endpoints/task_create.py +0 -0
  35. {taskbadger-2.0.0 → taskbadger-2.1.0a2}/taskbadger/internal/api/task_endpoints/task_get.py +0 -0
  36. {taskbadger-2.0.0 → taskbadger-2.1.0a2}/taskbadger/internal/api/task_endpoints/task_list.py +0 -0
  37. {taskbadger-2.0.0 → taskbadger-2.1.0a2}/taskbadger/internal/api/task_endpoints/task_partial_update.py +0 -0
  38. {taskbadger-2.0.0 → taskbadger-2.1.0a2}/taskbadger/internal/api/task_endpoints/task_update.py +0 -0
  39. {taskbadger-2.0.0 → taskbadger-2.1.0a2}/taskbadger/internal/client.py +0 -0
  40. {taskbadger-2.0.0 → taskbadger-2.1.0a2}/taskbadger/internal/errors.py +0 -0
  41. {taskbadger-2.0.0 → taskbadger-2.1.0a2}/taskbadger/internal/models/__init__.py +0 -0
  42. {taskbadger-2.0.0 → taskbadger-2.1.0a2}/taskbadger/internal/models/action.py +0 -0
  43. {taskbadger-2.0.0 → taskbadger-2.1.0a2}/taskbadger/internal/models/action_request.py +0 -0
  44. {taskbadger-2.0.0 → taskbadger-2.1.0a2}/taskbadger/internal/models/paginated_task_list.py +0 -0
  45. {taskbadger-2.0.0 → taskbadger-2.1.0a2}/taskbadger/internal/models/patched_action_request.py +0 -0
  46. {taskbadger-2.0.0 → taskbadger-2.1.0a2}/taskbadger/internal/models/patched_task_request.py +0 -0
  47. {taskbadger-2.0.0 → taskbadger-2.1.0a2}/taskbadger/internal/models/patched_task_request_tags.py +0 -0
  48. {taskbadger-2.0.0 → taskbadger-2.1.0a2}/taskbadger/internal/models/status_enum.py +0 -0
  49. {taskbadger-2.0.0 → taskbadger-2.1.0a2}/taskbadger/internal/models/task.py +0 -0
  50. {taskbadger-2.0.0 → taskbadger-2.1.0a2}/taskbadger/internal/models/task_request.py +0 -0
  51. {taskbadger-2.0.0 → taskbadger-2.1.0a2}/taskbadger/internal/models/task_request_tags.py +0 -0
  52. {taskbadger-2.0.0 → taskbadger-2.1.0a2}/taskbadger/internal/models/task_tags.py +0 -0
  53. {taskbadger-2.0.0 → taskbadger-2.1.0a2}/taskbadger/internal/py.typed +0 -0
  54. {taskbadger-2.0.0 → taskbadger-2.1.0a2}/taskbadger/internal/types.py +0 -0
  55. {taskbadger-2.0.0 → taskbadger-2.1.0a2}/taskbadger/mug.py +0 -0
  56. {taskbadger-2.0.0 → taskbadger-2.1.0a2}/taskbadger/process.py +0 -0
  57. {taskbadger-2.0.0 → taskbadger-2.1.0a2}/taskbadger/safe_sdk.py +0 -0
  58. {taskbadger-2.0.0 → taskbadger-2.1.0a2}/taskbadger/sdk.py +0 -0
  59. {taskbadger-2.0.0 → taskbadger-2.1.0a2}/taskbadger/systems/__init__.py +0 -0
  60. {taskbadger-2.0.0 → taskbadger-2.1.0a2}/taskbadger/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: taskbadger
3
- Version: 2.0.0
3
+ Version: 2.1.0a2
4
4
  Summary: The official Python SDK for Task Badger
5
5
  Project-URL: Changelog, https://github.com/taskbadger/taskbadger-python/releases
6
6
  Project-URL: homepage, https://taskbadger.net/
@@ -28,6 +28,8 @@ Requires-Dist: celery<6.0.0,>=4.0.0; extra == 'celery'
28
28
  Provides-Extra: cli
29
29
  Requires-Dist: rich>=13.0; extra == 'cli'
30
30
  Requires-Dist: typer>=0.12; extra == 'cli'
31
+ Provides-Extra: procrastinate
32
+ Requires-Dist: procrastinate>=3.0; extra == 'procrastinate'
31
33
  Description-Content-Type: text/markdown
32
34
 
33
35
  # Task Badger Python Client
@@ -73,3 +75,60 @@ taskbadger.init(
73
75
  $ export TASKBADGER_API_KEY=***
74
76
  $ taskbadger run "nightly-backup" -- ./backup.sh
75
77
  ```
78
+
79
+ ### Procrastinate Integration
80
+
81
+ The SDK includes optional support for the [Procrastinate](https://procrastinate.readthedocs.io/) task queue.
82
+
83
+ Install with the extra:
84
+
85
+ ```bash
86
+ pip install 'taskbadger[procrastinate]'
87
+ ```
88
+
89
+ Opt a single task into tracking with the `track` decorator:
90
+
91
+ ```python
92
+ import procrastinate
93
+ from taskbadger.procrastinate import track, current_task
94
+
95
+ app = procrastinate.App(connector=...)
96
+
97
+ @track
98
+ @app.task(queue="default")
99
+ async def add(a, b):
100
+ return a + b
101
+
102
+ @track(name="report", value_max=100, tags={"env": "prod"})
103
+ @app.task
104
+ async def report(rows):
105
+ tb = current_task()
106
+ for i, row in enumerate(rows):
107
+ await process(row)
108
+ if i % 10 == 0:
109
+ tb.update(value=i)
110
+ ```
111
+
112
+ To auto-track every task on an App, register the system integration:
113
+
114
+ ```python
115
+ import taskbadger
116
+ from taskbadger.systems.procrastinate import ProcrastinateSystemIntegration
117
+
118
+ taskbadger.init(
119
+ token="***",
120
+ systems=[ProcrastinateSystemIntegration(
121
+ app=app,
122
+ auto_track_tasks=True,
123
+ includes=[r"myapp\..*"],
124
+ excludes=[r"myapp\.cleanup\..*"],
125
+ record_task_args=True,
126
+ )],
127
+ )
128
+ ```
129
+
130
+ #### Known limitations
131
+
132
+ - **`task.configure(...).defer(...)` is not tracked.** Procrastinate's `configure()` returns a separate `JobDeferrer` whose methods bypass our wrapper. Use `task.defer(...)` directly for tracked deferrals. Tasks deferred via `configure().defer()` will run normally but will not appear in TaskBadger.
133
+ - **`task.batch_defer*` is not tracked.** Same reason as `configure().defer()`.
134
+ - **Tasks added via `app.add_tasks_from(blueprint)` after `ProcrastinateSystemIntegration` is constructed are not auto-instrumented.** Construct the integration after all blueprints are registered, or apply `@track` to those tasks explicitly.
@@ -0,0 +1,100 @@
1
+ # Task Badger Python Client
2
+
3
+ This is the official Python SDK for [Task Badger](https://taskbadger.net/).
4
+
5
+ For full documentation go to https://docs.taskbadger.net/python/.
6
+
7
+ ![Integration Tests](https://github.com/taskbadger/taskbadger-python/actions/workflows/integration_tests.yml/badge.svg)
8
+
9
+ ---
10
+
11
+ ## Getting Started
12
+
13
+ ### Install
14
+
15
+ ```bash
16
+ pip install taskbadger
17
+ ```
18
+
19
+ To use the `taskbadger` command-line tool, install the `cli` extra:
20
+
21
+ ```bash
22
+ pip install 'taskbadger[cli]'
23
+ ```
24
+
25
+ ### Client Usage
26
+
27
+ ```python
28
+ import taskbadger
29
+ from taskbadger.systems import CelerySystemIntegration
30
+
31
+ taskbadger.init(
32
+ token="***",
33
+ systems=[CelerySystemIntegration()],
34
+ tags={"environment": "production"}
35
+ )
36
+ ```
37
+
38
+ ### CLI Usage
39
+
40
+ ```shell
41
+ $ export TASKBADGER_API_KEY=***
42
+ $ taskbadger run "nightly-backup" -- ./backup.sh
43
+ ```
44
+
45
+ ### Procrastinate Integration
46
+
47
+ The SDK includes optional support for the [Procrastinate](https://procrastinate.readthedocs.io/) task queue.
48
+
49
+ Install with the extra:
50
+
51
+ ```bash
52
+ pip install 'taskbadger[procrastinate]'
53
+ ```
54
+
55
+ Opt a single task into tracking with the `track` decorator:
56
+
57
+ ```python
58
+ import procrastinate
59
+ from taskbadger.procrastinate import track, current_task
60
+
61
+ app = procrastinate.App(connector=...)
62
+
63
+ @track
64
+ @app.task(queue="default")
65
+ async def add(a, b):
66
+ return a + b
67
+
68
+ @track(name="report", value_max=100, tags={"env": "prod"})
69
+ @app.task
70
+ async def report(rows):
71
+ tb = current_task()
72
+ for i, row in enumerate(rows):
73
+ await process(row)
74
+ if i % 10 == 0:
75
+ tb.update(value=i)
76
+ ```
77
+
78
+ To auto-track every task on an App, register the system integration:
79
+
80
+ ```python
81
+ import taskbadger
82
+ from taskbadger.systems.procrastinate import ProcrastinateSystemIntegration
83
+
84
+ taskbadger.init(
85
+ token="***",
86
+ systems=[ProcrastinateSystemIntegration(
87
+ app=app,
88
+ auto_track_tasks=True,
89
+ includes=[r"myapp\..*"],
90
+ excludes=[r"myapp\.cleanup\..*"],
91
+ record_task_args=True,
92
+ )],
93
+ )
94
+ ```
95
+
96
+ #### Known limitations
97
+
98
+ - **`task.configure(...).defer(...)` is not tracked.** Procrastinate's `configure()` returns a separate `JobDeferrer` whose methods bypass our wrapper. Use `task.defer(...)` directly for tracked deferrals. Tasks deferred via `configure().defer()` will run normally but will not appear in TaskBadger.
99
+ - **`task.batch_defer*` is not tracked.** Same reason as `configure().defer()`.
100
+ - **Tasks added via `app.add_tasks_from(blueprint)` after `ProcrastinateSystemIntegration` is constructed are not auto-instrumented.** Construct the integration after all blueprints are registered, or apply `@track` to those tasks explicitly.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "taskbadger"
3
- version = "2.0.0"
3
+ version = "2.1.0a2"
4
4
  description = "The official Python SDK for Task Badger"
5
5
  requires-python = ">=3.10"
6
6
  authors = []
@@ -44,6 +44,9 @@ cli = [
44
44
  "typer >=0.12",
45
45
  "rich >=13.0",
46
46
  ]
47
+ procrastinate = [
48
+ "procrastinate>=3.0",
49
+ ]
47
50
 
48
51
  [tool.uv]
49
52
  package = true
@@ -58,6 +61,7 @@ documentation = "https://docs.taskbadger.net/"
58
61
  [dependency-groups]
59
62
  dev = [
60
63
  "pre-commit",
64
+ "procrastinate",
61
65
  "pytest",
62
66
  "pytest-httpx",
63
67
  "invoke",
@@ -0,0 +1,110 @@
1
+ """Shared internals for taskbadger's optional system integrations
2
+ (Celery, Procrastinate). Not part of the public API.
3
+
4
+ A single module-level ``TaskCache`` (``task_cache``) is shared across all
5
+ integrations; task ids are UUIDs so cross-integration key collisions are not
6
+ a concern. ``BaseSystemIntegration`` provides the common ctor/include-exclude
7
+ shape; subclasses override ``track_task`` if they need to filter additional
8
+ task names (e.g. Procrastinate built-ins).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import collections
14
+ import logging
15
+ import re
16
+
17
+ from . import sdk
18
+ from .internal.models import StatusEnum
19
+ from .systems import System
20
+
21
+ log = logging.getLogger("taskbadger")
22
+
23
+ TERMINAL_STATES = {
24
+ StatusEnum.SUCCESS,
25
+ StatusEnum.ERROR,
26
+ StatusEnum.CANCELLED,
27
+ StatusEnum.STALE,
28
+ }
29
+
30
+
31
+ class TaskCache:
32
+ """Bounded LRU-ish cache for TaskBadger Task objects.
33
+
34
+ Keys are arbitrary hashable values chosen by the caller (typically the
35
+ task id). Auto-prunes on ``set`` when ``maxsize`` is exceeded.
36
+ """
37
+
38
+ def __init__(self, maxsize: int = 128):
39
+ self.cache: collections.OrderedDict = collections.OrderedDict()
40
+ self.maxsize = maxsize
41
+
42
+ def set(self, key, value) -> None:
43
+ self.cache[key] = value
44
+ if len(self.cache) > self.maxsize:
45
+ self.cache.popitem(last=False)
46
+
47
+ def get(self, key):
48
+ return self.cache.get(key)
49
+
50
+ def unset(self, key) -> None:
51
+ self.cache.pop(key, None)
52
+
53
+
54
+ task_cache = TaskCache()
55
+
56
+
57
+ def safe_get_task(task_id: str):
58
+ """Cache-aware ``get_task``: returns the cached entry if present, otherwise
59
+ fetches from the API and caches the result. Errors are logged and swallowed
60
+ (returns ``None``). ``None`` results are not cached.
61
+ """
62
+ cached = task_cache.get(task_id)
63
+ if cached is not None:
64
+ return cached
65
+ try:
66
+ task = sdk.get_task(task_id)
67
+ except Exception as e:
68
+ log.warning("Error fetching task '%s': %s", task_id, e)
69
+ return None
70
+ task_cache.set(task_id, task)
71
+ return task
72
+
73
+
74
+ def match_task_name(task_name: str, includes, excludes) -> bool:
75
+ """Return True if ``task_name`` should be tracked under the given rules.
76
+
77
+ Excludes win over includes. Both lists contain regex strings matched with
78
+ ``re.fullmatch``. ``None`` means "no rule".
79
+ """
80
+ if excludes:
81
+ for exclude in excludes:
82
+ if re.fullmatch(exclude, task_name):
83
+ return False
84
+
85
+ if includes:
86
+ for include in includes:
87
+ if re.fullmatch(include, task_name):
88
+ return True
89
+ return False
90
+
91
+ return True
92
+
93
+
94
+ class BaseSystemIntegration(System):
95
+ """Common ctor + ``track_task`` body for system integrations.
96
+
97
+ Subclasses set ``identifier`` and may override ``track_task`` to add
98
+ additional filtering (e.g. skipping built-in tasks).
99
+ """
100
+
101
+ def __init__(self, auto_track_tasks=True, includes=None, excludes=None, record_task_args=False):
102
+ self.auto_track_tasks = auto_track_tasks
103
+ self.includes = includes
104
+ self.excludes = excludes
105
+ self.record_task_args = record_task_args
106
+
107
+ def track_task(self, task_name: str) -> bool:
108
+ if not self.auto_track_tasks:
109
+ return False
110
+ return match_task_name(task_name, self.includes, self.excludes)
@@ -1,4 +1,3 @@
1
- import collections
2
1
  import functools
3
2
  import json
4
3
  import logging
@@ -13,66 +12,21 @@ from celery.signals import (
13
12
  )
14
13
  from kombu import serialization
15
14
 
15
+ from . import sdk
16
+ from ._integrations import TERMINAL_STATES, safe_get_task, task_cache
16
17
  from .internal.models import StatusEnum
17
18
  from .mug import Badger
18
19
  from .safe_sdk import create_task_safe, update_task_safe
19
- from .sdk import DefaultMergeStrategy, get_task
20
+ from .sdk import DefaultMergeStrategy
20
21
 
21
22
  KWARG_PREFIX = "taskbadger_"
22
23
  TB_KWARGS_ARG = f"{KWARG_PREFIX}kwargs"
23
24
  IGNORE_ARGS = {TB_KWARGS_ARG, f"{KWARG_PREFIX}task", f"{KWARG_PREFIX}task_id", f"{KWARG_PREFIX}record_task_args"}
24
25
  TB_TASK_ID = f"{KWARG_PREFIX}task_id"
25
26
 
26
- TERMINAL_STATES = {
27
- StatusEnum.SUCCESS,
28
- StatusEnum.ERROR,
29
- StatusEnum.CANCELLED,
30
- StatusEnum.STALE,
31
- }
32
-
33
27
  log = logging.getLogger("taskbadger")
34
28
 
35
29
 
36
- class Cache:
37
- def __init__(self, maxsize=128):
38
- self.cache = collections.OrderedDict()
39
- self.maxsize = maxsize
40
-
41
- def set(self, key, value):
42
- self.cache[key] = value
43
-
44
- def unset(self, key):
45
- self.cache.pop(key, None)
46
-
47
- def get(self, key):
48
- return self.cache.get(key)
49
-
50
- def prune(self):
51
- if len(self.cache) > self.maxsize:
52
- self.cache.popitem(last=False)
53
-
54
-
55
- def cached(cache_none=True, maxsize=128):
56
- cache = Cache(maxsize=maxsize)
57
-
58
- def _wrapper(func):
59
- @functools.wraps(func)
60
- def _inner(*args, **kwargs):
61
- key = args + tuple(sorted(kwargs.items()))
62
- if key in cache.cache:
63
- return cache.get(key)
64
-
65
- result = func(*args, **kwargs)
66
- if result is not None or cache_none:
67
- cache.set(key, result)
68
- return result
69
-
70
- _inner.cache = cache
71
- return _inner
72
-
73
- return _wrapper
74
-
75
-
76
30
  class Task(celery.Task):
77
31
  """A Celery Task that tracks itself with TaskBadger.
78
32
 
@@ -135,7 +89,7 @@ class Task(celery.Task):
135
89
  tb_task_id = info.get(TB_TASK_ID) if isinstance(info, dict) else None
136
90
  setattr(result, TB_TASK_ID, tb_task_id)
137
91
 
138
- _get_task = functools.partial(get_task, tb_task_id) if tb_task_id else lambda: None
92
+ _get_task = functools.partial(sdk.get_task, tb_task_id) if tb_task_id else lambda: None
139
93
  setattr(result, "get_taskbadger_task", _get_task)
140
94
 
141
95
  return result
@@ -292,7 +246,7 @@ def _maybe_create_task(signal_sender):
292
246
  if task:
293
247
  # Store the task ID in the request so _update_task can find it
294
248
  signal_sender.request.update({TB_TASK_ID: task.id})
295
- safe_get_task.cache.set((task.id,), task)
249
+ task_cache.set(task.id, task)
296
250
 
297
251
 
298
252
  @task_prerun.connect
@@ -344,7 +298,7 @@ def _update_task(signal_sender, status, einfo=None):
344
298
  data = DefaultMergeStrategy().merge(task.data, {"exception": str(einfo)})
345
299
  task = update_task_safe(task.id, status=status, data=data)
346
300
  if task:
347
- safe_get_task.cache.set((task_id,), task)
301
+ task_cache.set(task_id, task)
348
302
 
349
303
 
350
304
  def enter_session():
@@ -364,22 +318,13 @@ def exit_session(signal_sender):
364
318
  if not task_id or not Badger.is_configured():
365
319
  return
366
320
 
367
- safe_get_task.cache.unset((task_id,))
368
- safe_get_task.cache.prune()
321
+ task_cache.unset(task_id)
369
322
 
370
323
  session = Badger.current.session()
371
324
  if session.client:
372
325
  session.__exit__()
373
326
 
374
327
 
375
- @cached(cache_none=False)
376
- def safe_get_task(task_id: str):
377
- try:
378
- return get_task(task_id)
379
- except Exception as e:
380
- log.warning("Error fetching task '%s': %s", task_id, e)
381
-
382
-
383
328
  def _get_taskbadger_task_id(request):
384
329
  if not request:
385
330
  return
@@ -0,0 +1,335 @@
1
+ """TaskBadger integration for the Procrastinate task queue.
2
+
3
+ This module is opt-in. Users install Procrastinate themselves (or via the
4
+ ``taskbadger[procrastinate]`` extra) and import from here.
5
+
6
+ Public API:
7
+ - ``track``: decorator to opt a single task into TaskBadger tracking.
8
+ - ``current_task()``: accessor for the TaskBadger task associated with the
9
+ currently-running Procrastinate job (returns ``None`` if not tracked).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import functools
15
+ import inspect
16
+ import json
17
+ import logging
18
+ from contextvars import ContextVar
19
+
20
+ from ._integrations import TERMINAL_STATES, safe_get_task, task_cache
21
+ from .internal.models import StatusEnum
22
+ from .mug import Badger
23
+ from .safe_sdk import create_task_safe, update_task_safe
24
+ from .sdk import DefaultMergeStrategy
25
+
26
+ log = logging.getLogger("taskbadger")
27
+
28
+ # Reserved key used to smuggle the TaskBadger task id through Procrastinate's
29
+ # task_kwargs from the deferring process to the worker. Stripped before the
30
+ # user function is called.
31
+ TB_TASK_ID_KWARG = "__taskbadger_task_id__"
32
+
33
+ # Sentinel attribute names set on a Procrastinate Task object once it has been
34
+ # instrumented. Used to make instrumentation idempotent.
35
+ _INSTRUMENTED_ATTR = "_taskbadger_instrumented"
36
+ _MANUAL_ATTR = "_taskbadger_manual"
37
+ _OPTS_ATTR = "_taskbadger_opts"
38
+
39
+ _current_tb_task_id: ContextVar[str | None] = ContextVar("_current_tb_task_id", default=None)
40
+
41
+
42
+ def _instrument_task(task, system=None, manual=False, opts=None):
43
+ """Wrap a Procrastinate Task's ``func`` so the worker side updates TaskBadger.
44
+
45
+ Idempotent: a second call on the same task is a no-op (but ``manual`` and
46
+ ``opts`` will be merged onto the existing attributes if provided).
47
+ """
48
+ if opts is not None:
49
+ existing_opts = getattr(task, _OPTS_ATTR, {}) or {}
50
+ merged = {**existing_opts, **opts}
51
+ setattr(task, _OPTS_ATTR, merged)
52
+ elif not hasattr(task, _OPTS_ATTR):
53
+ setattr(task, _OPTS_ATTR, {})
54
+
55
+ if manual:
56
+ setattr(task, _MANUAL_ATTR, True)
57
+
58
+ if getattr(task, _INSTRUMENTED_ATTR, False):
59
+ return
60
+
61
+ original_func = task.func
62
+ is_async = inspect.iscoroutinefunction(original_func)
63
+
64
+ # pass_context=True works transparently: Procrastinate passes the context
65
+ # object as the first positional arg; our *args/**kwargs wrapper forwards it.
66
+ if is_async:
67
+
68
+ @functools.wraps(original_func)
69
+ async def wrapped(*args, **kwargs):
70
+ tb_id = kwargs.pop(TB_TASK_ID_KWARG, None)
71
+ if tb_id is None:
72
+ return await original_func(*args, **kwargs)
73
+ token = _current_tb_task_id.set(tb_id)
74
+ try:
75
+ _update_status(tb_id, StatusEnum.PROCESSING)
76
+ try:
77
+ result = await original_func(*args, **kwargs)
78
+ except Exception as exc:
79
+ _update_status(tb_id, StatusEnum.ERROR, exception=exc)
80
+ raise
81
+ _update_status(tb_id, StatusEnum.SUCCESS)
82
+ return result
83
+ finally:
84
+ _current_tb_task_id.reset(token)
85
+ else:
86
+
87
+ @functools.wraps(original_func)
88
+ def wrapped(*args, **kwargs):
89
+ tb_id = kwargs.pop(TB_TASK_ID_KWARG, None)
90
+ if tb_id is None:
91
+ return original_func(*args, **kwargs)
92
+ token = _current_tb_task_id.set(tb_id)
93
+ try:
94
+ _update_status(tb_id, StatusEnum.PROCESSING)
95
+ try:
96
+ result = original_func(*args, **kwargs)
97
+ except Exception as exc:
98
+ _update_status(tb_id, StatusEnum.ERROR, exception=exc)
99
+ raise
100
+ _update_status(tb_id, StatusEnum.SUCCESS)
101
+ return result
102
+ finally:
103
+ _current_tb_task_id.reset(token)
104
+
105
+ _wrap_defer(task)
106
+ task.func = wrapped
107
+ setattr(task, _INSTRUMENTED_ATTR, True)
108
+ setattr(task, "_taskbadger_system", system)
109
+
110
+
111
+ def _update_status(tb_id, status, exception=None):
112
+ """Update the TaskBadger task to ``status``. Skips if already terminal."""
113
+ if not Badger.is_configured():
114
+ return
115
+
116
+ if exception is not None or status in TERMINAL_STATES:
117
+ # Bypass the cache for the terminal-state check: the user may have
118
+ # updated the task to a terminal state via the regular SDK during
119
+ # the body, which wouldn't be reflected in our local cache.
120
+ task_cache.unset(tb_id)
121
+ current = safe_get_task(tb_id)
122
+ if current is not None and current.status in TERMINAL_STATES:
123
+ return
124
+ data = None
125
+ if exception is not None and current is not None:
126
+ base = dict(current.data) if current.data else None
127
+ data = DefaultMergeStrategy().merge(base, {"exception": str(exception)})
128
+ if data is not None:
129
+ updated = update_task_safe(tb_id, status=status, data=data)
130
+ else:
131
+ updated = update_task_safe(tb_id, status=status)
132
+ else:
133
+ updated = update_task_safe(tb_id, status=status)
134
+
135
+ if updated is not None:
136
+ task_cache.set(tb_id, updated)
137
+
138
+
139
+ def _wrap_defer(task):
140
+ """Wrap ``task.defer`` and ``task.defer_async`` so they create a TaskBadger
141
+ task in PENDING state and inject its id into the job's task_kwargs.
142
+
143
+ Not idempotent on its own — the caller (``_instrument_task``) gates this
144
+ via ``_INSTRUMENTED_ATTR`` so each task is wrapped at most once.
145
+ """
146
+ original_defer = task.defer
147
+ original_defer_async = task.defer_async
148
+
149
+ @functools.wraps(original_defer)
150
+ def defer(**kwargs):
151
+ kwargs = _maybe_create_pending(task, kwargs)
152
+ return original_defer(**kwargs)
153
+
154
+ @functools.wraps(original_defer_async)
155
+ async def defer_async(**kwargs):
156
+ kwargs = _maybe_create_pending(task, kwargs)
157
+ return await original_defer_async(**kwargs)
158
+
159
+ task.defer = defer
160
+ task.defer_async = defer_async
161
+
162
+
163
+ def _create_pending_task(task, task_kwargs):
164
+ """Create a PENDING TaskBadger task for ``task`` if it should be tracked.
165
+
166
+ Returns the created TaskBadger task, or ``None`` if Badger isn't
167
+ configured, the task isn't tracked (neither manual nor auto), or the
168
+ create call failed. ``task_kwargs`` is used only for the
169
+ ``record_task_args`` data capture.
170
+ """
171
+ if not Badger.is_configured():
172
+ return None
173
+
174
+ system = getattr(task, "_taskbadger_system", None)
175
+ manual = getattr(task, _MANUAL_ATTR, False)
176
+ auto = bool(system) and system.track_task(task.name)
177
+ if not manual and not auto:
178
+ return None
179
+
180
+ opts = dict(getattr(task, _OPTS_ATTR, {}) or {})
181
+ name = opts.pop("name", None) or task.name
182
+ create_kwargs = {"status": StatusEnum.PENDING}
183
+ for key in ("value_max", "tags"):
184
+ if key in opts and opts[key] is not None:
185
+ create_kwargs[key] = opts[key]
186
+
187
+ data = dict(opts.get("data") or {})
188
+
189
+ record_args = opts.get("record_task_args")
190
+ if record_args is None:
191
+ record_args = bool(system) and system.record_task_args
192
+ if record_args:
193
+ data["procrastinate_task_kwargs"] = _serialize_kwargs(task_kwargs)
194
+
195
+ if data:
196
+ create_kwargs["data"] = data
197
+
198
+ return create_task_safe(name, **create_kwargs)
199
+
200
+
201
+ def _maybe_create_pending(task, kwargs):
202
+ """Decide whether to track this defer, and if so create the TaskBadger
203
+ task and inject its id into ``kwargs``. Always returns the kwargs dict."""
204
+ tb_task = _create_pending_task(task, kwargs)
205
+ if tb_task is None:
206
+ return kwargs
207
+
208
+ new_kwargs = dict(kwargs)
209
+ new_kwargs[TB_TASK_ID_KWARG] = tb_task.id
210
+ return new_kwargs
211
+
212
+
213
+ def _serialize_kwargs(kwargs):
214
+ """Return a JSON-roundtrippable copy of the defer kwargs.
215
+
216
+ Procrastinate already requires kwargs be JSON-serializable, so a json
217
+ dumps/loads roundtrip is safe. Non-serializable values are dropped with
218
+ a warning."""
219
+ try:
220
+ return json.loads(json.dumps(kwargs))
221
+ except (TypeError, ValueError) as e:
222
+ log.warning("Error serializing task arguments: %s", e)
223
+ return {}
224
+
225
+
226
+ _TRACK_OPT_KEYS = ("name", "value_max", "tags", "data", "record_task_args")
227
+
228
+
229
+ def track(original_task=None, **opts):
230
+ """Opt a Procrastinate task into TaskBadger tracking.
231
+
232
+ Usage:
233
+
234
+ @track
235
+ @app.task(...)
236
+ def my_task(...): ...
237
+
238
+ @track(name="custom", value_max=100, tags={"env": "prod"})
239
+ @app.task(...)
240
+ async def big_job(...): ...
241
+
242
+ Accepted keyword options (all optional):
243
+ name: TaskBadger task name (defaults to the Procrastinate task's name).
244
+ value_max: Maximum value for the TaskBadger task.
245
+ tags: Dict of tags applied to the TaskBadger task.
246
+ data: Dict of initial data merged into the TaskBadger task.
247
+ record_task_args: If True, serialize the Procrastinate job kwargs and
248
+ store them under ``data["procrastinate_task_kwargs"]``. Defaults to
249
+ ``None`` meaning "inherit from system integration if any, else False".
250
+ """
251
+ unknown = set(opts) - set(_TRACK_OPT_KEYS)
252
+ if unknown:
253
+ raise TypeError(f"track() got unexpected keyword arguments: {sorted(unknown)}")
254
+
255
+ def wrap(task):
256
+ _instrument_task(task, system=None, manual=True, opts=opts)
257
+ return task
258
+
259
+ if original_task is None:
260
+ return wrap
261
+ return wrap(original_task)
262
+
263
+
264
+ def current_task():
265
+ """Return the TaskBadger Task for the currently-running Procrastinate job.
266
+
267
+ Returns ``None`` outside of a tracked task or if the task can't be fetched.
268
+ Result is cached for the lifetime of the worker process via an LRU.
269
+ """
270
+ tb_id = _current_tb_task_id.get()
271
+ if tb_id is None:
272
+ return None
273
+ return safe_get_task(tb_id)
274
+
275
+
276
+ def _patch_job_manager(app, system):
277
+ """Patch ``app.job_manager.defer_periodic_job`` so periodic tasks are tracked.
278
+
279
+ Procrastinate's ``PeriodicDeferrer`` enqueues jobs by calling
280
+ ``job_manager.defer_periodic_job(job=..., ...)`` directly, bypassing
281
+ ``task.defer``/``defer_async``. Without this hook, ``@app.periodic`` tasks
282
+ would never get a PENDING TaskBadger task created at enqueue time.
283
+
284
+ Idempotent: a second call updates the system reference but doesn't
285
+ re-wrap.
286
+ """
287
+ jm = app.job_manager
288
+ if not getattr(jm, "_taskbadger_original_defer_periodic_job", None):
289
+ original = jm.defer_periodic_job
290
+ jm._taskbadger_original_defer_periodic_job = original
291
+
292
+ @functools.wraps(original)
293
+ async def patched(*, job, periodic_id, defer_timestamp):
294
+ task = app.tasks.get(job.task_name)
295
+ if task is not None:
296
+ tb_task = _create_pending_task(task, job.task_kwargs)
297
+ if tb_task is not None:
298
+ new_kwargs = {**job.task_kwargs, TB_TASK_ID_KWARG: tb_task.id}
299
+ job = job.evolve(task_kwargs=new_kwargs)
300
+ return await jm._taskbadger_original_defer_periodic_job(
301
+ job=job, periodic_id=periodic_id, defer_timestamp=defer_timestamp
302
+ )
303
+
304
+ jm.defer_periodic_job = patched
305
+
306
+
307
+ def _patch_app_task(app, system):
308
+ """Replace ``app.task`` with a wrapper that instruments newly-registered
309
+ tasks under the supplied ``system``. Idempotent — a second call replaces
310
+ the wrapper but keeps the same original task method."""
311
+ original = getattr(app, "_taskbadger_original_task", None) or app.task
312
+ if not getattr(app, "_taskbadger_original_task", None):
313
+ app._taskbadger_original_task = original
314
+
315
+ @functools.wraps(original)
316
+ def patched(*args, **kwargs):
317
+ task = original(*args, **kwargs)
318
+ # ``original`` may return the Task directly or a decorator depending on
319
+ # call form. Procrastinate's ``app.task`` always returns a decorator
320
+ # when called with arguments and the Task when called bare.
321
+ if callable(task) and not hasattr(task, "name"):
322
+ # decorator form: wrap the returned decorator
323
+ inner_decorator = task
324
+
325
+ @functools.wraps(inner_decorator)
326
+ def outer(func):
327
+ t = inner_decorator(func)
328
+ _instrument_task(t, system=system)
329
+ return t
330
+
331
+ return outer
332
+ _instrument_task(task, system=system)
333
+ return task
334
+
335
+ app.task = patched
@@ -1,9 +1,7 @@
1
- import re
1
+ from taskbadger._integrations import BaseSystemIntegration
2
2
 
3
- from taskbadger.systems import System
4
3
 
5
-
6
- class CelerySystemIntegration(System):
4
+ class CelerySystemIntegration(BaseSystemIntegration):
7
5
  identifier = "celery"
8
6
 
9
7
  def __init__(self, auto_track_tasks=True, includes=None, excludes=None, record_task_args=False):
@@ -18,29 +16,13 @@ class CelerySystemIntegration(System):
18
16
  the full task name or a regular expression. Exclusions take precedence over inclusions.
19
17
  record_task_args: Record the arguments passed to each task.
20
18
  """
21
- self.auto_track_tasks = auto_track_tasks
22
- self.includes = includes
23
- self.excludes = excludes
24
- self.record_task_args = record_task_args
19
+ super().__init__(
20
+ auto_track_tasks=auto_track_tasks,
21
+ includes=includes,
22
+ excludes=excludes,
23
+ record_task_args=record_task_args,
24
+ )
25
25
 
26
26
  if auto_track_tasks:
27
27
  # Importing this here ensures that the Celery signal handlers are registered
28
- import taskbadger.celery # noqa
29
-
30
- def track_task(self, task_name):
31
- if not self.auto_track_tasks:
32
- return False
33
-
34
- if self.excludes:
35
- for exclude in self.excludes:
36
- if re.fullmatch(exclude, task_name):
37
- return False
38
-
39
- if self.includes:
40
- for include in self.includes:
41
- if re.fullmatch(include, task_name):
42
- break
43
- else:
44
- return False
45
-
46
- return True
28
+ import taskbadger.celery # noqa: F401
@@ -0,0 +1,51 @@
1
+ """ProcrastinateSystemIntegration — auto-track tasks on a Procrastinate App."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from taskbadger._integrations import BaseSystemIntegration
6
+ from taskbadger.procrastinate import _instrument_task, _patch_app_task, _patch_job_manager
7
+
8
+
9
+ class ProcrastinateSystemIntegration(BaseSystemIntegration):
10
+ identifier = "procrastinate"
11
+
12
+ def __init__(
13
+ self,
14
+ app,
15
+ auto_track_tasks=True,
16
+ includes=None,
17
+ excludes=None,
18
+ record_task_args=False,
19
+ ):
20
+ """
21
+ Args:
22
+ app: The ``procrastinate.App`` instance to instrument.
23
+ auto_track_tasks: Track all tasks regardless of whether they use
24
+ the ``@taskbadger.procrastinate.track`` decorator.
25
+ includes: List of task names to include in auto-tracking. Each
26
+ entry can be a full name or a regex (matched with
27
+ ``re.fullmatch``).
28
+ excludes: List of task names to exclude. Same semantics as
29
+ ``includes``. Exclusions take precedence.
30
+ record_task_args: Record the task's defer kwargs into the
31
+ TaskBadger task's ``data`` under ``procrastinate_task_kwargs``.
32
+ """
33
+ super().__init__(
34
+ auto_track_tasks=auto_track_tasks,
35
+ includes=includes,
36
+ excludes=excludes,
37
+ record_task_args=record_task_args,
38
+ )
39
+ self.app = app
40
+
41
+ for task in list(app.tasks.values()):
42
+ _instrument_task(task, system=self)
43
+ _patch_app_task(app, system=self)
44
+ _patch_job_manager(app, system=self)
45
+
46
+ def track_task(self, task_name):
47
+ # Never auto-track Procrastinate's built-in housekeeping tasks
48
+ # (e.g. ``builtin:procrastinate.builtin_tasks.remove_old_jobs``).
49
+ if task_name.startswith("builtin:") or task_name.startswith("procrastinate."):
50
+ return False
51
+ return super().track_task(task_name)
@@ -1,43 +0,0 @@
1
- # Task Badger Python Client
2
-
3
- This is the official Python SDK for [Task Badger](https://taskbadger.net/).
4
-
5
- For full documentation go to https://docs.taskbadger.net/python/.
6
-
7
- ![Integration Tests](https://github.com/taskbadger/taskbadger-python/actions/workflows/integration_tests.yml/badge.svg)
8
-
9
- ---
10
-
11
- ## Getting Started
12
-
13
- ### Install
14
-
15
- ```bash
16
- pip install taskbadger
17
- ```
18
-
19
- To use the `taskbadger` command-line tool, install the `cli` extra:
20
-
21
- ```bash
22
- pip install 'taskbadger[cli]'
23
- ```
24
-
25
- ### Client Usage
26
-
27
- ```python
28
- import taskbadger
29
- from taskbadger.systems import CelerySystemIntegration
30
-
31
- taskbadger.init(
32
- token="***",
33
- systems=[CelerySystemIntegration()],
34
- tags={"environment": "production"}
35
- )
36
- ```
37
-
38
- ### CLI Usage
39
-
40
- ```shell
41
- $ export TASKBADGER_API_KEY=***
42
- $ taskbadger run "nightly-backup" -- ./backup.sh
43
- ```
File without changes
File without changes