diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 0000000..3dcfe84 --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,33 @@ +name: Python CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ startsWith(github.ref, 'refs/pull/') }} + +jobs: + test: + name: Build & Test + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 + with: + install_args: rust + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + - run: pip install maturin pytest typst + - run: maturin build --out dist + - run: pip install dist/*.whl + - run: pytest bindings/python/tests diff --git a/.github/workflows/python-release.yml b/.github/workflows/python-release.yml new file mode 100644 index 0000000..8c98b26 --- /dev/null +++ b/.github/workflows/python-release.yml @@ -0,0 +1,134 @@ +name: Python Release + +on: + push: + tags: + - "v*" + workflow_dispatch: + +permissions: + contents: read + +jobs: + linux: + name: Wheels (linux-${{ matrix.platform.target }}) + runs-on: ${{ matrix.platform.runner || 'ubuntu-latest' }} + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + platform: + - target: x86_64 + - target: aarch64 + runner: ubuntu-24.04-arm + - target: armv7 + - target: s390x + - target: ppc64le + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + - name: Build wheels + uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0 + with: + target: ${{ matrix.platform.target }} + args: --release --out dist + manylinux: auto + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: wheels-linux-${{ matrix.platform.target }} + path: dist + if-no-files-found: error + + macos: + name: Wheels (macos-${{ matrix.target }}) + runs-on: macos-latest + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + target: [x86_64, aarch64] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 + with: + install_args: rust + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + - name: Build wheels + uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0 + with: + target: ${{ matrix.target }} + args: --release --out dist + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: wheels-macos-${{ matrix.target }} + path: dist + if-no-files-found: error + + windows: + name: Wheels (windows-${{ matrix.target }}) + runs-on: windows-latest + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + target: [x64] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 + with: + install_args: rust + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + architecture: ${{ matrix.target }} + - name: Build wheels + uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0 + with: + target: ${{ matrix.target }} + args: --release --out dist + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: wheels-windows-${{ matrix.target }} + path: dist + if-no-files-found: error + + sdist: + name: sdist + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Build sdist + uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0 + with: + command: sdist + args: --out dist + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: wheels-sdist + path: dist + if-no-files-found: error + + release: + name: Publish to PyPI + if: startsWith(github.ref, 'refs/tags/v') + needs: [linux, macos, windows, sdist] + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + id-token: write + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: wheels-* + path: dist + merge-multiple: true + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + with: + packages-dir: dist + skip-existing: true diff --git a/.gitignore b/.gitignore index ad67955..1f3bedb 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,15 @@ target # Contains mutation testing data **/mutants.out*/ +# Python bindings build artifacts +__pycache__/ +*.egg-info/ +.pytest_cache/ +dist/ +.venv/ +*.so +*.pyd + # RustRover # JetBrains specific template is maintained in a separate JetBrains.gitignore that can # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore diff --git a/Cargo.lock b/Cargo.lock index 5d51feb..086926b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -283,6 +283,63 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pyo3" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4688ddedf473e32662b9b067670129a8afb8c18e351482c70d62ba4a88171e8b" +dependencies = [ + "libc", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", +] + +[[package]] +name = "pyo3-build-config" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f41027e41b4bd03f6e60f9f417fe24a6341a6bb744edd62b6f709f2a52ea30e9" +dependencies = [ + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e591a95526fead067432c3b3a33fc74770b87b1e04e73671090d9c2055a2b327" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73225868fc1cd84eef2c3c230ddb91273bf1de46aeb8a4248da76d32a0924a1c" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "571575aa3749fa6216757dd47d2a3e7ef360f329a40f0666a9fbd14889024952" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "quote" version = "1.0.44" @@ -420,6 +477,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + [[package]] name = "thin-vec" version = "0.2.16" @@ -478,6 +541,14 @@ dependencies = [ "typst-syntax", ] +[[package]] +name = "typdiff-python" +version = "0.1.2" +dependencies = [ + "pyo3", + "typdiff", +] + [[package]] name = "typst-syntax" version = "0.14.2" diff --git a/Cargo.toml b/Cargo.toml index 4808d9e..70b1285 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,11 +1,19 @@ +[workspace] +members = ["bindings/python"] + +[workspace.package] +version = "0.1.2" +edition = "2024" +license = "Apache-2.0" + [package] name = "typdiff" -version = "0.1.2" +version.workspace = true authors = ["@sou1118"] -edition = "2024" +edition.workspace = true description = "A diff tool for Typst documents, similar to latexdiff" repository = "https://github.com/sou1118/typdiff" -license = "Apache-2.0" +license.workspace = true readme = "README.md" [[bin]] diff --git a/README.md b/README.md index 2ae40bf..3932825 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,38 @@ typdiff old.typ new.typ -o diff.typ typdiff old.typ new.typ -o diff.typ && typst compile diff.typ ``` +## Python + +`typdiff` is also available as a Python package. + +### Installation + +```sh +pip install typdiff +``` + +### Usage + +```python +import typdiff + +diff = typdiff.diff(old_bytes, new_bytes) # from bytes +diff = typdiff.diff_files("old.typ", "new.typ") # from file paths (str or Path) +``` + +`diff()` takes and returns `bytes`, matching how [`typst`](https://pypi.org/project/typst/)'s `compile()` treats `bytes` as inline source (a plain `str` argument is instead treated as a path to read) — so `typdiff`'s output can be passed straight into `compile()`. `diff_files()` still takes file paths as `str`/`Path`, since those are paths rather than document content. + +### Producing a PDF + +Combine `typdiff` with `typst` (Python bindings for the Typst compiler, `pip install typst`) to go straight from two Typst files to a diff PDF, without shelling out to either CLI: + +```python +import typdiff +import typst + +pdf_bytes = typst.compile(typdiff.diff_files("old.typ", "new.typ")) +``` + ## Features - **Block-level diffing** — Detects structural changes in headings, paragraphs, list items, enum items, and term list items diff --git a/bindings/python/Cargo.toml b/bindings/python/Cargo.toml new file mode 100644 index 0000000..e298d8e --- /dev/null +++ b/bindings/python/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "typdiff-python" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false + +[lib] +name = "_typdiff" +crate-type = ["cdylib"] + +[dependencies] +typdiff = { path = "../.." } +pyo3 = { version = "0.29", features = ["extension-module", "abi3-py38"] } diff --git a/bindings/python/python/typdiff/__init__.py b/bindings/python/python/typdiff/__init__.py new file mode 100644 index 0000000..5b21244 --- /dev/null +++ b/bindings/python/python/typdiff/__init__.py @@ -0,0 +1,3 @@ +from ._typdiff import diff, diff_files + +__all__ = ["diff", "diff_files"] diff --git a/bindings/python/python/typdiff/__init__.pyi b/bindings/python/python/typdiff/__init__.pyi new file mode 100644 index 0000000..8a70353 --- /dev/null +++ b/bindings/python/python/typdiff/__init__.pyi @@ -0,0 +1,7 @@ +import os + +def diff(old: bytes, new: bytes) -> bytes: + """Diff two Typst documents given as source bytes, returning diff markup as bytes.""" + +def diff_files(old_path: str | os.PathLike[str], new_path: str | os.PathLike[str]) -> bytes: + """Diff two Typst documents given as file paths, returning diff markup as bytes.""" diff --git a/bindings/python/python/typdiff/py.typed b/bindings/python/python/typdiff/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs new file mode 100644 index 0000000..aa587c3 --- /dev/null +++ b/bindings/python/src/lib.rs @@ -0,0 +1,43 @@ +use std::path::PathBuf; + +use pyo3::exceptions::{PyOSError, PyValueError}; +use pyo3::prelude::*; + +fn diff_sources(old: &str, new: &str) -> Vec { + let filter = |b: &typdiff::Block| !matches!(b, typdiff::Block::Parbreak); + let old_blocks: Vec<_> = typdiff::parse::parse(old) + .into_iter() + .filter(filter) + .collect(); + let new_blocks: Vec<_> = typdiff::parse::parse(new) + .into_iter() + .filter(filter) + .collect(); + let diff_results = typdiff::diff::diff(&old_blocks, &new_blocks); + typdiff::render::render(&diff_results).into_bytes() +} + +/// Diff two Typst documents given as source bytes, returning diff markup as +/// bytes (matching what typst.compile() expects for inline source). +#[pyfunction] +fn diff(old: &[u8], new: &[u8]) -> PyResult> { + let old = std::str::from_utf8(old).map_err(|e| PyValueError::new_err(e.to_string()))?; + let new = std::str::from_utf8(new).map_err(|e| PyValueError::new_err(e.to_string()))?; + Ok(diff_sources(old, new)) +} + +/// Diff two Typst documents given as file paths, returning diff markup as +/// bytes (matching what typst.compile() expects for inline source). +#[pyfunction] +fn diff_files(old_path: PathBuf, new_path: PathBuf) -> PyResult> { + let old = std::fs::read_to_string(&old_path).map_err(|e| PyOSError::new_err(e.to_string()))?; + let new = std::fs::read_to_string(&new_path).map_err(|e| PyOSError::new_err(e.to_string()))?; + Ok(diff_sources(&old, &new)) +} + +#[pymodule] +fn _typdiff(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(diff, m)?)?; + m.add_function(wrap_pyfunction!(diff_files, m)?)?; + Ok(()) +} diff --git a/bindings/python/tests/test_diff.py b/bindings/python/tests/test_diff.py new file mode 100644 index 0000000..f7c867d --- /dev/null +++ b/bindings/python/tests/test_diff.py @@ -0,0 +1,48 @@ +from pathlib import Path + +import pytest + +import typdiff + +FIXTURES = Path(__file__).parents[3] / "tests" / "fixtures" + + +def test_diff(): + old = (FIXTURES / "old.typ").read_bytes() + new = (FIXTURES / "new.typ").read_bytes() + + output = typdiff.diff(old, new) + + assert isinstance(output, bytes) + assert b"#diff-deleted[Introduction]" in output + assert b"#diff-added[Background]" in output + assert b"#diff-deleted[Second]#diff-added[Third] item" in output + + +@pytest.mark.parametrize("as_path", [str, Path]) +def test_diff_files(as_path): + output = typdiff.diff_files(as_path(FIXTURES / "old.typ"), as_path(FIXTURES / "new.typ")) + + assert output == typdiff.diff( + (FIXTURES / "old.typ").read_bytes(), (FIXTURES / "new.typ").read_bytes() + ) + + +def test_diff_files_missing(): + with pytest.raises(OSError): + typdiff.diff_files(str(FIXTURES / "does-not-exist.typ"), str(FIXTURES / "new.typ")) + + +def test_diff_invalid_utf8(): + with pytest.raises(ValueError): + typdiff.diff(b"\xff\xfe", b"new") + + +@pytest.mark.pdf +def test_diff_compiles_to_pdf(): + typst = pytest.importorskip("typst") + + output = typdiff.diff_files(FIXTURES / "old.typ", FIXTURES / "new.typ") + pdf = typst.compile(output) + + assert pdf.startswith(b"%PDF-") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9f8eb18 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,41 @@ +[build-system] +requires = ["maturin>=1.9,<2.0"] +build-backend = "maturin" + +[project] +name = "typdiff" +requires-python = ">=3.8" +classifiers = [ + "Programming Language :: Rust", + "Programming Language :: Python :: Implementation :: CPython", +] +dynamic = ["version"] +dependencies = [] +license = "Apache-2.0" +license-files = ["LICENSE"] +description = "A diff tool for Typst documents, similar to latexdiff for LaTeX." +readme = "README.md" + +[project.urls] +Homepage = "https://github.com/sou1118/typdiff" +Repository = "https://github.com/sou1118/typdiff" +Readme = "https://github.com/sou1118/typdiff/blob/main/README.md" + +[dependency-groups] +dev = [ + "pytest>=8", +] +pdf = [ + "typst>=0.13", +] + +[tool.pytest.ini_options] +markers = [ + "pdf: exercises compiling typdiff output to PDF via the `typst` package (optional, requires the `pdf` dependency group)", +] + +[tool.maturin] +manifest-path = "bindings/python/Cargo.toml" +module-name = "typdiff._typdiff" +python-source = "bindings/python/python" +features = ["pyo3/extension-module"]