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/_errors.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Exceptions raised by the DecisionNode client.
|
|
2
|
+
|
|
3
|
+
Every exception derives from :class:`DecisionNodeError`. Errors that come from an
|
|
4
|
+
HTTP response carry the status and the ``error_type`` from the body, the request id
|
|
5
|
+
and the server's explanation (``reason``) from the response headers. The exception's
|
|
6
|
+
own message (``str(error)``) is the server's ``message``, or the client's description
|
|
7
|
+
when no response was received.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import TYPE_CHECKING, Any, Dict, Mapping, Optional
|
|
13
|
+
|
|
14
|
+
from ._boosters import BoosterStatus, header, parse_boosters
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from ._types import Safety
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"DecisionNodeError",
|
|
21
|
+
"BadRequestError",
|
|
22
|
+
"ContextLimitError",
|
|
23
|
+
"AuthenticationError",
|
|
24
|
+
"InsufficientCreditError",
|
|
25
|
+
"RefusalError",
|
|
26
|
+
"PermissionDeniedError",
|
|
27
|
+
"NotFoundError",
|
|
28
|
+
"RequestTooLargeError",
|
|
29
|
+
"ValidationError",
|
|
30
|
+
"RateLimitError",
|
|
31
|
+
"SessionLimitError",
|
|
32
|
+
"OverloadedError",
|
|
33
|
+
"ServerError",
|
|
34
|
+
"ConnectionError",
|
|
35
|
+
"TimeoutError",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class DecisionNodeError(Exception):
|
|
40
|
+
"""Base class of every error this package raises.
|
|
41
|
+
|
|
42
|
+
Also raised as it is for a status without a class of its own (a redirect, which
|
|
43
|
+
is never followed, or a status such as 405, 409 or 418), with :attr:`status` set.
|
|
44
|
+
|
|
45
|
+
``str(error)`` is the server's ``message`` exactly: ``detail.message`` when the
|
|
46
|
+
error body has one, ``detail`` itself when the body carries a plain string, else
|
|
47
|
+
the empty string (never ``None``). When the body is not JSON (an HTML or text page,
|
|
48
|
+
or no body), it is the client's ``HTTP <status> <text>``, for example
|
|
49
|
+
``HTTP 413 Content Too Large``, with `` (request id <id>)`` when the response has an
|
|
50
|
+
``x-request-id``. Errors raised without a response carry the client's description
|
|
51
|
+
instead.
|
|
52
|
+
|
|
53
|
+
Attributes:
|
|
54
|
+
status: The HTTP status, or ``None`` when no response was received.
|
|
55
|
+
error_type: ``detail.error_type`` from the error body, when there is one.
|
|
56
|
+
message: The same text as ``str(error)``.
|
|
57
|
+
request_id: The ``x-request-id`` response header.
|
|
58
|
+
reason: The server's explanation in words, from the ``x-decisionnode-error``
|
|
59
|
+
header, for example ``question 'q': a score takes up to 10 levels, got 11``.
|
|
60
|
+
body: The parsed error body, or ``None`` when it is not JSON (an HTML or text
|
|
61
|
+
page, or no body): such a body is never read, and the message is then
|
|
62
|
+
``HTTP <status> <text>``, with the request id when the response has one.
|
|
63
|
+
headers: The response headers, with lower-case names.
|
|
64
|
+
boosters: For a request that named boosters, what each booster did, from the
|
|
65
|
+
``x-decisionnode-boosters`` header: booster name to :class:`BoosterStatus`, in
|
|
66
|
+
the request's order, so a caller can see which required booster failed.
|
|
67
|
+
Empty when the header is absent.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
message: Optional[str] = None,
|
|
73
|
+
*,
|
|
74
|
+
status: Optional[int] = None,
|
|
75
|
+
error_type: Optional[str] = None,
|
|
76
|
+
request_id: Optional[str] = None,
|
|
77
|
+
reason: Optional[str] = None,
|
|
78
|
+
body: Any = None,
|
|
79
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
80
|
+
) -> None:
|
|
81
|
+
self.status = status
|
|
82
|
+
self.error_type = error_type
|
|
83
|
+
self.request_id = request_id
|
|
84
|
+
self.reason = reason
|
|
85
|
+
self.body = body
|
|
86
|
+
self.headers: dict[str, str] = dict(headers or {})
|
|
87
|
+
self.boosters: Dict[str, BoosterStatus] = parse_boosters(header(self.headers))
|
|
88
|
+
super().__init__(message if isinstance(message, str) else "")
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def message(self) -> str:
|
|
92
|
+
"""The error's message: the same text as ``str(error)``."""
|
|
93
|
+
return str(self.args[0]) if self.args else ""
|
|
94
|
+
|
|
95
|
+
def __repr__(self) -> str:
|
|
96
|
+
parts = [repr(self.message)]
|
|
97
|
+
for name in ("status", "error_type", "request_id", "reason"):
|
|
98
|
+
value = getattr(self, name)
|
|
99
|
+
if value is not None:
|
|
100
|
+
parts.append("%s=%r" % (name, value))
|
|
101
|
+
return "%s(%s)" % (self.__class__.__name__, ", ".join(parts))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class BadRequestError(DecisionNodeError):
|
|
105
|
+
"""400: the request is well formed but cannot be used."""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class ContextLimitError(BadRequestError):
|
|
109
|
+
"""400 with ``error_type`` ``max_tokens_exceeded``: the request is over the model's limit.
|
|
110
|
+
|
|
111
|
+
The count and the limit are in :attr:`reason`.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class AuthenticationError(DecisionNodeError):
|
|
116
|
+
"""401: the API key is missing or not valid.
|
|
117
|
+
|
|
118
|
+
Also raised before any request is sent when no key was configured.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class InsufficientCreditError(DecisionNodeError):
|
|
123
|
+
"""402: the account is out of credit."""
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class RefusalError(DecisionNodeError):
|
|
127
|
+
"""403 with ``error_type`` ``refusal_error``: the request was refused under the usage policy. Never retried.
|
|
128
|
+
|
|
129
|
+
Attributes:
|
|
130
|
+
category: ``harm`` or ``self_harm``.
|
|
131
|
+
safety: What the safety check found, parsed from ``x-decisionnode-safety``.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
def __init__(
|
|
135
|
+
self,
|
|
136
|
+
message: Optional[str] = None,
|
|
137
|
+
*,
|
|
138
|
+
category: Optional[str] = None,
|
|
139
|
+
safety: Optional[Safety] = None,
|
|
140
|
+
**kwargs: Any,
|
|
141
|
+
) -> None:
|
|
142
|
+
self.category = category
|
|
143
|
+
self.safety = safety
|
|
144
|
+
super().__init__(message, **kwargs)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class PermissionDeniedError(DecisionNodeError):
|
|
148
|
+
"""403 that is not a refusal: the workspace may not do this, for example use a booster
|
|
149
|
+
that is turned off for it (``error_type`` ``booster_not_enabled``). Never retried."""
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class NotFoundError(DecisionNodeError):
|
|
153
|
+
"""404: the path does not exist."""
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class RequestTooLargeError(DecisionNodeError):
|
|
157
|
+
"""413, or found before sending: the request body is over the API's limit of
|
|
158
|
+
26,214,400 bytes (25 MiB). Never retried. Send large images as uploads and name them
|
|
159
|
+
by ``upload_id``; images given as a path or bytes over the inline threshold already
|
|
160
|
+
are."""
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class ValidationError(DecisionNodeError):
|
|
164
|
+
"""422: the request does not have the right shape. The message is empty; the
|
|
165
|
+
problems are in :attr:`entries` and summed up in :attr:`reason`.
|
|
166
|
+
|
|
167
|
+
Attributes:
|
|
168
|
+
entries: The server's list of problems, one entry per field.
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
def __init__(self, message: Optional[str] = None, *, entries: Optional[list[Any]] = None, **kwargs: Any) -> None:
|
|
172
|
+
self.entries = entries
|
|
173
|
+
super().__init__(message, **kwargs)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class RateLimitError(DecisionNodeError):
|
|
177
|
+
"""429: too many requests.
|
|
178
|
+
|
|
179
|
+
Attributes:
|
|
180
|
+
retry_after: Seconds the server asked to wait, capped at 60, or ``None``.
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
def __init__(self, message: Optional[str] = None, *, retry_after: Optional[float] = None, **kwargs: Any) -> None:
|
|
184
|
+
self.retry_after = retry_after
|
|
185
|
+
super().__init__(message, **kwargs)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class SessionLimitError(RateLimitError):
|
|
189
|
+
"""429 with ``error_type`` ``session_limit_exceeded``: the key already holds as many open
|
|
190
|
+
sessions as it may. Never retried; end a session or wait for one to expire.
|
|
191
|
+
|
|
192
|
+
A subclass of :class:`RateLimitError`, so code that catches that catches this too.
|
|
193
|
+
"""
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class OverloadedError(DecisionNodeError):
|
|
197
|
+
"""529: the server is overloaded, or (``error_type`` ``booster_unavailable``) a booster
|
|
198
|
+
the request required failed or did not answer in time. Retried like every 529.
|
|
199
|
+
|
|
200
|
+
Attributes:
|
|
201
|
+
retry_after: Seconds the server asked to wait, capped at 60, or ``None``.
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
def __init__(self, message: Optional[str] = None, *, retry_after: Optional[float] = None, **kwargs: Any) -> None:
|
|
205
|
+
self.retry_after = retry_after
|
|
206
|
+
super().__init__(message, **kwargs)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class ServerError(DecisionNodeError):
|
|
210
|
+
"""Any other 5xx status, or a 2xx response whose body is not JSON."""
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class ConnectionError(DecisionNodeError): # noqa: A001 - the name is part of the public surface
|
|
214
|
+
"""No response was received: the connection failed or was dropped."""
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class TimeoutError(DecisionNodeError): # noqa: A001 - the name is part of the public surface
|
|
218
|
+
"""No response arrived within the client's timeout."""
|
decisionnode/_images.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Images sent beside the state: the ``Image`` type, format detection and header parsing.
|
|
2
|
+
|
|
3
|
+
Local bytes are checked here, without decoding the image: the format from its first
|
|
4
|
+
bytes, its size, and its pixel dimensions from the header (JPEG SOF markers, the PNG
|
|
5
|
+
IHDR chunk, the WebP VP8, VP8L and VP8X headers).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import binascii
|
|
12
|
+
import hashlib
|
|
13
|
+
import os
|
|
14
|
+
import struct
|
|
15
|
+
from typing import Any, Dict, Optional, Tuple, Union
|
|
16
|
+
|
|
17
|
+
__all__ = ["Image", "DEFAULT_INLINE_THRESHOLD", "image_dimensions", "sniff_media_type"]
|
|
18
|
+
|
|
19
|
+
DEFAULT_INLINE_THRESHOLD = 524_288
|
|
20
|
+
"""Local images of one request (or session frame, or batch line) that total fewer bytes
|
|
21
|
+
than this go inline as base64; at or above it they are uploaded first. The client's
|
|
22
|
+
``inline_threshold`` overrides it."""
|
|
23
|
+
|
|
24
|
+
MEDIA_TYPES = ("image/jpeg", "image/png", "image/webp")
|
|
25
|
+
MAX_IMAGE_BYTES = 10_485_760
|
|
26
|
+
MAX_IMAGE_PIXELS = 40_000_000
|
|
27
|
+
MAX_URL_CHARS = 2048
|
|
28
|
+
|
|
29
|
+
# Start-of-frame markers carry the frame's height and width. C4 (DHT), C8 (reserved)
|
|
30
|
+
# and CC (DAC) share the range but are not frames.
|
|
31
|
+
_JPEG_SOF = frozenset(range(0xC0, 0xD0)) - {0xC4, 0xC8, 0xCC}
|
|
32
|
+
# Markers that stand alone, without a length: TEM, RST0 to RST7, SOI.
|
|
33
|
+
_JPEG_STANDALONE = frozenset({0x01, 0xD8} | set(range(0xD0, 0xD8)))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def sniff_media_type(data: bytes) -> Optional[str]:
|
|
37
|
+
"""The media type of JPEG, PNG or WebP bytes, read from their first bytes."""
|
|
38
|
+
if data[:3] == b"\xff\xd8\xff":
|
|
39
|
+
return "image/jpeg"
|
|
40
|
+
if data[:8] == b"\x89PNG\r\n\x1a\n":
|
|
41
|
+
return "image/png"
|
|
42
|
+
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
|
43
|
+
return "image/webp"
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _png_dimensions(data: bytes) -> Optional[Tuple[int, int]]:
|
|
48
|
+
# Signature (8), then the IHDR chunk: length (4), type (4), width (4), height (4).
|
|
49
|
+
if len(data) < 24 or data[12:16] != b"IHDR":
|
|
50
|
+
return None
|
|
51
|
+
width, height = struct.unpack(">II", data[16:24])
|
|
52
|
+
return width, height
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _jpeg_dimensions(data: bytes) -> Optional[Tuple[int, int]]:
|
|
56
|
+
i = 2
|
|
57
|
+
n = len(data)
|
|
58
|
+
while i < n:
|
|
59
|
+
if data[i] != 0xFF:
|
|
60
|
+
return None
|
|
61
|
+
# Any number of 0xFF fill bytes may precede a marker.
|
|
62
|
+
while i < n and data[i] == 0xFF:
|
|
63
|
+
i += 1
|
|
64
|
+
if i >= n:
|
|
65
|
+
return None
|
|
66
|
+
marker = data[i]
|
|
67
|
+
i += 1
|
|
68
|
+
if marker in _JPEG_STANDALONE:
|
|
69
|
+
continue
|
|
70
|
+
if marker in (0xD9, 0xDA): # end of image, or the scan began before any frame header
|
|
71
|
+
return None
|
|
72
|
+
if i + 2 > n:
|
|
73
|
+
return None
|
|
74
|
+
(length,) = struct.unpack(">H", data[i : i + 2])
|
|
75
|
+
if length < 2:
|
|
76
|
+
return None
|
|
77
|
+
if marker in _JPEG_SOF:
|
|
78
|
+
# Length (2), precision (1), height (2), width (2).
|
|
79
|
+
if i + 7 > n:
|
|
80
|
+
return None
|
|
81
|
+
height, width = struct.unpack(">HH", data[i + 3 : i + 7])
|
|
82
|
+
return width, height
|
|
83
|
+
i += length
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _webp_dimensions(data: bytes) -> Optional[Tuple[int, int]]:
|
|
88
|
+
if len(data) < 30:
|
|
89
|
+
return None
|
|
90
|
+
chunk = data[12:16]
|
|
91
|
+
if chunk == b"VP8 ":
|
|
92
|
+
# Frame tag (3 bytes), start code 9D 01 2A, then 14-bit width and height.
|
|
93
|
+
if data[23:26] != b"\x9d\x01\x2a":
|
|
94
|
+
return None
|
|
95
|
+
width, height = struct.unpack("<HH", data[26:30])
|
|
96
|
+
return width & 0x3FFF, height & 0x3FFF
|
|
97
|
+
if chunk == b"VP8L":
|
|
98
|
+
# Signature 0x2F, then width - 1 and height - 1 in 14 bits each.
|
|
99
|
+
if data[20] != 0x2F:
|
|
100
|
+
return None
|
|
101
|
+
bits = int.from_bytes(data[21:25], "little")
|
|
102
|
+
return (bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1
|
|
103
|
+
if chunk == b"VP8X":
|
|
104
|
+
# Flags (1), reserved (3), canvas width - 1 (3), canvas height - 1 (3).
|
|
105
|
+
width = int.from_bytes(data[24:27], "little") + 1
|
|
106
|
+
height = int.from_bytes(data[27:30], "little") + 1
|
|
107
|
+
return width, height
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def image_dimensions(data: bytes, media_type: Optional[str] = None) -> Optional[Tuple[int, int]]:
|
|
112
|
+
"""``(width, height)`` read from the image's header, without decoding it.
|
|
113
|
+
|
|
114
|
+
``None`` when the bytes are not JPEG, PNG or WebP or the header cannot be read.
|
|
115
|
+
|
|
116
|
+
>>> image_dimensions(b"\\x89PNG\\r\\n\\x1a\\n\\x00\\x00\\x00\\rIHDR\\x00\\x00\\x00\\x03\\x00\\x00\\x00\\x05")
|
|
117
|
+
(3, 5)
|
|
118
|
+
"""
|
|
119
|
+
kind = media_type or sniff_media_type(data)
|
|
120
|
+
try:
|
|
121
|
+
if kind == "image/png":
|
|
122
|
+
dims = _png_dimensions(data)
|
|
123
|
+
elif kind == "image/jpeg":
|
|
124
|
+
dims = _jpeg_dimensions(data)
|
|
125
|
+
elif kind == "image/webp":
|
|
126
|
+
dims = _webp_dimensions(data)
|
|
127
|
+
else:
|
|
128
|
+
return None
|
|
129
|
+
except (struct.error, IndexError):
|
|
130
|
+
return None
|
|
131
|
+
if dims is None or dims[0] <= 0 or dims[1] <= 0:
|
|
132
|
+
return None
|
|
133
|
+
return dims
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def bytes_fault(image_id: str, data: bytes, declared: Optional[str]) -> Optional[str]:
|
|
137
|
+
"""What the API would refuse in these bytes, in its words, or ``None``."""
|
|
138
|
+
if len(data) > MAX_IMAGE_BYTES:
|
|
139
|
+
return "Image %s is larger than 10 MB." % image_id
|
|
140
|
+
if declared is not None and declared not in MEDIA_TYPES:
|
|
141
|
+
return "Image %s: media_type must be image/jpeg, image/png or image/webp." % image_id
|
|
142
|
+
found = sniff_media_type(data)
|
|
143
|
+
if found is None:
|
|
144
|
+
if declared is not None:
|
|
145
|
+
return "Image %s could not be decoded as %s." % (image_id, declared)
|
|
146
|
+
return "Image %s is not a JPEG, PNG or WebP image." % image_id
|
|
147
|
+
if declared is not None and declared != found:
|
|
148
|
+
return "Image %s could not be decoded as %s." % (image_id, declared)
|
|
149
|
+
dims = image_dimensions(data, found)
|
|
150
|
+
if dims is None:
|
|
151
|
+
return "Image %s could not be decoded as %s." % (image_id, found)
|
|
152
|
+
if dims[0] * dims[1] > MAX_IMAGE_PIXELS:
|
|
153
|
+
return "Image %s is %dx%d pixels, over the limit of 40 megapixels." % (image_id, dims[0], dims[1])
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class Image:
|
|
158
|
+
"""An image every question in the request can see, together with the state.
|
|
159
|
+
|
|
160
|
+
Give exactly one source::
|
|
161
|
+
|
|
162
|
+
Image("receipt", url="https://example.com/receipt.jpg") # fetched by the server; the fastest
|
|
163
|
+
Image("receipt", path="receipt.jpg") # read here
|
|
164
|
+
Image("receipt", data=raw_bytes) # bytes you already have
|
|
165
|
+
Image("receipt", data="iVBORw0KGgo...") # base64 text
|
|
166
|
+
Image("receipt", upload_id="upl_...") # an earlier upload
|
|
167
|
+
|
|
168
|
+
The id is unique within a request. The model sees the image as "Picture <n>" (its
|
|
169
|
+
place in the request's images, from 1) with this id beside it, so text in the state
|
|
170
|
+
or the instructions can refer to it by either.
|
|
171
|
+
|
|
172
|
+
A file is read when the ``Image`` is made; a file that cannot be read raises
|
|
173
|
+
``ValueError`` here. Local bytes are checked before any request, without decoding
|
|
174
|
+
the image: JPEG, PNG or WebP by their first bytes (a ``media_type`` given with them
|
|
175
|
+
must match), at most 10 MB, at most 40 megapixels by the header. A fault raises
|
|
176
|
+
:class:`BadRequestError` when the image is sent, as the API would.
|
|
177
|
+
|
|
178
|
+
Small local images go inline as base64; when the local images of a request total
|
|
179
|
+
``inline_threshold`` bytes or more they are uploaded first and sent by ``upload_id``.
|
|
180
|
+
An ``Image`` can be reused across calls; its checks and hashes are computed once.
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
__slots__ = ("id", "url", "upload_id", "data", "media_type", "declared", "fault", "_sha256", "_md5", "_b64")
|
|
184
|
+
|
|
185
|
+
def __init__(
|
|
186
|
+
self,
|
|
187
|
+
id: str, # noqa: A002 - the wire name
|
|
188
|
+
path: Union[str, os.PathLike[str], None] = None,
|
|
189
|
+
*,
|
|
190
|
+
data: Union[bytes, bytearray, memoryview, str, None] = None,
|
|
191
|
+
media_type: Optional[str] = None,
|
|
192
|
+
url: Optional[str] = None,
|
|
193
|
+
upload_id: Optional[str] = None,
|
|
194
|
+
) -> None:
|
|
195
|
+
sources = sum(x is not None for x in (path, data, url, upload_id))
|
|
196
|
+
if sources != 1:
|
|
197
|
+
raise ValueError("Image needs exactly one of path, data, url or upload_id")
|
|
198
|
+
if not isinstance(id, str):
|
|
199
|
+
raise ValueError("Image id must be a string, got %r" % (id,))
|
|
200
|
+
self.id = id
|
|
201
|
+
self.url = url
|
|
202
|
+
self.upload_id = upload_id
|
|
203
|
+
self.data: Optional[bytes] = None
|
|
204
|
+
self.declared: Optional[str] = None
|
|
205
|
+
self.media_type: Optional[str] = None
|
|
206
|
+
# What the API would refuse in these bytes, raised as BadRequestError when sent.
|
|
207
|
+
self.fault: Optional[str] = None
|
|
208
|
+
self._sha256: Optional[bytes] = None
|
|
209
|
+
self._md5: Optional[bytes] = None
|
|
210
|
+
self._b64: Optional[str] = None
|
|
211
|
+
if url is not None:
|
|
212
|
+
if not isinstance(url, str):
|
|
213
|
+
raise ValueError("image %r: url must be a string" % id)
|
|
214
|
+
if media_type is not None:
|
|
215
|
+
raise ValueError("media_type goes with data, a path or an upload_id, not with url")
|
|
216
|
+
return
|
|
217
|
+
if upload_id is not None:
|
|
218
|
+
if not isinstance(upload_id, str):
|
|
219
|
+
raise ValueError("image %r: upload_id must be a string" % id)
|
|
220
|
+
self.media_type = media_type
|
|
221
|
+
return
|
|
222
|
+
if path is not None:
|
|
223
|
+
try:
|
|
224
|
+
with open(path, "rb") as fh:
|
|
225
|
+
data = fh.read()
|
|
226
|
+
except OSError as exc:
|
|
227
|
+
raise ValueError("image %r: cannot read %s: %s" % (id, os.fspath(path), exc.strerror or exc)) from exc
|
|
228
|
+
raw: bytes
|
|
229
|
+
if isinstance(data, str):
|
|
230
|
+
try:
|
|
231
|
+
raw = base64.b64decode(data, validate=True)
|
|
232
|
+
except (binascii.Error, ValueError):
|
|
233
|
+
self.data = b""
|
|
234
|
+
self.declared = media_type
|
|
235
|
+
self.media_type = media_type
|
|
236
|
+
self.fault = "Image %s: data is not valid base64." % id
|
|
237
|
+
return
|
|
238
|
+
elif isinstance(data, (bytes, bytearray, memoryview)):
|
|
239
|
+
raw = bytes(data)
|
|
240
|
+
else:
|
|
241
|
+
raise ValueError("image %r: data must be bytes or base64 text" % id)
|
|
242
|
+
self.data = raw
|
|
243
|
+
self.declared = media_type
|
|
244
|
+
self.fault = bytes_fault(id, raw, media_type)
|
|
245
|
+
self.media_type = media_type or sniff_media_type(raw)
|
|
246
|
+
|
|
247
|
+
# ---- values computed once and reused across calls
|
|
248
|
+
|
|
249
|
+
def sha256(self) -> bytes:
|
|
250
|
+
"""The SHA-256 digest of the bytes."""
|
|
251
|
+
if self._sha256 is None:
|
|
252
|
+
self._sha256 = hashlib.sha256(self.data or b"").digest()
|
|
253
|
+
return self._sha256
|
|
254
|
+
|
|
255
|
+
def md5(self) -> bytes:
|
|
256
|
+
"""The MD5 digest of the bytes, the storage's integrity check."""
|
|
257
|
+
if self._md5 is None:
|
|
258
|
+
self._md5 = hashlib.md5(self.data or b"").digest()
|
|
259
|
+
return self._md5
|
|
260
|
+
|
|
261
|
+
def base64(self) -> str:
|
|
262
|
+
"""The bytes in base64."""
|
|
263
|
+
if self._b64 is None:
|
|
264
|
+
self._b64 = base64.b64encode(self.data or b"").decode("ascii")
|
|
265
|
+
return self._b64
|
|
266
|
+
|
|
267
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
268
|
+
"""The image in its wire form, with local bytes inline."""
|
|
269
|
+
if self.url is not None:
|
|
270
|
+
return {"id": self.id, "url": self.url}
|
|
271
|
+
if self.upload_id is not None:
|
|
272
|
+
out: Dict[str, Any] = {"id": self.id, "upload_id": self.upload_id}
|
|
273
|
+
if self.media_type is not None:
|
|
274
|
+
out["media_type"] = self.media_type
|
|
275
|
+
return out
|
|
276
|
+
return {"id": self.id, "media_type": self.media_type, "data": self.base64()}
|
|
277
|
+
|
|
278
|
+
def __repr__(self) -> str:
|
|
279
|
+
if self.url is not None:
|
|
280
|
+
return "Image(%r, url=%r)" % (self.id, self.url)
|
|
281
|
+
if self.upload_id is not None:
|
|
282
|
+
return "Image(%r, upload_id=%r)" % (self.id, self.upload_id)
|
|
283
|
+
return "Image(%r, media_type=%r, %d bytes)" % (self.id, self.media_type, len(self.data or b""))
|