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.
- dramatiq_workflow/__init__.py +13 -0
- dramatiq_workflow/_barrier.py +38 -0
- dramatiq_workflow/_base.py +185 -0
- dramatiq_workflow/_constants.py +2 -0
- dramatiq_workflow/_helpers.py +18 -0
- dramatiq_workflow/_middleware.py +67 -0
- dramatiq_workflow/_models.py +47 -0
- dramatiq_workflow/_serialize.py +79 -0
- dramatiq_workflow/tests/__init__.py +0 -0
- dramatiq_workflow/tests/test_workflow.py +383 -0
- dramatiq_workflow-0.1.0.dist-info/LICENSE +21 -0
- dramatiq_workflow-0.1.0.dist-info/METADATA +111 -0
- dramatiq_workflow-0.1.0.dist-info/RECORD +15 -0
- dramatiq_workflow-0.1.0.dist-info/WHEEL +5 -0
- dramatiq_workflow-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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,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 @@
|
|
|
1
|
+
dramatiq_workflow
|