mirror of
https://github.com/encounter/lzokay-rs.git
synced 2025-10-21 16:35:46 +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 ]
|
on: [ push, pull_request ]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
default:
|
build:
|
||||||
name: Default
|
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
platform: [ ubuntu-latest, macos-latest, windows-latest ]
|
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
|
/target
|
||||||
Cargo.lock
|
Cargo.lock
|
||||||
.idea
|
.idea
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
dist/
|
||||||
|
.venv/
|
||||||
|
.pytest_cache/
|
@ -18,7 +18,9 @@ alloc = ["zerocopy/alloc"]
|
|||||||
std = ["alloc", "zerocopy/std"]
|
std = ["alloc", "zerocopy/std"]
|
||||||
decompress = []
|
decompress = []
|
||||||
compress = []
|
compress = []
|
||||||
|
python = ["pyo3/extension-module", "compress", "decompress", "std"]
|
||||||
default = ["compress", "decompress", "std"]
|
default = ["compress", "decompress", "std"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
zerocopy = { version = "0.8.27", default-features = false, features = ["derive"] }
|
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")]
|
#[cfg(feature = "decompress")]
|
||||||
pub mod decompress;
|
pub mod decompress;
|
||||||
|
|
||||||
|
// Python bindings module
|
||||||
|
#[cfg(feature = "python")]
|
||||||
|
mod python;
|
||||||
|
|
||||||
/// Error result codes
|
/// Error result codes
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub enum Error {
|
pub enum Error {
|
||||||
@ -64,15 +68,22 @@ pub enum Error {
|
|||||||
InputNotConsumed,
|
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 {
|
impl core::fmt::Display for Error {
|
||||||
fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result {
|
fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result {
|
||||||
match self {
|
write!(f, "{}", self.as_str())
|
||||||
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"),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -108,3 +119,13 @@ mod tests {
|
|||||||
assert_eq!(INPUT2, dst.as_slice());
|
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