From 158b86fdb6d2bb4d60403c73cb4cef011f7d4d28 Mon Sep 17 00:00:00 2001 From: artem-ogre Date: Tue, 6 Oct 2026 23:51:36 +0200 Subject: [PATCH 1/8] Package for PyPI with scikit-build-core Replace setup.py with scikit-build-core and move the metadata into pyproject.toml. The setuptools sdist contained no sources; the new one includes the CDT submodule and builds on its own. pybind11 now comes from the build requirements instead of a submodule, numpy is declared as a dependency, and Python 3.10 or newer is required. Rename the package to pythoncdt and make it a package with the compiled module as pythoncdt._core, so that it can ship type stubs (_core.pyi and py.typed). CI checks the stubs against the module with mypy.stubtest. Build wheels with cibuildwheel for Linux, macOS and Windows, and publish them to PyPI with trusted publishing when a GitHub release is published. Run the tests on several threads at once on free-threaded Python. Declare the package and its free-threading support stable. --- .github/dependabot.yml | 9 + .github/workflows/ci.yml | 52 ++---- .github/workflows/wheels.yml | 61 +++++++ .gitignore | 1 + .gitmodules | 3 - CMakeLists.txt | 36 +--- README.md | 25 ++- benchmark_gil_release.py | 2 +- benchmark_list_vs_array_access.py | 2 +- pybind11 | 1 - pyproject.toml | 81 +++++++-- requirements.txt | 2 - setup.py | 171 ------------------ cdt_bindings.cpp => src/cdt_bindings.cpp | 6 +- src/pythoncdt/__init__.py | 8 + src/pythoncdt/_core.pyi | 155 ++++++++++++++++ src/pythoncdt/py.typed | 0 tests/stubtest-allowlist.txt | 12 ++ .../test_bindings.py | 11 +- 19 files changed, 366 insertions(+), 272 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/wheels.yml delete mode 160000 pybind11 delete mode 100644 requirements.txt delete mode 100644 setup.py rename cdt_bindings.cpp => src/cdt_bindings.cpp (99%) create mode 100644 src/pythoncdt/__init__.py create mode 100644 src/pythoncdt/_core.pyi create mode 100644 src/pythoncdt/py.typed create mode 100644 tests/stubtest-allowlist.txt rename cdt_bindings_test.py => tests/test_bindings.py (98%) diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..478231b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,9 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + groups: + actions: + patterns: ["*"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4892186..fecec68 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,53 +5,39 @@ on: branches: [master] pull_request: -# Cancel superseded runs on the same branch/PR concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: - build-and-test: + test: name: ${{ matrix.os }} / Python ${{ matrix.python-version }} runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ["3.10", "3.13", "3.14t"] - - defaults: - run: - shell: bash - + python-version: ["3.10", "3.14", "3.14t"] steps: - - name: Check out with submodules - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: - submodules: recursive - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + submodules: true + - uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} + - run: python -m pip install --upgrade pip + - run: python -m pip install . --group test + - run: python -m pytest ${{ endsWith(matrix.python-version, 't') && '--parallel-threads=4' || '' }} - - name: Install build and test dependencies - run: | - python -m pip install --upgrade pip - python -m pip install build - python -m pip install -r requirements.txt - - - name: Build the wheel - run: python -m build --wheel --outdir dist - - - name: Install the built wheel - run: python -m pip install --no-index --find-links dist PythonCDT - - - name: Run tests - run: pytest ./cdt_bindings_test.py - - - name: Upload wheel - uses: actions/upload-artifact@v4 + stubs: + name: Type stubs match the module + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + submodules: true + - uses: actions/setup-python@v7 with: - name: wheel-${{ matrix.os }}-py${{ matrix.python-version }} - path: dist/*.whl + python-version: "3.x" + - run: python -m pip install . mypy + - run: python -m mypy.stubtest pythoncdt --allowlist tests/stubtest-allowlist.txt diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml new file mode 100644 index 0000000..36efc35 --- /dev/null +++ b/.github/workflows/wheels.yml @@ -0,0 +1,61 @@ +name: Wheels + +on: + pull_request: + release: + types: [published] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + sdist: + name: Source distribution + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + submodules: true + # Builds the sdist, then a wheel from it: proves the sdist is complete + - run: pipx run build + - uses: actions/upload-artifact@v7 + with: + name: dist-sdist + path: dist/*.tar.gz + + wheels: + name: Wheels on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, ubuntu-24.04-arm, windows-latest, macos-15-intel, macos-latest] + steps: + - uses: actions/checkout@v7 + with: + submodules: true + - uses: pypa/cibuildwheel@v4.3.0 + - uses: actions/upload-artifact@v7 + with: + name: dist-wheels-${{ matrix.os }} + path: wheelhouse/*.whl + + publish: + name: Publish to PyPI + if: github.event_name == 'release' + needs: [sdist, wheels] + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/pythoncdt + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v8 + with: + pattern: dist-* + path: dist + merge-multiple: true + - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.gitignore b/.gitignore index e27938d..ef8e93c 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ cmake-build-*/* python/.venv/* .venv/* out/ +wheelhouse/ diff --git a/.gitmodules b/.gitmodules index 498897e..5ccc340 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,3 @@ [submodule "CDT"] path = CDT url = https://github.com/artem-ogre/CDT.git -[submodule "pybind11"] - path = pybind11 - url = https://github.com/pybind/pybind11.git diff --git a/CMakeLists.txt b/CMakeLists.txt index 9c1d80a..afe82f5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,29 +1,13 @@ -cmake_minimum_required(VERSION 3.13.0) +cmake_minimum_required(VERSION 3.15...4.0) +project(${SKBUILD_PROJECT_NAME} LANGUAGES CXX) -set(CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake" ${CMAKE_MODULE_PATH}) +find_package(pybind11 CONFIG REQUIRED) +add_subdirectory(CDT/CDT) -project( - PythonCDT - VERSION 2.0.0 - DESCRIPTION "Python bindings for CDT: constrained Delaunay triangulation" - LANGUAGES CXX -) +pybind11_add_module(_core src/cdt_bindings.cpp) +target_compile_features(_core PRIVATE cxx_std_17) +target_compile_definitions(_core PRIVATE VERSION_INFO=${SKBUILD_PROJECT_VERSION_FULL}) +target_link_libraries(_core PRIVATE CDT::CDT) -set(CMAKE_CXX_STANDARD 17) -set(CMAKE_POSITION_INDEPENDENT_CODE ON) - -add_subdirectory(CDT/CDT CDT) -add_subdirectory(pybind11) -pybind11_add_module(PythonCDT cdt_bindings.cpp) -target_include_directories( - PythonCDT PRIVATE $ -) -target_link_libraries(PythonCDT PRIVATE CDT::CDT) -# Exposed to Python as PythonCDT.__version__. setup.py passes the wheel -# version; fall back to the project version for plain CMake builds. -if(NOT DEFINED VERSION_INFO) - set(VERSION_INFO ${PROJECT_VERSION}) -endif() -target_compile_definitions(PythonCDT PRIVATE VERSION_INFO=${VERSION_INFO}) -# Use rasterizer as pre-compiled header for faster test-only re-compiles -#target_precompile_headers(PythonCDT PRIVATE include/rasterizer.h) +# Installed as a component, so that CDT's own install rules stay out of the wheel +install(TARGETS _core DESTINATION pythoncdt COMPONENT python) diff --git a/README.md b/README.md index 3fa86f8..2c2ace3 100644 --- a/README.md +++ b/README.md @@ -3,17 +3,24 @@ Python bindings for [CDT: C++ library for constrained Delaunay triangulation](https://github.com/artem-ogre/CDT) implemented with [pybind11](https://github.com/pybind/pybind11) ***If PythonCDT helped you please consider adding a star on [GitHub](https://github.com/artem-ogre/PythonCDT). This means a lot to the authors*** 🤩 -## Building -### Pre-conditions -- Clone with submodules: `git clone --recurse-submodules https://github.com/artem-ogre/PythonCDT.git` -- Make sure packages from requirements.txt are available. +## Installation ```bash -# build the wheel and install the package with pip -pip3 install . -# run tests -pytest ./cdt_bindings_test.py +pip install pythoncdt +``` + +Wheels are published for Linux, macOS and Windows. + +## Building from source + +Building needs a C++17 compiler; pip fetches pybind11, and CMake if it is missing. + +```bash +git clone --recurse-submodules https://github.com/artem-ogre/PythonCDT.git +cd PythonCDT +pip install . --group test # needs pip 25.1+ +pytest ``` ## Usage examples @@ -22,7 +29,7 @@ pytest ./cdt_bindings_test.py ```python import numpy as np -import PythonCDT as cdt +import pythoncdt as cdt vertices = np.array([[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0], [0.4, 0.4]]) edges = np.array([[0, 1], [1, 2], [2, 3], [3, 0]], dtype=np.uintc) diff --git a/benchmark_gil_release.py b/benchmark_gil_release.py index 8aa9398..a611ca8 100644 --- a/benchmark_gil_release.py +++ b/benchmark_gil_release.py @@ -7,7 +7,7 @@ import numpy as np -import PythonCDT as cdt +import pythoncdt as cdt N_THREADS = min(8, os.cpu_count()) N_VERTICES = 200_000 diff --git a/benchmark_list_vs_array_access.py b/benchmark_list_vs_array_access.py index aaad84a..eb00480 100644 --- a/benchmark_list_vs_array_access.py +++ b/benchmark_list_vs_array_access.py @@ -4,7 +4,7 @@ import numpy as np -import PythonCDT as cdt +import pythoncdt as cdt t = cdt.Triangulation(cdt.VertexInsertionOrder.AUTO, cdt.IntersectingConstraintEdges.NOT_ALLOWED, 0.0) t.insert_vertices(np.random.default_rng(0).random((200_000, 2))) diff --git a/pybind11 b/pybind11 deleted file mode 160000 index d03662f..0000000 --- a/pybind11 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit d03662f0984f652b60e7ddce53d3868002275197 diff --git a/pyproject.toml b/pyproject.toml index 135e709..93a5994 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,25 +1,70 @@ [build-system] -requires = [ - "setuptools>=42", - "wheel", - "ninja", - "cmake>=3.12", +requires = ["scikit-build-core>=0.11", "pybind11>=3.0.4"] +build-backend = "scikit_build_core.build" + +[project] +name = "pythoncdt" +version = "2.0.0" +description = "Python bindings for CDT: constrained Delaunay triangulation" +readme = "README.md" +license = "MPL-2.0" +license-files = ["LICENSE"] +authors = [{ name = "Artem Amirkhanov" }] +requires-python = ">=3.10" +dependencies = ["numpy"] +keywords = [ + "cdt", + "computational-geometry", + "conforming", + "constrained", + "constrained-delaunay-triangulation", + "delaunay", + "delaunay-triangulation", + "fast", + "refinement", + "robust", + "triangle", + "triangulation", +] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Intended Audience :: Science/Research", + "Operating System :: MacOS", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX :: Linux", + "Programming Language :: C++", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Free Threading :: 3 - Stable", + "Topic :: Scientific/Engineering :: Mathematics", + "Topic :: Multimedia :: Graphics", + "Typing :: Typed", ] -build-backend = "setuptools.build_meta" -[tool.isort] -profile = "black" +[project.urls] +Source = "https://github.com/artem-ogre/PythonCDT" +Issues = "https://github.com/artem-ogre/PythonCDT/issues" +CDT = "https://github.com/artem-ogre/CDT" + +[dependency-groups] +test = ["pytest>=9", "pytest-run-parallel"] -[tool.pytest.ini_options] -minversion = "6.0" -addopts = ["-ra", "--showlocals", "--strict-markers", "--strict-config"] -xfail_strict = true +[tool.scikit-build] +minimum-version = "build-system.requires" +install.components = ["python"] +sdist.exclude = ["CDT/docs", "CDT/CDT/tests"] + +[tool.pytest] +addopts = ["-ra"] +strict = true filterwarnings = ["error"] -testpaths = ["cdt_bindings_test.py"] +testpaths = ["tests"] [tool.cibuildwheel] -test-command = "pytest {project}/cdt_bindings_test.py" -test-extras = ["test"] -test-skip = ["*universal2:arm64"] -# Setuptools bug causes collision between pypy and cpython artifacts -before-build = "rm -rf {project}/build" +test-groups = ["test"] +test-command = "pytest {project}/tests" + +[tool.cibuildwheel.windows] +# 32-bit is skipped: pybind11's structured dtypes crash it on free-threaded Python (pybind/pybind11#6196) +archs = ["AMD64"] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 31ef97d..0000000 --- a/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -numpy -pytest \ No newline at end of file diff --git a/setup.py b/setup.py deleted file mode 100644 index 7def8bb..0000000 --- a/setup.py +++ /dev/null @@ -1,171 +0,0 @@ -import os -import re -import subprocess -import sys - -from setuptools import Extension, setup -from setuptools.command.build_ext import build_ext - -# Convert distutils Windows platform specifiers to CMake -A arguments -PLAT_TO_CMAKE = { - "win32": "Win32", - "win-amd64": "x64", - "win-arm32": "ARM", - "win-arm64": "ARM64", -} - - -# A CMakeExtension needs a sourcedir instead of a file list. -# The name must be the _single_ output extension from the CMake build. -# If you need multiple extensions, see scikit-build. -class CMakeExtension(Extension): - def __init__(self, name, sourcedir=""): - Extension.__init__(self, name, sources=[]) - self.sourcedir = os.path.abspath(sourcedir) - - -class CMakeBuild(build_ext): - def build_extension(self, ext): - extdir = os.path.abspath(os.path.dirname( - self.get_ext_fullpath(ext.name))) - - # required for auto-detection & inclusion of auxiliary "native" libs - if not extdir.endswith(os.path.sep): - extdir += os.path.sep - - debug = int(os.environ.get("DEBUG", 0) - ) if self.debug is None else self.debug - cfg = "Debug" if debug else "Release" - - # CMake lets you override the generator - we need to check this. - # Can be set with Conda-Build, for example. - cmake_generator = os.environ.get("CMAKE_GENERATOR", "") - - # Both spellings are passed on purpose: pybind11 < 3 uses its own - # PYTHON_EXECUTABLE, while pybind11 >= 3 defaults to CMake's - # FindPython, which reads Python_EXECUTABLE. Passing only the former - # lets CMake pick an unrelated interpreter and build against the - # wrong Python headers. - # VERSION_INFO is passed into the C++ code and surfaces as - # PythonCDT.__version__. - cmake_args = [ - f"-DCMAKE_LIBRARY_OUTPUT_DIRECTORY={extdir}", - f"-DPYTHON_EXECUTABLE={sys.executable}", - f"-DPython_EXECUTABLE={sys.executable}", - f"-DCMAKE_BUILD_TYPE={cfg}", # not used on MSVC, but no harm - ] - build_args = [] - # Adding CMake arguments set as environment variable - # (needed e.g. to build for ARM OSx on conda-forge) - if "CMAKE_ARGS" in os.environ: - cmake_args += [ - item for item in os.environ["CMAKE_ARGS"].split(" ") if item] - - cmake_args += [f"-DVERSION_INFO={self.distribution.get_version()}"] - - if self.compiler.compiler_type != "msvc": - # Using Ninja-build since it a) is available as a wheel and b) - # multithreads automatically. MSVC would require all variables be - # exported for Ninja to pick it up, which is a little tricky to do. - # Users can override the generator with CMAKE_GENERATOR in CMake - # 3.15+. - if not cmake_generator: - try: - import ninja # noqa: F401 - - cmake_args += ["-GNinja"] - except ImportError: - pass - - else: - - # Single config generators are handled "normally" - single_config = any( - x in cmake_generator for x in {"NMake", "Ninja"}) - - # CMake allows an arch-in-generator style for backward compatibility - contains_arch = any(x in cmake_generator for x in {"ARM", "Win64"}) - - # Specify the arch if using MSVC generator, but only if it doesn't - # contain a backward-compatibility arch spec already in the - # generator name. - if not single_config and not contains_arch: - cmake_args += ["-A", PLAT_TO_CMAKE[self.plat_name]] - - # Multi-config generators have a different way to specify configs - if not single_config: - cmake_args += [ - f"-DCMAKE_LIBRARY_OUTPUT_DIRECTORY_{cfg.upper()}={extdir}" - ] - build_args += ["--config", cfg] - - if sys.platform.startswith("darwin"): - # Cross-compile support for macOS - respect ARCHFLAGS if set - archs = re.findall(r"-arch (\S+)", os.environ.get("ARCHFLAGS", "")) - if archs: - cmake_args += [ - "-DCMAKE_OSX_ARCHITECTURES={}".format(";".join(archs))] - - # Set CMAKE_BUILD_PARALLEL_LEVEL to control the parallel build level - # across all generators. - if "CMAKE_BUILD_PARALLEL_LEVEL" not in os.environ: - # self.parallel is a Python 3 only way to set parallel jobs by hand - # using -j in the build_ext call, not supported by pip or PyPA-build. - if hasattr(self, "parallel") and self.parallel: - # CMake 3.12+ only. - build_args += [f"-j{self.parallel}"] - - if not os.path.exists(self.build_temp): - os.makedirs(self.build_temp) - - subprocess.check_call( - ["cmake", ext.sourcedir] + cmake_args, cwd=self.build_temp - ) - subprocess.check_call( - ["cmake", "--build", "."] + build_args, cwd=self.build_temp - ) - - -# Explicit encoding: the README contains non-ASCII characters, which would -# fail to decode under the default locale on Windows. -with open( - os.path.join(os.path.dirname(os.path.abspath(__file__)), "README.md"), - encoding="utf-8", -) as f: - long_description = f.read() - -# The information here can also be placed in setup.cfg - better separation of -# logic and declaration, and simpler if you include description/version in a file. -setup( - name="PythonCDT", - version="2.0.0", - author="Artem Amirkhanov", - description=( - "Python bindings for CDT: constrained Delaunay triangulation" - ), - long_description=long_description, - long_description_content_type="text/markdown", - url="https://github.com/artem-ogre/PythonCDT", - project_urls={ - "Source": "https://github.com/artem-ogre/PythonCDT", - "Issues": "https://github.com/artem-ogre/PythonCDT/issues", - "CDT": "https://github.com/artem-ogre/CDT", - }, - license="MPL-2.0", - classifiers=[ - "Development Status :: 4 - Beta", - "Intended Audience :: Developers", - "Intended Audience :: Science/Research", - "Programming Language :: C++", - "Programming Language :: Python :: 3", - "Topic :: Scientific/Engineering :: Mathematics", - "Topic :: Multimedia :: Graphics :: 3D Modeling", - ], - keywords="delaunay triangulation constrained cdt computational-geometry", - ext_modules=[CMakeExtension("PythonCDT")], - cmdclass={"build_ext": CMakeBuild}, - zip_safe=False, - extras_require={"test": ["pytest>=6.0"]}, - # pybind11 3.x supports Python 3.8 and newer - python_requires=">=3.8", -) diff --git a/cdt_bindings.cpp b/src/cdt_bindings.cpp similarity index 99% rename from cdt_bindings.cpp rename to src/cdt_bindings.cpp index 75512c3..cdd42f8 100644 --- a/cdt_bindings.cpp +++ b/src/cdt_bindings.cpp @@ -170,14 +170,14 @@ std::string TriInd2str(CDT::TriInd it) } // namespace -PYBIND11_MODULE(PythonCDT, m, py::mod_gil_not_used()) +PYBIND11_MODULE(_core, m, py::mod_gil_not_used()) { // clang-format off m.doc() = R"pbdoc( - PythonCDT module: python bindings for CDT: + pythoncdt module: python bindings for CDT: Constrained Delaunay Triangulation ----------------------- - .. currentmodule:: PythonCDT + .. currentmodule:: pythoncdt .. autosummary:: :toctree: _generate )pbdoc"; diff --git a/src/pythoncdt/__init__.py b/src/pythoncdt/__init__.py new file mode 100644 index 0000000..33e7637 --- /dev/null +++ b/src/pythoncdt/__init__.py @@ -0,0 +1,8 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. + +"""Python bindings for CDT: constrained Delaunay triangulation""" + +from ._core import * +from ._core import __version__ diff --git a/src/pythoncdt/_core.pyi b/src/pythoncdt/_core.pyi new file mode 100644 index 0000000..fb53973 --- /dev/null +++ b/src/pythoncdt/_core.pyi @@ -0,0 +1,155 @@ +from collections.abc import Iterator, Sequence +from collections.abc import Set as AbstractSet +from typing import ClassVar, SupportsIndex, overload + +import numpy as np +from numpy.typing import NDArray +from typing_extensions import Buffer + +__version__: str +NO_NEIGHBOR: int +NO_VERTEX: int + +# pybind11 enums; not an actual base class at runtime +class _Enum: + def __init__(self, value: SupportsIndex) -> None: ... + def __index__(self) -> int: ... + def __int__(self) -> int: ... + @property + def name(self) -> str: ... + @property + def value(self) -> int: ... + +class VertexInsertionOrder(_Enum): + AUTO: ClassVar[VertexInsertionOrder] + AS_PROVIDED: ClassVar[VertexInsertionOrder] + __members__: ClassVar[dict[str, VertexInsertionOrder]] + +class IntersectingConstraintEdges(_Enum): + NOT_ALLOWED: ClassVar[IntersectingConstraintEdges] + TRY_RESOLVE: ClassVar[IntersectingConstraintEdges] + DONT_CHECK: ClassVar[IntersectingConstraintEdges] + __members__: ClassVar[dict[str, IntersectingConstraintEdges]] + +class RefinementCriterion(_Enum): + SMALLEST_ANGLE: ClassVar[RefinementCriterion] + LARGEST_AREA: ClassVar[RefinementCriterion] + __members__: ClassVar[dict[str, RefinementCriterion]] + +class Unrefined: + @property + def short_edge_triangles(self) -> int: ... + @property + def circumcenter_outside(self) -> int: ... + @property + def circumcenter_on_vertex(self) -> int: ... + @property + def sharp_fixed_corner(self) -> int: ... + @property + def short_edges(self) -> int: ... + @property + def split_vertex_invalid(self) -> int: ... + +class V2d: + __hash__: ClassVar[None] # type: ignore[assignment] + x: float + y: float + @overload + def __init__(self, x: float, y: float) -> None: ... + @overload + def __init__(self, buffer: Buffer, /) -> None: ... + +class Triangle: + __hash__: ClassVar[None] # type: ignore[assignment] + @property + def vertices(self) -> list[int]: ... + @vertices.setter + def vertices(self, value: Sequence[int]) -> None: ... + @property + def neighbors(self) -> list[int]: ... + @neighbors.setter + def neighbors(self, value: Sequence[int]) -> None: ... + +class Edge: + @overload + def __init__(self, index_vert_a: int, index_vert_b: int) -> None: ... + @overload + def __init__(self, buffer: Buffer, /) -> None: ... + @property + def v1(self) -> int: ... + @property + def v2(self) -> int: ... + def __lt__(self, other: Edge) -> bool: ... + +class Triangulation: + def __init__( + self, + vertex_insertion_order: VertexInsertionOrder, + intersecting_edges_strategy: IntersectingConstraintEdges, + min_dist_to_constraint_edge: float, + ) -> None: ... + @property + def vertices(self) -> list[V2d]: ... + @property + def triangles(self) -> list[Triangle]: ... + @property + def fixed_edges(self) -> set[Edge]: ... + @property + def overlap_count(self) -> dict[Edge, int]: ... + @property + def piece_to_originals(self) -> dict[Edge, list[Edge]]: ... + def vertices_count(self) -> int: ... + def triangles_count(self) -> int: ... + def fixed_edges_count(self) -> int: ... + def overlap_count_count(self) -> int: ... + def piece_to_originals_count(self) -> int: ... + def vertices_iter(self) -> Iterator[V2d]: ... + def triangles_iter(self) -> Iterator[Triangle]: ... + def fixed_edges_iter(self) -> Iterator[Edge]: ... + def overlap_count_iter(self) -> Iterator[tuple[Edge, int]]: ... + def piece_to_originals_iter(self) -> Iterator[tuple[Edge, list[Edge]]]: ... + def vertices_array(self, *, copy: bool = True) -> NDArray[np.void]: ... + def triangles_array(self, *, copy: bool = True) -> NDArray[np.void]: ... + @overload + def insert_vertices(self, vertices: Sequence[V2d]) -> None: ... + @overload + def insert_vertices(self, vertex_buffer: Buffer) -> None: ... + @overload + def insert_edges(self, edges: Sequence[Edge]) -> None: ... + @overload + def insert_edges(self, edge_buffer: Buffer) -> None: ... + @overload + def conform_to_edges(self, edges: Sequence[Edge]) -> None: ... + @overload + def conform_to_edges(self, edge_buffer: Buffer) -> None: ... + def refine_triangles( + self, + max_vertices_to_insert: int, + refinement_criterion: RefinementCriterion = ..., + refinement_threshold: float = ..., + to_erase: set[int] | None = None, + min_edge_length: float = ..., + ) -> Unrefined: ... + def find_unrefined_triangles( + self, + refinement_criterion: RefinementCriterion = ..., + refinement_threshold: float = ..., + ) -> list[int]: ... + def find_encroached_fixed_edges(self) -> list[Edge]: ... + def calculate_triangle_depths(self) -> list[int]: ... + def collect_super_triangle(self) -> set[int]: ... + def collect_outer_triangles(self) -> set[int]: ... + def collect_outer_triangles_and_holes(self) -> set[int]: ... + def erase_super_triangle(self) -> None: ... + def erase_outer_triangles(self) -> None: ... + def erase_outer_triangles_and_holes(self) -> None: ... + def remove_triangles(self, triangle_indices: AbstractSet[int]) -> None: ... + def finalize_triangulation(self, removed_triangles: AbstractSet[int]) -> None: ... + def is_finalized(self) -> bool: ... + +def verify_topology(triangulation: Triangulation) -> bool: ... +def verify_winding(triangulation: Triangulation) -> bool: ... +def area(a: V2d, b: V2d, c: V2d) -> float: ... +def circumcenter(a: V2d, b: V2d, c: V2d) -> V2d: ... +def smallest_angle(a: V2d, b: V2d, c: V2d) -> float: ... +def deg_to_rad(degrees: float) -> float: ... diff --git a/src/pythoncdt/py.typed b/src/pythoncdt/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/stubtest-allowlist.txt b/tests/stubtest-allowlist.txt new file mode 100644 index 0000000..a21b6d9 --- /dev/null +++ b/tests/stubtest-allowlist.txt @@ -0,0 +1,12 @@ +# pybind11 classes have pybind11's own metaclass +pythoncdt._core.Edge +pythoncdt._core.IntersectingConstraintEdges +pythoncdt._core.RefinementCriterion +pythoncdt._core.Triangle +pythoncdt._core.Triangulation +pythoncdt._core.Unrefined +pythoncdt._core.V2d +pythoncdt._core.VertexInsertionOrder +# no constructor is bound: instances only come from a triangulation +pythoncdt._core.Triangle.__init__ +pythoncdt._core.Unrefined.__init__ diff --git a/cdt_bindings_test.py b/tests/test_bindings.py similarity index 98% rename from cdt_bindings_test.py rename to tests/test_bindings.py index 53f6286..1eabd80 100644 --- a/cdt_bindings_test.py +++ b/tests/test_bindings.py @@ -11,8 +11,11 @@ import tempfile import hashlib from concurrent.futures import ThreadPoolExecutor +from pathlib import Path -import PythonCDT as cdt +import pythoncdt as cdt + +DATA_DIR = Path(__file__).parents[1] / "CDT" / "visualizer" / "data" def test_constants() -> None: @@ -165,7 +168,7 @@ def triangulation_md5_checksum(t: cdt.Triangulation): return md5_checksum(off_file) def test_triangulate_input_file() -> None: - vv, ee = read_input_file("CDT/visualizer/data/Constrained Sweden.txt") + vv, ee = read_input_file(DATA_DIR / "Constrained Sweden.txt") t = cdt.Triangulation(cdt.VertexInsertionOrder.AS_PROVIDED, cdt.IntersectingConstraintEdges.TRY_RESOLVE, 0.0) t.insert_vertices(vv) t.insert_edges(ee) @@ -177,7 +180,7 @@ def test_triangulate_input_file() -> None: def test_conform_to_edges() -> None: - vv, ee = read_input_file("CDT/visualizer/data/ditch.txt") + vv, ee = read_input_file(DATA_DIR / "ditch.txt") t = cdt.Triangulation(cdt.VertexInsertionOrder.AS_PROVIDED, cdt.IntersectingConstraintEdges.TRY_RESOLVE, 0.0) t.insert_vertices(vv) t.conform_to_edges(ee) @@ -213,7 +216,7 @@ def triangulation_with_sharp_input_corner() -> cdt.Triangulation: lambda t, threshold: max(triangle_areas(t)) <= threshold), ]) def test_refine_triangles_fulfills_criterion(criterion, threshold, is_criterion_fulfilled) -> None: - vv, ee = read_input_file("CDT/visualizer/data/Capital A.txt") + vv, ee = read_input_file(DATA_DIR / "Capital A.txt") t = cdt.Triangulation(cdt.VertexInsertionOrder.AS_PROVIDED, cdt.IntersectingConstraintEdges.NOT_ALLOWED, 0.0) t.insert_vertices(vv) t.insert_edges(ee) From e3adb0f8addd357091cb073c544122fa16530739 Mon Sep 17 00:00:00 2001 From: artem-ogre Date: Tue, 6 Oct 2026 23:51:36 +0200 Subject: [PATCH 2/8] Take the version from git tags, and draft releases with Release Drafter The version is no longer set in pyproject.toml: setuptools-scm takes it from the git tag, so publishing a GitHub release tagged 2.1.0 publishes version 2.1.0 to PyPI. Builds between releases get dev versions. Release Drafter keeps a draft of the next release, listing the merged pull requests and choosing the version from their labels: major, minor, or patch when unlabeled. Publishing the draft releases it. --- .github/release-drafter.yml | 15 +++++++++++++++ .github/workflows/release-drafter.yml | 15 +++++++++++++++ .github/workflows/wheels.yml | 2 ++ pyproject.toml | 5 +++-- 4 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 .github/release-drafter.yml create mode 100644 .github/workflows/release-drafter.yml diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 0000000..069f2d4 --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1,15 @@ +name-template: $RESOLVED_VERSION +tag-template: $RESOLVED_VERSION +# The pull request labels choose the next version; unlabeled ones count as patch +categories: + - type: version-resolver + semver-increment: major + when: + label: major + - type: version-resolver + semver-increment: minor + when: + label: minor + - type: version-resolver +template: | + $CHANGES diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml new file mode 100644 index 0000000..d22fe6f --- /dev/null +++ b/.github/workflows/release-drafter.yml @@ -0,0 +1,15 @@ +name: Release Drafter + +on: + push: + branches: [master] + +permissions: + contents: write + pull-requests: read + +jobs: + draft: + runs-on: ubuntu-latest + steps: + - uses: release-drafter/release-drafter@v7 diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 36efc35..9b36cb6 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -18,6 +18,7 @@ jobs: - uses: actions/checkout@v7 with: submodules: true + fetch-depth: 0 # the version comes from git tags # Builds the sdist, then a wheel from it: proves the sdist is complete - run: pipx run build - uses: actions/upload-artifact@v7 @@ -36,6 +37,7 @@ jobs: - uses: actions/checkout@v7 with: submodules: true + fetch-depth: 0 - uses: pypa/cibuildwheel@v4.3.0 - uses: actions/upload-artifact@v7 with: diff --git a/pyproject.toml b/pyproject.toml index 93a5994..bb62c6d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,10 @@ [build-system] -requires = ["scikit-build-core>=0.11", "pybind11>=3.0.4"] +requires = ["scikit-build-core>=0.11", "pybind11>=3.0.4", "setuptools-scm>=8"] build-backend = "scikit_build_core.build" [project] name = "pythoncdt" -version = "2.0.0" +dynamic = ["version"] description = "Python bindings for CDT: constrained Delaunay triangulation" readme = "README.md" license = "MPL-2.0" @@ -52,6 +52,7 @@ test = ["pytest>=9", "pytest-run-parallel"] [tool.scikit-build] minimum-version = "build-system.requires" +metadata.version.provider = "scikit_build_core.metadata.setuptools_scm" install.components = ["python"] sdist.exclude = ["CDT/docs", "CDT/CDT/tests"] From decd1c8804d9f743745de5875998d78a080fb1d4 Mon Sep 17 00:00:00 2001 From: artem-ogre Date: Tue, 6 Oct 2026 23:51:36 +0200 Subject: [PATCH 3/8] Lint and format with ruff, enforced by pre-commit Add pre-commit hooks running ruff's linter and formatter, and a CI job running the same hooks so that unformatted or failing code can't be merged. Fix what the linter found and reformat the tests. --- .github/workflows/ci.yml | 6 ++ .pre-commit-config.yaml | 7 ++ README.md | 6 +- benchmark_list_vs_array_access.py | 3 +- pyproject.toml | 4 ++ src/pythoncdt/__init__.py | 2 +- tests/test_bindings.py | 105 +++++++++++++++++++----------- 7 files changed, 91 insertions(+), 42 deletions(-) create mode 100644 .pre-commit-config.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fecec68..6f7371c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,12 @@ concurrency: cancel-in-progress: true jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - run: pipx run pre-commit run --all-files --show-diff-on-failure + test: name: ${{ matrix.os }} / Python ${{ matrix.python-version }} runs-on: ${{ matrix.os }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..06950bb --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,7 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.16.10 + hooks: + - id: ruff-check + args: [--fix] + - id: ruff-format diff --git a/README.md b/README.md index 2c2ace3..7062940 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ pip install . --group test # needs pip 25.1+ pytest ``` +Before committing, install the [pre-commit](https://pre-commit.com) hooks with `pre-commit install`: CI runs the same checks. + ## Usage examples ### Constrained triangulation @@ -39,9 +41,9 @@ t.insert_vertices(vertices) t.insert_edges(edges) t.erase_outer_triangles_and_holes() -vv = t.vertices_array() # numpy array with fields 'x' and 'y' +vv = t.vertices_array() # numpy array with fields 'x' and 'y' tt = t.triangles_array() # numpy array with fields 'vertices' and 'neighbors' -tt["vertices"] # (T, 3) vertex indices into vv +tt["vertices"] # (T, 3) vertex indices into vv ``` #### Notes diff --git a/benchmark_list_vs_array_access.py b/benchmark_list_vs_array_access.py index eb00480..d2ae07d 100644 --- a/benchmark_list_vs_array_access.py +++ b/benchmark_list_vs_array_access.py @@ -1,6 +1,7 @@ """Compares reading a triangulation as Python lists vs numpy arrays""" import timeit +from functools import partial import numpy as np @@ -12,6 +13,6 @@ print(f"{'':<10}{'count':>9}{'list':>12}{'copy':>12}{'view':>12}") for name in ["vertices", "triangles"]: array = getattr(t, f"{name}_array") - ways = [lambda: getattr(t, name), array, lambda: array(copy=False)] + ways = [partial(getattr, t, name), array, partial(array, copy=False)] ms = [1e3 * min(timeit.repeat(way, number=1, repeat=5)) for way in ways] print(f"{name:<10}{len(array()):>9}" + "".join(f"{m:>9.3f} ms" for m in ms)) diff --git a/pyproject.toml b/pyproject.toml index bb62c6d..1b822b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,6 +62,10 @@ strict = true filterwarnings = ["error"] testpaths = ["tests"] +[tool.ruff] +line-length = 120 +extend-exclude = ["CDT"] + [tool.cibuildwheel] test-groups = ["test"] test-command = "pytest {project}/tests" diff --git a/src/pythoncdt/__init__.py b/src/pythoncdt/__init__.py index 33e7637..1802982 100644 --- a/src/pythoncdt/__init__.py +++ b/src/pythoncdt/__init__.py @@ -5,4 +5,4 @@ """Python bindings for CDT: constrained Delaunay triangulation""" from ._core import * -from ._core import __version__ +from ._core import __version__ as __version__ diff --git a/tests/test_bindings.py b/tests/test_bindings.py index 1eabd80..29be92a 100644 --- a/tests/test_bindings.py +++ b/tests/test_bindings.py @@ -1,18 +1,17 @@ -#!/usr/bin/env python3 - # This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at https://mozilla.org/MPL/2.0/. -""" Tests for CDT Python bindings """ +"""Tests for CDT Python bindings""" -import numpy as np -import pytest -import tempfile import hashlib +import tempfile from concurrent.futures import ThreadPoolExecutor from pathlib import Path +import numpy as np +import pytest + import pythoncdt as cdt DATA_DIR = Path(__file__).parents[1] / "CDT" / "visualizer" / "data" @@ -35,7 +34,7 @@ def test_V2d() -> None: assert p.x == 42 and p.y == 42, "Error in constructing 2D vector with int" p = cdt.V2d(42.0, 42.0) assert p.x == 42 and p.y == 42, "Error in constructing 2D vector with float" - p = cdt.V2d(np.array([42., 42.])) + p = cdt.V2d(np.array([42.0, 42.0])) assert p.x == 42 and p.y == 42, "Error in constructing 2D vector with buffer protocol" assert cdt.V2d(1.23, 2).__repr__() == "V2d(1.23, 2)", "Wrong __repr__ output for V2d" @@ -141,25 +140,26 @@ def test_triangle_geometry() -> None: def save_triangulation_as_off(t: cdt.Triangulation, off_file) -> None: with open(off_file, "w") as f: - f.write(f"OFF\n") + f.write("OFF\n") f.write(f"{t.vertices_count()} {t.triangles_count()} 0\n") - for v in t.vertices_iter(): - f.write(f"{v.x} {v.y} 0\n") + f.writelines(f"{v.x} {v.y} 0\n" for v in t.vertices_iter()) for tri in t.triangles_iter(): vv = tri.vertices f.write(f"3 {int(vv[0])} {int(vv[1])} {int(vv[2])}\n") def read_input_file(input_file): - with open(input_file, "r") as f: + with open(input_file) as f: n_verts, n_edges = (int(s) for s in f.readline().split()) verts = [cdt.V2d(*(float(s) for s in f.readline().split())) for _ in range(n_verts)] edges = [cdt.Edge(*(int(s) for s in f.readline().split())) for _ in range(n_edges)] return verts, edges + def md5_checksum(file_path): - with open(file_path, 'r') as f: - return hashlib.md5(f.read().encode('utf-8')).hexdigest() + with open(file_path) as f: + return hashlib.md5(f.read().encode("utf-8")).hexdigest() + def triangulation_md5_checksum(t: cdt.Triangulation): with tempfile.TemporaryDirectory() as tmp_dir: @@ -167,6 +167,7 @@ def triangulation_md5_checksum(t: cdt.Triangulation): save_triangulation_as_off(t, off_file) return md5_checksum(off_file) + def test_triangulate_input_file() -> None: vv, ee = read_input_file(DATA_DIR / "Constrained Sweden.txt") t = cdt.Triangulation(cdt.VertexInsertionOrder.AS_PROVIDED, cdt.IntersectingConstraintEdges.TRY_RESOLVE, 0.0) @@ -176,7 +177,7 @@ def test_triangulate_input_file() -> None: with tempfile.TemporaryDirectory() as tmp_dir: off_file = f"{tmp_dir}/cdt.off" save_triangulation_as_off(t, off_file) - assert md5_checksum(off_file) == 'db59c00d9dad866781cd96779e5262b7', "Wrong OFF file contents" + assert md5_checksum(off_file) == "db59c00d9dad866781cd96779e5262b7", "Wrong OFF file contents" def test_conform_to_edges() -> None: @@ -185,7 +186,7 @@ def test_conform_to_edges() -> None: t.insert_vertices(vv) t.conform_to_edges(ee) t.erase_outer_triangles_and_holes() - assert triangulation_md5_checksum(t) == 'b64cae39c91a55dd4e23a146eb7df0d3', "Wrong OFF file contents" + assert triangulation_md5_checksum(t) == "b64cae39c91a55dd4e23a146eb7df0d3", "Wrong OFF file contents" def triangle_smallest_angles(t: cdt.Triangulation): @@ -209,12 +210,17 @@ def triangulation_with_sharp_input_corner() -> cdt.Triangulation: return t -@pytest.mark.parametrize("criterion, threshold, is_criterion_fulfilled", [ - (cdt.RefinementCriterion.SMALLEST_ANGLE, cdt.deg_to_rad(20.0), - lambda t, threshold: min(triangle_smallest_angles(t)) >= threshold), - (cdt.RefinementCriterion.LARGEST_AREA, 2.0, - lambda t, threshold: max(triangle_areas(t)) <= threshold), -]) +@pytest.mark.parametrize( + "criterion, threshold, is_criterion_fulfilled", + [ + ( + cdt.RefinementCriterion.SMALLEST_ANGLE, + cdt.deg_to_rad(20.0), + lambda t, threshold: min(triangle_smallest_angles(t)) >= threshold, + ), + (cdt.RefinementCriterion.LARGEST_AREA, 2.0, lambda t, threshold: max(triangle_areas(t)) <= threshold), + ], +) def test_refine_triangles_fulfills_criterion(criterion, threshold, is_criterion_fulfilled) -> None: vv, ee = read_input_file(DATA_DIR / "Capital A.txt") t = cdt.Triangulation(cdt.VertexInsertionOrder.AS_PROVIDED, cdt.IntersectingConstraintEdges.NOT_ALLOWED, 0.0) @@ -224,8 +230,14 @@ def test_refine_triangles_fulfills_criterion(criterion, threshold, is_criterion_ to_erase = t.collect_outer_triangles_and_holes() assert to_erase == t.collect_outer_triangles_and_holes(), "Collecting triangles must not change the triangulation" unrefined = t.refine_triangles(1000, criterion, threshold, to_erase) - gave_up = [unrefined.short_edge_triangles, unrefined.circumcenter_outside, unrefined.circumcenter_on_vertex, - unrefined.sharp_fixed_corner, unrefined.short_edges, unrefined.split_vertex_invalid] + gave_up = [ + unrefined.short_edge_triangles, + unrefined.circumcenter_outside, + unrefined.circumcenter_on_vertex, + unrefined.sharp_fixed_corner, + unrefined.short_edges, + unrefined.split_vertex_invalid, + ] assert not any(gave_up), f"Refinement gave up: {unrefined}" assert t.find_encroached_fixed_edges() == [], "Triangulation has encroached fixed edges" @@ -255,8 +267,9 @@ def test_refine_triangles_reports_sharp_input_corner() -> None: to_erase = t.collect_outer_triangles() unrefined = t.refine_triangles(1000, cdt.RefinementCriterion.SMALLEST_ANGLE, threshold, to_erase) assert unrefined.sharp_fixed_corner > 0, f"Sharp input corner was not reported: {unrefined}" - assert len(t.find_unrefined_triangles(cdt.RefinementCriterion.SMALLEST_ANGLE, threshold)) == 1, \ + assert len(t.find_unrefined_triangles(cdt.RefinementCriterion.SMALLEST_ANGLE, threshold)) == 1, ( "Triangle left sharp by the input must be found" + ) t.finalize_triangulation(to_erase) assert t.triangles_count() == 1, "Only the sharp input triangle must be left" @@ -272,28 +285,43 @@ def test_refining_and_collecting_fail_on_finalized_triangulation() -> None: t = triangulation_with_bad_triangles() t.erase_super_triangle() assert t.is_finalized(), "Triangulation must be finalized" - for call in [lambda: t.refine_triangles(10), t.find_encroached_fixed_edges, t.collect_super_triangle, - t.collect_outer_triangles, t.collect_outer_triangles_and_holes, - lambda: t.finalize_triangulation(set())]: + for call in [ + lambda: t.refine_triangles(10), + t.find_encroached_fixed_edges, + t.collect_super_triangle, + t.collect_outer_triangles, + t.collect_outer_triangles_and_holes, + lambda: t.finalize_triangulation(set()), + ]: with pytest.raises(RuntimeError): call() -@pytest.mark.parametrize("vv", [[cdt.V2d(-1, 0), cdt.V2d(0, 0.5), cdt.V2d(1, 0), cdt.V2d(0, -0.5)], - np.array([[-1, 0], [0, 0.5], [1, 0], [0, -0.5]], dtype=np.float64), - np.array([-1, 0, 0, 0.5, 1, 0, 0, -0.5], dtype=np.float64)]) +@pytest.mark.parametrize( + "vv", + [ + [cdt.V2d(-1, 0), cdt.V2d(0, 0.5), cdt.V2d(1, 0), cdt.V2d(0, -0.5)], + np.array([[-1, 0], [0, 0.5], [1, 0], [0, -0.5]], dtype=np.float64), + np.array([-1, 0, 0, 0.5, 1, 0, 0, -0.5], dtype=np.float64), + ], +) def test_insert_vertices(vv) -> None: t = cdt.Triangulation(cdt.VertexInsertionOrder.AS_PROVIDED, cdt.IntersectingConstraintEdges.NOT_ALLOWED, 0.0) t.insert_vertices(vv) assert len(t.vertices) == 7, "Wrong vertex count in triangulation" assert len(t.triangles) == 9, "Wrong triangle count in triangulation" assert len(t.fixed_edges) == 0, "Wrong fixed edge count in triangulation" - assert triangulation_md5_checksum(t) == 'db9176f4429942862a7a73155fb55322', "Wrong OFF file contents" + assert triangulation_md5_checksum(t) == "db9176f4429942862a7a73155fb55322", "Wrong OFF file contents" -@pytest.mark.parametrize("ee", [[cdt.Edge(0, 1), cdt.Edge(2, 3), cdt.Edge(3, 4), cdt.Edge(5, 6)], - np.array([[0, 1], [2, 3], [3, 4], [5, 6]], dtype=np.uintc), - np.array([0, 1, 2, 3, 3, 4, 5, 6], dtype=np.uintc)]) +@pytest.mark.parametrize( + "ee", + [ + [cdt.Edge(0, 1), cdt.Edge(2, 3), cdt.Edge(3, 4), cdt.Edge(5, 6)], + np.array([[0, 1], [2, 3], [3, 4], [5, 6]], dtype=np.uintc), + np.array([0, 1, 2, 3, 3, 4, 5, 6], dtype=np.uintc), + ], +) def test_insert_conform_edges(ee) -> None: # insert edges t = cdt.Triangulation(cdt.VertexInsertionOrder.AS_PROVIDED, cdt.IntersectingConstraintEdges.NOT_ALLOWED, 0.0) @@ -302,7 +330,7 @@ def test_insert_conform_edges(ee) -> None: assert len(t.vertices) == 10, "Wrong vertex count in triangulation" assert len(t.triangles) == 15, "Wrong triangle count in triangulation" assert len(t.fixed_edges) == 4, "Wrong fixed edge count in triangulation" - assert triangulation_md5_checksum(t) == '639c7a1492b2adb8f25464ec81ff6a00', "Wrong OFF file contents" + assert triangulation_md5_checksum(t) == "639c7a1492b2adb8f25464ec81ff6a00", "Wrong OFF file contents" # conform to edges t = cdt.Triangulation(cdt.VertexInsertionOrder.AS_PROVIDED, cdt.IntersectingConstraintEdges.NOT_ALLOWED, 0.0) @@ -311,14 +339,15 @@ def test_insert_conform_edges(ee) -> None: assert len(t.vertices) == 12, "Wrong vertex count in triangulation" assert len(t.triangles) == 19, "Wrong triangle count in triangulation" assert len(t.fixed_edges) == 6, "Wrong fixed edge count in triangulation" - assert triangulation_md5_checksum(t) == '9c87b435e247c1658ec0f04af3340dc7', "Wrong OFF file contents" + assert triangulation_md5_checksum(t) == "9c87b435e247c1658ec0f04af3340dc7", "Wrong OFF file contents" @pytest.mark.parametrize("copy", [True, False]) def test_arrays(copy) -> None: t = cdt.Triangulation(cdt.VertexInsertionOrder.AS_PROVIDED, cdt.IntersectingConstraintEdges.NOT_ALLOWED, 0.0) - assert t.vertices_array(copy=copy).shape == t.triangles_array(copy=copy).shape == (0,), \ + assert t.vertices_array(copy=copy).shape == t.triangles_array(copy=copy).shape == (0,), ( "Empty triangulation must give empty arrays" + ) t.insert_vertices([cdt.V2d(-1, 0), cdt.V2d(0, 0.5), cdt.V2d(1, 0), cdt.V2d(0, -0.5)]) vertices = t.vertices_array(copy=copy) @@ -329,7 +358,7 @@ def test_arrays(copy) -> None: expected = [vertices.copy(), triangles.copy()] del t - for arr, before in zip([vertices, triangles], expected): + for arr, before in zip([vertices, triangles], expected, strict=True): assert arr.flags.owndata == arr.flags.writeable == copy, "Copy must be owned and writeable, view neither" assert np.array_equal(arr, before), "Array must stay valid after the triangulation is deleted" From f92492eb1dc83b24b6d28fcd8e3dd3498370e075 Mon Sep 17 00:00:00 2001 From: artem-ogre Date: Tue, 6 Oct 2026 23:57:24 +0200 Subject: [PATCH 4/8] Simplify the version define and the test checksums CMake passes the version as a string literal, so the stringify macros and the "dev" fallback are gone. The tests hash the OFF text in memory instead of writing it to a temporary file. --- CMakeLists.txt | 2 +- src/cdt_bindings.cpp | 9 +-------- tests/test_bindings.py | 37 +++++++++---------------------------- 3 files changed, 11 insertions(+), 37 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index afe82f5..da8e823 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -6,7 +6,7 @@ add_subdirectory(CDT/CDT) pybind11_add_module(_core src/cdt_bindings.cpp) target_compile_features(_core PRIVATE cxx_std_17) -target_compile_definitions(_core PRIVATE VERSION_INFO=${SKBUILD_PROJECT_VERSION_FULL}) +target_compile_definitions(_core PRIVATE VERSION_INFO="${SKBUILD_PROJECT_VERSION_FULL}") target_link_libraries(_core PRIVATE CDT::CDT) # Installed as a component, so that CDT's own install rules stay out of the wheel diff --git a/src/cdt_bindings.cpp b/src/cdt_bindings.cpp index cdd42f8..4301354 100644 --- a/src/cdt_bindings.cpp +++ b/src/cdt_bindings.cpp @@ -16,9 +16,6 @@ #include #include -#define STRINGIFY(x) #x -#define MACRO_STRINGIFY(x) STRINGIFY(x) - namespace py = pybind11; using coord_t = double; @@ -183,11 +180,7 @@ PYBIND11_MODULE(_core, m, py::mod_gil_not_used()) )pbdoc"; // clang-format on -#ifdef VERSION_INFO - m.attr("__version__") = MACRO_STRINGIFY(VERSION_INFO); -#else - m.attr("__version__") = "dev"; -#endif + m.attr("__version__") = VERSION_INFO; m.attr("NO_NEIGHBOR") = py::int_(CDT::noNeighbor); m.attr("NO_VERTEX") = py::int_(CDT::noVertex); diff --git a/tests/test_bindings.py b/tests/test_bindings.py index 29be92a..f485cb7 100644 --- a/tests/test_bindings.py +++ b/tests/test_bindings.py @@ -5,7 +5,6 @@ """Tests for CDT Python bindings""" import hashlib -import tempfile from concurrent.futures import ThreadPoolExecutor from pathlib import Path @@ -25,7 +24,7 @@ def test_constants() -> None: def test_version() -> None: """Test that the version is passed in from the build system""" - assert cdt.__version__ != "dev", "Version was not passed in from the build system" + assert cdt.__version__, "Version was not passed in from the build system" def test_V2d() -> None: @@ -37,7 +36,7 @@ def test_V2d() -> None: p = cdt.V2d(np.array([42.0, 42.0])) assert p.x == 42 and p.y == 42, "Error in constructing 2D vector with buffer protocol" - assert cdt.V2d(1.23, 2).__repr__() == "V2d(1.23, 2)", "Wrong __repr__ output for V2d" + assert repr(cdt.V2d(1.23, 2)) == "V2d(1.23, 2)", "Wrong __repr__ output for V2d" def test_Edge() -> None: @@ -49,7 +48,7 @@ def test_Edge() -> None: e = cdt.Edge(np.array([2, 1], dtype=np.uintc)) assert e.v1 == 1 and e.v2 == 2, "Constructed wrong edge" - assert cdt.Edge(1, 2).__repr__() == "Edge(1, 2)", "Wrong __repr__ output for Edge" + assert repr(cdt.Edge(1, 2)) == "Edge(1, 2)", "Wrong __repr__ output for Edge" ee = [cdt.Edge(2, 3), cdt.Edge(0, 5), cdt.Edge(0, 1)] assert sorted(ee) == [cdt.Edge(0, 1), cdt.Edge(0, 5), cdt.Edge(2, 3)], "Edges are ordered wrong" @@ -138,16 +137,6 @@ def test_triangle_geometry() -> None: assert cdt.circumcenter(a, b, c) == cdt.V2d(0.5, 0.5), "Wrong triangle circumcenter" -def save_triangulation_as_off(t: cdt.Triangulation, off_file) -> None: - with open(off_file, "w") as f: - f.write("OFF\n") - f.write(f"{t.vertices_count()} {t.triangles_count()} 0\n") - f.writelines(f"{v.x} {v.y} 0\n" for v in t.vertices_iter()) - for tri in t.triangles_iter(): - vv = tri.vertices - f.write(f"3 {int(vv[0])} {int(vv[1])} {int(vv[2])}\n") - - def read_input_file(input_file): with open(input_file) as f: n_verts, n_edges = (int(s) for s in f.readline().split()) @@ -156,16 +145,11 @@ def read_input_file(input_file): return verts, edges -def md5_checksum(file_path): - with open(file_path) as f: - return hashlib.md5(f.read().encode("utf-8")).hexdigest() - - -def triangulation_md5_checksum(t: cdt.Triangulation): - with tempfile.TemporaryDirectory() as tmp_dir: - off_file = f"{tmp_dir}/cdt.off" - save_triangulation_as_off(t, off_file) - return md5_checksum(off_file) +def triangulation_md5_checksum(t: cdt.Triangulation) -> str: + lines = ["OFF", f"{t.vertices_count()} {t.triangles_count()} 0"] + lines += [f"{v.x} {v.y} 0" for v in t.vertices_iter()] + lines += ["3 {} {} {}".format(*tri.vertices) for tri in t.triangles_iter()] + return hashlib.md5("".join(f"{line}\n" for line in lines).encode()).hexdigest() def test_triangulate_input_file() -> None: @@ -174,10 +158,7 @@ def test_triangulate_input_file() -> None: t.insert_vertices(vv) t.insert_edges(ee) t.erase_outer_triangles_and_holes() - with tempfile.TemporaryDirectory() as tmp_dir: - off_file = f"{tmp_dir}/cdt.off" - save_triangulation_as_off(t, off_file) - assert md5_checksum(off_file) == "db59c00d9dad866781cd96779e5262b7", "Wrong OFF file contents" + assert triangulation_md5_checksum(t) == "db59c00d9dad866781cd96779e5262b7", "Wrong OFF file contents" def test_conform_to_edges() -> None: From 3a98723d8f197ff62bc9168aef3d19d60bae72ad Mon Sep 17 00:00:00 2001 From: artem-ogre Date: Tue, 6 Oct 2026 23:59:05 +0200 Subject: [PATCH 5/8] Update CDT to 2.0.1 The update only bumps CDT's version and its docs, so the bindings need no changes. --- CDT | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CDT b/CDT index 870c07c..3765e08 160000 --- a/CDT +++ b/CDT @@ -1 +1 @@ -Subproject commit 870c07cc1ed750641f4e30bc77d6351fadc3d641 +Subproject commit 3765e08ec4bbef0c6b721204d7c0e45d279084e9 From 1ff3b264de265bda36a440d1fe2df608846efc1a Mon Sep 17 00:00:00 2001 From: artem-ogre Date: Wed, 7 Oct 2026 00:11:46 +0200 Subject: [PATCH 6/8] Fix the V2d and Edge buffers, and the version in CI The V2d and Edge buffers had twice the item size as stride, so the second item was read past the end of the object, and Edge reported its items as doubles. Their constructors read past short buffers and ignored strides. They now reuse the checks of the insert overloads, and the Edge buffer is read-only so that it can't break the v1 < v2 order. The stubs declare __buffer__ for both classes, so type checkers accept them as buffers. CI fetches the full history, so the test builds get the version from the git tags. --- .github/workflows/ci.yml | 2 ++ src/cdt_bindings.cpp | 59 ++++++++++++++++------------------------ src/pythoncdt/_core.pyi | 2 ++ tests/test_bindings.py | 9 ++++++ 4 files changed, 36 insertions(+), 36 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f7371c..f300321 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,7 @@ jobs: - uses: actions/checkout@v7 with: submodules: true + fetch-depth: 0 - uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} @@ -42,6 +43,7 @@ jobs: - uses: actions/checkout@v7 with: submodules: true + fetch-depth: 0 - uses: actions/setup-python@v7 with: python-version: "3.x" diff --git a/src/cdt_bindings.cpp b/src/cdt_bindings.cpp index 4301354..c8e191c 100644 --- a/src/cdt_bindings.cpp +++ b/src/cdt_bindings.cpp @@ -160,6 +160,21 @@ std::pair*, std::size_t> buffer_pairs( return {static_cast*>(info.ptr), info.size / 2}; } +// Reads a buffer holding a single pair +template +BufferPair buffer_pair( + const py::buffer& b, + const std::string& type_name, + const std::string& item_name) +{ + const py::buffer_info info = b.request(); + const auto [ptr, n] = buffer_pairs(info, type_name, item_name); + if (n != 1) + throw std::runtime_error( + "Buffer must hold one " + type_name + " pair!"); + return *ptr; +} + std::string TriInd2str(CDT::TriInd it) { return it != CDT::noNeighbor ? std::to_string(it) : "-"; @@ -224,14 +239,8 @@ PYBIND11_MODULE(_core, m, py::mod_gil_not_used()) py::class_(m, "V2d", py::buffer_protocol()) .def(py::init(), py::arg("x"), py::arg("y")) .def(py::init([](py::buffer b) { - py::buffer_info info = b.request(); - if (info.format != py::format_descriptor::format()) - throw std::runtime_error( - "Incompatible format: expected a double array!"); - if (info.ndim != 1) - throw std::runtime_error("Incompatible buffer dimension!"); - const coord_t* const ptr = static_cast(info.ptr); - return V2d{ptr[0], ptr[1]}; + const auto p = buffer_pair(b, "double", "vertex"); + return V2d{p.v[0], p.v[1]}; })) .def_readwrite("x", &V2d::x) .def_readwrite("y", &V2d::y) @@ -247,15 +256,7 @@ PYBIND11_MODULE(_core, m, py::mod_gil_not_used()) oss << "V2d(" << v.x << ", " << v.y << ")"; return oss.str(); }) - .def_buffer([](V2d& v) -> py::buffer_info { - return py::buffer_info( - &v, - sizeof(coord_t), - py::format_descriptor::format(), - 1, - {2}, - {sizeof(coord_t) * 2}); - }); + .def_buffer([](V2d& v) { return py::buffer_info(&v.x, 2); }); PYBIND11_NUMPY_DTYPE(V2d, x, y); PYBIND11_NUMPY_DTYPE(CDT::Triangle, vertices, neighbors); @@ -290,27 +291,13 @@ PYBIND11_MODULE(_core, m, py::mod_gil_not_used()) py::arg("index_vert_a"), py::arg("index_vert_b")) .def(py::init([](py::buffer b) { - // Request a buffer descriptor from Python - py::buffer_info info = b.request(); - // Some sanity checks ... - if (info.format != py::format_descriptor::format()) - throw std::runtime_error( - "Incompatible format: expected a CDT::VertInd array!"); - if (info.ndim != 1) - throw std::runtime_error("Incompatible buffer dimension!"); - // create from buffer - const CDT::VertInd* const ptr = - static_cast(info.ptr); - return CDT::Edge(ptr[0], ptr[1]); + const auto p = buffer_pair(b, "CDT::VertInd", "edge"); + return CDT::Edge(p.v[0], p.v[1]); })) - .def_buffer([](CDT::Edge& e) -> py::buffer_info { + // read-only: writing could break the v1 < v2 invariant + .def_buffer([](const CDT::Edge& e) { return py::buffer_info( - &e, - sizeof(CDT::VertInd), - py::format_descriptor::format(), - 1, - {2}, - {sizeof(CDT::VertInd) * 2}); + reinterpret_cast(&e), 2); }) .def_property_readonly("v1", &CDT::Edge::v1) .def_property_readonly("v2", &CDT::Edge::v2) diff --git a/src/pythoncdt/_core.pyi b/src/pythoncdt/_core.pyi index fb53973..fd907fe 100644 --- a/src/pythoncdt/_core.pyi +++ b/src/pythoncdt/_core.pyi @@ -58,6 +58,7 @@ class V2d: def __init__(self, x: float, y: float) -> None: ... @overload def __init__(self, buffer: Buffer, /) -> None: ... + def __buffer__(self, flags: int, /) -> memoryview: ... class Triangle: __hash__: ClassVar[None] # type: ignore[assignment] @@ -75,6 +76,7 @@ class Edge: def __init__(self, index_vert_a: int, index_vert_b: int) -> None: ... @overload def __init__(self, buffer: Buffer, /) -> None: ... + def __buffer__(self, flags: int, /) -> memoryview: ... @property def v1(self) -> int: ... @property diff --git a/tests/test_bindings.py b/tests/test_bindings.py index f485cb7..ffc1457 100644 --- a/tests/test_bindings.py +++ b/tests/test_bindings.py @@ -37,6 +37,10 @@ def test_V2d() -> None: assert p.x == 42 and p.y == 42, "Error in constructing 2D vector with buffer protocol" assert repr(cdt.V2d(1.23, 2)) == "V2d(1.23, 2)", "Wrong __repr__ output for V2d" + assert np.asarray(cdt.V2d(1, 2)).tolist() == [1, 2], "Wrong V2d buffer" + for bad in [np.array([1.0]), np.zeros(4), np.arange(4.0).reshape(2, 2)[:, 0]]: + with pytest.raises(RuntimeError): + cdt.V2d(bad) def test_Edge() -> None: @@ -49,6 +53,11 @@ def test_Edge() -> None: assert e.v1 == 1 and e.v2 == 2, "Constructed wrong edge" assert repr(cdt.Edge(1, 2)) == "Edge(1, 2)", "Wrong __repr__ output for Edge" + assert np.asarray(cdt.Edge(1, 2)).tolist() == [1, 2], "Wrong Edge buffer" + assert memoryview(cdt.Edge(1, 2)).readonly, "Edge buffer must be read-only" + for bad in [np.array([1], dtype=np.uintc), np.arange(4, dtype=np.uintc).reshape(2, 2)[:, 0]]: + with pytest.raises(RuntimeError): + cdt.Edge(bad) ee = [cdt.Edge(2, 3), cdt.Edge(0, 5), cdt.Edge(0, 1)] assert sorted(ee) == [cdt.Edge(0, 1), cdt.Edge(0, 5), cdt.Edge(2, 3)], "Edges are ordered wrong" From 8f4fd75591b883e766c05e1dbef4a2b1e4f31a9a Mon Sep 17 00:00:00 2001 From: artem-ogre Date: Wed, 7 Oct 2026 00:25:51 +0200 Subject: [PATCH 7/8] Credit the packaging contributors Their pull requests (#8, #14 and #15) shaped how PythonCDT is packaged and published to PyPI. Co-authored-by: Mauricio Souza de Alencar <856825+mdealencar@users.noreply.github.com> Co-authored-by: Aidan Newsome <32934243+aidannewsome@users.noreply.github.com> --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 7062940..676c53b 100644 --- a/README.md +++ b/README.md @@ -97,3 +97,5 @@ Iterators (`*_iter()`) and `copy=False` views are not protected: don't use them ## Contributors - [SioulisChris](https://github.com/SioulisChris): fixing the tests on Windows - [sccolbert](https://github.com/sccolbert): reading the triangulation back as numpy arrays, releasing the GIL +- [mdealencar](https://github.com/mdealencar): packaging for PyPI +- [aidannewsome](https://github.com/aidannewsome): free-threading support, building wheels for PyPI From 1a75961e9ab0f1004ecb36ea33124b12aed3e99d Mon Sep 17 00:00:00 2001 From: aidannewsome Date: Wed, 7 Oct 2026 00:27:33 +0200 Subject: [PATCH 8/8] Publish to PyPI only from artem-ogre/PythonCDT, so a fork's release builds the wheels without a failed publish --- .github/workflows/wheels.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 9b36cb6..b90e5fe 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -46,7 +46,7 @@ jobs: publish: name: Publish to PyPI - if: github.event_name == 'release' + if: github.event_name == 'release' && github.repository == 'artem-ogre/PythonCDT' needs: [sdist, wheels] runs-on: ubuntu-latest environment: