From 4143517e76f7b673ecfa4be5061059408f0ff5a7 Mon Sep 17 00:00:00 2001 From: Dewey Dunnington Date: Thu, 29 Oct 2026 13:53:25 -0500 Subject: [PATCH 1/4] fix(python): require PyArrow 25 for raster buffer construction --- .github/workflows/python-wheels.yml | 3 +-- python/sedonadb/pyproject.toml | 7 +++++-- python/sedonadb/python/sedonadb/raster.py | 14 ++++++++++--- python/sedonadb/tests/test_raster.py | 25 +++++++++++++++++++++++ 4 files changed, 42 insertions(+), 7 deletions(-) diff --git a/.github/workflows/python-wheels.yml b/.github/workflows/python-wheels.yml index fc228356e8..4f6cba8a2f 100644 --- a/.github/workflows/python-wheels.yml +++ b/.github/workflows/python-wheels.yml @@ -93,8 +93,7 @@ jobs: VCPKG_DEFAULT_TRIPLET: x64-windows-dynamic-release VCPKG_DEFAULT_BINARY_CACHE: ${{ github.workspace }}/vcpkg-bincache CMAKE_TOOLCHAIN_FILE: ${{ github.workspace }}/vcpkg/scripts/buildsystems/vcpkg.cmake - CIBW_BUILD: "cp39-win_amd64" - CIBW_TEST_REQUIRES: pytest adbc_driver_manager geoarrow-pyarrow geopandas + CIBW_BUILD: "cp310-win_amd64" # Test command exits 0 even on failure so CIBW continues building all wheels; # failures are recorded in .test_failed and checked after upload. # We use Python here to be absolutely sure there are no shell escaping issues. diff --git a/python/sedonadb/pyproject.toml b/python/sedonadb/pyproject.toml index ce10679a1c..48941a54e4 100644 --- a/python/sedonadb/pyproject.toml +++ b/python/sedonadb/pyproject.toml @@ -42,6 +42,7 @@ test = [ "geopandas", "pandas", "polars", + "pyarrow>=25", "pytest", "pyyaml", "rasterio", @@ -57,10 +58,12 @@ geopandas = [ [tool.cibuildwheel] # PyO3's stable ABI support makes this one wheel per platform compatible with # every supported CPython version, rather than producing one wheel per version. -build = "cp39-*" +# Test with Python 3.10 so PyArrow >= 25 is available for raster construction. +# The wheel remains compatible with Python 3.9 via abi3-py39 below. +build = "cp310-*" # Set here rather than via CIBW_TEST_REQUIRES in the workflow: environment # variables take precedence over the overrides below. -test-requires = ["pytest", "adbc_driver_manager", "geoarrow-pyarrow", "geopandas", "duckdb"] +test-requires = ["pyarrow>=25", "pytest", "adbc_driver_manager", "geoarrow-pyarrow", "geopandas", "duckdb"] [tool.maturin] features = ["pyo3/extension-module", "pyo3/abi3-py39"] diff --git a/python/sedonadb/python/sedonadb/raster.py b/python/sedonadb/python/sedonadb/raster.py index 7db719dfc7..2e329befc9 100644 --- a/python/sedonadb/python/sedonadb/raster.py +++ b/python/sedonadb/python/sedonadb/raster.py @@ -106,7 +106,10 @@ def from_numpy( The mirror of `lazy`: identical dimension/CRS conventions, but the pixel data is carried in the raster rather than referenced by URI. - This operation is zero-copy for contiguous arrays. + Requires PyArrow >= 25.0.0. + + Contiguous arrays with more than 12 bytes share their pixel buffer. + Smaller arrays are copied into Arrow's inline binary view storage. Args: array: The pixel data. The trailing two axes are the spatial `(y, x)` @@ -709,11 +712,16 @@ def _get_binary_view_buffer( def _wrap_data_zero_copy(data) -> pa.BinaryViewArray: """Wrap data as a single-element BinaryViewArray without copying. - For numpy arrays and memoryviews, this creates a zero-copy view. - For bytes, pyarrow handles the wrapping efficiently. + For contiguous buffers larger than 12 bytes, this creates a zero-copy + view. Smaller buffers are copied into the binary view's inline storage. """ import numpy as np + # Earlier Arrow releases can segfault when exporting inline binary views: + # https://github.com/apache/arrow/pull/50166 + if int(pa.__version__.split(".")[0]) < 25: + raise ImportError("Raster.from_numpy() requires PyArrow >= 25.0.0") + view = memoryview(data) if not view.c_contiguous: # Must copy if not contiguous diff --git a/python/sedonadb/tests/test_raster.py b/python/sedonadb/tests/test_raster.py index 9befc2a660..778c714628 100644 --- a/python/sedonadb/tests/test_raster.py +++ b/python/sedonadb/tests/test_raster.py @@ -382,6 +382,31 @@ def test_raster_from_numpy_2d(): assert np.shares_memory(b.to_numpy(), arr) +@pytest.mark.parametrize("version", ["21.0.0", "22.0.0", "24.0.0"]) +def test_raster_from_numpy_requires_pyarrow_25(monkeypatch, version): + monkeypatch.setattr(pa, "__version__", version) + with pytest.raises(ImportError, match=r"requires PyArrow >= 25\.0\.0"): + Raster.from_numpy(np.arange(6, dtype=np.uint8).reshape(2, 3)) + + +@pytest.mark.parametrize("nbytes", [0, 1, 6, 12, 13, 1024]) +@pytest.mark.parametrize("strided", [False, True]) +def test_raster_from_numpy_arrow_roundtrip(nbytes, strided): + source = np.arange(nbytes * (2 if strided else 1), dtype=np.uint8) + pixels = (source[::2] if strided else source).reshape(1, nbytes) + raster = Raster.from_numpy(pixels) + + # Exporting an inline binary view cast from binary crashes older PyArrow. + # Exercise the C Data protocol, not just Arrow's array validation. + schema, array = raster.__arrow_c_array__() + result = pa.Array._import_from_c_capsule(schema, array) + result.validate(full=True) + restored = Raster(result).to_numpy()[0] + np.testing.assert_array_equal(restored, pixels) + if nbytes > 12: + assert np.shares_memory(restored, pixels) == (not strided) + + def test_raster_from_numpy_nd_with_crs(): arr = np.arange(2 * 2 * 3, dtype="float32").reshape(2, 2, 3) r = Raster.from_numpy(arr, dim_names=["time", "y", "x"], crs="EPSG:4326") From aa566677842f16c7c8d0549818cfddbb3426f1d6 Mon Sep 17 00:00:00 2001 From: Dewey Dunnington Date: Thu, 29 Oct 2026 14:35:11 -0500 Subject: [PATCH 2/4] test(python): retain Python 3.9 coverage with targeted raster skips --- .github/workflows/python-wheels.yml | 2 +- python/sedonadb/pyproject.toml | 7 ++---- python/sedonadb/python/sedonadb/raster.py | 10 ++++----- python/sedonadb/tests/conftest.py | 6 +++++ python/sedonadb/tests/expr/test_literal.py | 4 ++-- .../tests/functions/test_raster_functions.py | 6 ++--- .../tests/functions/test_raster_nd.py | 4 ++-- .../sedonadb/tests/functions/test_rs_clip.py | 5 ++++- python/sedonadb/tests/test_raster.py | 22 +++++++++---------- python/sedonadb/tests/test_udf.py | 4 ++-- 10 files changed, 38 insertions(+), 32 deletions(-) diff --git a/.github/workflows/python-wheels.yml b/.github/workflows/python-wheels.yml index 4f6cba8a2f..5e210fbc91 100644 --- a/.github/workflows/python-wheels.yml +++ b/.github/workflows/python-wheels.yml @@ -93,7 +93,7 @@ jobs: VCPKG_DEFAULT_TRIPLET: x64-windows-dynamic-release VCPKG_DEFAULT_BINARY_CACHE: ${{ github.workspace }}/vcpkg-bincache CMAKE_TOOLCHAIN_FILE: ${{ github.workspace }}/vcpkg/scripts/buildsystems/vcpkg.cmake - CIBW_BUILD: "cp310-win_amd64" + CIBW_BUILD: "cp39-win_amd64" # Test command exits 0 even on failure so CIBW continues building all wheels; # failures are recorded in .test_failed and checked after upload. # We use Python here to be absolutely sure there are no shell escaping issues. diff --git a/python/sedonadb/pyproject.toml b/python/sedonadb/pyproject.toml index 48941a54e4..ce10679a1c 100644 --- a/python/sedonadb/pyproject.toml +++ b/python/sedonadb/pyproject.toml @@ -42,7 +42,6 @@ test = [ "geopandas", "pandas", "polars", - "pyarrow>=25", "pytest", "pyyaml", "rasterio", @@ -58,12 +57,10 @@ geopandas = [ [tool.cibuildwheel] # PyO3's stable ABI support makes this one wheel per platform compatible with # every supported CPython version, rather than producing one wheel per version. -# Test with Python 3.10 so PyArrow >= 25 is available for raster construction. -# The wheel remains compatible with Python 3.9 via abi3-py39 below. -build = "cp310-*" +build = "cp39-*" # Set here rather than via CIBW_TEST_REQUIRES in the workflow: environment # variables take precedence over the overrides below. -test-requires = ["pyarrow>=25", "pytest", "adbc_driver_manager", "geoarrow-pyarrow", "geopandas", "duckdb"] +test-requires = ["pytest", "adbc_driver_manager", "geoarrow-pyarrow", "geopandas", "duckdb"] [tool.maturin] features = ["pyo3/extension-module", "pyo3/abi3-py39"] diff --git a/python/sedonadb/python/sedonadb/raster.py b/python/sedonadb/python/sedonadb/raster.py index 2e329befc9..4951c7190f 100644 --- a/python/sedonadb/python/sedonadb/raster.py +++ b/python/sedonadb/python/sedonadb/raster.py @@ -717,11 +717,6 @@ def _wrap_data_zero_copy(data) -> pa.BinaryViewArray: """ import numpy as np - # Earlier Arrow releases can segfault when exporting inline binary views: - # https://github.com/apache/arrow/pull/50166 - if int(pa.__version__.split(".")[0]) < 25: - raise ImportError("Raster.from_numpy() requires PyArrow >= 25.0.0") - view = memoryview(data) if not view.c_contiguous: # Must copy if not contiguous @@ -734,6 +729,11 @@ def _wrap_data_zero_copy(data) -> pa.BinaryViewArray: elif nbytes >= 2**31: raise ValueError(f"Can't store {nbytes} (>2GB) in a single raster band") + # Earlier Arrow releases can segfault when exporting inline binary views: + # https://github.com/apache/arrow/pull/50166 + if int(pa.__version__.split(".")[0]) < 25: + raise ImportError("Raster.from_numpy() requires PyArrow >= 25.0.0") + # Build BinaryArray zero-copy using from_buffers, then cast to binary_view # BinaryArray buffers: [validity, offsets, data] offsets = pa.py_buffer(np.array([0, nbytes], dtype=np.int32)) diff --git a/python/sedonadb/tests/conftest.py b/python/sedonadb/tests/conftest.py index 9b0cc2b833..a46d692b59 100644 --- a/python/sedonadb/tests/conftest.py +++ b/python/sedonadb/tests/conftest.py @@ -38,3 +38,9 @@ def geoarrow_data(): @pytest.fixture() def sedona_testing(): return HERE.parent.parent.parent / "submodules" / "sedona-testing" + + +@pytest.fixture() +def pyarrow25(): + """Require the Arrow export fix needed by Raster.from_numpy().""" + return pytest.importorskip("pyarrow", minversion="25.0.0") diff --git a/python/sedonadb/tests/expr/test_literal.py b/python/sedonadb/tests/expr/test_literal.py index 6d42887396..b6fe106fd0 100644 --- a/python/sedonadb/tests/expr/test_literal.py +++ b/python/sedonadb/tests/expr/test_literal.py @@ -233,7 +233,7 @@ def test_geometry_array_rejects_metadata_change(source_type): @pytest.mark.parametrize("kind", ["empty", "null", "mixed"]) -def test_raster_array(kind): +def test_raster_array(pyarrow25, kind): from sedonadb._lib import raster_type from sedonadb.raster import Raster @@ -260,7 +260,7 @@ def test_raster_array_rejects_invalid_element(): _resolve_arrow_array([None, 1], raster_type()) -def test_raster_array_same_type(): +def test_raster_array_same_type(pyarrow25): from sedonadb._lib import raster_type from sedonadb.raster import Raster diff --git a/python/sedonadb/tests/functions/test_raster_functions.py b/python/sedonadb/tests/functions/test_raster_functions.py index cdc46110d1..f3b16e0a4a 100644 --- a/python/sedonadb/tests/functions/test_raster_functions.py +++ b/python/sedonadb/tests/functions/test_raster_functions.py @@ -230,7 +230,7 @@ def test_rs_worldtorastercoord_empty_point_is_null(): ) -def test_rs_value_matches_rasterio(con): +def test_rs_value_matches_rasterio(pyarrow25, con): """Cross-check RS_Value against rasterio on a random raster. Builds an in-memory raster from a random numpy array with a known @@ -527,7 +527,7 @@ def test_rs_values_ensureloaded_outdb(con, sedona_testing): con.drop_view(view) -def test_rs_values_matches_rasterio(con): +def test_rs_values_matches_rasterio(pyarrow25, con): """Cross-check RS_Values against rasterio on a random raster. The plural counterpart of `test_rs_value_matches_rasterio`: the same dense @@ -643,7 +643,7 @@ def test_rs_asgeotiff_out_of_range_quality_errors(con): # floating-point prediction for float bands). @pytest.mark.parametrize("dtype", ["uint8", "uint16", "int32", "float32", "float64"]) @pytest.mark.parametrize("compression_args", ["", ", 'DEFLATE', 0.85", ", 'LZW', 0.85"]) -def test_rs_asgeotiff_roundtrips_contents(con, dtype, compression_args): +def test_rs_asgeotiff_roundtrips_contents(pyarrow25, con, dtype, compression_args): pytest.importorskip("rasterio") from rasterio.io import MemoryFile diff --git a/python/sedonadb/tests/functions/test_raster_nd.py b/python/sedonadb/tests/functions/test_raster_nd.py index 4216e0c91b..cfb2c520d3 100644 --- a/python/sedonadb/tests/functions/test_raster_nd.py +++ b/python/sedonadb/tests/functions/test_raster_nd.py @@ -56,13 +56,13 @@ def _eval(expr, *rasters): @pytest.fixture -def r2d(): +def r2d(pyarrow25): """2-D raster ``[y=4, x=5]`` with sequential pixel values 0..19.""" return Raster.from_numpy(np.arange(4 * 5, dtype="uint8").reshape(4, 5)) @pytest.fixture -def r3d(): +def r3d(pyarrow25): """3-D raster ``[time=3, y=4, x=5]`` with sequential pixel values 0..59.""" return Raster.from_numpy( np.arange(3 * 4 * 5, dtype="uint8").reshape(3, 4, 5), diff --git a/python/sedonadb/tests/functions/test_rs_clip.py b/python/sedonadb/tests/functions/test_rs_clip.py index 684990e0ca..b4d13fc8eb 100644 --- a/python/sedonadb/tests/functions/test_rs_clip.py +++ b/python/sedonadb/tests/functions/test_rs_clip.py @@ -170,7 +170,10 @@ def _rasterio_clip( if band < 0 or band > src.count: raise ValueError(f"band {band} out of range for a {src.count}-band raster") if crop: - window = rasterio.features.geometry_window(src, [geom]) + # RS_Clip crops to the world-space envelope. Rasterio <1.5 uses + # the geometry's vertices directly, which gives a tighter window + # on rotated grids; pass the envelope explicitly on all versions. + window = rasterio.features.geometry_window(src, [geom.envelope]) else: window = rasterio.windows.Window(0, 0, src.width, src.height) transform = src.window_transform(window) diff --git a/python/sedonadb/tests/test_raster.py b/python/sedonadb/tests/test_raster.py index 778c714628..20f896bbc5 100644 --- a/python/sedonadb/tests/test_raster.py +++ b/python/sedonadb/tests/test_raster.py @@ -108,7 +108,7 @@ def test_iter_rasters_handles_chunks_and_nulls(con): assert sum(raster is None for raster in rasters) == 1 -def test_iter_rasters_handles_sliced_array_offsets(): +def test_iter_rasters_handles_sliced_array_offsets(pyarrow25): first = pa.array(Raster.from_numpy(np.full((4, 4), 11, dtype=np.uint16))) second = pa.array(Raster.from_numpy(np.full((4, 4), 22, dtype=np.uint16))) array = pa.concat_arrays([first, second]).slice(1, 1) @@ -119,7 +119,7 @@ def test_iter_rasters_handles_sliced_array_offsets(): @pytest.mark.parametrize("parent_null", [False, True]) @pytest.mark.parametrize("start", [0, 1, 2]) -def test_iter_rasters_null_bands_with_nonempty_offsets(parent_null, start): +def test_iter_rasters_null_bands_with_nonempty_offsets(pyarrow25, parent_null, start): storage = pa.concat_arrays( [ Raster.from_numpy(np.full((4, 4), value, dtype=np.uint16))._array @@ -160,7 +160,7 @@ def test_iter_rasters_null_bands_with_nonempty_offsets(parent_null, start): @pytest.mark.parametrize("start, length", [(0, 10), (3, 6), (3, 1), (4, 0)]) -def test_iter_rasters_sliced_validity(start, length): +def test_iter_rasters_sliced_validity(pyarrow25, start, length): raster = Raster.from_numpy(np.ones((4, 4), dtype=np.uint16)) storage = pa.concat_arrays([raster._array] * 10) nulls = [True, False, False, True, False, False, False, True, False, True] @@ -366,7 +366,7 @@ def test_raster_lazy_nd(): assert r.height == 256 -def test_raster_from_numpy_2d(): +def test_raster_from_numpy_2d(pyarrow25): # Use array >12 bytes so BinaryView uses out-of-line storage (zero-copy eligible) arr = np.arange(4 * 5, dtype="uint8").reshape(4, 5) r = Raster.from_numpy(arr) @@ -391,7 +391,7 @@ def test_raster_from_numpy_requires_pyarrow_25(monkeypatch, version): @pytest.mark.parametrize("nbytes", [0, 1, 6, 12, 13, 1024]) @pytest.mark.parametrize("strided", [False, True]) -def test_raster_from_numpy_arrow_roundtrip(nbytes, strided): +def test_raster_from_numpy_arrow_roundtrip(pyarrow25, nbytes, strided): source = np.arange(nbytes * (2 if strided else 1), dtype=np.uint8) pixels = (source[::2] if strided else source).reshape(1, nbytes) raster = Raster.from_numpy(pixels) @@ -407,7 +407,7 @@ def test_raster_from_numpy_arrow_roundtrip(nbytes, strided): assert np.shares_memory(restored, pixels) == (not strided) -def test_raster_from_numpy_nd_with_crs(): +def test_raster_from_numpy_nd_with_crs(pyarrow25): arr = np.arange(2 * 2 * 3, dtype="float32").reshape(2, 2, 3) r = Raster.from_numpy(arr, dim_names=["time", "y", "x"], crs="EPSG:4326") @@ -426,7 +426,7 @@ def test_raster_from_numpy_invalid_shape(): Raster.from_numpy(np.zeros((2, 2, 3), dtype="uint8")) -def test_raster_from_numpy_bbox_matches_rasterio(): +def test_raster_from_numpy_bbox_matches_rasterio(pyarrow25): # bbox (pixel/default) must produce rasterio.transform.from_bounds, in GDAL order. rasterio = pytest.importorskip("rasterio") @@ -452,7 +452,7 @@ def test_raster_from_numpy_bbox_and_transform_mutually_exclusive(): ) -def test_raster_from_numpy_bbox_registration_node(): +def test_raster_from_numpy_bbox_registration_node(pyarrow25): # A node-registered grid maps the bbox to cell centers, so its transform # differs from the default pixel registration (exact values are pinned by the # Rust unit test); this is the Python end-to-end smoke. @@ -583,17 +583,17 @@ def test_get_binary_view_buffer_sliced(): ("float64", -12345.5), ], ) -def test_band_nodata(dtype, nodata): +def test_band_nodata(pyarrow25, dtype, nodata): r = Raster.from_numpy(np.zeros((2, 3), dtype=dtype), nodata=nodata) assert r.bands[0].nodata == nodata -def test_band_nodata_unset(): +def test_band_nodata_unset(pyarrow25): r = Raster.from_numpy(np.zeros((2, 3), dtype="uint8")) assert r.bands[0].nodata is None -def test_raster_to_numpy_single_band_is_zero_copy(): +def test_raster_to_numpy_single_band_is_zero_copy(pyarrow25): data = np.arange(6, dtype="float32").reshape(2, 3) r = Raster.from_numpy(data) out = r.to_numpy() diff --git a/python/sedonadb/tests/test_udf.py b/python/sedonadb/tests/test_udf.py index 53e5645d82..42ff97a30b 100644 --- a/python/sedonadb/tests/test_udf.py +++ b/python/sedonadb/tests/test_udf.py @@ -450,7 +450,7 @@ def points(arg): assert result.to_pylist() == [1.0, None, 3.0] -def test_arrow_udf_raster_list_output(con): +def test_arrow_udf_raster_list_output(pyarrow25, con): import numpy as np from sedonadb.raster import Raster @@ -574,7 +574,7 @@ def test_native_scalar_udf_register_appends_overload(con): ) -def test_raster_udf_over_loaded_raster_as_pixel_argument(con, tmp_path): +def test_raster_udf_over_loaded_raster_as_pixel_argument(pyarrow25, con, tmp_path): """A Python raster UDF over RS_EnsureLoaded(..), passed as the raster of functions that read pixels. The planner wraps the UDF's output in another RS_EnsureLoaded, which passes the already loaded pixels through. Two calls From 023f92b90e667ef449d724bec70ee406001b4de6 Mon Sep 17 00:00:00 2001 From: Dewey Dunnington Date: Thu, 29 Oct 2026 21:51:59 -0500 Subject: [PATCH 3/4] build(python): require Python 3.10 and PyArrow 25 --- .github/workflows/python-wheels.yml | 10 +++---- .github/workflows/wheels-zarr.yml | 2 +- ci/scripts/wheels-build-linux.sh | 2 +- ci/scripts/wheels-build-macos.sh | 2 +- ci/scripts/wheels-build-windows.ps1 | 2 +- docs/postgis.ipynb | 2 +- docs/postgis.md | 2 +- python/sedonadb-zarr/pyproject.toml | 3 +- python/sedonadb/README.md | 2 ++ python/sedonadb/pyproject.toml | 9 ++++-- python/sedonadb/python/sedonadb/raster.py | 7 ----- python/sedonadb/src/raster_loader.rs | 2 +- python/sedonadb/tests/conftest.py | 6 ---- python/sedonadb/tests/expr/test_literal.py | 4 +-- .../tests/functions/test_raster_functions.py | 6 ++-- .../tests/functions/test_raster_nd.py | 4 +-- .../sedonadb/tests/functions/test_rs_clip.py | 5 +--- python/sedonadb/tests/test_raster.py | 29 +++++++------------ python/sedonadb/tests/test_udf.py | 4 +-- 19 files changed, 43 insertions(+), 60 deletions(-) diff --git a/.github/workflows/python-wheels.yml b/.github/workflows/python-wheels.yml index 5e210fbc91..f3d29e373e 100644 --- a/.github/workflows/python-wheels.yml +++ b/.github/workflows/python-wheels.yml @@ -93,7 +93,7 @@ jobs: VCPKG_DEFAULT_TRIPLET: x64-windows-dynamic-release VCPKG_DEFAULT_BINARY_CACHE: ${{ github.workspace }}/vcpkg-bincache CMAKE_TOOLCHAIN_FILE: ${{ github.workspace }}/vcpkg/scripts/buildsystems/vcpkg.cmake - CIBW_BUILD: "cp39-win_amd64" + CIBW_BUILD: "cp310-win_amd64" # Test command exits 0 even on failure so CIBW continues building all wheels; # failures are recorded in .test_failed and checked after upload. # We use Python here to be absolutely sure there are no shell escaping issues. @@ -112,7 +112,7 @@ jobs: - uses: actions/upload-artifact@v7 with: name: release-wheels-windows-x86_64 - path: python/sedonadb/dist/*-cp39-abi3-*.whl + path: python/sedonadb/dist/*-cp310-abi3-*.whl if-no-files-found: error - name: Fail if tests failed @@ -178,7 +178,7 @@ jobs: - uses: actions/upload-artifact@v7 with: name: release-wheels-macOS-arm64 - path: python/sedonadb/dist/*-cp39-abi3-*.whl + path: python/sedonadb/dist/*-cp310-abi3-*.whl if-no-files-found: error - name: Fail if tests failed @@ -245,7 +245,7 @@ jobs: - uses: actions/upload-artifact@v7 with: name: release-wheels-macOS-amd64 - path: python/sedonadb/dist/*-cp39-abi3-*.whl + path: python/sedonadb/dist/*-cp310-abi3-*.whl if-no-files-found: error - name: Fail if tests failed @@ -311,7 +311,7 @@ jobs: - uses: actions/upload-artifact@v7 with: name: release-wheels-${{ matrix.config.label }} - path: python/sedonadb/dist/*-cp39-abi3-*.whl + path: python/sedonadb/dist/*-cp310-abi3-*.whl if-no-files-found: error - name: Fail if tests failed diff --git a/.github/workflows/wheels-zarr.yml b/.github/workflows/wheels-zarr.yml index 483d13fc98..cdd02f3621 100644 --- a/.github/workflows/wheels-zarr.yml +++ b/.github/workflows/wheels-zarr.yml @@ -58,7 +58,7 @@ jobs: cd ci/scripts .\wheels-build-zarr-windows.ps1 env: - CIBW_BUILD: "cp39-win_amd64" + CIBW_BUILD: "cp310-win_amd64" # Install the sedonadb dev wheel downloaded into the package dir # above: first sedonadb itself (--no-deps --pre, since the dev # version is a prerelease), then its dependencies without --pre so diff --git a/ci/scripts/wheels-build-linux.sh b/ci/scripts/wheels-build-linux.sh index 8bda4e9d27..3cc7162201 100755 --- a/ci/scripts/wheels-build-linux.sh +++ b/ci/scripts/wheels-build-linux.sh @@ -53,7 +53,7 @@ BEFORE_ALL_MANYLINUX="yum install -y curl zip unzip tar clang perl" # add quite a bit of complexity but could save time if we build wheels for linux frequently. # The native and Rust builds are cached on each image such that compile work is effectively # cached between Python versions (just not between invocations of this script). -export CIBW_ENVIRONMENT_LINUX="VCPKG_ROOT=/vcpkg VCPKG_REF=$VCPKG_REF VCPKG_DEFAULT_TRIPLET=$VCPKG_DEFAULT_TRIPLET CMAKE_TOOLCHAIN_FILE=/vcpkg/scripts/buildsystems/vcpkg.cmake PKG_CONFIG_PATH=/vcpkg/installed/$VCPKG_DEFAULT_TRIPLET/lib/pkgconfig LD_LIBRARY_PATH=/vcpkg/installed/$VCPKG_DEFAULT_TRIPLET/lib $SEDONADB_RELEASE_PROFILE_ENV MATURIN_PEP517_ARGS='--features s2geography,pyo3/extension-module,pyo3/abi3-py39'" +export CIBW_ENVIRONMENT_LINUX="VCPKG_ROOT=/vcpkg VCPKG_REF=$VCPKG_REF VCPKG_DEFAULT_TRIPLET=$VCPKG_DEFAULT_TRIPLET CMAKE_TOOLCHAIN_FILE=/vcpkg/scripts/buildsystems/vcpkg.cmake PKG_CONFIG_PATH=/vcpkg/installed/$VCPKG_DEFAULT_TRIPLET/lib/pkgconfig LD_LIBRARY_PATH=/vcpkg/installed/$VCPKG_DEFAULT_TRIPLET/lib $SEDONADB_RELEASE_PROFILE_ENV MATURIN_PEP517_ARGS='--features s2geography,pyo3/extension-module,pyo3/abi3-py310'" # Host directories to bind-mount into the build container. cibuildwheel copies # the project into the container rather than bind-mounting it, so anything the diff --git a/ci/scripts/wheels-build-macos.sh b/ci/scripts/wheels-build-macos.sh index bd73e8c4a7..56a7bb0a91 100755 --- a/ci/scripts/wheels-build-macos.sh +++ b/ci/scripts/wheels-build-macos.sh @@ -47,7 +47,7 @@ source ./wheels-bootstrap-vcpkg.sh export CIBW_REPAIR_WHEEL_COMMAND_MACOS="DYLD_LIBRARY_PATH=$VCPKG_INSTALL_NAME_DIR delocate-listdeps {wheel} && DYLD_LIBRARY_PATH=$VCPKG_INSTALL_NAME_DIR delocate-wheel --require-archs {delocate_archs} -w {dest_dir} {wheel}" # Pass on environment variables specifically for the build -export CIBW_ENVIRONMENT_MACOS="$CIBW_ENVIRONMENT_MACOS _PYTHON_HOST_PLATFORM=macosx-12.0-${SEDONADB_MACOS_ARCH} MACOSX_DEPLOYMENT_TARGET=12.0 CMAKE_TOOLCHAIN_FILE=${CMAKE_TOOLCHAIN_FILE} MATURIN_PEP517_ARGS='--features s2geography,pyo3/extension-module,pyo3/abi3-py39'" +export CIBW_ENVIRONMENT_MACOS="$CIBW_ENVIRONMENT_MACOS _PYTHON_HOST_PLATFORM=macosx-12.0-${SEDONADB_MACOS_ARCH} MACOSX_DEPLOYMENT_TARGET=12.0 CMAKE_TOOLCHAIN_FILE=${CMAKE_TOOLCHAIN_FILE} MATURIN_PEP517_ARGS='--features s2geography,pyo3/extension-module,pyo3/abi3-py310'" pushd "${SEDONADB_DIR}" python -m cibuildwheel --output-dir python/$1/dist python/$1 diff --git a/ci/scripts/wheels-build-windows.ps1 b/ci/scripts/wheels-build-windows.ps1 index bee971998c..0d238db6a6 100644 --- a/ci/scripts/wheels-build-windows.ps1 +++ b/ci/scripts/wheels-build-windows.ps1 @@ -73,7 +73,7 @@ $env:PATH += ";$scriptDirectory\windows" $env:GEOS_LIB_DIR = "$vcpkgLibDirectory" $env:GEOS_VERSION = "3.13.0" -$env:MATURIN_PEP517_ARGS='--features s2geography,pyo3/extension-module,pyo3/abi3-py39' +$env:MATURIN_PEP517_ARGS='--features s2geography,pyo3/extension-module,pyo3/abi3-py310' # Some CMake configurations needs this separately from the toolchain file $env:CMAKE_PREFIX_PATH="$vcpkgInstalledDirectory" diff --git a/docs/postgis.ipynb b/docs/postgis.ipynb index db46117dd9..d7f2882c9e 100644 --- a/docs/postgis.ipynb +++ b/docs/postgis.ipynb @@ -50,7 +50,7 @@ "This notebook assumes:\n", "\n", "- A running PostgreSQL instance with PostGIS enabled\n", - "- Python 3.9+\n", + "- Python 3.10+\n", "- The following Python packages available:\n", " - `geopandas`\n", " - `sqlalchemy`\n", diff --git a/docs/postgis.md b/docs/postgis.md index e60ce60f19..d6c808a338 100644 --- a/docs/postgis.md +++ b/docs/postgis.md @@ -33,7 +33,7 @@ Two approaches are covered: This notebook assumes: - A running PostgreSQL instance with PostGIS enabled -- Python 3.9+ +- Python 3.10+ - The following Python packages available: - `geopandas` - `sqlalchemy` diff --git a/python/sedonadb-zarr/pyproject.toml b/python/sedonadb-zarr/pyproject.toml index 2def92ec29..2d38c241fb 100644 --- a/python/sedonadb-zarr/pyproject.toml +++ b/python/sedonadb-zarr/pyproject.toml @@ -48,7 +48,8 @@ test = [ [tool.cibuildwheel] # PyO3's stable ABI support makes this one wheel per platform compatible with # every supported non-free-threaded CPython version. -build = "cp39-*" +# Test against the current sedonadb wheel, which requires Python 3.10. +build = "cp310-*" [tool.maturin] features = ["pyo3/extension-module", "pyo3/abi3-py39"] diff --git a/python/sedonadb/README.md b/python/sedonadb/README.md index a7ae99e152..fa24cc943a 100644 --- a/python/sedonadb/README.md +++ b/python/sedonadb/README.md @@ -23,6 +23,8 @@ This package is a Python interface to [SedonaDB](https://sedona.apache.org/sedon ## Installation +Requires Python 3.10 or later and PyArrow 25 or later. + SedonaDB for Python can be installed from [PyPI](https://pypi.org): ```shell diff --git a/python/sedonadb/pyproject.toml b/python/sedonadb/pyproject.toml index ce10679a1c..69fd5382bb 100644 --- a/python/sedonadb/pyproject.toml +++ b/python/sedonadb/pyproject.toml @@ -22,12 +22,15 @@ build-backend = "maturin" [project] name = "sedonadb" readme = "README.md" -requires-python = ">=3.9" +requires-python = ">=3.10" classifiers = [ "Programming Language :: Rust", "Programming Language :: Python :: Implementation :: CPython", ] dynamic = ["version"] +# Earlier PyArrow releases can segfault when exporting inline binary views. +# https://github.com/apache/arrow/pull/50166 +dependencies = ["pyarrow>=25.0.0"] [project.optional-dependencies] test = [ @@ -57,13 +60,13 @@ geopandas = [ [tool.cibuildwheel] # PyO3's stable ABI support makes this one wheel per platform compatible with # every supported CPython version, rather than producing one wheel per version. -build = "cp39-*" +build = "cp310-*" # Set here rather than via CIBW_TEST_REQUIRES in the workflow: environment # variables take precedence over the overrides below. test-requires = ["pytest", "adbc_driver_manager", "geoarrow-pyarrow", "geopandas", "duckdb"] [tool.maturin] -features = ["pyo3/extension-module", "pyo3/abi3-py39"] +features = ["pyo3/extension-module", "pyo3/abi3-py310"] module-name = "sedonadb._lib" python-source = "python" strip = true diff --git a/python/sedonadb/python/sedonadb/raster.py b/python/sedonadb/python/sedonadb/raster.py index 4951c7190f..713e9fc36e 100644 --- a/python/sedonadb/python/sedonadb/raster.py +++ b/python/sedonadb/python/sedonadb/raster.py @@ -106,8 +106,6 @@ def from_numpy( The mirror of `lazy`: identical dimension/CRS conventions, but the pixel data is carried in the raster rather than referenced by URI. - Requires PyArrow >= 25.0.0. - Contiguous arrays with more than 12 bytes share their pixel buffer. Smaller arrays are copied into Arrow's inline binary view storage. @@ -729,11 +727,6 @@ def _wrap_data_zero_copy(data) -> pa.BinaryViewArray: elif nbytes >= 2**31: raise ValueError(f"Can't store {nbytes} (>2GB) in a single raster band") - # Earlier Arrow releases can segfault when exporting inline binary views: - # https://github.com/apache/arrow/pull/50166 - if int(pa.__version__.split(".")[0]) < 25: - raise ImportError("Raster.from_numpy() requires PyArrow >= 25.0.0") - # Build BinaryArray zero-copy using from_buffers, then cast to binary_view # BinaryArray buffers: [validity, offsets, data] offsets = pa.py_buffer(np.array([0, nbytes], dtype=np.int32)) diff --git a/python/sedonadb/src/raster_loader.rs b/python/sedonadb/src/raster_loader.rs index cff283c0fc..cf51cdd134 100644 --- a/python/sedonadb/src/raster_loader.rs +++ b/python/sedonadb/src/raster_loader.rs @@ -134,7 +134,7 @@ impl AsyncRasterLoader for PyRasterLoader { // The buffer protocol did not join Python's Limited API // until 3.11. Convert through the built-in bytes type so - // this extension can use abi3 while retaining Python 3.9 + // this extension can use abi3 while retaining Python 3.10 // support. `Bytes` takes ownership of the resulting Vec, // so this performs one copy at the Python/Rust boundary. let py_bytes = py diff --git a/python/sedonadb/tests/conftest.py b/python/sedonadb/tests/conftest.py index a46d692b59..9b0cc2b833 100644 --- a/python/sedonadb/tests/conftest.py +++ b/python/sedonadb/tests/conftest.py @@ -38,9 +38,3 @@ def geoarrow_data(): @pytest.fixture() def sedona_testing(): return HERE.parent.parent.parent / "submodules" / "sedona-testing" - - -@pytest.fixture() -def pyarrow25(): - """Require the Arrow export fix needed by Raster.from_numpy().""" - return pytest.importorskip("pyarrow", minversion="25.0.0") diff --git a/python/sedonadb/tests/expr/test_literal.py b/python/sedonadb/tests/expr/test_literal.py index b6fe106fd0..6d42887396 100644 --- a/python/sedonadb/tests/expr/test_literal.py +++ b/python/sedonadb/tests/expr/test_literal.py @@ -233,7 +233,7 @@ def test_geometry_array_rejects_metadata_change(source_type): @pytest.mark.parametrize("kind", ["empty", "null", "mixed"]) -def test_raster_array(pyarrow25, kind): +def test_raster_array(kind): from sedonadb._lib import raster_type from sedonadb.raster import Raster @@ -260,7 +260,7 @@ def test_raster_array_rejects_invalid_element(): _resolve_arrow_array([None, 1], raster_type()) -def test_raster_array_same_type(pyarrow25): +def test_raster_array_same_type(): from sedonadb._lib import raster_type from sedonadb.raster import Raster diff --git a/python/sedonadb/tests/functions/test_raster_functions.py b/python/sedonadb/tests/functions/test_raster_functions.py index f3b16e0a4a..cdc46110d1 100644 --- a/python/sedonadb/tests/functions/test_raster_functions.py +++ b/python/sedonadb/tests/functions/test_raster_functions.py @@ -230,7 +230,7 @@ def test_rs_worldtorastercoord_empty_point_is_null(): ) -def test_rs_value_matches_rasterio(pyarrow25, con): +def test_rs_value_matches_rasterio(con): """Cross-check RS_Value against rasterio on a random raster. Builds an in-memory raster from a random numpy array with a known @@ -527,7 +527,7 @@ def test_rs_values_ensureloaded_outdb(con, sedona_testing): con.drop_view(view) -def test_rs_values_matches_rasterio(pyarrow25, con): +def test_rs_values_matches_rasterio(con): """Cross-check RS_Values against rasterio on a random raster. The plural counterpart of `test_rs_value_matches_rasterio`: the same dense @@ -643,7 +643,7 @@ def test_rs_asgeotiff_out_of_range_quality_errors(con): # floating-point prediction for float bands). @pytest.mark.parametrize("dtype", ["uint8", "uint16", "int32", "float32", "float64"]) @pytest.mark.parametrize("compression_args", ["", ", 'DEFLATE', 0.85", ", 'LZW', 0.85"]) -def test_rs_asgeotiff_roundtrips_contents(pyarrow25, con, dtype, compression_args): +def test_rs_asgeotiff_roundtrips_contents(con, dtype, compression_args): pytest.importorskip("rasterio") from rasterio.io import MemoryFile diff --git a/python/sedonadb/tests/functions/test_raster_nd.py b/python/sedonadb/tests/functions/test_raster_nd.py index cfb2c520d3..4216e0c91b 100644 --- a/python/sedonadb/tests/functions/test_raster_nd.py +++ b/python/sedonadb/tests/functions/test_raster_nd.py @@ -56,13 +56,13 @@ def _eval(expr, *rasters): @pytest.fixture -def r2d(pyarrow25): +def r2d(): """2-D raster ``[y=4, x=5]`` with sequential pixel values 0..19.""" return Raster.from_numpy(np.arange(4 * 5, dtype="uint8").reshape(4, 5)) @pytest.fixture -def r3d(pyarrow25): +def r3d(): """3-D raster ``[time=3, y=4, x=5]`` with sequential pixel values 0..59.""" return Raster.from_numpy( np.arange(3 * 4 * 5, dtype="uint8").reshape(3, 4, 5), diff --git a/python/sedonadb/tests/functions/test_rs_clip.py b/python/sedonadb/tests/functions/test_rs_clip.py index b4d13fc8eb..684990e0ca 100644 --- a/python/sedonadb/tests/functions/test_rs_clip.py +++ b/python/sedonadb/tests/functions/test_rs_clip.py @@ -170,10 +170,7 @@ def _rasterio_clip( if band < 0 or band > src.count: raise ValueError(f"band {band} out of range for a {src.count}-band raster") if crop: - # RS_Clip crops to the world-space envelope. Rasterio <1.5 uses - # the geometry's vertices directly, which gives a tighter window - # on rotated grids; pass the envelope explicitly on all versions. - window = rasterio.features.geometry_window(src, [geom.envelope]) + window = rasterio.features.geometry_window(src, [geom]) else: window = rasterio.windows.Window(0, 0, src.width, src.height) transform = src.window_transform(window) diff --git a/python/sedonadb/tests/test_raster.py b/python/sedonadb/tests/test_raster.py index 20f896bbc5..5f2a92f7ae 100644 --- a/python/sedonadb/tests/test_raster.py +++ b/python/sedonadb/tests/test_raster.py @@ -108,7 +108,7 @@ def test_iter_rasters_handles_chunks_and_nulls(con): assert sum(raster is None for raster in rasters) == 1 -def test_iter_rasters_handles_sliced_array_offsets(pyarrow25): +def test_iter_rasters_handles_sliced_array_offsets(): first = pa.array(Raster.from_numpy(np.full((4, 4), 11, dtype=np.uint16))) second = pa.array(Raster.from_numpy(np.full((4, 4), 22, dtype=np.uint16))) array = pa.concat_arrays([first, second]).slice(1, 1) @@ -119,7 +119,7 @@ def test_iter_rasters_handles_sliced_array_offsets(pyarrow25): @pytest.mark.parametrize("parent_null", [False, True]) @pytest.mark.parametrize("start", [0, 1, 2]) -def test_iter_rasters_null_bands_with_nonempty_offsets(pyarrow25, parent_null, start): +def test_iter_rasters_null_bands_with_nonempty_offsets(parent_null, start): storage = pa.concat_arrays( [ Raster.from_numpy(np.full((4, 4), value, dtype=np.uint16))._array @@ -160,7 +160,7 @@ def test_iter_rasters_null_bands_with_nonempty_offsets(pyarrow25, parent_null, s @pytest.mark.parametrize("start, length", [(0, 10), (3, 6), (3, 1), (4, 0)]) -def test_iter_rasters_sliced_validity(pyarrow25, start, length): +def test_iter_rasters_sliced_validity(start, length): raster = Raster.from_numpy(np.ones((4, 4), dtype=np.uint16)) storage = pa.concat_arrays([raster._array] * 10) nulls = [True, False, False, True, False, False, False, True, False, True] @@ -366,7 +366,7 @@ def test_raster_lazy_nd(): assert r.height == 256 -def test_raster_from_numpy_2d(pyarrow25): +def test_raster_from_numpy_2d(): # Use array >12 bytes so BinaryView uses out-of-line storage (zero-copy eligible) arr = np.arange(4 * 5, dtype="uint8").reshape(4, 5) r = Raster.from_numpy(arr) @@ -382,16 +382,9 @@ def test_raster_from_numpy_2d(pyarrow25): assert np.shares_memory(b.to_numpy(), arr) -@pytest.mark.parametrize("version", ["21.0.0", "22.0.0", "24.0.0"]) -def test_raster_from_numpy_requires_pyarrow_25(monkeypatch, version): - monkeypatch.setattr(pa, "__version__", version) - with pytest.raises(ImportError, match=r"requires PyArrow >= 25\.0\.0"): - Raster.from_numpy(np.arange(6, dtype=np.uint8).reshape(2, 3)) - - @pytest.mark.parametrize("nbytes", [0, 1, 6, 12, 13, 1024]) @pytest.mark.parametrize("strided", [False, True]) -def test_raster_from_numpy_arrow_roundtrip(pyarrow25, nbytes, strided): +def test_raster_from_numpy_arrow_roundtrip(nbytes, strided): source = np.arange(nbytes * (2 if strided else 1), dtype=np.uint8) pixels = (source[::2] if strided else source).reshape(1, nbytes) raster = Raster.from_numpy(pixels) @@ -407,7 +400,7 @@ def test_raster_from_numpy_arrow_roundtrip(pyarrow25, nbytes, strided): assert np.shares_memory(restored, pixels) == (not strided) -def test_raster_from_numpy_nd_with_crs(pyarrow25): +def test_raster_from_numpy_nd_with_crs(): arr = np.arange(2 * 2 * 3, dtype="float32").reshape(2, 2, 3) r = Raster.from_numpy(arr, dim_names=["time", "y", "x"], crs="EPSG:4326") @@ -426,7 +419,7 @@ def test_raster_from_numpy_invalid_shape(): Raster.from_numpy(np.zeros((2, 2, 3), dtype="uint8")) -def test_raster_from_numpy_bbox_matches_rasterio(pyarrow25): +def test_raster_from_numpy_bbox_matches_rasterio(): # bbox (pixel/default) must produce rasterio.transform.from_bounds, in GDAL order. rasterio = pytest.importorskip("rasterio") @@ -452,7 +445,7 @@ def test_raster_from_numpy_bbox_and_transform_mutually_exclusive(): ) -def test_raster_from_numpy_bbox_registration_node(pyarrow25): +def test_raster_from_numpy_bbox_registration_node(): # A node-registered grid maps the bbox to cell centers, so its transform # differs from the default pixel registration (exact values are pinned by the # Rust unit test); this is the Python end-to-end smoke. @@ -583,17 +576,17 @@ def test_get_binary_view_buffer_sliced(): ("float64", -12345.5), ], ) -def test_band_nodata(pyarrow25, dtype, nodata): +def test_band_nodata(dtype, nodata): r = Raster.from_numpy(np.zeros((2, 3), dtype=dtype), nodata=nodata) assert r.bands[0].nodata == nodata -def test_band_nodata_unset(pyarrow25): +def test_band_nodata_unset(): r = Raster.from_numpy(np.zeros((2, 3), dtype="uint8")) assert r.bands[0].nodata is None -def test_raster_to_numpy_single_band_is_zero_copy(pyarrow25): +def test_raster_to_numpy_single_band_is_zero_copy(): data = np.arange(6, dtype="float32").reshape(2, 3) r = Raster.from_numpy(data) out = r.to_numpy() diff --git a/python/sedonadb/tests/test_udf.py b/python/sedonadb/tests/test_udf.py index 42ff97a30b..53e5645d82 100644 --- a/python/sedonadb/tests/test_udf.py +++ b/python/sedonadb/tests/test_udf.py @@ -450,7 +450,7 @@ def points(arg): assert result.to_pylist() == [1.0, None, 3.0] -def test_arrow_udf_raster_list_output(pyarrow25, con): +def test_arrow_udf_raster_list_output(con): import numpy as np from sedonadb.raster import Raster @@ -574,7 +574,7 @@ def test_native_scalar_udf_register_appends_overload(con): ) -def test_raster_udf_over_loaded_raster_as_pixel_argument(pyarrow25, con, tmp_path): +def test_raster_udf_over_loaded_raster_as_pixel_argument(con, tmp_path): """A Python raster UDF over RS_EnsureLoaded(..), passed as the raster of functions that read pixels. The planner wraps the UDF's output in another RS_EnsureLoaded, which passes the already loaded pixels through. Two calls From 1123833c1e2a04ee086730258562a8e9b147c08d Mon Sep 17 00:00:00 2001 From: Dewey Dunnington Date: Thu, 29 Oct 2026 22:09:02 -0500 Subject: [PATCH 4/4] build(python): keep PyArrow optional and bound test dependencies --- python/sedonadb/README.md | 2 -- python/sedonadb/pyproject.toml | 7 +++---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/python/sedonadb/README.md b/python/sedonadb/README.md index fa24cc943a..a7ae99e152 100644 --- a/python/sedonadb/README.md +++ b/python/sedonadb/README.md @@ -23,8 +23,6 @@ This package is a Python interface to [SedonaDB](https://sedona.apache.org/sedon ## Installation -Requires Python 3.10 or later and PyArrow 25 or later. - SedonaDB for Python can be installed from [PyPI](https://pypi.org): ```shell diff --git a/python/sedonadb/pyproject.toml b/python/sedonadb/pyproject.toml index 69fd5382bb..1014b99c12 100644 --- a/python/sedonadb/pyproject.toml +++ b/python/sedonadb/pyproject.toml @@ -28,9 +28,6 @@ classifiers = [ "Programming Language :: Python :: Implementation :: CPython", ] dynamic = ["version"] -# Earlier PyArrow releases can segfault when exporting inline binary views. -# https://github.com/apache/arrow/pull/50166 -dependencies = ["pyarrow>=25.0.0"] [project.optional-dependencies] test = [ @@ -45,6 +42,8 @@ test = [ "geopandas", "pandas", "polars", + # Raster export tests need the inline binary view fix in Arrow 25. + "pyarrow>=25.0.0", "pytest", "pyyaml", "rasterio", @@ -63,7 +62,7 @@ geopandas = [ build = "cp310-*" # Set here rather than via CIBW_TEST_REQUIRES in the workflow: environment # variables take precedence over the overrides below. -test-requires = ["pytest", "adbc_driver_manager", "geoarrow-pyarrow", "geopandas", "duckdb"] +test-requires = ["pyarrow>=25.0.0", "pytest", "adbc_driver_manager", "geoarrow-pyarrow", "geopandas", "duckdb"] [tool.maturin] features = ["pyo3/extension-module", "pyo3/abi3-py310"]