Skip to content

Commit 5bb969f

Browse files
authored
Merge pull request #6 from e-sensing/arrow
backend: enhance arrow compatibility libs
2 parents 97590e8 + 968ced5 commit 5bb969f

7 files changed

Lines changed: 438 additions & 24 deletions

File tree

.github/workflows/tests.yaml

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
name: Tests
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
reason:
7+
required: false
8+
description: 'Reason'
9+
default: 'Manual trigger'
10+
11+
jobs:
12+
test:
13+
name: Python ${{ matrix.python-version }}
14+
runs-on: ubuntu-latest
15+
16+
strategy:
17+
# Report every version, so a single break does not hide the others.
18+
fail-fast: false
19+
matrix:
20+
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
21+
22+
env:
23+
# Fail the build on R warnings that would otherwise pass unnoticed
24+
R_KEEP_PKG_SOURCE: yes
25+
26+
steps:
27+
- name: Checkout code
28+
uses: actions/checkout@v4
29+
30+
- name: Set up R
31+
uses: r-lib/actions/setup-r@v2
32+
with:
33+
use-public-rspm: true
34+
35+
- name: Install R dependencies
36+
uses: r-lib/actions/setup-r-dependencies@v2
37+
with:
38+
# `arrow` and `kohonen` are required by pysits but are not part of a
39+
# default `install.packages("sits")`, so they are listed explicitly.
40+
packages: |
41+
any::sits
42+
any::arrow
43+
any::kohonen
44+
any::jsonlite
45+
46+
- name: Set up Python ${{ matrix.python-version }}
47+
uses: actions/setup-python@v5
48+
with:
49+
python-version: ${{ matrix.python-version }}
50+
51+
- name: Create a clean virtual environment
52+
# Mirrors how users install pysits, so a broken dependency resolution
53+
# is caught here rather than by them.
54+
run: python -m venv .venv
55+
56+
- name: Install pysits
57+
# `--only-binary` makes pip fail instead of falling back to a source
58+
# build when no wheel matches this interpreter. Those builds have
59+
# shipped binaries that segfault. Only pysits itself is built here.
60+
run: |
61+
.venv/bin/python -m pip install --upgrade pip
62+
.venv/bin/python -m pip install \
63+
--only-binary=:all: --no-binary pysits \
64+
".[dev]"
65+
66+
- name: Report resolved dependency versions
67+
run: .venv/bin/python -m pip list
68+
69+
- name: Verify Arrow interoperability between R and Python
70+
# pyarrow and R arrow package each bundle libarrow. If they stop
71+
# sharing an allocator, transfers are silently corrupted rather than
72+
# failing, so this asserts a known value survives a round-trip.
73+
run: |
74+
.venv/bin/python - <<'EOF'
75+
import pyarrow as pa
76+
77+
import pysits
78+
from pysits.conversions.tibble import (
79+
pandas_sits_to_tibble,
80+
tibble_sits_to_pandas,
81+
)
82+
83+
probe = [0.5, 1.5, 2.5]
84+
assert pa.array(probe).to_pylist() == probe, "pyarrow returns corrupted data"
85+
86+
samples = pysits.samples_modis_ndvi
87+
expected = samples["time_series"][0]["NDVI"].tolist()
88+
actual = tibble_sits_to_pandas(pandas_sits_to_tibble(samples.head(1)))
89+
actual = actual["time_series"][0]["NDVI"].tolist()
90+
91+
assert actual == expected, f"round-trip corrupted data: {actual} != {expected}"
92+
print("Arrow round-trip is lossless.")
93+
EOF
94+
95+
- name: Run tests
96+
run: .venv/bin/python -m pytest

pyproject.toml

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,13 @@ classifiers = [
5858
]
5959

6060
dependencies = [
61-
"rpy2>=3.5.17,<4.0.0",
61+
# 3.6.0 is the first release distributing wheels. Earlier ones are
62+
# source-only, so pip would compile rpy2 against the local R installation
63+
"rpy2>=3.6.0,<4.0.0",
6264
"pandas>=2.2.3",
65+
# pandas ships no wheels for Python 3.14 before 2.3.3. Without this floor
66+
# pip builds pandas from source there, and the result segfaults.
67+
"pandas>=2.3.3; python_version>='3.14'",
6368
"pillow>=11.1.0",
6469
"matplotlib>=3.10.1",
6570
"geopandas>=1.1.0",
@@ -90,6 +95,9 @@ dev = [
9095
"pytest-cov>=6.1.1",
9196
"pytest-randomly>=3.16.0",
9297
"cloudpickle>=3.1.1",
98+
# The test suite covers the xarray exporters and reads rasters directly
99+
"pysits[xarray]",
100+
"rasterio>=1.3.0",
93101
]
94102

95103
[tool.ruff]

pysits/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@
1717

1818
"""pysits module."""
1919

20+
# Importing `backend.arrow` first selects the libarrow allocator, which must
21+
# happen before anything imports `pyarrow` or loads the R `arrow` package.
22+
# Keep this import above the others. See `pysits.backend.arrow`.
23+
from .backend import arrow as _arrow # noqa: F401
2024
from .conversions.dsl.mask import MaskValue
2125
from .conversions.dsl.tuning import hparam
2226
from .settings import __version__

pysits/backend/arrow.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
#
2+
# Copyright (C) 2025 sits developers.
3+
#
4+
# This program is free software; you can redistribute it and/or modify it
5+
# under the terms of the GNU General Public License as published by
6+
# the Free Software Foundation; either version 2 of the License, or
7+
# (at your option) any later version.
8+
#
9+
# This program is distributed in the hope that it will be useful,
10+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
# GNU General Public License for more details.
13+
#
14+
# You should have received a copy of the GNU General Public License
15+
# along with this program; if not, see <https://www.gnu.org/licenses/>.
16+
#
17+
18+
"""Arrow interoperability between R and Python.
19+
20+
``pysits`` moves data between R and Python as Arrow IPC streams. That puts two
21+
independent builds of the libarrow C++ library in a single process: the one
22+
bundled in the R ``arrow`` package and the one bundled in ``pyarrow``. Their
23+
symbols collapse into a single namespace, so a call made through ``pyarrow``
24+
can be served by R's libarrow.
25+
26+
Each build keeps a private ``mimalloc`` heap. When the two are mixed, a buffer
27+
allocated by one library is read back as zeroed memory by the other, and every
28+
transfer is silently corrupted instead of failing. Both builds also support the
29+
system allocator, which is shared, so selecting it keeps them interoperable.
30+
31+
The allocator is chosen by libarrow the first time it initializes, so importing
32+
this module sets ``ARROW_DEFAULT_MEMORY_POOL``. It is imported before anything
33+
that pulls in ``pyarrow`` or the R ``arrow`` package.
34+
"""
35+
36+
import os
37+
38+
#
39+
# Allocator selected to keep R libarrow and pyarrow interoperable
40+
#
41+
ARROW_MEMORY_POOL_ENVVAR = "ARROW_DEFAULT_MEMORY_POOL"
42+
ARROW_MEMORY_POOL = "system"
43+
44+
# Set on import: libarrow reads this once, when it initializes. A value already
45+
# present in the environment is left alone so users can override it.
46+
os.environ.setdefault(ARROW_MEMORY_POOL_ENVVAR, ARROW_MEMORY_POOL)
47+
48+
#
49+
# Values transferred to R and back to verify the round-trip is lossless. A
50+
# mismatched pair of libarrow builds reads them back as zeros.
51+
#
52+
PROBE_COLUMN = "pysits_arrow_probe"
53+
PROBE_VALUES = [0.5, 1.5, 2.5]
54+
55+
56+
def check_arrow_memory_pool() -> None:
57+
"""Refuse to load R ``arrow`` package under an unshared allocator.
58+
59+
Called before the R ``arrow`` package is loaded. Corruption appears only
60+
once both libarrow builds are in use, and is silent when it does, so the
61+
configuration is rejected up front rather than probed for afterwards.
62+
63+
Raises:
64+
RuntimeError: If the selected allocator is not the shared one.
65+
"""
66+
selected = os.environ.get(ARROW_MEMORY_POOL_ENVVAR)
67+
68+
if selected == ARROW_MEMORY_POOL:
69+
return
70+
71+
raise RuntimeError(
72+
f"{ARROW_MEMORY_POOL_ENVVAR} is set to '{selected}', but pysits "
73+
f"requires '{ARROW_MEMORY_POOL}'.\n\n"
74+
"The R `arrow` package and `pyarrow` each bundle their own build of "
75+
"the libarrow C++ library. Loaded together, they must use the system "
76+
"allocator to share buffers. With any other allocator, data sent "
77+
"between R and Python is silently replaced by zeros.\n\n"
78+
f"Unset {ARROW_MEMORY_POOL_ENVVAR}, or set it before starting Python:\n\n"
79+
f" export {ARROW_MEMORY_POOL_ENVVAR}={ARROW_MEMORY_POOL}"
80+
)
81+
82+
83+
def arrow_interop_error(observed: list) -> RuntimeError:
84+
"""Build the error raised when an Arrow round-trip loses data.
85+
86+
Args:
87+
observed (list): Values read back from R for `PROBE_VALUES`.
88+
89+
Returns:
90+
RuntimeError: Error describing the cause and how to resolve it.
91+
"""
92+
import pyarrow as pa
93+
94+
pool = pa.default_memory_pool().backend_name
95+
96+
return RuntimeError(
97+
"Data sent to R is coming back corrupted, so pysits cannot run.\n\n"
98+
f"Sent {PROBE_VALUES}, received {observed}.\n\n"
99+
"The R `arrow` package and `pyarrow` each bundle their own build of "
100+
"the libarrow C++ library. Loaded together, they must use the system "
101+
f"allocator to share buffers, but pyarrow is using '{pool}'.\n\n"
102+
"This happens when pyarrow is initialized before pysits with a "
103+
"different allocator. Either import pysits before pyarrow, or set the "
104+
"environment variable before starting Python:\n\n"
105+
f" export {ARROW_MEMORY_POOL_ENVVAR}={ARROW_MEMORY_POOL}"
106+
)

pysits/backend/pkgs.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
"""backend packages."""
1919

20+
from pysits.backend.arrow import check_arrow_memory_pool
2021
from pysits.backend.loaders import load_package
2122
from pysits.settings import __sitsver__
2223

@@ -33,4 +34,9 @@
3334
r_pkg_kohonen = load_package("kohonen")
3435
r_pkg_sf = load_package("sf")
3536
r_pkg_htmlwidgets = load_package("htmlwidgets")
37+
38+
# `arrow` brings a second libarrow build into the process, so the allocator has
39+
# to be compatible before it is loaded. See `pysits.backend.arrow`.
40+
check_arrow_memory_pool()
41+
3642
r_pkg_arrow = load_package("arrow")

pysits/conversions/tibble.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
from rpy2.robjects.vectors import DataFrame as RDataFrame
3737
from shapely import wkt
3838

39+
from pysits.backend.arrow import PROBE_COLUMN, PROBE_VALUES, arrow_interop_error
3940
from pysits.backend.functions import r_fnc_class, r_fnc_set_column
4041
from pysits.backend.pkgs import r_pkg_base, r_pkg_sf, r_pkg_sits
4142
from pysits.models.frame import NestedFrame
@@ -90,6 +91,39 @@ def _sf_to_shapely(sf_object: RDataFrame) -> list:
9091
#
9192
# Arrow IPC helpers
9293
#
94+
def _check_arrow_interop() -> None:
95+
"""Verify that Arrow data is not corrupted once R's libarrow is loaded.
96+
97+
R libarrow only initializes on first use, so a probe is sent through R to
98+
force it, and ``pyarrow`` is then re-tested. A mismatched pair of libarrow
99+
builds corrupts the conversion of Python objects to Arrow arrays, returning
100+
zeros instead of raising, which would silently damage every nested column.
101+
102+
Raises:
103+
RuntimeError: If either transfer loses data.
104+
"""
105+
probe = PandasDataFrame({PROBE_COLUMN: PROBE_VALUES})
106+
107+
# Forces R libarrow to initialize, and checks the Python -> R direction
108+
r_probe = rpy2_globalenv["pysits_read_ipc_raw"](
109+
robjects.vectors.ByteVector(_dataframe_to_ipc_bytes(probe))
110+
)
111+
112+
# Load content from R
113+
observed = list(r_probe.rx2(PROBE_COLUMN))
114+
115+
# If it is different, then given an error
116+
if observed != PROBE_VALUES:
117+
raise arrow_interop_error(observed)
118+
119+
# Both libarrow builds are live now: check the conversion of Python objects
120+
# to Arrow arrays, which is the path nested columns depend on.
121+
observed = pa.array(PROBE_VALUES).to_pylist()
122+
123+
if observed != PROBE_VALUES:
124+
raise arrow_interop_error(observed)
125+
126+
93127
def _ensure_r_ipc_functions():
94128
"""Define R-side IPC reader/writer/unnester functions once."""
95129
if "pysits_write_ipc_raw" in rpy2_globalenv:
@@ -162,6 +196,8 @@ def _ensure_r_ipc_functions():
162196
}
163197
""")
164198

199+
_check_arrow_interop()
200+
165201

166202
def _dataframe_to_ipc_bytes(df: PandasDataFrame) -> bytes:
167203
"""Serialize a pandas DataFrame to Arrow IPC bytes.

0 commit comments

Comments
 (0)