Skip to content

Commit efbe0a3

Browse files
Python SDK: add the Join helper for parallel branches (#1554)
1 parent 250adbd commit efbe0a3

16 files changed

Lines changed: 2615 additions & 16 deletions

File tree

‎.github/workflows/sdk-python-ci.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ jobs:
131131
dex/flow_config.py \
132132
dex/flow_info.py \
133133
dex/flow_options.py \
134+
dex/join.py \
134135
dex/runtime_errors.py \
135136
dex/step.py \
136137
dex/step_execution.py \

‎cli/internal/flowviz/python_analyzer.py‎

Lines changed: 99 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@
1212

1313
# Each of these builtins can run code or read and write attributes by computed name.
1414
DYNAMIC_BUILTIN_CALLS = {"getattr", "setattr", "delattr", "__import__", "import_module", "exec", "eval", "globals", "locals"}
15+
CHANNEL_WAIT_METHODS = {"for_one", "for_n", "at_least", "at_most", "for_range"}
16+
# A Join is a ChannelMap; these methods wait on its round instance or build its movements.
17+
JOIN_WAIT_METHODS = {"for_all_branches", "for_first_branch", "for_branches", "for_remaining_branches"}
18+
JOIN_FORK_METHODS = {"fork", "fork_with_round_key"}
1519

1620

1721
class Analyzer(ConnectorAnalyzer, StepBindingAnalyzer):
@@ -42,6 +46,7 @@ def __init__(self, path, source):
4246
self.parents = {}
4347
self.resources = {}
4448
self.resource_calls = {}
49+
self.join_resources = set()
4550
self.step_resources = {}
4651
self.failure_transitions = set()
4752
self.node_ids = set()
@@ -281,6 +286,7 @@ def index_resource_statements(self, statements, owner):
281286
self.resources[key] = node_id
282287
self.resources[target.id] = node_id
283288
self.resource_calls[value] = node_id
289+
self.record_join_resource(value, node_id)
284290
resource = self.resource_details(value)
285291
if resource["valueType"] == "unknown":
286292
self.warning("unknown_resource_type", f"{kind} {resource_name} has no statically readable value type", self.span(value))
@@ -395,6 +401,7 @@ def add_resource(self, kind, target_name, call, owner, source_node):
395401
self.resources[key] = node_id
396402
self.resources[target_name] = node_id
397403
self.resource_calls[call] = node_id
404+
self.record_join_resource(call, node_id)
398405
resource = self.resource_details(call)
399406
if resource["valueType"] == "unknown":
400407
self.warning("unknown_resource_type", f"{kind} {resource_name} has no statically readable value type", self.span(call))
@@ -927,6 +934,8 @@ def decision_type(self, expression, rpc):
927934
return None
928935
if isinstance(expression.func, ast.Attribute) and expression.func.attr in {"with_canceling_steps", "with_canceling_sibling_steps"}:
929936
return self.decision_type(expression.func.value, rpc)
937+
if not rpc and self.is_join_call(expression, {"end_branch"}):
938+
return "deadEnd"
930939
short = self.symbol(expression.func).rsplit(".", 1)[-1]
931940
names = {
932941
"go_to": "goTo",
@@ -996,7 +1005,7 @@ def wait_type(self, call):
9961005
def conditional_wait_resource(self, wait_call):
9971006
for node in ast.walk(wait_call):
9981007
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and isinstance(node.func.value, ast.IfExp):
999-
if node.func.attr in {"for_one", "for_n", "at_least", "at_most", "for_range"}:
1008+
if node.func.attr in CHANNEL_WAIT_METHODS | JOIN_WAIT_METHODS:
10001009
return node.func.value
10011010
return None
10021011

@@ -1017,7 +1026,7 @@ def wait_conditions(self, owner_id, wait_id, wait_call, resource_override):
10171026
self.add_edge("subflow", wait_id, subflow_id, label="start", span=self.span(call))
10181027
conditions.append({"kind": "subflow", "label": name, "subFlowId": subflow_id, "span": self.span(call)})
10191028
continue
1020-
if not isinstance(call.func, ast.Attribute) or call.func.attr not in {"for_one", "for_n", "at_least", "at_most", "for_range"}:
1029+
if not isinstance(call.func, ast.Attribute) or call.func.attr not in CHANNEL_WAIT_METHODS | JOIN_WAIT_METHODS:
10211030
continue
10221031
resource_expression = resource_override if isinstance(call.func.value, ast.IfExp) and resource_override is not None else call.func.value
10231032
resource_id = self.resource_for(resource_expression, owner_id)
@@ -1042,7 +1051,15 @@ def channel_condition_label(self, resource_id, call):
10421051
instance = f"[{arguments[0]}]"
10431052
counts = arguments[1:]
10441053
keywords = {keyword.arg: self.unparse(keyword.value) for keyword in call.keywords if keyword.arg}
1045-
if call.func.attr == "for_one":
1054+
if call.func.attr == "for_all_branches":
1055+
suffix = "for all branches"
1056+
elif call.func.attr == "for_first_branch":
1057+
suffix = "for first branch"
1058+
elif call.func.attr == "for_branches":
1059+
suffix = f"for {counts[0] if counts else 'N'} branches"
1060+
elif call.func.attr == "for_remaining_branches":
1061+
suffix = f"for remaining branches after {counts[0] if counts else 'N'}"
1062+
elif call.func.attr == "for_one":
10461063
suffix = "for 1"
10471064
elif call.func.attr == "for_n":
10481065
suffix = f"for {counts[0] if counts else 'N'}"
@@ -1177,6 +1194,8 @@ def parse_decision(self, expression, condition, rpc, locals_map):
11771194
for argument in expression.args:
11781195
transitions.append({"kind": "cancel", "target": self.step_target(argument), "targetNode": argument, "label": expression.func.attr, "condition": condition, "span": self.span(argument)})
11791196
return transitions
1197+
if not rpc and self.is_join_call(expression, {"end_branch"}):
1198+
return [{"kind": "terminal", "target": "dead_end", "label": "Dead end", "condition": condition, "span": self.span(expression)}]
11801199
symbol = self.symbol(expression.func)
11811200
short = symbol.rsplit(".", 1)[-1]
11821201
if short == "go_to" and self.is_dex_reference(expression.func, "go_to"):
@@ -1187,7 +1206,11 @@ def parse_decision(self, expression, condition, rpc, locals_map):
11871206
for argument in expression.args:
11881207
if isinstance(argument, ast.Starred) and isinstance(argument.value, ast.Name):
11891208
for movement in locals_map.get(argument.value.id, []):
1190-
result.append({**movement, "condition": condition, "multiplicity": movement.get("multiplicity") or "×N"})
1209+
multiplicity = movement.get("multiplicity") or ("" if movement.get("hasExactMultiplicity") else "×N")
1210+
result.append({**movement, "condition": condition, "multiplicity": multiplicity})
1211+
elif isinstance(argument, ast.Starred) and self.is_join_call(argument.value, JOIN_FORK_METHODS):
1212+
for movement in self.join_fork_movements(argument.value):
1213+
result.append({**movement, "condition": condition})
11911214
elif isinstance(argument, ast.Starred) and isinstance(argument.value, (ast.GeneratorExp, ast.ListComp)):
11921215
movement = self.parse_movement(argument.value.elt, condition)
11931216
if movement:
@@ -1223,6 +1246,8 @@ def parse_decision(self, expression, condition, rpc, locals_map):
12231246
movement = self.parse_movement(node, condition)
12241247
if movement:
12251248
result.append(movement)
1249+
elif isinstance(node, ast.Name):
1250+
result.extend({**local_movement, "condition": condition} for local_movement in locals_map.get(node.id, []))
12261251
return result
12271252
if rpc:
12281253
movement = self.parse_movement(expression, condition)
@@ -1245,7 +1270,9 @@ def local_movements(self, method):
12451270
if not targets or not isinstance(targets[0], ast.Name):
12461271
continue
12471272
name = targets[0].id
1248-
if isinstance(value, (ast.List, ast.Tuple)):
1273+
if self.is_join_call(value, JOIN_FORK_METHODS):
1274+
result[name] = self.join_fork_movements(value)
1275+
elif isinstance(value, (ast.List, ast.Tuple)):
12491276
result[name] = [movement for element in value.elts if (movement := self.parse_movement(element, ""))]
12501277
elif isinstance(value, ast.ListComp):
12511278
movement = self.parse_movement(value.elt, "")
@@ -1413,6 +1440,8 @@ def has_hidden_decision(self, method):
14131440
known = {"go_to", "go_to_many", "graceful_complete", "force_complete", "force_fail", "dead_end", "force_complete_if_channels_empty", "RPCResult"}
14141441
for node in ast.walk(method):
14151442
if isinstance(node, ast.Return) and isinstance(node.value, ast.Call):
1443+
if self.is_join_call(node.value, {"end_branch"}):
1444+
continue
14161445
short = self.symbol(node.value.func).rsplit(".", 1)[-1]
14171446
if short not in known or not self.is_dex_reference(node.value.func, short):
14181447
return True
@@ -1471,7 +1500,7 @@ def resource_kind(self, name):
14711500
short = name.rsplit(".", 1)[-1]
14721501
if short in {"Attribute", "AttributeMap"}:
14731502
return "attribute"
1474-
if short in {"Channel", "ChannelMap"}:
1503+
if short in {"Channel", "ChannelMap", "Join"}:
14751504
return "channel"
14761505
if short == "Stream":
14771506
return "stream"
@@ -1486,16 +1515,76 @@ def resource_details(self, call):
14861515
value_type = "None"
14871516
elif isinstance(call.func, ast.Subscript):
14881517
value_type = self.unparse(call.func.slice)
1489-
return {"valueType": value_type or "unknown", "map": short in {"AttributeMap", "ChannelMap"}}
1518+
return {"valueType": value_type or "unknown", "map": short in {"AttributeMap", "ChannelMap", "Join"}}
1519+
1520+
def record_join_resource(self, call, node_id):
1521+
if self.symbol(call.func).rsplit(".", 1)[-1] == "Join":
1522+
self.join_resources.add(node_id)
1523+
1524+
def is_join_call(self, expression, methods):
1525+
return (
1526+
isinstance(expression, ast.Call)
1527+
and isinstance(expression.func, ast.Attribute)
1528+
and expression.func.attr in methods
1529+
and self.resource_for(expression.func.value, "") in self.join_resources
1530+
)
1531+
1532+
def join_fork_movements(self, call):
1533+
"""Return the branch movements, then the join movement, built by one Join fork call."""
1534+
keywords = {keyword.arg: keyword.value for keyword in call.keywords if keyword.arg}
1535+
branch_builders = keywords.get("branch_builders", call.args[1] if len(call.args) > 1 else None)
1536+
join_builder = keywords.get("join_builder", call.args[2] if len(call.args) > 2 else None)
1537+
movements = []
1538+
if isinstance(branch_builders, (ast.List, ast.Tuple)):
1539+
for builder in branch_builders.elts:
1540+
movements.extend(self.join_builder_movements(builder, ""))
1541+
elif isinstance(branch_builders, (ast.ListComp, ast.GeneratorExp)):
1542+
movements.extend(self.join_builder_movements(branch_builders.elt, "×N"))
1543+
else:
1544+
self.dynamic_target("Join fork branch builders must be a list literal or a comprehension", branch_builders or call)
1545+
if join_builder is None:
1546+
self.dynamic_target("Join fork must pass a join builder", call)
1547+
else:
1548+
movements.extend(self.join_builder_movements(join_builder, ""))
1549+
return movements
1550+
1551+
def join_builder_movements(self, builder, multiplicity):
1552+
expression = self.join_builder_movement_expression(builder)
1553+
movement = self.parse_movement(expression, "") if expression is not None else None
1554+
if movement is None:
1555+
self.dynamic_target("Join fork builders must be lambdas or module functions that return StepMovement.of", builder)
1556+
return []
1557+
movement["multiplicity"] = multiplicity
1558+
movement["hasExactMultiplicity"] = True
1559+
return [movement]
1560+
1561+
def join_builder_movement_expression(self, builder):
1562+
if isinstance(builder, ast.Lambda):
1563+
return builder.body
1564+
if isinstance(builder, ast.Name):
1565+
return self.function_return_value(self.functions.get(builder.id))
1566+
if isinstance(builder, ast.Call) and isinstance(builder.func, ast.Name):
1567+
factory_result = self.function_return_value(self.functions.get(builder.func.id))
1568+
if isinstance(factory_result, ast.Lambda):
1569+
return factory_result.body
1570+
return None
1571+
1572+
def function_return_value(self, function):
1573+
if function is None:
1574+
return None
1575+
for statement in function.body:
1576+
if isinstance(statement, ast.Return) and statement.value is not None:
1577+
return statement.value
1578+
return None
14901579

14911580
def resource_edge_kind(self, method, phase):
1492-
if phase == "wait_for" and method in {"for_one", "for_n", "at_least", "at_most", "for_range"}:
1581+
if phase == "wait_for" and method in CHANNEL_WAIT_METHODS | JOIN_WAIT_METHODS:
14931582
return "wait_condition"
1494-
if method in {"get", "size", "map_size", "all_instance_keys", "get_map_size", "get_all_instance_keys", "results", "pending_messages", "find_pending_message"}:
1583+
if method in {"get", "size", "map_size", "all_instance_keys", "get_map_size", "get_all_instance_keys", "results", "pending_messages", "find_pending_message", "branch_results", "remaining_branch_results"}:
14951584
return "resource_read"
14961585
if method in {"set", "delete", "write"}:
14971586
return "resource_write"
1498-
if method == "publish":
1587+
if method in {"publish", "end_branch", "report_branch_result"}:
14991588
return "resource_publish"
15001589
return None
15011590

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// Copyright (c) 2026 Super Durable, Inc.
2+
//
3+
// Licensed under the Sustainable Use License 1.0.
4+
// You may not use this file except in compliance with the License.
5+
// See the LICENSE file in the repository root.
6+
//
7+
// SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0
8+
9+
package flowviz
10+
11+
import (
12+
"context"
13+
"os"
14+
"path/filepath"
15+
"testing"
16+
17+
"github.com/stretchr/testify/require"
18+
)
19+
20+
func TestPythonJoinDefinitionGolden(t *testing.T) {
21+
directory := t.TempDir()
22+
source, err := os.ReadFile(filepath.Join("testdata", "python", "join.py"))
23+
require.NoError(t, err)
24+
sourcePath := filepath.Join(directory, "join.py")
25+
require.NoError(t, os.WriteFile(sourcePath, source, 0o644))
26+
27+
graph, err := Analyze(context.Background(), sourcePath, AnalyzeOptions{})
28+
require.NoError(t, err)
29+
require.True(t, graph.Valid, "%+v", graph.Diagnostics)
30+
require.Empty(t, graph.Diagnostics)
31+
32+
requirePythonJoinEdge(t, graph, "transition", "step:RequestQuotes", "step:QuoteFromSupplierA")
33+
requirePythonJoinEdge(t, graph, "transition", "step:RequestQuotes", "step:QuoteFromSupplierB")
34+
requirePythonJoinEdge(t, graph, "transition", "step:RequestQuotes", "step:AcceptQuotes")
35+
requirePythonJoinEdge(t, graph, "transition", "rpc:request_reviews", "step:ReviewDocument")
36+
requirePythonJoinEdge(t, graph, "transition", "rpc:request_reviews", "step:CollectReviews")
37+
requirePythonJoinEdge(t, graph, "resource_publish", "step:QuoteFromSupplierA", "resource:channel:QUOTE_RECEIVED")
38+
requirePythonJoinEdge(t, graph, "resource_publish", "step:QuoteFromSupplierB", "resource:channel:QUOTE_RECEIVED")
39+
requirePythonJoinEdge(t, graph, "resource_read", "resource:channel:QUOTE_RECEIVED", "step:AcceptQuotes")
40+
requirePythonJoinEdge(t, graph, "wait_condition", "resource:channel:QUOTE_RECEIVED", "wait:AcceptQuotes")
41+
requirePythonJoinEdge(t, graph, "wait_condition", "resource:channel:QUOTE_RECEIVED", "wait:RequestQuotes")
42+
requirePythonJoinEdge(t, graph, "wait_condition", "resource:channel:QUOTE_RECEIVED", "wait:CollectReviews")
43+
44+
graph.Source.Path = "join.py"
45+
actual, err := MarshalJSON(graph)
46+
require.NoError(t, err)
47+
goldenPath := filepath.Join("testdata", "python", "join.golden.json")
48+
if os.Getenv("UPDATE_SCRIPT_GOLDENS") == "1" {
49+
require.NoError(t, os.WriteFile(goldenPath, actual, 0o644))
50+
}
51+
expected, err := os.ReadFile(goldenPath)
52+
require.NoError(t, err)
53+
require.Equal(t, string(expected), string(actual))
54+
}
55+
56+
// requirePythonJoinEdge matches an edge whose endpoints equal or start with the given node IDs.
57+
func requirePythonJoinEdge(t *testing.T, graph *Graph, kind string, from string, to string) {
58+
t.Helper()
59+
for _, edge := range graph.Edges {
60+
if edge.Kind != kind || !pythonJoinNodeMatches(graph, edge.From, from) || !pythonJoinNodeMatches(graph, edge.To, to) {
61+
continue
62+
}
63+
return
64+
}
65+
require.Failf(t, "missing edge", "%s %s -> %s in %+v", kind, from, to, graph.Edges)
66+
}
67+
68+
// pythonJoinNodeMatches accepts the node itself, an ID prefix, or a child node of it.
69+
func pythonJoinNodeMatches(graph *Graph, nodeID string, expected string) bool {
70+
if nodeID == expected || len(nodeID) > len(expected) && nodeID[:len(expected)+1] == expected+":" {
71+
return true
72+
}
73+
for _, node := range graph.Nodes {
74+
if node.ID == nodeID && node.ParentID == expected {
75+
return true
76+
}
77+
}
78+
return false
79+
}

0 commit comments

Comments
 (0)