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
|
@@ -0,0 +1,934 @@
|
|
|
1
|
+
"""The API's fixed request rules, checked before anything is sent.
|
|
2
|
+
|
|
3
|
+
A fault raises the API's own error class with the message, ``error_type``, 422 entries
|
|
4
|
+
and explanation the API would answer with, but no status and no request id, since no
|
|
5
|
+
request was made. Only rules that hold for every model are checked here; limits that
|
|
6
|
+
differ by model, such as the token limit, stay the server's.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import decimal
|
|
12
|
+
import ipaddress
|
|
13
|
+
import json
|
|
14
|
+
import math
|
|
15
|
+
import re
|
|
16
|
+
from decimal import Decimal
|
|
17
|
+
from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Set, Tuple
|
|
18
|
+
from urllib.parse import urlsplit
|
|
19
|
+
|
|
20
|
+
from ._errors import BadRequestError, RequestTooLargeError, ValidationError
|
|
21
|
+
from ._images import MAX_IMAGE_BYTES, MAX_URL_CHARS, MEDIA_TYPES, Image
|
|
22
|
+
from ._types import LEGACY_TRUTH
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"check_request",
|
|
26
|
+
"check_questions",
|
|
27
|
+
"check_images",
|
|
28
|
+
"check_session_open",
|
|
29
|
+
"check_frame",
|
|
30
|
+
"check_lines",
|
|
31
|
+
"MAX_IMAGES",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
MAX_IMAGES = 16
|
|
35
|
+
MAX_VIDEOS = 4
|
|
36
|
+
MAX_UPLOAD_FILES = 16
|
|
37
|
+
MAX_CHOICE_OPTIONS = 255
|
|
38
|
+
MAX_SCORE_LEVELS = 10
|
|
39
|
+
MAX_GRID_VALUES = 4096
|
|
40
|
+
NUMBER_MAX_DECIMALS = 6
|
|
41
|
+
TWO_53 = 2**53
|
|
42
|
+
MAX_NESTING = 128
|
|
43
|
+
# An image id's length in characters (code points), decided 2026-10-09.
|
|
44
|
+
MAX_IMAGE_ID_CHARS = 64
|
|
45
|
+
# The API's request body limit, 25 MiB: inline images count, uploads and URLs do not.
|
|
46
|
+
MAX_BODY_BYTES = 26_214_400
|
|
47
|
+
BODY_TOO_LARGE = (
|
|
48
|
+
"The request body is over 26,214,400 bytes (25 MiB). "
|
|
49
|
+
"Send large images as uploads (POST /v1/uploads) and name them by upload_id."
|
|
50
|
+
)
|
|
51
|
+
MAX_WINDOW = 64
|
|
52
|
+
MAX_TTL_SECONDS = 3600
|
|
53
|
+
MAX_SEQ = 2**53 - 1
|
|
54
|
+
LIST_LIMIT_MAX = 100
|
|
55
|
+
MAX_CUSTOM_ID_CHARS = 512
|
|
56
|
+
DECIDE_TYPES = ("truth", LEGACY_TRUTH, "choice", "score", "number", "point", "box")
|
|
57
|
+
PLACE_TYPES = ("point", "box")
|
|
58
|
+
# Several places (decided 2026-10-12): a point or box question's max, 2 to 16.
|
|
59
|
+
PLACES_MIN, PLACES_MAX = 2, 16
|
|
60
|
+
# Collections (decided 2026-10-12): a collection's name, and an item's reference and label.
|
|
61
|
+
COLLECTION_TYPES = ("face",)
|
|
62
|
+
COLLECTION_NAME_MAX = 80
|
|
63
|
+
ITEM_REFERENCE_MAX = 64
|
|
64
|
+
ITEM_REFERENCE_PATTERN = "^[A-Za-z0-9_.:-]{1,64}$"
|
|
65
|
+
ITEM_LABEL_MAX = 128
|
|
66
|
+
UNITS = ("pixels", "normalized")
|
|
67
|
+
Entry = Tuple[Dict[str, Any], Optional[str]]
|
|
68
|
+
Limits = Mapping[str, Any]
|
|
69
|
+
_DEFAULTS = {"min": Decimal(0), "max": Decimal(255), "step": Decimal(1)}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _compact(value: Any) -> str:
|
|
73
|
+
return json.dumps(value, separators=(",", ":"), ensure_ascii=False)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _ascii(text: str) -> str:
|
|
77
|
+
"""Visible ASCII only, as the API writes its explanation header."""
|
|
78
|
+
out = []
|
|
79
|
+
for ch in text:
|
|
80
|
+
code = ord(ch)
|
|
81
|
+
if 0x20 <= code <= 0x7E:
|
|
82
|
+
out.append(ch)
|
|
83
|
+
elif code <= 0xFFFF:
|
|
84
|
+
out.append("\\u%04x" % code)
|
|
85
|
+
else:
|
|
86
|
+
code -= 0x10000
|
|
87
|
+
out.append("\\u%04x\\u%04x" % (0xD800 + (code >> 10), 0xDC00 + (code & 0x3FF)))
|
|
88
|
+
return "".join(out)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def usage_error(message: str, reason: Optional[str] = None) -> BadRequestError:
|
|
92
|
+
"""A 400 ``api_usage_error`` raised before any request."""
|
|
93
|
+
return BadRequestError(message, error_type="api_usage_error", reason=_ascii(reason or message))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def check_body_size(size: int) -> None:
|
|
97
|
+
"""The API's body limit, against the size of the body exactly as it will be sent."""
|
|
98
|
+
if size > MAX_BODY_BYTES:
|
|
99
|
+
raise RequestTooLargeError(
|
|
100
|
+
BODY_TOO_LARGE,
|
|
101
|
+
error_type="request_too_large_error",
|
|
102
|
+
reason="the body is %d bytes; the limit is %d" % (size, MAX_BODY_BYTES),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _plain_400(message: str, reason: str) -> BadRequestError:
|
|
107
|
+
# The API answers these with a plain-string detail, so there is no error_type.
|
|
108
|
+
return BadRequestError(message, reason=_ascii(reason))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _entry(kind: str, loc: List[Any], msg: str, value: Any, ctx: Any = None) -> Dict[str, Any]:
|
|
112
|
+
out: Dict[str, Any] = {"type": kind, "loc": loc, "msg": msg, "input": value}
|
|
113
|
+
if ctx is not None:
|
|
114
|
+
out["ctx"] = ctx
|
|
115
|
+
return out
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# --------------------------------------------------------------------------- questions
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _dec_text(value: Decimal) -> str:
|
|
122
|
+
if value == 0:
|
|
123
|
+
return "0"
|
|
124
|
+
text = format(value.normalize(), "f")
|
|
125
|
+
if "." in text:
|
|
126
|
+
text = text.rstrip("0").rstrip(".")
|
|
127
|
+
return text
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _places(value: Decimal) -> int:
|
|
131
|
+
exp = value.normalize().as_tuple().exponent
|
|
132
|
+
return max(0, -exp) if isinstance(exp, int) else 0
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _json_number(value: Decimal) -> Any:
|
|
136
|
+
text = _dec_text(value)
|
|
137
|
+
return float(text) if "." in text else int(text)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _number_entries(
|
|
141
|
+
qid: str, q: Mapping[str, Any], loc: List[Any], max_values: int = MAX_GRID_VALUES
|
|
142
|
+
) -> Tuple[List[Tuple[Dict[str, Any], str]], bool]:
|
|
143
|
+
"""The 422 entries of one number question, with the API's explanation of each, and
|
|
144
|
+
whether the grid could be built."""
|
|
145
|
+
entries: List[Tuple[Dict[str, Any], str]] = []
|
|
146
|
+
values: Dict[str, Decimal] = {}
|
|
147
|
+
ok = True
|
|
148
|
+
for name in ("min", "max", "step"):
|
|
149
|
+
sent = q.get(name)
|
|
150
|
+
floc = loc + [name]
|
|
151
|
+
if sent is None:
|
|
152
|
+
values[name] = _DEFAULTS[name]
|
|
153
|
+
continue
|
|
154
|
+
if isinstance(sent, bool) or not isinstance(sent, (int, float)):
|
|
155
|
+
kind = (
|
|
156
|
+
"a string"
|
|
157
|
+
if isinstance(sent, str)
|
|
158
|
+
else "a boolean"
|
|
159
|
+
if isinstance(sent, bool)
|
|
160
|
+
else "an object"
|
|
161
|
+
if isinstance(sent, Mapping)
|
|
162
|
+
else "an array"
|
|
163
|
+
)
|
|
164
|
+
entries.append(
|
|
165
|
+
(
|
|
166
|
+
_entry("float_type", floc, "Input should be a valid number", sent),
|
|
167
|
+
"question '%s': %s must be a number, got %s" % (qid, name, kind),
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
ok = False
|
|
171
|
+
continue
|
|
172
|
+
if isinstance(sent, float) and not math.isfinite(sent):
|
|
173
|
+
entries.append(
|
|
174
|
+
(
|
|
175
|
+
_entry("finite_number", floc, "Input should be a finite number", sent),
|
|
176
|
+
"question '%s': %s must be a finite number" % (qid, name),
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
ok = False
|
|
180
|
+
continue
|
|
181
|
+
# The value as the request will write it: Python writes a float as its shortest
|
|
182
|
+
# repr, the same text the server reads exactly.
|
|
183
|
+
dec = Decimal(repr(sent)) if isinstance(sent, float) else Decimal(sent)
|
|
184
|
+
if name in ("min", "max") and dec < -TWO_53:
|
|
185
|
+
entries.append(
|
|
186
|
+
(
|
|
187
|
+
_entry(
|
|
188
|
+
"greater_than_equal",
|
|
189
|
+
floc,
|
|
190
|
+
"Input should be greater than or equal to -%d" % TWO_53,
|
|
191
|
+
sent,
|
|
192
|
+
{"ge": -TWO_53},
|
|
193
|
+
),
|
|
194
|
+
"question '%s': %s must be within plus or minus 2^53" % (qid, name),
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
ok = False
|
|
198
|
+
continue
|
|
199
|
+
if name in ("min", "max") and dec > TWO_53:
|
|
200
|
+
entries.append(
|
|
201
|
+
(
|
|
202
|
+
_entry(
|
|
203
|
+
"less_than_equal",
|
|
204
|
+
floc,
|
|
205
|
+
"Input should be less than or equal to %d" % TWO_53,
|
|
206
|
+
sent,
|
|
207
|
+
{"le": TWO_53},
|
|
208
|
+
),
|
|
209
|
+
"question '%s': %s must be within plus or minus 2^53" % (qid, name),
|
|
210
|
+
)
|
|
211
|
+
)
|
|
212
|
+
ok = False
|
|
213
|
+
continue
|
|
214
|
+
if name == "step" and dec <= 0:
|
|
215
|
+
entries.append(
|
|
216
|
+
(
|
|
217
|
+
_entry("greater_than", floc, "Input should be greater than 0", sent, {"gt": 0}),
|
|
218
|
+
"question '%s': step must be above 0, got %s" % (qid, _compact(sent)),
|
|
219
|
+
)
|
|
220
|
+
)
|
|
221
|
+
ok = False
|
|
222
|
+
continue
|
|
223
|
+
if name in ("min", "step") and _places(dec) > NUMBER_MAX_DECIMALS:
|
|
224
|
+
entries.append(
|
|
225
|
+
(
|
|
226
|
+
_entry(
|
|
227
|
+
"decimal_max_places",
|
|
228
|
+
floc,
|
|
229
|
+
"Decimal input should have no more than %d decimal places" % NUMBER_MAX_DECIMALS,
|
|
230
|
+
sent,
|
|
231
|
+
{"decimal_places": NUMBER_MAX_DECIMALS},
|
|
232
|
+
),
|
|
233
|
+
"question '%s': %s has %d decimals, at most %d" % (qid, name, _places(dec), NUMBER_MAX_DECIMALS),
|
|
234
|
+
)
|
|
235
|
+
)
|
|
236
|
+
ok = False
|
|
237
|
+
continue
|
|
238
|
+
values[name] = dec
|
|
239
|
+
if not ok:
|
|
240
|
+
return entries, False
|
|
241
|
+
lo, hi, step = values["min"], values["max"], values["step"]
|
|
242
|
+
d = max(_places(lo), _places(step))
|
|
243
|
+
if d > 0:
|
|
244
|
+
bound = Decimal(10) ** (15 - d) - Decimal(10) ** (-d)
|
|
245
|
+
btext = _dec_text(bound)
|
|
246
|
+
why = "question '%s': %%s must be within plus or minus %s on a grid with %d decimals, " % (qid, btext, d)
|
|
247
|
+
why += "so every value fits 15 significant digits"
|
|
248
|
+
for name, val in (("min", lo), ("max", hi)):
|
|
249
|
+
if val > bound:
|
|
250
|
+
entries.append(
|
|
251
|
+
(
|
|
252
|
+
_entry(
|
|
253
|
+
"less_than_equal",
|
|
254
|
+
loc + [name],
|
|
255
|
+
"Input should be less than or equal to %s" % btext,
|
|
256
|
+
q.get(name),
|
|
257
|
+
{"le": _json_number(bound)},
|
|
258
|
+
),
|
|
259
|
+
why % name,
|
|
260
|
+
)
|
|
261
|
+
)
|
|
262
|
+
return entries, False
|
|
263
|
+
if val < -bound:
|
|
264
|
+
entries.append(
|
|
265
|
+
(
|
|
266
|
+
_entry(
|
|
267
|
+
"greater_than_equal",
|
|
268
|
+
loc + [name],
|
|
269
|
+
"Input should be greater than or equal to -%s" % btext,
|
|
270
|
+
q.get(name),
|
|
271
|
+
{"ge": _json_number(-bound)},
|
|
272
|
+
),
|
|
273
|
+
why % name,
|
|
274
|
+
)
|
|
275
|
+
)
|
|
276
|
+
return entries, False
|
|
277
|
+
if lo > hi:
|
|
278
|
+
max_sent = q.get("max") if q.get("max") is not None else 255
|
|
279
|
+
min_sent = q.get("min") if q.get("min") is not None else 0
|
|
280
|
+
entries.append(
|
|
281
|
+
(
|
|
282
|
+
_entry(
|
|
283
|
+
"less_than_equal",
|
|
284
|
+
loc + ["min"],
|
|
285
|
+
"Input should be less than or equal to %s" % _compact(max_sent),
|
|
286
|
+
min_sent,
|
|
287
|
+
{"le": max_sent},
|
|
288
|
+
),
|
|
289
|
+
"question '%s': min %s is above max %s" % (qid, _compact(min_sent), _compact(max_sent)),
|
|
290
|
+
)
|
|
291
|
+
)
|
|
292
|
+
return entries, False
|
|
293
|
+
scale = Decimal(10) ** d
|
|
294
|
+
m = int(lo * scale)
|
|
295
|
+
s = int(step * scale)
|
|
296
|
+
top = int((hi * scale).to_integral_value(rounding=decimal.ROUND_FLOOR))
|
|
297
|
+
size = (top - m) // s + 1
|
|
298
|
+
if size > max_values:
|
|
299
|
+
entries.append(
|
|
300
|
+
(
|
|
301
|
+
_entry(
|
|
302
|
+
"too_long",
|
|
303
|
+
loc,
|
|
304
|
+
"Grid should have at most %d values, not %d" % (max_values, size),
|
|
305
|
+
dict(q),
|
|
306
|
+
{"field_type": "Grid", "max_length": max_values, "actual_length": size},
|
|
307
|
+
),
|
|
308
|
+
"question '%s': the grid holds %d values, the limit is %d; narrow the range or widen the step"
|
|
309
|
+
% (qid, size, max_values),
|
|
310
|
+
)
|
|
311
|
+
)
|
|
312
|
+
return entries, False
|
|
313
|
+
return entries, True
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _is_sdl(value: Any) -> bool:
|
|
317
|
+
return isinstance(value, (str, Mapping, list, tuple))
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _is_int(value: Any) -> bool:
|
|
321
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _sdl_entries(loc: List[Any], value: Any) -> List[Entry]:
|
|
325
|
+
"""A value that must be a string, an object or an array: the API's three entries."""
|
|
326
|
+
return [
|
|
327
|
+
(_entry("string_type", loc + ["str"], "Input should be a valid string", value), None),
|
|
328
|
+
(_entry("dict_type", loc + ["dict[any,any]"], "Input should be a valid dictionary", value), None),
|
|
329
|
+
(_entry("list_type", loc + ["list[any]"], "Input should be a valid list", value), None),
|
|
330
|
+
]
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def nesting_depth(value: Any) -> int:
|
|
334
|
+
"""How many objects and arrays sit inside each other, the outermost included."""
|
|
335
|
+
deepest = 0
|
|
336
|
+
stack: List[Tuple[Any, int]] = [(value, 1)]
|
|
337
|
+
while stack:
|
|
338
|
+
node, depth = stack.pop()
|
|
339
|
+
if isinstance(node, Mapping):
|
|
340
|
+
children: Iterable[Any] = node.values()
|
|
341
|
+
elif isinstance(node, (list, tuple)):
|
|
342
|
+
children = node
|
|
343
|
+
else:
|
|
344
|
+
continue
|
|
345
|
+
if depth > deepest:
|
|
346
|
+
deepest = depth
|
|
347
|
+
stack.extend((c, depth + 1) for c in children if isinstance(c, (Mapping, list, tuple)))
|
|
348
|
+
return deepest
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def check_nesting(body: Any) -> None:
|
|
352
|
+
depth = nesting_depth(body)
|
|
353
|
+
if depth > MAX_NESTING:
|
|
354
|
+
raise usage_error(
|
|
355
|
+
"Invalid request.",
|
|
356
|
+
"the request nests %d objects and arrays inside each other; the limit is %d" % (depth, MAX_NESTING),
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _raise_422(entries: List[Entry]) -> None:
|
|
361
|
+
first, reason = entries[0]
|
|
362
|
+
if reason is None:
|
|
363
|
+
reason = "%s: %s" % (".".join(str(p) for p in first["loc"][1:]) or "body", first["msg"])
|
|
364
|
+
if len(entries) > 1:
|
|
365
|
+
reason += " and %d more" % (len(entries) - 1)
|
|
366
|
+
raise ValidationError("", entries=[e for e, _ in entries], reason=_ascii(reason))
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _score_levels(criteria: Any) -> Optional[Sequence[Any]]:
|
|
370
|
+
if isinstance(criteria, (list, tuple)):
|
|
371
|
+
return criteria
|
|
372
|
+
if isinstance(criteria, Mapping):
|
|
373
|
+
keys = list(criteria)
|
|
374
|
+
if all(isinstance(k, str) for k in keys) and sorted(keys, key=lambda k: (len(k), k)) == [
|
|
375
|
+
str(i) for i in range(len(keys))
|
|
376
|
+
]:
|
|
377
|
+
return [criteria[str(i)] for i in range(len(keys))]
|
|
378
|
+
return None
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _check_place(
|
|
382
|
+
qid: str,
|
|
383
|
+
q: Mapping[str, Any],
|
|
384
|
+
loc: List[Any],
|
|
385
|
+
entries: List[Entry],
|
|
386
|
+
deferred: List[BadRequestError],
|
|
387
|
+
image_ids: Optional[Sequence[Any]],
|
|
388
|
+
) -> None:
|
|
389
|
+
"""A point or box question: the image it looks in (none to look in, none named among
|
|
390
|
+
two or more, not a string, not one of the request's), its units, its instructions.
|
|
391
|
+
``image_ids`` is ``None`` where the request carries no images of its own (a
|
|
392
|
+
session's open: its images come with its frames)."""
|
|
393
|
+
kind = q["type"]
|
|
394
|
+
if image_ids is not None:
|
|
395
|
+
image = q.get("image")
|
|
396
|
+
if not image_ids:
|
|
397
|
+
entries.append(
|
|
398
|
+
(_entry("image_required", loc, "A %s question needs an image in the request" % kind, dict(q)), None)
|
|
399
|
+
)
|
|
400
|
+
elif image is None:
|
|
401
|
+
if len(image_ids) >= 2:
|
|
402
|
+
entries.append((_entry("missing", loc + ["image"], "Field required", dict(q)), None))
|
|
403
|
+
elif not isinstance(image, str):
|
|
404
|
+
entries.append((_entry("string_type", loc + ["image"], "Input should be a valid string", image), None))
|
|
405
|
+
elif image not in image_ids:
|
|
406
|
+
entries.append(
|
|
407
|
+
(
|
|
408
|
+
_entry(
|
|
409
|
+
"unknown_image", loc + ["image"], "Input should be the id of an image of the request", image
|
|
410
|
+
),
|
|
411
|
+
None,
|
|
412
|
+
)
|
|
413
|
+
)
|
|
414
|
+
units = q.get("units")
|
|
415
|
+
if units is not None and units not in UNITS:
|
|
416
|
+
ctx = {"expected": "'pixels' or 'normalized'"}
|
|
417
|
+
entries.append(
|
|
418
|
+
(_entry("literal_error", loc + ["units"], "Input should be 'pixels' or 'normalized'", units, ctx), None)
|
|
419
|
+
)
|
|
420
|
+
most = q.get("max")
|
|
421
|
+
if most is not None:
|
|
422
|
+
if not _is_int(most):
|
|
423
|
+
entries.append((_entry("int_type", loc + ["max"], "Input should be a valid integer", most), None))
|
|
424
|
+
elif most < PLACES_MIN:
|
|
425
|
+
msg = "Input should be greater than or equal to %d" % PLACES_MIN
|
|
426
|
+
entries.append((_entry("greater_than_equal", loc + ["max"], msg, most, {"ge": PLACES_MIN}), None))
|
|
427
|
+
elif most > PLACES_MAX:
|
|
428
|
+
msg = "Input should be less than or equal to %d" % PLACES_MAX
|
|
429
|
+
entries.append((_entry("less_than_equal", loc + ["max"], msg, most, {"le": PLACES_MAX}), None))
|
|
430
|
+
if q.get("instructions") is None:
|
|
431
|
+
deferred.append(
|
|
432
|
+
_plain_400(
|
|
433
|
+
"%s question must have instructions: %s" % (kind.capitalize(), qid),
|
|
434
|
+
"question '%s': a %s question needs instructions" % (qid, kind),
|
|
435
|
+
)
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _check_question(
|
|
440
|
+
qid: str,
|
|
441
|
+
q: Any,
|
|
442
|
+
entries: List[Entry],
|
|
443
|
+
deferred: List[BadRequestError],
|
|
444
|
+
limits: Limits,
|
|
445
|
+
image_ids: Optional[Sequence[Any]] = None,
|
|
446
|
+
) -> None:
|
|
447
|
+
base: List[Any] = ["body", "questions", qid]
|
|
448
|
+
if not isinstance(q, Mapping):
|
|
449
|
+
entries.append((_entry("model_type", base, "Input should be a valid dictionary or object", q), None))
|
|
450
|
+
return
|
|
451
|
+
if q.get("type") is None:
|
|
452
|
+
entries.append((_entry("missing", base + ["type"], "Field required", dict(q)), None))
|
|
453
|
+
return
|
|
454
|
+
kind = q["type"]
|
|
455
|
+
if kind not in DECIDE_TYPES:
|
|
456
|
+
# An unknown type is the API's 400 "Invalid request.", its fields never read.
|
|
457
|
+
why = "question '%s': unknown type %s" % (qid, _compact(kind))
|
|
458
|
+
deferred.append(usage_error("Invalid request.", why))
|
|
459
|
+
return
|
|
460
|
+
loc = base + [kind]
|
|
461
|
+
instructions = q.get("instructions")
|
|
462
|
+
if instructions is not None and not _is_sdl(instructions):
|
|
463
|
+
entries.extend(_sdl_entries(loc + ["instructions"], instructions))
|
|
464
|
+
criteria = q.get("criteria")
|
|
465
|
+
if kind in PLACE_TYPES:
|
|
466
|
+
_check_place(qid, q, loc, entries, deferred, image_ids)
|
|
467
|
+
elif kind == "number":
|
|
468
|
+
grid = limits.get("number_max_grid_values")
|
|
469
|
+
if grid == 0:
|
|
470
|
+
deferred.append(usage_error("Model %s does not answer number questions." % limits.get("model")))
|
|
471
|
+
return
|
|
472
|
+
found, grid_ok = _number_entries(qid, q, loc, grid or MAX_GRID_VALUES)
|
|
473
|
+
entries.extend(found)
|
|
474
|
+
if grid_ok and instructions in (None, ""):
|
|
475
|
+
deferred.append(
|
|
476
|
+
_plain_400(
|
|
477
|
+
"Number question must have instructions: %s" % qid,
|
|
478
|
+
"question '%s': a number question needs instructions" % qid,
|
|
479
|
+
)
|
|
480
|
+
)
|
|
481
|
+
elif kind == "choice":
|
|
482
|
+
most = limits.get("max_choice_options") or MAX_CHOICE_OPTIONS
|
|
483
|
+
if criteria is None:
|
|
484
|
+
entries.append((_entry("missing", loc + ["criteria"], "Field required", dict(q)), None))
|
|
485
|
+
elif not isinstance(criteria, Mapping):
|
|
486
|
+
entries.append(
|
|
487
|
+
(_entry("dict_type", loc + ["criteria"], "Input should be a valid dictionary", criteria), None)
|
|
488
|
+
)
|
|
489
|
+
else:
|
|
490
|
+
for name, desc in criteria.items():
|
|
491
|
+
if desc is not None and not _is_sdl(desc):
|
|
492
|
+
entries.extend(_sdl_entries(loc + ["criteria", name], desc))
|
|
493
|
+
if len(criteria) == 0:
|
|
494
|
+
deferred.append(
|
|
495
|
+
_plain_400(
|
|
496
|
+
"Choice question must have at least one choice: %s" % qid,
|
|
497
|
+
"question '%s': a choice needs at least one option" % qid,
|
|
498
|
+
)
|
|
499
|
+
)
|
|
500
|
+
elif len(criteria) > most:
|
|
501
|
+
deferred.append(
|
|
502
|
+
_plain_400(
|
|
503
|
+
"Too many choices. Must have at most %d choices." % most,
|
|
504
|
+
"question '%s': a choice takes up to %d options, got %d" % (qid, most, len(criteria)),
|
|
505
|
+
)
|
|
506
|
+
)
|
|
507
|
+
elif kind == "score":
|
|
508
|
+
most = limits.get("max_score_levels") or MAX_SCORE_LEVELS
|
|
509
|
+
if criteria is None:
|
|
510
|
+
entries.append((_entry("missing", loc + ["criteria"], "Field required", dict(q)), None))
|
|
511
|
+
return
|
|
512
|
+
levels = _score_levels(criteria)
|
|
513
|
+
if levels is None:
|
|
514
|
+
entries.append((_entry("list_type", loc + ["criteria"], "Input should be a valid list", criteria), None))
|
|
515
|
+
return
|
|
516
|
+
if len(levels) == 0:
|
|
517
|
+
entries.append(
|
|
518
|
+
(
|
|
519
|
+
_entry(
|
|
520
|
+
"too_short",
|
|
521
|
+
loc + ["criteria"],
|
|
522
|
+
"List should have at least 1 item after validation, not 0",
|
|
523
|
+
criteria,
|
|
524
|
+
),
|
|
525
|
+
None,
|
|
526
|
+
)
|
|
527
|
+
)
|
|
528
|
+
return
|
|
529
|
+
for i, level in enumerate(levels):
|
|
530
|
+
if not _is_sdl(level):
|
|
531
|
+
entries.extend(_sdl_entries(loc + ["criteria", i], level))
|
|
532
|
+
if len(levels) > most:
|
|
533
|
+
deferred.append(
|
|
534
|
+
_plain_400(
|
|
535
|
+
"Too many score levels. Must have at most %d levels." % most,
|
|
536
|
+
"question '%s': a score takes up to %d levels, got %d" % (qid, most, len(levels)),
|
|
537
|
+
)
|
|
538
|
+
)
|
|
539
|
+
else: # truth, and the type under its other wire name
|
|
540
|
+
if criteria is not None and not isinstance(criteria, Mapping):
|
|
541
|
+
entries.append(
|
|
542
|
+
(_entry("dict_type", loc + ["criteria"], "Input should be a valid dictionary", criteria), None)
|
|
543
|
+
)
|
|
544
|
+
return
|
|
545
|
+
crit: Mapping[str, Any] = criteria or {}
|
|
546
|
+
for side in ("true", "false"):
|
|
547
|
+
if crit.get(side) is not None and not _is_sdl(crit[side]):
|
|
548
|
+
entries.extend(_sdl_entries(loc + ["criteria", side], crit[side]))
|
|
549
|
+
if instructions is None and crit.get("true") is None and crit.get("false") is None:
|
|
550
|
+
# The API names the type as the request wrote it.
|
|
551
|
+
name = kind[:1].upper() + kind[1:]
|
|
552
|
+
deferred.append(
|
|
553
|
+
_plain_400(
|
|
554
|
+
"%s question must have criteria or instructions: %s" % (name, qid),
|
|
555
|
+
"question '%s': a %s question needs instructions or criteria" % (qid, kind),
|
|
556
|
+
)
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def check_request(
|
|
561
|
+
body: Mapping[str, Any],
|
|
562
|
+
limits: Optional[Limits] = None,
|
|
563
|
+
extra: Sequence[Entry] = (),
|
|
564
|
+
image_ids: Optional[Sequence[Any]] = None,
|
|
565
|
+
tail: Sequence[Entry] = (),
|
|
566
|
+
) -> None:
|
|
567
|
+
"""Raise what the API would answer for this body's fixed rules, in the API's order:
|
|
568
|
+
the nesting limit, every 422 entry, then the first 400 in question order.
|
|
569
|
+
``image_ids`` are the request's image ids, for the point and box rules; ``tail`` are
|
|
570
|
+
422 entries listed after the questions' (the image and video counts). Whether a model
|
|
571
|
+
version answers several places is the server's to say."""
|
|
572
|
+
check_nesting(body)
|
|
573
|
+
held: Limits = limits or {}
|
|
574
|
+
entries: List[Entry] = list(extra)
|
|
575
|
+
deferred: List[BadRequestError] = []
|
|
576
|
+
state = body.get("state")
|
|
577
|
+
if state is not None and not _is_sdl(state):
|
|
578
|
+
entries.extend(_sdl_entries(["body", "state"], state))
|
|
579
|
+
questions = body.get("questions")
|
|
580
|
+
if isinstance(questions, Mapping):
|
|
581
|
+
for qid, q in questions.items():
|
|
582
|
+
_check_question(qid, q, entries, deferred, held, image_ids)
|
|
583
|
+
entries.extend(tail)
|
|
584
|
+
if entries:
|
|
585
|
+
_raise_422(entries)
|
|
586
|
+
if deferred:
|
|
587
|
+
raise deferred[0]
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def check_questions(questions: Mapping[str, Any], limits: Optional[Limits] = None) -> None:
|
|
591
|
+
check_request({"questions": questions}, limits)
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
# --------------------------------------------------------------------------- images
|
|
595
|
+
|
|
596
|
+
BLOCKED_NETWORKS = [
|
|
597
|
+
ipaddress.ip_network(n)
|
|
598
|
+
for n in (
|
|
599
|
+
"0.0.0.0/8",
|
|
600
|
+
"10.0.0.0/8",
|
|
601
|
+
"100.64.0.0/10",
|
|
602
|
+
"127.0.0.0/8",
|
|
603
|
+
"169.254.0.0/16",
|
|
604
|
+
"172.16.0.0/12",
|
|
605
|
+
"192.0.0.0/24",
|
|
606
|
+
"192.0.2.0/24",
|
|
607
|
+
"192.88.99.0/24",
|
|
608
|
+
"192.168.0.0/16",
|
|
609
|
+
"198.18.0.0/15",
|
|
610
|
+
"198.51.100.0/24",
|
|
611
|
+
"203.0.113.0/24",
|
|
612
|
+
"224.0.0.0/4",
|
|
613
|
+
"240.0.0.0/4",
|
|
614
|
+
"255.255.255.255/32",
|
|
615
|
+
"::/128",
|
|
616
|
+
"::1/128",
|
|
617
|
+
"fc00::/7",
|
|
618
|
+
"fe80::/10",
|
|
619
|
+
"ff00::/8",
|
|
620
|
+
"2001:db8::/32",
|
|
621
|
+
"fd00:ec2::254/128",
|
|
622
|
+
)
|
|
623
|
+
]
|
|
624
|
+
NAT64 = ipaddress.ip_network("64:ff9b::/96")
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def blocked_address(host: str) -> bool:
|
|
628
|
+
"""True for a literal address in a range the server never fetches from. A DNS name
|
|
629
|
+
is the server's to resolve and judge."""
|
|
630
|
+
try:
|
|
631
|
+
addr: Any = ipaddress.ip_address(host.strip("[]"))
|
|
632
|
+
except ValueError:
|
|
633
|
+
return False
|
|
634
|
+
if isinstance(addr, ipaddress.IPv6Address):
|
|
635
|
+
if addr.ipv4_mapped is not None:
|
|
636
|
+
addr = addr.ipv4_mapped
|
|
637
|
+
elif addr in NAT64:
|
|
638
|
+
addr = ipaddress.IPv4Address(int(addr) & 0xFFFFFFFF)
|
|
639
|
+
return any(addr.version == net.version and addr in net for net in BLOCKED_NETWORKS)
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def check_url(image_id: str, url: Any) -> None:
|
|
643
|
+
"""The URL rules that need no network: ``https`` (else ``api_usage_error``), then
|
|
644
|
+
``image_url_refused`` for the length, user information, a port other than 443, no
|
|
645
|
+
host, or a literal blocked address."""
|
|
646
|
+
if not isinstance(url, str) or not url.lower().startswith("https://"):
|
|
647
|
+
raise usage_error("Image %s: only https URLs are accepted." % image_id)
|
|
648
|
+
reason = None
|
|
649
|
+
parts = urlsplit(url)
|
|
650
|
+
if len(url) > MAX_URL_CHARS:
|
|
651
|
+
reason = "the URL is %d characters, over the limit of %d" % (len(url), MAX_URL_CHARS)
|
|
652
|
+
elif "@" in parts.netloc:
|
|
653
|
+
reason = "the URL carries user information"
|
|
654
|
+
else:
|
|
655
|
+
try:
|
|
656
|
+
port = parts.port
|
|
657
|
+
except ValueError:
|
|
658
|
+
port = -1
|
|
659
|
+
if port not in (None, 443):
|
|
660
|
+
reason = "the URL uses a port other than 443"
|
|
661
|
+
elif not parts.hostname:
|
|
662
|
+
reason = "the URL has no host"
|
|
663
|
+
elif blocked_address(parts.hostname):
|
|
664
|
+
reason = "the URL resolves to a private address"
|
|
665
|
+
if reason is not None:
|
|
666
|
+
message = "Image '%s': %s." % (image_id, reason)
|
|
667
|
+
raise BadRequestError(message, error_type="image_url_refused", reason=_ascii(message))
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def max_images(limits: Optional[Limits] = None) -> int:
|
|
671
|
+
"""The image limit: the held ``/v1/models`` entry's ``max_images``, else its
|
|
672
|
+
``images.max_images``, else 16."""
|
|
673
|
+
held: Limits = limits or {}
|
|
674
|
+
published = held.get("images")
|
|
675
|
+
nested: Mapping[str, Any] = published if isinstance(published, Mapping) else {}
|
|
676
|
+
return int(held.get("max_images") or nested.get("max_images") or MAX_IMAGES)
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def image_input(img: Any) -> Any:
|
|
680
|
+
"""An image as a 422 entry's ``input`` lists it: its wire form, a local image's bytes
|
|
681
|
+
left out (nothing is encoded to build an error)."""
|
|
682
|
+
if not isinstance(img, Image):
|
|
683
|
+
return dict(img) if isinstance(img, Mapping) else img
|
|
684
|
+
if img.url is not None:
|
|
685
|
+
return {"id": img.id, "url": img.url}
|
|
686
|
+
out: Dict[str, Any] = {"id": img.id}
|
|
687
|
+
if img.upload_id is not None:
|
|
688
|
+
out["upload_id"] = img.upload_id
|
|
689
|
+
if img.media_type is not None:
|
|
690
|
+
out["media_type"] = img.media_type
|
|
691
|
+
return out
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def count_entries(images: Sequence[Any], videos: Sequence[Any], limits: Optional[Limits] = None) -> List[Entry]:
|
|
695
|
+
"""More images or videos than a request takes: the API's 422 ``too_long`` on
|
|
696
|
+
``["body", "images"]`` or ``["body", "videos"]``. ``videos`` are in wire form."""
|
|
697
|
+
out: List[Entry] = []
|
|
698
|
+
for name, given, most in (
|
|
699
|
+
("images", [image_input(i) for i in images], max_images(limits)),
|
|
700
|
+
("videos", list(videos), MAX_VIDEOS),
|
|
701
|
+
):
|
|
702
|
+
if len(given) > most:
|
|
703
|
+
msg = "List should have at most %d items after validation, not %d" % (most, len(given))
|
|
704
|
+
ctx = {"field_type": "List", "max_length": most, "actual_length": len(given)}
|
|
705
|
+
out.append((_entry("too_long", ["body", name], msg, given, ctx), None))
|
|
706
|
+
return out
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def check_images(images: Sequence[Any], limits: Optional[Limits] = None, id_length: bool = True) -> None:
|
|
710
|
+
"""Raise what the API would answer for these images: their ids, URLs, the media type
|
|
711
|
+
of an upload reference, and for local bytes the format, size and pixels. Their count
|
|
712
|
+
is a 422 entry of the request (``count_entries``). ``id_length`` is the 64-character
|
|
713
|
+
id rule, for a body that carries the ids; a session frame's image is sent without its
|
|
714
|
+
id, so a frame skips it."""
|
|
715
|
+
held: Limits = limits or {}
|
|
716
|
+
biggest = held.get("max_image_bytes") or MAX_IMAGE_BYTES
|
|
717
|
+
seen: Set[str] = set()
|
|
718
|
+
for i, img in enumerate(images):
|
|
719
|
+
if isinstance(img, Image):
|
|
720
|
+
iid: Any = img.id
|
|
721
|
+
elif isinstance(img, Mapping):
|
|
722
|
+
iid = img.get("id")
|
|
723
|
+
else:
|
|
724
|
+
raise ValueError("images must be Image objects or wire-form mappings, got %r" % (img,))
|
|
725
|
+
if not isinstance(iid, str) or iid == "":
|
|
726
|
+
raise usage_error("Image %d needs an id." % i)
|
|
727
|
+
if id_length and len(iid) > MAX_IMAGE_ID_CHARS:
|
|
728
|
+
raise usage_error("Image %d: its id is longer than %d characters." % (i, MAX_IMAGE_ID_CHARS))
|
|
729
|
+
if iid in seen:
|
|
730
|
+
raise usage_error("Duplicate image id: %s" % iid)
|
|
731
|
+
seen.add(iid)
|
|
732
|
+
if isinstance(img, Image):
|
|
733
|
+
url, upload_id, media_type = img.url, img.upload_id, img.media_type
|
|
734
|
+
else:
|
|
735
|
+
url, upload_id, media_type = img.get("url"), img.get("upload_id"), img.get("media_type")
|
|
736
|
+
if url is not None:
|
|
737
|
+
check_url(iid, url)
|
|
738
|
+
continue
|
|
739
|
+
if upload_id is not None:
|
|
740
|
+
if media_type is not None and media_type not in MEDIA_TYPES:
|
|
741
|
+
raise usage_error("Image %s: media_type must be image/jpeg, image/png or image/webp." % iid)
|
|
742
|
+
continue
|
|
743
|
+
if isinstance(img, Image):
|
|
744
|
+
if img.fault is not None:
|
|
745
|
+
raise usage_error(img.fault)
|
|
746
|
+
if img.data is not None and len(img.data) > biggest:
|
|
747
|
+
raise usage_error("Image %s is larger than 10 MB." % iid)
|
|
748
|
+
continue
|
|
749
|
+
# A wire-form mapping is sent as given, after the same checks.
|
|
750
|
+
if isinstance(img.get("data"), str):
|
|
751
|
+
if media_type is None:
|
|
752
|
+
raise usage_error("Image %s: media_type must be image/jpeg, image/png or image/webp." % iid)
|
|
753
|
+
local = Image(iid, data=img["data"], media_type=media_type)
|
|
754
|
+
if local.fault is not None:
|
|
755
|
+
raise usage_error(local.fault)
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
# --------------------------------------------------------------------------- sessions, frames, batches
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def check_session_open(body: Mapping[str, Any], limits: Optional[Limits] = None) -> None:
|
|
762
|
+
"""The open request's fixed rules: window 1 to 64 and ttl_seconds 1 to 3,600 (a value
|
|
763
|
+
held from ``models()`` replaces the bound), instructions and state of the three
|
|
764
|
+
shapes, every question rule, and a model held as serving no sessions."""
|
|
765
|
+
held: Limits = limits or {}
|
|
766
|
+
sessions = held.get("sessions") if isinstance(held.get("sessions"), Mapping) else {}
|
|
767
|
+
assert isinstance(sessions, Mapping)
|
|
768
|
+
entries: List[Entry] = []
|
|
769
|
+
if body.get("instructions") is not None and not _is_sdl(body["instructions"]):
|
|
770
|
+
entries.extend(_sdl_entries(["body", "instructions"], body["instructions"]))
|
|
771
|
+
bounds = {
|
|
772
|
+
"window": sessions.get("max_window") or MAX_WINDOW,
|
|
773
|
+
"ttl_seconds": sessions.get("max_ttl_seconds") or MAX_TTL_SECONDS,
|
|
774
|
+
}
|
|
775
|
+
for name, top in bounds.items():
|
|
776
|
+
value = body.get(name)
|
|
777
|
+
if value is None:
|
|
778
|
+
continue
|
|
779
|
+
if not _is_int(value):
|
|
780
|
+
entries.append((_entry("int_type", ["body", name], "Input should be a valid integer", value), None))
|
|
781
|
+
elif value < 1:
|
|
782
|
+
entries.append(
|
|
783
|
+
(
|
|
784
|
+
_entry(
|
|
785
|
+
"greater_than_equal",
|
|
786
|
+
["body", name],
|
|
787
|
+
"Input should be greater than or equal to 1",
|
|
788
|
+
value,
|
|
789
|
+
{"ge": 1},
|
|
790
|
+
),
|
|
791
|
+
None,
|
|
792
|
+
)
|
|
793
|
+
)
|
|
794
|
+
elif value > top:
|
|
795
|
+
entries.append(
|
|
796
|
+
(
|
|
797
|
+
_entry(
|
|
798
|
+
"less_than_equal",
|
|
799
|
+
["body", name],
|
|
800
|
+
"Input should be less than or equal to %d" % top,
|
|
801
|
+
value,
|
|
802
|
+
{"le": top},
|
|
803
|
+
),
|
|
804
|
+
None,
|
|
805
|
+
)
|
|
806
|
+
)
|
|
807
|
+
check_request(body, held, entries)
|
|
808
|
+
if sessions.get("enabled") is False:
|
|
809
|
+
raise usage_error("Model %s does not serve sessions." % held.get("model"))
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def check_frame(
|
|
813
|
+
frame: Any, image: Any, questions: Optional[List[Any]], reset: Any, seq: Any, known: Optional[Sequence[Any]]
|
|
814
|
+
) -> None:
|
|
815
|
+
"""A frame's fixed rules, raised as the API's frame error would be."""
|
|
816
|
+
if seq is not None and (not _is_int(seq) or not 0 <= seq <= MAX_SEQ):
|
|
817
|
+
raise usage_error("seq: must be an integer from 0 to %d." % MAX_SEQ)
|
|
818
|
+
if frame is None and image is None:
|
|
819
|
+
raise usage_error("frame: a frame needs a frame value or an image.")
|
|
820
|
+
if frame is not None and (not _is_sdl(frame) or frame == ""):
|
|
821
|
+
raise usage_error("frame: must be a string, an object or an array, not empty.")
|
|
822
|
+
if questions is not None:
|
|
823
|
+
if not questions:
|
|
824
|
+
raise usage_error("questions: must be a non-empty list of the session's question ids.")
|
|
825
|
+
if known is not None:
|
|
826
|
+
for qid in questions:
|
|
827
|
+
if qid not in known:
|
|
828
|
+
shown = "'%s'" % qid if isinstance(qid, str) else _compact(qid)
|
|
829
|
+
raise usage_error("questions: unknown question id %s" % shown)
|
|
830
|
+
if reset is not None and not isinstance(reset, bool):
|
|
831
|
+
raise usage_error("reset: must be true or false.")
|
|
832
|
+
if frame is not None and nesting_depth(frame) + 1 > MAX_NESTING:
|
|
833
|
+
raise usage_error("The frame nests more than %d objects and arrays inside each other." % MAX_NESTING)
|
|
834
|
+
if (isinstance(image, Image) and image.url is not None) or (
|
|
835
|
+
isinstance(image, Mapping) and image.get("url") is not None
|
|
836
|
+
):
|
|
837
|
+
raise usage_error("image: a frame takes bytes or an upload, not a url.")
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def check_lines(requests: Sequence[Any], allow_empty: bool, limits: Optional[Limits] = None) -> None:
|
|
841
|
+
"""The ``custom_id`` rules of one batch call (lines numbered from 1), an empty add, and
|
|
842
|
+
the batch limits held from ``models()``."""
|
|
843
|
+
if not requests and not allow_empty:
|
|
844
|
+
raise usage_error("The call holds no requests.")
|
|
845
|
+
held: Limits = limits or {}
|
|
846
|
+
batch = held.get("batch") if isinstance(held.get("batch"), Mapping) else {}
|
|
847
|
+
assert isinstance(batch, Mapping)
|
|
848
|
+
most = batch.get("requests_per_batch")
|
|
849
|
+
if most is not None and len(requests) > most:
|
|
850
|
+
raise usage_error("The batch would hold %d requests. The limit is %d." % (len(requests), most))
|
|
851
|
+
longest = batch.get("max_custom_id_chars") or MAX_CUSTOM_ID_CHARS
|
|
852
|
+
seen: Set[str] = set()
|
|
853
|
+
for n, req in enumerate(requests, start=1):
|
|
854
|
+
if not isinstance(req, Mapping):
|
|
855
|
+
raise ValueError("batch request %d is not a mapping of custom_id, state, questions, images and model" % n)
|
|
856
|
+
if req.get("custom_id") is None:
|
|
857
|
+
raise usage_error("Line %d has no custom_id." % n)
|
|
858
|
+
cid = req["custom_id"]
|
|
859
|
+
if not isinstance(cid, str) or not 1 <= len(cid) <= longest:
|
|
860
|
+
raise usage_error("Line %d: custom_id must be a string of 1 to %d characters." % (n, longest))
|
|
861
|
+
if cid in seen:
|
|
862
|
+
shown = _compact(cid)
|
|
863
|
+
if len(shown) > 64:
|
|
864
|
+
shown = shown[:64] + "..."
|
|
865
|
+
raise usage_error("Line %d: custom_id %s is already in the batch." % (n, shown))
|
|
866
|
+
seen.add(cid)
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def _string_entries(loc: List[Any], value: Any, least: int, most: int, pattern: Optional[str] = None) -> List[Entry]:
|
|
870
|
+
"""A string field's 422 entry, as the API answers it: missing, not a string, too
|
|
871
|
+
short, too long, or not matching ``pattern``."""
|
|
872
|
+
if value is None:
|
|
873
|
+
return [(_entry("missing", loc, "Field required", None), None)] if least else []
|
|
874
|
+
if not isinstance(value, str):
|
|
875
|
+
return [(_entry("string_type", loc, "Input should be a valid string", value), None)]
|
|
876
|
+
if len(value) < least:
|
|
877
|
+
msg = "String should have at least %d character%s" % (least, "" if least == 1 else "s")
|
|
878
|
+
return [(_entry("string_too_short", loc, msg, value, {"min_length": least}), None)]
|
|
879
|
+
if len(value) > most:
|
|
880
|
+
msg = "String should have at most %d characters" % most
|
|
881
|
+
return [(_entry("string_too_long", loc, msg, value, {"max_length": most}), None)]
|
|
882
|
+
if pattern is not None and not re.fullmatch(pattern, value):
|
|
883
|
+
msg = "String should match pattern '%s'" % pattern
|
|
884
|
+
return [(_entry("string_pattern_mismatch", loc, msg, value, {"pattern": pattern}), None)]
|
|
885
|
+
return []
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
def check_collection(kind: Any, name: Any) -> None:
|
|
889
|
+
"""A new collection's type and name, as the API checks them."""
|
|
890
|
+
entries: List[Entry] = []
|
|
891
|
+
if kind not in COLLECTION_TYPES:
|
|
892
|
+
entries.append(
|
|
893
|
+
(_entry("literal_error", ["body", "type"], "Input should be 'face'", kind, {"expected": "'face'"}), None)
|
|
894
|
+
)
|
|
895
|
+
entries.extend(_string_entries(["body", "name"], name, 1, COLLECTION_NAME_MAX))
|
|
896
|
+
if entries:
|
|
897
|
+
_raise_422(entries)
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
def check_item(image: Any, reference: Any, label: Any) -> None:
|
|
901
|
+
"""An enrollment's fields, as the API checks them. An empty reference fails the
|
|
902
|
+
pattern, as the API answers it; of the picture only a URL's rules are checked here."""
|
|
903
|
+
entries: List[Entry] = []
|
|
904
|
+
if image is None:
|
|
905
|
+
entries.append((_entry("missing", ["body", "image"], "Field required", {"reference": reference}), None))
|
|
906
|
+
if reference is None:
|
|
907
|
+
entries.append((_entry("missing", ["body", "reference"], "Field required", None), None))
|
|
908
|
+
else:
|
|
909
|
+
entries.extend(_string_entries(["body", "reference"], reference, 0, ITEM_REFERENCE_MAX, ITEM_REFERENCE_PATTERN))
|
|
910
|
+
if label is not None:
|
|
911
|
+
entries.extend(_string_entries(["body", "label"], label, 0, ITEM_LABEL_MAX))
|
|
912
|
+
if entries:
|
|
913
|
+
_raise_422(entries)
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
def check_enroll_image(image: Any) -> None:
|
|
917
|
+
"""An enrollment's picture: only a URL's rules are a decision's (``image_url_refused``,
|
|
918
|
+
or ``api_usage_error`` for a URL that is not https). Every other fault of the picture
|
|
919
|
+
is the server's 422 ``image_unreadable``, which no client can foresee."""
|
|
920
|
+
if isinstance(image, Image):
|
|
921
|
+
if image.url is not None:
|
|
922
|
+
check_url(image.id, image.url)
|
|
923
|
+
elif isinstance(image, Mapping):
|
|
924
|
+
if image.get("url") is not None:
|
|
925
|
+
check_url(str(image.get("id")), image.get("url"))
|
|
926
|
+
else:
|
|
927
|
+
raise ValueError("image must be an Image or a wire-form mapping, got %r" % (image,))
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
def check_list_query(offset: Any, limit: Any) -> None:
|
|
931
|
+
if offset is not None and (not _is_int(offset) or offset < 0):
|
|
932
|
+
raise usage_error("Query parameter offset must be an integer of 0 or more, got %s." % (offset,))
|
|
933
|
+
if limit is not None and (not _is_int(limit) or not 1 <= limit <= LIST_LIMIT_MAX):
|
|
934
|
+
raise usage_error("Query parameter limit must be an integer from 1 to %d, got %s." % (LIST_LIMIT_MAX, limit))
|