fileboy 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.
fileboy/__init__.py ADDED
@@ -0,0 +1,93 @@
1
+ """Official Python client for Fileboy (https://fileboy.io).
2
+
3
+ import fileboy
4
+
5
+ fileboy.configure(public_key="YOUR_PUBLIC_KEY")
6
+ fileboy.client().upload_file("report.pdf")
7
+ """
8
+
9
+ import os
10
+ from dataclasses import dataclass, field
11
+ from typing import Any, Optional
12
+
13
+ from . import mime, token, transforms
14
+ from ._version import __version__
15
+ from .client import DEFAULT_API_URL, Client, Uploadable
16
+ from .errors import (
17
+ AuthenticationError,
18
+ BadRequestError,
19
+ ConfigurationError,
20
+ FileboyConnectionError,
21
+ FileboyError,
22
+ FileboyPermissionError,
23
+ InvalidArgumentError,
24
+ NotFoundError,
25
+ RateLimitError,
26
+ ServerError,
27
+ )
28
+ from .file_data import FileData, Video
29
+ from .webhooks import WebhookVerificationError, sign_webhook, verify_webhook
30
+
31
+
32
+ @dataclass
33
+ class Configuration:
34
+ """Settings for the shared client. Unset values fall back to environment variables."""
35
+
36
+ public_key: Optional[str] = field(default_factory=lambda: os.environ.get("FILEBOY_PUBLIC_KEY"))
37
+ private_key: Optional[str] = field(default_factory=lambda: os.environ.get("FILEBOY_PRIVATE_KEY"))
38
+ api_url: str = field(default_factory=lambda: os.environ.get("FILEBOY_API_URL") or DEFAULT_API_URL)
39
+ timeout: float = 60
40
+
41
+
42
+ _configuration: Optional[Configuration] = None
43
+ _client: Optional[Client] = None
44
+
45
+
46
+ def configuration() -> Configuration:
47
+ """The current settings. Created from the environment on first use."""
48
+ global _configuration
49
+ if _configuration is None:
50
+ _configuration = Configuration()
51
+ return _configuration
52
+
53
+
54
+ def configure(**settings: Any) -> Configuration:
55
+ """Set ``public_key``, ``private_key``, ``api_url`` and/or ``timeout`` for :func:`client`."""
56
+ global _client
57
+ config = configuration()
58
+ for key, value in settings.items():
59
+ if not hasattr(config, key):
60
+ raise ConfigurationError(f"Unknown setting {key!r}")
61
+ setattr(config, key, value)
62
+ _client = None
63
+ return config
64
+
65
+
66
+ def client() -> Client:
67
+ """A client built from :func:`configuration`. Memoized, and rebuilt after :func:`configure`."""
68
+ global _client
69
+ if _client is None:
70
+ config = configuration()
71
+ _client = Client(
72
+ public_key=config.public_key or "",
73
+ private_key=config.private_key,
74
+ api_url=config.api_url,
75
+ timeout=config.timeout,
76
+ )
77
+ return _client
78
+
79
+
80
+ def reset() -> None:
81
+ """Forget all settings and the shared client. Mostly useful in tests."""
82
+ global _configuration, _client
83
+ _configuration = None
84
+ _client = None
85
+
86
+
87
+ __all__ = [
88
+ "__version__", "Client", "Configuration", "configuration", "configure", "client", "reset",
89
+ "FileData", "Video", "Uploadable", "DEFAULT_API_URL", "mime", "token", "transforms",
90
+ "FileboyError", "ConfigurationError", "InvalidArgumentError", "FileboyConnectionError",
91
+ "BadRequestError", "AuthenticationError", "FileboyPermissionError", "NotFoundError",
92
+ "RateLimitError", "ServerError", "WebhookVerificationError", "sign_webhook", "verify_webhook",
93
+ ]
fileboy/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
fileboy/client.py ADDED
@@ -0,0 +1,414 @@
1
+ """The Fileboy client."""
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ from io import BytesIO
7
+ from typing import Any, BinaryIO, Dict, List, Mapping, Optional, Sequence, Tuple, Union
8
+ from urllib.parse import quote
9
+
10
+ import requests
11
+
12
+ from . import mime as _mime
13
+ from . import token as _token
14
+ from . import transforms as _transforms
15
+ from ._version import __version__
16
+ from .errors import (
17
+ AuthenticationError,
18
+ BadRequestError,
19
+ ConfigurationError,
20
+ FileboyConnectionError,
21
+ FileboyError,
22
+ FileboyPermissionError,
23
+ InvalidArgumentError,
24
+ NotFoundError,
25
+ RateLimitError,
26
+ ServerError,
27
+ )
28
+ from .file_data import FileData, Video
29
+
30
+ DEFAULT_API_URL = "https://api.fileboy.io"
31
+ UUID = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", re.IGNORECASE)
32
+
33
+ #: What ``upload_*`` accept: a path, an open binary file object, or raw bytes.
34
+ Uploadable = Union[str, "os.PathLike[str]", BinaryIO, bytes]
35
+
36
+
37
+ def _enc(value: str) -> str:
38
+ return quote(str(value), safe="")
39
+
40
+
41
+ class Client:
42
+ """A client for one Fileboy app.
43
+
44
+ >>> fileboy = Client(public_key="YOUR_PUBLIC_KEY")
45
+ >>> file = fileboy.upload_file("report.pdf")
46
+ >>> fileboy.build_file_url(file.id)
47
+
48
+ Reading a file (``GET /files/get``, ``/images/get``, video data) needs no authorization, so URL
49
+ builders never sign anything. Uploads send your public key, or a signed token when you also
50
+ pass a private key.
51
+
52
+ Args:
53
+ public_key: your app's public key.
54
+ private_key: your app's private key, for signed requests.
55
+ api_url: override to point at another Fileboy server, for example a CDN host.
56
+ timeout: seconds to wait for a response (uploads included).
57
+ connect_timeout: seconds to wait for a connection.
58
+ """
59
+
60
+ def __init__(
61
+ self,
62
+ public_key: str,
63
+ private_key: Optional[str] = None,
64
+ api_url: str = DEFAULT_API_URL,
65
+ timeout: float = 60,
66
+ connect_timeout: float = 10,
67
+ ) -> None:
68
+ if not public_key:
69
+ raise ConfigurationError("public_key is required")
70
+ self.public_key = public_key
71
+ self._private_key = private_key or None
72
+ self.api_url = str(api_url).rstrip("/")
73
+ self._timeout = timeout
74
+ self._connect_timeout = connect_timeout
75
+ self._session = requests.Session()
76
+
77
+ # --- URLs ----------------------------------------------------------------------------------
78
+
79
+ def build_file_url(self, id_or_label: str, *, filename: Optional[str] = None, download: bool = False) -> str:
80
+ """URL that serves the original file.
81
+
82
+ Args:
83
+ id_or_label: a file id, or a label name.
84
+ filename: sets the downloaded file name.
85
+ download: ask the browser to download instead of showing inline.
86
+ """
87
+ url = f"{self.api_url}/files/get/{self._resolve(id_or_label)}"
88
+ if filename:
89
+ url += f"/{_enc(filename)}"
90
+ if download:
91
+ url += "?download"
92
+ return url
93
+
94
+ def build_image_url(
95
+ self,
96
+ id_or_label: str,
97
+ *,
98
+ transforms: _transforms.TransformsInput = None,
99
+ filename: Optional[str] = None,
100
+ download: bool = False,
101
+ ) -> str:
102
+ """URL that serves an image, optionally transformed.
103
+
104
+ >>> fileboy.build_image_url(id, transforms=[{"type": "resize", "width": 300}, {"type": "format", "format": "webp"}])
105
+ >>> fileboy.build_image_url(id, transforms={"resize": "300x_", "format": "webp", "quality": 75})
106
+
107
+ See :func:`fileboy.transforms.build` for the accepted forms.
108
+ """
109
+ url = f"{self.api_url}/images/get/{self._resolve(id_or_label)}"
110
+ if filename:
111
+ url += f"/{_enc(filename)}"
112
+ params: List[str] = []
113
+ transform = _transforms.build(transforms)
114
+ if transform:
115
+ params.append(f"transform={transform}")
116
+ if download:
117
+ params.append("download")
118
+ if params:
119
+ url += "?" + "&".join(params)
120
+ return url
121
+
122
+ def build_video_manifest_url(self, video_id: str, type: str = "auto") -> str:
123
+ """Manifest URL for adaptive playback. ``type`` is ``"auto"``, ``"hls"`` or ``"dash"``."""
124
+ type = str(type)
125
+ if type not in ("auto", "hls", "dash"):
126
+ raise InvalidArgumentError("type must be auto, hls or dash")
127
+ return f"{self.api_url}/videos/{video_id}/manifest?type={type}"
128
+
129
+ def build_video_poster_url(self, video_id: str) -> str:
130
+ """URL of a video's poster image."""
131
+ return f"{self.api_url}/videos/{video_id}/poster"
132
+
133
+ # --- Files ---------------------------------------------------------------------------------
134
+
135
+ def upload_file(
136
+ self,
137
+ file: Uploadable,
138
+ *,
139
+ name: Optional[str] = None,
140
+ mime: Optional[str] = None,
141
+ metadata: Optional[Mapping[str, Any]] = None,
142
+ ) -> FileData:
143
+ """Upload a file from a path, an open binary file object or bytes.
144
+
145
+ Args:
146
+ name: name to store the file under. Defaults to the file name.
147
+ mime: content type. Guessed from the name when omitted.
148
+ metadata: custom string key/value pairs stored with the file.
149
+ """
150
+ stream, filename, opened = _open_upload(file, name)
151
+ try:
152
+ content_type = mime or _mime.for_filename(filename)
153
+ data: Dict[str, Any] = {"name": name or filename, "mime": content_type}
154
+ if metadata:
155
+ data["metadata"] = _stringify(metadata)
156
+ files = {"file": (filename, stream, content_type)}
157
+ body = self._request("POST", "/files/upload", files=files, form={"data": json.dumps(data)})
158
+ finally:
159
+ if opened:
160
+ stream.close()
161
+ return FileData.from_api(body["data"])
162
+
163
+ def upload_file_from_path(self, path: Union[str, "os.PathLike[str]"], **options: Any) -> FileData:
164
+ """Same as :meth:`upload_file` with a path."""
165
+ return self.upload_file(os.fspath(path), **options)
166
+
167
+ def upload_file_from_url(
168
+ self,
169
+ url: str,
170
+ *,
171
+ name: Optional[str] = None,
172
+ metadata: Optional[Mapping[str, Any]] = None,
173
+ expect: Union[str, Sequence[str], None] = None,
174
+ ) -> FileData:
175
+ """Ask Fileboy to fetch a public http(s) URL and store the result.
176
+
177
+ Args:
178
+ expect: accepted types, like ``"image/*"`` or ``".png"``.
179
+ """
180
+ payload: Dict[str, Any] = {"url": url}
181
+ if name:
182
+ payload["name"] = name
183
+ if metadata:
184
+ payload["metadata"] = _stringify(metadata)
185
+ if expect:
186
+ payload["expect"] = expect if isinstance(expect, str) else list(expect)
187
+ return FileData.from_api(self._request("POST", "/files/upload", json_body=payload)["data"])
188
+
189
+ def fetch_file_data(self, id_or_label: str) -> FileData:
190
+ """The details of a stored file."""
191
+ return FileData.from_api(self._request("GET", f"/files/data/{self._resolve(id_or_label)}")["data"])
192
+
193
+ def download_file(self, id_or_label: str, *, range: Optional[Tuple[int, int]] = None) -> bytes:
194
+ """The bytes of a file. ``range`` is ``(first_byte, last_byte)``."""
195
+ return self._request("GET", f"/files/get/{self._resolve(id_or_label)}", raw=True, byte_range=range)
196
+
197
+ def download_image(
198
+ self,
199
+ id_or_label: str,
200
+ *,
201
+ transforms: _transforms.TransformsInput = None,
202
+ range: Optional[Tuple[int, int]] = None,
203
+ ) -> bytes:
204
+ """The bytes of an image, optionally transformed."""
205
+ url = self.build_image_url(id_or_label, transforms=transforms)
206
+ return self._request("GET", url[len(self.api_url):], raw=True, byte_range=range)
207
+
208
+ # --- Videos --------------------------------------------------------------------------------
209
+
210
+ def upload_video(self, file: Uploadable, *, name: Optional[str] = None, mime: Optional[str] = None) -> Video:
211
+ """Upload a video.
212
+
213
+ Videos are processed in the background: poll :meth:`fetch_video_data` and wait for
214
+ ``extractionStatus`` to finish. Not available with the shared demo key.
215
+ """
216
+ stream, filename, opened = _open_upload(file, name)
217
+ try:
218
+ files = {"file": (filename, stream, mime or _mime.for_filename(filename))}
219
+ form = {"data": json.dumps({"name": name or filename})}
220
+ body = self._request("POST", "/videos/upload", files=files, form=form)
221
+ finally:
222
+ if opened:
223
+ stream.close()
224
+ return Video.from_api(body)
225
+
226
+ def fetch_video_data(self, video_id: str) -> Dict[str, Any]:
227
+ """Processing state, renditions, poster ids and transcripts for a video.
228
+
229
+ Returns the ``data`` object of the response as a dict.
230
+ """
231
+ return self._request("GET", f"/videos/{video_id}/data")["data"]
232
+
233
+ def fetch_video_info(self, video_id: str) -> Dict[str, Any]:
234
+ """Lighter details for a video: id, name, rawVideoUrl, duration, createdAt."""
235
+ return self._request("GET", f"/videos/{video_id}/info")
236
+
237
+ def upload_video_poster(
238
+ self, video_id: str, image: Uploadable, *, name: Optional[str] = None, mime: Optional[str] = None
239
+ ) -> str:
240
+ """Set a custom poster image. Returns the id of the stored image file."""
241
+ stream, filename, opened = _open_upload(image, name)
242
+ try:
243
+ files = {"file": (filename, stream, mime or _mime.for_filename(filename))}
244
+ body = self._request("POST", f"/videos/{video_id}/poster/upload", files=files)
245
+ finally:
246
+ if opened:
247
+ stream.close()
248
+ return body["data"]["id"]
249
+
250
+ def upload_video_transcript(
251
+ self,
252
+ video_id: str,
253
+ file: Uploadable,
254
+ *,
255
+ language: str,
256
+ name: Optional[str] = None,
257
+ mime: Optional[str] = None,
258
+ ) -> str:
259
+ """Attach a text transcript (WebVTT or plain text) in a language. Returns the transcript id."""
260
+ if not language:
261
+ raise InvalidArgumentError("language is required")
262
+ stream, filename, opened = _open_upload(file, name)
263
+ try:
264
+ files = {"file": (filename, stream, mime or _text_mime(filename))}
265
+ form = {"data": json.dumps({"language": language})}
266
+ body = self._request("POST", f"/videos/{video_id}/transcripts/upload", files=files, form=form)
267
+ finally:
268
+ if opened:
269
+ stream.close()
270
+ return body["data"]["id"]
271
+
272
+ def fetch_video_transcript(self, video_id: str, transcript_id: str) -> str:
273
+ """The text of a transcript."""
274
+ raw = self._request("GET", f"/videos/{video_id}/transcripts/{transcript_id}", raw=True)
275
+ return raw.decode("utf-8", errors="replace")
276
+
277
+ def download_video_poster(self, video_id: str) -> bytes:
278
+ """The poster image bytes."""
279
+ return self._request("GET", f"/videos/{video_id}/poster", raw=True)
280
+
281
+ # --- Tokens --------------------------------------------------------------------------------
282
+
283
+ def create_access_token(
284
+ self, *, read: bool = False, write: bool = False, upload: bool = False, expires_in: float = 60
285
+ ) -> str:
286
+ """A short-lived signed token for handing a browser limited access. Needs a private key."""
287
+ if not self._private_key:
288
+ raise ConfigurationError("Cannot create access tokens without a private key")
289
+ return _token.build(
290
+ public_key=self.public_key,
291
+ private_key=self._private_key,
292
+ expires_in=expires_in,
293
+ payload={"access": {"read": read, "write": write, "upload": upload}},
294
+ )
295
+
296
+ # --- Internals -----------------------------------------------------------------------------
297
+
298
+ def _resolve(self, id_or_label: str) -> str:
299
+ """UUIDs are ids; anything with a ``+`` is passed through; the rest is a label of this app."""
300
+ value = "" if id_or_label is None else str(id_or_label)
301
+ if not value:
302
+ raise InvalidArgumentError("No id given")
303
+ if UUID.fullmatch(value) or "+" in value:
304
+ return value
305
+ return f"{self.public_key}+{_enc(value)}"
306
+
307
+ def _auth_header(self) -> str:
308
+ if self._private_key:
309
+ return "token " + _token.build(self.public_key, self._private_key, expires_in=10)
310
+ return f"public {self.public_key}"
311
+
312
+ def _request(
313
+ self,
314
+ method: str,
315
+ path: str,
316
+ *,
317
+ json_body: Any = None,
318
+ files: Any = None,
319
+ form: Optional[Mapping[str, str]] = None,
320
+ raw: bool = False,
321
+ byte_range: Optional[Tuple[int, int]] = None,
322
+ ) -> Any:
323
+ headers = {"Authorization": self._auth_header(), "User-Agent": f"fileboy-python/{__version__}"}
324
+ if not raw:
325
+ headers["Accept"] = "application/json"
326
+ if byte_range:
327
+ headers["Range"] = f"bytes={byte_range[0]}-{byte_range[1]}"
328
+
329
+ kwargs: Dict[str, Any] = {}
330
+ if json_body is not None:
331
+ headers["Content-Type"] = "application/json"
332
+ kwargs["data"] = json.dumps(json_body)
333
+ elif files is not None:
334
+ kwargs["files"] = files
335
+ kwargs["data"] = dict(form or {})
336
+
337
+ try:
338
+ response = self._session.request(
339
+ method,
340
+ f"{self.api_url}{path}",
341
+ headers=headers,
342
+ timeout=(self._connect_timeout, self._timeout),
343
+ **kwargs,
344
+ )
345
+ except requests.RequestException as exc:
346
+ raise FileboyConnectionError(f"Could not reach Fileboy at {self.api_url}: {exc}") from exc
347
+
348
+ if not 200 <= response.status_code < 300:
349
+ self._raise_for_status(response)
350
+ if raw:
351
+ return response.content
352
+ try:
353
+ return response.json()
354
+ except ValueError:
355
+ raise FileboyError(
356
+ "Fileboy sent a response that is not JSON", status=response.status_code, body=response.text
357
+ ) from None
358
+
359
+ @staticmethod
360
+ def _raise_for_status(response: "requests.Response") -> None:
361
+ status = response.status_code
362
+ text = response.text or ""
363
+ try:
364
+ parsed = json.loads(text)
365
+ except ValueError:
366
+ parsed = None
367
+ code = str(parsed.get("error") or "") if isinstance(parsed, dict) else ""
368
+ detail = str(parsed.get("message") or "") if isinstance(parsed, dict) else text.strip()
369
+ message = f"Fileboy responded {status}"
370
+ if code:
371
+ message += f" ({code})"
372
+ if detail:
373
+ message += f": {detail}"
374
+
375
+ if status == 401:
376
+ cls = AuthenticationError
377
+ elif status == 403:
378
+ cls = FileboyPermissionError
379
+ elif status == 404:
380
+ cls = NotFoundError
381
+ elif status == 429:
382
+ cls = RateLimitError
383
+ elif 400 <= status <= 499:
384
+ cls = BadRequestError
385
+ else:
386
+ cls = ServerError
387
+ raise cls(message, status=status, code=code or None, body=parsed if parsed is not None else text)
388
+
389
+
390
+ def _stringify(metadata: Mapping[str, Any]) -> Dict[str, str]:
391
+ return {str(k): str(v) for k, v in dict(metadata).items()}
392
+
393
+
394
+ def _text_mime(filename: str) -> str:
395
+ mime = _mime.for_filename(filename)
396
+ return mime if mime.startswith("text/") else "text/plain"
397
+
398
+
399
+ def _open_upload(file: Any, name: Optional[str]) -> Tuple[Any, str, bool]:
400
+ """Return ``(stream, filename, opened_by_us)``."""
401
+ if isinstance(file, (bytes, bytearray, memoryview)):
402
+ return BytesIO(bytes(file)), name or "upload", False
403
+ if isinstance(file, (str, os.PathLike)):
404
+ path = os.fspath(file)
405
+ if not os.path.isfile(path):
406
+ raise InvalidArgumentError(f"No such file: {path}")
407
+ return open(path, "rb"), os.path.basename(path), True
408
+ if not hasattr(file, "read"):
409
+ raise InvalidArgumentError("Pass a path, bytes or a file object to upload")
410
+ path_attr = getattr(file, "name", None)
411
+ filename = name or (os.path.basename(path_attr) if isinstance(path_attr, str) and path_attr else "upload")
412
+ if hasattr(file, "seek"):
413
+ file.seek(0)
414
+ return file, filename, False
fileboy/errors.py ADDED
@@ -0,0 +1,62 @@
1
+ """Exceptions raised by the Fileboy client."""
2
+
3
+ from typing import Any, Optional
4
+
5
+
6
+ class FileboyError(Exception):
7
+ """Base class for everything this package raises on purpose.
8
+
9
+ Attributes:
10
+ status: HTTP status of the response, if there was one.
11
+ code: the ``error`` code the API sent back, for example ``"unauthorized"``.
12
+ body: parsed JSON body or the raw text of the response.
13
+ """
14
+
15
+ def __init__(
16
+ self,
17
+ message: Optional[str] = None,
18
+ *,
19
+ status: Optional[int] = None,
20
+ code: Optional[str] = None,
21
+ body: Any = None,
22
+ ) -> None:
23
+ super().__init__(message)
24
+ self.status = status
25
+ self.code = code
26
+ self.body = body
27
+
28
+
29
+ class ConfigurationError(FileboyError):
30
+ """The client was set up wrongly. Raised before any request is made."""
31
+
32
+
33
+ class InvalidArgumentError(FileboyError):
34
+ """A method was called with bad arguments. Raised before any request is made."""
35
+
36
+
37
+ class FileboyConnectionError(FileboyError):
38
+ """The network failed, timed out or the server could not be reached."""
39
+
40
+
41
+ class BadRequestError(FileboyError):
42
+ """400, 413, 415, 422 and other 4xx responses not listed below."""
43
+
44
+
45
+ class AuthenticationError(FileboyError):
46
+ """401."""
47
+
48
+
49
+ class FileboyPermissionError(FileboyError):
50
+ """403, including plan limits."""
51
+
52
+
53
+ class NotFoundError(FileboyError):
54
+ """404."""
55
+
56
+
57
+ class RateLimitError(FileboyError):
58
+ """429."""
59
+
60
+
61
+ class ServerError(FileboyError):
62
+ """5xx."""
fileboy/file_data.py ADDED
@@ -0,0 +1,65 @@
1
+ """Result types returned by the client."""
2
+
3
+ import re
4
+ from dataclasses import dataclass, field
5
+ from datetime import datetime
6
+ from typing import Any, Dict, Mapping, Optional
7
+
8
+
9
+ def _parse_time(value: Any) -> Optional[datetime]:
10
+ if value is None or value == "":
11
+ return None
12
+ text = str(value).strip()
13
+ if text.endswith(("Z", "z")):
14
+ text = text[:-1] + "+00:00"
15
+ # Go may send up to nine fractional digits; Python accepts at most six.
16
+ text = re.sub(r"(\.\d{6})\d+", r"\1", text)
17
+ try:
18
+ return datetime.fromisoformat(text)
19
+ except ValueError:
20
+ return None
21
+
22
+
23
+ @dataclass
24
+ class FileData:
25
+ """The details of one stored file, as returned by an upload or ``fetch_file_data``."""
26
+
27
+ id: Optional[str] = None
28
+ name: Optional[str] = None
29
+ original_filename: Optional[str] = None
30
+ mime: Optional[str] = None
31
+ metadata: Dict[str, str] = field(default_factory=dict)
32
+ file_size: Optional[int] = None
33
+ content_hash: Optional[str] = None
34
+ signed: Optional[bool] = None
35
+ dies_at: Optional[datetime] = None
36
+ created_at: Optional[datetime] = None
37
+
38
+ @classmethod
39
+ def from_api(cls, data: Mapping[str, Any]) -> "FileData":
40
+ """Build from the ``data`` object of an API response."""
41
+ return cls(
42
+ id=data.get("id"),
43
+ name=data.get("name"),
44
+ original_filename=data.get("originalFilename"),
45
+ mime=data.get("mime"),
46
+ metadata=data.get("metadata") or {},
47
+ file_size=data.get("fileSize"),
48
+ content_hash=data.get("hash"),
49
+ signed=data.get("signed"),
50
+ dies_at=_parse_time(data.get("diesAt")),
51
+ created_at=_parse_time(data.get("createdAt")),
52
+ )
53
+
54
+
55
+ @dataclass
56
+ class Video:
57
+ """The details of one video, as returned by ``upload_video``."""
58
+
59
+ id: Optional[str] = None
60
+ name: Optional[str] = None
61
+ raw_file_id: Optional[str] = None
62
+
63
+ @classmethod
64
+ def from_api(cls, data: Mapping[str, Any]) -> "Video":
65
+ return cls(id=data.get("id"), name=data.get("name"), raw_file_id=data.get("rawFileId"))
fileboy/mime.py ADDED
@@ -0,0 +1,27 @@
1
+ """A small MIME type table so uploads from a path get a sensible content type.
2
+
3
+ Pass ``mime=`` to ``upload_file`` for anything not listed here.
4
+ """
5
+
6
+ import os
7
+
8
+ TYPES = {
9
+ ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".gif": "image/gif",
10
+ ".webp": "image/webp", ".avif": "image/avif", ".heic": "image/heic",
11
+ ".svg": "image/svg+xml", ".bmp": "image/bmp", ".tif": "image/tiff", ".tiff": "image/tiff",
12
+ ".mp4": "video/mp4", ".m4v": "video/mp4", ".mov": "video/quicktime",
13
+ ".webm": "video/webm", ".mkv": "video/x-matroska", ".avi": "video/x-msvideo",
14
+ ".mp3": "audio/mpeg", ".wav": "audio/wav", ".m4a": "audio/mp4", ".ogg": "audio/ogg",
15
+ ".pdf": "application/pdf", ".zip": "application/zip", ".json": "application/json",
16
+ ".txt": "text/plain", ".csv": "text/csv", ".html": "text/html", ".vtt": "text/vtt",
17
+ ".srt": "text/plain", ".md": "text/markdown",
18
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
19
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
20
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
21
+ ".xls": "application/vnd.ms-excel", ".doc": "application/msword",
22
+ }
23
+
24
+
25
+ def for_filename(name: str) -> str:
26
+ """Return the content type for a file name, or ``application/octet-stream``."""
27
+ return TYPES.get(os.path.splitext(str(name))[1].lower(), "application/octet-stream")
fileboy/py.typed ADDED
File without changes
fileboy/token.py ADDED
@@ -0,0 +1,53 @@
1
+ """Signed tokens for apps that use a private key.
2
+
3
+ The format is ``PUBLIC_KEY$NONCE$JWT``, where the JWT is HS256-signed with the private key. This
4
+ is what the Fileboy API verifies, and what the Node client's ``createAccessToken`` produces.
5
+ """
6
+
7
+ import base64
8
+ import hashlib
9
+ import hmac
10
+ import json
11
+ import secrets
12
+ import string
13
+ import time
14
+ from typing import Any, Dict, Optional
15
+
16
+ from .errors import InvalidArgumentError
17
+
18
+ _ALPHANUMERIC = string.ascii_letters + string.digits
19
+
20
+
21
+ def _b64(data: bytes) -> str:
22
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
23
+
24
+
25
+ def _json(value: Any) -> bytes:
26
+ return json.dumps(value, separators=(",", ":")).encode("utf-8")
27
+
28
+
29
+ def jwt(claims: Dict[str, Any], secret: str) -> str:
30
+ """Encode ``claims`` as an HS256 JWT."""
31
+ header = _b64(_json({"alg": "HS256", "typ": "JWT"}))
32
+ body = _b64(_json(claims))
33
+ signature = hmac.new(secret.encode("utf-8"), f"{header}.{body}".encode("ascii"), hashlib.sha256).digest()
34
+ return f"{header}.{body}.{_b64(signature)}"
35
+
36
+
37
+ def build(
38
+ public_key: str,
39
+ private_key: str,
40
+ expires_in: float = 60,
41
+ payload: Optional[Dict[str, Any]] = None,
42
+ now: Optional[float] = None,
43
+ ) -> str:
44
+ """Build a ``PUBLIC$NONCE$JWT`` token. ``now`` is a Unix timestamp, for tests."""
45
+ if "$" in public_key or "$" in private_key:
46
+ raise InvalidArgumentError("Keys must not contain a $ symbol")
47
+
48
+ nonce = "".join(secrets.choice(_ALPHANUMERIC) for _ in range(32))
49
+ iat = int(time.time() if now is None else now)
50
+ claims: Dict[str, Any] = {"v": 3, "token": nonce, "iat": iat, "exp": iat + int(expires_in)}
51
+ if payload:
52
+ claims["payload"] = payload
53
+ return "$".join([public_key, nonce, jwt(claims, private_key)])
fileboy/transforms.py ADDED
@@ -0,0 +1,149 @@
1
+ """Build the value of the ``transform`` query parameter on image URLs.
2
+
3
+ For example ``resize:300x_~fit:cover;format:webp;quality:75``. It only emits what the Fileboy
4
+ image service understands: resize, format, quality, background and invert.
5
+ """
6
+
7
+ import re
8
+ from typing import Any, Iterable, List, Mapping, Optional, Union
9
+
10
+ from .errors import InvalidArgumentError
11
+
12
+ FITS = ("contain", "cover", "fill", "inside", "outside")
13
+ POSITIONS = (
14
+ "top-left", "top", "top-right", "left", "center", "right", "bottom-left", "bottom", "bottom-right",
15
+ )
16
+ FORMATS = ("jpeg", "jpg", "png", "bmp", "tiff", "tif", "webp")
17
+ TYPES = ("resize", "format", "quality", "background", "invert")
18
+
19
+ _HEX = r"(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})"
20
+ _HEX_CALL = re.compile(r"hex\(" + _HEX + r"\)", re.IGNORECASE)
21
+ _HEX_BARE = re.compile(_HEX, re.IGNORECASE)
22
+ _AXIS_ORDER = ("top", "bottom", "left", "right")
23
+
24
+ TransformsInput = Union[None, str, Mapping[str, Any], Iterable[Mapping[str, Any]]]
25
+
26
+
27
+ def build(transforms: TransformsInput) -> str:
28
+ """Return the query value, or ``""`` when there is nothing to apply.
29
+
30
+ ``transforms`` can be:
31
+
32
+ * a list of dicts like ``{"type": "resize", "width": 300, "fit": "cover"}``, applied in order
33
+ * a dict of ``name -> value``, like ``{"resize": "300x_", "format": "webp", "quality": 75}``
34
+ * a string, passed through untouched
35
+ """
36
+ if transforms is None:
37
+ return ""
38
+ if isinstance(transforms, str):
39
+ return transforms
40
+ if isinstance(transforms, Mapping):
41
+ parts = (_shorthand(name, value) for name, value in transforms.items())
42
+ return ";".join(p for p in parts if p is not None)
43
+ if isinstance(transforms, (list, tuple)):
44
+ return ";".join(_build_one(t) for t in transforms)
45
+ raise InvalidArgumentError("transforms must be a list, dict or string")
46
+
47
+
48
+ def _shorthand(name: Any, value: Any) -> Optional[str]:
49
+ if value is None or str(value) == "":
50
+ return None
51
+ if value is True:
52
+ return str(name)
53
+ return f"{name}:{value}"
54
+
55
+
56
+ def _build_one(transform: Any) -> str:
57
+ if not isinstance(transform, Mapping):
58
+ raise InvalidArgumentError("Each transform must be a dict with a 'type'")
59
+ kind = str(transform.get("type"))
60
+ if kind not in TYPES:
61
+ raise InvalidArgumentError(f"Unknown transform {kind!r}. Use one of: {', '.join(TYPES)}")
62
+ return _BUILDERS[kind](transform)
63
+
64
+
65
+ def _is_int(value: Any) -> bool:
66
+ return isinstance(value, int) and not isinstance(value, bool)
67
+
68
+
69
+ def _build_invert(_t: Mapping[str, Any]) -> str:
70
+ return "invert"
71
+
72
+
73
+ def _build_format(t: Mapping[str, Any]) -> str:
74
+ return f"format:{_format_name(t.get('format'))}"
75
+
76
+
77
+ def _build_quality(t: Mapping[str, Any]) -> str:
78
+ value = t.get("value")
79
+ if not (_is_int(value) and 1 <= value <= 100):
80
+ raise InvalidArgumentError("quality must be a whole number from 1 to 100")
81
+ return f"quality:{value}"
82
+
83
+
84
+ def _build_background(t: Mapping[str, Any]) -> str:
85
+ return f"background:{_color(t.get('color'))}"
86
+
87
+
88
+ def _build_resize(t: Mapping[str, Any]) -> str:
89
+ width = t.get("width")
90
+ height = t.get("height")
91
+ if width is None and height is None:
92
+ raise InvalidArgumentError("resize needs a width, a height or both")
93
+ for n in (width, height):
94
+ if n is not None and not (_is_int(n) and n > 0):
95
+ raise InvalidArgumentError("resize width and height must be positive whole numbers")
96
+
97
+ out = f"resize:{'_' if width is None else width}x{'_' if height is None else height}"
98
+ if t.get("position"):
99
+ out += f"~position:{_position(t['position'])}"
100
+ if t.get("fit"):
101
+ out += f"~fit:{_choice(t['fit'], FITS, 'fit')}"
102
+ if t.get("background"):
103
+ out += f"~background:{_color(t['background'])}"
104
+ if t.get("format"):
105
+ out += f"~format:{_format_name(t['format'])}"
106
+ return out
107
+
108
+
109
+ def _position(value: Any) -> str:
110
+ # "right top", "top right" and "top-right" all mean the same corner, which the image service
111
+ # spells top-right (vertical word first).
112
+ parts = [p for p in re.split(r"[\s-]+", str(value).lower().strip()) if p]
113
+ if len(parts) == 2:
114
+ parts.sort(key=lambda p: _AXIS_ORDER.index(p) if p in _AXIS_ORDER else 9)
115
+ return _choice("-".join(parts), POSITIONS, "position")
116
+
117
+
118
+ def _format_name(value: Any) -> str:
119
+ return _choice(value, FORMATS, "format")
120
+
121
+
122
+ def _choice(value: Any, allowed: Iterable[str], label: str) -> str:
123
+ allowed = tuple(allowed)
124
+ v = str(value).lower()
125
+ if v not in allowed:
126
+ raise InvalidArgumentError(f"Unsupported {label} {value!r}. Use one of: {', '.join(allowed)}")
127
+ return v
128
+
129
+
130
+ def _color(value: Any) -> str:
131
+ """``#fff``, ``#ffffff`` and ``#ffffff80`` become ``hex(...)``. ``hex(...)`` is kept."""
132
+ s = str(value)
133
+ if _HEX_CALL.fullmatch(s):
134
+ return s
135
+ bare = s[1:] if s.startswith("#") else s
136
+ if not _HEX_BARE.fullmatch(bare):
137
+ raise InvalidArgumentError(f"Colours must be hex, like #ff8800 (got {value!r})")
138
+ return f"hex({bare})"
139
+
140
+
141
+ _BUILDERS = {
142
+ "resize": _build_resize,
143
+ "format": _build_format,
144
+ "quality": _build_quality,
145
+ "background": _build_background,
146
+ "invert": _build_invert,
147
+ }
148
+
149
+ __all__: List[str] = ["build", "FITS", "POSITIONS", "FORMATS", "TYPES"]
fileboy/webhooks.py ADDED
@@ -0,0 +1,106 @@
1
+ """Verify webhooks sent by Fileboy.
2
+
3
+ Every delivery is a POST with a JSON body and two headers:
4
+
5
+ * ``Fileboy-Timestamp``: unix time in seconds when it was sent
6
+ * ``Fileboy-Signature``: ``v1=`` and the hex HMAC-SHA256, keyed with your endpoint's signing
7
+ secret, of the string ``"<timestamp>.<raw body>"``
8
+
9
+ Use the exact bytes received. Parsing and re-serialising the JSON changes them and breaks the
10
+ signature::
11
+
12
+ event = fileboy.verify_webhook(request.body, request.headers, settings.FILEBOY_WEBHOOK_SECRET)
13
+ event["type"] # "video.ready"
14
+ """
15
+
16
+ import hashlib
17
+ import hmac
18
+ import json
19
+ import time
20
+ from typing import Any, Dict, Mapping, Optional, Union
21
+
22
+ from .errors import FileboyError
23
+
24
+ SIGNATURE_HEADER = "Fileboy-Signature"
25
+ TIMESTAMP_HEADER = "Fileboy-Timestamp"
26
+ DEFAULT_TOLERANCE_SECONDS = 300
27
+
28
+
29
+ class WebhookVerificationError(FileboyError):
30
+ """Raised by :func:`verify_webhook`. ``code`` is one of ``missing_header``,
31
+ ``invalid_timestamp``, ``timestamp_out_of_tolerance``, ``invalid_signature`` or
32
+ ``invalid_body``."""
33
+
34
+
35
+ def _error(code: str, message: str) -> WebhookVerificationError:
36
+ return WebhookVerificationError(message, code=code)
37
+
38
+
39
+ def sign_webhook(raw_body: Union[str, bytes], timestamp: int, secret: str) -> str:
40
+ """The signature Fileboy sends for this secret, timestamp and body. Mostly useful in tests."""
41
+ body = raw_body.encode("utf-8") if isinstance(raw_body, str) else bytes(raw_body)
42
+ mac = hmac.new(secret.encode("utf-8"), f"{int(timestamp)}.".encode("ascii") + body, hashlib.sha256)
43
+ return "v1=" + mac.hexdigest()
44
+
45
+
46
+ def _read_header(headers: Mapping[str, Any], name: str) -> Optional[str]:
47
+ """Find a header however the framework spells it: ``Fileboy-Signature``, lower case, or the
48
+ WSGI/Django ``META`` key ``HTTP_FILEBOY_SIGNATURE``."""
49
+ for key in (name, name.lower(), "HTTP_" + name.upper().replace("-", "_")):
50
+ try:
51
+ value = headers[key]
52
+ except (KeyError, TypeError):
53
+ continue
54
+ if value is not None:
55
+ return str(value)
56
+ for key in list(headers.keys()):
57
+ if str(key).lower() == name.lower():
58
+ return str(headers[key])
59
+ return None
60
+
61
+
62
+ def verify_webhook(
63
+ raw_body: Union[str, bytes],
64
+ headers: Mapping[str, Any],
65
+ secret: str,
66
+ tolerance_seconds: int = DEFAULT_TOLERANCE_SECONDS,
67
+ now: Optional[float] = None,
68
+ ) -> Dict[str, Any]:
69
+ """Check a webhook really came from Fileboy and is recent, then return the parsed event.
70
+
71
+ Args:
72
+ raw_body: the request body exactly as received (``bytes`` or ``str``).
73
+ headers: the request headers, in any case. Django, Flask and plain dicts all work.
74
+ secret: the endpoint's signing secret from the dashboard.
75
+ tolerance_seconds: how far from now the timestamp may be.
76
+ now: replaces the current time in unix seconds, for tests.
77
+
78
+ Raises:
79
+ WebhookVerificationError: with a ``code`` saying what was wrong.
80
+ """
81
+ if not isinstance(raw_body, (str, bytes, bytearray)):
82
+ raise _error("invalid_body", "The body must be the raw bytes or string received, not parsed JSON")
83
+
84
+ timestamp_header = _read_header(headers, TIMESTAMP_HEADER)
85
+ signature_header = _read_header(headers, SIGNATURE_HEADER)
86
+ if not timestamp_header or not signature_header:
87
+ raise _error("missing_header", f"Missing {TIMESTAMP_HEADER} or {SIGNATURE_HEADER} header")
88
+ if not (timestamp_header.isascii() and timestamp_header.isdigit()):
89
+ raise _error("invalid_timestamp", "Timestamp is not a number")
90
+
91
+ timestamp = int(timestamp_header)
92
+ current = int(time.time() if now is None else now)
93
+ if abs(current - timestamp) > tolerance_seconds:
94
+ raise _error("timestamp_out_of_tolerance", "Timestamp is outside of the allowed time window")
95
+
96
+ expected = sign_webhook(raw_body, timestamp, secret)
97
+ # More than one signature may be sent in future, for example while rotating secrets
98
+ candidates = [c for c in signature_header.replace(",", " ").split() if c]
99
+ if not any(hmac.compare_digest(c.encode("utf-8"), expected.encode("utf-8")) for c in candidates):
100
+ raise _error("invalid_signature", "Signature does not match")
101
+
102
+ try:
103
+ text = raw_body.decode("utf-8") if isinstance(raw_body, (bytes, bytearray)) else raw_body
104
+ return json.loads(text)
105
+ except ValueError:
106
+ raise _error("invalid_body", "Body is not valid JSON")
@@ -0,0 +1,207 @@
1
+ Metadata-Version: 2.4
2
+ Name: fileboy
3
+ Version: 0.1.0
4
+ Summary: Official Python client for Fileboy: upload files and videos, build image transform URLs, read video data.
5
+ Author: Fileboy
6
+ License: MIT
7
+ Project-URL: Homepage, https://fileboy.io/docs/libraries/python
8
+ Project-URL: Documentation, https://fileboy.io/docs/libraries/python
9
+ Project-URL: Changelog, https://github.com/CD2/Fileboy/blob/master/packages/python/CHANGELOG.md
10
+ Keywords: fileboy,file upload,image resize,video hosting,sdk
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.8
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Internet :: WWW/HTTP
24
+ Classifier: Typing :: Typed
25
+ Requires-Python: >=3.8
26
+ Description-Content-Type: text/markdown
27
+ License-File: LICENSE
28
+ Requires-Dist: requests>=2.25
29
+ Provides-Extra: dev
30
+ Requires-Dist: pytest>=7; extra == "dev"
31
+ Requires-Dist: requests>=2.25; extra == "dev"
32
+ Dynamic: license-file
33
+
34
+ # Fileboy for Python
35
+
36
+ The official Python client for [Fileboy](https://fileboy.io): upload files and videos, build image
37
+ URLs that resize on the fly, and read video data. Its only dependency is `requests`.
38
+
39
+ ## Install
40
+
41
+ ```bash
42
+ pip install fileboy
43
+ ```
44
+
45
+ Python 3.8 or newer.
46
+
47
+ ## Quick start
48
+
49
+ ```python
50
+ import os
51
+ import fileboy
52
+
53
+ client = fileboy.Client(public_key=os.environ["FILEBOY_PUBLIC_KEY"])
54
+
55
+ file = client.upload_file("report.pdf") # a path, a binary file object or bytes
56
+ print(file.id) # store this
57
+
58
+ client.build_file_url(file.id, download=True)
59
+ client.build_image_url(file.id, transforms=[{"type": "resize", "width": 300}, {"type": "format", "format": "webp"}])
60
+ # https://api.fileboy.io/images/get/<id>?transform=resize:300x_;format:webp
61
+ ```
62
+
63
+ ## Django and other frameworks
64
+
65
+ Configure once, then call `fileboy.client()` anywhere:
66
+
67
+ ```python
68
+ # settings.py (or an AppConfig.ready())
69
+ import os
70
+ import fileboy
71
+
72
+ fileboy.configure(
73
+ public_key=os.environ["FILEBOY_PUBLIC_KEY"],
74
+ # private_key=..., only if your app uses signed requests
75
+ # api_url="https://api.fileboy.io",
76
+ )
77
+ ```
78
+
79
+ ```python
80
+ # views.py
81
+ import fileboy
82
+
83
+ def upload(request):
84
+ file = fileboy.client().upload_file(request.FILES["file"], name=request.FILES["file"].name)
85
+ ...
86
+ ```
87
+
88
+ `FILEBOY_PUBLIC_KEY`, `FILEBOY_PRIVATE_KEY` and `FILEBOY_API_URL` are read from the environment when you do not set them.
89
+
90
+ ## What you can do
91
+
92
+ | Task | Method |
93
+ | --- | --- |
94
+ | Upload a path, file object or bytes | `upload_file(file, name=, mime=, metadata=)` |
95
+ | Upload from a path | `upload_file_from_path(path, ...)` |
96
+ | Upload from a URL | `upload_file_from_url(url, name=, metadata=, expect=)` |
97
+ | File details | `fetch_file_data(id_or_label)` |
98
+ | File URL | `build_file_url(id_or_label, filename=, download=)` |
99
+ | Image URL with transforms | `build_image_url(id_or_label, transforms=, filename=, download=)` |
100
+ | Download bytes | `download_file(id_or_label, range=)`, `download_image(id_or_label, transforms=, range=)` |
101
+ | Upload a video | `upload_video(file, name=, mime=)` |
102
+ | Video details | `fetch_video_data(id)`, `fetch_video_info(id)` |
103
+ | Manifest and poster URLs | `build_video_manifest_url(id, type="hls")`, `build_video_poster_url(id)` |
104
+ | Custom poster | `upload_video_poster(video_id, image)`, `download_video_poster(video_id)` |
105
+ | Transcripts | `upload_video_transcript(id, file, language="English")`, `fetch_video_transcript(id, transcript_id)` |
106
+ | Signed access token | `create_access_token(read=, write=, upload=, expires_in=)` |
107
+
108
+ An id that is a UUID is used as is. Anything else is treated as a label in your app, and a value
109
+ that already contains a `+` is passed through. Labels are created in the dashboard.
110
+
111
+ Uploads return a `FileData` dataclass (`id`, `name`, `original_filename`, `mime`, `metadata`,
112
+ `file_size`, `content_hash`, `signed`, `dies_at`, `created_at`). `upload_video` returns a `Video`
113
+ (`id`, `name`, `raw_file_id`). `range` is a `(first_byte, last_byte)` tuple.
114
+
115
+ ### Image transforms
116
+
117
+ `transforms=` takes a list of dicts applied in order. The image service supports `resize`,
118
+ `format`, `quality`, `background` and `invert`:
119
+
120
+ ```python
121
+ [
122
+ {"type": "resize", "width": 200, "height": 200, "fit": "cover"}, # fit: contain cover fill inside outside
123
+ {"type": "resize", "width": 400, "fit": "contain", "position": "top-left", "background": "#ffffff"},
124
+ {"type": "format", "format": "webp"}, # jpeg png webp tiff bmp
125
+ {"type": "quality", "value": 75},
126
+ {"type": "background", "color": "#fff"},
127
+ {"type": "invert"},
128
+ ]
129
+ ```
130
+
131
+ You can also pass a dict of `name: value`, such as `{"resize": "300x_", "format": "webp", "quality": 75}`, or a ready-made string.
132
+
133
+ ## Errors
134
+
135
+ Every error is a `FileboyError` with `status`, `code` (the API's error code) and `body`.
136
+
137
+ | Class | When |
138
+ | --- | --- |
139
+ | `AuthenticationError` | 401 |
140
+ | `FileboyPermissionError` | 403, including plan limits |
141
+ | `NotFoundError` | 404 |
142
+ | `RateLimitError` | 429 |
143
+ | `BadRequestError` | other 4xx, such as a file that is too large |
144
+ | `ServerError` | 5xx |
145
+ | `FileboyConnectionError` | timeouts and network failures |
146
+ | `InvalidArgumentError`, `ConfigurationError` | bad arguments, before any request |
147
+
148
+ `FileboyPermissionError` and `FileboyConnectionError` are prefixed so they do not shadow the
149
+ Python built-ins `PermissionError` and `ConnectionError`.
150
+
151
+ ## Notes
152
+
153
+ * File, image and video reads need no authorization, so URL builders never sign anything.
154
+ * The shared `demo` key only accepts small images and cannot upload video.
155
+ * There is no endpoint yet to update, delete or list files, so the client has no such methods.
156
+ * The client uses a `requests.Session`, so connections are reused between calls.
157
+
158
+ ## Development
159
+
160
+ ```bash
161
+ pip install -e ".[dev]"
162
+ pytest
163
+ ```
164
+
165
+ The tests run against a local fake server. The transform builder is also checked against
166
+ `packages/sdk-fixtures/transforms.json`, the same cases the Go image parser is tested with.
167
+
168
+ MIT licensed.
169
+
170
+ ## Webhooks
171
+
172
+ Verify the requests Fileboy sends to your endpoints. Use the raw body, exactly as received:
173
+
174
+ ```python
175
+ # Django
176
+ import fileboy
177
+ from django.http import HttpResponse, HttpResponseBadRequest
178
+ from django.views.decorators.csrf import csrf_exempt
179
+
180
+ @csrf_exempt
181
+ def fileboy_webhook(request):
182
+ try:
183
+ event = fileboy.verify_webhook(request.body, request.headers, settings.FILEBOY_WEBHOOK_SECRET)
184
+ except fileboy.WebhookVerificationError as error:
185
+ return HttpResponseBadRequest(error.code) # "invalid_signature", "timestamp_out_of_tolerance", ...
186
+ if event["type"] == "video.ready":
187
+ ... # event["resource"]["urls"]["hls"], event["resource"]["duration"]
188
+ return HttpResponse(status=200)
189
+ ```
190
+
191
+ `verify_webhook` returns the parsed event (a dict) or raises `WebhookVerificationError`. The timestamp
192
+ must be within 5 minutes (`tolerance_seconds=` changes that). Events carry a unique `id` that is the same
193
+ on every retry, so use it to ignore duplicates. `fileboy.sign_webhook(body, timestamp, secret)` builds a
194
+ signature for your own tests.
195
+
196
+ ## Publishing (maintainers)
197
+
198
+ You need a [PyPI](https://pypi.org) account that owns `fileboy` (the first upload claims the name) and an API token (account settings, scope: that project once it exists). Try [TestPyPI](https://test.pypi.org) first.
199
+
200
+ 1. Bump the version in `src/fileboy/_version.py` and `pyproject.toml` (keep them equal) and add a section to `CHANGELOG.md`.
201
+ 2. `pip install -e '.[dev]' && pytest` must pass.
202
+ 3. `python -m pip install build twine && rm -rf dist && python -m build && twine check dist/*`
203
+ 4. Rehearse: `twine upload --repository testpypi dist/*`.
204
+ 5. Publish: `twine upload dist/*` with username `__token__` and the API token as the password.
205
+ 6. Tag the commit: `git tag python-vX.Y.Z && git push origin python-vX.Y.Z`.
206
+
207
+ PyPI never lets you reuse a version number, so bump it for every fix.
@@ -0,0 +1,15 @@
1
+ fileboy/__init__.py,sha256=LW94VsrGInvARSJei-yPStf06RkPVlm-QiAMNmb98FA,3033
2
+ fileboy/_version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
3
+ fileboy/client.py,sha256=l8gC55TCLWlORPUsTCqiK5uVpAJmAXg2hGAiBkJKLB0,16220
4
+ fileboy/errors.py,sha256=UtG5MmnywnqU6ScQ4JIuONmM2AC3jN0R2cxsIXraf3U,1502
5
+ fileboy/file_data.py,sha256=ffRd_bdDODGByZC-TrAY8ajJl0TvAnIRwk4AsAW5iBc,2098
6
+ fileboy/mime.py,sha256=XFeQDzm-QXJ9042yR_xGLTB-QMwb5f1pD0EvKNAGFnE,1441
7
+ fileboy/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ fileboy/token.py,sha256=1E7PSAhQWigNttRLZ8o6FGvxCXd_hcsxpI7Ax6yRjVg,1741
9
+ fileboy/transforms.py,sha256=2G5mhd4Tj_JgkNWLMfdqae-WBXd7jl18IiIJP6JlB68,5248
10
+ fileboy/webhooks.py,sha256=5N-9k7G8mvUu_U2odgGtErn4u6EgKCBufy8RV2RI4L8,4423
11
+ fileboy-0.1.0.dist-info/licenses/LICENSE,sha256=tUG1zhm-40RO51aTvOxwTFbVPQpMn1gkoSO0IxwGuOU,1069
12
+ fileboy-0.1.0.dist-info/METADATA,sha256=litAgGaPj5wE1njUJnXmAKw48RAK9cNEsE4NpUqdLjE,8286
13
+ fileboy-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
14
+ fileboy-0.1.0.dist-info/top_level.txt,sha256=vHXKzU-YZZJUuulcaBeV477m-ZM1KheoNI3Kgn1QQrk,8
15
+ fileboy-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) CD2 Solutions Ltd
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
+ fileboy