dramatiq-workflow 0.1.0__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.
@@ -0,0 +1,13 @@
1
+ from ._base import Workflow
2
+ from ._middleware import WorkflowMiddleware
3
+ from ._models import Chain, Group, Message, WithDelay, WorkflowType
4
+
5
+ __all__ = [
6
+ "Chain",
7
+ "Group",
8
+ "Message",
9
+ "WithDelay",
10
+ "Workflow",
11
+ "WorkflowMiddleware",
12
+ "WorkflowType",
13
+ ]
@@ -0,0 +1,38 @@
1
+ import dramatiq.rate_limits
2
+
3
+
4
+ class AtMostOnceBarrier(dramatiq.rate_limits.Barrier):
5
+ """
6
+ The AtMostOnceBarrier is a barrier that ensures that it is released at most
7
+ once.
8
+
9
+ We use this because we want to avoid running callbacks in chains multiple
10
+ times. Running callbacks more than once can have compounding effects
11
+ especially when groups are involved.
12
+
13
+ The downside of this is that we cannot guarantee that the barrier will be
14
+ released at all. Theoretically a worker could die after releasing the
15
+ barrier but just before it has a chance to schedule the callbacks.
16
+ """
17
+
18
+ def __init__(self, backend, key, *args, ttl=900000):
19
+ super().__init__(backend, key, *args, ttl=ttl)
20
+ self.ran_key = f"{key}_ran"
21
+
22
+ def create(self, parties):
23
+ self.backend.add(self.ran_key, 0, self.ttl)
24
+ return super().create(parties)
25
+
26
+ def wait(self, *args, block=True, timeout=None):
27
+ if block:
28
+ # Blocking with an AtMostOnceBarrier is not supported as it could
29
+ # lead to clients waiting indefinitely if the barrier already
30
+ # released.
31
+ raise ValueError("Blocking is not supported by AtMostOnceBarrier")
32
+
33
+ released = super().wait(*args, block=False)
34
+ if released:
35
+ never_released = self.backend.incr(self.ran_key, 1, 1, self.ttl)
36
+ return never_released
37
+
38
+ return False
@@ -0,0 +1,185 @@
1
+ import logging
2
+ import time
3
+ from uuid import uuid4
4
+
5
+ import dramatiq
6
+ import dramatiq.rate_limits
7
+
8
+ from ._constants import CALLBACK_BARRIER_TTL, OPTION_KEY_CALLBACKS
9
+ from ._helpers import workflow_with_completion_callbacks
10
+ from ._middleware import WorkflowMiddleware, workflow_noop
11
+ from ._models import Barrier, Chain, CompletionCallbacks, Group, Message, WithDelay, WorkflowType
12
+ from ._serialize import serialize_callbacks, serialize_workflow
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class Workflow:
18
+ """
19
+ A workflow allows running tasks in parallel and in sequence. It is a way to
20
+ define a workflow of tasks, a combination of chains and groups in any
21
+ order and nested as needed.
22
+
23
+ Example:
24
+
25
+ Let's assume we want a workflow that looks like this:
26
+
27
+ ╭────────╮ ╭────────╮
28
+ │ Task 2 │ │ Task 5 │
29
+ ╭──┼● ●┼──┼● ●┼╮
30
+ ╭────────╮│ ╰────────╯ ╰────────╯│ ╭────────╮
31
+ │ Task 1 ││ ╭────────╮ │ │ Task 8 │
32
+ │ ●┼╯ │ Task 3 │ ╰──┼● │
33
+ │ ●┼───┼● ●┼───────────────┼● │
34
+ │ ●┼╮ ╰────────╯ ╭─┼● │
35
+ ╰────────╯│ ╭────────╮ ╭────────╮│╭┼● │
36
+ │ │ Task 4 │ │ Task 6 │││╰────────╯
37
+ ╰──┼● ●┼───┼● ●┼╯│
38
+ │ ●┼╮ ╰────────╯ │
39
+ ╰────────╯│ │
40
+ │ ╭────────╮ │
41
+ │ │ Task 7 │ │
42
+ ╰──┼● ●┼─╯
43
+ ╰────────╯
44
+
45
+ We can define this workflow as follows:
46
+
47
+ ```python
48
+ from dramatiq_workflow import Workflow, Chain, Group
49
+
50
+ workflow = Workflow(
51
+ Chain(
52
+ task1.message(),
53
+ Group(
54
+ Chain(
55
+ task2.message(),
56
+ task5.message(),
57
+ ),
58
+ task3.message(),
59
+ Chain(
60
+ task4.message(),
61
+ Group(
62
+ task6.message(),
63
+ task7.message(),
64
+ ),
65
+ ),
66
+ ),
67
+ task8.message(),
68
+ ),
69
+ )
70
+ workflow.run() # Schedules the workflow to run in the background
71
+ ```
72
+
73
+ In this example, the execution would look like this*:
74
+ 1. Task 1 runs
75
+ 2. Task 2, 3, and 4 run in parallel once Task 1 finishes
76
+ 3. Task 5 runs once Task 2 finishes
77
+ 4. Task 6 and 7 run in parallel once Task 4 finishes
78
+ 5. Task 8 runs once Task 5, 6, and 7 finish
79
+
80
+ * This is a simplified example. The actual execution order may vary because
81
+ tasks that can run in parallel (i.e. in a Group) are not guaranteed to run
82
+ in the order they are defined in the workflow.
83
+ """
84
+
85
+ def __init__(
86
+ self,
87
+ workflow: WorkflowType,
88
+ broker: dramatiq.Broker | None = None,
89
+ ):
90
+ self.workflow = workflow
91
+ self.broker = broker or dramatiq.get_broker()
92
+
93
+ self._delay = None
94
+ self._completion_callbacks = []
95
+
96
+ while isinstance(self.workflow, WithDelay):
97
+ self._delay = (self._delay or 0) + self.workflow.delay
98
+ self.workflow = self.workflow.task
99
+
100
+ def run(self):
101
+ current = self.workflow
102
+ completion_callbacks = self._completion_callbacks.copy()
103
+
104
+ if isinstance(current, Message):
105
+ current = self.__augment_message(current, completion_callbacks)
106
+ self.broker.enqueue(current, delay=self._delay)
107
+ return
108
+
109
+ if isinstance(current, Chain):
110
+ tasks = current.tasks[:]
111
+ if not tasks:
112
+ self.__schedule_noop(completion_callbacks)
113
+ return
114
+
115
+ task = tasks.pop(0)
116
+ if tasks:
117
+ completion_id = self.__create_barrier(1)
118
+ completion_callbacks.append((completion_id, Chain(*tasks), False))
119
+ self.__workflow_with_completion_callbacks(task, completion_callbacks).run()
120
+ return
121
+
122
+ if isinstance(current, Group):
123
+ tasks = current.tasks[:]
124
+ if not tasks:
125
+ self.__schedule_noop(completion_callbacks)
126
+ return
127
+
128
+ completion_id = self.__create_barrier(len(tasks))
129
+ completion_callbacks.append((completion_id, None, True))
130
+ for task in tasks:
131
+ self.__workflow_with_completion_callbacks(task, completion_callbacks).run()
132
+ return
133
+
134
+ raise TypeError(f"Unsupported workflow type: {type(current)}")
135
+
136
+ def __workflow_with_completion_callbacks(self, task, completion_callbacks) -> "Workflow":
137
+ return workflow_with_completion_callbacks(
138
+ task,
139
+ self.broker,
140
+ completion_callbacks,
141
+ delay=self._delay,
142
+ )
143
+
144
+ def __schedule_noop(self, completion_callbacks: CompletionCallbacks):
145
+ noop_message = workflow_noop.message()
146
+ noop_message = self.__augment_message(noop_message, completion_callbacks)
147
+ self.broker.enqueue(noop_message, delay=self._delay)
148
+
149
+ def __augment_message(self, message: Message, completion_callbacks: CompletionCallbacks) -> Message:
150
+ return message.copy(
151
+ # We reset the message timestamp to better represent the time the
152
+ # message was actually enqueued. This is to avoid tripping the max_age
153
+ # check in the broker.
154
+ message_timestamp=time.time() * 1000,
155
+ options={OPTION_KEY_CALLBACKS: serialize_callbacks(completion_callbacks)},
156
+ )
157
+
158
+ @property
159
+ def __rate_limiter_backend(self):
160
+ if not hasattr(self, "__cached_rate_limiter_backend"):
161
+ for middleware in self.broker.middleware:
162
+ if isinstance(middleware, WorkflowMiddleware):
163
+ self.__cached_rate_limiter_backend = middleware.rate_limiter_backend
164
+ break
165
+ else:
166
+ raise RuntimeError(
167
+ "WorkflowMiddleware middleware not found! Did you forget "
168
+ "to set it up? It is required if you want to use "
169
+ "workflows."
170
+ )
171
+ return self.__cached_rate_limiter_backend
172
+
173
+ def __create_barrier(self, count: int):
174
+ if count == 1:
175
+ # No need to create a distributed barrier if there is only one task
176
+ return None
177
+
178
+ completion_uuid = str(uuid4())
179
+ completion_barrier = Barrier(self.__rate_limiter_backend, completion_uuid, ttl=CALLBACK_BARRIER_TTL)
180
+ completion_barrier.create(count)
181
+ logger.debug("Barrier created: %s (%d tasks)", completion_uuid, count)
182
+ return completion_uuid
183
+
184
+ def __str__(self):
185
+ return f"Workflow({serialize_workflow(self.workflow)})"
@@ -0,0 +1,2 @@
1
+ CALLBACK_BARRIER_TTL = 86_400_000
2
+ OPTION_KEY_CALLBACKS = "workflow_completion_callbacks"
@@ -0,0 +1,18 @@
1
+ import dramatiq
2
+
3
+ from ._models import CompletionCallbacks, WorkflowType
4
+
5
+
6
+ def workflow_with_completion_callbacks(
7
+ workflow: WorkflowType,
8
+ broker: dramatiq.Broker,
9
+ completion_callbacks: CompletionCallbacks,
10
+ delay: int | None = None,
11
+ ):
12
+ from ._base import Workflow
13
+
14
+ w = Workflow(workflow, broker)
15
+ w._completion_callbacks = completion_callbacks
16
+ if delay is not None:
17
+ w._delay = (w._delay or 0) + delay
18
+ return w
@@ -0,0 +1,67 @@
1
+ import logging
2
+
3
+ import dramatiq
4
+ import dramatiq.rate_limits
5
+
6
+ from ._constants import OPTION_KEY_CALLBACKS
7
+ from ._helpers import workflow_with_completion_callbacks
8
+ from ._models import Barrier
9
+ from ._serialize import unserialize_callbacks, unserialize_workflow
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class WorkflowMiddleware(dramatiq.Middleware):
15
+ def __init__(self, rate_limiter_backend: dramatiq.rate_limits.RateLimiterBackend):
16
+ self.rate_limiter_backend = rate_limiter_backend
17
+
18
+ def after_process_boot(self, broker: dramatiq.Broker):
19
+ broker.declare_actor(workflow_noop)
20
+
21
+ def after_process_message(
22
+ self, broker: dramatiq.Broker, message: dramatiq.broker.MessageProxy, *, result=None, exception=None
23
+ ):
24
+ if exception is not None:
25
+ # TODO: Add a way to handle exceptions in the workflow?
26
+ return
27
+
28
+ if message.failed:
29
+ return
30
+
31
+ completion_callbacks: list[dict] | None = message.options.get(OPTION_KEY_CALLBACKS)
32
+ if completion_callbacks is None:
33
+ return
34
+
35
+ # Go through the completion callbacks backwards until we hit the first non-completed barrier
36
+ while len(completion_callbacks) > 0:
37
+ completion_id, remaining_workflow, propagate = completion_callbacks[-1]
38
+ if completion_id is not None:
39
+ barrier = Barrier(self.rate_limiter_backend, completion_id)
40
+ if not barrier.wait(block=False):
41
+ logger.debug("Barrier not completed: %s", completion_id)
42
+ break
43
+
44
+ logger.debug("Barrier completed: %s", completion_id)
45
+ completion_callbacks.pop()
46
+ if remaining_workflow is None:
47
+ continue
48
+ workflow_with_completion_callbacks(
49
+ unserialize_workflow(remaining_workflow),
50
+ broker,
51
+ # TODO: This is somewhat inefficient because we're unserializing all callbacks
52
+ # even though we are just going to serialize them again.
53
+ unserialize_callbacks(completion_callbacks),
54
+ ).run()
55
+
56
+ if not propagate:
57
+ break
58
+
59
+
60
+ @dramatiq.actor
61
+ def workflow_noop():
62
+ """
63
+ This task does nothing. It is used as a placeholder when a Group or a Chain
64
+ has no tasks. This allows us to schedule the execution of the completion
65
+ callbacks via the WorkflowMiddleware.
66
+ """
67
+ pass
@@ -0,0 +1,47 @@
1
+ import dramatiq
2
+ import dramatiq.rate_limits
3
+
4
+ from ._barrier import AtMostOnceBarrier
5
+
6
+
7
+ class Chain:
8
+ def __init__(self, *tasks: "WorkflowType"):
9
+ self.tasks = list(tasks)
10
+
11
+ def __str__(self):
12
+ return f"Chain({self.tasks})"
13
+
14
+ def __eq__(self, other):
15
+ return isinstance(other, Chain) and self.tasks == other.tasks
16
+
17
+
18
+ class Group:
19
+ def __init__(self, *tasks: "WorkflowType"):
20
+ self.tasks = list(tasks)
21
+
22
+ def __str__(self):
23
+ return f"Group({self.tasks})"
24
+
25
+ def __eq__(self, other):
26
+ return isinstance(other, Group) and self.tasks == other.tasks
27
+
28
+
29
+ class WithDelay:
30
+ def __init__(self, task: "WorkflowType", delay: int):
31
+ self.task = task
32
+ self.delay = delay
33
+
34
+ def __str__(self):
35
+ return f"WithDelay({self.task}, {self.delay})"
36
+
37
+ def __eq__(self, other):
38
+ return isinstance(other, WithDelay) and self.task == other.task and self.delay == other.delay
39
+
40
+
41
+ Barrier = AtMostOnceBarrier
42
+ Message = dramatiq.Message
43
+ WorkflowType = Message | Chain | Group | WithDelay
44
+
45
+ # CompletionCallback is a tuple containing: ID, WorkflowType, propagate
46
+ CompletionCallback = tuple[str, WorkflowType | None, bool]
47
+ CompletionCallbacks = list[CompletionCallback]
@@ -0,0 +1,79 @@
1
+ import typing
2
+
3
+ from ._models import Chain, CompletionCallbacks, Group, Message, WithDelay, WorkflowType
4
+
5
+
6
+ def serialize_workflow(workflow: WorkflowType | None) -> typing.Any:
7
+ """
8
+ Return a serialized version of the workflow that can be JSON-encoded.
9
+ """
10
+ if workflow is None:
11
+ return None
12
+
13
+ if isinstance(workflow, Message):
14
+ return {
15
+ "__type__": "message",
16
+ **workflow.asdict(),
17
+ }
18
+ if isinstance(workflow, Chain):
19
+ return {
20
+ "__type__": "chain",
21
+ "children": [serialize_workflow(task) for task in workflow.tasks],
22
+ }
23
+ if isinstance(workflow, Group):
24
+ return {
25
+ "__type__": "group",
26
+ "children": [serialize_workflow(task) for task in workflow.tasks],
27
+ }
28
+ if isinstance(workflow, WithDelay):
29
+ return {
30
+ "__type__": "with_delay",
31
+ "delay": workflow.delay,
32
+ "task": serialize_workflow(workflow.task),
33
+ }
34
+
35
+ # NOTE: This line is unreachable by design but I don't know how to tell
36
+ # Pyright that.
37
+ raise TypeError(f"Unsupported workflow type: {type(workflow)}")
38
+
39
+
40
+ def unserialize_workflow(workflow: typing.Any) -> WorkflowType:
41
+ """
42
+ Return an unserialized version of the workflow that can be used to create
43
+ a Workflow instance.
44
+ """
45
+ if workflow is None:
46
+ raise ValueError("Cannot unserialize None")
47
+
48
+ if not isinstance(workflow, dict):
49
+ raise TypeError(f"Unsupported data type: {type(workflow)}")
50
+
51
+ workflow_type = workflow.pop("__type__")
52
+ match workflow_type:
53
+ case "message":
54
+ return Message(**workflow)
55
+ case "chain":
56
+ return Chain(*[unserialize_workflow(task) for task in workflow["children"]])
57
+ case "group":
58
+ return Group(*[unserialize_workflow(task) for task in workflow["children"]])
59
+ case "with_delay":
60
+ return WithDelay(
61
+ task=unserialize_workflow(workflow["task"]),
62
+ delay=workflow["delay"],
63
+ )
64
+
65
+ raise TypeError(f"Unsupported workflow type: {workflow_type}")
66
+
67
+
68
+ def unserialize_workflow_or_none(workflow: typing.Any) -> WorkflowType | None:
69
+ if workflow is None:
70
+ return None
71
+ return unserialize_workflow(workflow)
72
+
73
+
74
+ def serialize_callbacks(callbacks: CompletionCallbacks) -> list[tuple[str, typing.Any, bool]]:
75
+ return [(id, serialize_workflow(g), propagate) for id, g, propagate in callbacks]
76
+
77
+
78
+ def unserialize_callbacks(callbacks: list[dict]) -> CompletionCallbacks:
79
+ return [(id, unserialize_workflow_or_none(g), propagate) for id, g, propagate in callbacks]
File without changes
@@ -0,0 +1,383 @@
1
+ import unittest
2
+ from unittest import mock
3
+
4
+ import dramatiq
5
+
6
+ from .. import Chain, Group, WithDelay, Workflow, WorkflowMiddleware
7
+ from .._serialize import serialize_workflow, unserialize_workflow
8
+
9
+
10
+ class WorkflowTests(unittest.TestCase):
11
+ def setUp(self):
12
+ self.broker = mock.MagicMock(middleware=[WorkflowMiddleware(mock.MagicMock())])
13
+ self.task = mock.MagicMock()
14
+ self.task.message.side_effect = lambda *args, **kwargs: self.__make_message(
15
+ self.__generate_id(), *args, **kwargs
16
+ )
17
+ self.message_ids = []
18
+ self.message_timestamp = 1717526084640
19
+
20
+ def __generate_id(self):
21
+ current_id = len(self.message_ids) + 1
22
+ self.message_ids.append(current_id)
23
+ return current_id
24
+
25
+ def __make_message(self, message_id, *args, message_options={}, message_timestamp=None, **kwargs):
26
+ return dramatiq.Message(
27
+ message_id=message_id,
28
+ message_timestamp=message_timestamp or self.message_timestamp,
29
+ queue_name="default",
30
+ actor_name="task",
31
+ args=args,
32
+ kwargs=kwargs,
33
+ options=message_options,
34
+ )
35
+
36
+ @mock.patch("dramatiq_workflow._base.time.time")
37
+ def test_simple_workflow(self, time_mock):
38
+ time_mock.return_value = 1717526000.12
39
+ updated_timestamp = time_mock.return_value * 1000
40
+ workflow = Workflow(
41
+ Chain(
42
+ self.task.message(),
43
+ Group(
44
+ Chain(
45
+ self.task.message(),
46
+ self.task.message(),
47
+ ),
48
+ self.task.message(),
49
+ Chain(
50
+ self.task.message(),
51
+ Group(
52
+ self.task.message(),
53
+ self.task.message(),
54
+ ),
55
+ ),
56
+ ),
57
+ Chain(),
58
+ self.task.message(),
59
+ ),
60
+ broker=self.broker,
61
+ )
62
+ workflow.run()
63
+
64
+ time_mock.assert_called_once()
65
+
66
+ self.broker.enqueue.assert_called_once_with(
67
+ self.__make_message(
68
+ 1,
69
+ message_timestamp=updated_timestamp,
70
+ message_options={
71
+ "workflow_completion_callbacks": [
72
+ (
73
+ None,
74
+ {
75
+ "__type__": "chain",
76
+ "children": [
77
+ {
78
+ "__type__": "group",
79
+ "children": [
80
+ {
81
+ "__type__": "chain",
82
+ "children": [
83
+ {
84
+ "__type__": "message",
85
+ "queue_name": "default",
86
+ "actor_name": "task",
87
+ "args": (),
88
+ "kwargs": {},
89
+ "options": {},
90
+ "message_id": 2,
91
+ "message_timestamp": self.message_timestamp,
92
+ },
93
+ {
94
+ "__type__": "message",
95
+ "queue_name": "default",
96
+ "actor_name": "task",
97
+ "args": (),
98
+ "kwargs": {},
99
+ "options": {},
100
+ "message_id": 3,
101
+ "message_timestamp": self.message_timestamp,
102
+ },
103
+ ],
104
+ },
105
+ {
106
+ "__type__": "message",
107
+ "queue_name": "default",
108
+ "actor_name": "task",
109
+ "args": (),
110
+ "kwargs": {},
111
+ "options": {},
112
+ "message_id": 4,
113
+ "message_timestamp": self.message_timestamp,
114
+ },
115
+ {
116
+ "__type__": "chain",
117
+ "children": [
118
+ {
119
+ "__type__": "message",
120
+ "queue_name": "default",
121
+ "actor_name": "task",
122
+ "args": (),
123
+ "kwargs": {},
124
+ "options": {},
125
+ "message_id": 5,
126
+ "message_timestamp": self.message_timestamp,
127
+ },
128
+ {
129
+ "__type__": "group",
130
+ "children": [
131
+ {
132
+ "__type__": "message",
133
+ "queue_name": "default",
134
+ "actor_name": "task",
135
+ "args": (),
136
+ "kwargs": {},
137
+ "options": {},
138
+ "message_id": 6,
139
+ "message_timestamp": self.message_timestamp,
140
+ },
141
+ {
142
+ "__type__": "message",
143
+ "queue_name": "default",
144
+ "actor_name": "task",
145
+ "args": (),
146
+ "kwargs": {},
147
+ "options": {},
148
+ "message_id": 7,
149
+ "message_timestamp": self.message_timestamp,
150
+ },
151
+ ],
152
+ },
153
+ ],
154
+ },
155
+ ],
156
+ },
157
+ {"__type__": "chain", "children": []},
158
+ {
159
+ "__type__": "message",
160
+ "queue_name": "default",
161
+ "actor_name": "task",
162
+ "args": (),
163
+ "kwargs": {},
164
+ "options": {},
165
+ "message_id": 8,
166
+ "message_timestamp": self.message_timestamp,
167
+ },
168
+ ],
169
+ },
170
+ False,
171
+ )
172
+ ]
173
+ },
174
+ ),
175
+ delay=None,
176
+ )
177
+
178
+ @mock.patch("dramatiq_workflow._base.time.time")
179
+ def test_noop_workflow(self, time_mock):
180
+ time_mock.return_value = 1717526000.12
181
+ workflow = Workflow(Chain(), broker=self.broker)
182
+ workflow.run()
183
+
184
+ self.broker.enqueue.assert_called_once()
185
+
186
+ def test_unsupported_workflow(self):
187
+ with self.assertRaises(TypeError):
188
+ Workflow(1).run() # type: ignore
189
+
190
+ @mock.patch("dramatiq_workflow._base.time.time")
191
+ def test_chain_with_delay(self, time_mock):
192
+ time_mock.return_value = 1717526000.12
193
+ updated_timestamp = time_mock.return_value * 1000
194
+ workflow = Workflow(
195
+ WithDelay(
196
+ Chain(self.task.message(), self.task.message()),
197
+ delay=10,
198
+ ),
199
+ broker=self.broker,
200
+ )
201
+ workflow.run()
202
+
203
+ self.broker.enqueue.assert_called_once_with(
204
+ self.__make_message(
205
+ 1,
206
+ message_timestamp=updated_timestamp,
207
+ message_options={
208
+ "workflow_completion_callbacks": [
209
+ (
210
+ None,
211
+ {
212
+ "__type__": "chain",
213
+ "children": [
214
+ {
215
+ "__type__": "message",
216
+ "queue_name": "default",
217
+ "actor_name": "task",
218
+ "args": (),
219
+ "kwargs": {},
220
+ "options": {},
221
+ "message_id": 2,
222
+ "message_timestamp": self.message_timestamp,
223
+ },
224
+ ],
225
+ },
226
+ False,
227
+ )
228
+ ]
229
+ },
230
+ ),
231
+ delay=10,
232
+ )
233
+
234
+ @mock.patch("dramatiq_workflow._base.time.time")
235
+ @mock.patch("dramatiq_workflow._base.Barrier")
236
+ def test_group_with_delay(self, barrier_mock, time_mock):
237
+ time_mock.return_value = 1717526000.12
238
+ updated_timestamp = time_mock.return_value * 1000
239
+ workflow = Workflow(
240
+ WithDelay(
241
+ Group(self.task.message(), self.task.message()),
242
+ delay=10,
243
+ ),
244
+ broker=self.broker,
245
+ )
246
+ workflow.run()
247
+ self.broker.enqueue.assert_has_calls(
248
+ [
249
+ mock.call(
250
+ self.__make_message(
251
+ 1,
252
+ message_timestamp=updated_timestamp,
253
+ message_options={
254
+ "workflow_completion_callbacks": [
255
+ (
256
+ mock.ANY, # Accept any callback ID
257
+ None, # No more workflow to run after this
258
+ True,
259
+ )
260
+ ]
261
+ },
262
+ ),
263
+ delay=10,
264
+ ),
265
+ mock.call(
266
+ self.__make_message(
267
+ 2,
268
+ message_timestamp=updated_timestamp,
269
+ message_options={
270
+ "workflow_completion_callbacks": [
271
+ (
272
+ mock.ANY,
273
+ None,
274
+ True,
275
+ )
276
+ ]
277
+ },
278
+ ),
279
+ delay=10,
280
+ ),
281
+ ],
282
+ any_order=True,
283
+ )
284
+
285
+ def test_serialize_unserialize(self):
286
+ workflow = Workflow(
287
+ Chain(
288
+ self.task.message(),
289
+ WithDelay(
290
+ Group(
291
+ self.task.message(),
292
+ self.task.message(),
293
+ ),
294
+ delay=10,
295
+ ),
296
+ ),
297
+ )
298
+
299
+ serialized = serialize_workflow(workflow.workflow)
300
+ unserialized = unserialize_workflow(serialized)
301
+ self.assertEqual(workflow.workflow, unserialized)
302
+
303
+ @mock.patch("dramatiq_workflow._base.time.time")
304
+ @mock.patch("dramatiq_workflow._base.Barrier")
305
+ def test_additive_delays(self, barrier_mock, time_mock):
306
+ time_mock.return_value = 1717526000.12
307
+ updated_timestamp = time_mock.return_value * 1000
308
+ workflow = Workflow(
309
+ WithDelay(
310
+ Group(
311
+ self.task.message(),
312
+ WithDelay(
313
+ self.task.message(),
314
+ delay=10,
315
+ ),
316
+ ),
317
+ delay=10,
318
+ ),
319
+ broker=self.broker,
320
+ )
321
+ workflow.run()
322
+ self.broker.enqueue.assert_has_calls(
323
+ [
324
+ mock.call(
325
+ self.__make_message(
326
+ 1,
327
+ message_timestamp=updated_timestamp,
328
+ message_options={
329
+ "workflow_completion_callbacks": [
330
+ (
331
+ mock.ANY, # Accept any callback ID
332
+ None, # No more workflow to run after this
333
+ True,
334
+ )
335
+ ]
336
+ },
337
+ ),
338
+ delay=10,
339
+ ),
340
+ mock.call(
341
+ self.__make_message(
342
+ 2,
343
+ message_timestamp=updated_timestamp,
344
+ message_options={
345
+ "workflow_completion_callbacks": [
346
+ (
347
+ mock.ANY,
348
+ None,
349
+ True,
350
+ )
351
+ ]
352
+ },
353
+ ),
354
+ delay=20,
355
+ ),
356
+ ],
357
+ any_order=True,
358
+ )
359
+
360
+ @mock.patch("dramatiq_workflow._base.time.time")
361
+ def test_nested_delays(self, time_mock):
362
+ time_mock.return_value = 1717526000.12
363
+ updated_timestamp = time_mock.return_value * 1000
364
+ workflow = Workflow(
365
+ WithDelay(
366
+ WithDelay(
367
+ self.task.message(),
368
+ delay=10,
369
+ ),
370
+ delay=10,
371
+ ),
372
+ broker=self.broker,
373
+ )
374
+
375
+ workflow.run()
376
+ self.broker.enqueue.assert_called_once_with(
377
+ self.__make_message(
378
+ 1,
379
+ message_timestamp=updated_timestamp,
380
+ message_options={"workflow_completion_callbacks": []},
381
+ ),
382
+ delay=20,
383
+ )
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Parnassus Labs, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,111 @@
1
+ Metadata-Version: 2.1
2
+ Name: dramatiq-workflow
3
+ Version: 0.1.0
4
+ Summary: A library for running workflows (chains and groups of tasks) using the Python background task processing library dramatiq.
5
+ Home-page: https://github.com/Outset-AI/dramatiq-workflow
6
+ Author: Outset
7
+ Author-email: engineering@outset.ai
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.10
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: dramatiq>=1.10.0
15
+
16
+ # dramatiq-workflow
17
+
18
+ `dramatiq-workflow` allows running workflows (chains and groups of tasks) using
19
+ the Python background task processing library [dramatiq](https://dramatiq.io/).
20
+
21
+ A workflow allows running tasks in parallel and in sequence. It is a way to
22
+ define a workflow of tasks, a combination of chains and groups in any order and
23
+ nested as needed.
24
+
25
+ ## Features
26
+
27
+ - Define workflows with tasks running in parallel and in sequence.
28
+ - Nest chains and groups of tasks to create complex workflows.
29
+ - Schedules workflows to run in the background using dramatiq.
30
+
31
+ **Note:** `dramatiq-workflow` does not support passing the results from one task
32
+ to the next one in a chain. We recommend using a database to store intermediate
33
+ results if needed.
34
+
35
+ ## Installation
36
+
37
+ You can install `dramatiq-workflow` from PyPI:
38
+
39
+ ```sh
40
+ pip install dramatiq-workflow
41
+ ```
42
+
43
+ ## Example
44
+
45
+ Let's assume we want a workflow that looks like this:
46
+
47
+ ```text
48
+ ╭────────╮ ╭────────╮
49
+ │ Task 2 │ │ Task 5 │
50
+ ╭──┼● ●┼──┼● ●┼╮
51
+ ╭────────╮│ ╰────────╯ ╰────────╯│ ╭────────╮
52
+ │ Task 1 ││ ╭────────╮ │ │ Task 8 │
53
+ │ ●┼╯ │ Task 3 │ ╰──┼● │
54
+ │ ●┼───┼● ●┼───────────────┼● │
55
+ │ ●┼╮ ╰────────╯ ╭─┼● │
56
+ ╰────────╯│ ╭────────╮ ╭────────╮│╭┼● │
57
+ │ │ Task 4 │ │ Task 6 │││╰────────╯
58
+ ╰──┼● ●┼───┼● ●┼╯│
59
+ │ ●┼╮ ╰────────╯ │
60
+ ╰────────╯│ │
61
+ │ ╭────────╮ │
62
+ │ │ Task 7 │ │
63
+ ╰──┼● ●┼─╯
64
+ ╰────────╯
65
+ ```
66
+
67
+ We can define this workflow as follows:
68
+
69
+ ```python
70
+ from dramatiq_workflow import Workflow, Chain, Group
71
+
72
+ workflow = Workflow(
73
+ Chain(
74
+ task1.message(),
75
+ Group(
76
+ Chain(
77
+ task2.message(),
78
+ task5.message(),
79
+ ),
80
+ task3.message(),
81
+ Chain(
82
+ task4.message(),
83
+ Group(
84
+ task6.message(),
85
+ task7.message(),
86
+ ),
87
+ ),
88
+ ),
89
+ task8.message(),
90
+ ),
91
+ )
92
+ workflow.run() # Schedules the workflow to run in the background
93
+ ```
94
+
95
+ ### Execution Order
96
+
97
+ In this example, the execution would look like this:
98
+
99
+ 1. Task 1 runs
100
+ 2. Task 2, 3, and 4 run in parallel once Task 1 finishes
101
+ 3. Task 5 runs once Task 2 finishes
102
+ 4. Task 6 and 7 run in parallel once Task 4 finishes
103
+ 5. Task 8 runs once Task 5, 6, and 7 finish
104
+
105
+ *This is a simplified example. The actual execution order may vary because
106
+ tasks that can run in parallel (i.e., in a Group) are not guaranteed to run in
107
+ the order they are defined in the workflow.*
108
+
109
+ ## License
110
+
111
+ This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
@@ -0,0 +1,15 @@
1
+ dramatiq_workflow/__init__.py,sha256=Dz9OGbZyGefD04-5afcESdjGZqv3mYtf3KEdjkTUOFU,275
2
+ dramatiq_workflow/_barrier.py,sha256=fprBihVkXPdmK-W-KWikFuDw5sAtHgjbkeBXV27cZy4,1414
3
+ dramatiq_workflow/_base.py,sha256=Y5tcG-Ke5eqxce-izmbC3he2Kxd8Ye0fxG4RRgtQIJo,7329
4
+ dramatiq_workflow/_constants.py,sha256=rQ08oG6jiZ0-cvFn7H7nU7hxH53r-9zqjBwGK5dZ7zc,89
5
+ dramatiq_workflow/_helpers.py,sha256=6JXHJuWx5oy3bSSAVg-RuU0CR2sOHdUsOcfDqce4Hkw,452
6
+ dramatiq_workflow/_middleware.py,sha256=i2Iq74qsw7mcnim25RLj5fdTAwdZ5j8_25ZVu6aGIfY,2434
7
+ dramatiq_workflow/_models.py,sha256=01loxf-aOJvadmz7t-VAOTOCxFeaB-XfktsqMLXa53I,1228
8
+ dramatiq_workflow/_serialize.py,sha256=xe0Vk1MVB8BLQGn-Ae2_WPwjmJ8TtB9qoKkK6hSRekg,2645
9
+ dramatiq_workflow/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ dramatiq_workflow/tests/test_workflow.py,sha256=qj3LIKD0DWKIqaDlkZzSt4WMhN9gLxGZwltd3fi_5zA,16171
11
+ dramatiq_workflow-0.1.0.dist-info/LICENSE,sha256=4_cXQK7MP9oD26jid8lsdQGc3asMOKyIWbZ_-qeXfzg,1077
12
+ dramatiq_workflow-0.1.0.dist-info/METADATA,sha256=RbYpuMEYko9xI4OlG-czS-WXtHi4hD6bsebhYoZcDIc,3984
13
+ dramatiq_workflow-0.1.0.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
14
+ dramatiq_workflow-0.1.0.dist-info/top_level.txt,sha256=y5C0rpDQONUprkF7q1mHKVx2ep-obyNGvEP4JDIrbNE,18
15
+ dramatiq_workflow-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (75.6.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ dramatiq_workflow