jsonfold 0.1.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.
@@ -0,0 +1,3 @@
1
+ Metadata-Version: 2.4
2
+ Name: jsonfold
3
+ Version: 0.1.0
@@ -0,0 +1,5 @@
1
+ jsonfold.py,sha256=RoVJet3m35oTe0phaaD4FDSmoyBaViWQzBQn6ylC1l4,25228
2
+ jsonfold-0.1.0.dist-info/METADATA,sha256=QUwsR9uX8c0oOSRVUCQWdbRJxLDfFJMZXPzyK1frUz4,52
3
+ jsonfold-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
4
+ jsonfold-0.1.0.dist-info/top_level.txt,sha256=s1z9jUWqRFejaz5NlLTlnsUZj1wW_cKF3xHFX5_kbfE,9
5
+ jsonfold-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ jsonfold
jsonfold.py ADDED
@@ -0,0 +1,830 @@
1
+ #!/usr/bin/env python3
2
+ """jsonfold.py - hybrid pretty/compact JSON output.
3
+
4
+ jsonfold wraps Python's standard json.dump/json.dumps output and keeps the
5
+ normal pretty-printed structure, but selectively compacts small containers and
6
+ runs of scalar items when they fit within a configured line width.
7
+
8
+ The goal is readable JSON:
9
+ - large or complex structures stay expanded;
10
+ - small lists and objects can stay on one line;
11
+ - adjacent scalar items can be packed together;
12
+ - nested folding is controlled by explicit depth limits.
13
+
14
+ Public API
15
+ ----------
16
+ dump(obj, fp, *, compact="", indent=2, **kwargs)
17
+ Serialize obj to fp using json.dump(), then fold the generated stream.
18
+
19
+ dumps(obj, *, compact="", indent=2, **kwargs) -> str
20
+ Return the folded JSON as a string.
21
+
22
+ JSONFold(...)
23
+ Immutable configuration object controlling width, packing, and folding.
24
+
25
+ JSONFoldWriter(fp, *, compact="")
26
+ File-like wrapper used internally by dump(), but also usable directly
27
+ with json.dump(obj, JSONFoldWriter(fp, compact=cfg), indent=2).
28
+
29
+ Configuration
30
+ -------------
31
+ width
32
+ Maximum target line width. Lines are only packed/folded when the result
33
+ fits within this width.
34
+
35
+ pack_array_items / pack_obj_items
36
+ Maximum number of scalar list items or object properties that may be
37
+ packed onto one physical line.
38
+
39
+ pack_nesting
40
+ Maximum container depth where scalar packing is allowed.
41
+
42
+ fold_array_items / fold_obj_items
43
+ Maximum number of items/properties allowed when folding a container
44
+ onto one line.
45
+
46
+ fold_nesting
47
+ Maximum nested-container depth allowed in a folded line.
48
+
49
+ Presets
50
+ -------
51
+ "default" (also "")
52
+ Balanced default settings.
53
+ Up to 8 array elements, up to 4 key/value pairs, max nesting = 1
54
+
55
+ "none"
56
+ Disable all packing and folding.
57
+
58
+ "low":
59
+ Same as default, No nested structures in fold/join
60
+
61
+ "med":
62
+ Same as default, No nested structures in "join"
63
+
64
+ "high":
65
+ aggressive setting. Up to 16 array elements, up to key/value pairs, max nesting = 2
66
+
67
+ "max"
68
+ Enable aggressive packing and folding, still subject to width.
69
+
70
+ "pack"
71
+ Enable packing only; disable folding.
72
+
73
+ "fold"
74
+ Enable folding only; disable packing.
75
+
76
+ "join"
77
+ Enable folding and joining.
78
+
79
+ Algorithm
80
+ ---------
81
+ The writer receives the tokenized line stream produced by json.dump(...,
82
+ indent=N). It does not re-parse full JSON. Instead, it tracks pretty-printed
83
+ lines and container frames.
84
+
85
+ Phase 1: Pack
86
+ Consecutive scalar lines inside the same container may be joined onto one
87
+ output line, subject to:
88
+ - width limit,
89
+ - item limit,
90
+ - nesting limit,
91
+ - same indentation level.
92
+
93
+ Phase 2: Fold
94
+ A container may be collapsed from:
95
+
96
+ [
97
+ 1, 2, 3
98
+ ]
99
+
100
+ into:
101
+
102
+ [ 1, 2, 3 ]
103
+
104
+ only when it has exactly one content line after packing, and the folded
105
+ result fits within the configured limits.
106
+
107
+ Phase 3: Join
108
+ Repeat the pack step, allowing folded lines to be joined with scalar items
109
+ or other folded lines. subject to same limits
110
+
111
+ A container may be collapsed from:
112
+ [
113
+ [ 1, 2, 3],
114
+ [ 4, 5, 6]
115
+ ]
116
+
117
+ into:
118
+ [ [ 1, 2, 3], [4, 5, 6] ]
119
+
120
+ -
121
+ Consecutive scalar lines inside the same container may be joined onto one
122
+ output line, subject to:
123
+ - width limit,
124
+ - item limit,
125
+ - nesting limit,
126
+ - same indentation level.
127
+
128
+
129
+
130
+ Streaming behavior
131
+ ------------------
132
+ The implementation is designed as a streaming filter around json.dump().
133
+ It buffers only the currently open container frames needed to decide whether
134
+ packing/folding is still possible. Once a frame can no longer fold, older lines
135
+ are streamed forward.
136
+
137
+ Limitations
138
+ -----------
139
+ - Input must be normal json.dump(..., indent=N) style output.
140
+ - The filter assumes standard JSON syntax emitted by Python's json module.
141
+ - It is a formatting filter, not a validating JSON parser.
142
+ - Folding decisions are based on physical line structure, indentation,
143
+ item counts, nesting limits, and width.
144
+
145
+ Example
146
+ -------
147
+ from jsonfold import dumps, JSONFold
148
+
149
+ data = {
150
+ "ids": [1, 2, 3, 4],
151
+ "meta": {"version": 1, "ok": True},
152
+ }
153
+
154
+ print(dumps(data, compact=JSONFold(width=80)))
155
+
156
+ CLI
157
+ ---
158
+ python jsonfold.py < input.json
159
+ python jsonfold.py --compact=max --width=100 < input.json
160
+ python jsonfold.py --pack-items=20 --fold-items=8 < input.json
161
+ """
162
+
163
+ from __future__ import annotations
164
+
165
+ import io
166
+ import json
167
+ import sys
168
+ from dataclasses import dataclass, KW_ONLY, replace, field
169
+ from typing import Any, TextIO
170
+ from enum import IntEnum, auto
171
+
172
+ # ---------------------------------------------------------------------------
173
+ # Configuration
174
+ # ---------------------------------------------------------------------------
175
+
176
+ MAX_ARRAY_ITEMS = 1000
177
+ MAX_OBJ_ITEMS = 1000
178
+ MAX_NESTING = 10
179
+
180
+ @dataclass(frozen=True)
181
+ class JSONFold:
182
+ """Configuration for hybrid pretty/compact JSON formatting.
183
+
184
+ A value of 0 disables the corresponding packing or folding rule.
185
+ Larger values allow more aggressive compaction, but all output remains
186
+ subject to the configured width limit.
187
+ """
188
+ width: int = 80
189
+ _: KW_ONLY
190
+ # Phase 1 – pack scalars N-per-line
191
+ pack_array_items: int = 8 # max scalars per line inside a list
192
+ pack_obj_items: int = 4 # max scalars per line inside a dict
193
+ pack_nesting: int = 1 # max container nesting depth for packing
194
+ # Phase 2 – fold single-content-line containers onto one line
195
+ fold_array_items: int = 8 # max items allowed in a folded list
196
+ fold_obj_items: int = 4 # max items allowed in a folded dict
197
+ fold_nesting: int = 1 # max container nesting depth for folding
198
+ # Phase 3 - merging folded lines.
199
+ join_array_items: int = 8
200
+ join_obj_items: int = 4
201
+ join_nesting: int = 1
202
+
203
+ JSONFold.NONE = JSONFold(
204
+ pack_array_items = 0,
205
+ pack_obj_items = 0,
206
+ pack_nesting = 0,
207
+ fold_array_items = 0,
208
+ fold_obj_items = 0,
209
+ fold_nesting = 0,
210
+ join_array_items = 0,
211
+ join_obj_items = 0,
212
+ join_nesting = 0,
213
+ )
214
+
215
+ JSONFold.DEFAULT = JSONFold()
216
+
217
+ JSONFold.PRESETS = {
218
+ "default": JSONFold.DEFAULT,
219
+ "": JSONFold.DEFAULT,
220
+ "none": JSONFold.NONE,
221
+
222
+ "low": replace(JSONFold.DEFAULT,
223
+ fold_nesting = 0,
224
+ join_nesting = 0,
225
+ ),
226
+
227
+ "med": replace(JSONFold.DEFAULT,
228
+ join_nesting = 0,
229
+ ),
230
+
231
+ "high": replace(JSONFold.DEFAULT,
232
+ pack_array_items = 16,
233
+ pack_obj_items = 8,
234
+ pack_nesting = 4,
235
+ fold_array_items = 16,
236
+ fold_obj_items = 8,
237
+ fold_nesting = 4,
238
+ join_array_items = 16,
239
+ join_obj_items = 8,
240
+ join_nesting = 2,
241
+ ),
242
+
243
+
244
+ "max": replace(JSONFold.NONE,
245
+ width = 255,
246
+ pack_array_items = MAX_ARRAY_ITEMS,
247
+ pack_obj_items = MAX_OBJ_ITEMS,
248
+ pack_nesting = MAX_NESTING,
249
+ fold_array_items = MAX_ARRAY_ITEMS,
250
+ fold_obj_items = MAX_OBJ_ITEMS,
251
+ fold_nesting = MAX_NESTING,
252
+ join_array_items = MAX_ARRAY_ITEMS,
253
+ join_obj_items = MAX_OBJ_ITEMS,
254
+ join_nesting = MAX_NESTING,
255
+ ),
256
+ # pack only – no folding
257
+ "pack": replace(JSONFold.NONE,
258
+ pack_array_items = MAX_ARRAY_ITEMS,
259
+ pack_obj_items = MAX_OBJ_ITEMS,
260
+ pack_nesting = MAX_NESTING,
261
+ ),
262
+ # fold only – no packing
263
+ "fold": replace(JSONFold.NONE,
264
+ fold_array_items = MAX_ARRAY_ITEMS,
265
+ fold_obj_items = MAX_OBJ_ITEMS,
266
+ fold_nesting = MAX_NESTING,
267
+ ),
268
+ "join": replace(JSONFold.NONE,
269
+ fold_array_items = MAX_ARRAY_ITEMS,
270
+ fold_obj_items = MAX_OBJ_ITEMS,
271
+ fold_nesting = MAX_NESTING,
272
+ join_array_items = MAX_ARRAY_ITEMS,
273
+ join_obj_items = MAX_OBJ_ITEMS,
274
+ join_nesting = MAX_NESTING,
275
+ ),
276
+
277
+ }
278
+
279
+
280
+ # ---------------------------------------------------------------------------
281
+ # Internal data structures
282
+ # ---------------------------------------------------------------------------
283
+
284
+ class Kind(IntEnum):
285
+ NONE = 0
286
+ DICT = auto()
287
+ LIST = auto()
288
+
289
+ _CLOSING_KIND: dict[str, Kind] = {
290
+ "}": Kind.DICT, "},": Kind.DICT,
291
+ "]": Kind.LIST, "],": Kind.LIST,
292
+ }
293
+
294
+ @dataclass
295
+ class Line:
296
+ indent: int
297
+ text: str
298
+ parent_kind: Kind = Kind.NONE # "dict", "list", or None
299
+ items: int = 1 # packed scalar count (>=1)
300
+ leafs: int = 1 # Total leaf items
301
+ # nesting of the deepest folded child within this line (-1 = scalar)
302
+ child_nesting: int = -1
303
+ opener: Kind = Kind.NONE
304
+ closer: Kind = Kind.NONE
305
+
306
+ @classmethod
307
+ def parse(cls, s: str, parent_kind: Kind) -> "Line":
308
+ stripped = s.lstrip()
309
+ body=stripped.rstrip()
310
+ opener= (
311
+ Kind.DICT if body.endswith("{")
312
+ else Kind.LIST if body.endswith("[")
313
+ else Kind.NONE
314
+ )
315
+ closer=_CLOSING_KIND.get(body, Kind.NONE)
316
+
317
+ return cls(indent=len(s) - len(stripped),
318
+ text=body,
319
+ parent_kind=parent_kind,
320
+ opener=opener,
321
+ closer=closer,
322
+ )
323
+
324
+ def raw(self) -> str:
325
+ return " " * self.indent + self.text + "\n"
326
+
327
+ def width(self) -> int:
328
+ return self.indent + len(self.text)
329
+
330
+ def is_joinable(self) -> bool:
331
+ return (
332
+ self.parent_kind
333
+ and not self.opener
334
+ and not self.closer
335
+ )
336
+
337
+ def is_packable(self) -> bool:
338
+ return (
339
+ self.child_nesting < 0
340
+ and self.is_joinable()
341
+ )
342
+
343
+
344
+ def join_line(self, other: Line) -> None:
345
+ self.text += " " + other.text
346
+ self.items += other.items
347
+ self.leafs += other.leafs
348
+ self.child_nesting = max(self.child_nesting, other.child_nesting)
349
+
350
+ @dataclass
351
+ class Frame:
352
+ kind: Kind
353
+ depth: int
354
+ lines: list[Line] = field(default_factory=list)
355
+
356
+ pack_limit: int = 0
357
+ fold_limit: int = 0
358
+ join_limit: int = 0
359
+
360
+ content_lines: int = 0
361
+ items: int = 0
362
+ leafs: int = 0
363
+
364
+ fold_ok: bool = True
365
+ child_nesting: int = -1
366
+
367
+ @dataclass
368
+ class JSONFoldStats:
369
+ bytes_in: int = 0
370
+ bytes_out: int = 0
371
+ lines_in: int = 0
372
+ lines_out: int = 0
373
+
374
+ class JSONFoldWriter:
375
+ """File-like wrapper that folds pretty-printed JSON as it is written.
376
+
377
+ JSONFoldWriter is intended to be passed to json.dump() as the output file.
378
+ It intercepts write() calls, reconstructs complete pretty-printed lines,
379
+ tracks open list/dict frames, and emits either the original lines or a
380
+ packed/folded equivalent.
381
+
382
+ Most callers should use dump() or dumps() instead of instantiating this
383
+ class directly.
384
+ """
385
+
386
+ def __init__(self, fp: TextIO, *,
387
+ compact: JSONFold | str = ""):
388
+ self.fp = fp
389
+ self.stats = JSONFoldStats()
390
+ if isinstance(compact, str):
391
+ compact = JSONFold.PRESETS[compact]
392
+ self.cfg = compact
393
+ self.pending = ""
394
+ self.stack: list[Frame] = []
395
+
396
+ # ------------------------------------------------------------------ I/O
397
+ def write(self, s: str) -> int:
398
+ self.stats.bytes_in += len(s)
399
+ self.stats.lines_in += s.count("\n")
400
+ if not self.cfg:
401
+ return self._write_str(s)
402
+
403
+ parts = s.splitlines(keepends=True)
404
+
405
+ if self.pending:
406
+ if parts:
407
+ parts[0] = self.pending + parts[0]
408
+ else:
409
+ parts = [self.pending]
410
+ self.pending = ""
411
+
412
+ if parts and not parts[-1].endswith("\n"):
413
+ self.pending = parts.pop()
414
+
415
+ for part in parts:
416
+ self._feed(Line.parse(part[:-1], self._parent_kind()))
417
+
418
+ if self.pending and len(self.pending.rstrip()) > self.cfg.width:
419
+ self._mark_no_fold()
420
+
421
+ return len(s)
422
+
423
+ def flush(self) -> None:
424
+ self.finish()
425
+ self.fp.flush()
426
+
427
+ def close(self) -> None:
428
+ self.finish()
429
+
430
+ def finish(self) -> None:
431
+ if self.pending:
432
+ self._feed(Line.parse(self.pending, self._parent_kind()))
433
+ self.pending = ""
434
+
435
+ # Should not happen with valid json.dump output.
436
+ # If it does, flush raw without further processing.
437
+ for frame in self.stack:
438
+ for line in frame.lines:
439
+ self._write_line(line)
440
+ self.stack.clear()
441
+
442
+
443
+ def __enter__(self) -> "JSONFoldWriter":
444
+ return self
445
+
446
+ def __exit__(self, exc_type, exc, tb) -> None:
447
+ self.finish()
448
+
449
+ def __getattr__(self, name: str) -> Any:
450
+ return getattr(self.fp, name)
451
+
452
+
453
+ def _write_str(self, s: str):
454
+ bytes = self.fp.write(s)
455
+ self.stats.lines_out += 1
456
+ self.stats.bytes_out += bytes
457
+ return bytes
458
+
459
+ def _write_line(self, line: Line):
460
+ self._write_str(line.raw())
461
+
462
+ # ------------------------------------------------------------ core feed
463
+
464
+ def _feed(self, line: Line) -> None:
465
+ opener = line.opener
466
+ if opener:
467
+ self.stack.append(Frame(
468
+ kind=opener,
469
+ depth=len(self.stack),
470
+ lines=[line],
471
+ pack_limit=self._pack_limit(opener),
472
+ fold_limit=self._fold_limit(opener),
473
+ join_limit=self._join_limit(opener),
474
+ )
475
+ )
476
+
477
+ if line.width() > self.cfg.width:
478
+ self._mark_no_fold()
479
+ return
480
+
481
+ closer = line.closer
482
+ if closer:
483
+ self._close_frame(line, closer)
484
+ return
485
+
486
+ self._emit_line(line)
487
+
488
+ def _emit_line(self, line: Line) -> None:
489
+ if self.stack:
490
+ self._add_to_frame(self.stack[-1], line)
491
+ else:
492
+ self._write_line(line)
493
+
494
+ def _choose_limit(self, kind: Kind, *, default: int =0, list_limit: int =0, dict_limit: int):
495
+ return (
496
+ list_limit if kind == Kind.LIST else
497
+ dict_limit if kind == Kind.DICT else
498
+ default
499
+ )
500
+
501
+ def _pack_limit(self, kind: Kind) -> int:
502
+ return self._choose_limit(kind,
503
+ list_limit = self.cfg.pack_array_items,
504
+ dict_limit = self.cfg.pack_obj_items )
505
+
506
+
507
+ def _fold_limit(self, kind: Kind) -> int:
508
+ return self._choose_limit(kind,
509
+ list_limit = self.cfg.fold_array_items,
510
+ dict_limit = self.cfg.fold_obj_items)
511
+
512
+ def _join_limit(self, kind: Kind) -> int:
513
+ return self._choose_limit(kind,
514
+ list_limit = self.cfg.join_array_items,
515
+ dict_limit = self.cfg.join_obj_items)
516
+
517
+ # --------------------------------------------------------- phase 1: pack
518
+
519
+ def _add_to_frame(self, frame: Frame, line: Line) -> None:
520
+ if self._try_pack(frame, line):
521
+ return
522
+
523
+ if self._try_join(frame, line):
524
+ return
525
+
526
+ frame.lines.append(line)
527
+ self._update_frame(frame, line)
528
+
529
+ if frame.fold_ok and line.width() > self.cfg.width:
530
+ self._mark_no_fold()
531
+
532
+ if not frame.fold_ok:
533
+ self._stream_frame(frame, keep_last=True)
534
+
535
+
536
+ def _can_join(self, prev: Line, line: Line, limit: int) -> bool:
537
+ return (
538
+ prev.indent == line.indent
539
+ and prev.items + line.items <= limit
540
+ and prev.indent + len(prev.text) + 1 + len(line.text) <= self.cfg.width
541
+ )
542
+
543
+
544
+
545
+ def _join_into_frame(self, frame: Frame, prev: Line, line: Line) -> None:
546
+ prev.join_line(line)
547
+
548
+ if line.parent_kind == frame.kind:
549
+ frame.items += line.items
550
+ frame.leafs += line.leafs
551
+
552
+ self._check_fold_limits(frame)
553
+
554
+ def _try_pack(self, frame: Frame, line: Line) -> bool:
555
+ if (
556
+ not frame.lines or
557
+ frame.pack_limit <= 1 or
558
+ not line.is_packable()
559
+ ):
560
+ return False
561
+
562
+ prev = frame.lines[-1]
563
+
564
+ if not (prev.is_packable() and self._can_join(prev, line, frame.pack_limit)):
565
+ return False
566
+
567
+ self._join_into_frame(frame, prev, line)
568
+
569
+ return True
570
+
571
+ def _try_join(self, frame: Frame, line: Line) -> bool:
572
+ if (
573
+ not frame.lines
574
+ or frame.join_limit <= 1
575
+ or not line.is_joinable()
576
+ or line.child_nesting > self.cfg.join_nesting
577
+ ):
578
+ return False
579
+
580
+ prev = frame.lines[-1]
581
+
582
+ if not (prev.is_joinable() and
583
+ prev.child_nesting <= self.cfg.join_nesting and
584
+ self._can_join(prev, line, frame.join_limit)
585
+ ):
586
+ return False
587
+
588
+ self._join_into_frame(frame, prev, line)
589
+ return True
590
+
591
+
592
+
593
+ # --------------------------------------------------------- frame tracking
594
+
595
+ def _update_frame(self, frame: Frame, line: Line) -> None:
596
+ if line.closer:
597
+ return
598
+
599
+ frame.content_lines += 1
600
+
601
+ if line.parent_kind == frame.kind:
602
+ frame.leafs += line.leafs
603
+ frame.items += line.items
604
+
605
+ if line.child_nesting >= 0:
606
+ frame.child_nesting = max(frame.child_nesting, line.child_nesting + 1)
607
+
608
+ self._check_fold_limits(frame)
609
+
610
+ def _check_fold_limits(self, frame: Frame) -> None:
611
+ if frame.content_lines > 1:
612
+ frame.fold_ok = False
613
+
614
+ if frame.items > frame.fold_limit:
615
+ frame.fold_ok = False
616
+
617
+ if frame.child_nesting > self.cfg.fold_nesting:
618
+ frame.fold_ok = False
619
+
620
+ # --------------------------------------------------------- phase 2: fold
621
+
622
+ def _close_frame(self, closer: Line, closing_kind: Kind) -> None:
623
+ if not self.stack:
624
+ self._write_line(closer)
625
+ return
626
+
627
+ frame = self.stack.pop()
628
+ frame.lines.append(closer)
629
+
630
+ if frame.kind != closing_kind:
631
+ frame.fold_ok = False
632
+
633
+ folded = self._try_fold(frame)
634
+
635
+ if folded is not None:
636
+ frame.lines = [ folded]
637
+
638
+ for line in frame.lines:
639
+ self._emit_line(line)
640
+ frame.lines.clear()
641
+
642
+ def _try_fold(self, frame: Frame) -> Line | None:
643
+
644
+ if (not frame.fold_ok or
645
+ frame.content_lines != 1 or
646
+ len(frame.lines) != 3
647
+ ):
648
+ return None
649
+
650
+ folded_length = sum(1 + len(line.text) for line in frame.lines) - 1
651
+
652
+ if frame.lines[0].indent + folded_length > self.cfg.width:
653
+ return None
654
+
655
+ text = " ".join(line.text for line in frame.lines)
656
+
657
+ return Line(
658
+ indent=frame.lines[0].indent,
659
+ text=text,
660
+ parent_kind=self._parent_kind(),
661
+ items=1,
662
+ leafs=frame.leafs,
663
+ child_nesting=max(0, frame.child_nesting),
664
+ )
665
+
666
+ # --------------------------------------------------------- streaming
667
+
668
+ def _stream_frame(self, frame: Frame, *, keep_last: bool) -> None:
669
+ keep = 1 if keep_last and frame.lines and frame.lines[-1].is_joinable() else 0
670
+
671
+ emit_lines = frame.lines[:-keep] if keep else frame.lines
672
+ frame.lines = frame.lines[-keep:] if keep else []
673
+
674
+ for line in emit_lines:
675
+ if frame.depth == 0:
676
+ self._write_line(line)
677
+ else:
678
+ self._add_to_frame(self.stack[frame.depth - 1], line)
679
+
680
+ # --------------------------------------------------------- misc helpers
681
+
682
+ def _mark_no_fold(self) -> None:
683
+ for frame in self.stack:
684
+ frame.fold_ok = False
685
+
686
+ if self.stack:
687
+ self._stream_frame(self.stack[-1], keep_last=True)
688
+
689
+ def _parent_kind(self) -> Kind:
690
+ return self.stack[-1].kind if self.stack else Kind.NONE
691
+ # ---------------------------------------------------------------------------
692
+ # Public helpers
693
+ # ---------------------------------------------------------------------------
694
+
695
+
696
+ def dump(obj: Any, fp: TextIO, *,
697
+ compact: JSONFold | str = "",
698
+ indent: int = 2, **kwargs: Any) -> None:
699
+
700
+ with JSONFoldWriter(fp, compact=compact) as out:
701
+ json.dump(obj, out, indent=indent, **kwargs)
702
+
703
+ def dumpi(obj: Any, fp: TextIO, *,
704
+ compact: JSONFold | str = "",
705
+ indent: int = 2, **kwargs: Any) -> None:
706
+
707
+ with JSONFoldWriter(fp, compact=compact) as out:
708
+ json.dump(obj, out, indent=indent, **kwargs)
709
+ return out.stats
710
+
711
+ def dumps(obj: Any, *,
712
+ compact: JSONFold | str = "",
713
+ indent: int = 2, **kwargs: Any) -> str:
714
+ out = io.StringIO()
715
+ dump(obj, out, compact=compact, indent=indent, **kwargs)
716
+ return out.getvalue()
717
+
718
+
719
+ # ---------------------------------------------------------------------------
720
+ # Demo data
721
+ # ---------------------------------------------------------------------------
722
+
723
+ def _demo() -> dict[str, Any]:
724
+ return {
725
+ "meta": {"version": 1, "ok": True},
726
+ "items": [{"id": 1, "name": "alpha"}, {"id": 2, "name": "beta"}],
727
+ "matrix": [[1, 2], [3, 4]],
728
+ "long": [
729
+ "this is a long message that may force the block to stay expanded",
730
+ "second", "third", "fourth",
731
+ ],
732
+ "single-array": [ 1 ],
733
+ "single-obj": [ 2 ],
734
+ }
735
+
736
+
737
+ # ---------------------------------------------------------------------------
738
+ # CLI
739
+ # ---------------------------------------------------------------------------
740
+
741
+ def main(argv: list[str] | None = None) -> int:
742
+ import argparse
743
+
744
+ p = argparse.ArgumentParser(
745
+ description="Read JSON from stdin; write folded JSON to stdout.")
746
+ p.add_argument("--demo", action="store_true")
747
+ p.add_argument("--compact", choices=JSONFold.PRESETS.keys(), default="default")
748
+ p.add_argument("--width", type=int, default=None, help="line width limit (default: terminal width/80)")
749
+ p.add_argument("--verbose", "-v", action="store_true", help="Enable verbose/debug output")
750
+
751
+ # Pack phase
752
+ g = p.add_argument_group("pack phase (combine scalars N-per-line)")
753
+ g.add_argument("--pack-items", type=int, default=None,
754
+ help="set both --pack-array-items and --pack-obj-items")
755
+ g.add_argument("--pack-array-items", type=int, default=None)
756
+ g.add_argument("--pack-obj-items", type=int, default=None)
757
+ g.add_argument("--pack-nesting", type=int, default=None)
758
+
759
+ # Fold phase
760
+ g = p.add_argument_group("fold phase (collapse single-content-line containers)")
761
+ g.add_argument("--fold-items", type=int, default=None,
762
+ help="set both --fold-array-items and --fold-obj-items")
763
+ g.add_argument("--fold-array-items", type=int, default=None)
764
+ g.add_argument("--fold-obj-items", type=int, default=None)
765
+ g.add_argument("--fold-nesting", type=int, default=None)
766
+
767
+ # Join phase
768
+ g = p.add_argument_group("Join phase (combine scalars/folded containers)")
769
+ g.add_argument("--join-items", type=int, default=None,
770
+ help="set both --join-array-items and --join-obj-items")
771
+ g.add_argument("--join-array-items", type=int, default=None)
772
+ g.add_argument("--join-obj-items", type=int, default=None)
773
+ g.add_argument("--join-nesting", type=int, default=None)
774
+
775
+
776
+ p.add_argument("--indent", type=int, default=2)
777
+ p.add_argument("--sort-keys", action="store_true")
778
+ args = p.parse_args(argv)
779
+
780
+ # Start from preset, apply overrides where explicitly given.
781
+ cfg = JSONFold.PRESETS[args.compact]
782
+
783
+ overrides: dict[str, int] = {}
784
+
785
+ # Convenience shorthands (lower priority than individual flags).
786
+ if args.pack_items is not None:
787
+ overrides["pack_array_items"] = args.pack_items
788
+ overrides["pack_obj_items"] = args.pack_items
789
+ if args.fold_items is not None:
790
+ overrides["fold_array_items"] = args.fold_items
791
+ overrides["fold_obj_items"] = args.fold_items
792
+ if args.join_items is not None:
793
+ overrides["join_array_items"] = args.join_items
794
+ overrides["join_obj_items"] = args.join_items
795
+
796
+ # Individual flags (higher priority — applied after shorthands).
797
+ for key in ("width",
798
+ "pack_array_items", "pack_obj_items", "pack_nesting",
799
+ "fold_array_items", "fold_obj_items", "fold_nesting",
800
+ "join_array_items", "join_obj_items", "join_nesting",
801
+ ):
802
+ val = getattr(args, key)
803
+ if val is not None:
804
+ overrides[key] = val
805
+
806
+ if args.width is None:
807
+ if sys.stdout.isatty():
808
+ import shutil
809
+ overrides["width"] = shutil.get_terminal_size(fallback=(24,80)).columns
810
+
811
+ cfg = replace(cfg, **overrides)
812
+
813
+ if args.verbose:
814
+ print(cfg, file= sys.stderr)
815
+
816
+ if args.demo:
817
+ data = _demo()
818
+ else:
819
+ data = json.load(sys.stdin)
820
+
821
+ info = dumpi(data, sys.stdout, compact=cfg, indent=args.indent,
822
+ sort_keys=args.sort_keys)
823
+ if args.verbose:
824
+ print(info, file=sys.stderr)
825
+
826
+ return 0
827
+
828
+
829
+ if __name__ == "__main__":
830
+ raise SystemExit(main())