deeptutor-visualize 1.7.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.
- deeptutor/agents/visualize/__init__.py +5 -0
- deeptutor/agents/visualize/agents/__init__.py +11 -0
- deeptutor/agents/visualize/agents/analysis_agent.py +102 -0
- deeptutor/agents/visualize/agents/code_generator_agent.py +117 -0
- deeptutor/agents/visualize/agents/review_agent.py +75 -0
- deeptutor/agents/visualize/capability.py +571 -0
- deeptutor/agents/visualize/models.py +120 -0
- deeptutor/agents/visualize/pipeline.py +91 -0
- deeptutor/agents/visualize/prompts/en/analysis_agent.yaml +132 -0
- deeptutor/agents/visualize/prompts/en/code_generator_agent.yaml +189 -0
- deeptutor/agents/visualize/prompts/en/review_agent.yaml +38 -0
- deeptutor/agents/visualize/prompts/en/visualize.yaml +14 -0
- deeptutor/agents/visualize/prompts/zh/analysis_agent.yaml +123 -0
- deeptutor/agents/visualize/prompts/zh/code_generator_agent.yaml +161 -0
- deeptutor/agents/visualize/prompts/zh/review_agent.yaml +35 -0
- deeptutor/agents/visualize/prompts/zh/visualize.yaml +14 -0
- deeptutor/agents/visualize/utils.py +210 -0
- deeptutor/api/routers/visualizers.py +144 -0
- deeptutor/visualizers/README.md +79 -0
- deeptutor/visualizers/__init__.py +20 -0
- deeptutor/visualizers/builtin.py +369 -0
- deeptutor/visualizers/loop_capability.py +125 -0
- deeptutor/visualizers/protocol.py +225 -0
- deeptutor/visualizers/registry.py +155 -0
- deeptutor/visualizers/store.py +260 -0
- deeptutor/visualizers/tool.py +150 -0
- deeptutor_visualize-1.7.0.dist-info/METADATA +6 -0
- deeptutor_visualize-1.7.0.dist-info/RECORD +30 -0
- deeptutor_visualize-1.7.0.dist-info/WHEEL +5 -0
- deeptutor_visualize-1.7.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Stable contracts shared by visualizer generators, validators and canvases."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from typing import Any, Callable, Literal
|
|
9
|
+
|
|
10
|
+
from jsonschema.exceptions import SchemaError, ValidationError
|
|
11
|
+
from jsonschema.validators import validator_for
|
|
12
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
13
|
+
|
|
14
|
+
VISUALIZE_MODE_KEY = "_visualizer_mode"
|
|
15
|
+
VISUALIZATION_RESULT_KEY = "_visualizer_result"
|
|
16
|
+
REQUESTED_VISUALIZER_KEY = "_requested_visualizer"
|
|
17
|
+
|
|
18
|
+
_VISUALIZER_ID_RE = re.compile(r"^[a-z][a-z0-9_.-]{1,63}$")
|
|
19
|
+
MAX_PAYLOAD_CHARS = 200_000
|
|
20
|
+
MAX_MANIFEST_SCHEMA_CHARS = 32_000
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class VisualizerManifest(BaseModel):
|
|
24
|
+
"""Static, serializable description of one visualization type."""
|
|
25
|
+
|
|
26
|
+
model_config = ConfigDict(extra="forbid")
|
|
27
|
+
|
|
28
|
+
id: str
|
|
29
|
+
version: str = Field(default="1.0.0", min_length=1, max_length=32)
|
|
30
|
+
display_name: str = Field(min_length=1, max_length=80)
|
|
31
|
+
description: str = Field(min_length=1, max_length=1_000)
|
|
32
|
+
author: str = Field(default="DeepTutor", min_length=1, max_length=120)
|
|
33
|
+
subjects: list[str] = Field(default_factory=list, max_length=32)
|
|
34
|
+
intents: list[str] = Field(default_factory=list, max_length=32)
|
|
35
|
+
render_target: Literal["native", "iframe", "artifact"] = "native"
|
|
36
|
+
native_renderer: str = Field(default="", max_length=80)
|
|
37
|
+
renderer_entry: str = Field(default="", max_length=240)
|
|
38
|
+
payload_format: str = Field(min_length=1, max_length=160)
|
|
39
|
+
payload_kind: Literal["text", "json"] = "text"
|
|
40
|
+
payload_schema: dict[str, Any] = Field(default_factory=dict)
|
|
41
|
+
language_tag: str = Field(default="text", min_length=1, max_length=40)
|
|
42
|
+
prompt: str = Field(min_length=1, max_length=12_000)
|
|
43
|
+
agentic: bool = True
|
|
44
|
+
core: bool = False
|
|
45
|
+
default_installed: bool = True
|
|
46
|
+
priority: int = 100
|
|
47
|
+
|
|
48
|
+
@field_validator("id")
|
|
49
|
+
@classmethod
|
|
50
|
+
def _valid_id(cls, value: str) -> str:
|
|
51
|
+
value = value.strip().lower()
|
|
52
|
+
if not _VISUALIZER_ID_RE.fullmatch(value):
|
|
53
|
+
raise ValueError("visualizer id must match [a-z][a-z0-9_.-]{1,63}")
|
|
54
|
+
return value
|
|
55
|
+
|
|
56
|
+
@field_validator("renderer_entry")
|
|
57
|
+
@classmethod
|
|
58
|
+
def _safe_renderer_entry(cls, value: str) -> str:
|
|
59
|
+
value = value.strip().replace("\\", "/")
|
|
60
|
+
if not value:
|
|
61
|
+
return ""
|
|
62
|
+
parts = value.split("/")
|
|
63
|
+
if value.startswith("/") or ".." in parts:
|
|
64
|
+
raise ValueError("renderer_entry must be a safe relative path")
|
|
65
|
+
return value
|
|
66
|
+
|
|
67
|
+
@model_validator(mode="after")
|
|
68
|
+
def _renderer_contract(self) -> "VisualizerManifest":
|
|
69
|
+
if self.render_target == "native" and not self.native_renderer:
|
|
70
|
+
raise ValueError("native visualizers require native_renderer")
|
|
71
|
+
if self.render_target == "iframe" and not self.renderer_entry:
|
|
72
|
+
raise ValueError("iframe visualizers require renderer_entry")
|
|
73
|
+
if len(json.dumps(self.payload_schema, ensure_ascii=False)) > MAX_MANIFEST_SCHEMA_CHARS:
|
|
74
|
+
raise ValueError(f"payload_schema exceeds {MAX_MANIFEST_SCHEMA_CHARS} characters")
|
|
75
|
+
if self.payload_schema:
|
|
76
|
+
remote_ref = _find_remote_schema_ref(self.payload_schema)
|
|
77
|
+
if remote_ref:
|
|
78
|
+
raise ValueError(
|
|
79
|
+
f"payload_schema may only use local # references, found: {remote_ref}"
|
|
80
|
+
)
|
|
81
|
+
try:
|
|
82
|
+
validator_for(self.payload_schema).check_schema(self.payload_schema)
|
|
83
|
+
except SchemaError as exc:
|
|
84
|
+
raise ValueError(f"payload_schema is invalid: {exc.message}") from exc
|
|
85
|
+
return self
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class RendererRef(BaseModel):
|
|
89
|
+
id: str
|
|
90
|
+
version: str
|
|
91
|
+
target: Literal["native", "iframe", "artifact"]
|
|
92
|
+
native_renderer: str = ""
|
|
93
|
+
entry_url: str = ""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class VisualizationPayload(BaseModel):
|
|
97
|
+
format: str
|
|
98
|
+
data: Any
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class VisualizationPresentation(BaseModel):
|
|
102
|
+
title: str = ""
|
|
103
|
+
description: str = ""
|
|
104
|
+
alt_text: str = ""
|
|
105
|
+
aspect_ratio: str = ""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class VisualizationInteraction(BaseModel):
|
|
109
|
+
events: list[str] = Field(default_factory=list)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class VisualizationEnvelope(BaseModel):
|
|
113
|
+
"""Versioned result understood by the generic frontend canvas."""
|
|
114
|
+
|
|
115
|
+
schema_version: str = "deeptutor.visualization/v1"
|
|
116
|
+
render_type: str
|
|
117
|
+
renderer: RendererRef
|
|
118
|
+
payload: VisualizationPayload
|
|
119
|
+
presentation: VisualizationPresentation = Field(default_factory=VisualizationPresentation)
|
|
120
|
+
interaction: VisualizationInteraction = Field(default_factory=VisualizationInteraction)
|
|
121
|
+
fallback: dict[str, Any] = Field(default_factory=dict)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
PayloadValidator = Callable[[str], tuple[bool, Any, str]]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass(frozen=True)
|
|
128
|
+
class VisualizerPlugin:
|
|
129
|
+
"""Runtime wrapper around a manifest and its trusted validator."""
|
|
130
|
+
|
|
131
|
+
manifest: VisualizerManifest
|
|
132
|
+
origin: Literal["core", "bundled", "user"]
|
|
133
|
+
root: str = ""
|
|
134
|
+
validator: PayloadValidator | None = None
|
|
135
|
+
|
|
136
|
+
def validate_payload(self, raw_payload: str) -> tuple[bool, Any, str]:
|
|
137
|
+
raw = str(raw_payload or "").strip()
|
|
138
|
+
if not raw:
|
|
139
|
+
return False, None, "payload is empty"
|
|
140
|
+
if len(raw) > MAX_PAYLOAD_CHARS:
|
|
141
|
+
return False, None, f"payload exceeds {MAX_PAYLOAD_CHARS} characters"
|
|
142
|
+
if self.validator is not None:
|
|
143
|
+
return self.validator(raw)
|
|
144
|
+
if self.manifest.payload_kind == "json":
|
|
145
|
+
try:
|
|
146
|
+
data = json.loads(raw)
|
|
147
|
+
except json.JSONDecodeError as exc:
|
|
148
|
+
return False, None, f"payload is not valid JSON: {exc}"
|
|
149
|
+
if self.manifest.payload_schema:
|
|
150
|
+
validator_type = validator_for(self.manifest.payload_schema)
|
|
151
|
+
try:
|
|
152
|
+
validator_type(self.manifest.payload_schema).validate(data)
|
|
153
|
+
except ValidationError as exc:
|
|
154
|
+
location = "$"
|
|
155
|
+
if exc.absolute_path:
|
|
156
|
+
location += "".join(
|
|
157
|
+
f"[{part}]" if isinstance(part, int) else f".{part}"
|
|
158
|
+
for part in exc.absolute_path
|
|
159
|
+
)
|
|
160
|
+
return (
|
|
161
|
+
False,
|
|
162
|
+
None,
|
|
163
|
+
f"payload schema violation at {location}: {exc.message}",
|
|
164
|
+
)
|
|
165
|
+
return True, data, ""
|
|
166
|
+
return True, raw, ""
|
|
167
|
+
|
|
168
|
+
def serialize_payload(self, data: Any) -> str:
|
|
169
|
+
if isinstance(data, str):
|
|
170
|
+
return data
|
|
171
|
+
return json.dumps(data, ensure_ascii=False, indent=2)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def manifest_public_dict(
|
|
175
|
+
plugin: VisualizerPlugin,
|
|
176
|
+
*,
|
|
177
|
+
installed: bool,
|
|
178
|
+
enabled: bool,
|
|
179
|
+
) -> dict[str, Any]:
|
|
180
|
+
result = plugin.manifest.model_dump()
|
|
181
|
+
# Prompt rules are runtime implementation detail and can be very large.
|
|
182
|
+
result.pop("prompt", None)
|
|
183
|
+
result.update(
|
|
184
|
+
{
|
|
185
|
+
"origin": plugin.origin,
|
|
186
|
+
"installed": installed,
|
|
187
|
+
"enabled": enabled,
|
|
188
|
+
"uninstallable": not plugin.manifest.core,
|
|
189
|
+
}
|
|
190
|
+
)
|
|
191
|
+
return result
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _find_remote_schema_ref(value: Any) -> str:
|
|
195
|
+
if isinstance(value, dict):
|
|
196
|
+
for key, item in value.items():
|
|
197
|
+
if key in {"$ref", "$dynamicRef"} and isinstance(item, str):
|
|
198
|
+
if not item.startswith("#"):
|
|
199
|
+
return item
|
|
200
|
+
found = _find_remote_schema_ref(item)
|
|
201
|
+
if found:
|
|
202
|
+
return found
|
|
203
|
+
elif isinstance(value, list):
|
|
204
|
+
for item in value:
|
|
205
|
+
found = _find_remote_schema_ref(item)
|
|
206
|
+
if found:
|
|
207
|
+
return found
|
|
208
|
+
return ""
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
__all__ = [
|
|
212
|
+
"MAX_PAYLOAD_CHARS",
|
|
213
|
+
"MAX_MANIFEST_SCHEMA_CHARS",
|
|
214
|
+
"REQUESTED_VISUALIZER_KEY",
|
|
215
|
+
"VISUALIZATION_RESULT_KEY",
|
|
216
|
+
"VISUALIZE_MODE_KEY",
|
|
217
|
+
"RendererRef",
|
|
218
|
+
"VisualizationEnvelope",
|
|
219
|
+
"VisualizationInteraction",
|
|
220
|
+
"VisualizationPayload",
|
|
221
|
+
"VisualizationPresentation",
|
|
222
|
+
"VisualizerManifest",
|
|
223
|
+
"VisualizerPlugin",
|
|
224
|
+
"manifest_public_dict",
|
|
225
|
+
]
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Registry composition for core, bundled and per-user visualizer types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .builtin import bundled_visualizers, core_visualizers
|
|
9
|
+
from .protocol import VisualizerPlugin, manifest_public_dict
|
|
10
|
+
from .store import VisualizerStore, VisualizerStoreError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class VisualizerRegistry:
|
|
14
|
+
def __init__(self, *, store: VisualizerStore | None = None) -> None:
|
|
15
|
+
self.store = store or VisualizerStore()
|
|
16
|
+
self._catalog: dict[str, VisualizerPlugin] = {}
|
|
17
|
+
self._installed: set[str] = set()
|
|
18
|
+
self._enabled: set[str] = set()
|
|
19
|
+
self.reload()
|
|
20
|
+
|
|
21
|
+
def reload(self) -> None:
|
|
22
|
+
state = self.store.state()
|
|
23
|
+
explicitly_installed = set(state["installed"])
|
|
24
|
+
disabled = set(state["disabled"])
|
|
25
|
+
uninstalled = set(state["uninstalled"])
|
|
26
|
+
catalog: dict[str, VisualizerPlugin] = {}
|
|
27
|
+
installed: set[str] = set()
|
|
28
|
+
|
|
29
|
+
for plugin in core_visualizers():
|
|
30
|
+
catalog.setdefault(plugin.manifest.id, plugin)
|
|
31
|
+
installed.add(plugin.manifest.id)
|
|
32
|
+
for plugin in bundled_visualizers():
|
|
33
|
+
catalog.setdefault(plugin.manifest.id, plugin)
|
|
34
|
+
if plugin.manifest.id in explicitly_installed or (
|
|
35
|
+
plugin.manifest.default_installed and plugin.manifest.id not in uninstalled
|
|
36
|
+
):
|
|
37
|
+
installed.add(plugin.manifest.id)
|
|
38
|
+
for manifest, root in self.store.user_packages():
|
|
39
|
+
if manifest.id in catalog:
|
|
40
|
+
continue
|
|
41
|
+
catalog[manifest.id] = VisualizerPlugin(
|
|
42
|
+
manifest=manifest,
|
|
43
|
+
origin="user",
|
|
44
|
+
root=str(root),
|
|
45
|
+
)
|
|
46
|
+
installed.add(manifest.id)
|
|
47
|
+
|
|
48
|
+
self._catalog = catalog
|
|
49
|
+
self._installed = installed
|
|
50
|
+
self._enabled = installed - disabled
|
|
51
|
+
|
|
52
|
+
def catalog(self) -> list[VisualizerPlugin]:
|
|
53
|
+
return sorted(self._catalog.values(), key=lambda item: item.manifest.priority)
|
|
54
|
+
|
|
55
|
+
def installed(self, *, include_disabled: bool = False) -> list[VisualizerPlugin]:
|
|
56
|
+
ids = self._installed if include_disabled else self._enabled
|
|
57
|
+
return [plugin for plugin in self.catalog() if plugin.manifest.id in ids]
|
|
58
|
+
|
|
59
|
+
def agentic(self) -> list[VisualizerPlugin]:
|
|
60
|
+
return [plugin for plugin in self.installed() if plugin.manifest.agentic]
|
|
61
|
+
|
|
62
|
+
def get(self, visualizer_id: str, *, require_enabled: bool = True) -> VisualizerPlugin | None:
|
|
63
|
+
plugin = self._catalog.get(str(visualizer_id or "").strip().lower())
|
|
64
|
+
if plugin is None:
|
|
65
|
+
return None
|
|
66
|
+
if plugin.manifest.id not in self._installed:
|
|
67
|
+
return None
|
|
68
|
+
if require_enabled and plugin.manifest.id not in self._enabled:
|
|
69
|
+
return None
|
|
70
|
+
return plugin
|
|
71
|
+
|
|
72
|
+
def public_catalog(self) -> list[dict[str, object]]:
|
|
73
|
+
return [
|
|
74
|
+
manifest_public_dict(
|
|
75
|
+
plugin,
|
|
76
|
+
installed=plugin.manifest.id in self._installed,
|
|
77
|
+
enabled=plugin.manifest.id in self._enabled,
|
|
78
|
+
)
|
|
79
|
+
for plugin in self.catalog()
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
def set_enabled(self, visualizer_id: str, enabled: bool) -> None:
|
|
83
|
+
plugin = self._catalog.get(visualizer_id)
|
|
84
|
+
if plugin is None or plugin.manifest.id not in self._installed:
|
|
85
|
+
raise VisualizerStoreError(f"visualizer is not installed: {visualizer_id}")
|
|
86
|
+
self.store.set_enabled(visualizer_id, enabled)
|
|
87
|
+
self.reload()
|
|
88
|
+
|
|
89
|
+
def install_bundled(self, visualizer_id: str) -> None:
|
|
90
|
+
plugin = self._catalog.get(visualizer_id)
|
|
91
|
+
if plugin is None or plugin.origin != "bundled":
|
|
92
|
+
raise VisualizerStoreError(f"bundled visualizer not found: {visualizer_id}")
|
|
93
|
+
self.store.set_bundled_installed(visualizer_id, True)
|
|
94
|
+
self.reload()
|
|
95
|
+
|
|
96
|
+
def uninstall(self, visualizer_id: str) -> None:
|
|
97
|
+
plugin = self._catalog.get(visualizer_id)
|
|
98
|
+
if plugin is None:
|
|
99
|
+
raise VisualizerStoreError(f"visualizer not found: {visualizer_id}")
|
|
100
|
+
if plugin.manifest.core:
|
|
101
|
+
raise VisualizerStoreError("core visualizers cannot be uninstalled; disable them")
|
|
102
|
+
if plugin.origin == "bundled":
|
|
103
|
+
self.store.set_bundled_installed(visualizer_id, False)
|
|
104
|
+
elif plugin.origin == "user":
|
|
105
|
+
self.store.uninstall_user(visualizer_id)
|
|
106
|
+
self.reload()
|
|
107
|
+
|
|
108
|
+
def install_archive(self, archive_path: Path) -> VisualizerPlugin:
|
|
109
|
+
manifest = self.store.install_archive(
|
|
110
|
+
archive_path,
|
|
111
|
+
reserved_ids=set(self._catalog),
|
|
112
|
+
)
|
|
113
|
+
self.reload()
|
|
114
|
+
plugin = self.get(manifest.id)
|
|
115
|
+
if plugin is None: # pragma: no cover - defensive
|
|
116
|
+
raise VisualizerStoreError("installed visualizer was not discoverable")
|
|
117
|
+
return plugin
|
|
118
|
+
|
|
119
|
+
def asset_path(self, visualizer_id: str, relative_path: str) -> Path:
|
|
120
|
+
plugin = self.get(visualizer_id)
|
|
121
|
+
if plugin is None or plugin.origin != "user":
|
|
122
|
+
raise VisualizerStoreError("visualizer asset not found")
|
|
123
|
+
return self.store.asset_path(visualizer_id, relative_path)
|
|
124
|
+
|
|
125
|
+
def prompt_catalog(self, requested: str = "auto") -> str:
|
|
126
|
+
plugins = self.agentic()
|
|
127
|
+
if requested != "auto":
|
|
128
|
+
selected = self.get(requested)
|
|
129
|
+
if selected is None or not selected.manifest.agentic:
|
|
130
|
+
raise VisualizerStoreError(f"visualizer is unavailable: {requested}")
|
|
131
|
+
plugins = [selected]
|
|
132
|
+
blocks: list[str] = []
|
|
133
|
+
for plugin in plugins:
|
|
134
|
+
manifest = plugin.manifest
|
|
135
|
+
schema = ""
|
|
136
|
+
if manifest.payload_schema:
|
|
137
|
+
schema = "\nPayload JSON Schema:\n" + json.dumps(
|
|
138
|
+
manifest.payload_schema, ensure_ascii=False, indent=2
|
|
139
|
+
)
|
|
140
|
+
blocks.append(
|
|
141
|
+
f"### {manifest.id} — {manifest.display_name}\n"
|
|
142
|
+
f"Use for: {manifest.description}\n"
|
|
143
|
+
f"Payload format: {manifest.payload_format}{schema}\n"
|
|
144
|
+
f"Rules:\n{manifest.prompt.strip()}"
|
|
145
|
+
)
|
|
146
|
+
return "\n\n".join(blocks)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def get_visualizer_registry() -> VisualizerRegistry:
|
|
150
|
+
# Per-user paths are resolved by PathService/current-user context, so a
|
|
151
|
+
# process singleton would leak one user's installed set into another's.
|
|
152
|
+
return VisualizerRegistry()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
__all__ = ["VisualizerRegistry", "get_visualizer_registry"]
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Per-user installation state and safe declarative visualizer imports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import shutil
|
|
8
|
+
import tempfile
|
|
9
|
+
import zipfile
|
|
10
|
+
|
|
11
|
+
from .protocol import VisualizerManifest
|
|
12
|
+
|
|
13
|
+
_MAX_ARCHIVE_BYTES = 25 * 1024 * 1024
|
|
14
|
+
_MAX_ENTRY_BYTES = 10 * 1024 * 1024
|
|
15
|
+
_MAX_MANIFEST_BYTES = 128 * 1024
|
|
16
|
+
_MAX_ENTRIES = 200
|
|
17
|
+
_MAX_RATIO = 100.0
|
|
18
|
+
_ALLOWED_SUFFIXES = {
|
|
19
|
+
".json",
|
|
20
|
+
".html",
|
|
21
|
+
".js",
|
|
22
|
+
".mjs",
|
|
23
|
+
".css",
|
|
24
|
+
".svg",
|
|
25
|
+
".png",
|
|
26
|
+
".jpg",
|
|
27
|
+
".jpeg",
|
|
28
|
+
".webp",
|
|
29
|
+
".woff2",
|
|
30
|
+
".wasm",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class VisualizerStoreError(ValueError):
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class VisualizerStore:
|
|
39
|
+
"""Owns the mutable layer; registry composition remains read-only."""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
*,
|
|
44
|
+
root: Path | None = None,
|
|
45
|
+
state_file: Path | None = None,
|
|
46
|
+
) -> None:
|
|
47
|
+
from deeptutor.services.path_service import get_path_service
|
|
48
|
+
|
|
49
|
+
service = get_path_service()
|
|
50
|
+
self.root = (root or (service.get_user_root() / "visualizers")).resolve()
|
|
51
|
+
self.state_file = (state_file or service.get_settings_file("visualizers.json")).resolve()
|
|
52
|
+
|
|
53
|
+
def state(self) -> dict[str, list[str]]:
|
|
54
|
+
try:
|
|
55
|
+
raw = json.loads(self.state_file.read_text(encoding="utf-8"))
|
|
56
|
+
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|
57
|
+
raw = {}
|
|
58
|
+
return {
|
|
59
|
+
"installed": _string_list(raw.get("installed")),
|
|
60
|
+
"disabled": _string_list(raw.get("disabled")),
|
|
61
|
+
"uninstalled": _string_list(raw.get("uninstalled")),
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
def save_state(self, state: dict[str, list[str]]) -> None:
|
|
65
|
+
normalized = {
|
|
66
|
+
"installed": sorted(set(_string_list(state.get("installed")))),
|
|
67
|
+
"disabled": sorted(set(_string_list(state.get("disabled")))),
|
|
68
|
+
"uninstalled": sorted(set(_string_list(state.get("uninstalled")))),
|
|
69
|
+
}
|
|
70
|
+
self.state_file.parent.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
temp = self.state_file.with_suffix(self.state_file.suffix + ".tmp")
|
|
72
|
+
temp.write_text(
|
|
73
|
+
json.dumps(normalized, ensure_ascii=False, indent=2) + "\n",
|
|
74
|
+
encoding="utf-8",
|
|
75
|
+
)
|
|
76
|
+
temp.replace(self.state_file)
|
|
77
|
+
|
|
78
|
+
def set_enabled(self, visualizer_id: str, enabled: bool) -> None:
|
|
79
|
+
state = self.state()
|
|
80
|
+
disabled = set(state["disabled"])
|
|
81
|
+
if enabled:
|
|
82
|
+
disabled.discard(visualizer_id)
|
|
83
|
+
else:
|
|
84
|
+
disabled.add(visualizer_id)
|
|
85
|
+
state["disabled"] = sorted(disabled)
|
|
86
|
+
self.save_state(state)
|
|
87
|
+
|
|
88
|
+
def set_bundled_installed(self, visualizer_id: str, installed: bool) -> None:
|
|
89
|
+
state = self.state()
|
|
90
|
+
explicitly_installed = set(state["installed"])
|
|
91
|
+
uninstalled = set(state["uninstalled"])
|
|
92
|
+
disabled = set(state["disabled"])
|
|
93
|
+
if installed:
|
|
94
|
+
explicitly_installed.add(visualizer_id)
|
|
95
|
+
uninstalled.discard(visualizer_id)
|
|
96
|
+
disabled.discard(visualizer_id)
|
|
97
|
+
else:
|
|
98
|
+
explicitly_installed.discard(visualizer_id)
|
|
99
|
+
uninstalled.add(visualizer_id)
|
|
100
|
+
disabled.discard(visualizer_id)
|
|
101
|
+
state["installed"] = sorted(explicitly_installed)
|
|
102
|
+
state["uninstalled"] = sorted(uninstalled)
|
|
103
|
+
state["disabled"] = sorted(disabled)
|
|
104
|
+
self.save_state(state)
|
|
105
|
+
|
|
106
|
+
def user_packages(self) -> list[tuple[VisualizerManifest, Path]]:
|
|
107
|
+
if not self.root.is_dir():
|
|
108
|
+
return []
|
|
109
|
+
packages: list[tuple[VisualizerManifest, Path]] = []
|
|
110
|
+
for child in sorted(self.root.iterdir()):
|
|
111
|
+
manifest_file = child / "visualizer.json"
|
|
112
|
+
if not child.is_dir() or not manifest_file.is_file():
|
|
113
|
+
continue
|
|
114
|
+
try:
|
|
115
|
+
if manifest_file.stat().st_size > _MAX_MANIFEST_BYTES:
|
|
116
|
+
continue
|
|
117
|
+
manifest = VisualizerManifest.model_validate_json(
|
|
118
|
+
manifest_file.read_text(encoding="utf-8")
|
|
119
|
+
)
|
|
120
|
+
self._validate_imported_manifest(manifest, child)
|
|
121
|
+
except Exception:
|
|
122
|
+
# A broken package is ignored by discovery. The API installer
|
|
123
|
+
# validates eagerly, so this is primarily crash recovery.
|
|
124
|
+
continue
|
|
125
|
+
packages.append((manifest, child.resolve()))
|
|
126
|
+
return packages
|
|
127
|
+
|
|
128
|
+
def install_archive(
|
|
129
|
+
self,
|
|
130
|
+
archive_path: Path,
|
|
131
|
+
*,
|
|
132
|
+
reserved_ids: set[str] | None = None,
|
|
133
|
+
) -> VisualizerManifest:
|
|
134
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
135
|
+
if archive_path.stat().st_size > _MAX_ARCHIVE_BYTES:
|
|
136
|
+
raise VisualizerStoreError("visualizer package exceeds 25 MB")
|
|
137
|
+
with tempfile.TemporaryDirectory(prefix="dtviz-") as tmp_name:
|
|
138
|
+
extracted = Path(tmp_name)
|
|
139
|
+
self._extract_archive(archive_path, extracted)
|
|
140
|
+
package_root = self._locate_package_root(extracted)
|
|
141
|
+
manifest_path = package_root / "visualizer.json"
|
|
142
|
+
try:
|
|
143
|
+
if manifest_path.stat().st_size > _MAX_MANIFEST_BYTES:
|
|
144
|
+
raise VisualizerStoreError("visualizer.json exceeds 128 KB")
|
|
145
|
+
manifest = VisualizerManifest.model_validate_json(
|
|
146
|
+
manifest_path.read_text(encoding="utf-8")
|
|
147
|
+
)
|
|
148
|
+
except Exception as exc:
|
|
149
|
+
raise VisualizerStoreError(f"invalid visualizer.json: {exc}") from exc
|
|
150
|
+
self._validate_imported_manifest(manifest, package_root)
|
|
151
|
+
if manifest.id in (reserved_ids or set()):
|
|
152
|
+
raise VisualizerStoreError(f"visualizer id is reserved by the host: {manifest.id}")
|
|
153
|
+
destination = (self.root / manifest.id).resolve()
|
|
154
|
+
if not destination.is_relative_to(self.root):
|
|
155
|
+
raise VisualizerStoreError("visualizer id escapes the installation root")
|
|
156
|
+
if destination.exists():
|
|
157
|
+
raise VisualizerStoreError(f"visualizer already installed: {manifest.id}")
|
|
158
|
+
staging = Path(tempfile.mkdtemp(prefix=f".{manifest.id}-", dir=self.root))
|
|
159
|
+
try:
|
|
160
|
+
for item in package_root.iterdir():
|
|
161
|
+
target = staging / item.name
|
|
162
|
+
if item.is_dir():
|
|
163
|
+
shutil.copytree(item, target)
|
|
164
|
+
else:
|
|
165
|
+
shutil.copy2(item, target)
|
|
166
|
+
staging.replace(destination)
|
|
167
|
+
finally:
|
|
168
|
+
if staging.exists():
|
|
169
|
+
shutil.rmtree(staging, ignore_errors=True)
|
|
170
|
+
self.set_enabled(manifest.id, True)
|
|
171
|
+
return manifest
|
|
172
|
+
|
|
173
|
+
def uninstall_user(self, visualizer_id: str) -> None:
|
|
174
|
+
target = (self.root / visualizer_id).resolve()
|
|
175
|
+
if not target.is_relative_to(self.root) or target == self.root:
|
|
176
|
+
raise VisualizerStoreError("invalid visualizer id")
|
|
177
|
+
if not (target / "visualizer.json").is_file():
|
|
178
|
+
raise VisualizerStoreError(f"user visualizer not found: {visualizer_id}")
|
|
179
|
+
shutil.rmtree(target)
|
|
180
|
+
state = self.state()
|
|
181
|
+
state["disabled"] = [item for item in state["disabled"] if item != visualizer_id]
|
|
182
|
+
state["installed"] = [item for item in state["installed"] if item != visualizer_id]
|
|
183
|
+
self.save_state(state)
|
|
184
|
+
|
|
185
|
+
def asset_path(self, visualizer_id: str, relative_path: str) -> Path:
|
|
186
|
+
package = (self.root / visualizer_id).resolve()
|
|
187
|
+
candidate = (package / relative_path).resolve()
|
|
188
|
+
if not package.is_dir() or not candidate.is_relative_to(package) or not candidate.is_file():
|
|
189
|
+
raise VisualizerStoreError("visualizer asset not found")
|
|
190
|
+
return candidate
|
|
191
|
+
|
|
192
|
+
@staticmethod
|
|
193
|
+
def _validate_imported_manifest(manifest: VisualizerManifest, root: Path) -> None:
|
|
194
|
+
if manifest.core or manifest.default_installed:
|
|
195
|
+
# Installation policy is host-owned. Imported packages cannot make
|
|
196
|
+
# themselves immutable or silently default-enabled for other users.
|
|
197
|
+
manifest.core = False
|
|
198
|
+
manifest.default_installed = False
|
|
199
|
+
if manifest.render_target != "iframe":
|
|
200
|
+
raise VisualizerStoreError(
|
|
201
|
+
"imported visualizers must use the sandboxed iframe render target"
|
|
202
|
+
)
|
|
203
|
+
entry = (root / manifest.renderer_entry).resolve()
|
|
204
|
+
if not entry.is_relative_to(root.resolve()) or not entry.is_file():
|
|
205
|
+
raise VisualizerStoreError("renderer_entry does not exist in the package")
|
|
206
|
+
if entry.suffix.lower() != ".html":
|
|
207
|
+
raise VisualizerStoreError("renderer_entry must be an HTML file")
|
|
208
|
+
|
|
209
|
+
@staticmethod
|
|
210
|
+
def _locate_package_root(extracted: Path) -> Path:
|
|
211
|
+
if (extracted / "visualizer.json").is_file():
|
|
212
|
+
return extracted
|
|
213
|
+
subdirs = [item for item in extracted.iterdir() if item.is_dir()]
|
|
214
|
+
if len(subdirs) == 1 and (subdirs[0] / "visualizer.json").is_file():
|
|
215
|
+
return subdirs[0]
|
|
216
|
+
raise VisualizerStoreError("package must contain visualizer.json at its root")
|
|
217
|
+
|
|
218
|
+
@staticmethod
|
|
219
|
+
def _extract_archive(archive_path: Path, destination: Path) -> None:
|
|
220
|
+
total = 0
|
|
221
|
+
try:
|
|
222
|
+
archive = zipfile.ZipFile(archive_path)
|
|
223
|
+
except zipfile.BadZipFile as exc:
|
|
224
|
+
raise VisualizerStoreError("package is not a valid zip archive") from exc
|
|
225
|
+
with archive:
|
|
226
|
+
members = [item for item in archive.infolist() if not item.is_dir()]
|
|
227
|
+
if len(members) > _MAX_ENTRIES:
|
|
228
|
+
raise VisualizerStoreError("package contains too many files")
|
|
229
|
+
root = destination.resolve()
|
|
230
|
+
for info in members:
|
|
231
|
+
raw = info.filename.replace("\\", "/")
|
|
232
|
+
rel = Path(raw)
|
|
233
|
+
if rel.is_absolute() or ".." in rel.parts:
|
|
234
|
+
raise VisualizerStoreError(f"illegal package path: {raw}")
|
|
235
|
+
if raw.startswith("__MACOSX/") or rel.name.startswith("."):
|
|
236
|
+
continue
|
|
237
|
+
if rel.suffix.lower() not in _ALLOWED_SUFFIXES:
|
|
238
|
+
raise VisualizerStoreError(f"unsupported package file type: {raw}")
|
|
239
|
+
if info.file_size > _MAX_ENTRY_BYTES:
|
|
240
|
+
raise VisualizerStoreError(f"package entry is too large: {raw}")
|
|
241
|
+
if info.compress_size and info.file_size / info.compress_size > _MAX_RATIO:
|
|
242
|
+
raise VisualizerStoreError(f"suspicious compression ratio: {raw}")
|
|
243
|
+
total += info.file_size
|
|
244
|
+
if total > _MAX_ARCHIVE_BYTES:
|
|
245
|
+
raise VisualizerStoreError("expanded package exceeds 25 MB")
|
|
246
|
+
target = (root / rel).resolve()
|
|
247
|
+
if not target.is_relative_to(root):
|
|
248
|
+
raise VisualizerStoreError(f"illegal package path: {raw}")
|
|
249
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
250
|
+
with archive.open(info) as source, open(target, "wb") as sink:
|
|
251
|
+
shutil.copyfileobj(source, sink, length=1 << 16)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _string_list(value: object) -> list[str]:
|
|
255
|
+
if not isinstance(value, list):
|
|
256
|
+
return []
|
|
257
|
+
return [str(item) for item in value if str(item).strip()]
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
__all__ = ["VisualizerStore", "VisualizerStoreError"]
|