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.
- {evo_cli-0.22.0 → evo_cli-0.23.0}/PKG-INFO +4 -1
- evo_cli-0.23.0/evo_cli/VERSION +1 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/cli.py +2 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/gdrive.py +238 -2
- evo_cli-0.23.0/evo_cli/commands/image.py +293 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/credentials/registry.py +1 -1
- evo_cli-0.23.0/evo_cli/imaging/__init__.py +28 -0
- evo_cli-0.23.0/evo_cli/imaging/align.py +138 -0
- evo_cli-0.23.0/evo_cli/imaging/core.py +456 -0
- evo_cli-0.23.0/evo_cli/imaging/creds.py +31 -0
- evo_cli-0.23.0/evo_cli/imaging/errors.py +2 -0
- evo_cli-0.23.0/evo_cli/imaging/gemini.py +123 -0
- evo_cli-0.23.0/evo_cli/imaging/ncnn.py +187 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli.egg-info/PKG-INFO +4 -1
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli.egg-info/SOURCES.txt +10 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli.egg-info/requires.txt +4 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/pyproject.toml +4 -0
- evo_cli-0.23.0/tests/test_gdrive.py +95 -0
- evo_cli-0.23.0/tests/test_image.py +1416 -0
- evo_cli-0.22.0/evo_cli/VERSION +0 -1
- {evo_cli-0.22.0 → evo_cli-0.23.0}/Containerfile +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/HISTORY.md +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/LICENSE +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/MANIFEST.in +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/README.md +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/__init__.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/__main__.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/base.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/__init__.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/agent_toy.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/claude_code.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/cloudflare.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/cred.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/download.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/fix_claude.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/gh.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/harness/__init__.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/harness/_dag.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/harness/_git.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/harness/_model.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/harness/_mutate.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/harness/_paths.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/harness/_render.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/harness/_server.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/harness/check.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/harness/clone.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/harness/edit.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/harness/pull.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/harness/serve.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/harness/show.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/harness/views.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/harness/web/assets/index-CsALFg16.css +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/harness/web/assets/index-DwEXL5pV.js +0 -0
- {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
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/harness/web/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- {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
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/harness/web/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- {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
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/harness/web/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/harness/web/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- {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
- {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
- {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
- {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
- {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
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/harness/web/index.html +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/hwid.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/hwid_reset.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/localproxy.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/mcp.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/miniconda.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/netcheck.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/opencode.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/plantuml.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/serp.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/site2s.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/ssh.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/sysmon.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/tts.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/update.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/commands/wifi.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/console.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/credentials/__init__.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/credentials/doctor.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/credentials/google_oauth.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/credentials/migrate.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/credentials/oauth_flow.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/credentials/store.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/credentials/sync.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/mcp_registry.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/serp/__init__.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/serp/client.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/serp/creds.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/serp/errors.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/serp/install.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/serp/render.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/tts/__init__.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/tts/chunking.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/tts/core.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/tts/creds.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/tts/errors.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/tts/gemini.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/tts/http.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/tts/openai.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/tts/player.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli/tts/vbee.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli.egg-info/dependency_links.txt +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli.egg-info/entry_points.txt +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/evo_cli.egg-info/top_level.txt +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/setup.cfg +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/tests/__init__.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/tests/test_agent_toy.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/tests/test_claude_code.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/tests/test_cli.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/tests/test_console.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/tests/test_cred.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/tests/test_download.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/tests/test_fix_claude.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/tests/test_gh.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/tests/test_harness.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/tests/test_harness_clone.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/tests/test_harness_dag.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/tests/test_mcp.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/tests/test_opencode.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/tests/test_plantuml.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/tests/test_serp.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/tests/test_sysmon.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/tests/test_tts.py +0 -0
- {evo_cli-0.22.0 → evo_cli-0.23.0}/tests/test_update.py +0 -0
- {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.
|
|
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
|
|
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
|
+
]
|