Skip to content

Commit b1edaaf

Browse files
authored
Merge pull request #114 from nauticalab/eywalker/plt-1180-restore-pipelineshow_graph-method-with-tests
Restore Pipeline.show_graph, fix graph rendering, add tests
2 parents 37fab05 + 727f221 commit b1edaaf

9 files changed

Lines changed: 860 additions & 399 deletions

File tree

.github/workflows/run-postgres-tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ jobs:
4747
run: uv sync --locked --all-extras --dev
4848

4949
- name: Run PostgreSQL tests
50-
run: uv run pytest -v -m postgres --cov=src --cov-report=term-missing --cov-report=xml
50+
run: uv run pytest -v -m postgres --postgres --cov=src --cov-report=term-missing --cov-report=xml
5151
env:
5252
PGHOST: localhost
5353
PGPORT: 5432

.github/workflows/run-tests.yml

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,7 @@ jobs:
4444
# Only run on push/manual trigger — not on PRs — to avoid consuming SpiralDB
4545
# resources on every review cycle.
4646
# Requires SPIRAL_WORKLOAD_ID secret to be configured; skips gracefully if absent.
47-
if: >
48-
(github.event_name == 'push' || github.event_name == 'workflow_dispatch') &&
49-
secrets.SPIRAL_WORKLOAD_ID != ''
47+
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
5048
needs: test
5149
permissions:
5250
id-token: write # required to request a GitHub OIDC token for SpiralDB auth
@@ -58,21 +56,33 @@ jobs:
5856
SPIRAL_SERVER_URL: ${{ vars.SPIRAL_SERVER_URL || 'http://api.spiraldb.dev' }}
5957

6058
steps:
59+
- name: Check for SPIRAL_WORKLOAD_ID
60+
if: env.SPIRAL_WORKLOAD_ID == ''
61+
run: |
62+
echo "::notice::SPIRAL_WORKLOAD_ID not set — skipping SpiralDB integration tests"
63+
exit 0
64+
6165
- uses: actions/checkout@v4
66+
if: env.SPIRAL_WORKLOAD_ID != ''
6267

6368
- name: Install uv
69+
if: env.SPIRAL_WORKLOAD_ID != ''
6470
uses: astral-sh/setup-uv@v5
6571

6672
- name: Set up Python 3.11
73+
if: env.SPIRAL_WORKLOAD_ID != ''
6774
uses: actions/setup-python@v5
6875
with:
6976
python-version: "3.11"
7077

7178
- name: Install system dependencies
79+
if: env.SPIRAL_WORKLOAD_ID != ''
7280
run: sudo apt-get update && sudo apt-get install -y graphviz libgraphviz-dev
7381

7482
- name: Install dependencies
83+
if: env.SPIRAL_WORKLOAD_ID != ''
7584
run: uv sync --locked --all-extras --dev
7685

7786
- name: Run SpiralDB integration tests
87+
if: env.SPIRAL_WORKLOAD_ID != ''
7888
run: uv run pytest tests/test_databases/test_spiraldb_connector_integration.py -v

codecov.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
coverage:
2+
status:
3+
project:
4+
default:
5+
target: 70
6+
threshold: 5 # allow up to 5% drop from base before failing
7+
patch:
8+
default:
9+
target: 80
10+
threshold: 5

notebooks/tutorials/01_introduction_to_orcapod.ipynb

Lines changed: 252 additions & 261 deletions
Large diffs are not rendered by default.

src/orcapod/pipeline/graph.py

Lines changed: 73 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -27,29 +27,6 @@
2727
logger = logging.getLogger(__name__)
2828

2929

30-
# ---------------------------------------------------------------------------
31-
# Visualization helper (unrelated to pipeline node types)
32-
# ---------------------------------------------------------------------------
33-
34-
35-
class VizGraphNode:
36-
def __init__(self, label: str, id: int, kernel_type: str):
37-
self.label = label
38-
self.id = id
39-
self.kernel_type = kernel_type
40-
41-
def __hash__(self):
42-
return hash((self.id, self.kernel_type))
43-
44-
def __eq__(self, other):
45-
if not isinstance(other, VizGraphNode):
46-
return NotImplemented
47-
return (self.id, self.kernel_type) == (
48-
other.id,
49-
other.kernel_type,
50-
)
51-
52-
5330
# ---------------------------------------------------------------------------
5431
# Pipeline
5532
# ---------------------------------------------------------------------------
@@ -533,6 +510,19 @@ def _compute_pipeline_snapshot_hash(self) -> str:
533510
combined = "\n".join(node_lines + edge_lines)
534511
return hashlib.sha256(combined.encode()).hexdigest()[:16]
535512

513+
def show_graph(self, **kwargs) -> str | None:
514+
"""Render the pipeline's node graph.
515+
516+
Args:
517+
**kwargs: Forwarded to ``render_graph``.
518+
519+
Raises:
520+
RuntimeError: If the pipeline has not been compiled yet.
521+
"""
522+
if self._node_graph is None:
523+
raise RuntimeError("Pipeline must be compiled before showing the graph.")
524+
return render_graph(self._node_graph, **kwargs)
525+
536526
def flush(self) -> None:
537527
"""Flush all databases."""
538528
self._pipeline_database.flush()
@@ -806,8 +796,7 @@ def load(cls, path: str | Path, mode: str = "full") -> "Pipeline":
806796
all_upstreams_usable = (
807797
all(
808798
hasattr(n, "load_status")
809-
and n.load_status
810-
in (LoadStatus.FULL, LoadStatus.READ_ONLY)
799+
and n.load_status in (LoadStatus.FULL, LoadStatus.READ_ONLY)
811800
for n in upstream_nodes
812801
)
813802
if upstream_nodes
@@ -1141,7 +1130,7 @@ class GraphRenderer:
11411130
"style": "filled",
11421131
"typefontcolor": "lightgray", # Light text for dark background
11431132
},
1144-
"pod": {
1133+
"function": {
11451134
"fillcolor": "#090271", # darker navy blue
11461135
"shape": "cylinder",
11471136
"fontcolor": "white",
@@ -1153,26 +1142,22 @@ class GraphRenderer:
11531142
def __init__(self):
11541143
pass
11551144

1156-
def _sanitize_node_id(self, node_id: Any) -> str:
1145+
def _sanitize_node_id(self, node_id: GraphNode) -> str:
11571146
return f"node_{hash(node_id)}"
11581147

1159-
def _create_default_html_label(self, node, node_attrs) -> str:
1160-
"""
1161-
Create HTML for the label (text) section of the node
1148+
def _create_default_html_label(
1149+
self, node: GraphNode, node_attrs: dict[str, str]
1150+
) -> str:
1151+
"""Create HTML for the label (text) section of the node.
11621152
11631153
Format:
1164-
kernel_type (11pt, small text)
1154+
node_type (11pt, small text)
11651155
main_label (14pt, normal text)
11661156
"""
1157+
main_label = str(node.label)
1158+
node_type = node.node_type
11671159

1168-
main_label = str(node.label) if hasattr(node, "label") else str(node)
1169-
kernel_type = str(node.kernel_type) if hasattr(node, "kernel_type") else ""
1170-
1171-
if not kernel_type:
1172-
# No kernel_type, just return main label
1173-
return f'<FONT POINT-SIZE="{self.DEFAULT_STYLES["main_font_size"]}">{main_label}</FONT>'
1174-
1175-
# Create HTML label: small kernel_type above, main label below
1160+
# Create HTML label: small node_type above, main label below
11761161
main_size = self.DEFAULT_STYLES["main_font_size"]
11771162
type_size = self.DEFAULT_STYLES["type_font_size"]
11781163
font_name = self.DEFAULT_STYLES["font_name"]
@@ -1182,30 +1167,30 @@ def _create_default_html_label(self, node, node_attrs) -> str:
11821167

11831168
html_label = f'''<
11841169
<TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0">
1185-
<TR><TD ALIGN="CENTER"><FONT POINT-SIZE="{type_size}" COLOR="{type_font_color}" FACE="{font_name}, bold">{kernel_type}</FONT></TD></TR>
1170+
<TR><TD ALIGN="CENTER"><FONT POINT-SIZE="{type_size}" COLOR="{type_font_color}" FACE="{font_name}, bold">{node_type}</FONT></TD></TR>
11861171
<TR><TD ALIGN="CENTER"><FONT POINT-SIZE="{main_size}">{main_label}</FONT></TD></TR>
11871172
</TABLE>
11881173
>'''
11891174

11901175
return html_label
11911176

11921177
def _get_node_label(
1193-
self, node_id: Any, label_lut: dict[Any, str] | None = None
1178+
self,
1179+
node: GraphNode,
1180+
label_lut: dict[GraphNode, str] | None = None,
11941181
) -> str:
1195-
if label_lut and node_id in label_lut:
1196-
return label_lut[node_id]
1197-
return str(node_id)
1182+
if label_lut and node in label_lut:
1183+
return label_lut[node]
1184+
return str(node.label)
11981185

11991186
def _get_node_attributes(
1200-
self, node_id: Any, style_rules: dict | None = None
1187+
self,
1188+
node: GraphNode,
1189+
style_rules: dict[str, dict[str, str]] | None = None,
12011190
) -> dict[str, str]:
1202-
"""
1203-
Get styling attributes for a specific node based on its properties
1204-
"""
1205-
# Use provided rules or defaults
1191+
"""Get styling attributes for a specific node based on its node_type."""
12061192
rules = style_rules or self.DEFAULT_STYLE_RULES
12071193

1208-
# Default attributes
12091194
default_attrs = {
12101195
"fillcolor": self.DEFAULT_STYLES["node_color"],
12111196
"shape": self.DEFAULT_STYLES["node_shape"],
@@ -1216,13 +1201,8 @@ def _get_node_attributes(
12161201
"typefontcolor": self.DEFAULT_STYLES["type_font_color"],
12171202
}
12181203

1219-
# Check if node has kernel_type attribute
1220-
if hasattr(node_id, "kernel_type"):
1221-
kernel_type = node_id.kernel_type
1222-
if kernel_type in rules:
1223-
# Override defaults with rule-specific attributes
1224-
rule_attrs = rules[kernel_type].copy()
1225-
default_attrs.update(rule_attrs)
1204+
if node.node_type in rules:
1205+
default_attrs.update(rules[node.node_type])
12261206

12271207
return default_attrs
12281208

@@ -1238,11 +1218,10 @@ def _merge_styles(self, **override_styles) -> dict:
12381218
def generate_dot(
12391219
self,
12401220
graph: "nx.DiGraph",
1241-
label_lut: dict[Any, str] | None = None,
1242-
style_rules: dict | None = None,
1221+
label_lut: dict[GraphNode, str] | None = None,
1222+
style_rules: dict[str, dict[str, str]] | None = None,
12431223
**style_overrides,
12441224
) -> str:
1245-
# Get final styles (defaults + overrides)
12461225
styles = self._merge_styles(**style_overrides)
12471226

12481227
import graphviz
@@ -1252,28 +1231,24 @@ def generate_dot(
12521231
# Apply global styles
12531232
dot.attr(rankdir=styles["rankdir"], dpi=str(styles["dpi"]))
12541233
dot.attr(fontname=styles["font_name"])
1255-
if styles.get("font_size"):
1256-
dot.attr(fontsize=styles["fontsize"])
1234+
if styles.get("fontsize"):
1235+
dot.attr(fontsize=str(styles["fontsize"]))
12571236
if styles["font_path"]:
12581237
dot.attr(fontpath=styles["font_path"])
12591238

12601239
# Set default edge attributes
12611240
dot.attr("edge", color=styles["edge_color"])
12621241

1263-
# Add nodes with default attribute specific styling
1264-
for node_id in graph.nodes():
1265-
sanitized_id = self._sanitize_node_id(node_id)
1242+
# Add nodes with styling based on node_type
1243+
for node in graph.nodes():
1244+
sanitized_id = self._sanitize_node_id(node)
1245+
node_attrs = self._get_node_attributes(node, style_rules)
12661246

1267-
node_attrs = self._get_node_attributes(node_id, style_rules)
1268-
1269-
if label_lut and node_id in label_lut:
1270-
# Use custom label if provided
1271-
label = label_lut[node_id]
1247+
if label_lut and node in label_lut:
1248+
label = label_lut[node]
12721249
else:
1273-
# Use default HTML label with kernel_type above main label
1274-
label = self._create_default_html_label(node_id, node_attrs)
1250+
label = self._create_default_html_label(node, node_attrs)
12751251

1276-
# Add nodes with its specific attributes
12771252
dot.node(sanitized_id, label=label, **node_attrs)
12781253

12791254
# Add edges
@@ -1287,13 +1262,13 @@ def generate_dot(
12871262
def render_graph(
12881263
self,
12891264
graph: "nx.DiGraph",
1290-
label_lut: dict[Any, str] | None = None,
1265+
label_lut: dict[GraphNode, str] | None = None,
12911266
show: bool = True,
12921267
output_path: str | None = None,
12931268
raw_output: bool = False,
12941269
figsize: tuple = (12, 8),
12951270
dpi: int = 150,
1296-
style_rules: dict | None = None,
1271+
style_rules: dict[str, dict[str, str]] | None = None,
12971272
**style_overrides,
12981273
) -> str | None:
12991274
# Always generate DOT first
@@ -1321,14 +1296,14 @@ def render_graph(
13211296
dot.attr("edge", color=styles["edge_color"])
13221297

13231298
# Add nodes with specific styling
1324-
for node_id in graph.nodes():
1325-
sanitized_id = self._sanitize_node_id(node_id)
1326-
node_attrs = self._get_node_attributes(node_id, style_rules)
1299+
for node in graph.nodes():
1300+
sanitized_id = self._sanitize_node_id(node)
1301+
node_attrs = self._get_node_attributes(node, style_rules)
13271302

1328-
if label_lut and node_id in label_lut:
1329-
label = label_lut[node_id]
1303+
if label_lut and node in label_lut:
1304+
label = label_lut[node]
13301305
else:
1331-
label = self._create_default_html_label(node_id, node_attrs)
1306+
label = self._create_default_html_label(node, node_attrs)
13321307

13331308
dot.node(sanitized_id, label=label, **node_attrs)
13341309

@@ -1367,30 +1342,28 @@ def render_graph(
13671342
# =====================
13681343
def render_graph(
13691344
graph: "nx.DiGraph",
1370-
label_lut: dict[Any, str] | None = None,
1371-
style_rules: dict | None = None,
1345+
label_lut: dict[GraphNode, str] | None = None,
1346+
style_rules: dict[str, dict[str, str]] | None = None,
13721347
**kwargs,
13731348
) -> str | None:
1374-
"""
1375-
Convenience function with conditional node styling
1349+
"""Convenience function with conditional node styling.
13761350
13771351
Args:
1378-
graph: NetworkX DiGraph
1379-
label_lut: Optional node labels
1380-
style_rules: Dict mapping node attributes to styling rules
1381-
**kwargs: Other styling arguments
1352+
graph: NetworkX DiGraph whose nodes are GraphNode instances.
1353+
label_lut: Optional mapping from node to custom display label.
1354+
style_rules: Mapping from node_type to graphviz attribute overrides.
1355+
**kwargs: Other styling arguments forwarded to GraphRenderer.
13821356
"""
13831357
renderer = GraphRenderer()
13841358
return renderer.render_graph(graph, label_lut, style_rules=style_rules, **kwargs)
13851359

13861360

13871361
def render_graph_dark_theme(
1388-
graph: "nx.DiGraph", label_lut: dict[Any, str] | None = None, **kwargs
1362+
graph: "nx.DiGraph",
1363+
label_lut: dict[GraphNode, str] | None = None,
1364+
**kwargs,
13891365
) -> str | None:
1390-
"""
1391-
Render with dark theme - all backgrounds dark, all pod type fonts light
1392-
Perfect for dark themed presentations or displays
1393-
"""
1366+
"""Render with dark theme — dark backgrounds, light fonts."""
13941367
renderer = GraphRenderer()
13951368
return renderer.render_graph(
13961369
graph, label_lut, style_rules=renderer.DARK_THEME_RULES, **kwargs
@@ -1425,29 +1398,29 @@ def create_custom_rules(
14251398
pod_main_fcolor="white",
14261399
source_type_fcolor="darkgray",
14271400
operator_type_fcolor="darkgray",
1428-
kernel_type_fcolor="lightgray",
1429-
):
1430-
"""Create custom theme rules"""
1401+
node_type_fcolor="lightgray",
1402+
) -> dict[str, dict[str, str]]:
1403+
"""Create custom theme rules."""
14311404
return {
14321405
"source": {
14331406
"fillcolor": source_bg,
14341407
"shape": "ellipse",
14351408
"fontcolor": source_main_fcolor,
14361409
"style": "filled",
1437-
"type_font_color": source_type_fcolor,
1410+
"typefontcolor": source_type_fcolor,
14381411
},
14391412
"operator": {
14401413
"fillcolor": operator_bg,
14411414
"shape": "diamond",
14421415
"fontcolor": operator_main_fcolor,
14431416
"style": "filled",
1444-
"type_font_color": operator_type_fcolor,
1417+
"typefontcolor": operator_type_fcolor,
14451418
},
14461419
"function": {
14471420
"fillcolor": pod_bg,
14481421
"shape": "box",
14491422
"fontcolor": pod_main_fcolor,
14501423
"style": "filled,rounded",
1451-
"type_font_color": kernel_type_fcolor,
1424+
"typefontcolor": node_type_fcolor,
14521425
},
14531426
}

0 commit comments

Comments
 (0)