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
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
Bundled template: minimal_flowchart.vsdx
|
|
2
|
+
=========================================
|
|
3
|
+
|
|
4
|
+
This file is derived from the test fixture `media/media.vsdx` shipped with the
|
|
5
|
+
`vsdx` Python library (https://github.com/dave-howard/vsdx), which is
|
|
6
|
+
distributed under the BSD 3-Clause License. We re-distribute it here under the
|
|
7
|
+
same terms as a stamp template for the `vsdx-template` engine.
|
|
8
|
+
|
|
9
|
+
Original copyright:
|
|
10
|
+
|
|
11
|
+
BSD 3-Clause License
|
|
12
|
+
|
|
13
|
+
Copyright (c) 2020, Dave Howard
|
|
14
|
+
All rights reserved.
|
|
15
|
+
|
|
16
|
+
Redistribution and use in source and binary forms, with or without
|
|
17
|
+
modification, are permitted provided that the following conditions are met:
|
|
18
|
+
|
|
19
|
+
1. Redistributions of source code must retain the above copyright notice,
|
|
20
|
+
this list of conditions and the following disclaimer.
|
|
21
|
+
|
|
22
|
+
2. Redistributions in binary form must reproduce the above copyright
|
|
23
|
+
notice, this list of conditions and the following disclaimer in the
|
|
24
|
+
documentation and/or other materials provided with the distribution.
|
|
25
|
+
|
|
26
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
27
|
+
contributors may be used to endorse or promote products derived from
|
|
28
|
+
this software without specific prior written permission.
|
|
29
|
+
|
|
30
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
31
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
32
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
|
33
|
+
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
|
34
|
+
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
|
35
|
+
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
|
36
|
+
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
|
37
|
+
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
|
38
|
+
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
|
39
|
+
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
|
40
|
+
POSSIBILITY OF SUCH DAMAGE.
|
|
41
|
+
|
|
42
|
+
Users may supply their own template via the CLI flag `--template PATH`.
|
|
Binary file
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Topology IR for network diagrams and Technical Reference Diagrams (TRDs).
|
|
2
|
+
|
|
3
|
+
Public surface:
|
|
4
|
+
|
|
5
|
+
- :func:`parse_topology` — turn a raw YAML/JSON topology source into the IR.
|
|
6
|
+
- :func:`is_topology_ir` — discriminator used by builder/render dispatch.
|
|
7
|
+
- :data:`STENCIL_MAP` — kind → display label + glyph used by previews and
|
|
8
|
+
workbooks. Unknown kinds degrade to ``"generic"`` with a warning.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from .model import (
|
|
14
|
+
STENCIL_MAP,
|
|
15
|
+
SUPPORTED_NODE_KINDS,
|
|
16
|
+
SUPPORTED_CONTAINER_KINDS,
|
|
17
|
+
is_topology_ir,
|
|
18
|
+
parse_topology,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"STENCIL_MAP",
|
|
23
|
+
"SUPPORTED_NODE_KINDS",
|
|
24
|
+
"SUPPORTED_CONTAINER_KINDS",
|
|
25
|
+
"is_topology_ir",
|
|
26
|
+
"parse_topology",
|
|
27
|
+
]
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""Topology IR data model, source parsing, and stencil taxonomy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from spolvero.constants import SCHEMA_VERSION
|
|
10
|
+
from spolvero.models import DiagramSource
|
|
11
|
+
|
|
12
|
+
# Glyphs are geometric primitives recognized by the topology preview renderer.
|
|
13
|
+
_GLYPH_RECT = "rect"
|
|
14
|
+
_GLYPH_ROUNDED = "rounded"
|
|
15
|
+
_GLYPH_DIAMOND = "diamond"
|
|
16
|
+
_GLYPH_CYLINDER = "cylinder"
|
|
17
|
+
_GLYPH_CLOUD = "cloud"
|
|
18
|
+
_GLYPH_CIRCLE = "circle"
|
|
19
|
+
|
|
20
|
+
# kind -> {label (Visio "Shape Type" / workbook), glyph (preview)}
|
|
21
|
+
STENCIL_MAP: dict[str, dict[str, str]] = {
|
|
22
|
+
"router": {"label": "Router", "glyph": _GLYPH_ROUNDED},
|
|
23
|
+
"switch": {"label": "Switch", "glyph": _GLYPH_RECT},
|
|
24
|
+
"firewall": {"label": "Firewall", "glyph": _GLYPH_DIAMOND},
|
|
25
|
+
"load_balancer": {"label": "Load Balancer", "glyph": _GLYPH_DIAMOND},
|
|
26
|
+
"server": {"label": "Server", "glyph": _GLYPH_RECT},
|
|
27
|
+
"database": {"label": "Database", "glyph": _GLYPH_CYLINDER},
|
|
28
|
+
"storage": {"label": "Storage", "glyph": _GLYPH_CYLINDER},
|
|
29
|
+
"client": {"label": "Client", "glyph": _GLYPH_RECT},
|
|
30
|
+
"cloud": {"label": "Cloud", "glyph": _GLYPH_CLOUD},
|
|
31
|
+
"internet": {"label": "Internet", "glyph": _GLYPH_CLOUD},
|
|
32
|
+
"application": {"label": "Application", "glyph": _GLYPH_RECT},
|
|
33
|
+
"service": {"label": "Service", "glyph": _GLYPH_ROUNDED},
|
|
34
|
+
"actor": {"label": "Actor", "glyph": _GLYPH_CIRCLE},
|
|
35
|
+
"datastore": {"label": "Data Store", "glyph": _GLYPH_CYLINDER},
|
|
36
|
+
"external_system": {"label": "External System", "glyph": _GLYPH_ROUNDED},
|
|
37
|
+
"generic": {"label": "Process", "glyph": _GLYPH_RECT},
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
SUPPORTED_NODE_KINDS = frozenset(STENCIL_MAP.keys())
|
|
41
|
+
SUPPORTED_CONTAINER_KINDS = frozenset(
|
|
42
|
+
{"subnet", "zone", "region", "environment", "vpc", "system", "boundary"}
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def is_topology_ir(ir: dict[str, Any]) -> bool:
|
|
47
|
+
return ir.get("diagram_type") == "topology"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def parse_topology(diagram: DiagramSource) -> dict[str, Any]:
|
|
51
|
+
"""Parse a topology source (YAML or JSON) into the topology IR.
|
|
52
|
+
|
|
53
|
+
YAML support requires PyYAML (the ``[topology]`` extra). JSON sources work
|
|
54
|
+
with the stdlib alone.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
raw_text = diagram.text
|
|
58
|
+
fmt = (diagram.source_type or "").rsplit(".", 1)[-1].lower()
|
|
59
|
+
warnings: list[str] = list(diagram.warnings)
|
|
60
|
+
|
|
61
|
+
data, parse_warning = _load_source(raw_text, fmt)
|
|
62
|
+
if parse_warning:
|
|
63
|
+
warnings.append(parse_warning)
|
|
64
|
+
if not isinstance(data, dict):
|
|
65
|
+
warnings.append("Topology source did not parse to a mapping; producing empty IR.")
|
|
66
|
+
return _empty_ir(diagram, warnings)
|
|
67
|
+
|
|
68
|
+
nodes_raw = _list_or_empty(data.get("nodes"), "nodes", warnings)
|
|
69
|
+
edges_raw = _list_or_empty(data.get("edges"), "edges", warnings)
|
|
70
|
+
containers_raw = _list_or_empty(data.get("containers"), "containers", warnings)
|
|
71
|
+
|
|
72
|
+
nodes: list[dict[str, Any]] = []
|
|
73
|
+
seen_ids: set[str] = set()
|
|
74
|
+
explicit_node_containers: set[str] = set()
|
|
75
|
+
for index, raw in enumerate(nodes_raw, start=1):
|
|
76
|
+
if not isinstance(raw, dict):
|
|
77
|
+
warnings.append(f"Node #{index} is not a mapping; skipped.")
|
|
78
|
+
continue
|
|
79
|
+
nid = _str_or_none(raw.get("id")) or f"N{index:03d}"
|
|
80
|
+
if nid in seen_ids:
|
|
81
|
+
warnings.append(f"Duplicate node id '{nid}' at position {index}; skipped.")
|
|
82
|
+
continue
|
|
83
|
+
seen_ids.add(nid)
|
|
84
|
+
kind = str(raw.get("kind") or "generic").strip().lower()
|
|
85
|
+
if kind not in SUPPORTED_NODE_KINDS:
|
|
86
|
+
warnings.append(f"Unknown node kind '{kind}' for '{nid}'; mapped to 'generic'.")
|
|
87
|
+
kind = "generic"
|
|
88
|
+
container = _str_or_none(raw.get("container"))
|
|
89
|
+
if container:
|
|
90
|
+
explicit_node_containers.add(nid)
|
|
91
|
+
nodes.append(
|
|
92
|
+
{
|
|
93
|
+
"id": nid,
|
|
94
|
+
"label": str(raw.get("label") or nid),
|
|
95
|
+
"kind": kind,
|
|
96
|
+
"container": container,
|
|
97
|
+
"metadata": _metadata_or_empty(raw.get("metadata"), f"Node '{nid}'", warnings),
|
|
98
|
+
}
|
|
99
|
+
)
|
|
100
|
+
node_by_id = {node["id"]: node for node in nodes}
|
|
101
|
+
|
|
102
|
+
edges: list[dict[str, Any]] = []
|
|
103
|
+
for index, raw in enumerate(edges_raw, start=1):
|
|
104
|
+
if not isinstance(raw, dict):
|
|
105
|
+
warnings.append(f"Edge #{index} is not a mapping; skipped.")
|
|
106
|
+
continue
|
|
107
|
+
src = str(raw.get("from") or "").strip()
|
|
108
|
+
dst = str(raw.get("to") or "").strip()
|
|
109
|
+
eid = str(raw.get("id") or f"E{index:03d}")
|
|
110
|
+
if not src or not dst:
|
|
111
|
+
warnings.append(f"Edge #{index} missing from/to; skipped.")
|
|
112
|
+
continue
|
|
113
|
+
if src not in seen_ids:
|
|
114
|
+
warnings.append(f"Edge {index} source '{src}' not declared as a node; skipped.")
|
|
115
|
+
continue
|
|
116
|
+
if dst not in seen_ids:
|
|
117
|
+
warnings.append(f"Edge {index} target '{dst}' not declared as a node; skipped.")
|
|
118
|
+
continue
|
|
119
|
+
edges.append(
|
|
120
|
+
{
|
|
121
|
+
"id": eid,
|
|
122
|
+
"from": src,
|
|
123
|
+
"to": dst,
|
|
124
|
+
"label": str(raw.get("label") or ""),
|
|
125
|
+
"kind": str(raw.get("kind") or "link"),
|
|
126
|
+
"metadata": _metadata_or_empty(raw.get("metadata"), f"Edge '{eid}'", warnings),
|
|
127
|
+
}
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
containers: list[dict[str, Any]] = []
|
|
131
|
+
seen_container_ids: set[str] = set()
|
|
132
|
+
member_assignments: dict[str, str] = {}
|
|
133
|
+
for index, raw in enumerate(containers_raw, start=1):
|
|
134
|
+
if not isinstance(raw, dict):
|
|
135
|
+
warnings.append(f"Container #{index} is not a mapping; skipped.")
|
|
136
|
+
continue
|
|
137
|
+
cid = _str_or_none(raw.get("id")) or f"C{index:03d}"
|
|
138
|
+
if cid in seen_container_ids:
|
|
139
|
+
warnings.append(f"Duplicate container id '{cid}' at position {index}; skipped.")
|
|
140
|
+
continue
|
|
141
|
+
seen_container_ids.add(cid)
|
|
142
|
+
kind = str(raw.get("kind") or "zone").strip().lower()
|
|
143
|
+
if kind not in SUPPORTED_CONTAINER_KINDS:
|
|
144
|
+
warnings.append(f"Unknown container kind '{kind}' for '{cid}'; rendered as zone.")
|
|
145
|
+
kind = "zone"
|
|
146
|
+
for member_id in _member_ids(raw.get("members"), cid, warnings):
|
|
147
|
+
node = node_by_id.get(member_id)
|
|
148
|
+
if node is None:
|
|
149
|
+
warnings.append(f"Container '{cid}' members[] references unknown node id '{member_id}'; skipped.")
|
|
150
|
+
continue
|
|
151
|
+
node_container = node.get("container")
|
|
152
|
+
if member_id in explicit_node_containers and node_container:
|
|
153
|
+
if node_container != cid:
|
|
154
|
+
warnings.append(
|
|
155
|
+
f"Node '{member_id}' already declares container '{node_container}'; "
|
|
156
|
+
f"container '{cid}' members[] assignment ignored."
|
|
157
|
+
)
|
|
158
|
+
continue
|
|
159
|
+
previous = member_assignments.get(member_id)
|
|
160
|
+
if previous and previous != cid:
|
|
161
|
+
warnings.append(
|
|
162
|
+
f"Node '{member_id}' is listed in containers '{previous}' and '{cid}'; "
|
|
163
|
+
f"keeping '{previous}'."
|
|
164
|
+
)
|
|
165
|
+
continue
|
|
166
|
+
member_assignments[member_id] = cid
|
|
167
|
+
node["container"] = cid
|
|
168
|
+
containers.append(
|
|
169
|
+
{
|
|
170
|
+
"id": cid,
|
|
171
|
+
"label": str(raw.get("label") or cid),
|
|
172
|
+
"kind": kind,
|
|
173
|
+
"parent": _str_or_none(raw.get("parent")),
|
|
174
|
+
}
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
"schema_version": SCHEMA_VERSION,
|
|
179
|
+
"id": diagram.id,
|
|
180
|
+
"title": str(data.get("title") or diagram.title),
|
|
181
|
+
"diagram_type": "topology",
|
|
182
|
+
"orientation": str(data.get("orientation") or "left_to_right"),
|
|
183
|
+
"pages": [{"id": "page-001", "title": diagram.title, "size": "Letter Landscape"}],
|
|
184
|
+
"nodes": nodes,
|
|
185
|
+
"edges": edges,
|
|
186
|
+
"containers": containers,
|
|
187
|
+
"warnings": list(dict.fromkeys(warnings)),
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _load_source(text: str, fmt: str) -> tuple[Any, str | None]:
|
|
192
|
+
"""Try JSON first, then YAML (gated on PyYAML)."""
|
|
193
|
+
|
|
194
|
+
text = text.strip()
|
|
195
|
+
if not text:
|
|
196
|
+
return None, "Topology source is empty."
|
|
197
|
+
|
|
198
|
+
if fmt == "json" or text.startswith("{"):
|
|
199
|
+
try:
|
|
200
|
+
return json.loads(text), None
|
|
201
|
+
except json.JSONDecodeError as exc:
|
|
202
|
+
if fmt == "json":
|
|
203
|
+
return None, f"Topology JSON parse error: {exc}"
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
import yaml # type: ignore[import-not-found]
|
|
207
|
+
except ImportError:
|
|
208
|
+
return None, (
|
|
209
|
+
"Topology YAML detected but PyYAML is not installed. Install with: "
|
|
210
|
+
"pip install 'spolvero[topology]'."
|
|
211
|
+
)
|
|
212
|
+
try:
|
|
213
|
+
return yaml.safe_load(text), None
|
|
214
|
+
except yaml.YAMLError as exc: # pragma: no cover - thin wrapper
|
|
215
|
+
return None, f"Topology YAML parse error: {exc}"
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _str_or_none(value: Any) -> str | None:
|
|
219
|
+
if value is None:
|
|
220
|
+
return None
|
|
221
|
+
text = str(value).strip()
|
|
222
|
+
return text or None
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _list_or_empty(value: Any, field: str, warnings: list[str]) -> list[Any]:
|
|
226
|
+
if value is None:
|
|
227
|
+
return []
|
|
228
|
+
if isinstance(value, list):
|
|
229
|
+
return value
|
|
230
|
+
warnings.append(f"Topology field '{field}' must be a list; skipped.")
|
|
231
|
+
return []
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _metadata_or_empty(value: Any, owner: str, warnings: list[str]) -> dict[str, Any]:
|
|
235
|
+
if value is None:
|
|
236
|
+
return {}
|
|
237
|
+
if isinstance(value, Mapping):
|
|
238
|
+
return dict(value)
|
|
239
|
+
warnings.append(f"{owner} metadata must be a mapping; using empty metadata.")
|
|
240
|
+
return {}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _member_ids(value: Any, container_id: str, warnings: list[str]) -> list[str]:
|
|
244
|
+
if value is None:
|
|
245
|
+
return []
|
|
246
|
+
if not isinstance(value, list):
|
|
247
|
+
warnings.append(f"Container '{container_id}' members must be a list of node ids; ignored.")
|
|
248
|
+
return []
|
|
249
|
+
members: list[str] = []
|
|
250
|
+
seen: set[str] = set()
|
|
251
|
+
for index, raw in enumerate(value, start=1):
|
|
252
|
+
member_id = _str_or_none(raw)
|
|
253
|
+
if member_id is None:
|
|
254
|
+
warnings.append(f"Container '{container_id}' member #{index} is blank; skipped.")
|
|
255
|
+
continue
|
|
256
|
+
if member_id in seen:
|
|
257
|
+
continue
|
|
258
|
+
seen.add(member_id)
|
|
259
|
+
members.append(member_id)
|
|
260
|
+
return members
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _empty_ir(diagram: DiagramSource, warnings: list[str]) -> dict[str, Any]:
|
|
264
|
+
return {
|
|
265
|
+
"schema_version": SCHEMA_VERSION,
|
|
266
|
+
"id": diagram.id,
|
|
267
|
+
"title": diagram.title,
|
|
268
|
+
"diagram_type": "topology",
|
|
269
|
+
"orientation": "left_to_right",
|
|
270
|
+
"pages": [{"id": "page-001", "title": diagram.title, "size": "Letter Landscape"}],
|
|
271
|
+
"nodes": [],
|
|
272
|
+
"edges": [],
|
|
273
|
+
"containers": [],
|
|
274
|
+
"warnings": list(dict.fromkeys(warnings)),
|
|
275
|
+
}
|
spolvero/utils.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Small IO and formatting helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime as dt
|
|
6
|
+
import html
|
|
7
|
+
import json
|
|
8
|
+
import textwrap
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def write_json(path: Path, data: Any) -> None:
|
|
14
|
+
path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def write_text(path: Path, content: str) -> None:
|
|
18
|
+
path.write_text(content, encoding="utf-8", newline="\n")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def rel(path: Path | None, root: Path) -> str | None:
|
|
22
|
+
if path is None:
|
|
23
|
+
return None
|
|
24
|
+
return path.resolve().relative_to(root.resolve()).as_posix()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def escape(value: Any) -> str:
|
|
28
|
+
return html.escape(str(value), quote=False)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def escape_attr(value: Any) -> str:
|
|
32
|
+
return html.escape(str(value), quote=True)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def wrap_label(label: str, width: int = 18) -> list[str]:
|
|
36
|
+
lines: list[str] = []
|
|
37
|
+
for raw in str(label).splitlines() or [""]:
|
|
38
|
+
wrapped = textwrap.wrap(raw, width=width) or [raw]
|
|
39
|
+
lines.extend(wrapped)
|
|
40
|
+
return lines[:4] or [""]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def column_name(index: int) -> str:
|
|
44
|
+
name = ""
|
|
45
|
+
while index:
|
|
46
|
+
index, remainder = divmod(index - 1, 26)
|
|
47
|
+
name = chr(65 + remainder) + name
|
|
48
|
+
return name
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def backup_if_manual_candidate(path: Path) -> None:
|
|
52
|
+
if not path.exists():
|
|
53
|
+
return
|
|
54
|
+
stamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
55
|
+
backup = path.with_name(f"{path.stem}.bak-{stamp}{path.suffix}")
|
|
56
|
+
path.replace(backup)
|
|
57
|
+
|
spolvero/workbook.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Minimal XLSX writer for Visio Data Visualizer tables."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime as dt
|
|
6
|
+
import zipfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from spolvero.constants import DATA_VIS_HEADERS, SHAPE_TYPE_MAP, SKILL_ID
|
|
11
|
+
from spolvero.utils import column_name, escape, escape_attr
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def write_data_visualizer_workbook(path: Path, ir: dict[str, Any]) -> None:
|
|
15
|
+
rows = data_visualizer_rows(ir)
|
|
16
|
+
write_xlsx_table(path, "Process Map", DATA_VIS_HEADERS, rows, "ProcessMap")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def data_visualizer_rows(ir: dict[str, Any]) -> list[list[str]]:
|
|
20
|
+
outgoing: dict[str, list[dict[str, Any]]] = {}
|
|
21
|
+
for edge in ir.get("edges", []):
|
|
22
|
+
outgoing.setdefault(edge["from"], []).append(edge)
|
|
23
|
+
|
|
24
|
+
container_labels: dict[str, str] = {
|
|
25
|
+
container["id"]: container.get("label", container["id"])
|
|
26
|
+
for container in ir.get("containers", [])
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
is_topology = ir.get("diagram_type") == "topology"
|
|
30
|
+
if is_topology:
|
|
31
|
+
from spolvero.topology import STENCIL_MAP
|
|
32
|
+
|
|
33
|
+
# Visio Data Visualizer requires `Process Step ID` to be unique. For
|
|
34
|
+
# branching nodes, fan-out must be expressed as comma-separated values in
|
|
35
|
+
# the `Next Step ID` and `Connector Label` columns.
|
|
36
|
+
rows: list[list[str]] = []
|
|
37
|
+
for node in ir.get("nodes", []):
|
|
38
|
+
node_edges = outgoing.get(node["id"], [])
|
|
39
|
+
next_ids = ",".join(edge.get("to", "") for edge in node_edges)
|
|
40
|
+
connector_labels = ",".join(edge.get("label", "") for edge in node_edges)
|
|
41
|
+
metadata = node.get("metadata", {})
|
|
42
|
+
container_id = node.get("container", "") or ""
|
|
43
|
+
function = container_labels.get(container_id, container_id)
|
|
44
|
+
phase = metadata.get("phase") or (function if is_topology else "")
|
|
45
|
+
if is_topology:
|
|
46
|
+
kind = node.get("kind", "generic")
|
|
47
|
+
shape_type = STENCIL_MAP.get(kind, STENCIL_MAP["generic"])["label"]
|
|
48
|
+
else:
|
|
49
|
+
shape_type = SHAPE_TYPE_MAP.get(node.get("shape_kind", "process"), "Process")
|
|
50
|
+
rows.append(
|
|
51
|
+
[
|
|
52
|
+
node["id"],
|
|
53
|
+
node.get("label", node["id"]),
|
|
54
|
+
next_ids,
|
|
55
|
+
connector_labels,
|
|
56
|
+
shape_type,
|
|
57
|
+
function,
|
|
58
|
+
phase,
|
|
59
|
+
metadata.get("system", ""),
|
|
60
|
+
metadata.get("owner", ""),
|
|
61
|
+
metadata.get("criticality", ""),
|
|
62
|
+
metadata.get("source_ref", ""),
|
|
63
|
+
]
|
|
64
|
+
)
|
|
65
|
+
return rows
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def write_xlsx_table(path: Path, sheet_name: str, headers: list[str], rows: list[list[str]], table_name: str) -> None:
|
|
69
|
+
ref = f"A1:{column_name(len(headers))}{len(rows) + 1}"
|
|
70
|
+
now = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
71
|
+
sheet_rows = [headers] + rows
|
|
72
|
+
|
|
73
|
+
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
74
|
+
zf.writestr("[Content_Types].xml", content_types_xml())
|
|
75
|
+
zf.writestr("_rels/.rels", root_rels_xml())
|
|
76
|
+
zf.writestr("docProps/core.xml", core_props_xml(now))
|
|
77
|
+
zf.writestr("docProps/app.xml", app_props_xml())
|
|
78
|
+
zf.writestr("xl/workbook.xml", workbook_xml(sheet_name))
|
|
79
|
+
zf.writestr("xl/_rels/workbook.xml.rels", workbook_rels_xml())
|
|
80
|
+
zf.writestr("xl/styles.xml", styles_xml())
|
|
81
|
+
zf.writestr("xl/worksheets/sheet1.xml", sheet_xml(sheet_rows, ref))
|
|
82
|
+
zf.writestr("xl/worksheets/_rels/sheet1.xml.rels", sheet_rels_xml())
|
|
83
|
+
zf.writestr("xl/tables/table1.xml", table_xml(table_name, ref, headers))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def content_types_xml() -> str:
|
|
87
|
+
return """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
88
|
+
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
|
89
|
+
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
|
90
|
+
<Default Extension="xml" ContentType="application/xml"/>
|
|
91
|
+
<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>
|
|
92
|
+
<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>
|
|
93
|
+
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
|
|
94
|
+
<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
|
|
95
|
+
<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>
|
|
96
|
+
<Override PartName="/xl/tables/table1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/>
|
|
97
|
+
</Types>
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def root_rels_xml() -> str:
|
|
102
|
+
return """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
103
|
+
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
|
104
|
+
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
|
|
105
|
+
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>
|
|
106
|
+
<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>
|
|
107
|
+
</Relationships>
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def workbook_xml(sheet_name: str) -> str:
|
|
112
|
+
return f"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
113
|
+
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
|
|
114
|
+
<sheets>
|
|
115
|
+
<sheet name="{escape_attr(sheet_name)}" sheetId="1" r:id="rId1"/>
|
|
116
|
+
</sheets>
|
|
117
|
+
</workbook>
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def workbook_rels_xml() -> str:
|
|
122
|
+
return """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
123
|
+
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
|
124
|
+
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
|
|
125
|
+
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
|
|
126
|
+
</Relationships>
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def sheet_rels_xml() -> str:
|
|
131
|
+
return """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
132
|
+
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
|
133
|
+
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/table" Target="../tables/table1.xml"/>
|
|
134
|
+
</Relationships>
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def styles_xml() -> str:
|
|
139
|
+
return """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
140
|
+
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
|
141
|
+
<fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts>
|
|
142
|
+
<fills count="1"><fill><patternFill patternType="none"/></fill></fills>
|
|
143
|
+
<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>
|
|
144
|
+
<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>
|
|
145
|
+
<cellXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/></cellXfs>
|
|
146
|
+
<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>
|
|
147
|
+
</styleSheet>
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def sheet_xml(rows: list[list[str]], ref: str) -> str:
|
|
152
|
+
row_xml = []
|
|
153
|
+
for r_index, row in enumerate(rows, start=1):
|
|
154
|
+
cells = []
|
|
155
|
+
for c_index, value in enumerate(row, start=1):
|
|
156
|
+
cell_ref = f"{column_name(c_index)}{r_index}"
|
|
157
|
+
cells.append(f'<c r="{cell_ref}" t="inlineStr"><is><t>{escape(value)}</t></is></c>')
|
|
158
|
+
row_xml.append(f'<row r="{r_index}">' + "".join(cells) + "</row>")
|
|
159
|
+
return f"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
160
|
+
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
|
|
161
|
+
<dimension ref="{ref}"/>
|
|
162
|
+
<sheetViews><sheetView workbookViewId="0"/></sheetViews>
|
|
163
|
+
<sheetFormatPr defaultRowHeight="15"/>
|
|
164
|
+
<sheetData>{''.join(row_xml)}</sheetData>
|
|
165
|
+
<tableParts count="1"><tablePart r:id="rId1"/></tableParts>
|
|
166
|
+
</worksheet>
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def table_xml(table_name: str, ref: str, headers: list[str]) -> str:
|
|
171
|
+
columns = "".join(
|
|
172
|
+
f'<tableColumn id="{index}" name="{escape_attr(header)}"/>' for index, header in enumerate(headers, start=1)
|
|
173
|
+
)
|
|
174
|
+
return f"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
175
|
+
<table xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" id="1" name="{table_name}" displayName="{table_name}" ref="{ref}" totalsRowShown="0">
|
|
176
|
+
<autoFilter ref="{ref}"/>
|
|
177
|
+
<tableColumns count="{len(headers)}">{columns}</tableColumns>
|
|
178
|
+
<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>
|
|
179
|
+
</table>
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def core_props_xml(now: str) -> str:
|
|
184
|
+
return f"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
185
|
+
<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:dcmitype="http://purl.org/dc/dcmitype/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
|
186
|
+
<dc:creator>{SKILL_ID}</dc:creator>
|
|
187
|
+
<cp:lastModifiedBy>{SKILL_ID}</cp:lastModifiedBy>
|
|
188
|
+
<dcterms:created xsi:type="dcterms:W3CDTF">{now}</dcterms:created>
|
|
189
|
+
<dcterms:modified xsi:type="dcterms:W3CDTF">{now}</dcterms:modified>
|
|
190
|
+
</cp:coreProperties>
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def app_props_xml() -> str:
|
|
195
|
+
return """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
196
|
+
<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">
|
|
197
|
+
<Application>Spolvero</Application>
|
|
198
|
+
</Properties>
|
|
199
|
+
"""
|
|
200
|
+
|