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.
- jsonfold-0.1.0.dist-info/METADATA +3 -0
- jsonfold-0.1.0.dist-info/RECORD +5 -0
- jsonfold-0.1.0.dist-info/WHEEL +5 -0
- jsonfold-0.1.0.dist-info/top_level.txt +1 -0
- jsonfold.py +830 -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 @@
|
|
|
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())
|