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.
Files changed (30) hide show
  1. deeptutor/agents/visualize/__init__.py +5 -0
  2. deeptutor/agents/visualize/agents/__init__.py +11 -0
  3. deeptutor/agents/visualize/agents/analysis_agent.py +102 -0
  4. deeptutor/agents/visualize/agents/code_generator_agent.py +117 -0
  5. deeptutor/agents/visualize/agents/review_agent.py +75 -0
  6. deeptutor/agents/visualize/capability.py +571 -0
  7. deeptutor/agents/visualize/models.py +120 -0
  8. deeptutor/agents/visualize/pipeline.py +91 -0
  9. deeptutor/agents/visualize/prompts/en/analysis_agent.yaml +132 -0
  10. deeptutor/agents/visualize/prompts/en/code_generator_agent.yaml +189 -0
  11. deeptutor/agents/visualize/prompts/en/review_agent.yaml +38 -0
  12. deeptutor/agents/visualize/prompts/en/visualize.yaml +14 -0
  13. deeptutor/agents/visualize/prompts/zh/analysis_agent.yaml +123 -0
  14. deeptutor/agents/visualize/prompts/zh/code_generator_agent.yaml +161 -0
  15. deeptutor/agents/visualize/prompts/zh/review_agent.yaml +35 -0
  16. deeptutor/agents/visualize/prompts/zh/visualize.yaml +14 -0
  17. deeptutor/agents/visualize/utils.py +210 -0
  18. deeptutor/api/routers/visualizers.py +144 -0
  19. deeptutor/visualizers/README.md +79 -0
  20. deeptutor/visualizers/__init__.py +20 -0
  21. deeptutor/visualizers/builtin.py +369 -0
  22. deeptutor/visualizers/loop_capability.py +125 -0
  23. deeptutor/visualizers/protocol.py +225 -0
  24. deeptutor/visualizers/registry.py +155 -0
  25. deeptutor/visualizers/store.py +260 -0
  26. deeptutor/visualizers/tool.py +150 -0
  27. deeptutor_visualize-1.7.0.dist-info/METADATA +6 -0
  28. deeptutor_visualize-1.7.0.dist-info/RECORD +30 -0
  29. deeptutor_visualize-1.7.0.dist-info/WHEEL +5 -0
  30. 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"]