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/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/ci.yml b/.github/workflows/ci.yml index 4892186..f300321 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,53 +5,47 @@ 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: + 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 }} 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 + fetch-depth: 0 + - 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 + fetch-depth: 0 + - 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/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 new file mode 100644 index 0000000..b90e5fe --- /dev/null +++ b/.github/workflows/wheels.yml @@ -0,0 +1,63 @@ +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 + 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 + 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 + fetch-depth: 0 + - 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' && github.repository == 'artem-ogre/PythonCDT' + 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/.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/CDT b/CDT index 870c07c..3765e08 160000 --- a/CDT +++ b/CDT @@ -1 +1 @@ -Subproject commit 870c07cc1ed750641f4e30bc77d6351fadc3d641 +Subproject commit 3765e08ec4bbef0c6b721204d7c0e45d279084e9 diff --git a/CMakeLists.txt b/CMakeLists.txt index 9c1d80a..da8e823 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..676c53b 100644 --- a/README.md +++ b/README.md @@ -3,26 +3,35 @@ 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 +``` + +Before committing, install the [pre-commit](https://pre-commit.com) hooks with `pre-commit install`: CI runs the same checks. + ## Usage examples ### Constrained triangulation ```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) @@ -32,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 @@ -88,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 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..d2ae07d 100644 --- a/benchmark_list_vs_array_access.py +++ b/benchmark_list_vs_array_access.py @@ -1,10 +1,11 @@ """Compares reading a triangulation as Python lists vs numpy arrays""" import timeit +from functools import partial 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))) @@ -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/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..1b822b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,25 +1,75 @@ [build-system] -requires = [ - "setuptools>=42", - "wheel", - "ninja", - "cmake>=3.12", +requires = ["scikit-build-core>=0.11", "pybind11>=3.0.4", "setuptools-scm>=8"] +build-backend = "scikit_build_core.build" + +[project] +name = "pythoncdt" +dynamic = ["version"] +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" +metadata.version.provider = "scikit_build_core.metadata.setuptools_scm" +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.ruff] +line-length = 120 +extend-exclude = ["CDT"] [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 90% rename from cdt_bindings.cpp rename to src/cdt_bindings.cpp index 75512c3..c8e191c 100644 --- a/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; @@ -163,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) : "-"; @@ -170,24 +182,20 @@ 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"; // 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); @@ -231,14 +239,8 @@ PYBIND11_MODULE(PythonCDT, 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) @@ -254,15 +256,7 @@ PYBIND11_MODULE(PythonCDT, 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); @@ -297,27 +291,13 @@ PYBIND11_MODULE(PythonCDT, 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/__init__.py b/src/pythoncdt/__init__.py new file mode 100644 index 0000000..1802982 --- /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__ as __version__ diff --git a/src/pythoncdt/_core.pyi b/src/pythoncdt/_core.pyi new file mode 100644 index 0000000..fd907fe --- /dev/null +++ b/src/pythoncdt/_core.pyi @@ -0,0 +1,157 @@ +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: ... + def __buffer__(self, flags: int, /) -> memoryview: ... + +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: ... + def __buffer__(self, flags: int, /) -> memoryview: ... + @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 80% rename from cdt_bindings_test.py rename to tests/test_bindings.py index 53f6286..ffc1457 100644 --- a/cdt_bindings_test.py +++ b/tests/test_bindings.py @@ -1,18 +1,19 @@ -#!/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 from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +import numpy as np +import pytest -import PythonCDT as cdt +import pythoncdt as cdt + +DATA_DIR = Path(__file__).parents[1] / "CDT" / "visualizer" / "data" def test_constants() -> None: @@ -23,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: @@ -32,10 +33,14 @@ 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" + 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: @@ -47,7 +52,12 @@ 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" + 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" @@ -136,53 +146,37 @@ 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(f"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") - 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() -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: - 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) 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: - 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) 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): @@ -206,14 +200,19 @@ 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("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) @@ -221,8 +220,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" @@ -252,8 +257,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" @@ -269,28 +275,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) @@ -299,7 +320,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) @@ -308,14 +329,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) @@ -326,7 +348,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"