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 +21 -0
- k3kroki-0.1.0/PKG-INFO +86 -0
- k3kroki-0.1.0/README.md +46 -0
- k3kroki-0.1.0/__init__.py +32 -0
- k3kroki-0.1.0/k3kroki.egg-info/PKG-INFO +86 -0
- k3kroki-0.1.0/k3kroki.egg-info/SOURCES.txt +16 -0
- k3kroki-0.1.0/k3kroki.egg-info/dependency_links.txt +1 -0
- k3kroki-0.1.0/k3kroki.egg-info/requires.txt +20 -0
- k3kroki-0.1.0/k3kroki.egg-info/top_level.txt +1 -0
- k3kroki-0.1.0/kroki.py +205 -0
- k3kroki-0.1.0/pyproject.toml +63 -0
- k3kroki-0.1.0/setup.cfg +4 -0
- k3kroki-0.1.0/synopsis.py +10 -0
- k3kroki-0.1.0/test/test_doctest.py +8 -0
- k3kroki-0.1.0/test/test_kroki.py +361 -0
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
|
+
[](https://github.com/pykit3/k3kroki/actions/workflows/python-package.yml)
|
|
44
|
+
[](https://k3kroki.readthedocs.io/en/stable/?badge=stable)
|
|
45
|
+
[](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
|
k3kroki-0.1.0/README.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# k3kroki
|
|
2
|
+
|
|
3
|
+
[](https://github.com/pykit3/k3kroki/actions/workflows/python-package.yml)
|
|
4
|
+
[](https://k3kroki.readthedocs.io/en/stable/?badge=stable)
|
|
5
|
+
[](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
|
+
[](https://github.com/pykit3/k3kroki/actions/workflows/python-package.yml)
|
|
44
|
+
[](https://k3kroki.readthedocs.io/en/stable/?badge=stable)
|
|
45
|
+
[](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 @@
|
|
|
1
|
+
|
|
@@ -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
|
k3kroki-0.1.0/setup.cfg
ADDED
|
@@ -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,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)
|