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.
@@ -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`.
@@ -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
+