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/reports.py ADDED
@@ -0,0 +1,180 @@
1
+ """Markdown report generation for handoff packages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from spolvero.models import DiagramSource
9
+ from spolvero.utils import write_text
10
+
11
+
12
+ def write_template_notes(
13
+ path: Path,
14
+ diagram: DiagramSource,
15
+ ir: dict[str, Any],
16
+ target: str,
17
+ editability: str,
18
+ warnings: list[str],
19
+ ) -> None:
20
+ containers = ir.get("containers", [])
21
+ lines = [
22
+ f"# Visio Template Notes: {diagram.id}",
23
+ "",
24
+ f"- Requested target: `{target}`",
25
+ f"- Current editability: `{editability}`",
26
+ f"- Source type: `{diagram.source_type}`",
27
+ f"- Nodes recognized: {len(ir.get('nodes', []))}",
28
+ f"- Edges recognized: {len(ir.get('edges', []))}",
29
+ f"- Containers/swimlanes: {len(containers)}",
30
+ "",
31
+ ]
32
+ if containers:
33
+ container_labels = ", ".join(f"`{c.get('label', c['id'])}`" for c in containers)
34
+ lines.extend(
35
+ [
36
+ "## Recommended Visio Template",
37
+ "",
38
+ f"This diagram declares {len(containers)} container(s) ({container_labels}). When importing the generated Data Visualizer workbook into Visio, choose the **Cross-Functional Flowchart** (swimlanes) template — not Basic Flowchart — so the `Function` column maps to swimlanes.",
39
+ "",
40
+ ]
41
+ )
42
+ else:
43
+ lines.extend(
44
+ [
45
+ "## Recommended Visio Template",
46
+ "",
47
+ "No containers were detected. Importing into Visio's **Basic Flowchart** template is appropriate.",
48
+ "",
49
+ ]
50
+ )
51
+ lines.extend(
52
+ [
53
+ "## Native Visio Follow-Up",
54
+ "",
55
+ "Use the generated Data Visualizer workbook for the first Visio-native pass when available. For higher fidelity, map the normalized IR to organization-approved templates and stencils, then generate native `.vsdx` with Visio COM automation or a tested VSDX template mutation engine.",
56
+ "",
57
+ "## Warnings",
58
+ "",
59
+ ]
60
+ )
61
+ if warnings:
62
+ lines.extend(f"- {warning}" for warning in warnings)
63
+ else:
64
+ lines.append("- None")
65
+ write_text(path, "\n".join(lines) + "\n")
66
+
67
+
68
+ def write_qa_report(
69
+ path: Path,
70
+ diagram: DiagramSource,
71
+ ir: dict[str, Any],
72
+ editability: str,
73
+ engine: str,
74
+ score: int,
75
+ warnings: list[str],
76
+ workbook_path: Path | None,
77
+ svg_path: Path,
78
+ ) -> None:
79
+ source = diagram.source_file
80
+ workbook_line = f"- Data Visualizer workbook: `{workbook_path.name}`" if workbook_path else "- Data Visualizer workbook: not generated"
81
+ warnings_block = "\n".join(f"- {warning}" for warning in warnings) if warnings else "- None"
82
+ if warnings:
83
+ warning_summary = f"**{len(warnings)} warning(s) — review before sharing.** See the Warnings section below."
84
+ else:
85
+ warning_summary = "**No warnings.**"
86
+ content = f"""# Diagram QA Report: {diagram.id}
87
+
88
+ {warning_summary}
89
+
90
+ ## Summary
91
+
92
+ - Source: `{source}`
93
+ - Preview: `{svg_path.name}`
94
+ {workbook_line}
95
+ - Editability: `{editability}`
96
+ - Engine: `{engine}`
97
+ - Conversion score: {score}/100
98
+
99
+ ## Preserved
100
+
101
+ - Node labels: {len(ir.get("nodes", []))}/{len(ir.get("nodes", []))}
102
+ - Edges: {len(ir.get("edges", []))}/{len(ir.get("edges", []))}
103
+ - Containers/swimlanes: {len(ir.get("containers", []))}
104
+ - Source references: {"yes" if ir.get("nodes") else "not available"}
105
+
106
+ ## Warnings
107
+
108
+ {warnings_block}
109
+
110
+ ## Manual Review Checklist
111
+
112
+ - [ ] Confirm all node labels are readable.
113
+ - [ ] Confirm connector directions.
114
+ - [ ] Confirm decision branch labels.
115
+ - [ ] Confirm title block and document metadata.
116
+ - [ ] Confirm shape data is suitable before external sharing.
117
+ - [ ] Decide whether future edits belong in source or only in Visio.
118
+ """
119
+ write_text(path, content)
120
+
121
+
122
+ def conversion_score(ir: dict[str, Any], editability: str, warnings: list[str]) -> int:
123
+ score = 100
124
+ if editability == "vector_reference":
125
+ score -= 25
126
+ if editability == "raster_reference":
127
+ score -= 40
128
+ if not ir.get("nodes"):
129
+ score -= 20
130
+ if not ir.get("edges") and ir.get("diagram_type") == "flowchart":
131
+ score -= 15
132
+ score -= min(30, len(warnings) * 5)
133
+ return max(0, score)
134
+
135
+
136
+ def write_package_readme(path: Path, input_path: Path, target: str, rows: list[tuple[dict[str, Any], int]]) -> None:
137
+ diagram_lines = []
138
+ for entry, score in rows:
139
+ first = entry.get("data_visualizer_workbook") or entry.get("preview_svg")
140
+ warnings = entry.get("warnings") or []
141
+ warning_text = " ".join(warnings) if warnings else "No warnings."
142
+ diagram_lines.append(
143
+ "\n".join(
144
+ [
145
+ f"### {entry['id']}: {entry['title']}",
146
+ "",
147
+ f"- Open first: `{first}`",
148
+ f"- Editability: `{entry['editability']}`",
149
+ f"- Engine: `{entry['conversion_engine']}`",
150
+ f"- QA score: {score}/100",
151
+ f"- Known limitations: {warning_text}",
152
+ ]
153
+ )
154
+ )
155
+ content = f"""# Visio Handoff Package
156
+
157
+ ## What Was Generated
158
+
159
+ This package was generated from `{input_path.name}` with target `{target}`. It preserves the original source, extracted diagram source, preview images, normalized IR, Visio handoff candidates, and QA reports.
160
+
161
+ ## Which File To Open First
162
+
163
+ Start with the entries below, then review `qa/` before making final Visio edits.
164
+
165
+ {chr(10).join(diagram_lines)}
166
+
167
+ ## Editability Declaration
168
+
169
+ Generated Visio workbooks are downstream handoff artifacts. The original docs-as-code source remains canonical unless the project explicitly chooses a Visio-first lifecycle.
170
+
171
+ ## Manual Review Checklist
172
+
173
+ - [ ] Confirm all labels and connector directions.
174
+ - [ ] Review warnings in each QA report.
175
+ - [ ] Open the Data Visualizer workbook in Excel/Visio if present.
176
+ - [ ] Apply organization templates, stencils, title blocks, and approvals in Visio.
177
+ - [ ] Push durable content edits back to the source diagram when source remains canonical.
178
+ """
179
+ write_text(path, content)
180
+
spolvero/schema.py ADDED
@@ -0,0 +1,65 @@
1
+ """Small schema loader and validator for generated artifacts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from importlib.resources import files
7
+ from typing import Any
8
+
9
+
10
+ class SchemaValidationError(ValueError):
11
+ """Raised when generated JSON does not match the bundled schema subset."""
12
+
13
+
14
+ def load_schema(name: str) -> dict[str, Any]:
15
+ schema_path = files("spolvero.schemas").joinpath(name)
16
+ return json.loads(schema_path.read_text(encoding="utf-8"))
17
+
18
+
19
+ def validate_manifest(manifest: dict[str, Any]) -> None:
20
+ validate_with_schema(manifest, load_schema("manifest.schema.json"))
21
+
22
+
23
+ def validate_ir(ir: dict[str, Any]) -> None:
24
+ if ir.get("diagram_type") == "topology":
25
+ validate_with_schema(ir, load_schema("topology.schema.json"))
26
+ return
27
+ validate_with_schema(ir, load_schema("ir.schema.json"))
28
+
29
+
30
+ def validate_with_schema(value: Any, schema: dict[str, Any], path: str = "$") -> None:
31
+ expected_type = schema.get("type")
32
+ if expected_type:
33
+ _assert_type(value, expected_type, path)
34
+
35
+ if "enum" in schema and value not in schema["enum"]:
36
+ raise SchemaValidationError(f"{path} must be one of {schema['enum']!r}")
37
+
38
+ if isinstance(value, dict):
39
+ for key in schema.get("required", []):
40
+ if key not in value:
41
+ raise SchemaValidationError(f"{path}.{key} is required")
42
+ properties = schema.get("properties", {})
43
+ for key, child_schema in properties.items():
44
+ if key in value:
45
+ validate_with_schema(value[key], child_schema, f"{path}.{key}")
46
+
47
+ if isinstance(value, list) and "items" in schema:
48
+ for index, item in enumerate(value):
49
+ validate_with_schema(item, schema["items"], f"{path}[{index}]")
50
+
51
+
52
+ def _assert_type(value: Any, expected_type: str | list[str], path: str) -> None:
53
+ types = [expected_type] if isinstance(expected_type, str) else expected_type
54
+ checks = {
55
+ "object": lambda candidate: isinstance(candidate, dict),
56
+ "array": lambda candidate: isinstance(candidate, list),
57
+ "string": lambda candidate: isinstance(candidate, str),
58
+ "integer": lambda candidate: isinstance(candidate, int) and not isinstance(candidate, bool),
59
+ "number": lambda candidate: isinstance(candidate, int | float) and not isinstance(candidate, bool),
60
+ "boolean": lambda candidate: isinstance(candidate, bool),
61
+ "null": lambda candidate: candidate is None,
62
+ }
63
+ if not any(checks[type_name](value) for type_name in types):
64
+ raise SchemaValidationError(f"{path} expected {types!r}, got {type(value).__name__}")
65
+
@@ -0,0 +1,2 @@
1
+ """Bundled JSON schemas."""
2
+
@@ -0,0 +1,83 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://example.invalid/spolvero/ir.schema.json",
4
+ "title": "Visio Handoff Normalized IR",
5
+ "type": "object",
6
+ "required": [
7
+ "schema_version",
8
+ "id",
9
+ "title",
10
+ "diagram_type",
11
+ "orientation",
12
+ "pages",
13
+ "nodes",
14
+ "edges",
15
+ "containers",
16
+ "warnings"
17
+ ],
18
+ "properties": {
19
+ "schema_version": { "type": "string" },
20
+ "id": { "type": "string" },
21
+ "title": { "type": "string" },
22
+ "diagram_type": { "type": "string", "enum": ["flowchart", "reference"] },
23
+ "orientation": { "type": "string" },
24
+ "pages": {
25
+ "type": "array",
26
+ "items": {
27
+ "type": "object",
28
+ "required": ["id", "title", "size"],
29
+ "properties": {
30
+ "id": { "type": "string" },
31
+ "title": { "type": "string" },
32
+ "size": { "type": "string" }
33
+ }
34
+ }
35
+ },
36
+ "nodes": {
37
+ "type": "array",
38
+ "items": {
39
+ "type": "object",
40
+ "required": ["id", "label", "shape_kind", "metadata"],
41
+ "properties": {
42
+ "id": { "type": "string" },
43
+ "label": { "type": "string" },
44
+ "shape_kind": { "type": "string" },
45
+ "container": { "type": "string" },
46
+ "metadata": { "type": "object" }
47
+ }
48
+ }
49
+ },
50
+ "edges": {
51
+ "type": "array",
52
+ "items": {
53
+ "type": "object",
54
+ "required": ["id", "from", "to", "label", "kind", "metadata"],
55
+ "properties": {
56
+ "id": { "type": "string" },
57
+ "from": { "type": "string" },
58
+ "to": { "type": "string" },
59
+ "label": { "type": "string" },
60
+ "kind": { "type": "string" },
61
+ "metadata": { "type": "object" }
62
+ }
63
+ }
64
+ },
65
+ "containers": {
66
+ "type": "array",
67
+ "items": {
68
+ "type": "object",
69
+ "required": ["id", "label", "kind"],
70
+ "properties": {
71
+ "id": { "type": "string" },
72
+ "label": { "type": "string" },
73
+ "kind": { "type": "string" }
74
+ }
75
+ }
76
+ },
77
+ "warnings": {
78
+ "type": "array",
79
+ "items": { "type": "string" }
80
+ }
81
+ }
82
+ }
83
+
@@ -0,0 +1,74 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://example.invalid/spolvero/manifest.schema.json",
4
+ "title": "Visio Handoff Package Manifest",
5
+ "type": "object",
6
+ "required": [
7
+ "schema_version",
8
+ "skill",
9
+ "version",
10
+ "created_at",
11
+ "source_files",
12
+ "target",
13
+ "diagrams"
14
+ ],
15
+ "properties": {
16
+ "schema_version": { "type": "string" },
17
+ "skill": { "type": "string" },
18
+ "version": { "type": "string" },
19
+ "created_at": { "type": "string" },
20
+ "source_files": {
21
+ "type": "array",
22
+ "items": { "type": ["string", "null"] }
23
+ },
24
+ "target": {
25
+ "type": "string",
26
+ "enum": ["package-only", "data-visualizer", "vsdx-template", "visio-com"]
27
+ },
28
+ "diagrams": {
29
+ "type": "array",
30
+ "items": {
31
+ "type": "object",
32
+ "required": [
33
+ "id",
34
+ "title",
35
+ "source_type",
36
+ "source",
37
+ "normalized_ir",
38
+ "output_vsdx",
39
+ "data_visualizer_workbook",
40
+ "preview_svg",
41
+ "preview_png",
42
+ "editability",
43
+ "conversion_engine",
44
+ "requested_engine",
45
+ "actual_engine",
46
+ "warnings"
47
+ ],
48
+ "properties": {
49
+ "id": { "type": "string" },
50
+ "title": { "type": "string" },
51
+ "source_type": { "type": "string" },
52
+ "source": { "type": "string" },
53
+ "normalized_ir": { "type": "string" },
54
+ "output_vsdx": { "type": ["string", "null"] },
55
+ "data_visualizer_workbook": { "type": ["string", "null"] },
56
+ "preview_svg": { "type": "string" },
57
+ "preview_png": { "type": "string" },
58
+ "editability": {
59
+ "type": "string",
60
+ "enum": ["native", "semi_native", "vector_reference", "raster_reference"]
61
+ },
62
+ "conversion_engine": { "type": "string" },
63
+ "requested_engine": { "type": "string" },
64
+ "actual_engine": { "type": "string" },
65
+ "warnings": {
66
+ "type": "array",
67
+ "items": { "type": "string" }
68
+ }
69
+ }
70
+ }
71
+ }
72
+ }
73
+ }
74
+
@@ -0,0 +1,85 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://example.invalid/spolvero/topology.schema.json",
4
+ "title": "Visio Handoff Topology IR",
5
+ "description": "Normalized intermediate representation for network diagrams and Technical Reference Diagrams (TRDs).",
6
+ "type": "object",
7
+ "required": [
8
+ "schema_version",
9
+ "id",
10
+ "title",
11
+ "diagram_type",
12
+ "nodes",
13
+ "edges",
14
+ "containers",
15
+ "warnings"
16
+ ],
17
+ "properties": {
18
+ "schema_version": { "type": "string" },
19
+ "id": { "type": "string" },
20
+ "title": { "type": "string" },
21
+ "diagram_type": { "type": "string", "enum": ["topology"] },
22
+ "orientation": {
23
+ "type": "string",
24
+ "enum": ["left_to_right", "top_down", "unknown"]
25
+ },
26
+ "pages": {
27
+ "type": "array",
28
+ "items": {
29
+ "type": "object",
30
+ "required": ["id", "title"],
31
+ "properties": {
32
+ "id": { "type": "string" },
33
+ "title": { "type": "string" },
34
+ "size": { "type": "string" }
35
+ }
36
+ }
37
+ },
38
+ "nodes": {
39
+ "type": "array",
40
+ "items": {
41
+ "type": "object",
42
+ "required": ["id", "label", "kind"],
43
+ "properties": {
44
+ "id": { "type": "string" },
45
+ "label": { "type": "string" },
46
+ "kind": { "type": "string" },
47
+ "container": { "type": ["string", "null"] },
48
+ "metadata": { "type": "object" }
49
+ }
50
+ }
51
+ },
52
+ "edges": {
53
+ "type": "array",
54
+ "items": {
55
+ "type": "object",
56
+ "required": ["id", "from", "to"],
57
+ "properties": {
58
+ "id": { "type": "string" },
59
+ "from": { "type": "string" },
60
+ "to": { "type": "string" },
61
+ "label": { "type": "string" },
62
+ "kind": { "type": "string" },
63
+ "metadata": { "type": "object" }
64
+ }
65
+ }
66
+ },
67
+ "containers": {
68
+ "type": "array",
69
+ "items": {
70
+ "type": "object",
71
+ "required": ["id", "label", "kind"],
72
+ "properties": {
73
+ "id": { "type": "string" },
74
+ "label": { "type": "string" },
75
+ "kind": { "type": "string" },
76
+ "parent": { "type": ["string", "null"] }
77
+ }
78
+ }
79
+ },
80
+ "warnings": {
81
+ "type": "array",
82
+ "items": { "type": "string" }
83
+ }
84
+ }
85
+ }
spolvero/source.py ADDED
@@ -0,0 +1,160 @@
1
+ """Input discovery and diagram source extraction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+
8
+ from spolvero.constants import DIAGRAM_LANGS
9
+ from spolvero.models import DiagramSource
10
+
11
+
12
+ def extract_diagrams(input_path: Path, title: str = "") -> list[DiagramSource]:
13
+ suffix = input_path.suffix.lower()
14
+ name = input_path.name.lower()
15
+
16
+ # Topology files: ``foo.topology.yaml`` / ``.yml`` / ``.json`` or any
17
+ # ``*.topology.*`` extension; also bare .topology files.
18
+ is_topology = (
19
+ ".topology." in name
20
+ or name.endswith((".topology.yaml", ".topology.yml", ".topology.json"))
21
+ )
22
+ if is_topology:
23
+ text = input_path.read_text(encoding="utf-8", errors="replace")
24
+ fmt = "json" if name.endswith(".json") else "yaml"
25
+ return [
26
+ DiagramSource(
27
+ id="diagram-001",
28
+ title=title or input_path.stem.replace(".topology", ""),
29
+ language="topology",
30
+ source_type=f"topology.{fmt}",
31
+ text=text,
32
+ source_file=input_path.name,
33
+ origin_file=input_path.name,
34
+ )
35
+ ]
36
+
37
+ if suffix in {".md", ".markdown"}:
38
+ return extract_markdown_diagrams(input_path, title)
39
+ if suffix in {".mmd", ".mermaid"}:
40
+ text = input_path.read_text(encoding="utf-8", errors="replace")
41
+ return [
42
+ DiagramSource(
43
+ id="diagram-001",
44
+ title=title or input_path.stem,
45
+ language="mermaid",
46
+ source_type=classify_source_type("mermaid", text),
47
+ text=text,
48
+ source_file=input_path.name,
49
+ origin_file=input_path.name,
50
+ )
51
+ ]
52
+ if suffix in {".puml", ".plantuml"}:
53
+ text = input_path.read_text(encoding="utf-8", errors="replace")
54
+ return [
55
+ DiagramSource(
56
+ id="diagram-001",
57
+ title=title or input_path.stem,
58
+ language="plantuml",
59
+ source_type="plantuml",
60
+ text=text,
61
+ source_file=input_path.name,
62
+ origin_file=input_path.name,
63
+ warnings=["PlantUML parsing is not implemented; preview/reference output only."],
64
+ )
65
+ ]
66
+ if suffix in {".dot", ".gv"}:
67
+ text = input_path.read_text(encoding="utf-8", errors="replace")
68
+ return [
69
+ DiagramSource(
70
+ id="diagram-001",
71
+ title=title or input_path.stem,
72
+ language="graphviz",
73
+ source_type="graphviz_dot",
74
+ text=text,
75
+ source_file=input_path.name,
76
+ origin_file=input_path.name,
77
+ warnings=["Graphviz parsing is not implemented; preview/reference output only."],
78
+ )
79
+ ]
80
+ if suffix in {".svg", ".png", ".jpg", ".jpeg", ".pdf"}:
81
+ editability = "vector" if suffix == ".svg" else "raster"
82
+ return [
83
+ DiagramSource(
84
+ id="diagram-001",
85
+ title=title or input_path.stem,
86
+ language="image",
87
+ source_type=f"reference{suffix}",
88
+ text=f"Reference artifact: {input_path.name}\n",
89
+ source_file=input_path.name,
90
+ origin_file=input_path.name,
91
+ warnings=[f"Existing {editability} references are packaged as review artifacts, not native Visio shapes."],
92
+ )
93
+ ]
94
+ return []
95
+
96
+
97
+ def extract_markdown_diagrams(input_path: Path, title: str = "") -> list[DiagramSource]:
98
+ content = input_path.read_text(encoding="utf-8", errors="replace")
99
+ fence_re = re.compile(
100
+ r"(?P<fence>```|~~~)\s*(?P<lang>[A-Za-z0-9_-]+)?[^\n]*\n(?P<body>.*?)(?:\n(?P=fence)\s*)",
101
+ re.DOTALL,
102
+ )
103
+ diagrams: list[DiagramSource] = []
104
+ for match in fence_re.finditer(content):
105
+ raw_lang = (match.group("lang") or "").strip().lower()
106
+ language = DIAGRAM_LANGS.get(raw_lang)
107
+ if not language:
108
+ continue
109
+ body = match.group("body").strip("\n")
110
+ line_offset = content[: match.start("body")].count("\n") + 1
111
+ diagram_title = title or nearest_heading(content[: match.start()]) or f"{input_path.stem} diagram {len(diagrams) + 1}"
112
+ warnings: list[str] = []
113
+ if language not in ("mermaid", "topology"):
114
+ warnings.append(f"{language} parsing is not implemented; preview/reference output only.")
115
+ diagrams.append(
116
+ DiagramSource(
117
+ id=f"diagram-{len(diagrams) + 1:03d}",
118
+ title=diagram_title,
119
+ language=language,
120
+ source_type=classify_source_type(language, body),
121
+ text=body + "\n",
122
+ line_offset=line_offset,
123
+ source_file=input_path.name,
124
+ origin_file=input_path.name,
125
+ warnings=warnings,
126
+ )
127
+ )
128
+ return diagrams
129
+
130
+
131
+ def nearest_heading(markdown_prefix: str) -> str:
132
+ headings = re.findall(r"(?m)^#{1,6}\s+(.+?)\s*$", markdown_prefix)
133
+ return headings[-1].strip() if headings else ""
134
+
135
+
136
+ def classify_source_type(language: str, text: str) -> str:
137
+ if language == "mermaid":
138
+ first = first_significant_line(text)
139
+ if re.match(r"^(flowchart|graph)\b", first, re.IGNORECASE):
140
+ return "mermaid_flowchart"
141
+ if first.lower().startswith("sequencediagram"):
142
+ return "mermaid_sequence"
143
+ return "mermaid_unknown"
144
+ if language == "plantuml":
145
+ return "plantuml"
146
+ if language == "graphviz":
147
+ return "graphviz_dot"
148
+ if language == "topology":
149
+ stripped = text.strip()
150
+ return "topology.json" if stripped.startswith("{") else "topology.yaml"
151
+ return language
152
+
153
+
154
+ def first_significant_line(text: str) -> str:
155
+ for line in text.splitlines():
156
+ stripped = line.strip()
157
+ if stripped and not stripped.startswith("%%"):
158
+ return stripped
159
+ return ""
160
+