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,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))