bynn-decisionnode 0.3.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bynn_decisionnode-0.3.1.dist-info/METADATA +939 -0
- bynn_decisionnode-0.3.1.dist-info/RECORD +19 -0
- bynn_decisionnode-0.3.1.dist-info/WHEEL +4 -0
- bynn_decisionnode-0.3.1.dist-info/licenses/LICENSE +21 -0
- decisionnode/__init__.py +195 -0
- decisionnode/_base.py +548 -0
- decisionnode/_batches.py +333 -0
- decisionnode/_boosters.py +553 -0
- decisionnode/_client.py +2269 -0
- decisionnode/_errors.py +218 -0
- decisionnode/_images.py +283 -0
- decisionnode/_questions.py +262 -0
- decisionnode/_sessions.py +711 -0
- decisionnode/_types.py +764 -0
- decisionnode/_uploads.py +265 -0
- decisionnode/_validate.py +934 -0
- decisionnode/_version.py +5 -0
- decisionnode/_videos.py +197 -0
- decisionnode/py.typed +0 -0
decisionnode/_uploads.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""Image uploads: the content-hash cache, the inline-or-upload plan and the storage PUT.
|
|
2
|
+
|
|
3
|
+
The network calls live in the clients; everything here is shared and does no I/O.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import base64
|
|
9
|
+
import collections
|
|
10
|
+
import re
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Set, Tuple
|
|
14
|
+
from urllib.parse import urlsplit
|
|
15
|
+
|
|
16
|
+
from ._errors import DecisionNodeError
|
|
17
|
+
from ._images import Image
|
|
18
|
+
|
|
19
|
+
__all__ = ["UploadCache", "ImagePlan", "plan_images"]
|
|
20
|
+
|
|
21
|
+
CACHE_ENTRIES = 256
|
|
22
|
+
# An upload is reused until this many seconds before its expires_at, so a request never
|
|
23
|
+
# names an upload that expires while it is under way.
|
|
24
|
+
CACHE_MARGIN = 300.0
|
|
25
|
+
LOCK_STRIPES = 32
|
|
26
|
+
LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
|
|
27
|
+
|
|
28
|
+
CacheKey = Tuple[bytes, Optional[str]]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def cache_key(img: Image) -> CacheKey:
|
|
32
|
+
return img.sha256(), img.media_type
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def stripe(key: CacheKey) -> int:
|
|
36
|
+
return key[0][0] % LOCK_STRIPES
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class UploadCache:
|
|
40
|
+
"""The client's uploads by the SHA-256 of their bytes and their media type.
|
|
41
|
+
|
|
42
|
+
In memory only, never shared between clients, at most 256 entries with the least
|
|
43
|
+
recently used out first. An entry is used until 5 minutes before its ``expires_at``.
|
|
44
|
+
Safe to use from several threads.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self, entries: int = CACHE_ENTRIES, margin: float = CACHE_MARGIN) -> None:
|
|
48
|
+
self._entries = entries
|
|
49
|
+
self._margin = margin
|
|
50
|
+
self._items: collections.OrderedDict[CacheKey, Tuple[str, float]] = collections.OrderedDict()
|
|
51
|
+
self._lock = threading.Lock()
|
|
52
|
+
|
|
53
|
+
def __len__(self) -> int:
|
|
54
|
+
return len(self._items)
|
|
55
|
+
|
|
56
|
+
def get(self, key: CacheKey, now: Optional[float] = None) -> Optional[str]:
|
|
57
|
+
if not self._items:
|
|
58
|
+
return None
|
|
59
|
+
with self._lock:
|
|
60
|
+
hit = self._items.get(key)
|
|
61
|
+
if hit is None:
|
|
62
|
+
return None
|
|
63
|
+
if (time.time() if now is None else now) >= hit[1] - self._margin:
|
|
64
|
+
del self._items[key]
|
|
65
|
+
return None
|
|
66
|
+
self._items.move_to_end(key)
|
|
67
|
+
return hit[0]
|
|
68
|
+
|
|
69
|
+
def put(self, key: CacheKey, upload_id: str, expires_at: Optional[float]) -> None:
|
|
70
|
+
if expires_at is None:
|
|
71
|
+
return
|
|
72
|
+
with self._lock:
|
|
73
|
+
self._items[key] = (upload_id, expires_at)
|
|
74
|
+
self._items.move_to_end(key)
|
|
75
|
+
while len(self._items) > self._entries:
|
|
76
|
+
self._items.popitem(last=False)
|
|
77
|
+
|
|
78
|
+
def drop(self, upload_ids: Iterable[str]) -> None:
|
|
79
|
+
ids = set(upload_ids)
|
|
80
|
+
if not ids:
|
|
81
|
+
return
|
|
82
|
+
with self._lock:
|
|
83
|
+
for key in [k for k, v in self._items.items() if v[0] in ids]:
|
|
84
|
+
del self._items[key]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# Stands for an upload id ("upl_" and 32 characters) when a body is measured before its
|
|
88
|
+
# images are uploaded.
|
|
89
|
+
UPLOAD_ID_PLACEHOLDER = "upl_" + "0" * 32
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class ImagePlan:
|
|
93
|
+
"""How the images of one request (or frame, or batch line) go out.
|
|
94
|
+
|
|
95
|
+
``wires`` holds the wire form of each image, ``None`` where it waits for an upload;
|
|
96
|
+
``uploads`` the distinct local images to upload; ``placed`` the upload ids the client
|
|
97
|
+
put in the request itself (cache hits and new uploads), which it may replace once
|
|
98
|
+
when the server reports them stale.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
__slots__ = ("wires", "pending", "uploads", "placed", "strip_id")
|
|
102
|
+
|
|
103
|
+
def __init__(self, strip_id: bool) -> None:
|
|
104
|
+
self.wires: List[Optional[Dict[str, Any]]] = []
|
|
105
|
+
self.pending: List[Tuple[int, Image]] = []
|
|
106
|
+
self.uploads: List[Image] = []
|
|
107
|
+
self.placed: Set[str] = set()
|
|
108
|
+
self.strip_id = strip_id
|
|
109
|
+
|
|
110
|
+
def by_upload(self, img: Image, upload_id: str) -> Dict[str, Any]:
|
|
111
|
+
self.placed.add(upload_id)
|
|
112
|
+
if self.strip_id:
|
|
113
|
+
return {"upload_id": upload_id}
|
|
114
|
+
return {"id": img.id, "upload_id": upload_id}
|
|
115
|
+
|
|
116
|
+
def project(self) -> List[Dict[str, Any]]:
|
|
117
|
+
"""The wire forms with each image still to upload named by a placeholder of an
|
|
118
|
+
upload id's length, so the body's size is known before anything is uploaded."""
|
|
119
|
+
wires = list(self.wires)
|
|
120
|
+
for index, img in self.pending:
|
|
121
|
+
wires[index] = (
|
|
122
|
+
{"upload_id": UPLOAD_ID_PLACEHOLDER}
|
|
123
|
+
if self.strip_id
|
|
124
|
+
else {"id": img.id, "upload_id": UPLOAD_ID_PLACEHOLDER}
|
|
125
|
+
)
|
|
126
|
+
return [w for w in wires if w is not None]
|
|
127
|
+
|
|
128
|
+
def finish(self, uploaded: Mapping[CacheKey, str]) -> List[Dict[str, Any]]:
|
|
129
|
+
for index, img in self.pending:
|
|
130
|
+
self.wires[index] = self.by_upload(img, uploaded[cache_key(img)])
|
|
131
|
+
self.pending = []
|
|
132
|
+
return [w for w in self.wires if w is not None]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _inline(img: Image, strip_id: bool) -> Dict[str, Any]:
|
|
136
|
+
if strip_id:
|
|
137
|
+
return {"media_type": img.media_type, "data": img.base64()}
|
|
138
|
+
return {"id": img.id, "media_type": img.media_type, "data": img.base64()}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def plan_images(
|
|
142
|
+
images: Sequence[Any],
|
|
143
|
+
cache: UploadCache,
|
|
144
|
+
threshold: int,
|
|
145
|
+
always_upload: bool = False,
|
|
146
|
+
strip_id: bool = False,
|
|
147
|
+
now: Optional[float] = None,
|
|
148
|
+
) -> ImagePlan:
|
|
149
|
+
"""Plan the images, already checked: URLs, upload ids and wire-form mappings as given;
|
|
150
|
+
a local image with a live cache entry by its ``upload_id``; the other local images
|
|
151
|
+
inline when their bytes total under ``threshold``, else uploaded."""
|
|
152
|
+
plan = ImagePlan(strip_id)
|
|
153
|
+
local: List[Tuple[int, Image]] = []
|
|
154
|
+
for i, img in enumerate(images):
|
|
155
|
+
if not isinstance(img, Image):
|
|
156
|
+
wire = dict(img)
|
|
157
|
+
if strip_id:
|
|
158
|
+
wire.pop("id", None)
|
|
159
|
+
plan.wires.append(wire)
|
|
160
|
+
continue
|
|
161
|
+
if img.data is None:
|
|
162
|
+
wire = img.to_dict()
|
|
163
|
+
if strip_id:
|
|
164
|
+
wire.pop("id", None)
|
|
165
|
+
plan.wires.append(wire)
|
|
166
|
+
continue
|
|
167
|
+
hit = cache.get(cache_key(img), now) if len(cache) else None
|
|
168
|
+
if hit is not None:
|
|
169
|
+
plan.wires.append(plan.by_upload(img, hit))
|
|
170
|
+
continue
|
|
171
|
+
plan.wires.append(None)
|
|
172
|
+
local.append((i, img))
|
|
173
|
+
if not local:
|
|
174
|
+
return plan
|
|
175
|
+
total = sum(len(img.data or b"") for _, img in local)
|
|
176
|
+
if not always_upload and total < threshold:
|
|
177
|
+
for i, img in local:
|
|
178
|
+
plan.wires[i] = _inline(img, strip_id)
|
|
179
|
+
return plan
|
|
180
|
+
plan.pending = local
|
|
181
|
+
seen: Set[CacheKey] = set()
|
|
182
|
+
for _, img in local:
|
|
183
|
+
key = cache_key(img)
|
|
184
|
+
if key not in seen:
|
|
185
|
+
seen.add(key)
|
|
186
|
+
plan.uploads.append(img)
|
|
187
|
+
return plan
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def presign_body(images: Sequence[Image]) -> Dict[str, Any]:
|
|
191
|
+
return {
|
|
192
|
+
"files": [
|
|
193
|
+
{
|
|
194
|
+
"content_type": img.media_type,
|
|
195
|
+
"byte_size": len(img.data or b""),
|
|
196
|
+
"checksum": base64.b64encode(img.md5()).decode("ascii"),
|
|
197
|
+
"sha256": base64.b64encode(img.sha256()).decode("ascii"),
|
|
198
|
+
}
|
|
199
|
+
for img in images
|
|
200
|
+
]
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def presigned_entries(body: Any, count: int) -> List[Dict[str, Any]]:
|
|
205
|
+
"""The presign response's uploads, one per file, or a ``DecisionNodeError``."""
|
|
206
|
+
uploads = body.get("uploads") if isinstance(body, Mapping) else None
|
|
207
|
+
if not isinstance(uploads, list) or len(uploads) != count:
|
|
208
|
+
raise DecisionNodeError("the upload response does not hold one upload per file", status=201, body=body)
|
|
209
|
+
out = []
|
|
210
|
+
for entry in uploads:
|
|
211
|
+
if (
|
|
212
|
+
not isinstance(entry, Mapping)
|
|
213
|
+
or not isinstance(entry.get("upload_id"), str)
|
|
214
|
+
or not isinstance(entry.get("url"), str)
|
|
215
|
+
):
|
|
216
|
+
raise DecisionNodeError("the upload response holds an upload without an id or a URL", status=201, body=body)
|
|
217
|
+
headers = entry.get("headers")
|
|
218
|
+
out.append(
|
|
219
|
+
{
|
|
220
|
+
"upload_id": entry["upload_id"],
|
|
221
|
+
"url": entry["url"],
|
|
222
|
+
"headers": {str(k): str(v) for k, v in headers.items()} if isinstance(headers, Mapping) else {},
|
|
223
|
+
"expires_at": entry.get("expires_at"),
|
|
224
|
+
}
|
|
225
|
+
)
|
|
226
|
+
return out
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def check_storage_url(url: str) -> None:
|
|
230
|
+
"""Refuse a storage URL that is not ``https`` (a loopback ``http`` URL is accepted)
|
|
231
|
+
before any byte is sent."""
|
|
232
|
+
try:
|
|
233
|
+
parts = urlsplit(url)
|
|
234
|
+
host = (parts.hostname or "").lower()
|
|
235
|
+
except ValueError:
|
|
236
|
+
host, parts = "", None
|
|
237
|
+
scheme = parts.scheme.lower() if parts is not None else ""
|
|
238
|
+
if scheme == "https" and host:
|
|
239
|
+
return
|
|
240
|
+
if scheme == "http" and host in LOOPBACK_HOSTS:
|
|
241
|
+
return
|
|
242
|
+
raise DecisionNodeError(
|
|
243
|
+
"the storage URL of an upload is not https; nothing was sent to it",
|
|
244
|
+
error_type="upload_failed",
|
|
245
|
+
reason="storage URL is not https",
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
_S3_CODE = re.compile(r"<Code>([^<]*)</Code>")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def storage_error(image_id: str, status: int, headers: Mapping[str, str], text: str) -> DecisionNodeError:
|
|
253
|
+
match = _S3_CODE.search(text or "")
|
|
254
|
+
return DecisionNodeError(
|
|
255
|
+
"the upload of image %r to storage failed with %d" % (image_id, status),
|
|
256
|
+
status=status,
|
|
257
|
+
error_type="upload_failed",
|
|
258
|
+
reason=match.group(1) if match else None,
|
|
259
|
+
body=text,
|
|
260
|
+
headers=headers,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class UrlExpired(Exception):
|
|
265
|
+
"""A 403 from storage after the presigned URL's lifetime: presign again, once."""
|