diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index c48bc0d..f30049d 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -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 ] diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml new file mode 100644 index 0000000..8ed80b3 --- /dev/null +++ b/.github/workflows/python.yml @@ -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-*/* diff --git a/.gitignore b/.gitignore index b471067..78fee8a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,9 @@ /target Cargo.lock .idea + +# Python +__pycache__/ +dist/ +.venv/ +.pytest_cache/ \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index 91b2ad4..4f71cee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 } diff --git a/lzokay.pyi b/lzokay.pyi new file mode 100644 index 0000000..32455f6 --- /dev/null +++ b/lzokay.pyi @@ -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.""" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9e754b4 --- /dev/null +++ b/pyproject.toml @@ -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" diff --git a/python/tests/test_native.py b/python/tests/test_native.py new file mode 100644 index 0000000..94f4682 --- /dev/null +++ b/python/tests/test_native.py @@ -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)) diff --git a/src/lib.rs b/src/lib.rs index 4adc4a2..626d868 100644 --- a/src/lib.rs +++ b/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) +} diff --git a/src/python.rs b/src/python.rs new file mode 100644 index 0000000..20f40ec --- /dev/null +++ b/src/python.rs @@ -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> { + 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> { + 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 { + 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::())?; + m.add("LookbehindOverrunError", m.py().get_type::())?; + m.add("OutputOverrunError", m.py().get_type::())?; + m.add("InputOverrunError", m.py().get_type::())?; + m.add("LzokayUnknownError", m.py().get_type::())?; + m.add("InputNotConsumedError", m.py().get_type::())?; + + Ok(()) +}