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 ADDED
@@ -0,0 +1,7 @@
1
+ """Public API for Visio handoff package generation."""
2
+
3
+ from spolvero.builder import build_package
4
+ from spolvero.constants import VERSION
5
+
6
+ __all__ = ["VERSION", "build_package"]
7
+
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)