everypixel-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- everypixel_cli/__init__.py +1 -0
- everypixel_cli/__main__.py +5 -0
- everypixel_cli/application/__init__.py +10 -0
- everypixel_cli/application/models.py +59 -0
- everypixel_cli/application/serialization.py +20 -0
- everypixel_cli/application/services.py +1376 -0
- everypixel_cli/cli.py +1520 -0
- everypixel_cli/client.py +159 -0
- everypixel_cli/config.py +221 -0
- everypixel_cli/errors.py +262 -0
- everypixel_cli/files.py +298 -0
- everypixel_cli/mcp_server.py +828 -0
- everypixel_cli/openapi.py +338 -0
- everypixel_cli/output.py +159 -0
- everypixel_cli/resources/__init__.py +1 -0
- everypixel_cli/resources/openapi.json +5402 -0
- everypixel_cli/schemas.py +846 -0
- everypixel_cli-0.1.0.dist-info/METADATA +454 -0
- everypixel_cli-0.1.0.dist-info/RECORD +22 -0
- everypixel_cli-0.1.0.dist-info/WHEEL +4 -0
- everypixel_cli-0.1.0.dist-info/entry_points.txt +3 -0
- everypixel_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
everypixel_cli/files.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""Local file handling, data URIs, and result downloads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
import mimetypes
|
|
8
|
+
import re
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from contextlib import suppress
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
from urllib.parse import urlparse
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from .errors import DownloadError, FileReadError, FileWriteError, SerializationError
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class ResultDownload:
|
|
23
|
+
"""URL and fallback extension inferred from the API result metadata."""
|
|
24
|
+
|
|
25
|
+
url: str
|
|
26
|
+
fallback_ext: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def is_url(value: str) -> bool:
|
|
30
|
+
"""Return whether value is an HTTP(S) URL."""
|
|
31
|
+
|
|
32
|
+
return value.startswith(("http://", "https://"))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def media_value(value: str) -> str:
|
|
36
|
+
"""Return URL/data URI or encode a local file as data URI."""
|
|
37
|
+
|
|
38
|
+
if is_url(value) or value.startswith("data:"):
|
|
39
|
+
return value
|
|
40
|
+
return file_to_data_uri(Path(value))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def file_to_data_uri(path: Path) -> str:
|
|
44
|
+
"""Encode a local file as data URI for JSON API endpoints."""
|
|
45
|
+
|
|
46
|
+
if not path.exists() or not path.is_file():
|
|
47
|
+
raise FileReadError(
|
|
48
|
+
"Unable to read input file",
|
|
49
|
+
code="file_not_found",
|
|
50
|
+
details={"path": str(path)},
|
|
51
|
+
)
|
|
52
|
+
mime, _ = mimetypes.guess_type(path.name)
|
|
53
|
+
if not mime:
|
|
54
|
+
mime = "application/octet-stream"
|
|
55
|
+
try:
|
|
56
|
+
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
|
|
57
|
+
except OSError as exc:
|
|
58
|
+
raise FileReadError(
|
|
59
|
+
"Unable to read input file", details={"path": str(path)}
|
|
60
|
+
) from exc
|
|
61
|
+
return f"data:{mime};base64,{encoded}"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def result_urls(value: Any) -> list[str]:
|
|
65
|
+
"""Recursively collect URLs from an arbitrary API result."""
|
|
66
|
+
|
|
67
|
+
urls: list[str] = []
|
|
68
|
+
if isinstance(value, str) and is_url(value):
|
|
69
|
+
urls.append(value)
|
|
70
|
+
elif isinstance(value, list):
|
|
71
|
+
for item in value:
|
|
72
|
+
urls.extend(result_urls(item))
|
|
73
|
+
elif isinstance(value, dict):
|
|
74
|
+
for item in value.values():
|
|
75
|
+
urls.extend(result_urls(item))
|
|
76
|
+
return urls
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def result_downloads(value: Any, *, fallback_ext: str = ".bin") -> list[ResultDownload]:
|
|
80
|
+
"""Recursively collect downloadable URLs with metadata-based fallback extensions."""
|
|
81
|
+
|
|
82
|
+
downloads: list[ResultDownload] = []
|
|
83
|
+
if isinstance(value, str) and is_url(value):
|
|
84
|
+
downloads.append(ResultDownload(value, fallback_ext))
|
|
85
|
+
elif isinstance(value, list):
|
|
86
|
+
for item in value:
|
|
87
|
+
downloads.extend(result_downloads(item, fallback_ext=fallback_ext))
|
|
88
|
+
elif isinstance(value, dict):
|
|
89
|
+
scoped_fallback = extension_from_metadata(value, fallback_ext)
|
|
90
|
+
for item in value.values():
|
|
91
|
+
downloads.extend(result_downloads(item, fallback_ext=scoped_fallback))
|
|
92
|
+
return downloads
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def extension_from_metadata(value: dict[str, Any], fallback: str) -> str:
|
|
96
|
+
"""Infer a file extension from common result metadata fields."""
|
|
97
|
+
|
|
98
|
+
for key in ("mime_type", "content_type", "contentType", "mime"):
|
|
99
|
+
raw = value.get(key)
|
|
100
|
+
if isinstance(raw, str):
|
|
101
|
+
ext = extension_for_mime(raw)
|
|
102
|
+
if ext:
|
|
103
|
+
return ext
|
|
104
|
+
for key in ("extension", "ext", "format", "file_format", "type"):
|
|
105
|
+
raw = value.get(key)
|
|
106
|
+
if isinstance(raw, str):
|
|
107
|
+
ext = normalize_extension(raw)
|
|
108
|
+
if ext:
|
|
109
|
+
return ext
|
|
110
|
+
return normalize_extension(fallback) or ".bin"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def extension_for_mime(content_type: str | None) -> str | None:
|
|
114
|
+
"""Return an extension for a MIME type."""
|
|
115
|
+
|
|
116
|
+
if not content_type:
|
|
117
|
+
return None
|
|
118
|
+
ext = mimetypes.guess_extension(content_type.split(";")[0].strip())
|
|
119
|
+
return normalize_extension(ext) if ext else None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def normalize_extension(value: str | None) -> str | None:
|
|
123
|
+
"""Normalize extension-like API data to a safe suffix."""
|
|
124
|
+
|
|
125
|
+
if not value:
|
|
126
|
+
return None
|
|
127
|
+
candidate = value.strip().lower()
|
|
128
|
+
if "/" in candidate:
|
|
129
|
+
return extension_for_mime(candidate)
|
|
130
|
+
if not candidate.startswith("."):
|
|
131
|
+
candidate = f".{candidate}"
|
|
132
|
+
if re.fullmatch(r"\.[a-z0-9][a-z0-9]{0,9}", candidate):
|
|
133
|
+
return candidate
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def extension_for(url: str, content_type: str | None, fallback: str) -> str:
|
|
138
|
+
"""Infer a result extension from Content-Type or URL."""
|
|
139
|
+
|
|
140
|
+
ext = extension_for_mime(content_type)
|
|
141
|
+
if ext:
|
|
142
|
+
return ext
|
|
143
|
+
suffix = Path(urlparse(url).path).suffix
|
|
144
|
+
return normalize_extension(suffix) or normalize_extension(fallback) or ".bin"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def safe_task_stem(task_id: str | None) -> str | None:
|
|
148
|
+
"""Return a filesystem-safe task id stem without path traversal."""
|
|
149
|
+
|
|
150
|
+
if not task_id:
|
|
151
|
+
return None
|
|
152
|
+
stem = re.sub(r"[^A-Za-z0-9._-]+", "_", task_id).strip("._")
|
|
153
|
+
return stem or None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def result_target(
|
|
157
|
+
output_dir: Path, *, task_id: str | None, index: int, total: int, ext: str
|
|
158
|
+
) -> Path:
|
|
159
|
+
"""Build a target path for a downloaded or inline result."""
|
|
160
|
+
|
|
161
|
+
stem = safe_task_stem(task_id)
|
|
162
|
+
if stem:
|
|
163
|
+
name = f"{stem}{ext}" if total == 1 else f"{stem}-{index}{ext}"
|
|
164
|
+
else:
|
|
165
|
+
name = f"result-{index}{ext}"
|
|
166
|
+
return output_dir / name
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def download_urls(
|
|
170
|
+
urls: list[str] | list[ResultDownload],
|
|
171
|
+
output_dir: Path,
|
|
172
|
+
*,
|
|
173
|
+
fallback_ext: str = ".bin",
|
|
174
|
+
task_id: str | None = None,
|
|
175
|
+
cancel_check: Callable[[], None] | None = None,
|
|
176
|
+
) -> list[str]:
|
|
177
|
+
"""Download URLs into a directory and return saved file paths."""
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
181
|
+
except OSError as exc:
|
|
182
|
+
raise FileWriteError(
|
|
183
|
+
"Unable to create download directory", details={"path": str(output_dir)}
|
|
184
|
+
) from exc
|
|
185
|
+
downloaded: list[str] = []
|
|
186
|
+
items = [
|
|
187
|
+
item if isinstance(item, ResultDownload) else ResultDownload(item, fallback_ext)
|
|
188
|
+
for item in urls
|
|
189
|
+
]
|
|
190
|
+
with httpx.Client(timeout=120.0, follow_redirects=True) as client:
|
|
191
|
+
total = len(items)
|
|
192
|
+
for index, item in enumerate(items, start=1):
|
|
193
|
+
url = item.url
|
|
194
|
+
target: Path | None = None
|
|
195
|
+
partial: Path | None = None
|
|
196
|
+
try:
|
|
197
|
+
if cancel_check is not None:
|
|
198
|
+
cancel_check()
|
|
199
|
+
with client.stream("GET", url) as response:
|
|
200
|
+
response.raise_for_status()
|
|
201
|
+
ext = extension_for(
|
|
202
|
+
url,
|
|
203
|
+
response.headers.get("content-type"),
|
|
204
|
+
item.fallback_ext,
|
|
205
|
+
)
|
|
206
|
+
target = result_target(
|
|
207
|
+
output_dir,
|
|
208
|
+
task_id=task_id,
|
|
209
|
+
index=index,
|
|
210
|
+
total=total,
|
|
211
|
+
ext=ext,
|
|
212
|
+
)
|
|
213
|
+
partial = target.with_name(f".{target.name}.part")
|
|
214
|
+
with partial.open("wb") as file_handle:
|
|
215
|
+
for chunk in response.iter_bytes():
|
|
216
|
+
if cancel_check is not None:
|
|
217
|
+
cancel_check()
|
|
218
|
+
file_handle.write(chunk)
|
|
219
|
+
if cancel_check is not None:
|
|
220
|
+
cancel_check()
|
|
221
|
+
partial.replace(target)
|
|
222
|
+
downloaded.append(str(target))
|
|
223
|
+
except httpx.HTTPError as exc:
|
|
224
|
+
raise DownloadError(
|
|
225
|
+
"Unable to download result", details={"url": url.split("?")[0]}
|
|
226
|
+
) from exc
|
|
227
|
+
except OSError as exc:
|
|
228
|
+
raise FileWriteError(
|
|
229
|
+
"Unable to write downloaded result",
|
|
230
|
+
details={"path": str(target or output_dir)},
|
|
231
|
+
) from exc
|
|
232
|
+
finally:
|
|
233
|
+
if partial is not None:
|
|
234
|
+
with suppress(OSError):
|
|
235
|
+
partial.unlink(missing_ok=True)
|
|
236
|
+
return downloaded
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def save_inline_result(
|
|
240
|
+
value: Any,
|
|
241
|
+
output_dir: Path,
|
|
242
|
+
*,
|
|
243
|
+
task_id: str,
|
|
244
|
+
fallback_ext: str = ".bin",
|
|
245
|
+
cancel_check: Callable[[], None] | None = None,
|
|
246
|
+
) -> list[str]:
|
|
247
|
+
"""Save non-URL API results such as ASR text or structured transcription JSON."""
|
|
248
|
+
|
|
249
|
+
if value is None:
|
|
250
|
+
return []
|
|
251
|
+
try:
|
|
252
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
253
|
+
except OSError as exc:
|
|
254
|
+
raise FileWriteError(
|
|
255
|
+
"Unable to create download directory", details={"path": str(output_dir)}
|
|
256
|
+
) from exc
|
|
257
|
+
ext, content, encoding = inline_result_payload(value, fallback_ext=fallback_ext)
|
|
258
|
+
target = result_target(output_dir, task_id=task_id, index=1, total=1, ext=ext)
|
|
259
|
+
try:
|
|
260
|
+
if cancel_check is not None:
|
|
261
|
+
cancel_check()
|
|
262
|
+
if encoding:
|
|
263
|
+
target.write_text(content, encoding=encoding)
|
|
264
|
+
else:
|
|
265
|
+
target.write_bytes(content)
|
|
266
|
+
except OSError as exc:
|
|
267
|
+
raise FileWriteError(
|
|
268
|
+
"Unable to write result file", details={"path": str(target)}
|
|
269
|
+
) from exc
|
|
270
|
+
return [str(target)]
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def inline_result_payload(
|
|
274
|
+
value: Any, *, fallback_ext: str
|
|
275
|
+
) -> tuple[str, Any, str | None]:
|
|
276
|
+
"""Return extension, content and text encoding for a non-URL result."""
|
|
277
|
+
|
|
278
|
+
fallback = normalize_extension(fallback_ext) or ".bin"
|
|
279
|
+
if isinstance(value, str):
|
|
280
|
+
return ".txt" if fallback == ".bin" else fallback, value, "utf-8"
|
|
281
|
+
if is_plain_transcript(value):
|
|
282
|
+
transcript = next(item for item in value.values() if isinstance(item, str))
|
|
283
|
+
return ".txt", transcript, "utf-8"
|
|
284
|
+
if isinstance(value, (dict, list)):
|
|
285
|
+
try:
|
|
286
|
+
return ".json", json.dumps(value, ensure_ascii=False, indent=2), "utf-8"
|
|
287
|
+
except (TypeError, ValueError) as exc:
|
|
288
|
+
raise SerializationError("Unable to serialize result for saving") from exc
|
|
289
|
+
return fallback, str(value), "utf-8"
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def is_plain_transcript(value: Any) -> bool:
|
|
293
|
+
"""Return whether an ASR result is only a single transcript string."""
|
|
294
|
+
|
|
295
|
+
if not isinstance(value, dict) or len(value) != 1:
|
|
296
|
+
return False
|
|
297
|
+
key, item = next(iter(value.items()))
|
|
298
|
+
return key in {"text", "transcript", "transcription"} and isinstance(item, str)
|