Skip to content

Commit a02419a

Browse files
authored
Fixes for pandas 2&3 (#976)
1 parent dfe8d4e commit a02419a

18 files changed

Lines changed: 518 additions & 57 deletions

.github/workflows/main.yaml

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,43 @@ jobs:
4242
shell: bash -l {0}
4343
run: |
4444
pytest --verbose --cov=fastparquet
45+
46+
linux-pandas3:
47+
name: ${{ matrix.CONDA_ENV }}-pandas3-pytest
48+
runs-on: ubuntu-latest
49+
strategy:
50+
fail-fast: false
51+
matrix:
52+
CONDA_ENV: [py312, py313, py314]
53+
steps:
54+
- name: APT
55+
run: sudo apt-get install liblzo2-dev
56+
57+
- name: Checkout
58+
uses: actions/checkout@v4
59+
with:
60+
fetch-depth: 0
61+
62+
- name: Fetch upstream tags
63+
run: |
64+
git remote add upstream https://github.com/dask/fastparquet.git
65+
git fetch upstream --tags
66+
67+
- name: Setup conda
68+
uses: conda-incubator/setup-miniconda@v3
69+
with:
70+
environment-file: ci/environment-${{ matrix.CONDA_ENV }}-pandas3.yml
71+
72+
- name: pip-install
73+
shell: bash -l {0}
74+
run: |
75+
pip install -e . --no-deps
76+
77+
- name: Run Tests
78+
shell: bash -l {0}
79+
run: |
80+
pytest --verbose --cov=fastparquet
81+
4582
v2:
4683
name: v2-py310
4784
runs-on: ubuntu-latest
@@ -156,3 +193,4 @@ jobs:
156193
shell: bash -l {0}
157194
run: |
158195
pytest --verbose --cov=fastparquet
196+

ci/environment-py312-pandas3.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
name: test_env
2+
channels:
3+
- conda-forge
4+
dependencies:
5+
- python=3.12
6+
- bson
7+
- lz4
8+
- lzo
9+
- pytest
10+
- pandas>=3.0
11+
- dask
12+
- pytest-cov
13+
- thrift
14+
- numpy
15+
- cramjam
16+
- packaging
17+
- orjson
18+
- ujson
19+
- python-rapidjson
20+
- pyarrow

ci/environment-py313-pandas3.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
name: test_env
2+
channels:
3+
- conda-forge
4+
dependencies:
5+
- python=3.13
6+
- bson
7+
- lz4
8+
- lzo
9+
- pytest
10+
- pandas>=3.0
11+
- dask
12+
- pytest-cov
13+
- thrift
14+
- numpy
15+
- cramjam
16+
- packaging
17+
- orjson
18+
- ujson
19+
- python-rapidjson
20+
- pyarrow

ci/environment-py314-pandas3.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
name: test_env
2+
channels:
3+
- conda-forge
4+
dependencies:
5+
- python=3.14
6+
- bson
7+
- lz4
8+
- lzo
9+
- pytest
10+
- pandas>=3.0
11+
- dask
12+
- pytest-cov
13+
- thrift
14+
- numpy
15+
- cramjam
16+
- packaging
17+
- orjson
18+
- ujson
19+
- python-rapidjson
20+
- pyarrow

fastparquet/api.py

Lines changed: 77 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@
1515
from fastparquet.util import (default_open, default_remove, ParquetException, val_to_num,
1616
ops, ensure_bytes, ensure_str, check_column_names, metadata_from_many,
1717
ex_from_sep, _strip_path_tail, get_fs, PANDAS_VERSION, join_path)
18+
from fastparquet.encoding import _HAVE_ARROW, _USE_ARROW_STRINGS
19+
20+
if _HAVE_ARROW:
21+
import pyarrow as _pa
1822

1923

2024
# Find in names of partition files the integer matching "**part.*.parquet",
@@ -102,11 +106,16 @@ class ParquetFile(object):
102106
_pdm = None
103107
_kvm = None
104108
_categories = None
109+
_user_dtypes = False # whether _base_dtype was user-supplied
105110

106111
def __init__(self, fn, verify=False, open_with=default_open, root=False,
107112
sep=None, fs=None, pandas_nulls=True, dtypes=None):
108113
self.pandas_nulls = pandas_nulls
109114
self._base_dtype = dtypes
115+
# Track whether _base_dtype was user-supplied (True) or auto-inferred (False).
116+
# This is needed so that an auto-inferred dtype('O') for a UTF-8 column
117+
# does not prevent the arrow string path from activating.
118+
self._user_dtypes = dtypes is not None
110119
self.tz = None
111120
self._columns_dtype = None
112121
if open_with is default_open and fs is None:
@@ -346,7 +355,7 @@ def row_group_filename(self, rg):
346355

347356
def read_row_group_file(self, rg, columns, categories, index=None,
348357
assign=None, partition_meta=None, row_filter=False,
349-
infile=None):
358+
infile=None, arrow_string_columns=None):
350359
""" Open file for reading, and process it as a row-group
351360
352361
assign is None if this method is called directly (not from to_pandas),
@@ -389,7 +398,7 @@ def read_row_group_file(self, rg, columns, categories, index=None,
389398
f, rg, columns, categories, self.schema, self.cats,
390399
selfmade=self.selfmade, index=index,
391400
assign=assign, scheme=self.file_scheme, partition_meta=partition_meta,
392-
row_filter=row_filter
401+
row_filter=row_filter, arrow_string_columns=arrow_string_columns,
393402
)
394403
if ret:
395404
return df
@@ -665,26 +674,26 @@ def _column_filter(self, df, filters):
665674
if name in self.cats:
666675
continue
667676
if op == 'in':
668-
out |= df[name].isin(val).values
677+
out |= np.asarray(df[name].isin(val), dtype=bool)
669678
elif op == "not in":
670-
out |= ~df[name].isin(val).values
679+
out |= ~np.asarray(df[name].isin(val), dtype=bool)
671680
elif op in ops:
672-
out |= ops[op](df[name], val).values
681+
out |= np.asarray(ops[op](df[name], val), dtype=bool)
673682
elif op == "~":
674-
out |= ~df[name].values
683+
out |= ~np.asarray(df[name], dtype=bool)
675684
else:
676685
and_part = np.ones(len(df), dtype=bool)
677686
for name, op, val in or_part:
678687
if name in self.cats:
679688
continue
680689
if op == 'in':
681-
and_part &= df[name].isin(val).values
690+
and_part &= np.asarray(df[name].isin(val), dtype=bool)
682691
elif op == "not in":
683-
and_part &= ~df[name].isin(val).values
692+
and_part &= ~np.asarray(df[name].isin(val), dtype=bool)
684693
elif op in ops:
685-
and_part &= ops[op](df[name].values, val)
694+
and_part &= np.asarray(ops[op](df[name], val), dtype=bool)
686695
elif op == "~":
687-
and_part &= ~df[name].values
696+
and_part &= ~np.asarray(df[name], dtype=bool)
688697
out |= and_part
689698
return out
690699

@@ -771,6 +780,44 @@ def to_pandas(self, columns=None, categories=None, filters=[],
771780
import json
772781
df.attrs = json.loads(self.key_value_metadata["PANDAS_ATTRS"])
773782

783+
# Build per-column accumulators for arrow string columns.
784+
# Object-dtype columns whose parquet schema marks them as UTF-8 will
785+
# use the fast Cython path that builds ArrowStringArray directly.
786+
# Skip columns whose dtype was explicitly requested as object (via dtypes=)
787+
# to honour the caller's preference and to handle schema-evolution cases.
788+
arrow_string_columns = {}
789+
if _HAVE_ARROW and _USE_ARROW_STRINGS:
790+
_cats = categories or {}
791+
# Resolve user-supplied dtype overrides only (not auto-inferred ones).
792+
# Auto-inferred dtypes always show dtype('O') for UTF-8 columns, so
793+
# including them would block the arrow path for all string columns.
794+
_user_dtypes = {}
795+
if self._user_dtypes and self._base_dtype:
796+
_user_dtypes.update(self._base_dtype)
797+
if dtypes is not None:
798+
_user_dtypes.update(dtypes)
799+
# Detect schema-evolution columns: absent from some row groups.
800+
# For those, the existing object-array fallback is more reliable.
801+
_schema_evo_cols = set()
802+
if len(rgs) > 1:
803+
for rg in rgs:
804+
rg_cols = {'.'.join(c.meta_data.path_in_schema)
805+
for c in rg.columns}
806+
for col in df.columns:
807+
if col not in rg_cols:
808+
_schema_evo_cols.add(col)
809+
for col in df.columns:
810+
if (df[col].dtype == np.dtype('O')
811+
and col not in _cats
812+
and col not in _schema_evo_cols
813+
and _user_dtypes.get(col) != np.dtype('O')):
814+
arrow_string_columns[col] = []
815+
# Also check index columns that are object dtype
816+
if hasattr(df.index, 'dtype') and df.index.dtype == np.dtype('O'):
817+
idx_name = df.index.name
818+
if idx_name and idx_name in columns and idx_name not in _schema_evo_cols:
819+
arrow_string_columns[idx_name] = []
820+
774821
start = 0
775822
if self.file_scheme == 'simple':
776823
infile = self.open(self.fn, 'rb')
@@ -789,8 +836,27 @@ def to_pandas(self, columns=None, categories=None, filters=[],
789836
for (name, v) in views.items()}
790837
self.read_row_group_file(rg, columns, categories, index,
791838
assign=parts, partition_meta=self.partition_meta,
792-
row_filter=sel, infile=infile)
839+
row_filter=sel, infile=infile,
840+
arrow_string_columns=arrow_string_columns)
793841
start += thislen
842+
843+
# Finalize arrow string columns: concatenate per-page arrays and assign.
844+
if _HAVE_ARROW and _USE_ARROW_STRINGS:
845+
for col, parts in arrow_string_columns.items():
846+
if not parts:
847+
continue
848+
final_arr = _pa.concat_arrays(parts)
849+
if len(final_arr) != len(df):
850+
# Schema evolution: column absent in some row groups. The
851+
# pre-allocated object array already has None for the missing
852+
# rows; skip the arrow conversion so callers see partial data.
853+
continue
854+
arrow_str = pd.arrays.ArrowStringArray(_pa.chunked_array([final_arr]))
855+
if col in df.columns:
856+
df[col] = pd.Series(arrow_str, index=df.index, name=col)
857+
elif col == df.index.name:
858+
df.index = pd.Index(arrow_str, name=col)
859+
794860
return df
795861

796862
def pre_allocate(self, size, columns, categories, index, dtypes=None):

fastparquet/benchmarks/columns.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ def time_column():
6969
d.loc[n//2, col] = np.nan
7070
elif d.dtypes[col].kind in ['i', 'u']:
7171
continue
72+
elif d.dtypes[col].kind == "b":
73+
d[col] = d[col].astype("boolean")
74+
d.loc[n // 2, col] = pd.NA
7275
else:
7376
d.loc[n//2, col] = None
7477
with measure('%s: write, with null, has_null=True' % d.dtypes[col], result):

fastparquet/converted_types.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -167,9 +167,13 @@ def convert(data, se, timestamp96=True, dtype=None):
167167
if ctype is None:
168168
return data
169169
if ctype == parquet_thrift.ConvertedType.UTF8:
170+
import pandas as pd
171+
if isinstance(data.dtype, pd.StringDtype):
172+
# pandas 3+: data is already an arrow-backed string array (decoded),
173+
# no further conversion needed.
174+
return data
170175
if data.dtype != "O" or (len(data) == 1 and not isinstance(data[0], str)):
171-
# fixed string
172-
import pandas as pd
176+
# fixed-length byte string (e.g. FIXED_LEN_BYTE_ARRAY): decode to str
173177
return pd.Series(data).str.decode("utf8").values
174178
# already converted in speedups.unpack_byte_array
175179
return data
@@ -204,7 +208,7 @@ def convert(data, se, timestamp96=True, dtype=None):
204208
return data.view('datetime64[ns]')
205209
elif ctype == parquet_thrift.ConvertedType.TIME_MILLIS:
206210
# this was not covered by new pandas time units
207-
data = data.astype('int64', copy=False)
211+
data = data.astype('int64')
208212
time_shift(data, 1000000)
209213
return data.view('timedelta64[ns]')
210214
elif ctype == parquet_thrift.ConvertedType.TIMESTAMP_MILLIS:

0 commit comments

Comments
 (0)