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 ADDED
@@ -0,0 +1,13 @@
1
+ """Polypress -- lossless compression for data tables.
2
+
3
+ from polypress import dtz, fast
4
+ table = dtz.read_any("data.csv")
5
+ blob = fast.encode(table)
6
+
7
+ `fast` is the single-shot codec; `stream` is the bounded-memory block
8
+ variant for files larger than RAM. `caccel` is an optional C accelerator
9
+ that builds itself on first import and falls back to numpy if it cannot.
10
+ """
11
+
12
+ # Keep in step with pyproject.toml; the app's About screen reads this one.
13
+ __version__ = "0.2.0"
polypress/caccel.py ADDED
@@ -0,0 +1,164 @@
1
+ """ctypes binding for tcz.c, with a working pure-Python fallback.
2
+
3
+ Builds the shared library on first import if it is missing or older than the
4
+ source. If anything goes wrong -- no compiler, unusual platform -- `HAVE_C`
5
+ stays False and the callers use numpy instead. Nothing here is required for
6
+ correctness.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import ctypes
12
+ import os
13
+ import subprocess
14
+ import sys
15
+ from typing import List, Optional, Tuple
16
+
17
+ import numpy as np
18
+
19
+ _DIR = os.path.dirname(os.path.abspath(__file__))
20
+ _SRC = os.path.join(_DIR, "tcz.c")
21
+ _LIB = os.path.join(_DIR, "libtcz.so")
22
+
23
+ HAVE_C = False
24
+ _lib = None
25
+
26
+
27
+ def _build() -> bool:
28
+ if not os.path.exists(_SRC):
29
+ return False
30
+ if os.path.exists(_LIB) and \
31
+ os.path.getmtime(_LIB) >= os.path.getmtime(_SRC):
32
+ return True
33
+ cmd = ["cc", "-O3", "-shared", "-fPIC", "-o", _LIB, _SRC]
34
+ try:
35
+ r = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
36
+ timeout=120)
37
+ return r.returncode == 0
38
+ except Exception:
39
+ return False
40
+
41
+
42
+ def _load() -> None:
43
+ global HAVE_C, _lib
44
+ if not _build():
45
+ return
46
+ try:
47
+ lib = ctypes.CDLL(_LIB)
48
+ except OSError:
49
+ return
50
+
51
+ c64 = ctypes.c_int64
52
+ p64 = ctypes.POINTER(ctypes.c_int64)
53
+ pu8 = ctypes.POINTER(ctypes.c_uint8)
54
+ pu64 = ctypes.POINTER(ctypes.c_uint64)
55
+
56
+ lib.scan_decimals.restype = ctypes.c_int32
57
+ lib.scan_decimals.argtypes = [ctypes.c_char_p, c64, p64]
58
+ lib.parse_fixed.restype = ctypes.c_int32
59
+ lib.parse_fixed.argtypes = [ctypes.c_char_p, c64, ctypes.c_int32, p64, c64]
60
+ lib.fmt_fixed.restype = c64
61
+ lib.fmt_fixed.argtypes = [p64, c64, ctypes.c_int32, ctypes.c_char_p, c64]
62
+ lib.pack_ints.restype = c64
63
+ lib.pack_ints.argtypes = [p64, c64, pu8, pu64]
64
+ lib.unpack_ints.restype = None
65
+ lib.unpack_ints.argtypes = [pu8, c64, pu64, p64]
66
+
67
+ _lib = lib
68
+ HAVE_C = True
69
+
70
+
71
+ _load()
72
+
73
+
74
+ def _ptr(a: np.ndarray, ct):
75
+ return a.ctypes.data_as(ctypes.POINTER(ct))
76
+
77
+
78
+ # --------------------------------------------------------------- numeric
79
+
80
+ def parse_column(cells: List[str]) -> Optional[Tuple[np.ndarray, int]]:
81
+ """Decimal text -> (scaled int64 array, decimals), or None if the column
82
+ is not exactly representable that way.
83
+
84
+ Exactness is checked the only way that cannot be argued with: format the
85
+ parsed integers back and compare the bytes."""
86
+ if not HAVE_C or not cells:
87
+ return None
88
+ blob = "\n".join(cells).encode()
89
+ n = len(cells)
90
+ cnt = ctypes.c_int64(0)
91
+ dec = _lib.scan_decimals(blob, len(blob), ctypes.byref(cnt))
92
+ if dec < 0 or cnt.value != n:
93
+ return None
94
+ out = np.empty(n, dtype=np.int64)
95
+ if _lib.parse_fixed(blob, len(blob), dec, _ptr(out, ctypes.c_int64), n) != 0:
96
+ return None
97
+ if format_column(out, dec) != blob.decode():
98
+ return None # leading zeros, "-0.0", ragged
99
+ return out, dec
100
+
101
+
102
+ def format_column(a: np.ndarray, dec: int) -> str:
103
+ """Scaled int64 array -> newline-joined decimal text."""
104
+ a = np.ascontiguousarray(a, dtype=np.int64)
105
+ cap = a.size * 42 + 64
106
+ buf = ctypes.create_string_buffer(cap)
107
+ wrote = _lib.fmt_fixed(_ptr(a, ctypes.c_int64), a.size, dec, buf, cap)
108
+ if wrote < 0:
109
+ raise RuntimeError("fmt_fixed capacity")
110
+ return buf.raw[:wrote].decode()
111
+
112
+
113
+ def cells_from_ints(a: np.ndarray, dec: int) -> List[str]:
114
+ if a.size == 0:
115
+ return []
116
+ return format_column(a, dec).split("\n")
117
+
118
+
119
+ # --------------------------------------------------------------- varints
120
+
121
+ def pack(res: np.ndarray) -> bytes:
122
+ """Layout: width byte (4 or 8), then one head byte per value, then the
123
+ escaped values at that width.
124
+
125
+ The width is per-array. Escapes always fit in 64 bits, but on real tables
126
+ they almost always fit in 32 -- a fixed 8-byte tail cost 5% on a table of
127
+ 9-digit vehicle IDs."""
128
+ a = np.ascontiguousarray(res, dtype=np.int64)
129
+ head = np.empty(a.size, dtype=np.uint8)
130
+ tail = np.empty(a.size, dtype=np.uint64)
131
+ nbig = _lib.pack_ints(_ptr(a, ctypes.c_int64), a.size,
132
+ _ptr(head, ctypes.c_uint8),
133
+ _ptr(tail, ctypes.c_uint64))
134
+ tail = tail[:nbig]
135
+ if nbig and int(tail.max()) >= (1 << 32):
136
+ return b"\x08" + head.tobytes() + tail.tobytes()
137
+ return b"\x04" + head.tobytes() + tail.astype("<u4").tobytes()
138
+
139
+
140
+ def unpack(buf: bytes, n: int) -> np.ndarray:
141
+ width = buf[0]
142
+ head = np.ascontiguousarray(np.frombuffer(buf[1:1 + n], dtype=np.uint8))
143
+ nbig = int((head == 255).sum())
144
+ at = 1 + n
145
+ if width == 8:
146
+ tail = np.frombuffer(buf[at:at + 8 * nbig], dtype="<u8")
147
+ else:
148
+ tail = np.frombuffer(buf[at:at + 4 * nbig], dtype="<u4").astype(np.uint64)
149
+ tail = np.ascontiguousarray(tail)
150
+ out = np.empty(n, dtype=np.int64)
151
+ _lib.unpack_ints(_ptr(head, ctypes.c_uint8), n,
152
+ _ptr(tail, ctypes.c_uint64), _ptr(out, ctypes.c_int64))
153
+ return out
154
+
155
+
156
+ if __name__ == "__main__":
157
+ print("C acceleration:", "available" if HAVE_C else "NOT available")
158
+ if HAVE_C:
159
+ cells = ["5.40", "-73.67", "0.05", "0.00", "12345.99"]
160
+ got = parse_column(cells)
161
+ print("parse ", got[0].tolist(), "dec", got[1])
162
+ print("format ", cells_from_ints(got[0], got[1]))
163
+ arr = np.array([0, 1, -1, 300, -99999], dtype=np.int64)
164
+ print("varint ", unpack(pack(arr), arr.size).tolist())
polypress/cli.py ADDED
@@ -0,0 +1,255 @@
1
+ """Polypress command line.
2
+
3
+ Installed as the `polypress` console script; `python3 tzip.py` still works and
4
+ calls straight into here, so nothing that used the old entry point breaks.
5
+
6
+ polypress compress data.csv -> data.csv.ppz
7
+ polypress restore data.csv.ppz -> data.csv
8
+ polypress restore data.csv.ppz -o x.parquet
9
+ polypress info data.csv.ppz
10
+
11
+ For a file too large to hold in memory, the same three commands in a
12
+ block-at-a-time form, with a settable memory budget:
13
+
14
+ polypress stream-compress big.csv --budget 1.0
15
+ polypress stream-restore big.csv.ppz -o back.csv
16
+ polypress stream-info big.csv.ppz
17
+
18
+ Restoring writes whatever format the output extension asks for, so this
19
+ doubles as a converter. Compression verifies the round trip in memory before
20
+ writing anything.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import json
27
+ import lzma
28
+ import os
29
+ import struct
30
+ import sys
31
+ import time
32
+
33
+ from . import dtz, fast, stream
34
+
35
+ PACKED_EXT = ".ppz"
36
+ LEGACY_EXT = ".tcz" # archives written before the rename
37
+
38
+
39
+ def human(n: float) -> str:
40
+ for unit in ("B", "KB", "MB", "GB"):
41
+ if abs(n) < 1024 or unit == "GB":
42
+ return "{:,.0f} {}".format(n, unit) if unit == "B" \
43
+ else "{:,.1f} {}".format(n, unit)
44
+ n /= 1024.0
45
+ return str(n)
46
+
47
+
48
+ def cmd_compress(args) -> int:
49
+ src = args.path
50
+ dst = args.output or src + PACKED_EXT
51
+ raw = os.path.getsize(src)
52
+
53
+ t0 = time.time()
54
+ table = dtz.read_any(src, args.encoding)
55
+ blob = fast.encode(table)
56
+ secs = time.time() - t0
57
+
58
+ if not args.no_verify:
59
+ back = fast.decode(blob)
60
+ if back.columns != table.columns or back.rows != table.rows:
61
+ print("verification FAILED -- nothing written", file=sys.stderr)
62
+ return 1
63
+
64
+ with open(dst, "wb") as fh:
65
+ fh.write(blob)
66
+ rows, cols = table.shape
67
+ print("{:,} rows x {} cols {} -> {} {:.2f}x {:.1f} MB/s".format(
68
+ rows, cols, human(raw), human(len(blob)), raw / max(len(blob), 1),
69
+ raw / 1e6 / max(secs, 1e-9)))
70
+ print(dst)
71
+ return 0
72
+
73
+
74
+ def cmd_restore(args) -> int:
75
+ src = args.path
76
+ if args.output:
77
+ dst = args.output
78
+ else:
79
+ low = src.lower()
80
+ if low.endswith(PACKED_EXT) or low.endswith(LEGACY_EXT):
81
+ dst = src[:-4]
82
+ else:
83
+ dst = src + ".csv"
84
+ if os.path.abspath(dst) == os.path.abspath(src):
85
+ dst = dst + ".restored.csv"
86
+ blob = open(src, "rb").read()
87
+ t0 = time.time()
88
+ table = fast.decode(blob)
89
+ secs = time.time() - t0
90
+ dtz.write_any(table, dst)
91
+ rows, cols = table.shape
92
+ out = os.path.getsize(dst)
93
+ print("{:,} rows x {} cols {} -> {} {:.1f} MB/s".format(
94
+ rows, cols, human(len(blob)), human(out),
95
+ out / 1e6 / max(secs, 1e-9)))
96
+ print(dst)
97
+ return 0
98
+
99
+
100
+ def cmd_info(args) -> int:
101
+ blob = open(args.path, "rb").read()
102
+ if blob[:4] not in (fast.MAGIC, fast.MAGIC_V0):
103
+ print("not a Polypress archive (bad magic)", file=sys.stderr)
104
+ return 1
105
+ ml = int.from_bytes(blob[4:8], "big")
106
+ meta = json.loads(lzma.decompress(blob[16:16 + ml], **fast.XZ))
107
+ kinds = {}
108
+ for c in meta["cols"]:
109
+ kinds[c["kind"]] = kinds.get(c["kind"], 0) + 1
110
+ print("file {}".format(args.path))
111
+ print("size {}".format(human(len(blob))))
112
+ print("rows {:,}".format(meta["nrows"]))
113
+ print("columns {}".format(len(meta["cols"])))
114
+ print("plan {}".format(", ".join(
115
+ "{} {}".format(v, k) for k, v in sorted(kinds.items()))))
116
+ if meta["groups"]:
117
+ print("2D groups {}".format("; ".join(
118
+ ", ".join(meta["columns"][i] for i in g) for g in meta["groups"])))
119
+ parented = sum(1 for c in meta["cols"]
120
+ if c["kind"] == "dict" and c.get("parent") is not None)
121
+ print("reordered {} columns sorted by a parent".format(parented))
122
+ return 0
123
+
124
+
125
+ def main(argv=None) -> int:
126
+ ap = argparse.ArgumentParser(prog="polypress", description=__doc__.split("\n")[0])
127
+ sub = ap.add_subparsers(dest="cmd", required=True)
128
+
129
+ c = sub.add_parser("compress", help="table -> .ppz")
130
+ c.add_argument("path")
131
+ c.add_argument("-o", "--output")
132
+ c.add_argument("--no-verify", action="store_true",
133
+ help="skip the in-memory round-trip check (not advised)")
134
+ c.add_argument("--encoding",
135
+ help="text encoding of the input (default: utf-8, or "
136
+ "whatever a byte-order mark says)")
137
+ c.set_defaults(fn=cmd_compress)
138
+
139
+ r = sub.add_parser("restore", help=".ppz -> table")
140
+ r.add_argument("path")
141
+ r.add_argument("-o", "--output",
142
+ help="output path; the extension picks the format")
143
+ r.set_defaults(fn=cmd_restore)
144
+
145
+ i = sub.add_parser("info", help="what is inside an archive")
146
+ i.add_argument("path")
147
+ i.set_defaults(fn=cmd_info)
148
+
149
+ # The streaming variants share stream.py's implementation rather than
150
+ # reimplementing it, so there is one code path and one archive format.
151
+ sc = sub.add_parser("stream-compress",
152
+ help="table -> .ppz, one block at a time")
153
+ sc.add_argument("path")
154
+ sc.add_argument("-o", "--output")
155
+ sc.add_argument("--budget", type=float, default=stream.DEFAULT_BUDGET_GB,
156
+ help="approximate peak memory in GB (default 1.0)")
157
+ sc.add_argument("--rows", type=int,
158
+ help="rows per block, overrides --budget")
159
+ sc.add_argument("--no-verify", action="store_true")
160
+ sc.add_argument("--encoding",
161
+ help="text encoding of the input (default: utf-8, or "
162
+ "whatever a byte-order mark says)")
163
+ sc.set_defaults(fn=lambda a: stream.main(
164
+ ["compress", a.path] + (["-o", a.output] if a.output else [])
165
+ + ["--budget", str(a.budget)]
166
+ + (["--rows", str(a.rows)] if a.rows else [])
167
+ + (["--no-verify"] if a.no_verify else [])
168
+ + (["--encoding", a.encoding] if a.encoding else [])))
169
+
170
+ sr = sub.add_parser("stream-restore",
171
+ help="streamed .ppz -> table, one block at a time")
172
+ sr.add_argument("path")
173
+ sr.add_argument("-o", "--output",
174
+ help="output path; the extension picks the format")
175
+ sr.set_defaults(fn=lambda a: stream.main(
176
+ ["restore", a.path] + (["-o", a.output] if a.output else [])))
177
+
178
+ si = sub.add_parser("stream-info", help="blocks and sizes in a stream archive")
179
+ si.add_argument("path")
180
+ si.set_defaults(fn=lambda a: stream.main(["info", a.path]))
181
+
182
+ args = ap.parse_args(argv)
183
+ return _run(args)
184
+
185
+
186
+ # Everything a damaged archive can raise on its way up. lzma and bz2 report
187
+ # corruption through their own exception types, and a header that decompresses
188
+ # into something that is not the JSON we expect surfaces as a KeyError or an
189
+ # IndexError several frames further in. None of these are bugs -- they are the
190
+ # decoder correctly refusing input someone else's disk or mail client mangled.
191
+ _CORRUPT = (ValueError, lzma.LZMAError, EOFError, KeyError, IndexError,
192
+ TypeError, UnicodeDecodeError, OverflowError, MemoryError,
193
+ struct.error)
194
+
195
+
196
+ def _run(args) -> int:
197
+ """Dispatch, turning an expected failure into one line instead of a dump.
198
+
199
+ `info` already did this by checking the magic itself; `restore` did not,
200
+ so the same damaged file produced a clean message from one command and a
201
+ twelve-line traceback from the other. A traceback reads as "this tool is
202
+ broken" rather than "your file is damaged", which is exactly backwards
203
+ when the whole point is that the decoder reads files other people made.
204
+ """
205
+ path = getattr(args, "path", None)
206
+ try:
207
+ return args.fn(args)
208
+ except KeyboardInterrupt:
209
+ print("\ninterrupted", file=sys.stderr)
210
+ return 130
211
+ except BrokenPipeError:
212
+ raise
213
+ except dtz.EncodingRefused as exc:
214
+ # Not a damaged archive and not an unreadable table -- the file is
215
+ # fine, we just will not guess its encoding. The message already says
216
+ # which byte and what to pass, so print it as-is rather than wrapping
217
+ # it in the generic "cannot read this" text.
218
+ print("polypress: {}".format(exc), file=sys.stderr)
219
+ return 1
220
+ except FileNotFoundError:
221
+ print("polypress: no such file: {}".format(path), file=sys.stderr)
222
+ return 1
223
+ except IsADirectoryError:
224
+ print("polypress: {} is a directory, not a file".format(path),
225
+ file=sys.stderr)
226
+ return 1
227
+ except PermissionError:
228
+ print("polypress: not allowed to read {}".format(path),
229
+ file=sys.stderr)
230
+ return 1
231
+ except _CORRUPT as exc:
232
+ if args.cmd in ("compress", "stream-compress"):
233
+ print("polypress: cannot read {} as a table.".format(path),
234
+ file=sys.stderr)
235
+ else:
236
+ print("polypress: cannot read {} -- it is not a Polypress "
237
+ "archive, or it is damaged.".format(path), file=sys.stderr)
238
+ print(" ({}: {})".format(type(exc).__name__, exc),
239
+ file=sys.stderr)
240
+ return 1
241
+ except OSError as exc:
242
+ # bz2 reports a corrupt stream as a plain OSError with no dedicated
243
+ # class, so a damaged bzip2-fallback archive lands here rather than in
244
+ # _CORRUPT above. Say what it means for the file the user named.
245
+ if args.cmd in ("restore", "info", "stream-restore", "stream-info"):
246
+ print("polypress: cannot read {} -- it is not a Polypress "
247
+ "archive, or it is damaged.".format(path), file=sys.stderr)
248
+ print(" (OSError: {})".format(exc), file=sys.stderr)
249
+ else:
250
+ print("polypress: {}".format(exc), file=sys.stderr)
251
+ return 1
252
+
253
+
254
+ if __name__ == "__main__":
255
+ sys.exit(main())