polypress 0.2.0__tar.gz
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-0.2.0/LICENSE +21 -0
- polypress-0.2.0/PKG-INFO +121 -0
- polypress-0.2.0/README.md +99 -0
- polypress-0.2.0/polypress/__init__.py +13 -0
- polypress-0.2.0/polypress/caccel.py +164 -0
- polypress-0.2.0/polypress/cli.py +255 -0
- polypress-0.2.0/polypress/codec.py +343 -0
- polypress-0.2.0/polypress/dtz.py +978 -0
- polypress-0.2.0/polypress/fast.py +1397 -0
- polypress-0.2.0/polypress/stream.py +438 -0
- polypress-0.2.0/polypress/tcz.c +207 -0
- polypress-0.2.0/polypress/turbo.py +1515 -0
- polypress-0.2.0/polypress.egg-info/PKG-INFO +121 -0
- polypress-0.2.0/polypress.egg-info/SOURCES.txt +29 -0
- polypress-0.2.0/polypress.egg-info/dependency_links.txt +1 -0
- polypress-0.2.0/polypress.egg-info/entry_points.txt +2 -0
- polypress-0.2.0/polypress.egg-info/requires.txt +4 -0
- polypress-0.2.0/polypress.egg-info/top_level.txt +1 -0
- polypress-0.2.0/pyproject.toml +46 -0
- polypress-0.2.0/setup.cfg +4 -0
- polypress-0.2.0/tests/test_cbin.py +352 -0
- polypress-0.2.0/tests/test_cbin_corpus.py +142 -0
- polypress-0.2.0/tests/test_dtz.py +155 -0
- polypress-0.2.0/tests/test_encoding.py +238 -0
- polypress-0.2.0/tests/test_fast.py +309 -0
- polypress-0.2.0/tests/test_fuzz.py +119 -0
- polypress-0.2.0/tests/test_hostile.py +181 -0
- polypress-0.2.0/tests/test_input_guard.py +199 -0
- polypress-0.2.0/tests/test_lying_header.py +242 -0
- polypress-0.2.0/tests/test_stream.py +183 -0
- polypress-0.2.0/tests/test_turbo.py +156 -0
polypress-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mahdi Akbarin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
polypress-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: polypress
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Lossless compression for data tables, smaller than xz, zstd, brotli and Parquet on coded survey and administrative data
|
|
5
|
+
Author: Mahdi Akbarin
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://www.polypressapp.com
|
|
8
|
+
Project-URL: Repository, https://github.com/potJim80/PolyPressApp
|
|
9
|
+
Keywords: compression,csv,parquet,columnar,survey data
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: System :: Archiving :: Compression
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: numpy>=1.20
|
|
19
|
+
Provides-Extra: parquet
|
|
20
|
+
Requires-Dist: pyarrow>=8; extra == "parquet"
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# Polypress
|
|
24
|
+
|
|
25
|
+
**A lossless compressor for data tables.** CSV, Parquet, and anything else
|
|
26
|
+
shaped like rows and columns.
|
|
27
|
+
|
|
28
|
+
General-purpose compressors see a table as a stream of bytes. Columnar formats
|
|
29
|
+
see it as columns, and compress each one on its own. Polypress is built on the
|
|
30
|
+
fact that **the columns of a real table are not independent of each other** —
|
|
31
|
+
`City` is nearly determined by `Postal Code`, `latitude` is repeated inside
|
|
32
|
+
`location`, adjacent sensor columns hold nearly the same number — and models
|
|
33
|
+
those relationships directly.
|
|
34
|
+
|
|
35
|
+
The original table comes back **exactly**: same columns, same column order,
|
|
36
|
+
same row order, every cell as the exact string it was.
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
pip install polypress
|
|
40
|
+
polypress compress data.csv # -> data.csv.ppz
|
|
41
|
+
polypress restore data.csv.ppz # -> data.csv
|
|
42
|
+
polypress info data.csv.ppz # plan, shape, how much was reordered
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## The benchmark, and how it was chosen
|
|
46
|
+
|
|
47
|
+
The obvious objection to any compression result is *"you picked the files"*, so
|
|
48
|
+
the main benchmark does not pick. It asks the Socrata open-data catalog for its
|
|
49
|
+
datasets **in descending order of page views** and takes the first 500 that
|
|
50
|
+
pass four mechanical filters (the CSV downloads; at least 2 columns and 20
|
|
51
|
+
rows; at least 50 KB; not a duplicate). Rank order is public and fixed, so the
|
|
52
|
+
list reproduces, and every rejection is recorded with its reason.
|
|
53
|
+
|
|
54
|
+
**500 tables, 3.57 GB of CSV, 14.3 million rows, 10,557 columns**, against 17
|
|
55
|
+
competing codecs:
|
|
56
|
+
|
|
57
|
+
| | |
|
|
58
|
+
|---|---|
|
|
59
|
+
| Round-trips exactly | **500 of 500** |
|
|
60
|
+
| Smaller than the best of all 17 competitors | **478 of 500** |
|
|
61
|
+
| Margin over the best other tool | median **1.25x**, best 2.73x, worst 0.65x |
|
|
62
|
+
| Compression vs raw CSV | median **14.35x** |
|
|
63
|
+
|
|
64
|
+
Head to head: it beats `xz -9e`, `zstd -22`, `gzip -9` and `lz4 -9` on **all
|
|
65
|
+
500**; `parquet+zstd` on 449 of 450; `bzip2 -9` on 491 of 500; `brotli -q 11`
|
|
66
|
+
on 487 of 500.
|
|
67
|
+
|
|
68
|
+
**Two things belong next to those numbers.** Parquet, read with type inference,
|
|
69
|
+
reproduced the exact printed text on only 81 of the 500 tables — turning
|
|
70
|
+
`"1.50"` into `1.5` makes a smaller file for reasons that have nothing to do
|
|
71
|
+
with compression, and Polypress guarantees the printed cell. And the margin is
|
|
72
|
+
not a better final compressor: re-finishing the same modelled streams with the
|
|
73
|
+
competitor's own entropy coder still wins 449 of 450 against `parquet+zstd`.
|
|
74
|
+
|
|
75
|
+
## How it works
|
|
76
|
+
|
|
77
|
+
1. **Predict down a column.** Fit a low-degree polynomial to the last few
|
|
78
|
+
values, extrapolate one step, store the error. Re-centring the fit at every
|
|
79
|
+
cell means the coefficients never have to be stored — order-*k*
|
|
80
|
+
extrapolation is exactly the *k*-th finite difference.
|
|
81
|
+
2. **The same thing in two dimensions.** Where adjacent numeric columns are
|
|
82
|
+
*commensurable*, a cell is predicted from its left, upper and upper-left
|
|
83
|
+
neighbours. This is what wins on matrix-shaped tables, and no shipped
|
|
84
|
+
columnar codec does it — Gorilla, DoubleDelta, T64, zfp and fpzip all
|
|
85
|
+
predict down a single column.
|
|
86
|
+
3. **Reorder the rows so a column collapses into runs.** Rather than model that
|
|
87
|
+
`City` depends on `Postal Code`, sort by the parent: equal parents become
|
|
88
|
+
adjacent and the child collapses. **The permutation is free** — the decoder
|
|
89
|
+
has already rebuilt the parent and recomputes the same stable sort. Your
|
|
90
|
+
table comes back in its original row order.
|
|
91
|
+
|
|
92
|
+
## What it is honest about
|
|
93
|
+
|
|
94
|
+
- **It is slow to compress.** Median 2.0 MB/s encode, 134 MB/s decode. The
|
|
95
|
+
never-worse guarantee is what costs it: eligible tables are encoded twice and
|
|
96
|
+
the smaller result wins.
|
|
97
|
+
- **The core idea is not novel.** US 8,312,026 B2 (Vo, AT&T, filed 2009)
|
|
98
|
+
discloses it. It was arrived at here independently and the novelty claim was
|
|
99
|
+
withdrawn. Both relevant patents have expired.
|
|
100
|
+
- **The 2x results are matrix-shaped tables.** 1.3–1.5x is typical, and
|
|
101
|
+
text-heavy tables are the weak genre.
|
|
102
|
+
- **All 22 losses out of 500 are published**, including the one genuine
|
|
103
|
+
undiagnosed loss.
|
|
104
|
+
- **Every benchmark dataset is a government open-data table.** There is no
|
|
105
|
+
reason to assume the result transfers to other genres.
|
|
106
|
+
- **Nobody outside the project has run it.**
|
|
107
|
+
|
|
108
|
+
## Requirements
|
|
109
|
+
|
|
110
|
+
Python 3.9+ and numpy. A C compiler is optional — the accelerator compiles
|
|
111
|
+
itself on first import and falls back to numpy without one. `pyarrow` is
|
|
112
|
+
needed only for `.parquet` input and output: `pip install 'polypress[parquet]'`.
|
|
113
|
+
|
|
114
|
+
## More
|
|
115
|
+
|
|
116
|
+
Source, tests, the full benchmark harness and the committed sweep results:
|
|
117
|
+
**https://github.com/potJim80/PolyPressApp** — including `memory/LAWS.md`, the
|
|
118
|
+
findings that govern what gets built, and the README section listing everything
|
|
119
|
+
that was tried and failed.
|
|
120
|
+
|
|
121
|
+
MIT licensed.
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# Polypress
|
|
2
|
+
|
|
3
|
+
**A lossless compressor for data tables.** CSV, Parquet, and anything else
|
|
4
|
+
shaped like rows and columns.
|
|
5
|
+
|
|
6
|
+
General-purpose compressors see a table as a stream of bytes. Columnar formats
|
|
7
|
+
see it as columns, and compress each one on its own. Polypress is built on the
|
|
8
|
+
fact that **the columns of a real table are not independent of each other** —
|
|
9
|
+
`City` is nearly determined by `Postal Code`, `latitude` is repeated inside
|
|
10
|
+
`location`, adjacent sensor columns hold nearly the same number — and models
|
|
11
|
+
those relationships directly.
|
|
12
|
+
|
|
13
|
+
The original table comes back **exactly**: same columns, same column order,
|
|
14
|
+
same row order, every cell as the exact string it was.
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
pip install polypress
|
|
18
|
+
polypress compress data.csv # -> data.csv.ppz
|
|
19
|
+
polypress restore data.csv.ppz # -> data.csv
|
|
20
|
+
polypress info data.csv.ppz # plan, shape, how much was reordered
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## The benchmark, and how it was chosen
|
|
24
|
+
|
|
25
|
+
The obvious objection to any compression result is *"you picked the files"*, so
|
|
26
|
+
the main benchmark does not pick. It asks the Socrata open-data catalog for its
|
|
27
|
+
datasets **in descending order of page views** and takes the first 500 that
|
|
28
|
+
pass four mechanical filters (the CSV downloads; at least 2 columns and 20
|
|
29
|
+
rows; at least 50 KB; not a duplicate). Rank order is public and fixed, so the
|
|
30
|
+
list reproduces, and every rejection is recorded with its reason.
|
|
31
|
+
|
|
32
|
+
**500 tables, 3.57 GB of CSV, 14.3 million rows, 10,557 columns**, against 17
|
|
33
|
+
competing codecs:
|
|
34
|
+
|
|
35
|
+
| | |
|
|
36
|
+
|---|---|
|
|
37
|
+
| Round-trips exactly | **500 of 500** |
|
|
38
|
+
| Smaller than the best of all 17 competitors | **478 of 500** |
|
|
39
|
+
| Margin over the best other tool | median **1.25x**, best 2.73x, worst 0.65x |
|
|
40
|
+
| Compression vs raw CSV | median **14.35x** |
|
|
41
|
+
|
|
42
|
+
Head to head: it beats `xz -9e`, `zstd -22`, `gzip -9` and `lz4 -9` on **all
|
|
43
|
+
500**; `parquet+zstd` on 449 of 450; `bzip2 -9` on 491 of 500; `brotli -q 11`
|
|
44
|
+
on 487 of 500.
|
|
45
|
+
|
|
46
|
+
**Two things belong next to those numbers.** Parquet, read with type inference,
|
|
47
|
+
reproduced the exact printed text on only 81 of the 500 tables — turning
|
|
48
|
+
`"1.50"` into `1.5` makes a smaller file for reasons that have nothing to do
|
|
49
|
+
with compression, and Polypress guarantees the printed cell. And the margin is
|
|
50
|
+
not a better final compressor: re-finishing the same modelled streams with the
|
|
51
|
+
competitor's own entropy coder still wins 449 of 450 against `parquet+zstd`.
|
|
52
|
+
|
|
53
|
+
## How it works
|
|
54
|
+
|
|
55
|
+
1. **Predict down a column.** Fit a low-degree polynomial to the last few
|
|
56
|
+
values, extrapolate one step, store the error. Re-centring the fit at every
|
|
57
|
+
cell means the coefficients never have to be stored — order-*k*
|
|
58
|
+
extrapolation is exactly the *k*-th finite difference.
|
|
59
|
+
2. **The same thing in two dimensions.** Where adjacent numeric columns are
|
|
60
|
+
*commensurable*, a cell is predicted from its left, upper and upper-left
|
|
61
|
+
neighbours. This is what wins on matrix-shaped tables, and no shipped
|
|
62
|
+
columnar codec does it — Gorilla, DoubleDelta, T64, zfp and fpzip all
|
|
63
|
+
predict down a single column.
|
|
64
|
+
3. **Reorder the rows so a column collapses into runs.** Rather than model that
|
|
65
|
+
`City` depends on `Postal Code`, sort by the parent: equal parents become
|
|
66
|
+
adjacent and the child collapses. **The permutation is free** — the decoder
|
|
67
|
+
has already rebuilt the parent and recomputes the same stable sort. Your
|
|
68
|
+
table comes back in its original row order.
|
|
69
|
+
|
|
70
|
+
## What it is honest about
|
|
71
|
+
|
|
72
|
+
- **It is slow to compress.** Median 2.0 MB/s encode, 134 MB/s decode. The
|
|
73
|
+
never-worse guarantee is what costs it: eligible tables are encoded twice and
|
|
74
|
+
the smaller result wins.
|
|
75
|
+
- **The core idea is not novel.** US 8,312,026 B2 (Vo, AT&T, filed 2009)
|
|
76
|
+
discloses it. It was arrived at here independently and the novelty claim was
|
|
77
|
+
withdrawn. Both relevant patents have expired.
|
|
78
|
+
- **The 2x results are matrix-shaped tables.** 1.3–1.5x is typical, and
|
|
79
|
+
text-heavy tables are the weak genre.
|
|
80
|
+
- **All 22 losses out of 500 are published**, including the one genuine
|
|
81
|
+
undiagnosed loss.
|
|
82
|
+
- **Every benchmark dataset is a government open-data table.** There is no
|
|
83
|
+
reason to assume the result transfers to other genres.
|
|
84
|
+
- **Nobody outside the project has run it.**
|
|
85
|
+
|
|
86
|
+
## Requirements
|
|
87
|
+
|
|
88
|
+
Python 3.9+ and numpy. A C compiler is optional — the accelerator compiles
|
|
89
|
+
itself on first import and falls back to numpy without one. `pyarrow` is
|
|
90
|
+
needed only for `.parquet` input and output: `pip install 'polypress[parquet]'`.
|
|
91
|
+
|
|
92
|
+
## More
|
|
93
|
+
|
|
94
|
+
Source, tests, the full benchmark harness and the committed sweep results:
|
|
95
|
+
**https://github.com/potJim80/PolyPressApp** — including `memory/LAWS.md`, the
|
|
96
|
+
findings that govern what gets built, and the README section listing everything
|
|
97
|
+
that was tried and failed.
|
|
98
|
+
|
|
99
|
+
MIT licensed.
|
|
@@ -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"
|
|
@@ -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())
|
|
@@ -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())
|