onetool-mcp 1.0.0b1__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.
- bench/__init__.py +5 -0
- bench/cli.py +69 -0
- bench/harness/__init__.py +66 -0
- bench/harness/client.py +692 -0
- bench/harness/config.py +397 -0
- bench/harness/csv_writer.py +109 -0
- bench/harness/evaluate.py +512 -0
- bench/harness/metrics.py +283 -0
- bench/harness/runner.py +899 -0
- bench/py.typed +0 -0
- bench/reporter.py +629 -0
- bench/run.py +487 -0
- bench/secrets.py +101 -0
- bench/utils.py +16 -0
- onetool/__init__.py +4 -0
- onetool/cli.py +391 -0
- onetool/py.typed +0 -0
- onetool_mcp-1.0.0b1.dist-info/METADATA +163 -0
- onetool_mcp-1.0.0b1.dist-info/RECORD +132 -0
- onetool_mcp-1.0.0b1.dist-info/WHEEL +4 -0
- onetool_mcp-1.0.0b1.dist-info/entry_points.txt +3 -0
- onetool_mcp-1.0.0b1.dist-info/licenses/LICENSE.txt +687 -0
- onetool_mcp-1.0.0b1.dist-info/licenses/NOTICE.txt +64 -0
- ot/__init__.py +37 -0
- ot/__main__.py +6 -0
- ot/_cli.py +107 -0
- ot/_tui.py +53 -0
- ot/config/__init__.py +46 -0
- ot/config/defaults/bench.yaml +4 -0
- ot/config/defaults/diagram-templates/api-flow.mmd +33 -0
- ot/config/defaults/diagram-templates/c4-context.puml +30 -0
- ot/config/defaults/diagram-templates/class-diagram.mmd +87 -0
- ot/config/defaults/diagram-templates/feature-mindmap.mmd +70 -0
- ot/config/defaults/diagram-templates/microservices.d2 +81 -0
- ot/config/defaults/diagram-templates/project-gantt.mmd +37 -0
- ot/config/defaults/diagram-templates/state-machine.mmd +42 -0
- ot/config/defaults/onetool.yaml +25 -0
- ot/config/defaults/prompts.yaml +97 -0
- ot/config/defaults/servers.yaml +7 -0
- ot/config/defaults/snippets.yaml +4 -0
- ot/config/defaults/tool_templates/__init__.py +7 -0
- ot/config/defaults/tool_templates/extension.py +52 -0
- ot/config/defaults/tool_templates/isolated.py +61 -0
- ot/config/dynamic.py +121 -0
- ot/config/global_templates/__init__.py +2 -0
- ot/config/global_templates/bench-secrets-template.yaml +6 -0
- ot/config/global_templates/bench.yaml +9 -0
- ot/config/global_templates/onetool.yaml +27 -0
- ot/config/global_templates/secrets-template.yaml +44 -0
- ot/config/global_templates/servers.yaml +18 -0
- ot/config/global_templates/snippets.yaml +235 -0
- ot/config/loader.py +1087 -0
- ot/config/mcp.py +145 -0
- ot/config/secrets.py +190 -0
- ot/config/tool_config.py +125 -0
- ot/decorators.py +116 -0
- ot/executor/__init__.py +35 -0
- ot/executor/base.py +16 -0
- ot/executor/fence_processor.py +83 -0
- ot/executor/linter.py +142 -0
- ot/executor/pack_proxy.py +260 -0
- ot/executor/param_resolver.py +140 -0
- ot/executor/pep723.py +288 -0
- ot/executor/result_store.py +369 -0
- ot/executor/runner.py +496 -0
- ot/executor/simple.py +163 -0
- ot/executor/tool_loader.py +396 -0
- ot/executor/validator.py +398 -0
- ot/executor/worker_pool.py +388 -0
- ot/executor/worker_proxy.py +189 -0
- ot/http_client.py +145 -0
- ot/logging/__init__.py +37 -0
- ot/logging/config.py +315 -0
- ot/logging/entry.py +213 -0
- ot/logging/format.py +188 -0
- ot/logging/span.py +349 -0
- ot/meta.py +1555 -0
- ot/paths.py +453 -0
- ot/prompts.py +218 -0
- ot/proxy/__init__.py +21 -0
- ot/proxy/manager.py +396 -0
- ot/py.typed +0 -0
- ot/registry/__init__.py +189 -0
- ot/registry/models.py +57 -0
- ot/registry/parser.py +269 -0
- ot/registry/registry.py +413 -0
- ot/server.py +315 -0
- ot/shortcuts/__init__.py +15 -0
- ot/shortcuts/aliases.py +87 -0
- ot/shortcuts/snippets.py +258 -0
- ot/stats/__init__.py +35 -0
- ot/stats/html.py +250 -0
- ot/stats/jsonl_writer.py +283 -0
- ot/stats/reader.py +354 -0
- ot/stats/timing.py +57 -0
- ot/support.py +63 -0
- ot/tools.py +114 -0
- ot/utils/__init__.py +81 -0
- ot/utils/batch.py +161 -0
- ot/utils/cache.py +120 -0
- ot/utils/deps.py +403 -0
- ot/utils/exceptions.py +23 -0
- ot/utils/factory.py +179 -0
- ot/utils/format.py +65 -0
- ot/utils/http.py +202 -0
- ot/utils/platform.py +45 -0
- ot/utils/sanitize.py +130 -0
- ot/utils/truncate.py +69 -0
- ot_tools/__init__.py +4 -0
- ot_tools/_convert/__init__.py +12 -0
- ot_tools/_convert/excel.py +279 -0
- ot_tools/_convert/pdf.py +254 -0
- ot_tools/_convert/powerpoint.py +268 -0
- ot_tools/_convert/utils.py +358 -0
- ot_tools/_convert/word.py +283 -0
- ot_tools/brave_search.py +604 -0
- ot_tools/code_search.py +736 -0
- ot_tools/context7.py +495 -0
- ot_tools/convert.py +614 -0
- ot_tools/db.py +415 -0
- ot_tools/diagram.py +1604 -0
- ot_tools/diagram.yaml +167 -0
- ot_tools/excel.py +1372 -0
- ot_tools/file.py +1348 -0
- ot_tools/firecrawl.py +732 -0
- ot_tools/grounding_search.py +646 -0
- ot_tools/package.py +604 -0
- ot_tools/py.typed +0 -0
- ot_tools/ripgrep.py +544 -0
- ot_tools/scaffold.py +471 -0
- ot_tools/transform.py +213 -0
- ot_tools/web_fetch.py +384 -0
ot_tools/diagram.py
ADDED
|
@@ -0,0 +1,1604 @@
|
|
|
1
|
+
"""Diagram generation tools using Kroki as the rendering backend.
|
|
2
|
+
|
|
3
|
+
Provides a two-stage pipeline for creating diagrams:
|
|
4
|
+
1. Generate source - creates diagram source code for review
|
|
5
|
+
2. Render diagram - renders source via Kroki to SVG/PNG/PDF
|
|
6
|
+
|
|
7
|
+
Supports 28+ diagram types through Kroki, with focus providers:
|
|
8
|
+
- Mermaid: flowcharts, sequences, state diagrams, Gantt, mindmaps
|
|
9
|
+
- PlantUML: UML diagrams, C4 architecture
|
|
10
|
+
- D2: modern architecture diagrams with auto-layout
|
|
11
|
+
|
|
12
|
+
Reference: https://kroki.io/
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
# Pack for dot notation: diagram.generate_source(), diagram.render(), etc.
|
|
18
|
+
pack = "diagram"
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"batch_render",
|
|
22
|
+
"generate_source",
|
|
23
|
+
"get_diagram_instructions",
|
|
24
|
+
"get_diagram_policy",
|
|
25
|
+
"get_output_config",
|
|
26
|
+
"get_playground_url",
|
|
27
|
+
"get_render_status",
|
|
28
|
+
"get_template",
|
|
29
|
+
"list_providers",
|
|
30
|
+
"render_diagram",
|
|
31
|
+
"render_directory",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
import base64
|
|
35
|
+
import re
|
|
36
|
+
import threading
|
|
37
|
+
import time
|
|
38
|
+
import uuid
|
|
39
|
+
import zlib
|
|
40
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
41
|
+
from datetime import datetime
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
from typing import Any, Literal
|
|
44
|
+
|
|
45
|
+
import httpx
|
|
46
|
+
from pydantic import BaseModel, Field
|
|
47
|
+
|
|
48
|
+
from ot.config import get_tool_config
|
|
49
|
+
from ot.logging import LogSpan
|
|
50
|
+
from ot.paths import get_effective_cwd, get_project_dir
|
|
51
|
+
from ot.utils import truncate
|
|
52
|
+
|
|
53
|
+
# ==================== Configuration Classes ====================
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class BackendConfig(BaseModel):
|
|
57
|
+
"""Kroki backend settings."""
|
|
58
|
+
|
|
59
|
+
type: Literal["kroki"] = Field(
|
|
60
|
+
default="kroki",
|
|
61
|
+
description="Backend type (only kroki supported)",
|
|
62
|
+
)
|
|
63
|
+
remote_url: str = Field(
|
|
64
|
+
default="https://kroki.io",
|
|
65
|
+
description="Remote Kroki service URL",
|
|
66
|
+
)
|
|
67
|
+
self_hosted_url: str = Field(
|
|
68
|
+
default="http://localhost:8000",
|
|
69
|
+
description="Self-hosted Kroki URL",
|
|
70
|
+
)
|
|
71
|
+
prefer: Literal["remote", "self_hosted", "auto"] = Field(
|
|
72
|
+
default="remote",
|
|
73
|
+
description="Preferred backend: remote, self_hosted, or auto",
|
|
74
|
+
)
|
|
75
|
+
timeout: float = Field(
|
|
76
|
+
default=30.0,
|
|
77
|
+
ge=1.0,
|
|
78
|
+
le=120.0,
|
|
79
|
+
description="Request timeout in seconds",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class PolicyConfig(BaseModel):
|
|
84
|
+
"""Policy rules for diagram generation."""
|
|
85
|
+
|
|
86
|
+
rules: str = Field(
|
|
87
|
+
default="""\
|
|
88
|
+
NEVER use ASCII art or text-based diagrams in markdown.
|
|
89
|
+
Use the diagram tools for all visual representations.
|
|
90
|
+
Save output as SVG and reference in markdown.
|
|
91
|
+
Always generate source first, then render.""",
|
|
92
|
+
description="Policy rules for LLM guidance",
|
|
93
|
+
)
|
|
94
|
+
preferred_format: Literal["svg", "png", "pdf"] = Field(
|
|
95
|
+
default="svg",
|
|
96
|
+
description="Default output format",
|
|
97
|
+
)
|
|
98
|
+
preferred_providers: list[str] = Field(
|
|
99
|
+
default_factory=lambda: ["mermaid", "d2", "plantuml"],
|
|
100
|
+
description="Preferred diagram providers in order",
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class OutputConfig(BaseModel):
|
|
105
|
+
"""Output settings for generated diagrams."""
|
|
106
|
+
|
|
107
|
+
dir: str = Field(
|
|
108
|
+
default="diagrams",
|
|
109
|
+
description="Output directory for rendered diagrams (relative to project dir)",
|
|
110
|
+
)
|
|
111
|
+
naming: str = Field(
|
|
112
|
+
default="{provider}_{name}_{timestamp}",
|
|
113
|
+
description="Filename pattern (supports {provider}, {name}, {timestamp})",
|
|
114
|
+
)
|
|
115
|
+
default_format: Literal["svg", "png", "pdf"] = Field(
|
|
116
|
+
default="svg",
|
|
117
|
+
description="Default output format",
|
|
118
|
+
)
|
|
119
|
+
save_source: bool = Field(
|
|
120
|
+
default=True,
|
|
121
|
+
description="Save diagram source alongside rendered output",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class ProviderInstructions(BaseModel):
|
|
126
|
+
"""Instructions for a specific diagram provider."""
|
|
127
|
+
|
|
128
|
+
when_to_use: str = Field(
|
|
129
|
+
default="",
|
|
130
|
+
description="Guidance on when to use this provider",
|
|
131
|
+
)
|
|
132
|
+
style_tips: str = Field(
|
|
133
|
+
default="",
|
|
134
|
+
description="Style and syntax tips",
|
|
135
|
+
)
|
|
136
|
+
syntax_guide: str = Field(
|
|
137
|
+
default="",
|
|
138
|
+
description="Link to syntax documentation",
|
|
139
|
+
)
|
|
140
|
+
example: str = Field(
|
|
141
|
+
default="",
|
|
142
|
+
description="Example diagram source",
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class TemplateRef(BaseModel):
|
|
147
|
+
"""Reference to a diagram template file."""
|
|
148
|
+
|
|
149
|
+
provider: str = Field(
|
|
150
|
+
...,
|
|
151
|
+
description="Diagram provider (mermaid, plantuml, d2)",
|
|
152
|
+
)
|
|
153
|
+
diagram_type: str = Field(
|
|
154
|
+
...,
|
|
155
|
+
description="Type of diagram (sequence, flowchart, etc.)",
|
|
156
|
+
)
|
|
157
|
+
description: str = Field(
|
|
158
|
+
default="",
|
|
159
|
+
description="Template description",
|
|
160
|
+
)
|
|
161
|
+
file: str = Field(
|
|
162
|
+
...,
|
|
163
|
+
description="Path to template file (relative to config)",
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class Config(BaseModel):
|
|
168
|
+
"""Pack configuration - discovered by registry."""
|
|
169
|
+
|
|
170
|
+
backend: BackendConfig = Field(
|
|
171
|
+
default_factory=BackendConfig,
|
|
172
|
+
description="Kroki backend settings",
|
|
173
|
+
)
|
|
174
|
+
policy: PolicyConfig = Field(
|
|
175
|
+
default_factory=PolicyConfig,
|
|
176
|
+
description="Policy rules for diagram generation",
|
|
177
|
+
)
|
|
178
|
+
output: OutputConfig = Field(
|
|
179
|
+
default_factory=OutputConfig,
|
|
180
|
+
description="Output settings",
|
|
181
|
+
)
|
|
182
|
+
instructions: dict[str, ProviderInstructions] = Field(
|
|
183
|
+
default_factory=dict,
|
|
184
|
+
description="Provider-specific instructions",
|
|
185
|
+
)
|
|
186
|
+
templates: dict[str, TemplateRef] = Field(
|
|
187
|
+
default_factory=dict,
|
|
188
|
+
description="Named template references",
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _get_config() -> Config:
|
|
193
|
+
"""Get diagram pack configuration."""
|
|
194
|
+
return get_tool_config("diagram", Config)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# Shared HTTP client for connection pooling
|
|
198
|
+
_http_client = httpx.Client(
|
|
199
|
+
timeout=30.0,
|
|
200
|
+
limits=httpx.Limits(
|
|
201
|
+
max_keepalive_connections=20,
|
|
202
|
+
max_connections=100,
|
|
203
|
+
keepalive_expiry=30.0,
|
|
204
|
+
),
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# ==================== Constants ====================
|
|
209
|
+
|
|
210
|
+
# Kroki-supported diagram providers
|
|
211
|
+
KROKI_PROVIDERS = [
|
|
212
|
+
"actdiag",
|
|
213
|
+
"blockdiag",
|
|
214
|
+
"bpmn",
|
|
215
|
+
"bytefield",
|
|
216
|
+
"c4plantuml",
|
|
217
|
+
"d2",
|
|
218
|
+
"dbml",
|
|
219
|
+
"ditaa",
|
|
220
|
+
"erd",
|
|
221
|
+
"excalidraw",
|
|
222
|
+
"graphviz",
|
|
223
|
+
"mermaid",
|
|
224
|
+
"nomnoml",
|
|
225
|
+
"nwdiag",
|
|
226
|
+
"packetdiag",
|
|
227
|
+
"pikchr",
|
|
228
|
+
"plantuml",
|
|
229
|
+
"rackdiag",
|
|
230
|
+
"seqdiag",
|
|
231
|
+
"structurizr",
|
|
232
|
+
"svgbob",
|
|
233
|
+
"symbolator",
|
|
234
|
+
"tikz",
|
|
235
|
+
"umlet",
|
|
236
|
+
"vega",
|
|
237
|
+
"vegalite",
|
|
238
|
+
"wavedrom",
|
|
239
|
+
"wireviz",
|
|
240
|
+
]
|
|
241
|
+
|
|
242
|
+
# Focus providers with full guidance
|
|
243
|
+
FOCUS_PROVIDERS = ["mermaid", "plantuml", "d2"]
|
|
244
|
+
|
|
245
|
+
# File extensions for diagram sources
|
|
246
|
+
PROVIDER_EXTENSIONS = {
|
|
247
|
+
"mermaid": ".mmd",
|
|
248
|
+
"plantuml": ".puml",
|
|
249
|
+
"d2": ".d2",
|
|
250
|
+
"graphviz": ".dot",
|
|
251
|
+
"ditaa": ".ditaa",
|
|
252
|
+
"erd": ".erd",
|
|
253
|
+
"nomnoml": ".nomnoml",
|
|
254
|
+
"svgbob": ".bob",
|
|
255
|
+
"vega": ".vg.json",
|
|
256
|
+
"vegalite": ".vl.json",
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
# Reverse mapping: extension -> provider (for file inference)
|
|
260
|
+
EXTENSION_TO_PROVIDER = {v: k for k, v in PROVIDER_EXTENSIONS.items()}
|
|
261
|
+
|
|
262
|
+
# Set of all known diagram extensions (for directory scanning)
|
|
263
|
+
DIAGRAM_EXTENSIONS = frozenset(PROVIDER_EXTENSIONS.values())
|
|
264
|
+
|
|
265
|
+
# Output format MIME types
|
|
266
|
+
FORMAT_MIME_TYPES = {
|
|
267
|
+
"svg": "image/svg+xml",
|
|
268
|
+
"png": "image/png",
|
|
269
|
+
"pdf": "application/pdf",
|
|
270
|
+
"jpeg": "image/jpeg",
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
# Async task storage for batch operations
|
|
274
|
+
_render_tasks: dict[str, dict[str, Any]] = {}
|
|
275
|
+
_render_tasks_lock = threading.Lock()
|
|
276
|
+
|
|
277
|
+
# Cached backend URL to avoid redundant health checks
|
|
278
|
+
# TTL-based invalidation: cache expires after _CACHE_TTL_SECONDS
|
|
279
|
+
_CACHE_TTL_SECONDS = 300 # 5 minutes
|
|
280
|
+
_cached_backend: dict[str, Any] = {"url": None, "is_self_hosted": None, "timestamp": 0.0}
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
# ==================== Path Resolution ====================
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _resolve_project_path(path_str: str) -> Path:
|
|
287
|
+
"""Resolve a path relative to the project directory (OT_CWD).
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
path_str: Path string (relative, absolute, or with ~)
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
Resolved absolute Path
|
|
294
|
+
"""
|
|
295
|
+
p = Path(path_str).expanduser()
|
|
296
|
+
if p.is_absolute():
|
|
297
|
+
return p
|
|
298
|
+
return (get_effective_cwd() / p).resolve()
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _resolve_config_path(path_str: str) -> Path:
|
|
302
|
+
"""Resolve a path relative to the config directory (.onetool/).
|
|
303
|
+
|
|
304
|
+
Args:
|
|
305
|
+
path_str: Path string (relative, absolute, or with ~)
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
Resolved absolute Path
|
|
309
|
+
"""
|
|
310
|
+
p = Path(path_str).expanduser()
|
|
311
|
+
if p.is_absolute():
|
|
312
|
+
return p
|
|
313
|
+
# Try project-level .onetool first, fall back to global
|
|
314
|
+
project_ot = get_project_dir()
|
|
315
|
+
if project_ot:
|
|
316
|
+
return (project_ot / p).resolve()
|
|
317
|
+
# Fall back to project root if no .onetool dir
|
|
318
|
+
return (get_effective_cwd() / p).resolve()
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _resolve_output_dir(output_dir: str | None) -> Path:
|
|
322
|
+
"""Resolve the output directory for diagrams.
|
|
323
|
+
|
|
324
|
+
Output is always relative to the project directory (OT_CWD), not the config
|
|
325
|
+
directory. This ensures diagrams are saved where the user expects them.
|
|
326
|
+
|
|
327
|
+
Path resolution:
|
|
328
|
+
- Relative paths: resolved relative to project directory (OT_CWD)
|
|
329
|
+
- Absolute paths: used as-is
|
|
330
|
+
- ~ paths: expanded to home directory
|
|
331
|
+
|
|
332
|
+
Args:
|
|
333
|
+
output_dir: Output directory path, or None to use config default.
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
Resolved absolute Path to output directory (created if needed).
|
|
337
|
+
"""
|
|
338
|
+
if output_dir is None:
|
|
339
|
+
output_dir = _get_config().output.dir
|
|
340
|
+
|
|
341
|
+
path = _resolve_project_path(output_dir)
|
|
342
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
343
|
+
return path
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
# ==================== Encoding Utilities ====================
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def encode_source(source: str) -> str:
|
|
350
|
+
"""Encode diagram source for GET URL requests.
|
|
351
|
+
|
|
352
|
+
Uses deflate compression + base64url encoding as required by Kroki.
|
|
353
|
+
|
|
354
|
+
Args:
|
|
355
|
+
source: The diagram source code.
|
|
356
|
+
|
|
357
|
+
Returns:
|
|
358
|
+
Encoded string suitable for Kroki GET URLs.
|
|
359
|
+
"""
|
|
360
|
+
compressed = zlib.compress(source.encode("utf-8"), level=9)
|
|
361
|
+
return base64.urlsafe_b64encode(compressed).decode("ascii")
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _decode_source(encoded: str) -> str:
|
|
365
|
+
"""Decode diagram source from GET URL encoding.
|
|
366
|
+
|
|
367
|
+
Args:
|
|
368
|
+
encoded: The encoded diagram source.
|
|
369
|
+
|
|
370
|
+
Returns:
|
|
371
|
+
Original diagram source code.
|
|
372
|
+
"""
|
|
373
|
+
compressed = base64.urlsafe_b64decode(encoded)
|
|
374
|
+
return zlib.decompress(compressed).decode("utf-8")
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _encode_plantuml(source: str) -> str:
|
|
378
|
+
"""Encode diagram source using PlantUML-specific encoding.
|
|
379
|
+
|
|
380
|
+
PlantUML uses a custom alphabet for URL encoding.
|
|
381
|
+
|
|
382
|
+
Args:
|
|
383
|
+
source: The PlantUML diagram source.
|
|
384
|
+
|
|
385
|
+
Returns:
|
|
386
|
+
PlantUML-encoded string for plantuml.com URLs.
|
|
387
|
+
"""
|
|
388
|
+
# PlantUML's custom base64 alphabet
|
|
389
|
+
alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_"
|
|
390
|
+
|
|
391
|
+
compressed = zlib.compress(source.encode("utf-8"), level=9)[2:-4]
|
|
392
|
+
result = []
|
|
393
|
+
|
|
394
|
+
for i in range(0, len(compressed), 3):
|
|
395
|
+
chunk = compressed[i : i + 3]
|
|
396
|
+
if len(chunk) == 3:
|
|
397
|
+
b1, b2, b3 = chunk
|
|
398
|
+
result.append(alphabet[b1 >> 2])
|
|
399
|
+
result.append(alphabet[((b1 & 0x3) << 4) | (b2 >> 4)])
|
|
400
|
+
result.append(alphabet[((b2 & 0xF) << 2) | (b3 >> 6)])
|
|
401
|
+
result.append(alphabet[b3 & 0x3F])
|
|
402
|
+
elif len(chunk) == 2:
|
|
403
|
+
b1, b2 = chunk
|
|
404
|
+
result.append(alphabet[b1 >> 2])
|
|
405
|
+
result.append(alphabet[((b1 & 0x3) << 4) | (b2 >> 4)])
|
|
406
|
+
result.append(alphabet[(b2 & 0xF) << 2])
|
|
407
|
+
elif len(chunk) == 1:
|
|
408
|
+
b1 = chunk[0]
|
|
409
|
+
result.append(alphabet[b1 >> 2])
|
|
410
|
+
result.append(alphabet[(b1 & 0x3) << 4])
|
|
411
|
+
|
|
412
|
+
return "".join(result)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
# ==================== Kroki Client Utilities ====================
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _get_kroki_url() -> str:
|
|
419
|
+
"""Get the appropriate Kroki URL based on configuration.
|
|
420
|
+
|
|
421
|
+
Uses cached result with TTL to avoid redundant health checks in batch
|
|
422
|
+
operations while allowing recovery if a backend goes down.
|
|
423
|
+
|
|
424
|
+
Returns:
|
|
425
|
+
The Kroki base URL (remote or self-hosted).
|
|
426
|
+
"""
|
|
427
|
+
# Return cached URL if available and not expired
|
|
428
|
+
now = time.monotonic()
|
|
429
|
+
if (
|
|
430
|
+
_cached_backend["url"] is not None
|
|
431
|
+
and now - _cached_backend["timestamp"] < _CACHE_TTL_SECONDS
|
|
432
|
+
):
|
|
433
|
+
return _cached_backend["url"]
|
|
434
|
+
|
|
435
|
+
config = _get_config()
|
|
436
|
+
prefer = config.backend.prefer
|
|
437
|
+
remote_url = config.backend.remote_url
|
|
438
|
+
self_hosted_url = config.backend.self_hosted_url
|
|
439
|
+
|
|
440
|
+
if prefer == "self_hosted":
|
|
441
|
+
_cached_backend["url"] = self_hosted_url
|
|
442
|
+
_cached_backend["is_self_hosted"] = True
|
|
443
|
+
elif prefer == "auto":
|
|
444
|
+
# Try self-hosted first, fall back to remote
|
|
445
|
+
try:
|
|
446
|
+
resp = _http_client.get(f"{self_hosted_url}/health", timeout=2.0)
|
|
447
|
+
if resp.status_code == 200:
|
|
448
|
+
_cached_backend["url"] = self_hosted_url
|
|
449
|
+
_cached_backend["is_self_hosted"] = True
|
|
450
|
+
else:
|
|
451
|
+
_cached_backend["url"] = remote_url
|
|
452
|
+
_cached_backend["is_self_hosted"] = False
|
|
453
|
+
except Exception:
|
|
454
|
+
_cached_backend["url"] = remote_url
|
|
455
|
+
_cached_backend["is_self_hosted"] = False
|
|
456
|
+
else:
|
|
457
|
+
_cached_backend["url"] = remote_url
|
|
458
|
+
_cached_backend["is_self_hosted"] = False
|
|
459
|
+
|
|
460
|
+
_cached_backend["timestamp"] = now
|
|
461
|
+
return _cached_backend["url"]
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _is_self_hosted() -> bool:
|
|
465
|
+
"""Check if using self-hosted Kroki backend.
|
|
466
|
+
|
|
467
|
+
Uses cached result from _get_kroki_url() to avoid redundant health checks.
|
|
468
|
+
"""
|
|
469
|
+
# Ensure cache is populated
|
|
470
|
+
if _cached_backend["is_self_hosted"] is None:
|
|
471
|
+
_get_kroki_url()
|
|
472
|
+
return _cached_backend["is_self_hosted"] or False
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _render_via_kroki(
|
|
476
|
+
source: str, provider: str, output_format: str = "svg", timeout: float = 30.0
|
|
477
|
+
) -> bytes:
|
|
478
|
+
"""Render diagram source via Kroki HTTP API.
|
|
479
|
+
|
|
480
|
+
Uses POST for rendering to handle large diagrams without URL limits.
|
|
481
|
+
|
|
482
|
+
Args:
|
|
483
|
+
source: The diagram source code.
|
|
484
|
+
provider: The diagram provider (mermaid, plantuml, d2, etc.).
|
|
485
|
+
output_format: Output format (svg, png, pdf).
|
|
486
|
+
timeout: Request timeout in seconds.
|
|
487
|
+
|
|
488
|
+
Returns:
|
|
489
|
+
Rendered diagram as bytes.
|
|
490
|
+
|
|
491
|
+
Raises:
|
|
492
|
+
Exception: If rendering fails.
|
|
493
|
+
"""
|
|
494
|
+
kroki_url = _get_kroki_url()
|
|
495
|
+
url = f"{kroki_url}/{provider}/{output_format}"
|
|
496
|
+
|
|
497
|
+
with LogSpan(span="diagram.kroki", provider=provider, format=output_format, url=url) as span:
|
|
498
|
+
resp = _http_client.post(
|
|
499
|
+
url,
|
|
500
|
+
content=source.encode("utf-8"),
|
|
501
|
+
headers={"Content-Type": "text/plain"},
|
|
502
|
+
timeout=timeout,
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
span.add(status=resp.status_code)
|
|
506
|
+
|
|
507
|
+
if resp.status_code != 200:
|
|
508
|
+
error_msg = resp.text[:500] if resp.text else f"HTTP {resp.status_code}"
|
|
509
|
+
span.add(error=error_msg)
|
|
510
|
+
raise Exception(f"Kroki render failed: {error_msg}")
|
|
511
|
+
|
|
512
|
+
span.add(responseLen=len(resp.content))
|
|
513
|
+
return resp.content
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _get_kroki_get_url(source: str, provider: str, output_format: str = "svg") -> str:
|
|
517
|
+
"""Generate a Kroki GET URL for sharing.
|
|
518
|
+
|
|
519
|
+
Args:
|
|
520
|
+
source: The diagram source code.
|
|
521
|
+
provider: The diagram provider.
|
|
522
|
+
output_format: Output format.
|
|
523
|
+
|
|
524
|
+
Returns:
|
|
525
|
+
Shareable Kroki GET URL.
|
|
526
|
+
"""
|
|
527
|
+
encoded = encode_source(source)
|
|
528
|
+
kroki_url = _get_kroki_url()
|
|
529
|
+
return f"{kroki_url}/{provider}/{output_format}/{encoded}"
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
# ==================== Playground URL Generators ====================
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _get_mermaid_playground_url(source: str) -> str:
|
|
536
|
+
"""Generate Mermaid Live Editor URL.
|
|
537
|
+
|
|
538
|
+
Args:
|
|
539
|
+
source: Mermaid diagram source.
|
|
540
|
+
|
|
541
|
+
Returns:
|
|
542
|
+
Mermaid Live Editor URL.
|
|
543
|
+
"""
|
|
544
|
+
# Mermaid.live uses pako (deflate + base64)
|
|
545
|
+
encoded = encode_source(source)
|
|
546
|
+
return f"https://mermaid.live/edit#pako:{encoded}"
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _get_plantuml_playground_url(source: str) -> str:
|
|
550
|
+
"""Generate PlantUML web server URL.
|
|
551
|
+
|
|
552
|
+
Args:
|
|
553
|
+
source: PlantUML diagram source.
|
|
554
|
+
|
|
555
|
+
Returns:
|
|
556
|
+
PlantUML playground URL.
|
|
557
|
+
"""
|
|
558
|
+
encoded = _encode_plantuml(source)
|
|
559
|
+
return f"https://www.plantuml.com/plantuml/uml/{encoded}"
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _get_d2_playground_url(source: str) -> str:
|
|
563
|
+
"""Generate D2 playground URL.
|
|
564
|
+
|
|
565
|
+
Args:
|
|
566
|
+
source: D2 diagram source.
|
|
567
|
+
|
|
568
|
+
Returns:
|
|
569
|
+
D2 playground URL.
|
|
570
|
+
"""
|
|
571
|
+
# D2 playground uses base64url encoding
|
|
572
|
+
encoded = base64.urlsafe_b64encode(source.encode("utf-8")).decode("ascii")
|
|
573
|
+
return f"https://play.d2lang.com/?script={encoded}"
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
# ==================== Validation Utilities ====================
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _validate_provider(provider: str) -> None:
|
|
580
|
+
"""Validate that the provider is supported by Kroki.
|
|
581
|
+
|
|
582
|
+
Args:
|
|
583
|
+
provider: The diagram provider name.
|
|
584
|
+
|
|
585
|
+
Raises:
|
|
586
|
+
ValueError: If provider is not supported.
|
|
587
|
+
"""
|
|
588
|
+
if provider not in KROKI_PROVIDERS:
|
|
589
|
+
raise ValueError(
|
|
590
|
+
f"Unknown provider '{provider}'. "
|
|
591
|
+
f"Supported: {', '.join(FOCUS_PROVIDERS)} (focus), "
|
|
592
|
+
f"plus {len(KROKI_PROVIDERS) - len(FOCUS_PROVIDERS)} others."
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _validate_format(output_format: str) -> None:
|
|
597
|
+
"""Validate that the output format is supported.
|
|
598
|
+
|
|
599
|
+
Args:
|
|
600
|
+
output_format: The output format.
|
|
601
|
+
|
|
602
|
+
Raises:
|
|
603
|
+
ValueError: If format is not supported.
|
|
604
|
+
"""
|
|
605
|
+
if output_format not in FORMAT_MIME_TYPES:
|
|
606
|
+
raise ValueError(
|
|
607
|
+
f"Unknown format '{output_format}'. "
|
|
608
|
+
f"Supported: {', '.join(FORMAT_MIME_TYPES.keys())}"
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _basic_source_validation(source: str, provider: str) -> list[str]:
|
|
613
|
+
"""Perform basic validation of diagram source.
|
|
614
|
+
|
|
615
|
+
Returns warnings/errors that don't prevent rendering but may cause issues.
|
|
616
|
+
|
|
617
|
+
Args:
|
|
618
|
+
source: The diagram source code.
|
|
619
|
+
provider: The diagram provider.
|
|
620
|
+
|
|
621
|
+
Returns:
|
|
622
|
+
List of warning messages (empty if no issues).
|
|
623
|
+
"""
|
|
624
|
+
warnings: list[str] = []
|
|
625
|
+
|
|
626
|
+
# Check for common Mermaid issues
|
|
627
|
+
if provider == "mermaid":
|
|
628
|
+
# Check for quoted aliases in sequence diagrams (common mistake)
|
|
629
|
+
if "sequenceDiagram" in source and re.search(
|
|
630
|
+
r'participant\s+\w+\s+as\s+"[^"]+"', source
|
|
631
|
+
):
|
|
632
|
+
warnings.append(
|
|
633
|
+
"Mermaid sequence diagrams: quotes after 'as' appear literally. "
|
|
634
|
+
"Use: participant ID as Display Name (no quotes)"
|
|
635
|
+
)
|
|
636
|
+
# Check for spaces in node IDs
|
|
637
|
+
if re.search(r'\b\w+\s+\w+\["', source):
|
|
638
|
+
warnings.append(
|
|
639
|
+
"Mermaid: node IDs should not contain spaces. "
|
|
640
|
+
'Use ID["Label with spaces"] format.'
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
# Check for PlantUML markers
|
|
644
|
+
if provider == "plantuml" and not source.strip().startswith("@start"):
|
|
645
|
+
warnings.append(
|
|
646
|
+
"PlantUML source should start with @startuml, @startmindmap, etc."
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
# Check for D2 issues - D2 is pretty forgiving, just check for obvious issues
|
|
650
|
+
if provider == "d2" and source.strip().startswith("@"):
|
|
651
|
+
warnings.append("D2 doesn't use @ markers. This looks like PlantUML syntax.")
|
|
652
|
+
|
|
653
|
+
return warnings
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
# ==================== File Utilities ====================
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _get_source_extension(provider: str) -> str:
|
|
660
|
+
"""Get the file extension for a diagram provider.
|
|
661
|
+
|
|
662
|
+
Args:
|
|
663
|
+
provider: The diagram provider.
|
|
664
|
+
|
|
665
|
+
Returns:
|
|
666
|
+
File extension including the dot.
|
|
667
|
+
"""
|
|
668
|
+
return PROVIDER_EXTENSIONS.get(provider, f".{provider}")
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def _generate_filename(
|
|
672
|
+
name: str, provider: str, output_format: str, is_source: bool = False
|
|
673
|
+
) -> str:
|
|
674
|
+
"""Generate a filename based on the configured naming pattern.
|
|
675
|
+
|
|
676
|
+
Args:
|
|
677
|
+
name: Base name for the file.
|
|
678
|
+
provider: The diagram provider.
|
|
679
|
+
output_format: Output format for rendered files.
|
|
680
|
+
is_source: Whether this is a source file (not rendered).
|
|
681
|
+
|
|
682
|
+
Returns:
|
|
683
|
+
Generated filename.
|
|
684
|
+
"""
|
|
685
|
+
naming_pattern = _get_config().output.naming
|
|
686
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
687
|
+
|
|
688
|
+
filename = naming_pattern.format(
|
|
689
|
+
provider=provider,
|
|
690
|
+
name=name,
|
|
691
|
+
timestamp=timestamp,
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
if is_source:
|
|
695
|
+
return filename + _get_source_extension(provider)
|
|
696
|
+
else:
|
|
697
|
+
return filename + f".{output_format}"
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
# ==================== Core Tools ====================
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def generate_source(
|
|
704
|
+
*,
|
|
705
|
+
source: str,
|
|
706
|
+
provider: Literal[
|
|
707
|
+
"mermaid", "plantuml", "d2", "graphviz", "ditaa", "erd", "nomnoml", "svgbob"
|
|
708
|
+
]
|
|
709
|
+
| str,
|
|
710
|
+
name: str,
|
|
711
|
+
output_dir: str | None = None,
|
|
712
|
+
validate: bool = True,
|
|
713
|
+
) -> str:
|
|
714
|
+
"""Save diagram source code to a file for review before rendering.
|
|
715
|
+
|
|
716
|
+
This is the first stage of the two-stage pipeline. The source can be
|
|
717
|
+
reviewed and edited before calling render_diagram().
|
|
718
|
+
|
|
719
|
+
Args:
|
|
720
|
+
source: The diagram source code.
|
|
721
|
+
provider: Diagram provider (mermaid, plantuml, d2, etc.).
|
|
722
|
+
name: Base name for the file (used in filename generation).
|
|
723
|
+
output_dir: Output directory (defaults to config tools.diagram.output.dir).
|
|
724
|
+
validate: Perform basic source validation (default: True).
|
|
725
|
+
|
|
726
|
+
Returns:
|
|
727
|
+
Result message with file path and any validation warnings.
|
|
728
|
+
|
|
729
|
+
Example:
|
|
730
|
+
result = diagram.generate_source(
|
|
731
|
+
source=\"\"\"
|
|
732
|
+
sequenceDiagram
|
|
733
|
+
participant C as Client
|
|
734
|
+
participant S as Server
|
|
735
|
+
C->>S: Request
|
|
736
|
+
S-->>C: Response
|
|
737
|
+
\"\"\",
|
|
738
|
+
provider="mermaid",
|
|
739
|
+
name="api-flow"
|
|
740
|
+
)
|
|
741
|
+
"""
|
|
742
|
+
with LogSpan(span="diagram.generate_source", provider=provider, diagram_name=name) as s:
|
|
743
|
+
try:
|
|
744
|
+
_validate_provider(provider)
|
|
745
|
+
|
|
746
|
+
# Get output directory (resolved relative to project directory)
|
|
747
|
+
output_path = _resolve_output_dir(output_dir)
|
|
748
|
+
|
|
749
|
+
# Generate filename and write source
|
|
750
|
+
filename = _generate_filename(name, provider, "", is_source=True)
|
|
751
|
+
file_path = output_path / filename
|
|
752
|
+
|
|
753
|
+
file_path.write_text(source)
|
|
754
|
+
|
|
755
|
+
# Validation warnings
|
|
756
|
+
warnings: list[str] = []
|
|
757
|
+
if validate:
|
|
758
|
+
warnings = _basic_source_validation(source, provider)
|
|
759
|
+
|
|
760
|
+
# Get playground URL for debugging
|
|
761
|
+
playground_url = get_playground_url(source=source, provider=provider)
|
|
762
|
+
|
|
763
|
+
result_parts = [
|
|
764
|
+
f"Source saved: {file_path}",
|
|
765
|
+
f"Provider: {provider}",
|
|
766
|
+
f"Lines: {len(source.splitlines())}",
|
|
767
|
+
]
|
|
768
|
+
|
|
769
|
+
if playground_url:
|
|
770
|
+
result_parts.append(f"Playground: {playground_url}")
|
|
771
|
+
|
|
772
|
+
if warnings:
|
|
773
|
+
result_parts.append("\nWarnings:")
|
|
774
|
+
result_parts.extend(f" - {w}" for w in warnings)
|
|
775
|
+
|
|
776
|
+
s.add(path=str(file_path), warnings=len(warnings))
|
|
777
|
+
return "\n".join(result_parts)
|
|
778
|
+
|
|
779
|
+
except Exception as e:
|
|
780
|
+
s.add(error=str(e))
|
|
781
|
+
return f"Error generating source: {e}"
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def render_diagram(
|
|
785
|
+
*,
|
|
786
|
+
source: str | None = None,
|
|
787
|
+
source_file: str | None = None,
|
|
788
|
+
provider: str | None = None,
|
|
789
|
+
name: str | None = None,
|
|
790
|
+
output_format: Literal["svg", "png", "pdf"] = "svg",
|
|
791
|
+
output_dir: str | None = None,
|
|
792
|
+
save_source: bool | None = None,
|
|
793
|
+
async_mode: bool = False,
|
|
794
|
+
) -> str:
|
|
795
|
+
"""Render a diagram from source code or file via Kroki.
|
|
796
|
+
|
|
797
|
+
This is the second stage of the two-stage pipeline. Renders the diagram
|
|
798
|
+
and saves the output file.
|
|
799
|
+
|
|
800
|
+
Args:
|
|
801
|
+
source: The diagram source code (mutually exclusive with source_file).
|
|
802
|
+
source_file: Path to source file (mutually exclusive with source).
|
|
803
|
+
provider: Diagram provider (required if source given, inferred from file).
|
|
804
|
+
name: Base name for output file (defaults to source filename).
|
|
805
|
+
output_format: Output format (svg, png, pdf). Default: svg.
|
|
806
|
+
output_dir: Output directory (defaults to config).
|
|
807
|
+
save_source: Save source alongside output (defaults to config).
|
|
808
|
+
async_mode: Return immediately with task ID for status polling.
|
|
809
|
+
|
|
810
|
+
Returns:
|
|
811
|
+
If sync: Result message with output file path.
|
|
812
|
+
If async: Task ID for polling with get_render_status().
|
|
813
|
+
|
|
814
|
+
Example:
|
|
815
|
+
# From source
|
|
816
|
+
result = diagram.render_diagram(
|
|
817
|
+
source=\"\"\"
|
|
818
|
+
sequenceDiagram
|
|
819
|
+
C->>S: Request
|
|
820
|
+
\"\"\",
|
|
821
|
+
provider="mermaid",
|
|
822
|
+
name="sequence"
|
|
823
|
+
)
|
|
824
|
+
|
|
825
|
+
# From file
|
|
826
|
+
result = diagram.render_diagram(
|
|
827
|
+
source_file="../diagrams/mermaid_api-flow.mmd"
|
|
828
|
+
)
|
|
829
|
+
"""
|
|
830
|
+
with LogSpan(
|
|
831
|
+
span="diagram.render_diagram",
|
|
832
|
+
provider=provider,
|
|
833
|
+
diagram_name=name,
|
|
834
|
+
format=output_format,
|
|
835
|
+
async_mode=async_mode,
|
|
836
|
+
) as s:
|
|
837
|
+
try:
|
|
838
|
+
# Validate inputs
|
|
839
|
+
if source is None and source_file is None:
|
|
840
|
+
raise ValueError("Either source or source_file must be provided")
|
|
841
|
+
if source is not None and source_file is not None:
|
|
842
|
+
raise ValueError("Cannot provide both source and source_file")
|
|
843
|
+
|
|
844
|
+
_validate_format(output_format)
|
|
845
|
+
|
|
846
|
+
# Load source from file if needed
|
|
847
|
+
if source_file is not None:
|
|
848
|
+
file_path = Path(source_file)
|
|
849
|
+
if not file_path.exists():
|
|
850
|
+
raise FileNotFoundError(f"Source file not found: {source_file}")
|
|
851
|
+
|
|
852
|
+
source = file_path.read_text()
|
|
853
|
+
|
|
854
|
+
# Infer provider from extension
|
|
855
|
+
if provider is None:
|
|
856
|
+
ext = file_path.suffix.lower()
|
|
857
|
+
provider = EXTENSION_TO_PROVIDER.get(ext)
|
|
858
|
+
if provider is None:
|
|
859
|
+
raise ValueError(
|
|
860
|
+
f"Cannot infer provider from extension '{ext}'. "
|
|
861
|
+
"Please specify provider explicitly."
|
|
862
|
+
)
|
|
863
|
+
|
|
864
|
+
# Use filename as name if not provided
|
|
865
|
+
if name is None:
|
|
866
|
+
name = file_path.stem
|
|
867
|
+
|
|
868
|
+
if provider is None:
|
|
869
|
+
raise ValueError("Provider must be specified when using source")
|
|
870
|
+
if name is None:
|
|
871
|
+
name = "diagram"
|
|
872
|
+
|
|
873
|
+
_validate_provider(provider)
|
|
874
|
+
|
|
875
|
+
# Get output directory (resolved relative to project directory)
|
|
876
|
+
output_path = _resolve_output_dir(output_dir)
|
|
877
|
+
|
|
878
|
+
# Generate output filename
|
|
879
|
+
output_filename = _generate_filename(name, provider, output_format)
|
|
880
|
+
output_file = output_path / output_filename
|
|
881
|
+
|
|
882
|
+
# Handle async mode
|
|
883
|
+
if async_mode:
|
|
884
|
+
task_id = f"render-{uuid.uuid4().hex[:8]}"
|
|
885
|
+
timeout = _get_config().backend.timeout
|
|
886
|
+
|
|
887
|
+
with _render_tasks_lock:
|
|
888
|
+
_render_tasks[task_id] = {
|
|
889
|
+
"status": "running",
|
|
890
|
+
"source": source,
|
|
891
|
+
"provider": provider,
|
|
892
|
+
"output_format": output_format,
|
|
893
|
+
"output_file": str(output_file),
|
|
894
|
+
"started_at": datetime.now().isoformat(),
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
def _do_async_render() -> None:
|
|
898
|
+
"""Background thread worker for async rendering."""
|
|
899
|
+
try:
|
|
900
|
+
rendered = _render_via_kroki(
|
|
901
|
+
source, provider, output_format, timeout
|
|
902
|
+
)
|
|
903
|
+
output_file.write_bytes(rendered)
|
|
904
|
+
|
|
905
|
+
with _render_tasks_lock:
|
|
906
|
+
_render_tasks[task_id]["status"] = "completed"
|
|
907
|
+
_render_tasks[task_id]["completed_at"] = (
|
|
908
|
+
datetime.now().isoformat()
|
|
909
|
+
)
|
|
910
|
+
except Exception as e:
|
|
911
|
+
with _render_tasks_lock:
|
|
912
|
+
_render_tasks[task_id]["status"] = "failed"
|
|
913
|
+
_render_tasks[task_id]["error"] = str(e)
|
|
914
|
+
|
|
915
|
+
# Start rendering in background thread
|
|
916
|
+
thread = threading.Thread(target=_do_async_render, daemon=True)
|
|
917
|
+
thread.start()
|
|
918
|
+
|
|
919
|
+
s.add(task_id=task_id)
|
|
920
|
+
return f"Rendering started. Task ID: {task_id}"
|
|
921
|
+
|
|
922
|
+
# Synchronous rendering
|
|
923
|
+
timeout = _get_config().backend.timeout
|
|
924
|
+
rendered = _render_via_kroki(source, provider, output_format, timeout)
|
|
925
|
+
|
|
926
|
+
# Save output
|
|
927
|
+
output_file.write_bytes(rendered)
|
|
928
|
+
|
|
929
|
+
# Optionally save source alongside
|
|
930
|
+
if save_source is None:
|
|
931
|
+
save_source = _get_config().output.save_source
|
|
932
|
+
|
|
933
|
+
source_saved = ""
|
|
934
|
+
if save_source and source_file is None:
|
|
935
|
+
source_filename = _generate_filename(name, provider, "", is_source=True)
|
|
936
|
+
source_file_path = output_path / source_filename
|
|
937
|
+
source_file_path.write_text(source)
|
|
938
|
+
source_saved = f"\nSource saved: {source_file_path}"
|
|
939
|
+
|
|
940
|
+
# Get share URL
|
|
941
|
+
share_url = _get_kroki_get_url(source, provider, output_format)
|
|
942
|
+
|
|
943
|
+
s.add(output=str(output_file), size=len(rendered), format=output_format)
|
|
944
|
+
|
|
945
|
+
return (
|
|
946
|
+
f"Rendered: {output_file}\n"
|
|
947
|
+
f"Size: {len(rendered):,} bytes\n"
|
|
948
|
+
f"Format: {output_format.upper()}{source_saved}\n"
|
|
949
|
+
f"Share URL: {truncate(share_url, 100)}"
|
|
950
|
+
)
|
|
951
|
+
|
|
952
|
+
except Exception as e:
|
|
953
|
+
s.add(error=str(e))
|
|
954
|
+
return f"Error rendering diagram: {e}"
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
def get_render_status(*, task_id: str) -> str:
|
|
958
|
+
"""Check the status of an async render task.
|
|
959
|
+
|
|
960
|
+
Args:
|
|
961
|
+
task_id: The task ID returned by render_diagram(async_mode=True).
|
|
962
|
+
|
|
963
|
+
Returns:
|
|
964
|
+
JSON-formatted status including progress and output file path.
|
|
965
|
+
|
|
966
|
+
Example:
|
|
967
|
+
status = diagram.get_render_status(task_id="render-abc123")
|
|
968
|
+
"""
|
|
969
|
+
with LogSpan(span="diagram.get_render_status", task_id=task_id) as s:
|
|
970
|
+
with _render_tasks_lock:
|
|
971
|
+
task = _render_tasks.get(task_id)
|
|
972
|
+
if task is None:
|
|
973
|
+
s.add(error="not_found")
|
|
974
|
+
return f"Task not found: {task_id}"
|
|
975
|
+
# Copy task data under lock to avoid races
|
|
976
|
+
task = dict(task)
|
|
977
|
+
|
|
978
|
+
status = task.get("status", "unknown")
|
|
979
|
+
result_parts = [
|
|
980
|
+
f"Task: {task_id}",
|
|
981
|
+
f"Status: {status}",
|
|
982
|
+
]
|
|
983
|
+
|
|
984
|
+
if status == "completed":
|
|
985
|
+
result_parts.append(f"Output: {task.get('output_file')}")
|
|
986
|
+
if task.get("completed_at"):
|
|
987
|
+
result_parts.append(f"Completed: {task.get('completed_at')}")
|
|
988
|
+
elif status == "failed":
|
|
989
|
+
result_parts.append(f"Error: {task.get('error')}")
|
|
990
|
+
elif status == "running":
|
|
991
|
+
result_parts.append(f"Started: {task.get('started_at')}")
|
|
992
|
+
|
|
993
|
+
s.add(status=status)
|
|
994
|
+
return "\n".join(result_parts)
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
# ==================== Batch Operations (Self-Hosted Only) ====================
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
def _render_single_diagram(
|
|
1001
|
+
item: dict[str, str],
|
|
1002
|
+
output_format: str,
|
|
1003
|
+
output_path: Path,
|
|
1004
|
+
timeout: float,
|
|
1005
|
+
) -> dict[str, Any]:
|
|
1006
|
+
"""Render a single diagram - helper for concurrent batch processing.
|
|
1007
|
+
|
|
1008
|
+
Args:
|
|
1009
|
+
item: Dict with 'source', 'provider', 'name' keys.
|
|
1010
|
+
output_format: Output format (svg, png, pdf).
|
|
1011
|
+
output_path: Directory to save output.
|
|
1012
|
+
timeout: Request timeout.
|
|
1013
|
+
|
|
1014
|
+
Returns:
|
|
1015
|
+
Result dict with name, status, file/error.
|
|
1016
|
+
"""
|
|
1017
|
+
source = item.get("source", "")
|
|
1018
|
+
provider = item.get("provider", "mermaid")
|
|
1019
|
+
name = item.get("name", "diagram")
|
|
1020
|
+
|
|
1021
|
+
try:
|
|
1022
|
+
_validate_provider(provider)
|
|
1023
|
+
_validate_format(output_format)
|
|
1024
|
+
|
|
1025
|
+
rendered = _render_via_kroki(source, provider, output_format, timeout)
|
|
1026
|
+
|
|
1027
|
+
output_filename = _generate_filename(name, provider, output_format)
|
|
1028
|
+
output_file = output_path / output_filename
|
|
1029
|
+
output_file.write_bytes(rendered)
|
|
1030
|
+
|
|
1031
|
+
return {"name": name, "status": "success", "file": str(output_file)}
|
|
1032
|
+
except Exception as e:
|
|
1033
|
+
return {"name": name, "status": "failed", "error": str(e)}
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
def batch_render(
|
|
1037
|
+
*,
|
|
1038
|
+
sources: list[dict[str, str]],
|
|
1039
|
+
output_format: Literal["svg", "png", "pdf"] = "svg",
|
|
1040
|
+
output_dir: str | None = None,
|
|
1041
|
+
max_concurrent: int = 5,
|
|
1042
|
+
) -> str:
|
|
1043
|
+
"""Render multiple diagrams concurrently. Self-hosted Kroki only.
|
|
1044
|
+
|
|
1045
|
+
This operation requires a self-hosted Kroki instance to avoid
|
|
1046
|
+
overloading the public kroki.io service.
|
|
1047
|
+
|
|
1048
|
+
Args:
|
|
1049
|
+
sources: List of dicts with 'source', 'provider', 'name' keys.
|
|
1050
|
+
output_format: Output format for all diagrams.
|
|
1051
|
+
output_dir: Output directory.
|
|
1052
|
+
max_concurrent: Maximum concurrent render requests (default: 5).
|
|
1053
|
+
|
|
1054
|
+
Returns:
|
|
1055
|
+
Task ID for polling status with get_render_status().
|
|
1056
|
+
|
|
1057
|
+
Example:
|
|
1058
|
+
result = diagram.batch_render(
|
|
1059
|
+
sources=[
|
|
1060
|
+
{"source": "...", "provider": "mermaid", "name": "diagram1"},
|
|
1061
|
+
{"source": "...", "provider": "d2", "name": "diagram2"},
|
|
1062
|
+
]
|
|
1063
|
+
)
|
|
1064
|
+
"""
|
|
1065
|
+
with LogSpan(span="diagram.batch_render", count=len(sources), format=output_format) as s:
|
|
1066
|
+
# Check for self-hosted requirement
|
|
1067
|
+
if not _is_self_hosted():
|
|
1068
|
+
s.add(error="requires_self_hosted")
|
|
1069
|
+
return (
|
|
1070
|
+
"Error: batch_render requires self-hosted Kroki.\n"
|
|
1071
|
+
"The public kroki.io service has rate limits.\n"
|
|
1072
|
+
"Run: onetool diagram setup --self-hosted"
|
|
1073
|
+
)
|
|
1074
|
+
|
|
1075
|
+
task_id = f"batch-{uuid.uuid4().hex[:8]}"
|
|
1076
|
+
|
|
1077
|
+
with _render_tasks_lock:
|
|
1078
|
+
_render_tasks[task_id] = {
|
|
1079
|
+
"status": "running",
|
|
1080
|
+
"type": "batch",
|
|
1081
|
+
"total": len(sources),
|
|
1082
|
+
"completed": 0,
|
|
1083
|
+
"failed": 0,
|
|
1084
|
+
"results": [],
|
|
1085
|
+
"started_at": datetime.now().isoformat(),
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
# Get output directory (resolved relative to project directory)
|
|
1089
|
+
output_path = _resolve_output_dir(output_dir)
|
|
1090
|
+
|
|
1091
|
+
timeout = _get_config().backend.timeout
|
|
1092
|
+
|
|
1093
|
+
# Process sources concurrently using thread pool
|
|
1094
|
+
with ThreadPoolExecutor(max_workers=max_concurrent) as executor:
|
|
1095
|
+
futures = {
|
|
1096
|
+
executor.submit(
|
|
1097
|
+
_render_single_diagram, item, output_format, output_path, timeout
|
|
1098
|
+
): item
|
|
1099
|
+
for item in sources
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
for future in as_completed(futures):
|
|
1103
|
+
result = future.result()
|
|
1104
|
+
with _render_tasks_lock:
|
|
1105
|
+
_render_tasks[task_id]["results"].append(result)
|
|
1106
|
+
if result["status"] == "success":
|
|
1107
|
+
_render_tasks[task_id]["completed"] += 1
|
|
1108
|
+
else:
|
|
1109
|
+
_render_tasks[task_id]["failed"] += 1
|
|
1110
|
+
|
|
1111
|
+
with _render_tasks_lock:
|
|
1112
|
+
_render_tasks[task_id]["status"] = "completed"
|
|
1113
|
+
_render_tasks[task_id]["completed_at"] = datetime.now().isoformat()
|
|
1114
|
+
|
|
1115
|
+
completed = _render_tasks[task_id]["completed"]
|
|
1116
|
+
failed = _render_tasks[task_id]["failed"]
|
|
1117
|
+
|
|
1118
|
+
s.add(task_id=task_id, completed=completed, failed=failed)
|
|
1119
|
+
return (
|
|
1120
|
+
f"Batch complete. Task ID: {task_id}\n"
|
|
1121
|
+
f"Completed: {completed}/{len(sources)}\n"
|
|
1122
|
+
f"Failed: {failed}"
|
|
1123
|
+
)
|
|
1124
|
+
|
|
1125
|
+
|
|
1126
|
+
def _render_file(
|
|
1127
|
+
source_file: Path,
|
|
1128
|
+
provider: str,
|
|
1129
|
+
output_format: str,
|
|
1130
|
+
output_path: Path,
|
|
1131
|
+
timeout: float,
|
|
1132
|
+
) -> dict[str, Any]:
|
|
1133
|
+
"""Render a diagram from file - reads file just-in-time to save memory.
|
|
1134
|
+
|
|
1135
|
+
Args:
|
|
1136
|
+
source_file: Path to the source file.
|
|
1137
|
+
provider: Diagram provider.
|
|
1138
|
+
output_format: Output format (svg, png, pdf).
|
|
1139
|
+
output_path: Directory to save output.
|
|
1140
|
+
timeout: Request timeout.
|
|
1141
|
+
|
|
1142
|
+
Returns:
|
|
1143
|
+
Result dict with name, status, file/error.
|
|
1144
|
+
"""
|
|
1145
|
+
name = source_file.stem
|
|
1146
|
+
try:
|
|
1147
|
+
# Read file just-in-time (not pre-loaded)
|
|
1148
|
+
source = source_file.read_text()
|
|
1149
|
+
|
|
1150
|
+
_validate_provider(provider)
|
|
1151
|
+
_validate_format(output_format)
|
|
1152
|
+
|
|
1153
|
+
rendered = _render_via_kroki(source, provider, output_format, timeout)
|
|
1154
|
+
|
|
1155
|
+
output_filename = _generate_filename(name, provider, output_format)
|
|
1156
|
+
output_file = output_path / output_filename
|
|
1157
|
+
output_file.write_bytes(rendered)
|
|
1158
|
+
|
|
1159
|
+
return {"name": name, "status": "success", "file": str(output_file)}
|
|
1160
|
+
except Exception as e:
|
|
1161
|
+
return {"name": name, "status": "failed", "error": str(e)}
|
|
1162
|
+
|
|
1163
|
+
|
|
1164
|
+
def render_directory(
|
|
1165
|
+
*,
|
|
1166
|
+
directory: str,
|
|
1167
|
+
output_format: Literal["svg", "png", "pdf"] = "svg",
|
|
1168
|
+
output_dir: str | None = None,
|
|
1169
|
+
recursive: bool = False,
|
|
1170
|
+
pattern: str = "*",
|
|
1171
|
+
max_concurrent: int = 5,
|
|
1172
|
+
) -> str:
|
|
1173
|
+
"""Discover and render all diagram source files in a directory.
|
|
1174
|
+
|
|
1175
|
+
Self-hosted Kroki only. Files are read just-in-time to minimise memory usage.
|
|
1176
|
+
|
|
1177
|
+
Args:
|
|
1178
|
+
directory: Directory containing diagram source files.
|
|
1179
|
+
output_format: Output format for all diagrams.
|
|
1180
|
+
output_dir: Output directory (defaults to same as source).
|
|
1181
|
+
recursive: Search subdirectories.
|
|
1182
|
+
pattern: Glob pattern to match files (e.g., "*.mmd").
|
|
1183
|
+
max_concurrent: Maximum concurrent render requests (default: 5).
|
|
1184
|
+
|
|
1185
|
+
Returns:
|
|
1186
|
+
Summary of rendered files.
|
|
1187
|
+
|
|
1188
|
+
Example:
|
|
1189
|
+
result = diagram.render_directory(
|
|
1190
|
+
directory="../diagrams/source",
|
|
1191
|
+
output_format="svg",
|
|
1192
|
+
recursive=True
|
|
1193
|
+
)
|
|
1194
|
+
"""
|
|
1195
|
+
with LogSpan(span="diagram.render_directory", directory=directory, pattern=pattern) as s:
|
|
1196
|
+
if not _is_self_hosted():
|
|
1197
|
+
s.add(error="requires_self_hosted")
|
|
1198
|
+
return (
|
|
1199
|
+
"Error: render_directory requires self-hosted Kroki.\n"
|
|
1200
|
+
"Run: onetool diagram setup --self-hosted"
|
|
1201
|
+
)
|
|
1202
|
+
|
|
1203
|
+
dir_path = Path(directory)
|
|
1204
|
+
if not dir_path.exists():
|
|
1205
|
+
s.add(error="dir_not_found")
|
|
1206
|
+
return f"Error: Directory not found: {directory}"
|
|
1207
|
+
|
|
1208
|
+
# Find source files (just paths, don't read content yet)
|
|
1209
|
+
if recursive:
|
|
1210
|
+
files = list(dir_path.rglob(pattern))
|
|
1211
|
+
else:
|
|
1212
|
+
files = list(dir_path.glob(pattern))
|
|
1213
|
+
|
|
1214
|
+
# Filter to known diagram extensions and pair with provider
|
|
1215
|
+
file_provider_pairs: list[tuple[Path, str]] = []
|
|
1216
|
+
for f in files:
|
|
1217
|
+
if f.suffix in DIAGRAM_EXTENSIONS:
|
|
1218
|
+
provider = EXTENSION_TO_PROVIDER.get(f.suffix)
|
|
1219
|
+
if provider:
|
|
1220
|
+
file_provider_pairs.append((f, provider))
|
|
1221
|
+
|
|
1222
|
+
if not file_provider_pairs:
|
|
1223
|
+
s.add(found=0)
|
|
1224
|
+
return f"No diagram source files found in {directory}"
|
|
1225
|
+
|
|
1226
|
+
s.add(found=len(file_provider_pairs))
|
|
1227
|
+
|
|
1228
|
+
# Get output directory (defaults to source directory if not specified)
|
|
1229
|
+
if output_dir is not None:
|
|
1230
|
+
output_path = _resolve_output_dir(output_dir)
|
|
1231
|
+
else:
|
|
1232
|
+
output_path = dir_path
|
|
1233
|
+
output_path.mkdir(parents=True, exist_ok=True)
|
|
1234
|
+
|
|
1235
|
+
timeout = _get_config().backend.timeout
|
|
1236
|
+
|
|
1237
|
+
# Process files concurrently - files are read just-in-time
|
|
1238
|
+
completed = 0
|
|
1239
|
+
failed = 0
|
|
1240
|
+
results: list[dict[str, Any]] = []
|
|
1241
|
+
|
|
1242
|
+
with ThreadPoolExecutor(max_workers=max_concurrent) as executor:
|
|
1243
|
+
futures = {
|
|
1244
|
+
executor.submit(
|
|
1245
|
+
_render_file,
|
|
1246
|
+
source_file,
|
|
1247
|
+
provider,
|
|
1248
|
+
output_format,
|
|
1249
|
+
output_path,
|
|
1250
|
+
timeout,
|
|
1251
|
+
): source_file
|
|
1252
|
+
for source_file, provider in file_provider_pairs
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
for future in as_completed(futures):
|
|
1256
|
+
result = future.result()
|
|
1257
|
+
results.append(result)
|
|
1258
|
+
if result["status"] == "success":
|
|
1259
|
+
completed += 1
|
|
1260
|
+
else:
|
|
1261
|
+
failed += 1
|
|
1262
|
+
|
|
1263
|
+
s.add(completed=completed, failed=failed)
|
|
1264
|
+
return (
|
|
1265
|
+
f"Directory render complete.\n"
|
|
1266
|
+
f"Completed: {completed}/{len(file_provider_pairs)}\n"
|
|
1267
|
+
f"Failed: {failed}"
|
|
1268
|
+
)
|
|
1269
|
+
|
|
1270
|
+
|
|
1271
|
+
# ==================== Configuration Tools ====================
|
|
1272
|
+
|
|
1273
|
+
|
|
1274
|
+
def get_diagram_policy() -> str:
|
|
1275
|
+
"""Get the diagram policy rules from configuration.
|
|
1276
|
+
|
|
1277
|
+
Returns policy rules that guide LLM diagram generation behaviour.
|
|
1278
|
+
|
|
1279
|
+
Returns:
|
|
1280
|
+
Policy rules as formatted text.
|
|
1281
|
+
|
|
1282
|
+
Example:
|
|
1283
|
+
policy = diagram.get_diagram_policy()
|
|
1284
|
+
"""
|
|
1285
|
+
with LogSpan(span="diagram.get_diagram_policy") as s:
|
|
1286
|
+
config = _get_config()
|
|
1287
|
+
policy = config.policy
|
|
1288
|
+
|
|
1289
|
+
result_parts = [
|
|
1290
|
+
"Diagram Policy",
|
|
1291
|
+
"=" * 40,
|
|
1292
|
+
"",
|
|
1293
|
+
"Rules:",
|
|
1294
|
+
policy.rules,
|
|
1295
|
+
"",
|
|
1296
|
+
f"Preferred format: {policy.preferred_format}",
|
|
1297
|
+
f"Preferred providers: {', '.join(policy.preferred_providers)}",
|
|
1298
|
+
]
|
|
1299
|
+
|
|
1300
|
+
s.add(source="config")
|
|
1301
|
+
return "\n".join(result_parts)
|
|
1302
|
+
|
|
1303
|
+
|
|
1304
|
+
def get_diagram_instructions(
|
|
1305
|
+
*, provider: Literal["mermaid", "plantuml", "d2"] | str | None = None
|
|
1306
|
+
) -> str:
|
|
1307
|
+
"""Get provider-specific diagram instructions.
|
|
1308
|
+
|
|
1309
|
+
Returns guidance on when to use the provider, style tips,
|
|
1310
|
+
syntax guides, and examples.
|
|
1311
|
+
|
|
1312
|
+
Args:
|
|
1313
|
+
provider: Specific provider to get instructions for.
|
|
1314
|
+
If None, returns instructions for all focus providers.
|
|
1315
|
+
|
|
1316
|
+
Returns:
|
|
1317
|
+
Formatted instructions text.
|
|
1318
|
+
|
|
1319
|
+
Example:
|
|
1320
|
+
# All providers
|
|
1321
|
+
instructions = diagram.get_diagram_instructions()
|
|
1322
|
+
|
|
1323
|
+
# Specific provider
|
|
1324
|
+
mermaid_guide = diagram.get_diagram_instructions(provider="mermaid")
|
|
1325
|
+
"""
|
|
1326
|
+
with LogSpan(span="diagram.get_diagram_instructions", provider=provider) as s:
|
|
1327
|
+
config = _get_config()
|
|
1328
|
+
# Convert Pydantic models to dicts for backward compatibility
|
|
1329
|
+
instructions = {k: v.model_dump() for k, v in config.instructions.items()} if config.instructions else {}
|
|
1330
|
+
|
|
1331
|
+
# If no config, use defaults
|
|
1332
|
+
if not instructions:
|
|
1333
|
+
instructions = _get_default_instructions()
|
|
1334
|
+
|
|
1335
|
+
if provider is not None:
|
|
1336
|
+
if provider not in instructions:
|
|
1337
|
+
s.add(found=False)
|
|
1338
|
+
return f"No instructions found for provider: {provider}"
|
|
1339
|
+
|
|
1340
|
+
instr = instructions[provider]
|
|
1341
|
+
result = _format_provider_instructions(provider, instr)
|
|
1342
|
+
s.add(found=True)
|
|
1343
|
+
return result
|
|
1344
|
+
|
|
1345
|
+
# Return all focus provider instructions
|
|
1346
|
+
result_parts = ["Diagram Provider Instructions", "=" * 40]
|
|
1347
|
+
|
|
1348
|
+
for p in FOCUS_PROVIDERS:
|
|
1349
|
+
if p in instructions:
|
|
1350
|
+
result_parts.append("")
|
|
1351
|
+
result_parts.append(_format_provider_instructions(p, instructions[p]))
|
|
1352
|
+
|
|
1353
|
+
s.add(providers=len([p for p in FOCUS_PROVIDERS if p in instructions]))
|
|
1354
|
+
return "\n".join(result_parts)
|
|
1355
|
+
|
|
1356
|
+
|
|
1357
|
+
def _get_default_instructions() -> dict[str, Any]:
|
|
1358
|
+
"""Get default instructions when not configured."""
|
|
1359
|
+
return {
|
|
1360
|
+
"mermaid": {
|
|
1361
|
+
"when_to_use": (
|
|
1362
|
+
"Flowcharts and decision trees\n"
|
|
1363
|
+
"Sequence diagrams for API flows\n"
|
|
1364
|
+
"Class diagrams for data models\n"
|
|
1365
|
+
"State diagrams for workflows"
|
|
1366
|
+
),
|
|
1367
|
+
"style_tips": (
|
|
1368
|
+
"Use subgraphs to group related nodes\n"
|
|
1369
|
+
"Keep flowcharts top-to-bottom (TD) for readability\n"
|
|
1370
|
+
"QUOTING: NO quotes after 'as' in sequence diagrams"
|
|
1371
|
+
),
|
|
1372
|
+
"syntax_guide": "https://mermaid.js.org/syntax/",
|
|
1373
|
+
"example": (
|
|
1374
|
+
"sequenceDiagram\n"
|
|
1375
|
+
" participant C as Client\n"
|
|
1376
|
+
" participant S as Server\n"
|
|
1377
|
+
" C->>S: Request\n"
|
|
1378
|
+
" S-->>C: Response"
|
|
1379
|
+
),
|
|
1380
|
+
},
|
|
1381
|
+
"plantuml": {
|
|
1382
|
+
"when_to_use": (
|
|
1383
|
+
"Complex UML diagrams\n"
|
|
1384
|
+
"C4 architecture diagrams\n"
|
|
1385
|
+
"Detailed sequence diagrams with notes"
|
|
1386
|
+
),
|
|
1387
|
+
"style_tips": (
|
|
1388
|
+
"Use skinparam for consistent theming\n"
|
|
1389
|
+
"QUOTING: Quote display names BEFORE 'as'"
|
|
1390
|
+
),
|
|
1391
|
+
"syntax_guide": "https://plantuml.com/",
|
|
1392
|
+
},
|
|
1393
|
+
"d2": {
|
|
1394
|
+
"when_to_use": (
|
|
1395
|
+
"Clean architecture diagrams\n"
|
|
1396
|
+
"System context diagrams\n"
|
|
1397
|
+
"Hand-drawn style (sketch mode)"
|
|
1398
|
+
),
|
|
1399
|
+
"style_tips": (
|
|
1400
|
+
"Use containers for logical grouping\n"
|
|
1401
|
+
"D2 auto-layouts well\n"
|
|
1402
|
+
"QUOTING: Always quote labels after colon"
|
|
1403
|
+
),
|
|
1404
|
+
"syntax_guide": "https://d2lang.com/tour/intro",
|
|
1405
|
+
},
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
|
|
1409
|
+
def _format_provider_instructions(provider: str, instr: dict[str, Any]) -> str:
|
|
1410
|
+
"""Format instructions for a single provider."""
|
|
1411
|
+
parts = [f"## {provider.upper()}"]
|
|
1412
|
+
|
|
1413
|
+
if instr.get("when_to_use"):
|
|
1414
|
+
parts.append("\nWhen to use:")
|
|
1415
|
+
parts.append(instr["when_to_use"])
|
|
1416
|
+
|
|
1417
|
+
if instr.get("style_tips"):
|
|
1418
|
+
parts.append("\nStyle tips:")
|
|
1419
|
+
parts.append(instr["style_tips"])
|
|
1420
|
+
|
|
1421
|
+
if instr.get("syntax_guide"):
|
|
1422
|
+
parts.append(f"\nSyntax guide: {instr['syntax_guide']}")
|
|
1423
|
+
|
|
1424
|
+
if instr.get("example"):
|
|
1425
|
+
parts.append("\nExample:")
|
|
1426
|
+
parts.append("```")
|
|
1427
|
+
parts.append(instr["example"])
|
|
1428
|
+
parts.append("```")
|
|
1429
|
+
|
|
1430
|
+
return "\n".join(parts)
|
|
1431
|
+
|
|
1432
|
+
|
|
1433
|
+
def get_output_config() -> str:
|
|
1434
|
+
"""Get diagram output configuration settings.
|
|
1435
|
+
|
|
1436
|
+
Returns:
|
|
1437
|
+
Formatted output configuration.
|
|
1438
|
+
|
|
1439
|
+
Example:
|
|
1440
|
+
config = diagram.get_output_config()
|
|
1441
|
+
"""
|
|
1442
|
+
with LogSpan(span="diagram.get_output_config") as s:
|
|
1443
|
+
config = _get_config()
|
|
1444
|
+
output = config.output
|
|
1445
|
+
|
|
1446
|
+
result = (
|
|
1447
|
+
"Diagram Output Configuration\n"
|
|
1448
|
+
+ "=" * 40 + "\n\n"
|
|
1449
|
+
+ f"Output directory: {output.dir}\n"
|
|
1450
|
+
+ f"Naming pattern: {output.naming}\n"
|
|
1451
|
+
+ f"Default format: {output.default_format}\n"
|
|
1452
|
+
+ f"Save source: {output.save_source}"
|
|
1453
|
+
)
|
|
1454
|
+
|
|
1455
|
+
s.add(dir=output.dir, format=output.default_format)
|
|
1456
|
+
return result
|
|
1457
|
+
|
|
1458
|
+
|
|
1459
|
+
def get_template(*, name: str) -> str:
|
|
1460
|
+
"""Load a diagram template by name.
|
|
1461
|
+
|
|
1462
|
+
Templates are defined in the diagram config and provide
|
|
1463
|
+
starting points for common diagram types.
|
|
1464
|
+
|
|
1465
|
+
Args:
|
|
1466
|
+
name: Template name as configured in onetool.yaml.
|
|
1467
|
+
|
|
1468
|
+
Returns:
|
|
1469
|
+
Template source code with metadata.
|
|
1470
|
+
|
|
1471
|
+
Example:
|
|
1472
|
+
# First configure templates in onetool.yaml:
|
|
1473
|
+
# tools:
|
|
1474
|
+
# diagram:
|
|
1475
|
+
# templates:
|
|
1476
|
+
# api-flow:
|
|
1477
|
+
# provider: mermaid
|
|
1478
|
+
# diagram_type: sequence
|
|
1479
|
+
# description: API flow template
|
|
1480
|
+
# file: templates/api-flow.mmd
|
|
1481
|
+
#
|
|
1482
|
+
# Then load it:
|
|
1483
|
+
template = diagram.get_template(name="api-flow")
|
|
1484
|
+
"""
|
|
1485
|
+
with LogSpan(span="diagram.get_template", template_name=name) as s:
|
|
1486
|
+
config = _get_config()
|
|
1487
|
+
templates = config.templates
|
|
1488
|
+
|
|
1489
|
+
if name not in templates:
|
|
1490
|
+
s.add(found=False)
|
|
1491
|
+
available = ", ".join(templates.keys()) if templates else "none configured"
|
|
1492
|
+
return f"Template not found: {name}\nAvailable: {available}"
|
|
1493
|
+
|
|
1494
|
+
template = templates[name]
|
|
1495
|
+
file_path = template.file
|
|
1496
|
+
|
|
1497
|
+
# Try to load the template file
|
|
1498
|
+
if file_path:
|
|
1499
|
+
# Resolve relative to config directory (.onetool/)
|
|
1500
|
+
path = _resolve_config_path(file_path)
|
|
1501
|
+
|
|
1502
|
+
if path.exists():
|
|
1503
|
+
source = path.read_text()
|
|
1504
|
+
s.add(found=True, lines=len(source.splitlines()))
|
|
1505
|
+
return (
|
|
1506
|
+
f"Template: {name}\n"
|
|
1507
|
+
f"Provider: {template.provider}\n"
|
|
1508
|
+
f"Type: {template.diagram_type}\n"
|
|
1509
|
+
f"Description: {template.description}\n"
|
|
1510
|
+
f"\n--- Source ---\n{source}"
|
|
1511
|
+
)
|
|
1512
|
+
|
|
1513
|
+
s.add(found=False, reason="file_not_found")
|
|
1514
|
+
return f"Template '{name}' configured but file not found: {file_path}"
|
|
1515
|
+
|
|
1516
|
+
|
|
1517
|
+
def list_providers(*, focus_only: bool = False) -> str:
|
|
1518
|
+
"""List all available diagram providers.
|
|
1519
|
+
|
|
1520
|
+
Args:
|
|
1521
|
+
focus_only: Only list focus providers with full guidance
|
|
1522
|
+
(mermaid, plantuml, d2).
|
|
1523
|
+
|
|
1524
|
+
Returns:
|
|
1525
|
+
Formatted list of providers.
|
|
1526
|
+
|
|
1527
|
+
Example:
|
|
1528
|
+
# All providers
|
|
1529
|
+
providers = diagram.list_providers()
|
|
1530
|
+
|
|
1531
|
+
# Focus providers only
|
|
1532
|
+
focus = diagram.list_providers(focus_only=True)
|
|
1533
|
+
"""
|
|
1534
|
+
with LogSpan(span="diagram.list_providers", focus_only=focus_only) as s:
|
|
1535
|
+
if focus_only:
|
|
1536
|
+
result = (
|
|
1537
|
+
"Focus Providers (with full guidance)\n"
|
|
1538
|
+
"=" * 40 + "\n\n"
|
|
1539
|
+
"- mermaid: Flowcharts, sequences, state, Gantt, mindmaps\n"
|
|
1540
|
+
"- plantuml: UML diagrams, C4 architecture\n"
|
|
1541
|
+
"- d2: Modern architecture diagrams with auto-layout\n"
|
|
1542
|
+
"\nUse diagram.get_diagram_instructions(provider='...') for details."
|
|
1543
|
+
)
|
|
1544
|
+
s.add(count=len(FOCUS_PROVIDERS))
|
|
1545
|
+
return result
|
|
1546
|
+
|
|
1547
|
+
result_parts = [
|
|
1548
|
+
"All Kroki Providers",
|
|
1549
|
+
"=" * 40,
|
|
1550
|
+
"",
|
|
1551
|
+
"Focus providers (with guidance):",
|
|
1552
|
+
]
|
|
1553
|
+
result_parts.extend(f" - {p}" for p in FOCUS_PROVIDERS)
|
|
1554
|
+
|
|
1555
|
+
result_parts.append("\nOther providers:")
|
|
1556
|
+
other = [p for p in KROKI_PROVIDERS if p not in FOCUS_PROVIDERS]
|
|
1557
|
+
result_parts.extend(f" - {p}" for p in sorted(other))
|
|
1558
|
+
|
|
1559
|
+
result_parts.append(f"\nTotal: {len(KROKI_PROVIDERS)} providers")
|
|
1560
|
+
|
|
1561
|
+
s.add(count=len(KROKI_PROVIDERS))
|
|
1562
|
+
return "\n".join(result_parts)
|
|
1563
|
+
|
|
1564
|
+
|
|
1565
|
+
# ==================== Utility Tools ====================
|
|
1566
|
+
|
|
1567
|
+
|
|
1568
|
+
def get_playground_url(
|
|
1569
|
+
*,
|
|
1570
|
+
source: str,
|
|
1571
|
+
provider: Literal["mermaid", "plantuml", "d2"] | str,
|
|
1572
|
+
) -> str:
|
|
1573
|
+
"""Generate a playground URL for interactive editing.
|
|
1574
|
+
|
|
1575
|
+
Playground URLs allow editing diagrams in the browser before
|
|
1576
|
+
saving the final source.
|
|
1577
|
+
|
|
1578
|
+
Args:
|
|
1579
|
+
source: The diagram source code.
|
|
1580
|
+
provider: The diagram provider.
|
|
1581
|
+
|
|
1582
|
+
Returns:
|
|
1583
|
+
Playground URL or message if not supported.
|
|
1584
|
+
|
|
1585
|
+
Example:
|
|
1586
|
+
url = diagram.get_playground_url(
|
|
1587
|
+
source="sequenceDiagram\\n A->>B: Hello",
|
|
1588
|
+
provider="mermaid"
|
|
1589
|
+
)
|
|
1590
|
+
"""
|
|
1591
|
+
with LogSpan(span="diagram.get_playground_url", provider=provider) as s:
|
|
1592
|
+
if provider == "mermaid":
|
|
1593
|
+
url = _get_mermaid_playground_url(source)
|
|
1594
|
+
elif provider == "plantuml":
|
|
1595
|
+
url = _get_plantuml_playground_url(source)
|
|
1596
|
+
elif provider == "d2":
|
|
1597
|
+
url = _get_d2_playground_url(source)
|
|
1598
|
+
else:
|
|
1599
|
+
s.add(supported=False)
|
|
1600
|
+
# Fall back to Kroki GET URL
|
|
1601
|
+
url = _get_kroki_get_url(source, provider, "svg")
|
|
1602
|
+
|
|
1603
|
+
s.add(supported=True, url_length=len(url))
|
|
1604
|
+
return url
|