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/__init__.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""FlatType for Python: JSON <-> Flat Notation, the compact ``key: value`` format of the @flattype/* npm packages.
|
|
2
|
+
|
|
3
|
+
>>> import flattype
|
|
4
|
+
>>> print(flattype.encode({"name": "John", "age": 30, "city": "New York"}))
|
|
5
|
+
name: John
|
|
6
|
+
age: 30
|
|
7
|
+
city: New York
|
|
8
|
+
>>> flattype.decode("name: John\\nage: 30")
|
|
9
|
+
{'name': 'John', 'age': 30}
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from ._decode import Decoder, TableRow, decode
|
|
19
|
+
from ._encode import encode
|
|
20
|
+
from ._flatten import flatten, unflatten
|
|
21
|
+
from ._syntax import FlatDecodeError
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"MEDIA_TYPE",
|
|
25
|
+
"CONTENT_TYPE",
|
|
26
|
+
"Decoder",
|
|
27
|
+
"FlatDecodeError",
|
|
28
|
+
"FlatStats",
|
|
29
|
+
"TableRow",
|
|
30
|
+
"decode",
|
|
31
|
+
"dumps",
|
|
32
|
+
"encode",
|
|
33
|
+
"flatten",
|
|
34
|
+
"loads",
|
|
35
|
+
"stats",
|
|
36
|
+
"unflatten",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
__version__ = "1.11.0"
|
|
40
|
+
|
|
41
|
+
#: Media type for Flat Notation over HTTP.
|
|
42
|
+
MEDIA_TYPE = "text/x-flat"
|
|
43
|
+
#: ``Content-Type`` for Flat Notation responses.
|
|
44
|
+
CONTENT_TYPE = "text/x-flat; charset=utf-8"
|
|
45
|
+
|
|
46
|
+
#: ``json``-style aliases.
|
|
47
|
+
dumps = encode
|
|
48
|
+
loads = decode
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class FlatStats:
|
|
53
|
+
json_bytes: int
|
|
54
|
+
flat_bytes: int
|
|
55
|
+
#: Fraction saved, e.g. 0.45 for 45% smaller than minified JSON.
|
|
56
|
+
saved: float
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def stats(value: Any, **options: Any) -> FlatStats:
|
|
60
|
+
"""UTF-8 size of ``value`` as minified JSON and as Flat Notation."""
|
|
61
|
+
j = len(json.dumps(value, separators=(",", ":"), ensure_ascii=False).encode("utf-8", "surrogatepass"))
|
|
62
|
+
f = len(encode(value, **options).encode("utf-8", "surrogatepass"))
|
|
63
|
+
return FlatStats(j, f, 0.0 if j == 0 else 1 - f / j)
|
flattype/_decode.py
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""Flat Notation -> Python values, whole or streamed."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Callable
|
|
7
|
+
|
|
8
|
+
from ._path import Ref, Segment, assign, check, parse_path, resolve_refs, show_number
|
|
9
|
+
from ._syntax import INVALID, FlatDecodeError, format_key, js_trim, parse_scalar, scan_string
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class TableRow:
|
|
14
|
+
"""One table row, passed to ``on_row`` as soon as its line has been read."""
|
|
15
|
+
|
|
16
|
+
#: Path of the table, e.g. ``"users"`` or ``"data.items"``; ``""`` for a root table.
|
|
17
|
+
path: str
|
|
18
|
+
#: Row number within the table, from 0.
|
|
19
|
+
index: int
|
|
20
|
+
value: Any
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _Table:
|
|
24
|
+
__slots__ = ("at", "segments", "count", "delimiter", "fields", "path", "rows", "read", "keys")
|
|
25
|
+
|
|
26
|
+
def __init__(self, at: int, segments: list[Segment], terminal: tuple[Any, ...]) -> None:
|
|
27
|
+
self.at = at
|
|
28
|
+
self.segments = segments
|
|
29
|
+
_, self.count, self.delimiter, self.fields = terminal
|
|
30
|
+
self.path = _format_path(segments)
|
|
31
|
+
self.rows: list[Any] = []
|
|
32
|
+
self.read = 0
|
|
33
|
+
# Fields that are plain keys or dot paths that can't conflict (the usual case) are built directly, without
|
|
34
|
+
# the general assign() and its duplicate / conflict checks.
|
|
35
|
+
paths = [tuple(f) for f in self.fields]
|
|
36
|
+
unique = set(paths)
|
|
37
|
+
simple = (
|
|
38
|
+
len(unique) == len(paths)
|
|
39
|
+
and all(isinstance(k, str) for p in paths for k in p)
|
|
40
|
+
and not any(p[:i] in unique for p in paths for i in range(1, len(p)))
|
|
41
|
+
)
|
|
42
|
+
self.keys: list[tuple[Any, ...]] | None = paths if simple else None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Decoder:
|
|
46
|
+
"""Incremental decoder: :meth:`write` chunks as they arrive (split anywhere, even mid-line), then :meth:`end`.
|
|
47
|
+
|
|
48
|
+
``on_row`` is called for every table row as soon as its line is complete. With ``keep_rows=False``, rows are
|
|
49
|
+
only passed to ``on_row`` and their tables stay empty, so memory stays flat for huge lists.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
*,
|
|
55
|
+
strict: bool = True,
|
|
56
|
+
refs: bool = False,
|
|
57
|
+
on_row: Callable[[TableRow], None] | None = None,
|
|
58
|
+
keep_rows: bool = True,
|
|
59
|
+
skip_invalid_lines: bool = False,
|
|
60
|
+
) -> None:
|
|
61
|
+
self._strict = strict
|
|
62
|
+
# Lenient only: a line that can't be read is dropped, as if it weren't there.
|
|
63
|
+
self._skip = not strict and skip_invalid_lines
|
|
64
|
+
self._refs = refs
|
|
65
|
+
self._on_row = on_row
|
|
66
|
+
self._keep = keep_rows
|
|
67
|
+
self._holder: dict[str, Any] = {}
|
|
68
|
+
self._line_no = 0
|
|
69
|
+
# The unfinished last line, as a list of pieces: appending to one growing str would be quadratic.
|
|
70
|
+
self._rest: list[str] = []
|
|
71
|
+
self._table: _Table | None = None
|
|
72
|
+
self._pending: list[Ref] = []
|
|
73
|
+
|
|
74
|
+
def write(self, chunk: str) -> None:
|
|
75
|
+
"""Feeds the next chunk of text."""
|
|
76
|
+
nl = chunk.find("\n")
|
|
77
|
+
if nl < 0:
|
|
78
|
+
if chunk:
|
|
79
|
+
self._rest.append(chunk)
|
|
80
|
+
return
|
|
81
|
+
self._rest.append(chunk[:nl])
|
|
82
|
+
self._line("".join(self._rest))
|
|
83
|
+
start = nl + 1
|
|
84
|
+
while True:
|
|
85
|
+
nl = chunk.find("\n", start)
|
|
86
|
+
if nl < 0:
|
|
87
|
+
break
|
|
88
|
+
self._line(chunk[start:nl])
|
|
89
|
+
start = nl + 1
|
|
90
|
+
self._rest = [chunk[start:]] if start < len(chunk) else []
|
|
91
|
+
|
|
92
|
+
def end(self) -> Any:
|
|
93
|
+
"""Finishes decoding and returns the value. An empty document is ``{}``."""
|
|
94
|
+
self._line("".join(self._rest))
|
|
95
|
+
self._rest = []
|
|
96
|
+
table = self._table
|
|
97
|
+
if table is not None:
|
|
98
|
+
if self._strict:
|
|
99
|
+
raise FlatDecodeError(f"Expected {show_number(table.count)} rows, got {table.read}", table.at, 1)
|
|
100
|
+
try:
|
|
101
|
+
self._close_table()
|
|
102
|
+
except FlatDecodeError:
|
|
103
|
+
if not self._skip:
|
|
104
|
+
raise
|
|
105
|
+
if self._pending:
|
|
106
|
+
resolve_refs(self._holder, self._pending, self._strict)
|
|
107
|
+
return self._holder.get("root", {})
|
|
108
|
+
|
|
109
|
+
def _put(self, segments: list[Segment], value: Any, at: int) -> None:
|
|
110
|
+
"""``assign`` into the result. When skipping lines, checked first, so a line that fails changes nothing."""
|
|
111
|
+
if self._skip:
|
|
112
|
+
check(self._holder, segments, self._strict, at)
|
|
113
|
+
assign(self._holder, segments, value, self._strict, at)
|
|
114
|
+
|
|
115
|
+
def _close_table(self) -> None:
|
|
116
|
+
# Cleared first: a table whose path can't be written is dropped, not left half-open.
|
|
117
|
+
t = self._table
|
|
118
|
+
assert t is not None
|
|
119
|
+
self._table = None
|
|
120
|
+
self._put(t.segments, t.rows, t.at)
|
|
121
|
+
|
|
122
|
+
def _line(self, raw: str) -> None:
|
|
123
|
+
self._line_no += 1
|
|
124
|
+
if not self._skip:
|
|
125
|
+
self._read_line(raw)
|
|
126
|
+
return
|
|
127
|
+
try:
|
|
128
|
+
self._read_line(raw)
|
|
129
|
+
except FlatDecodeError:
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
def _read_line(self, raw: str) -> None:
|
|
133
|
+
line_no = self._line_no
|
|
134
|
+
strict = self._strict
|
|
135
|
+
text = raw[:-1] if raw.endswith("\r") else raw
|
|
136
|
+
t = self._table
|
|
137
|
+
if t is not None:
|
|
138
|
+
width = len(t.fields)
|
|
139
|
+
row = _cells(text, t.delimiter, strict, line_no, 1)
|
|
140
|
+
if len(row) != width and strict:
|
|
141
|
+
raise FlatDecodeError(f"Expected {width} values, got {len(row)}", line_no, 1)
|
|
142
|
+
if len(row) < width:
|
|
143
|
+
row += [None] * (width - len(row))
|
|
144
|
+
if t.keys is not None:
|
|
145
|
+
value: Any = {}
|
|
146
|
+
for path, cell in zip(t.keys, row):
|
|
147
|
+
d = value
|
|
148
|
+
for k in path[:-1]:
|
|
149
|
+
d = d.setdefault(k, {})
|
|
150
|
+
d[path[-1]] = cell
|
|
151
|
+
else:
|
|
152
|
+
obj: dict[str, Any] = {"root": {}}
|
|
153
|
+
for f in range(width):
|
|
154
|
+
assign(obj, t.fields[f], row[f], strict, line_no)
|
|
155
|
+
value = obj["root"]
|
|
156
|
+
if self._keep:
|
|
157
|
+
t.rows.append(value)
|
|
158
|
+
if self._on_row is not None:
|
|
159
|
+
self._on_row(TableRow(t.path, t.read, value))
|
|
160
|
+
t.read += 1
|
|
161
|
+
if t.read == t.count:
|
|
162
|
+
self._close_table()
|
|
163
|
+
return
|
|
164
|
+
pos = 0
|
|
165
|
+
n = len(text)
|
|
166
|
+
while pos < n and text[pos] in " \t":
|
|
167
|
+
pos += 1
|
|
168
|
+
if pos == n or text[pos] == "#":
|
|
169
|
+
return # blank or # comment
|
|
170
|
+
|
|
171
|
+
segments, terminal, end = parse_path(text, pos, line_no)
|
|
172
|
+
eq = end
|
|
173
|
+
while eq < n and text[eq] == " ":
|
|
174
|
+
eq += 1
|
|
175
|
+
if terminal[0] == "value" and eq < n and text[eq] == "=":
|
|
176
|
+
self._reference(segments, text, eq)
|
|
177
|
+
return
|
|
178
|
+
if end >= n or text[end] != ":":
|
|
179
|
+
raise FlatDecodeError("Expected ':' after path", line_no, end + 1)
|
|
180
|
+
value = text[end + 1 :]
|
|
181
|
+
kind = terminal[0]
|
|
182
|
+
if kind == "value":
|
|
183
|
+
self._put(segments, _scalar(js_trim(value), strict, line_no, end + 2), line_no)
|
|
184
|
+
elif kind == "inline":
|
|
185
|
+
self._put(segments, _cells(value, terminal[1], strict, line_no, end + 2), line_no)
|
|
186
|
+
else:
|
|
187
|
+
if js_trim(value) != "" and strict:
|
|
188
|
+
raise FlatDecodeError("Table rows go on the following lines", line_no, end + 2)
|
|
189
|
+
self._table = _Table(line_no, segments, terminal)
|
|
190
|
+
if terminal[1] == 0:
|
|
191
|
+
self._close_table()
|
|
192
|
+
|
|
193
|
+
def _reference(self, segments: list[Segment], text: str, eq: int) -> None:
|
|
194
|
+
"""``path = target`` (``~`` is the root)."""
|
|
195
|
+
line_no = self._line_no
|
|
196
|
+
if not self._refs:
|
|
197
|
+
if self._strict:
|
|
198
|
+
raise FlatDecodeError("References need { refs: true }", line_no, eq + 1)
|
|
199
|
+
return
|
|
200
|
+
if not segments:
|
|
201
|
+
raise FlatDecodeError("The root can't be a reference", line_no, eq + 1)
|
|
202
|
+
pos = eq + 1
|
|
203
|
+
n = len(text)
|
|
204
|
+
while pos < n and text[pos] == " ":
|
|
205
|
+
pos += 1
|
|
206
|
+
if pos < n and text[pos] == "~":
|
|
207
|
+
target: list[Segment] = []
|
|
208
|
+
end = pos + 1
|
|
209
|
+
else:
|
|
210
|
+
target, terminal, end = parse_path(text, pos, line_no)
|
|
211
|
+
if terminal[0] != "value" or not target:
|
|
212
|
+
raise FlatDecodeError("Expected a path or '~' after '='", line_no, pos + 1)
|
|
213
|
+
while end < n and text[end] == " ":
|
|
214
|
+
end += 1
|
|
215
|
+
if end != n:
|
|
216
|
+
raise FlatDecodeError("Unexpected text after reference", line_no, end + 1)
|
|
217
|
+
ref = Ref(segments, target, line_no)
|
|
218
|
+
self._put(segments, ref, line_no)
|
|
219
|
+
self._pending.append(ref)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def decode(text: str, *, strict: bool = True, refs: bool = False, skip_invalid_lines: bool = False) -> Any:
|
|
223
|
+
"""Decodes Flat Notation back into the value it came from. An empty document is ``{}``.
|
|
224
|
+
|
|
225
|
+
``strict=False`` recovers what it can from malformed input (for example LLM output) instead of raising.
|
|
226
|
+
``refs=True`` accepts ``path = other.path`` references and rebuilds the shared objects and cycles they describe.
|
|
227
|
+
It is off by default: a few such lines can describe a graph that is endless or enormous when walked.
|
|
228
|
+
``skip_invalid_lines=True`` (with ``strict=False``) drops lines that can't be read, such as prose around data.
|
|
229
|
+
"""
|
|
230
|
+
d = Decoder(strict=strict, refs=refs, skip_invalid_lines=skip_invalid_lines)
|
|
231
|
+
d.write(text)
|
|
232
|
+
return d.end()
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _format_path(segments: list[Segment]) -> str:
|
|
236
|
+
out = ""
|
|
237
|
+
for s in segments:
|
|
238
|
+
if not isinstance(s, str):
|
|
239
|
+
out += f"[{show_number(s)}]"
|
|
240
|
+
else:
|
|
241
|
+
out += format_key(s) if out == "" else "." + format_key(s)
|
|
242
|
+
return out
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _scalar(tok: str, strict: bool, line: int, column: int) -> Any:
|
|
246
|
+
if tok == "":
|
|
247
|
+
if strict:
|
|
248
|
+
raise FlatDecodeError("Missing value", line, column)
|
|
249
|
+
return None
|
|
250
|
+
v = parse_scalar(tok)
|
|
251
|
+
if v is INVALID:
|
|
252
|
+
if strict:
|
|
253
|
+
raise FlatDecodeError("Invalid quoted string", line, column)
|
|
254
|
+
return tok
|
|
255
|
+
return v
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _cells(s: str, delimiter: str, strict: bool, line: int, column: int) -> list[Any]:
|
|
259
|
+
"""Splits ``a, "b, c", 3`` on the delimiter, honouring quotes."""
|
|
260
|
+
if '"' not in s:
|
|
261
|
+
# No quotes: a plain split gives the same cells. (Column numbers are only needed for errors.)
|
|
262
|
+
parts = [js_trim(x) for x in s.split(delimiter)]
|
|
263
|
+
if not strict or "" not in parts:
|
|
264
|
+
return [_scalar(p, strict, line, column) for p in parts]
|
|
265
|
+
out: list[Any] = []
|
|
266
|
+
n = len(s)
|
|
267
|
+
spaces = " " if delimiter == "\t" else " \t"
|
|
268
|
+
i = 0
|
|
269
|
+
while True:
|
|
270
|
+
while i < n and s[i] in spaces:
|
|
271
|
+
i += 1
|
|
272
|
+
if i < n and s[i] == '"':
|
|
273
|
+
close = scan_string(s, i)
|
|
274
|
+
if close < 0:
|
|
275
|
+
if strict:
|
|
276
|
+
raise FlatDecodeError("Unterminated string", line, column + i)
|
|
277
|
+
out.append(s[i + 1 :])
|
|
278
|
+
return out
|
|
279
|
+
out.append(_scalar(s[i : close + 1], strict, line, column + i))
|
|
280
|
+
i = close + 1
|
|
281
|
+
while i < n and s[i] in spaces:
|
|
282
|
+
i += 1
|
|
283
|
+
if i < n and s[i] != delimiter and strict:
|
|
284
|
+
raise FlatDecodeError("Expected delimiter after quoted string", line, column + i)
|
|
285
|
+
nxt = s.find(delimiter, i)
|
|
286
|
+
i = n if nxt < 0 else nxt
|
|
287
|
+
else:
|
|
288
|
+
nxt = s.find(delimiter, i)
|
|
289
|
+
j = n if nxt < 0 else nxt
|
|
290
|
+
out.append(_scalar(js_trim(s[i:j]), strict, line, column + i))
|
|
291
|
+
i = j
|
|
292
|
+
if i >= n:
|
|
293
|
+
return out
|
|
294
|
+
i += 1
|
flattype/_encode.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Python values -> Flat Notation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Callable
|
|
6
|
+
|
|
7
|
+
from ._syntax import format_number, format_primitive, join_key
|
|
8
|
+
|
|
9
|
+
_SCALARS = (str, int, float, bool, type(None))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _key(k: Any) -> str:
|
|
13
|
+
"""Dict keys as ``json.dumps`` converts them."""
|
|
14
|
+
if isinstance(k, str):
|
|
15
|
+
return k
|
|
16
|
+
if k is True:
|
|
17
|
+
return "true"
|
|
18
|
+
if k is False:
|
|
19
|
+
return "false"
|
|
20
|
+
if k is None:
|
|
21
|
+
return "null"
|
|
22
|
+
if isinstance(k, int):
|
|
23
|
+
return str(int(k))
|
|
24
|
+
if isinstance(k, float):
|
|
25
|
+
return format_number(k)
|
|
26
|
+
raise TypeError(f"keys must be str, int, float, bool or None, not {type(k).__name__}")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def encode(
|
|
30
|
+
value: Any,
|
|
31
|
+
*,
|
|
32
|
+
delimiter: str = ",",
|
|
33
|
+
indent: int = 2,
|
|
34
|
+
tabular: bool = True,
|
|
35
|
+
refs: bool = False,
|
|
36
|
+
default: Callable[[Any], Any] | None = None,
|
|
37
|
+
) -> str:
|
|
38
|
+
"""Encodes a JSON-compatible value (dicts, lists, tuples, str, int, float, bool, None) as Flat Notation::
|
|
39
|
+
|
|
40
|
+
name: John
|
|
41
|
+
address.city: New York
|
|
42
|
+
tags[]: admin, editor
|
|
43
|
+
users[2]{id,name}:
|
|
44
|
+
1, Alice
|
|
45
|
+
2, Bob
|
|
46
|
+
|
|
47
|
+
``decode(encode(x)) == x`` for every such value. Floats are written like JavaScript writes them, so a float
|
|
48
|
+
with no fractional part (``30.0``) comes back as an int (``30``).
|
|
49
|
+
|
|
50
|
+
:param delimiter: separator for inline arrays and table rows: ``","``, ``"|"`` or ``"\\t"``.
|
|
51
|
+
:param indent: spaces before each table row.
|
|
52
|
+
:param tabular: write lists of same-shaped dicts as tables (header once, then one row each).
|
|
53
|
+
:param refs: write an object that appears more than once (shared, or a cycle) only once, and
|
|
54
|
+
``path = first.path`` after that. Without it, shared objects are repeated and cycles raise ``ValueError``.
|
|
55
|
+
:param default: called for any other object, like ``json.dumps(default=...)``; returns something encodable.
|
|
56
|
+
"""
|
|
57
|
+
if delimiter not in (",", "|", "\t"):
|
|
58
|
+
raise ValueError('delimiter must be ",", "|" or "\\t"')
|
|
59
|
+
pad = " " * indent
|
|
60
|
+
sep = ", " if delimiter == "," else " | " if delimiter == "|" else "\t"
|
|
61
|
+
mark = "" if delimiter == "," else delimiter
|
|
62
|
+
out: list[str] = []
|
|
63
|
+
# ids of the containers on the current path (cycle check)...
|
|
64
|
+
stack: set[int] = set()
|
|
65
|
+
# ...or with refs, every container and the path it was written at. The value is kept so its id stays unique.
|
|
66
|
+
seen: dict[int, tuple[str, Any]] | None = {} if refs else None
|
|
67
|
+
|
|
68
|
+
def prep(v: Any) -> Any:
|
|
69
|
+
if isinstance(v, (_SCALARS, dict, list, tuple)):
|
|
70
|
+
return v
|
|
71
|
+
if default is None:
|
|
72
|
+
raise TypeError(f"Object of type {type(v).__name__} is not Flat Notation serializable")
|
|
73
|
+
return prep(default(v))
|
|
74
|
+
|
|
75
|
+
def cell(v: Any) -> str:
|
|
76
|
+
return format_primitive(v, delimiter)
|
|
77
|
+
|
|
78
|
+
def visit(path: str, v: Any) -> None:
|
|
79
|
+
if not isinstance(v, (dict, list, tuple)):
|
|
80
|
+
out.append(path + ": " + format_primitive(v))
|
|
81
|
+
return
|
|
82
|
+
i = id(v)
|
|
83
|
+
if isinstance(v, tuple):
|
|
84
|
+
pass # Immutable, and Python shares equal tuple constants: never a reference, never a cycle on its own.
|
|
85
|
+
elif seen is not None:
|
|
86
|
+
first = seen.get(i)
|
|
87
|
+
if first is not None:
|
|
88
|
+
out.append(path + " = " + (first[0] or "~"))
|
|
89
|
+
return
|
|
90
|
+
seen[i] = (path, v)
|
|
91
|
+
else:
|
|
92
|
+
if i in stack:
|
|
93
|
+
raise ValueError("Circular reference detected (use refs=True)")
|
|
94
|
+
stack.add(i)
|
|
95
|
+
if isinstance(v, dict):
|
|
96
|
+
visit_dict(path, v)
|
|
97
|
+
else:
|
|
98
|
+
visit_list(path, v)
|
|
99
|
+
stack.discard(i)
|
|
100
|
+
|
|
101
|
+
def visit_dict(path: str, obj: dict[Any, Any]) -> None:
|
|
102
|
+
if not obj:
|
|
103
|
+
# An empty root dict is an empty document.
|
|
104
|
+
if path != "":
|
|
105
|
+
out.append(path + ": {}")
|
|
106
|
+
return
|
|
107
|
+
for k, child in obj.items():
|
|
108
|
+
visit(join_key(path, _key(k)), prep(child))
|
|
109
|
+
|
|
110
|
+
def visit_list(path: str, arr: Any) -> None:
|
|
111
|
+
if not arr:
|
|
112
|
+
out.append(path + ": []")
|
|
113
|
+
return
|
|
114
|
+
items = [prep(x) for x in arr]
|
|
115
|
+
if not any(isinstance(x, (dict, list, tuple)) for x in items):
|
|
116
|
+
out.append(path + "[" + mark + "]: " + sep.join(map(cell, items)))
|
|
117
|
+
return
|
|
118
|
+
if tabular and table(path, items):
|
|
119
|
+
return
|
|
120
|
+
for n, x in enumerate(items):
|
|
121
|
+
visit(path + "[" + str(n) + "]", x)
|
|
122
|
+
|
|
123
|
+
def taken(v: Any, path: str, claimed: dict[int, tuple[str, Any]]) -> bool:
|
|
124
|
+
"""True if ``v`` can't go in a table row: it is on the current path (a cycle), or with refs, it was already
|
|
125
|
+
written or appears twice in this table. Otherwise, with refs, claims it for ``path``."""
|
|
126
|
+
i = id(v)
|
|
127
|
+
if i in stack:
|
|
128
|
+
return True
|
|
129
|
+
if seen is None:
|
|
130
|
+
return False
|
|
131
|
+
if i in claimed or i in seen:
|
|
132
|
+
return True
|
|
133
|
+
claimed[i] = (path, v)
|
|
134
|
+
return False
|
|
135
|
+
|
|
136
|
+
def flatten_row(
|
|
137
|
+
obj: dict[Any, Any], prefix: str, at: str, keys: list[str], vals: list[Any], claimed: dict[int, tuple[str, Any]]
|
|
138
|
+
) -> bool:
|
|
139
|
+
"""Collects the leaf paths and values of one row. False if the row can't be a table row."""
|
|
140
|
+
if not obj:
|
|
141
|
+
return False
|
|
142
|
+
for k, child in obj.items():
|
|
143
|
+
v = prep(child)
|
|
144
|
+
p = join_key(prefix, _key(k))
|
|
145
|
+
if isinstance(v, (dict, list, tuple)):
|
|
146
|
+
if not isinstance(v, dict) or taken(v, at + "." + p, claimed):
|
|
147
|
+
return False
|
|
148
|
+
stack.add(id(v))
|
|
149
|
+
ok = flatten_row(v, p, at, keys, vals, claimed)
|
|
150
|
+
stack.discard(id(v))
|
|
151
|
+
if not ok:
|
|
152
|
+
return False
|
|
153
|
+
else:
|
|
154
|
+
keys.append(p)
|
|
155
|
+
vals.append(v)
|
|
156
|
+
return True
|
|
157
|
+
|
|
158
|
+
def table(path: str, items: list[Any]) -> bool:
|
|
159
|
+
"""Writes same-shaped dicts as ``path[n]{a,b.c}:`` plus one row each."""
|
|
160
|
+
header: list[str] | None = None
|
|
161
|
+
rows: list[str] = []
|
|
162
|
+
claimed: dict[int, tuple[str, Any]] = {}
|
|
163
|
+
for n, item in enumerate(items):
|
|
164
|
+
at = path + "[" + str(n) + "]"
|
|
165
|
+
if not isinstance(item, dict) or taken(item, at, claimed):
|
|
166
|
+
return False
|
|
167
|
+
keys: list[str] = []
|
|
168
|
+
vals: list[Any] = []
|
|
169
|
+
stack.add(id(item))
|
|
170
|
+
ok = flatten_row(item, "", at, keys, vals, claimed)
|
|
171
|
+
stack.discard(id(item))
|
|
172
|
+
if not ok:
|
|
173
|
+
return False
|
|
174
|
+
if header is None:
|
|
175
|
+
header = keys
|
|
176
|
+
elif keys != header:
|
|
177
|
+
return False
|
|
178
|
+
rows.append(pad + sep.join(map(cell, vals)))
|
|
179
|
+
if seen is not None:
|
|
180
|
+
seen.update(claimed)
|
|
181
|
+
assert header is not None
|
|
182
|
+
out.append(path + "[" + str(len(items)) + mark + "]{" + delimiter.join(header) + "}:")
|
|
183
|
+
out.extend(rows)
|
|
184
|
+
return True
|
|
185
|
+
|
|
186
|
+
visit("", prep(value))
|
|
187
|
+
return "\n".join(out)
|
flattype/_flatten.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Path -> leaf dicts for in-memory use: ``{"user.name": "John", "tags[0]": "a"}``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ._path import assign, parse_path
|
|
9
|
+
from ._syntax import FlatDecodeError, join_key
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def flatten(value: Any) -> dict[str, Any]:
|
|
13
|
+
"""Flattens a value into a path -> leaf dict, e.g. ``{"user": {"name": "John"}}`` -> ``{"user.name": "John"}``.
|
|
14
|
+
|
|
15
|
+
Empty dicts and lists are kept as ``{}`` / ``[]`` leaves so :func:`unflatten` can restore them. A scalar root is
|
|
16
|
+
stored under the empty path ``""``.
|
|
17
|
+
"""
|
|
18
|
+
out: dict[str, Any] = {}
|
|
19
|
+
stack: set[int] = set()
|
|
20
|
+
|
|
21
|
+
def visit(path: str, v: Any) -> None:
|
|
22
|
+
if not isinstance(v, (dict, list, tuple)):
|
|
23
|
+
if not isinstance(v, (str, int, float, type(None))):
|
|
24
|
+
raise TypeError(f"Object of type {type(v).__name__} is not Flat Notation serializable")
|
|
25
|
+
out[path] = None if isinstance(v, float) and not math.isfinite(v) else v
|
|
26
|
+
return
|
|
27
|
+
if id(v) in stack:
|
|
28
|
+
raise ValueError("Circular reference detected")
|
|
29
|
+
stack.add(id(v))
|
|
30
|
+
if isinstance(v, dict):
|
|
31
|
+
if not v and path != "":
|
|
32
|
+
out[path] = {}
|
|
33
|
+
for k, x in v.items():
|
|
34
|
+
visit(join_key(path, str(k)), x)
|
|
35
|
+
else:
|
|
36
|
+
if not v:
|
|
37
|
+
out[path] = []
|
|
38
|
+
for i, x in enumerate(v):
|
|
39
|
+
visit(f"{path}[{i}]", x)
|
|
40
|
+
stack.discard(id(v))
|
|
41
|
+
|
|
42
|
+
visit("", value)
|
|
43
|
+
return out
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def unflatten(record: dict[str, Any]) -> Any:
|
|
47
|
+
"""Rebuilds the value :func:`flatten` produced. Raises :class:`FlatDecodeError` on a malformed or conflicting path."""
|
|
48
|
+
holder: dict[str, Any] = {}
|
|
49
|
+
for n, path in enumerate(record, 1):
|
|
50
|
+
segments, terminal, end = parse_path(path, 0, n)
|
|
51
|
+
if terminal[0] != "value" or end != len(path):
|
|
52
|
+
raise FlatDecodeError(f"Invalid path '{path}'", n, end + 1)
|
|
53
|
+
leaf = record[path]
|
|
54
|
+
assign(holder, segments, [] if isinstance(leaf, list) else {} if isinstance(leaf, dict) else leaf, True, n)
|
|
55
|
+
return holder.get("root", {})
|