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/__init__.py +63 -0
- flattype/_decode.py +294 -0
- flattype/_encode.py +187 -0
- flattype/_flatten.py +55 -0
- flattype/_path.py +298 -0
- flattype/_syntax.py +171 -0
- flattype/ai.py +154 -0
- flattype/asgi.py +93 -0
- flattype/negotiate.py +47 -0
- flattype/py.typed +0 -0
- flattype/starlette.py +26 -0
- flattype-1.11.0.dist-info/METADATA +240 -0
- flattype-1.11.0.dist-info/RECORD +15 -0
- flattype-1.11.0.dist-info/WHEEL +4 -0
- flattype-1.11.0.dist-info/licenses/LICENSE +21 -0
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("&", "&").replace('"', """).replace("<", "<")
|
|
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
|