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/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())