evo-cli 0.4.0__tar.gz → 0.5.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. {evo_cli-0.4.0 → evo_cli-0.5.1}/PKG-INFO +1 -1
  2. evo_cli-0.5.1/evo_cli/VERSION +1 -0
  3. {evo_cli-0.4.0 → evo_cli-0.5.1}/evo_cli/cli.py +2 -0
  4. evo_cli-0.5.1/evo_cli/commands/plantuml.py +753 -0
  5. {evo_cli-0.4.0 → evo_cli-0.5.1}/evo_cli.egg-info/PKG-INFO +1 -1
  6. {evo_cli-0.4.0 → evo_cli-0.5.1}/evo_cli.egg-info/SOURCES.txt +3 -1
  7. evo_cli-0.5.1/tests/test_plantuml.py +80 -0
  8. evo_cli-0.4.0/evo_cli/VERSION +0 -1
  9. {evo_cli-0.4.0 → evo_cli-0.5.1}/Containerfile +0 -0
  10. {evo_cli-0.4.0 → evo_cli-0.5.1}/HISTORY.md +0 -0
  11. {evo_cli-0.4.0 → evo_cli-0.5.1}/LICENSE +0 -0
  12. {evo_cli-0.4.0 → evo_cli-0.5.1}/MANIFEST.in +0 -0
  13. {evo_cli-0.4.0 → evo_cli-0.5.1}/README.md +0 -0
  14. {evo_cli-0.4.0 → evo_cli-0.5.1}/evo_cli/__init__.py +0 -0
  15. {evo_cli-0.4.0 → evo_cli-0.5.1}/evo_cli/__main__.py +0 -0
  16. {evo_cli-0.4.0 → evo_cli-0.5.1}/evo_cli/base.py +0 -0
  17. {evo_cli-0.4.0 → evo_cli-0.5.1}/evo_cli/commands/__init__.py +0 -0
  18. {evo_cli-0.4.0 → evo_cli-0.5.1}/evo_cli/commands/cloudflare.py +0 -0
  19. {evo_cli-0.4.0 → evo_cli-0.5.1}/evo_cli/commands/fix_claude.py +0 -0
  20. {evo_cli-0.4.0 → evo_cli-0.5.1}/evo_cli/commands/gdrive.py +0 -0
  21. {evo_cli-0.4.0 → evo_cli-0.5.1}/evo_cli/commands/localproxy.py +0 -0
  22. {evo_cli-0.4.0 → evo_cli-0.5.1}/evo_cli/commands/miniconda.py +0 -0
  23. {evo_cli-0.4.0 → evo_cli-0.5.1}/evo_cli/commands/netcheck.py +0 -0
  24. {evo_cli-0.4.0 → evo_cli-0.5.1}/evo_cli/commands/site2s.py +0 -0
  25. {evo_cli-0.4.0 → evo_cli-0.5.1}/evo_cli/commands/ssh.py +0 -0
  26. {evo_cli-0.4.0 → evo_cli-0.5.1}/evo_cli/console.py +0 -0
  27. {evo_cli-0.4.0 → evo_cli-0.5.1}/evo_cli.egg-info/dependency_links.txt +0 -0
  28. {evo_cli-0.4.0 → evo_cli-0.5.1}/evo_cli.egg-info/entry_points.txt +0 -0
  29. {evo_cli-0.4.0 → evo_cli-0.5.1}/evo_cli.egg-info/requires.txt +0 -0
  30. {evo_cli-0.4.0 → evo_cli-0.5.1}/evo_cli.egg-info/top_level.txt +0 -0
  31. {evo_cli-0.4.0 → evo_cli-0.5.1}/pyproject.toml +0 -0
  32. {evo_cli-0.4.0 → evo_cli-0.5.1}/setup.cfg +0 -0
  33. {evo_cli-0.4.0 → evo_cli-0.5.1}/tests/__init__.py +0 -0
  34. {evo_cli-0.4.0 → evo_cli-0.5.1}/tests/test_cli.py +0 -0
  35. {evo_cli-0.4.0 → evo_cli-0.5.1}/tests/test_fix_claude.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: evo_cli
3
- Version: 0.4.0
3
+ Version: 0.5.1
4
4
  Summary: Evolution CLI - a developer toolbox for setting up dev machines
5
5
  Author: maycuatroi
6
6
  Project-URL: Homepage, https://github.com/maycuatroi/evo-cli
@@ -0,0 +1 @@
1
+ 0.5.1
@@ -7,6 +7,7 @@ from evo_cli.commands.gdrive import gdrive
7
7
  from evo_cli.commands.localproxy import localproxy
8
8
  from evo_cli.commands.miniconda import miniconda
9
9
  from evo_cli.commands.netcheck import netcheck
10
+ from evo_cli.commands.plantuml import plantuml
10
11
  from evo_cli.commands.site2s import site2s
11
12
  from evo_cli.commands.ssh import setupssh
12
13
 
@@ -39,6 +40,7 @@ cli.add_command(gdrive)
39
40
  cli.add_command(site2s)
40
41
  cli.add_command(localproxy)
41
42
  cli.add_command(netcheck)
43
+ cli.add_command(plantuml)
42
44
 
43
45
 
44
46
  def main():
@@ -0,0 +1,753 @@
1
+ """
2
+ evo plantuml - install, check and render PlantUML diagrams.
3
+
4
+ A self-contained PlantUML toolbox: it downloads the official plantuml.jar
5
+ (no system package needed), checks the runtime (Java + Graphviz), renders
6
+ .puml files to PNG/SVG/PDF and scaffolds vivid, ready-to-edit templates.
7
+
8
+ When Graphviz is missing, rendering falls back to PlantUML's pure-Java
9
+ Smetana layout engine so most diagrams still work with Java alone.
10
+ """
11
+ import json as jsonlib
12
+ import os
13
+ import platform
14
+ import re
15
+ import shutil
16
+ import subprocess
17
+ import sys
18
+ import tempfile
19
+ import time
20
+ import urllib.error
21
+ import urllib.request
22
+ from pathlib import Path
23
+
24
+ import rich_click as click
25
+ from rich.panel import Panel
26
+ from rich.table import Table
27
+ from rich.text import Text
28
+
29
+ from evo_cli.console import console, download_file, error, info, step, success, warning
30
+
31
+ GITHUB_LATEST = "https://api.github.com/repos/plantuml/plantuml/releases/latest"
32
+ GITHUB_TAG = "https://api.github.com/repos/plantuml/plantuml/releases/tags/{tag}"
33
+ FALLBACK_VERSION = "1.2026.6"
34
+ FALLBACK_URL = (
35
+ "https://github.com/plantuml/plantuml/releases/download/"
36
+ "v{ver}/plantuml-{ver}.jar"
37
+ )
38
+
39
+ FORMATS = ["png", "svg", "pdf", "txt", "eps", "vdx", "latex"]
40
+
41
+ THEMES = [
42
+ "amiga", "aws-orange", "black-knight", "bluegray", "blueprint", "carbon-gray",
43
+ "cerulean", "cerulean-outline", "crt-amber", "crt-green", "cyborg",
44
+ "cyborg-outline", "hacker", "lightgray", "mars", "materia", "materia-outline",
45
+ "metal", "mimeograph", "minty", "mono", "plain", "reddress-darkblue",
46
+ "reddress-darkgreen", "reddress-darkorange", "reddress-darkred",
47
+ "reddress-lightblue", "reddress-lightgreen", "reddress-lightorange",
48
+ "reddress-lightred", "sandstone", "silver", "sketchy", "sketchy-outline",
49
+ "spacelab", "spacelab-white", "superhero", "superhero-outline", "toy",
50
+ "united", "vibrant",
51
+ ]
52
+
53
+ EPILOG = Text.from_markup(
54
+ "[bold]Examples[/bold]\n\n"
55
+ " [cyan]evo plantuml install[/cyan] download plantuml.jar + check runtime\n"
56
+ " [cyan]evo plantuml install --with-deps[/cyan] also install Java + Graphviz\n"
57
+ " [cyan]evo plantuml check[/cyan] verify Java / Graphviz / jar\n"
58
+ " [cyan]evo plantuml new sequence -o login.puml[/cyan] scaffold a vivid template\n"
59
+ " [cyan]evo plantuml render login.puml[/cyan] render to PNG next to the source\n"
60
+ " [cyan]evo plantuml render diagrams/ -f svg[/cyan] render a whole folder to SVG\n"
61
+ " [cyan]evo plantuml render login.puml -t cerulean --open[/cyan]\n"
62
+ " [cyan]evo plantuml render login.puml --watch[/cyan] re-render on every save\n"
63
+ " [cyan]evo plantuml themes[/cyan] list bundled themes\n\n"
64
+ "[dim]Diagrams that need layout (class, component, state, activity) use Graphviz when\n"
65
+ "present and fall back to the built-in Smetana engine otherwise.[/dim]"
66
+ )
67
+
68
+
69
+ def base_dir():
70
+ d = Path.home() / ".evo" / "plantuml"
71
+ d.mkdir(parents=True, exist_ok=True)
72
+ return d
73
+
74
+
75
+ def jar_path():
76
+ return base_dir() / "plantuml.jar"
77
+
78
+
79
+ def version_path():
80
+ return base_dir() / "version.txt"
81
+
82
+
83
+ def installed_version():
84
+ p = version_path()
85
+ if p.exists():
86
+ return p.read_text(encoding="utf-8").strip()
87
+ return None
88
+
89
+
90
+ def find_java():
91
+ return shutil.which("java")
92
+
93
+
94
+ def find_dot():
95
+ return shutil.which("dot")
96
+
97
+
98
+ def java_version():
99
+ java = find_java()
100
+ if not java:
101
+ return None
102
+ try:
103
+ r = subprocess.run([java, "-version"], capture_output=True, text=True)
104
+ out = (r.stderr or r.stdout or "").strip().splitlines()
105
+ return out[0] if out else None
106
+ except OSError:
107
+ return None
108
+
109
+
110
+ def dot_version():
111
+ dot = find_dot()
112
+ if not dot:
113
+ return None
114
+ try:
115
+ r = subprocess.run([dot, "-V"], capture_output=True, text=True)
116
+ return (r.stderr or r.stdout or "").strip() or None
117
+ except OSError:
118
+ return None
119
+
120
+
121
+ def fetch_json(url):
122
+ req = urllib.request.Request(url, headers={"User-Agent": "evo-cli/plantuml"})
123
+ with urllib.request.urlopen(req, timeout=20) as resp:
124
+ return jsonlib.loads(resp.read().decode("utf-8"))
125
+
126
+
127
+ def pick_jar_asset(assets):
128
+ candidates = []
129
+ for a in assets:
130
+ name = a.get("name", "")
131
+ if not name.endswith(".jar"):
132
+ continue
133
+ if "javadoc" in name or "sources" in name:
134
+ continue
135
+ candidates.append(a)
136
+ exact = [a for a in candidates if re.match(r"^plantuml-\d.*\.jar$", a["name"])]
137
+ pool = exact or candidates
138
+ if not pool:
139
+ return None
140
+ pool.sort(key=lambda a: len(a["name"]))
141
+ return pool[0]
142
+
143
+
144
+ def resolve_download(release):
145
+ try:
146
+ url = GITHUB_LATEST if release in (None, "latest") else GITHUB_TAG.format(tag=release)
147
+ data = fetch_json(url)
148
+ tag = data.get("tag_name", "").lstrip("v")
149
+ asset = pick_jar_asset(data.get("assets", []))
150
+ if asset:
151
+ return asset["browser_download_url"], (tag or asset["name"])
152
+ except (urllib.error.URLError, OSError, ValueError, KeyError) as exc:
153
+ warning(f"GitHub API lookup failed ({exc}); using fallback {FALLBACK_VERSION}")
154
+ ver = FALLBACK_VERSION if release in (None, "latest") else release.lstrip("v")
155
+ return FALLBACK_URL.format(ver=ver), ver
156
+
157
+
158
+ def detect_package_manager():
159
+ for mgr in ("apt-get", "dnf", "yum", "pacman", "zypper", "brew", "winget", "choco"):
160
+ if shutil.which(mgr):
161
+ return mgr
162
+ return None
163
+
164
+
165
+ def install_packages(packages, assume_yes):
166
+ mgr = detect_package_manager()
167
+ if not mgr:
168
+ warning("No supported package manager found. Install manually: " + ", ".join(packages))
169
+ return False
170
+ sudo = [] if (os.name == "nt" or mgr == "brew" or os.geteuid() == 0) else ["sudo"]
171
+ yes = ["-y"] if assume_yes else []
172
+ pkgmap = {
173
+ "java": {"apt-get": "default-jre", "dnf": "java-latest-openjdk", "yum": "java-latest-openjdk",
174
+ "pacman": "jre-openjdk", "zypper": "java-openjdk", "brew": "openjdk",
175
+ "winget": "EclipseAdoptium.Temurin.21.JRE", "choco": "temurin"},
176
+ "graphviz": {"apt-get": "graphviz", "dnf": "graphviz", "yum": "graphviz",
177
+ "pacman": "graphviz", "zypper": "graphviz", "brew": "graphviz",
178
+ "winget": "Graphviz.Graphviz", "choco": "graphviz"},
179
+ }
180
+ cmds = {
181
+ "apt-get": lambda p: sudo + ["apt-get", "install"] + yes + p,
182
+ "dnf": lambda p: sudo + ["dnf", "install"] + yes + p,
183
+ "yum": lambda p: sudo + ["yum", "install"] + yes + p,
184
+ "pacman": lambda p: sudo + ["pacman", "-S", "--noconfirm" if assume_yes else "--needed"] + p,
185
+ "zypper": lambda p: sudo + ["zypper", "install"] + yes + p,
186
+ "brew": lambda p: ["brew", "install"] + p,
187
+ "choco": lambda p: ["choco", "install"] + (["-y"] if assume_yes else []) + p,
188
+ }
189
+ resolved = [pkgmap[pkg][mgr] for pkg in packages if pkg in pkgmap and mgr in pkgmap[pkg]]
190
+ if not resolved:
191
+ warning(f"Don't know how to install {packages} with {mgr}.")
192
+ return False
193
+ if mgr == "apt-get":
194
+ subprocess.run(sudo + ["apt-get", "update"], check=False)
195
+ if mgr == "winget":
196
+ ok = True
197
+ for pkg in packages:
198
+ ident = pkgmap[pkg][mgr]
199
+ cmd = ["winget", "install", "-e", "--id", ident]
200
+ console.print(f"[cmd]$ {' '.join(cmd)}[/cmd]")
201
+ ok = subprocess.run(cmd).returncode == 0 and ok
202
+ return ok
203
+ cmd = cmds[mgr](resolved)
204
+ console.print(f"[cmd]$ {' '.join(cmd)}[/cmd]")
205
+ return subprocess.run(cmd).returncode == 0
206
+
207
+
208
+ def run_jar(args, capture=True):
209
+ java = find_java()
210
+ if not java:
211
+ raise click.ClickException("Java not found. Run `evo plantuml install --with-deps` first.")
212
+ if not jar_path().exists():
213
+ raise click.ClickException("plantuml.jar not found. Run `evo plantuml install` first.")
214
+ cmd = [java, "-jar", str(jar_path())] + list(args)
215
+ return subprocess.run(cmd, capture_output=capture, text=True)
216
+
217
+
218
+ # install -------------------------------------------------------------------
219
+
220
+ def do_install(release, force, with_deps, assume_yes):
221
+ if with_deps:
222
+ missing = []
223
+ if not find_java():
224
+ missing.append("java")
225
+ if not find_dot():
226
+ missing.append("graphviz")
227
+ if missing:
228
+ info(f"Installing system packages: [accent]{', '.join(missing)}[/accent]")
229
+ install_packages(missing, assume_yes)
230
+ else:
231
+ info("Java and Graphviz already present.")
232
+
233
+ jar = jar_path()
234
+ if jar.exists() and not force:
235
+ warning(f"plantuml.jar already present at [accent]{jar}[/accent] "
236
+ f"(version {installed_version() or 'unknown'})")
237
+ info("Pass --force to re-download.")
238
+ else:
239
+ url, ver = resolve_download(release)
240
+ info(f"Downloading PlantUML [accent]{ver}[/accent]")
241
+ with tempfile.TemporaryDirectory() as tmp:
242
+ tmp_jar = Path(tmp) / "plantuml.jar"
243
+ try:
244
+ download_file(url, str(tmp_jar), "plantuml.jar")
245
+ except (urllib.error.URLError, OSError) as exc:
246
+ raise click.ClickException(f"Download failed: {exc}")
247
+ shutil.move(str(tmp_jar), str(jar))
248
+ version_path().write_text(ver, encoding="utf-8")
249
+ success(f"Installed plantuml.jar ({ver}) at [accent]{jar}[/accent]")
250
+
251
+ report_environment()
252
+
253
+
254
+ def report_environment():
255
+ jv = java_version()
256
+ dv = dot_version()
257
+ console.print()
258
+ table = Table(show_header=True, header_style="accent", expand=False)
259
+ table.add_column("Component", style="info", no_wrap=True)
260
+ table.add_column("Status", no_wrap=True)
261
+ table.add_column("Detail", style="dim")
262
+ table.add_row("plantuml.jar",
263
+ "[success]ok[/success]" if jar_path().exists() else "[error]missing[/error]",
264
+ f"{jar_path()} ({installed_version() or '-'})")
265
+ table.add_row("Java", "[success]ok[/success]" if jv else "[error]missing[/error]",
266
+ jv or "needed to run PlantUML")
267
+ table.add_row("Graphviz (dot)", "[success]ok[/success]" if dv else "[warning]missing[/warning]",
268
+ dv or "optional - Smetana fallback will be used")
269
+ console.print(table)
270
+
271
+
272
+ # check ---------------------------------------------------------------------
273
+
274
+ def do_check():
275
+ report_environment()
276
+ if not find_java() or not jar_path().exists():
277
+ error("PlantUML is not ready. Run `evo plantuml install --with-deps`.")
278
+ sys.exit(1)
279
+
280
+ r = run_jar(["-version"])
281
+ out = (r.stdout or "") + (r.stderr or "")
282
+ for line in out.strip().splitlines()[:4]:
283
+ console.print(f"[dim]{line}[/dim]")
284
+
285
+ info("Testing Graphviz integration...")
286
+ rt = run_jar(["-testdot"])
287
+ test_out = ((rt.stdout or "") + (rt.stderr or "")).strip()
288
+ if "Error" in test_out or "cannot" in test_out.lower():
289
+ warning("Graphviz not usable by PlantUML; layout diagrams will use Smetana.")
290
+ for line in test_out.splitlines()[:4]:
291
+ console.print(f"[dim]{line}[/dim]")
292
+ else:
293
+ for line in test_out.splitlines()[:4]:
294
+ console.print(f"[dim]{line}[/dim]")
295
+ success("Graphviz works with PlantUML.")
296
+
297
+ step("Verdict")
298
+ if find_dot():
299
+ success("Ready to render every diagram type.")
300
+ else:
301
+ success("Ready to render (Graphviz missing: using Smetana for layout diagrams).")
302
+
303
+
304
+ # render --------------------------------------------------------------------
305
+
306
+ def collect_sources(target):
307
+ p = Path(target)
308
+ if p.is_dir():
309
+ files = sorted(set(p.rglob("*.puml")) | set(p.rglob("*.plantuml")) | set(p.rglob("*.pu")))
310
+ return [str(f) for f in files]
311
+ if p.exists():
312
+ return [str(p)]
313
+ raise click.ClickException(f"No such file or directory: {target}")
314
+
315
+
316
+ def build_config(theme, engine):
317
+ lines = []
318
+ if engine == "smetana" or (engine == "auto" and not find_dot()):
319
+ lines.append("!pragma layout smetana")
320
+ if theme:
321
+ lines.append(f"!theme {theme}")
322
+ if not lines:
323
+ return None
324
+ fd, path = tempfile.mkstemp(suffix=".puml", prefix="evo-config-")
325
+ with os.fdopen(fd, "w", encoding="utf-8") as h:
326
+ h.write("\n".join(lines) + "\n")
327
+ return path
328
+
329
+
330
+ def output_for(src, outdir, fmt):
331
+ src_path = Path(src)
332
+ ext = "tex" if fmt == "latex" else fmt
333
+ target_dir = Path(outdir) if outdir else src_path.parent
334
+ return target_dir / f"{src_path.stem}.{ext}"
335
+
336
+
337
+ def render_once(sources, fmt, outdir, config, scale, charset, extra):
338
+ args = [f"-t{fmt}", "-charset", charset]
339
+ if outdir:
340
+ args += ["-o", str(Path(outdir).resolve())]
341
+ if config:
342
+ args += ["-config", config]
343
+ if scale:
344
+ args += [f"-Sdpi={scale}"]
345
+ args += list(extra)
346
+ args += sources
347
+ r = run_jar(args, capture=True)
348
+ out = ((r.stdout or "") + (r.stderr or "")).strip()
349
+ return r.returncode, out
350
+
351
+
352
+ def do_render(target, fmt, outdir, theme, engine, scale, charset, do_open, watch):
353
+ sources = collect_sources(target)
354
+ if not sources:
355
+ warning("No .puml / .plantuml / .pu files found.")
356
+ return
357
+ if outdir:
358
+ Path(outdir).mkdir(parents=True, exist_ok=True)
359
+
360
+ config = build_config(theme, engine)
361
+ extra = []
362
+ if engine == "smetana":
363
+ info("Layout engine: Smetana (pure Java)")
364
+ elif not find_dot():
365
+ info("Graphviz not found: using Smetana layout engine")
366
+
367
+ def _render():
368
+ code, out = render_once(sources, fmt, outdir, config, scale, charset, extra)
369
+ if out:
370
+ level = error if code != 0 else (warning if "Warning" in out else info)
371
+ for line in out.splitlines()[:8]:
372
+ level(line)
373
+ if code != 0:
374
+ error("Rendering failed.")
375
+ return False
376
+ for src in sources:
377
+ produced = output_for(src, outdir, fmt)
378
+ mark = "[success]ok[/success]" if produced.exists() else "[warning]?[/warning]"
379
+ console.print(f" {mark} {src} -> [accent]{produced}[/accent]")
380
+ return True
381
+
382
+ try:
383
+ ok = _render()
384
+ if ok:
385
+ success(f"Rendered {len(sources)} file(s) to {fmt.upper()}.")
386
+ if do_open and not watch:
387
+ open_file(output_for(sources[0], outdir, fmt))
388
+
389
+ if watch:
390
+ info("Watching for changes - Ctrl+C to stop.")
391
+ mtimes = {s: _mtime(s) for s in sources}
392
+ while True:
393
+ time.sleep(1)
394
+ for s in list(sources):
395
+ m = _mtime(s)
396
+ if m != mtimes.get(s):
397
+ mtimes[s] = m
398
+ console.print(f"[cmd]changed: {s}[/cmd]")
399
+ render_once([s], fmt, outdir, config, scale, charset, extra)
400
+ produced = output_for(s, outdir, fmt)
401
+ success(f"re-rendered -> {produced}")
402
+ except KeyboardInterrupt:
403
+ console.print()
404
+ info("Stopped watching.")
405
+ finally:
406
+ if config:
407
+ try:
408
+ os.unlink(config)
409
+ except OSError:
410
+ pass
411
+
412
+
413
+ def _mtime(path):
414
+ try:
415
+ return os.path.getmtime(path)
416
+ except OSError:
417
+ return None
418
+
419
+
420
+ def open_file(path):
421
+ path = str(path)
422
+ try:
423
+ if os.name == "nt":
424
+ os.startfile(path) # noqa: S606
425
+ elif platform.system() == "Darwin":
426
+ subprocess.run(["open", path], check=False)
427
+ else:
428
+ subprocess.run(["xdg-open", path], check=False)
429
+ except OSError as exc:
430
+ warning(f"Could not open {path}: {exc}")
431
+
432
+
433
+ # new -----------------------------------------------------------------------
434
+
435
+ TEMPLATES = {
436
+ "sequence": """@startuml
437
+ !theme cerulean
438
+ skinparam backgroundColor #FDFDFD
439
+ skinparam shadowing true
440
+ skinparam roundCorner 12
441
+ skinparam sequence {
442
+ ArrowColor #2C6FBB
443
+ LifeLineBorderColor #2C6FBB
444
+ ParticipantBorderColor #2C6FBB
445
+ ParticipantBackgroundColor #E8F1FB
446
+ }
447
+ title Login flow
448
+
449
+ actor User as user
450
+ participant "Web App" as web #E8F1FB
451
+ participant "Auth API" as auth #FFF3E0
452
+ database "DB" as db #E8F5E9
453
+
454
+ user -> web : open /login
455
+ web -> auth : POST /token
456
+ activate auth
457
+ auth -> db : verify credentials
458
+ db --> auth : user record
459
+ auth --> web : JWT
460
+ deactivate auth
461
+ web --> user : set cookie + redirect
462
+ note right of user : Session established
463
+ @enduml
464
+ """,
465
+ "class": """@startuml
466
+ !theme vibrant
467
+ skinparam shadowing true
468
+ skinparam roundCorner 10
469
+ skinparam classAttributeIconSize 0
470
+ title Domain model
471
+
472
+ class User {
473
+ +id: UUID
474
+ +email: String
475
+ --
476
+ +login(): Session
477
+ }
478
+
479
+ class Session {
480
+ +token: String
481
+ +expiresAt: DateTime
482
+ }
483
+
484
+ interface Repository<T> {
485
+ +get(id): T
486
+ +save(entity: T)
487
+ }
488
+
489
+ class UserRepository
490
+ Repository <|.. UserRepository
491
+ User "1" o-- "many" Session : owns >
492
+ UserRepository ..> User : manages
493
+ @enduml
494
+ """,
495
+ "component": """@startuml
496
+ !theme aws-orange
497
+ skinparam shadowing true
498
+ skinparam roundCorner 12
499
+ title System components
500
+
501
+ package "Frontend" {
502
+ [Web App] as web
503
+ }
504
+ package "Backend" {
505
+ [API Gateway] as gw
506
+ [Auth Service] as auth
507
+ [Orders Service] as orders
508
+ }
509
+ database "PostgreSQL" as db
510
+ queue "Kafka" as bus
511
+
512
+ web --> gw : HTTPS
513
+ gw --> auth
514
+ gw --> orders
515
+ auth --> db
516
+ orders --> db
517
+ orders --> bus : events
518
+ @enduml
519
+ """,
520
+ "activity": """@startuml
521
+ !theme materia
522
+ skinparam shadowing true
523
+ title Checkout
524
+
525
+ start
526
+ :Add items to cart;
527
+ if (Logged in?) then (yes)
528
+ :Load saved address;
529
+ else (no)
530
+ :Prompt sign-in;
531
+ endif
532
+ :Review order;
533
+ repeat
534
+ :Try payment;
535
+ repeat while (Payment failed?) is (retry)
536
+ ->success;
537
+ :Send confirmation email;
538
+ stop
539
+ @enduml
540
+ """,
541
+ "state": """@startuml
542
+ !theme superhero
543
+ skinparam shadowing true
544
+ title Order lifecycle
545
+
546
+ [*] --> Pending
547
+ Pending --> Paid : payment ok
548
+ Pending --> Cancelled : timeout
549
+ Paid --> Shipped : dispatch
550
+ Shipped --> Delivered : courier
551
+ Delivered --> [*]
552
+ Cancelled --> [*]
553
+ @enduml
554
+ """,
555
+ "usecase": """@startuml
556
+ !theme spacelab
557
+ skinparam shadowing true
558
+ left to right direction
559
+ title Shop use cases
560
+
561
+ actor Customer
562
+ actor Admin
563
+
564
+ rectangle Shop {
565
+ Customer --> (Browse catalog)
566
+ Customer --> (Place order)
567
+ Customer --> (Track order)
568
+ Admin --> (Manage products)
569
+ Admin --> (View reports)
570
+ }
571
+ @enduml
572
+ """,
573
+ "mindmap": """@startmindmap
574
+ !theme vibrant
575
+ * Project
576
+ ** Backend
577
+ *** API
578
+ *** Database
579
+ ** Frontend
580
+ *** Web
581
+ *** Mobile
582
+ ** Ops
583
+ *** CI/CD
584
+ *** Monitoring
585
+ @endmindmap
586
+ """,
587
+ "gantt": """@startgantt
588
+ !theme cerulean
589
+ project starts 2026-01-06
590
+ [Design] lasts 5 days
591
+ [Build] lasts 10 days
592
+ [Build] starts at [Design]'s end
593
+ [Test] lasts 4 days
594
+ [Test] starts at [Build]'s end
595
+ [Launch] happens at [Test]'s end
596
+ @endgantt
597
+ """,
598
+ "er": """@startuml
599
+ !theme reddress-lightblue
600
+ skinparam shadowing true
601
+ title ER diagram
602
+
603
+ entity User {
604
+ *id : UUID <<PK>>
605
+ --
606
+ email : String
607
+ created_at : DateTime
608
+ }
609
+ entity Order {
610
+ *id : UUID <<PK>>
611
+ --
612
+ user_id : UUID <<FK>>
613
+ total : Decimal
614
+ }
615
+ entity Item {
616
+ *id : UUID <<PK>>
617
+ --
618
+ order_id : UUID <<FK>>
619
+ name : String
620
+ }
621
+ User ||--o{ Order
622
+ Order ||--|{ Item
623
+ @enduml
624
+ """,
625
+ "json": """@startjson
626
+ !theme vibrant
627
+ {
628
+ "user": "binhna",
629
+ "roles": ["admin", "dev"],
630
+ "active": true,
631
+ "limits": { "cpu": 4, "ram_gb": 16 }
632
+ }
633
+ @endjson
634
+ """,
635
+ }
636
+
637
+
638
+ def do_new(kind, output, force, render, fmt):
639
+ if kind not in TEMPLATES:
640
+ raise click.ClickException(
641
+ f"Unknown template '{kind}'. Choose from: {', '.join(sorted(TEMPLATES))}"
642
+ )
643
+ out = Path(output) if output else Path(f"{kind}.puml")
644
+ if out.exists() and not force:
645
+ raise click.ClickException(f"{out} already exists. Pass --force to overwrite.")
646
+ out.write_text(TEMPLATES[kind], encoding="utf-8")
647
+ success(f"Created [accent]{out}[/accent] ({kind} template)")
648
+ info(f"Render it: [accent]evo plantuml render {out}[/accent]")
649
+ if render:
650
+ do_render(str(out), fmt, None, None, "auto", None, "UTF-8", True, False)
651
+
652
+
653
+ # command group -------------------------------------------------------------
654
+
655
+ @click.group("plantuml", epilog=EPILOG, context_settings={"help_option_names": ["-h", "--help"]})
656
+ def plantuml():
657
+ """Install, check and render **PlantUML** diagrams.
658
+
659
+ Downloads the official `plantuml.jar` into `~/.evo/plantuml` (no system
660
+ package needed), checks the runtime (Java + Graphviz), renders `.puml`
661
+ files to PNG/SVG/PDF and scaffolds vivid templates with `evo plantuml new`.
662
+
663
+ Run `evo plantuml <command> -h` for the options of each subcommand.
664
+ """
665
+
666
+
667
+ @plantuml.command("install")
668
+ @click.option("-r", "--release", default="latest", show_default=True,
669
+ help="PlantUML release tag, e.g. `v1.2026.6`, or `latest`.")
670
+ @click.option("-f", "--force", is_flag=True, help="Re-download even if plantuml.jar exists.")
671
+ @click.option("--with-deps", is_flag=True, help="Also install Java and Graphviz via the system package manager.")
672
+ @click.option("-y", "--yes", "assume_yes", is_flag=True, help="Assume yes for package-manager prompts.")
673
+ def install_cmd(release, force, with_deps, assume_yes):
674
+ """Download plantuml.jar and check the runtime.
675
+
676
+ The jar is fetched straight from the official GitHub releases. With
677
+ `--with-deps` it also installs **Java** and **Graphviz** using whichever
678
+ package manager it detects (apt, dnf, pacman, brew, winget, choco).
679
+ """
680
+ step("evo plantuml install")
681
+ do_install(release, force, with_deps, assume_yes)
682
+
683
+
684
+ @plantuml.command("check")
685
+ def check_cmd():
686
+ """Verify Java, Graphviz and plantuml.jar are working.
687
+
688
+ Prints the version of each component and runs PlantUML's own `-testdot`
689
+ so you know whether layout diagrams will use Graphviz or the Smetana
690
+ fallback.
691
+ """
692
+ step("evo plantuml check")
693
+ do_check()
694
+
695
+
696
+ @plantuml.command("render", epilog=EPILOG)
697
+ @click.argument("target", type=click.Path())
698
+ @click.option("-f", "--format", "fmt", type=click.Choice(FORMATS), default="png",
699
+ show_default=True, help="Output format.")
700
+ @click.option("-o", "--output", type=click.Path(), default=None,
701
+ help="Output directory (default: next to each source).")
702
+ @click.option("-t", "--theme", type=str, default=None, help="Apply a PlantUML theme to every diagram.")
703
+ @click.option("-e", "--engine", type=click.Choice(["auto", "dot", "smetana"]), default="auto",
704
+ show_default=True, help="Layout engine. `auto` uses Graphviz when available.")
705
+ @click.option("--scale", type=int, default=None, help="Output DPI (e.g. 150, 300) for raster formats.")
706
+ @click.option("--charset", default="UTF-8", show_default=True, help="Source charset.")
707
+ @click.option("--open", "do_open", is_flag=True, help="Open the first rendered file when done.")
708
+ @click.option("-w", "--watch", is_flag=True, help="Re-render automatically when sources change.")
709
+ def render_cmd(target, fmt, output, theme, engine, scale, charset, do_open, watch):
710
+ """Render a `.puml` file or a directory of them.
711
+
712
+ `TARGET` may be a single file or a folder (rendered recursively, matching
713
+ `*.puml`, `*.plantuml`, `*.pu`). Use `-t` to apply a theme, `-w` to watch
714
+ and re-render on save, and `--open` to preview the result.
715
+ """
716
+ step("evo plantuml render")
717
+ do_render(target, fmt, output, theme, engine, scale, charset, do_open, watch)
718
+
719
+
720
+ @plantuml.command("new")
721
+ @click.argument("kind", type=click.Choice(sorted(TEMPLATES)))
722
+ @click.option("-o", "--output", type=click.Path(), default=None, help="Output file (default: `<kind>.puml`).")
723
+ @click.option("-f", "--force", is_flag=True, help="Overwrite the file if it exists.")
724
+ @click.option("-r", "--render", is_flag=True, help="Render the new file right away.")
725
+ @click.option("--format", "fmt", type=click.Choice(FORMATS), default="png", show_default=True,
726
+ help="Format to use with --render.")
727
+ def new_cmd(kind, output, force, render, fmt):
728
+ """Scaffold a vivid, ready-to-edit diagram template.
729
+
730
+ `KIND` is one of: sequence, class, component, activity, state, usecase,
731
+ mindmap, gantt, er, json. Each template ships with a theme and styling so
732
+ the output looks good immediately.
733
+ """
734
+ step("evo plantuml new")
735
+ do_new(kind, output, force, render, fmt)
736
+
737
+
738
+ @plantuml.command("themes")
739
+ def themes_cmd():
740
+ """List the built-in PlantUML themes you can pass to `render -t`."""
741
+ step("evo plantuml themes")
742
+ table = Table(show_header=False, expand=False, box=None)
743
+ cols = 3
744
+ for i in range(0, len(THEMES), cols):
745
+ table.add_row(*[f"[cyan]{t}[/cyan]" for t in THEMES[i:i + cols]])
746
+ console.print(table)
747
+ console.print()
748
+ info(f"{len(THEMES)} themes. Use: [accent]evo plantuml render diagram.puml -t cerulean[/accent]")
749
+ console.print(Panel(
750
+ "Themes are also set inside a file with `!theme <name>` on its own line "
751
+ "right after `@startuml`.",
752
+ border_style="info", expand=False,
753
+ ))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: evo_cli
3
- Version: 0.4.0
3
+ Version: 0.5.1
4
4
  Summary: Evolution CLI - a developer toolbox for setting up dev machines
5
5
  Author: maycuatroi
6
6
  Project-URL: Homepage, https://github.com/maycuatroi/evo-cli
@@ -23,8 +23,10 @@ evo_cli/commands/gdrive.py
23
23
  evo_cli/commands/localproxy.py
24
24
  evo_cli/commands/miniconda.py
25
25
  evo_cli/commands/netcheck.py
26
+ evo_cli/commands/plantuml.py
26
27
  evo_cli/commands/site2s.py
27
28
  evo_cli/commands/ssh.py
28
29
  tests/__init__.py
29
30
  tests/test_cli.py
30
- tests/test_fix_claude.py
31
+ tests/test_fix_claude.py
32
+ tests/test_plantuml.py
@@ -0,0 +1,80 @@
1
+ from click.testing import CliRunner
2
+
3
+ from evo_cli.cli import cli
4
+ from evo_cli.commands import plantuml
5
+
6
+
7
+ def test_plantuml_registered():
8
+ assert "plantuml" in cli.commands
9
+
10
+
11
+ def test_plantuml_help_runs():
12
+ result = CliRunner().invoke(cli, ["plantuml", "--help"])
13
+ assert result.exit_code == 0
14
+ for sub in ("install", "check", "render", "new", "themes"):
15
+ assert sub in result.output
16
+
17
+
18
+ def test_pick_jar_asset_prefers_versioned_gpl_build():
19
+ assets = [
20
+ {"name": "plantuml-1.2026.6-javadoc.jar", "browser_download_url": "x"},
21
+ {"name": "plantuml-1.2026.6-sources.jar", "browser_download_url": "x"},
22
+ {"name": "plantuml-mit-1.2026.6.jar", "browser_download_url": "x"},
23
+ {"name": "plantuml-1.2026.6.jar", "browser_download_url": "good"},
24
+ {"name": "plantuml-1.2026.6.pdf", "browser_download_url": "x"},
25
+ ]
26
+ chosen = plantuml.pick_jar_asset(assets)
27
+ assert chosen["name"] == "plantuml-1.2026.6.jar"
28
+
29
+
30
+ def test_resolve_download_fallback_on_failure(monkeypatch):
31
+ def boom(_url):
32
+ raise OSError("no network")
33
+
34
+ monkeypatch.setattr(plantuml, "fetch_json", boom)
35
+ url, ver = plantuml.resolve_download("latest")
36
+ assert ver == plantuml.FALLBACK_VERSION
37
+ assert url.endswith(f"plantuml-{plantuml.FALLBACK_VERSION}.jar")
38
+
39
+
40
+ def test_templates_are_well_formed():
41
+ for kind, body in plantuml.TEMPLATES.items():
42
+ assert body.lstrip().startswith("@start")
43
+ assert "@end" in body
44
+
45
+
46
+ def test_new_creates_file(tmp_path):
47
+ out = tmp_path / "demo.puml"
48
+ result = CliRunner().invoke(cli, ["plantuml", "new", "sequence", "-o", str(out)])
49
+ assert result.exit_code == 0
50
+ assert out.exists()
51
+ assert out.read_text(encoding="utf-8").startswith("@startuml")
52
+
53
+
54
+ def test_new_rejects_existing_without_force(tmp_path):
55
+ out = tmp_path / "demo.puml"
56
+ out.write_text("keep", encoding="utf-8")
57
+ result = CliRunner().invoke(cli, ["plantuml", "new", "class", "-o", str(out)])
58
+ assert result.exit_code != 0
59
+ assert out.read_text(encoding="utf-8") == "keep"
60
+
61
+
62
+ def test_output_for_maps_extension(tmp_path):
63
+ src = tmp_path / "a.puml"
64
+ src.write_text("@startuml\n@enduml\n", encoding="utf-8")
65
+ assert plantuml.output_for(str(src), None, "svg").name == "a.svg"
66
+ assert plantuml.output_for(str(src), None, "latex").name == "a.tex"
67
+
68
+
69
+ def test_build_config_smetana(monkeypatch):
70
+ monkeypatch.setattr(plantuml, "find_dot", lambda: None)
71
+ path = plantuml.build_config("cerulean", "auto")
72
+ assert path is not None
73
+ import os
74
+
75
+ try:
76
+ content = open(path, encoding="utf-8").read()
77
+ assert "smetana" in content
78
+ assert "!theme cerulean" in content
79
+ finally:
80
+ os.unlink(path)
@@ -1 +0,0 @@
1
- 0.4.0
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes