spolvero 0.3.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.
spolvero/ir.py ADDED
@@ -0,0 +1,248 @@
1
+ """Normalized IR generation and Mermaid flowchart parsing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html
6
+ import re
7
+ from typing import Any
8
+
9
+ from spolvero.constants import SCHEMA_VERSION
10
+ from spolvero.models import DiagramSource
11
+ from spolvero.source import first_significant_line
12
+
13
+
14
+ def normalize_diagram(diagram: DiagramSource) -> dict[str, Any]:
15
+ if diagram.source_type == "mermaid_flowchart":
16
+ return parse_mermaid_flowchart(diagram)
17
+ if diagram.source_type and diagram.source_type.startswith("topology"):
18
+ from spolvero.topology import parse_topology
19
+
20
+ return parse_topology(diagram)
21
+ warnings = list(diagram.warnings)
22
+ warnings.append("No normalized native-shape IR was produced for this source type.")
23
+ return {
24
+ "schema_version": SCHEMA_VERSION,
25
+ "id": diagram.id,
26
+ "title": diagram.title,
27
+ "diagram_type": "reference",
28
+ "orientation": "unknown",
29
+ "pages": [{"id": "page-001", "title": diagram.title, "size": "Letter Landscape"}],
30
+ "nodes": [],
31
+ "edges": [],
32
+ "containers": [],
33
+ "warnings": list(dict.fromkeys(warnings)),
34
+ }
35
+
36
+
37
+ def parse_mermaid_flowchart(diagram: DiagramSource) -> dict[str, Any]:
38
+ lines = diagram.text.splitlines()
39
+ first = first_significant_line(diagram.text)
40
+ orientation = "top_down"
41
+ direction_match = re.match(r"^(?:flowchart|graph)\s+([A-Za-z]{2})\b", first, re.IGNORECASE)
42
+ if direction_match:
43
+ direction = direction_match.group(1).upper()
44
+ if direction in {"LR", "RL"}:
45
+ orientation = "left_to_right"
46
+ elif direction in {"TD", "TB", "BT"}:
47
+ orientation = "top_down"
48
+
49
+ nodes: dict[str, dict[str, Any]] = {}
50
+ edges: list[dict[str, Any]] = []
51
+ containers: dict[str, dict[str, Any]] = {}
52
+ warnings: list[str] = []
53
+ container_stack: list[str] = []
54
+ ref_file = diagram.origin_file or diagram.source_file or f"{diagram.id}.mmd"
55
+
56
+ def current_container() -> str:
57
+ return container_stack[-1] if container_stack else ""
58
+
59
+ def upsert_node(endpoint: str, source_ref: str) -> str:
60
+ node_id, label, shape_kind = parse_mermaid_endpoint(endpoint)
61
+ if not node_id:
62
+ fallback_id = f"N{len(nodes) + 1:03d}"
63
+ warnings.append(f"Could not parse node endpoint '{endpoint}' at {source_ref}; assigned {fallback_id}.")
64
+ node_id, label, shape_kind = fallback_id, endpoint.strip(), "process"
65
+ if node_id not in nodes:
66
+ nodes[node_id] = {
67
+ "id": node_id,
68
+ "label": label or node_id,
69
+ "shape_kind": shape_kind,
70
+ "container": current_container(),
71
+ "metadata": {"source_ref": source_ref},
72
+ }
73
+ else:
74
+ if label and nodes[node_id]["label"] == node_id:
75
+ nodes[node_id]["label"] = label
76
+ if shape_kind != "process":
77
+ nodes[node_id]["shape_kind"] = shape_kind
78
+ if current_container() and not nodes[node_id].get("container"):
79
+ nodes[node_id]["container"] = current_container()
80
+ return node_id
81
+
82
+ for offset, raw_line in enumerate(lines, start=diagram.line_offset):
83
+ for statement in split_mermaid_statements(raw_line):
84
+ stripped = strip_mermaid_comment(statement).strip()
85
+ if not stripped:
86
+ continue
87
+ if re.match(r"^(flowchart|graph)\b", stripped, re.IGNORECASE):
88
+ continue
89
+ source_ref = f"{ref_file}:{offset}"
90
+ lower = stripped.lower()
91
+ if lower.startswith("subgraph "):
92
+ container_id, container_label = parse_subgraph(stripped)
93
+ container_stack.append(container_id)
94
+ containers[container_id] = {"id": container_id, "label": container_label, "kind": "swimlane"}
95
+ continue
96
+ if lower == "end":
97
+ if container_stack:
98
+ container_stack.pop()
99
+ else:
100
+ warnings.append(f"Unexpected Mermaid subgraph end at {source_ref}.")
101
+ continue
102
+ if lower.startswith(("classdef ", "class ", "style ", "linkstyle ", "click ")):
103
+ warnings.append(f"Skipped Mermaid styling/directive at {source_ref}: {stripped}")
104
+ continue
105
+
106
+ edge = parse_mermaid_edge(stripped)
107
+ if edge:
108
+ src_id = upsert_node(edge["src"], source_ref)
109
+ dst_id = upsert_node(edge["dst"], source_ref)
110
+ edges.append(
111
+ {
112
+ "id": f"E{len(edges) + 1:03d}",
113
+ "from": src_id,
114
+ "to": dst_id,
115
+ "label": edge.get("label", ""),
116
+ "kind": "dynamic_connector",
117
+ "metadata": {"source_ref": source_ref, "mermaid_arrow": edge.get("arrow", "-->")},
118
+ }
119
+ )
120
+ if looks_like_chained_endpoint(edge["dst"]):
121
+ warnings.append(f"Chained edge may require manual review at {source_ref}: {stripped}")
122
+ continue
123
+
124
+ node_id, _, _ = parse_mermaid_endpoint(stripped)
125
+ if node_id:
126
+ upsert_node(stripped, source_ref)
127
+ else:
128
+ warnings.append(f"Skipped unsupported Mermaid statement at {source_ref}: {stripped}")
129
+
130
+ if container_stack:
131
+ warnings.append("One or more Mermaid subgraphs were not closed with `end`.")
132
+ if not nodes:
133
+ warnings.append("No nodes were recognized in the Mermaid flowchart.")
134
+ if not edges:
135
+ warnings.append("No edges were recognized in the Mermaid flowchart.")
136
+
137
+ return {
138
+ "schema_version": SCHEMA_VERSION,
139
+ "id": diagram.id,
140
+ "title": diagram.title,
141
+ "diagram_type": "flowchart",
142
+ "orientation": orientation,
143
+ "pages": [{"id": "page-001", "title": diagram.title, "size": "Letter Landscape"}],
144
+ "nodes": list(nodes.values()),
145
+ "edges": edges,
146
+ "containers": list(containers.values()),
147
+ "warnings": list(dict.fromkeys(diagram.warnings + warnings)),
148
+ }
149
+
150
+
151
+ def split_mermaid_statements(line: str) -> list[str]:
152
+ stripped = line.strip()
153
+ if not stripped:
154
+ return []
155
+ return [part for part in stripped.split(";") if part.strip()]
156
+
157
+
158
+ def strip_mermaid_comment(line: str) -> str:
159
+ stripped = line.strip()
160
+ if stripped.startswith("%%"):
161
+ return ""
162
+ if "%%" in line:
163
+ return line.split("%%", 1)[0]
164
+ return line
165
+
166
+
167
+ def parse_subgraph(statement: str) -> tuple[str, str]:
168
+ rest = statement.split(None, 1)[1].strip()
169
+ bracket_match = re.match(r"(?P<id>[A-Za-z0-9_.$:/-]+)?\s*\[(?P<label>.+)\]$", rest)
170
+ if bracket_match:
171
+ label = clean_label(bracket_match.group("label"))
172
+ raw_id = bracket_match.group("id") or label
173
+ return sanitize_id(raw_id), label
174
+ return sanitize_id(rest), clean_label(rest)
175
+
176
+
177
+ def parse_mermaid_edge(statement: str) -> dict[str, str] | None:
178
+ patterns = [
179
+ (r"^(?P<src>.+?)\s*-->\|(?P<label>.*?)\|\s*(?P<dst>.+)$", "-->"),
180
+ (r"^(?P<src>.+?)\s*--\s*(?P<label>.*?)\s*-->\s*(?P<dst>.+)$", "-->"),
181
+ (r"^(?P<src>.+?)\s*-->\s*(?P<dst>.+)$", "-->"),
182
+ (r"^(?P<src>.+?)\s*---\|(?P<label>.*?)\|\s*(?P<dst>.+)$", "---"),
183
+ (r"^(?P<src>.+?)\s*---\s*(?P<dst>.+)$", "---"),
184
+ (r"^(?P<src>.+?)\s*==>\|(?P<label>.*?)\|\s*(?P<dst>.+)$", "==>"),
185
+ (r"^(?P<src>.+?)\s*==\s*(?P<label>.*?)\s*==>\s*(?P<dst>.+)$", "==>"),
186
+ (r"^(?P<src>.+?)\s*==>\s*(?P<dst>.+)$", "==>"),
187
+ (r"^(?P<src>.+?)\s*-\.\->\|(?P<label>.*?)\|\s*(?P<dst>.+)$", "-.->"),
188
+ (r"^(?P<src>.+?)\s*-\.\s*(?P<label>.*?)\s*-\.\->\s*(?P<dst>.+)$", "-.->"),
189
+ (r"^(?P<src>.+?)\s*-\.\->\s*(?P<dst>.+)$", "-.->"),
190
+ ]
191
+ for pattern, arrow in patterns:
192
+ match = re.match(pattern, statement)
193
+ if match:
194
+ return {
195
+ "src": match.group("src").strip(),
196
+ "dst": match.group("dst").strip(),
197
+ "label": clean_label(match.groupdict().get("label", "") or ""),
198
+ "arrow": arrow,
199
+ }
200
+ return None
201
+
202
+
203
+ def looks_like_chained_endpoint(endpoint: str) -> bool:
204
+ return any(token in endpoint for token in (" --> ", "-->", " --- ", "---", "==>", "-.->"))
205
+
206
+
207
+ def parse_mermaid_endpoint(endpoint: str) -> tuple[str, str, str]:
208
+ token = endpoint.strip()
209
+ token = re.sub(r"\s*:::[A-Za-z0-9_-]+$", "", token)
210
+ token = token.strip()
211
+ node_id = r"(?P<id>[A-Za-z0-9_.$:/-]+)"
212
+ patterns = [
213
+ (rf"^{node_id}\s*\(\[(?P<label>.+)\]\)$", "terminator"),
214
+ (rf"^{node_id}\s*\[\((?P<label>.+)\)\]$", "database"),
215
+ (rf"^{node_id}\s*\[\[(?P<label>.+)\]\]$", "subprocess"),
216
+ (rf"^{node_id}\s*\[/(?P<label>.+)/\]$", "data_io"),
217
+ (rf"^{node_id}\s*\[\\(?P<label>.+)\\\]$", "data_io"),
218
+ (rf"^{node_id}\s*\{{\{{(?P<label>.+)\}}\}}$", "decision"),
219
+ (rf"^{node_id}\s*\{{(?P<label>.+)\}}$", "decision"),
220
+ (rf"^{node_id}\s*\(\((?P<label>.+)\)\)$", "circle"),
221
+ (rf"^{node_id}\s*\((?P<label>.+)\)$", "rounded"),
222
+ (rf"^{node_id}\s*>\s*(?P<label>.+)\]$", "document"),
223
+ (rf"^{node_id}\s*\[(?P<label>.+)\]$", "process"),
224
+ ]
225
+ for pattern, shape_kind in patterns:
226
+ match = re.match(pattern, token)
227
+ if match:
228
+ return match.group("id"), clean_label(match.group("label")), shape_kind
229
+ if re.match(r"^[A-Za-z0-9_.$:/-]+$", token):
230
+ return token, token, "process"
231
+ return "", clean_label(token), "process"
232
+
233
+
234
+ def clean_label(label: str) -> str:
235
+ label = label.strip()
236
+ if (label.startswith('"') and label.endswith('"')) or (label.startswith("'") and label.endswith("'")):
237
+ label = label[1:-1]
238
+ return html.unescape(label.replace("<br/>", " ").replace("<br>", " ").strip())
239
+
240
+
241
+ def sanitize_id(value: str) -> str:
242
+ cleaned = re.sub(r"[^A-Za-z0-9_.-]+", "_", clean_label(value)).strip("_")
243
+ return cleaned or "container"
244
+
245
+
246
+ def is_flowchart_ir(ir: dict[str, Any]) -> bool:
247
+ return ir.get("diagram_type") == "flowchart" and bool(ir.get("nodes"))
248
+
spolvero/models.py ADDED
@@ -0,0 +1,19 @@
1
+ """Data models used across package generation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+
8
+ @dataclass
9
+ class DiagramSource:
10
+ id: str
11
+ title: str
12
+ language: str
13
+ source_type: str
14
+ text: str
15
+ line_offset: int = 1
16
+ source_file: str = ""
17
+ origin_file: str = ""
18
+ warnings: list[str] = field(default_factory=list)
19
+