Agentic-GenUI-Tools 0.2.0__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,581 @@
1
+ """Lenient parsing of requests and values.
2
+
3
+ Policy: repair the *form* of what the model sent, never its *facts*. Text is
4
+ never rewritten, numbers are parsed but never estimated, and anything that
5
+ cannot be interpreted safely is dropped with a warning instead of failing the
6
+ whole card. Hard errors are reserved for unreadable requests and safety limits.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import datetime as dt
11
+ import json
12
+ import math
13
+ import re
14
+ import unicodedata
15
+ from dataclasses import dataclass, field
16
+ from decimal import Decimal
17
+ from fractions import Fraction
18
+ from urllib.parse import quote, urlsplit
19
+
20
+
21
+ class CardError(ValueError):
22
+ """Raised only when nothing usable can be built or a safety limit is hit."""
23
+
24
+ def __init__(self, path: str, message: str, code: str = "invalid_request", **details):
25
+ super().__init__(message)
26
+ self.detail = {"path": path, "code": code, "message": message, **details}
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class BuilderConfig:
31
+ """Developer settings; never selectable by the model.
32
+
33
+ profile: "portable" emits core Adaptive Cards 1.5 only. "microsoft" emits
34
+ native Microsoft extensions (charts, Badge, Rating, ProgressBar...) with
35
+ core fallbacks. native_types optionally restricts which extensions a
36
+ known renderer supports.
37
+ day_first: how to read ambiguous dates such as 03/04/2026.
38
+ repair_typos: allow edit-distance matching of type and field names.
39
+ Widths are estimates relative to available_width_px, not measurements.
40
+ """
41
+ profile: str = "portable"
42
+ native_types: tuple[str, ...] | None = None
43
+ available_width_px: int = 1200
44
+ day_first: bool = False
45
+ repair_typos: bool = True
46
+ max_elements: int = 150
47
+ max_items: int = 500
48
+ max_depth: int = 12
49
+ max_nodes: int = 20000
50
+ max_payload_bytes: int = 512000
51
+ max_output_bytes: int = 2000000
52
+
53
+ def __post_init__(self):
54
+ if self.profile not in {"portable", "microsoft"}:
55
+ raise ValueError("profile must be 'portable' or 'microsoft'")
56
+ for name in ("available_width_px", "max_elements", "max_items", "max_depth",
57
+ "max_nodes", "max_payload_bytes", "max_output_bytes"):
58
+ value = getattr(self, name)
59
+ if type(value) is not int or value < 1:
60
+ raise ValueError(f"{name} must be a positive integer")
61
+ for name in ("repair_typos", "day_first"):
62
+ if type(getattr(self, name)) is not bool:
63
+ raise ValueError(f"{name} must be boolean")
64
+ if self.native_types is not None:
65
+ allowed = {"Input.Rating", "Rating", "Badge", "ProgressBar", "ProgressRing",
66
+ "Chart.Pie", "Chart.Donut", "Chart.VerticalBar", "Chart.HorizontalBar",
67
+ "Chart.VerticalBar.Grouped", "Chart.HorizontalBar.Stacked", "Chart.Line", "Chart.Gauge"}
68
+ if not isinstance(self.native_types, tuple) or any(t not in allowed for t in self.native_types):
69
+ raise ValueError("native_types must be a tuple of supported native extension type names")
70
+
71
+
72
+ @dataclass
73
+ class Context:
74
+ config: BuilderConfig
75
+ normalizations: list[dict] = field(default_factory=list)
76
+ warnings: list[dict] = field(default_factory=list)
77
+
78
+ def fixed(self, path: str, kind: str, **info):
79
+ """A repair that kept all content (alias, format conversion, ignored styling)."""
80
+ entry = {"path": path, "kind": kind, **info}
81
+ if entry not in self.normalizations:
82
+ self.normalizations.append(entry)
83
+
84
+ def warn(self, path: str, code: str, message: str):
85
+ """Something was dropped or reinterpreted; the developer or model may care."""
86
+ entry = {"path": path, "code": code, "message": message}
87
+ if entry not in self.warnings:
88
+ self.warnings.append(entry)
89
+
90
+
91
+ # ---------------------------------------------------------------- names
92
+
93
+ def token(value) -> str:
94
+ """Case-, separator- and Unicode-insensitive form of a name."""
95
+ return re.sub(r"[\W_]+", "", unicodedata.normalize("NFKC", str(value)).casefold())
96
+
97
+
98
+ def edit_distance(a: str, b: str, limit: int = 2) -> int:
99
+ """Optimal string alignment distance, capped at limit + 1."""
100
+ if abs(len(a) - len(b)) > limit:
101
+ return limit + 1
102
+ prev2, prev = None, list(range(len(b) + 1))
103
+ for i, ca in enumerate(a, 1):
104
+ cur = [i] + [0] * len(b)
105
+ for j, cb in enumerate(b, 1):
106
+ cur[j] = min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (ca != cb))
107
+ if prev2 is not None and i > 1 and j > 1 and ca == b[j - 2] and a[i - 2] == cb:
108
+ cur[j] = min(cur[j], prev2[j - 2] + 1)
109
+ if min(cur) > limit:
110
+ return limit + 1
111
+ prev2, prev = prev, cur
112
+ return prev[-1]
113
+
114
+
115
+ def closest(name: str, candidates, ctx: Context) -> str | None:
116
+ """Unique nearest candidate token for a misspelled name, or None."""
117
+ if not ctx.config.repair_typos or len(name) < 4:
118
+ return None
119
+ limit = 1 if len(name) < 8 else 2
120
+ scored = {}
121
+ for candidate in candidates:
122
+ if len(candidate) >= 4:
123
+ d = edit_distance(name, candidate, limit)
124
+ if d <= limit:
125
+ scored.setdefault(d, set()).add(candidate)
126
+ if not scored:
127
+ return None
128
+ best = scored[min(scored)]
129
+ return next(iter(best)) if len(best) == 1 else None
130
+
131
+
132
+ def slug(value, fallback: str = "field") -> str:
133
+ """Lower snake-case machine name derived from human text."""
134
+ text = unicodedata.normalize("NFKD", str(value)).encode("ascii", "ignore").decode()
135
+ result = re.sub(r"[^a-z0-9]+", "_", text.casefold()).strip("_")[:64] or fallback
136
+ return fallback + "_" + result if result[0].isdigit() else result
137
+
138
+
139
+ def is_slug_like(value) -> bool:
140
+ return isinstance(value, str) and bool(re.fullmatch(r"[A-Za-z_][A-Za-z0-9_.\-]{0,127}", value.strip()))
141
+
142
+
143
+ def as_id(value) -> str | None:
144
+ """Sanitize a caller-supplied input ID, keeping its case and meaning."""
145
+ if isinstance(value, bool) or not isinstance(value, (str, int)):
146
+ return None
147
+ text = re.sub(r"[^A-Za-z0-9_.\-]+", "_", str(value).strip()).strip("_")[:128]
148
+ if not text:
149
+ return None
150
+ return "field_" + text if not re.match(r"[A-Za-z_]", text) else text
151
+
152
+
153
+ # ---------------------------------------------------------------- text
154
+
155
+ def format_number(value) -> str:
156
+ if isinstance(value, float) and value.is_integer() and abs(value) < 1e15:
157
+ return str(int(value))
158
+ return repr(value) if isinstance(value, float) else str(value)
159
+
160
+
161
+ def display_text(value) -> str:
162
+ """Human-facing text for any JSON value."""
163
+ if value is None:
164
+ return ""
165
+ if isinstance(value, bool):
166
+ return "Yes" if value else "No"
167
+ if isinstance(value, (int, float)):
168
+ return format_number(value)
169
+ if isinstance(value, str):
170
+ return value
171
+ if isinstance(value, list):
172
+ return ", ".join(t for t in (display_text(v) for v in value) if t)
173
+ if isinstance(value, dict):
174
+ # A {text|label|title|value} wrapper is usually just a value.
175
+ for key in ("text", "label", "title", "value", "name", "display"):
176
+ if key in value and len(value) <= 2:
177
+ return display_text(value[key])
178
+ return "; ".join(f"{k}: {display_text(v)}" for k, v in value.items())
179
+ return str(value)
180
+
181
+
182
+ def submit_text(value) -> str:
183
+ """Machine-facing string for submitted values."""
184
+ if isinstance(value, bool):
185
+ return "true" if value else "false"
186
+ if isinstance(value, (dict, list)):
187
+ return json.dumps(value, ensure_ascii=False, sort_keys=True)
188
+ return display_text(value)
189
+
190
+
191
+ def clean_text(value) -> str:
192
+ """display_text, treating whitespace-only text as empty."""
193
+ text = display_text(value)
194
+ return text if text.strip() else ""
195
+
196
+
197
+ # ---------------------------------------------------------------- booleans
198
+
199
+ TRUE_WORDS = {"true", "yes", "y", "1", "on", "checked", "selected", "required", "mandatory",
200
+ "enabled", "enable", "t", "x", "ok", "multiple", "multi", "many", "always"}
201
+ FALSE_WORDS = {"false", "no", "n", "0", "off", "unchecked", "optional", "disabled", "disable",
202
+ "f", "none", "null", "", "single", "one", "never", "notrequired"}
203
+
204
+
205
+ def as_bool(value, default: bool | None = None) -> bool | None:
206
+ if isinstance(value, bool):
207
+ return value
208
+ if isinstance(value, (int, float)) and math.isfinite(value):
209
+ return value != 0
210
+ if isinstance(value, str):
211
+ word = token(value) if value.strip() not in {"✓", "✔"} else "true"
212
+ if word in TRUE_WORDS:
213
+ return True
214
+ if word in FALSE_WORDS:
215
+ return False
216
+ return default
217
+
218
+
219
+ # ---------------------------------------------------------------- numbers
220
+
221
+ _CURRENCY = "$€£¥₹₩₽₺₫₪฿¢"
222
+ _CODES = r"(?:USD|EUR|GBP|CAD|AUD|NZD|JPY|CNY|INR|CHF|SEK|NOK|DKK|MXN|BRL|ZAR|SGD|HKD|US\$|C\$|A\$)"
223
+ _SCALE = {"k": 1e3, "m": 1e6, "mm": 1e6, "mn": 1e6, "b": 1e9, "bn": 1e9}
224
+ _ONE_NUMBER = re.compile(r"[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?")
225
+
226
+
227
+ @dataclass(frozen=True)
228
+ class Parsed:
229
+ value: float | int
230
+ percent: bool = False
231
+ denominator: float | int | None = None
232
+
233
+
234
+ def _number_core(text: str):
235
+ if re.fullmatch(r"\d{1,3}(,\d{3})+(\.\d+)?", text):
236
+ text = text.replace(",", "")
237
+ elif re.fullmatch(r"\d{1,3}(\.\d{3})+(,\d+)?", text):
238
+ text = text.replace(".", "").replace(",", ".")
239
+ elif re.fullmatch(r"\d{1,3}([ '’]\d{3})+(\.\d+)?", text):
240
+ text = re.sub(r"[ '’]", "", text)
241
+ elif re.fullmatch(r"\d+,\d+", text):
242
+ text = text.replace(",", ".")
243
+ if not re.fullmatch(r"(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?", text) or len(text) > 64:
244
+ return None
245
+ return int(text) if re.fullmatch(r"\d+", text) else float(text)
246
+
247
+
248
+ def parse_number(value) -> Parsed | None:
249
+ """Read numbers like 12, "1,200", "$1.2M", "75%", "(40)", "4.5/5", "12 GB"."""
250
+ if isinstance(value, bool) or value is None:
251
+ return None
252
+ if isinstance(value, (int, float)):
253
+ return Parsed(value) if math.isfinite(value) and abs(value) <= 9007199254740991 else None
254
+ if isinstance(value, dict):
255
+ for key in ("value", "amount", "y", "count", "total"):
256
+ if key in value:
257
+ return parse_number(value[key])
258
+ return None
259
+ if not isinstance(value, str):
260
+ return None
261
+ text = unicodedata.normalize("NFKC", value).strip()
262
+ text = re.sub(r"[−‒–—]", "-", text).replace(" ", " ").replace(" ", " ")
263
+ fraction = re.fullmatch(r"(.+?)\s*(?:/|out of|of)\s*(.+)", text, re.I)
264
+ if fraction and not re.search(r"\d+[/-]\d+[/-]\d+", text):
265
+ top, bottom = parse_number(fraction.group(1)), parse_number(fraction.group(2))
266
+ if top and bottom and bottom.value > 0 and not top.percent:
267
+ return Parsed(top.value, denominator=bottom.value)
268
+ negative = False
269
+ if re.fullmatch(r"\(.*\)", text):
270
+ negative, text = True, text[1:-1].strip()
271
+ text = re.sub(rf"^{_CODES}\s*|\s*{_CODES}$", "", text)
272
+ sign = ""
273
+ if text and text[0] in "+-":
274
+ sign, text = text[0], text[1:].strip()
275
+ text = text.strip(_CURRENCY + " ~≈")
276
+ if text and text[0] in "+-" and not sign:
277
+ sign, text = text[0], text[1:].strip()
278
+ if not text:
279
+ return None
280
+ percent = text.endswith("%")
281
+ text = text.rstrip("%").strip()
282
+ scale = 1
283
+ suffix = re.fullmatch(r"(.*?\d)\s*(k|m|mm|mn|b|bn)", text, re.I)
284
+ if suffix:
285
+ text, scale = suffix.group(1), _SCALE[suffix.group(2).lower()]
286
+ number = _number_core(text)
287
+ if number is None:
288
+ # "12 GB", "approx. 40 hours": exactly one number surrounded by words.
289
+ matches = _ONE_NUMBER.findall(text)
290
+ leftover = _ONE_NUMBER.sub("", text)
291
+ if len(matches) != 1 or re.search(r"\d", leftover) or not re.fullmatch(r"[^\W\d]*[\s\w.\-/%]*", leftover):
292
+ return None
293
+ number = _number_core(matches[0].lstrip("+"))
294
+ if number is None:
295
+ return None
296
+ if scale != 1:
297
+ number = number * scale
298
+ number = int(number) if float(number).is_integer() else number
299
+ if sign == "-" or negative:
300
+ number = -number
301
+ if not math.isfinite(number) or abs(number) > 9007199254740991:
302
+ return None
303
+ return Parsed(number, percent=percent)
304
+
305
+
306
+ def as_number(value):
307
+ parsed = parse_number(value)
308
+ return None if parsed is None else parsed.value
309
+
310
+
311
+ # ---------------------------------------------------------------- dates and times
312
+
313
+ MONTHS = {m: i for i, names in enumerate([("jan", "january"), ("feb", "february"), ("mar", "march"),
314
+ ("apr", "april"), ("may",), ("jun", "june"), ("jul", "july"), ("aug", "august"),
315
+ ("sep", "sept", "september"), ("oct", "october"), ("nov", "november"), ("dec", "december")], 1)
316
+ for m in names}
317
+
318
+
319
+ def _valid_date(y: int, m: int, d: int) -> str | None:
320
+ try:
321
+ return dt.date(y, m, d).isoformat()
322
+ except ValueError:
323
+ return None
324
+
325
+
326
+ def as_date(value, day_first: bool = False) -> tuple[str | None, bool]:
327
+ """Return (YYYY-MM-DD or None, ambiguous). Never invents a missing year."""
328
+ if isinstance(value, (dt.date, dt.datetime)):
329
+ return value.isoformat()[:10], False
330
+ if not isinstance(value, str):
331
+ return None, False
332
+ text = value.strip()
333
+ m = re.match(r"(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})(?:$|[T\s])", text)
334
+ if m:
335
+ return _valid_date(*map(int, m.groups())), False
336
+ m = re.fullmatch(r"(\d{1,2})[-/.](\d{1,2})[-/.](\d{2}|\d{4})", text)
337
+ if m:
338
+ a, b, y = map(int, m.groups())
339
+ if len(m.group(3)) == 2:
340
+ y += 2000 if y < 70 else 1900
341
+ if a > 12 or (day_first and b <= 12):
342
+ return _valid_date(y, b, a), a <= 12 and b <= 12 and a != b
343
+ return _valid_date(y, a, b), a <= 12 and b <= 12 and a != b
344
+ words = re.findall(r"[A-Za-z]+|\d+", text)
345
+ month = [MONTHS[w.lower()] for w in words if w.lower() in MONTHS]
346
+ numbers = [w for w in words if w.isdigit()]
347
+ years = [int(n) for n in numbers if len(n) == 4]
348
+ days = [int(n) for n in numbers if len(n) <= 2]
349
+ if len(month) == 1 and len(years) == 1 and len(days) == 1:
350
+ return _valid_date(years[0], month[0], days[0]), False
351
+ return None, False
352
+
353
+
354
+ def as_time(value) -> str | None:
355
+ """Return HH:MM for inputs like 9:30, 9am, 9:30 PM, 21h30, 14:30:00, noon."""
356
+ if isinstance(value, (dt.time, dt.datetime)):
357
+ return value.strftime("%H:%M")
358
+ if not isinstance(value, str):
359
+ return None
360
+ text = value.strip().lower().replace(".", "")
361
+ if text in {"noon", "midday"}:
362
+ return "12:00"
363
+ if text == "midnight":
364
+ return "00:00"
365
+ iso = re.search(r"\d{4}-\d{2}-\d{2}[t\s](\d{2}:\d{2})", text)
366
+ if iso:
367
+ text = iso.group(1)
368
+ m = re.fullmatch(r"(\d{1,2})(?:\s*[:h]?\s*(\d{2}))?(?::\d{2}(?:\d+)?)?\s*(am|pm|a|p)?", text)
369
+ if not m:
370
+ return None
371
+ hour, minute, half = int(m.group(1)), int(m.group(2) or 0), m.group(3)
372
+ if half:
373
+ if not 1 <= hour <= 12:
374
+ return None
375
+ hour = hour % 12 + (12 if half.startswith("p") else 0)
376
+ if hour > 23 or minute > 59:
377
+ return None
378
+ return f"{hour:02d}:{minute:02d}"
379
+
380
+
381
+ # ---------------------------------------------------------------- URLs
382
+
383
+ _UNSAFE_SCHEMES = {"javascript", "data", "file", "vbscript", "about", "blob", "ftp", "chrome", "ms-settings"}
384
+
385
+
386
+ def as_url(value) -> str | None:
387
+ """Absolute http(s) URL or None. Adds https:// to bare domains; never fetches."""
388
+ if isinstance(value, dict):
389
+ value = next((value[k] for k in ("url", "href", "src", "link") if k in value), None)
390
+ if not isinstance(value, str):
391
+ return None
392
+ text = value.strip().strip("<>\"'")
393
+ markdown = re.fullmatch(r"\[[^\]]*\]\((\S+?)\)", text)
394
+ if markdown:
395
+ text = markdown.group(1)
396
+ if text.startswith("//"):
397
+ text = "https:" + text
398
+ scheme = text.split(":", 1)[0].lower() if ":" in text else ""
399
+ if scheme in _UNSAFE_SCHEMES:
400
+ return None
401
+ if not re.match(r"https?://", text, re.I):
402
+ if re.match(r"(www\.)?[a-z0-9-]+(\.[a-z0-9-]+)*\.[a-z]{2,}(:\d+)?([/?#]|$)", text, re.I):
403
+ text = "https://" + text
404
+ else:
405
+ return None
406
+ if "\\" in text or re.search(r"[\x00-\x1f\x7f]", text):
407
+ return None
408
+ text = quote(text, safe=":/?#[]@!$&'()*+,;=%~-._")
409
+ try:
410
+ parts = urlsplit(text)
411
+ ok = parts.hostname and parts.username is None and parts.password is None and parts.port != 0
412
+ except ValueError:
413
+ ok = False
414
+ return text if ok else None
415
+
416
+
417
+ # ---------------------------------------------------------------- request loading
418
+
419
+ WRAPPER_KEYS = {"request", "card", "adaptivecard", "spec", "payload", "input", "args", "arguments",
420
+ "params", "parameters", "json", "requestjson", "cardjson", "content", "data", "ui"}
421
+
422
+
423
+ def _repair_json(text: str) -> str:
424
+ """String-aware cleanup: comments, trailing commas, Python literals, single quotes."""
425
+ out, i, n = [], 0, len(text)
426
+ while i < n:
427
+ c = text[i]
428
+ if c in "\"'":
429
+ quote_char, j, chunk = c, i + 1, []
430
+ while j < n and text[j] != quote_char:
431
+ if text[j] == "\\" and j + 1 < n:
432
+ chunk.append(text[j:j + 2])
433
+ j += 2
434
+ continue
435
+ chunk.append('\\"' if text[j] == '"' else text[j])
436
+ j += 1
437
+ body = "".join(chunk)
438
+ if quote_char == "'":
439
+ body = body.replace("\\'", "'")
440
+ out.append('"' + body + '"')
441
+ i = j + 1
442
+ elif text.startswith("//", i):
443
+ i = text.find("\n", i) if "\n" in text[i:] else n
444
+ elif text.startswith("/*", i):
445
+ end = text.find("*/", i + 2)
446
+ i = n if end < 0 else end + 2
447
+ elif c == ",":
448
+ j = i + 1
449
+ while j < n and text[j] in " \t\r\n":
450
+ j += 1
451
+ if j < n and text[j] in "}]":
452
+ i += 1
453
+ else:
454
+ out.append(c)
455
+ i += 1
456
+ elif c.isalpha():
457
+ m = re.match(r"[A-Za-z_]+", text[i:])
458
+ word = m.group(0)
459
+ out.append({"True": "true", "False": "false", "None": "null", "undefined": "null"}.get(word, word))
460
+ i += len(word)
461
+ else:
462
+ out.append(c)
463
+ i += 1
464
+ repaired = "".join(out)
465
+ # Close brackets left open by a truncated call (never an unterminated string).
466
+ stack, in_string, escape = [], False, False
467
+ for ch in repaired:
468
+ if in_string:
469
+ escape = not escape and ch == "\\"
470
+ if ch == '"' and not escape:
471
+ in_string = False
472
+ if ch != "\\":
473
+ escape = False
474
+ elif ch == '"':
475
+ in_string = True
476
+ elif ch in "[{":
477
+ stack.append("]" if ch == "[" else "}")
478
+ elif ch in "]}" and stack:
479
+ stack.pop()
480
+ if stack and not in_string:
481
+ repaired = repaired.rstrip().rstrip(",") + "".join(reversed(stack))
482
+ return repaired
483
+
484
+
485
+ def parse_json_text(text: str, ctx: Context, path: str = "$"):
486
+ text = text.strip().lstrip("")
487
+ fenced = re.search(r"```(?:json|javascript|js)?\s*\n?([\s\S]*?)```", text, re.I)
488
+ if fenced:
489
+ text = fenced.group(1).strip()
490
+ ctx.fixed(path, "removed_code_fence")
491
+ if text[:1] not in "[{":
492
+ start = min([i for i in (text.find("{"), text.find("[")) if i >= 0], default=-1)
493
+ if start < 0:
494
+ raise CardError(path, "The request is not JSON. Send an object like "
495
+ '{"title": "...", "elements": [{"type": "text", "text": "..."}]}.', "invalid_json")
496
+ text = text[start:]
497
+ ctx.fixed(path, "extracted_json_from_text")
498
+
499
+ def pairs_hook(items):
500
+ result = {}
501
+ for k, v in items:
502
+ if k in result:
503
+ ctx.fixed(path, "duplicate_key_last_wins", key=k)
504
+ result[k] = v
505
+ return result
506
+
507
+ def constant(_):
508
+ ctx.warn(path, "non_finite_number", "NaN/Infinity replaced by null.")
509
+ return None
510
+
511
+ decoder = json.JSONDecoder(object_pairs_hook=pairs_hook, parse_constant=constant)
512
+ for repaired in (False, True):
513
+ candidate = _repair_json(text) if repaired else text
514
+ try:
515
+ value, end = decoder.raw_decode(candidate)
516
+ except json.JSONDecodeError as exc:
517
+ if repaired:
518
+ raise CardError(path, f"Invalid JSON near line {exc.lineno}, column {exc.colno}: {exc.msg}. "
519
+ "Send a JSON object with double-quoted keys.", "invalid_json") from None
520
+ continue
521
+ except (RecursionError, ValueError):
522
+ raise CardError(path, "JSON exceeds nesting or numeric limits.", "limit_exceeded") from None
523
+ if repaired:
524
+ ctx.fixed(path, "repaired_json_syntax")
525
+ if candidate[end:].strip():
526
+ ctx.fixed(path, "ignored_text_after_json")
527
+ return value
528
+ raise AssertionError("unreachable")
529
+
530
+
531
+ def _json_compatible(value, ctx: Context, depth: int, seen: set, counter: list):
532
+ counter[0] += 1
533
+ if counter[0] > ctx.config.max_nodes or depth > ctx.config.max_depth + 10:
534
+ raise CardError("$", "Request exceeds node or nesting limits.", "limit_exceeded")
535
+ if isinstance(value, (dict, list, tuple, set, frozenset)):
536
+ if id(value) in seen:
537
+ raise CardError("$", "Circular data is not supported.", "invalid_shape")
538
+ seen = seen | {id(value)}
539
+ if len(value) > ctx.config.max_nodes:
540
+ raise CardError("$", "Request container is too large.", "limit_exceeded")
541
+ if isinstance(value, dict):
542
+ return {str(k): _json_compatible(v, ctx, depth + 1, seen, counter) for k, v in value.items()}
543
+ items = sorted(value, key=str) if isinstance(value, (set, frozenset)) else value
544
+ return [_json_compatible(v, ctx, depth + 1, seen, counter) for v in items]
545
+ if value is None or isinstance(value, (bool, int, str)):
546
+ if isinstance(value, str):
547
+ try:
548
+ value.encode("utf-8")
549
+ except UnicodeError:
550
+ value = value.encode("utf-8", "replace").decode("utf-8")
551
+ ctx.warn("$", "invalid_unicode", "Invalid Unicode characters were replaced.")
552
+ return value
553
+ if isinstance(value, float):
554
+ if math.isfinite(value):
555
+ return value
556
+ ctx.warn("$", "non_finite_number", "NaN/Infinity replaced by null.")
557
+ return None
558
+ if isinstance(value, (Decimal, Fraction)):
559
+ return float(value)
560
+ if isinstance(value, (dt.date, dt.datetime, dt.time)):
561
+ return value.isoformat()
562
+ raise CardError("$", f"Unsupported Python value of type {type(value).__name__}; use JSON data.", "invalid_shape")
563
+
564
+
565
+ def load_request(value, ctx: Context):
566
+ """Accept dicts, lists, JSON strings (fenced, wrapped in prose, lightly broken)."""
567
+ for _ in range(3):
568
+ if not isinstance(value, str):
569
+ break
570
+ if len(value.encode("utf-8", "replace")) > ctx.config.max_payload_bytes:
571
+ raise CardError("$", "Request exceeds configured byte limit.", "limit_exceeded")
572
+ value = parse_json_text(value, ctx)
573
+ value = _json_compatible(value, ctx, 0, set(), [0])
574
+ encoded = json.dumps(value, ensure_ascii=False)
575
+ if len(encoded.encode("utf-8")) > ctx.config.max_payload_bytes:
576
+ raise CardError("$", "Request exceeds configured byte limit.", "limit_exceeded")
577
+ if not isinstance(value, (dict, list)):
578
+ if isinstance(value, str) and value.strip():
579
+ return {"elements": [value]}
580
+ raise CardError("$", 'Expected an object like {"title": "...", "elements": [...]}.', "invalid_shape")
581
+ return value
File without changes