simit 0.1.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.
- simit/__init__.py +39 -0
- simit/api.py +258 -0
- simit/backends/__init__.py +112 -0
- simit/backends/bagel.py +359 -0
- simit/backends/base.py +114 -0
- simit/backends/hf.py +336 -0
- simit/backends/imagegen.py +136 -0
- simit/backends/lance.py +338 -0
- simit/backends/mot/engine.py +844 -0
- simit/backends/mot/kernels.py +130 -0
- simit/backends/mot/modeling.py +514 -0
- simit/backends/mot/wan_vae.py +872 -0
- simit/backends/vllm.py +204 -0
- simit/config.py +112 -0
- simit/metrics.py +113 -0
- simit/pipeline/core.py +586 -0
- simit/pipeline/prompts.py +167 -0
- simit/skills/__init__.py +6 -0
- simit/skills/assets/MERMAID_LICENSE +21 -0
- simit/skills/assets/mermaid.min.js +3587 -0
- simit/skills/base.py +172 -0
- simit/skills/builtin.py +614 -0
- simit/skills/helpers.py +197 -0
- simit/skills/pool.py +262 -0
- simit/skills/prompts.py +2374 -0
- simit/skills/renderers.py +1292 -0
- simit/skills/routing.py +89 -0
- simit/skills/sandbox.py +188 -0
- simit/skills/web.py +253 -0
- simit/tune.py +323 -0
- simit/types.py +96 -0
- simit/utils.py +67 -0
- simit-0.1.0.dist-info/METADATA +388 -0
- simit-0.1.0.dist-info/RECORD +38 -0
- simit-0.1.0.dist-info/WHEEL +5 -0
- simit-0.1.0.dist-info/licenses/LICENSE +202 -0
- simit-0.1.0.dist-info/licenses/src/simit/skills/assets/MERMAID_LICENSE +21 -0
- simit-0.1.0.dist-info/top_level.txt +1 -0
simit/skills/routing.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Visualization routing: pick the skill that should realize a description.
|
|
2
|
+
|
|
3
|
+
The router prompt is assembled from the active skills: each contributes its
|
|
4
|
+
category definition (``route_hint``) and example requests. With exactly the
|
|
5
|
+
built-in skills active, the assembled prompt is identical to the original
|
|
6
|
+
SIMIT router prompt (``prompts.CLASSIFY_PROMPT``).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from typing import Sequence
|
|
13
|
+
|
|
14
|
+
from . import prompts as P
|
|
15
|
+
|
|
16
|
+
_NUMBER_WORDS = ["zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten",
|
|
17
|
+
"eleven", "twelve", "thirteen", "fourteen", "fifteen", "sixteen", "seventeen", "eighteen",
|
|
18
|
+
"nineteen", "twenty", "twenty-one", "twenty-two", "twenty-three", "twenty-four",
|
|
19
|
+
"twenty-five", "twenty-six", "twenty-seven", "twenty-eight", "twenty-nine", "thirty"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _parse_builtin_prompt(text: str):
|
|
23
|
+
"""Split CLASSIFY_PROMPT into (definitions{LABEL: paragraph}, label order,
|
|
24
|
+
few-shot [(request, LABEL)])."""
|
|
25
|
+
head, rest = text.split("\n\n", 1)
|
|
26
|
+
defs_part, tail = rest.split("\n\nAnswer with exactly one word:", 1)
|
|
27
|
+
definitions, order = {}, []
|
|
28
|
+
for para in defs_part.split("\n\n"):
|
|
29
|
+
label, body = para.split(":", 1)
|
|
30
|
+
definitions[label] = body.strip()
|
|
31
|
+
order.append(label)
|
|
32
|
+
shots = re.findall(r'Request: "(.*?)"\nAnswer: (\w+)\n', tail, re.DOTALL)
|
|
33
|
+
shots = [(r, a) for r, a in shots if r != "{request}"]
|
|
34
|
+
return head, definitions, order, shots
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_HEAD, BUILTIN_DEFINITIONS, BUILTIN_ORDER, BUILTIN_SHOTS = _parse_builtin_prompt(P.CLASSIFY_PROMPT)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _number_word(n: int) -> str:
|
|
41
|
+
return _NUMBER_WORDS[n] if n < len(_NUMBER_WORDS) else str(n)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def build_router_prompt(skills: Sequence) -> str:
|
|
45
|
+
"""Router prompt (with a ``{request}`` placeholder) for ``skills``."""
|
|
46
|
+
labels = [s.label for s in skills]
|
|
47
|
+
head = _HEAD.replace("seventeen", _number_word(len(labels)))
|
|
48
|
+
defs = "\n\n".join(f"{s.label}: {_wrap_hint(s)}" for s in skills)
|
|
49
|
+
if len(labels) == 1:
|
|
50
|
+
answer = f"Answer with exactly one word: {labels[0]}."
|
|
51
|
+
else:
|
|
52
|
+
answer = "Answer with exactly one word: " + ", ".join(labels[:-1]) + f", or {labels[-1]}."
|
|
53
|
+
active = set(labels)
|
|
54
|
+
shots = [(req, lab) for req, lab in BUILTIN_SHOTS if lab in active]
|
|
55
|
+
for s in skills:
|
|
56
|
+
if s.label not in BUILTIN_DEFINITIONS:
|
|
57
|
+
shots += [(req, s.label) for req in s.get_route_examples()]
|
|
58
|
+
body = "".join(f'Request: "{req}"\nAnswer: {lab}\n\n' for req, lab in shots)
|
|
59
|
+
prompt = f"{head}\n\n{defs}\n\n{answer}\n\n{body}" + 'Request: "{request}"\nAnswer:'
|
|
60
|
+
return prompt
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _wrap_hint(skill) -> str:
|
|
64
|
+
hint = BUILTIN_DEFINITIONS.get(skill.label) if skill.route_hint == "" else skill.route_hint
|
|
65
|
+
return (hint or skill.name.replace("_", " ")).strip()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def parse_route(answer: str, skills: Sequence, default: str) -> str:
|
|
69
|
+
"""Map the router's free-text answer to a skill name (longest label first,
|
|
70
|
+
so HTML_COMPOSITE beats HTML)."""
|
|
71
|
+
answer = answer.strip().upper()
|
|
72
|
+
for skill in sorted(skills, key=lambda s: len(s.label), reverse=True):
|
|
73
|
+
if skill.label in answer:
|
|
74
|
+
return skill.name
|
|
75
|
+
return default
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def pre_route(request: str, skills: Sequence) -> str | None:
|
|
79
|
+
"""Deterministic routing rules that bypass the model (``Skill.route_pattern``)."""
|
|
80
|
+
ordered = sorted(skills, key=lambda s: -getattr(s, "route_priority", 0))
|
|
81
|
+
for skill in ordered:
|
|
82
|
+
pattern = getattr(skill, "route_pattern", None)
|
|
83
|
+
if pattern is None or not pattern.search(request):
|
|
84
|
+
continue
|
|
85
|
+
exclude = getattr(skill, "route_exclude_pattern", None)
|
|
86
|
+
if exclude is not None and exclude.search(request):
|
|
87
|
+
continue
|
|
88
|
+
return skill.name
|
|
89
|
+
return None
|
simit/skills/sandbox.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Isolated execution of model-written matplotlib code (the FIGURE skill)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from PIL import Image
|
|
13
|
+
|
|
14
|
+
from ..utils import CJK_RE
|
|
15
|
+
from .helpers import GEOMETRY_HELPERS_SRC, TEXT_OVERLAP_FIX_SRC
|
|
16
|
+
|
|
17
|
+
CODE_BLOCK_RE = re.compile(r"```(?:python)?\s*\n(.*?)```", re.DOTALL)
|
|
18
|
+
|
|
19
|
+
# The model is only ever asked for matplotlib/numpy code; anything touching
|
|
20
|
+
# the OS is refused rather than executed.
|
|
21
|
+
DENYLIST = (
|
|
22
|
+
"import os", "import sys", "import subprocess", "import socket",
|
|
23
|
+
"import shutil", "__import__", "eval(", "exec(", "open(", "os.", "sys.",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
MAX_DIM = 2000
|
|
27
|
+
MAX_ASPECT_RATIO = 9.0
|
|
28
|
+
TIMEOUT_S = 20
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def extract_code(text: str) -> str | None:
|
|
32
|
+
m = CODE_BLOCK_RE.search(text)
|
|
33
|
+
return m.group(1).strip() if m else None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def is_safe(code: str) -> bool:
|
|
37
|
+
return not any(pattern in code for pattern in DENYLIST)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def find_non_ascii_issue(code: str) -> str | None:
|
|
41
|
+
if CJK_RE.search(code):
|
|
42
|
+
return (
|
|
43
|
+
"The code mixes Chinese characters into Python identifiers/calls "
|
|
44
|
+
"(e.g. `ax.plot共同点列表,...`), which is invalid syntax. Rewrite the "
|
|
45
|
+
"entire script using only English words and standard ASCII "
|
|
46
|
+
"characters for every variable name, function call, and argument."
|
|
47
|
+
)
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def find_known_api_issues(code: str) -> str | None:
|
|
52
|
+
if "boxplot(" in code and "tick_labels" not in code and re.search(r"\blabels\s*=", code):
|
|
53
|
+
return (
|
|
54
|
+
"ax.boxplot's label argument is called tick_labels in this matplotlib "
|
|
55
|
+
"version, not labels. Replace labels= with tick_labels= in the boxplot() call."
|
|
56
|
+
)
|
|
57
|
+
if re.search(r"\bArc\(", code) and re.search(r"\b(angle1|angle2|start_angle|end_angle)\s*=", code):
|
|
58
|
+
return (
|
|
59
|
+
"matplotlib's Arc patch takes theta1 and theta2 for its sweep angles -- "
|
|
60
|
+
"angle1/angle2/start_angle/end_angle are not real Arc parameters. Either fix the "
|
|
61
|
+
"Arc(...) call to use theta1=/theta2=, or better, use the pre-defined "
|
|
62
|
+
"angle_arc(ax, vertex, theta1, theta2, radius=..., label=...) helper instead of "
|
|
63
|
+
"constructing Arc directly."
|
|
64
|
+
)
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
_ZYGOTE_SRC = r"""
|
|
69
|
+
import os, sys, signal, struct, pickle, time, traceback
|
|
70
|
+
import matplotlib
|
|
71
|
+
matplotlib.use("Agg")
|
|
72
|
+
import matplotlib.pyplot, numpy # noqa: F401 (preloaded: forked jobs start warm)
|
|
73
|
+
rd, wr = os.fdopen(0, "rb"), os.fdopen(1, "wb")
|
|
74
|
+
def send(obj):
|
|
75
|
+
data = pickle.dumps(obj); wr.write(struct.pack("<I", len(data)) + data); wr.flush()
|
|
76
|
+
while True:
|
|
77
|
+
head = rd.read(4)
|
|
78
|
+
if len(head) < 4:
|
|
79
|
+
break
|
|
80
|
+
script, workdir, timeout = pickle.loads(rd.read(struct.unpack("<I", head)[0]))
|
|
81
|
+
err_path = os.path.join(workdir, "stderr.txt")
|
|
82
|
+
pid = os.fork()
|
|
83
|
+
if pid == 0:
|
|
84
|
+
try:
|
|
85
|
+
os.chdir(workdir)
|
|
86
|
+
fd = os.open(err_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC)
|
|
87
|
+
os.dup2(fd, 2)
|
|
88
|
+
code = compile(script, "script.py", "exec")
|
|
89
|
+
exec(code, {"__name__": "__main__"})
|
|
90
|
+
os._exit(0)
|
|
91
|
+
except SystemExit as e:
|
|
92
|
+
os._exit(e.code if isinstance(e.code, int) else 1)
|
|
93
|
+
except BaseException:
|
|
94
|
+
traceback.print_exc()
|
|
95
|
+
sys.stderr.flush()
|
|
96
|
+
os._exit(1)
|
|
97
|
+
deadline = time.time() + timeout
|
|
98
|
+
status = None
|
|
99
|
+
while time.time() < deadline:
|
|
100
|
+
done, st = os.waitpid(pid, os.WNOHANG)
|
|
101
|
+
if done:
|
|
102
|
+
status = st
|
|
103
|
+
break
|
|
104
|
+
time.sleep(0.01)
|
|
105
|
+
if status is None:
|
|
106
|
+
os.kill(pid, signal.SIGKILL)
|
|
107
|
+
os.waitpid(pid, 0)
|
|
108
|
+
send((False, f"Execution timed out after {timeout}s"))
|
|
109
|
+
continue
|
|
110
|
+
err = ""
|
|
111
|
+
if os.path.exists(err_path):
|
|
112
|
+
with open(err_path) as f:
|
|
113
|
+
err = f.read()[-2000:]
|
|
114
|
+
send((os.WIFEXITED(status) and os.WEXITSTATUS(status) == 0, err))
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class _Zygote:
|
|
119
|
+
"""A warm interpreter (matplotlib/numpy imported) that forks one child per
|
|
120
|
+
script, so each FIGURE render skips the ~1.5s interpreter+import start."""
|
|
121
|
+
|
|
122
|
+
def __init__(self):
|
|
123
|
+
self.proc = None
|
|
124
|
+
|
|
125
|
+
def _start(self):
|
|
126
|
+
env = {"MPLBACKEND": "Agg", "PATH": "/usr/bin:/bin"}
|
|
127
|
+
if "HOME" in os.environ:
|
|
128
|
+
env["HOME"] = os.environ["HOME"]
|
|
129
|
+
self.proc = subprocess.Popen([sys.executable, "-c", _ZYGOTE_SRC], stdin=subprocess.PIPE,
|
|
130
|
+
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, env=env)
|
|
131
|
+
|
|
132
|
+
def run(self, script: str, workdir: str, timeout: int) -> tuple[bool, str]:
|
|
133
|
+
import pickle
|
|
134
|
+
import struct
|
|
135
|
+
if self.proc is None or self.proc.poll() is not None:
|
|
136
|
+
self._start()
|
|
137
|
+
data = pickle.dumps((script, workdir, timeout))
|
|
138
|
+
self.proc.stdin.write(struct.pack("<I", len(data)) + data)
|
|
139
|
+
self.proc.stdin.flush()
|
|
140
|
+
head = self.proc.stdout.read(4)
|
|
141
|
+
if len(head) < 4:
|
|
142
|
+
self.proc = None
|
|
143
|
+
raise RuntimeError("figure sandbox died")
|
|
144
|
+
return pickle.loads(self.proc.stdout.read(struct.unpack("<I", head)[0]))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
_ZYGOTE = _Zygote() if hasattr(os, "fork") and sys.platform.startswith("linux") else None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def run_script(code: str) -> tuple[Image.Image | None, str]:
|
|
151
|
+
"""Run ``code`` isolated from this process; it must save ``output.png``.
|
|
152
|
+
Returns (image, "") on success or (None, error_text)."""
|
|
153
|
+
with tempfile.TemporaryDirectory(prefix="simit_fig_") as tmp:
|
|
154
|
+
workdir = Path(tmp)
|
|
155
|
+
script = GEOMETRY_HELPERS_SRC + "\n" + TEXT_OVERLAP_FIX_SRC + "\n" + code
|
|
156
|
+
ok, err = None, ""
|
|
157
|
+
if _ZYGOTE is not None:
|
|
158
|
+
try:
|
|
159
|
+
ok, err = _ZYGOTE.run(script, tmp, TIMEOUT_S)
|
|
160
|
+
except Exception:
|
|
161
|
+
ok = None
|
|
162
|
+
if ok is None: # no fork available (or the zygote died): fresh interpreter
|
|
163
|
+
(workdir / "script.py").write_text(script)
|
|
164
|
+
env = {"MPLBACKEND": "Agg", "PATH": "/usr/bin:/bin", "MPLCONFIGDIR": tmp}
|
|
165
|
+
if "HOME" in os.environ:
|
|
166
|
+
env["HOME"] = os.environ["HOME"]
|
|
167
|
+
try:
|
|
168
|
+
proc = subprocess.run([sys.executable, "script.py"], cwd=workdir, capture_output=True,
|
|
169
|
+
text=True, timeout=TIMEOUT_S, env=env)
|
|
170
|
+
ok, err = proc.returncode == 0, proc.stderr[-2000:]
|
|
171
|
+
except subprocess.TimeoutExpired:
|
|
172
|
+
ok, err = False, f"Execution timed out after {TIMEOUT_S}s"
|
|
173
|
+
if not ok:
|
|
174
|
+
return None, err or "script failed"
|
|
175
|
+
png = workdir / "output.png"
|
|
176
|
+
if not png.exists():
|
|
177
|
+
return None, "Script exited cleanly but did not produce output.png"
|
|
178
|
+
image = Image.open(png)
|
|
179
|
+
image.load()
|
|
180
|
+
w, h = image.size
|
|
181
|
+
if w > MAX_DIM or h > MAX_DIM or max(w, h) / max(1, min(w, h)) > MAX_ASPECT_RATIO:
|
|
182
|
+
return None, (
|
|
183
|
+
f"Rendered image has pathological dimensions {w}x{h}px. This usually "
|
|
184
|
+
"means a shape was added with ax.add_patch(...) without also calling "
|
|
185
|
+
"ax.set_xlim(...)/ax.set_ylim(...) explicitly, or bbox_inches='tight' picked up "
|
|
186
|
+
"an artist placed far outside the intended view. Fix the axis limits."
|
|
187
|
+
)
|
|
188
|
+
return image, ""
|
simit/skills/web.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Browser-backed rendering (HTML, Mermaid) through Playwright's Chromium.
|
|
2
|
+
|
|
3
|
+
One headless browser is kept alive per process, so a render costs one page
|
|
4
|
+
load instead of a browser launch. The Chromium binary is downloaded on first
|
|
5
|
+
use (``python -m playwright install chromium``) -- no Node.js, Mermaid CLI,
|
|
6
|
+
or system Chromium is needed: Mermaid runs from the ``mermaid.min.js`` bundled
|
|
7
|
+
with this package. Network access from rendered pages is blocked.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import threading
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from .base import SpecError
|
|
21
|
+
|
|
22
|
+
_ASSETS = Path(__file__).parent / "assets"
|
|
23
|
+
_MERMAID_JS = _ASSETS / "mermaid.min.js"
|
|
24
|
+
|
|
25
|
+
_SCRIPT_RE = re.compile(r"<script[\s>]", re.IGNORECASE)
|
|
26
|
+
_HANDLER_RE = re.compile(r"\bon\w+\s*=", re.IGNORECASE)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def is_safe_markup(code: str) -> bool:
|
|
30
|
+
"""Model-written HTML/SVG must not run scripts."""
|
|
31
|
+
return not (_SCRIPT_RE.search(code) or _HANDLER_RE.search(code))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_LAUNCH_ARGS = ["--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage"]
|
|
35
|
+
_SYSTEM_BROWSERS = ("chromium", "chromium-browser", "google-chrome", "google-chrome-stable", "chrome")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _system_browser() -> str | None:
|
|
39
|
+
if os.environ.get("SIMIT_CHROMIUM"):
|
|
40
|
+
return os.environ["SIMIT_CHROMIUM"]
|
|
41
|
+
import shutil
|
|
42
|
+
|
|
43
|
+
for name in _SYSTEM_BROWSERS:
|
|
44
|
+
path = shutil.which(name)
|
|
45
|
+
if path:
|
|
46
|
+
return path
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _install_chromium():
|
|
51
|
+
"""Download Playwright's headless Chromium once, serialized across processes."""
|
|
52
|
+
import fcntl
|
|
53
|
+
import tempfile
|
|
54
|
+
|
|
55
|
+
lock_path = Path(tempfile.gettempdir()) / "simit_playwright_install.lock"
|
|
56
|
+
with open(lock_path, "w") as lock:
|
|
57
|
+
fcntl.flock(lock, fcntl.LOCK_EX)
|
|
58
|
+
print("[simit] downloading headless Chromium for HTML/Mermaid rendering (one-time)...",
|
|
59
|
+
file=sys.stderr, flush=True)
|
|
60
|
+
r = subprocess.run([sys.executable, "-m", "playwright", "install", "chromium-headless-shell"],
|
|
61
|
+
check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=900)
|
|
62
|
+
if r.returncode != 0: # Playwright's (Node) downloader fails on some networks
|
|
63
|
+
_download_headless_shell()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _download_headless_shell():
|
|
67
|
+
"""Fetch the build Playwright expects with plain Python and unpack it where
|
|
68
|
+
Playwright looks for it."""
|
|
69
|
+
import shutil
|
|
70
|
+
import tempfile
|
|
71
|
+
import urllib.request
|
|
72
|
+
import zipfile
|
|
73
|
+
|
|
74
|
+
out = subprocess.run([sys.executable, "-m", "playwright", "install", "--dry-run", "chromium-headless-shell"],
|
|
75
|
+
capture_output=True, text=True, timeout=120).stdout
|
|
76
|
+
block = out.split("\n\n")[0]
|
|
77
|
+
loc = re.search(r"Install location:\s*(.+)", block)
|
|
78
|
+
urls = re.findall(r"Download (?:url|fallback \d+):\s*(\S+)", block)
|
|
79
|
+
if not loc or not urls:
|
|
80
|
+
return
|
|
81
|
+
dest = Path(loc.group(1).strip())
|
|
82
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
83
|
+
archive = Path(tmp) / "browser.zip"
|
|
84
|
+
for url in urls:
|
|
85
|
+
try:
|
|
86
|
+
with urllib.request.urlopen(url, timeout=60) as resp, open(archive, "wb") as f:
|
|
87
|
+
shutil.copyfileobj(resp, f)
|
|
88
|
+
break
|
|
89
|
+
except Exception:
|
|
90
|
+
continue
|
|
91
|
+
else:
|
|
92
|
+
return
|
|
93
|
+
staging = dest.with_name(dest.name + ".partial")
|
|
94
|
+
shutil.rmtree(staging, ignore_errors=True)
|
|
95
|
+
with zipfile.ZipFile(archive) as z:
|
|
96
|
+
for info in z.infolist():
|
|
97
|
+
path = z.extract(info, staging)
|
|
98
|
+
mode = (info.external_attr >> 16) & 0o777
|
|
99
|
+
if mode:
|
|
100
|
+
os.chmod(path, mode)
|
|
101
|
+
shutil.rmtree(dest, ignore_errors=True)
|
|
102
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
os.replace(staging, dest)
|
|
104
|
+
(dest / "INSTALLATION_COMPLETE").touch()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class _Browser:
|
|
108
|
+
def __init__(self):
|
|
109
|
+
self._pw = None
|
|
110
|
+
self._browser = None
|
|
111
|
+
self._owner = None
|
|
112
|
+
|
|
113
|
+
def _launch(self):
|
|
114
|
+
"""Playwright's own Chromium, else a system Chrome/Chromium, else
|
|
115
|
+
download Playwright's Chromium and retry."""
|
|
116
|
+
from playwright.sync_api import sync_playwright
|
|
117
|
+
|
|
118
|
+
if self._pw is not None: # a previous launch failed or another thread owned it
|
|
119
|
+
try:
|
|
120
|
+
self._pw.stop()
|
|
121
|
+
except Exception:
|
|
122
|
+
pass
|
|
123
|
+
self._pw = sync_playwright().start()
|
|
124
|
+
errors = []
|
|
125
|
+
for attempt in ("bundled", "system", "install"):
|
|
126
|
+
try:
|
|
127
|
+
if attempt == "system":
|
|
128
|
+
exe = _system_browser()
|
|
129
|
+
if exe is None:
|
|
130
|
+
continue
|
|
131
|
+
self._browser = self._pw.chromium.launch(executable_path=exe, args=_LAUNCH_ARGS)
|
|
132
|
+
else:
|
|
133
|
+
if attempt == "install":
|
|
134
|
+
_install_chromium()
|
|
135
|
+
self._browser = self._pw.chromium.launch(args=_LAUNCH_ARGS)
|
|
136
|
+
return
|
|
137
|
+
except Exception as e:
|
|
138
|
+
errors.append(f"{attempt}: {str(e).splitlines()[0]}")
|
|
139
|
+
raise SpecError("no usable Chromium for HTML/Mermaid rendering (" + "; ".join(errors) +
|
|
140
|
+
"). Run `python -m playwright install chromium` or set SIMIT_CHROMIUM.")
|
|
141
|
+
|
|
142
|
+
def page(self, width: int, height: int):
|
|
143
|
+
# Playwright's sync API is bound to the thread that started it.
|
|
144
|
+
if self._browser is None or self._owner != (os.getpid(), threading.get_ident()):
|
|
145
|
+
self._launch()
|
|
146
|
+
self._owner = (os.getpid(), threading.get_ident())
|
|
147
|
+
page = self._browser.new_page(viewport={"width": width, "height": height})
|
|
148
|
+
page.route("**/*", lambda route: route.abort()
|
|
149
|
+
if route.request.url.startswith(("http://", "https://")) else route.continue_())
|
|
150
|
+
return page
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
_BROWSER = _Browser()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def render_html(html: str, width: int = 900, height: int = 700) -> bytes:
|
|
157
|
+
"""Screenshot of the ``width`` x ``height`` viewport (PNG bytes)."""
|
|
158
|
+
if not is_safe_markup(html):
|
|
159
|
+
raise SpecError("HTML contains script elements or event handlers")
|
|
160
|
+
page = _BROWSER.page(width, height)
|
|
161
|
+
try:
|
|
162
|
+
page.set_content(html, wait_until="load", timeout=30_000)
|
|
163
|
+
return page.screenshot(timeout=30_000)
|
|
164
|
+
finally:
|
|
165
|
+
page.close()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
_MERMAID_PAGE = """<!DOCTYPE html><html><head><meta charset="utf-8">
|
|
169
|
+
<style>body{margin:0;background:white;} #out{width:100%;background:white;}</style>
|
|
170
|
+
</head><body><div id="out"></div></body></html>"""
|
|
171
|
+
|
|
172
|
+
_MERMAID_RENDER_JS = """async (code) => {
|
|
173
|
+
try {
|
|
174
|
+
mermaid.initialize({startOnLoad: false, securityLevel: 'strict', theme: 'default'});
|
|
175
|
+
const {svg} = await mermaid.render('simit_graph', code);
|
|
176
|
+
const out = document.getElementById('out');
|
|
177
|
+
out.innerHTML = svg;
|
|
178
|
+
return {ok: true};
|
|
179
|
+
} catch (e) {
|
|
180
|
+
return {ok: false, error: String((e && e.message) || e)};
|
|
181
|
+
}
|
|
182
|
+
}"""
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def render_mermaid(code: str, width: int = 900) -> bytes:
|
|
186
|
+
"""Render Mermaid code to PNG (same page width as ``mmdc -w 900``)."""
|
|
187
|
+
page = _BROWSER.page(width, 600)
|
|
188
|
+
try:
|
|
189
|
+
page.set_content(_MERMAID_PAGE, wait_until="load")
|
|
190
|
+
page.add_script_tag(path=str(_MERMAID_JS))
|
|
191
|
+
result = page.evaluate(_MERMAID_RENDER_JS, code)
|
|
192
|
+
if not result.get("ok"):
|
|
193
|
+
msg = result.get("error", "Mermaid could not render this diagram")
|
|
194
|
+
raise SpecError(msg.strip().splitlines()[0][:400] if msg.strip() else "Mermaid syntax error")
|
|
195
|
+
# Like mmdc: screenshot the SVG's bounding box (mermaid caps its width
|
|
196
|
+
# at the natural size, and the page width caps it at ``width``).
|
|
197
|
+
element = page.query_selector("#out svg")
|
|
198
|
+
if element is None:
|
|
199
|
+
raise SpecError("Mermaid produced no diagram")
|
|
200
|
+
return element.screenshot(timeout=30_000)
|
|
201
|
+
finally:
|
|
202
|
+
page.close()
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def render_svg(svg_code: str, output_size: int = 600) -> bytes:
|
|
206
|
+
"""Rasterize SVG onto a white ``output_size`` square, scaled to fit and
|
|
207
|
+
centred (as cairosvg's output_width/output_height did), via resvg in
|
|
208
|
+
vl-convert -- no system cairo needed."""
|
|
209
|
+
import io
|
|
210
|
+
|
|
211
|
+
import vl_convert
|
|
212
|
+
from PIL import Image
|
|
213
|
+
|
|
214
|
+
if not is_safe_markup(svg_code):
|
|
215
|
+
raise SpecError("SVG contains script elements or event handlers")
|
|
216
|
+
m = re.search(r"<svg\b[^>]*>", svg_code, re.IGNORECASE | re.DOTALL)
|
|
217
|
+
if not m:
|
|
218
|
+
raise SpecError("no <svg> element found")
|
|
219
|
+
w, h = _svg_size(m.group(0))
|
|
220
|
+
scale = output_size / max(w, h) if w and h else 1.0
|
|
221
|
+
try:
|
|
222
|
+
png = vl_convert.svg_to_png(svg_code, scale=scale)
|
|
223
|
+
except Exception as e: # malformed XML etc.
|
|
224
|
+
raise SpecError(f"SVG could not be rendered: {str(e).splitlines()[0][:300]}") from None
|
|
225
|
+
drawing = Image.open(io.BytesIO(png)).convert("RGBA")
|
|
226
|
+
drawing.thumbnail((output_size, output_size))
|
|
227
|
+
canvas = Image.new("RGB", (output_size, output_size), "white")
|
|
228
|
+
canvas.paste(drawing, ((output_size - drawing.width) // 2, (output_size - drawing.height) // 2), drawing)
|
|
229
|
+
buf = io.BytesIO()
|
|
230
|
+
canvas.save(buf, format="PNG")
|
|
231
|
+
return buf.getvalue()
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _svg_size(tag: str) -> tuple[float, float]:
|
|
235
|
+
def num(attr):
|
|
236
|
+
m = re.search(rf'\b{attr}\s*=\s*["\']\s*([\d.]+)', tag)
|
|
237
|
+
return float(m.group(1)) if m else 0.0
|
|
238
|
+
|
|
239
|
+
w, h = num("width"), num("height")
|
|
240
|
+
if not (w and h):
|
|
241
|
+
vb = re.search(r'viewBox\s*=\s*["\']\s*[-\d.]+[\s,]+[-\d.]+[\s,]+([\d.]+)[\s,]+([\d.]+)', tag)
|
|
242
|
+
if vb:
|
|
243
|
+
w, h = float(vb.group(1)), float(vb.group(2))
|
|
244
|
+
return w, h
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def render_vegalite(spec: dict) -> bytes:
|
|
248
|
+
import vl_convert
|
|
249
|
+
|
|
250
|
+
try:
|
|
251
|
+
return vl_convert.vegalite_to_png(vl_spec=json.dumps(spec), scale=2)
|
|
252
|
+
except Exception as e:
|
|
253
|
+
raise SpecError(str(e).strip().splitlines()[0][:400] or "invalid Vega-Lite spec") from None
|