django-tasks-pubsub 0.1.1__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.
- django_tasks_pubsub-0.1.1/PKG-INFO +10 -0
- django_tasks_pubsub-0.1.1/README.md +1 -0
- django_tasks_pubsub-0.1.1/django_tasks_pubsub/__init__.py +7 -0
- django_tasks_pubsub-0.1.1/django_tasks_pubsub/backend.py +110 -0
- django_tasks_pubsub-0.1.1/django_tasks_pubsub/dispatcher.py +54 -0
- django_tasks_pubsub-0.1.1/django_tasks_pubsub/publisher.py +14 -0
- django_tasks_pubsub-0.1.1/django_tasks_pubsub/pubsub_task_decorator.py +19 -0
- django_tasks_pubsub-0.1.1/django_tasks_pubsub/views.py +71 -0
- django_tasks_pubsub-0.1.1/django_tasks_pubsub.egg-info/PKG-INFO +10 -0
- django_tasks_pubsub-0.1.1/django_tasks_pubsub.egg-info/SOURCES.txt +16 -0
- django_tasks_pubsub-0.1.1/django_tasks_pubsub.egg-info/dependency_links.txt +1 -0
- django_tasks_pubsub-0.1.1/django_tasks_pubsub.egg-info/requires.txt +2 -0
- django_tasks_pubsub-0.1.1/django_tasks_pubsub.egg-info/top_level.txt +1 -0
- django_tasks_pubsub-0.1.1/pyproject.toml +18 -0
- django_tasks_pubsub-0.1.1/setup.cfg +4 -0
- django_tasks_pubsub-0.1.1/tests/test_dispatching_with_pubsub_task_backend.py +119 -0
- django_tasks_pubsub-0.1.1/tests/test_enqueing_with_pubsub_task_backend.py +211 -0
- django_tasks_pubsub-0.1.1/tests/test_pubsub_view.py +120 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-tasks-pubsub
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Django tasks backend for Google Cloud Pub/Sub
|
|
5
|
+
Requires-Python: >=3.13
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: django>=6.0.5
|
|
8
|
+
Requires-Dist: google-cloud-pubsub>=2.38.0
|
|
9
|
+
|
|
10
|
+
hello, world.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
hello, world.
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
import json
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from django.conf import settings
|
|
8
|
+
from django.tasks import Task
|
|
9
|
+
from django.tasks.backends.base import BaseTaskBackend
|
|
10
|
+
from django.tasks.base import TaskResult as BaseTaskResult, TaskResultStatus
|
|
11
|
+
from django.utils import timezone
|
|
12
|
+
from django.utils.crypto import get_random_string
|
|
13
|
+
|
|
14
|
+
from django_tasks_pubsub.publisher import get_publisher
|
|
15
|
+
from django_tasks_pubsub.publisher import publish
|
|
16
|
+
from django_tasks_pubsub.pubsub_task_decorator import PubSubMetaData
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class TaskPayload:
|
|
21
|
+
backend: str
|
|
22
|
+
module_path: str
|
|
23
|
+
name: str
|
|
24
|
+
priority: int
|
|
25
|
+
queue_name: str
|
|
26
|
+
takes_context: bool
|
|
27
|
+
run_after: Optional[datetime] = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class Payload:
|
|
32
|
+
task: TaskPayload
|
|
33
|
+
task_id: str
|
|
34
|
+
enqueued_at: str
|
|
35
|
+
args: list
|
|
36
|
+
kwargs: dict
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TaskResult(BaseTaskResult):
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class PubSubBackend(BaseTaskBackend):
|
|
44
|
+
@staticmethod
|
|
45
|
+
def get_pubsub_metadata(task: Task) -> PubSubMetaData:
|
|
46
|
+
task_func = getattr(task, "func")
|
|
47
|
+
|
|
48
|
+
return getattr(task_func, "__pubsub_metadata", PubSubMetaData())
|
|
49
|
+
|
|
50
|
+
def get_topic_id(self, task: Task):
|
|
51
|
+
return (
|
|
52
|
+
self.get_pubsub_metadata(task=task).topic_id
|
|
53
|
+
or settings.PUBSUB_DEFAULT_TOPIC_ID
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def get_topic_path(self, task: Task):
|
|
57
|
+
topic_id = self.get_topic_id(task)
|
|
58
|
+
|
|
59
|
+
publisher = get_publisher()
|
|
60
|
+
|
|
61
|
+
topic_path = publisher.topic_path(
|
|
62
|
+
settings.PUBSUB_PROJECT_ID,
|
|
63
|
+
topic_id,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
return topic_path
|
|
67
|
+
|
|
68
|
+
def enqueue(self, task: Task, args, kwargs):
|
|
69
|
+
enqueued_at = timezone.now()
|
|
70
|
+
task_id = get_random_string(32)
|
|
71
|
+
|
|
72
|
+
payload: Payload = Payload(
|
|
73
|
+
task=TaskPayload(
|
|
74
|
+
backend=task.backend,
|
|
75
|
+
module_path=task.module_path,
|
|
76
|
+
name=task.name,
|
|
77
|
+
priority=task.priority,
|
|
78
|
+
queue_name=task.queue_name,
|
|
79
|
+
takes_context=task.takes_context,
|
|
80
|
+
),
|
|
81
|
+
task_id=task_id,
|
|
82
|
+
enqueued_at=enqueued_at.isoformat(),
|
|
83
|
+
args=args,
|
|
84
|
+
kwargs=kwargs,
|
|
85
|
+
)
|
|
86
|
+
# Data to send to the queue.
|
|
87
|
+
data = json.dumps(
|
|
88
|
+
dataclasses.asdict(payload),
|
|
89
|
+
).encode("utf-8")
|
|
90
|
+
|
|
91
|
+
topic_path = self.get_topic_path(
|
|
92
|
+
task,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
publish(topic=topic_path, data=data)
|
|
96
|
+
|
|
97
|
+
return TaskResult(
|
|
98
|
+
id=task_id,
|
|
99
|
+
task=task,
|
|
100
|
+
enqueued_at=enqueued_at,
|
|
101
|
+
started_at=None,
|
|
102
|
+
finished_at=None,
|
|
103
|
+
status=TaskResultStatus.READY,
|
|
104
|
+
args=args,
|
|
105
|
+
kwargs=kwargs,
|
|
106
|
+
last_attempted_at=None,
|
|
107
|
+
backend=task.backend,
|
|
108
|
+
errors=[],
|
|
109
|
+
worker_ids=[],
|
|
110
|
+
)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
|
|
4
|
+
from django.tasks import TaskContext
|
|
5
|
+
from django.tasks.base import TaskResultStatus
|
|
6
|
+
|
|
7
|
+
from django_tasks_pubsub.backend import Payload, TaskResult
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def dispatch(payload: Payload):
|
|
11
|
+
module_path = payload.task.module_path
|
|
12
|
+
task_name = payload.task.name
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
module = importlib.import_module(module_path)
|
|
16
|
+
except ModuleNotFoundError:
|
|
17
|
+
module_path, task_name_from_path = module_path.rsplit(".", 1)
|
|
18
|
+
module = importlib.import_module(module_path)
|
|
19
|
+
task_name = task_name or task_name_from_path
|
|
20
|
+
|
|
21
|
+
# This is the decorated task function, decoration makes it a Task object
|
|
22
|
+
task = getattr(module, task_name)
|
|
23
|
+
|
|
24
|
+
# Actual task function that we can call
|
|
25
|
+
task_function = getattr(task, "func", task)
|
|
26
|
+
|
|
27
|
+
if payload.task.takes_context:
|
|
28
|
+
context = TaskContext(
|
|
29
|
+
task_result=TaskResult(
|
|
30
|
+
id=payload.task_id,
|
|
31
|
+
task=task,
|
|
32
|
+
enqueued_at=datetime.fromisoformat(payload.enqueued_at),
|
|
33
|
+
started_at=None,
|
|
34
|
+
finished_at=None,
|
|
35
|
+
status=TaskResultStatus.READY,
|
|
36
|
+
args=payload.args,
|
|
37
|
+
kwargs=payload.kwargs,
|
|
38
|
+
last_attempted_at=None,
|
|
39
|
+
backend=payload.task.backend,
|
|
40
|
+
errors=[],
|
|
41
|
+
worker_ids=[],
|
|
42
|
+
),
|
|
43
|
+
)
|
|
44
|
+
task_function(
|
|
45
|
+
context,
|
|
46
|
+
*payload.args,
|
|
47
|
+
**payload.kwargs,
|
|
48
|
+
)
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
task_function(
|
|
52
|
+
*payload.args,
|
|
53
|
+
**payload.kwargs,
|
|
54
|
+
)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from google.cloud import pubsub_v1
|
|
2
|
+
|
|
3
|
+
_publisher: pubsub_v1.PublisherClient | None = None
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def get_publisher():
|
|
7
|
+
global _publisher
|
|
8
|
+
if _publisher is None:
|
|
9
|
+
_publisher = pubsub_v1.PublisherClient()
|
|
10
|
+
return _publisher
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def publish(topic: str, data: bytes):
|
|
14
|
+
get_publisher().publish(topic=topic, data=data)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
6
|
+
class PubSubMetaData:
|
|
7
|
+
topic_id: str | None = None
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def pubsub_task(topic_id: str | None = None):
|
|
11
|
+
def wrapper(func: Callable):
|
|
12
|
+
setattr(
|
|
13
|
+
func,
|
|
14
|
+
"__pubsub_metadata",
|
|
15
|
+
PubSubMetaData(topic_id=topic_id),
|
|
16
|
+
)
|
|
17
|
+
return func
|
|
18
|
+
|
|
19
|
+
return wrapper
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from django.http import HttpResponse
|
|
6
|
+
from django.utils.decorators import method_decorator
|
|
7
|
+
from django.views import View
|
|
8
|
+
from django.views.decorators.csrf import csrf_exempt
|
|
9
|
+
|
|
10
|
+
from django_tasks_pubsub.dispatcher import dispatch
|
|
11
|
+
from django_tasks_pubsub.backend import Payload, TaskPayload
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@method_decorator(csrf_exempt, name="dispatch")
|
|
15
|
+
class PubSubPushView(View):
|
|
16
|
+
"""
|
|
17
|
+
Receives Pub/Sub push messages and dispatches them to TASK_DISPATCH_FUNCTION.
|
|
18
|
+
|
|
19
|
+
Pub/Sub push format:
|
|
20
|
+
{
|
|
21
|
+
"message": {
|
|
22
|
+
"data": "<base64-encoded JSON payload>",
|
|
23
|
+
"messageId": "...",
|
|
24
|
+
"publishTime": "..."
|
|
25
|
+
},
|
|
26
|
+
"subscription": "projects/.../subscriptions/..."
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
Returns 204 on success (Pub/Sub ACKs on any 2xx).
|
|
30
|
+
Returns 500 on task failure (Pub/Sub will retry).
|
|
31
|
+
Returns 400 on malformed message (no retry).
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def post(self, request):
|
|
35
|
+
try:
|
|
36
|
+
envelope = json.loads(request.body)
|
|
37
|
+
except (json.JSONDecodeError, ValueError):
|
|
38
|
+
logging.error("PubSubPushView: invalid JSON body")
|
|
39
|
+
return HttpResponse(status=400)
|
|
40
|
+
|
|
41
|
+
encoded_data = envelope.get("message", {}).get("data", "")
|
|
42
|
+
if not encoded_data:
|
|
43
|
+
logging.error("PubSubPushView: missing message.data")
|
|
44
|
+
return HttpResponse(status=400)
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
payload_dict = json.loads(base64.b64decode(encoded_data))
|
|
48
|
+
except Exception as exc:
|
|
49
|
+
logging.error(f"PubSubPushView: failed to decode message: {exc}")
|
|
50
|
+
return HttpResponse(status=400)
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
payload = Payload(
|
|
54
|
+
task=TaskPayload(
|
|
55
|
+
**payload_dict.pop("task"),
|
|
56
|
+
),
|
|
57
|
+
**payload_dict,
|
|
58
|
+
)
|
|
59
|
+
except Exception as exc:
|
|
60
|
+
logging.exception(f"PubSubPushView: failed to create payload: {exc}")
|
|
61
|
+
return HttpResponse(status=400)
|
|
62
|
+
|
|
63
|
+
logging.info(f"PubSubPushView: dispatching task_type={payload.task.name!r}")
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
dispatch(payload=payload)
|
|
67
|
+
except BaseException as exc:
|
|
68
|
+
logging.exception(f"PubSubPushView: task failed: {payload!r} error={exc}")
|
|
69
|
+
return HttpResponse(status=500)
|
|
70
|
+
|
|
71
|
+
return HttpResponse(status=204)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-tasks-pubsub
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Django tasks backend for Google Cloud Pub/Sub
|
|
5
|
+
Requires-Python: >=3.13
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: django>=6.0.5
|
|
8
|
+
Requires-Dist: google-cloud-pubsub>=2.38.0
|
|
9
|
+
|
|
10
|
+
hello, world.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
django_tasks_pubsub/__init__.py
|
|
4
|
+
django_tasks_pubsub/backend.py
|
|
5
|
+
django_tasks_pubsub/dispatcher.py
|
|
6
|
+
django_tasks_pubsub/publisher.py
|
|
7
|
+
django_tasks_pubsub/pubsub_task_decorator.py
|
|
8
|
+
django_tasks_pubsub/views.py
|
|
9
|
+
django_tasks_pubsub.egg-info/PKG-INFO
|
|
10
|
+
django_tasks_pubsub.egg-info/SOURCES.txt
|
|
11
|
+
django_tasks_pubsub.egg-info/dependency_links.txt
|
|
12
|
+
django_tasks_pubsub.egg-info/requires.txt
|
|
13
|
+
django_tasks_pubsub.egg-info/top_level.txt
|
|
14
|
+
tests/test_dispatching_with_pubsub_task_backend.py
|
|
15
|
+
tests/test_enqueing_with_pubsub_task_backend.py
|
|
16
|
+
tests/test_pubsub_view.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
django_tasks_pubsub
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "django-tasks-pubsub"
|
|
3
|
+
version = "v0.1.1"
|
|
4
|
+
description = "Django tasks backend for Google Cloud Pub/Sub"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.13"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"django>=6.0.5",
|
|
9
|
+
"google-cloud-pubsub>=2.38.0",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[dependency-groups]
|
|
13
|
+
dev = [
|
|
14
|
+
"build>=1.5.0",
|
|
15
|
+
"coverage>=7.14.0",
|
|
16
|
+
"mypy>=2.1.0",
|
|
17
|
+
"ruff>=0.15.13",
|
|
18
|
+
]
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from unittest.mock import patch, Mock
|
|
2
|
+
|
|
3
|
+
from django.tasks import task, TaskContext, Task
|
|
4
|
+
from django.test import SimpleTestCase
|
|
5
|
+
from django.test.utils import override_settings
|
|
6
|
+
|
|
7
|
+
from django_tasks_pubsub.dispatcher import dispatch
|
|
8
|
+
from django_tasks_pubsub.backend import Payload, TaskPayload
|
|
9
|
+
|
|
10
|
+
sample_task_with_context_mock = Mock()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@task(takes_context=True)
|
|
14
|
+
def sample_task_with_context(context: TaskContext, specified_arg, *args, **kwargs):
|
|
15
|
+
sample_task_with_context_mock(
|
|
16
|
+
context,
|
|
17
|
+
specified_arg,
|
|
18
|
+
*args,
|
|
19
|
+
**kwargs,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@override_settings(PUBSUB_PROJECT_ID="project_name")
|
|
24
|
+
@override_settings(PUBSUB_DEFAULT_TOPIC_ID="default_topic_id")
|
|
25
|
+
@override_settings(TASKS={"default": {"BACKEND": "django_tasks_pubsub.PubSubBackend"}})
|
|
26
|
+
class TestPubSubBackendEnqueuing(SimpleTestCase):
|
|
27
|
+
def setUp(self):
|
|
28
|
+
self.get_topic_path_patcher = patch(
|
|
29
|
+
"django_tasks_pubsub.google_cloud_pubsub_backend.GoogleCloudPubSubBackend.get_topic_path",
|
|
30
|
+
return_value="expected/topic/path",
|
|
31
|
+
)
|
|
32
|
+
self.get_topic_path_mock = self.get_topic_path_patcher.start()
|
|
33
|
+
|
|
34
|
+
def tearDown(self):
|
|
35
|
+
self.get_topic_path_patcher.stop()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TestPubSubTaskDispatching(SimpleTestCase):
|
|
39
|
+
def setUp(self):
|
|
40
|
+
sample_task_with_context_mock.reset_mock()
|
|
41
|
+
|
|
42
|
+
def test_dispatching_task_with_context_passes_expected_context_to_task_function(
|
|
43
|
+
self,
|
|
44
|
+
):
|
|
45
|
+
payload = Payload(
|
|
46
|
+
task=TaskPayload(
|
|
47
|
+
backend="default",
|
|
48
|
+
module_path="tests.test_dispatching_with_pubsub_task_backend",
|
|
49
|
+
name="sample_task_with_context",
|
|
50
|
+
priority=0,
|
|
51
|
+
queue_name="default",
|
|
52
|
+
takes_context=True,
|
|
53
|
+
),
|
|
54
|
+
task_id="test-task-id",
|
|
55
|
+
enqueued_at="2026-05-12T10:00:00+00:00",
|
|
56
|
+
args=[
|
|
57
|
+
"spec_arg",
|
|
58
|
+
"arg1",
|
|
59
|
+
"arg2",
|
|
60
|
+
],
|
|
61
|
+
kwargs={
|
|
62
|
+
"kwarg1": "val1",
|
|
63
|
+
"kwarg2": "val2",
|
|
64
|
+
},
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
dispatch(payload)
|
|
68
|
+
|
|
69
|
+
sample_task_with_context_mock.assert_called_once()
|
|
70
|
+
|
|
71
|
+
context: TaskContext = sample_task_with_context_mock.call_args.args[0]
|
|
72
|
+
|
|
73
|
+
self.assertIsNotNone(context)
|
|
74
|
+
|
|
75
|
+
with self.subTest("context contains the task result id"):
|
|
76
|
+
self.assertEqual(context.task_result.id, "test-task-id")
|
|
77
|
+
|
|
78
|
+
with self.subTest("task receives expected args and kwargs after context"):
|
|
79
|
+
sample_task_with_context_mock.assert_called_once_with(
|
|
80
|
+
context,
|
|
81
|
+
"spec_arg",
|
|
82
|
+
"arg1",
|
|
83
|
+
"arg2",
|
|
84
|
+
kwarg1="val1",
|
|
85
|
+
kwarg2="val2",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# SubTest
|
|
89
|
+
with self.subTest("context should be TaskContext"):
|
|
90
|
+
self.assertIsInstance(context, TaskContext)
|
|
91
|
+
|
|
92
|
+
called_task_result_task: Task = context.task_result.task
|
|
93
|
+
|
|
94
|
+
self.assertIsNotNone(
|
|
95
|
+
called_task_result_task,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
self.assertIsInstance(
|
|
99
|
+
called_task_result_task,
|
|
100
|
+
Task,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# SubTest should have ALL the expected properties set
|
|
104
|
+
with self.subTest(
|
|
105
|
+
"should have ALL the expected properties set at the task oject"
|
|
106
|
+
):
|
|
107
|
+
self.assertEqual(called_task_result_task.name, "sample_task_with_context")
|
|
108
|
+
self.assertEqual(called_task_result_task.backend, "default")
|
|
109
|
+
self.assertEqual(
|
|
110
|
+
called_task_result_task.module_path,
|
|
111
|
+
"tests.test_dispatching_with_pubsub_task_backend.sample_task_with_context",
|
|
112
|
+
)
|
|
113
|
+
self.assertEqual(called_task_result_task.priority, 0)
|
|
114
|
+
self.assertEqual(called_task_result_task.queue_name, "default")
|
|
115
|
+
self.assertEqual(called_task_result_task.takes_context, True)
|
|
116
|
+
self.assertEqual(
|
|
117
|
+
called_task_result_task.func,
|
|
118
|
+
sample_task_with_context.func,
|
|
119
|
+
)
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from unittest.mock import patch, Mock
|
|
4
|
+
|
|
5
|
+
from django.tasks import task
|
|
6
|
+
from django.test import SimpleTestCase
|
|
7
|
+
from django.test.utils import override_settings
|
|
8
|
+
|
|
9
|
+
from django_tasks_pubsub import pubsub_task
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@task
|
|
13
|
+
def sample_task(specified_arg, *args, **kwargs):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@task(takes_context=True)
|
|
18
|
+
def sample_task_with_context(context, specified_arg, *args, **kwargs):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@task
|
|
23
|
+
@pubsub_task("test-topic-name")
|
|
24
|
+
def sample_task_with_pubsub_topic(specified_arg, *args, **kwargs):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
sample_task_with_pubsub_topic_watchdog = Mock()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@task
|
|
32
|
+
@pubsub_task("test-topic-name")
|
|
33
|
+
def sample_task_with_pubsub_topic_with_watchdog(*args, **kwargs):
|
|
34
|
+
sample_task_with_pubsub_topic_watchdog(
|
|
35
|
+
*args,
|
|
36
|
+
**kwargs,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@override_settings(PUBSUB_PROJECT_ID="project_name")
|
|
41
|
+
@override_settings(PUBSUB_DEFAULT_TOPIC_ID="default_topic_id")
|
|
42
|
+
@override_settings(TASKS={"default": {"BACKEND": "django_tasks_pubsub.PubSubBackend"}})
|
|
43
|
+
class TestPubsubTopicGeneration(SimpleTestCase):
|
|
44
|
+
@override_settings(PUBSUB_DEFAULT_TOPIC_ID="other_default_topic_id")
|
|
45
|
+
@patch("django_tasks_pubsub.backend.get_publisher")
|
|
46
|
+
@patch("django_tasks_pubsub.backend.publish", return_value="")
|
|
47
|
+
def test_enqueuing_a_task_with_out_topic_defined_should_use_default(
|
|
48
|
+
self, mocked_publish_method, mocked_get_publisher
|
|
49
|
+
):
|
|
50
|
+
class DummyPublisher:
|
|
51
|
+
def __init__(self):
|
|
52
|
+
self.topic_path = Mock()
|
|
53
|
+
|
|
54
|
+
dummy_publisher = DummyPublisher()
|
|
55
|
+
|
|
56
|
+
mocked_get_publisher.return_value = dummy_publisher
|
|
57
|
+
|
|
58
|
+
sample_task.enqueue(
|
|
59
|
+
"spec_arg",
|
|
60
|
+
"arg1",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
dummy_publisher.topic_path.assert_called_once_with(
|
|
64
|
+
"project_name",
|
|
65
|
+
"other_default_topic_id",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
@patch("django_tasks_pubsub.backend.get_publisher")
|
|
69
|
+
@patch("django_tasks_pubsub.backend.publish", return_value="")
|
|
70
|
+
def test_enqueuing_a_task_with_pubsub_topic(
|
|
71
|
+
self, mocked_publish_method, mocked_get_publisher
|
|
72
|
+
):
|
|
73
|
+
class DummyPublisher:
|
|
74
|
+
def __init__(self):
|
|
75
|
+
self.topic_path = Mock()
|
|
76
|
+
|
|
77
|
+
dummy_publisher = DummyPublisher()
|
|
78
|
+
|
|
79
|
+
mocked_get_publisher.return_value = dummy_publisher
|
|
80
|
+
|
|
81
|
+
sample_task_with_pubsub_topic.enqueue(
|
|
82
|
+
"spec_arg",
|
|
83
|
+
"arg1",
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
dummy_publisher.topic_path.assert_called_once_with(
|
|
87
|
+
"project_name",
|
|
88
|
+
"test-topic-name",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
@patch("django_tasks_pubsub.backend.publish", return_value="")
|
|
92
|
+
@patch(
|
|
93
|
+
"django_tasks_pubsub.PubSubBackend.get_topic_path",
|
|
94
|
+
return_value="expected/topic/path",
|
|
95
|
+
)
|
|
96
|
+
def test_enqueuing_task_should_use_value_from_get_topic_path(
|
|
97
|
+
self, mocked_get_topic_path, mocked_publish_method
|
|
98
|
+
):
|
|
99
|
+
sample_task.enqueue(
|
|
100
|
+
"spec_arg",
|
|
101
|
+
"arg1",
|
|
102
|
+
"arg2",
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
self.assertEqual(mocked_publish_method.call_count, 1)
|
|
106
|
+
self.assertEqual(
|
|
107
|
+
mocked_publish_method.call_args.kwargs["topic"],
|
|
108
|
+
"expected/topic/path",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@override_settings(TASKS={"default": {"BACKEND": "django_tasks_pubsub.PubSubBackend"}})
|
|
113
|
+
class TestPubSubBackendEnqueuing(SimpleTestCase):
|
|
114
|
+
def setUp(self):
|
|
115
|
+
self.get_topic_path_patcher = patch(
|
|
116
|
+
"django_tasks_pubsub.backend.PubSubBackend.get_topic_path",
|
|
117
|
+
return_value="expected/topic/path",
|
|
118
|
+
)
|
|
119
|
+
self.get_topic_path_mock = self.get_topic_path_patcher.start()
|
|
120
|
+
|
|
121
|
+
def tearDown(self):
|
|
122
|
+
self.get_topic_path_patcher.stop()
|
|
123
|
+
|
|
124
|
+
@patch("django_tasks_pubsub.backend.publish", return_value="")
|
|
125
|
+
def test_enqueuing_a_task_with_the_pub_sub_backend_should_not_raise_an_error(
|
|
126
|
+
self, mocked_publish_method
|
|
127
|
+
):
|
|
128
|
+
sample_task.enqueue(
|
|
129
|
+
"spec_arg",
|
|
130
|
+
"arg1",
|
|
131
|
+
"arg2",
|
|
132
|
+
kwarg1="val1",
|
|
133
|
+
kwarg2="val2",
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
self.assertEqual(mocked_publish_method.call_count, 1)
|
|
137
|
+
|
|
138
|
+
with self.subTest("payload data is serialized as expected"):
|
|
139
|
+
payload_data = mocked_publish_method.call_args.kwargs["data"]
|
|
140
|
+
payload = json.loads(payload_data.decode("utf-8"))
|
|
141
|
+
|
|
142
|
+
self.assertIn("task", payload)
|
|
143
|
+
self.assertIn("task_id", payload)
|
|
144
|
+
self.assertIn("enqueued_at", payload)
|
|
145
|
+
self.assertIn("args", payload)
|
|
146
|
+
self.assertIn("kwargs", payload)
|
|
147
|
+
|
|
148
|
+
datetime.fromisoformat(payload["enqueued_at"])
|
|
149
|
+
|
|
150
|
+
self.assertEqual(
|
|
151
|
+
payload["args"],
|
|
152
|
+
["spec_arg", "arg1", "arg2"],
|
|
153
|
+
)
|
|
154
|
+
self.assertEqual(
|
|
155
|
+
payload["kwargs"],
|
|
156
|
+
{
|
|
157
|
+
"kwarg1": "val1",
|
|
158
|
+
"kwarg2": "val2",
|
|
159
|
+
},
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
self.assertEqual(payload["task"]["name"], "sample_task")
|
|
163
|
+
self.assertEqual(payload["task"]["takes_context"], False)
|
|
164
|
+
self.assertEqual(
|
|
165
|
+
payload["task"]["module_path"],
|
|
166
|
+
"tests.test_enqueing_with_pubsub_task_backend.sample_task",
|
|
167
|
+
)
|
|
168
|
+
self.assertEqual(
|
|
169
|
+
payload["task"]["name"],
|
|
170
|
+
"sample_task",
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
self.assertIsNone(
|
|
174
|
+
payload["task"]["run_after"],
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
@patch("django_tasks_pubsub.backend.publish", return_value="")
|
|
178
|
+
def test_enqueuing_a_task_with_context_with_the_pub_sub_backend_should_not_raise_an_error(
|
|
179
|
+
self, mocked_publish_method
|
|
180
|
+
):
|
|
181
|
+
sample_task_with_context.enqueue(
|
|
182
|
+
"spec_arg",
|
|
183
|
+
"arg1",
|
|
184
|
+
"arg2",
|
|
185
|
+
kwarg1="val1",
|
|
186
|
+
kwarg2="val2",
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
self.assertEqual(mocked_publish_method.call_count, 1)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class TestPubSubTopicEnqueuingWithImmediateBackend(SimpleTestCase):
|
|
193
|
+
@override_settings(
|
|
194
|
+
TASKS={
|
|
195
|
+
"default": {"BACKEND": "django.tasks.backends.immediate.ImmediateBackend"}
|
|
196
|
+
}
|
|
197
|
+
)
|
|
198
|
+
def test_enqueuing_a_task_with_pubsub_topic_should_raise_an_error(self):
|
|
199
|
+
sample_task_with_pubsub_topic_with_watchdog.enqueue(
|
|
200
|
+
"arg1",
|
|
201
|
+
"arg2",
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
self.assertEqual(sample_task_with_pubsub_topic_watchdog.call_count, 1)
|
|
205
|
+
|
|
206
|
+
self.assertEqual(
|
|
207
|
+
sample_task_with_pubsub_topic_watchdog.call_args.args[0], "arg1"
|
|
208
|
+
)
|
|
209
|
+
self.assertEqual(
|
|
210
|
+
sample_task_with_pubsub_topic_watchdog.call_args.args[1], "arg2"
|
|
211
|
+
)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import json
|
|
3
|
+
from unittest.mock import patch, Mock
|
|
4
|
+
|
|
5
|
+
from django.tasks import task
|
|
6
|
+
from django.test import RequestFactory, SimpleTestCase
|
|
7
|
+
|
|
8
|
+
from django_tasks_pubsub.backend import Payload, TaskPayload
|
|
9
|
+
from django_tasks_pubsub.views import PubSubPushView
|
|
10
|
+
|
|
11
|
+
sample_task_function_mock = Mock()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@task
|
|
15
|
+
def sample_task_function(specified_arg, *args, **kwargs):
|
|
16
|
+
sample_task_function_mock(specified_arg, *args, **kwargs)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TestDjangoTasksPubSubPushView(SimpleTestCase):
|
|
20
|
+
def setUp(self):
|
|
21
|
+
self.request_factory = RequestFactory()
|
|
22
|
+
|
|
23
|
+
def build_pubsub_push_request(self, payload: dict):
|
|
24
|
+
encoded_payload = base64.b64encode(
|
|
25
|
+
json.dumps(payload).encode("utf-8"),
|
|
26
|
+
).decode("utf-8")
|
|
27
|
+
|
|
28
|
+
envelope = {
|
|
29
|
+
"message": {
|
|
30
|
+
"data": encoded_payload,
|
|
31
|
+
"messageId": "test-message-id",
|
|
32
|
+
"publishTime": "2026-05-12T10:00:00Z",
|
|
33
|
+
},
|
|
34
|
+
"subscription": "projects/test-project/subscriptions/test-subscription",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return self.request_factory.post(
|
|
38
|
+
"/pubsub/",
|
|
39
|
+
data=json.dumps(envelope),
|
|
40
|
+
content_type="application/json",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
@patch("django_tasks_pubsub.views.dispatch")
|
|
44
|
+
def test_pubsub_view_calls_dispatch_with_expected_payload(self, mocked_dispatch):
|
|
45
|
+
payload = {
|
|
46
|
+
"task": {
|
|
47
|
+
"backend": "default",
|
|
48
|
+
"module_path": "django_tasks_pubsub.tests.test_pubsub_view",
|
|
49
|
+
"name": "sample_task",
|
|
50
|
+
"priority": 0,
|
|
51
|
+
"queue_name": "default",
|
|
52
|
+
"takes_context": False,
|
|
53
|
+
},
|
|
54
|
+
"task_id": "test-task-id",
|
|
55
|
+
"enqueued_at": "2026-05-12T10:00:00+00:00",
|
|
56
|
+
"args": [
|
|
57
|
+
"spec_arg",
|
|
58
|
+
"arg1",
|
|
59
|
+
"arg2",
|
|
60
|
+
],
|
|
61
|
+
"kwargs": {
|
|
62
|
+
"kwarg1": "val1",
|
|
63
|
+
"kwarg2": "val2",
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
request = self.build_pubsub_push_request(payload)
|
|
68
|
+
|
|
69
|
+
response = PubSubPushView.as_view()(request)
|
|
70
|
+
|
|
71
|
+
self.assertEqual(response.status_code, 204)
|
|
72
|
+
|
|
73
|
+
with self.subTest("dispatch is called once"):
|
|
74
|
+
mocked_dispatch.assert_called_once()
|
|
75
|
+
|
|
76
|
+
with self.subTest("dispatch is called with payload object"):
|
|
77
|
+
mocked_dispatch.assert_called_once_with(
|
|
78
|
+
payload=Payload(
|
|
79
|
+
task=TaskPayload(**payload.pop("task")),
|
|
80
|
+
**payload,
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def test_pubsub_view_calls_actual_task_function_with_expected_arguments(self):
|
|
85
|
+
payload = {
|
|
86
|
+
"task": {
|
|
87
|
+
"backend": "default",
|
|
88
|
+
"module_path": "tests.test_pubsub_view",
|
|
89
|
+
"name": "sample_task_function",
|
|
90
|
+
"priority": 0,
|
|
91
|
+
"queue_name": "default",
|
|
92
|
+
"takes_context": False,
|
|
93
|
+
},
|
|
94
|
+
"task_id": "test-task-id",
|
|
95
|
+
"enqueued_at": "2026-05-12T10:00:00+00:00",
|
|
96
|
+
"args": [
|
|
97
|
+
"spec_arg",
|
|
98
|
+
"arg1",
|
|
99
|
+
"arg2",
|
|
100
|
+
],
|
|
101
|
+
"kwargs": {
|
|
102
|
+
"kwarg1": "val1",
|
|
103
|
+
"kwarg2": "val2",
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
request = self.build_pubsub_push_request(payload)
|
|
108
|
+
|
|
109
|
+
response = PubSubPushView.as_view()(request)
|
|
110
|
+
|
|
111
|
+
self.assertEqual(response.status_code, 204)
|
|
112
|
+
|
|
113
|
+
with self.subTest("actual task function is called with expected arguments"):
|
|
114
|
+
sample_task_function_mock.assert_called_once_with(
|
|
115
|
+
"spec_arg",
|
|
116
|
+
"arg1",
|
|
117
|
+
"arg2",
|
|
118
|
+
kwarg1="val1",
|
|
119
|
+
kwarg2="val2",
|
|
120
|
+
)
|