Add python bindings using maturin (#3)

Co-authored-by: Luke Street <luke@street.dev>
This commit is contained in:
Henrique Gemignani Passos Lima 2025-10-17 23:16:26 +02:00 committed by GitHub
parent c762f2522d
commit fe436d4386
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 424 additions and 10 deletions

View File

@ -1,10 +1,9 @@
name: build
name: Build
on: [ push, pull_request ]
jobs:
default:
name: Default
build:
strategy:
matrix:
platform: [ ubuntu-latest, macos-latest, windows-latest ]

143
.github/workflows/python.yml vendored Normal file
View File

@ -0,0 +1,143 @@
name: Python
on:
push:
branches:
- main
tags:
- "*"
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
wheels:
name: Build wheels (${{ matrix.platform }}, ${{ matrix.target }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
# Linux (glibc)
- os: ubuntu-latest
platform: linux
target: x86_64
- os: ubuntu-latest
platform: linux
target: x86
- os: ubuntu-latest
platform: linux
target: aarch64
- os: ubuntu-latest
platform: linux
target: armv7
- os: ubuntu-latest
platform: linux
target: s390x
- os: ubuntu-latest
platform: linux
target: ppc64le
# Linux (musl libc)
- os: ubuntu-latest
platform: musllinux
target: x86_64
manylinux: musllinux_1_2
- os: ubuntu-latest
platform: musllinux
target: x86
manylinux: musllinux_1_2
- os: ubuntu-latest
platform: musllinux
target: aarch64
manylinux: musllinux_1_2
- os: ubuntu-latest
platform: musllinux
target: armv7
manylinux: musllinux_1_2
# macOS
- os: macos-latest
platform: macos
target: x86_64
- os: macos-latest
platform: macos
target: aarch64
# Windows
- os: windows-latest
platform: windows
target: x64
- os: windows-latest
platform: windows
target: x86
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
if: matrix.platform == 'windows'
with:
architecture: ${{ matrix.target }}
python-version: 3.x
- name: Build wheels
uses: PyO3/maturin-action@v1
with:
target: ${{ matrix.target }}
args: --release --out dist
sccache: ${{ !startsWith(github.ref, 'refs/tags/') }}
manylinux: ${{ matrix.manylinux || 'auto' }}
- uses: actions/setup-python@v5
if: matrix.platform == 'windows'
with:
architecture: ${{ matrix.target }}
python-version: |
3.13t
3.14t
- name: Build free-threaded wheels
uses: PyO3/maturin-action@v1
with:
target: ${{ matrix.target }}
args: --release --out dist -i python3.13t -i python3.14t
sccache: ${{ !startsWith(github.ref, 'refs/tags/') }}
manylinux: ${{ matrix.manylinux || 'auto' }}
- name: Upload wheels
uses: actions/upload-artifact@v4
with:
name: wheels-${{ matrix.platform }}-${{ matrix.target }}
path: dist
sdist:
name: Build sdist
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build sdist
uses: PyO3/maturin-action@v1
with:
command: sdist
args: --out dist
- name: Upload sdist
uses: actions/upload-artifact@v4
with:
name: wheels-sdist
path: dist
release:
name: Release
runs-on: ubuntu-latest
if: ${{ startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' }}
needs: [wheels, sdist]
permissions:
id-token: write
contents: write
attestations: write
steps:
- uses: actions/download-artifact@v4
- name: Generate artifact attestation
uses: actions/attest-build-provenance@v2
with:
subject-path: "wheels-*/*"
- name: Publish to PyPI
if: ${{ startsWith(github.ref, 'refs/tags/') }}
uses: PyO3/maturin-action@v1
with:
command: upload
args: --non-interactive --skip-existing wheels-*/*

6
.gitignore vendored
View File

@ -1,3 +1,9 @@
/target
Cargo.lock
.idea
# Python
__pycache__/
dist/
.venv/
.pytest_cache/

View File

@ -18,7 +18,9 @@ alloc = ["zerocopy/alloc"]
std = ["alloc", "zerocopy/std"]
decompress = []
compress = []
python = ["pyo3/extension-module", "compress", "decompress", "std"]
default = ["compress", "decompress", "std"]
[dependencies]
zerocopy = { version = "0.8.27", default-features = false, features = ["derive"] }
pyo3 = { version = "0.26.0", features = ["extension-module", "abi3-py310", "generate-import-lib"], optional = true }

57
lzokay.pyi Normal file
View File

@ -0,0 +1,57 @@
"""Type stubs for lzokay - Pure Rust LZO compression library."""
# Exception hierarchy
class LzokayError(Exception):
"""Base exception for all lzokay errors."""
class LookbehindOverrunError(LzokayError):
"""Likely indicates bad compressed LZO input."""
class OutputOverrunError(LzokayError):
"""Output buffer was not large enough to store the compression/decompression result."""
class InputOverrunError(LzokayError):
"""Compressed input buffer is invalid or truncated."""
class LzokayUnknownError(LzokayError):
"""Unknown error."""
class InputNotConsumedError(LzokayError):
"""Decompression succeeded, but input buffer has remaining data."""
def compress(data: bytes) -> bytes:
"""
Compress data using LZO compression.
Args:
data: The input bytes to compress
Returns:
The compressed data as bytes
Raises:
OutputOverrunError:
LzokayUnknownError:
"""
def decompress(data: bytes, buffer_size: int) -> bytes:
"""
Decompress LZO compressed data.
Args:
data: The compressed input bytes
buffer_size: Expected size of the decompressed output
Returns:
The decompressed data as bytes
Raises:
LookbehindOverrunError:
OutputOverrunError:
InputOverrunError:
InputNotConsumedError:
LzokayUnknownError:
"""
def compress_worst_size(length: int) -> int:
"""Returns the worst-case size for LZO compression of data of given length."""

67
pyproject.toml Normal file
View File

@ -0,0 +1,67 @@
[build-system]
requires = ["maturin>=1.9,<2.0"]
build-backend = "maturin"
[project]
name = "lzokay"
description = "Python bindings for LZ👌, a LZO compression/decompression algorithm."
authors = [
{ name = "Luke Street", email = "luke@street.dev" },
{ name = "Henrique Gemignani", email = "henrique@gemignani.org" },
]
license = "MIT"
classifiers = [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"Programming Language :: Rust",
]
requires-python = ">=3.10"
dependencies = []
dynamic = ["version"]
[project.readme]
file = "README.md"
content-type = "text/markdown"
[project.urls]
Homepage = "https://github.com/encounter/lzokay-rs"
[project.optional-dependencies]
test = [
"pytest",
]
[tool.pytest.ini_options]
minversion = "6.0"
filterwarnings = [
"error",
]
xfail_strict = true
testpaths = [
"python/tests",
]
[tool.ruff]
line-length = 120
[tool.ruff.lint]
select = [
"E", "F", "W", "C90", "I", "UP", "C4",
"RSE",
"TCH",
"PTH",
"COM818", "COM819",
"ISC",
"PIE",
"PLC",
"PLE",
"PLR",
"PLW",
]
[tool.maturin]
features = ["python"]
# Matches pyo3 features in Cargo.toml
python-versions = ">=3.10"

View File

@ -0,0 +1,40 @@
import pytest
import lzokay
@pytest.mark.parametrize(
"data",
[
b"Hello World",
(
b"Hello Worldello Worldello Worldello Worldello Worldello Worldello Worldello "
b"Worldello Worldello Worldello Worldello Worldello Worldello Worldello World"
),
],
)
def test_compress_and_decompress(data):
compressed = lzokay.compress(data)
decompressed = lzokay.decompress(compressed, len(data))
assert decompressed == data
def test_output_overrun_decompress():
compressed = lzokay.compress(b"Hello World")
with pytest.raises(lzokay.OutputOverrunError):
lzokay.decompress(compressed, 1)
def test_input_overrun_decompress():
with pytest.raises(lzokay.InputOverrunError):
lzokay.decompress(b"", 1)
def test_input_not_consumed_decompress():
compressed = lzokay.compress(b"Hello World")
with pytest.raises(lzokay.InputNotConsumedError):
lzokay.decompress(compressed + b"00000000000", len(compressed))

View File

@ -49,6 +49,10 @@ pub mod compress;
#[cfg(feature = "decompress")]
pub mod decompress;
// Python bindings module
#[cfg(feature = "python")]
mod python;
/// Error result codes
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Error {
@ -64,15 +68,22 @@ pub enum Error {
InputNotConsumed,
}
impl Error {
/// Returns the error message as a string slice.
pub const fn as_str(self) -> &'static str {
match self {
Error::LookbehindOverrun => "lookbehind overrun",
Error::OutputOverrun => "output overrun",
Error::InputOverrun => "input overrun",
Error::Error => "unknown error",
Error::InputNotConsumed => "input not consumed",
}
}
}
impl core::fmt::Display for Error {
fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result {
match self {
Error::LookbehindOverrun => write!(f, "lookbehind overrun"),
Error::OutputOverrun => write!(f, "output overrun"),
Error::InputOverrun => write!(f, "input overrun"),
Error::Error => write!(f, "unknown error"),
Error::InputNotConsumed => write!(f, "input not consumed"),
}
write!(f, "{}", self.as_str())
}
}
@ -108,3 +119,13 @@ mod tests {
assert_eq!(INPUT2, dst.as_slice());
}
}
// Export Python module when python feature is enabled
#[cfg(feature = "python")]
use pyo3::prelude::*;
#[cfg(feature = "python")]
#[pymodule(gil_used = false)]
fn lzokay(m: &Bound<'_, PyModule>) -> PyResult<()> {
python::lzokay(m)
}

79
src/python.rs Normal file
View File

@ -0,0 +1,79 @@
use pyo3::{exceptions::PyException, prelude::*};
use crate::{compress, decompress, Error};
pyo3::create_exception!(lzokay, LzokayError, PyException, "Any kind of error.");
// Custom Python exception classes for each lzokay::Error variant
pyo3::create_exception!(
lzokay,
LookbehindOverrunError,
LzokayError,
"Likely indicates bad compressed LZO input."
);
pyo3::create_exception!(
lzokay,
OutputOverrunError,
LzokayError,
"Output buffer was not large enough to store the compression/decompression result."
);
pyo3::create_exception!(
lzokay,
InputOverrunError,
LzokayError,
"Compressed input buffer is invalid or truncated."
);
pyo3::create_exception!(lzokay, LzokayUnknownError, LzokayError, "Unknown error.");
pyo3::create_exception!(
lzokay,
InputNotConsumedError,
LzokayError,
"Decompression succeeded, but input buffer has remaining data."
);
// Helper function to convert lzokay::Error to appropriate Python exception
fn lzokay_error_to_pyerr(error: Error) -> PyErr {
match error {
Error::LookbehindOverrun => LookbehindOverrunError::new_err(error.as_str()),
Error::OutputOverrun => OutputOverrunError::new_err(error.as_str()),
Error::InputOverrun => InputOverrunError::new_err(error.as_str()),
Error::Error => LzokayUnknownError::new_err(error.as_str()),
Error::InputNotConsumed => InputNotConsumedError::new_err(error.as_str()),
}
}
/// Decompress
#[pyfunction(name = "decompress")]
fn py_decompress(data: &[u8], buffer_size: usize) -> PyResult<Vec<u8>> {
let mut dst = vec![0u8; buffer_size];
decompress::decompress(data, &mut dst).map_err(lzokay_error_to_pyerr)?;
Ok(dst)
}
/// Compress data using LZO compression.
#[pyfunction(name = "compress")]
fn py_compress(data: &[u8]) -> PyResult<Vec<u8>> {
compress::compress(data).map_err(lzokay_error_to_pyerr)
}
/// Returns the worst-case size for LZO compression of data of given length.
#[pyfunction(name = "compress_worst_size")]
fn py_compress_worst_size(length: usize) -> PyResult<usize> {
Ok(compress::compress_worst_size(length))
}
pub fn lzokay(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(py_decompress, m)?)?;
m.add_function(wrap_pyfunction!(py_compress, m)?)?;
m.add_function(wrap_pyfunction!(py_compress_worst_size, m)?)?;
// Add exception classes to the module
m.add("LzokayError", m.py().get_type::<LzokayError>())?;
m.add("LookbehindOverrunError", m.py().get_type::<LookbehindOverrunError>())?;
m.add("OutputOverrunError", m.py().get_type::<OutputOverrunError>())?;
m.add("InputOverrunError", m.py().get_type::<InputOverrunError>())?;
m.add("LzokayUnknownError", m.py().get_type::<LzokayUnknownError>())?;
m.add("InputNotConsumedError", m.py().get_type::<InputNotConsumedError>())?;
Ok(())
}