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/__init__.py
ADDED
spolvero/builder.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Package assembly orchestration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime as dt
|
|
6
|
+
import shutil
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from spolvero.constants import SCHEMA_VERSION, SKILL_ID, SOURCE_EXT, TARGETS, VERSION
|
|
12
|
+
from spolvero.engines import REQUESTED_ENGINES, EngineContext, run_engine
|
|
13
|
+
from spolvero.ir import normalize_diagram
|
|
14
|
+
from spolvero.models import DiagramSource
|
|
15
|
+
from spolvero.render import render_preview
|
|
16
|
+
from spolvero.reports import conversion_score, write_package_readme, write_qa_report, write_template_notes
|
|
17
|
+
from spolvero.schema import SchemaValidationError, validate_ir, validate_manifest
|
|
18
|
+
from spolvero.source import extract_diagrams
|
|
19
|
+
from spolvero.utils import rel, write_json, write_text
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build_package(
|
|
23
|
+
input_path: str | Path,
|
|
24
|
+
out_dir: str | Path,
|
|
25
|
+
target: str = "data-visualizer",
|
|
26
|
+
title: str = "",
|
|
27
|
+
engine_override: str | None = None,
|
|
28
|
+
template: str | Path | None = None,
|
|
29
|
+
) -> int:
|
|
30
|
+
input_path = Path(input_path).resolve()
|
|
31
|
+
out_dir = Path(out_dir).resolve()
|
|
32
|
+
if target not in TARGETS:
|
|
33
|
+
print(f"Unsupported target '{target}'. Expected one of: {', '.join(TARGETS)}", file=sys.stderr)
|
|
34
|
+
return 2
|
|
35
|
+
if engine_override is not None and engine_override not in TARGETS:
|
|
36
|
+
print(
|
|
37
|
+
f"Unsupported --engine '{engine_override}'. Expected one of: {', '.join(TARGETS)}",
|
|
38
|
+
file=sys.stderr,
|
|
39
|
+
)
|
|
40
|
+
return 2
|
|
41
|
+
if not input_path.exists():
|
|
42
|
+
print(f"Input file not found: {input_path}", file=sys.stderr)
|
|
43
|
+
return 1
|
|
44
|
+
|
|
45
|
+
dirs = {
|
|
46
|
+
"source": out_dir / "source",
|
|
47
|
+
"normalized": out_dir / "normalized",
|
|
48
|
+
"preview": out_dir / "preview",
|
|
49
|
+
"visio": out_dir / "visio",
|
|
50
|
+
"qa": out_dir / "qa",
|
|
51
|
+
}
|
|
52
|
+
for directory in dirs.values():
|
|
53
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
|
|
55
|
+
original_name = f"original{input_path.suffix or '.txt'}"
|
|
56
|
+
original_dest = dirs["source"] / original_name
|
|
57
|
+
shutil.copy2(input_path, original_dest)
|
|
58
|
+
|
|
59
|
+
diagrams = extract_diagrams(input_path, title)
|
|
60
|
+
if not diagrams:
|
|
61
|
+
diagrams = [
|
|
62
|
+
DiagramSource(
|
|
63
|
+
id="diagram-001",
|
|
64
|
+
title=title or input_path.stem,
|
|
65
|
+
language="unknown",
|
|
66
|
+
source_type="unknown_text",
|
|
67
|
+
text=input_path.read_text(encoding="utf-8", errors="replace"),
|
|
68
|
+
warnings=["No supported diagram block was detected; packaged input as an unsupported reference."],
|
|
69
|
+
)
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
manifest: dict[str, Any] = {
|
|
73
|
+
"schema_version": SCHEMA_VERSION,
|
|
74
|
+
"skill": SKILL_ID,
|
|
75
|
+
"version": VERSION,
|
|
76
|
+
"created_at": dt.datetime.now(dt.timezone.utc).isoformat(),
|
|
77
|
+
"source_files": [rel(original_dest, out_dir)],
|
|
78
|
+
"target": target,
|
|
79
|
+
"diagrams": [],
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
readme_rows: list[tuple[dict[str, Any], int]] = []
|
|
83
|
+
|
|
84
|
+
for index, diagram in enumerate(diagrams, start=1):
|
|
85
|
+
diagram.id = f"diagram-{index:03d}"
|
|
86
|
+
source_ext = _source_extension(diagram)
|
|
87
|
+
source_name = f"{diagram.id}{source_ext or '.txt'}"
|
|
88
|
+
source_path = dirs["source"] / source_name
|
|
89
|
+
write_text(source_path, diagram.text)
|
|
90
|
+
diagram.source_file = rel(source_path, out_dir) or source_path.name
|
|
91
|
+
manifest["source_files"].append(diagram.source_file)
|
|
92
|
+
|
|
93
|
+
ir = normalize_diagram(diagram)
|
|
94
|
+
try:
|
|
95
|
+
validate_ir(ir)
|
|
96
|
+
except SchemaValidationError as exc:
|
|
97
|
+
print(f"IR schema validation failed for {diagram.id}: {exc}", file=sys.stderr)
|
|
98
|
+
return 1
|
|
99
|
+
ir_path = dirs["normalized"] / f"{diagram.id}.ir.json"
|
|
100
|
+
write_json(ir_path, ir)
|
|
101
|
+
|
|
102
|
+
svg_path = dirs["preview"] / f"{diagram.id}.svg"
|
|
103
|
+
png_path = dirs["preview"] / f"{diagram.id}.png"
|
|
104
|
+
render_preview(diagram, ir, svg_path, png_path)
|
|
105
|
+
|
|
106
|
+
workbook_path: Path | None = None
|
|
107
|
+
template_notes_path = dirs["visio"] / f"{diagram.id}-template-notes.md"
|
|
108
|
+
warnings = list(dict.fromkeys(diagram.warnings + ir.get("warnings", [])))
|
|
109
|
+
requested_engine = REQUESTED_ENGINES.get(target, "internal_preview")
|
|
110
|
+
|
|
111
|
+
engine_target = engine_override or target
|
|
112
|
+
ctx = EngineContext(
|
|
113
|
+
diagram=diagram,
|
|
114
|
+
ir=ir,
|
|
115
|
+
target=engine_target,
|
|
116
|
+
requested_engine=requested_engine,
|
|
117
|
+
out_dir=out_dir,
|
|
118
|
+
visio_dir=dirs["visio"],
|
|
119
|
+
options={"template": template} if template else {},
|
|
120
|
+
)
|
|
121
|
+
engine_result = run_engine(ctx)
|
|
122
|
+
conversion_engine = engine_result.actual_engine
|
|
123
|
+
editability = engine_result.editability
|
|
124
|
+
workbook_path = engine_result.workbook_path
|
|
125
|
+
warnings = list(dict.fromkeys(warnings + engine_result.warnings))
|
|
126
|
+
|
|
127
|
+
write_template_notes(template_notes_path, diagram, ir, target, editability, warnings)
|
|
128
|
+
|
|
129
|
+
qa_path = dirs["qa"] / f"{diagram.id}-qa.md"
|
|
130
|
+
score = conversion_score(ir, editability, warnings)
|
|
131
|
+
write_qa_report(qa_path, diagram, ir, editability, conversion_engine, score, warnings, workbook_path, svg_path)
|
|
132
|
+
|
|
133
|
+
log_path = dirs["qa"] / f"{diagram.id}-conversion-log.json"
|
|
134
|
+
write_json(
|
|
135
|
+
log_path,
|
|
136
|
+
{
|
|
137
|
+
"schema_version": SCHEMA_VERSION,
|
|
138
|
+
"diagram_id": diagram.id,
|
|
139
|
+
"source_type": diagram.source_type,
|
|
140
|
+
"target": target,
|
|
141
|
+
"requested_engine": requested_engine,
|
|
142
|
+
"actual_engine": conversion_engine,
|
|
143
|
+
"conversion_engine": conversion_engine,
|
|
144
|
+
"editability": editability,
|
|
145
|
+
"node_count": len(ir.get("nodes", [])),
|
|
146
|
+
"edge_count": len(ir.get("edges", [])),
|
|
147
|
+
"warnings": warnings,
|
|
148
|
+
"outputs": {
|
|
149
|
+
"source": diagram.source_file,
|
|
150
|
+
"ir": rel(ir_path, out_dir),
|
|
151
|
+
"preview_svg": rel(svg_path, out_dir),
|
|
152
|
+
"preview_png": rel(png_path, out_dir),
|
|
153
|
+
"data_visualizer_workbook": rel(workbook_path, out_dir) if workbook_path else None,
|
|
154
|
+
"output_vsdx": rel(engine_result.output_vsdx, out_dir) if engine_result.output_vsdx else None,
|
|
155
|
+
"template_notes": rel(template_notes_path, out_dir),
|
|
156
|
+
"qa": rel(qa_path, out_dir),
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
manifest_entry = {
|
|
162
|
+
"id": diagram.id,
|
|
163
|
+
"title": diagram.title,
|
|
164
|
+
"source_type": diagram.source_type,
|
|
165
|
+
"source": diagram.source_file,
|
|
166
|
+
"normalized_ir": rel(ir_path, out_dir),
|
|
167
|
+
"output_vsdx": rel(engine_result.output_vsdx, out_dir) if engine_result.output_vsdx else None,
|
|
168
|
+
"data_visualizer_workbook": rel(workbook_path, out_dir) if workbook_path else None,
|
|
169
|
+
"preview_svg": rel(svg_path, out_dir),
|
|
170
|
+
"preview_png": rel(png_path, out_dir),
|
|
171
|
+
"editability": editability,
|
|
172
|
+
"conversion_engine": conversion_engine,
|
|
173
|
+
"requested_engine": requested_engine,
|
|
174
|
+
"actual_engine": conversion_engine,
|
|
175
|
+
"warnings": warnings,
|
|
176
|
+
}
|
|
177
|
+
manifest["diagrams"].append(manifest_entry)
|
|
178
|
+
readme_rows.append((manifest_entry, score))
|
|
179
|
+
|
|
180
|
+
manifest_path = out_dir / "manifest.json"
|
|
181
|
+
try:
|
|
182
|
+
validate_manifest(manifest)
|
|
183
|
+
except SchemaValidationError as exc:
|
|
184
|
+
print(f"Manifest schema validation failed: {exc}", file=sys.stderr)
|
|
185
|
+
return 1
|
|
186
|
+
write_json(manifest_path, manifest)
|
|
187
|
+
write_package_readme(out_dir / "README.md", input_path, target, readme_rows)
|
|
188
|
+
|
|
189
|
+
print(f"Created Visio handoff package: {out_dir}")
|
|
190
|
+
print(f"Open first: {out_dir / 'README.md'}")
|
|
191
|
+
return 0
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _source_extension(diagram: DiagramSource) -> str:
|
|
195
|
+
if diagram.source_type == "topology.json":
|
|
196
|
+
return ".json"
|
|
197
|
+
if diagram.source_type == "topology.yaml":
|
|
198
|
+
return ".yaml"
|
|
199
|
+
return SOURCE_EXT.get(diagram.language, ".txt")
|
|
200
|
+
|
spolvero/cli.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Command line interface for Spolvero."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from spolvero.builder import build_package
|
|
10
|
+
from spolvero.constants import TARGETS
|
|
11
|
+
from spolvero.hygiene import check_hygiene, render_hygiene_report
|
|
12
|
+
from spolvero.inspection import inspect_vsdx, render_vsdx_report
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main(argv: list[str] | None = None) -> int:
|
|
16
|
+
parser = argparse.ArgumentParser(description="Build and inspect Visio handoff artifacts.")
|
|
17
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
18
|
+
|
|
19
|
+
build = subparsers.add_parser("build", help="Build a package from one input file.")
|
|
20
|
+
build.add_argument("input", help="Markdown, Mermaid, PlantUML, DOT, SVG, PNG, PDF, or text input.")
|
|
21
|
+
build.add_argument("--out", default="spolvero-handoff", help="Output package directory.")
|
|
22
|
+
build.add_argument("--target", default="data-visualizer", choices=TARGETS, help="Requested conversion target.")
|
|
23
|
+
build.add_argument("--title", default="", help="Optional package or diagram title.")
|
|
24
|
+
build.add_argument(
|
|
25
|
+
"--template",
|
|
26
|
+
default=None,
|
|
27
|
+
help="Path to a Visio template (.vsdx) used by the vsdx-template engine.",
|
|
28
|
+
)
|
|
29
|
+
# Hidden override: force a specific engine regardless of target. Useful for
|
|
30
|
+
# testing fallbacks. Will be documented once stable.
|
|
31
|
+
build.add_argument("--engine", default=None, choices=TARGETS, help=argparse.SUPPRESS)
|
|
32
|
+
|
|
33
|
+
inspect = subparsers.add_parser("inspect-vsdx", help="Read-only inspection report for a .vsdx package.")
|
|
34
|
+
inspect.add_argument("input", help="Visio .vsdx package to inspect.")
|
|
35
|
+
inspect.add_argument("--json", action="store_true", help="Emit machine-readable JSON.")
|
|
36
|
+
inspect.add_argument("--report", default=None, help="Optional Markdown or JSON report path.")
|
|
37
|
+
inspect.add_argument(
|
|
38
|
+
"--personal-term",
|
|
39
|
+
action="append",
|
|
40
|
+
default=[],
|
|
41
|
+
help="Personal name or token to flag during text scans. Can be repeated.",
|
|
42
|
+
)
|
|
43
|
+
inspect.add_argument(
|
|
44
|
+
"--forbidden-term",
|
|
45
|
+
action="append",
|
|
46
|
+
default=[],
|
|
47
|
+
help="Organization-specific term to flag during text scans. Can be repeated.",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
hygiene = subparsers.add_parser("hygiene", help="Run repository migration hygiene checks.")
|
|
51
|
+
hygiene.add_argument("--root", default=".", help="Repository root to check.")
|
|
52
|
+
hygiene.add_argument("--json", action="store_true", help="Emit machine-readable JSON.")
|
|
53
|
+
hygiene.add_argument("--report", default=None, help="Optional Markdown or JSON report path.")
|
|
54
|
+
hygiene.add_argument("--include-untracked", action="store_true", help="Also check untracked non-ignored files.")
|
|
55
|
+
hygiene.add_argument(
|
|
56
|
+
"--allow-vsdx",
|
|
57
|
+
action="append",
|
|
58
|
+
default=[],
|
|
59
|
+
help="Tracked .vsdx path allowed in addition to the bundled template. Can be repeated.",
|
|
60
|
+
)
|
|
61
|
+
hygiene.add_argument(
|
|
62
|
+
"--personal-term",
|
|
63
|
+
action="append",
|
|
64
|
+
default=[],
|
|
65
|
+
help="Personal name or token to flag during text scans. Can be repeated.",
|
|
66
|
+
)
|
|
67
|
+
hygiene.add_argument(
|
|
68
|
+
"--forbidden-term",
|
|
69
|
+
action="append",
|
|
70
|
+
default=[],
|
|
71
|
+
help="Organization-specific term to flag during text scans. Can be repeated.",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
args = parser.parse_args(argv)
|
|
75
|
+
if args.command == "build":
|
|
76
|
+
return build_package(
|
|
77
|
+
Path(args.input),
|
|
78
|
+
Path(args.out),
|
|
79
|
+
args.target,
|
|
80
|
+
args.title,
|
|
81
|
+
engine_override=args.engine,
|
|
82
|
+
template=args.template,
|
|
83
|
+
)
|
|
84
|
+
if args.command == "inspect-vsdx":
|
|
85
|
+
report = inspect_vsdx(
|
|
86
|
+
Path(args.input),
|
|
87
|
+
personal_terms=args.personal_term,
|
|
88
|
+
forbidden_terms=args.forbidden_term,
|
|
89
|
+
)
|
|
90
|
+
_emit_report(report, render_vsdx_report(report), as_json=args.json, report_path=args.report)
|
|
91
|
+
return 0 if report["is_zip"] else 1
|
|
92
|
+
if args.command == "hygiene":
|
|
93
|
+
allowed = None
|
|
94
|
+
if args.allow_vsdx:
|
|
95
|
+
allowed = ["src/spolvero/templates/minimal_flowchart.vsdx", *args.allow_vsdx]
|
|
96
|
+
report = check_hygiene(
|
|
97
|
+
Path(args.root),
|
|
98
|
+
forbidden_terms=args.forbidden_term,
|
|
99
|
+
personal_terms=args.personal_term or None,
|
|
100
|
+
allowed_vsdx=allowed,
|
|
101
|
+
include_untracked=args.include_untracked,
|
|
102
|
+
)
|
|
103
|
+
_emit_report(report, render_hygiene_report(report), as_json=args.json, report_path=args.report)
|
|
104
|
+
return 0 if report["passed"] else 1
|
|
105
|
+
return 2
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _emit_report(data: dict, rendered: str, *, as_json: bool, report_path: str | None) -> None:
|
|
109
|
+
content = json.dumps(data, indent=2, ensure_ascii=False) + "\n" if as_json else rendered
|
|
110
|
+
if report_path:
|
|
111
|
+
path = Path(report_path)
|
|
112
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
113
|
+
path.write_text(content, encoding="utf-8", newline="\n")
|
|
114
|
+
print(content, end="")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
if __name__ == "__main__":
|
|
118
|
+
raise SystemExit(main())
|
spolvero/constants.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Shared constants for the Visio handoff package."""
|
|
2
|
+
|
|
3
|
+
VERSION = "0.3.0"
|
|
4
|
+
SCHEMA_VERSION = "1.0.0"
|
|
5
|
+
SKILL_ID = "spolvero"
|
|
6
|
+
|
|
7
|
+
TARGETS = ("package-only", "data-visualizer", "vsdx-template", "visio-com")
|
|
8
|
+
|
|
9
|
+
EDITABILITY_LEVELS = ("native", "semi_native", "vector_reference", "raster_reference")
|
|
10
|
+
|
|
11
|
+
DIAGRAM_LANGS = {
|
|
12
|
+
"mermaid": "mermaid",
|
|
13
|
+
"mmd": "mermaid",
|
|
14
|
+
"plantuml": "plantuml",
|
|
15
|
+
"puml": "plantuml",
|
|
16
|
+
"graphviz": "graphviz",
|
|
17
|
+
"dot": "graphviz",
|
|
18
|
+
"topology": "topology",
|
|
19
|
+
"trd": "topology",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
SOURCE_EXT = {
|
|
23
|
+
"mermaid": ".mmd",
|
|
24
|
+
"plantuml": ".puml",
|
|
25
|
+
"graphviz": ".dot",
|
|
26
|
+
"topology": ".yaml",
|
|
27
|
+
"image": ".txt",
|
|
28
|
+
"unknown": ".txt",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
DATA_VIS_HEADERS = [
|
|
32
|
+
"Process Step ID",
|
|
33
|
+
"Process Step Description",
|
|
34
|
+
"Next Step ID",
|
|
35
|
+
"Connector Label",
|
|
36
|
+
"Shape Type",
|
|
37
|
+
"Function",
|
|
38
|
+
"Phase",
|
|
39
|
+
"System",
|
|
40
|
+
"Owner",
|
|
41
|
+
"Criticality",
|
|
42
|
+
"SourceRef",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
SHAPE_TYPE_MAP = {
|
|
46
|
+
"terminator": "Start/End",
|
|
47
|
+
"decision": "Decision",
|
|
48
|
+
"document": "Document",
|
|
49
|
+
"data_io": "Data",
|
|
50
|
+
"database": "External Data",
|
|
51
|
+
"subprocess": "Subprocess",
|
|
52
|
+
"process": "Process",
|
|
53
|
+
"rounded": "Process",
|
|
54
|
+
"circle": "Process",
|
|
55
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Engine registry for Visio handoff conversion targets.
|
|
2
|
+
|
|
3
|
+
Each target (``package-only``, ``data-visualizer``, ``vsdx-template``,
|
|
4
|
+
``visio-com``) maps to an engine callable that consumes an
|
|
5
|
+
:class:`EngineContext` and returns an :class:`EngineResult`.
|
|
6
|
+
|
|
7
|
+
Engines are registered via :func:`register` (typically from the engine module's
|
|
8
|
+
import-time side effect) and dispatched via :func:`run_engine`. Unknown targets
|
|
9
|
+
fall back to the data-visualizer engine with a warning so behavior remains
|
|
10
|
+
predictable when adapters lag behind the registry.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Callable, Mapping
|
|
18
|
+
|
|
19
|
+
REQUESTED_ENGINES: Mapping[str, str] = {
|
|
20
|
+
"package-only": "internal_preview",
|
|
21
|
+
"data-visualizer": "data_visualizer_xlsx",
|
|
22
|
+
"vsdx-template": "vsdx_template_mutation",
|
|
23
|
+
"visio-com": "visio_com_automation",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class EngineContext:
|
|
29
|
+
"""Inputs an engine needs to produce its Visio artifact(s)."""
|
|
30
|
+
|
|
31
|
+
diagram: Any
|
|
32
|
+
ir: dict[str, Any]
|
|
33
|
+
target: str
|
|
34
|
+
requested_engine: str
|
|
35
|
+
out_dir: Path
|
|
36
|
+
visio_dir: Path
|
|
37
|
+
options: dict[str, Any] = field(default_factory=dict)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class EngineResult:
|
|
42
|
+
"""Outputs and metadata an engine produced."""
|
|
43
|
+
|
|
44
|
+
actual_engine: str = "internal_preview"
|
|
45
|
+
editability: str = "vector_reference"
|
|
46
|
+
workbook_path: Path | None = None
|
|
47
|
+
output_vsdx: Path | None = None
|
|
48
|
+
warnings: list[str] = field(default_factory=list)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
EngineFn = Callable[[EngineContext], EngineResult]
|
|
52
|
+
|
|
53
|
+
_REGISTRY: dict[str, EngineFn] = {}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def register(target: str, fn: EngineFn) -> None:
|
|
57
|
+
"""Register an engine callable for a target name."""
|
|
58
|
+
|
|
59
|
+
_REGISTRY[target] = fn
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get(target: str) -> EngineFn | None:
|
|
63
|
+
"""Return the engine callable registered for ``target`` or ``None``."""
|
|
64
|
+
|
|
65
|
+
_ensure_loaded()
|
|
66
|
+
return _REGISTRY.get(target)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def run_engine(ctx: EngineContext) -> EngineResult:
|
|
70
|
+
"""Dispatch ``ctx`` to its registered engine, falling back if missing."""
|
|
71
|
+
|
|
72
|
+
_ensure_loaded()
|
|
73
|
+
fn = _REGISTRY.get(ctx.target)
|
|
74
|
+
if fn is None:
|
|
75
|
+
from spolvero.engines.data_visualizer import run as fallback
|
|
76
|
+
|
|
77
|
+
result = fallback(ctx)
|
|
78
|
+
result.warnings.insert(
|
|
79
|
+
0,
|
|
80
|
+
f"Unknown target '{ctx.target}'; fell back to data-visualizer engine.",
|
|
81
|
+
)
|
|
82
|
+
return result
|
|
83
|
+
return fn(ctx)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
_LOADED = False
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _ensure_loaded() -> None:
|
|
90
|
+
"""Import bundled engines once so their ``register`` calls run."""
|
|
91
|
+
|
|
92
|
+
global _LOADED
|
|
93
|
+
if _LOADED:
|
|
94
|
+
return
|
|
95
|
+
_LOADED = True
|
|
96
|
+
# Side-effect imports populate the registry.
|
|
97
|
+
from spolvero.engines import ( # noqa: F401
|
|
98
|
+
data_visualizer,
|
|
99
|
+
package_only,
|
|
100
|
+
visio_com,
|
|
101
|
+
vsdx_template,
|
|
102
|
+
)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Data Visualizer (xlsx) engine — the default Visio handoff path."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from spolvero.engines import EngineContext, EngineResult, register
|
|
6
|
+
from spolvero.ir import is_flowchart_ir
|
|
7
|
+
from spolvero.topology import is_topology_ir
|
|
8
|
+
from spolvero.utils import backup_if_manual_candidate
|
|
9
|
+
from spolvero.workbook import write_data_visualizer_workbook
|
|
10
|
+
|
|
11
|
+
REFERENCE_RASTER_TYPES = {
|
|
12
|
+
"reference.png",
|
|
13
|
+
"reference.jpg",
|
|
14
|
+
"reference.jpeg",
|
|
15
|
+
"reference.pdf",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
_NOT_IMPLEMENTED_WARNING = (
|
|
19
|
+
"Native or semi-native conversion is not implemented for this diagram type."
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def run(ctx: EngineContext) -> EngineResult:
|
|
24
|
+
result = EngineResult(actual_engine="internal_preview", editability="vector_reference")
|
|
25
|
+
|
|
26
|
+
if is_flowchart_ir(ctx.ir):
|
|
27
|
+
workbook_path = ctx.visio_dir / f"{ctx.diagram.id}-data-visualizer.xlsx"
|
|
28
|
+
backup_if_manual_candidate(workbook_path)
|
|
29
|
+
write_data_visualizer_workbook(workbook_path, ctx.ir)
|
|
30
|
+
result.workbook_path = workbook_path
|
|
31
|
+
result.actual_engine = "data_visualizer_xlsx"
|
|
32
|
+
result.editability = "semi_native"
|
|
33
|
+
return result
|
|
34
|
+
|
|
35
|
+
if is_topology_ir(ctx.ir):
|
|
36
|
+
if not ctx.ir.get("nodes"):
|
|
37
|
+
result.warnings.append(
|
|
38
|
+
"Topology IR has no recognized nodes; Data Visualizer workbook was not generated. "
|
|
39
|
+
"Packaged preview/reference artifacts only."
|
|
40
|
+
)
|
|
41
|
+
return result
|
|
42
|
+
workbook_path = ctx.visio_dir / f"{ctx.diagram.id}-data-visualizer.xlsx"
|
|
43
|
+
backup_if_manual_candidate(workbook_path)
|
|
44
|
+
write_data_visualizer_workbook(workbook_path, ctx.ir)
|
|
45
|
+
result.workbook_path = workbook_path
|
|
46
|
+
result.actual_engine = "data_visualizer_xlsx"
|
|
47
|
+
result.editability = "semi_native"
|
|
48
|
+
return result
|
|
49
|
+
|
|
50
|
+
if ctx.diagram.source_type in REFERENCE_RASTER_TYPES:
|
|
51
|
+
result.editability = "raster_reference"
|
|
52
|
+
result.warnings.append(_NOT_IMPLEMENTED_WARNING)
|
|
53
|
+
return result
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
register("data-visualizer", run)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Package-only engine — no Visio artifact produced.
|
|
2
|
+
|
|
3
|
+
Even Mermaid flowcharts are downgraded to ``vector_reference`` because the
|
|
4
|
+
package contains no Visio handoff artifact.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from spolvero.engines import EngineContext, EngineResult, register
|
|
10
|
+
from spolvero.engines.data_visualizer import (
|
|
11
|
+
REFERENCE_RASTER_TYPES,
|
|
12
|
+
_NOT_IMPLEMENTED_WARNING,
|
|
13
|
+
)
|
|
14
|
+
from spolvero.ir import is_flowchart_ir
|
|
15
|
+
from spolvero.topology import is_topology_ir
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def run(ctx: EngineContext) -> EngineResult:
|
|
19
|
+
result = EngineResult(actual_engine="internal_preview", editability="vector_reference")
|
|
20
|
+
if is_flowchart_ir(ctx.ir) or is_topology_ir(ctx.ir):
|
|
21
|
+
return result
|
|
22
|
+
if ctx.diagram.source_type in REFERENCE_RASTER_TYPES:
|
|
23
|
+
result.editability = "raster_reference"
|
|
24
|
+
result.warnings.append(_NOT_IMPLEMENTED_WARNING)
|
|
25
|
+
return result
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
register("package-only", run)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""visio-com engine.
|
|
2
|
+
|
|
3
|
+
Drives Microsoft Visio desktop (Plan 2 / Professional) via COM automation.
|
|
4
|
+
Available only on Windows with ``pywin32`` installed and Visio reachable
|
|
5
|
+
through the ``Visio.Application`` ProgID.
|
|
6
|
+
|
|
7
|
+
If the COM probe fails, the engine cascades through the vsdx-template engine
|
|
8
|
+
(itself feature-detected); ultimately the data-visualizer engine takes over so
|
|
9
|
+
every input still produces a usable handoff package.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import importlib.util
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
from spolvero.engines import EngineContext, EngineResult, register
|
|
18
|
+
from spolvero.engines.vsdx_template import run as vsdx_run
|
|
19
|
+
|
|
20
|
+
_PROBE_CACHE: bool | None = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _platform_supports_com() -> bool:
|
|
24
|
+
return sys.platform == "win32"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _pywin32_available() -> bool:
|
|
28
|
+
return importlib.util.find_spec("win32com") is not None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _probe_visio() -> bool:
|
|
32
|
+
"""Return True if a Visio.Application COM object can be instantiated.
|
|
33
|
+
|
|
34
|
+
Result is cached for the life of the process so repeated builds do not pay
|
|
35
|
+
the COM startup cost. ``reset_probe_cache`` clears it for tests.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
global _PROBE_CACHE
|
|
39
|
+
if _PROBE_CACHE is not None:
|
|
40
|
+
return _PROBE_CACHE
|
|
41
|
+
if not _platform_supports_com() or not _pywin32_available():
|
|
42
|
+
_PROBE_CACHE = False
|
|
43
|
+
return False
|
|
44
|
+
try: # pragma: no cover - exercised only on Windows + Visio
|
|
45
|
+
import win32com.client # type: ignore[import-not-found]
|
|
46
|
+
|
|
47
|
+
app = win32com.client.Dispatch("Visio.Application")
|
|
48
|
+
try:
|
|
49
|
+
app.Visible = False
|
|
50
|
+
finally:
|
|
51
|
+
app.Quit()
|
|
52
|
+
except Exception:
|
|
53
|
+
_PROBE_CACHE = False
|
|
54
|
+
return False
|
|
55
|
+
_PROBE_CACHE = True
|
|
56
|
+
return True
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _engine_disabled_reason() -> str | None:
|
|
60
|
+
if not _platform_supports_com():
|
|
61
|
+
return f"visio-com requires Windows; current platform is {sys.platform}."
|
|
62
|
+
if not _pywin32_available():
|
|
63
|
+
return (
|
|
64
|
+
"visio-com requires pywin32 (install with: "
|
|
65
|
+
"pip install 'spolvero[com]')."
|
|
66
|
+
)
|
|
67
|
+
if not _probe_visio():
|
|
68
|
+
return (
|
|
69
|
+
"visio-com probe failed: Visio.Application COM ProgID was not "
|
|
70
|
+
"reachable. Ensure Microsoft Visio Plan 2 / Professional is "
|
|
71
|
+
"installed and licensed on this machine."
|
|
72
|
+
)
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def run(ctx: EngineContext) -> EngineResult:
|
|
77
|
+
reason = _engine_disabled_reason()
|
|
78
|
+
if reason is not None:
|
|
79
|
+
# Cascade through the vsdx-template engine; if that also can't engage
|
|
80
|
+
# it will itself fall back to the data-visualizer workbook. Either
|
|
81
|
+
# way we annotate why the requested engine did not run.
|
|
82
|
+
result = vsdx_run(ctx)
|
|
83
|
+
result.warnings.insert(
|
|
84
|
+
0,
|
|
85
|
+
f"Requested target '{ctx.target}' (engine '{ctx.requested_engine}'): {reason} "
|
|
86
|
+
"Falling back to vsdx-template / data-visualizer.",
|
|
87
|
+
)
|
|
88
|
+
return result
|
|
89
|
+
return _run_native(ctx)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _run_native(ctx: EngineContext) -> EngineResult: # pragma: no cover - Windows only
|
|
93
|
+
"""Run the COM-driven path.
|
|
94
|
+
|
|
95
|
+
Until a fully native COM implementation ships we delegate to the
|
|
96
|
+
vsdx-template engine so Windows users still receive the best available
|
|
97
|
+
artifact. Delegated output keeps its original ``actual_engine`` and
|
|
98
|
+
``editability`` because ``native`` is reserved for COM-created shapes and
|
|
99
|
+
connectors.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
result = vsdx_run(ctx)
|
|
103
|
+
result.warnings.insert(
|
|
104
|
+
0,
|
|
105
|
+
"visio-com native COM shape generation is not implemented yet; delegated to "
|
|
106
|
+
"vsdx-template / data-visualizer without relabeling output as native.",
|
|
107
|
+
)
|
|
108
|
+
return result
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def reset_probe_cache() -> None:
|
|
112
|
+
"""Clear the cached probe result. For test use only."""
|
|
113
|
+
|
|
114
|
+
global _PROBE_CACHE
|
|
115
|
+
_PROBE_CACHE = None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
register("visio-com", run)
|