Repository: duesee/imap-codec (language: python)

Issue:
feat: Python bindings.



Discussion / hints:

Fix patch:
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index e2c232b9..9a313076 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -55,6 +55,15 @@ jobs:
 #     - run: just bench_against_main
 #     - uses: ./.github/actions/cache_save
 
+  bindings:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - uses: ./.github/actions/cache_restore
+      - run: cargo install just
+      - run: just bindings
+      - uses: ./.github/actions/cache_save
+
   coverage:
     runs-on: ubuntu-latest
 
diff --git a/Cargo.lock b/Cargo.lock
index 46453f6c..7d78ad50 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1231,6 +1231,13 @@ dependencies = [
  "libfuzzer-sys",
 ]
 
+[[package]]
+name = "imap-codec-python"
+version = "0.1.0-dev1"
+dependencies = [
+ "pyo3",
+]
+
 [[package]]
 name = "imap-proto"
 version = "0.16.5"
@@ -1295,6 +1302,12 @@ dependencies = [
  "hashbrown 0.14.5",
 ]
 
+[[package]]
+name = "indoc"
+version = "2.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5"
+
 [[package]]
 name = "inout"
 version = "0.1.3"
@@ -1575,6 +1588,15 @@ dependencies = [
  "autocfg",
 ]
 
+[[package]]
+name = "memoffset"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
+dependencies = [
+ "autocfg",
+]
+
 [[package]]
 name = "mime"
 version = "0.3.17"
@@ -1616,7 +1638,7 @@ dependencies = [
  "bitflags 1.3.2",
  "cfg-if",
  "libc",
- "memoffset",
+ "memoffset 0.7.1",
  "pin-utils",
 ]
 
@@ -2042,6 +2064,12 @@ dependencies = [
  "plotters-backend",
 ]
 
+[[package]]
+name = "portable-atomic"
+version = "1.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7170ef9988bc169ba16dd36a7fa041e5c4cbeb6a35b76d4c03daded371eae7c0"
+
 [[package]]
 name = "powerfmt"
 version = "0.2.0"
@@ -2106,6 +2134,68 @@ dependencies = [
  "tokio",
 ]
 
+[[package]]
+name = "pyo3"
+version = "0.21.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a5e00b96a521718e08e03b1a622f01c8a8deb50719335de3f60b3b3950f069d8"
+dependencies = [
+ "cfg-if",
+ "indoc",
+ "libc",
+ "memoffset 0.9.1",
+ "parking_lot",
+ "portable-atomic",
+ "pyo3-build-config",
+ "pyo3-ffi",
+ "pyo3-macros",
+ "unindent",
+]
+
+[[package]]
+name = "pyo3-build-config"
+version = "0.21.2"
+source = "git+https://github.com/PyO3/pyo3/?rev=2c205d4586bef8f839eb6a55eb91b905074ddce9#2c205d4586bef8f839eb6a55eb91b905074ddce9"
+dependencies = [
+ "once_cell",
+ "target-lexicon",
+]
+
+[[package]]
+name = "pyo3-ffi"
+version = "0.21.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "01be5843dc60b916ab4dad1dca6d20b9b4e6ddc8e15f50c47fe6d85f1fb97403"
+dependencies = [
+ "libc",
+ "pyo3-build-config",
+]
+
+[[package]]
+name = "pyo3-macros"
+version = "0.21.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77b34069fc0682e11b31dbd10321cbf94808394c56fd996796ce45217dfac53c"
+dependencies = [
+ "proc-macro2",
+ "pyo3-macros-backend",
+ "quote",
+ "syn 2.0.66",
+]
+
+[[package]]
+name = "pyo3-macros-backend"
+version = "0.21.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08260721f32db5e1a5beae69a55553f56b99bd0e1c3e6e0a5e8851a9d0f5a85c"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "pyo3-build-config",
+ "quote",
+ "syn 2.0.66",
+]
+
 [[package]]
 name = "quick-error"
 version = "1.2.3"
@@ -2747,6 +2837,12 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "target-lexicon"
+version = "0.12.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1fc403891a21bcfb7c37834ba66a547a8f402146eba7265b5a6d88059c9ff2f"
+
 [[package]]
 name = "thiserror"
 version = "1.0.61"
@@ -3175,6 +3271,12 @@ version = "0.2.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f962df74c8c05a667b5ee8bcf162993134c104e96440b663c8daa176dc772d8c"
 
+[[package]]
+name = "unindent"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7de7d73e1754487cb58364ee906a499937a0dfabd86bcb980fa99ec8c8fa2ce"
+
 [[package]]
 name = "untrusted"
 version = "0.7.1"
diff --git a/Cargo.toml b/Cargo.toml
index 05834438..e32ef923 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,7 @@
 [workspace]
 resolver = "2"
 members = [
+    "bindings/imap-codec-python",
     "imap-codec",
     "imap-codec/benchmark",
     "imap-codec/fuzz",
@@ -14,3 +15,9 @@ members = [
 [patch.crates-io]
 imap-types = { path = "imap-types" }
 imap-codec = { path = "imap-codec" }
+# Package `pyo3-build-config` v0.21.2 specifies its `target-lexicon` dependency with version
+# "0.12", however, only versions from 0.12.6 on will work.
+# This patches `pyo3-build-config` with a version where this dependency has already been fixed
+# until a new version containing this fix has been released.
+# Note: When removing the patch, also remove the corresponding entry in `deny.toml`. (see #516)
+pyo3-build-config = { git = "https://github.com/PyO3/pyo3/", rev = "2c205d4586bef8f839eb6a55eb91b905074ddce9" }
diff --git a/bindings/imap-codec-python/.gitignore b/bindings/imap-codec-python/.gitignore
new file mode 100644
index 00000000..c8f04429
--- /dev/null
+++ b/bindings/imap-codec-python/.gitignore
@@ -0,0 +1,72 @@
+/target
+
+# Byte-compiled / optimized / DLL files
+__pycache__/
+.pytest_cache/
+*.py[cod]
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+.venv/
+env/
+bin/
+build/
+develop-eggs/
+dist/
+eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+include/
+man/
+venv/
+*.egg-info/
+.installed.cfg
+*.egg
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+pip-selfcheck.json
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.coverage
+.cache
+nosetests.xml
+coverage.xml
+
+# Translations
+*.mo
+
+# Mr Developer
+.mr.developer.cfg
+.project
+.pydevproject
+
+# Rope
+.ropeproject
+
+# Django stuff:
+*.log
+*.pot
+
+.DS_Store
+
+# Sphinx documentation
+docs/_build/
+
+# PyCharm
+.idea/
+
+# VSCode
+.vscode/
+
+# Pyenv
+.python-version
diff --git a/bindings/imap-codec-python/Cargo.toml b/bindings/imap-codec-python/Cargo.toml
new file mode 100644
index 00000000..eacdf1e4
--- /dev/null
+++ b/bindings/imap-codec-python/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "imap-codec-python"
+description = "Python bindings for imap-codec"
+version = "0.1.0-dev1"
+license = "MIT OR Apache-2.0"
+edition = "2021"
+
+# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+[lib]
+name = "imap_codec"
+crate-type = ["cdylib"]
+# required as this lib name is identical to `imap-codec`
+doc = false
+
+[dependencies]
+pyo3 = "0.21.2"
diff --git a/bindings/imap-codec-python/imap_codec.pyi b/bindings/imap-codec-python/imap_codec.pyi
new file mode 100644
index 00000000..83aca9f1
--- /dev/null
+++ b/bindings/imap-codec-python/imap_codec.pyi
@@ -0,0 +1,4 @@
+def sum_as_string(a: int, b: int) -> str:
+    """
+    Formats the sum of two numbers as string.
+    """
diff --git a/bindings/imap-codec-python/pyproject.toml b/bindings/imap-codec-python/pyproject.toml
new file mode 100644
index 00000000..fc03e511
--- /dev/null
+++ b/bindings/imap-codec-python/pyproject.toml
@@ -0,0 +1,15 @@
+[build-system]
+requires = ["maturin>=1.6,<2.0"]
+build-backend = "maturin"
+
+[project]
+name = "imap-codec"
+requires-python = ">=3.8"
+classifiers = [
+    "Programming Language :: Rust",
+    "Programming Language :: Python :: Implementation :: CPython",
+    "Programming Language :: Python :: Implementation :: PyPy",
+]
+dynamic = ["version"]
+[tool.maturin]
+features = ["pyo3/extension-module"]
diff --git a/bindings/imap-codec-python/src/lib.rs b/bindings/imap-codec-python/src/lib.rs
new file mode 100644
index 00000000..24f71f7f
--- /dev/null
+++ b/bindings/imap-codec-python/src/lib.rs
@@ -0,0 +1,15 @@
+use pyo3::prelude::*;
+
+/// Formats the sum of two numbers as string.
+#[pyfunction]
+fn sum_as_string(a: usize, b: usize) -> PyResult<String> {
+    Ok((a + b).to_string())
+}
+
+/// A Python module implemented in Rust.
+#[pymodule]
+#[pyo3(name = "imap_codec")]
+fn imap_codec_python(m: &Bound<'_, PyModule>) -> PyResult<()> {
+    m.add_function(wrap_pyfunction!(sum_as_string, m)?)?;
+    Ok(())
+}
diff --git a/deny.toml b/deny.toml
index 2e564a86..29abffcc 100644
--- a/deny.toml
+++ b/deny.toml
@@ -1,13 +1,21 @@
 [sources]
 unknown-registry = "deny"
-unknown-git      = "deny"
+unknown-git = "deny"
 allow-git = [
     # Used in benchmarking
     "https://github.com/stalwartlabs/mail-server?rev=53f0222f308b3e844c158fc0e603d10361da3c63",
+    # Used for patching `pyo3-build-config` (see #516)
+    "https://github.com/PyO3/pyo3/?rev=2c205d4586bef8f839eb6a55eb91b905074ddce9",
 ]
 
 [licenses]
-allow = [ "Apache-2.0", "MIT", "BSD-3-Clause", "Unicode-DFS-2016" ]
+allow = [
+    "Apache-2.0",
+    "Apache-2.0 WITH LLVM-exception",
+    "MIT",
+    "BSD-3-Clause",
+    "Unicode-DFS-2016",
+]
 
 [licenses.private]
 ignore = true
diff --git a/justfile b/justfile
index efa7802b..1a2b7416 100644
--- a/justfile
+++ b/justfile
@@ -137,6 +137,24 @@ bench_against_main:
     cargo bench -p imap-codec-bench
     rm -rf target/bench_tmp
 
+# Build and test bindings
+bindings: bindings_python
+
+# Build and test Python bindings
+bindings_python: install_python_black install_python_maturin install_python_mypy install_python_ruff
+    # Remove any old wheels
+    rm -rf target/wheels/*
+    # Lint Python code using Black and Ruff
+    python -m black --check bindings/imap-codec-python
+    python -m ruff check bindings/imap-codec-python
+    # Build Python extension for all available interpreter version
+    cd bindings/imap-codec-python; maturin build --release --find-interpreter
+    # Install extension and run unit tests
+    pip install --force-reinstall --find-links=target/wheels/ imap_codec
+    cd bindings/imap-codec-python; python -m unittest -v
+    # Perform static type checking using mypy
+    python -m mypy bindings/imap-codec-python
+
 # Measure test coverage
 coverage: install_rust_llvm_tools_preview install_cargo_grcov
     # Old build artifacts seem to be able to mess up coverage data (see #508),
@@ -190,13 +208,17 @@ minimal_versions: install_rust_1_65 install_rust_nightly
 install: install_rust_1_65 \
          install_rust_nightly \
          install_rust_nightly_fmt \
-	 install_rust_llvm_tools_preview \
+         install_rust_llvm_tools_preview \
          install_cargo_clippy \
          install_cargo_deny \
          install_cargo_fuzz \
-	 install_cargo_grcov \
+         install_cargo_grcov \
          install_cargo_hack \
-         install_cargo_semver_checks
+         install_cargo_semver_checks \
+         install_python_black \
+         install_python_maturin \
+         install_python_mypy \
+         install_python_ruff
 
 [private]
 install_rust_1_65:
@@ -238,3 +260,18 @@ install_cargo_hack:
 install_cargo_semver_checks:
     cargo install --locked cargo-semver-checks
 
+[private]
+install_python_black:
+    python -m pip install -U black
+
+[private]
+install_python_maturin:
+    python -m pip install -U maturin
+
+[private]
+install_python_mypy:
+    python -m pip install -U mypy
+
+[private]
+install_python_ruff:
+    python -m pip install -U ruff
\ No newline at end of file


Test patch:
diff --git a/bindings/imap-codec-python/tests/__init__.py b/bindings/imap-codec-python/tests/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/bindings/imap-codec-python/tests/test_sum_as_string.py b/bindings/imap-codec-python/tests/test_sum_as_string.py
new file mode 100644
index 00000000..269bef0c
--- /dev/null
+++ b/bindings/imap-codec-python/tests/test_sum_as_string.py
@@ -0,0 +1,23 @@
+import unittest
+
+from imap_codec import sum_as_string
+
+
+class TestSumAsString(unittest.TestCase):
+    def test_1(self):
+        self.assertEqual(sum_as_string(0, 1), "1")
+        self.assertEqual(sum_as_string(1, 0), "1")
+
+    def test_2(self):
+        self.assertEqual(sum_as_string(0, 2), "2")
+        self.assertEqual(sum_as_string(1, 1), "2")
+        self.assertEqual(sum_as_string(2, 0), "2")
+
+    def test_3(self):
+        self.assertEqual(sum_as_string(0, 3), "3")
+        self.assertEqual(sum_as_string(1, 2), "3")
+        self.assertEqual(sum_as_string(2, 1), "3")
+        self.assertEqual(sum_as_string(3, 0), "3")
+
+    def test_100(self):
+        self.assertEqual(sum_as_string(50, 50), "100")