evo-cli 0.22.0__tar.gz → 0.23.0__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 (130) hide show
  1. {evo_cli-0.22.0 → evo_cli-0.23.0}/PKG-INFO +4 -1
  2. evo_cli-0.23.0/evo_cli/VERSION +1 -0
  3. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/cli.py +2 -0
  4. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/gdrive.py +238 -2
  5. evo_cli-0.23.0/evo_cli/commands/image.py +293 -0
  6. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/credentials/registry.py +1 -1
  7. evo_cli-0.23.0/evo_cli/imaging/__init__.py +28 -0
  8. evo_cli-0.23.0/evo_cli/imaging/align.py +138 -0
  9. evo_cli-0.23.0/evo_cli/imaging/core.py +456 -0
  10. evo_cli-0.23.0/evo_cli/imaging/creds.py +31 -0
  11. evo_cli-0.23.0/evo_cli/imaging/errors.py +2 -0
  12. evo_cli-0.23.0/evo_cli/imaging/gemini.py +123 -0
  13. evo_cli-0.23.0/evo_cli/imaging/ncnn.py +187 -0
  14. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli.egg-info/PKG-INFO +4 -1
  15. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli.egg-info/SOURCES.txt +10 -0
  16. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli.egg-info/requires.txt +4 -0
  17. {evo_cli-0.22.0 → evo_cli-0.23.0}/pyproject.toml +4 -0
  18. evo_cli-0.23.0/tests/test_gdrive.py +95 -0
  19. evo_cli-0.23.0/tests/test_image.py +1416 -0
  20. evo_cli-0.22.0/evo_cli/VERSION +0 -1
  21. {evo_cli-0.22.0 → evo_cli-0.23.0}/Containerfile +0 -0
  22. {evo_cli-0.22.0 → evo_cli-0.23.0}/HISTORY.md +0 -0
  23. {evo_cli-0.22.0 → evo_cli-0.23.0}/LICENSE +0 -0
  24. {evo_cli-0.22.0 → evo_cli-0.23.0}/MANIFEST.in +0 -0
  25. {evo_cli-0.22.0 → evo_cli-0.23.0}/README.md +0 -0
  26. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/__init__.py +0 -0
  27. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/__main__.py +0 -0
  28. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/base.py +0 -0
  29. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/__init__.py +0 -0
  30. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/agent_toy.py +0 -0
  31. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/claude_code.py +0 -0
  32. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/cloudflare.py +0 -0
  33. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/cred.py +0 -0
  34. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/download.py +0 -0
  35. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/fix_claude.py +0 -0
  36. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/gh.py +0 -0
  37. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/harness/__init__.py +0 -0
  38. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/harness/_dag.py +0 -0
  39. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/harness/_git.py +0 -0
  40. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/harness/_model.py +0 -0
  41. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/harness/_mutate.py +0 -0
  42. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/harness/_paths.py +0 -0
  43. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/harness/_render.py +0 -0
  44. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/harness/_server.py +0 -0
  45. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/harness/check.py +0 -0
  46. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/harness/clone.py +0 -0
  47. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/harness/edit.py +0 -0
  48. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/harness/pull.py +0 -0
  49. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/harness/serve.py +0 -0
  50. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/harness/show.py +0 -0
  51. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/harness/views.py +0 -0
  52. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/harness/web/assets/index-CsALFg16.css +0 -0
  53. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/harness/web/assets/index-DwEXL5pV.js +0 -0
  54. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/harness/web/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  55. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/harness/web/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  56. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/harness/web/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  57. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/harness/web/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  58. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/harness/web/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  59. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/harness/web/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  60. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/harness/web/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  61. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  62. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  63. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  64. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  65. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  66. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/harness/web/index.html +0 -0
  67. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/hwid.py +0 -0
  68. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/hwid_reset.py +0 -0
  69. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/localproxy.py +0 -0
  70. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/mcp.py +0 -0
  71. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/miniconda.py +0 -0
  72. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/netcheck.py +0 -0
  73. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/opencode.py +0 -0
  74. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/plantuml.py +0 -0
  75. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/serp.py +0 -0
  76. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/site2s.py +0 -0
  77. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/ssh.py +0 -0
  78. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/sysmon.py +0 -0
  79. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/tts.py +0 -0
  80. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/update.py +0 -0
  81. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/wifi.py +0 -0
  82. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/console.py +0 -0
  83. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/credentials/__init__.py +0 -0
  84. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/credentials/doctor.py +0 -0
  85. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/credentials/google_oauth.py +0 -0
  86. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/credentials/migrate.py +0 -0
  87. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/credentials/oauth_flow.py +0 -0
  88. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/credentials/store.py +0 -0
  89. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/credentials/sync.py +0 -0
  90. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/mcp_registry.py +0 -0
  91. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/serp/__init__.py +0 -0
  92. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/serp/client.py +0 -0
  93. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/serp/creds.py +0 -0
  94. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/serp/errors.py +0 -0
  95. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/serp/install.py +0 -0
  96. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/serp/render.py +0 -0
  97. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/tts/__init__.py +0 -0
  98. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/tts/chunking.py +0 -0
  99. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/tts/core.py +0 -0
  100. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/tts/creds.py +0 -0
  101. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/tts/errors.py +0 -0
  102. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/tts/gemini.py +0 -0
  103. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/tts/http.py +0 -0
  104. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/tts/openai.py +0 -0
  105. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/tts/player.py +0 -0
  106. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/tts/vbee.py +0 -0
  107. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli.egg-info/dependency_links.txt +0 -0
  108. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli.egg-info/entry_points.txt +0 -0
  109. {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli.egg-info/top_level.txt +0 -0
  110. {evo_cli-0.22.0 → evo_cli-0.23.0}/setup.cfg +0 -0
  111. {evo_cli-0.22.0 → evo_cli-0.23.0}/tests/__init__.py +0 -0
  112. {evo_cli-0.22.0 → evo_cli-0.23.0}/tests/test_agent_toy.py +0 -0
  113. {evo_cli-0.22.0 → evo_cli-0.23.0}/tests/test_claude_code.py +0 -0
  114. {evo_cli-0.22.0 → evo_cli-0.23.0}/tests/test_cli.py +0 -0
  115. {evo_cli-0.22.0 → evo_cli-0.23.0}/tests/test_console.py +0 -0
  116. {evo_cli-0.22.0 → evo_cli-0.23.0}/tests/test_cred.py +0 -0
  117. {evo_cli-0.22.0 → evo_cli-0.23.0}/tests/test_download.py +0 -0
  118. {evo_cli-0.22.0 → evo_cli-0.23.0}/tests/test_fix_claude.py +0 -0
  119. {evo_cli-0.22.0 → evo_cli-0.23.0}/tests/test_gh.py +0 -0
  120. {evo_cli-0.22.0 → evo_cli-0.23.0}/tests/test_harness.py +0 -0
  121. {evo_cli-0.22.0 → evo_cli-0.23.0}/tests/test_harness_clone.py +0 -0
  122. {evo_cli-0.22.0 → evo_cli-0.23.0}/tests/test_harness_dag.py +0 -0
  123. {evo_cli-0.22.0 → evo_cli-0.23.0}/tests/test_mcp.py +0 -0
  124. {evo_cli-0.22.0 → evo_cli-0.23.0}/tests/test_opencode.py +0 -0
  125. {evo_cli-0.22.0 → evo_cli-0.23.0}/tests/test_plantuml.py +0 -0
  126. {evo_cli-0.22.0 → evo_cli-0.23.0}/tests/test_serp.py +0 -0
  127. {evo_cli-0.22.0 → evo_cli-0.23.0}/tests/test_sysmon.py +0 -0
  128. {evo_cli-0.22.0 → evo_cli-0.23.0}/tests/test_tts.py +0 -0
  129. {evo_cli-0.22.0 → evo_cli-0.23.0}/tests/test_update.py +0 -0
  130. {evo_cli-0.22.0 → evo_cli-0.23.0}/tests/test_wifi.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: evo_cli
3
- Version: 0.22.0
3
+ Version: 0.23.0
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
@@ -19,6 +19,9 @@ Requires-Dist: paramiko>=2.7.0
19
19
  Requires-Dist: PyYAML>=6.0
20
20
  Requires-Dist: rich>=13.0
21
21
  Requires-Dist: rich-click>=1.8
22
+ Provides-Extra: image
23
+ Requires-Dist: Pillow>=10.0; extra == "image"
24
+ Requires-Dist: numpy>=1.24; extra == "image"
22
25
  Provides-Extra: test
23
26
  Requires-Dist: pytest; extra == "test"
24
27
  Requires-Dist: pytest-cov; extra == "test"
@@ -0,0 +1 @@
1
+ 0.23.0
@@ -12,6 +12,7 @@ from evo_cli.commands.gh import setup_gh
12
12
  from evo_cli.commands.harness import harness_group
13
13
  from evo_cli.commands.hwid import hwid
14
14
  from evo_cli.commands.hwid_reset import hwid_reset
15
+ from evo_cli.commands.image import image_group
15
16
  from evo_cli.commands.localproxy import localproxy
16
17
  from evo_cli.commands.mcp import mcp_group
17
18
  from evo_cli.commands.miniconda import miniconda
@@ -70,6 +71,7 @@ cli.add_command(gdrive)
70
71
  cli.add_command(harness_group)
71
72
  cli.add_command(hwid)
72
73
  cli.add_command(hwid_reset)
74
+ cli.add_command(image_group)
73
75
  cli.add_command(site2s)
74
76
  cli.add_command(localproxy)
75
77
  cli.add_command(mcp_group)
@@ -1,18 +1,30 @@
1
1
  import base64
2
+ import html
3
+ import http.cookiejar
2
4
  import json
3
5
  import mimetypes
4
6
  import re
7
+ import shutil
5
8
  import sys
6
9
  import urllib.error
7
10
  import urllib.parse
8
11
  import urllib.request
12
+ import zipfile
9
13
  from datetime import datetime, timezone
10
14
  from pathlib import Path
11
15
 
12
16
  import rich_click as click
17
+ from rich.progress import (
18
+ BarColumn,
19
+ DownloadColumn,
20
+ Progress,
21
+ TextColumn,
22
+ TimeRemainingColumn,
23
+ TransferSpeedColumn,
24
+ )
13
25
  from rich.text import Text
14
26
 
15
- from evo_cli.console import error, info, step, success, warning
27
+ from evo_cli.console import console, error, info, step, success, warning
16
28
  from evo_cli.credentials import google_oauth
17
29
  from evo_cli.credentials.registry import dig, load_entries
18
30
  from evo_cli.credentials.store import compile_flat
@@ -22,8 +34,15 @@ TOKEN_URL = "https://oauth2.googleapis.com/token"
22
34
  DOCS_API = "https://docs.googleapis.com/v1/documents/{doc_id}"
23
35
  DRIVE_FILE_API = "https://www.googleapis.com/drive/v3/files/{file_id}"
24
36
  DRIVE_EXPORT_API = "https://www.googleapis.com/drive/v3/files/{file_id}/export"
37
+ DRIVE_UC_DOWNLOAD = "https://drive.google.com/uc?export=download&id={file_id}"
38
+ DOWNLOAD_CHUNK = 1 << 20
25
39
 
26
40
  DOC_ID_RE = re.compile(r"/document/d/([a-zA-Z0-9_-]+)")
41
+ FILE_ID_RE = re.compile(r"/file/d/([a-zA-Z0-9_-]+)")
42
+ QUERY_ID_RE = re.compile(r"[?&]id=([a-zA-Z0-9_-]+)")
43
+ CONFIRM_ACTION_RE = re.compile(r"<form[^>]*id=\"download-form\"[^>]*action=\"([^\"]+)\"", re.IGNORECASE)
44
+ CONFIRM_FIELD_RE = re.compile(r"<input[^>]*type=\"hidden\"[^>]*name=\"([^\"]+)\"[^>]*value=\"([^\"]*)\"", re.IGNORECASE)
45
+ UNSAFE_NAME_RE = re.compile(r"[\\/:*?\"<>|]+")
27
46
  RAW_ID_RE = re.compile(r"^[a-zA-Z0-9_-]{20,}$")
28
47
  MD_INLINE_IMAGE_RE = re.compile(r"!\[([^\]]*)\]\((https?://[^)\s]+)\)")
29
48
  MD_REF_IMAGE_RE = re.compile(r"!\[([^\]]*)\]\[([^\]]+)\]")
@@ -39,6 +58,16 @@ EPILOG = Text.from_markup(
39
58
  " [cyan]evo gdrive doc-read <url> --raw[/cyan] also dump the raw API response"
40
59
  )
41
60
 
61
+ GET_EPILOG = Text.from_markup(
62
+ "[bold]Examples[/bold]\n\n"
63
+ " [cyan]evo gdrive get <url>[/cyan] save into the current directory\n"
64
+ " [cyan]evo gdrive get <url> -o ./out[/cyan] save into ./out\n"
65
+ " [cyan]evo gdrive get <id> --unzip[/cyan] extract the archive next to it\n"
66
+ " [cyan]evo gdrive get <url> --name data.zip[/cyan] override the name Drive sends\n\n"
67
+ "[dim]Large files get a virus-scan interstitial instead of bytes; this command\n"
68
+ "follows it with the confirm token, which is why `evo download` cannot fetch them.[/dim]"
69
+ )
70
+
42
71
 
43
72
  def extract_doc_id(value):
44
73
  match = DOC_ID_RE.search(value)
@@ -49,6 +78,16 @@ def extract_doc_id(value):
49
78
  return None
50
79
 
51
80
 
81
+ def extract_file_id(value):
82
+ for pattern in (FILE_ID_RE, DOC_ID_RE, QUERY_ID_RE):
83
+ match = pattern.search(value)
84
+ if match:
85
+ return match.group(1)
86
+ if RAW_ID_RE.match(value):
87
+ return value
88
+ return None
89
+
90
+
52
91
  def drive_entry():
53
92
  for path, entry in load_entries():
54
93
  if entry.get("id") == DRIVE_SERVICE_ID and entry.get("oauth"):
@@ -447,9 +486,168 @@ def run_doc_read(target, out_dir, no_images, raw, via_docs_api):
447
486
  success(f"Wrote [accent]{md_path}[/accent] ({len(markdown)} bytes)")
448
487
 
449
488
 
489
+ def optional_access_token():
490
+ try:
491
+ return access_token()
492
+ except click.ClickException:
493
+ return None
494
+
495
+
496
+ def drive_opener():
497
+ return urllib.request.build_opener(urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()))
498
+
499
+
500
+ def open_drive_url(opener, url, token=None, timeout=120):
501
+ req = urllib.request.Request(url, method="GET")
502
+ req.add_header("User-Agent", "evo-cli")
503
+ if token:
504
+ req.add_header("Authorization", f"Bearer {token}")
505
+ return opener.open(req, timeout=timeout)
506
+
507
+
508
+ def is_html_response(response):
509
+ return "text/html" in (response.headers.get("Content-Type") or "").lower()
510
+
511
+
512
+ def parse_confirm_form(body):
513
+ match = CONFIRM_ACTION_RE.search(body)
514
+ if not match:
515
+ return None, {}
516
+ fields = {name: html.unescape(value) for name, value in CONFIRM_FIELD_RE.findall(body)}
517
+ return html.unescape(match.group(1)), fields
518
+
519
+
520
+ def open_drive_download(file_id, token):
521
+ opener = drive_opener()
522
+ response = open_drive_url(opener, DRIVE_UC_DOWNLOAD.format(file_id=file_id), token=token)
523
+ if not is_html_response(response):
524
+ return response
525
+ body = response.read().decode("utf-8", "replace")
526
+ response.close()
527
+ action, fields = parse_confirm_form(body)
528
+ if not action:
529
+ return None
530
+ info("Virus-scan interstitial - re-issuing with the confirm token.")
531
+ response = open_drive_url(opener, action + "?" + urllib.parse.urlencode(fields), token=token)
532
+ if is_html_response(response):
533
+ response.close()
534
+ return None
535
+ return response
536
+
537
+
538
+ def open_drive_file(file_id):
539
+ token = optional_access_token()
540
+ if token:
541
+ try:
542
+ response = open_drive_download(file_id, token)
543
+ except urllib.error.HTTPError as exc:
544
+ warning(f"Drive OAuth path failed: HTTP {exc.code}.")
545
+ response = None
546
+ if response is not None:
547
+ return response
548
+ info("Falling back to the anonymous public download path.")
549
+ try:
550
+ response = open_drive_download(file_id, None)
551
+ except urllib.error.HTTPError as exc:
552
+ raise click.ClickException(f"download failed: HTTP {exc.code} {exc.reason}") from exc
553
+ if response is None:
554
+ raise click.ClickException(
555
+ "Drive answered with HTML instead of file bytes. The file may be private, "
556
+ "over its download quota, or removed."
557
+ )
558
+ return response
559
+
560
+
561
+ def safe_download_name(name):
562
+ cleaned = UNSAFE_NAME_RE.sub("_", name).strip().strip(".")
563
+ return cleaned or "download.bin"
564
+
565
+
566
+ def stream_to_file(response, dest):
567
+ total = int(response.headers.get("Content-Length") or 0)
568
+ columns = [
569
+ TextColumn("[info]{task.description}[/info]"),
570
+ BarColumn(),
571
+ DownloadColumn(),
572
+ TransferSpeedColumn(),
573
+ TimeRemainingColumn(),
574
+ ]
575
+ with Progress(*columns, console=console) as progress:
576
+ task = progress.add_task(dest.name, total=total or None)
577
+ with open(dest, "wb") as handle:
578
+ while True:
579
+ chunk = response.read(DOWNLOAD_CHUNK)
580
+ if not chunk:
581
+ break
582
+ handle.write(chunk)
583
+ progress.update(task, advance=len(chunk))
584
+ return dest
585
+
586
+
587
+ def skip_archive_member(name):
588
+ parts = [part for part in name.replace("\\", "/").split("/") if part]
589
+ if not parts:
590
+ return True
591
+ return "__MACOSX" in parts or parts[-1] == ".DS_Store"
592
+
593
+
594
+ def safe_member_path(dest_dir, name):
595
+ root = dest_dir.resolve()
596
+ target = (dest_dir / name).resolve()
597
+ if target != root and root not in target.parents:
598
+ return None
599
+ return target
600
+
601
+
602
+ def extract_archive(archive, dest_dir):
603
+ if not zipfile.is_zipfile(archive):
604
+ warning(f"{archive.name} is not a zip archive - skipping --unzip.")
605
+ return 0
606
+ written = 0
607
+ with zipfile.ZipFile(archive) as bundle:
608
+ for member in bundle.infolist():
609
+ if member.is_dir() or skip_archive_member(member.filename):
610
+ continue
611
+ target = safe_member_path(dest_dir, member.filename)
612
+ if target is None:
613
+ warning(f"Skipping archive entry outside the output folder: {member.filename}")
614
+ continue
615
+ target.parent.mkdir(parents=True, exist_ok=True)
616
+ with bundle.open(member) as source, open(target, "wb") as handle:
617
+ shutil.copyfileobj(source, handle, DOWNLOAD_CHUNK)
618
+ written += 1
619
+ return written
620
+
621
+
622
+ def run_get(target, out_dir, unzip, name):
623
+ file_id = extract_file_id(target)
624
+ if not file_id:
625
+ raise click.ClickException("could not extract a Google Drive file ID from input.")
626
+ info(f"File ID: [accent]{file_id}[/accent]")
627
+
628
+ dest_dir = Path(out_dir) if out_dir else Path.cwd()
629
+ dest_dir.mkdir(parents=True, exist_ok=True)
630
+
631
+ response = open_drive_file(file_id)
632
+ try:
633
+ filename = name or parse_filename_from_disposition(dict(response.headers)) or f"{file_id}.bin"
634
+ dest = dest_dir / safe_download_name(filename)
635
+ info(f"Saving to [accent]{dest}[/accent]")
636
+ stream_to_file(response, dest)
637
+ finally:
638
+ response.close()
639
+ success(f"Downloaded [accent]{dest.name}[/accent] ({dest.stat().st_size} bytes)")
640
+
641
+ if unzip:
642
+ step("Extract archive")
643
+ written = extract_archive(dest, dest_dir)
644
+ if written:
645
+ success(f"Extracted [accent]{written}[/accent] files into [accent]{dest_dir}[/accent]")
646
+
647
+
450
648
  @click.group("gdrive")
451
649
  def gdrive():
452
- """**Google Drive** helpers. Read Google Docs (text + images) from URL or ID."""
650
+ """**Google Drive** helpers. Read Google Docs and download files from URL or ID."""
453
651
 
454
652
 
455
653
  @gdrive.command("doc-read", epilog=EPILOG)
@@ -482,3 +680,41 @@ def doc_read(target, out_dir, no_images, raw, via_docs_api):
482
680
  except Exception as exc:
483
681
  error(str(exc))
484
682
  sys.exit(1)
683
+
684
+
685
+ @gdrive.command(
686
+ "get",
687
+ epilog=GET_EPILOG,
688
+ context_settings={"help_option_names": ["-h", "--help"]},
689
+ help=(
690
+ "Download a Drive file from `TARGET` (share URL or raw file ID).\n\n"
691
+ "Google answers the download endpoint for a large file with an HTML "
692
+ "virus-scan interstitial instead of bytes; this command re-issues the "
693
+ "request with the confirm token and uuid that form carries, which is why "
694
+ "`evo download` cannot fetch these URLs. The stored `google_drive` OAuth "
695
+ "token is tried first, then the anonymous public path, so a private file "
696
+ "you do own still works.\n\n"
697
+ "With `--unzip` the archive is extracted next to it, skipping `__MACOSX/` "
698
+ "and `.DS_Store`."
699
+ ),
700
+ )
701
+ @click.argument("target")
702
+ @click.option(
703
+ "-o",
704
+ "--output",
705
+ "out_dir",
706
+ type=click.Path(file_okay=False),
707
+ default=None,
708
+ help="Output folder. Default: the current directory.",
709
+ )
710
+ @click.option("--unzip", is_flag=True, help="Extract the downloaded archive into the output folder.")
711
+ @click.option("--name", default=None, metavar="FILENAME", help="Save under this name instead of the one Drive sends.")
712
+ def get_cmd(target, out_dir, unzip, name):
713
+ step("evo gdrive get")
714
+ try:
715
+ run_get(target, out_dir, unzip, name)
716
+ except click.ClickException:
717
+ raise
718
+ except Exception as exc:
719
+ error(str(exc))
720
+ sys.exit(1)
@@ -0,0 +1,293 @@
1
+ import ctypes.util
2
+ import importlib.metadata
3
+ import os
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ import rich_click as click
8
+ from rich.markup import escape
9
+ from rich.table import Table
10
+ from rich.text import Text
11
+
12
+ from evo_cli.console import console, info, step, success, warning
13
+ from evo_cli.imaging import core, load_numpy, load_pillow, ncnn
14
+ from evo_cli.imaging.creds import has_gemini_credentials
15
+ from evo_cli.imaging.errors import ImagingError
16
+
17
+ OUTPUT_CHOICES = ("master", "ui", "both")
18
+ PROVIDER_CHOICES = ("auto", "gemini", "ncnn")
19
+ DEFAULT_OUT_DIR = "image-out"
20
+ IMAGE_EXTRA_FIX = "run `pip install evo_cli[image]`"
21
+ BINARY_FIX = "run `evo image install`"
22
+ MODEL_FIX = f"`evo image install` fetches {ncnn.DEFAULT_MODEL}"
23
+ VULKAN_FIX = "install the GPU driver / Vulkan runtime the local engine needs"
24
+ GEMINI_FIX = "run `evo cred add gemini_api_key --from-stdin`"
25
+
26
+ EPILOG = Text.from_markup(
27
+ "[bold]Examples[/bold]\n\n"
28
+ " [cyan]evo image upscale hero.png[/cyan] one file into ./image-out\n"
29
+ " [cyan]evo image upscale ./in -o ./out[/cyan] master + ui + report.json\n"
30
+ " [cyan]evo image upscale ./in -o ./out --dry-run[/cyan] count what is cached\n"
31
+ " [cyan]evo image upscale ./in --provider ncnn[/cyan] force the local engine\n"
32
+ " [cyan]evo image upscale ./in --only ui --scale 0.5[/cyan] half-size UI set\n"
33
+ " [cyan]evo image upscale ./in --force[/cyan] ignore the cache\n"
34
+ " [cyan]evo image check[/cyan] is every moving part there?\n"
35
+ " [cyan]evo image install[/cyan] fetch upscayl-bin + model\n\n"
36
+ "[dim]Gemini renders when its key is stored, upscayl-ncnn otherwise.\n"
37
+ "Every render is cached by content hash, so a second run is free.[/dim]"
38
+ )
39
+
40
+ UPSCALE_EPILOG = Text.from_markup(
41
+ "[bold]Examples[/bold]\n\n"
42
+ " [cyan]evo image upscale ./in -o ./out[/cyan] master + ui + report.json\n"
43
+ " [cyan]evo image upscale ./in -o ./out --dry-run[/cyan] prints `cached: <n>`\n"
44
+ " [cyan]evo image upscale ./in --model remacri-4x[/cyan] pick the local model\n"
45
+ " [cyan]evo image upscale card.png --only master[/cyan] skip the UI set\n\n"
46
+ "[dim]`master` keeps the rendered resolution, `ui` lands on the size the\n"
47
+ "filename declares (`name_256x256.png`), or the preset ratio otherwise.[/dim]"
48
+ )
49
+
50
+
51
+ def pillow_version():
52
+ try:
53
+ load_pillow()
54
+ except ImagingError:
55
+ return None
56
+ try:
57
+ return importlib.metadata.version("pillow")
58
+ except importlib.metadata.PackageNotFoundError:
59
+ return "installed"
60
+
61
+
62
+ def numpy_version():
63
+ try:
64
+ numpy = load_numpy()
65
+ except ImagingError:
66
+ return None
67
+ return getattr(numpy, "__version__", "installed")
68
+
69
+
70
+ def vulkan_loader():
71
+ if os.name == "nt":
72
+ names = ("vulkan-1",)
73
+ elif sys.platform == "darwin":
74
+ names = ("vulkan", "MoltenVK")
75
+ else:
76
+ names = ("vulkan",)
77
+ for name in names:
78
+ found = ctypes.util.find_library(name)
79
+ if found:
80
+ return found
81
+ return None
82
+
83
+
84
+ def installed_models():
85
+ root = ncnn.models_dir()
86
+ if not root.is_dir():
87
+ return []
88
+ return [name for name in ncnn.MODELS if all((root / f"{name}.{ext}").is_file() for ext in ncnn.MODEL_EXTS)]
89
+
90
+
91
+ def environment_parts():
92
+ binary = ncnn.find_binary()
93
+ models = installed_models()
94
+ return [
95
+ ("Pillow", pillow_version(), IMAGE_EXTRA_FIX),
96
+ ("numpy", numpy_version(), IMAGE_EXTRA_FIX),
97
+ ("upscayl-bin", str(binary) if binary else None, BINARY_FIX),
98
+ ("Models", ", ".join(models) if ncnn.DEFAULT_MODEL in models else None, MODEL_FIX),
99
+ ("Vulkan", vulkan_loader(), VULKAN_FIX),
100
+ ("Gemini key", "gemini_api_key" if has_gemini_credentials() else None, GEMINI_FIX),
101
+ ]
102
+
103
+
104
+ def report_environment():
105
+ console.print()
106
+ table = Table(show_header=True, header_style="accent", expand=False)
107
+ table.add_column("Component", style="info", no_wrap=True)
108
+ table.add_column("Status", no_wrap=True)
109
+ table.add_column("Detail", style="dim")
110
+ missing = []
111
+ for name, detail, fix in environment_parts():
112
+ if detail:
113
+ table.add_row(name, "[success]ok[/success]", escape(str(detail)))
114
+ else:
115
+ missing.append(name)
116
+ table.add_row(name, "[error]missing[/error]", fix)
117
+ table.add_row("Cache", "[info]-[/info]", escape(str(core.cache_root())))
118
+ console.print(table)
119
+ return missing
120
+
121
+
122
+ def build_settings(preset_name, provider, model, scale, only):
123
+ return core.preset(
124
+ preset_name,
125
+ provider=core.resolve_provider(provider),
126
+ model=model,
127
+ master_scale=scale,
128
+ outputs=list(core.OUTPUT_SETS) if only == "both" else [only],
129
+ )
130
+
131
+
132
+ @click.group(
133
+ "image",
134
+ epilog=EPILOG,
135
+ context_settings={"help_option_names": ["-h", "--help"]},
136
+ help=(
137
+ "Upscale images with **Gemini** or **upscayl-ncnn**.\n\n"
138
+ "`upscale` walks a file or a whole tree and writes two PNG sets - `master` at the "
139
+ "rendered resolution and `ui` at the size each filename declares - plus a "
140
+ "`report.json` recording which engine shipped every image. Renders are cached by "
141
+ "content hash, so re-running over the same tree is free.\n\n"
142
+ "`check` says which moving part is missing, `install` fetches the local engine."
143
+ ),
144
+ )
145
+ def image_group():
146
+ pass
147
+
148
+
149
+ @image_group.command(
150
+ "upscale",
151
+ epilog=UPSCALE_EPILOG,
152
+ help=(
153
+ "Upscale `SOURCE` - one image or a directory tree.\n\n"
154
+ "The output tree mirrors the input under `<out>/master` and `<out>/ui`, keeping "
155
+ "alpha where the source had it, and `<out>/report.json` lists the engine, the "
156
+ "sizes and the timings per image."
157
+ ),
158
+ )
159
+ @click.argument("source", type=click.Path(exists=True))
160
+ @click.option(
161
+ "-o",
162
+ "--output",
163
+ "out_dir",
164
+ type=click.Path(file_okay=False),
165
+ default=DEFAULT_OUT_DIR,
166
+ show_default=True,
167
+ help="Where `master/`, `ui/` and `report.json` land.",
168
+ )
169
+ @click.option(
170
+ "--preset",
171
+ "preset_name",
172
+ type=click.Choice(sorted(core.PRESETS)),
173
+ default=core.DEFAULT_PRESET,
174
+ show_default=True,
175
+ help="Measured defaults to start from.",
176
+ )
177
+ @click.option(
178
+ "--provider",
179
+ type=click.Choice(PROVIDER_CHOICES),
180
+ default="auto",
181
+ show_default=True,
182
+ help="`auto` picks Gemini when its key is stored, else the local engine.",
183
+ )
184
+ @click.option(
185
+ "--model",
186
+ type=click.Choice(ncnn.MODELS),
187
+ default=None,
188
+ help=f"Local upscayl model. Default: `{ncnn.DEFAULT_MODEL}`.",
189
+ )
190
+ @click.option("--scale", type=float, default=None, help="Master size relative to the source. Default: `1.0`.")
191
+ @click.option(
192
+ "--only",
193
+ type=click.Choice(OUTPUT_CHOICES),
194
+ default="both",
195
+ show_default=True,
196
+ help="Write just one of the two output sets.",
197
+ )
198
+ @click.option("-j", "--jobs", type=int, default=4, show_default=True, help="Images in flight at once.")
199
+ @click.option("--dry-run", is_flag=True, help="Report what the cache already covers and write nothing.")
200
+ @click.option("--force", is_flag=True, help="Render again even when the cache has the image.")
201
+ def upscale_cmd(source, out_dir, preset_name, provider, model, scale, only, jobs, dry_run, force):
202
+ step("evo image upscale")
203
+ try:
204
+ settings = build_settings(preset_name, provider, model, scale, only)
205
+ sources = core.find_sources(source)
206
+ except ImagingError as exc:
207
+ raise click.ClickException(str(exc)) from exc
208
+
209
+ info(
210
+ f"{len(sources)} image(s), engine [accent]{settings['provider']}[/accent], "
211
+ f"sets [accent]{', '.join(settings['outputs'])}[/accent]"
212
+ )
213
+
214
+ if dry_run:
215
+ results = core.process_many(
216
+ sources, out_dir, root=source, settings=settings, jobs=jobs, force=force, dry_run=True
217
+ )
218
+ ready = sum(1 for item in results if item.get("cached"))
219
+ info(f"Dry run: {len(results)} images, cached: {ready}, to render: {len(results) - ready}")
220
+ return
221
+
222
+ done = []
223
+
224
+ def on_item(entry):
225
+ done.append(entry)
226
+ state = entry.get("error") or ("cached" if entry.get("cached") else entry.get("engine") or "done")
227
+ info(f"{len(done)}/{len(sources)} {escape(str(entry.get('file')))} - {escape(str(state))}")
228
+
229
+ results = core.process_many(
230
+ sources, out_dir, root=source, settings=settings, jobs=jobs, force=force, on_item=on_item
231
+ )
232
+ failed = [item for item in results if item and item.get("error")]
233
+ cached = sum(1 for item in results if item and item.get("cached"))
234
+ for item in failed:
235
+ warning(f"{escape(str(item.get('file')))}: {escape(str(item['error']))}")
236
+ info(f"cached: {cached}, rendered: {len(results) - cached - len(failed)}, failed: {len(failed)}")
237
+ if failed:
238
+ raise click.ClickException(f"{len(failed)} of {len(results)} images failed.")
239
+ success(
240
+ f"Wrote {', '.join(settings['outputs'])} + {core.REPORT_NAME} "
241
+ f"under [accent]{escape(str(Path(out_dir)))}[/accent]"
242
+ )
243
+
244
+
245
+ @image_group.command(
246
+ "check",
247
+ help=(
248
+ "Verify every moving part: Pillow, numpy, the upscayl binary, the model files, "
249
+ "a Vulkan device and the Gemini key. Exits non-zero when one is missing."
250
+ ),
251
+ )
252
+ def check_cmd():
253
+ step("evo image check")
254
+ missing = report_environment()
255
+ if missing:
256
+ raise click.ClickException(
257
+ f"missing: {', '.join(missing)}. "
258
+ "Run `evo image install` for the local engine, "
259
+ "`evo cred add gemini_api_key --from-stdin` for the hosted one."
260
+ )
261
+ success("Ready to upscale.")
262
+
263
+
264
+ @image_group.command(
265
+ "install",
266
+ help=(
267
+ "Fetch **upscayl-bin** and the default model ahead of time.\n\n"
268
+ "Everything lands under `~/.evo/upscayl` (or `$EVO_UPSCAYL_DIR`), so the first "
269
+ "`evo image upscale` does not stop to download."
270
+ ),
271
+ )
272
+ @click.option(
273
+ "-m",
274
+ "--model",
275
+ "models",
276
+ type=click.Choice(ncnn.MODELS),
277
+ multiple=True,
278
+ help=f"Model to fetch; repeatable. Default: `{ncnn.DEFAULT_MODEL}`.",
279
+ )
280
+ @click.option("--all-models", is_flag=True, help="Fetch every model upscayl ships.")
281
+ def install_cmd(models, all_models):
282
+ step("evo image install")
283
+ wanted = list(ncnn.MODELS) if all_models else list(models) or [ncnn.DEFAULT_MODEL]
284
+ try:
285
+ binary = ncnn.ensure_binary()
286
+ info(f"upscayl-bin at [accent]{escape(str(binary))}[/accent]")
287
+ for name in wanted:
288
+ ncnn.ensure_model(name)
289
+ info(f"model [accent]{name}[/accent] ready")
290
+ except ImagingError as exc:
291
+ raise click.ClickException(str(exc)) from exc
292
+ report_environment()
293
+ success("Local engine ready.")
@@ -78,7 +78,7 @@ SPECS = [
78
78
  "category": "ai",
79
79
  "type": "api_key",
80
80
  "lifetime": "stable",
81
- "description": "Gemini API key (evo tts gemini provider, generateContent)",
81
+ "description": "Gemini API key (evo tts gemini provider, evo image upscale, generateContent)",
82
82
  "rotate": "https://aistudio.google.com/apikey -> create or rotate the key",
83
83
  "keys": ["gemini_api_key"],
84
84
  },
@@ -0,0 +1,28 @@
1
+ from evo_cli.imaging.errors import ImagingError
2
+
3
+ IMAGE_EXTRA_HINT = "Pillow and numpy are required for image work.\nInstall them with:\n pip install evo_cli[image]"
4
+
5
+
6
+ def load_pillow():
7
+ try:
8
+ from PIL import Image
9
+ except ImportError as exc:
10
+ raise ImagingError(IMAGE_EXTRA_HINT) from exc
11
+ Image.MAX_IMAGE_PIXELS = None
12
+ return Image
13
+
14
+
15
+ def load_numpy():
16
+ try:
17
+ import numpy
18
+ except ImportError as exc:
19
+ raise ImagingError(IMAGE_EXTRA_HINT) from exc
20
+ return numpy
21
+
22
+
23
+ __all__ = [
24
+ "IMAGE_EXTRA_HINT",
25
+ "ImagingError",
26
+ "load_numpy",
27
+ "load_pillow",
28
+ ]