json-compress 1.0.3__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,242 @@
1
+ """json-compress: lossless JSON compression that is still JSON.
2
+
3
+ Three ideas, applied together:
4
+
5
+ 1. Every object key is replaced by a short token and the real names are listed
6
+ once, in a catalogue. A key that occurs ten thousand times is spelled out
7
+ once.
8
+ 2. A list of like-shaped dicts becomes a table - one column list and a matrix of
9
+ rows - so the keys disappear from the rows entirely. Columns whose value
10
+ never changes are hoisted out of the rows altogether, and columns drawn from
11
+ a small set of strings become integer indices.
12
+ 3. String values that repeat are moved into a second catalogue and referenced by
13
+ token.
14
+
15
+ The output is an ordinary JSON value with no binary framing, so it survives
16
+ anything that carries JSON: an HTTP body, a WebSocket frame, a ``jsonb`` column,
17
+ a queue message, a log line.
18
+
19
+ >>> from json_compress import compress, decompress
20
+ >>> envelope = compress([{"id": 1, "role": "admin"}, {"id": 2, "role": "admin"}])
21
+ >>> decompress(envelope)
22
+ [{'id': 1, 'role': 'admin'}, {'id': 2, 'role': 'admin'}]
23
+
24
+ The wire format is specified in ``SPEC.md`` at the root of the repository, and
25
+ this implementation writes the same bytes as the JavaScript and .NET ones.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from dataclasses import dataclass
31
+ from typing import Any, Mapping, Optional, Union
32
+
33
+ from .constants import FORMAT_VERSION
34
+ from .decode import DecodeError, decompress, is_compressed
35
+ from .encode import compress
36
+ from .jsontext import dumps, loads
37
+ from .locate import JsonLocation, locate
38
+ from .normalize import normalize
39
+ from .options import DEFAULT_OPTIONS, CompressOptions, resolve_options
40
+ from .pack import is_packed, pack, pack_all, text_to_array, unpack, unpack_all
41
+ from .sizes import byte_length, gunzip, gzip_bytes, gzip_size
42
+
43
+ __version__ = "1.0.3"
44
+
45
+ __all__ = [
46
+ "FORMAT_VERSION",
47
+ "__version__",
48
+ "CompressOptions",
49
+ "DEFAULT_OPTIONS",
50
+ "DecodeError",
51
+ "JsonCompressCodec",
52
+ "JsonLocation",
53
+ "SizeReport",
54
+ "beautify",
55
+ "byte_length",
56
+ "compress",
57
+ "create_codec",
58
+ "decompress",
59
+ "decompress_if_needed",
60
+ "dumps",
61
+ "gunzip",
62
+ "gzip_bytes",
63
+ "gzip_size",
64
+ "is_compressed",
65
+ "is_packed",
66
+ "loads",
67
+ "locate",
68
+ "measure",
69
+ "minify",
70
+ "pack",
71
+ "pack_all",
72
+ "parse",
73
+ "stringify",
74
+ "text_to_array",
75
+ "unpack",
76
+ "unpack_all",
77
+ "validate",
78
+ ]
79
+
80
+ _Options = Union[CompressOptions, Mapping[str, Any], None]
81
+
82
+
83
+ def stringify(
84
+ value: Any,
85
+ options: _Options = None,
86
+ indent: Union[int, str, None] = None,
87
+ **overrides: Any,
88
+ ) -> str:
89
+ """Compress a value and serialise it in one step."""
90
+ return dumps(compress(value, options, **overrides), indent)
91
+
92
+
93
+ def parse(text: str) -> Any:
94
+ """Parse a compressed document and restore the original value."""
95
+ return decompress(loads(text))
96
+
97
+
98
+ def decompress_if_needed(value: Any) -> Any:
99
+ """Restore a value whether or not it was compressed.
100
+
101
+ Handy at a boundary you do not fully control - an endpoint being migrated, a
102
+ cache holding entries written by two versions of the same service.
103
+ """
104
+ return decompress(value) if is_compressed(value) else value
105
+
106
+
107
+ def minify(text: str) -> str:
108
+ """Strip the whitespace from a JSON document without changing what it means."""
109
+ return dumps(loads(text))
110
+
111
+
112
+ def beautify(text: str, indent: Union[int, str] = 2) -> str:
113
+ """Re-indent a JSON document."""
114
+ return dumps(loads(text), indent)
115
+
116
+
117
+ def validate(text: str) -> JsonLocation:
118
+ """Check that a string is valid JSON, and say exactly where it stops being so.
119
+
120
+ The verdict comes from the standard library, so it is the same verdict
121
+ ``json.loads`` would give. The location comes from a scan of the document,
122
+ because parser messages are written for parser authors and often name the
123
+ wrong culprit.
124
+ """
125
+ try:
126
+ loads(text)
127
+ return JsonLocation(True)
128
+ except Exception as error: # noqa: BLE001 - any parse failure is a failure
129
+ found = locate(text)
130
+ if not found.valid:
131
+ return found
132
+
133
+ # The scan found nothing but the parse still failed - report what the
134
+ # parser said rather than claiming the document is fine.
135
+ return JsonLocation(False, str(error))
136
+
137
+
138
+ @dataclass(frozen=True)
139
+ class SizeReport:
140
+ """What compression bought on one value."""
141
+
142
+ #: Bytes of the input as given.
143
+ original: int
144
+ #: Bytes once whitespace is removed.
145
+ minified: int
146
+ #: Bytes of the compressed envelope.
147
+ compressed: int
148
+ #: Bytes saved against the minified form.
149
+ saved: int
150
+ #: Compressed divided by minified, 0 to 1.
151
+ ratio: float
152
+ #: Percentage saved against the minified form.
153
+ percent: float
154
+ #: Minified bytes after gzip, or ``None`` when not measured.
155
+ gzip_minified: Optional[int] = None
156
+ #: Compressed bytes after gzip, or ``None`` when not measured.
157
+ gzip_compressed: Optional[int] = None
158
+
159
+
160
+ def measure(
161
+ value: Any,
162
+ options: _Options = None,
163
+ source: Optional[str] = None,
164
+ gzip: bool = True,
165
+ **overrides: Any,
166
+ ) -> SizeReport:
167
+ """Measure what compression buys on a given value.
168
+
169
+ The honest comparison is against minified JSON, not against whatever
170
+ whitespace the input happened to arrive with, so ``ratio`` and ``percent``
171
+ both use the minified size as their baseline. Gzip figures are included
172
+ because that is what most payloads meet in production, and the interesting
173
+ question is whether the format still helps once gzip has had its turn.
174
+
175
+ Pass ``source`` to have ``original`` reflect the text the document really
176
+ arrived as, and ``gzip=False`` to skip the gzip measurements.
177
+ """
178
+ minified = dumps(normalize(value))
179
+ compressed = dumps(compress(value, options, **overrides))
180
+
181
+ minified_bytes = byte_length(minified)
182
+ compressed_bytes = byte_length(compressed)
183
+
184
+ return SizeReport(
185
+ original=minified_bytes if source is None else byte_length(source),
186
+ minified=minified_bytes,
187
+ compressed=compressed_bytes,
188
+ saved=minified_bytes - compressed_bytes,
189
+ ratio=1.0 if minified_bytes == 0 else compressed_bytes / minified_bytes,
190
+ percent=(
191
+ 0.0
192
+ if minified_bytes == 0
193
+ else (minified_bytes - compressed_bytes) / minified_bytes * 100
194
+ ),
195
+ gzip_minified=gzip_size(minified) if gzip else None,
196
+ gzip_compressed=gzip_size(compressed) if gzip else None,
197
+ )
198
+
199
+
200
+ class JsonCompressCodec:
201
+ """A set of options, bound once and reused.
202
+
203
+ A codec is the natural unit when the same options apply everywhere - a
204
+ transport layer, a cache adapter, a storage helper - and it keeps the option
205
+ object off every call site. It holds nothing but its options, which are
206
+ frozen, so one instance is safe to share across threads.
207
+ """
208
+
209
+ __slots__ = ("options",)
210
+
211
+ def __init__(self, options: _Options = None, **overrides: Any) -> None:
212
+ self.options = resolve_options(options, overrides)
213
+
214
+ def compress(self, value: Any) -> dict:
215
+ """Compress a value with this codec's options."""
216
+ return compress(value, self.options)
217
+
218
+ def decompress(self, envelope: Any) -> Any:
219
+ """Restore a compressed value."""
220
+ return decompress(envelope)
221
+
222
+ def stringify(self, value: Any, indent: Union[int, str, None] = None) -> str:
223
+ """Compress a value and serialise it."""
224
+ return dumps(compress(value, self.options), indent)
225
+
226
+ def parse(self, text: str) -> Any:
227
+ """Parse and restore a compressed document."""
228
+ return decompress(loads(text))
229
+
230
+ def measure(
231
+ self, value: Any, source: Optional[str] = None, gzip: bool = True
232
+ ) -> SizeReport:
233
+ """Measure what this codec's options buy on a value."""
234
+ return measure(value, self.options, source=source, gzip=gzip)
235
+
236
+ def __repr__(self) -> str:
237
+ return "JsonCompressCodec(" + repr(self.options) + ")"
238
+
239
+
240
+ def create_codec(options: _Options = None, **overrides: Any) -> JsonCompressCodec:
241
+ """Bind a set of options once and reuse them."""
242
+ return JsonCompressCodec(options, **overrides)
@@ -0,0 +1,5 @@
1
+ """Allow `python -m json_compress` to run the command line."""
2
+
3
+ from .cli import main
4
+
5
+ raise SystemExit(main())
json_compress/cli.py ADDED
@@ -0,0 +1,243 @@
1
+ """The json-compress command line.
2
+
3
+ Reads from a file or from stdin, writes to a file or to stdout, so it drops into
4
+ a pipeline without ceremony::
5
+
6
+ curl -s https://api.example.com/orders | json-compress compress > orders.jc.json
7
+ json-compress stats orders.json
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import contextlib
14
+ import os
15
+ import sys
16
+ from typing import Any, List, NoReturn, Optional
17
+
18
+ from . import (
19
+ __version__,
20
+ beautify,
21
+ compress,
22
+ decompress,
23
+ dumps,
24
+ is_compressed,
25
+ loads,
26
+ measure,
27
+ minify,
28
+ validate,
29
+ )
30
+
31
+ COMMANDS = ("compress", "decompress", "stats", "minify", "beautify", "validate")
32
+
33
+ _EPILOG = """
34
+ commands:
35
+ compress [file] Compress a JSON document
36
+ decompress [file] Restore a compressed document
37
+ stats [file] Report what compression would save, and change nothing
38
+ minify [file] Remove whitespace only
39
+ beautify [file] Re-indent
40
+ validate [file] Check the document parses, and say where it does not
41
+
42
+ With no input file, the document is read from stdin.
43
+ """
44
+
45
+
46
+ def _fail(message: str) -> NoReturn:
47
+ sys.stderr.write("json-compress: " + message + "\n")
48
+ raise SystemExit(1)
49
+
50
+
51
+ def _build_parser() -> argparse.ArgumentParser:
52
+ parser = argparse.ArgumentParser(
53
+ prog="json-compress",
54
+ description="lossless JSON compression that is still JSON",
55
+ epilog=_EPILOG,
56
+ formatter_class=argparse.RawDescriptionHelpFormatter,
57
+ )
58
+ parser.add_argument("command", nargs="?", choices=COMMANDS, help=argparse.SUPPRESS)
59
+ parser.add_argument("input", nargs="?", help="the document to read (default stdin)")
60
+ parser.add_argument(
61
+ "-o", "--out", metavar="FILE", help="write to a file instead of stdout"
62
+ )
63
+ parser.add_argument(
64
+ "-p", "--pretty", action="store_true", help="indent the output"
65
+ )
66
+ parser.add_argument(
67
+ "-i",
68
+ "--indent",
69
+ type=int,
70
+ default=2,
71
+ metavar="N",
72
+ help="indent width for --pretty and beautify (default 2)",
73
+ )
74
+ parser.add_argument(
75
+ "--no-strings",
76
+ dest="strings",
77
+ action="store_false",
78
+ help="do not build a string-value catalogue",
79
+ )
80
+ parser.add_argument(
81
+ "--no-tables",
82
+ dest="tables",
83
+ action="store_false",
84
+ help="do not pack arrays of objects into tables",
85
+ )
86
+ parser.add_argument(
87
+ "--no-constants",
88
+ dest="constants",
89
+ action="store_false",
90
+ help="do not hoist unchanging columns out of rows",
91
+ )
92
+ parser.add_argument(
93
+ "--no-dict-columns",
94
+ dest="dict_columns",
95
+ action="store_false",
96
+ help="do not encode low-cardinality string columns as indices",
97
+ )
98
+ parser.add_argument(
99
+ "--min-rows",
100
+ type=int,
101
+ default=2,
102
+ metavar="N",
103
+ help="smallest array that may become a table (default 2)",
104
+ )
105
+ parser.add_argument(
106
+ "-v", "--version", action="version", version=__version__
107
+ )
108
+ return parser
109
+
110
+
111
+ def _read_input(path: Optional[str]) -> str:
112
+ if path:
113
+ try:
114
+ with open(path, encoding="utf-8") as handle:
115
+ return handle.read()
116
+ except OSError as error:
117
+ _fail("cannot read " + path + " (" + (error.strerror or "failed") + ")")
118
+ if sys.stdin is None or sys.stdin.isatty():
119
+ _fail("no input file given and nothing on stdin")
120
+ return sys.stdin.read()
121
+
122
+
123
+ def _write_output(text: str, path: Optional[str]) -> None:
124
+ if path:
125
+ with open(path, "w", encoding="utf-8", newline="\n") as handle:
126
+ handle.write(text)
127
+ return
128
+ sys.stdout.write(text + "\n")
129
+
130
+
131
+ def _kilobytes(count: int) -> str:
132
+ if count < 1024:
133
+ return str(count) + " B"
134
+ if count < 1024 * 1024:
135
+ return "%.1f KB" % (count / 1024)
136
+ return "%.2f MB" % (count / (1024 * 1024))
137
+
138
+
139
+ def _change(percent: float) -> str:
140
+ """"31% smaller" or "31% larger" - never a negative percentage."""
141
+ return "%.1f%% %s" % (abs(percent), "larger" if percent < 0 else "smaller")
142
+
143
+
144
+ def _stats(report: Any, name: str) -> str:
145
+ lines = [
146
+ name,
147
+ " as given " + _kilobytes(report.original).rjust(10),
148
+ " minified " + _kilobytes(report.minified).rjust(10),
149
+ " compressed " + _kilobytes(report.compressed).rjust(10),
150
+ " saved "
151
+ + _kilobytes(report.saved).rjust(10)
152
+ + " "
153
+ + _change(report.percent)
154
+ + " than minified",
155
+ ]
156
+ if report.gzip_minified is not None:
157
+ saved = report.gzip_minified - report.gzip_compressed
158
+ percent = 0.0 if report.gzip_minified == 0 else saved / report.gzip_minified * 100
159
+ lines += [
160
+ "",
161
+ " minified + gzip " + _kilobytes(report.gzip_minified).rjust(10),
162
+ " compressed + gzip " + _kilobytes(report.gzip_compressed).rjust(10),
163
+ " saved "
164
+ + _kilobytes(saved).rjust(10)
165
+ + " "
166
+ + _change(percent)
167
+ + " than minified + gzip",
168
+ ]
169
+ return "\n".join(lines)
170
+
171
+
172
+ def main(argv: Optional[List[str]] = None) -> int:
173
+ """Run the command line. Returns the process exit code."""
174
+ for stream in (sys.stdin, sys.stdout, sys.stderr):
175
+ # A JSON document is UTF-8 whatever the console code page says, and the
176
+ # output is compared byte for byte by the interoperability checks.
177
+ if hasattr(stream, "reconfigure"):
178
+ # An exotic console may refuse; the default encoding then stands.
179
+ with contextlib.suppress(ValueError, OSError):
180
+ stream.reconfigure(encoding="utf-8", newline="\n")
181
+
182
+ parser = _build_parser()
183
+ args = parser.parse_args(argv)
184
+
185
+ if args.command is None:
186
+ parser.print_help()
187
+ return 0
188
+
189
+ text = _read_input(args.input)
190
+ indent = args.indent if args.pretty else None
191
+
192
+ if args.command == "validate":
193
+ found = validate(text)
194
+ if found.valid:
195
+ sys.stdout.write("valid JSON\n")
196
+ return 0
197
+ where = (
198
+ " at line " + str(found.line) + ", column " + str(found.column)
199
+ if found.line
200
+ else ""
201
+ )
202
+ _fail("invalid JSON" + where + ": " + str(found.error))
203
+
204
+ if args.command == "minify":
205
+ _write_output(minify(text), args.out)
206
+ return 0
207
+
208
+ if args.command == "beautify":
209
+ _write_output(beautify(text, args.indent), args.out)
210
+ return 0
211
+
212
+ try:
213
+ parsed = loads(text)
214
+ except ValueError as error:
215
+ _fail("input is not valid JSON: " + str(error))
216
+
217
+ options = {
218
+ "strings": args.strings,
219
+ "tables": args.tables,
220
+ "constants": args.constants,
221
+ "dict_columns": args.dict_columns,
222
+ "min_rows": args.min_rows,
223
+ }
224
+
225
+ if args.command == "compress":
226
+ _write_output(dumps(compress(parsed, options), indent), args.out)
227
+ return 0
228
+
229
+ if args.command == "decompress":
230
+ if not is_compressed(parsed):
231
+ _fail("input is not a compressed document")
232
+ _write_output(dumps(decompress(parsed), indent), args.out)
233
+ return 0
234
+
235
+ # args.command == "stats"
236
+ report = measure(parsed, options, source=text)
237
+ name = os.path.basename(args.input) if args.input else "stdin"
238
+ sys.stdout.write(_stats(report, name) + "\n")
239
+ return 0
240
+
241
+
242
+ if __name__ == "__main__": # pragma: no cover
243
+ raise SystemExit(main())
@@ -0,0 +1,82 @@
1
+ """Choosing the column order for a table.
2
+
3
+ A table has one column list, but the rows that feed it each have their own key
4
+ order. The decoder rebuilds every row by walking the columns, so the order it
5
+ produces is only faithful if each row's keys appear in the column list in the
6
+ same sequence they appeared in the row.
7
+
8
+ That is a topological sort. Every row contributes the constraint "this key came
9
+ before that one", and any linear extension of those constraints reproduces every
10
+ row exactly. Where the constraints contradict each other - one row says name
11
+ before id, another says id before name - no column order can satisfy both, and
12
+ the array is left as ordinary objects rather than quietly reordered.
13
+
14
+ Ties are broken by first appearance, so the same input always produces the same
15
+ output.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import heapq
21
+ from typing import Any, Dict, List, Mapping, Optional, Sequence
22
+
23
+ __all__ = ["order_columns"]
24
+
25
+
26
+ def order_columns(rows: Sequence[Mapping[str, Any]]) -> Optional[List[str]]:
27
+ """Work out a column order that reproduces every row's key order.
28
+
29
+ Returns the columns, or ``None`` when no order can satisfy every row.
30
+ """
31
+ position: Dict[str, int] = {}
32
+ columns: List[str] = []
33
+
34
+ # First pass: collect the keys, in the order the document first mentions
35
+ # them. That order is the tie-break, and gives a stable result.
36
+ for row in rows:
37
+ for key in row:
38
+ if key not in position:
39
+ position[key] = len(columns)
40
+ columns.append(key)
41
+
42
+ count = len(columns)
43
+ if count <= 1:
44
+ return columns
45
+
46
+ after: List[set] = [set() for _ in range(count)]
47
+ incoming = [0] * count
48
+
49
+ # Second pass: one edge per adjacent pair of keys in each row. Transitivity
50
+ # along the chain covers the rest of the row's ordering.
51
+ saw_edge = False
52
+ for row in rows:
53
+ previous = -1
54
+ for key in row:
55
+ current = position[key]
56
+ if previous != -1 and current not in after[previous]:
57
+ after[previous].add(current)
58
+ incoming[current] += 1
59
+ saw_edge = True
60
+ previous = current
61
+
62
+ # Every row was a single key or empty: nothing constrains the order.
63
+ if not saw_edge:
64
+ return columns
65
+
66
+ # A heap rather than a queue, so ties break towards the key the document
67
+ # mentioned first however many columns there are.
68
+ ready = [at for at in range(count) if incoming[at] == 0]
69
+ heapq.heapify(ready)
70
+
71
+ ordered: List[str] = []
72
+ while ready:
73
+ at = heapq.heappop(ready)
74
+ ordered.append(columns[at])
75
+ for following in after[at]:
76
+ incoming[following] -= 1
77
+ if incoming[following] == 0:
78
+ heapq.heappush(ready, following)
79
+
80
+ # Fewer keys placed than we started with means the constraints form a cycle:
81
+ # two rows disagree about which key comes first.
82
+ return ordered if len(ordered) == count else None
@@ -0,0 +1,50 @@
1
+ """Wire-format constants for the json-compress envelope.
2
+
3
+ Everything the encoder emits is still ordinary JSON. The format therefore has
4
+ to be self-describing without any out-of-band type tags, which it achieves by
5
+ reserving a single character - the tilde - inside object keys and strings.
6
+
7
+ Because every original object key is replaced by a catalogue token, and key
8
+ tokens are drawn from ``[A-Za-z][0-9A-Za-z]*``, no original key can ever appear
9
+ as an object key in the encoded document. That makes every key that starts with
10
+ ``~`` safe to reserve for structural nodes.
11
+
12
+ String values are escaped on the way in (a value that already starts with a
13
+ tilde gains one more), which frees the ``~``-prefixed string space for catalogue
14
+ references and the row hole marker.
15
+ """
16
+
17
+ #: Envelope tag and format version. Bumped only on a breaking format change.
18
+ FORMAT_VERSION = 1
19
+
20
+ #: Envelope field: format version.
21
+ ENV_VERSION = "jc"
22
+ #: Envelope field: key catalogue, a list of the original key names.
23
+ ENV_KEYS = "k"
24
+ #: Envelope field: string-value catalogue. Omitted when empty.
25
+ ENV_STRINGS = "s"
26
+ #: Envelope field: the encoded document.
27
+ ENV_DATA = "d"
28
+
29
+ #: Table node: column list, as indices into the key catalogue.
30
+ T_COLUMNS = "~t"
31
+ #: Table node: per-column encoding. Omitted when every column is raw.
32
+ T_ENCODINGS = "~e"
33
+ #: Table node: values of the constant columns, in column order.
34
+ T_CONSTS = "~cv"
35
+ #: Table node: the rows, each a list of cells for the non-constant columns.
36
+ T_ROWS = "~r"
37
+ #: Table node: row count. Only present when every column is constant.
38
+ T_ROWCOUNT = "~n"
39
+
40
+ #: Cell marker for "this row does not have this key at all".
41
+ HOLE = "~"
42
+ #: Dictionary-column equivalent of :data:`HOLE`.
43
+ DICT_HOLE = -1
44
+
45
+ #: Column encoding: cells are ordinary encoded values.
46
+ COL_RAW = 0
47
+ #: Column encoding: cells are integer indices into the string catalogue.
48
+ COL_DICT = 1
49
+ #: Column encoding: the column is constant; its value lives in ``~cv``.
50
+ COL_CONST = 2