trave 1.0.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.
trave-1.0.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 edde746
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.
trave-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,18 @@
1
+ Metadata-Version: 2.4
2
+ Name: trave
3
+ Version: 1.0.0
4
+ Summary: Trave: tables of named, typed columns packed for an app to read with nothing but a gzip decoder
5
+ Author: edde746
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/edde746/trave
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Operating System :: OS Independent
10
+ Requires-Python: >=3.9
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Dynamic: license-file
14
+
15
+ # trave (Python)
16
+
17
+ The reference writer and reader of [Trave](https://github.com/edde746/trave): one module, Python 3.9 or newer,
18
+ standard library only. The format, the API and examples are in the repository's README.
trave-1.0.0/README.md ADDED
@@ -0,0 +1,4 @@
1
+ # trave (Python)
2
+
3
+ The reference writer and reader of [Trave](https://github.com/edde746/trave): one module, Python 3.9 or newer,
4
+ standard library only. The format, the API and examples are in the repository's README.
@@ -0,0 +1,20 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "trave"
7
+ version = "1.0.0"
8
+ description = "Trave: tables of named, typed columns packed for an app to read with nothing but a gzip decoder"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.9"
13
+ authors = [{ name = "edde746" }]
14
+ classifiers = ["Programming Language :: Python :: 3", "Operating System :: OS Independent"]
15
+
16
+ [project.urls]
17
+ Homepage = "https://github.com/edde746/trave"
18
+
19
+ [tool.setuptools]
20
+ py-modules = ["trave"]
trave-1.0.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,90 @@
1
+ import json
2
+ import sys
3
+ import unittest
4
+ from pathlib import Path
5
+
6
+ PYTHON = Path(__file__).resolve().parent.parent
7
+ sys.path.insert(0, str(PYTHON))
8
+ import trave # noqa: E402
9
+
10
+ VECTORS = json.loads((PYTHON.parent / "vectors.json").read_text(encoding="utf-8"))
11
+ MAGIC = VECTORS["magic"].encode("ascii")
12
+ TYPES = {name: kind for kind, name in trave.TYPE_NAMES.items()}
13
+
14
+
15
+ def expected(vector):
16
+ """The vector's tables as read() returns them."""
17
+ return {t["name"]: (t["rows"], {c["name"]: c["values"] for c in t["columns"]}) for t in vector["tables"]}
18
+
19
+
20
+ def to_pack(vector):
21
+ return [(t["name"], t["rows"], [(c["name"], TYPES[c["type"]], c["values"]) for c in t["columns"]])
22
+ for t in vector["tables"]]
23
+
24
+
25
+ class Vectors(unittest.TestCase):
26
+ def test_valid_files_read_as_expected(self):
27
+ for v in VECTORS["vectors"]:
28
+ if "error" in v:
29
+ continue
30
+ with self.subTest(v["name"]):
31
+ got = trave.read(bytes.fromhex(v["hex"]), MAGIC)
32
+ self.assertEqual(got, expected(v))
33
+ self.assertEqual(list(got), [t["name"] for t in v["tables"]]) # in the file's order
34
+
35
+ def test_canonical_files_pack_to_the_same_bytes(self):
36
+ for v in VECTORS["vectors"]:
37
+ if v.get("canonical"):
38
+ with self.subTest(v["name"]):
39
+ self.assertEqual(trave.pack(MAGIC, to_pack(v)), bytes.fromhex(v["hex"]))
40
+
41
+ def test_invalid_files_are_errors(self):
42
+ for v in VECTORS["vectors"]:
43
+ if "error" in v:
44
+ with self.subTest(v["name"]):
45
+ with self.assertRaises(ValueError):
46
+ trave.read(bytes.fromhex(v["hex"]), MAGIC)
47
+
48
+
49
+ class Compression(unittest.TestCase):
50
+ data = trave.pack(MAGIC, [("t", 2, [("s", trave.STR, ["a", None])])])
51
+
52
+ def test_gzip_is_read_and_deterministic(self):
53
+ packed = trave.gzip_bytes(self.data)
54
+ self.assertEqual(packed, trave.gzip_bytes(self.data))
55
+ self.assertEqual(trave.read(packed), trave.read(self.data))
56
+
57
+ @unittest.skipIf(trave.zstd is None, "zstd needs Python 3.14")
58
+ def test_zstd_is_read(self):
59
+ self.assertEqual(trave.read(trave.zstd.compress(self.data)), trave.read(self.data))
60
+
61
+
62
+ class Errors(unittest.TestCase):
63
+ def test_another_magic_is_an_error(self):
64
+ with self.assertRaises(ValueError):
65
+ trave.read(trave.pack(b"AAAA", []), b"BBBB")
66
+
67
+ def test_writer_refuses_what_readers_cannot_hold(self):
68
+ cases = {
69
+ "int -2^63": [("t", 1, [("i", trave.INT, [-(1 << 63)])])],
70
+ "delta over 2^63 - 1": [("t", 2, [("d", trave.INT_DELTA, [-1, (1 << 63) - 1])])],
71
+ "int_delta value -2^63": [("t", 2, [("d", trave.INT_DELTA, [-1, -(1 << 63)])])],
72
+ "list item 2^63": [("t", 1, [("l", trave.INT_LIST, [[1 << 63]])])],
73
+ "values for another row count": [("t", 2, [("i", trave.INT, [1])])],
74
+ "two columns of one name": [("t", 0, [("i", trave.INT, []), ("i", trave.STR, [])])],
75
+ "unknown type": [("t", 0, [("i", 9, [])])],
76
+ }
77
+ for name, tables in cases.items():
78
+ with self.subTest(name):
79
+ with self.assertRaises(ValueError):
80
+ trave.pack(MAGIC, tables)
81
+
82
+ def test_magic_that_reads_as_compressed_is_refused(self):
83
+ for magic in (b"\x1f\x8bAB", b"\x28\xb5\x2f\xfd", b"ABC"):
84
+ with self.subTest(magic):
85
+ with self.assertRaises(ValueError):
86
+ trave.pack(magic, [])
87
+
88
+
89
+ if __name__ == "__main__":
90
+ unittest.main()
@@ -0,0 +1,18 @@
1
+ Metadata-Version: 2.4
2
+ Name: trave
3
+ Version: 1.0.0
4
+ Summary: Trave: tables of named, typed columns packed for an app to read with nothing but a gzip decoder
5
+ Author: edde746
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/edde746/trave
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Operating System :: OS Independent
10
+ Requires-Python: >=3.9
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Dynamic: license-file
14
+
15
+ # trave (Python)
16
+
17
+ The reference writer and reader of [Trave](https://github.com/edde746/trave): one module, Python 3.9 or newer,
18
+ standard library only. The format, the API and examples are in the repository's README.
@@ -0,0 +1,9 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ trave.py
5
+ tests/test_trave.py
6
+ trave.egg-info/PKG-INFO
7
+ trave.egg-info/SOURCES.txt
8
+ trave.egg-info/dependency_links.txt
9
+ trave.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ trave
trave-1.0.0/trave.py ADDED
@@ -0,0 +1,287 @@
1
+ """Trave: tables of named, typed columns packed for an app to read with nothing but a gzip or zstd decoder.
2
+
3
+ The reference writer and reader of the README's format, Python 3.9 or newer, standard library only (zstd input needs
4
+ Python 3.14's compression.zstd). One module with no dependencies: install the `trave` package or copy this file.
5
+
6
+ pack(magic, tables) -> bytes tables: [(name, rows, [(column name, type, values)])], None for an absent value
7
+ gzip_bytes(data) -> bytes gzip -9 with no file name and no time: the same data gives the same bytes
8
+ read(data, magic=None) -> {table: (rows, {column: values})}, gzip, zstd or not compressed
9
+
10
+ Usage:
11
+ python -m trave FILE... print each file's magic, tables and columns with their sizes
12
+ """
13
+ import argparse
14
+ import gzip
15
+ import io
16
+ from pathlib import Path
17
+
18
+ try:
19
+ from compression import zstd
20
+ except ImportError:
21
+ zstd = None
22
+
23
+ VERSION = 1
24
+
25
+ # Column types, one byte each in the file. A reader skips a column of any other type.
26
+ INT, INT_DELTA, STR, DICT, INT_LIST, STR_LIST = 1, 2, 3, 4, 5, 6
27
+ TYPE_NAMES = {INT: "int", INT_DELTA: "int_delta", STR: "str", DICT: "dict", INT_LIST: "int_list", STR_LIST: "str_list"}
28
+
29
+ GZIP_MAGIC, ZSTD_MAGIC = b"\x1f\x8b", b"\x28\xb5\x2f\xfd"
30
+ U64 = 1 << 64
31
+ # An int or int_delta value, and an int_delta difference, is within ±MAX_INT; only a list item can be -2**63.
32
+ MAX_INT = (1 << 63) - 1
33
+
34
+
35
+ # --- writing ---------------------------------------------------------------------------------------------------------
36
+
37
+ def varint(n, out):
38
+ if not 0 <= n < U64:
39
+ raise ValueError(f"varint of {n}, outside 0 .. 2**64 - 1")
40
+ while n > 0x7F:
41
+ out.append(n & 0x7F | 0x80)
42
+ n >>= 7
43
+ out.append(n)
44
+
45
+
46
+ def zigzag(n):
47
+ return n * 2 if n >= 0 else -n * 2 - 1
48
+
49
+
50
+ def text(s, out):
51
+ b = s.encode("utf-8")
52
+ varint(len(b), out)
53
+ out += b
54
+
55
+
56
+ def column(kind, values):
57
+ """The column's bytes: one value per row, `None` for an absent value."""
58
+ out = bytearray()
59
+ if kind == DICT:
60
+ words = list(dict.fromkeys(v for v in values if v is not None)) # in order of first use
61
+ varint(len(words), out)
62
+ for w in words:
63
+ text(w, out)
64
+ index = {w: i for i, w in enumerate(words)}
65
+ previous = 0
66
+ for v in values:
67
+ if v is None:
68
+ out.append(0)
69
+ elif kind in (INT, INT_DELTA):
70
+ if not -MAX_INT <= v <= MAX_INT:
71
+ raise ValueError(f"value {v} outside ±(2**63 - 1)")
72
+ if kind == INT:
73
+ varint(zigzag(v) + 1, out)
74
+ else:
75
+ if not -MAX_INT <= v - previous <= MAX_INT:
76
+ raise ValueError(f"difference {v - previous} outside ±(2**63 - 1)")
77
+ varint(zigzag(v - previous) + 1, out)
78
+ previous = v
79
+ elif kind == STR:
80
+ b = v.encode("utf-8")
81
+ varint(len(b) + 1, out)
82
+ out += b
83
+ elif kind == DICT:
84
+ varint(index[v] + 1, out)
85
+ elif kind == INT_LIST:
86
+ varint(len(v) + 1, out)
87
+ for x in v:
88
+ if not -(1 << 63) <= x < 1 << 63:
89
+ raise ValueError(f"list item {x} is not a signed 64-bit integer")
90
+ varint(zigzag(x), out)
91
+ elif kind == STR_LIST:
92
+ varint(len(v) + 1, out)
93
+ for x in v:
94
+ text(x, out)
95
+ return out
96
+
97
+
98
+ def pack(magic, tables):
99
+ """tables: [(name, rows, [(column name, type, values)])] -> the file's bytes, not compressed."""
100
+ if len(magic) != 4 or magic.startswith(GZIP_MAGIC) or magic.startswith(ZSTD_MAGIC):
101
+ raise ValueError(f"magic {magic!r}: 4 bytes expected, not starting like gzip or zstd")
102
+ out = bytearray(magic)
103
+ out.append(VERSION)
104
+ varint(len(tables), out)
105
+ if len({name for name, _, _ in tables}) != len(tables):
106
+ raise ValueError("two tables of the same name")
107
+ for name, nrows, columns in tables:
108
+ text(name, out)
109
+ varint(nrows, out)
110
+ varint(len(columns), out)
111
+ if len({cname for cname, _, _ in columns}) != len(columns):
112
+ raise ValueError(f"{name}: two columns of the same name")
113
+ for cname, kind, values in columns:
114
+ if kind not in TYPE_NAMES:
115
+ raise ValueError(f"{name}.{cname}: unknown type {kind}")
116
+ if len(values) != nrows:
117
+ raise ValueError(f"{name}.{cname}: {len(values)} values for {nrows} rows")
118
+ try:
119
+ body = column(kind, values)
120
+ except ValueError as e:
121
+ raise ValueError(f"{name}.{cname}: {e}") from None
122
+ text(cname, out)
123
+ out.append(kind)
124
+ varint(len(body), out)
125
+ out += body
126
+ return bytes(out)
127
+
128
+
129
+ def gzip_bytes(data):
130
+ """gzip -9 with no file name and no time, so the same data gives the same bytes."""
131
+ buffer = io.BytesIO()
132
+ with gzip.GzipFile(filename="", mode="wb", compresslevel=9, fileobj=buffer, mtime=0) as f:
133
+ f.write(data)
134
+ return buffer.getvalue()
135
+
136
+
137
+ # --- reading ---------------------------------------------------------------------------------------------------------
138
+
139
+ class Reader:
140
+ def __init__(self, data, pos=0, end=None):
141
+ self.data, self.pos, self.end = data, pos, len(data) if end is None else end
142
+
143
+ def byte(self):
144
+ if self.pos >= self.end:
145
+ raise ValueError("cut short")
146
+ self.pos += 1
147
+ return self.data[self.pos - 1]
148
+
149
+ def varint(self):
150
+ n = shift = 0
151
+ while True:
152
+ b = self.byte()
153
+ n |= (b & 0x7F) << shift
154
+ if b < 0x80:
155
+ break
156
+ shift += 7
157
+ if shift > 63:
158
+ raise ValueError("a varint longer than 10 bytes")
159
+ if n >= U64:
160
+ raise ValueError("a varint over 2**64 - 1")
161
+ return n
162
+
163
+ def bytes(self, n):
164
+ if self.pos + n > self.end:
165
+ raise ValueError("cut short")
166
+ self.pos += n
167
+ return self.data[self.pos - n:self.pos]
168
+
169
+ def text(self):
170
+ return self.bytes(self.varint()).decode("utf-8")
171
+
172
+ def signed(self):
173
+ return unzigzag(self.varint())
174
+
175
+
176
+ def unzigzag(n):
177
+ return n >> 1 if n % 2 == 0 else -(n >> 1) - 1
178
+
179
+
180
+ def read_column(kind, nrows, r):
181
+ """A column's values, `None` for absent; `r` reads exactly the column's bytes."""
182
+ words = [r.text() for _ in range(r.varint())] if kind == DICT else None
183
+ values, previous = [], 0
184
+ for _ in range(nrows):
185
+ tag = r.varint()
186
+ if tag == 0:
187
+ values.append(None)
188
+ continue
189
+ if kind == INT:
190
+ values.append(unzigzag(tag - 1))
191
+ elif kind == INT_DELTA:
192
+ previous += unzigzag(tag - 1)
193
+ if not -MAX_INT <= previous <= MAX_INT:
194
+ raise ValueError(f"int_delta value {previous} outside ±(2**63 - 1)")
195
+ values.append(previous)
196
+ elif kind == STR:
197
+ values.append(r.bytes(tag - 1).decode("utf-8"))
198
+ elif kind == DICT:
199
+ if tag > len(words):
200
+ raise ValueError(f"dictionary string {tag - 1} of {len(words)}")
201
+ values.append(words[tag - 1])
202
+ elif kind == INT_LIST:
203
+ values.append([r.signed() for _ in range(tag - 1)])
204
+ elif kind == STR_LIST:
205
+ values.append([r.text() for _ in range(tag - 1)])
206
+ if r.pos != r.end:
207
+ raise ValueError(f"{r.end - r.pos} bytes left in a column")
208
+ return values
209
+
210
+
211
+ def unpack(data):
212
+ """The file's bytes, decompressed if they are gzip or zstd."""
213
+ if data[:2] == GZIP_MAGIC:
214
+ return gzip.decompress(data)
215
+ if data[:4] == ZSTD_MAGIC:
216
+ if zstd is None:
217
+ raise ValueError("zstd input needs Python 3.14 or newer")
218
+ return zstd.decompress(data)
219
+ return data
220
+
221
+
222
+ def read(data, magic=None):
223
+ """The file (gzip, zstd or not compressed) as {table: (rows, {column: values})}, the tables and columns in the
224
+ file's order; columns of an unknown type are skipped. With `magic`, a file with another magic is an error."""
225
+ data = unpack(data)
226
+ r = Reader(data)
227
+ found = r.bytes(4)
228
+ if magic is not None and found != magic:
229
+ raise ValueError(f"magic {found!r}, not {magic!r}")
230
+ version = r.byte()
231
+ if version != VERSION:
232
+ raise ValueError(f"version {version}, not {VERSION}")
233
+ out = {}
234
+ for _ in range(r.varint()):
235
+ name, nrows = r.text(), r.varint()
236
+ if name in out:
237
+ raise ValueError(f"two tables named {name!r}")
238
+ columns, seen = {}, set()
239
+ for _ in range(r.varint()):
240
+ cname, kind, length = r.text(), r.byte(), r.varint()
241
+ if cname in seen:
242
+ raise ValueError(f"{name}: two columns named {cname!r}")
243
+ seen.add(cname)
244
+ body = Reader(data, r.pos, r.pos + length)
245
+ r.bytes(length)
246
+ if kind in TYPE_NAMES:
247
+ try:
248
+ columns[cname] = read_column(kind, nrows, body)
249
+ except ValueError as e:
250
+ raise ValueError(f"{name}.{cname}: {e}") from None
251
+ out[name] = (nrows, columns)
252
+ if r.pos != len(data):
253
+ raise ValueError(f"{len(data) - r.pos} bytes after the last table")
254
+ return out
255
+
256
+
257
+ # --- command line ----------------------------------------------------------------------------------------------------
258
+
259
+ def dump(path):
260
+ data = Path(path).read_bytes()
261
+ raw = unpack(data)
262
+ read(raw) # the whole file is valid before anything is printed
263
+ r = Reader(raw)
264
+ magic, version = r.bytes(4), r.byte()
265
+ print(f"{path}: magic {magic.decode('ascii', 'backslashreplace')}, version {version}, "
266
+ f"{len(data):,} bytes, {len(raw):,} unpacked")
267
+ for _ in range(r.varint()):
268
+ name, nrows = r.text(), r.varint()
269
+ print(f"{name}: {nrows:,} rows")
270
+ for _ in range(r.varint()):
271
+ cname, kind, length = r.text(), r.byte(), r.varint()
272
+ r.bytes(length)
273
+ print(f" {cname:20} {TYPE_NAMES.get(kind, f'type {kind}'):10} {length:>11,} bytes")
274
+
275
+
276
+ def main():
277
+ p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
278
+ p.add_argument("files", nargs="+", type=Path)
279
+ for path in p.parse_args().files:
280
+ try:
281
+ dump(path)
282
+ except ValueError as e:
283
+ raise SystemExit(f"{path}: {e}")
284
+
285
+
286
+ if __name__ == "__main__":
287
+ main()