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.
@@ -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)