dipsac 0.1.1__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.
- dipsac/__init__.py +41 -0
- dipsac/_client.py +263 -0
- dipsac/_errors.py +182 -0
- dipsac/_files.py +101 -0
- dipsac/_models.py +195 -0
- dipsac/_resources.py +2707 -0
- dipsac/_version.py +1 -0
- dipsac/py.typed +0 -0
- dipsac-0.1.1.dist-info/METADATA +242 -0
- dipsac-0.1.1.dist-info/RECORD +12 -0
- dipsac-0.1.1.dist-info/WHEEL +4 -0
- dipsac-0.1.1.dist-info/licenses/LICENSE +21 -0
dipsac/__init__.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""The official Python library for the DIPSAC API.
|
|
2
|
+
|
|
3
|
+
from dipsac import Dipsac
|
|
4
|
+
|
|
5
|
+
client = Dipsac(api_key="dps_...") # or set DIPSAC_API_KEY
|
|
6
|
+
client.pdf.merge(["a.pdf", "b.pdf"]).save("merged.pdf")
|
|
7
|
+
|
|
8
|
+
Docs: https://dipsac.com/dashboard/reference
|
|
9
|
+
"""
|
|
10
|
+
from ._client import DEFAULT_BASE_URL, AsyncDipsac, Dipsac
|
|
11
|
+
from ._errors import (
|
|
12
|
+
APIConnectionError,
|
|
13
|
+
APIStatusError,
|
|
14
|
+
APITimeoutError,
|
|
15
|
+
AuthenticationError,
|
|
16
|
+
BadRequestError,
|
|
17
|
+
ConflictError,
|
|
18
|
+
DipsacError,
|
|
19
|
+
InternalServerError,
|
|
20
|
+
JobFailedError,
|
|
21
|
+
JobTimeoutError,
|
|
22
|
+
NotFoundError,
|
|
23
|
+
PayloadTooLargeError,
|
|
24
|
+
PaymentRequiredError,
|
|
25
|
+
PermissionDeniedError,
|
|
26
|
+
RateLimitError,
|
|
27
|
+
UnprocessableEntityError,
|
|
28
|
+
UnsupportedMediaTypeError,
|
|
29
|
+
)
|
|
30
|
+
from ._files import FileInput
|
|
31
|
+
from ._models import AsyncJob, DipsacFile, Job
|
|
32
|
+
from ._version import __version__
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"Dipsac", "AsyncDipsac", "DipsacFile", "Job", "AsyncJob", "FileInput", "DEFAULT_BASE_URL",
|
|
36
|
+
"DipsacError", "APIConnectionError", "APITimeoutError", "APIStatusError", "BadRequestError",
|
|
37
|
+
"AuthenticationError", "PaymentRequiredError", "PermissionDeniedError", "NotFoundError",
|
|
38
|
+
"ConflictError", "PayloadTooLargeError", "UnsupportedMediaTypeError",
|
|
39
|
+
"UnprocessableEntityError", "RateLimitError", "InternalServerError", "JobFailedError",
|
|
40
|
+
"JobTimeoutError", "__version__",
|
|
41
|
+
]
|
dipsac/_client.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""The transport: auth, uploads, errors, retries. One method per endpoint lives in
|
|
2
|
+
_resources.py, which is generated; everything here is what those methods share.
|
|
3
|
+
|
|
4
|
+
WHAT IS RETRIED, AND WHAT IS DELIBERATELY NOT.
|
|
5
|
+
|
|
6
|
+
* A 429 whose Retry-After is short (the per-second limit; the Free plan allows
|
|
7
|
+
two requests a second) is waited out and sent again. The API refused it
|
|
8
|
+
before doing any work, so sending it again cannot do the work twice.
|
|
9
|
+
* A 429 that asks for a long wait is the plan's MONTHLY limit. Waiting a day
|
|
10
|
+
inside a function call helps nobody, so it is raised at once.
|
|
11
|
+
* A connection that could not be opened is tried again: nothing was sent.
|
|
12
|
+
* A GET that got 502, 503 or 504, or lost its connection, is tried again.
|
|
13
|
+
* A POST that got a 5xx or lost its connection part-way is NOT. The server may
|
|
14
|
+
have done the work, and some of it (speech) is paid for per call.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import os
|
|
20
|
+
import random
|
|
21
|
+
import time
|
|
22
|
+
from typing import Any, Dict, List, Mapping, Optional, Tuple
|
|
23
|
+
|
|
24
|
+
import httpx
|
|
25
|
+
|
|
26
|
+
from ._errors import APIConnectionError, APITimeoutError, DipsacError, error_for
|
|
27
|
+
from ._files import to_part
|
|
28
|
+
from ._models import AsyncJob, DipsacFile, Job, filename_from
|
|
29
|
+
from ._resources import RESOURCES
|
|
30
|
+
from ._version import __version__
|
|
31
|
+
|
|
32
|
+
DEFAULT_BASE_URL = "https://api.dipsac.com"
|
|
33
|
+
DEFAULT_TIMEOUT = 300.0
|
|
34
|
+
DEFAULT_MAX_RETRIES = 2
|
|
35
|
+
# The longest Retry-After worth sleeping through. The per-second limit asks for
|
|
36
|
+
# 1; the monthly limit asks for 86400.
|
|
37
|
+
MAX_RETRY_AFTER = 30.0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _scalar(value: Any) -> str:
|
|
41
|
+
"""A form or query value as the API reads it."""
|
|
42
|
+
if isinstance(value, bool):
|
|
43
|
+
return "true" if value else "false"
|
|
44
|
+
return str(value)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _clean(values: Optional[Mapping[str, Any]]) -> Dict[str, Any]:
|
|
48
|
+
"""Without the entries the caller left out, so the server's defaults apply."""
|
|
49
|
+
return {k: v for k, v in (values or {}).items() if v is not None}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class _Core:
|
|
53
|
+
"""What the sync and the async client have in common: everything but the wire."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, api_key: Optional[str], base_url: Optional[str], timeout: float,
|
|
56
|
+
max_retries: int, default_headers: Optional[Mapping[str, str]]) -> None:
|
|
57
|
+
api_key = api_key or os.environ.get("DIPSAC_API_KEY")
|
|
58
|
+
if not api_key:
|
|
59
|
+
raise DipsacError(
|
|
60
|
+
"No API key. Pass api_key=\"dps_...\" or set the DIPSAC_API_KEY environment "
|
|
61
|
+
"variable. Keys are made at https://dipsac.com/dashboard/keys")
|
|
62
|
+
self.api_key = api_key
|
|
63
|
+
self.base_url = (base_url or os.environ.get("DIPSAC_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
64
|
+
self.timeout = timeout
|
|
65
|
+
self.max_retries = max(0, int(max_retries))
|
|
66
|
+
self._headers = {"X-API-Key": api_key, "User-Agent": f"dipsac-python/{__version__}",
|
|
67
|
+
"Accept": "*/*", **dict(default_headers or {})}
|
|
68
|
+
|
|
69
|
+
def _build(self, method: str, path: str, *, query: Optional[Mapping[str, Any]],
|
|
70
|
+
json: Optional[Mapping[str, Any]], form: Optional[Mapping[str, Any]],
|
|
71
|
+
files: Optional[Mapping[str, Any]], timeout: Optional[float]) -> Dict[str, Any]:
|
|
72
|
+
kwargs: Dict[str, Any] = {
|
|
73
|
+
"method": method,
|
|
74
|
+
"url": path if path.startswith(("http://", "https://")) else self.base_url + path,
|
|
75
|
+
"timeout": self.timeout if timeout is None else timeout,
|
|
76
|
+
}
|
|
77
|
+
q = {k: _scalar(v) for k, v in _clean(query).items()}
|
|
78
|
+
if q:
|
|
79
|
+
kwargs["params"] = q
|
|
80
|
+
if json is not None:
|
|
81
|
+
kwargs["json"] = _clean(json)
|
|
82
|
+
fields = {k: _scalar(v) for k, v in _clean(form).items()}
|
|
83
|
+
parts: List[Tuple[str, Tuple[str, bytes, str]]] = []
|
|
84
|
+
for name, value in _clean(files).items():
|
|
85
|
+
many = value if isinstance(value, (list, tuple)) and not _is_file_tuple(value) else [value]
|
|
86
|
+
for one in many:
|
|
87
|
+
parts.append((name, to_part(one, name)))
|
|
88
|
+
if parts:
|
|
89
|
+
kwargs["files"] = parts
|
|
90
|
+
if fields:
|
|
91
|
+
kwargs["data"] = fields
|
|
92
|
+
return kwargs
|
|
93
|
+
|
|
94
|
+
def _parse(self, response: httpx.Response) -> Tuple[str, Any]:
|
|
95
|
+
"""("json" | "file" | "job" | "none", value), or raise for an error status."""
|
|
96
|
+
ctype = (response.headers.get("content-type") or "").split(";")[0].strip().lower()
|
|
97
|
+
is_json = ctype == "application/json" or ctype.endswith("+json")
|
|
98
|
+
if response.status_code >= 400:
|
|
99
|
+
body: Any
|
|
100
|
+
try:
|
|
101
|
+
body = response.json() if is_json else response.text
|
|
102
|
+
except ValueError:
|
|
103
|
+
body = response.text
|
|
104
|
+
raise error_for(response.status_code, body, response.headers)
|
|
105
|
+
if is_json:
|
|
106
|
+
data = response.json()
|
|
107
|
+
if response.status_code == 202 and isinstance(data, dict) and data.get("job_id"):
|
|
108
|
+
return "job", data
|
|
109
|
+
return "json", data
|
|
110
|
+
if not response.content:
|
|
111
|
+
return "none", None
|
|
112
|
+
return "file", DipsacFile(response.content, content_type=ctype or "application/octet-stream",
|
|
113
|
+
filename=filename_from(response.headers), headers=response.headers)
|
|
114
|
+
|
|
115
|
+
def _retry_in(self, attempt: int, method: str, *, response: Optional[httpx.Response] = None,
|
|
116
|
+
exc: Optional[BaseException] = None) -> Optional[float]:
|
|
117
|
+
"""Seconds to wait before another try, or None to give up. See the header."""
|
|
118
|
+
if attempt >= self.max_retries:
|
|
119
|
+
return None
|
|
120
|
+
backoff = min(8.0, 0.5 * (2 ** attempt)) * (0.75 + random.random() / 2)
|
|
121
|
+
if exc is not None:
|
|
122
|
+
never_sent = isinstance(exc, (httpx.ConnectError, httpx.ConnectTimeout))
|
|
123
|
+
return backoff if never_sent or method == "GET" else None
|
|
124
|
+
assert response is not None
|
|
125
|
+
if response.status_code == 429:
|
|
126
|
+
try:
|
|
127
|
+
asked = float(response.headers.get("retry-after", "1"))
|
|
128
|
+
except ValueError:
|
|
129
|
+
asked = 1.0
|
|
130
|
+
return max(asked, 0.2) if asked <= MAX_RETRY_AFTER else None
|
|
131
|
+
if response.status_code in (502, 503, 504) and method == "GET":
|
|
132
|
+
return backoff
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _is_file_tuple(value: Any) -> bool:
|
|
137
|
+
"""("name.pdf", content[, type]) is ONE file, not a list of two or three."""
|
|
138
|
+
return (isinstance(value, tuple) and len(value) in (2, 3) and isinstance(value[0], str)
|
|
139
|
+
and not isinstance(value[1], (str, os.PathLike)) and (len(value) == 2 or isinstance(value[2], str)))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _wrap(exc: httpx.HTTPError) -> APIConnectionError:
|
|
143
|
+
if isinstance(exc, httpx.TimeoutException):
|
|
144
|
+
return APITimeoutError(f"No answer from the API within the timeout ({type(exc).__name__}).")
|
|
145
|
+
return APIConnectionError(f"Could not reach the API ({type(exc).__name__}: {exc}).")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class Dipsac(_Core):
|
|
149
|
+
"""The DIPSAC API.
|
|
150
|
+
|
|
151
|
+
from dipsac import Dipsac
|
|
152
|
+
|
|
153
|
+
client = Dipsac(api_key="dps_...") # or set DIPSAC_API_KEY
|
|
154
|
+
client.pdf.merge(["a.pdf", "b.pdf"]).save("merged.pdf")
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
api_key: your key; read from DIPSAC_API_KEY when left out.
|
|
158
|
+
base_url: the API's address; https://api.dipsac.com unless set.
|
|
159
|
+
timeout: seconds to wait for an answer (default 300; conversions take time).
|
|
160
|
+
max_retries: how many times a refused or unsent request is tried again.
|
|
161
|
+
default_headers: extra headers for every request.
|
|
162
|
+
http_client: your own httpx.Client, e.g. for a proxy.
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
def __init__(self, api_key: Optional[str] = None, *, base_url: Optional[str] = None,
|
|
166
|
+
timeout: float = DEFAULT_TIMEOUT, max_retries: int = DEFAULT_MAX_RETRIES,
|
|
167
|
+
default_headers: Optional[Mapping[str, str]] = None,
|
|
168
|
+
http_client: Optional[httpx.Client] = None) -> None:
|
|
169
|
+
super().__init__(api_key, base_url, timeout, max_retries, default_headers)
|
|
170
|
+
self._http = http_client or httpx.Client(follow_redirects=False)
|
|
171
|
+
self._owns_http = http_client is None
|
|
172
|
+
for name, (sync_cls, _async_cls) in RESOURCES.items():
|
|
173
|
+
setattr(self, name, sync_cls(self))
|
|
174
|
+
|
|
175
|
+
def job(self, job_id: str) -> Job:
|
|
176
|
+
"""A handle on a /v1 job you already have the id of. Makes no request."""
|
|
177
|
+
return Job(self, {"job_id": job_id, "status_url": f"/v1/jobs/{job_id}"})
|
|
178
|
+
|
|
179
|
+
def _request(self, method: str, path: str, *, query: Optional[Mapping[str, Any]] = None,
|
|
180
|
+
json: Optional[Mapping[str, Any]] = None, form: Optional[Mapping[str, Any]] = None,
|
|
181
|
+
files: Optional[Mapping[str, Any]] = None, multipart: bool = False,
|
|
182
|
+
timeout: Optional[float] = None) -> Any:
|
|
183
|
+
kwargs = self._build(method, path, query=query, json=json, form=form, files=files, timeout=timeout)
|
|
184
|
+
attempt = 0
|
|
185
|
+
while True:
|
|
186
|
+
try:
|
|
187
|
+
response = self._http.request(headers=self._headers, **kwargs)
|
|
188
|
+
except httpx.HTTPError as exc:
|
|
189
|
+
wait = self._retry_in(attempt, method, exc=exc)
|
|
190
|
+
if wait is None:
|
|
191
|
+
raise _wrap(exc) from exc
|
|
192
|
+
else:
|
|
193
|
+
wait = self._retry_in(attempt, method, response=response)
|
|
194
|
+
if wait is None:
|
|
195
|
+
kind, value = self._parse(response)
|
|
196
|
+
return Job(self, value) if kind == "job" else value
|
|
197
|
+
attempt += 1
|
|
198
|
+
time.sleep(wait)
|
|
199
|
+
|
|
200
|
+
def close(self) -> None:
|
|
201
|
+
if self._owns_http:
|
|
202
|
+
self._http.close()
|
|
203
|
+
|
|
204
|
+
def __enter__(self) -> "Dipsac":
|
|
205
|
+
return self
|
|
206
|
+
|
|
207
|
+
def __exit__(self, *exc: Any) -> None:
|
|
208
|
+
self.close()
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class AsyncDipsac(_Core):
|
|
212
|
+
"""The DIPSAC API, for asyncio. Same methods as Dipsac, awaited.
|
|
213
|
+
|
|
214
|
+
async with AsyncDipsac() as client:
|
|
215
|
+
merged = await client.pdf.merge(["a.pdf", "b.pdf"])
|
|
216
|
+
"""
|
|
217
|
+
|
|
218
|
+
def __init__(self, api_key: Optional[str] = None, *, base_url: Optional[str] = None,
|
|
219
|
+
timeout: float = DEFAULT_TIMEOUT, max_retries: int = DEFAULT_MAX_RETRIES,
|
|
220
|
+
default_headers: Optional[Mapping[str, str]] = None,
|
|
221
|
+
http_client: Optional[httpx.AsyncClient] = None) -> None:
|
|
222
|
+
super().__init__(api_key, base_url, timeout, max_retries, default_headers)
|
|
223
|
+
self._http = http_client or httpx.AsyncClient(follow_redirects=False)
|
|
224
|
+
self._owns_http = http_client is None
|
|
225
|
+
for name, (_sync_cls, async_cls) in RESOURCES.items():
|
|
226
|
+
setattr(self, name, async_cls(self))
|
|
227
|
+
|
|
228
|
+
def job(self, job_id: str) -> AsyncJob:
|
|
229
|
+
"""A handle on a /v1 job you already have the id of. Makes no request."""
|
|
230
|
+
return AsyncJob(self, {"job_id": job_id, "status_url": f"/v1/jobs/{job_id}"})
|
|
231
|
+
|
|
232
|
+
async def _request(self, method: str, path: str, *, query: Optional[Mapping[str, Any]] = None,
|
|
233
|
+
json: Optional[Mapping[str, Any]] = None, form: Optional[Mapping[str, Any]] = None,
|
|
234
|
+
files: Optional[Mapping[str, Any]] = None, multipart: bool = False,
|
|
235
|
+
timeout: Optional[float] = None) -> Any:
|
|
236
|
+
# Reading the files is blocking work; a large upload would hold the loop.
|
|
237
|
+
kwargs = await asyncio.to_thread(self._build, method, path, query=query, json=json,
|
|
238
|
+
form=form, files=files, timeout=timeout)
|
|
239
|
+
attempt = 0
|
|
240
|
+
while True:
|
|
241
|
+
try:
|
|
242
|
+
response = await self._http.request(headers=self._headers, **kwargs)
|
|
243
|
+
except httpx.HTTPError as exc:
|
|
244
|
+
wait = self._retry_in(attempt, method, exc=exc)
|
|
245
|
+
if wait is None:
|
|
246
|
+
raise _wrap(exc) from exc
|
|
247
|
+
else:
|
|
248
|
+
wait = self._retry_in(attempt, method, response=response)
|
|
249
|
+
if wait is None:
|
|
250
|
+
kind, value = self._parse(response)
|
|
251
|
+
return AsyncJob(self, value) if kind == "job" else value
|
|
252
|
+
attempt += 1
|
|
253
|
+
await asyncio.sleep(wait)
|
|
254
|
+
|
|
255
|
+
async def close(self) -> None:
|
|
256
|
+
if self._owns_http:
|
|
257
|
+
await self._http.aclose()
|
|
258
|
+
|
|
259
|
+
async def __aenter__(self) -> "AsyncDipsac":
|
|
260
|
+
return self
|
|
261
|
+
|
|
262
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
263
|
+
await self.close()
|
dipsac/_errors.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Everything this library raises.
|
|
2
|
+
|
|
3
|
+
DipsacError
|
|
4
|
+
├── APIConnectionError the request never got an answer
|
|
5
|
+
│ └── APITimeoutError
|
|
6
|
+
├── APIStatusError the API answered with an error
|
|
7
|
+
│ ├── BadRequestError 400
|
|
8
|
+
│ ├── AuthenticationError 401 the key is missing or wrong
|
|
9
|
+
│ ├── PaymentRequiredError 402 an allowance is used up
|
|
10
|
+
│ ├── PermissionDeniedError 403 the plan does not include this
|
|
11
|
+
│ ├── NotFoundError 404
|
|
12
|
+
│ ├── ConflictError 409 e.g. a job that has not finished
|
|
13
|
+
│ ├── PayloadTooLargeError 413
|
|
14
|
+
│ ├── UnsupportedMediaTypeError 415 the wrong kind of file
|
|
15
|
+
│ ├── UnprocessableEntityError 422 a parameter is missing or invalid
|
|
16
|
+
│ ├── RateLimitError 429
|
|
17
|
+
│ └── InternalServerError 500 and above
|
|
18
|
+
├── JobFailedError a background job ended in failure
|
|
19
|
+
└── JobTimeoutError wait() ran out of time; the job may still finish
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from typing import Any, Dict, List, Mapping, Optional
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class DipsacError(Exception):
|
|
27
|
+
"""Base class of every error raised by this library."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class APIConnectionError(DipsacError):
|
|
31
|
+
"""The API could not be reached, or the connection broke before an answer."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class APITimeoutError(APIConnectionError):
|
|
35
|
+
"""No answer arrived within the timeout."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class APIStatusError(DipsacError):
|
|
39
|
+
"""The API answered with an error status.
|
|
40
|
+
|
|
41
|
+
Attributes:
|
|
42
|
+
status_code: the HTTP status.
|
|
43
|
+
code: the API's short name for the error, e.g. ``rate_limited``.
|
|
44
|
+
message: the API's sentence about it.
|
|
45
|
+
request_id: quote this to DIPSAC support.
|
|
46
|
+
body: the whole response body, parsed if it was JSON.
|
|
47
|
+
headers: the response headers (lower-case names).
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, message: str, *, status_code: int, code: Optional[str] = None,
|
|
51
|
+
request_id: Optional[str] = None, body: Any = None,
|
|
52
|
+
headers: Optional[Mapping[str, str]] = None) -> None:
|
|
53
|
+
super().__init__(message)
|
|
54
|
+
self.message = message
|
|
55
|
+
self.status_code = status_code
|
|
56
|
+
self.code = code
|
|
57
|
+
self.request_id = request_id
|
|
58
|
+
self.body = body
|
|
59
|
+
self.headers: Dict[str, str] = dict(headers or {})
|
|
60
|
+
|
|
61
|
+
def __str__(self) -> str:
|
|
62
|
+
bits = [f"{self.status_code}"]
|
|
63
|
+
if self.code:
|
|
64
|
+
bits.append(self.code)
|
|
65
|
+
text = f"[{' '.join(bits)}] {self.message}"
|
|
66
|
+
return f"{text} (request_id {self.request_id})" if self.request_id else text
|
|
67
|
+
|
|
68
|
+
def _field(self, name: str) -> Any:
|
|
69
|
+
return self.body.get(name) if isinstance(self.body, dict) else None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class BadRequestError(APIStatusError):
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class AuthenticationError(APIStatusError):
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class PaymentRequiredError(APIStatusError):
|
|
81
|
+
"""An allowance or quota is used up; the body carries the numbers."""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class PermissionDeniedError(APIStatusError):
|
|
85
|
+
"""The key's plan does not include this endpoint."""
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def required_plan(self) -> Optional[str]:
|
|
89
|
+
return self._field("required_plan")
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def current_plan(self) -> Optional[str]:
|
|
93
|
+
return self._field("current_plan")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class NotFoundError(APIStatusError):
|
|
97
|
+
pass
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class ConflictError(APIStatusError):
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class PayloadTooLargeError(APIStatusError):
|
|
105
|
+
pass
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class UnsupportedMediaTypeError(APIStatusError):
|
|
109
|
+
@property
|
|
110
|
+
def accepted(self) -> Optional[List[str]]:
|
|
111
|
+
"""The file types this endpoint takes."""
|
|
112
|
+
return self._field("accepted")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class UnprocessableEntityError(APIStatusError):
|
|
116
|
+
@property
|
|
117
|
+
def fields(self) -> Optional[List[Dict[str, Any]]]:
|
|
118
|
+
"""Which parameters were refused, and why."""
|
|
119
|
+
return self._field("fields")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class RateLimitError(APIStatusError):
|
|
123
|
+
"""Too many requests.
|
|
124
|
+
|
|
125
|
+
``retry_after`` is how many seconds the API asked to wait. A second or two
|
|
126
|
+
is the per-second limit; a day is the plan's monthly limit, used up.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def retry_after(self) -> Optional[float]:
|
|
131
|
+
raw = self.headers.get("retry-after")
|
|
132
|
+
try:
|
|
133
|
+
return float(raw) if raw is not None else None
|
|
134
|
+
except ValueError:
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class InternalServerError(APIStatusError):
|
|
139
|
+
pass
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class JobFailedError(DipsacError):
|
|
143
|
+
"""A background job ended in failure. ``job`` is the job, with its last status."""
|
|
144
|
+
|
|
145
|
+
def __init__(self, message: str, job: Any = None) -> None:
|
|
146
|
+
super().__init__(message)
|
|
147
|
+
self.job = job
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class JobTimeoutError(DipsacError):
|
|
151
|
+
"""wait() gave up. The job itself was not cancelled and may still finish."""
|
|
152
|
+
|
|
153
|
+
def __init__(self, message: str, job: Any = None) -> None:
|
|
154
|
+
super().__init__(message)
|
|
155
|
+
self.job = job
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
_BY_STATUS = {
|
|
159
|
+
400: BadRequestError, 401: AuthenticationError, 402: PaymentRequiredError,
|
|
160
|
+
403: PermissionDeniedError, 404: NotFoundError, 409: ConflictError,
|
|
161
|
+
413: PayloadTooLargeError, 415: UnsupportedMediaTypeError,
|
|
162
|
+
422: UnprocessableEntityError, 429: RateLimitError,
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def error_for(status_code: int, body: Any, headers: Mapping[str, str]) -> APIStatusError:
|
|
167
|
+
"""The right exception for an error response."""
|
|
168
|
+
lowered = {k.lower(): v for k, v in headers.items()}
|
|
169
|
+
code = message = request_id = None
|
|
170
|
+
if isinstance(body, dict):
|
|
171
|
+
code = body.get("error") if isinstance(body.get("error"), str) else None
|
|
172
|
+
for key in ("message", "detail"):
|
|
173
|
+
if isinstance(body.get(key), str) and body[key]:
|
|
174
|
+
message = body[key]
|
|
175
|
+
break
|
|
176
|
+
request_id = body.get("request_id") if isinstance(body.get("request_id"), str) else None
|
|
177
|
+
elif isinstance(body, str) and body.strip():
|
|
178
|
+
message = body.strip()[:500]
|
|
179
|
+
request_id = request_id or lowered.get("x-request-id")
|
|
180
|
+
cls = _BY_STATUS.get(status_code) or (InternalServerError if status_code >= 500 else APIStatusError)
|
|
181
|
+
return cls(message or f"The API answered {status_code}.", status_code=status_code, code=code,
|
|
182
|
+
request_id=request_id, body=body, headers=lowered)
|
dipsac/_files.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Turning what a caller hands over into one part of an upload.
|
|
2
|
+
|
|
3
|
+
A file may be given as:
|
|
4
|
+
|
|
5
|
+
"report.pdf" or Path("report.pdf") a path; the file is read
|
|
6
|
+
open("report.pdf", "rb") an open binary file
|
|
7
|
+
b"%PDF-1.7 ..." the bytes themselves
|
|
8
|
+
("report.pdf", data) a name and bytes (or an open file)
|
|
9
|
+
("report.pdf", data, "application/pdf")
|
|
10
|
+
|
|
11
|
+
THE NAME MATTERS. The API decides what a file is by the extension of its name
|
|
12
|
+
and refuses one it does not expect ("'file' has unsupported type"). A path and
|
|
13
|
+
an open file carry their own name. Bare bytes carry none, so their type is read
|
|
14
|
+
from how they begin (PDF, PNG, JPEG, GIF, WebP, WAV, MP3, MP4, OGG, FLAC, WebM
|
|
15
|
+
and GLB are recognised); for anything else, a Word file for one, pass the tuple
|
|
16
|
+
form.
|
|
17
|
+
|
|
18
|
+
EVERY UPLOAD IS READ INTO MEMORY BEFORE IT IS SENT. The API requires a
|
|
19
|
+
Content-Length on uploads and answers 411 to a chunked body, and a length is
|
|
20
|
+
only certain once the bytes are in hand.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import mimetypes
|
|
25
|
+
import os
|
|
26
|
+
from typing import IO, Any, Optional, Tuple, Union
|
|
27
|
+
|
|
28
|
+
FileContent = Union[bytes, bytearray, memoryview, IO[bytes]]
|
|
29
|
+
FileInput = Union[
|
|
30
|
+
str,
|
|
31
|
+
"os.PathLike[str]",
|
|
32
|
+
bytes,
|
|
33
|
+
bytearray,
|
|
34
|
+
memoryview,
|
|
35
|
+
IO[bytes],
|
|
36
|
+
Tuple[str, FileContent],
|
|
37
|
+
Tuple[str, FileContent, str],
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
_MAGIC = (
|
|
41
|
+
(b"%PDF", "pdf"), (b"\x89PNG\r\n\x1a\n", "png"), (b"\xff\xd8\xff", "jpg"),
|
|
42
|
+
(b"GIF87a", "gif"), (b"GIF89a", "gif"), (b"ID3", "mp3"), (b"\xff\xfb", "mp3"),
|
|
43
|
+
(b"\xff\xf3", "mp3"), (b"OggS", "ogg"), (b"fLaC", "flac"), (b"glTF", "glb"),
|
|
44
|
+
(b"\x1a\x45\xdf\xa3", "webm"),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _sniff(head: bytes) -> Optional[str]:
|
|
49
|
+
"""An extension for bytes that came with no name, or None."""
|
|
50
|
+
for magic, ext in _MAGIC:
|
|
51
|
+
if head.startswith(magic):
|
|
52
|
+
return ext
|
|
53
|
+
if head[:4] == b"RIFF" and head[8:12] == b"WEBP":
|
|
54
|
+
return "webp"
|
|
55
|
+
if head[:4] == b"RIFF" and head[8:12] == b"WAVE":
|
|
56
|
+
return "wav"
|
|
57
|
+
if head[4:8] == b"ftyp":
|
|
58
|
+
return "mp4"
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _read(content: Any) -> bytes:
|
|
63
|
+
if isinstance(content, (bytes, bytearray, memoryview)):
|
|
64
|
+
return bytes(content)
|
|
65
|
+
if hasattr(content, "read"):
|
|
66
|
+
data = content.read()
|
|
67
|
+
if isinstance(data, str):
|
|
68
|
+
raise TypeError("a file must be opened in binary mode ('rb'), not text mode")
|
|
69
|
+
return bytes(data)
|
|
70
|
+
raise TypeError(f"cannot upload a {type(content).__name__}; pass a path, bytes, "
|
|
71
|
+
f"an open binary file, or a (filename, content) tuple")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def to_part(value: Any, field: str = "file") -> Tuple[str, bytes, str]:
|
|
75
|
+
"""(filename, bytes, content type) for one uploaded file."""
|
|
76
|
+
filename: Optional[str] = None
|
|
77
|
+
content_type: Optional[str] = None
|
|
78
|
+
if isinstance(value, tuple):
|
|
79
|
+
if len(value) == 2:
|
|
80
|
+
filename, content = value
|
|
81
|
+
elif len(value) == 3:
|
|
82
|
+
filename, content, content_type = value
|
|
83
|
+
else:
|
|
84
|
+
raise TypeError("a file tuple is (filename, content) or (filename, content, content_type)")
|
|
85
|
+
data = _read(content)
|
|
86
|
+
elif isinstance(value, (str, os.PathLike)):
|
|
87
|
+
path = os.fspath(value)
|
|
88
|
+
with open(path, "rb") as fh:
|
|
89
|
+
data = fh.read()
|
|
90
|
+
filename = os.path.basename(path)
|
|
91
|
+
else:
|
|
92
|
+
data = _read(value)
|
|
93
|
+
name = getattr(value, "name", None)
|
|
94
|
+
if isinstance(name, str) and name and not name.startswith("<"):
|
|
95
|
+
filename = os.path.basename(name)
|
|
96
|
+
if not filename:
|
|
97
|
+
ext = _sniff(data[:16])
|
|
98
|
+
filename = f"{field}.{ext}" if ext else field
|
|
99
|
+
if not content_type:
|
|
100
|
+
content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
|
|
101
|
+
return str(filename), data, content_type
|