Skip to content

Commit d9ddcd4

Browse files
Add YAML Editor and Visualization Panel (#35947)
* Yaml Panel * Update CHANGES.md * Update * Update sdks/python/apache_beam/runners/interactive/extensions/apache-beam-jupyterlab-sidepanel/apache_beam_jupyterlab_sidepanel/yaml_parse_utils.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Update CHANGES.md * Fix CI/CD fails * Update CHANGES.md * Update yaml_parse_utils.py * Update CHANGES.md * Update CHANGES.md --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
1 parent 9ac64e0 commit d9ddcd4

File tree

19 files changed

+2632
-16
lines changed

19 files changed

+2632
-16
lines changed

CHANGES.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565

6666
* New highly anticipated feature X added to Python SDK ([#X](https://github.com/apache/beam/issues/X)).
6767
* New highly anticipated feature Y added to Java SDK ([#Y](https://github.com/apache/beam/issues/Y)).
68+
* (Python) Add YAML Editor and Visualization Panel ([#35772](https://github.com/apache/beam/issues/35772)).
6869

6970
## I/Os
7071

@@ -96,7 +97,7 @@
9697

9798
* New highly anticipated feature X added to Python SDK ([#X](https://github.com/apache/beam/issues/X)).
9899
* New highly anticipated feature Y added to Java SDK ([#Y](https://github.com/apache/beam/issues/Y)).
99-
* (Python) Prism runner now enabled by default for most Python pipelines using the direct runner ([#34612](https://github.com/apache/beam/pull/34612)). This may break some tests, see https://github.com/apache/beam/pull/34612 for details on how to handle issues.
100+
* [Python] Prism runner now enabled by default for most Python pipelines using the direct runner ([#34612](https://github.com/apache/beam/pull/34612)). This may break some tests, see https://github.com/apache/beam/pull/34612 for details on how to handle issues.
100101

101102
## I/Os
102103

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
# Licensed under the Apache License, Version 2.0 (the 'License'); you may not
2+
# use this file except in compliance with the License. You may obtain a copy of
3+
# the License at
4+
#
5+
# http://www.apache.org/licenses/LICENSE-2.0
6+
#
7+
# Unless required by applicable law or agreed to in writing, software
8+
# distributed under the License is distributed on an 'AS IS' BASIS, WITHOUT
9+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
10+
# License for the specific language governing permissions and limitations under
11+
# the License.
12+
13+
import dataclasses
14+
import json
15+
from dataclasses import dataclass
16+
from typing import Any
17+
from typing import Dict
18+
from typing import List
19+
from typing import TypedDict
20+
21+
import yaml
22+
23+
import apache_beam as beam
24+
from apache_beam.yaml.main import build_pipeline_components_from_yaml
25+
26+
# ======================== Type Definitions ========================
27+
28+
29+
@dataclass
30+
class NodeData:
31+
id: str
32+
label: str
33+
type: str = ""
34+
35+
def __post_init__(self):
36+
# Ensure ID is not empty
37+
if not self.id:
38+
raise ValueError("Node ID cannot be empty")
39+
40+
41+
@dataclass
42+
class EdgeData:
43+
source: str
44+
target: str
45+
label: str = ""
46+
47+
def __post_init__(self):
48+
if not self.source or not self.target:
49+
raise ValueError("Edge source and target cannot be empty")
50+
51+
52+
class FlowGraph(TypedDict):
53+
nodes: List[Dict[str, Any]]
54+
edges: List[Dict[str, Any]]
55+
56+
57+
# ======================== Main Function ========================
58+
59+
60+
def parse_beam_yaml(yaml_str: str, isDryRunMode: bool = False) -> str:
61+
"""
62+
Parse Beam YAML and convert to flow graph data structure
63+
64+
Args:
65+
yaml_str: Input YAML string
66+
67+
Returns:
68+
Standardized response format:
69+
- Success: {'status': 'success', 'data': {...}, 'error': None}
70+
- Failure: {'status': 'error', 'data': None, 'error': 'message'}
71+
"""
72+
# Phase 1: YAML Parsing
73+
try:
74+
parsed_yaml = yaml.safe_load(yaml_str)
75+
if not parsed_yaml or 'pipeline' not in parsed_yaml:
76+
return build_error_response(
77+
"Invalid YAML structure: missing 'pipeline' section")
78+
except yaml.YAMLError as e:
79+
return build_error_response(f"YAML parsing error: {str(e)}")
80+
81+
# Phase 2: Pipeline Validation
82+
try:
83+
options, constructor = build_pipeline_components_from_yaml(
84+
yaml_str,
85+
[],
86+
validate_schema='per_transform'
87+
)
88+
if isDryRunMode:
89+
with beam.Pipeline(options=options) as p:
90+
constructor(p)
91+
except Exception as e:
92+
return build_error_response(f"Pipeline validation failed: {str(e)}")
93+
94+
# Phase 3: Graph Construction
95+
try:
96+
pipeline = parsed_yaml['pipeline']
97+
transforms = pipeline.get('transforms', [])
98+
99+
nodes: List[NodeData] = []
100+
edges: List[EdgeData] = []
101+
102+
nodes.append(NodeData(id='0', label='Input', type='input'))
103+
nodes.append(NodeData(id='1', label='Output', type='output'))
104+
105+
# Process transform nodes
106+
for idx, transform in enumerate(transforms):
107+
if not isinstance(transform, dict):
108+
continue
109+
110+
payload = {k: v for k, v in transform.items() if k not in {"type"}}
111+
112+
node_id = f"t{idx}"
113+
node_data = NodeData(
114+
id=node_id,
115+
label=transform.get('type', 'unnamed'),
116+
type='default',
117+
**payload)
118+
nodes.append(node_data)
119+
120+
# Create connections between nodes
121+
if idx > 0:
122+
edges.append(
123+
EdgeData(source=f"t{idx-1}", target=node_id, label='chain'))
124+
125+
if transforms:
126+
edges.append(EdgeData(source='0', target='t0', label='start'))
127+
edges.append(EdgeData(source=node_id, target='1', label='stop'))
128+
129+
def to_dict(node):
130+
if hasattr(node, '__dataclass_fields__'):
131+
return dataclasses.asdict(node)
132+
return node
133+
134+
nodes_serializable = [to_dict(n) for n in nodes]
135+
136+
return build_success_response(
137+
nodes=nodes_serializable, edges=[dataclasses.asdict(e) for e in edges])
138+
139+
except Exception as e:
140+
return build_error_response(f"Graph construction failed: {str(e)}")
141+
142+
143+
# ======================== Utility Functions ========================
144+
145+
146+
def build_success_response(
147+
nodes: List[Dict[str, Any]], edges: List[Dict[str, Any]]) -> str:
148+
"""Build success response"""
149+
return json.dumps({'data': {'nodes': nodes, 'edges': edges}, 'error': None})
150+
151+
152+
def build_error_response(error_msg: str) -> str:
153+
"""Build error response"""
154+
return json.dumps({'data': None, 'error': error_msg})
155+
156+
157+
if __name__ == "__main__":
158+
# Example usage
159+
example_yaml = """
160+
pipeline:
161+
transforms:
162+
- type: ReadFromCsv
163+
name: A
164+
config:
165+
path: /path/to/input*.csv
166+
- type: WriteToJson
167+
name: B
168+
config:
169+
path: /path/to/output.json
170+
input: ReadFromCsv
171+
- type: Join
172+
input: [A, B]
173+
"""
174+
175+
response = parse_beam_yaml(example_yaml, isDryRunMode=False)
176+
print(response)

sdks/python/apache_beam/runners/interactive/extensions/apache-beam-jupyterlab-sidepanel/package.json

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,27 +47,37 @@
4747
"@jupyterlab/launcher": "^4.3.6",
4848
"@jupyterlab/mainmenu": "^4.3.6",
4949
"@lumino/widgets": "^2.2.1",
50+
"@monaco-editor/react": "^4.7.0",
5051
"@rmwc/base": "^14.0.0",
5152
"@rmwc/button": "^8.0.6",
53+
"@rmwc/card": "^14.3.5",
5254
"@rmwc/data-table": "^8.0.6",
5355
"@rmwc/dialog": "^8.0.6",
5456
"@rmwc/drawer": "^8.0.6",
5557
"@rmwc/fab": "^8.0.6",
58+
"@rmwc/grid": "^14.3.5",
5659
"@rmwc/list": "^8.0.6",
5760
"@rmwc/ripple": "^14.0.0",
5861
"@rmwc/textfield": "^8.0.6",
5962
"@rmwc/tooltip": "^8.0.6",
6063
"@rmwc/top-app-bar": "^8.0.6",
64+
"@rmwc/touch-target": "^14.3.5",
65+
"@xyflow/react": "^12.8.2",
66+
"dagre": "^0.8.5",
67+
"lodash": "^4.17.21",
6168
"material-design-icons": "^3.0.1",
6269
"react": "^18.2.0",
63-
"react-dom": "^18.2.0"
70+
"react-dom": "^18.2.0",
71+
"react-split": "^2.0.14"
6472
},
6573
"devDependencies": {
6674
"@jupyterlab/builder": "^4.3.6",
6775
"@testing-library/dom": "^9.3.0",
6876
"@testing-library/jest-dom": "^6.1.4",
6977
"@testing-library/react": "^14.0.0",
78+
"@types/dagre": "^0.7.53",
7079
"@types/jest": "^29.5.14",
80+
"@types/lodash": "^4.17.20",
7181
"@types/react": "^18.2.0",
7282
"@types/react-dom": "^18.2.0",
7383
"@typescript-eslint/eslint-plugin": "^7.3.1",
@@ -97,5 +107,6 @@
97107
"test": "jest",
98108
"resolutions": {
99109
"@types/react": "^18.2.0"
100-
}
101-
}
110+
},
111+
"packageManager": "[email protected]+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
112+
}

sdks/python/apache_beam/runners/interactive/extensions/apache-beam-jupyterlab-sidepanel/src/SidePanel.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,14 +58,17 @@ export class SidePanel extends BoxPanel {
5858
const sessionModelItr = manager.sessions.running();
5959
const firstModel = sessionModelItr.next();
6060
let onlyOneUniqueKernelExists = true;
61-
if (firstModel === undefined) {
62-
// There is zero unique running kernel.
61+
62+
if (firstModel.done) {
63+
// No Running kernel
6364
onlyOneUniqueKernelExists = false;
6465
} else {
66+
// firstModel.value is the first session
6567
let sessionModel = sessionModelItr.next();
66-
while (sessionModel !== undefined) {
68+
69+
while (!sessionModel.done) {
70+
// Check if there is more than one unique kernel
6771
if (sessionModel.value.kernel.id !== firstModel.value.kernel.id) {
68-
// There is more than one unique running kernel.
6972
onlyOneUniqueKernelExists = false;
7073
break;
7174
}

sdks/python/apache_beam/runners/interactive/extensions/apache-beam-jupyterlab-sidepanel/src/index.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,15 @@ import { SidePanel } from './SidePanel';
2828
import {
2929
InteractiveInspectorWidget
3030
} from './inspector/InteractiveInspectorWidget';
31+
import { YamlWidget } from './yaml/YamlWidget';
3132

3233
namespace CommandIDs {
3334
export const open_inspector =
3435
'apache-beam-jupyterlab-sidepanel:open_inspector';
3536
export const open_clusters_panel =
3637
'apache-beam-jupyterlab-sidepanel:open_clusters_panel';
38+
export const open_yaml_editor =
39+
'apache-beam-jupyterlab-sidepanel:open_yaml_editor';
3740
}
3841

3942
/**
@@ -67,6 +70,7 @@ function activate(
6770
const category = 'Interactive Beam';
6871
const inspectorCommandLabel = 'Open Inspector';
6972
const clustersCommandLabel = 'Manage Clusters';
73+
const yamlCommandLabel = 'Edit YAML Pipeline';
7074
const { commands, shell, serviceManager } = app;
7175

7276
async function createInspectorPanel(): Promise<SidePanel> {
@@ -105,6 +109,24 @@ function activate(
105109
return panel;
106110
}
107111

112+
async function createYamlPanel(): Promise<SidePanel> {
113+
const sessionContext = new SessionContext({
114+
sessionManager: serviceManager.sessions,
115+
specsManager: serviceManager.kernelspecs,
116+
name: 'Interactive Beam YAML Session'
117+
});
118+
const yamlEditor = new YamlWidget(sessionContext);
119+
const panel = new SidePanel(
120+
serviceManager,
121+
rendermime,
122+
sessionContext,
123+
'Interactive Beam YAML Editor',
124+
yamlEditor
125+
);
126+
activatePanel(panel);
127+
return panel;
128+
}
129+
108130
function activatePanel(panel: SidePanel): void {
109131
shell.add(panel, 'main');
110132
shell.activateById(panel.id);
@@ -122,6 +144,12 @@ function activate(
122144
execute: createClustersPanel
123145
});
124146

147+
// The open_yaml_editor command is also used by the below entry points.
148+
commands.addCommand(CommandIDs.open_yaml_editor, {
149+
label: yamlCommandLabel,
150+
execute: createYamlPanel
151+
});
152+
125153
// Entry point in launcher.
126154
if (launcher) {
127155
launcher.add({
@@ -132,6 +160,10 @@ function activate(
132160
command: CommandIDs.open_clusters_panel,
133161
category: category
134162
});
163+
launcher.add({
164+
command: CommandIDs.open_yaml_editor,
165+
category: category
166+
});
135167
}
136168

137169
// Entry point in top menu.
@@ -140,10 +172,11 @@ function activate(
140172
mainMenu.addMenu(menu);
141173
menu.addItem({ command: CommandIDs.open_inspector });
142174
menu.addItem({ command: CommandIDs.open_clusters_panel });
175+
menu.addItem({ command: CommandIDs.open_yaml_editor });
143176

144177
// Entry point in commands palette.
145178
palette.addItem({ command: CommandIDs.open_inspector, category });
146179
palette.addItem({ command: CommandIDs.open_clusters_panel, category });
180+
palette.addItem({ command: CommandIDs.open_yaml_editor, category });
147181
}
148-
149182
export default extension;

0 commit comments

Comments
 (0)