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.
- json_compress/__init__.py +242 -0
- json_compress/__main__.py +5 -0
- json_compress/cli.py +243 -0
- json_compress/column_order.py +82 -0
- json_compress/constants.py +50 -0
- json_compress/decode.py +224 -0
- json_compress/encode.py +325 -0
- json_compress/jsontext.py +249 -0
- json_compress/locate.py +358 -0
- json_compress/normalize.py +151 -0
- json_compress/options.py +112 -0
- json_compress/pack.py +134 -0
- json_compress/py.typed +0 -0
- json_compress/sizes.py +48 -0
- json_compress/table.py +170 -0
- json_compress/tokens.py +125 -0
- json_compress-1.0.3.dist-info/METADATA +495 -0
- json_compress-1.0.3.dist-info/RECORD +21 -0
- json_compress-1.0.3.dist-info/WHEEL +4 -0
- json_compress-1.0.3.dist-info/entry_points.txt +2 -0
- json_compress-1.0.3.dist-info/licenses/LICENSE +21 -0
|
@@ -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)
|
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
|