from __future__ import annotations
import ctypes
import os
from dataclasses import dataclass
from pathlib import Path
__all__ = [
"Archive",
"Member",
"FfiError",
"LibraryNotFoundError",
"find_cdylib",
"members",
"parse_members",
"STATUS_OK",
"STATUS_INVALID",
"STATUS_REJECTED",
]
STATUS_OK = 0
STATUS_INVALID = -1
STATUS_REJECTED = -2
CDYLIB_NAMES = ("pith_zip.dll", "libpith_zip.so", "libpith_zip.dylib")
@dataclass(frozen=True)
class Member:
name: str
method: int
data_descriptor: bool
compressed_size: int
uncompressed_size: int
crc32: int
content: bytes
@dataclass(frozen=True)
class Archive:
members: tuple[Member, ...]
raw: bytes
class LibraryNotFoundError(OSError):
class FfiError(Exception):
def __init__(self, op: str, status: int) -> None:
kind = {
STATUS_INVALID: "invalid argument",
STATUS_REJECTED: "input rejected",
}.get(status, "unknown failure")
super().__init__(f"{op} failed: {kind} (status {status})")
self.status = status
def find_cdylib() -> Path:
explicit = os.environ.get("PITH_CDYLIB")
if explicit:
p = Path(explicit)
if p.is_file():
return p
env_dir = os.environ.get("PITH_CDYLIB_DIR")
candidates: list[Path] = []
if env_dir:
env_dir_path = Path(env_dir)
candidates.append(env_dir_path)
if not env_dir_path.is_absolute():
candidates.append(Path.cwd() / env_dir_path)
candidates.append(Path(__file__).resolve().parents[3] / env_dir_path)
candidates.append(Path(__file__).resolve().parent) candidates.append(Path(__file__).resolve().parents[3] / "target" / "release")
for directory in candidates:
for name in CDYLIB_NAMES:
p = directory / name
if p.is_file():
return p
raise LibraryNotFoundError(
"no pith-zip cdylib found (searched PITH_CDYLIB, PITH_CDYLIB_DIR, "
"the package directory and <repo>/target/release); "
"run `cargo build --release` first"
)
_lib: ctypes.CDLL | None = None
def _load() -> ctypes.CDLL:
global _lib
if _lib is None:
lib = ctypes.CDLL(str(find_cdylib()))
lib.pith_zip_members.argtypes = [
ctypes.c_void_p, ctypes.c_size_t, ctypes.POINTER(ctypes.c_void_p), ctypes.POINTER(ctypes.c_size_t), ]
lib.pith_zip_members.restype = ctypes.c_int32
lib.pith_zip_free.argtypes = [ctypes.c_void_p, ctypes.c_size_t]
lib.pith_zip_free.restype = None
_lib = lib
return _lib
def members(data: bytes) -> bytes:
out = ctypes.c_void_p()
out_len = ctypes.c_size_t()
status = _load().pith_zip_members(data, len(data), ctypes.byref(out), ctypes.byref(out_len))
if status != STATUS_OK:
raise FfiError("pith_zip_members", status)
try:
return ctypes.string_at(out, out_len.value)
finally:
_load().pith_zip_free(out, out_len.value)
def _need(raw: bytes, pos: int, n: int) -> None:
if pos + n > len(raw):
raise ValueError(f"canonical stream is truncated at byte {pos} (need {n} more)")
def _be(raw: bytes, pos: int, n: int) -> tuple[int, int]:
_need(raw, pos, n)
return int.from_bytes(raw[pos : pos + n], "big"), pos + n
def parse_members(raw: bytes) -> Archive:
count, pos = _be(raw, 0, 4)
parsed: list[Member] = []
for _ in range(count):
name_len, pos = _be(raw, pos, 4)
_need(raw, pos, name_len + 15)
name = raw[pos : pos + name_len].decode("utf-8")
pos += name_len
method, pos = _be(raw, pos, 2)
if raw[pos] not in (0, 1):
raise ValueError(f"unknown data-descriptor byte {raw[pos]}")
descriptor = raw[pos] == 1
pos += 1
compressed_size, pos = _be(raw, pos, 8)
uncompressed_size, pos = _be(raw, pos, 8)
crc32, pos = _be(raw, pos, 4)
content_len, pos = _be(raw, pos, 8)
_need(raw, pos, content_len)
content = raw[pos : pos + content_len]
pos += content_len
parsed.append(
Member(
name=name,
method=method,
data_descriptor=descriptor,
compressed_size=compressed_size,
uncompressed_size=uncompressed_size,
crc32=crc32,
content=content,
)
)
if pos != len(raw):
raise ValueError(f"{len(raw) - pos} trailing bytes after the last member")
return Archive(members=tuple(parsed), raw=raw)