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 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