polypress 0.2.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.
- polypress/__init__.py +13 -0
- polypress/caccel.py +164 -0
- polypress/cli.py +255 -0
- polypress/codec.py +343 -0
- polypress/dtz.py +978 -0
- polypress/fast.py +1397 -0
- polypress/stream.py +438 -0
- polypress/tcz.c +207 -0
- polypress/turbo.py +1515 -0
- polypress-0.2.0.dist-info/METADATA +121 -0
- polypress-0.2.0.dist-info/RECORD +15 -0
- polypress-0.2.0.dist-info/WHEEL +5 -0
- polypress-0.2.0.dist-info/entry_points.txt +2 -0
- polypress-0.2.0.dist-info/licenses/LICENSE +21 -0
- polypress-0.2.0.dist-info/top_level.txt +1 -0
polypress/dtz.py
ADDED
|
@@ -0,0 +1,978 @@
|
|
|
1
|
+
"""dtz -- a table compressor that picks the best available strategy.
|
|
2
|
+
|
|
3
|
+
Reads a table in whatever form you have it, tries every encoding validated in
|
|
4
|
+
this project plus the standard general-purpose compressors, keeps whichever
|
|
5
|
+
produced the smallest output, and writes a self-describing container.
|
|
6
|
+
|
|
7
|
+
The point is not a new compression breakthrough. It is that the standard tools
|
|
8
|
+
are among the candidates, so the result is never worse than the best of them,
|
|
9
|
+
and is sometimes meaningfully better.
|
|
10
|
+
|
|
11
|
+
Fidelity contract
|
|
12
|
+
-----------------
|
|
13
|
+
dtz preserves the LOGICAL table exactly: column names, column order, row
|
|
14
|
+
order, and every cell as an exact string. It does not promise to reproduce an
|
|
15
|
+
input file byte-for-byte, because CSV quoting and line endings are not
|
|
16
|
+
canonical. Every compress verifies the round trip in memory and refuses to
|
|
17
|
+
write output if it does not match.
|
|
18
|
+
|
|
19
|
+
Row order
|
|
20
|
+
---------
|
|
21
|
+
Preserved by default. Sorting the rows compresses substantially better, but it
|
|
22
|
+
destroys information, so it is only attempted with --unordered, which is you
|
|
23
|
+
declaring that the table is a set of records rather than a sequence.
|
|
24
|
+
|
|
25
|
+
Usage
|
|
26
|
+
-----
|
|
27
|
+
python3 dtz.py compress data.csv -o data.dtz [--unordered]
|
|
28
|
+
python3 dtz.py decompress data.dtz -o out.csv
|
|
29
|
+
python3 dtz.py inspect data.dtz
|
|
30
|
+
python3 dtz.py bench data.csv [--unordered]
|
|
31
|
+
|
|
32
|
+
Input formats: .csv .tsv .psv .txt .json .jsonl .ndjson .parquet
|
|
33
|
+
Output formats: .csv .tsv .json .jsonl .parquet
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import argparse
|
|
39
|
+
import bz2
|
|
40
|
+
import codecs
|
|
41
|
+
import csv
|
|
42
|
+
import datetime
|
|
43
|
+
import decimal
|
|
44
|
+
import io
|
|
45
|
+
import json
|
|
46
|
+
import lzma
|
|
47
|
+
import os
|
|
48
|
+
import struct
|
|
49
|
+
import sys
|
|
50
|
+
from dataclasses import dataclass, field
|
|
51
|
+
from typing import Callable, Dict, List, Optional, Sequence, Tuple
|
|
52
|
+
|
|
53
|
+
from . import codec # the polynomial-predictor bit codec
|
|
54
|
+
|
|
55
|
+
MAGIC = b"DTZ1"
|
|
56
|
+
XZ_FILTERS = None
|
|
57
|
+
XZ_PRESET = 9 | lzma.PRESET_EXTREME
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
import zstandard as zstd
|
|
61
|
+
except ImportError:
|
|
62
|
+
zstd = None
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
import pyarrow as pa
|
|
66
|
+
import pyarrow.parquet as pq
|
|
67
|
+
except ImportError:
|
|
68
|
+
pa = None
|
|
69
|
+
pq = None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ============================================================== the table
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class Table:
|
|
76
|
+
"""A table as exact cell strings. Everything round-trips through this."""
|
|
77
|
+
columns: List[str]
|
|
78
|
+
rows: List[List[str]]
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def shape(self) -> Tuple[int, int]:
|
|
82
|
+
return len(self.rows), len(self.columns)
|
|
83
|
+
|
|
84
|
+
def column(self, index: int) -> List[str]:
|
|
85
|
+
return [row[index] for row in self.rows]
|
|
86
|
+
|
|
87
|
+
def normalise(self) -> "Table":
|
|
88
|
+
"""Pad or trim ragged rows so every row matches the header width."""
|
|
89
|
+
width = len(self.columns)
|
|
90
|
+
fixed = []
|
|
91
|
+
for row in self.rows:
|
|
92
|
+
if len(row) < width:
|
|
93
|
+
row = row + [""] * (width - len(row))
|
|
94
|
+
elif len(row) > width:
|
|
95
|
+
row = row[:width]
|
|
96
|
+
fixed.append([("" if c is None else str(c)) for c in row])
|
|
97
|
+
return Table(list(self.columns), fixed)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ================================================================ readers
|
|
101
|
+
|
|
102
|
+
EXT_DELIMITER = {".csv": ",", ".tsv": "\t", ".psv": "|"}
|
|
103
|
+
|
|
104
|
+
# Byte-order marks, longest first. BOM_UTF32_LE starts with BOM_UTF16_LE, so
|
|
105
|
+
# testing 16 before 32 would read a UTF-32 file as UTF-16 and get nonsense.
|
|
106
|
+
# Python's "utf-16"/"utf-32" codecs consume the BOM themselves and pick the
|
|
107
|
+
# byte order from it; "utf-8-sig" strips a UTF-8 BOM that would otherwise
|
|
108
|
+
# become part of the first column's name.
|
|
109
|
+
_BOMS = (
|
|
110
|
+
(codecs.BOM_UTF32_LE, "utf-32"),
|
|
111
|
+
(codecs.BOM_UTF32_BE, "utf-32"),
|
|
112
|
+
(codecs.BOM_UTF8, "utf-8-sig"),
|
|
113
|
+
(codecs.BOM_UTF16_LE, "utf-16"),
|
|
114
|
+
(codecs.BOM_UTF16_BE, "utf-16"),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class EncodingRefused(ValueError):
|
|
119
|
+
"""The input is not text in the encoding we were told to expect.
|
|
120
|
+
|
|
121
|
+
Its own class so the CLI can print the one thing the user can act on --
|
|
122
|
+
which encoding to name -- rather than a generic parse failure.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def sniff_encoding(path: str) -> str:
|
|
127
|
+
"""Encoding from a byte-order mark, else utf-8.
|
|
128
|
+
|
|
129
|
+
Only a BOM is trusted. Guessing an encoding from the bytes is how a file
|
|
130
|
+
gets silently misread, and this codec's whole promise is that it does not
|
|
131
|
+
alter data -- so an unmarked file is assumed to be UTF-8 and *refused* if
|
|
132
|
+
it is not, rather than decoded on a hunch. `--encoding` is how the user
|
|
133
|
+
tells us the thing we decline to guess.
|
|
134
|
+
"""
|
|
135
|
+
with open(path, "rb") as fh:
|
|
136
|
+
head = fh.read(4)
|
|
137
|
+
for bom, enc in _BOMS:
|
|
138
|
+
if head.startswith(bom):
|
|
139
|
+
return enc
|
|
140
|
+
return "utf-8"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def open_text(path: str, encoding: Optional[str] = None, newline=""):
|
|
144
|
+
"""Open a text file strictly -- never with errors="replace".
|
|
145
|
+
|
|
146
|
+
This used to be errors="replace", which turns any byte the codec does not
|
|
147
|
+
recognise into U+FFFD. That is silent data destruction in a lossless
|
|
148
|
+
compressor: the table is already wrong before it is ever encoded, so the
|
|
149
|
+
round-trip check compares the corrupted table against itself and passes.
|
|
150
|
+
A latin-1 file lost every accented character this way and reported
|
|
151
|
+
success. Refusing is the only honest option, so decoding is strict and a
|
|
152
|
+
failure is raised as EncodingRefused with the offending byte located.
|
|
153
|
+
"""
|
|
154
|
+
enc = encoding or sniff_encoding(path)
|
|
155
|
+
return open(path, newline=newline, encoding=enc, errors="strict")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def encoding_error(path: str, exc: UnicodeDecodeError, encoding: str) -> str:
|
|
159
|
+
"""The refusal message. Names the byte, where it is, and what to do."""
|
|
160
|
+
return ("{} is not valid {} -- byte 0x{:02X} at offset {} cannot be "
|
|
161
|
+
"decoded.\nIf you know the file's encoding, name it: "
|
|
162
|
+
"--encoding latin-1 (or cp1252, utf-16, ...).".format(
|
|
163
|
+
os.path.basename(path), encoding.upper(),
|
|
164
|
+
exc.object[exc.start], exc.start))
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _read_strict(path: str, encoding: Optional[str], fn):
|
|
168
|
+
"""Run `fn(handle)`, turning a decode failure into EncodingRefused."""
|
|
169
|
+
enc = encoding or sniff_encoding(path)
|
|
170
|
+
try:
|
|
171
|
+
with open_text(path, enc) as fh:
|
|
172
|
+
return fn(fh)
|
|
173
|
+
except UnicodeDecodeError as exc:
|
|
174
|
+
raise EncodingRefused(encoding_error(path, exc, enc)) from None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _sniff_delimiter(sample: str) -> str:
|
|
178
|
+
"""Only used for extensions that do not name their delimiter.
|
|
179
|
+
|
|
180
|
+
Sniffs the header line alone: a tab or semicolon inside a data value must
|
|
181
|
+
not be mistaken for the delimiter.
|
|
182
|
+
"""
|
|
183
|
+
header = sample.split("\n", 1)[0]
|
|
184
|
+
try:
|
|
185
|
+
return csv.Sniffer().sniff(header, delimiters=",\t;|").delimiter
|
|
186
|
+
except csv.Error:
|
|
187
|
+
counts = {d: header.count(d) for d in ",\t;|"}
|
|
188
|
+
return max(counts, key=counts.get) if max(counts.values()) else ","
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def read_delimited(path: str, encoding: Optional[str] = None) -> Table:
|
|
192
|
+
ext = os.path.splitext(path)[1].lower()
|
|
193
|
+
|
|
194
|
+
def parse(fh):
|
|
195
|
+
delimiter = EXT_DELIMITER.get(ext)
|
|
196
|
+
if delimiter is None:
|
|
197
|
+
delimiter = _sniff_delimiter(fh.read(65536))
|
|
198
|
+
fh.seek(0)
|
|
199
|
+
return list(csv.reader(fh, delimiter=delimiter))
|
|
200
|
+
|
|
201
|
+
rows = _read_strict(path, encoding, parse)
|
|
202
|
+
if not rows:
|
|
203
|
+
return Table([], [])
|
|
204
|
+
return Table(rows[0], rows[1:]).normalise()
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _from_records(records: Sequence[dict]) -> Table:
|
|
208
|
+
columns: List[str] = []
|
|
209
|
+
seen = set()
|
|
210
|
+
for rec in records:
|
|
211
|
+
for key in rec:
|
|
212
|
+
if key not in seen:
|
|
213
|
+
seen.add(key)
|
|
214
|
+
columns.append(key)
|
|
215
|
+
rows = []
|
|
216
|
+
for rec in records:
|
|
217
|
+
rows.append(["" if rec.get(c) is None else _scalar(rec.get(c))
|
|
218
|
+
for c in columns])
|
|
219
|
+
return Table(columns, rows).normalise()
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _scalar(value) -> str:
|
|
223
|
+
"""Render any scalar a reader might hand us as an exact string.
|
|
224
|
+
|
|
225
|
+
Parquet columns can be timestamps, dates, decimals or binary, none of
|
|
226
|
+
which json.dumps will touch, so they are handled before the fallback.
|
|
227
|
+
"""
|
|
228
|
+
if isinstance(value, str):
|
|
229
|
+
return value
|
|
230
|
+
if isinstance(value, bool):
|
|
231
|
+
return "true" if value else "false"
|
|
232
|
+
if isinstance(value, (int, float)):
|
|
233
|
+
return repr(value) if isinstance(value, float) else str(value)
|
|
234
|
+
if isinstance(value, (datetime.datetime, datetime.date, datetime.time)):
|
|
235
|
+
return value.isoformat()
|
|
236
|
+
if isinstance(value, datetime.timedelta):
|
|
237
|
+
return str(value)
|
|
238
|
+
if isinstance(value, decimal.Decimal):
|
|
239
|
+
return str(value)
|
|
240
|
+
if isinstance(value, (bytes, bytearray)):
|
|
241
|
+
try:
|
|
242
|
+
return value.decode("utf-8")
|
|
243
|
+
except UnicodeDecodeError:
|
|
244
|
+
return value.hex()
|
|
245
|
+
try:
|
|
246
|
+
return json.dumps(value, separators=(",", ":"), sort_keys=True)
|
|
247
|
+
except TypeError:
|
|
248
|
+
return str(value)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def read_json(path: str, encoding: Optional[str] = None) -> Table:
|
|
252
|
+
data = _read_strict(path, encoding, json.load)
|
|
253
|
+
if isinstance(data, dict):
|
|
254
|
+
# column-oriented {name: [values]}
|
|
255
|
+
columns = list(data.keys())
|
|
256
|
+
n = max((len(v) for v in data.values()), default=0)
|
|
257
|
+
rows = [[_scalar(data[c][i]) if i < len(data[c]) else ""
|
|
258
|
+
for c in columns] for i in range(n)]
|
|
259
|
+
return Table(columns, rows).normalise()
|
|
260
|
+
return _from_records(data)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def read_jsonl(path: str, encoding: Optional[str] = None) -> Table:
|
|
264
|
+
def parse(fh):
|
|
265
|
+
return [json.loads(line) for line in fh if line.strip()]
|
|
266
|
+
|
|
267
|
+
return _from_records(_read_strict(path, encoding, parse))
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def read_parquet(path: str) -> Table:
|
|
271
|
+
if pq is None:
|
|
272
|
+
raise RuntimeError("reading parquet needs pyarrow installed")
|
|
273
|
+
table = pq.read_table(path)
|
|
274
|
+
columns = list(table.column_names)
|
|
275
|
+
data = {c: table.column(c).to_pylist() for c in columns}
|
|
276
|
+
n = table.num_rows
|
|
277
|
+
rows = [["" if data[c][i] is None else _scalar(data[c][i])
|
|
278
|
+
for c in columns] for i in range(n)]
|
|
279
|
+
return Table(columns, rows).normalise()
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
READERS = {
|
|
283
|
+
".csv": read_delimited, ".tsv": read_delimited, ".psv": read_delimited,
|
|
284
|
+
".txt": read_delimited, ".dat": read_delimited,
|
|
285
|
+
".json": read_json,
|
|
286
|
+
".jsonl": read_jsonl, ".ndjson": read_jsonl,
|
|
287
|
+
".parquet": read_parquet, ".pq": read_parquet,
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def read_any(path: str, encoding: Optional[str] = None) -> Table:
|
|
292
|
+
ext = os.path.splitext(path)[1].lower()
|
|
293
|
+
reader = READERS.get(ext)
|
|
294
|
+
if reader is None:
|
|
295
|
+
# unknown extension: try delimited, it covers most real files
|
|
296
|
+
reader = read_delimited
|
|
297
|
+
if reader is read_parquet:
|
|
298
|
+
return reader(path) # binary container, carries its own text
|
|
299
|
+
return reader(path, encoding)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# ================================================================ writers
|
|
303
|
+
|
|
304
|
+
def write_delimited(table: Table, path: str, delimiter: str = ",") -> None:
|
|
305
|
+
with open(path, "w", newline="", encoding="utf-8") as fh:
|
|
306
|
+
writer = csv.writer(fh, delimiter=delimiter, lineterminator="\n")
|
|
307
|
+
writer.writerow(table.columns)
|
|
308
|
+
writer.writerows(table.rows)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
class FormatLimit(Exception):
|
|
312
|
+
"""The requested OUTPUT format cannot represent this table.
|
|
313
|
+
|
|
314
|
+
Raised instead of writing something subtly wrong. Neither the .dtz
|
|
315
|
+
container nor the delimited formats have these limits, so there is always
|
|
316
|
+
a lossless option available.
|
|
317
|
+
"""
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _require_unique_columns(table: Table, fmt: str) -> None:
|
|
321
|
+
seen = set()
|
|
322
|
+
for name in table.columns:
|
|
323
|
+
if name in seen:
|
|
324
|
+
raise FormatLimit(
|
|
325
|
+
"{} cannot represent the duplicate column name {!r}; "
|
|
326
|
+
"write .csv or .tsv instead".format(fmt, name))
|
|
327
|
+
seen.add(name)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def write_json(table: Table, path: str) -> None:
|
|
331
|
+
_require_unique_columns(table, "json")
|
|
332
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
333
|
+
if not table.rows:
|
|
334
|
+
# a list of records cannot carry headers; use the column form,
|
|
335
|
+
# which read_json also understands
|
|
336
|
+
json.dump({c: [] for c in table.columns}, fh, ensure_ascii=False)
|
|
337
|
+
return
|
|
338
|
+
records = [dict(zip(table.columns, row)) for row in table.rows]
|
|
339
|
+
json.dump(records, fh, ensure_ascii=False, indent=1)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def write_jsonl(table: Table, path: str) -> None:
|
|
343
|
+
_require_unique_columns(table, "jsonl")
|
|
344
|
+
if not table.rows and table.columns:
|
|
345
|
+
raise FormatLimit(
|
|
346
|
+
"jsonl cannot carry column names for a zero-row table; "
|
|
347
|
+
"write .csv, .json or .parquet instead")
|
|
348
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
349
|
+
for row in table.rows:
|
|
350
|
+
fh.write(json.dumps(dict(zip(table.columns, row)),
|
|
351
|
+
ensure_ascii=False, separators=(",", ":")))
|
|
352
|
+
fh.write("\n")
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def write_parquet_out(table: Table, path: str) -> None:
|
|
356
|
+
if pq is None:
|
|
357
|
+
raise RuntimeError("writing parquet needs pyarrow installed")
|
|
358
|
+
_require_unique_columns(table, "parquet")
|
|
359
|
+
arrays = {c: pa.array(table.column(i))
|
|
360
|
+
for i, c in enumerate(table.columns)}
|
|
361
|
+
pq.write_table(pa.table(arrays), path, compression="zstd")
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def write_any(table: Table, path: str) -> None:
|
|
365
|
+
ext = os.path.splitext(path)[1].lower()
|
|
366
|
+
if ext in (".tsv",):
|
|
367
|
+
write_delimited(table, path, "\t")
|
|
368
|
+
elif ext == ".json":
|
|
369
|
+
write_json(table, path)
|
|
370
|
+
elif ext in (".jsonl", ".ndjson"):
|
|
371
|
+
write_jsonl(table, path)
|
|
372
|
+
elif ext in (".parquet", ".pq"):
|
|
373
|
+
write_parquet_out(table, path)
|
|
374
|
+
else:
|
|
375
|
+
write_delimited(table, path, ",")
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
# ================================================== canonical serialisation
|
|
379
|
+
|
|
380
|
+
def canonical_csv(table: Table) -> bytes:
|
|
381
|
+
buf = io.StringIO()
|
|
382
|
+
writer = csv.writer(buf, lineterminator="\n")
|
|
383
|
+
writer.writerow(table.columns)
|
|
384
|
+
writer.writerows(table.rows)
|
|
385
|
+
return buf.getvalue().encode("utf-8")
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def parse_canonical_csv(data: bytes) -> Table:
|
|
389
|
+
text = data.decode("utf-8")
|
|
390
|
+
rows = list(csv.reader(io.StringIO(text)))
|
|
391
|
+
if not rows:
|
|
392
|
+
return Table([], [])
|
|
393
|
+
return Table(rows[0], rows[1:])
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _xz(data: bytes) -> bytes:
|
|
397
|
+
return lzma.compress(data, preset=XZ_PRESET)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _unxz(data: bytes) -> bytes:
|
|
401
|
+
return lzma.decompress(data)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _zstd(data: bytes) -> bytes:
|
|
405
|
+
if zstd is None:
|
|
406
|
+
return _xz(data)
|
|
407
|
+
return zstd.ZstdCompressor(level=22, write_content_size=True).compress(data)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _unzstd(data: bytes) -> bytes:
|
|
411
|
+
if zstd is None:
|
|
412
|
+
return _unxz(data)
|
|
413
|
+
return zstd.ZstdDecompressor().decompress(data)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
# ============================================== typed column serialisation
|
|
417
|
+
|
|
418
|
+
def _pack_blobs(blobs: Sequence[bytes]) -> bytes:
|
|
419
|
+
out = [struct.pack("<I", len(blobs))]
|
|
420
|
+
for b in blobs:
|
|
421
|
+
out.append(struct.pack("<Q", len(b)))
|
|
422
|
+
out.append(b)
|
|
423
|
+
return b"".join(out)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _unpack_blobs(data: bytes) -> List[bytes]:
|
|
427
|
+
(count,), pos = struct.unpack_from("<I", data, 0), 4
|
|
428
|
+
blobs = []
|
|
429
|
+
for _ in range(count):
|
|
430
|
+
(length,) = struct.unpack_from("<Q", data, pos)
|
|
431
|
+
pos += 8
|
|
432
|
+
blobs.append(data[pos:pos + length])
|
|
433
|
+
pos += length
|
|
434
|
+
return blobs
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
INT64_MIN, INT64_MAX = -(2 ** 63), 2 ** 63 - 1
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _arrow_columns(table: Table) -> Tuple[Dict[str, list], Dict[str, int]]:
|
|
441
|
+
"""Represent each column as ints+scale when that round-trips exactly,
|
|
442
|
+
else as strings. Gives Parquet integer columns to work with while keeping
|
|
443
|
+
the fidelity contract.
|
|
444
|
+
|
|
445
|
+
Values outside int64 stay strings: arrow has no arbitrary-precision int,
|
|
446
|
+
and silently widening would lose digits.
|
|
447
|
+
"""
|
|
448
|
+
arrays: Dict[str, list] = {}
|
|
449
|
+
scales: Dict[str, int] = {}
|
|
450
|
+
for i, name in enumerate(table.columns):
|
|
451
|
+
cells = table.column(i)
|
|
452
|
+
numeric = codec.as_numeric_column(cells) if cells else None
|
|
453
|
+
key = "c{}".format(i)
|
|
454
|
+
if numeric is not None:
|
|
455
|
+
ints, decimals = numeric
|
|
456
|
+
if all(INT64_MIN <= v <= INT64_MAX for v in ints):
|
|
457
|
+
arrays[key] = ints
|
|
458
|
+
scales[key] = decimals
|
|
459
|
+
continue
|
|
460
|
+
arrays[key] = cells
|
|
461
|
+
return arrays, scales
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _restore_columns(arrays: Dict[str, list], scales: Dict[str, int],
|
|
465
|
+
names: Sequence[str]) -> Table:
|
|
466
|
+
cols = []
|
|
467
|
+
for i in range(len(names)):
|
|
468
|
+
key = "c{}".format(i)
|
|
469
|
+
values = arrays[key]
|
|
470
|
+
if key in scales:
|
|
471
|
+
decimals = scales[key]
|
|
472
|
+
cols.append([codec.int_to_cell(int(v), decimals) for v in values])
|
|
473
|
+
else:
|
|
474
|
+
cols.append(["" if v is None else v for v in values])
|
|
475
|
+
n = len(cols[0]) if cols else 0
|
|
476
|
+
rows = [[cols[j][i] for j in range(len(cols))] for i in range(n)]
|
|
477
|
+
return Table(list(names), rows)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _write_parquet_bytes(arrays: Dict[str, list], level: int = 22) -> bytes:
|
|
481
|
+
buf = io.BytesIO()
|
|
482
|
+
pq.write_table(pa.table({k: pa.array(v) for k, v in arrays.items()}), buf,
|
|
483
|
+
compression="zstd", compression_level=level,
|
|
484
|
+
use_dictionary=True)
|
|
485
|
+
return buf.getvalue()
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _read_parquet_bytes(data: bytes) -> Dict[str, list]:
|
|
489
|
+
table = pq.read_table(io.BytesIO(data))
|
|
490
|
+
return {c: table.column(c).to_pylist() for c in table.column_names}
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
# ============================================================= strategies
|
|
494
|
+
#
|
|
495
|
+
# Each strategy is (encode, decode). encode returns (payload, meta).
|
|
496
|
+
# `ordered` False means the strategy does not preserve row order and is only
|
|
497
|
+
# offered when the user passes --unordered.
|
|
498
|
+
|
|
499
|
+
@dataclass
|
|
500
|
+
class Strategy:
|
|
501
|
+
name: str
|
|
502
|
+
encode: Callable[[Table], Tuple[bytes, dict]]
|
|
503
|
+
decode: Callable[[bytes, dict], Table]
|
|
504
|
+
ordered: bool = True
|
|
505
|
+
needs_arrow: bool = False
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
# ---- 1. canonical CSV through a general compressor ----------------------
|
|
509
|
+
|
|
510
|
+
def _enc_csv_xz(t): return _xz(canonical_csv(t)), {}
|
|
511
|
+
def _dec_csv_xz(p, m): return parse_canonical_csv(_unxz(p))
|
|
512
|
+
def _enc_csv_zstd(t): return _zstd(canonical_csv(t)), {}
|
|
513
|
+
def _dec_csv_zstd(p, m): return parse_canonical_csv(_unzstd(p))
|
|
514
|
+
def _enc_csv_bz2(t): return bz2.compress(canonical_csv(t), 9), {}
|
|
515
|
+
def _dec_csv_bz2(p, m): return parse_canonical_csv(bz2.decompress(p))
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
# ---- 2. column-major text ----------------------------------------------
|
|
519
|
+
|
|
520
|
+
SEP = "\x1f"
|
|
521
|
+
ROW = "\x1e"
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _enc_colmajor(table: Table):
|
|
525
|
+
parts = [SEP.join(table.columns)]
|
|
526
|
+
for i in range(len(table.columns)):
|
|
527
|
+
parts.append(SEP.join(table.column(i)))
|
|
528
|
+
return _xz(ROW.join(parts).encode("utf-8")), {"names": table.columns}
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _dec_colmajor(payload: bytes, meta: dict) -> Table:
|
|
532
|
+
parts = _unxz(payload).decode("utf-8").split(ROW)
|
|
533
|
+
names = parts[0].split(SEP) if parts[0] else []
|
|
534
|
+
cols = [p.split(SEP) if p else [] for p in parts[1:]]
|
|
535
|
+
n = len(cols[0]) if cols else 0
|
|
536
|
+
rows = [[cols[j][i] for j in range(len(cols))] for i in range(n)]
|
|
537
|
+
return Table(names, rows)
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
# ---- 3. polynomial predictor codec (this project's codec.py) -----------
|
|
541
|
+
|
|
542
|
+
def _enc_poly(table: Table):
|
|
543
|
+
bw = codec.BitWriter()
|
|
544
|
+
bw.uvarint(len(table.rows))
|
|
545
|
+
bw.uvarint(len(table.columns))
|
|
546
|
+
for i in range(len(table.columns)):
|
|
547
|
+
cells = table.column(i)
|
|
548
|
+
numeric = codec.as_numeric_column(cells) if cells else None
|
|
549
|
+
if numeric is None:
|
|
550
|
+
bw.bit(0)
|
|
551
|
+
for cell in cells:
|
|
552
|
+
raw = cell.encode("utf-8")
|
|
553
|
+
bw.uvarint(len(raw))
|
|
554
|
+
for byte in raw:
|
|
555
|
+
bw.bits(byte, 8)
|
|
556
|
+
else:
|
|
557
|
+
ints, decimals = numeric
|
|
558
|
+
order = codec.choose_order(ints)
|
|
559
|
+
bw.bit(1)
|
|
560
|
+
bw.bits(decimals, 6)
|
|
561
|
+
bw.bits(order, 3)
|
|
562
|
+
for value in ints[:order]:
|
|
563
|
+
bw.svarint(value)
|
|
564
|
+
for block in codec._blocks(codec.residuals(ints, order)):
|
|
565
|
+
k, _ = codec._best_k(block)
|
|
566
|
+
bw.bits(k, codec.K_BITS)
|
|
567
|
+
for u in block:
|
|
568
|
+
bw.rice(u, k)
|
|
569
|
+
return bw.bytes_out(), {"names": table.columns}
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _dec_poly(payload: bytes, meta: dict) -> Table:
|
|
573
|
+
br = codec.BitReader(payload)
|
|
574
|
+
nrows = br.uvarint()
|
|
575
|
+
ncols = br.uvarint()
|
|
576
|
+
cols = []
|
|
577
|
+
for _ in range(ncols):
|
|
578
|
+
if br.bit() == 0:
|
|
579
|
+
cells = []
|
|
580
|
+
for _ in range(nrows):
|
|
581
|
+
n = br.uvarint()
|
|
582
|
+
cells.append(bytes(br.bits(8) for _ in range(n)).decode("utf-8"))
|
|
583
|
+
cols.append(cells)
|
|
584
|
+
else:
|
|
585
|
+
decimals = br.bits(6)
|
|
586
|
+
order = br.bits(3)
|
|
587
|
+
warmup = [br.svarint() for _ in range(order)]
|
|
588
|
+
res, remaining = [], nrows - order
|
|
589
|
+
while remaining > 0:
|
|
590
|
+
k = br.bits(codec.K_BITS)
|
|
591
|
+
take = min(codec.BLOCK, remaining)
|
|
592
|
+
for _ in range(take):
|
|
593
|
+
res.append(codec.unzigzag(br.rice(k)))
|
|
594
|
+
remaining -= take
|
|
595
|
+
ints = codec.restore(warmup, res, order, nrows)
|
|
596
|
+
cols.append([codec.int_to_cell(v, decimals) for v in ints])
|
|
597
|
+
rows = [[cols[j][i] for j in range(ncols)] for i in range(nrows)]
|
|
598
|
+
return Table(list(meta["names"]), rows)
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _enc_poly_xz(table: Table):
|
|
602
|
+
payload, meta = _enc_poly(table)
|
|
603
|
+
return _xz(payload), meta
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _dec_poly_xz(payload: bytes, meta: dict) -> Table:
|
|
607
|
+
return _dec_poly(_unxz(payload), meta)
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
# ---- 4. typed Parquet + zstd ------------------------------------------
|
|
611
|
+
|
|
612
|
+
def _enc_parquet(table: Table):
|
|
613
|
+
arrays, scales = _arrow_columns(table)
|
|
614
|
+
return _write_parquet_bytes(arrays), {"names": table.columns,
|
|
615
|
+
"scales": scales}
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _dec_parquet(payload: bytes, meta: dict) -> Table:
|
|
619
|
+
arrays = _read_parquet_bytes(payload)
|
|
620
|
+
return _restore_columns(arrays, {k: int(v) for k, v
|
|
621
|
+
in meta["scales"].items()},
|
|
622
|
+
meta["names"])
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
# ---- 5. functional-dependency normalisation ---------------------------
|
|
626
|
+
|
|
627
|
+
def _find_dependencies(table: Table) -> Dict[int, int]:
|
|
628
|
+
"""Map dependent column -> determinant column, for exact FDs only.
|
|
629
|
+
|
|
630
|
+
Picks the lowest-cardinality determinant, never chains onto a column that
|
|
631
|
+
has itself been moved, and breaks 1:1 ties deterministically.
|
|
632
|
+
"""
|
|
633
|
+
n_cols = len(table.columns)
|
|
634
|
+
if not table.rows or n_cols < 2:
|
|
635
|
+
return {}
|
|
636
|
+
cols = [table.column(i) for i in range(n_cols)]
|
|
637
|
+
cards = [len(set(c)) for c in cols]
|
|
638
|
+
order = sorted(range(n_cols), key=lambda i: cards[i])
|
|
639
|
+
|
|
640
|
+
owner: Dict[int, int] = {}
|
|
641
|
+
for b in order:
|
|
642
|
+
if cards[b] <= 1:
|
|
643
|
+
continue # constant: general codecs handle it
|
|
644
|
+
for a in order:
|
|
645
|
+
if a == b or a in owner:
|
|
646
|
+
continue
|
|
647
|
+
if cards[a] > cards[b]:
|
|
648
|
+
continue
|
|
649
|
+
if cards[a] == cards[b] and a > b:
|
|
650
|
+
continue
|
|
651
|
+
if cards[a] == len(table.rows):
|
|
652
|
+
continue # a unique key determines everything
|
|
653
|
+
mapping, ok = {}, True
|
|
654
|
+
for x, y in zip(cols[a], cols[b]):
|
|
655
|
+
prev = mapping.get(x, y)
|
|
656
|
+
if prev != y:
|
|
657
|
+
ok = False
|
|
658
|
+
break
|
|
659
|
+
mapping[x] = y
|
|
660
|
+
if ok:
|
|
661
|
+
owner[b] = a
|
|
662
|
+
break
|
|
663
|
+
return owner
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def _enc_fd(table: Table):
|
|
667
|
+
owner = _find_dependencies(table)
|
|
668
|
+
if not owner:
|
|
669
|
+
raise ValueError("no functional dependencies found")
|
|
670
|
+
|
|
671
|
+
kept = [i for i in range(len(table.columns)) if i not in owner]
|
|
672
|
+
main = Table([table.columns[i] for i in kept],
|
|
673
|
+
[[row[i] for i in kept] for row in table.rows])
|
|
674
|
+
main_arrays, main_scales = _arrow_columns(main)
|
|
675
|
+
blobs = [_write_parquet_bytes(main_arrays)]
|
|
676
|
+
|
|
677
|
+
groups: Dict[int, List[int]] = {}
|
|
678
|
+
for dep, det in owner.items():
|
|
679
|
+
groups.setdefault(det, []).append(dep)
|
|
680
|
+
|
|
681
|
+
dims = []
|
|
682
|
+
for det, deps in sorted(groups.items()):
|
|
683
|
+
seen: Dict[str, List[str]] = {}
|
|
684
|
+
det_col = table.column(det)
|
|
685
|
+
dep_cols = [table.column(d) for d in deps]
|
|
686
|
+
for i, keyval in enumerate(det_col):
|
|
687
|
+
if keyval not in seen:
|
|
688
|
+
seen[keyval] = [c[i] for c in dep_cols]
|
|
689
|
+
dim = Table(["k"] + ["v{}".format(d) for d in deps],
|
|
690
|
+
[[k] + v for k, v in seen.items()])
|
|
691
|
+
dim_arrays, dim_scales = _arrow_columns(dim)
|
|
692
|
+
blobs.append(_write_parquet_bytes(dim_arrays))
|
|
693
|
+
dims.append({"det": det, "deps": deps, "scales": dim_scales,
|
|
694
|
+
"names": dim.columns})
|
|
695
|
+
|
|
696
|
+
meta = {"names": table.columns, "kept": kept, "main_scales": main_scales,
|
|
697
|
+
"dims": dims}
|
|
698
|
+
return _pack_blobs(blobs), meta
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def _dec_fd(payload: bytes, meta: dict) -> Table:
|
|
702
|
+
blobs = _unpack_blobs(payload)
|
|
703
|
+
kept = list(meta["kept"])
|
|
704
|
+
main = _restore_columns(_read_parquet_bytes(blobs[0]),
|
|
705
|
+
{k: int(v) for k, v in meta["main_scales"].items()},
|
|
706
|
+
[meta["names"][i] for i in kept])
|
|
707
|
+
n_rows = len(main.rows)
|
|
708
|
+
total = len(meta["names"])
|
|
709
|
+
out: List[List[Optional[str]]] = [[None] * total for _ in range(n_rows)]
|
|
710
|
+
for pos, col in enumerate(kept):
|
|
711
|
+
for r in range(n_rows):
|
|
712
|
+
out[r][col] = main.rows[r][pos]
|
|
713
|
+
|
|
714
|
+
for blob, spec in zip(blobs[1:], meta["dims"]):
|
|
715
|
+
dim = _restore_columns(_read_parquet_bytes(blob),
|
|
716
|
+
{k: int(v) for k, v in spec["scales"].items()},
|
|
717
|
+
spec["names"])
|
|
718
|
+
lookup = {row[0]: row[1:] for row in dim.rows}
|
|
719
|
+
det = spec["det"]
|
|
720
|
+
deps = spec["deps"]
|
|
721
|
+
for r in range(n_rows):
|
|
722
|
+
values = lookup[out[r][det]]
|
|
723
|
+
for j, d in enumerate(deps):
|
|
724
|
+
out[r][d] = values[j]
|
|
725
|
+
|
|
726
|
+
return Table(list(meta["names"]), [[c for c in row] for row in out])
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
# ---- 6. order-free variants (only with --unordered) -------------------
|
|
730
|
+
|
|
731
|
+
def _enc_sorted_parquet(table: Table):
|
|
732
|
+
"""Sort rows to expose structure. Discards the original row order."""
|
|
733
|
+
rows = sorted(table.rows)
|
|
734
|
+
payload, meta = _enc_parquet(Table(table.columns, rows))
|
|
735
|
+
meta["sorted"] = True
|
|
736
|
+
return payload, meta
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def _enc_sorted_fd(table: Table):
|
|
740
|
+
rows = sorted(table.rows)
|
|
741
|
+
payload, meta = _enc_fd(Table(table.columns, rows))
|
|
742
|
+
meta["sorted"] = True
|
|
743
|
+
return payload, meta
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def build_strategies() -> List[Strategy]:
|
|
747
|
+
have_arrow = pa is not None
|
|
748
|
+
strategies = [
|
|
749
|
+
Strategy("csv.xz", _enc_csv_xz, _dec_csv_xz),
|
|
750
|
+
Strategy("csv.zstd", _enc_csv_zstd, _dec_csv_zstd),
|
|
751
|
+
Strategy("csv.bz2", _enc_csv_bz2, _dec_csv_bz2),
|
|
752
|
+
Strategy("colmajor.xz", _enc_colmajor, _dec_colmajor),
|
|
753
|
+
Strategy("poly", _enc_poly, _dec_poly),
|
|
754
|
+
Strategy("poly.xz", _enc_poly_xz, _dec_poly_xz),
|
|
755
|
+
]
|
|
756
|
+
if have_arrow:
|
|
757
|
+
strategies += [
|
|
758
|
+
Strategy("parquet.zstd", _enc_parquet, _dec_parquet,
|
|
759
|
+
needs_arrow=True),
|
|
760
|
+
Strategy("fd.parquet.zstd", _enc_fd, _dec_fd, needs_arrow=True),
|
|
761
|
+
Strategy("sorted.parquet.zstd", _enc_sorted_parquet, _dec_parquet,
|
|
762
|
+
ordered=False, needs_arrow=True),
|
|
763
|
+
Strategy("sorted.fd.parquet.zstd", _enc_sorted_fd, _dec_fd,
|
|
764
|
+
ordered=False, needs_arrow=True),
|
|
765
|
+
]
|
|
766
|
+
return strategies
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
# ============================================================== container
|
|
770
|
+
#
|
|
771
|
+
# DTZ1 | u16 name length | name | u32 meta length | xz(json meta) | payload
|
|
772
|
+
|
|
773
|
+
def pack(name: str, meta: dict, payload: bytes) -> bytes:
|
|
774
|
+
name_b = name.encode("utf-8")
|
|
775
|
+
meta_b = _xz(json.dumps(meta, separators=(",", ":")).encode("utf-8"))
|
|
776
|
+
return b"".join([MAGIC,
|
|
777
|
+
struct.pack("<H", len(name_b)), name_b,
|
|
778
|
+
struct.pack("<I", len(meta_b)), meta_b,
|
|
779
|
+
payload])
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def unpack(blob: bytes) -> Tuple[str, dict, bytes]:
|
|
783
|
+
if blob[:4] != MAGIC:
|
|
784
|
+
raise ValueError("not a dtz file (bad magic)")
|
|
785
|
+
pos = 4
|
|
786
|
+
(name_len,) = struct.unpack_from("<H", blob, pos)
|
|
787
|
+
pos += 2
|
|
788
|
+
name = blob[pos:pos + name_len].decode("utf-8")
|
|
789
|
+
pos += name_len
|
|
790
|
+
(meta_len,) = struct.unpack_from("<I", blob, pos)
|
|
791
|
+
pos += 4
|
|
792
|
+
meta = json.loads(_unxz(blob[pos:pos + meta_len]).decode("utf-8"))
|
|
793
|
+
pos += meta_len
|
|
794
|
+
return name, meta, blob[pos:]
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
# ============================================================ top level
|
|
798
|
+
|
|
799
|
+
@dataclass
|
|
800
|
+
class Attempt:
|
|
801
|
+
name: str
|
|
802
|
+
size: Optional[int] = None
|
|
803
|
+
error: Optional[str] = None
|
|
804
|
+
ordered: bool = True
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def try_all(table: Table, unordered: bool = False,
|
|
808
|
+
verify: bool = True) -> Tuple[List[Attempt], Dict[str, bytes]]:
|
|
809
|
+
attempts, blobs = [], {}
|
|
810
|
+
for strat in build_strategies():
|
|
811
|
+
if not strat.ordered and not unordered:
|
|
812
|
+
continue
|
|
813
|
+
try:
|
|
814
|
+
payload, meta = strat.encode(table)
|
|
815
|
+
blob = pack(strat.name, meta, payload)
|
|
816
|
+
if verify:
|
|
817
|
+
name, m, p = unpack(blob)
|
|
818
|
+
restored = strat.decode(p, m)
|
|
819
|
+
if strat.ordered:
|
|
820
|
+
if restored.columns != table.columns or \
|
|
821
|
+
restored.rows != table.rows:
|
|
822
|
+
raise AssertionError("round trip mismatch")
|
|
823
|
+
else:
|
|
824
|
+
if restored.columns != table.columns or \
|
|
825
|
+
sorted(restored.rows) != sorted(table.rows):
|
|
826
|
+
raise AssertionError("round trip mismatch (unordered)")
|
|
827
|
+
attempts.append(Attempt(strat.name, len(blob), None, strat.ordered))
|
|
828
|
+
blobs[strat.name] = blob
|
|
829
|
+
except Exception as exc: # a failed candidate is
|
|
830
|
+
attempts.append(Attempt(strat.name, None, # not fatal
|
|
831
|
+
"{}: {}".format(type(exc).__name__, exc),
|
|
832
|
+
strat.ordered))
|
|
833
|
+
return attempts, blobs
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def compress(table: Table, unordered: bool = False):
|
|
837
|
+
attempts, blobs = try_all(table, unordered=unordered, verify=True)
|
|
838
|
+
winners = [a for a in attempts if a.size is not None]
|
|
839
|
+
if not winners:
|
|
840
|
+
raise RuntimeError("every strategy failed; see bench for details")
|
|
841
|
+
best = min(winners, key=lambda a: a.size)
|
|
842
|
+
return blobs[best.name], best, attempts
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def decompress(blob: bytes) -> Table:
|
|
846
|
+
name, meta, payload = unpack(blob)
|
|
847
|
+
for strat in build_strategies():
|
|
848
|
+
if strat.name == name:
|
|
849
|
+
return strat.decode(payload, meta)
|
|
850
|
+
raise ValueError("unknown strategy in container: {}".format(name))
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
# ==================================================================== CLI
|
|
854
|
+
|
|
855
|
+
def _human(n: int) -> str:
|
|
856
|
+
for unit in ("B", "KB", "MB", "GB"):
|
|
857
|
+
if n < 1024 or unit == "GB":
|
|
858
|
+
return "{:.1f} {}".format(n, unit) if unit != "B" else "{} B".format(n)
|
|
859
|
+
n /= 1024.0
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def cmd_compress(args) -> int:
|
|
863
|
+
table = read_any(args.input)
|
|
864
|
+
raw = os.path.getsize(args.input)
|
|
865
|
+
blob, best, attempts = compress(table, unordered=args.unordered)
|
|
866
|
+
out = args.output or (args.input + ".dtz")
|
|
867
|
+
with open(out, "wb") as fh:
|
|
868
|
+
fh.write(blob)
|
|
869
|
+
rows, cols = table.shape
|
|
870
|
+
print("{} -> {}".format(args.input, out))
|
|
871
|
+
print(" table {:,} rows x {} columns".format(rows, cols))
|
|
872
|
+
print(" input {}".format(_human(raw)))
|
|
873
|
+
print(" output {} ({:.2f}x smaller)".format(
|
|
874
|
+
_human(len(blob)), raw / len(blob) if len(blob) else 0))
|
|
875
|
+
print(" strategy {}{}".format(
|
|
876
|
+
best.name, "" if best.ordered else " [row order NOT preserved]"))
|
|
877
|
+
runner = sorted((a for a in attempts if a.size and a.name != best.name),
|
|
878
|
+
key=lambda a: a.size)
|
|
879
|
+
if runner:
|
|
880
|
+
print(" runner-up {} at {} ({:+.1%})".format(
|
|
881
|
+
runner[0].name, _human(runner[0].size),
|
|
882
|
+
runner[0].size / best.size - 1))
|
|
883
|
+
print(" round trip verified")
|
|
884
|
+
return 0
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
def cmd_decompress(args) -> int:
|
|
888
|
+
with open(args.input, "rb") as fh:
|
|
889
|
+
blob = fh.read()
|
|
890
|
+
name, meta, _ = unpack(blob)
|
|
891
|
+
table = decompress(blob)
|
|
892
|
+
out = args.output or os.path.splitext(args.input)[0]
|
|
893
|
+
if not os.path.splitext(out)[1]:
|
|
894
|
+
out += ".csv"
|
|
895
|
+
write_any(table, out)
|
|
896
|
+
rows, cols = table.shape
|
|
897
|
+
print("{} -> {}".format(args.input, out))
|
|
898
|
+
print(" strategy {}".format(name))
|
|
899
|
+
print(" table {:,} rows x {} columns".format(rows, cols))
|
|
900
|
+
return 0
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
def cmd_inspect(args) -> int:
|
|
904
|
+
with open(args.input, "rb") as fh:
|
|
905
|
+
blob = fh.read()
|
|
906
|
+
name, meta, payload = unpack(blob)
|
|
907
|
+
print("dtz container: {}".format(args.input))
|
|
908
|
+
print(" strategy {}".format(name))
|
|
909
|
+
print(" container {}".format(_human(len(blob))))
|
|
910
|
+
print(" payload {}".format(_human(len(payload))))
|
|
911
|
+
print(" columns {}".format(len(meta.get("names", [])) or "n/a"))
|
|
912
|
+
if meta.get("sorted"):
|
|
913
|
+
print(" note rows were sorted; original order not preserved")
|
|
914
|
+
if meta.get("dims"):
|
|
915
|
+
print(" dependencies {} group(s):".format(len(meta["dims"])))
|
|
916
|
+
names = meta["names"]
|
|
917
|
+
for spec in meta["dims"]:
|
|
918
|
+
deps = ", ".join(names[d] for d in spec["deps"])
|
|
919
|
+
print(" {} -> {}".format(names[spec["det"]], deps))
|
|
920
|
+
return 0
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
def cmd_bench(args) -> int:
|
|
924
|
+
table = read_any(args.input)
|
|
925
|
+
raw = os.path.getsize(args.input)
|
|
926
|
+
attempts, _ = try_all(table, unordered=args.unordered, verify=True)
|
|
927
|
+
rows, cols = table.shape
|
|
928
|
+
print("\n{} {:,} rows x {} columns input {}\n".format(
|
|
929
|
+
args.input, rows, cols, _human(raw)))
|
|
930
|
+
ok = sorted((a for a in attempts if a.size), key=lambda a: a.size)
|
|
931
|
+
width = max((len(a.name) for a in attempts), default=10)
|
|
932
|
+
for i, a in enumerate(ok):
|
|
933
|
+
flag = "" if a.ordered else " [unordered]"
|
|
934
|
+
mark = " <-- best" if i == 0 else ""
|
|
935
|
+
print(" {:<{w}} {:>10} {:>6.2f}x{}{}".format(
|
|
936
|
+
a.name, _human(a.size), raw / a.size, flag, mark, w=width))
|
|
937
|
+
for a in attempts:
|
|
938
|
+
if a.size is None:
|
|
939
|
+
print(" {:<{w}} {:>10} {}".format(
|
|
940
|
+
a.name, "failed", a.error, w=width))
|
|
941
|
+
if not args.unordered:
|
|
942
|
+
print("\n pass --unordered to also try row-sorting strategies")
|
|
943
|
+
return 0
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
def main(argv=None) -> int:
|
|
947
|
+
parser = argparse.ArgumentParser(
|
|
948
|
+
prog="dtz", description="compress a data table with the best "
|
|
949
|
+
"available strategy")
|
|
950
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
951
|
+
|
|
952
|
+
p = sub.add_parser("compress", help="compress a table to .dtz")
|
|
953
|
+
p.add_argument("input")
|
|
954
|
+
p.add_argument("-o", "--output")
|
|
955
|
+
p.add_argument("--unordered", action="store_true",
|
|
956
|
+
help="allow row reordering (declares row order meaningless)")
|
|
957
|
+
p.set_defaults(func=cmd_compress)
|
|
958
|
+
|
|
959
|
+
p = sub.add_parser("decompress", help="restore a table from .dtz")
|
|
960
|
+
p.add_argument("input")
|
|
961
|
+
p.add_argument("-o", "--output", help="extension picks the output format")
|
|
962
|
+
p.set_defaults(func=cmd_decompress)
|
|
963
|
+
|
|
964
|
+
p = sub.add_parser("inspect", help="show what is inside a .dtz")
|
|
965
|
+
p.add_argument("input")
|
|
966
|
+
p.set_defaults(func=cmd_inspect)
|
|
967
|
+
|
|
968
|
+
p = sub.add_parser("bench", help="show every strategy's size")
|
|
969
|
+
p.add_argument("input")
|
|
970
|
+
p.add_argument("--unordered", action="store_true")
|
|
971
|
+
p.set_defaults(func=cmd_bench)
|
|
972
|
+
|
|
973
|
+
args = parser.parse_args(argv)
|
|
974
|
+
return args.func(args)
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
if __name__ == "__main__":
|
|
978
|
+
sys.exit(main())
|