cozip 2026.4.29__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.
- cozip-2026.4.29/.gitignore +16 -0
- cozip-2026.4.29/PKG-INFO +7 -0
- cozip-2026.4.29/cozip/__init__.py +14 -0
- cozip-2026.4.29/cozip/_core.py +124 -0
- cozip-2026.4.29/cozip/_writer.py +295 -0
- cozip-2026.4.29/hatch_build.py +16 -0
- cozip-2026.4.29/pyproject.toml +23 -0
- cozip-2026.4.29/tests/__init__.py +1 -0
- cozip-2026.4.29/tests/test_smoke.py +343 -0
cozip-2026.4.29/PKG-INFO
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""cozip — Cloud-Optimized ZIP."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from ._core import CozipError, ffi, lib
|
|
6
|
+
from ._writer import create
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
__version__ = version("cozip")
|
|
10
|
+
except PackageNotFoundError:
|
|
11
|
+
# Running from a checkout without `pip install`.
|
|
12
|
+
__version__ = "0.0.0+unknown"
|
|
13
|
+
|
|
14
|
+
__all__ = ["CozipError", "create", "ffi", "lib", "__version__"]
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""cffi bindings to libcozip. Loads the bundled shared library at import time."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import cffi
|
|
8
|
+
|
|
9
|
+
# Status codes. Must match cozip.h.
|
|
10
|
+
COZIP_OK = 0
|
|
11
|
+
COZIP_ERR_INVALID_LFH = 1
|
|
12
|
+
COZIP_ERR_ARCHIVE_TOO_SMALL = 2
|
|
13
|
+
COZIP_ERR_INVALID_ARGUMENT = 100
|
|
14
|
+
COZIP_ERR_BUFFER_TOO_SMALL = 101
|
|
15
|
+
COZIP_ERR_IO = 102
|
|
16
|
+
|
|
17
|
+
# Profile selector for cozip_build_index_payload.
|
|
18
|
+
COZIP_PROFILE_NONE = 0
|
|
19
|
+
COZIP_PROFILE_FLAT = 1
|
|
20
|
+
COZIP_PROFILE_TACO = 2
|
|
21
|
+
|
|
22
|
+
# Source kind for cozip_entry_t.source.kind.
|
|
23
|
+
COZIP_SOURCE_NONE = 0
|
|
24
|
+
COZIP_SOURCE_PATH = 1
|
|
25
|
+
COZIP_SOURCE_BUFFER = 2
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
ffi = cffi.FFI()
|
|
29
|
+
ffi.cdef("""
|
|
30
|
+
typedef struct { int code; char message[192]; } cozip_error_t;
|
|
31
|
+
|
|
32
|
+
typedef struct {
|
|
33
|
+
int kind;
|
|
34
|
+
union {
|
|
35
|
+
const char* path;
|
|
36
|
+
struct { const uint8_t* data; size_t size; } buffer;
|
|
37
|
+
} u;
|
|
38
|
+
} cozip_source_t;
|
|
39
|
+
|
|
40
|
+
typedef struct {
|
|
41
|
+
const char* arc_name;
|
|
42
|
+
uint64_t payload_size;
|
|
43
|
+
bool in_index;
|
|
44
|
+
cozip_source_t source;
|
|
45
|
+
uint64_t lfh_offset, lfh_size, payload_offset;
|
|
46
|
+
} cozip_entry_t;
|
|
47
|
+
|
|
48
|
+
const char* cozip_status_string(int status);
|
|
49
|
+
|
|
50
|
+
int cozip_plan(cozip_entry_t*, size_t, cozip_error_t*);
|
|
51
|
+
int cozip_index_payload_size(const cozip_entry_t*, size_t,
|
|
52
|
+
size_t*, cozip_error_t*);
|
|
53
|
+
int cozip_build_index_payload(const cozip_entry_t*, size_t, int,
|
|
54
|
+
uint8_t*, size_t, cozip_error_t*);
|
|
55
|
+
int cozip_write_archive(const char*, const cozip_entry_t*, size_t,
|
|
56
|
+
const uint8_t*, size_t, cozip_error_t*);
|
|
57
|
+
int cozip_patch_integrity_hash(const char*, size_t, cozip_error_t*);
|
|
58
|
+
""")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _lib_filename() -> str:
|
|
62
|
+
"""Returns the shared library filename for the current platform.
|
|
63
|
+
|
|
64
|
+
No 'lib' prefix anywhere because the CMakeLists sets PREFIX ""
|
|
65
|
+
so the artifact name is identical across Linux/macOS/Windows.
|
|
66
|
+
"""
|
|
67
|
+
if sys.platform == "darwin":
|
|
68
|
+
return "cozip.dylib"
|
|
69
|
+
if sys.platform == "win32":
|
|
70
|
+
return "cozip.dll"
|
|
71
|
+
return "cozip.so" # linux + bsd
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _resolve_lib_path() -> str:
|
|
75
|
+
"""Searches for the shared library in several candidate locations."""
|
|
76
|
+
name = _lib_filename()
|
|
77
|
+
|
|
78
|
+
# 1. Env var override (for debug / development).
|
|
79
|
+
env = os.environ.get("COZIP_LIB_PATH")
|
|
80
|
+
if env:
|
|
81
|
+
if not Path(env).exists():
|
|
82
|
+
raise ImportError(f"cozip: COZIP_LIB_PATH={env!r} does not exist")
|
|
83
|
+
return env
|
|
84
|
+
|
|
85
|
+
# 2. Canonical path, next to this file, under _lib/.
|
|
86
|
+
here = Path(__file__).resolve().parent
|
|
87
|
+
canonical = here / "_lib" / name
|
|
88
|
+
if canonical.exists():
|
|
89
|
+
return str(canonical)
|
|
90
|
+
|
|
91
|
+
# 3. Fallback to any neighboring build/ dir (dev mode without reinstall).
|
|
92
|
+
repo = here.parent.parent
|
|
93
|
+
for build_dir in (repo / "python" / "build", repo / "build"):
|
|
94
|
+
if build_dir.is_dir():
|
|
95
|
+
for found in build_dir.rglob(name):
|
|
96
|
+
return str(found)
|
|
97
|
+
|
|
98
|
+
raise ImportError(
|
|
99
|
+
f"cozip: native library {name!r} not found.\n"
|
|
100
|
+
f" Tried: {canonical}\n"
|
|
101
|
+
f" Fix: run `pip install -e python/` from the repo root, "
|
|
102
|
+
f"or set COZIP_LIB_PATH=/abs/path/to/{name}"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
lib = ffi.dlopen(_resolve_lib_path())
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class CozipError(RuntimeError):
|
|
110
|
+
"""Raised when libcozip returns a non-zero status."""
|
|
111
|
+
|
|
112
|
+
def __init__(self, code: int, name: str, message: str):
|
|
113
|
+
super().__init__(f"[{name}] {message}")
|
|
114
|
+
self.code = code
|
|
115
|
+
self.name = name
|
|
116
|
+
self.message = message
|
|
117
|
+
|
|
118
|
+
@classmethod
|
|
119
|
+
def from_struct(cls, err) -> "CozipError":
|
|
120
|
+
"""Builds the exception from a cozip_error_t struct."""
|
|
121
|
+
code = int(err.code)
|
|
122
|
+
msg = ffi.string(err.message).decode("utf-8", errors="replace")
|
|
123
|
+
name = ffi.string(lib.cozip_status_string(code)).decode("ascii")
|
|
124
|
+
return cls(code, name, msg)
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""High-level archive writer (PROFILE_FLAT only, path sources only).
|
|
2
|
+
|
|
3
|
+
Walks the libcozip pipeline end-to-end and drops a `__metadata__.parquet`
|
|
4
|
+
at the tail with a row per indexed user file. Extra columns from the
|
|
5
|
+
input table flow through to the parquet untouched.
|
|
6
|
+
|
|
7
|
+
For TACO/NONE profiles or buffer sources, drop down to the low-level
|
|
8
|
+
API: `cozip.lib.cozip_build_index_payload` etc.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import tempfile
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import pyarrow as pa
|
|
16
|
+
import pyarrow.parquet as pq
|
|
17
|
+
|
|
18
|
+
from ._core import (
|
|
19
|
+
COZIP_PROFILE_FLAT,
|
|
20
|
+
COZIP_SOURCE_PATH,
|
|
21
|
+
CozipError,
|
|
22
|
+
ffi,
|
|
23
|
+
lib,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Names the writer reserves for itself; users cannot use them.
|
|
27
|
+
_RESERVED_NAMES = frozenset({"__cozip__", "__metadata__"})
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _check(status: int, err: Any) -> None:
|
|
31
|
+
"""Raise a CozipError if a libcozip call returned a non-zero status.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
status (int): Return code from a libcozip function call.
|
|
35
|
+
err (Any): Pointer to a populated `cozip_error_t` struct.
|
|
36
|
+
|
|
37
|
+
Raises:
|
|
38
|
+
CozipError: When `status != 0`, built from the error struct.
|
|
39
|
+
"""
|
|
40
|
+
if status != 0:
|
|
41
|
+
raise CozipError.from_struct(err)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _build_metadata_parquet(
|
|
45
|
+
table: pa.Table,
|
|
46
|
+
entries: Any,
|
|
47
|
+
n_users: int,
|
|
48
|
+
in_idx: list[bool],
|
|
49
|
+
create_options: dict | None,
|
|
50
|
+
temp_dir: str | Path | None,
|
|
51
|
+
) -> Path:
|
|
52
|
+
"""Build the `__metadata__.parquet` temp file.
|
|
53
|
+
|
|
54
|
+
Filters the input table to in-index rows, drops the columns the
|
|
55
|
+
spec considers writer-private (`path`, `in_index`), and appends
|
|
56
|
+
`offset` (uint64) and `size` (uint64) computed by `cozip_plan`.
|
|
57
|
+
The output column order is `name, offset, size, ...` followed by
|
|
58
|
+
every other user-supplied column in input order.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
table (pa.Table): Original input table from the caller.
|
|
62
|
+
entries (Any): cffi `cozip_entry_t[]` array, post-`cozip_plan`,
|
|
63
|
+
so each entry's `payload_offset` and `payload_size` are set.
|
|
64
|
+
n_users (int): Number of user entries (excludes `__metadata__`,
|
|
65
|
+
which sits at index `n_users`).
|
|
66
|
+
in_idx (list[bool]): Per-user-row indexing flag, length `n_users`.
|
|
67
|
+
create_options (dict | None): kwargs forwarded to
|
|
68
|
+
`pyarrow.parquet.write_table`.
|
|
69
|
+
temp_dir (str | Path | None): Directory for the temp parquet.
|
|
70
|
+
None means use `tempfile.gettempdir()` (which respects the
|
|
71
|
+
`TMPDIR` env var).
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
Path: Filesystem path of the written `.parquet` file. The caller
|
|
75
|
+
is responsible for deleting it.
|
|
76
|
+
"""
|
|
77
|
+
indexed = [i for i in range(n_users) if in_idx[i]]
|
|
78
|
+
offsets = [int(entries[i].payload_offset) for i in indexed]
|
|
79
|
+
sizes = [int(entries[i].payload_size) for i in indexed]
|
|
80
|
+
|
|
81
|
+
mask = pa.array(in_idx, type=pa.bool_())
|
|
82
|
+
meta = table.filter(mask)
|
|
83
|
+
drop = [c for c in ("path", "in_index") if c in meta.column_names]
|
|
84
|
+
if drop:
|
|
85
|
+
meta = meta.drop_columns(drop)
|
|
86
|
+
|
|
87
|
+
meta = meta.append_column("offset", pa.array(offsets, type=pa.uint64()))
|
|
88
|
+
meta = meta.append_column("size", pa.array(sizes, type=pa.uint64()))
|
|
89
|
+
|
|
90
|
+
# Column order: name, offset, size, ...rest-in-input-order.
|
|
91
|
+
rest = [c for c in meta.column_names if c not in ("name", "offset", "size")]
|
|
92
|
+
meta = meta.select(["name", "offset", "size"] + rest)
|
|
93
|
+
|
|
94
|
+
if temp_dir is not None:
|
|
95
|
+
Path(temp_dir).mkdir(parents=True, exist_ok=True)
|
|
96
|
+
|
|
97
|
+
tmp = tempfile.NamedTemporaryFile(
|
|
98
|
+
suffix=".parquet",
|
|
99
|
+
dir=str(temp_dir) if temp_dir is not None else None,
|
|
100
|
+
delete=False,
|
|
101
|
+
)
|
|
102
|
+
tmp.close()
|
|
103
|
+
parquet_path = Path(tmp.name)
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
pq.write_table(meta, str(parquet_path), **(create_options or {}))
|
|
107
|
+
except Exception:
|
|
108
|
+
parquet_path.unlink(missing_ok=True)
|
|
109
|
+
raise
|
|
110
|
+
|
|
111
|
+
return parquet_path
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def create(
|
|
115
|
+
out_path: str | Path,
|
|
116
|
+
table: pa.Table,
|
|
117
|
+
create_options: dict | None = None,
|
|
118
|
+
temp_dir: str | Path | None = None,
|
|
119
|
+
) -> str:
|
|
120
|
+
"""Build a FLAT-profile .cozip archive on disk.
|
|
121
|
+
|
|
122
|
+
Walks the libcozip pipeline end-to-end: plan offsets, drop a
|
|
123
|
+
`__metadata__.parquet` describing the indexed user files, serialize
|
|
124
|
+
the cozip index, write the archive via libzip, and patch the
|
|
125
|
+
FNV-1a 64 integrity hash. The output file is overwritten if it
|
|
126
|
+
exists. The temp parquet is always cleaned up.
|
|
127
|
+
|
|
128
|
+
The metadata parquet contains: `name` (str), `offset` (uint64),
|
|
129
|
+
`size` (uint64), plus every additional column from `table` other
|
|
130
|
+
than `path` and `in_index`. Only rows with `in_index=True` are
|
|
131
|
+
listed.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
out_path (str | Path): Destination file path. Resolved to an
|
|
135
|
+
absolute path before writing.
|
|
136
|
+
table (pa.Table): Source entries. Must provide 'name' (str) and
|
|
137
|
+
'path' (str). Optional 'in_index' (bool, default True)
|
|
138
|
+
controls whether the entry is listed in the cozip index
|
|
139
|
+
and the metadata parquet. Any other columns flow through
|
|
140
|
+
to the metadata parquet.
|
|
141
|
+
create_options (dict | None, optional): Forwarded as kwargs to
|
|
142
|
+
`pyarrow.parquet.write_table` when writing the metadata
|
|
143
|
+
parquet. Use this to set compression, row_group_size, etc.
|
|
144
|
+
Defaults to None (pyarrow defaults).
|
|
145
|
+
temp_dir (str | Path | None, optional): Directory where the
|
|
146
|
+
temporary metadata parquet is written before being absorbed
|
|
147
|
+
into the archive. Created if missing. Defaults to None,
|
|
148
|
+
which uses `tempfile.gettempdir()` (respects the `TMPDIR`
|
|
149
|
+
env var, useful when /tmp is restricted or full).
|
|
150
|
+
|
|
151
|
+
Raises:
|
|
152
|
+
ValueError: A required column is missing, the table is empty,
|
|
153
|
+
a row uses a reserved name, or names contain duplicates.
|
|
154
|
+
FileNotFoundError: A row's source path does not exist.
|
|
155
|
+
CozipError: A libcozip call failed (I/O, allocation, malformed
|
|
156
|
+
archive output, etc.).
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
str: Absolute path of the created archive.
|
|
160
|
+
"""
|
|
161
|
+
cols = table.column_names
|
|
162
|
+
missing = {"name", "path"} - set(cols)
|
|
163
|
+
if missing:
|
|
164
|
+
raise ValueError(
|
|
165
|
+
f"cozip.create: table is missing required column(s): "
|
|
166
|
+
f"{sorted(missing)}"
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
n_users = len(table)
|
|
170
|
+
if n_users == 0:
|
|
171
|
+
raise ValueError("cozip.create: empty entry list")
|
|
172
|
+
|
|
173
|
+
names = table.column("name").to_pylist()
|
|
174
|
+
paths = table.column("path").to_pylist()
|
|
175
|
+
in_idx = (
|
|
176
|
+
table.column("in_index").to_pylist()
|
|
177
|
+
if "in_index" in cols
|
|
178
|
+
else [True] * n_users
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
# Reserved names + duplicates: format-level rules belong to the
|
|
182
|
+
# binding, not to libcozip (cozip.c only protects memory).
|
|
183
|
+
seen: set[str] = set()
|
|
184
|
+
for i, name in enumerate(names):
|
|
185
|
+
if name in _RESERVED_NAMES:
|
|
186
|
+
raise ValueError(
|
|
187
|
+
f"cozip.create: row {i} uses reserved name {name!r}"
|
|
188
|
+
)
|
|
189
|
+
if name in seen:
|
|
190
|
+
raise ValueError(
|
|
191
|
+
f"cozip.create: duplicate name {name!r} at row {i}"
|
|
192
|
+
)
|
|
193
|
+
seen.add(name)
|
|
194
|
+
|
|
195
|
+
out_path = str(Path(out_path).resolve())
|
|
196
|
+
|
|
197
|
+
# Allocate one extra slot for the __metadata__ entry, which always
|
|
198
|
+
# sits last so its size never shifts the offsets of user entries.
|
|
199
|
+
n_total = n_users + 1
|
|
200
|
+
entries = ffi.new(f"cozip_entry_t[{n_total}]")
|
|
201
|
+
keepalive: list[Any] = []
|
|
202
|
+
|
|
203
|
+
for i in range(n_users):
|
|
204
|
+
path = Path(paths[i])
|
|
205
|
+
if not path.exists():
|
|
206
|
+
raise FileNotFoundError(
|
|
207
|
+
f"cozip.create: row {i} ({names[i]!r}): "
|
|
208
|
+
f"source file not found: {path}"
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
name_c = ffi.new("char[]", names[i].encode("utf-8"))
|
|
212
|
+
path_c = ffi.new("char[]", str(path).encode("utf-8"))
|
|
213
|
+
keepalive.extend((name_c, path_c))
|
|
214
|
+
|
|
215
|
+
entries[i].arc_name = name_c
|
|
216
|
+
entries[i].payload_size = path.stat().st_size
|
|
217
|
+
entries[i].in_index = bool(in_idx[i])
|
|
218
|
+
entries[i].source.kind = COZIP_SOURCE_PATH
|
|
219
|
+
entries[i].source.u.path = path_c
|
|
220
|
+
|
|
221
|
+
# __metadata__ entry: real size and source path are unknown until
|
|
222
|
+
# we generate the parquet. Use 0/NULL placeholders for the first
|
|
223
|
+
# cozip_plan call.
|
|
224
|
+
meta_idx = n_users
|
|
225
|
+
meta_name_c = ffi.new("char[]", b"__metadata__")
|
|
226
|
+
keepalive.append(meta_name_c)
|
|
227
|
+
|
|
228
|
+
entries[meta_idx].arc_name = meta_name_c
|
|
229
|
+
entries[meta_idx].payload_size = 0
|
|
230
|
+
entries[meta_idx].in_index = True
|
|
231
|
+
entries[meta_idx].source.kind = COZIP_SOURCE_PATH
|
|
232
|
+
entries[meta_idx].source.u.path = ffi.NULL
|
|
233
|
+
|
|
234
|
+
err = ffi.new("cozip_error_t*")
|
|
235
|
+
|
|
236
|
+
# 1. First plan: produces correct payload_offset/size for every
|
|
237
|
+
# user entry. The __metadata__ slot's offset is also correct
|
|
238
|
+
# (it sits last and its placeholder size doesn't perturb anyone
|
|
239
|
+
# earlier in the archive).
|
|
240
|
+
_check(lib.cozip_plan(entries, n_total, err), err)
|
|
241
|
+
|
|
242
|
+
# 2. Build the metadata parquet using the user offsets we just got.
|
|
243
|
+
parquet_path = _build_metadata_parquet(
|
|
244
|
+
table, entries, n_users, in_idx, create_options, temp_dir
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
try:
|
|
248
|
+
# 3. Now we know the real parquet size — patch it into the
|
|
249
|
+
# __metadata__ entry and re-plan. Re-planning is cheap and
|
|
250
|
+
# handles the rare case where the parquet crosses 4 GiB and
|
|
251
|
+
# needs a ZIP64 LFH extra (which would shift the metadata
|
|
252
|
+
# payload_offset by 20 bytes; user offsets stay untouched).
|
|
253
|
+
parquet_size = parquet_path.stat().st_size
|
|
254
|
+
meta_path_c = ffi.new("char[]", str(parquet_path).encode("utf-8"))
|
|
255
|
+
keepalive.append(meta_path_c)
|
|
256
|
+
|
|
257
|
+
entries[meta_idx].payload_size = parquet_size
|
|
258
|
+
entries[meta_idx].source.u.path = meta_path_c
|
|
259
|
+
|
|
260
|
+
_check(lib.cozip_plan(entries, n_total, err), err)
|
|
261
|
+
|
|
262
|
+
# 4. Size the cozip index payload buffer.
|
|
263
|
+
idx_size = ffi.new("size_t*")
|
|
264
|
+
_check(
|
|
265
|
+
lib.cozip_index_payload_size(entries, n_total, idx_size, err),
|
|
266
|
+
err,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
# 5. Serialize the cozip index payload (profile = FLAT).
|
|
270
|
+
payload = ffi.new(f"uint8_t[{idx_size[0]}]")
|
|
271
|
+
_check(
|
|
272
|
+
lib.cozip_build_index_payload(
|
|
273
|
+
entries, n_total, COZIP_PROFILE_FLAT, payload, idx_size[0], err
|
|
274
|
+
),
|
|
275
|
+
err,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
# 6. Write the archive (libzip-backed).
|
|
279
|
+
out_path_b = out_path.encode("utf-8")
|
|
280
|
+
_check(
|
|
281
|
+
lib.cozip_write_archive(
|
|
282
|
+
out_path_b, entries, n_total, payload, idx_size[0], err
|
|
283
|
+
),
|
|
284
|
+
err,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
# 7. Patch the FNV-1a 64 integrity hash into bytes 43..50.
|
|
288
|
+
_check(
|
|
289
|
+
lib.cozip_patch_integrity_hash(out_path_b, idx_size[0], err),
|
|
290
|
+
err,
|
|
291
|
+
)
|
|
292
|
+
finally:
|
|
293
|
+
parquet_path.unlink(missing_ok=True)
|
|
294
|
+
|
|
295
|
+
return out_path
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Tag the wheel as platform-specific but ABI-agnostic.
|
|
2
|
+
|
|
3
|
+
cffi ABI mode loads the native library via libffi at runtime, so the
|
|
4
|
+
wheel works on any Python 3.x — but the bundled .dylib is for one
|
|
5
|
+
specific OS+arch.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CustomBuildHook(BuildHookInterface):
|
|
12
|
+
def initialize(self, version: str, build_data: dict) -> None:
|
|
13
|
+
# Force a platform-tagged wheel (e.g. py3-none-macosx_11_0_arm64)
|
|
14
|
+
# instead of the default py3-none-any.
|
|
15
|
+
build_data["pure_python"] = False
|
|
16
|
+
build_data["infer_tag"] = True
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "cozip"
|
|
7
|
+
version = "2026.4.29"
|
|
8
|
+
description = "Cloud-Optimized ZIP — Python bindings"
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
dependencies = ["cffi>=1.16", "pyarrow>=14"]
|
|
11
|
+
|
|
12
|
+
[tool.hatch.build.targets.wheel]
|
|
13
|
+
packages = ["cozip"]
|
|
14
|
+
# Include the prebuilt native lib in the wheel.
|
|
15
|
+
artifacts = ["cozip/_lib/*.dylib", "cozip/_lib/*.so", "cozip/_lib/*.dll"]
|
|
16
|
+
|
|
17
|
+
# Mark the wheel as platform-specific (because of the bundled .dylib),
|
|
18
|
+
# but ABI-agnostic (cffi ABI mode works on any Python 3.x).
|
|
19
|
+
[tool.hatch.build.targets.wheel.hooks.custom]
|
|
20
|
+
path = "hatch_build.py"
|
|
21
|
+
|
|
22
|
+
[tool.pytest.ini_options]
|
|
23
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# empty — just so pytest recognizes the tests/ package
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"""Smoke + roundtrip tests for the cozip Python binding."""
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import struct
|
|
5
|
+
import zipfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pyarrow as pa
|
|
9
|
+
import pyarrow.parquet as pq
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
import cozip
|
|
13
|
+
|
|
14
|
+
# Optional geo stack — only needed for the geoparquet test.
|
|
15
|
+
try:
|
|
16
|
+
import geopandas as gpd
|
|
17
|
+
from shapely.geometry import Point
|
|
18
|
+
HAS_GEO = True
|
|
19
|
+
except ImportError:
|
|
20
|
+
HAS_GEO = False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------- bindings
|
|
24
|
+
|
|
25
|
+
def test_library_loads():
|
|
26
|
+
"""The shared library loads and exposes the expected symbols."""
|
|
27
|
+
assert cozip.lib is not None
|
|
28
|
+
for sym in (
|
|
29
|
+
"cozip_plan",
|
|
30
|
+
"cozip_index_payload_size",
|
|
31
|
+
"cozip_build_index_payload",
|
|
32
|
+
"cozip_write_archive",
|
|
33
|
+
"cozip_patch_integrity_hash",
|
|
34
|
+
):
|
|
35
|
+
assert hasattr(cozip.lib, sym), f"missing symbol: {sym}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_error_struct():
|
|
39
|
+
"""cozip_error_t can be instantiated and read back."""
|
|
40
|
+
err = cozip.ffi.new("cozip_error_t*")
|
|
41
|
+
err.code = 42
|
|
42
|
+
cozip.ffi.memmove(err.message, b"oops\x00", 5)
|
|
43
|
+
assert err.code == 42
|
|
44
|
+
assert cozip.ffi.string(err.message).decode() == "oops"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ---------------------------------------------------------------- helpers
|
|
48
|
+
|
|
49
|
+
def _make_payload(seed: int, size: int) -> bytes:
|
|
50
|
+
"""Deterministic byte payload — same seed always yields same bytes."""
|
|
51
|
+
return bytes([(seed + i) & 0xFF for i in range(size)])
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------------- roundtrip
|
|
55
|
+
|
|
56
|
+
def test_roundtrip(tmp_path: Path):
|
|
57
|
+
"""End-to-end: create a .cozip archive and validate every layer.
|
|
58
|
+
|
|
59
|
+
Validates:
|
|
60
|
+
1. archive exists and clears the spec minimum size (>= 32819)
|
|
61
|
+
2. zipfile sees a valid ZIP with __cozip__ first and __metadata__ present
|
|
62
|
+
3. LFH layout for the __cozip__ entry (filename len, extra len, 0xCA0C)
|
|
63
|
+
4. integrity hash (bytes 43..50) is non-zero
|
|
64
|
+
5. CZIP magic at byte 51
|
|
65
|
+
6. cozip index lists 4 entries (3 users + __metadata__)
|
|
66
|
+
7. user payloads round-trip byte-for-byte through zipfile
|
|
67
|
+
8. __metadata__.parquet has the expected schema and offsets
|
|
68
|
+
"""
|
|
69
|
+
sizes = [12000, 13000, 14000]
|
|
70
|
+
src_files: list[Path] = []
|
|
71
|
+
src_payloads: list[bytes] = []
|
|
72
|
+
for i, sz in enumerate(sizes):
|
|
73
|
+
p = tmp_path / f"src_{i}.bin"
|
|
74
|
+
payload = _make_payload(0xA0 + i, sz)
|
|
75
|
+
p.write_bytes(payload)
|
|
76
|
+
src_files.append(p)
|
|
77
|
+
src_payloads.append(payload)
|
|
78
|
+
|
|
79
|
+
arc_names = [f"data/file_{i}.bin" for i in range(3)]
|
|
80
|
+
table = pa.table({
|
|
81
|
+
"name": arc_names,
|
|
82
|
+
"path": [str(p) for p in src_files],
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
out = tmp_path / "out.cozip"
|
|
86
|
+
returned = cozip.create(out, table)
|
|
87
|
+
|
|
88
|
+
# 1. exists + size >= minimum
|
|
89
|
+
assert Path(returned).exists()
|
|
90
|
+
assert out.exists()
|
|
91
|
+
archive_size = out.stat().st_size
|
|
92
|
+
assert archive_size >= 32819, f"archive too small: {archive_size}"
|
|
93
|
+
|
|
94
|
+
# 2. valid ZIP, first entry __cozip__, __metadata__ present
|
|
95
|
+
with zipfile.ZipFile(out, "r") as z:
|
|
96
|
+
names = z.namelist()
|
|
97
|
+
infos = z.infolist()
|
|
98
|
+
assert names[0] == "__cozip__", f"first entry is {names[0]!r}"
|
|
99
|
+
assert "__metadata__" in names, "__metadata__ entry missing"
|
|
100
|
+
for arc in arc_names:
|
|
101
|
+
assert arc in names, f"missing entry: {arc}"
|
|
102
|
+
|
|
103
|
+
cozip_info = next(i for i in infos if i.filename == "__cozip__")
|
|
104
|
+
assert cozip_info.compress_type == zipfile.ZIP_STORED
|
|
105
|
+
|
|
106
|
+
# 3. inspect raw bytes for the __cozip__ LFH
|
|
107
|
+
raw = out.read_bytes()
|
|
108
|
+
assert raw[:4] == b"PK\x03\x04", "missing local file header signature"
|
|
109
|
+
|
|
110
|
+
fname_len = struct.unpack_from("<H", raw, 26)[0]
|
|
111
|
+
extra_len = struct.unpack_from("<H", raw, 28)[0]
|
|
112
|
+
assert fname_len == 9, f"__cozip__ name length: {fname_len}"
|
|
113
|
+
assert extra_len == 12, f"0xCA0C extra length: {extra_len}"
|
|
114
|
+
assert raw[30:39] == b"__cozip__"
|
|
115
|
+
|
|
116
|
+
extra_id = struct.unpack_from("<H", raw, 39)[0]
|
|
117
|
+
assert extra_id == 0xCA0C, f"unexpected extra id: 0x{extra_id:04X}"
|
|
118
|
+
assert struct.unpack_from("<H", raw, 41)[0] == 8
|
|
119
|
+
|
|
120
|
+
# 4. hash patched (non-zero)
|
|
121
|
+
assert raw[43:51] != b"\x00" * 8, "integrity hash was not patched"
|
|
122
|
+
|
|
123
|
+
# 5. CZIP magic at byte 51
|
|
124
|
+
assert raw[51:55] == b"CZIP", f"bad magic: {raw[51:55]!r}"
|
|
125
|
+
|
|
126
|
+
# 6. index header: version, profile, n_entries
|
|
127
|
+
version = struct.unpack_from("<H", raw, 55)[0]
|
|
128
|
+
profile = raw[57]
|
|
129
|
+
n_index = struct.unpack_from("<I", raw, 58)[0]
|
|
130
|
+
assert version == 1
|
|
131
|
+
assert profile == 1, f"profile must be FLAT (1), got {profile}"
|
|
132
|
+
assert n_index == 4, f"index count: expected 4 (3 users + meta), got {n_index}"
|
|
133
|
+
|
|
134
|
+
# 7. user payloads round-trip via zipfile
|
|
135
|
+
with zipfile.ZipFile(out, "r") as z:
|
|
136
|
+
for arc, expected in zip(arc_names, src_payloads):
|
|
137
|
+
assert z.read(arc) == expected, f"payload mismatch for {arc}"
|
|
138
|
+
|
|
139
|
+
# 8. __metadata__.parquet schema and content
|
|
140
|
+
with zipfile.ZipFile(out, "r") as z:
|
|
141
|
+
meta_bytes = z.read("__metadata__")
|
|
142
|
+
meta = pq.read_table(io.BytesIO(meta_bytes))
|
|
143
|
+
|
|
144
|
+
assert meta.column_names[:3] == ["name", "offset", "size"]
|
|
145
|
+
assert "path" not in meta.column_names, "path must not appear in __metadata__"
|
|
146
|
+
assert "in_index" not in meta.column_names, "in_index must not appear in __metadata__"
|
|
147
|
+
assert len(meta) == 3, f"expected 3 rows in __metadata__, got {len(meta)}"
|
|
148
|
+
|
|
149
|
+
# offsets + sizes in the parquet must point to the actual user payloads
|
|
150
|
+
meta_names = meta.column("name").to_pylist()
|
|
151
|
+
meta_offsets = meta.column("offset").to_pylist()
|
|
152
|
+
meta_sizes = meta.column("size").to_pylist()
|
|
153
|
+
for arc, expected in zip(arc_names, src_payloads):
|
|
154
|
+
idx = meta_names.index(arc)
|
|
155
|
+
off = int(meta_offsets[idx])
|
|
156
|
+
sz = int(meta_sizes[idx])
|
|
157
|
+
assert raw[off:off + sz] == expected, f"offset/size wrong for {arc}"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_roundtrip_in_index_false(tmp_path: Path):
|
|
161
|
+
"""Entries with in_index=False are written but not listed in the index.
|
|
162
|
+
|
|
163
|
+
The cozip index still gets __metadata__ entry, so n_index = 1 + 1 = 2
|
|
164
|
+
when only one user is in_index=True.
|
|
165
|
+
"""
|
|
166
|
+
src = tmp_path / "small.bin"
|
|
167
|
+
big = tmp_path / "big.bin"
|
|
168
|
+
src.write_bytes(_make_payload(0x10, 100))
|
|
169
|
+
big.write_bytes(_make_payload(0x20, 33000)) # alone clears the 32 KiB minimum
|
|
170
|
+
|
|
171
|
+
table = pa.table({
|
|
172
|
+
"name": ["a.bin", "b.bin"],
|
|
173
|
+
"path": [str(src), str(big)],
|
|
174
|
+
"in_index": [True, False],
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
out = tmp_path / "out.cozip"
|
|
178
|
+
cozip.create(out, table)
|
|
179
|
+
|
|
180
|
+
raw = out.read_bytes()
|
|
181
|
+
n_index = struct.unpack_from("<I", raw, 58)[0]
|
|
182
|
+
assert n_index == 2, f"expected a.bin + __metadata__ in index, got {n_index}"
|
|
183
|
+
|
|
184
|
+
with zipfile.ZipFile(out, "r") as z:
|
|
185
|
+
names = z.namelist()
|
|
186
|
+
assert "a.bin" in names
|
|
187
|
+
assert "b.bin" in names
|
|
188
|
+
assert "__metadata__" in names
|
|
189
|
+
|
|
190
|
+
# The metadata parquet must NOT list b.bin (in_index=False).
|
|
191
|
+
with zipfile.ZipFile(out, "r") as z:
|
|
192
|
+
meta_bytes = z.read("__metadata__")
|
|
193
|
+
meta = pq.read_table(io.BytesIO(meta_bytes))
|
|
194
|
+
meta_names = meta.column("name").to_pylist()
|
|
195
|
+
assert meta_names == ["a.bin"], f"expected only a.bin in metadata, got {meta_names}"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# ---------------------------------------------------------------- geoparquet
|
|
199
|
+
|
|
200
|
+
@pytest.mark.skipif(not HAS_GEO, reason="geopandas/shapely not installed")
|
|
201
|
+
def test_geoparquet_metadata_roundtrip(tmp_path: Path):
|
|
202
|
+
"""A GeoDataFrame as input survives as a GeoParquet inside __metadata__.
|
|
203
|
+
|
|
204
|
+
Validates:
|
|
205
|
+
1. user-supplied geometry + CRS reach the metadata parquet
|
|
206
|
+
2. extra user columns flow through unchanged
|
|
207
|
+
3. offsets in the metadata parquet point at the correct user payloads
|
|
208
|
+
4. GeoPandas can read __metadata__ directly from the zip
|
|
209
|
+
"""
|
|
210
|
+
n = 3
|
|
211
|
+
src_files: list[Path] = []
|
|
212
|
+
src_payloads: list[bytes] = []
|
|
213
|
+
for i in range(n):
|
|
214
|
+
p = tmp_path / f"src_{i}.bin"
|
|
215
|
+
payload = _make_payload(0xA0 + i, 12000 + i * 1000)
|
|
216
|
+
p.write_bytes(payload)
|
|
217
|
+
src_files.append(p)
|
|
218
|
+
src_payloads.append(payload)
|
|
219
|
+
|
|
220
|
+
arc_names = [f"data/file_{i}.bin" for i in range(n)]
|
|
221
|
+
countries = ["Peru", "Chile", "Brazil"]
|
|
222
|
+
elevations = [3000, 4500, 1200]
|
|
223
|
+
points = [
|
|
224
|
+
Point(-77.0, -12.0), # Lima
|
|
225
|
+
Point(-70.7, -33.4), # Santiago
|
|
226
|
+
Point(-47.9, -15.7), # Brasilia
|
|
227
|
+
]
|
|
228
|
+
|
|
229
|
+
gdf = gpd.GeoDataFrame(
|
|
230
|
+
{
|
|
231
|
+
"name": arc_names,
|
|
232
|
+
"path": [str(p) for p in src_files],
|
|
233
|
+
"country": countries,
|
|
234
|
+
"elevation": elevations,
|
|
235
|
+
"geometry": points,
|
|
236
|
+
},
|
|
237
|
+
crs="EPSG:4326",
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
# GDF -> parquet -> pa.Table preserves the b"geo" schema metadata that
|
|
241
|
+
# GeoParquet readers (incl. GeoPandas) look for.
|
|
242
|
+
in_parquet = tmp_path / "input.parquet"
|
|
243
|
+
gdf.to_parquet(in_parquet)
|
|
244
|
+
table = pq.read_table(in_parquet)
|
|
245
|
+
|
|
246
|
+
assert table.schema.metadata is not None
|
|
247
|
+
assert b"geo" in table.schema.metadata, \
|
|
248
|
+
"input table should carry the b'geo' schema metadata"
|
|
249
|
+
|
|
250
|
+
# Build the cozip.
|
|
251
|
+
out = tmp_path / "out.cozip"
|
|
252
|
+
cozip.create(out, table)
|
|
253
|
+
assert out.exists()
|
|
254
|
+
|
|
255
|
+
raw = out.read_bytes()
|
|
256
|
+
|
|
257
|
+
# Read __metadata__ straight out of the zip and parse with GeoPandas.
|
|
258
|
+
with zipfile.ZipFile(out, "r") as z:
|
|
259
|
+
assert "__metadata__" in z.namelist()
|
|
260
|
+
meta_bytes = z.read("__metadata__")
|
|
261
|
+
|
|
262
|
+
meta_gdf = gpd.read_parquet(io.BytesIO(meta_bytes))
|
|
263
|
+
|
|
264
|
+
# 1. CRS preserved
|
|
265
|
+
assert meta_gdf.crs is not None, "CRS lost in roundtrip"
|
|
266
|
+
assert meta_gdf.crs.to_epsg() == 4326
|
|
267
|
+
|
|
268
|
+
# Geometry round-trip
|
|
269
|
+
assert "geometry" in meta_gdf.columns
|
|
270
|
+
assert set(meta_gdf.geometry.geom_type) == {"Point"}
|
|
271
|
+
assert len(meta_gdf) == n
|
|
272
|
+
|
|
273
|
+
# 2. Extra columns preserved, writer-private columns dropped
|
|
274
|
+
assert "country" in meta_gdf.columns
|
|
275
|
+
assert "elevation" in meta_gdf.columns
|
|
276
|
+
assert "path" not in meta_gdf.columns
|
|
277
|
+
assert "in_index" not in meta_gdf.columns
|
|
278
|
+
|
|
279
|
+
# Writer-added columns
|
|
280
|
+
assert "name" in meta_gdf.columns
|
|
281
|
+
assert "offset" in meta_gdf.columns
|
|
282
|
+
assert "size" in meta_gdf.columns
|
|
283
|
+
|
|
284
|
+
# User column values come back intact
|
|
285
|
+
by_name = meta_gdf.set_index("name")
|
|
286
|
+
for arc, country, elev, pt in zip(arc_names, countries, elevations, points):
|
|
287
|
+
row = by_name.loc[arc]
|
|
288
|
+
assert row["country"] == country
|
|
289
|
+
assert int(row["elevation"]) == elev
|
|
290
|
+
assert row["geometry"].equals(pt), f"geometry mismatch for {arc}"
|
|
291
|
+
|
|
292
|
+
# 3. offsets/sizes resolve to the original user payloads
|
|
293
|
+
for arc, expected in zip(arc_names, src_payloads):
|
|
294
|
+
row = by_name.loc[arc]
|
|
295
|
+
off = int(row["offset"])
|
|
296
|
+
sz = int(row["size"])
|
|
297
|
+
assert raw[off:off + sz] == expected, f"offset/size wrong for {arc}"
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# ---------------------------------------------------------------- negatives
|
|
301
|
+
|
|
302
|
+
def test_create_rejects_empty(tmp_path: Path):
|
|
303
|
+
"""create() refuses to build a 0-entry archive."""
|
|
304
|
+
out = tmp_path / "empty.cozip"
|
|
305
|
+
with pytest.raises(ValueError, match="empty"):
|
|
306
|
+
cozip.create(out, pa.table({"name": [], "path": []}))
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def test_create_rejects_missing_source(tmp_path: Path):
|
|
310
|
+
"""Missing source file is caught before any C call."""
|
|
311
|
+
out = tmp_path / "out.cozip"
|
|
312
|
+
table = pa.table({
|
|
313
|
+
"name": ["does/not/exist.bin"],
|
|
314
|
+
"path": [str(tmp_path / "nope.bin")],
|
|
315
|
+
})
|
|
316
|
+
with pytest.raises(FileNotFoundError):
|
|
317
|
+
cozip.create(out, table)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def test_create_rejects_reserved_name(tmp_path: Path):
|
|
321
|
+
"""User cannot use names the writer reserves for itself."""
|
|
322
|
+
src = tmp_path / "src.bin"
|
|
323
|
+
src.write_bytes(_make_payload(0x33, 33000))
|
|
324
|
+
table = pa.table({
|
|
325
|
+
"name": ["__metadata__"],
|
|
326
|
+
"path": [str(src)],
|
|
327
|
+
})
|
|
328
|
+
with pytest.raises(ValueError, match="reserved"):
|
|
329
|
+
cozip.create(tmp_path / "out.cozip", table)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def test_create_rejects_duplicate_names(tmp_path: Path):
|
|
333
|
+
"""Duplicate names within one archive are rejected upfront."""
|
|
334
|
+
src1 = tmp_path / "a.bin"
|
|
335
|
+
src2 = tmp_path / "b.bin"
|
|
336
|
+
src1.write_bytes(_make_payload(0x10, 100))
|
|
337
|
+
src2.write_bytes(_make_payload(0x20, 33000))
|
|
338
|
+
table = pa.table({
|
|
339
|
+
"name": ["dupe.bin", "dupe.bin"],
|
|
340
|
+
"path": [str(src1), str(src2)],
|
|
341
|
+
})
|
|
342
|
+
with pytest.raises(ValueError, match="duplicate"):
|
|
343
|
+
cozip.create(tmp_path / "out.cozip", table)
|