django-tasks-pubsub 1.3.2__py3-none-any.whl
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/__init__.py +7 -0
- django_tasks_pubsub/backend.py +110 -0
- django_tasks_pubsub/dispatcher.py +54 -0
- django_tasks_pubsub/publisher.py +14 -0
- django_tasks_pubsub/pubsub_task_decorator.py +19 -0
- django_tasks_pubsub/views.py +71 -0
- django_tasks_pubsub-1.3.2.dist-info/METADATA +10 -0
- django_tasks_pubsub-1.3.2.dist-info/RECORD +10 -0
- django_tasks_pubsub-1.3.2.dist-info/WHEEL +5 -0
- django_tasks_pubsub-1.3.2.dist-info/top_level.txt +1 -0
|
@@ -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: 1.3.2
|
|
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,10 @@
|
|
|
1
|
+
django_tasks_pubsub/__init__.py,sha256=ahTeEplIae_GaOfv7P4BbOaOmvGdz6svdxhjXlA2oac,137
|
|
2
|
+
django_tasks_pubsub/backend.py,sha256=y3opKnxoWDjgn9DVvvGoG9Nh7p-LuM0UpjxInhLryMI,2868
|
|
3
|
+
django_tasks_pubsub/dispatcher.py,sha256=JcXR7_rHP0y0kFpv31ZNxrU7lKHl6Gb_kVr5c0zFyys,1583
|
|
4
|
+
django_tasks_pubsub/publisher.py,sha256=BFRP_GLSnOanXOBTjPfiG5WQlHyIy-9W0peyYrbT73U,323
|
|
5
|
+
django_tasks_pubsub/pubsub_task_decorator.py,sha256=fnaDEjcPssBkfs2AEmNyfBul5KfANn2EfQt9RrXWUwk,423
|
|
6
|
+
django_tasks_pubsub/views.py,sha256=Auhz_I30RGwNAz_nv4ZMHGoC9WAraFCbpTnB44bbCmY,2283
|
|
7
|
+
django_tasks_pubsub-1.3.2.dist-info/METADATA,sha256=wvc_nDJRbraFDVuttO2uxDf2csbernvbRSMEq5CluZg,269
|
|
8
|
+
django_tasks_pubsub-1.3.2.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
9
|
+
django_tasks_pubsub-1.3.2.dist-info/top_level.txt,sha256=pPege62SzuwnEUOVf67AYjKqoPZJAZqDn5Q0R3DkOCM,20
|
|
10
|
+
django_tasks_pubsub-1.3.2.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
django_tasks_pubsub
|