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 +93 -0
- fileboy/_version.py +1 -0
- fileboy/client.py +414 -0
- fileboy/errors.py +62 -0
- fileboy/file_data.py +65 -0
- fileboy/mime.py +27 -0
- fileboy/py.typed +0 -0
- fileboy/token.py +53 -0
- fileboy/transforms.py +149 -0
- fileboy/webhooks.py +106 -0
- fileboy-0.1.0.dist-info/METADATA +207 -0
- fileboy-0.1.0.dist-info/RECORD +15 -0
- fileboy-0.1.0.dist-info/WHEEL +5 -0
- fileboy-0.1.0.dist-info/licenses/LICENSE +21 -0
- fileboy-0.1.0.dist-info/top_level.txt +1 -0
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,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
|