Skip to content

Commit f777188

Browse files
Add typed Python package (pyo3 + .pyi/py.typed) behind the python feature, and CI
Mixed maturin layout: compiled difflib_fast._difflib_fast + python/difflib_fast/ with type stubs + py.typed (pyright/mypy see full signatures). abi3-py39 → one wheel per platform for CPython 3.9+. Installable as a git dependency (pip drives maturin; no manual build). GitHub Actions: test+clippy, build wheels (linux/macos/windows) + sdist, and on a vX.Y.Z tag attach them to a GitHub Release + publish to crates.io. No PyPI. Benchmarks excluded from the published crate/sdist.
1 parent 2324df4 commit f777188

9 files changed

Lines changed: 224 additions & 11 deletions

File tree

.github/workflows/CI.yml

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
# Build + test, then on a version tag (`vX.Y.Z`): attach wheels + sdist to a GitHub Release and publish
2+
# the crate to crates.io. (No PyPI — install the Python package from a release wheel or via
3+
# `pip install git+https://github.com/prostomarkeloff/difflib-fast`.)
4+
#
5+
# Required repository secret for the release:
6+
# CARGO_REGISTRY_TOKEN — a crates.io API token (the GitHub Release uses the built-in GITHUB_TOKEN)
7+
#
8+
# abi3 (pyo3 abi3-py39) → one wheel per platform/arch works on CPython 3.9+, so no per-Python matrix.
9+
name: CI
10+
11+
on:
12+
push:
13+
branches: [main]
14+
tags: ["v*"]
15+
pull_request:
16+
workflow_dispatch:
17+
18+
permissions:
19+
contents: read
20+
21+
jobs:
22+
test:
23+
name: test + clippy
24+
runs-on: ubuntu-latest
25+
steps:
26+
- uses: actions/checkout@v4
27+
- uses: dtolnay/rust-toolchain@stable
28+
with:
29+
components: clippy
30+
- run: cargo test --release
31+
- run: cargo clippy --release --all-targets --features bench
32+
33+
linux:
34+
runs-on: ubuntu-latest
35+
strategy:
36+
matrix:
37+
target: [x86_64, aarch64]
38+
steps:
39+
- uses: actions/checkout@v4
40+
- uses: actions/setup-python@v5
41+
with:
42+
python-version: "3.12"
43+
- name: Build wheels
44+
uses: PyO3/maturin-action@v1
45+
with:
46+
target: ${{ matrix.target }}
47+
args: --release --out dist
48+
sccache: "true"
49+
manylinux: auto
50+
- uses: actions/upload-artifact@v4
51+
with:
52+
name: wheels-linux-${{ matrix.target }}
53+
path: dist
54+
55+
macos:
56+
runs-on: macos-latest
57+
strategy:
58+
matrix:
59+
target: [x86_64, aarch64]
60+
steps:
61+
- uses: actions/checkout@v4
62+
- uses: actions/setup-python@v5
63+
with:
64+
python-version: "3.12"
65+
- name: Build wheels
66+
uses: PyO3/maturin-action@v1
67+
with:
68+
target: ${{ matrix.target }}
69+
args: --release --out dist
70+
sccache: "true"
71+
- uses: actions/upload-artifact@v4
72+
with:
73+
name: wheels-macos-${{ matrix.target }}
74+
path: dist
75+
76+
windows:
77+
runs-on: windows-latest
78+
steps:
79+
- uses: actions/checkout@v4
80+
- uses: actions/setup-python@v5
81+
with:
82+
python-version: "3.12"
83+
- name: Build wheels
84+
uses: PyO3/maturin-action@v1
85+
with:
86+
target: x64
87+
args: --release --out dist
88+
sccache: "true"
89+
- uses: actions/upload-artifact@v4
90+
with:
91+
name: wheels-windows
92+
path: dist
93+
94+
sdist:
95+
runs-on: ubuntu-latest
96+
steps:
97+
- uses: actions/checkout@v4
98+
- name: Build sdist
99+
uses: PyO3/maturin-action@v1
100+
with:
101+
command: sdist
102+
args: --out dist
103+
- uses: actions/upload-artifact@v4
104+
with:
105+
name: wheels-sdist
106+
path: dist
107+
108+
release:
109+
name: GitHub release (wheels + sdist)
110+
runs-on: ubuntu-latest
111+
if: startsWith(github.ref, 'refs/tags/')
112+
needs: [test, linux, macos, windows, sdist]
113+
permissions:
114+
contents: write
115+
steps:
116+
- uses: actions/download-artifact@v4
117+
with:
118+
path: dist
119+
pattern: wheels-*
120+
merge-multiple: true
121+
- name: Attach wheels + sdist to the release
122+
uses: softprops/action-gh-release@v2
123+
with:
124+
files: dist/*
125+
126+
crates:
127+
name: publish to crates.io
128+
runs-on: ubuntu-latest
129+
if: startsWith(github.ref, 'refs/tags/')
130+
needs: [test]
131+
steps:
132+
- uses: actions/checkout@v4
133+
- uses: dtolnay/rust-toolchain@stable
134+
- run: cargo publish --token ${{ secrets.CARGO_REGISTRY_TOKEN }}

.gitignore

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
# Rust build artifacts (this crate's, the bench bin's, and benchmarks/compare's)
22
target/
33

4+
# Python build artifacts (maturin develop drops the compiled module + caches into python-source)
5+
__pycache__/
6+
*.py[cod]
7+
*.so
8+
*.pyd
9+
*.dylib
10+
dist/
11+
*.egg-info/
12+
413
# samply / profiling output
514
*.json.gz
615
flame.svg

Cargo.toml

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ edition = "2021"
88
license = "MIT"
99
repository = "https://github.com/prostomarkeloff/difflib-fast"
1010
authors = ["prostomarkeloff"]
11+
# The benchmark suite + corpora (large, derived) are not part of the published crate / Python sdist.
12+
exclude = ["/benchmarks"]
1113

1214
[lib]
1315
name = "difflib_fast"
@@ -16,9 +18,9 @@ crate-type = ["cdylib", "rlib"]
1618

1719
[dependencies]
1820
rayon = "1"
19-
# Optional Python bindings (pip install difflib-fast): build the extension module with maturin
20-
# and `--features python`. The pure-Rust crate has zero Python dependency.
21-
pyo3 = { version = "0.28", optional = true, features = ["extension-module"] }
21+
# Optional Python bindings (the `python` feature, built by maturin). `abi3-py39` → one wheel per
22+
# platform works on CPython 3.9+. The pure-Rust crate has zero Python dependency by default.
23+
pyo3 = { version = "0.28", optional = true, features = ["extension-module", "abi3-py39"] }
2224
# Used ONLY as the bench binary's global allocator (libraries must not set one) — gated so the
2325
# published library never pulls it in. macOS's default malloc madvise churn cost ~25% once parallel.
2426
mimalloc = { version = "0.1", default-features = false, optional = true }

README.md

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -144,16 +144,30 @@ Levenshtein), not `difflib`'s.
144144

145145
---
146146

147-
## Python bindings
147+
## Python package
148148

149-
The pure-Rust crate has **zero** Python dependency. Build with the `python` feature +
150-
[maturin](https://github.com/PyO3/maturin) for a `pip install`-able extension exposing `ratio`,
151-
`cluster_canonicals`, and `cluster_canonicals_lsh`:
149+
A proper, **typed** Python package (`py.typed` + `.pyi` stubs — pyright/mypy see full signatures),
150+
gated behind the `python` cargo feature so the pure-Rust crate keeps **zero** Python dependency by
151+
default.
152152

153153
```bash
154-
maturin develop --release --features python
154+
# from source (needs a Rust toolchain; pip drives maturin automatically — no manual build):
155+
pip install git+https://github.com/prostomarkeloff/difflib-fast
156+
157+
# or grab a prebuilt abi3 wheel (CPython 3.9+) from the GitHub Releases page — no Rust needed.
155158
```
156159

160+
```python
161+
import difflib_fast
162+
163+
difflib_fast.ratio("the quick brown fox", "the quick brown dog") # 0.8947368421052632 — == difflib
164+
difflib_fast.cluster_canonicals(["def f(a): ...", "def f(x): ...", "other"], 0.5)
165+
# → [([0, 1], 0.86…)]
166+
```
167+
168+
Built with [maturin](https://github.com/PyO3/maturin) (mixed layout: compiled `_difflib_fast` +
169+
`python/difflib_fast/` package). Build locally into a venv with `maturin develop --release`.
170+
157171
---
158172

159173
## How it works

pyproject.toml

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,26 @@ build-backend = "maturin"
55
[project]
66
name = "difflib-fast"
77
description = "Fast, byte-for-byte exact difflib Ratcliff-Obershelp similarity + clustering."
8+
readme = "README.md"
89
requires-python = ">=3.9"
910
license = { text = "MIT" }
10-
classifiers = ["Programming Language :: Rust"]
11+
authors = [{ name = "prostomarkeloff" }]
12+
keywords = ["difflib", "similarity", "ratcliff-obershelp", "fuzzy", "diff"]
13+
classifiers = [
14+
"Programming Language :: Rust",
15+
"Programming Language :: Python :: Implementation :: CPython",
16+
"Topic :: Text Processing :: General",
17+
"License :: OSI Approved :: MIT License",
18+
"Typing :: Typed",
19+
]
1120
dynamic = ["version"]
1221

22+
[project.urls]
23+
Repository = "https://github.com/prostomarkeloff/difflib-fast"
24+
1325
[tool.maturin]
1426
features = ["python"]
15-
module-name = "difflib_fast"
27+
# mixed layout: the compiled extension is `difflib_fast._difflib_fast`; the Python package under
28+
# `python/difflib_fast/` re-exports it and ships the type stubs (`.pyi`) + `py.typed`.
29+
module-name = "difflib_fast._difflib_fast"
30+
python-source = "python"

python/difflib_fast/__init__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
"""difflib-fast — fast, byte-for-byte exact difflib Ratcliff-Obershelp similarity + clustering.
2+
3+
A drop-in for ``difflib.SequenceMatcher(None, a, b, autojunk=False).ratio()``, computed with a suffix
4+
automaton (Rust), plus exact single-linkage clustering of a corpus.
5+
"""
6+
7+
from ._difflib_fast import cluster_canonicals, cluster_canonicals_lsh, ratio
8+
9+
__all__ = ["ratio", "cluster_canonicals", "cluster_canonicals_lsh"]

python/difflib_fast/__init__.pyi

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"""Type stubs for difflib-fast — exact difflib Ratcliff-Obershelp similarity + clustering."""
2+
3+
def ratio(a: str, b: str) -> float:
4+
"""Exact ``difflib.SequenceMatcher(None, a, b, autojunk=False).ratio()`` — byte-for-byte.
5+
6+
The Ratcliff-Obershelp similarity ``2*M / (len(a) + len(b))``, identical to Python's ``difflib``
7+
(including its argument-order asymmetry), computed via a suffix automaton so it stays linear on
8+
long, repetitive inputs where ``difflib`` degrades.
9+
"""
10+
11+
def cluster_canonicals(canonicals: list[str], threshold: float) -> list[tuple[list[int], float]]:
12+
"""Exact single-linkage clustering of ``canonicals`` by RO similarity.
13+
14+
Two strings join a cluster when their exact ratio is ``>= threshold``. Returns one
15+
``(member_indices, min_pairwise_ratio)`` per cluster of >= 2 members; ``member_indices`` index
16+
into ``canonicals`` (sorted), ``min_pairwise_ratio`` is the cluster's exact minimum pairwise ratio.
17+
"""
18+
19+
def cluster_canonicals_lsh(
20+
canonicals: list[str], threshold: float, num_perm: int, band_rows: int
21+
) -> list[tuple[list[int], float]]:
22+
"""Scalable MinHash-LSH variant of :func:`cluster_canonicals` for very large corpora.
23+
24+
Generates candidate pairs via MinHash-LSH (``num_perm`` permutations, ``band_rows`` rows per band),
25+
then verifies each candidate with the exact ratio. Clusters match the exact path modulo LSH recall
26+
(tuned via ``band_rows``); use :func:`cluster_canonicals` when exact recall is required.
27+
"""
28+
29+
__all__: list[str]

python/difflib_fast/py.typed

Whitespace-only changes.

src/lib.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -698,8 +698,9 @@ mod python {
698698
super::cluster_canonicals_lsh(&canonicals, threshold, num_perm, band_rows)
699699
}
700700

701+
/// Compiled core of the `difflib_fast` Python package (re-exported by `difflib_fast/__init__.py`).
701702
#[pymodule]
702-
fn difflib_fast(m: &Bound<'_, PyModule>) -> PyResult<()> {
703+
fn _difflib_fast(m: &Bound<'_, PyModule>) -> PyResult<()> {
703704
m.add_function(wrap_pyfunction!(ratio, m)?)?;
704705
m.add_function(wrap_pyfunction!(cluster_canonicals, m)?)?;
705706
m.add_function(wrap_pyfunction!(cluster_canonicals_lsh, m)?)?;

0 commit comments

Comments
 (0)