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 +178 -0
- switchfile/_http.py +314 -0
- switchfile/client.py +434 -0
- switchfile/errors.py +59 -0
- switchfile/py.typed +0 -0
- switchfile/version.py +2 -0
- switchfile_sdk-0.1.0.dist-info/METADATA +266 -0
- switchfile_sdk-0.1.0.dist-info/RECORD +11 -0
- switchfile_sdk-0.1.0.dist-info/WHEEL +5 -0
- switchfile_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
- switchfile_sdk-0.1.0.dist-info/top_level.txt +1 -0
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,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,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
|