From 6fc166fec294a9d2bbdcd0a2815753f717acdd73 Mon Sep 17 00:00:00 2001 From: Joshua Levy Date: Fri, 18 Sep 2026 23:57:34 -0700 Subject: [PATCH 1/2] Add Python 3.14/3.14t support and Cython 3.3 ## Summary Already in place and unchanged: - 3.9+ drop-in for stdlib `difflib`, with no runtime dependencies - scikit-build-core + CMake - sdist without Cython after generate + `tools/sdist.patch` - pip/cibuildwheel as the published path This PR adds 3.14 and free-threaded 3.14t support and the Cython / concurrency work those builds require. ## Problem - No 3.14 or free-threaded 3.14t wheels. - Cython was `>=3.0.12,<3.1.0`. 3.14t does not compile on Cython 3.0.x. - No `# cython: freethreading_compatible=True`, so a 3.14t import re-enables the GIL. - No documented or tested concurrency contract. `SequenceMatcher` scratch is per-instance; sharing one matcher across threads is unsupported (same as stdlib). `HtmlDiff._default_prefix` is process-wide. - Local uv workflow was not set up (optional; not a user-facing break). ## Changes - Cython `>=3.3.0,<3.4` (build-only); scikit-build-core `>=1.0`; CMake max 3.30. - `freethreading_compatible=True` pragma; `HtmlDiff._default_prefix` lock; docs; `tests/test_concurrency.py`. - Wheel matrix includes 3.14/3.14t with cibuildwheel 4.2.1 and small portability fixes (3.9 `zip` without `strict=`, skip thread tests when threads cannot start, Windows ARM64 delvewheel exclude MSVC CRT); Intel Mac CI uses macos-15-intel because macos-13 runners are retired. - Optional local uv (`uv.toml` / `uv.lock`); no `.python-version`; `AGENTS.md` is contributor docs only. ## Compatibility - Python 3.9+ and the public API are unchanged. - Optional uv does not change pip, conda, thefuzz, PyPy, or sdist-without-Cython. CI stays on pip. ## Dependencies - Runtime: none. - Build: Cython and scikit-build-core bumps (cmake/ninja stay out of `build-system.requires`). - Dev: optional `pytest>=8`. ## Test plan - Local pytest on 3.11, 3.12, 3.13, 3.14, and 3.14t. - Isolated wheel build. - CI matrix including 3.14/3.14t. --- .github/workflows/build.yml | 19 +++-- .gitignore | 2 + AGENTS.md | 51 +++++++++++ CHANGELOG.md | 8 ++ CLAUDE.md | 1 + CMakeLists.txt | 2 +- README.md | 15 ++++ pyproject.toml | 18 +++- src/cydifflib/_initialize.pyx | 21 +++-- tests/test_concurrency.py | 154 ++++++++++++++++++++++++++++++++++ tools/sdist.patch | 11 ++- uv.lock | 82 ++++++++++++++++++ uv.toml | 5 ++ 13 files changed, 367 insertions(+), 22 deletions(-) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 tests/test_concurrency.py create mode 100644 uv.lock create mode 100644 uv.toml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 400161c..a8447ca 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -24,7 +24,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install Cython==3.0.12 + pip install "Cython>=3.3.0,<3.4" # The cythonized files allow installation from the sdist without cython - name: Generate cython @@ -72,8 +72,13 @@ jobs: - uses: actions/setup-python@v5 + - name: Skip vendoring host CRT on ARM64 + if: matrix.arch == 'ARM64' + shell: bash + run: echo 'CIBW_REPAIR_WHEEL_COMMAND=delvewheel repair --exclude msvcp140.dll --exclude vcruntime140.dll --exclude vcruntime140_1.dll --exclude vcruntime140_threads.dll -w {dest_dir} -v {wheel}' >> "$GITHUB_ENV" + - name: Build wheels - uses: pypa/cibuildwheel@v2.23.2 + uses: pypa/cibuildwheel@v4.2.1 with: package-dir: cydifflib.tar.gz output-dir: wheelhouse @@ -91,7 +96,7 @@ jobs: strategy: fail-fast: false matrix: - os: [macos-13, macos-14] + os: [macos-15-intel, macos-14] env: CIBW_ARCHS: native CIBW_TEST_SKIP: "pp*-macosx_*" @@ -112,7 +117,7 @@ jobs: run: cp dist/*.tar.gz cydifflib.tar.gz - name: Build wheels - uses: pypa/cibuildwheel@v2.23.2 + uses: pypa/cibuildwheel@v4.2.1 with: package-dir: cydifflib.tar.gz output-dir: wheelhouse @@ -131,7 +136,7 @@ jobs: fail-fast: false matrix: arch: [auto, aarch64, ppc64le, s390x] - python_tag: ["cp39-*", "cp310-*", "cp311-*", "cp312-*", "cp313-*", "pp39-*", "pp310-*", "pp311-*"] + python_tag: ["cp39-*", "cp310-*", "cp311-*", "cp312-*", "cp313-*", "cp314-*", "cp314t-*", "pp39-*", "pp310-*", "pp311-*"] exclude: # PyPy builds not available for these platforms - arch: ppc64le @@ -169,7 +174,7 @@ jobs: name: Set up QEMU - name: Build wheel - uses: pypa/cibuildwheel@v2.23.2 + uses: pypa/cibuildwheel@v4.2.1 with: package-dir: cydifflib.tar.gz output-dir: wheelhouse @@ -204,7 +209,7 @@ jobs: run: cp dist/*.tar.gz cydifflib.tar.gz - name: Build wheel - uses: pypa/cibuildwheel@v2.23.2 + uses: pypa/cibuildwheel@v4.2.1 with: package-dir: cydifflib.tar.gz output-dir: wheelhouse diff --git a/.gitignore b/.gitignore index ce77e38..6284bf8 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ __pycache__/ .idea/ .venv/ +.venv-*/ build/ _skbuild/ *.egg-info/ @@ -15,6 +16,7 @@ src/*.html .coverage coverage.xml sde/ +.pyproject.toml.sdist.bak # Sphinx documentation site/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d109935 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,51 @@ +# CyDifflib Agent Instructions + +This file follows the [AGENTS.md](https://agents.md) convention. +Claude Code reads `CLAUDE.md`, which imports this file through its `@AGENTS.md` line. + +## Build and Test + +Cython/C++ extension via **scikit-build-core** and **CMake**. + +Published installs and GitHub Actions use pip and cibuildwheel. +Do not switch CI to uv. + +Local work: use uv with the checked-in `uv.toml` if present. + +```bash +UV_CONFIG_FILE=uv.toml uv sync --python 3.13 --all-groups --reinstall-package cydifflib +UV_CONFIG_FILE=uv.toml uv run --python 3.13 pytest +``` + +Default local interpreter is 3.13. +Also test 3.11, 3.12, 3.14, and 3.14t (`3.14` is GIL, `3.14t` is free-threaded). + +Isolated wheel and sdist: + +```bash +UV_CONFIG_FILE=uv.toml uv build --python 3.13 +``` + +Sdist with generated C++ and Cython stripped from `build-system.requires`: + +1. Generate `.cxx`: + `UV_CONFIG_FILE=uv.toml uv run --python 3.13 --with "Cython>=3.3.0,<3.4" ./src/cydifflib/generate.sh` +2. `cp pyproject.toml .pyproject.toml.sdist.bak` +3. `git apply ./tools/sdist.patch` +4. `UV_CONFIG_FILE=uv.toml uv build --python 3.13 --sdist` +5. `mv .pyproject.toml.sdist.bak pyproject.toml` + +## Conventions + +- **Layout:** `src/` (`src/cydifflib/`, tests in `tests/`) +- **Python:** published wheels still support 3.9+; local default is 3.13 +- **Build:** Cython `>=3.3.0,<3.4` is build-only; do not list cmake or ninja in + `build-system.requires` +- **Version:** `src/cydifflib/__init__.py` (read at build time) +- **Free-threading:** `_initialize.pyx` sets `freethreading_compatible=True`; + `HtmlDiff._default_prefix` is locked; use one `SequenceMatcher` per thread +- **Lint:** do not run a repository-wide format of the `.pyx` sources + + diff --git a/CHANGELOG.md b/CHANGELOG.md index e3ddc2e..3bfe7f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [Unreleased] +### Changed +- require Cython 3.3+ so free-threaded Python 3.14 can compile +- mark the extension free-threading compatible (one SequenceMatcher per thread; + HtmlDiff's shared prefix counter is locked) +- add support for Python 3.14 and 3.14t +- allow CMake 3.15 through 3.30 + ## [1.2.0] - 2025-04-11 ### Changed - drop support for Python 3.8 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/CMakeLists.txt b/CMakeLists.txt index fa5dea0..697a414 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,4 +1,4 @@ -cmake_minimum_required(VERSION 3.15...3.26) +cmake_minimum_required(VERSION 3.15...3.30) cmake_policy(SET CMP0054 NEW) set(CMAKE_INTERPROCEDURAL_OPTIMIZATION TRUE) diff --git a/README.md b/README.md index ca2a1fc..e10e7a1 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,21 @@ For a source build (for example from a SDist packaged) you only require a C++11 pip install git+https://github.com/rapidfuzz/CyDifflib.git@main ``` +## Development + +`pip install` remains the supported user path. +Local work can use uv with the checked-in `uv.toml`: + +```bash +UV_CONFIG_FILE=uv.toml uv sync --python 3.13 --all-groups --reinstall-package cydifflib +UV_CONFIG_FILE=uv.toml uv run --python 3.13 pytest +``` + +Default is 3.13; also 3.11, 3.12, 3.14, and 3.14t (`3.14` GIL, `3.14t` free-threaded). +Isolated: `UV_CONFIG_FILE=uv.toml uv build --python 3.13`. + +Free-threaded 3.14 (`3.14t`) needs Cython 3.3+ at build time. Use a separate `SequenceMatcher` per thread. + ## 📖 Usage The library can be used in the same way as difflib. Just use the `cydifflib` module instead of `difflib`: diff --git a/pyproject.toml b/pyproject.toml index 3789764..0fc07bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [build-system] requires = [ - "scikit-build-core>=0.11", - "Cython>=3.0.12,<3.1.0" + "scikit-build-core>=1.0", + "Cython>=3.3.0,<3.4" ] build-backend = "scikit_build_core.build" @@ -25,6 +25,7 @@ classifiers=[ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ] [project.urls] @@ -33,7 +34,13 @@ Repository = "https://github.com/rapidfuzz/CyDifflib.git" Issues = "https://github.com/rapidfuzz/CyDifflib/issues" Changelog = "https://github.com/rapidfuzz/CyDifflib/blob/main/CHANGELOG.md" +[dependency-groups] +dev = [ + "pytest>=8", +] + [tool.scikit-build] +minimum-version = "build-system.requires" sdist.include = [ "src/cydifflib/*.cxx", ] @@ -49,10 +56,15 @@ wheel.exclude = [ "generate.sh" ] -[tool.scikit-build.metadata.version] +[[tool.dynamic-metadata]] provider = "scikit_build_core.metadata.regex" +field = "version" input = "src/cydifflib/__init__.py" +[tool.cibuildwheel] +# 3.14t needs no enable in cibuildwheel 4.2.1; pypy-eol keeps pp39/pp310. +enable = ["pypy", "pypy-eol"] + [tool.black] line-length = 120 diff --git a/src/cydifflib/_initialize.pyx b/src/cydifflib/_initialize.pyx index 45fe4f0..62c4ee2 100644 --- a/src/cydifflib/_initialize.pyx +++ b/src/cydifflib/_initialize.pyx @@ -1,5 +1,5 @@ # distutils: language=c++ -# cython: language_level=3, binding=True, linetrace=True +# cython: language_level=3, binding=True, linetrace=True, freethreading_compatible=True __all__ = ['get_close_matches', 'ndiff', 'restore', 'SequenceMatcher', 'Differ','IS_CHARACTER_JUNK', 'IS_LINE_JUNK', 'context_diff', @@ -7,6 +7,7 @@ __all__ = ['get_close_matches', 'ndiff', 'restore', 'SequenceMatcher', from heapq import nlargest as _nlargest from collections import namedtuple as _namedtuple +import threading # todo add this once it is supported in all Python versions #from types import GenericAlias @@ -116,6 +117,9 @@ cdef class SequenceMatcher: case. SequenceMatcher is quadratic time for the worst case and has expected-case behavior dependent in a complicated way on how many elements the sequences have in common; best case time is linear. + + A SequenceMatcher stores mutable scratch state on the instance. Do not + share one instance across threads; create one matcher per thread. """ cdef public object a @@ -129,7 +133,7 @@ cdef class SequenceMatcher: cdef public set bpopular cdef public object autojunk - # todo this is not threadsafe, which could be an problem in the long run + # Per-instance scratch for find_longest_match. Not process-global. cdef vector[Py_ssize_t] j2len_ cdef vector[Py_ssize_t] newj2len_ cdef Py_hash_t* a_ @@ -1722,13 +1726,19 @@ class HtmlDiff(object): make_file -- generates complete HTML file with a single side by side table See tools/scripts/diff.py for an example usage of this class. + + make_table writes per-instance state; do not overlap calls on one HtmlDiff. + The shared HTML anchor counter is locked so separate instances can run + concurrently. """ _file_template = _file_template _styles = _styles _table_template = _table_template _legend = _legend + # Unique fromN_/toN_ anchors across tables on one page. _default_prefix = 0 + _prefix_lock = threading.Lock() def __init__(self,tabsize=8,wrapcolumn=None,linejunk=None, charjunk=IS_CHARACTER_JUNK): @@ -1929,9 +1939,10 @@ class HtmlDiff(object): # Generate a unique anchor prefix so multiple tables # can exist on the same HTML page without conflicts. - fromprefix = "from%d_" % HtmlDiff._default_prefix - toprefix = "to%d_" % HtmlDiff._default_prefix - HtmlDiff._default_prefix += 1 + with HtmlDiff._prefix_lock: + fromprefix = "from%d_" % HtmlDiff._default_prefix + toprefix = "to%d_" % HtmlDiff._default_prefix + HtmlDiff._default_prefix += 1 # store prefixes so line format method has access self._prefix = [fromprefix,toprefix] diff --git a/tests/test_concurrency.py b/tests/test_concurrency.py new file mode 100644 index 0000000..ca2bbad --- /dev/null +++ b/tests/test_concurrency.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import difflib +import os +import re +import sys +import sysconfig +import threading +import time +import unittest +from concurrent.futures import ThreadPoolExecutor + +import cydifflib + +_PREFIX_RE = re.compile(r"(from|to)\d+_") + + +def _worker_count() -> int: + cpus = os.cpu_count() or 4 + return min(32, max(8, cpus * 2)) + + +def _threading_supported() -> bool: + if sys.platform == "emscripten": + return False + try: + done = threading.Event() + thread = threading.Thread(target=done.set) + thread.start() + thread.join() + return done.is_set() + except RuntimeError: + return False + + +def _snapshot(isjunk, a, b): + sm = difflib.SequenceMatcher(isjunk, a, b) + return ( + sm.ratio(), + sm.quick_ratio(), + sm.real_quick_ratio(), + sm.get_opcodes(), + sm.get_matching_blocks(), + sm.find_longest_match(), + ) + + +def _assert_snapshot(isjunk, a, b, expected) -> None: + sm = cydifflib.SequenceMatcher(isjunk, a, b) + assert sm.ratio() == expected[0] + assert sm.quick_ratio() == expected[1] + assert sm.real_quick_ratio() == expected[2] + assert sm.get_opcodes() == expected[3] + assert sm.get_matching_blocks() == expected[4] + assert sm.find_longest_match() == expected[5] + + +def _normalize_prefixes(html: str) -> str: + return _PREFIX_RE.sub(r"\1N_", html) + + +class TestConcurrency(unittest.TestCase): + """Separate instances may run in parallel; one instance is not shared.""" + + def setUp(self): + with cydifflib.HtmlDiff._prefix_lock: + self._saved_prefix = cydifflib.HtmlDiff._default_prefix + + def restore(): + with cydifflib.HtmlDiff._prefix_lock: + cydifflib.HtmlDiff._default_prefix = self._saved_prefix + + self.addCleanup(restore) + + def test_gil_stays_disabled(self): + if not hasattr(sys, "_is_gil_enabled"): + self.skipTest("sys._is_gil_enabled is unavailable") + if not sysconfig.get_config_var("Py_GIL_DISABLED"): + self.skipTest("not a free-threaded build") + self.assertFalse(sys._is_gil_enabled()) + + def test_high_concurrency_matches_stdlib(self): + if not _threading_supported(): + self.skipTest("interpreter cannot start threads") + cases = [ + (None, "", ""), + (None, "a", "a"), + (None, "a", "b"), + (None, "abcd" * 20, "abce" * 20), + (None, "hello world", "hallo w0rld"), + (None, list("abcabc"), list("abcbac")), + (None, "café naïve", "cafe naive"), + (None, "dabcd", "d" * 100 + "abc" + "d" * 100), + (lambda x: x == " ", "a" * 40 + " " + "b" * 40, "a" * 44 + "b" * 40 + " " * 20), + ] + words = ["apple", "apply", "applet", "banana", "bandana", "orange"] + expected = [_snapshot(isjunk, a, b) for isjunk, a, b in cases] + expected_close = difflib.get_close_matches("appel", words, n=3, cutoff=0.6) + + workers = _worker_count() + start = threading.Barrier(workers) + + def worker() -> None: + start.wait() + plain = cydifflib.SequenceMatcher() + junked = cydifflib.SequenceMatcher(lambda x: x == " ") + for _ in range(20): + for (isjunk, a, b), gold in zip(cases, expected): + _assert_snapshot(isjunk, a, b, gold) + reused = junked if isjunk else plain + reused.set_seqs(a, b) + assert reused.get_opcodes() == gold[3] + assert cydifflib.get_close_matches("appel", words, n=3, cutoff=0.6) == expected_close + if hasattr(sys, "_is_gil_enabled") and sysconfig.get_config_var("Py_GIL_DISABLED"): + assert sys._is_gil_enabled() is False + + t0 = time.perf_counter() + with ThreadPoolExecutor(max_workers=workers) as pool: + futures = [pool.submit(worker) for _ in range(workers)] + for future in futures: + future.result() + self.assertLess(time.perf_counter() - t0, 30.0) + + def test_concurrent_html_prefixes_are_unique(self): + if not _threading_supported(): + self.skipTest("interpreter cannot start threads") + fromlines = ["alpha", "beta gamma", "delta"] + tolines = ["alpha", "beta gammma", "epsilon"] + gold = _normalize_prefixes(cydifflib.HtmlDiff().make_table(fromlines, tolines)) + + workers = _worker_count() + start = threading.Barrier(workers) + htmls: list[str] = [] + lock = threading.Lock() + + def worker() -> None: + start.wait() + html = cydifflib.HtmlDiff().make_table(fromlines, tolines) + with lock: + htmls.append(html) + + with ThreadPoolExecutor(max_workers=workers) as pool: + futures = [pool.submit(worker) for _ in range(workers)] + for future in futures: + future.result() + + ids = [] + for html in htmls: + self.assertEqual(_normalize_prefixes(html), gold) + found = set(re.findall(r"(?:from|to)(\d+)_", html)) + self.assertEqual(len(found), 1) + ids.append(found.pop()) + self.assertEqual(len(ids), workers) + self.assertEqual(len(set(ids)), workers) diff --git a/tools/sdist.patch b/tools/sdist.patch index d73b39f..d100b52 100644 --- a/tools/sdist.patch +++ b/tools/sdist.patch @@ -1,13 +1,12 @@ diff --git a/pyproject.toml b/pyproject.toml -index 340fee3..87f6afe 100644 ---- a/pyproject.toml -+++ b/pyproject.toml +--- a/pyproject.toml 2026-09-18 22:26:46 ++++ b/pyproject.toml 2026-09-18 22:26:46 @@ -1,7 +1,6 @@ [build-system] requires = [ -- "scikit-build-core>=0.11", -- "Cython>=3.0.12,<3.1.0" -+ "scikit-build-core>=0.11" +- "scikit-build-core>=1.0", +- "Cython>=3.3.0,<3.4" ++ "scikit-build-core>=1.0" ] build-backend = "scikit_build_core.build" diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..fc79430 --- /dev/null +++ b/uv.lock @@ -0,0 +1,82 @@ +version = 1 +revision = 3 +requires-python = ">=3.9" + +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P14D" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cydifflib" +source = { editable = "." } + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=8" }] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] diff --git a/uv.toml b/uv.toml new file mode 100644 index 0000000..dccc54c --- /dev/null +++ b/uv.toml @@ -0,0 +1,5 @@ +# Keep resolution-affecting settings project-owned. Select this file with +# UV_CONFIG_FILE=uv.toml so user- or system-level uv settings cannot make +# uv.lock nonportable. +required-version = ">=0.12.0,<0.13" +exclude-newer = "14 days" From 3c0eefda5c5357daae5610acc07772084460137b Mon Sep 17 00:00:00 2001 From: Joshua Levy Date: Sat, 19 Sep 2026 00:01:19 -0700 Subject: [PATCH 2/2] Run pytest after the CI sdist install. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wheel jobs already install each tag and run the test suite; the sdist job only compiled. Match RapidFuzz’s install-then-pytest step and document which cibuildwheel skips stay build-only. Co-authored-by: Cursor --- .github/workflows/build.yml | 6 ++++++ AGENTS.md | 26 ++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a8447ca..ebed728 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -37,9 +37,15 @@ jobs: git apply ./tools/sdist.patch pip3 install build python3 -m build --sdist + # Isolated pip install uses the patched requires (no Cython). # test whether tarball contains all files required for compiling pip3 install dist/cydifflib-*.tar.gz + - name: Test sdist install + run: | + pip3 install pytest + pytest tests + - uses: actions/upload-artifact@v4 with: name: artifact-sdist diff --git a/AGENTS.md b/AGENTS.md index d109935..fec91a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,32 @@ UV_CONFIG_FILE=uv.toml uv run --python 3.13 pytest Default local interpreter is 3.13. Also test 3.11, 3.12, 3.14, and 3.14t (`3.14` is GIL, `3.14t` is free-threaded). +End-to-end from an installed interpreter (import + the same pytest suite CI runs +after each wheel install): + +```bash +for py in 3.11 3.12 3.13 3.14 3.14t; do + UV_CONFIG_FILE=uv.toml uv run --python "$py" python -c "import cydifflib; print(cydifflib.SequenceMatcher(None, 'abcd', 'bcde').ratio())" + UV_CONFIG_FILE=uv.toml uv run --python "$py" pytest +done +``` + +3.9, 3.10, and PyPy are CI-only. + +Wheel jobs in `build.yml` set `CIBW_TEST_REQUIRES=pytest` and +`CIBW_TEST_COMMAND=pytest {package}/tests`. That installs the built wheel, then +runs `tests/` (including `test_gil_stays_disabled` on free-threaded tags). +Skips: + +- Linux: `*_{aarch64,ppc64le,s390x}` and `*musllinux_*` (build only) +- Windows: `*-win32` (build only); `win_arm64` is cross-compiled on `windows-latest` +- macOS: `pp*-macosx_*` (build only) + +The sdist job generates `.cxx`, strips Cython from `build-system.requires`, +installs the tarball, and runs pytest. Linux wheel tags are `cp39`–`cp314`, +`cp314t`, and `pp39`–`pp311`. macOS/Windows also build cibuildwheel extras +`cp315` / `cp315t` because those jobs do not set `CIBW_BUILD`. + Isolated wheel and sdist: ```bash