flattype 1.11.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.
flattype/_path.py ADDED
@@ -0,0 +1,298 @@
1
+ """Paths (``user.address.zip``, ``rows[2]{id,name}``) and building the decoded value from them."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from typing import Any, Callable, NoReturn, Union
8
+
9
+ from ._syntax import FlatDecodeError, format_key, format_number, is_key_char, scan_string
10
+
11
+ #: A key (str) or an array index (int; a float only for indexes too large to be valid).
12
+ Segment = Union[str, int, float]
13
+ #: ("value",) | ("inline", delimiter) | ("table", count, delimiter, fields)
14
+ Terminal = tuple[Any, ...]
15
+
16
+ VALUE: Terminal = ("value",)
17
+ _DIGITS = re.compile(r"[0-9]+")
18
+
19
+ #: The most nulls lenient decoding inserts for a skipped index range.
20
+ MAX_GAP = 1000
21
+
22
+
23
+ def _at(s: str, i: int) -> str:
24
+ return s[i] if i < len(s) else ""
25
+
26
+
27
+ def _number(digits: str) -> int | float:
28
+ # Like JavaScript's Number(digits): exact up to 15 digits, a float (maybe inf) beyond.
29
+ return int(digits) if len(digits) <= 15 else float(digits)
30
+
31
+
32
+ def show_number(n: int | float) -> str:
33
+ return str(n) if isinstance(n, int) else format_number(n)
34
+
35
+
36
+ def parse_path(s: str, pos: int, line: int) -> tuple[list[Segment], Terminal, int]:
37
+ """Reads a path starting at ``pos``. Stops at the first character that can't continue a path (normally ``:``)."""
38
+ segments: list[Segment] = []
39
+
40
+ def fail(msg: str, at: int) -> NoReturn:
41
+ raise FlatDecodeError(msg, line, at + 1)
42
+
43
+ need_key = False
44
+ while True:
45
+ c = _at(s, pos)
46
+ if c == '"' or (c != "" and is_key_char(c)):
47
+ if segments and not need_key:
48
+ fail("Expected '.' or ':' before key", pos)
49
+ pos = _read_key(s, pos, segments, fail)
50
+ need_key = False
51
+ if _at(s, pos) == ".":
52
+ pos += 1
53
+ need_key = True
54
+ continue
55
+ if need_key:
56
+ fail("Expected a key after '.'", pos)
57
+ if c != "[":
58
+ break
59
+ close = s.find("]", pos)
60
+ if close < 0:
61
+ fail("Unclosed '['", pos)
62
+ inner = s[pos + 1 : close]
63
+ if inner in ("", "|", "\t"):
64
+ return segments, ("inline", inner or ","), close + 1
65
+ digits, mark = inner, ""
66
+ if inner[-1] in "|\t":
67
+ digits, mark = inner[:-1], inner[-1]
68
+ if not _DIGITS.fullmatch(digits):
69
+ fail(f"Invalid index '[{inner}]'", pos)
70
+ n = _number(digits)
71
+ pos = close + 1
72
+ if _at(s, pos) == "{":
73
+ delimiter = mark or ","
74
+ fields, end = _read_fields(s, pos + 1, delimiter, fail)
75
+ return segments, ("table", n, delimiter, fields), end
76
+ if mark != "":
77
+ fail("A delimiter mark is only allowed on inline arrays and tables", pos - 1)
78
+ segments.append(n)
79
+ if _at(s, pos) == ".":
80
+ pos += 1
81
+ need_key = True
82
+ return segments, VALUE, pos
83
+
84
+
85
+ def _read_key(s: str, pos: int, out: list[Segment], fail: Callable[[str, int], NoReturn]) -> int:
86
+ if s[pos] == '"':
87
+ close = scan_string(s, pos)
88
+ if close < 0:
89
+ fail("Unterminated quoted key", pos)
90
+ try:
91
+ out.append(json.loads(s[pos : close + 1]))
92
+ except ValueError:
93
+ fail("Invalid quoted key", pos)
94
+ return close + 1
95
+ end = pos
96
+ n = len(s)
97
+ while end < n and is_key_char(s[end]):
98
+ end += 1
99
+ out.append(s[pos:end])
100
+ return end
101
+
102
+
103
+ def _read_fields(s: str, pos: int, delimiter: str, fail: Callable[[str, int], NoReturn]) -> tuple[list[list[Segment]], int]:
104
+ """Reads ``id,user.name}`` (after the ``{``) into one key path per field."""
105
+ fields: list[list[Segment]] = []
106
+ while True:
107
+ while _at(s, pos) == " ":
108
+ pos += 1
109
+ field: list[Segment] = []
110
+ while True:
111
+ c = _at(s, pos)
112
+ if c != '"' and not (c != "" and is_key_char(c)):
113
+ fail("Expected a field name", pos)
114
+ pos = _read_key(s, pos, field, fail)
115
+ if _at(s, pos) != ".":
116
+ break
117
+ pos += 1
118
+ fields.append(field)
119
+ while _at(s, pos) == " ":
120
+ pos += 1
121
+ c = _at(s, pos)
122
+ if c == "}":
123
+ return fields, pos + 1
124
+ if c != delimiter:
125
+ fail(f"Expected '{chr(92) + 't' if delimiter == chr(9) else delimiter}' or '}}' in table header", pos)
126
+ pos += 1
127
+
128
+
129
+ class Ref:
130
+ """Placeholder for a ``path = target`` line until the whole document has been read."""
131
+
132
+ __slots__ = ("segments", "target", "line")
133
+
134
+ def __init__(self, segments: list[Segment], target: list[Segment], line: int) -> None:
135
+ self.segments = segments
136
+ self.target = target
137
+ self.line = line
138
+
139
+
140
+ def assign(holder: dict[str, Any], segments: list[Segment], value: Any, strict: bool, line: int) -> None:
141
+ """Writes ``value`` at ``segments`` under ``holder["root"]``, creating dicts and lists on the way."""
142
+ parent: Any = holder
143
+ key: Segment = "root"
144
+
145
+ def fail(msg: str) -> NoReturn:
146
+ raise FlatDecodeError(msg, line, 1)
147
+
148
+ def slot(create: Callable[[], Any], leaf: bool) -> Any:
149
+ if not isinstance(key, str):
150
+ arr: list[Any] = parent
151
+ if key < len(arr):
152
+ if leaf:
153
+ if strict:
154
+ fail(f"Duplicate index [{show_number(key)}]")
155
+ arr[int(key)] = create()
156
+ return arr[int(key)]
157
+ if key > len(arr):
158
+ if strict:
159
+ fail(f"Index [{show_number(key)}] skips [{len(arr)}]")
160
+ # Lenient mode fills small gaps with None, but `a[999999999]: 1` must not allocate a billion entries.
161
+ if key - len(arr) > MAX_GAP:
162
+ fail(f"Index [{show_number(key)}] is too far past [{len(arr)}]")
163
+ arr.extend([None] * (int(key) - len(arr)))
164
+ v = create()
165
+ arr.append(v)
166
+ return v
167
+ obj: dict[str, Any] = parent
168
+ if key in obj:
169
+ if not leaf:
170
+ return obj[key]
171
+ if strict:
172
+ fail(f"Duplicate key '{key}'")
173
+ v = create()
174
+ obj[key] = v
175
+ return v
176
+
177
+ for seg in segments:
178
+ want_list = not isinstance(seg, str)
179
+ cur = slot(list if want_list else dict, False)
180
+ if not isinstance(cur, list if want_list else dict):
181
+ fail(f"Path conflict at {'[' + show_number(key) + ']' if not isinstance(key, str) else repr_key(key)}")
182
+ parent = cur
183
+ key = seg
184
+ slot(lambda: value, True)
185
+
186
+
187
+ def check(holder: dict[str, Any], segments: list[Segment], strict: bool, line: int) -> None:
188
+ """Raises exactly the error :func:`assign` would, without changing anything, so a line that fails leaves no
189
+ trace (used when lenient decoding skips lines it can't read)."""
190
+
191
+ def fail(msg: str) -> NoReturn:
192
+ raise FlatDecodeError(msg, line, 1)
193
+
194
+ # What sits at `key` in `parent`; `fresh` means `parent` doesn't exist yet (it would be created empty).
195
+ parent: Any = holder
196
+ key: Segment = "root"
197
+ fresh = False
198
+
199
+ def lookup(leaf: bool) -> Any:
200
+ if not isinstance(key, str):
201
+ length = 0 if fresh else len(parent)
202
+ if key < length:
203
+ if leaf and strict:
204
+ fail(f"Duplicate index [{show_number(key)}]")
205
+ return _MISSING if leaf else parent[int(key)]
206
+ if key > length:
207
+ if strict:
208
+ fail(f"Index [{show_number(key)}] skips [{length}]")
209
+ if key - length > MAX_GAP:
210
+ fail(f"Index [{show_number(key)}] is too far past [{length}]")
211
+ return _MISSING
212
+ if not fresh and key in parent:
213
+ if not leaf:
214
+ return parent[key]
215
+ if strict:
216
+ fail(f"Duplicate key '{key}'")
217
+ return _MISSING
218
+
219
+ for seg in segments:
220
+ want_list = not isinstance(seg, str)
221
+ cur = lookup(False)
222
+ if cur is _MISSING:
223
+ fresh = True
224
+ else:
225
+ if not isinstance(cur, list if want_list else dict):
226
+ fail(f"Path conflict at {'[' + show_number(key) + ']' if not isinstance(key, str) else repr_key(key)}")
227
+ parent = cur
228
+ fresh = False
229
+ key = seg
230
+ lookup(True)
231
+
232
+
233
+ def repr_key(key: str) -> str:
234
+ return f"'{key}'"
235
+
236
+
237
+ _MISSING = object()
238
+
239
+
240
+ def _js_get(container: Any, seg: Segment) -> Any:
241
+ """``container[seg]`` if it is an own property in JavaScript terms (so ``[0]`` on a dict reads key "0")."""
242
+ if isinstance(container, dict):
243
+ k = seg if isinstance(seg, str) else show_number(seg)
244
+ return container.get(k, _MISSING)
245
+ if isinstance(container, list):
246
+ if isinstance(seg, str):
247
+ return len(container) if seg == "length" else _MISSING
248
+ return container[int(seg)] if 0 <= seg < len(container) else _MISSING
249
+ return _MISSING
250
+
251
+
252
+ def _show(segments: list[Segment]) -> str:
253
+ if not segments:
254
+ return "~"
255
+ out = ""
256
+ for i, s in enumerate(segments):
257
+ if not isinstance(s, str):
258
+ out += f"[{show_number(s)}]"
259
+ else:
260
+ out += format_key(s) if i == 0 else "." + format_key(s)
261
+ return out
262
+
263
+
264
+ def resolve_refs(holder: dict[str, Any], refs: list[Ref], strict: bool) -> None:
265
+ """Replaces each placeholder with the dict or list its target points to, in document order.
266
+
267
+ A target may pass through a reference resolved before it, but not through one still waiting.
268
+ """
269
+ for ref in refs:
270
+ # Lenient decoding may have overwritten the placeholder (or a parent) with a later line; that line wins.
271
+ parent: Any = holder.get("root", _MISSING)
272
+ last = len(ref.segments) - 1
273
+ for seg in ref.segments[:last]:
274
+ if not isinstance(parent, (dict, list)):
275
+ break
276
+ parent = _js_get(parent, seg)
277
+ if not isinstance(parent, (dict, list)) or _js_get(parent, ref.segments[last]) is not ref:
278
+ continue
279
+
280
+ cur: Any = holder.get("root", _MISSING)
281
+ for seg in ref.target:
282
+ cur = _js_get(cur, seg) if isinstance(cur, (dict, list)) else _MISSING
283
+ value = cur
284
+ if not isinstance(cur, (dict, list)):
285
+ if isinstance(cur, Ref):
286
+ why = "is a reference that comes later"
287
+ elif cur is _MISSING:
288
+ why = "doesn't exist"
289
+ else:
290
+ why = "isn't an object or array"
291
+ if strict:
292
+ raise FlatDecodeError(f"Reference target '{_show(ref.target)}' {why}", ref.line, 1)
293
+ value = None
294
+ k = ref.segments[last]
295
+ if isinstance(parent, list):
296
+ parent[int(k)] = value
297
+ else:
298
+ parent[k if isinstance(k, str) else show_number(k)] = value
flattype/_syntax.py ADDED
@@ -0,0 +1,171 @@
1
+ """Lexical rules shared by the encoder and decoder. Every rule matches @flattype/core exactly."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ import re
8
+ from typing import Any
9
+
10
+
11
+ class FlatDecodeError(ValueError):
12
+ """Raised by :func:`decode` on malformed input, with the 1-based ``line`` and ``column`` of the problem."""
13
+
14
+ def __init__(self, message: str, line: int, column: int) -> None:
15
+ super().__init__(f"{message} (line {line}, column {column})")
16
+ self.line = line
17
+ self.column = column
18
+
19
+
20
+ #: Exactly the JSON number grammar (ASCII digits only). Anything else bare is a string.
21
+ NUMBER = re.compile(r"-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?")
22
+ _BARE_KEY = re.compile(r"[A-Za-z0-9_$-]+")
23
+ _CONTROL = re.compile(r"[\x00-\x1f]")
24
+
25
+ #: The characters JavaScript's String#trim removes (WhiteSpace and LineTerminator).
26
+ JS_SPACE = "\t\n\v\f\r              

   "
27
+
28
+ #: CPython refuses (or takes quadratic time) to convert very long digit strings to int.
29
+ _MAX_INT_DIGITS = 4300
30
+
31
+
32
+ def js_trim(s: str) -> str:
33
+ return s.strip(JS_SPACE)
34
+
35
+
36
+ def is_key_char(c: str) -> bool:
37
+ """Characters allowed in an unquoted key: A-Z a-z 0-9 _ $ -"""
38
+ return ("a" <= c <= "z") or ("A" <= c <= "Z") or ("0" <= c <= "9") or c in "_$-"
39
+
40
+
41
+ _ESCAPES = {'"': '\\"', "\\": "\\\\", "\b": "\\b", "\f": "\\f", "\n": "\\n", "\r": "\\r", "\t": "\\t"}
42
+ _NEEDS_ESCAPE = re.compile('["\\\\\x00-\x1f\ud800-\udfff]')
43
+
44
+
45
+ def _escape(m: re.Match[str]) -> str:
46
+ c = m.group()
47
+ return _ESCAPES.get(c) or f"\\u{ord(c):04x}"
48
+
49
+
50
+ def quote(s: str) -> str:
51
+ """A JSON string literal, byte for byte what JavaScript's JSON.stringify writes (lone surrogates escaped)."""
52
+ return '"' + _NEEDS_ESCAPE.sub(_escape, s) + '"'
53
+
54
+
55
+ def format_key(key: str) -> str:
56
+ return key if _BARE_KEY.fullmatch(key) else quote(key)
57
+
58
+
59
+ def join_key(path: str, key: str) -> str:
60
+ return format_key(key) if path == "" else path + "." + format_key(key)
61
+
62
+
63
+ def format_string(s: str, delimiter: str | None = None) -> str:
64
+ """Writes a string bare when it can't be mistaken for anything else, quoted (JSON style) otherwise."""
65
+ if (
66
+ s == ""
67
+ or s[0] == '"'
68
+ or s != js_trim(s)
69
+ or s in ("null", "true", "false", "{}", "[]")
70
+ or NUMBER.fullmatch(s)
71
+ or _CONTROL.search(s)
72
+ or (delimiter is not None and delimiter in s)
73
+ ):
74
+ return quote(s)
75
+ return s
76
+
77
+
78
+ def format_number(x: float) -> str:
79
+ """Formats a number the way JavaScript does (``1e+21``, ``1e-7``, ``100``), so both libraries write the same text.
80
+
81
+ Integers are written exactly. Floats use the shortest round-trip digits, like ``repr``, laid out by the
82
+ ECMAScript Number::toString rules. ``NaN`` and infinities become ``null``, as in JSON.
83
+ """
84
+ if isinstance(x, int):
85
+ return str(x)
86
+ if not math.isfinite(x):
87
+ return "null"
88
+ if x == 0:
89
+ return "0"
90
+ r = repr(abs(x))
91
+ mant, _, exp = r.partition("e")
92
+ whole, _, frac = mant.partition(".")
93
+ digits = whole + frac
94
+ n = len(whole) + (int(exp) if exp else 0) # x = 0.digits * 10**n
95
+ stripped = digits.lstrip("0")
96
+ n -= len(digits) - len(stripped)
97
+ digits = stripped.rstrip("0")
98
+ k = len(digits)
99
+ if k <= n <= 21:
100
+ out = digits + "0" * (n - k)
101
+ elif 0 < n <= 21:
102
+ out = digits[:n] + "." + digits[n:]
103
+ elif -6 < n <= 0:
104
+ out = "0." + "0" * -n + digits
105
+ else:
106
+ e = n - 1
107
+ sign = "+" if e >= 0 else "-"
108
+ out = (digits if k == 1 else digits[0] + "." + digits[1:]) + "e" + sign + str(abs(e))
109
+ return "-" + out if x < 0 else out
110
+
111
+
112
+ def format_primitive(v: Any, delimiter: str | None = None) -> str:
113
+ if isinstance(v, str):
114
+ return format_string(v, delimiter)
115
+ if v is True:
116
+ return "true"
117
+ if v is False:
118
+ return "false"
119
+ if v is None:
120
+ return "null"
121
+ if isinstance(v, int):
122
+ return str(int(v))
123
+ return format_number(float(v))
124
+
125
+
126
+ def to_number(tok: str) -> int | float:
127
+ """A token that matched NUMBER: an int when it has no fraction or exponent, otherwise a float."""
128
+ if "." in tok or "e" in tok or "E" in tok or len(tok) > _MAX_INT_DIGITS:
129
+ return float(tok)
130
+ return int(tok)
131
+
132
+
133
+ #: Returned by parse_scalar for a malformed quoted token.
134
+ INVALID = object()
135
+
136
+
137
+ def parse_scalar(tok: str) -> Any:
138
+ """Parses one trimmed bare or quoted token. Returns ``INVALID`` when a quoted token is malformed."""
139
+ if tok == "null":
140
+ return None
141
+ if tok == "true":
142
+ return True
143
+ if tok == "false":
144
+ return False
145
+ if tok == "{}":
146
+ return {}
147
+ if tok == "[]":
148
+ return []
149
+ if tok[:1] == '"':
150
+ try:
151
+ v = json.loads(tok)
152
+ except ValueError:
153
+ return INVALID
154
+ return v if isinstance(v, str) else INVALID
155
+ return to_number(tok) if NUMBER.fullmatch(tok) else tok
156
+
157
+
158
+ _QUOTE_OR_BACKSLASH = re.compile(r'["\\]')
159
+
160
+
161
+ def scan_string(s: str, start: int) -> int:
162
+ """Index of the closing quote of the JSON string starting at ``start``, or -1."""
163
+ i = start + 1
164
+ while True:
165
+ m = _QUOTE_OR_BACKSLASH.search(s, i)
166
+ if m is None:
167
+ return -1
168
+ j = m.start()
169
+ if s[j] == '"':
170
+ return j
171
+ i = j + 2 # skip the escaped character
flattype/ai.py ADDED
@@ -0,0 +1,154 @@
1
+ """Flat Notation for LLM prompts and replies: the Python side of ``@flattype/ai``.
2
+
3
+ from flattype.ai import to_prompt, from_model_output
4
+
5
+ content = f"{to_prompt(orders, name='orders')}\\n\\nWhich customer spent the most?"
6
+ rows = from_model_output(reply_text)
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import re
13
+ from typing import Any, Callable, Protocol
14
+
15
+ from ._decode import decode
16
+ from ._encode import encode
17
+
18
+ __all__ = ["LEGEND", "claude_tool_result", "count_tokens", "from_model_output", "output_instructions", "to_prompt", "tool_result"]
19
+
20
+ #: One line that teaches a model the format. It is a ``#`` comment, so a block that includes it is still valid.
21
+ LEGEND = (
22
+ "# Flat Notation: one `path: value` per line. `a.b` is a nested key, `a[0]` an array index, `a[]: x, y` a list. "
23
+ "`t[N]{f,g}:` is a table: N rows follow, one per line, values in field order. Quoted values are JSON strings."
24
+ )
25
+
26
+
27
+ def _attr(s: str) -> str:
28
+ return s.replace("&", "&amp;").replace('"', "&quot;").replace("<", "&lt;")
29
+
30
+
31
+ def to_prompt(value: Any, *, name: str | None = None, legend: bool = True, tag: bool = True, **encode_options: Any) -> str:
32
+ """Formats data for an LLM prompt: Flat Notation inside a ``<data>`` tag, with a one-line legend.
33
+
34
+ :param name: adds ``name="..."`` to the tag, so a prompt can refer to several data blocks.
35
+ :param legend: include the format legend. Turn it off once a system prompt explains the format.
36
+ :param tag: wrap in ``<data format="flat-notation">...</data>``.
37
+ """
38
+ body = (LEGEND + "\n" if legend else "") + encode(value, **encode_options)
39
+ if not tag:
40
+ return body
41
+ return f'<data format="flat-notation"{f" name={chr(34)}{_attr(name)}{chr(34)}" if name else ""}>\n{body}\n</data>'
42
+
43
+
44
+ def output_instructions(example: Any = None) -> str:
45
+ """Instructions that ask a model to answer in Flat Notation, with an example of the shape you expect."""
46
+ lines = [
47
+ "Reply with the data only, in Flat Notation, inside <data> and </data> tags.",
48
+ LEGEND[2:],
49
+ "Write a list of objects with the same keys as a table. Quote a string value if it contains a comma "
50
+ "or could be read as a number, true, false or null.",
51
+ ]
52
+ if example is not None:
53
+ lines += ["Example of the expected shape:", to_prompt(example, legend=False)]
54
+ return "\n".join(lines)
55
+
56
+
57
+ _WORD = re.compile(r"[\w-]", re.ASCII)
58
+ _JSON_LANG = re.compile(r"json", re.I)
59
+
60
+
61
+ def _extract(text: str) -> tuple[str, bool]:
62
+ """The last ``<data>...</data>`` block, else the last fenced block, else the whole text. Linear scans only."""
63
+ lower = text.lower()
64
+ close = lower.rfind("</data>")
65
+ if close >= 0:
66
+ opening = lower.rfind("<data", 0, close + 5)
67
+ # `<data` must be the tag itself, not e.g. `<database>`.
68
+ while opening >= 0 and _WORD.match(lower[opening + 5 : opening + 6] or " "):
69
+ opening = lower.rfind("<data", 0, opening + 4) if opening > 0 else -1
70
+ gt = text.find(">", opening) if opening >= 0 else -1
71
+ if 0 <= gt < close:
72
+ return text[gt + 1 : close], False
73
+ fences: list[int] = []
74
+ i = text.find("```")
75
+ while i >= 0:
76
+ fences.append(i)
77
+ i = text.find("```", i + 3)
78
+ if len(fences) >= 2:
79
+ last = len(fences) - 2 if len(fences) % 2 == 0 else len(fences) - 3
80
+ start, end = fences[last], fences[last + 1]
81
+ nl = text.find("\n", start)
82
+ if 0 <= nl < end:
83
+ lang = text[start + 3 : nl].strip()
84
+ return text[nl + 1 : end], bool(_JSON_LANG.match(lang))
85
+ return text, False
86
+
87
+
88
+ def from_model_output(text: str, model: Callable[[Any], Any] | None = None, *, many: bool = False) -> Any:
89
+ """Reads data out of an LLM reply, tolerating what models actually send: ``<data>`` tags or code fences around
90
+ it, prose before or after, small format slips, or plain JSON instead.
91
+
92
+ :param model: validates the result, e.g. a Pydantic model class (``model_validate`` is used) or any callable.
93
+ :param many: the data is a list; ``model`` is applied to each item.
94
+ """
95
+ body, is_json = _extract(text)
96
+ trimmed = body.strip()
97
+ value: Any = None
98
+ parsed = False
99
+ if is_json or trimmed[:1] in ("[", "{"):
100
+ try:
101
+ value = json.loads(trimmed)
102
+ parsed = True
103
+ except ValueError:
104
+ pass
105
+ if not parsed:
106
+ # Lenient, and lines that aren't Flat Notation (prose like "Here are the results:") are dropped.
107
+ value = decode(body, strict=False, skip_invalid_lines=True)
108
+ if model is None:
109
+ return value
110
+ validate = getattr(model, "model_validate", model)
111
+ return [validate(v) for v in value] if many else validate(value)
112
+
113
+
114
+ class _Counter(Protocol):
115
+ def count_tokens(self, *, model: str, messages: list[dict[str, str]]) -> Any: ...
116
+
117
+
118
+ class _Client(Protocol):
119
+ @property
120
+ def messages(self) -> _Counter: ...
121
+
122
+
123
+ def count_tokens(client: _Client, value: Any, *, model: str = "claude-opus-5-5", **prompt_options: Any) -> dict[str, Any]:
124
+ """Tokens for ``value`` as minified JSON and as :func:`to_prompt` text, counted by Claude's token counter.
125
+
126
+ ``client`` is an ``anthropic.Anthropic()``. Counting is free. Returns ``{"model", "json", "flat", "saved"}``.
127
+ """
128
+
129
+ def count(content: str) -> int:
130
+ return int(client.messages.count_tokens(model=model, messages=[{"role": "user", "content": content}]).input_tokens)
131
+
132
+ # "." is one token; what's left is the message framing both counts share.
133
+ base = max(0, count(".") - 1)
134
+ j = count(json.dumps(value, separators=(",", ":"), ensure_ascii=False)) - base
135
+ f = count(to_prompt(value, **prompt_options)) - base
136
+ return {"model": model, "json": j, "flat": f, "saved": 0.0 if j == 0 else 1 - f / j}
137
+
138
+
139
+ def tool_result(value: Any, *, is_error: bool = False, structured: bool = False, **prompt_options: Any) -> dict[str, Any]:
140
+ """An MCP tool result whose text is the data as Flat Notation (compatible with the ``mcp`` SDK's ``CallToolResult``)."""
141
+ result: dict[str, Any] = {"content": [{"type": "text", "text": to_prompt(value, **prompt_options)}]}
142
+ if structured and isinstance(value, dict):
143
+ result["structuredContent"] = json.loads(json.dumps(value))
144
+ if is_error:
145
+ result["isError"] = True
146
+ return result
147
+
148
+
149
+ def claude_tool_result(tool_use_id: str, value: Any, *, is_error: bool = False, **prompt_options: Any) -> dict[str, Any]:
150
+ """A Claude API ``tool_result`` block whose content is the data as Flat Notation."""
151
+ block: dict[str, Any] = {"type": "tool_result", "tool_use_id": tool_use_id, "content": to_prompt(value, **prompt_options)}
152
+ if is_error:
153
+ block["is_error"] = True
154
+ return block