mirror of
https://github.com/encounter/lzokay-rs.git
synced 2025-10-20 16:05:47 +00:00
Add python bindings using maturin (#3)
Co-authored-by: Luke Street <luke@street.dev>
This commit is contained in:
parent
c762f2522d
commit
fe436d4386
5
.github/workflows/build.yaml
vendored
5
.github/workflows/build.yaml
vendored
@ -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
143
.github/workflows/python.yml
vendored
Normal 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
6
.gitignore
vendored
@ -1,3 +1,9 @@
|
||||
/target
|
||||
Cargo.lock
|
||||
.idea
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
dist/
|
||||
.venv/
|
||||
.pytest_cache/
|
@ -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
57
lzokay.pyi
Normal 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
67
pyproject.toml
Normal 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"
|
40
python/tests/test_native.py
Normal file
40
python/tests/test_native.py
Normal 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))
|
35
src/lib.rs
35
src/lib.rs
@ -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
79
src/python.rs
Normal 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(())
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user