Skip to content

Commit f01aa08

Browse files
committed
Accommodate Pandas 3 breaking changes
1 parent 2fad2e6 commit f01aa08

12 files changed

Lines changed: 272 additions & 119 deletions

File tree

matrix.toml

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# Matrix test configuration for testing pandas compatibility across Python versions
2+
# Run with: pymatrix --config matrix.toml
3+
#
4+
# Split into scenarios per package due to pytest conftest collision when running
5+
# multiple packages together (each has tests/conftest.py).
6+
7+
[[scenarios]]
8+
name = "datasets-pandas2"
9+
python = ["3.10", "3.11", "3.12", "3.13"]
10+
test-command = "pytest"
11+
test-args = ["tilebox-datasets/tests/", "-v"]
12+
13+
[scenarios.packages]
14+
pandas = ["2.2.3"]
15+
16+
[[scenarios]]
17+
name = "datasets-pandas3"
18+
python = ["3.11", "3.12", "3.13"] # pandas 3.0 requires Python 3.11+
19+
test-command = "pytest"
20+
test-args = ["tilebox-datasets/tests/", "-v"]
21+
22+
[scenarios.packages]
23+
pandas = ["3.0.0"]
24+
25+
[[scenarios]]
26+
name = "storage-pandas2"
27+
python = ["3.10", "3.11", "3.12", "3.13"]
28+
test-command = "pytest"
29+
test-args = ["tilebox-storage/tests/", "-v"]
30+
31+
[scenarios.packages]
32+
pandas = ["2.2.3"]
33+
34+
[[scenarios]]
35+
name = "storage-pandas3"
36+
python = ["3.11", "3.12", "3.13"] # pandas 3.0 requires Python 3.11+
37+
test-command = "pytest"
38+
test-args = ["tilebox-storage/tests/", "-v"]
39+
40+
[scenarios.packages]
41+
pandas = ["3.0.0"]
42+
43+
[[scenarios]]
44+
name = "grpc-pandas2"
45+
python = ["3.10", "3.11", "3.12", "3.13"]
46+
test-command = "pytest"
47+
test-args = ["tilebox-grpc/tests/", "-v"]
48+
49+
[scenarios.packages]
50+
pandas = ["2.2.3"]
51+
52+
[[scenarios]]
53+
name = "grpc-pandas3"
54+
python = ["3.11", "3.12", "3.13"] # pandas 3.0 requires Python 3.11+
55+
test-command = "pytest"
56+
test-args = ["tilebox-grpc/tests/", "-v"]
57+
58+
[scenarios.packages]
59+
pandas = ["3.0.0"]
60+
61+
[[scenarios]]
62+
name = "workflows-pandas2"
63+
python = ["3.10", "3.11", "3.12", "3.13"]
64+
test-command = "pytest"
65+
# Ignore FutureWarning: google-cloud-storage raises deprecation warning on Python 3.10
66+
test-args = ["tilebox-workflows/tests/", "-v", "-W", "ignore::FutureWarning"]
67+
68+
[scenarios.packages]
69+
pandas = ["2.2.3"]
70+
71+
[[scenarios]]
72+
name = "workflows-pandas3"
73+
python = ["3.11", "3.12", "3.13"] # pandas 3.0 requires Python 3.11+
74+
test-command = "pytest"
75+
test-args = ["tilebox-workflows/tests/", "-v"]
76+
77+
[scenarios.packages]
78+
pandas = ["3.0.0"]

pyproject.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@ dev = [
2828
"junitparser>=3.2.0",
2929
"ty>=0.0.11",
3030
"prek>=0.2.27",
31+
# testing
32+
"pytest>=8.3.2",
33+
"pytest-asyncio>=0.24.0",
34+
"pytest-cov>=5.0.0",
35+
"pytest-httpx>=0.30.0",
36+
"hypothesis>=6.112.1",
37+
"moto>=5",
3138
]
3239

3340
[project.scripts]

tilebox-datasets/pyproject.toml

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,7 @@ dependencies = [
3434
"promise>=2.3",
3535
]
3636

37-
[dependency-groups]
38-
dev = [
39-
"hypothesis>=6.112.1",
40-
"pytest-asyncio>=0.24.0",
41-
"pytest-cov>=5.0.0",
42-
"pytest>=8.3.2",
43-
]
37+
4438

4539

4640
[project.urls]

tilebox-datasets/tests/protobuf_conversion/test_protobuf_xarray.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from uuid import UUID
22

3+
import pandas as pd
34
import pytest
45
from hypothesis import given, settings
56
from hypothesis.strategies import lists
@@ -152,21 +153,21 @@ def test_convert_datapoints(datapoints: list[ExampleDatapoint]) -> None: # noqa
152153
for uuid in dataset.some_id.to_numpy():
153154
assert isinstance(uuid, str)
154155

155-
# strings should be stored as object arrays, with None as the fill value if missing
156+
# strings should be stored as object arrays, with missing values (None or NaN) as fill
156157
if "some_string" in dataset:
157158
for string in dataset.some_string.to_numpy():
158-
assert string is None or isinstance(string, str)
159+
assert pd.isna(string) or isinstance(string, str)
159160
if "some_repeated_string" in dataset:
160161
for string in dataset.some_repeated_string.to_numpy().ravel():
161-
assert string is None or isinstance(string, str)
162+
assert pd.isna(string) or isinstance(string, str)
162163

163-
# bytes should be stored as object arrays, with None as the fill value if missing
164+
# bytes should be stored as object arrays, with missing values (None or NaN) as fill
164165
if "some_bytes" in dataset:
165166
for bytes_ in dataset.some_bytes.to_numpy():
166-
assert bytes_ is None or isinstance(bytes_, bytes)
167+
assert pd.isna(bytes_) or isinstance(bytes_, bytes)
167168
if "some_repeated_bytes" in dataset:
168169
for bytes_ in dataset.some_repeated_bytes.to_numpy().ravel():
169-
assert bytes_ is None or isinstance(bytes_, bytes)
170+
assert pd.isna(bytes_) or isinstance(bytes_, bytes)
170171

171172

172173
@given(lists(example_datapoints(missing_fields=True), min_size=1, max_size=10))

tilebox-datasets/tilebox/datasets/protobuf_conversion/field_types.py

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from uuid import UUID
55

66
import numpy as np
7+
import pandas as pd
78
from google.protobuf.descriptor import FieldDescriptor
89
from google.protobuf.duration_pb2 import Duration
910
from google.protobuf.message import Message
@@ -17,6 +18,21 @@
1718
from tilebox.datasets.datasets.v1.well_known_types_pb2 import Geometry, LatLon, LatLonAlt, Quaternion, Vec3
1819

1920
ScalarProtoFieldValue = Message | float | str | bool | bytes
21+
22+
23+
def _is_missing(value: Any) -> bool:
24+
"""Check if a value represents a missing/null value.
25+
26+
Handles None, np.nan, pd.NA, NaT, and other pandas missing value sentinels.
27+
This is needed for pandas 3.0+ compatibility where object-dtype columns use
28+
np.nan instead of None for missing values.
29+
"""
30+
if value is None:
31+
return True
32+
try:
33+
return bool(pd.isna(value))
34+
except (TypeError, ValueError):
35+
return False
2036
ProtoFieldValue = ScalarProtoFieldValue | Sequence[ScalarProtoFieldValue] | None
2137

2238
_FILL_VALUES_BY_DTYPE: dict[type[np.dtype[Any]], Any] = {
@@ -107,7 +123,7 @@ def from_proto(self, value: ProtoFieldValue) -> int:
107123
return value.seconds * 10**9 + value.nanos
108124

109125
def to_proto(self, value: DatetimeScalar) -> Timestamp | None:
110-
if value is None or (isinstance(value, np.datetime64) and np.isnat(value)):
126+
if _is_missing(value) or (isinstance(value, np.datetime64) and np.isnat(value)):
111127
return None
112128
# we use pandas to_datetime function to handle a variety of input types that can be coerced to datetimes
113129
seconds, nanos = divmod(to_datetime(value, utc=True).value, 10**9)
@@ -124,7 +140,7 @@ def from_proto(self, value: ProtoFieldValue) -> int:
124140
return value.seconds * 10**9 + value.nanos
125141

126142
def to_proto(self, value: str | float | timedelta | np.timedelta64) -> Duration | None:
127-
if value is None or (isinstance(value, np.timedelta64) and np.isnat(value)):
143+
if _is_missing(value) or (isinstance(value, np.timedelta64) and np.isnat(value)):
128144
return None
129145
# we use pandas to_timedelta function to handle a variety of input types that can be coerced to timedeltas
130146
seconds, nanos = divmod(to_timedelta(value).value, 10**9) # type: ignore[arg-type]
@@ -141,7 +157,7 @@ def from_proto(self, value: ProtoFieldValue) -> str:
141157
return str(UUID(bytes=value.uuid))
142158

143159
def to_proto(self, value: str | UUID) -> UUIDMessage | None:
144-
if not value: # None or empty string
160+
if _is_missing(value) or value == "": # missing or empty string
145161
return None
146162

147163
if isinstance(value, str):
@@ -160,7 +176,7 @@ def from_proto(self, value: ProtoFieldValue) -> Any:
160176
return from_wkb(value.wkb)
161177

162178
def to_proto(self, value: Any) -> Geometry | None:
163-
if value is None:
179+
if _is_missing(value):
164180
return None
165181
return Geometry(wkb=value.wkb)
166182

@@ -175,7 +191,7 @@ def from_proto(self, value: ProtoFieldValue) -> tuple[float, float, float]:
175191
return value.x, value.y, value.z
176192

177193
def to_proto(self, value: tuple[float, float, float]) -> Vec3 | None:
178-
if value is None or np.all(np.isnan(value)):
194+
if _is_missing(value) or np.all(np.isnan(value)):
179195
return None
180196
return Vec3(x=value[0], y=value[1], z=value[2])
181197

@@ -190,7 +206,7 @@ def from_proto(self, value: ProtoFieldValue) -> tuple[float, float, float, float
190206
return value.q1, value.q2, value.q3, value.q4
191207

192208
def to_proto(self, value: tuple[float, float, float, float]) -> Quaternion | None:
193-
if value is None or np.all(np.isnan(value)):
209+
if _is_missing(value) or np.all(np.isnan(value)):
194210
return None
195211
return Quaternion(q1=value[0], q2=value[1], q3=value[2], q4=value[3])
196212

@@ -205,7 +221,7 @@ def from_proto(self, value: ProtoFieldValue) -> tuple[float, float]:
205221
return value.latitude, value.longitude
206222

207223
def to_proto(self, value: tuple[float, float]) -> LatLon | None:
208-
if value is None or np.all(np.isnan(value)):
224+
if _is_missing(value) or np.all(np.isnan(value)):
209225
return None
210226
return LatLon(latitude=value[0], longitude=value[1])
211227

@@ -221,7 +237,7 @@ def from_proto(self, value: ProtoFieldValue) -> tuple[float, float, float]:
221237
return value.latitude, value.longitude, value.altitude
222238

223239
def to_proto(self, value: tuple[float, float, float]) -> LatLonAlt | None:
224-
if value is None or np.all(np.isnan(value)):
240+
if _is_missing(value) or np.all(np.isnan(value)):
225241
return None
226242
return LatLonAlt(latitude=value[0], longitude=value[1], altitude=value[2])
227243

tilebox-datasets/tilebox/datasets/protobuf_conversion/to_protobuf.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,11 +116,29 @@ def columnar_to_row_based(
116116
yield datapoint
117117

118118

119+
def _is_scalar_missing(value: Any) -> bool:
120+
"""Check if a scalar value is missing (None, NaN, NA, NaT).
121+
122+
Handles both scalar and array-like values safely - for arrays, returns False
123+
since pd.isna would return an array which can't be used in a boolean context.
124+
"""
125+
if value is None:
126+
return True
127+
try:
128+
result = pd.isna(value)
129+
# pd.isna returns an array for array-like inputs; we only want scalar True/False
130+
if isinstance(result, (bool, np.bool_)):
131+
return bool(result)
132+
return False
133+
except (TypeError, ValueError):
134+
return False
135+
136+
119137
def convert_values_to_proto(
120138
values: np.ndarray | pd.Series, field_type: ProtobufFieldType, filter_none: bool = False
121139
) -> list[ProtoFieldValue]:
122140
if filter_none:
123-
return [field_type.to_proto(value) for value in values if value is not None]
141+
return [field_type.to_proto(value) for value in values if not _is_scalar_missing(value)]
124142
return [field_type.to_proto(value) for value in values]
125143

126144

tilebox-datasets/tilebox/datasets/query/time_interval.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
# A type alias for the different types that can be used to specify a time interval
1717
TimeIntervalLike: TypeAlias = (
18-
DatetimeScalar | tuple[DatetimeScalar, DatetimeScalar] | xr.DataArray | xr.Dataset | "TimeInterval"
18+
"DatetimeScalar | tuple[DatetimeScalar, DatetimeScalar] | xr.DataArray | xr.Dataset | TimeInterval"
1919
)
2020

2121

tilebox-grpc/pyproject.toml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,7 @@ dependencies = [
3434
]
3535

3636

37-
[dependency-groups]
38-
dev = ["pytest-asyncio>=0.24.0", "pytest-cov>=5.0.0", "pytest>=8.3.2"]
37+
3938

4039
[project.urls]
4140
Homepage = "https://tilebox.com"

tilebox-storage/pyproject.toml

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,7 @@ dependencies = [
2929
"obstore>=0.8.0",
3030
]
3131

32-
[dependency-groups]
33-
dev = [
34-
"hypothesis>=6.112.1",
35-
"pytest-httpx>=0.30.0",
36-
"pytest-asyncio>=0.24.0",
37-
"pytest-cov>=5.0.0",
38-
"pytest>=8.3.2",
39-
]
32+
4033

4134
[project.urls]
4235
Homepage = "https://tilebox.com"

tilebox-workflows/pyproject.toml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,7 @@ dependencies = [
3434
"python-dateutil>=2.9.0.post0",
3535
]
3636

37-
[dependency-groups]
38-
dev = ["hypothesis>=6.112.1", "pytest-cov>=5.0.0", "pytest>=8.3.2", "moto>=5"]
37+
3938

4039
[project.urls]
4140
Homepage = "https://tilebox.com"

0 commit comments

Comments
 (0)