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::OnceLock;
23
24use bytes::Bytes;
25use pyo3::create_exception;
26use pyo3::exceptions::{PyIOError, PyRuntimeError, PyValueError};
27use pyo3::prelude::*;
28use pyo3::types::PyBytes;
29use s4_codec_rs::{cpu_gzip, cpu_zstd, ChunkManifest, Codec, CodecError, CodecKind};
30use tokio::runtime::{Builder, Runtime};
31
32// v0.8.5 #85 M-5: surface CodecError variants as discriminable Python
33// exception classes so callers can `except S4CrcMismatchError:` instead of
34// string-matching on a flattened `PyValueError`. Hierarchy:
35//
36//   S4Error (base, ⊂ ValueError for backward-compat with code that catches
37//            ValueError from the previous flat mapping)
38//     ├─ S4CrcMismatchError              (CodecError::CrcMismatch)
39//     ├─ S4SizeMismatchError             (CodecError::SizeMismatch)
40//     ├─ S4CodecMismatchError            (CodecError::CodecMismatch)
41//     ├─ S4UnregisteredCodecError        (CodecError::UnregisteredCodec)
42//     ├─ S4ManifestSizeExceedsLimitError (CodecError::ManifestSizeExceedsLimit)
43//     └─ S4ManifestSizeMismatchError     (CodecError::ManifestSizeMismatch)
44//   S4BackendError (⊂ RuntimeError) — wraps anyhow / nvCOMP backend faults
45//   S4IoError      (⊂ IOError)      — wraps std::io::Error
46//
47// `Backend` and `Io` deliberately do NOT inherit S4Error: they map onto
48// stdlib semantics (RuntimeError / IOError) so frameworks already wired to
49// retry-on-IOError continue to do the right thing. `TruncatedStream` is rare
50// enough on the binding surface (server-side streaming) that we leave it on
51// the S4Error base rather than minting another class.
52create_exception!(s4_codec, S4Error, PyValueError);
53create_exception!(s4_codec, S4CrcMismatchError, S4Error);
54create_exception!(s4_codec, S4SizeMismatchError, S4Error);
55create_exception!(s4_codec, S4CodecMismatchError, S4Error);
56create_exception!(s4_codec, S4UnregisteredCodecError, S4Error);
57create_exception!(s4_codec, S4ManifestSizeExceedsLimitError, S4Error);
58create_exception!(s4_codec, S4ManifestSizeMismatchError, S4Error);
59create_exception!(s4_codec, S4BackendError, PyRuntimeError);
60create_exception!(s4_codec, S4IoError, PyIOError);
61
62fn runtime() -> &'static Runtime {
63    static RT: OnceLock<Runtime> = OnceLock::new();
64    RT.get_or_init(|| {
65        Builder::new_multi_thread()
66            .enable_all()
67            .thread_name("s4-codec-py")
68            .build()
69            .expect("failed to start tokio runtime for s4_codec python binding")
70    })
71}
72
73fn codec_err_to_py(e: CodecError) -> PyErr {
74    use s4_codec_rs::CodecError::*;
75    match e {
76        SizeMismatch { expected, got } => {
77            S4SizeMismatchError::new_err(format!("size mismatch: expected {expected}, got {got}"))
78        }
79        CrcMismatch { expected, got } => S4CrcMismatchError::new_err(format!(
80            "crc32c mismatch: expected {expected:#010x}, got {got:#010x}"
81        )),
82        CodecMismatch { expected, got } => S4CodecMismatchError::new_err(format!(
83            "codec mismatch: expected {expected:?}, got {got:?}"
84        )),
85        UnregisteredCodec(k) => {
86            S4UnregisteredCodecError::new_err(format!("codec {k:?} not registered"))
87        }
88        ManifestSizeExceedsLimit { requested, limit } => S4ManifestSizeExceedsLimitError::new_err(
89            format!("manifest claims {requested} bytes but limit is {limit}"),
90        ),
91        ManifestSizeMismatch { manifest, actual } => S4ManifestSizeMismatchError::new_err(format!(
92            "manifest claims {manifest} bytes but body is {actual}"
93        )),
94        Backend(msg) => S4BackendError::new_err(format!("backend: {msg}")),
95        Io(e) => S4IoError::new_err(format!("io: {e}")),
96        TruncatedStream { expected, got } => S4Error::new_err(format!(
97            "stream truncated: expected {expected} input bytes, got {got}"
98        )),
99        // v0.8.15 M-4: AWS S3 over-length analogue (declared
100        // Content-Length smaller than the wire body). Same shape as
101        // TruncatedStream — surface to Python callers via the
102        // generic `S4Error` since the in-process codec doesn't
103        // emit HTTP status codes.
104        OverlengthStream { expected, got } => S4Error::new_err(format!(
105            "stream over-length: expected {expected} input bytes, got at least {got}"
106        )),
107        // `Join` is a tokio internal that surfaces only if a blocking worker
108        // panics — surface as backend so retries hit the same class as
109        // anyhow-wrapped backend faults.
110        Join(e) => S4BackendError::new_err(format!("backend (worker join): {e}")),
111        // v1.0 F1: `CodecError` is `#[non_exhaustive]`, so newly-added
112        // variants in a future minor release must have a fallback here.
113        // Map to the generic `S4Error` carrying the upstream Display
114        // text — Python callers see the message but cannot pattern-
115        // match on the specific subclass until this wrapper is updated.
116        other => S4Error::new_err(format!("codec error: {other}")),
117    }
118}
119
120fn manifest_from_parts(
121    kind: CodecKind,
122    payload_len: u64,
123    original_size: u64,
124    crc32c: u32,
125) -> ChunkManifest {
126    ChunkManifest {
127        codec: kind,
128        original_size,
129        compressed_size: payload_len,
130        crc32c,
131    }
132}
133
134/// Run the supplied future on the shared multi-thread runtime, releasing
135/// the GIL while it runs so other Python threads aren't starved by a
136/// long-running compression worker.
137fn block_on<F, T>(py: Python<'_>, fut: F) -> T
138where
139    F: std::future::Future<Output = T> + Send,
140    T: Send,
141{
142    py.allow_threads(|| runtime().block_on(fut))
143}
144
145/// CPU zstd codec. Level is clamped to 1..=22 by the underlying crate;
146/// default 3 matches `zstd(1)`'s out-of-the-box level.
147#[pyclass(name = "CpuZstd", module = "s4_codec")]
148struct PyCpuZstd {
149    inner: cpu_zstd::CpuZstd,
150}
151
152#[pymethods]
153impl PyCpuZstd {
154    #[new]
155    #[pyo3(signature = (level = 3))]
156    fn new(level: i32) -> Self {
157        Self {
158            inner: cpu_zstd::CpuZstd::new(level),
159        }
160    }
161
162    /// Compress `data`. Returns `(compressed: bytes, original_size: int, crc32c: int)`.
163    /// The original size and crc32c are the manifest fields decompress needs;
164    /// the caller is expected to round-trip them alongside the payload.
165    fn compress<'py>(
166        &self,
167        py: Python<'py>,
168        data: &Bound<'py, PyBytes>,
169    ) -> PyResult<(Bound<'py, PyBytes>, u64, u32)> {
170        let input = Bytes::copy_from_slice(data.as_bytes());
171        let codec = self.inner.clone();
172        let (out, manifest) =
173            block_on(py, async move { codec.compress(input).await }).map_err(codec_err_to_py)?;
174        Ok((
175            PyBytes::new(py, &out),
176            manifest.original_size,
177            manifest.crc32c,
178        ))
179    }
180
181    /// Decompress `data`. `original_size` and `crc32c` are the matching
182    /// manifest fields returned by `compress`.
183    fn decompress<'py>(
184        &self,
185        py: Python<'py>,
186        data: &Bound<'py, PyBytes>,
187        original_size: u64,
188        crc32c: u32,
189    ) -> PyResult<Bound<'py, PyBytes>> {
190        let input = Bytes::copy_from_slice(data.as_bytes());
191        let manifest = manifest_from_parts(
192            CodecKind::CpuZstd,
193            input.len() as u64,
194            original_size,
195            crc32c,
196        );
197        let codec = self.inner.clone();
198        let out = block_on(py, async move { codec.decompress(input, &manifest).await })
199            .map_err(codec_err_to_py)?;
200        Ok(PyBytes::new(py, &out))
201    }
202
203    fn __repr__(&self) -> String {
204        format!("CpuZstd(level={})", cpu_zstd::CpuZstd::DEFAULT_LEVEL)
205    }
206}
207
208/// CPU gzip codec (RFC 1952). Level 0..=9, default 6 (matches `gzip(1)`).
209/// Output is a real gzip stream — any standard `gunzip`-aware decoder
210/// (browser, curl, Python's `gzip` module) decodes the payload bytes.
211#[pyclass(name = "CpuGzip", module = "s4_codec")]
212struct PyCpuGzip {
213    inner: cpu_gzip::CpuGzip,
214}
215
216#[pymethods]
217impl PyCpuGzip {
218    #[new]
219    #[pyo3(signature = (level = 6))]
220    fn new(level: u32) -> Self {
221        Self {
222            inner: cpu_gzip::CpuGzip::new(level),
223        }
224    }
225
226    fn compress<'py>(
227        &self,
228        py: Python<'py>,
229        data: &Bound<'py, PyBytes>,
230    ) -> PyResult<(Bound<'py, PyBytes>, u64, u32)> {
231        let input = Bytes::copy_from_slice(data.as_bytes());
232        let codec = self.inner.clone();
233        let (out, manifest) =
234            block_on(py, async move { codec.compress(input).await }).map_err(codec_err_to_py)?;
235        Ok((
236            PyBytes::new(py, &out),
237            manifest.original_size,
238            manifest.crc32c,
239        ))
240    }
241
242    fn decompress<'py>(
243        &self,
244        py: Python<'py>,
245        data: &Bound<'py, PyBytes>,
246        original_size: u64,
247        crc32c: u32,
248    ) -> PyResult<Bound<'py, PyBytes>> {
249        let input = Bytes::copy_from_slice(data.as_bytes());
250        let manifest = manifest_from_parts(
251            CodecKind::CpuGzip,
252            input.len() as u64,
253            original_size,
254            crc32c,
255        );
256        let codec = self.inner.clone();
257        let out = block_on(py, async move { codec.decompress(input, &manifest).await })
258            .map_err(codec_err_to_py)?;
259        Ok(PyBytes::new(py, &out))
260    }
261
262    fn __repr__(&self) -> String {
263        format!("CpuGzip(level={})", cpu_gzip::CpuGzip::DEFAULT_LEVEL)
264    }
265}
266
267/// True iff the wheel was built with `--features nvcomp-gpu` AND a
268/// CUDA-capable GPU is reachable at runtime. Default wheels return False.
269#[pyfunction]
270fn gpu_available() -> bool {
271    s4_codec_rs::nvcomp::is_gpu_available()
272}
273
274#[pymodule]
275fn s4_codec(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
276    m.add_class::<PyCpuZstd>()?;
277    m.add_class::<PyCpuGzip>()?;
278    m.add_function(wrap_pyfunction!(gpu_available, m)?)?;
279    m.add("__version__", env!("CARGO_PKG_VERSION"))?;
280    // v0.8.5 #85 M-5: export per-CodecError exception classes so Python
281    // callers can branch on error kind. See module-level doc comments above
282    // `create_exception!` for the inheritance hierarchy.
283    m.add("S4Error", py.get_type::<S4Error>())?;
284    m.add("S4CrcMismatchError", py.get_type::<S4CrcMismatchError>())?;
285    m.add("S4SizeMismatchError", py.get_type::<S4SizeMismatchError>())?;
286    m.add(
287        "S4CodecMismatchError",
288        py.get_type::<S4CodecMismatchError>(),
289    )?;
290    m.add(
291        "S4UnregisteredCodecError",
292        py.get_type::<S4UnregisteredCodecError>(),
293    )?;
294    m.add(
295        "S4ManifestSizeExceedsLimitError",
296        py.get_type::<S4ManifestSizeExceedsLimitError>(),
297    )?;
298    m.add(
299        "S4ManifestSizeMismatchError",
300        py.get_type::<S4ManifestSizeMismatchError>(),
301    )?;
302    m.add("S4BackendError", py.get_type::<S4BackendError>())?;
303    m.add("S4IoError", py.get_type::<S4IoError>())?;
304    Ok(())
305}