Skip to main content

s4_codec/
lib.rs

1// PyO3 0.22 ? on PyResult triggers `useless_conversion` because `From<PyErr>
2// for PyErr` is identity. The clippy warning is intrinsic to the binding
3// idiom; suppress at file scope.
4#![allow(clippy::useless_conversion)]
5//! Python bindings for `s4-codec`.
6//!
7//! Exposes the CPU codecs (`CpuZstd`, `CpuGzip`) and a `gpu_available()`
8//! helper. GPU codec classes are intentionally NOT exposed in v1.0 — the
9//! `nvcomp-gpu` cargo feature on this crate forwards to the underlying
10//! `s4-codec` GPU paths for the server build, but the Python module's
11//! runtime classes remain CPU-only. See `crates/s4-codec-py/README.md`
12//! for the rationale; GPU-Python exposure is a v1.x roadmap candidate.
13//!
14//! # Async bridge
15//!
16//! `s4_codec_rs::Codec` is async. Python callers expect blocking calls. We
17//! resolve this by running each call on a process-wide multi-thread tokio
18//! runtime stashed in a `OnceLock`. `Python::allow_threads` releases the
19//! GIL across the await so other Python threads can progress while the
20//! blocking compression worker churns.
21
22use std::sync::{Arc, OnceLock};
23
24use bytes::Bytes;
25use pyo3::create_exception;
26use pyo3::exceptions::{PyIOError, PyRuntimeError, PyValueError};
27use pyo3::prelude::*;
28use pyo3::types::{PyBytes, PyDict, PyList};
29use s4_codec_rs::index::{FrameIndex, IndexError};
30use s4_codec_rs::multipart::{FrameError, FrameHeader, FrameIter};
31use s4_codec_rs::{cpu_gzip, cpu_zstd, cpu_zstd_dict, ChunkManifest, Codec, CodecError, CodecKind};
32use tokio::runtime::{Builder, Runtime};
33
34// v0.8.5 #85 M-5: surface CodecError variants as discriminable Python
35// exception classes so callers can `except S4CrcMismatchError:` instead of
36// string-matching on a flattened `PyValueError`. Hierarchy:
37//
38//   S4Error (base, ⊂ ValueError for backward-compat with code that catches
39//            ValueError from the previous flat mapping)
40//     ├─ S4CrcMismatchError              (CodecError::CrcMismatch)
41//     ├─ S4SizeMismatchError             (CodecError::SizeMismatch)
42//     ├─ S4CodecMismatchError            (CodecError::CodecMismatch)
43//     ├─ S4UnregisteredCodecError        (CodecError::UnregisteredCodec)
44//     ├─ S4ManifestSizeExceedsLimitError (CodecError::ManifestSizeExceedsLimit)
45//     └─ S4ManifestSizeMismatchError     (CodecError::ManifestSizeMismatch)
46//   S4BackendError (⊂ RuntimeError) — wraps anyhow / nvCOMP backend faults
47//   S4IoError      (⊂ IOError)      — wraps std::io::Error
48//
49// `Backend` and `Io` deliberately do NOT inherit S4Error: they map onto
50// stdlib semantics (RuntimeError / IOError) so frameworks already wired to
51// retry-on-IOError continue to do the right thing. `TruncatedStream` is rare
52// enough on the binding surface (server-side streaming) that we leave it on
53// the S4Error base rather than minting another class.
54create_exception!(s4_codec, S4Error, PyValueError);
55create_exception!(s4_codec, S4CrcMismatchError, S4Error);
56create_exception!(s4_codec, S4SizeMismatchError, S4Error);
57create_exception!(s4_codec, S4CodecMismatchError, S4Error);
58create_exception!(s4_codec, S4UnregisteredCodecError, S4Error);
59create_exception!(s4_codec, S4ManifestSizeExceedsLimitError, S4Error);
60create_exception!(s4_codec, S4ManifestSizeMismatchError, S4Error);
61create_exception!(s4_codec, S4BackendError, PyRuntimeError);
62create_exception!(s4_codec, S4IoError, PyIOError);
63// v1.1 s4fs: frame-parse / sidecar-decode failures. Both sit under the
64// S4Error base (⊂ ValueError) so existing `except S4Error:` handlers
65// catch them without changes. Messages carry the upstream Display text
66// (`FrameError` / `IndexError` are `#[non_exhaustive]`, so we map by
67// message rather than per-variant subclasses).
68create_exception!(s4_codec, S4FrameError, S4Error);
69create_exception!(s4_codec, S4IndexError, S4Error);
70
71fn runtime() -> &'static Runtime {
72    static RT: OnceLock<Runtime> = OnceLock::new();
73    RT.get_or_init(|| {
74        Builder::new_multi_thread()
75            .enable_all()
76            .thread_name("s4-codec-py")
77            .build()
78            .expect("failed to start tokio runtime for s4_codec python binding")
79    })
80}
81
82fn codec_err_to_py(e: CodecError) -> PyErr {
83    use s4_codec_rs::CodecError::*;
84    match e {
85        SizeMismatch { expected, got } => {
86            S4SizeMismatchError::new_err(format!("size mismatch: expected {expected}, got {got}"))
87        }
88        CrcMismatch { expected, got } => S4CrcMismatchError::new_err(format!(
89            "crc32c mismatch: expected {expected:#010x}, got {got:#010x}"
90        )),
91        CodecMismatch { expected, got } => S4CodecMismatchError::new_err(format!(
92            "codec mismatch: expected {expected:?}, got {got:?}"
93        )),
94        UnregisteredCodec(k) => {
95            S4UnregisteredCodecError::new_err(format!("codec {k:?} not registered"))
96        }
97        ManifestSizeExceedsLimit { requested, limit } => S4ManifestSizeExceedsLimitError::new_err(
98            format!("manifest claims {requested} bytes but limit is {limit}"),
99        ),
100        ManifestSizeMismatch { manifest, actual } => S4ManifestSizeMismatchError::new_err(format!(
101            "manifest claims {manifest} bytes but body is {actual}"
102        )),
103        Backend(msg) => S4BackendError::new_err(format!("backend: {msg}")),
104        Io(e) => S4IoError::new_err(format!("io: {e}")),
105        TruncatedStream { expected, got } => S4Error::new_err(format!(
106            "stream truncated: expected {expected} input bytes, got {got}"
107        )),
108        // v0.8.15 M-4: AWS S3 over-length analogue (declared
109        // Content-Length smaller than the wire body). Same shape as
110        // TruncatedStream — surface to Python callers via the
111        // generic `S4Error` since the in-process codec doesn't
112        // emit HTTP status codes.
113        OverlengthStream { expected, got } => S4Error::new_err(format!(
114            "stream over-length: expected {expected} input bytes, got at least {got}"
115        )),
116        // `Join` is a tokio internal that surfaces only if a blocking worker
117        // panics — surface as backend so retries hit the same class as
118        // anyhow-wrapped backend faults.
119        Join(e) => S4BackendError::new_err(format!("backend (worker join): {e}")),
120        // v1.0 F1: `CodecError` is `#[non_exhaustive]`, so newly-added
121        // variants in a future minor release must have a fallback here.
122        // Map to the generic `S4Error` carrying the upstream Display
123        // text — Python callers see the message but cannot pattern-
124        // match on the specific subclass until this wrapper is updated.
125        other => S4Error::new_err(format!("codec error: {other}")),
126    }
127}
128
129fn manifest_from_parts(
130    kind: CodecKind,
131    payload_len: u64,
132    original_size: u64,
133    crc32c: u32,
134) -> ChunkManifest {
135    ChunkManifest {
136        codec: kind,
137        original_size,
138        compressed_size: payload_len,
139        crc32c,
140    }
141}
142
143/// Run the supplied future on the shared multi-thread runtime, releasing
144/// the GIL while it runs so other Python threads aren't starved by a
145/// long-running compression worker.
146fn block_on<F, T>(py: Python<'_>, fut: F) -> T
147where
148    F: std::future::Future<Output = T> + Send,
149    T: Send,
150{
151    py.allow_threads(|| runtime().block_on(fut))
152}
153
154/// CPU zstd codec. Level is clamped to 1..=22 by the underlying crate;
155/// default 3 matches `zstd(1)`'s out-of-the-box level.
156#[pyclass(name = "CpuZstd", module = "s4_codec")]
157struct PyCpuZstd {
158    inner: cpu_zstd::CpuZstd,
159}
160
161#[pymethods]
162impl PyCpuZstd {
163    #[new]
164    #[pyo3(signature = (level = 3))]
165    fn new(level: i32) -> Self {
166        Self {
167            inner: cpu_zstd::CpuZstd::new(level),
168        }
169    }
170
171    /// Compress `data`. Returns `(compressed: bytes, original_size: int, crc32c: int)`.
172    /// The original size and crc32c are the manifest fields decompress needs;
173    /// the caller is expected to round-trip them alongside the payload.
174    fn compress<'py>(
175        &self,
176        py: Python<'py>,
177        data: &Bound<'py, PyBytes>,
178    ) -> PyResult<(Bound<'py, PyBytes>, u64, u32)> {
179        let input = Bytes::copy_from_slice(data.as_bytes());
180        let codec = self.inner.clone();
181        let (out, manifest) =
182            block_on(py, async move { codec.compress(input).await }).map_err(codec_err_to_py)?;
183        Ok((
184            PyBytes::new(py, &out),
185            manifest.original_size,
186            manifest.crc32c,
187        ))
188    }
189
190    /// Decompress `data`. `original_size` and `crc32c` are the matching
191    /// manifest fields returned by `compress`.
192    fn decompress<'py>(
193        &self,
194        py: Python<'py>,
195        data: &Bound<'py, PyBytes>,
196        original_size: u64,
197        crc32c: u32,
198    ) -> PyResult<Bound<'py, PyBytes>> {
199        let input = Bytes::copy_from_slice(data.as_bytes());
200        let manifest = manifest_from_parts(
201            CodecKind::CpuZstd,
202            input.len() as u64,
203            original_size,
204            crc32c,
205        );
206        let codec = self.inner.clone();
207        let out = block_on(py, async move { codec.decompress(input, &manifest).await })
208            .map_err(codec_err_to_py)?;
209        Ok(PyBytes::new(py, &out))
210    }
211
212    fn __repr__(&self) -> String {
213        format!("CpuZstd(level={})", cpu_zstd::CpuZstd::DEFAULT_LEVEL)
214    }
215}
216
217/// CPU gzip codec (RFC 1952). Level 0..=9, default 6 (matches `gzip(1)`).
218/// Output is a real gzip stream — any standard `gunzip`-aware decoder
219/// (browser, curl, Python's `gzip` module) decodes the payload bytes.
220#[pyclass(name = "CpuGzip", module = "s4_codec")]
221struct PyCpuGzip {
222    inner: cpu_gzip::CpuGzip,
223}
224
225#[pymethods]
226impl PyCpuGzip {
227    #[new]
228    #[pyo3(signature = (level = 6))]
229    fn new(level: u32) -> Self {
230        Self {
231            inner: cpu_gzip::CpuGzip::new(level),
232        }
233    }
234
235    fn compress<'py>(
236        &self,
237        py: Python<'py>,
238        data: &Bound<'py, PyBytes>,
239    ) -> PyResult<(Bound<'py, PyBytes>, u64, u32)> {
240        let input = Bytes::copy_from_slice(data.as_bytes());
241        let codec = self.inner.clone();
242        let (out, manifest) =
243            block_on(py, async move { codec.compress(input).await }).map_err(codec_err_to_py)?;
244        Ok((
245            PyBytes::new(py, &out),
246            manifest.original_size,
247            manifest.crc32c,
248        ))
249    }
250
251    fn decompress<'py>(
252        &self,
253        py: Python<'py>,
254        data: &Bound<'py, PyBytes>,
255        original_size: u64,
256        crc32c: u32,
257    ) -> PyResult<Bound<'py, PyBytes>> {
258        let input = Bytes::copy_from_slice(data.as_bytes());
259        let manifest = manifest_from_parts(
260            CodecKind::CpuGzip,
261            input.len() as u64,
262            original_size,
263            crc32c,
264        );
265        let codec = self.inner.clone();
266        let out = block_on(py, async move { codec.decompress(input, &manifest).await })
267            .map_err(codec_err_to_py)?;
268        Ok(PyBytes::new(py, &out))
269    }
270
271    fn __repr__(&self) -> String {
272        format!("CpuGzip(level={})", cpu_gzip::CpuGzip::DEFAULT_LEVEL)
273    }
274}
275
276/// CPU zstd codec bound to a shared trained dictionary (`cpu-zstd-dict`,
277/// v1.1 `--zstd-dict`). The dictionary is a stock zstd dictionary
278/// (`zstd --train` / `ZDICT_trainFromBuffer` output); the compressed
279/// payload is a stock zstd frame referencing it. Level clamped to 1..=22.
280#[pyclass(name = "CpuZstdDict", module = "s4_codec")]
281struct PyCpuZstdDict {
282    inner: cpu_zstd_dict::CpuZstdDict,
283    level: i32,
284}
285
286#[pymethods]
287impl PyCpuZstdDict {
288    #[new]
289    #[pyo3(signature = (dict_bytes, level = 3))]
290    fn new(dict_bytes: &Bound<'_, PyBytes>, level: i32) -> PyResult<Self> {
291        let dict: Arc<[u8]> = Arc::from(dict_bytes.as_bytes().to_vec().into_boxed_slice());
292        let inner = cpu_zstd_dict::CpuZstdDict::new(dict, level).map_err(codec_err_to_py)?;
293        Ok(Self {
294            inner,
295            level: level.clamp(1, 22),
296        })
297    }
298
299    /// Compress `data` against the bound dictionary. Returns
300    /// `(compressed: bytes, original_size: int, crc32c: int)` — same shape
301    /// as `CpuZstd.compress`.
302    fn compress<'py>(
303        &self,
304        py: Python<'py>,
305        data: &Bound<'py, PyBytes>,
306    ) -> PyResult<(Bound<'py, PyBytes>, u64, u32)> {
307        let input = Bytes::copy_from_slice(data.as_bytes());
308        let codec = self.inner.clone();
309        let (out, manifest) =
310            block_on(py, async move { codec.compress(input).await }).map_err(codec_err_to_py)?;
311        Ok((
312            PyBytes::new(py, &out),
313            manifest.original_size,
314            manifest.crc32c,
315        ))
316    }
317
318    /// Decompress `data` against the bound dictionary. `original_size`
319    /// and `crc32c` are the matching manifest fields from `compress` (or
320    /// the S4F2 frame header for gateway-written objects).
321    fn decompress<'py>(
322        &self,
323        py: Python<'py>,
324        data: &Bound<'py, PyBytes>,
325        original_size: u64,
326        crc32c: u32,
327    ) -> PyResult<Bound<'py, PyBytes>> {
328        let input = Bytes::copy_from_slice(data.as_bytes());
329        let manifest = manifest_from_parts(
330            CodecKind::CpuZstdDict,
331            input.len() as u64,
332            original_size,
333            crc32c,
334        );
335        let codec = self.inner.clone();
336        let out = block_on(py, async move { codec.decompress(input, &manifest).await })
337            .map_err(codec_err_to_py)?;
338        Ok(PyBytes::new(py, &out))
339    }
340
341    fn __repr__(&self) -> String {
342        format!(
343            "CpuZstdDict(dict_len={}, level={})",
344            self.inner.dict().len(),
345            self.level
346        )
347    }
348}
349
350/// True iff the wheel was built with `--features nvcomp-gpu` AND a
351/// CUDA-capable GPU is reachable at runtime. Default wheels return False.
352#[pyfunction]
353fn gpu_available() -> bool {
354    s4_codec_rs::nvcomp::is_gpu_available()
355}
356
357fn frame_err_to_py(e: FrameError) -> PyErr {
358    S4FrameError::new_err(format!("frame error: {e}"))
359}
360
361fn index_err_to_py(e: IndexError) -> PyErr {
362    S4IndexError::new_err(format!("index error: {e}"))
363}
364
365/// S4F2 frame header → Python dict
366/// `{codec: str, original_size: int, compressed_size: int, crc32c: int}`.
367/// `codec` is the stable `CodecKind::as_str()` name (`"cpu-zstd"`, …).
368fn frame_header_dict<'py>(py: Python<'py>, h: &FrameHeader) -> PyResult<Bound<'py, PyDict>> {
369    let d = PyDict::new(py);
370    d.set_item("codec", h.codec.as_str())?;
371    d.set_item("original_size", h.original_size)?;
372    d.set_item("compressed_size", h.compressed_size)?;
373    d.set_item("crc32c", h.crc32c)?;
374    Ok(d)
375}
376
377/// Parse one S4F2 frame off the front of `data`.
378///
379/// Returns `(header: dict, payload: bytes, rest: bytes)` where `header`
380/// is `{codec, original_size, compressed_size, crc32c}`, `payload` is the
381/// (still-compressed) frame payload, and `rest` is everything after the
382/// frame. Raises `S4FrameError` on truncated / bad-magic / unknown-codec
383/// input. Thin wrapper over `s4_codec::multipart::read_frame` — the wire
384/// layout is the frozen v1.0 S4F2 format.
385#[pyfunction]
386fn read_frame<'py>(
387    py: Python<'py>,
388    data: &Bound<'py, PyBytes>,
389) -> PyResult<(Bound<'py, PyDict>, Bound<'py, PyBytes>, Bound<'py, PyBytes>)> {
390    let input = Bytes::copy_from_slice(data.as_bytes());
391    let (header, payload, rest) =
392        s4_codec_rs::multipart::read_frame(input).map_err(frame_err_to_py)?;
393    Ok((
394        frame_header_dict(py, &header)?,
395        PyBytes::new(py, &payload),
396        PyBytes::new(py, &rest),
397    ))
398}
399
400/// Parse `data` as a sequence of S4F2 frames (S4P1 padding frames are
401/// skipped, matching the gateway's GET path). Returns a list of
402/// `(header: dict, payload: bytes)` tuples. Raises `S4FrameError` on the
403/// first malformed frame. Thin wrapper over `s4_codec::multipart::FrameIter`.
404#[pyfunction]
405fn frame_iter<'py>(py: Python<'py>, data: &Bound<'py, PyBytes>) -> PyResult<Bound<'py, PyList>> {
406    let input = Bytes::copy_from_slice(data.as_bytes());
407    let out = PyList::empty(py);
408    for item in FrameIter::new(input) {
409        let (header, payload) = item.map_err(frame_err_to_py)?;
410        out.append((frame_header_dict(py, &header)?, PyBytes::new(py, &payload)))?;
411    }
412    Ok(out)
413}
414
415/// Decode a `<key>.s4index` sidecar (v1 / v2 / v3 layouts).
416///
417/// Returns a dict:
418///
419/// ```text
420/// {
421///   "total_padded_size": int,      # backend object size incl. padding
422///   "total_original_size": int,    # decompressed size (sum of entries)
423///   "source_etag": str | None,     # v2+: backend ETag binding
424///   "source_compressed_size": int | None,
425///   "entries": [ {original_offset, original_size,
426///                 compressed_offset, compressed_size}, ... ],
427///   "sse": None | {enc_chunk_size, enc_chunk_count, enc_key_id,
428///                  enc_salt: bytes, enc_plaintext_len, enc_header_bytes},
429/// }
430/// ```
431///
432/// `entries[i].compressed_size` includes the 28-byte frame header.
433/// Raises `S4IndexError` on malformed input. Thin wrapper over
434/// `s4_codec::index::decode_index`.
435#[pyfunction]
436fn decode_index<'py>(py: Python<'py>, data: &Bound<'py, PyBytes>) -> PyResult<Bound<'py, PyDict>> {
437    let input = Bytes::copy_from_slice(data.as_bytes());
438    let idx: FrameIndex = s4_codec_rs::index::decode_index(input).map_err(index_err_to_py)?;
439    let d = PyDict::new(py);
440    d.set_item("total_padded_size", idx.total_padded_size)?;
441    d.set_item("total_original_size", idx.total_original_size())?;
442    d.set_item("source_etag", idx.source_etag.as_deref())?;
443    d.set_item("source_compressed_size", idx.source_compressed_size)?;
444    let entries = PyList::empty(py);
445    for e in &idx.entries {
446        let ed = PyDict::new(py);
447        ed.set_item("original_offset", e.original_offset)?;
448        ed.set_item("original_size", e.original_size)?;
449        ed.set_item("compressed_offset", e.compressed_offset)?;
450        ed.set_item("compressed_size", e.compressed_size)?;
451        entries.append(ed)?;
452    }
453    d.set_item("entries", entries)?;
454    match idx.sse_v3 {
455        Some(sse) => {
456            let sd = PyDict::new(py);
457            sd.set_item("enc_chunk_size", sse.enc_chunk_size)?;
458            sd.set_item("enc_chunk_count", sse.enc_chunk_count)?;
459            sd.set_item("enc_key_id", sse.enc_key_id)?;
460            sd.set_item("enc_salt", PyBytes::new(py, &sse.enc_salt))?;
461            sd.set_item("enc_plaintext_len", sse.enc_plaintext_len)?;
462            sd.set_item("enc_header_bytes", sse.enc_header_bytes)?;
463            d.set_item("sse", sd)?;
464        }
465        None => d.set_item("sse", py.None())?,
466    }
467    Ok(d)
468}
469
470/// CRC32C (Castagnoli) of `data` — the checksum the S4F2 frame header
471/// carries. Exposed so pure-Python readers (s4fs) can verify
472/// `passthrough` frame payloads without an extra dependency.
473#[pyfunction]
474#[pyo3(name = "crc32c")]
475fn crc32c_py(data: &Bound<'_, PyBytes>) -> u32 {
476    ::crc32c::crc32c(data.as_bytes())
477}
478
479#[pymodule]
480fn s4_codec(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
481    m.add_class::<PyCpuZstd>()?;
482    m.add_class::<PyCpuGzip>()?;
483    m.add_class::<PyCpuZstdDict>()?;
484    m.add_function(wrap_pyfunction!(gpu_available, m)?)?;
485    // v1.1 s4fs: read-side wire-format helpers (S4F2 frames + .s4index
486    // sidecars) so pure-Python clients can decode gateway-written
487    // objects without the gateway.
488    m.add_function(wrap_pyfunction!(read_frame, m)?)?;
489    m.add_function(wrap_pyfunction!(frame_iter, m)?)?;
490    m.add_function(wrap_pyfunction!(decode_index, m)?)?;
491    m.add_function(wrap_pyfunction!(crc32c_py, m)?)?;
492    m.add(
493        "FRAME_MAGIC",
494        PyBytes::new(py, s4_codec_rs::multipart::FRAME_MAGIC),
495    )?;
496    m.add(
497        "PADDING_MAGIC",
498        PyBytes::new(py, s4_codec_rs::multipart::PADDING_MAGIC),
499    )?;
500    m.add(
501        "FRAME_HEADER_BYTES",
502        s4_codec_rs::multipart::FRAME_HEADER_BYTES,
503    )?;
504    m.add("SIDECAR_SUFFIX", s4_codec_rs::index::SIDECAR_SUFFIX)?;
505    m.add("__version__", env!("CARGO_PKG_VERSION"))?;
506    // v0.8.5 #85 M-5: export per-CodecError exception classes so Python
507    // callers can branch on error kind. See module-level doc comments above
508    // `create_exception!` for the inheritance hierarchy.
509    m.add("S4Error", py.get_type::<S4Error>())?;
510    m.add("S4CrcMismatchError", py.get_type::<S4CrcMismatchError>())?;
511    m.add("S4SizeMismatchError", py.get_type::<S4SizeMismatchError>())?;
512    m.add(
513        "S4CodecMismatchError",
514        py.get_type::<S4CodecMismatchError>(),
515    )?;
516    m.add(
517        "S4UnregisteredCodecError",
518        py.get_type::<S4UnregisteredCodecError>(),
519    )?;
520    m.add(
521        "S4ManifestSizeExceedsLimitError",
522        py.get_type::<S4ManifestSizeExceedsLimitError>(),
523    )?;
524    m.add(
525        "S4ManifestSizeMismatchError",
526        py.get_type::<S4ManifestSizeMismatchError>(),
527    )?;
528    m.add("S4BackendError", py.get_type::<S4BackendError>())?;
529    m.add("S4IoError", py.get_type::<S4IoError>())?;
530    m.add("S4FrameError", py.get_type::<S4FrameError>())?;
531    m.add("S4IndexError", py.get_type::<S4IndexError>())?;
532    Ok(())
533}