Skip to content

Commit 41bb0ca

Browse files
authored
DSL sample (#87)
Fixes #7
1 parent c587d20 commit 41bb0ca

12 files changed

+411
-2
lines changed

Diff for: .github/workflows/ci.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ jobs:
2727
# Using fixed Poetry version until
2828
# https://github.com/python-poetry/poetry/pull/7694 is fixed
2929
- run: python -m pip install --upgrade wheel "poetry==1.4.0" poethepoet
30-
- run: poetry install --with pydantic
30+
- run: poetry install --with pydantic --with dsl
3131
- run: poe lint
3232
- run: poe test -s -o log_cli_level=DEBUG
3333
- run: poe test -s -o log_cli_level=DEBUG --workflow-environment time-skipping

Diff for: README.md

+1
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ Some examples require extra dependencies. See each sample's directory for specif
5555
* [activity_worker](activity_worker) - Use Python activities from a workflow in another language.
5656
* [custom_converter](custom_converter) - Use a custom payload converter to handle custom types.
5757
* [custom_decorator](custom_decorator) - Custom decorator to auto-heartbeat a long-running activity.
58+
* [dsl](dsl) - DSL workflow that executes steps defined in a YAML file.
5859
* [encryption](encryption) - Apply end-to-end encryption for all input/output.
5960
* [gevent_async](gevent_async) - Combine gevent and Temporal.
6061
* [open_telemetry](open_telemetry) - Trace workflows with OpenTelemetry.

Diff for: dsl/README.md

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# DSL Sample
2+
3+
This sample shows how to have a workflow interpret/invoke arbitrary steps defined in a DSL. It is similar to the DSL
4+
samples [in TypeScript](https://github.com/temporalio/samples-typescript/tree/main/dsl-interpreter) and
5+
[in Go](https://github.com/temporalio/samples-go/tree/main/dsl).
6+
7+
For this sample, the optional `dsl` dependency group must be included. To include, run:
8+
9+
poetry install --with dsl
10+
11+
To run, first see [README.md](../README.md) for prerequisites. Then, run the following from this directory to start the
12+
worker:
13+
14+
poetry run python worker.py
15+
16+
This will start the worker. Then, in another terminal, run the following to execute a workflow of steps defined in
17+
[workflow1.yaml](workflow1.yaml):
18+
19+
poetry run python starter.py workflow1.yaml
20+
21+
This will run the workflow and show the final variables that the workflow returns. Looking in the worker terminal, each
22+
step executed will be visible.
23+
24+
Similarly we can do the same for the more advanced [workflow2.yaml](workflow2.yaml) file:
25+
26+
poetry run python starter.py workflow2.yaml
27+
28+
This sample gives a guide of how one can write a workflow to interpret arbitrary steps from a user-provided DSL. Many
29+
DSL models are more advanced and are more specific to conform to business logic needs.

Diff for: dsl/__init__.py

Whitespace-only changes.

Diff for: dsl/activities.py

+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from temporalio import activity
2+
3+
4+
class DSLActivities:
5+
@activity.defn
6+
async def activity1(self, arg: str) -> str:
7+
activity.logger.info(f"Executing activity1 with arg: {arg}")
8+
return f"[result from activity1: {arg}]"
9+
10+
@activity.defn
11+
async def activity2(self, arg: str) -> str:
12+
activity.logger.info(f"Executing activity2 with arg: {arg}")
13+
return f"[result from activity2: {arg}]"
14+
15+
@activity.defn
16+
async def activity3(self, arg1: str, arg2: str) -> str:
17+
activity.logger.info(f"Executing activity3 with args: {arg1} and {arg2}")
18+
return f"[result from activity3: {arg1} {arg2}]"
19+
20+
@activity.defn
21+
async def activity4(self, arg: str) -> str:
22+
activity.logger.info(f"Executing activity4 with arg: {arg}")
23+
return f"[result from activity4: {arg}]"
24+
25+
@activity.defn
26+
async def activity5(self, arg1: str, arg2: str) -> str:
27+
activity.logger.info(f"Executing activity5 with args: {arg1} and {arg2}")
28+
return f"[result from activity5: {arg1} {arg2}]"

Diff for: dsl/starter.py

+46
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import asyncio
2+
import logging
3+
import sys
4+
import uuid
5+
6+
import dacite
7+
import yaml
8+
from temporalio.client import Client
9+
10+
from dsl.workflow import DSLInput, DSLWorkflow
11+
12+
13+
async def main(dsl_yaml: str) -> None:
14+
# Convert the YAML to our dataclass structure. We use PyYAML + dacite to do
15+
# this but it can be done any number of ways.
16+
dsl_input = dacite.from_dict(DSLInput, yaml.safe_load(dsl_yaml))
17+
18+
# Connect client
19+
client = await Client.connect("localhost:7233")
20+
21+
# Run workflow
22+
result = await client.execute_workflow(
23+
DSLWorkflow.run,
24+
dsl_input,
25+
id=f"dsl-workflow-id-{uuid.uuid4()}",
26+
task_queue="dsl-task-queue",
27+
)
28+
logging.info(
29+
f"Final variables:\n "
30+
+ "\n ".join((f"{k}: {v}" for k, v in result.items()))
31+
)
32+
33+
34+
if __name__ == "__main__":
35+
logging.basicConfig(level=logging.INFO)
36+
37+
# Require the YAML file as an argument. We read this _outside_ of the async
38+
# def function because thread-blocking IO should never happen in async def
39+
# functions.
40+
if len(sys.argv) != 2:
41+
raise RuntimeError("Expected single argument for YAML file")
42+
with open(sys.argv[1], "r") as yaml_file:
43+
dsl_yaml = yaml_file.read()
44+
45+
# Run
46+
asyncio.run(main(dsl_yaml))

Diff for: dsl/worker.py

+44
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import asyncio
2+
import logging
3+
4+
from temporalio.client import Client
5+
from temporalio.worker import Worker
6+
7+
from dsl.activities import DSLActivities
8+
from dsl.workflow import DSLWorkflow
9+
10+
interrupt_event = asyncio.Event()
11+
12+
13+
async def main():
14+
# Connect client
15+
client = await Client.connect("localhost:7233")
16+
17+
# Run a worker for the activities and workflow
18+
activities = DSLActivities()
19+
async with Worker(
20+
client,
21+
task_queue="dsl-task-queue",
22+
activities=[
23+
activities.activity1,
24+
activities.activity2,
25+
activities.activity3,
26+
activities.activity4,
27+
activities.activity5,
28+
],
29+
workflows=[DSLWorkflow],
30+
):
31+
# Wait until interrupted
32+
logging.info("Worker started, ctrl+c to exit")
33+
await interrupt_event.wait()
34+
logging.info("Shutting down")
35+
36+
37+
if __name__ == "__main__":
38+
logging.basicConfig(level=logging.INFO)
39+
loop = asyncio.new_event_loop()
40+
try:
41+
loop.run_until_complete(main())
42+
except KeyboardInterrupt:
43+
interrupt_event.set()
44+
loop.run_until_complete(loop.shutdown_asyncgens())

Diff for: dsl/workflow.py

+85
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
import dataclasses
5+
from dataclasses import dataclass
6+
from datetime import timedelta
7+
from typing import Any, Dict, List, Optional, Union
8+
9+
from temporalio import workflow
10+
11+
12+
@dataclass
13+
class DSLInput:
14+
root: Statement
15+
variables: Dict[str, Any] = dataclasses.field(default_factory=dict)
16+
17+
18+
@dataclass
19+
class ActivityStatement:
20+
activity: ActivityInvocation
21+
22+
23+
@dataclass
24+
class ActivityInvocation:
25+
name: str
26+
arguments: List[str] = dataclasses.field(default_factory=list)
27+
result: Optional[str] = None
28+
29+
30+
@dataclass
31+
class SequenceStatement:
32+
sequence: Sequence
33+
34+
35+
@dataclass
36+
class Sequence:
37+
elements: List[Statement]
38+
39+
40+
@dataclass
41+
class ParallelStatement:
42+
parallel: Parallel
43+
44+
45+
@dataclass
46+
class Parallel:
47+
branches: List[Statement]
48+
49+
50+
Statement = Union[ActivityStatement, SequenceStatement, ParallelStatement]
51+
52+
53+
@workflow.defn
54+
class DSLWorkflow:
55+
@workflow.run
56+
async def run(self, input: DSLInput) -> Dict[str, Any]:
57+
self.variables = dict(input.variables)
58+
workflow.logger.info("Running DSL workflow")
59+
await self.execute_statement(input.root)
60+
workflow.logger.info("DSL workflow completed")
61+
return self.variables
62+
63+
async def execute_statement(self, stmt: Statement) -> None:
64+
if isinstance(stmt, ActivityStatement):
65+
# Invoke activity loading arguments from variables and optionally
66+
# storing result as a variable
67+
result = await workflow.execute_activity(
68+
stmt.activity.name,
69+
args=[self.variables.get(arg, "") for arg in stmt.activity.arguments],
70+
start_to_close_timeout=timedelta(minutes=1),
71+
)
72+
if stmt.activity.result:
73+
self.variables[stmt.activity.result] = result
74+
elif isinstance(stmt, SequenceStatement):
75+
# Execute each statement in order
76+
for elem in stmt.sequence.elements:
77+
await self.execute_statement(elem)
78+
elif isinstance(stmt, ParallelStatement):
79+
# Execute all in parallel. Note, this will raise an exception when
80+
# the first activity fails and will not cancel the others. We could
81+
# store tasks and cancel if we wanted. In newer Python versions this
82+
# would use a TaskGroup instead.
83+
await asyncio.gather(
84+
*[self.execute_statement(branch) for branch in stmt.parallel.branches]
85+
)

Diff for: dsl/workflow1.yaml

+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# This sample workflows execute 3 steps in sequence.
2+
# 1) Activity1, takes arg1 as input, and put result as result1.
3+
# 2) Activity2, takes result1 as input, and put result as result2.
4+
# 3) Activity3, takes args2 and result2 as input, and put result as result3.
5+
6+
variables:
7+
arg1: value1
8+
arg2: value2
9+
10+
root:
11+
sequence:
12+
elements:
13+
- activity:
14+
name: activity1
15+
arguments:
16+
- arg1
17+
result: result1
18+
- activity:
19+
name: activity2
20+
arguments:
21+
- result1
22+
result: result2
23+
- activity:
24+
name: activity3
25+
arguments:
26+
- arg2
27+
- result2
28+
result: result3

Diff for: dsl/workflow2.yaml

+58
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# This sample workflow executes 3 steps in sequence.
2+
# 1) activity1, takes arg1 as input, and put result as result1.
3+
# 2) it runs a parallel block which runs below sequence branches in parallel
4+
# 2.1) sequence 1
5+
# 2.1.1) activity2, takes result1 as input, and put result as result2
6+
# 2.1.2) activity3, takes arg2 and result2 as input, and put result as result3
7+
# 2.2) sequence 2
8+
# 2.2.1) activity4, takes result1 as input, and put result as result4
9+
# 2.2.2) activity5, takes arg3 and result4 as input, and put result as result5
10+
# 3) activity3, takes result3 and result5 as input, and put result as result6.
11+
12+
variables:
13+
arg1: value1
14+
arg2: value2
15+
arg3: value3
16+
17+
root:
18+
sequence:
19+
elements:
20+
- activity:
21+
name: activity1
22+
arguments:
23+
- arg1
24+
result: result1
25+
- parallel:
26+
branches:
27+
- sequence:
28+
elements:
29+
- activity:
30+
name: activity2
31+
arguments:
32+
- result1
33+
result: result2
34+
- activity:
35+
name: activity3
36+
arguments:
37+
- arg2
38+
- result2
39+
result: result3
40+
- sequence:
41+
elements:
42+
- activity:
43+
name: activity4
44+
arguments:
45+
- result1
46+
result: result4
47+
- activity:
48+
name: activity5
49+
arguments:
50+
- arg3
51+
- result4
52+
result: result5
53+
- activity:
54+
name: activity3
55+
arguments:
56+
- result3
57+
- result5
58+
result: result6

0 commit comments

Comments
 (0)