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.
- adaptive_card_tools/__init__.py +10 -0
- adaptive_card_tools/builder.py +1891 -0
- adaptive_card_tools/catalog.py +488 -0
- adaptive_card_tools/examples.py +169 -0
- adaptive_card_tools/normalize.py +581 -0
- adaptive_card_tools/py.typed +0 -0
- adaptive_card_tools/theme.py +210 -0
- adaptive_card_tools/tool.py +142 -0
- agentic_genui_tools-0.2.0.dist-info/METADATA +284 -0
- agentic_genui_tools-0.2.0.dist-info/RECORD +13 -0
- agentic_genui_tools-0.2.0.dist-info/WHEEL +5 -0
- agentic_genui_tools-0.2.0.dist-info/licenses/LICENSE +21 -0
- agentic_genui_tools-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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
|