Repository navigation
Conversation
Replace the hand-maintained ctypes prototypes in rtree/core.py with a pybind11 extension (src/_core.cpp) that binds the libspatialindex C API one-to-one using Python types, plus a generated rtree/_core.pyi stub. - index.py uses the typed handles; fully annotated, mypy --strict clean - build: setuptools -> scikit-build-core + CMake; feature detection at build time (Contains, array API); supports libspatialindex 1.8.5+ - wheels: static libspatialindex linked into the extension, one wheel per CPython version; drop repair_wheel.py and lib bundling - fix result_limit/result_offset being cross-wired to the C API - fix CustomStorageBase raising TypeError on registration - CustomStorage callbacks get an ErrorRef compatible with the old returnError.contents.value idiom See docs/pybind11-port.md for findings, packaging impact, and benchmarks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ATspgBTE6vTSYGpTJUZVCE
Rewrite src/_core.cpp against SpatialIndex::ISpatialIndex, IVisitor, IQueryStrategy and IStorageManager, dropping libspatialindex_c. The Python-facing rtree._core API (and _core.pyi signatures) is unchanged. - own visitors/strategies for ids, objects, counts, bounds and leaves; object results are extracted once instead of cloned twice - result_limit/result_offset applied while visiting - C++ exceptions translated to RTreeError; no C error stack - Python and ctypes custom storage implemented as IStorageManagers - contains() now also works with libspatialindex 1.8.5 - CMake links only libspatialindex; probes nearestNeighborQuery max_dist and ISpatialIndex::flush (both >= 1.9) - add benchmarks/bindings.py; document C API vs C++ API results Speed is unchanged for typical queries; object queries with many hits are ~1.1-1.2x and paged object queries ~2x faster. See docs/pybind11-port.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ATspgBTE6vTSYGpTJUZVCE
- IndexHandle.insert/delete/count/intersection/contains/nearest (and the *_obj variants) take the caller's coordinates plus the interleaved flag; point/box splitting, deinterleaving and validation happen in C++, with a fast path for 1-D float64 buffers - id queries return int64 NumPy arrays that own the C++ result vector (no copy); TPR id queries too - stream bulk loading consumes the Python iterator in C++ - min <= max is validated per dimension (was a lexicographic list compare) - numpy becomes a required dependency - tutorial doctests use .tolist(); new CoordinateParsing tests Per-call binding overhead drops from ~1.9 to ~1.05 us; small queries are 1.1-1.25x faster, stream bulk load 1.75x. See docs/pybind11-port.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ATspgBTE6vTSYGpTJUZVCE
With a shared libspatialindex and libc++ (macOS, or clang on Linux), exception types are matched by type_info address. pybind11 compiles the module with -fvisibility=hidden, so catch (Tools::Exception&) never matched exceptions thrown inside libspatialindex: from_stream let them escape as "RuntimeError: Caught an unknown exception!" (test_empty_stream) and every other call reported a generic "Unknown Error". Include the libspatialindex headers with default visibility, add a catch-all to from_stream, and make test_empty_stream check the library's own error message. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ATspgBTE6vTSYGpTJUZVCE
Ubuntu (libspatialindex 1.9.3): the PropertySet overload of createAndBulkLoadNewRTree defaults the external sorter to 0-byte pages before 2.0 and fails with "BufferedFileWriter::open: Cannot open file" (IndexSerialization::test_interleaving). Use the explicit overload for < 2.0, as libspatialindex_c did. Windows conda (libspatialindex 1.8.5): the 1.8.5 headers derive from std::binary_function, removed in C++17 by MSVC's STL and libc++. Define _HAS_AUTO_PTR_ETC / _LIBCPP_ENABLE_CXX17_REMOVED_UNARY_BINARY_FUNCTION before any standard header, and for the CMake feature probes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ATspgBTE6vTSYGpTJUZVCE
|
👎 You should start a new project for this kind of thing. |
|
To clarify, I'm all for switching to pybind11, just not like this. This is essentially a complete rewrite, is arguably not copyrightable, and decreases readability by nearly doubling the size of the library. I for one do not want to live in a world where the only maintainer who understands the current implementation is Claude. But Sean and I have been pretty vocal about our dislike of LLMs, so maybe we're just old-fashioned. |
Sure. It was just an investigation to see if it would be worth the bother to switch to pybind11.
A pybind11 implementation is harder to maintain regardless of whether an LLM or a doughy software developer wrote it. Now that maintainer must know C++, Python -> C+ boundary stuff, and CMake / scikit-build-core for building things. I would argue our ctypes implementation hasn't been particularly maintainable over the past twenty years either – Claude found some basic errors that still exist in the codebase where we're swapping parameters and mixing types because there's no way to test that stuff. I think the practical answer is no one is going to invest the time to do this port manually. There are performance wins to get, but they yet haven't mattered enough to anyone. |
Below is LLM output from Claude. I had it work through implementing the bindings in pybind11. I wanted to see what the performance improvement and readability benefits would be in addition to getting type annotations at the binding layer. It looks like a significant win if we are willing to live with the dependency changes.
Replace the ctypes bindings with a typed, compiled extension (nanobind, abi3)
Closes #223.
Summary
rtree has always talked to libspatialindex through ~700 lines of hand-written
ctypesprototypes (rtree/core.py) against the libspatialindex C API.This PR replaces them with a compiled extension,
rtree._core, that binds the libspatialindex C++ API directly.libspatialindex_cis no longer used.The main goal was proper type hints for the binding layer and the main library. Along the way the port made rtree faster, fixed two long-standing bugs, and simplified packaging.
rtree/_core.pyi.rtree/index.pyis fully annotated and clean undermypy --strict; onmain,rtree/has270 strict errors.
int64arrays, and coordinates are parsed in C++, including straight from NumPy arrays.cp312-abi3wheel serves Python 3.12, 3.13, 3.14 and later. libspatialindex is linked in statically, so the shared-library bundling,finder-based loading andrepair_wheel.pyretagging are gone.The branch got there in steps, each benchmarked:
docs/pybind11-port.mdhas the full notes and the numbers for each step.What changed
Bindings (
src/_core.cpp, new)SpatialIndex::ISpatialIndex,IVisitor,IQueryStrategyandIStorageManageritself. Whatlibspatialindex_cused to provide now lives in rtree's own ~200 lines of C++: index setup (storage → buffer → tree from aPropertySetwith the same defaults), id, object and count visitors, bounds and leaf queries, point-vs-region insert detection, result paging, and stream and array bulk loaders.IndexHandle,PropertyHandleandIndexItemare owned C++ objects with deterministic cleanup. The index keeps its properties alive, and teardown runs tree → buffer → storage.RTreeErroronce, carrying libspatialindex's own message. There's no global error stack to poll, and no errors swallowed by the C shim.CustomStorageis a C++IStorageManagerthat calls the Python methods withint/bytes. Callbacks get anErrorRefthat supports botherr.value = Xand the olderr.contents.value = X.CustomStorageBase, whose callbacks work on raw ctypes buffers, still works through its ctypes callback table.float64buffers (NumPy arrays,array.array('d'), strided views), and stream bulk loading consumes the iterator in C++.abi3audit --strictis clean. Id results are built withnumpy.frombuffer. That's about 1 µs per call cheaper than nanobind's generic ndarray export, and the arrays stay writable.try: rt.Foo except AttributeErrorat import time.1.9.3 and 2.1.0.
contains()now also works on 1.8.5 becausecontainsWhatQuerywas always in the C++ API.Python layer
rtree/index.pycalls the typed handles and is fully annotated.pyproject.tomlenablesdisallow_untyped_defsforrtree.indexandrtree._core.rtree/_core.pyiis generated bynanobind.stubgen(scripts/generate_stubs.sh). NumPy parameters carry dtype, shape and writability.rtree/core.pyre-exports the_coreobjects.core.rt(the rawCDLL) is removed.InvalidHandleExceptionmoved tortree.exceptionsand is re-exported fromrtree.index.Any, now fixed:fill_factorandsplit_distribution_factorwere annotatedint; they are Cdoubles._countTP(times: float)actually takes a(start, end)pair.get_coordinate_pointers()claimed to returntuple[float, float].interleave/deinterleaveare generic.intersection_v,nearest_vandleaves()have real return types.intersection/nearest/containsoverloads returnNDArray[np.int64]forobjects=False.Build, packaging and repository
nanobind_add_module(_core STABLE_ABI NOMINSIZE ...),wheel.py-api = "cp312").setup.pyis removed.scripts/install_libspatialindex.{sh,bat}build a static, position-independent libspatialindex (a universal build on macOS) intosidx-static/, which CMake picks up.abi3audit --stricton the wheels.scripts/repair_wheel.pyis removed.rtree/picks up the built module.libspatialindex-dev.DEPENDENCIES.txtlists the runtime requirements (Python 3.12+, NumPy) and the build requirements (libspatialindex C++ headers, C++17, CMake, nanobind).MANIFEST.inmirrors the sdist. scikit-build-core doesn't read it: dev-only files are excluded viasdist.exclude, andcheck-manifestpasses.FAQ.txt(empty) is removed.environment.ymltargets 3.12 and adds the build tools.benchmarks/bindings.pyis a binding-layer benchmark, used for the numbersbelow.
Bug fixes (both present on
main)result_limitandresult_offsetwere cross-wired. Each setter and getter called the other C function, so the round-trip tests passed. Onmain,idx.result_offset = 3limits results to 3 andidx.result_limit = 3skips the first 3. Covered bytest_result_limit_and_offset_semantics.CustomStorageBasecould not be used.registerCallbackspassed actypes.c_void_p()that got wrapped in a secondc_void_p, raisingTypeError: cannot be converted to pointer. There was no test; covered byIndexCustomStorageBase.Performance
benchmarks/bindings.py, same machine, CPython 3.12, libspatialindex 2.1.0:100k random boxes, 20k 10×10 query windows, best of 2 repetitions.
main(ctypes)objects=True, 20k queriesobjects=True, 20k queriesobjects=True, 100k hits ×5objects="raw", 100k hits ×5objects="raw", 20k querieslist(ids), 20k queriesbounds×20kleaves()intersection_v/nearest_v/ array bulk loadFixed cost per call, measured with a query that matches nothing:
main(ctypes)Index.countIndex.intersectionWhere the time goes:
intersection_v/nearest_v(already one C call for all queries), insert, delete andcontains: they're bound by libspatialindex itself.result_limitbug onmainreturning every hit instead of 10. Paged id queries are 1.9× faster.list(idx.intersection(...))pays to box eachnp.int64; use.tolist()to get Python ints.Behaviour changes
intersection(),nearest()andcontains()withobjects=Falsereturnnumpy.ndarray[int64]instead of an iterator ofint. Elements arenp.int64: they compare and hash likeint, butjson.dumpsrejects them and NumPy 2 prints them asnp.int64(3). The tutorial doctests now use.tolist().RtreeContaineris unchanged for users.min <= maxis validated per dimension. The old check compared lists lexicographically, so a box like(xmin=0, ymin=5, xmax=1, ymax=2)was accepted as an inverted box; it now raisesRTreeError. Stream bulk loading still doesn't validate, as before.result_limit/result_offsetnow do what their names say.TypeErrorinstead ofctypes.ArgumentError.rtree.core.rtis removed.Property.filenameand the extension properties acceptstrorbytesand returnstr.Property.custom_storage_callbacksreturns anintaddress orNone.Notes for reviewers
type_infoaddress, and extensions are built with hidden visibility, so exceptions thrown inside a shared libspatialindex were not caught. The libspatialindexinclude is wrapped in
#pragma GCC visibility push(default)to fix this, andtest_empty_streamasserts libspatialindex's real message so it can't regress silently.PropertySetbulk-load overload has a 0-byte external sort buffer, so the explicit overload is used there instead.std::binary_function, so_HAS_AUTO_PTR_ETCand_LIBCPP_ENABLE_CXX17_REMOVED_UNARY_BINARY_FUNCTIONare set for MSVC/libc++ builds.nb::value_error, ...) derive fromstd::runtime_error.run()only wraps libspatialindex calls, so they aren't re-wrapped asRTreeError. Keep that in mind when adding code insiderun().Error_Reset/Error_GetErrorCountare exported but not declared insidx_api.h.Index_Free(NULL)pushes an error.Index_Intersects_objreturns success after an error.IndexProperty_Set*string setters leak.Testing
abi3audit --strictreports only 3.12 stable-ABI symbols.libspatialindex-dev) and 2.1.0, with static and shared builds and with GCC/libstdc++ and clang/libc++.mypy --strict, ruff,check-manifestand the Sphinx build with-Ware all clean. nanobind reports no leaked instances, and memory stays flat over repeated queries.test_result_limit_and_offset_semanticsIndexCustomStorageBaseCoordinateParsing: array returns, input types, points, non-interleavedinput, per-dimension validation, streams of arrays
test_empty_streamnow asserts the error message