python-toon-parser 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,78 @@
1
+ Metadata-Version: 2.4
2
+ Name: python-toon-parser
3
+ Version: 0.1.1
4
+ Summary: TOON (Token-Oriented Object Notation) serializer & parser for Python
5
+ Author-email: Akash Wankhede <akash.wankhede.pune@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/akash1551/pytoon
8
+ Project-URL: Source, https://github.com/akash1551/pytoon
9
+ Project-URL: Issues, https://github.com/akash1551/pytoon/issues
10
+ Keywords: toon,serialization,parser,format,llm,data
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.8
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Topic :: Software Development :: Libraries
18
+ Classifier: License :: OSI Approved :: MIT License
19
+ Requires-Python: >=3.8
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Dynamic: license-file
23
+
24
+ # python-toon-parser
25
+
26
+ ![Build Status](https://img.shields.io/badge/build-passing-brightgreen)
27
+ ![Version](https://img.shields.io/badge/pypi-v0.1.0-blue)
28
+ ![License](https://img.shields.io/badge/license-MIT-green)
29
+
30
+ `python-toon-parser` — **TOON** (**T**oken-**O**riented **O**bject **N**otation) serializer and parser for Python.
31
+
32
+ ## Installation
33
+
34
+ ```bash
35
+ pip install python-toon-parser
36
+ ```
37
+
38
+ ## Features
39
+
40
+ - **Human-Readable**: Minimal syntax, similar to YAML but distinct.
41
+ - **Round-Trip**: `dumps(obj)` -> `loads(text)` preserves structure.
42
+ - **Compact Tables**: Automatically detects lists of uniform objects and formats them as compact tables.
43
+ - **Broad Support**: Handles `dict`, `list`, `tuple`, `set`, `dataclasses`, `namedtuples`, and simple objects.
44
+
45
+ ## Quickstart
46
+
47
+ ```python
48
+ from pytoon import dumps, loads
49
+
50
+ data = {"items": [{"id":1,"name":"A"}, {"id":2,"name":"B"}]}
51
+
52
+ # Serialize
53
+ s = dumps(data)
54
+ print(s)
55
+
56
+ # Parse back
57
+ obj = loads(s)
58
+ print(obj)
59
+ ```
60
+
61
+ **Output:**
62
+ ```yaml
63
+ items[2]{id,name}:
64
+ 1,A
65
+ 2,B
66
+ ```
67
+
68
+ ## API Reference
69
+
70
+ ### `dumps(obj, name=None, indent=0) -> str`
71
+ Serializes a Python object to a TOON string.
72
+ - `obj`: The object to serialize.
73
+ - `name`: (Optional) Root key name for the object.
74
+ - `indent`: (Optional) Starting indentation level (default 0).
75
+
76
+ ### `loads(toon_str) -> Any`
77
+ Parses a TOON string back into Python objects.
78
+ - Returns `dict`, `list`, or primitive depending on the input.
@@ -0,0 +1,8 @@
1
+ python_toon_parser-0.1.1.dist-info/licenses/LICENSE,sha256=Kzx0yVlLheJrcJIhhqsvCdI84pej78BMKzym0hIKmC0,1071
2
+ pytoon/__init__.py,sha256=Em5z8MCeyTy6I2MWtxrpfT9MGRWJFxWCzyOIM2Kzu0s,336
3
+ pytoon/__version__.py,sha256=8oAxKUG747GUokmxjkrWejyJa5yPNEsoJDlXxoedxTw,21
4
+ pytoon/toon.py,sha256=NLrmHY0R1InqAMeB7-pMqXWCOBHcAhI-Ozx0bwsMOzM,18527
5
+ python_toon_parser-0.1.1.dist-info/METADATA,sha256=BcHa9ZJYAFOhf_xXn5Yr6_lon1R0uOu9JbMIWgjMDl0,2359
6
+ python_toon_parser-0.1.1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
7
+ python_toon_parser-0.1.1.dist-info/top_level.txt,sha256=Gx16dVJRwYM-e4975VT8oorG4pfvAfdovdu_HZnpmIk,7
8
+ python_toon_parser-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.10.2)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Akash Wankhede
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ pytoon
pytoon/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ """
2
+ pytoon package - TOON (Token-Oriented Object Notation) for Python.
3
+ Expose dumps / loads API and package version.
4
+ """
5
+
6
+ from .toon import dumps, loads
7
+
8
+ try:
9
+ # populated by setuptools_scm at build time
10
+ from __version__ import __version__
11
+ except Exception:
12
+ __version__ = "0.0.0"
13
+
14
+ __all__ = ["dumps", "loads", "__version__"]
pytoon/__version__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.1"
pytoon/toon.py ADDED
@@ -0,0 +1,529 @@
1
+ """
2
+ toon.py
3
+
4
+ A self-contained, practical Python implementation of TOON (Token-Oriented Object Notation)
5
+ with a JSON-like API: `dumps(obj)` and `loads(toon_str)`.
6
+
7
+ - dumps(obj) -> str : Serialize common Python objects into TOON.
8
+ - loads(toon_str) -> Any : Parse TOON produced by dumps back into Python types.
9
+
10
+ This is a best-effort, usable implementation focused on round-tripping dumps -> loads for
11
+ typical Python objects: primitives, dicts, lists/tuples/sets, dataclasses/namedtuples and
12
+ uniform-table arrays. It is not a complete spec implementation, but is practical and easy
13
+ to extend.
14
+
15
+ Usage:
16
+ from toon import dumps, loads
17
+ s = dumps(my_obj)
18
+ obj = loads(s)
19
+ """
20
+
21
+ from dataclasses import is_dataclass, asdict
22
+ import json
23
+ import inspect
24
+ from typing import Any, List, Tuple, Dict
25
+
26
+ INDENT_STR = " " # two spaces per level (modifiable if desired)
27
+
28
+
29
+ # -------------------- Helpers for serialization --------------------
30
+
31
+
32
+ def _is_primitive(x):
33
+ return x is None or isinstance(x, (bool, int, float, str))
34
+
35
+
36
+ def _to_toon_primitive(x):
37
+ """Return a TOON-safe string for a primitive."""
38
+ if x is None:
39
+ return "null"
40
+ if isinstance(x, bool):
41
+ return "true" if x else "false"
42
+ if isinstance(x, (int, float)):
43
+ return str(x)
44
+ s = str(x)
45
+ if s == "":
46
+ return '""'
47
+ needs_quote = (
48
+ any(ch in s for ch in [",", "\n", "\r"])
49
+ or s[0].isspace()
50
+ or s[-1].isspace()
51
+ or s.startswith(('"', "'"))
52
+ or s.lower() in ("null", "true", "false")
53
+ )
54
+ if needs_quote:
55
+ return json.dumps(s, ensure_ascii=False)
56
+ return s
57
+
58
+
59
+ def _escape_header_key(k: str) -> str:
60
+ """Make a key safe for header usage; quote if it's not a simple identifier."""
61
+ if isinstance(k, str) and k.isidentifier():
62
+ return k
63
+ return json.dumps(str(k), ensure_ascii=False)
64
+
65
+
66
+ def _is_namedtuple_instance(x):
67
+ return isinstance(x, tuple) and hasattr(x, "_fields")
68
+
69
+
70
+ def _object_to_dict(obj):
71
+ """Convert dataclass/namedtuple/object to dict for serialization."""
72
+ if is_dataclass(obj):
73
+ return asdict(obj)
74
+ if _is_namedtuple_instance(obj):
75
+ return obj._asdict()
76
+ if hasattr(obj, "__dict__"):
77
+ return {
78
+ k: v
79
+ for k, v in vars(obj).items()
80
+ if not k.startswith("_") and not inspect.isroutine(v)
81
+ }
82
+ # fallback
83
+ return {"value": repr(obj)}
84
+
85
+
86
+ def _all_dicts_uniform(list_of_dicts: List[dict]) -> Tuple[bool, List[str] or None]:
87
+ """
88
+ Return (is_uniform, keys_order).
89
+ Uniform means every element is a dict and they all have the same set of keys.
90
+ If insertion order is consistent across elements, return that order; otherwise return sorted keys.
91
+ """
92
+ if not list_of_dicts:
93
+ return False, None
94
+ if not all(isinstance(item, dict) for item in list_of_dicts):
95
+ return False, None
96
+ sets = [set(d.keys()) for d in list_of_dicts]
97
+ first_set = sets[0]
98
+ if all(s == first_set for s in sets):
99
+ first_keys = list(list_of_dicts[0].keys())
100
+ if all(tuple(d.keys()) == tuple(first_keys) for d in list_of_dicts):
101
+ return True, first_keys
102
+ return True, sorted(first_set)
103
+ return False, None
104
+
105
+
106
+ # -------------------- Serialization: dumps --------------------
107
+
108
+
109
+ def dumps(obj: Any, name: str = None, indent: int = 0) -> str:
110
+ """
111
+ Serialize Python object into TOON. `name` is optional and produces a top-level key.
112
+ indent counts indentation levels (0 == top).
113
+ """
114
+ pad = INDENT_STR * indent
115
+
116
+ # primitives
117
+ if _is_primitive(obj):
118
+ val = _to_toon_primitive(obj)
119
+ if name:
120
+ return f"{pad}{name}: {val}\n"
121
+ return f"{pad}{val}\n"
122
+
123
+ # dataclass / namedtuple => dict
124
+ if _is_namedtuple_instance(obj) or is_dataclass(obj):
125
+ obj = _object_to_dict(obj)
126
+
127
+ # dict
128
+ if isinstance(obj, dict):
129
+ lines = []
130
+ if name:
131
+ lines.append(f"{pad}{name}:")
132
+ child_pad = INDENT_STR * (indent + 1)
133
+ else:
134
+ child_pad = pad
135
+ # explicit empty dict representation: return a '{}' line so parser can detect it
136
+ if not obj:
137
+ if name:
138
+ return f"{pad}{name}:\n{child_pad}{{}}\n"
139
+ return f"{pad}{{}}\n"
140
+ for k, v in obj.items():
141
+ key = _escape_header_key(k)
142
+ if _is_primitive(v):
143
+ lines.append(f"{child_pad}{key}: {_to_toon_primitive(v)}")
144
+ else:
145
+ lines.append(f"{child_pad}{key}:")
146
+ # use one-level deeper indentation for nested content
147
+ lines.append(dumps(v, name=None, indent=indent + 1).rstrip("\n"))
148
+ return "\n".join(lines) + ("\n" if lines else "")
149
+
150
+ # list / tuple / set
151
+ if isinstance(obj, (list, tuple, set)):
152
+ if isinstance(obj, set):
153
+ try:
154
+ lst = sorted(list(obj))
155
+ except Exception:
156
+ lst = list(obj)
157
+ else:
158
+ lst = list(obj)
159
+ n = len(lst)
160
+ if n == 0:
161
+ return f"{pad}{name}[0]:\n" if name else f"{pad}[]\n"
162
+
163
+ uniform, keys = _all_dicts_uniform(lst)
164
+ # compact table if uniform dicts and all primitive values
165
+ if (
166
+ uniform
167
+ and keys
168
+ and all(
169
+ _is_primitive(v)
170
+ for d in lst
171
+ for v in (d.values() if isinstance(d, dict) else [])
172
+ )
173
+ ):
174
+ keys_escaped = ",".join(_escape_header_key(k) for k in keys)
175
+ header = (
176
+ f"{pad}{name}[{n}]{{{keys_escaped}}}:"
177
+ if name
178
+ else f"{pad}[{n}]{{{keys_escaped}}}:"
179
+ )
180
+ lines = [header]
181
+ for d in lst:
182
+ row = ",".join(_to_toon_primitive(d.get(k)) for k in keys)
183
+ lines.append(f"{pad}{INDENT_STR}{row}")
184
+ return "\n".join(lines) + "\n"
185
+
186
+ # all primitives => single-line comma list (if named) or a simple "- ..." block
187
+ if all(_is_primitive(x) for x in lst):
188
+ vals = ",".join(_to_toon_primitive(x) for x in lst)
189
+ if name:
190
+ return f"{pad}{name}[{n}]: {vals}\n"
191
+ # unnamed list of primitives as a single "- ..." line (useful for readability)
192
+ return f"{pad}- " + ", ".join(_to_toon_primitive(x) for x in lst) + "\n"
193
+
194
+ # mixed/complex items => list block with '-' markers; nested items are indented one level deeper
195
+ lines = []
196
+ if name:
197
+ lines.append(f"{pad}{name}[{n}]:")
198
+ item_indent_str = pad + INDENT_STR
199
+ nested_indent = indent + 2
200
+ else:
201
+ item_indent_str = pad
202
+ nested_indent = indent + 1
203
+
204
+ for item in lst:
205
+ if _is_primitive(item):
206
+ lines.append(f"{item_indent_str}- {_to_toon_primitive(item)}")
207
+ else:
208
+ # migrate dataclass/namedtuple -> dict first
209
+ if _is_namedtuple_instance(item) or is_dataclass(item):
210
+ item = _object_to_dict(item)
211
+ # list item header
212
+ lines.append(f"{item_indent_str}-")
213
+ # nested item content at indent+2 (one level deeper than the '-' line)
214
+ lines.append(dumps(item, name=None, indent=nested_indent).rstrip("\n"))
215
+ return "\n".join(lines) + "\n"
216
+
217
+ # fallback for objects: try to convert to dict and include class name
218
+ try:
219
+ obj_dict = _object_to_dict(obj)
220
+ return dumps(obj_dict, name=name, indent=indent)
221
+ except Exception:
222
+ val = _to_toon_primitive(repr(obj))
223
+ if name:
224
+ return f"{pad}{name}: {val}\n"
225
+ return f"{pad}{val}\n"
226
+
227
+
228
+ # -------------------- Parsing: loads --------------------
229
+
230
+
231
+ def _parse_primitive_token(tok: str):
232
+ tok = tok.strip()
233
+ if tok == "":
234
+ return ""
235
+ if tok == "null":
236
+ return None
237
+ if tok == "true":
238
+ return True
239
+ if tok == "false":
240
+ return False
241
+ # JSON quoted string
242
+ if (tok.startswith('"') and tok.endswith('"')) or (
243
+ tok.startswith("'") and tok.endswith("'")
244
+ ):
245
+ try:
246
+ return json.loads(tok)
247
+ except Exception:
248
+ return tok[1:-1]
249
+ # try int then float (float handles nan/inf and exponent forms)
250
+ try:
251
+ # attempt exact int parsing first
252
+ try:
253
+ return int(tok)
254
+ except Exception:
255
+ # fall back to float parsing (accepts 'nan', 'inf', '1e3', etc.)
256
+ return float(tok)
257
+ except Exception:
258
+ return tok
259
+
260
+
261
+ def _count_leading_indent(s: str) -> int:
262
+ """Count how many INDENT_STR are at the start of s."""
263
+ count = 0
264
+ while s.startswith(INDENT_STR):
265
+ count += 1
266
+ s = s[len(INDENT_STR) :]
267
+ return count
268
+
269
+
270
+ def _split_csv_like(s: str) -> List[str]:
271
+ """
272
+ Split comma-separated tokens but respect quoted substrings.
273
+ Returns list of tokens (whitespace preserved trimmed).
274
+ """
275
+ parts = []
276
+ cur = ""
277
+ in_q = False
278
+ qchar = None
279
+ i = 0
280
+ while i < len(s):
281
+ ch = s[i]
282
+ if ch in ('"', "'"):
283
+ if not in_q:
284
+ in_q = True
285
+ qchar = ch
286
+ cur += ch
287
+ elif qchar == ch:
288
+ in_q = False
289
+ cur += ch
290
+ else:
291
+ cur += ch
292
+ elif ch == "," and not in_q:
293
+ parts.append(cur.strip())
294
+ cur = ""
295
+ else:
296
+ cur += ch
297
+ i += 1
298
+ if cur.strip() != "":
299
+ parts.append(cur.strip())
300
+ return parts
301
+
302
+
303
+ def loads(toon_str: str) -> Any:
304
+ """
305
+ Parse a TOON string (created by dumps) back into Python objects (dict/list/primitives).
306
+ This parser is intentionally aligned to the dumps() format above for reliable round-trips.
307
+ """
308
+ # Split and filter out blank lines but preserve structural indentation
309
+ raw_lines = toon_str.splitlines()
310
+ lines = []
311
+ for ln in raw_lines:
312
+ # keep lines that are not empty after stripping spaces (but preserve indentation)
313
+ if ln.strip() == "":
314
+ continue
315
+ lines.append(ln.rstrip("\n"))
316
+
317
+ # Preprocess into (indent_level, content) tuples
318
+ processed: List[Tuple[int, str]] = []
319
+ for ln in lines:
320
+ indent = _count_leading_indent(ln)
321
+ content = ln[indent * len(INDENT_STR) :]
322
+ processed.append((indent, content))
323
+
324
+ idx = 0
325
+ N = len(processed)
326
+
327
+ def parse_block(expected_indent: int):
328
+ nonlocal idx
329
+ result: Dict[str, Any] = {}
330
+ arr_mode = None # if we encounter '-' list items, we build a list
331
+
332
+ while idx < N:
333
+ indent, content = processed[idx]
334
+ if indent < expected_indent:
335
+ break
336
+ if indent > expected_indent:
337
+ # Deeper indentation than expected: this should be consumed by caller.
338
+ break
339
+
340
+ # Handle explicit empty list "[]"
341
+ if content.strip() == "[]":
342
+ idx += 1
343
+ if arr_mode is None and not result:
344
+ return []
345
+ continue
346
+ # Handle explicit empty dict "{}"
347
+ if content.strip() == "{}":
348
+ idx += 1
349
+ if arr_mode is None and not result:
350
+ return {}
351
+ continue
352
+
353
+ # Handle list item lines: "- value" or "-" (then nested)
354
+ if content.startswith("- ") or content == "-":
355
+ if arr_mode is None:
356
+ arr_mode = []
357
+ # consume the line
358
+ idx += 1
359
+ if content == "-":
360
+ # nested block follows with indent > current indent
361
+ if idx < N and processed[idx][0] > indent:
362
+ item = parse_block(indent + 1)
363
+ arr_mode.append(item)
364
+ else:
365
+ arr_mode.append(None)
366
+ else:
367
+ val_tok = content[2:].strip()
368
+ # if comma-separated tokens (e.g. "- a, b, c") we append each as separate primitives
369
+ if "," in val_tok and not (
370
+ val_tok.startswith('"') or val_tok.startswith("'")
371
+ ):
372
+ parts = _split_csv_like(val_tok)
373
+ for p in parts:
374
+ arr_mode.append(_parse_primitive_token(p))
375
+ else:
376
+ arr_mode.append(_parse_primitive_token(val_tok))
377
+ continue
378
+
379
+ # otherwise handle "key: value" or "key:" or "name[N]{...}:" or "name[N]: v1,v2" forms
380
+ # find colon not inside quotes
381
+ colon_pos = None
382
+ in_q = False
383
+ qch = None
384
+ for i, ch in enumerate(content):
385
+ if ch in ('"', "'"):
386
+ if not in_q:
387
+ in_q = True
388
+ qch = ch
389
+ elif qch == ch:
390
+ in_q = False
391
+ if ch == ":" and not in_q:
392
+ colon_pos = i
393
+ break
394
+
395
+ if colon_pos is None:
396
+ # No key: treat as a primitive-only line (top-level primitive or inline primitive)
397
+ val = _parse_primitive_token(content)
398
+ idx += 1
399
+ if arr_mode is None and not result:
400
+ return val
401
+ if arr_mode is not None:
402
+ arr_mode.append(val)
403
+ continue
404
+ # otherwise skip stray primitive
405
+ continue
406
+
407
+ key_part = content[:colon_pos].strip()
408
+ val_part = content[colon_pos + 1 :].strip()
409
+ idx += 1
410
+
411
+ # If val_part is empty -> either table header like name[N]{...}: OR nested block key:
412
+ if val_part == "":
413
+ # Table-style header?
414
+ if (
415
+ "[" in key_part
416
+ and "]" in key_part
417
+ and "{" in key_part
418
+ and "}" in key_part
419
+ ):
420
+ # parse header: name[NN]{k1,k2} (name may be empty if not provided)
421
+ name_section = key_part.split("[", 1)[0].strip()
422
+ # extract keys inside {}
423
+ try:
424
+ br_start = key_part.index("[")
425
+ br_end = key_part.index("]", br_start)
426
+ keys_start = key_part.index("{", br_end)
427
+ keys_end = key_part.index("}", keys_start)
428
+ n_section = key_part[br_start + 1 : br_end]
429
+ keys_str = key_part[keys_start + 1 : keys_end]
430
+ keys = [
431
+ k.strip().strip('"').strip("'")
432
+ for k in keys_str.split(",")
433
+ if k.strip() != ""
434
+ ]
435
+ except Exception:
436
+ keys = []
437
+ # collect rows at indent == expected_indent + 1
438
+ rows = []
439
+ while idx < N and processed[idx][0] == expected_indent + 1:
440
+ _, row_content = processed[idx]
441
+ parts = _split_csv_like(row_content.strip())
442
+ row = {}
443
+ for k, vtok in zip(keys, parts):
444
+ row[k] = _parse_primitive_token(vtok)
445
+ rows.append(row)
446
+ idx += 1
447
+ if name_section == "":
448
+ # anonymous table -> return as list? put under special key
449
+ # but to be consistent, put under "_table" with rows
450
+ result_key = "_table"
451
+ else:
452
+ result_key = name_section
453
+ result[result_key] = rows
454
+ continue
455
+ else:
456
+ # Check for list header: name[N]
457
+ real_key = key_part
458
+ is_list_header = False
459
+ if "[" in key_part and key_part.endswith("]"):
460
+ try:
461
+ name_part, rest = key_part.split("[", 1)
462
+ if rest.endswith("]"):
463
+ real_key = name_part.strip()
464
+ is_list_header = True
465
+ except ValueError:
466
+ pass
467
+
468
+ # nested block "key:" -> parse a nested block at indent+1
469
+ nested = parse_block(expected_indent + 1)
470
+
471
+ if is_list_header and nested == {}:
472
+ nested = []
473
+
474
+ result[real_key] = nested
475
+ continue
476
+ else:
477
+ # inline value exists after colon.
478
+ # handle name[N]: v1,v2 (array inline)
479
+ if "[" in key_part and "]" in key_part:
480
+ name = key_part.split("[", 1)[0].strip()
481
+ parts = _split_csv_like(val_part)
482
+ vals = [_parse_primitive_token(p) for p in parts if p != ""]
483
+ result[name] = vals
484
+ continue
485
+ # regular "key: value"
486
+ result[key_part] = _parse_primitive_token(val_part)
487
+ continue
488
+
489
+ if arr_mode is not None:
490
+ return arr_mode
491
+
492
+ # Unwrap anonymous table if it's the only thing
493
+ if len(result) == 1 and "_table" in result:
494
+ return result["_table"]
495
+
496
+ return result
497
+
498
+ # Start parse from top-level indent 0
499
+ idx = 0
500
+ parsed = parse_block(0)
501
+ return parsed
502
+
503
+
504
+ # -------------------- Quick demo when run as script --------------------
505
+ if __name__ == "__main__":
506
+ from collections import namedtuple
507
+
508
+ Person = namedtuple("Person", ["id", "name", "role"])
509
+ p1 = Person(1, "Alice", "admin")
510
+ p2 = Person(2, "Bob", "user")
511
+
512
+ example = {
513
+ "context": {"task": "Roundtrip demo", "season": "spring_2025"},
514
+ "friends": ["ana", "luis", "sam"],
515
+ "hikes": [
516
+ {"id": 1, "name": "Blue Lake Trail", "distanceKm": 7.5},
517
+ {"id": 2, "name": "Ridge, Overlook", "distanceKm": 9.2},
518
+ ],
519
+ "people": [p1, p2],
520
+ "misc": (1, None, "two"),
521
+ "empty_list": [],
522
+ }
523
+
524
+ s = dumps(example)
525
+ print("=== TOON ===")
526
+ print(s)
527
+ print("=== PARSED BACK ===")
528
+ parsed = loads(s)
529
+ print(parsed)