k3kroki 0.1.0__tar.gz

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.
k3kroki-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2020 张炎泼
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
k3kroki-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,86 @@
1
+ Metadata-Version: 2.4
2
+ Name: k3kroki
3
+ Version: 0.1.0
4
+ Summary: Convert diagrams to images via the kroki.io API — zero local dependencies
5
+ Author-email: Zhang Yanpo <drdr.xp@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/pykit3/k3kroki
8
+ Project-URL: Documentation, https://k3kroki.readthedocs.io
9
+ Keywords: diagram,kroki,graphviz,mermaid,plantuml,svg,png
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Topic :: Software Development :: Libraries
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: resvg-py>=0.2
23
+ Requires-Dist: Pillow>=10.0
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=7.0; extra == "dev"
26
+ Requires-Dist: ruff; extra == "dev"
27
+ Requires-Dist: coverage; extra == "dev"
28
+ Requires-Dist: numpy; extra == "dev"
29
+ Requires-Dist: scikit-image; extra == "dev"
30
+ Requires-Dist: Pillow; extra == "dev"
31
+ Provides-Extra: publish
32
+ Requires-Dist: build; extra == "publish"
33
+ Requires-Dist: twine; extra == "publish"
34
+ Requires-Dist: pk3; extra == "publish"
35
+ Provides-Extra: docs
36
+ Requires-Dist: mkdocs>=1.5; extra == "docs"
37
+ Requires-Dist: mkdocs-material>=9.0; extra == "docs"
38
+ Requires-Dist: mkdocstrings[python]>=0.24; extra == "docs"
39
+ Dynamic: license-file
40
+
41
+ # k3kroki
42
+
43
+ [![Action-CI](https://github.com/pykit3/k3kroki/actions/workflows/python-package.yml/badge.svg)](https://github.com/pykit3/k3kroki/actions/workflows/python-package.yml)
44
+ [![Documentation Status](https://readthedocs.org/projects/k3kroki/badge/?version=stable)](https://k3kroki.readthedocs.io/en/stable/?badge=stable)
45
+ [![Package](https://img.shields.io/pypi/pyversions/k3kroki)](https://pypi.org/project/k3kroki)
46
+
47
+ Convert diagrams to images via the kroki.io API — zero local dependencies
48
+
49
+ k3kroki is a component of [pykit3] project: a python3 toolkit set.
50
+
51
+ k3kroki converts diagrams to images via the free kroki.io HTTP API — no local tools needed.
52
+
53
+
54
+ # Install
55
+
56
+ ```
57
+ pip install k3kroki
58
+ ```
59
+
60
+ # Synopsis
61
+
62
+ ```python
63
+ import k3kroki
64
+
65
+ # Render a Graphviz diagram to SVG bytes
66
+ svg = k3kroki.convert("graphviz", "digraph { a -> b }")
67
+
68
+ # Render a Mermaid diagram and save to file
69
+ k3kroki.convert_to_file("mermaid", "graph TD\n A --> B", "diagram.svg")
70
+
71
+ # Render PlantUML to PNG
72
+ png = k3kroki.convert("plantuml", "@startuml\nAlice -> Bob: hello\n@enduml", "png")
73
+ ```
74
+
75
+ # Author
76
+
77
+ Zhang Yanpo (张炎泼) <drdr.xp@gmail.com>
78
+
79
+ # Copyright and License
80
+
81
+ The MIT License (MIT)
82
+
83
+ Copyright (c) 2015 Zhang Yanpo (张炎泼) <drdr.xp@gmail.com>
84
+
85
+
86
+ [pykit3]: https://github.com/pykit3
@@ -0,0 +1,46 @@
1
+ # k3kroki
2
+
3
+ [![Action-CI](https://github.com/pykit3/k3kroki/actions/workflows/python-package.yml/badge.svg)](https://github.com/pykit3/k3kroki/actions/workflows/python-package.yml)
4
+ [![Documentation Status](https://readthedocs.org/projects/k3kroki/badge/?version=stable)](https://k3kroki.readthedocs.io/en/stable/?badge=stable)
5
+ [![Package](https://img.shields.io/pypi/pyversions/k3kroki)](https://pypi.org/project/k3kroki)
6
+
7
+ Convert diagrams to images via the kroki.io API — zero local dependencies
8
+
9
+ k3kroki is a component of [pykit3] project: a python3 toolkit set.
10
+
11
+ k3kroki converts diagrams to images via the free kroki.io HTTP API — no local tools needed.
12
+
13
+
14
+ # Install
15
+
16
+ ```
17
+ pip install k3kroki
18
+ ```
19
+
20
+ # Synopsis
21
+
22
+ ```python
23
+ import k3kroki
24
+
25
+ # Render a Graphviz diagram to SVG bytes
26
+ svg = k3kroki.convert("graphviz", "digraph { a -> b }")
27
+
28
+ # Render a Mermaid diagram and save to file
29
+ k3kroki.convert_to_file("mermaid", "graph TD\n A --> B", "diagram.svg")
30
+
31
+ # Render PlantUML to PNG
32
+ png = k3kroki.convert("plantuml", "@startuml\nAlice -> Bob: hello\n@enduml", "png")
33
+ ```
34
+
35
+ # Author
36
+
37
+ Zhang Yanpo (张炎泼) <drdr.xp@gmail.com>
38
+
39
+ # Copyright and License
40
+
41
+ The MIT License (MIT)
42
+
43
+ Copyright (c) 2015 Zhang Yanpo (张炎泼) <drdr.xp@gmail.com>
44
+
45
+
46
+ [pykit3]: https://github.com/pykit3
@@ -0,0 +1,32 @@
1
+ """k3kroki converts diagrams to images via the free kroki.io HTTP API — no local tools needed."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("k3kroki")
6
+ __name__ = "k3kroki"
7
+
8
+ from .kroki import (
9
+ DEFAULT_BASE_URL,
10
+ DIAGRAM_TYPES,
11
+ OUTPUT_FORMATS,
12
+ KrokiAPIError,
13
+ KrokiError,
14
+ KrokiNetworkError,
15
+ UnsupportedDiagramError,
16
+ UnsupportedFormatError,
17
+ convert,
18
+ convert_to_file,
19
+ )
20
+
21
+ __all__ = [
22
+ "DEFAULT_BASE_URL",
23
+ "DIAGRAM_TYPES",
24
+ "OUTPUT_FORMATS",
25
+ "KrokiAPIError",
26
+ "KrokiError",
27
+ "KrokiNetworkError",
28
+ "UnsupportedDiagramError",
29
+ "UnsupportedFormatError",
30
+ "convert",
31
+ "convert_to_file",
32
+ ]
@@ -0,0 +1,86 @@
1
+ Metadata-Version: 2.4
2
+ Name: k3kroki
3
+ Version: 0.1.0
4
+ Summary: Convert diagrams to images via the kroki.io API — zero local dependencies
5
+ Author-email: Zhang Yanpo <drdr.xp@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/pykit3/k3kroki
8
+ Project-URL: Documentation, https://k3kroki.readthedocs.io
9
+ Keywords: diagram,kroki,graphviz,mermaid,plantuml,svg,png
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Topic :: Software Development :: Libraries
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: resvg-py>=0.2
23
+ Requires-Dist: Pillow>=10.0
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=7.0; extra == "dev"
26
+ Requires-Dist: ruff; extra == "dev"
27
+ Requires-Dist: coverage; extra == "dev"
28
+ Requires-Dist: numpy; extra == "dev"
29
+ Requires-Dist: scikit-image; extra == "dev"
30
+ Requires-Dist: Pillow; extra == "dev"
31
+ Provides-Extra: publish
32
+ Requires-Dist: build; extra == "publish"
33
+ Requires-Dist: twine; extra == "publish"
34
+ Requires-Dist: pk3; extra == "publish"
35
+ Provides-Extra: docs
36
+ Requires-Dist: mkdocs>=1.5; extra == "docs"
37
+ Requires-Dist: mkdocs-material>=9.0; extra == "docs"
38
+ Requires-Dist: mkdocstrings[python]>=0.24; extra == "docs"
39
+ Dynamic: license-file
40
+
41
+ # k3kroki
42
+
43
+ [![Action-CI](https://github.com/pykit3/k3kroki/actions/workflows/python-package.yml/badge.svg)](https://github.com/pykit3/k3kroki/actions/workflows/python-package.yml)
44
+ [![Documentation Status](https://readthedocs.org/projects/k3kroki/badge/?version=stable)](https://k3kroki.readthedocs.io/en/stable/?badge=stable)
45
+ [![Package](https://img.shields.io/pypi/pyversions/k3kroki)](https://pypi.org/project/k3kroki)
46
+
47
+ Convert diagrams to images via the kroki.io API — zero local dependencies
48
+
49
+ k3kroki is a component of [pykit3] project: a python3 toolkit set.
50
+
51
+ k3kroki converts diagrams to images via the free kroki.io HTTP API — no local tools needed.
52
+
53
+
54
+ # Install
55
+
56
+ ```
57
+ pip install k3kroki
58
+ ```
59
+
60
+ # Synopsis
61
+
62
+ ```python
63
+ import k3kroki
64
+
65
+ # Render a Graphviz diagram to SVG bytes
66
+ svg = k3kroki.convert("graphviz", "digraph { a -> b }")
67
+
68
+ # Render a Mermaid diagram and save to file
69
+ k3kroki.convert_to_file("mermaid", "graph TD\n A --> B", "diagram.svg")
70
+
71
+ # Render PlantUML to PNG
72
+ png = k3kroki.convert("plantuml", "@startuml\nAlice -> Bob: hello\n@enduml", "png")
73
+ ```
74
+
75
+ # Author
76
+
77
+ Zhang Yanpo (张炎泼) <drdr.xp@gmail.com>
78
+
79
+ # Copyright and License
80
+
81
+ The MIT License (MIT)
82
+
83
+ Copyright (c) 2015 Zhang Yanpo (张炎泼) <drdr.xp@gmail.com>
84
+
85
+
86
+ [pykit3]: https://github.com/pykit3
@@ -0,0 +1,16 @@
1
+ LICENSE
2
+ README.md
3
+ __init__.py
4
+ kroki.py
5
+ pyproject.toml
6
+ synopsis.py
7
+ ./__init__.py
8
+ ./kroki.py
9
+ ./synopsis.py
10
+ k3kroki.egg-info/PKG-INFO
11
+ k3kroki.egg-info/SOURCES.txt
12
+ k3kroki.egg-info/dependency_links.txt
13
+ k3kroki.egg-info/requires.txt
14
+ k3kroki.egg-info/top_level.txt
15
+ test/test_doctest.py
16
+ test/test_kroki.py
@@ -0,0 +1,20 @@
1
+ resvg-py>=0.2
2
+ Pillow>=10.0
3
+
4
+ [dev]
5
+ pytest>=7.0
6
+ ruff
7
+ coverage
8
+ numpy
9
+ scikit-image
10
+ Pillow
11
+
12
+ [docs]
13
+ mkdocs>=1.5
14
+ mkdocs-material>=9.0
15
+ mkdocstrings[python]>=0.24
16
+
17
+ [publish]
18
+ build
19
+ twine
20
+ pk3
@@ -0,0 +1 @@
1
+ k3kroki
k3kroki-0.1.0/kroki.py ADDED
@@ -0,0 +1,205 @@
1
+ """Convert diagrams to images via the kroki.io HTTP API with local SVG→bitmap fallback.
2
+
3
+ Note:
4
+ Excalidraw is **not** supported. The kroki.io excalidraw backend is
5
+ unreliable (its internal renderer frequently refuses connections),
6
+ making it impossible to produce stable output.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import http.client
12
+ import io
13
+ import urllib.error
14
+ import urllib.request
15
+ from pathlib import Path
16
+
17
+ from PIL import Image
18
+ from resvg_py import svg_to_bytes
19
+
20
+ DEFAULT_BASE_URL = "https://kroki.io"
21
+
22
+ DIAGRAM_TYPES: frozenset[str] = frozenset(
23
+ {
24
+ "actdiag",
25
+ "blockdiag",
26
+ "bpmn",
27
+ "bytefield",
28
+ "c4plantuml",
29
+ "d2",
30
+ "dbml",
31
+ "ditaa",
32
+ "erd",
33
+ "graphviz",
34
+ "mermaid",
35
+ "nomnoml",
36
+ "nwdiag",
37
+ "packetdiag",
38
+ "pikchr",
39
+ "plantuml",
40
+ "rackdiag",
41
+ "seqdiag",
42
+ "structurizr",
43
+ "svgbob",
44
+ "symbolator",
45
+ "tikz",
46
+ "umlet",
47
+ "vega",
48
+ "vegalite",
49
+ "wavedrom",
50
+ "wireviz",
51
+ }
52
+ )
53
+
54
+ OUTPUT_FORMATS: frozenset[str] = frozenset({"svg", "png", "jpeg", "pdf", "webp"})
55
+
56
+ _EXT_TO_FORMAT: dict[str, str] = {
57
+ ".svg": "svg",
58
+ ".png": "png",
59
+ ".jpg": "jpeg",
60
+ ".jpeg": "jpeg",
61
+ ".pdf": "pdf",
62
+ ".webp": "webp",
63
+ }
64
+
65
+ _BITMAP_FORMATS: frozenset[str] = frozenset({"png", "jpeg", "webp"})
66
+
67
+
68
+ def _fetch_kroki(base_url: str, diagram_type: str, output_format: str, data: bytes, timeout: float) -> bytes:
69
+ """Send a request to the kroki API and return response bytes."""
70
+ url = f"{base_url.rstrip('/')}/{diagram_type}/{output_format}"
71
+ headers = {
72
+ "Content-Type": "text/plain",
73
+ "User-Agent": "k3kroki/0.1",
74
+ }
75
+ req = urllib.request.Request(url, data=data, headers=headers)
76
+ try:
77
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
78
+ return resp.read()
79
+ except urllib.error.HTTPError as exc:
80
+ body = exc.read().decode("utf-8", errors="replace")
81
+ raise KrokiAPIError(exc.code, body) from exc
82
+ except urllib.error.URLError as exc:
83
+ raise KrokiNetworkError(str(exc.reason)) from exc
84
+ except (OSError, http.client.HTTPException) as exc:
85
+ # urllib wraps only the errors raised while sending the request; a
86
+ # timeout or reset while reading the response arrives unwrapped, and a
87
+ # truncated body raises `http.client.IncompleteRead`, not an OSError.
88
+ raise KrokiNetworkError(str(exc)) from exc
89
+
90
+
91
+ def _svg_to_bitmap(svg_bytes: bytes, output_format: str) -> bytes:
92
+ """Render SVG bytes to a bitmap format using resvg (SVG→PNG) and Pillow (PNG→JPEG/WebP)."""
93
+ png_bytes = svg_to_bytes(svg_string=svg_bytes.decode("utf-8"), dpi=192)
94
+ if output_format == "png":
95
+ return png_bytes
96
+
97
+ img = Image.open(io.BytesIO(png_bytes))
98
+ buf = io.BytesIO()
99
+ if output_format == "jpeg":
100
+ img = img.convert("RGB")
101
+ img.save(buf, format=output_format.upper(), quality=90)
102
+ return buf.getvalue()
103
+
104
+
105
+ class KrokiError(Exception):
106
+ """Base exception for k3kroki."""
107
+
108
+
109
+ class UnsupportedDiagramError(KrokiError, ValueError):
110
+ """Raised when the diagram type is not supported."""
111
+
112
+
113
+ class UnsupportedFormatError(KrokiError, ValueError):
114
+ """Raised when the output format is not supported."""
115
+
116
+
117
+ class KrokiAPIError(KrokiError):
118
+ """Raised when the kroki API returns a non-2xx response."""
119
+
120
+ def __init__(self, status_code: int, body: str) -> None:
121
+ self.status_code = status_code
122
+ self.body = body
123
+ super().__init__(f"Kroki API error {status_code}: {body}")
124
+
125
+
126
+ class KrokiNetworkError(KrokiError):
127
+ """Raised on network-level failures (DNS, timeout, connection refused)."""
128
+
129
+
130
+ def convert(
131
+ diagram_type: str,
132
+ source: str,
133
+ output_format: str = "svg",
134
+ *,
135
+ base_url: str = DEFAULT_BASE_URL,
136
+ timeout: float = 30.0,
137
+ ) -> bytes:
138
+ """Send diagram source to kroki and return rendered image bytes.
139
+
140
+ Args:
141
+ diagram_type: Diagram language (e.g. ``"graphviz"``, ``"mermaid"``).
142
+ source: Diagram source text.
143
+ output_format: One of ``"svg"``, ``"png"``, ``"jpeg"``, ``"pdf"``, ``"webp"``.
144
+ base_url: Kroki server URL. Defaults to ``https://kroki.io``.
145
+ timeout: HTTP timeout in seconds.
146
+
147
+ Returns:
148
+ Raw bytes of the rendered image.
149
+
150
+ Raises:
151
+ UnsupportedDiagramError: If *diagram_type* is not recognised.
152
+ UnsupportedFormatError: If *output_format* is not recognised.
153
+ KrokiAPIError: If the server returns a non-2xx status.
154
+ KrokiNetworkError: On connection / DNS / timeout failures.
155
+ """
156
+ diagram_type = diagram_type.lower()
157
+ output_format = output_format.lower()
158
+
159
+ if diagram_type not in DIAGRAM_TYPES:
160
+ raise UnsupportedDiagramError(f"Unsupported diagram type: {diagram_type!r}")
161
+ if output_format not in OUTPUT_FORMATS:
162
+ raise UnsupportedFormatError(f"Unsupported output format: {output_format!r}")
163
+
164
+ data = source.encode("utf-8")
165
+
166
+ try:
167
+ return _fetch_kroki(base_url, diagram_type, output_format, data, timeout)
168
+ except KrokiAPIError as exc:
169
+ if output_format not in _BITMAP_FORMATS or "Unsupported output format" not in exc.body:
170
+ raise
171
+
172
+ svg_bytes = _fetch_kroki(base_url, diagram_type, "svg", data, timeout)
173
+ return _svg_to_bitmap(svg_bytes, output_format)
174
+
175
+
176
+ def convert_to_file(
177
+ diagram_type: str,
178
+ source: str,
179
+ output_path: str,
180
+ output_format: str | None = None,
181
+ *,
182
+ base_url: str = DEFAULT_BASE_URL,
183
+ timeout: float = 30.0,
184
+ ) -> None:
185
+ """Render a diagram and write the result to *output_path*.
186
+
187
+ If *output_format* is ``None`` it is inferred from the file extension
188
+ (``.svg``, ``.png``, ``.jpg``/``.jpeg``, ``.pdf``).
189
+
190
+ Args:
191
+ diagram_type: Diagram language.
192
+ source: Diagram source text.
193
+ output_path: Destination file path.
194
+ output_format: Explicit format, or ``None`` to infer from extension.
195
+ base_url: Kroki server URL.
196
+ timeout: HTTP timeout in seconds.
197
+ """
198
+ if output_format is None:
199
+ ext = Path(output_path).suffix.lower()
200
+ output_format = _EXT_TO_FORMAT.get(ext)
201
+ if output_format is None:
202
+ raise UnsupportedFormatError(f"Cannot infer format from extension: {ext!r}")
203
+
204
+ data = convert(diagram_type, source, output_format, base_url=base_url, timeout=timeout)
205
+ Path(output_path).write_bytes(data)
@@ -0,0 +1,63 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "k3kroki"
7
+ version = "0.1.0"
8
+ description = "Convert diagrams to images via the kroki.io API — zero local dependencies"
9
+ readme = "README.md"
10
+ license = {text = "MIT"}
11
+ requires-python = ">=3.10"
12
+ authors = [
13
+ { name = "Zhang Yanpo", email = "drdr.xp@gmail.com" }
14
+ ]
15
+ keywords = ["diagram", "kroki", "graphviz", "mermaid", "plantuml", "svg", "png"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Developers",
19
+ "Topic :: Software Development :: Libraries",
20
+ "License :: OSI Approved :: MIT License",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ ]
27
+ dependencies = [
28
+ "resvg-py>=0.2",
29
+ "Pillow>=10.0",
30
+ ]
31
+
32
+ [project.urls]
33
+ Homepage = "https://github.com/pykit3/k3kroki"
34
+ Documentation = "https://k3kroki.readthedocs.io"
35
+
36
+ [project.optional-dependencies]
37
+ dev = [
38
+ "pytest>=7.0",
39
+ "ruff",
40
+ "coverage",
41
+ "numpy",
42
+ "scikit-image",
43
+ "Pillow",
44
+ ]
45
+ publish = [
46
+ "build",
47
+ "twine",
48
+ "pk3",
49
+ ]
50
+ docs = [
51
+ "mkdocs>=1.5",
52
+ "mkdocs-material>=9.0",
53
+ "mkdocstrings[python]>=0.24",
54
+ ]
55
+
56
+ [tool.setuptools]
57
+ packages = ["k3kroki"]
58
+
59
+ [tool.setuptools.package-dir]
60
+ k3kroki = "."
61
+
62
+ [tool.ruff]
63
+ line-length = 120
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,10 @@
1
+ import k3kroki
2
+
3
+ # Render a Graphviz diagram to SVG bytes
4
+ svg = k3kroki.convert("graphviz", "digraph { a -> b }")
5
+
6
+ # Render a Mermaid diagram and save to file
7
+ k3kroki.convert_to_file("mermaid", "graph TD\n A --> B", "diagram.svg")
8
+
9
+ # Render PlantUML to PNG
10
+ png = k3kroki.convert("plantuml", "@startuml\nAlice -> Bob: hello\n@enduml", "png")
@@ -0,0 +1,8 @@
1
+ import doctest
2
+
3
+ import k3kroki
4
+
5
+
6
+ def load_tests(loader, tests, ignore):
7
+ tests.addTests(doctest.DocTestSuite(k3kroki))
8
+ return tests
@@ -0,0 +1,361 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import http.client
5
+ import tempfile
6
+ import unittest
7
+ from pathlib import Path
8
+ from unittest.mock import MagicMock, patch
9
+
10
+ import numpy
11
+ from skimage.metrics import structural_similarity as ssim
12
+
13
+ import k3kroki
14
+ from k3kroki.kroki import (
15
+ _EXT_TO_FORMAT,
16
+ DEFAULT_BASE_URL,
17
+ DIAGRAM_TYPES,
18
+ OUTPUT_FORMATS,
19
+ KrokiAPIError,
20
+ KrokiNetworkError,
21
+ UnsupportedDiagramError,
22
+ UnsupportedFormatError,
23
+ convert,
24
+ convert_to_file,
25
+ )
26
+
27
+ DATA_DIR = Path(__file__).parent / "data"
28
+
29
+
30
+ def _mock_urlopen_response(response_data: bytes = b"<svg/>"):
31
+ mock_resp = MagicMock()
32
+ mock_resp.read.return_value = response_data
33
+ mock_resp.__enter__ = lambda s: s
34
+ mock_resp.__exit__ = MagicMock(return_value=False)
35
+ return mock_resp
36
+
37
+
38
+ def _image_ssim(want_path: Path, got_path: Path) -> float:
39
+ from PIL import Image as PILImage
40
+
41
+ want_img = PILImage.open(want_path).convert("RGB")
42
+ got_img = PILImage.open(got_path).convert("RGB")
43
+
44
+ if want_img.size != got_img.size:
45
+ got_img = got_img.resize(want_img.size, PILImage.LANCZOS)
46
+
47
+ img1 = numpy.asarray(want_img)
48
+ img2 = numpy.asarray(got_img)
49
+ return ssim(img1, img2, channel_axis=2, data_range=255)
50
+
51
+
52
+ class TestConstants(unittest.TestCase):
53
+ def test_diagram_types_is_frozenset(self):
54
+ self.assertIsInstance(DIAGRAM_TYPES, frozenset)
55
+
56
+ def test_well_known_types_present(self):
57
+ for t in ("graphviz", "mermaid", "plantuml", "d2", "ditaa", "erd"):
58
+ self.assertIn(t, DIAGRAM_TYPES)
59
+
60
+ def test_output_formats(self):
61
+ self.assertEqual(OUTPUT_FORMATS, frozenset({"svg", "png", "jpeg", "pdf", "webp"}))
62
+
63
+ def test_default_base_url(self):
64
+ self.assertEqual(DEFAULT_BASE_URL, "https://kroki.io")
65
+
66
+
67
+ class TestValidation(unittest.TestCase):
68
+ def test_unsupported_diagram_type(self):
69
+ with self.assertRaises(UnsupportedDiagramError):
70
+ convert("not_a_real_type", "source")
71
+
72
+ def test_unsupported_output_format(self):
73
+ with self.assertRaises(UnsupportedFormatError):
74
+ convert("graphviz", "source", "gif")
75
+
76
+ def test_diagram_type_case_insensitive(self):
77
+ with patch("k3kroki.kroki.urllib.request.urlopen") as mock_urlopen:
78
+ mock_urlopen.return_value = _mock_urlopen_response()
79
+ convert("Graphviz", "digraph{a->b}", "SVG")
80
+
81
+ req = mock_urlopen.call_args[0][0]
82
+ self.assertIn("/graphviz/svg", req.full_url)
83
+
84
+
85
+ class TestConvertMocked(unittest.TestCase):
86
+ @patch("k3kroki.kroki.urllib.request.urlopen")
87
+ def test_url_construction(self, mock_urlopen):
88
+ mock_urlopen.return_value = _mock_urlopen_response()
89
+ convert("mermaid", "graph TD; A-->B", "png")
90
+
91
+ req = mock_urlopen.call_args[0][0]
92
+ self.assertEqual(req.full_url, "https://kroki.io/mermaid/png")
93
+
94
+ @patch("k3kroki.kroki.urllib.request.urlopen")
95
+ def test_custom_base_url(self, mock_urlopen):
96
+ mock_urlopen.return_value = _mock_urlopen_response()
97
+ convert("graphviz", "digraph{}", "svg", base_url="http://localhost:8000")
98
+
99
+ req = mock_urlopen.call_args[0][0]
100
+ self.assertEqual(req.full_url, "http://localhost:8000/graphviz/svg")
101
+
102
+ @patch("k3kroki.kroki.urllib.request.urlopen")
103
+ def test_trailing_slash_stripped(self, mock_urlopen):
104
+ mock_urlopen.return_value = _mock_urlopen_response()
105
+ convert("graphviz", "digraph{}", "svg", base_url="https://kroki.io/")
106
+
107
+ req = mock_urlopen.call_args[0][0]
108
+ self.assertEqual(req.full_url, "https://kroki.io/graphviz/svg")
109
+
110
+ @patch("k3kroki.kroki.urllib.request.urlopen")
111
+ def test_content_type_header(self, mock_urlopen):
112
+ mock_urlopen.return_value = _mock_urlopen_response()
113
+ convert("graphviz", "digraph{a->b}", "svg")
114
+
115
+ req = mock_urlopen.call_args[0][0]
116
+ self.assertEqual(req.get_header("Content-type"), "text/plain")
117
+
118
+ @patch("k3kroki.kroki.urllib.request.urlopen")
119
+ def test_post_body_is_utf8_encoded(self, mock_urlopen):
120
+ mock_urlopen.return_value = _mock_urlopen_response()
121
+ source = 'digraph { label="日本語" }'
122
+ convert("graphviz", source, "svg")
123
+
124
+ req = mock_urlopen.call_args[0][0]
125
+ self.assertEqual(req.data, source.encode("utf-8"))
126
+
127
+ @patch("k3kroki.kroki.urllib.request.urlopen")
128
+ def test_returns_bytes(self, mock_urlopen):
129
+ mock_urlopen.return_value = _mock_urlopen_response(b"\x89PNG")
130
+ result = convert("graphviz", "digraph{}", "png")
131
+ self.assertEqual(result, b"\x89PNG")
132
+
133
+ @patch("k3kroki.kroki.urllib.request.urlopen")
134
+ def test_timeout_passed(self, mock_urlopen):
135
+ mock_urlopen.return_value = _mock_urlopen_response()
136
+ convert("graphviz", "digraph{}", "svg", timeout=5.0)
137
+ self.assertEqual(mock_urlopen.call_args[1]["timeout"], 5.0)
138
+
139
+
140
+ class TestSvgFallbackMocked(unittest.TestCase):
141
+ """Test the SVG→bitmap fallback path using mocks."""
142
+
143
+ @patch("k3kroki.kroki._svg_to_bitmap")
144
+ @patch("k3kroki.kroki._fetch_kroki")
145
+ def test_fallback_on_unsupported_format(self, mock_fetch, mock_bitmap):
146
+ mock_fetch.side_effect = [
147
+ KrokiAPIError(400, "Unsupported output format: png"),
148
+ b"<svg>mock</svg>",
149
+ ]
150
+ mock_bitmap.return_value = b"\x89PNG-mock"
151
+
152
+ result = convert("d2", "x -> y", "png")
153
+
154
+ self.assertEqual(result, b"\x89PNG-mock")
155
+ self.assertEqual(mock_fetch.call_count, 2)
156
+ self.assertEqual(mock_fetch.call_args_list[1][0][2], "svg")
157
+ mock_bitmap.assert_called_once_with(b"<svg>mock</svg>", "png")
158
+
159
+ @patch("k3kroki.kroki._fetch_kroki")
160
+ def test_non_format_api_error_propagates(self, mock_fetch):
161
+ mock_fetch.side_effect = KrokiAPIError(400, "syntax error in diagram")
162
+
163
+ with self.assertRaises(KrokiAPIError) as ctx:
164
+ convert("graphviz", "bad", "png")
165
+
166
+ self.assertIn("syntax error", ctx.exception.body)
167
+
168
+ @patch("k3kroki.kroki._fetch_kroki")
169
+ def test_fallback_not_triggered_for_svg(self, mock_fetch):
170
+ mock_fetch.side_effect = KrokiAPIError(400, "Unsupported output format: svg")
171
+
172
+ with self.assertRaises(KrokiAPIError):
173
+ convert("d2", "x -> y", "svg")
174
+
175
+ mock_fetch.assert_called_once()
176
+
177
+
178
+ class TestErrorWrapping(unittest.TestCase):
179
+ @patch("k3kroki.kroki.urllib.request.urlopen")
180
+ def test_http_error_becomes_api_error(self, mock_urlopen):
181
+ import urllib.error
182
+
183
+ exc = urllib.error.HTTPError("url", 400, "Bad Request", {}, MagicMock(read=lambda: b"syntax error"))
184
+ mock_urlopen.side_effect = exc
185
+
186
+ with self.assertRaises(KrokiAPIError) as ctx:
187
+ convert("graphviz", "bad source", "svg")
188
+
189
+ self.assertEqual(ctx.exception.status_code, 400)
190
+ self.assertIn("syntax error", ctx.exception.body)
191
+
192
+ @patch("k3kroki.kroki.urllib.request.urlopen")
193
+ def test_url_error_becomes_network_error(self, mock_urlopen):
194
+ import urllib.error
195
+
196
+ mock_urlopen.side_effect = urllib.error.URLError("Name or service not known")
197
+
198
+ with self.assertRaises(KrokiNetworkError):
199
+ convert("graphviz", "digraph{}", "svg")
200
+
201
+ @patch("k3kroki.kroki.urllib.request.urlopen")
202
+ def test_read_timeout_becomes_network_error(self, mock_urlopen):
203
+ timeout = TimeoutError("The read operation timed out")
204
+ mock_urlopen.side_effect = timeout
205
+
206
+ with self.assertRaises(KrokiNetworkError) as ctx:
207
+ convert("graphviz", "digraph{}", "svg")
208
+
209
+ self.assertEqual("The read operation timed out", str(ctx.exception))
210
+ self.assertIs(timeout, ctx.exception.__cause__)
211
+
212
+ @patch("k3kroki.kroki.urllib.request.urlopen")
213
+ def test_incomplete_read_becomes_network_error(self, mock_urlopen):
214
+ incomplete = http.client.IncompleteRead(b"<svg", 10)
215
+ resp = _mock_urlopen_response()
216
+ resp.read.side_effect = incomplete
217
+ mock_urlopen.return_value = resp
218
+
219
+ with self.assertRaises(KrokiNetworkError) as ctx:
220
+ convert("graphviz", "digraph{}", "svg")
221
+
222
+ self.assertEqual("IncompleteRead(4 bytes read, 10 more expected)", str(ctx.exception))
223
+ self.assertIs(incomplete, ctx.exception.__cause__)
224
+
225
+
226
+ class TestConvertToFileMocked(unittest.TestCase):
227
+ @patch("k3kroki.kroki.convert")
228
+ def test_writes_bytes_to_file(self, mock_convert):
229
+ mock_convert.return_value = b"<svg>test</svg>"
230
+
231
+ with tempfile.TemporaryDirectory() as tdir:
232
+ path = str(Path(tdir) / "out.svg")
233
+ convert_to_file("graphviz", "digraph{}", path)
234
+
235
+ self.assertEqual(Path(path).read_bytes(), b"<svg>test</svg>")
236
+ mock_convert.assert_called_once_with(
237
+ "graphviz", "digraph{}", "svg", base_url=DEFAULT_BASE_URL, timeout=30.0
238
+ )
239
+
240
+ @patch("k3kroki.kroki.convert")
241
+ def test_infers_format_from_extension(self, mock_convert):
242
+ mock_convert.return_value = b"\x89PNG"
243
+
244
+ with tempfile.TemporaryDirectory() as tdir:
245
+ for ext, fmt in _EXT_TO_FORMAT.items():
246
+ path = str(Path(tdir) / f"out{ext}")
247
+ convert_to_file("graphviz", "digraph{}", path)
248
+ self.assertEqual(mock_convert.call_args[0][2], fmt)
249
+
250
+ @patch("k3kroki.kroki.convert")
251
+ def test_explicit_format_overrides_extension(self, mock_convert):
252
+ mock_convert.return_value = b"data"
253
+
254
+ with tempfile.TemporaryDirectory() as tdir:
255
+ path = str(Path(tdir) / "out.svg")
256
+ convert_to_file("graphviz", "digraph{}", path, output_format="png")
257
+ self.assertEqual(mock_convert.call_args[0][2], "png")
258
+
259
+ def test_unknown_extension_raises(self):
260
+ with self.assertRaises(UnsupportedFormatError):
261
+ convert_to_file("graphviz", "digraph{}", "/tmp/out.bmp")
262
+
263
+
264
+ class TestExceptionHierarchy(unittest.TestCase):
265
+ def test_unsupported_diagram_is_value_error(self):
266
+ self.assertTrue(issubclass(UnsupportedDiagramError, ValueError))
267
+ self.assertTrue(issubclass(UnsupportedDiagramError, k3kroki.KrokiError))
268
+
269
+ def test_unsupported_format_is_value_error(self):
270
+ self.assertTrue(issubclass(UnsupportedFormatError, ValueError))
271
+ self.assertTrue(issubclass(UnsupportedFormatError, k3kroki.KrokiError))
272
+
273
+ def test_api_error_is_kroki_error(self):
274
+ self.assertTrue(issubclass(KrokiAPIError, k3kroki.KrokiError))
275
+
276
+ def test_network_error_is_kroki_error(self):
277
+ self.assertTrue(issubclass(KrokiNetworkError, k3kroki.KrokiError))
278
+
279
+
280
+ class TestImageOutput(unittest.TestCase):
281
+ """Tests that hit kroki.io and compare rendered images against expected outputs."""
282
+
283
+ @contextlib.contextmanager
284
+ def _skip_if_kroki_down(self):
285
+ """Skip, instead of fail, when kroki.io itself times out or returns 5xx."""
286
+ try:
287
+ yield
288
+ except KrokiNetworkError as e:
289
+ self.skipTest(f"kroki.io unreachable: {e}")
290
+ except KrokiAPIError as e:
291
+ if e.status_code < 500:
292
+ raise
293
+ self.skipTest(f"kroki.io failed: {e}")
294
+
295
+ def _assert_bitmap(self, diagram_type: str, fmt: str) -> None:
296
+ source = (DATA_DIR / diagram_type / "input").read_text()
297
+ with self._skip_if_kroki_down():
298
+ got_bytes = convert(diagram_type, source, fmt)
299
+
300
+ got_path = DATA_DIR / diagram_type / f"got.{fmt}"
301
+ got_path.write_bytes(got_bytes)
302
+ try:
303
+ sim = _image_ssim(DATA_DIR / diagram_type / f"want.{fmt}", got_path)
304
+ self.assertGreater(sim, 0.75)
305
+ finally:
306
+ got_path.unlink(missing_ok=True)
307
+
308
+ def test_convert_png(self):
309
+ for t in ("graphviz", "mermaid", "plantuml", "d2", "svgbob"):
310
+ with self.subTest(diagram_type=t):
311
+ self._assert_bitmap(t, "png")
312
+
313
+ def test_convert_webp(self):
314
+ for t in ("d2", "svgbob", "graphviz"):
315
+ with self.subTest(diagram_type=t):
316
+ self._assert_bitmap(t, "webp")
317
+
318
+ def test_convert_svg(self):
319
+ for t in ("graphviz", "mermaid", "plantuml", "d2", "svgbob"):
320
+ want_path = DATA_DIR / t / "want.svg"
321
+ if not want_path.exists():
322
+ continue
323
+
324
+ with self.subTest(diagram_type=t):
325
+ source = (DATA_DIR / t / "input").read_text()
326
+ with self._skip_if_kroki_down():
327
+ got = convert(t, source, "svg")
328
+ self.assertIn(b"<svg", want_path.read_bytes())
329
+ self.assertIn(b"<svg", got)
330
+
331
+ def test_convert_to_file(self):
332
+ cases = [
333
+ ("graphviz", "png"),
334
+ ("mermaid", "png"),
335
+ ("plantuml", "png"),
336
+ ("d2", "svg"),
337
+ ("d2", "png"),
338
+ ("d2", "webp"),
339
+ ("svgbob", "svg"),
340
+ ("svgbob", "png"),
341
+ ("svgbob", "webp"),
342
+ ]
343
+ for diagram_type, fmt in cases:
344
+ want_path = DATA_DIR / diagram_type / f"want.{fmt}"
345
+ if not want_path.exists():
346
+ continue
347
+
348
+ with self.subTest(diagram_type=diagram_type, fmt=fmt):
349
+ source = (DATA_DIR / diagram_type / "input").read_text()
350
+ got_path = DATA_DIR / diagram_type / f"got.{fmt}"
351
+
352
+ with self._skip_if_kroki_down():
353
+ convert_to_file(diagram_type, source, str(got_path), fmt)
354
+ try:
355
+ if fmt == "svg":
356
+ self.assertIn(b"<svg", got_path.read_bytes())
357
+ else:
358
+ sim = _image_ssim(want_path, got_path)
359
+ self.assertGreater(sim, 0.75)
360
+ finally:
361
+ got_path.unlink(missing_ok=True)