switchfile-sdk 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.
switchfile/__init__.py ADDED
@@ -0,0 +1,178 @@
1
+ """switchfile: Python-SDK für die switchfile-API.
2
+
3
+ Schnittstelle wie das CloudConvert-SDK::
4
+
5
+ import switchfile
6
+ switchfile.configure(api_key="...", sandbox=False)
7
+ job = switchfile.Job.create(payload={"tasks": {...}})
8
+ job = switchfile.Job.wait(id=job["id"])
9
+
10
+ Dazu ``switchfile.convert(file, to, options)`` für /v1 und ``switchfile.Client`` für mehrere Keys.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ from typing import Any
16
+
17
+ from .client import (_UNSET, DEFAULT_BASE_URL, MULTIPART_LIMIT, NO_STORE_LIMIT, NO_STORE_TIMEOUT, Client, Jobs,
18
+ NoStoreResult, Webhooks)
19
+ from .errors import ConversionFailedError, SwitchfileError
20
+ from .version import __version__
21
+
22
+ __all__ = [
23
+ "Account", "Client", "Conversion", "ConversionFailedError", "DEFAULT_BASE_URL", "Job", "MULTIPART_LIMIT",
24
+ "NO_STORE_LIMIT", "NO_STORE_TIMEOUT", "NoStoreResult", "SwitchfileError", "Task", "User", "Webhook", "__version__",
25
+ "configure", "convert", "convert_without_storage", "default", "download", "formats",
26
+ ]
27
+
28
+ _client: Client | None = None
29
+
30
+
31
+ def configure(**config: Any) -> Client:
32
+ """Standard-Client einstellen: api_key, sandbox, base_url, sandbox_base_url, timeout, max_retries, poll_interval,
33
+ user_agent. Gibt den Client zurück."""
34
+ global _client
35
+ _client = Client(**config)
36
+ return _client
37
+
38
+
39
+ def default() -> Client:
40
+ """Standard-Client aus der Umgebung (SWITCHFILE_API_KEY, SWITCHFILE_API_URL, SWITCHFILE_SANDBOX=true)."""
41
+ return configure(sandbox=os.environ.get("SWITCHFILE_SANDBOX", "false").lower() == "true")
42
+
43
+
44
+ def _c() -> Client:
45
+ return _client if _client is not None else default()
46
+
47
+
48
+ class Job:
49
+ """Jobs (/v2), Methoden wie cloudconvert.Job."""
50
+
51
+ @staticmethod
52
+ def create(payload: dict | None = None) -> dict:
53
+ return _c().jobs.create(payload)
54
+
55
+ @staticmethod
56
+ def find(id: str) -> dict:
57
+ return _c().jobs.find(id)
58
+
59
+ @staticmethod
60
+ def show(id: str) -> dict:
61
+ return _c().jobs.find(id)
62
+
63
+ @staticmethod
64
+ def wait(id: str, timeout: float | None = None) -> dict:
65
+ return _c().jobs.wait(id, timeout=timeout)
66
+
67
+ @staticmethod
68
+ def all(params: dict | None = None) -> list:
69
+ return _c().jobs.all(params)
70
+
71
+ @staticmethod
72
+ def delete(id: str) -> bool:
73
+ return _c().jobs.delete(id)
74
+
75
+ @staticmethod
76
+ def get_export_urls(job: dict) -> list:
77
+ return Jobs.get_export_urls(job)
78
+
79
+
80
+ class Task:
81
+ """Tasks (/v2), Methoden wie cloudconvert.Task."""
82
+
83
+ @staticmethod
84
+ def find(id: str, include: str | None = None) -> dict:
85
+ return _c().tasks.find(id, include)
86
+
87
+ @staticmethod
88
+ def show(id: str, include: str | None = None) -> dict:
89
+ return _c().tasks.find(id, include)
90
+
91
+ @staticmethod
92
+ def wait(id: str, timeout: float | None = None) -> dict:
93
+ return _c().tasks.wait(id, timeout=timeout)
94
+
95
+ @staticmethod
96
+ def all(params: dict | None = None) -> list:
97
+ return _c().tasks.all(params)
98
+
99
+ @staticmethod
100
+ def cancel(id: str) -> bool:
101
+ return _c().tasks.cancel(id)
102
+
103
+ @staticmethod
104
+ def retry(id: str) -> dict:
105
+ return _c().tasks.retry(id)
106
+
107
+ @staticmethod
108
+ def upload(file_name: Any, task: dict, filename: str | None = None) -> bool:
109
+ return _c().tasks.upload(file_name, task, filename)
110
+
111
+
112
+ class User:
113
+ @staticmethod
114
+ def me() -> dict:
115
+ return _c().users.me()
116
+
117
+
118
+ class Webhook:
119
+ """Webhook-Signaturen, wie cloudconvert.Webhook.verify."""
120
+
121
+ @staticmethod
122
+ def verify(payload_string: str | bytes, signature: str | None, signature_secret: str) -> bool:
123
+ return Webhooks.verify(payload_string, signature, signature_secret)
124
+
125
+ @staticmethod
126
+ def construct_event(payload_string: str | bytes, signature: str | None, signature_secret: str) -> dict:
127
+ return Webhooks.construct_event(payload_string, signature, signature_secret)
128
+
129
+
130
+ class Conversion:
131
+ """Umwandlungen (/v1)."""
132
+
133
+ @staticmethod
134
+ def find(id: str, wait: float | None = None) -> dict:
135
+ return _c().conversions.find(id, wait)
136
+
137
+ @staticmethod
138
+ def wait(id: str, timeout: float | None = None) -> dict:
139
+ return _c().conversions.wait(id, timeout)
140
+
141
+ @staticmethod
142
+ def list(ids: list[str]) -> list:
143
+ return _c().conversions.list(ids)
144
+
145
+ @staticmethod
146
+ def delete(id: str) -> bool:
147
+ return _c().conversions.delete(id)
148
+
149
+
150
+ class Account:
151
+ """Konto (/v1): Credits, Gratis-Kontingent, Webhook-URL und Signing-Secret."""
152
+
153
+ @staticmethod
154
+ def get() -> dict:
155
+ return _c().account.get()
156
+
157
+ @staticmethod
158
+ def update(webhook_url: Any = _UNSET, *, no_store: bool | None = None) -> dict:
159
+ return _c().account.update(webhook_url, no_store=no_store)
160
+
161
+
162
+ def convert(file: Any, to: str | None = None, options: dict | None = None, **kwargs: Any) -> dict:
163
+ """Datei umwandeln (/v1) und auf das Ergebnis warten. Siehe Client.convert."""
164
+ return _c().convert(file, to, options, **kwargs)
165
+
166
+
167
+ def convert_without_storage(file: Any, to: str | None = None, options: dict | None = None, **kwargs: Any) -> NoStoreResult:
168
+ """Datei ohne Speicherung umwandeln, Ergebnis in der Antwort. Siehe Client.convert_without_storage."""
169
+ return _c().convert_without_storage(file, to, options, **kwargs)
170
+
171
+
172
+ def formats(input: str | None = None) -> dict:
173
+ return _c().formats(input)
174
+
175
+
176
+ def download(url: Any = None, filename: str | os.PathLike | None = None) -> bytes | str:
177
+ """Datei von einer signierten URL laden; mit filename in die Datei schreiben (wie cloudconvert.download)."""
178
+ return _c().download(url, filename)
switchfile/_http.py ADDED
@@ -0,0 +1,314 @@
1
+ """HTTP-Kern mit urllib (keine Abhängigkeiten). Der API-Key geht nur an die Basis-URL der API, nie an signierte
2
+ Upload- und Download-URLs. Uploads und Downloads werden gestreamt, nicht im Speicher gepuffert."""
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import io
7
+ import json
8
+ import os
9
+ import random
10
+ import re
11
+ import secrets
12
+ import shutil
13
+ import socket
14
+ import time
15
+ import urllib.error
16
+ import urllib.parse
17
+ import urllib.request
18
+ import uuid
19
+ from typing import Any, BinaryIO, Callable, Iterable
20
+
21
+ from .errors import SwitchfileError, error_from_response
22
+
23
+ _SAFE_HEADER = re.compile(r"^[A-Za-z0-9-]{1,64}$")
24
+ _UNSAFE_VALUE = re.compile(r"[\r\n\x00]")
25
+ _BLOCKED_HEADER = re.compile(r"^(authorization|host|content-length|transfer-encoding|connection|cookie)$", re.I)
26
+
27
+
28
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
29
+ def redirect_request(self, *args, **kwargs): # Redirects der API nicht folgen (Key bliebe sonst im Header)
30
+ return None
31
+
32
+
33
+ _api_opener = urllib.request.build_opener(_NoRedirect)
34
+ _plain_opener = urllib.request.build_opener()
35
+
36
+
37
+ def redirect_error(status: int, location: str | None, url: str) -> SwitchfileError:
38
+ """Fehler für eine 3xx-Antwort der API."""
39
+ host = urllib.parse.urlsplit(url).netloc
40
+ target = f" to {location}" if location else ""
41
+ return SwitchfileError(
42
+ "UNEXPECTED_REDIRECT",
43
+ f"The switchfile API at {host} answered with a redirect (HTTP {status}{target}). "
44
+ "Check the base URL; it should be https://api.switchfile.io.",
45
+ status=status,
46
+ )
47
+
48
+
49
+ class Transport:
50
+ def __init__(self, api_key: str, base_url: str, timeout: float, max_retries: int, user_agent: str,
51
+ opener: urllib.request.OpenerDirector | None = None):
52
+ self.api_key = api_key
53
+ self.base_url = base_url
54
+ self.timeout = timeout
55
+ self.max_retries = max_retries
56
+ self.user_agent = user_agent
57
+ self._opener = opener or _api_opener
58
+
59
+ # ---------- API ----------
60
+ def request(self, method: str, path: str, *, query: dict | None = None, json_body: Any = None,
61
+ body_factory: Callable[[], tuple[Any, dict]] | None = None, auth: bool = True,
62
+ idempotency_key: str | None = None, timeout: float | None = None, retry: bool | None = None,
63
+ headers: dict | None = None, binary: bool = False) -> Any:
64
+ """JSON-Request an die API. Gibt den geparsten Body zurück (None bei 204). binary: (Bytes, Header) der
65
+ Erfolgsantwort, z. B. die Datei einer Umwandlung ohne Speicherung."""
66
+ url = self.base_url + path
67
+ q = {k: v for k, v in (query or {}).items() if v is not None}
68
+ if q:
69
+ url += "?" + urllib.parse.urlencode({k: (str(v).lower() if isinstance(v, bool) else v) for k, v in q.items()})
70
+ headers = {"Accept": "application/json", "User-Agent": self.user_agent, **(headers or {})}
71
+ if auth:
72
+ if not self.api_key:
73
+ raise SwitchfileError("MISSING_API_KEY", "No API key. Pass api_key or set SWITCHFILE_API_KEY.")
74
+ headers["Authorization"] = f"Bearer {self.api_key}"
75
+ if idempotency_key:
76
+ headers["Idempotency-Key"] = idempotency_key
77
+ retry_server = retry if retry is not None else (method in ("GET", "DELETE") or bool(idempotency_key))
78
+
79
+ attempt = 0
80
+ while True:
81
+ data: Any = None
82
+ extra: dict = {}
83
+ if json_body is not None:
84
+ data = json.dumps(json_body).encode()
85
+ extra = {"Content-Type": "application/json"}
86
+ elif body_factory is not None:
87
+ data, extra = body_factory()
88
+ req = urllib.request.Request(url, data=data, method=method, headers={**headers, **extra})
89
+ try:
90
+ with _closing(data), self._opener.open(req, timeout=timeout or self.timeout) as res:
91
+ raw = res.read()
92
+ if binary:
93
+ return raw, res.headers
94
+ return _parse(raw) if res.status != 204 else None
95
+ except urllib.error.HTTPError as e:
96
+ raw = e.read() if e.fp else b""
97
+ if 300 <= e.code < 400:
98
+ # Weiterleitungen nie folgen und nicht als Erfolg werten: meist ist die Basis-URL falsch.
99
+ raise redirect_error(e.code, e.headers.get("Location"), url) from None
100
+ err = error_from_response(e.code, _parse(raw), e.headers.get("Retry-After"))
101
+ again = ((e.code == 429 and err.code == "RATE_LIMIT_EXCEEDED")
102
+ or (e.code == 409 and err.code == "IDEMPOTENCY_KEY_IN_USE")
103
+ # Ohne Speicherung: ausgelastet oder belegt, die API hat nichts umgewandelt und nichts berechnet.
104
+ or (e.code == 429 and err.code == "CONCURRENCY_LIMIT")
105
+ or (e.code == 503 and err.code in ("SERVER_BUSY", "NO_STORE_UNAVAILABLE"))
106
+ or (retry_server and e.code in (500, 502, 503, 504)))
107
+ if again and attempt < self.max_retries:
108
+ wait = err.retry_after if err.retry_after is not None else _backoff(attempt)
109
+ if wait <= 60:
110
+ time.sleep(wait)
111
+ attempt += 1
112
+ continue
113
+ raise err from None
114
+ except (urllib.error.URLError, ConnectionError, socket.timeout, TimeoutError) as e:
115
+ if retry_server and attempt < self.max_retries:
116
+ time.sleep(_backoff(attempt))
117
+ attempt += 1
118
+ continue
119
+ host = urllib.parse.urlsplit(url).netloc
120
+ reason = getattr(e, "reason", e)
121
+ if isinstance(reason, (socket.timeout, TimeoutError)):
122
+ raise SwitchfileError("TIMEOUT", f"The switchfile API ({host}) did not answer in time.") from e
123
+ raise SwitchfileError("NETWORK_ERROR", f"Could not reach the switchfile API at {host}.") from e
124
+
125
+ # ---------- signierte URLs ----------
126
+ def send_signed(self, method: str, url: str, body_factory: Callable[[], tuple[Any, dict]]) -> Any:
127
+ """Upload an eine signierte URL, ohne Key. Gibt den JSON-Body zurück (falls vorhanden)."""
128
+ target = self.resolve_signed(url)
129
+ data, headers = body_factory()
130
+ req = urllib.request.Request(target, data=data, method=method, headers={"User-Agent": self.user_agent, **headers})
131
+ try:
132
+ with _closing(data), _plain_opener.open(req, timeout=max(self.timeout, 300)) as res:
133
+ return _parse(res.read())
134
+ except urllib.error.HTTPError as e:
135
+ raw = e.read() if e.fp else b""
136
+ raise error_from_response(e.code, _parse(raw), e.headers.get("Retry-After")) from None
137
+ except (urllib.error.URLError, ConnectionError, socket.timeout, TimeoutError) as e:
138
+ host = urllib.parse.urlsplit(target).netloc
139
+ raise SwitchfileError("NETWORK_ERROR", f"The upload to {host} failed.") from e
140
+
141
+ def download(self, url: str, filename: str | os.PathLike | None = None) -> bytes | str:
142
+ """Signierte URL laden (ohne Key). Mit filename in die Datei schreiben und den Pfad zurückgeben."""
143
+ target = self.resolve_signed(url)
144
+ host = urllib.parse.urlsplit(target).netloc
145
+ req = urllib.request.Request(target, headers={"User-Agent": self.user_agent})
146
+ for attempt in range(self.max_retries + 1):
147
+ try:
148
+ with _plain_opener.open(req, timeout=max(self.timeout, 300)) as res:
149
+ if filename is None:
150
+ return res.read()
151
+ path = os.fspath(filename)
152
+ tmp = f"{path}.part-{os.getpid()}-{secrets.token_hex(4)}"
153
+ try:
154
+ with open(tmp, "wb") as out:
155
+ shutil.copyfileobj(res, out, 1024 * 1024)
156
+ os.replace(tmp, path)
157
+ except BaseException:
158
+ try:
159
+ os.remove(tmp)
160
+ except OSError:
161
+ pass
162
+ raise
163
+ return path
164
+ except urllib.error.HTTPError as e:
165
+ if e.code >= 500 and attempt < self.max_retries:
166
+ time.sleep(_backoff(attempt))
167
+ continue
168
+ raw = e.read() if e.fp else b""
169
+ raise error_from_response(e.code, _parse(raw), e.headers.get("Retry-After")) from None
170
+ except (urllib.error.URLError, ConnectionError, socket.timeout, TimeoutError) as e:
171
+ if attempt < self.max_retries:
172
+ time.sleep(_backoff(attempt))
173
+ continue
174
+ raise SwitchfileError("NETWORK_ERROR", f"The download from {host} failed.") from e
175
+ raise SwitchfileError("NETWORK_ERROR", f"The download from {host} failed.")
176
+
177
+ def resolve_signed(self, url: str) -> str:
178
+ full = urllib.parse.urljoin(self.base_url + "/", url)
179
+ p = urllib.parse.urlsplit(full)
180
+ if p.scheme not in ("http", "https") or p.username or p.password:
181
+ raise SwitchfileError("BAD_RESPONSE", "The switchfile API returned a file URL that is not allowed.")
182
+ return full
183
+
184
+
185
+ # ---------- Dateien ----------
186
+ class PreparedFile:
187
+ """Datei aus Pfad, Bytes oder Datei-Objekt mit Name und Größe. open() liefert jedes Mal einen frischen Stream."""
188
+
189
+ def __init__(self, source: Any, filename: str | None = None):
190
+ self._path: str | None = None
191
+ self._bytes: bytes | None = None
192
+ self._fileobj: BinaryIO | None = None
193
+ self._start = 0
194
+ if isinstance(source, (str, os.PathLike)):
195
+ path = os.fspath(source)
196
+ if not os.path.isfile(path):
197
+ raise SwitchfileError("READ_FAILED", f"Could not read {path}: not a file.")
198
+ self._path = path
199
+ self.size = os.path.getsize(path)
200
+ self.filename = filename or os.path.basename(path)
201
+ elif isinstance(source, (bytes, bytearray, memoryview)):
202
+ self._bytes = bytes(source)
203
+ self.size = len(self._bytes)
204
+ self.filename = filename
205
+ elif hasattr(source, "read"):
206
+ name = getattr(source, "name", None)
207
+ self.filename = filename or (os.path.basename(name) if isinstance(name, str) else None)
208
+ try: # Datei mit bekannter Größe: von der aktuellen Position an streamen
209
+ self._start = source.tell()
210
+ source.seek(0, io.SEEK_END)
211
+ self.size = source.tell() - self._start
212
+ source.seek(self._start)
213
+ self._fileobj = source
214
+ except (AttributeError, OSError, io.UnsupportedOperation):
215
+ self._bytes = source.read()
216
+ self.size = len(self._bytes)
217
+ else:
218
+ raise SwitchfileError("INVALID_SOURCE", "Unsupported file source. Pass a path, bytes or a file object.")
219
+
220
+ def open(self) -> BinaryIO:
221
+ if self._path is not None:
222
+ return open(self._path, "rb")
223
+ if self._fileobj is not None:
224
+ self._fileobj.seek(self._start)
225
+ return _NoClose(self._fileobj)
226
+ return io.BytesIO(self._bytes or b"")
227
+
228
+
229
+ class _NoClose(io.RawIOBase):
230
+ """Datei-Objekt des Aufrufers lesen, ohne es zu schließen."""
231
+
232
+ def __init__(self, f: BinaryIO):
233
+ self._f = f
234
+
235
+ def readable(self) -> bool:
236
+ return True
237
+
238
+ def read(self, n: int = -1) -> bytes:
239
+ return self._f.read(n)
240
+
241
+
242
+ class MultipartBody(io.RawIOBase):
243
+ """multipart/form-data als Stream mit bekannter Länge (die Datei wird beim Senden gelesen)."""
244
+
245
+ def __init__(self, fields: Iterable[tuple[str, str]], file_field: str, file: PreparedFile, filename: str):
246
+ self.boundary = "----switchfile" + uuid.uuid4().hex
247
+ pre = b""
248
+ for k, v in fields:
249
+ pre += (f"--{self.boundary}\r\nContent-Disposition: form-data; name=\"{_q(k)}\"\r\n\r\n").encode() + v.encode() + b"\r\n"
250
+ pre += (f"--{self.boundary}\r\nContent-Disposition: form-data; name=\"{_q(file_field)}\"; filename=\"{_q(filename)}\"\r\n"
251
+ f"Content-Type: application/octet-stream\r\n\r\n").encode()
252
+ post = f"\r\n--{self.boundary}--\r\n".encode()
253
+ self.length = len(pre) + file.size + len(post)
254
+ self._parts: list[Any] = [io.BytesIO(pre), file.open(), io.BytesIO(post)]
255
+
256
+ def close(self) -> None:
257
+ # Auch nach einem abgebrochenen Senden: die Datei nicht offen lassen (_NoClose schließt die des Aufrufers nicht).
258
+ for part in self._parts:
259
+ part.close()
260
+ self._parts = []
261
+ super().close()
262
+
263
+ @property
264
+ def content_type(self) -> str:
265
+ return f"multipart/form-data; boundary={self.boundary}"
266
+
267
+ def readable(self) -> bool:
268
+ return True
269
+
270
+ def read(self, n: int = -1) -> bytes:
271
+ out = b""
272
+ while self._parts and (n < 0 or len(out) < n):
273
+ chunk = self._parts[0].read(-1 if n < 0 else n - len(out))
274
+ if not chunk:
275
+ part = self._parts.pop(0)
276
+ part.close()
277
+ continue
278
+ out += chunk
279
+ return out
280
+
281
+
282
+ @contextlib.contextmanager
283
+ def _closing(data: Any):
284
+ """Body (Datei, MultipartBody) nach dem Request schließen, auch bei Fehlern; Bytes bleiben unberührt."""
285
+ try:
286
+ yield data
287
+ finally:
288
+ if hasattr(data, "close"):
289
+ data.close()
290
+
291
+
292
+ def safe_upload_headers(h: dict | None) -> dict:
293
+ """Header der API für den PUT: ohne Authorization, Host, Längen; Werte mit CR, LF oder NUL werden verworfen
294
+ (wie integrations/shared/http-common.ts), sonst würfe urllib einen ValueError."""
295
+ return {k: str(v) for k, v in (h or {}).items()
296
+ if v is not None and isinstance(k, str) and _SAFE_HEADER.match(k) and not _BLOCKED_HEADER.match(k)
297
+ and not _UNSAFE_VALUE.search(str(v))}
298
+
299
+
300
+ def _q(s: str) -> str:
301
+ return s.replace("\\", "\\\\").replace('"', "%22").replace("\r", "").replace("\n", "")
302
+
303
+
304
+ def _parse(raw: bytes) -> Any:
305
+ if not raw:
306
+ return None
307
+ try:
308
+ return json.loads(raw)
309
+ except ValueError:
310
+ return None
311
+
312
+
313
+ def _backoff(attempt: int) -> float:
314
+ return min(8.0, 0.5 * 2 ** attempt) + random.random() * 0.25
switchfile/client.py ADDED
@@ -0,0 +1,434 @@
1
+ """Client mit allen Ressourcen. ``Client`` für mehrere Keys oder Basis-URLs; die Modul-Funktionen und -Klassen
2
+ (``switchfile.Job.create`` …) nutzen einen Standard-Client wie das CloudConvert-SDK."""
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import hmac
7
+ import json
8
+ import os
9
+ import re
10
+ import time
11
+ import urllib.parse
12
+ import uuid
13
+ from dataclasses import dataclass
14
+ from typing import Any
15
+
16
+ from ._http import MultipartBody, PreparedFile, Transport, safe_upload_headers
17
+ from .errors import ConversionFailedError, SwitchfileError
18
+ from .version import __version__
19
+
20
+ DEFAULT_BASE_URL = "https://api.switchfile.io"
21
+ #: Dateien bis zu dieser Größe gehen im Request mit (multipart), größere über POST /v1/uploads.
22
+ MULTIPART_LIMIT = 50 * 1024 * 1024
23
+ #: Größte Datei ohne Speicherung (die API lehnt größere mit FILE_TOO_LARGE ab).
24
+ NO_STORE_LIMIT = 50 * 1024 * 1024
25
+ # Zeitlimit eines Requests ohne Speicherung (s): so lang, wie die API ihm lässt (Hochladen 120 s, Umwandeln 90 s,
26
+ # Senden der Antwort 180 s). Ein längeres Zeitlimit des Clients gilt.
27
+ NO_STORE_TIMEOUT = 390
28
+ _WAIT_SECONDS = 30
29
+ _LIVE_KEY = re.compile(r"^(sf_live_|ac_live_)")
30
+
31
+
32
+ _UNSET: Any = object()
33
+
34
+
35
+ def _q(s: str) -> str:
36
+ return urllib.parse.quote(str(s), safe="")
37
+
38
+
39
+ def _poll(load, done, timeout: float | None, interval: float) -> dict:
40
+ deadline = None if timeout is None else time.monotonic() + timeout
41
+ while True:
42
+ item = load()
43
+ if done(item.get("status")):
44
+ return item
45
+ if deadline is not None:
46
+ left = deadline - time.monotonic()
47
+ if left <= 0:
48
+ raise SwitchfileError("TIMEOUT", f"Still {item.get('status')} after waiting {timeout} seconds.", body=item)
49
+ time.sleep(min(interval, left))
50
+ else:
51
+ time.sleep(interval)
52
+ interval = min(5.0, interval * 1.5)
53
+
54
+
55
+ def _job_done(s: Any) -> bool:
56
+ return s in ("finished", "error")
57
+
58
+
59
+ def _conv_done(s: Any) -> bool:
60
+ return s in ("finished", "failed")
61
+
62
+
63
+ class Jobs:
64
+ def __init__(self, t: Transport, poll: float):
65
+ self._t, self._poll = t, poll
66
+
67
+ def create(self, payload: dict | None = None) -> dict:
68
+ """Job anlegen (POST /v2/jobs). Gibt den Job mit allen Tasks zurück. Mit "store": False läuft er ohne
69
+ Speicherung über /sync/v2/jobs und kommt fertig zurück (Dateien als base64 in result.files[].content)."""
70
+ payload = payload or {}
71
+ if payload.get("store") is False:
72
+ # Der Header hebt die Grenze des JSON-Bodys auf die Dateigrenze ohne Speicherung (base64 von 50 MB).
73
+ return self._t.request("POST", "/sync/v2/jobs", json_body=payload, retry=False,
74
+ headers={"Switchfile-Store": "false"}, timeout=max(self._t.timeout, NO_STORE_TIMEOUT))["data"]
75
+ return self._t.request("POST", "/v2/jobs", json_body=payload, retry=False)["data"]
76
+
77
+ def find(self, id: str) -> dict:
78
+ return self._t.request("GET", f"/v2/jobs/{_q(id)}")["data"]
79
+
80
+ show = find
81
+
82
+ def wait(self, id: str, timeout: float | None = None) -> dict:
83
+ """Wartet, bis der Job finished oder error ist. Wirft bei error nicht."""
84
+ return _poll(lambda: self.find(id), _job_done, timeout, self._poll)
85
+
86
+ def all(self, params: dict | None = None) -> list:
87
+ return self._t.request("GET", "/v2/jobs", query=params)["data"]
88
+
89
+ def delete(self, id: str) -> bool:
90
+ self._t.request("DELETE", f"/v2/jobs/{_q(id)}")
91
+ return True
92
+
93
+ @staticmethod
94
+ def get_export_urls(job: dict) -> list:
95
+ """Alle Dateien der fertigen export/url-Tasks."""
96
+ return [f for t in job.get("tasks") or [] if t.get("operation") == "export/url" and t.get("status") == "finished"
97
+ for f in ((t.get("result") or {}).get("files") or [])]
98
+
99
+
100
+ class Tasks:
101
+ def __init__(self, t: Transport, poll: float):
102
+ self._t, self._poll = t, poll
103
+
104
+ def find(self, id: str, include: str | None = None) -> dict:
105
+ return self._t.request("GET", f"/v2/tasks/{_q(id)}", query={"include": include})["data"]
106
+
107
+ show = find
108
+
109
+ def wait(self, id: str, timeout: float | None = None) -> dict:
110
+ return _poll(lambda: self.find(id), _job_done, timeout, self._poll)
111
+
112
+ def all(self, params: dict | None = None) -> list:
113
+ return self._t.request("GET", "/v2/tasks", query=params)["data"]
114
+
115
+ def cancel(self, id: str) -> bool:
116
+ data = self._t.request("POST", f"/v2/tasks/{_q(id)}/cancel", retry=True)["data"]
117
+ return data.get("status") == "error"
118
+
119
+ def retry(self, id: str) -> dict:
120
+ return self._t.request("POST", f"/v2/tasks/{_q(id)}/retry", retry=False)["data"]
121
+
122
+ def upload(self, file_name: Any, task: dict, filename: str | None = None) -> bool:
123
+ """Datei für einen import/upload-Task hochladen. file_name: Pfad, Bytes oder Datei-Objekt.
124
+
125
+ Nutzt den direkten Upload (result.upload: PUT, danach upload-complete), sonst das Formular (result.form).
126
+ Gibt True zurück, wenn der Task danach fertig ist.
127
+ """
128
+ if task.get("operation") != "import/upload":
129
+ raise SwitchfileError("INVALID_TASK", f"Task {task.get('name') or task.get('id')} is not an import/upload task.")
130
+ file = PreparedFile(file_name, filename)
131
+ result = task.get("result") or {}
132
+ direct, form = result.get("upload"), result.get("form")
133
+ if direct and direct.get("url"):
134
+ headers = {"Content-Type": "application/octet-stream", **safe_upload_headers(direct.get("headers")),
135
+ "Content-Length": str(file.size)}
136
+ with file.open() as fh:
137
+ self._t.send_signed("PUT", direct["url"], lambda: (fh, headers))
138
+ body = {"filename": file.filename} if file.filename else {}
139
+ data = self._t.request("POST", f"/v2/tasks/{_q(task['id'])}/upload-complete", json_body=body, retry=True)["data"]
140
+ return data.get("status") == "finished"
141
+ if form and form.get("url"):
142
+ fields = [(k, str(v)) for k, v in (form.get("parameters") or {}).items()]
143
+
144
+ def body():
145
+ mp = MultipartBody(fields, "file", file, file.filename or "file")
146
+ return mp, {"Content-Type": mp.content_type, "Content-Length": str(mp.length)}
147
+
148
+ res = self._t.send_signed("POST", form["url"], body)
149
+ data = (res or {}).get("data") if isinstance(res, dict) else None
150
+ return (data or self.find(task["id"])).get("status") == "finished"
151
+ raise SwitchfileError("INVALID_TASK", f"Task {task.get('name') or task.get('id')} has no upload URL. "
152
+ "Pass the task from the job returned by Job.create().")
153
+
154
+
155
+ class Users:
156
+ def __init__(self, t: Transport):
157
+ self._t = t
158
+
159
+ def me(self) -> dict:
160
+ return self._t.request("GET", "/v2/users/me")["data"]
161
+
162
+
163
+ class Webhooks:
164
+ @staticmethod
165
+ def verify(payload: str | bytes, signature: str | None, signing_secret: str) -> bool:
166
+ """True, wenn die Signatur (Header CloudConvert-Signature bzw. Switchfile-Signature) zum rohen Body passt."""
167
+ if not signature or not signing_secret:
168
+ return False
169
+ raw = payload.encode() if isinstance(payload, str) else bytes(payload)
170
+ expected = hmac.new(signing_secret.encode(), raw, hashlib.sha256).hexdigest()
171
+ # Als Bytes vergleichen: compare_digest wirft bei str mit Nicht-ASCII-Zeichen TypeError statt False.
172
+ given = signature.strip().lower().encode("utf-8", "replace")
173
+ return hmac.compare_digest(expected.encode("ascii"), given)
174
+
175
+ @classmethod
176
+ def construct_event(cls, payload: str | bytes, signature: str | None, signing_secret: str) -> dict:
177
+ """Signatur prüfen und das Ereignis parsen. Wirft SwitchfileError INVALID_SIGNATURE bzw. INVALID_PAYLOAD."""
178
+ if not cls.verify(payload, signature, signing_secret):
179
+ raise SwitchfileError("INVALID_SIGNATURE", "The webhook signature does not match the payload.")
180
+ try:
181
+ event = json.loads(payload)
182
+ except ValueError as e:
183
+ raise SwitchfileError("INVALID_PAYLOAD", "The webhook payload is not valid JSON.") from e
184
+ if not isinstance(event, dict) or not isinstance(event.get("event"), str):
185
+ raise SwitchfileError("INVALID_PAYLOAD", "The webhook payload has no event name.")
186
+ return event
187
+
188
+
189
+ class Conversions:
190
+ def __init__(self, t: Transport, poll: float):
191
+ self._t, self._poll = t, poll
192
+
193
+ def find(self, id: str, wait: float | None = None) -> dict:
194
+ """Umwandlung lesen. wait: bis zu so vielen Sekunden auf das Ende warten (höchstens 30)."""
195
+ w = min(_WAIT_SECONDS, int(wait + 0.999)) if wait and wait > 0 else None
196
+ return self._t.request("GET", f"/v1/conversions/{_q(id)}", query={"wait": w},
197
+ timeout=self._t.timeout + (w or 0))
198
+
199
+ show = find
200
+
201
+ def list(self, ids: list[str]) -> list:
202
+ return self._t.request("GET", "/v1/conversions", query={"ids": ",".join(ids)})["data"]
203
+
204
+ def wait(self, id: str, timeout: float | None = None) -> dict:
205
+ """Wartet, bis die Umwandlung fertig ist. Wirft ConversionFailedError, wenn sie scheitert."""
206
+ c = _poll(lambda: self.find(id, wait=_WAIT_SECONDS), _conv_done, timeout, self._poll)
207
+ return _assert_finished(c)
208
+
209
+ def delete(self, id: str) -> bool:
210
+ self._t.request("DELETE", f"/v1/conversions/{_q(id)}")
211
+ return True
212
+
213
+
214
+ class Account:
215
+ def __init__(self, t: Transport):
216
+ self._t = t
217
+
218
+ def get(self) -> dict:
219
+ return self._t.request("GET", "/v1/account")
220
+
221
+ def update(self, webhook_url: Any = _UNSET, *, no_store: bool | None = None) -> dict:
222
+ """Webhook-URL setzen (None entfernt sie) und/oder no_store=True für „immer ohne Speicherung“."""
223
+ body: dict[str, Any] = {}
224
+ if webhook_url is not _UNSET:
225
+ body["webhook_url"] = webhook_url
226
+ if no_store is not None:
227
+ body["no_store"] = bool(no_store)
228
+ if not body:
229
+ raise SwitchfileError("INVALID_DATA", "Pass webhook_url or no_store.")
230
+ return self._t.request("PATCH", "/v1/account", json_body=body, retry=True)
231
+
232
+
233
+ @dataclass
234
+ class NoStoreResult:
235
+ """Ergebnis einer Umwandlung ohne Speicherung. Gespeichert ist nichts davon, nur der Eintrag im Nutzungsverlauf."""
236
+
237
+ #: Die Datei; mehrere Ergebnisse kommen als ein ZIP.
238
+ data: bytes
239
+ filename: str | None
240
+ content_type: str
241
+ #: ID des Eintrags im Nutzungsverlauf (GET /v1/conversions/{id} kennt sie nicht).
242
+ conversion_id: str | None
243
+ credits: int | None
244
+
245
+
246
+ def _assert_finished(c: dict) -> dict:
247
+ if c.get("status") == "failed":
248
+ err = c.get("error") or {}
249
+ raise ConversionFailedError(err.get("code") or "CONVERSION_FAILED", err.get("message") or "The conversion failed.", c)
250
+ return c
251
+
252
+
253
+ class Client:
254
+ """switchfile-Client.
255
+
256
+ ``Client(api_key=None, sandbox=False, base_url=None, ...)``. Ohne api_key gilt SWITCHFILE_API_KEY, ohne base_url
257
+ SWITCHFILE_API_URL bzw. https://api.switchfile.io. Mit ``sandbox=True`` werden Live-Keys abgelehnt (bei
258
+ switchfile entscheidet der Key: ``sf_test_…`` kostet keine Credits).
259
+ """
260
+
261
+ def __init__(self, api_key: str | None = None, sandbox: bool = False, base_url: str | None = None,
262
+ sandbox_base_url: str | None = None, timeout: float = 60.0, max_retries: int = 3,
263
+ poll_interval: float = 1.0, user_agent: str | None = None, **_ignored: Any):
264
+ self.api_key = (api_key if api_key is not None else os.environ.get("SWITCHFILE_API_KEY", "")).strip()
265
+ self.sandbox = bool(sandbox)
266
+ base = base_url or os.environ.get("SWITCHFILE_API_URL") or DEFAULT_BASE_URL
267
+ self.base_url = _normalize_base(sandbox_base_url or base if self.sandbox else base)
268
+ if self.sandbox and _LIVE_KEY.match(self.api_key):
269
+ raise SwitchfileError("SANDBOX_KEY_REQUIRED",
270
+ "Sandbox mode needs a sandbox key (sf_test_...). Create one in the dashboard under API keys.")
271
+ ua = f"switchfile-python/{__version__}" + (f" {user_agent}" if user_agent else "")
272
+ self._t = Transport(self.api_key, self.base_url, timeout, max_retries, ua)
273
+ self.jobs = Jobs(self._t, poll_interval)
274
+ self.tasks = Tasks(self._t, poll_interval)
275
+ self.users = Users(self._t)
276
+ self.webhooks = Webhooks()
277
+ self.conversions = Conversions(self._t, poll_interval)
278
+ self.account = Account(self._t)
279
+
280
+ # ---------- /v1 ----------
281
+ def convert(self, file: Any, to: str | None = None, options: dict | None = None, *, filename: str | None = None,
282
+ input_format: str | None = None, webhook_url: str | None = None, wait: bool = True,
283
+ timeout: float = 1800, idempotency_key: str | None = None, multipart_limit: int = MULTIPART_LIMIT,
284
+ preset: str | None = None) -> dict:
285
+ """Datei umwandeln und (Standard) bis zum Ende warten.
286
+
287
+ file: Pfad, Bytes, Datei-Objekt, eine http(s)-URL als String oder {"url": ...}, {"upload_id": ...},
288
+ {"from": conversion_id}. preset: ID oder Name eines Presets aus dem Dashboard (setzt Zielformat und
289
+ Einstellungen; to darf dann fehlen, options überschreiben einzelne Werte). Wirft ConversionFailedError, wenn die
290
+ Umwandlung scheitert.
291
+ """
292
+ if (not to or not isinstance(to, str)) and not preset:
293
+ raise SwitchfileError("INVALID_DATA", "The target format (to) is missing. Pass a format or a preset.")
294
+ key = idempotency_key or str(uuid.uuid4())
295
+ fields: dict[str, Any] = {}
296
+ if to:
297
+ fields["to"] = to
298
+ if preset:
299
+ fields["preset"] = preset
300
+ if options:
301
+ fields["options"] = options
302
+ if input_format:
303
+ fields["input_format"] = input_format
304
+ if webhook_url:
305
+ fields["webhook_url"] = webhook_url
306
+ query = {"wait": _WAIT_SECONDS if wait else None}
307
+ req_timeout = self._t.timeout + (_WAIT_SECONDS if wait else 0)
308
+
309
+ src = _json_source(file)
310
+ if src is not None:
311
+ body = {**src, **fields, **({"filename": filename} if filename else {})}
312
+ conv = self._t.request("POST", "/v1/convert", json_body=body, query=query, idempotency_key=key, timeout=req_timeout)
313
+ else:
314
+ pf = PreparedFile(file, filename)
315
+ name = pf.filename
316
+ if pf.size <= multipart_limit:
317
+ form = [("to", to)] if to else []
318
+ if preset:
319
+ form.append(("preset", preset))
320
+ if options:
321
+ form.append(("options", json.dumps(options)))
322
+ if input_format:
323
+ form.append(("input_format", input_format))
324
+ if webhook_url:
325
+ form.append(("webhook_url", webhook_url))
326
+ if name:
327
+ form.append(("filename", name))
328
+
329
+ def body_factory():
330
+ mp = MultipartBody(form, "file", pf, name or "file")
331
+ return mp, {"Content-Type": mp.content_type, "Content-Length": str(mp.length)}
332
+
333
+ conv = self._t.request("POST", "/v1/convert", body_factory=body_factory, query=query,
334
+ idempotency_key=key, timeout=max(req_timeout, 1800))
335
+ else:
336
+ up = self._t.request("POST", "/v1/uploads", json_body={**({"filename": name} if name else {}), "size": pf.size},
337
+ idempotency_key=f"{key}:upload")
338
+ headers = {"Content-Type": "application/octet-stream", **safe_upload_headers(up.get("headers")),
339
+ "Content-Length": str(pf.size)}
340
+ with pf.open() as fh:
341
+ self._t.send_signed("PUT", up["url"], lambda: (fh, headers))
342
+ body = {"upload_id": up["upload_id"], **fields, **({"filename": name} if name else {})}
343
+ conv = self._t.request("POST", "/v1/convert", json_body=body, query=query, idempotency_key=key, timeout=req_timeout)
344
+ if not wait:
345
+ return conv
346
+ if _conv_done(conv.get("status")):
347
+ return _assert_finished(conv)
348
+ return self.conversions.wait(conv["id"], timeout=timeout)
349
+
350
+ def convert_without_storage(self, file: Any, to: str | None = None, options: dict | None = None, *,
351
+ filename: str | None = None, input_format: str | None = None,
352
+ preset: str | None = None, timeout: float | None = None) -> NoStoreResult:
353
+ """Datei ohne Speicherung umwandeln (POST /v1/convert?store=false): Datei im Request, Ergebnis in der Antwort,
354
+ weder Eingabe noch Ergebnis bleiben bei switchfile. file: Pfad, Bytes oder Datei-Objekt bis 50 MB. Ohne
355
+ Idempotency-Key, weil die API das Ergebnis nicht behält; wiederholt wird nur, wenn sie nichts umgewandelt hat.
356
+ timeout: Standard NO_STORE_TIMEOUT (390 s) oder das längere Zeitlimit des Clients."""
357
+ if (not to or not isinstance(to, str)) and not preset:
358
+ raise SwitchfileError("INVALID_DATA", "The target format (to) is missing. Pass a format or a preset.")
359
+ if isinstance(file, dict) or (isinstance(file, str) and re.match(r"^https?://", file, re.I)):
360
+ raise SwitchfileError("INVALID_DATA", "Without storage, send the file itself. A URL or an ID needs storage.")
361
+ pf = PreparedFile(file, filename)
362
+ if pf.size > NO_STORE_LIMIT:
363
+ raise SwitchfileError("FILE_TOO_LARGE", f"Without storage, files can be at most {NO_STORE_LIMIT // 1024 // 1024} MB.")
364
+ name = pf.filename
365
+ form = [("to", to)] if to else []
366
+ if preset:
367
+ form.append(("preset", preset))
368
+ if options:
369
+ form.append(("options", json.dumps(options)))
370
+ if input_format:
371
+ form.append(("input_format", input_format))
372
+ if name:
373
+ form.append(("filename", name))
374
+
375
+ def body_factory():
376
+ mp = MultipartBody(form, "file", pf, name or "file")
377
+ return mp, {"Content-Type": mp.content_type, "Content-Length": str(mp.length)}
378
+
379
+ data, headers = self._t.request("POST", "/v1/convert", body_factory=body_factory, query={"store": "false"},
380
+ headers={"Switchfile-Store": "false"}, binary=True,
381
+ timeout=timeout or max(self._t.timeout, NO_STORE_TIMEOUT))
382
+ credits = headers.get("X-Credits")
383
+ return NoStoreResult(
384
+ data=data,
385
+ filename=_filename_of(headers.get("Content-Disposition")),
386
+ content_type=headers.get("Content-Type") or "application/octet-stream",
387
+ conversion_id=headers.get("X-Conversion-Id"),
388
+ credits=int(credits) if credits and credits.isdigit() else None,
389
+ )
390
+
391
+ def formats(self, input: str | None = None) -> dict:
392
+ """Alle Formate mit Zielen und Vorschlägen (ohne Key)."""
393
+ return self._t.request("GET", "/v1/formats", query={"input": input}, auth=False)
394
+
395
+ def download(self, url: Any = None, filename: str | os.PathLike | None = None) -> bytes | str:
396
+ """Ergebnis laden: URL, Datei aus result.files oder fertige Umwandlung (erste Datei). Mit filename in die Datei
397
+ schreiben (Rückgabe: Pfad), sonst kommen die Bytes zurück."""
398
+ if isinstance(url, dict):
399
+ url = url.get("download_url") or url.get("url")
400
+ if not url:
401
+ raise SwitchfileError("NO_FILE", "Nothing to download: the conversion has no result file yet.")
402
+ return self._t.download(url, filename)
403
+
404
+
405
+ def _filename_of(header: str | None) -> str | None:
406
+ """Dateiname aus Content-Disposition (filename* vor filename)."""
407
+ if not header:
408
+ return None
409
+ star = re.search(r"filename\*=UTF-8''([^;]+)", header, re.I)
410
+ if star:
411
+ try:
412
+ return urllib.parse.unquote(star.group(1).strip(), errors="strict")
413
+ except UnicodeDecodeError:
414
+ pass
415
+ plain = re.search(r'filename="([^"]*)"', header, re.I) or re.search(r"filename=([^;]+)", header, re.I)
416
+ return plain.group(1).strip() if plain else None
417
+
418
+
419
+ def _json_source(file: Any) -> dict | None:
420
+ if isinstance(file, str) and re.match(r"^https?://", file, re.I):
421
+ return {"url": file}
422
+ if isinstance(file, dict):
423
+ for k in ("url", "upload_id", "from"):
424
+ if isinstance(file.get(k), str):
425
+ return {k: file[k]}
426
+ raise SwitchfileError("INVALID_SOURCE", "Pass {'url': ...}, {'upload_id': ...} or {'from': ...}.")
427
+ return None
428
+
429
+
430
+ def _normalize_base(raw: str) -> str:
431
+ p = urllib.parse.urlsplit(raw)
432
+ if p.scheme not in ("http", "https") or not p.netloc:
433
+ raise SwitchfileError("INVALID_BASE_URL", f"Invalid base URL: {raw}")
434
+ return raw.rstrip("/")
switchfile/errors.py ADDED
@@ -0,0 +1,59 @@
1
+ """Fehler des SDKs. /v1 antwortet mit application/problem+json (code, detail), /v2 im CloudConvert-Format
2
+ (code, message). Beides wird zu SwitchfileError mit stabilem ``code``."""
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ class SwitchfileError(Exception):
9
+ """Fehler der API oder des Clients.
10
+
11
+ Attribute: ``code`` (z. B. INVALID_DATA, UNAUTHENTICATED, NOT_FOUND, RATE_LIMIT_EXCEEDED, NETWORK_ERROR),
12
+ ``status`` (HTTP-Status oder None), ``errors`` (Fehler je Feld), ``retry_after`` (Sekunden), ``body``.
13
+ """
14
+
15
+ def __init__(
16
+ self,
17
+ code: str,
18
+ message: str,
19
+ status: int | None = None,
20
+ errors: dict[str, list[str]] | None = None,
21
+ retry_after: float | None = None,
22
+ body: Any = None,
23
+ ):
24
+ super().__init__(message)
25
+ self.code = code
26
+ self.message = message
27
+ self.status = status
28
+ self.errors = errors
29
+ self.retry_after = retry_after
30
+ self.body = body
31
+
32
+ def __repr__(self) -> str:
33
+ return f"SwitchfileError(code={self.code!r}, status={self.status!r}, message={self.message!r})"
34
+
35
+
36
+ class ConversionFailedError(SwitchfileError):
37
+ """Eine Umwandlung (/v1) ist gescheitert. ``conversion`` enthält das ganze Objekt."""
38
+
39
+ def __init__(self, code: str, message: str, conversion: dict):
40
+ super().__init__(code, message, body=conversion)
41
+ self.conversion = conversion
42
+
43
+
44
+ _FALLBACK = {401: "UNAUTHENTICATED", 403: "FORBIDDEN", 404: "NOT_FOUND", 413: "FILE_TOO_LARGE", 422: "INVALID_DATA",
45
+ 429: "RATE_LIMIT_EXCEEDED"}
46
+
47
+
48
+ def error_from_response(status: int, body: Any, retry_after: str | None) -> SwitchfileError:
49
+ b = body if isinstance(body, dict) else {}
50
+ code = b.get("code") if isinstance(b.get("code"), str) and b.get("code") else (
51
+ "INTERNAL_ERROR" if status >= 500 else _FALLBACK.get(status, "HTTP_ERROR"))
52
+ message = next((b[k] for k in ("detail", "message", "title") if isinstance(b.get(k), str) and b.get(k)),
53
+ f"The switchfile API answered with HTTP {status}.")
54
+ errors = b.get("errors") if isinstance(b.get("errors"), dict) else None
55
+ try:
56
+ ra = float(retry_after) if retry_after is not None else None
57
+ except ValueError:
58
+ ra = None
59
+ return SwitchfileError(code, message, status=status, errors=errors, retry_after=ra, body=body)
switchfile/py.typed ADDED
File without changes
switchfile/version.py ADDED
@@ -0,0 +1,2 @@
1
+ # Gleich der Version in pyproject.toml (tests/test_client.py prüft das).
2
+ __version__ = "0.1.0"
@@ -0,0 +1,266 @@
1
+ Metadata-Version: 2.4
2
+ Name: switchfile-sdk
3
+ Version: 0.1.0
4
+ Summary: Python SDK for the switchfile file conversion API. Drop-in replacement for the CloudConvert v2 SDK, plus a one-call convert() helper.
5
+ Author-email: "Aura UG (haftungsbeschränkt)" <hello@switchfile.io>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://switchfile.io/developers#sdks
8
+ Project-URL: Documentation, https://switchfile.io/developers
9
+ Keywords: switchfile,file conversion,converter,cloudconvert,api,sdk
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Topic :: Multimedia
16
+ Classifier: Topic :: Software Development :: Libraries
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Provides-Extra: test
22
+ Requires-Dist: pytest>=8; extra == "test"
23
+ Dynamic: license-file
24
+
25
+ # switchfile for Python
26
+
27
+ The official Python SDK for the [switchfile](https://switchfile.io) file conversion API.
28
+
29
+ - The same functions and return values as the CloudConvert Python SDK (`Job.create`, `Job.wait`, `Task.upload`,
30
+ `Webhook.verify`, `download` and more), so existing code keeps working after you change the import.
31
+ - `convert()` for the simple API: one call with a file and a target format, and you get the finished conversion back.
32
+ - No dependencies. Python 3.10 or newer.
33
+
34
+ ```bash
35
+ pip install switchfile-sdk
36
+ ```
37
+
38
+ ## Moving from CloudConvert
39
+
40
+ Change the import and the configuration. Everything else stays the same.
41
+
42
+ ```diff
43
+ - import cloudconvert
44
+ - cloudconvert.configure(api_key=os.environ["CLOUDCONVERT_API_KEY"], sandbox=False)
45
+ + import switchfile as cloudconvert
46
+ + cloudconvert.configure(api_key=os.environ["SWITCHFILE_API_KEY"], sandbox=False)
47
+ ```
48
+
49
+ Create an API key in the [dashboard](https://switchfile.io/dashboard/keys). Keys starting with `sf_test_` are
50
+ sandbox keys: they never use credits.
51
+
52
+ What is different:
53
+
54
+ | CloudConvert | switchfile |
55
+ |---|---|
56
+ | `sandbox=True` uses a separate sandbox server | The key decides: `sf_test_` keys are sandbox keys. `sandbox=True` refuses live keys, so tests cannot use credits by mistake |
57
+ | `cloudconvert.default()` reads `CLOUDCONVERT_API_KEY` | `switchfile.default()` reads `SWITCHFILE_API_KEY`, `SWITCHFILE_API_URL` and `SWITCHFILE_SANDBOX` |
58
+ | Operations such as `optimize`, `merge`, `capture-website`, `import/s3` | Tasks: `import/url`, `import/upload`, `import/base64`, `convert`, `export/url` |
59
+ | `SignedUrl.sign()` | Not available |
60
+ | `Task.create()`, `Task.delete()` | Not available; create tasks inside a job with `Job.create()` |
61
+ | Errors from `cloudconvert.exceptions` | `switchfile.SwitchfileError` with `code`, `status` and `errors` |
62
+
63
+ ## Convert a file in one call
64
+
65
+ ```python
66
+ import switchfile
67
+
68
+ switchfile.configure(api_key=os.environ["SWITCHFILE_API_KEY"])
69
+
70
+ conversion = switchfile.convert("talk.mkv", "mp4")
71
+ switchfile.download(conversion, "talk.mp4")
72
+ ```
73
+
74
+ `convert(file, to, options)` takes a file path, `bytes`, an open file, a public URL as a string
75
+ (`"https://..."`) or `{"url": ...}`, `{"upload_id": ...}` for a file sent through `/v1/uploads`, or
76
+ `{"from": conversion_id}` to convert an earlier input again.
77
+
78
+ Files up to 50 MB are sent with the request, larger files (up to your plan's limit) go through an upload URL and are
79
+ read from disk while they upload. The call waits until the conversion is done and raises `ConversionFailedError` if
80
+ it fails.
81
+
82
+ ```python
83
+ conversion = switchfile.convert(
84
+ data, # bytes
85
+ "jpg",
86
+ {"quality": 80}, # settings for the conversion
87
+ filename="photo.heic", # name of the input (needed for bytes)
88
+ webhook_url="https://example.com/hooks/switchfile",
89
+ wait=True, # False: return right away with status queued
90
+ timeout=600, # stop waiting after 10 minutes
91
+ )
92
+
93
+ for f in conversion["result"]["files"]: # e.g. one image per page
94
+ switchfile.download(f["url"], f["filename"])
95
+ data = switchfile.download(conversion) # bytes without a file name
96
+ ```
97
+
98
+ A preset from your dashboard sets the target format and its settings. Leave out `to` and pass the ID or name;
99
+ settings in `options` override single values of the preset:
100
+
101
+ ```python
102
+ switchfile.convert("photo.heic", preset="Web images")
103
+ switchfile.convert("photo.heic", options={"quality": 90}, preset="Web images")
104
+ ```
105
+
106
+ ### Without storage
107
+
108
+ `convert_without_storage` sends the file with the request and gets the result back in the response. switchfile keeps
109
+ neither of them, only an entry in the usage history without the file name. Files can be up to 50 MB, several result
110
+ files come back as one ZIP.
111
+
112
+ ```python
113
+ result = switchfile.convert_without_storage("invoice.docx", "pdf")
114
+ with open(result.filename or "invoice.pdf", "wb") as f:
115
+ f.write(result.data)
116
+ result.conversion_id # the entry in the usage history
117
+ result.credits # credits used
118
+ ```
119
+
120
+ For jobs, pass `"store": False` to `Job.create` with three tasks, one of each: `import/base64`, `convert` and
121
+ `export/url`. The job comes back finished, and each file of the export task has its bytes as base64 in `content`.
122
+ `client.account.update(no_store=True)` makes the account refuse every request that would store a file. Keep using
123
+ `convert_without_storage` and `"store": False` after that: the other calls expect a stored conversion and are rejected.
124
+ Details are in the [guide](https://switchfile.io/docs/no-store).
125
+
126
+ More calls for the simple API:
127
+
128
+ ```python
129
+ switchfile.Conversion.find(id) # status and result
130
+ switchfile.Conversion.wait(id) # until finished
131
+ switchfile.Conversion.list([id1, id2]) # up to 50 at once
132
+ switchfile.Conversion.delete(id) # delete input and result now
133
+ switchfile.formats("heic") # targets for one input format (no key needed)
134
+ switchfile.Account.get() # credits, free quota, webhook signing secret
135
+ switchfile.Account.update("https://example.com/hooks/switchfile")
136
+ ```
137
+
138
+ ## Jobs and tasks
139
+
140
+ ```python
141
+ job = switchfile.Job.create(payload={
142
+ "tasks": {
143
+ "import-my-file": {"operation": "import/url", "url": "https://example.com/report.docx"},
144
+ "convert-my-file": {"operation": "convert", "input": "import-my-file", "output_format": "pdf"},
145
+ "export-my-file": {"operation": "export/url", "input": "convert-my-file"},
146
+ },
147
+ "tag": "order-1234",
148
+ })
149
+
150
+ job = switchfile.Job.wait(id=job["id"])
151
+ for file in switchfile.Job.get_export_urls(job):
152
+ switchfile.download(filename=file["filename"], url=file["url"])
153
+ ```
154
+
155
+ `Job.wait()` returns the job when it is `finished` or `error`; it does not raise for a failed job. Check
156
+ `job["status"]` and the `code` and `message` of each task.
157
+
158
+ ### Uploading files
159
+
160
+ ```python
161
+ job = switchfile.Job.create(payload={
162
+ "tasks": {
163
+ "upload-my-file": {"operation": "import/upload"},
164
+ "convert-my-file": {"operation": "convert", "input": "upload-my-file", "output_format": "pdf"},
165
+ "export-my-file": {"operation": "export/url", "input": "convert-my-file"},
166
+ }
167
+ })
168
+
169
+ upload_task = [t for t in job["tasks"] if t["name"] == "upload-my-file"][0]
170
+ switchfile.Task.upload(file_name="path/to/report.docx", task=upload_task)
171
+ ```
172
+
173
+ `Task.upload()` also takes `bytes` or an open file (pass `filename=` for bytes).
174
+
175
+ ### All functions
176
+
177
+ | Function | Returns |
178
+ |---|---|
179
+ | `Job.create(payload)` | job `dict` |
180
+ | `Job.find(id)`, `Job.show(id)` | job `dict` |
181
+ | `Job.wait(id, timeout=None)` | job `dict` once it is `finished` or `error` |
182
+ | `Job.all(params)` | `list` of jobs, filters such as `{"filter[tag]": "..."}` |
183
+ | `Job.delete(id)` | `True` |
184
+ | `Job.get_export_urls(job)` | files of all finished `export/url` tasks |
185
+ | `Task.find(id)`, `Task.show(id)` | task `dict` |
186
+ | `Task.wait(id)` | task `dict` |
187
+ | `Task.all(params)` | `list` of tasks |
188
+ | `Task.cancel(id)` | `True` if the task is cancelled |
189
+ | `Task.retry(id)` | the new task |
190
+ | `Task.upload(file_name, task)` | `True` if the upload finished the task |
191
+ | `User.me()` | account with credits and today's free quota |
192
+ | `Webhook.verify(payload, signature, secret)` | `bool` |
193
+ | `Webhook.construct_event(payload, signature, secret)` | the parsed event, raises if the signature is wrong |
194
+ | `download(url, filename)` | the path, or `bytes` without a file name |
195
+
196
+ ### Several keys
197
+
198
+ The functions above use one client for the whole program. For several keys or servers, create clients:
199
+
200
+ ```python
201
+ client = switchfile.Client(api_key="sf_live_...", timeout=60, max_retries=3)
202
+ job = client.jobs.create({...})
203
+ client.tasks.upload("report.docx", upload_task)
204
+ conversion = client.convert("report.docx", "pdf")
205
+ ```
206
+
207
+ ## Webhooks
208
+
209
+ ```python
210
+ from flask import Flask, request
211
+
212
+ app = Flask(__name__)
213
+
214
+ @app.post("/hooks/switchfile")
215
+ def hook():
216
+ signature = request.headers.get("CloudConvert-Signature") or request.headers.get("Switchfile-Signature")
217
+ if not switchfile.Webhook.verify(request.get_data(), signature, os.environ["SWITCHFILE_WEBHOOK_SECRET"]):
218
+ return "", 400
219
+ event = request.get_json()
220
+ # job.finished / job.failed: event["job"]
221
+ # conversion.finished / conversion.failed: event["data"]
222
+ return "", 200
223
+ ```
224
+
225
+ Jobs send `job.finished` and `job.failed` with the header `CloudConvert-Signature`. Conversions from `convert()` send
226
+ `conversion.finished` and `conversion.failed` with `Switchfile-Signature`. Both are signed the same way. The signing
227
+ secret is `webhook_signing_secret` from `switchfile.Account.get()` and in the dashboard.
228
+
229
+ ## Settings
230
+
231
+ ```python
232
+ switchfile.configure(
233
+ api_key="sf_test_...", # default: SWITCHFILE_API_KEY
234
+ base_url="https://api.switchfile.io", # default: SWITCHFILE_API_URL or https://api.switchfile.io
235
+ sandbox=True, # only accept sandbox keys (sf_test_)
236
+ timeout=60, # per request, in seconds
237
+ max_retries=3, # network errors, 429 and 5xx
238
+ user_agent="my-app/1.0", # added to the User-Agent header
239
+ )
240
+ ```
241
+
242
+ Requests are repeated after network errors, `429 RATE_LIMIT_EXCEEDED` (after `Retry-After`) and server errors.
243
+ `convert()` sends an `Idempotency-Key`, so a repeated request never creates a second conversion.
244
+
245
+ ## Errors
246
+
247
+ Every error is a `switchfile.SwitchfileError` with a stable `code` (for example `UNAUTHENTICATED`, `INVALID_DATA`,
248
+ `UNSUPPORTED_PAIR`, `CREDITS_EXCEEDED`, `RATE_LIMIT_EXCEEDED`), the HTTP `status` and, for `INVALID_DATA`, `errors`
249
+ per field. A failed conversion from `convert()` raises `switchfile.ConversionFailedError` with the conversion in
250
+ `conversion`.
251
+
252
+ ```python
253
+ try:
254
+ switchfile.convert("notes.txt", "mp4")
255
+ except switchfile.ConversionFailedError as e:
256
+ print("failed:", e.code, e.conversion["id"])
257
+ except switchfile.SwitchfileError as e:
258
+ print(e.status, e.code, e.message)
259
+ ```
260
+
261
+ ## Links
262
+
263
+ - [API documentation](https://switchfile.io/developers)
264
+ - [Supported formats](https://switchfile.io/convert)
265
+
266
+ License: MIT, Aura UG (haftungsbeschränkt).
@@ -0,0 +1,11 @@
1
+ switchfile/__init__.py,sha256=3AczTZcCrHv8ylL7i2G_ZPxuz9WcL5uh_lOvGCOZAsU,5587
2
+ switchfile/_http.py,sha256=XpMymdstFxEv6t8V9FCveMKr1-UOlt8ODsy7lqoPWYU,14428
3
+ switchfile/client.py,sha256=GGwblbTftSBVbxiW3yYTHli_bES2bAzBxynEHz0_SDs,20953
4
+ switchfile/errors.py,sha256=r18diL07b_x0MgRgwwM7I4gdyKdHCqVXxHYEPwBHBxs,2370
5
+ switchfile/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ switchfile/version.py,sha256=2TCbceqWBqs1r8sSPqT5Nh4zssuVNt_IRWnYhjwBH8U,96
7
+ switchfile_sdk-0.1.0.dist-info/licenses/LICENSE,sha256=f8v80ahpE8MC3QdDx4mCs_hyx1t0SP2_lIqN-kwRxaI,1086
8
+ switchfile_sdk-0.1.0.dist-info/METADATA,sha256=jN0hyMZ_x6-HtihBywtoM01LEU2ElulAmvoIEXG-k2I,11189
9
+ switchfile_sdk-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
10
+ switchfile_sdk-0.1.0.dist-info/top_level.txt,sha256=LN05KxK9SPIjXw8WvaGIYG1ELsAtVueorHJlfB7U8ws,11
11
+ switchfile_sdk-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Aura UG (haftungsbeschränkt)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ switchfile