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.
@@ -0,0 +1,16 @@
1
+
2
+ .DS_Store
3
+
4
+ spec/main.md
5
+ demo.py
6
+ demo.zip
7
+ .vscode/
8
+
9
+ .pytest_cache/
10
+ __pycache__/
11
+ *.pyc
12
+ build/
13
+ *.egg-info/
14
+ .DS_Store
15
+
16
+ *.dylib
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: cozip
3
+ Version: 2026.4.29
4
+ Summary: Cloud-Optimized ZIP — Python bindings
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: cffi>=1.16
7
+ Requires-Dist: pyarrow>=14
@@ -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)