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/__init__.py +7 -0
- spolvero/builder.py +200 -0
- spolvero/cli.py +118 -0
- spolvero/constants.py +55 -0
- spolvero/engines/__init__.py +102 -0
- spolvero/engines/data_visualizer.py +56 -0
- spolvero/engines/package_only.py +28 -0
- spolvero/engines/visio_com.py +118 -0
- spolvero/engines/vsdx_template.py +251 -0
- spolvero/hygiene.py +205 -0
- spolvero/inspection.py +222 -0
- spolvero/ir.py +248 -0
- spolvero/models.py +19 -0
- spolvero/render.py +564 -0
- spolvero/reports.py +180 -0
- spolvero/schema.py +65 -0
- spolvero/schemas/__init__.py +2 -0
- spolvero/schemas/ir.schema.json +83 -0
- spolvero/schemas/manifest.schema.json +74 -0
- spolvero/schemas/topology.schema.json +85 -0
- spolvero/source.py +160 -0
- spolvero/templates/LICENSE.txt +42 -0
- spolvero/templates/minimal_flowchart.vsdx +0 -0
- spolvero/topology/__init__.py +27 -0
- spolvero/topology/model.py +275 -0
- spolvero/utils.py +57 -0
- spolvero/workbook.py +200 -0
- spolvero-0.3.0.dist-info/METADATA +106 -0
- spolvero-0.3.0.dist-info/RECORD +31 -0
- spolvero-0.3.0.dist-info/WHEEL +4 -0
- spolvero-0.3.0.dist-info/entry_points.txt +2 -0
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
|
+
|