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.
@@ -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."""