From 9b3c6ac2814de318ae50155fbd338d848fbf10b8 Mon Sep 17 00:00:00 2001 From: arjun Date: Wed, 2 Jul 2025 17:02:31 +0530 Subject: [PATCH 01/51] Fix describe() for ExtensionArrays with multiple internal dtypes --- doc/source/whatsnew/v3.0.0.rst | 1 + pandas/core/methods/describe.py | 13 ++++++++++ pandas/tests/series/methods/test_describe.py | 26 ++++++++++++++++++++ 3 files changed, 40 insertions(+) diff --git a/doc/source/whatsnew/v3.0.0.rst b/doc/source/whatsnew/v3.0.0.rst index 5ff1ea9d194f6..c8e2c5584f79b 100644 --- a/doc/source/whatsnew/v3.0.0.rst +++ b/doc/source/whatsnew/v3.0.0.rst @@ -909,6 +909,7 @@ Other - Bug in :meth:`Index.sort_values` when passing a key function that turns values into tuples, e.g. ``key=natsort.natsort_key``, would raise ``TypeError`` (:issue:`56081`) - Bug in :meth:`MultiIndex.fillna` error message was referring to ``isna`` instead of ``fillna`` (:issue:`60974`) - Bug in :meth:`Series.describe` where median percentile was always included when the ``percentiles`` argument was passed (:issue:`60550`). +- Bug in :meth:`Series.describe` where statistics with multiple dtypes for ExtensionArrays were coerced to ``float64`` which raised a ``DimensionalityError``` (:issue:`61707`) - Bug in :meth:`Series.diff` allowing non-integer values for the ``periods`` argument. (:issue:`56607`) - Bug in :meth:`Series.dt` methods in :class:`ArrowDtype` that were returning incorrect values. (:issue:`57355`) - Bug in :meth:`Series.isin` raising ``TypeError`` when series is large (>10**6) and ``values`` contains NA (:issue:`60678`) diff --git a/pandas/core/methods/describe.py b/pandas/core/methods/describe.py index 944e28a9b0238..4d291c0edaa90 100644 --- a/pandas/core/methods/describe.py +++ b/pandas/core/methods/describe.py @@ -12,6 +12,7 @@ ) from typing import ( TYPE_CHECKING, + Any, cast, ) @@ -215,6 +216,14 @@ def reorder_columns(ldesc: Sequence[Series]) -> list[Hashable]: return names +def has_multiple_internal_dtypes(d: list[Any]) -> bool: + """Check if the sequence has multiple internal dtypes.""" + if not d: + return False + + return any(type(item) != type(d[0]) for item in d) + + def describe_numeric_1d(series: Series, percentiles: Sequence[float]) -> Series: """Describe series containing numerical data. @@ -251,6 +260,10 @@ def describe_numeric_1d(series: Series, percentiles: Sequence[float]) -> Series: import pyarrow as pa dtype = ArrowDtype(pa.float64()) + elif has_multiple_internal_dtypes(d): + # GH61707: describe() doesn't work on EAs + # with multiple internal dtypes, so return object dtype + dtype = None else: dtype = Float64Dtype() elif series.dtype.kind in "iufb": diff --git a/pandas/tests/series/methods/test_describe.py b/pandas/tests/series/methods/test_describe.py index 79ec11feb5308..35f126103d3f2 100644 --- a/pandas/tests/series/methods/test_describe.py +++ b/pandas/tests/series/methods/test_describe.py @@ -95,6 +95,32 @@ def test_describe_empty_object(self): assert np.isnan(result.iloc[2]) assert np.isnan(result.iloc[3]) + def test_describe_multiple_dtypes(self): + """ + GH61707: describe() doesn't work on EAs which generate + statistics with multiple dtypes. + """ + from decimal import Decimal + + from pandas.tests.extension.decimal import to_decimal + + s = Series(to_decimal([1, 2.5, 3]), dtype="decimal") + + expected = Series( + [ + 3, + Decimal("2.166666666666666666666666667"), + Decimal("0.8498365855987974716713706849"), + Decimal("1"), + Decimal("3"), + ], + index=["count", "mean", "std", "min", "max"], + dtype="object", + ) + + result = s.describe(percentiles=[]) + tm.assert_series_equal(result, expected) + def test_describe_with_tz(self, tz_naive_fixture): # GH 21332 tz = tz_naive_fixture From 355055635e8a77f0dcac04b033a83730d49f6cc4 Mon Sep 17 00:00:00 2001 From: ianlv <168640168+ianlv@users.noreply.github.com> Date: Thu, 3 Jul 2025 00:48:18 +0800 Subject: [PATCH 02/51] chore: remove redundant words in comment (#61759) Signed-off-by: ianlv --- doc/source/user_guide/io.rst | 2 +- pandas/compat/pickle_compat.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/source/user_guide/io.rst b/doc/source/user_guide/io.rst index 25f1e11e6b603..34c469bfc535b 100644 --- a/doc/source/user_guide/io.rst +++ b/doc/source/user_guide/io.rst @@ -5432,7 +5432,7 @@ A simple example loading all data from an Iceberg table ``my_table`` defined in df = pd.read_iceberg("my_table", catalog_name="my_catalog") Catalogs must be defined in the ``.pyiceberg.yaml`` file, usually in the home directory. -It is possible to to change properties of the catalog definition with the +It is possible to change properties of the catalog definition with the ``catalog_properties`` parameter: .. code-block:: python diff --git a/pandas/compat/pickle_compat.py b/pandas/compat/pickle_compat.py index beaaa3f8ed3cc..beb4a69232b27 100644 --- a/pandas/compat/pickle_compat.py +++ b/pandas/compat/pickle_compat.py @@ -36,7 +36,7 @@ "pandas._libs.internals", "_unpickle_block", ), - # Avoid Cython's warning "contradiction to to Python 'class private name' rules" + # Avoid Cython's warning "contradiction to Python 'class private name' rules" ("pandas._libs.tslibs.nattype", "__nat_unpickle"): ( "pandas._libs.tslibs.nattype", "_nat_unpickle", From 22f12fc5d3f7fda3f198760204e7c13150c78581 Mon Sep 17 00:00:00 2001 From: Joris Van den Bossche Date: Thu, 3 Jul 2025 10:18:42 +0200 Subject: [PATCH 03/51] DEPS: bump pyarrow minimum version from 10.0 to 12.0 (#61723) --- ci/deps/actions-310-minimum_versions.yaml | 2 +- ci/deps/actions-310.yaml | 2 +- ci/deps/actions-311-downstream_compat.yaml | 2 +- ci/deps/actions-311.yaml | 2 +- ci/deps/actions-312.yaml | 2 +- ci/deps/actions-313.yaml | 2 +- doc/source/getting_started/install.rst | 2 +- doc/source/whatsnew/v3.0.0.rst | 2 + environment.yml | 2 +- pandas/_testing/__init__.py | 4 +- pandas/compat/__init__.py | 6 +-- pandas/compat/_optional.py | 2 +- pandas/compat/pyarrow.py | 10 ++-- pandas/core/arrays/_arrow_string_mixins.py | 7 ++- pandas/core/arrays/arrow/accessors.py | 14 ++--- pandas/core/arrays/arrow/array.py | 52 +++---------------- pandas/core/arrays/string_.py | 6 +-- pandas/core/arrays/string_arrow.py | 9 ++-- pandas/core/dtypes/dtypes.py | 13 +++-- pandas/core/internals/__init__.py | 6 +++ pandas/core/internals/api.py | 19 +++++++ pandas/core/strings/accessor.py | 28 +++------- .../tests/arrays/period/test_arrow_compat.py | 6 --- pandas/tests/arrays/string_/test_string.py | 4 +- .../tests/arrays/string_/test_string_arrow.py | 2 +- pandas/tests/copy_view/test_astype.py | 4 +- pandas/tests/extension/test_arrow.py | 24 +-------- pandas/tests/groupby/test_groupby_dropna.py | 9 +--- pandas/tests/interchange/test_impl.py | 6 +-- pandas/tests/io/test_parquet.py | 13 ++--- .../series/accessors/test_list_accessor.py | 34 ++++-------- .../series/accessors/test_struct_accessor.py | 6 +-- pyproject.toml | 8 +-- requirements-dev.txt | 2 +- scripts/validate_unwanted_patterns.py | 1 + 35 files changed, 112 insertions(+), 201 deletions(-) diff --git a/ci/deps/actions-310-minimum_versions.yaml b/ci/deps/actions-310-minimum_versions.yaml index 9f12fe941d488..a9ea6a639043b 100644 --- a/ci/deps/actions-310-minimum_versions.yaml +++ b/ci/deps/actions-310-minimum_versions.yaml @@ -41,7 +41,7 @@ dependencies: - qtpy=2.3.0 - openpyxl=3.1.2 - psycopg2=2.9.6 - - pyarrow=10.0.1 + - pyarrow=12.0.1 - pyiceberg=0.7.1 - pymysql=1.1.0 - pyqt=5.15.9 diff --git a/ci/deps/actions-310.yaml b/ci/deps/actions-310.yaml index 66d49475bf34b..4904140f2e70b 100644 --- a/ci/deps/actions-310.yaml +++ b/ci/deps/actions-310.yaml @@ -39,7 +39,7 @@ dependencies: - qtpy>=2.3.0 - openpyxl>=3.1.2 - psycopg2>=2.9.6 - - pyarrow>=10.0.1 + - pyarrow>=12.0.1 - pyiceberg>=0.7.1 - pymysql>=1.1.0 - pyqt>=5.15.9 diff --git a/ci/deps/actions-311-downstream_compat.yaml b/ci/deps/actions-311-downstream_compat.yaml index 70e66a18daba9..1fc8a9ed21777 100644 --- a/ci/deps/actions-311-downstream_compat.yaml +++ b/ci/deps/actions-311-downstream_compat.yaml @@ -40,7 +40,7 @@ dependencies: - qtpy>=2.3.0 - openpyxl>=3.1.2 - psycopg2>=2.9.6 - - pyarrow>=10.0.1 + - pyarrow>=12.0.1 - pyiceberg>=0.7.1 - pymysql>=1.1.0 - pyqt>=5.15.9 diff --git a/ci/deps/actions-311.yaml b/ci/deps/actions-311.yaml index 9669c1e29a435..deb646a7ba86a 100644 --- a/ci/deps/actions-311.yaml +++ b/ci/deps/actions-311.yaml @@ -40,7 +40,7 @@ dependencies: - pyqt>=5.15.9 - openpyxl>=3.1.2 - psycopg2>=2.9.6 - - pyarrow>=10.0.1 + - pyarrow>=12.0.1 - pyiceberg>=0.7.1 - pymysql>=1.1.0 - pyreadstat>=1.2.6 diff --git a/ci/deps/actions-312.yaml b/ci/deps/actions-312.yaml index 61f1d602bb241..97b582b80fb8f 100644 --- a/ci/deps/actions-312.yaml +++ b/ci/deps/actions-312.yaml @@ -40,7 +40,7 @@ dependencies: - pyqt>=5.15.9 - openpyxl>=3.1.2 - psycopg2>=2.9.6 - - pyarrow>=10.0.1 + - pyarrow>=12.0.1 - pyiceberg>=0.7.1 - pymysql>=1.1.0 - pyreadstat>=1.2.6 diff --git a/ci/deps/actions-313.yaml b/ci/deps/actions-313.yaml index 11f4428be27e5..4bc363dc4a27e 100644 --- a/ci/deps/actions-313.yaml +++ b/ci/deps/actions-313.yaml @@ -41,7 +41,7 @@ dependencies: - pyqt>=5.15.9 - openpyxl>=3.1.2 - psycopg2>=2.9.6 - - pyarrow>=10.0.1 + - pyarrow>=12.0.1 - pymysql>=1.1.0 - pyreadstat>=1.2.6 - pytables>=3.8.0 diff --git a/doc/source/getting_started/install.rst b/doc/source/getting_started/install.rst index 1589fea5f8953..ed0c8bd05098d 100644 --- a/doc/source/getting_started/install.rst +++ b/doc/source/getting_started/install.rst @@ -307,7 +307,7 @@ Dependency Minimum Version pip ex `PyTables `__ 3.8.0 hdf5 HDF5-based reading / writing `zlib `__ hdf5 Compression for HDF5 `fastparquet `__ 2024.2.0 - Parquet reading / writing (pyarrow is default) -`pyarrow `__ 10.0.1 parquet, feather Parquet, ORC, and feather reading / writing +`pyarrow `__ 12.0.1 parquet, feather Parquet, ORC, and feather reading / writing `PyIceberg `__ 0.7.1 iceberg Apache Iceberg reading / writing `pyreadstat `__ 1.2.6 spss SPSS files (.sav) reading `odfpy `__ 1.4.1 excel Open document format (.odf, .ods, .odt) reading / writing diff --git a/doc/source/whatsnew/v3.0.0.rst b/doc/source/whatsnew/v3.0.0.rst index 5ff1ea9d194f6..57dce003c2846 100644 --- a/doc/source/whatsnew/v3.0.0.rst +++ b/doc/source/whatsnew/v3.0.0.rst @@ -321,6 +321,8 @@ Optional libraries below the lowest tested version may still work, but are not c +------------------------+---------------------+ | Package | New Minimum Version | +========================+=====================+ +| pyarrow | 12.0.1 | ++------------------------+---------------------+ | pytz | 2023.4 | +------------------------+---------------------+ | fastparquet | 2024.2.0 | diff --git a/environment.yml b/environment.yml index b698c4c2ec131..d89a788827109 100644 --- a/environment.yml +++ b/environment.yml @@ -43,7 +43,7 @@ dependencies: - openpyxl>=3.1.2 - odfpy>=1.4.1 - psycopg2>=2.9.6 - - pyarrow>=10.0.1 + - pyarrow>=12.0.1 - pyiceberg>=0.7.1 - pymysql>=1.1.0 - pyreadstat>=1.2.6 diff --git a/pandas/_testing/__init__.py b/pandas/_testing/__init__.py index ec9b5098c97c9..fc447aaba37db 100644 --- a/pandas/_testing/__init__.py +++ b/pandas/_testing/__init__.py @@ -18,7 +18,7 @@ set_locale, ) -from pandas.compat import pa_version_under10p1 +from pandas.compat import HAS_PYARROW import pandas as pd from pandas import ( @@ -183,7 +183,7 @@ ] ] -if not pa_version_under10p1: +if HAS_PYARROW: import pyarrow as pa UNSIGNED_INT_PYARROW_DTYPES = [pa.uint8(), pa.uint16(), pa.uint32(), pa.uint64()] diff --git a/pandas/compat/__init__.py b/pandas/compat/__init__.py index 9f3bfdc205498..8ed19f97958b9 100644 --- a/pandas/compat/__init__.py +++ b/pandas/compat/__init__.py @@ -26,8 +26,7 @@ from pandas.compat.numpy import is_numpy_dev from pandas.compat.pyarrow import ( HAS_PYARROW, - pa_version_under10p1, - pa_version_under11p0, + pa_version_under12p1, pa_version_under13p0, pa_version_under14p0, pa_version_under14p1, @@ -160,8 +159,7 @@ def is_ci_environment() -> bool: "PYPY", "WASM", "is_numpy_dev", - "pa_version_under10p1", - "pa_version_under11p0", + "pa_version_under12p1", "pa_version_under13p0", "pa_version_under14p0", "pa_version_under14p1", diff --git a/pandas/compat/_optional.py b/pandas/compat/_optional.py index 07a07ba4ab60c..c2a232d55d8e2 100644 --- a/pandas/compat/_optional.py +++ b/pandas/compat/_optional.py @@ -38,7 +38,7 @@ "openpyxl": "3.1.2", "psycopg2": "2.9.6", # (dt dec pq3 ext lo64) "pymysql": "1.1.0", - "pyarrow": "10.0.1", + "pyarrow": "12.0.1", "pyiceberg": "0.7.1", "pyreadstat": "1.2.6", "pytest": "7.3.2", diff --git a/pandas/compat/pyarrow.py b/pandas/compat/pyarrow.py index 163934bee509c..569d702592982 100644 --- a/pandas/compat/pyarrow.py +++ b/pandas/compat/pyarrow.py @@ -8,9 +8,7 @@ import pyarrow as pa _palv = Version(Version(pa.__version__).base_version) - pa_version_under10p1 = _palv < Version("10.0.1") - pa_version_under11p0 = _palv < Version("11.0.0") - pa_version_under12p0 = _palv < Version("12.0.0") + pa_version_under12p1 = _palv < Version("12.0.1") pa_version_under13p0 = _palv < Version("13.0.0") pa_version_under14p0 = _palv < Version("14.0.0") pa_version_under14p1 = _palv < Version("14.0.1") @@ -20,11 +18,9 @@ pa_version_under18p0 = _palv < Version("18.0.0") pa_version_under19p0 = _palv < Version("19.0.0") pa_version_under20p0 = _palv < Version("20.0.0") - HAS_PYARROW = True + HAS_PYARROW = _palv >= Version("12.0.1") except ImportError: - pa_version_under10p1 = True - pa_version_under11p0 = True - pa_version_under12p0 = True + pa_version_under12p1 = True pa_version_under13p0 = True pa_version_under14p0 = True pa_version_under14p1 = True diff --git a/pandas/core/arrays/_arrow_string_mixins.py b/pandas/core/arrays/_arrow_string_mixins.py index 1ca52ce64bd77..07cbf489cfe1c 100644 --- a/pandas/core/arrays/_arrow_string_mixins.py +++ b/pandas/core/arrays/_arrow_string_mixins.py @@ -12,13 +12,12 @@ from pandas._libs import lib from pandas.compat import ( - pa_version_under10p1, - pa_version_under11p0, + HAS_PYARROW, pa_version_under13p0, pa_version_under17p0, ) -if not pa_version_under10p1: +if HAS_PYARROW: import pyarrow as pa import pyarrow.compute as pc @@ -132,7 +131,7 @@ def _str_get(self, i: int) -> Self: def _str_slice( self, start: int | None = None, stop: int | None = None, step: int | None = None ) -> Self: - if pa_version_under11p0: + if pa_version_under13p0: # GH#59724 result = self._apply_elementwise(lambda val: val[start:stop:step]) return type(self)(pa.chunked_array(result, type=self._pa_array.type)) diff --git a/pandas/core/arrays/arrow/accessors.py b/pandas/core/arrays/arrow/accessors.py index b220a94d032b5..7f3da9be0c03d 100644 --- a/pandas/core/arrays/arrow/accessors.py +++ b/pandas/core/arrays/arrow/accessors.py @@ -11,14 +11,11 @@ cast, ) -from pandas.compat import ( - pa_version_under10p1, - pa_version_under11p0, -) +from pandas.compat import HAS_PYARROW from pandas.core.dtypes.common import is_list_like -if not pa_version_under10p1: +if HAS_PYARROW: import pyarrow as pa import pyarrow.compute as pc @@ -46,7 +43,7 @@ def _is_valid_pyarrow_dtype(self, pyarrow_dtype) -> bool: def _validate(self, data) -> None: dtype = data.dtype - if pa_version_under10p1 or not isinstance(dtype, ArrowDtype): + if not HAS_PYARROW or not isinstance(dtype, ArrowDtype): # Raise AttributeError so that inspect can handle non-struct Series. raise AttributeError(self._validation_msg.format(dtype=dtype)) @@ -171,11 +168,6 @@ def __getitem__(self, key: int | slice) -> Series: name=self._data.name, ) elif isinstance(key, slice): - if pa_version_under11p0: - raise NotImplementedError( - f"List slice not supported by pyarrow {pa.__version__}." - ) - # TODO: Support negative start/stop/step, ideally this would be added # upstream in pyarrow. start, stop, step = key.start, key.stop, key.step diff --git a/pandas/core/arrays/arrow/array.py b/pandas/core/arrays/arrow/array.py index c18f06c3a126d..b4e60819b033f 100644 --- a/pandas/core/arrays/arrow/array.py +++ b/pandas/core/arrays/arrow/array.py @@ -22,8 +22,8 @@ timezones, ) from pandas.compat import ( - pa_version_under10p1, - pa_version_under11p0, + HAS_PYARROW, + pa_version_under12p1, pa_version_under13p0, ) from pandas.util._decorators import doc @@ -74,7 +74,7 @@ from pandas.io._util import _arrow_dtype_mapping from pandas.tseries.frequencies import to_offset -if not pa_version_under10p1: +if HAS_PYARROW: import pyarrow as pa import pyarrow.compute as pc @@ -208,16 +208,6 @@ def floordiv_compat( from pandas.core.arrays.timedeltas import TimedeltaArray -def get_unit_from_pa_dtype(pa_dtype) -> str: - # https://github.com/pandas-dev/pandas/pull/50998#discussion_r1100344804 - if pa_version_under11p0: - unit = str(pa_dtype).split("[", 1)[-1][:-1] - if unit not in ["s", "ms", "us", "ns"]: - raise ValueError(pa_dtype) - return unit - return pa_dtype.unit - - def to_pyarrow_type( dtype: ArrowDtype | pa.DataType | Dtype | None, ) -> pa.DataType | None: @@ -300,7 +290,7 @@ class ArrowExtensionArray( _dtype: ArrowDtype def __init__(self, values: pa.Array | pa.ChunkedArray) -> None: - if pa_version_under10p1: + if pa_version_under12p1: msg = "pyarrow>=10.0.1 is required for PyArrow backed ArrowExtensionArray." raise ImportError(msg) if isinstance(values, pa.Array): @@ -1199,10 +1189,6 @@ def factorize( null_encoding = "mask" if use_na_sentinel else "encode" data = self._pa_array - pa_type = data.type - if pa_version_under11p0 and pa.types.is_duration(pa_type): - # https://github.com/apache/arrow/issues/15226#issuecomment-1376578323 - data = data.cast(pa.int64()) if pa.types.is_dictionary(data.type): if null_encoding == "encode": @@ -1227,8 +1213,6 @@ def factorize( ) uniques = type(self)(combined.dictionary) - if pa_version_under11p0 and pa.types.is_duration(pa_type): - uniques = cast(ArrowExtensionArray, uniques.astype(self.dtype)) return indices, uniques def reshape(self, *args, **kwargs): @@ -1515,19 +1499,7 @@ def unique(self) -> Self: ------- ArrowExtensionArray """ - pa_type = self._pa_array.type - - if pa_version_under11p0 and pa.types.is_duration(pa_type): - # https://github.com/apache/arrow/issues/15226#issuecomment-1376578323 - data = self._pa_array.cast(pa.int64()) - else: - data = self._pa_array - - pa_result = pc.unique(data) - - if pa_version_under11p0 and pa.types.is_duration(pa_type): - pa_result = pa_result.cast(pa_type) - + pa_result = pc.unique(self._pa_array) return type(self)(pa_result) def value_counts(self, dropna: bool = True) -> Series: @@ -1547,18 +1519,12 @@ def value_counts(self, dropna: bool = True) -> Series: -------- Series.value_counts """ - pa_type = self._pa_array.type - if pa_version_under11p0 and pa.types.is_duration(pa_type): - # https://github.com/apache/arrow/issues/15226#issuecomment-1376578323 - data = self._pa_array.cast(pa.int64()) - else: - data = self._pa_array - from pandas import ( Index, Series, ) + data = self._pa_array vc = data.value_counts() values = vc.field(0) @@ -1568,9 +1534,6 @@ def value_counts(self, dropna: bool = True) -> Series: values = values.filter(mask) counts = counts.filter(mask) - if pa_version_under11p0 and pa.types.is_duration(pa_type): - values = values.cast(pa_type) - counts = ArrowExtensionArray(counts) index = Index(type(self)(values)) @@ -1864,8 +1827,7 @@ def pyarrow_meth(data, skip_nulls, min_count=0): # type: ignore[misc] if pa.types.is_duration(pa_type): result = result.cast(pa_type) elif pa.types.is_time(pa_type): - unit = get_unit_from_pa_dtype(pa_type) - result = result.cast(pa.duration(unit)) + result = result.cast(pa.duration(pa_type.unit)) elif pa.types.is_date(pa_type): # go with closest available unit, i.e. "s" result = result.cast(pa.duration("s")) diff --git a/pandas/core/arrays/string_.py b/pandas/core/arrays/string_.py index 8048306df91a2..6087e42cf273d 100644 --- a/pandas/core/arrays/string_.py +++ b/pandas/core/arrays/string_.py @@ -25,7 +25,7 @@ from pandas._libs.lib import ensure_string_array from pandas.compat import ( HAS_PYARROW, - pa_version_under10p1, + pa_version_under12p1, ) from pandas.compat.numpy import function as nv from pandas.util._decorators import ( @@ -182,9 +182,9 @@ def __init__( raise ValueError( f"Storage must be 'python' or 'pyarrow'. Got {storage} instead." ) - if storage == "pyarrow" and pa_version_under10p1: + if storage == "pyarrow" and pa_version_under12p1: raise ImportError( - "pyarrow>=10.0.1 is required for PyArrow backed StringArray." + "pyarrow>=12.0.1 is required for PyArrow backed StringArray." ) if isinstance(na_value, float) and np.isnan(na_value): diff --git a/pandas/core/arrays/string_arrow.py b/pandas/core/arrays/string_arrow.py index 7264efa3298d9..2ca12870709f0 100644 --- a/pandas/core/arrays/string_arrow.py +++ b/pandas/core/arrays/string_arrow.py @@ -14,7 +14,8 @@ missing as libmissing, ) from pandas.compat import ( - pa_version_under10p1, + HAS_PYARROW, + pa_version_under12p1, pa_version_under13p0, pa_version_under16p0, ) @@ -38,7 +39,7 @@ ) from pandas.core.strings.object_array import ObjectStringArrayMixin -if not pa_version_under10p1: +if HAS_PYARROW: import pyarrow as pa import pyarrow.compute as pc @@ -63,8 +64,8 @@ def _chk_pyarrow_available() -> None: - if pa_version_under10p1: - msg = "pyarrow>=10.0.1 is required for PyArrow backed ArrowExtensionArray." + if pa_version_under12p1: + msg = "pyarrow>=12.0.1 is required for PyArrow backed ArrowExtensionArray." raise ImportError(msg) diff --git a/pandas/core/dtypes/dtypes.py b/pandas/core/dtypes/dtypes.py index 570074e047da6..3986392774f28 100644 --- a/pandas/core/dtypes/dtypes.py +++ b/pandas/core/dtypes/dtypes.py @@ -46,7 +46,10 @@ abbrev_to_npy_unit, ) from pandas._libs.tslibs.offsets import BDay -from pandas.compat import pa_version_under10p1 +from pandas.compat import ( + HAS_PYARROW, + pa_version_under12p1, +) from pandas.errors import PerformanceWarning from pandas.util._decorators import set_module from pandas.util._exceptions import find_stack_level @@ -66,7 +69,7 @@ is_list_like, ) -if not pa_version_under10p1: +if HAS_PYARROW: import pyarrow as pa if TYPE_CHECKING: @@ -2193,8 +2196,8 @@ class ArrowDtype(StorageExtensionDtype): def __init__(self, pyarrow_dtype: pa.DataType) -> None: super().__init__("pyarrow") - if pa_version_under10p1: - raise ImportError("pyarrow>=10.0.1 is required for ArrowDtype") + if pa_version_under12p1: + raise ImportError("pyarrow>=12.0.1 is required for ArrowDtype") if not isinstance(pyarrow_dtype, pa.DataType): raise ValueError( f"pyarrow_dtype ({pyarrow_dtype}) must be an instance " @@ -2346,7 +2349,7 @@ def construct_from_string(cls, string: str) -> ArrowDtype: if string in ("string[pyarrow]", "str[pyarrow]"): # Ensure Registry.find skips ArrowDtype to use StringDtype instead raise TypeError("string[pyarrow] should be constructed by StringDtype") - if pa_version_under10p1: + if pa_version_under12p1: raise ImportError("pyarrow>=10.0.1 is required for ArrowDtype") base_type = string[:-9] # get rid of "[pyarrow]" diff --git a/pandas/core/internals/__init__.py b/pandas/core/internals/__init__.py index d64c7e33657d4..12999a44a446b 100644 --- a/pandas/core/internals/__init__.py +++ b/pandas/core/internals/__init__.py @@ -8,6 +8,7 @@ __all__ = [ "Block", # pyright:ignore[reportUnsupportedDunderAll)] "BlockManager", + "DatetimeTZBlock", # pyright:ignore[reportUnsupportedDunderAll)] "ExtensionBlock", # pyright:ignore[reportUnsupportedDunderAll)] "SingleBlockManager", "concatenate_managers", @@ -36,6 +37,7 @@ def __getattr__(name: str): if name in [ "Block", "ExtensionBlock", + "DatetimeTZBlock", ]: warnings.warn( f"{name} is deprecated and will be removed in a future version. " @@ -45,6 +47,10 @@ def __getattr__(name: str): # on hard-coding stacklevel stacklevel=2, ) + if name == "DatetimeTZBlock": + from pandas.core.internals.api import _DatetimeTZBlock as DatetimeTZBlock + + return DatetimeTZBlock if name == "ExtensionBlock": from pandas.core.internals.blocks import ExtensionBlock diff --git a/pandas/core/internals/api.py b/pandas/core/internals/api.py index 04944db2ebd9c..c5d6a2fe7a6a6 100644 --- a/pandas/core/internals/api.py +++ b/pandas/core/internals/api.py @@ -29,6 +29,7 @@ ) from pandas.core.construction import extract_array from pandas.core.internals.blocks import ( + DatetimeLikeBlock, check_ndim, ensure_block_shape, extract_pandas_array, @@ -74,6 +75,14 @@ def _make_block(values: ArrayLike, placement: np.ndarray) -> Block: return klass(values, ndim=2, placement=placement_obj) +class _DatetimeTZBlock(DatetimeLikeBlock): + """implement a datetime64 block with a tz attribute""" + + values: DatetimeArray + + __slots__ = () + + def make_block( values, placement, klass=None, ndim=None, dtype: Dtype | None = None ) -> Block: @@ -114,6 +123,16 @@ def make_block( dtype = dtype or values.dtype klass = get_block_type(dtype) + elif klass is _DatetimeTZBlock and not isinstance(values.dtype, DatetimeTZDtype): + # pyarrow calls get here (pyarrow<15) + values = DatetimeArray._simple_new( + # error: Argument "dtype" to "_simple_new" of "DatetimeArray" has + # incompatible type "Union[ExtensionDtype, dtype[Any], None]"; + # expected "Union[dtype[datetime64], DatetimeTZDtype]" + values, + dtype=dtype, # type: ignore[arg-type] + ) + if not isinstance(placement, BlockPlacement): placement = BlockPlacement(placement) diff --git a/pandas/core/strings/accessor.py b/pandas/core/strings/accessor.py index 81f7441846589..d1cf1e7504ece 100644 --- a/pandas/core/strings/accessor.py +++ b/pandas/core/strings/accessor.py @@ -305,8 +305,6 @@ def _wrap_result( if isinstance(result.dtype, ArrowDtype): import pyarrow as pa - from pandas.compat import pa_version_under11p0 - from pandas.core.arrays.arrow.array import ArrowExtensionArray value_lengths = pa.compute.list_value_length(result._pa_array) @@ -319,26 +317,14 @@ def _wrap_result( ) if min_len < max_len: # append nulls to each scalar list element up to max_len - if not pa_version_under11p0: - result = ArrowExtensionArray( - pa.compute.list_slice( - result._pa_array, - start=0, - stop=max_len, - return_fixed_size_list=True, - ) + result = ArrowExtensionArray( + pa.compute.list_slice( + result._pa_array, + start=0, + stop=max_len, + return_fixed_size_list=True, ) - else: - all_null = np.full(max_len, fill_value=None, dtype=object) - values = result.to_numpy() - new_values = [] - for row in values: - if len(row) < max_len: - nulls = all_null[: max_len - len(row)] - row = np.append(row, nulls) - new_values.append(row) - pa_type = result._pa_array.type - result = ArrowExtensionArray(pa.array(new_values, type=pa_type)) + ) if name is None: name = range(max_len) result = ( diff --git a/pandas/tests/arrays/period/test_arrow_compat.py b/pandas/tests/arrays/period/test_arrow_compat.py index 431309aca0df2..c1d9ac0d1d273 100644 --- a/pandas/tests/arrays/period/test_arrow_compat.py +++ b/pandas/tests/arrays/period/test_arrow_compat.py @@ -1,7 +1,5 @@ import pytest -from pandas.compat.pyarrow import pa_version_under10p1 - from pandas.core.dtypes.dtypes import PeriodDtype import pandas as pd @@ -33,7 +31,6 @@ def test_arrow_extension_type(): assert hash(p1) != hash(p3) -@pytest.mark.xfail(not pa_version_under10p1, reason="Wrong behavior with pyarrow 10") @pytest.mark.parametrize( "data, freq", [ @@ -60,9 +57,6 @@ def test_arrow_array(data, freq): with pytest.raises(TypeError, match=msg): pa.array(periods, type="float64") - with pytest.raises(TypeError, match="different 'freq'"): - pa.array(periods, type=ArrowPeriodType("T")) - def test_arrow_array_missing(): from pandas.core.arrays.arrow.extension_types import ArrowPeriodType diff --git a/pandas/tests/arrays/string_/test_string.py b/pandas/tests/arrays/string_/test_string.py index 736c0e1782fc0..96e1cc05e284c 100644 --- a/pandas/tests/arrays/string_/test_string.py +++ b/pandas/tests/arrays/string_/test_string.py @@ -12,7 +12,7 @@ from pandas.compat import HAS_PYARROW from pandas.compat.pyarrow import ( - pa_version_under12p0, + pa_version_under12p1, pa_version_under19p0, ) import pandas.util._test_decorators as td @@ -600,7 +600,7 @@ def test_arrow_array(dtype): data = pd.array(["a", "b", "c"], dtype=dtype) arr = pa.array(data) expected = pa.array(list(data), type=pa.large_string(), from_pandas=True) - if dtype.storage == "pyarrow" and pa_version_under12p0: + if dtype.storage == "pyarrow" and pa_version_under12p1: expected = pa.chunked_array(expected) if dtype.storage == "python": expected = pc.cast(expected, pa.string()) diff --git a/pandas/tests/arrays/string_/test_string_arrow.py b/pandas/tests/arrays/string_/test_string_arrow.py index e6103da5021bb..2b5f60ce70b4c 100644 --- a/pandas/tests/arrays/string_/test_string_arrow.py +++ b/pandas/tests/arrays/string_/test_string_arrow.py @@ -178,7 +178,7 @@ def test_from_sequence_wrong_dtype_raises(using_infer_string): @td.skip_if_installed("pyarrow") def test_pyarrow_not_installed_raises(): - msg = re.escape("pyarrow>=10.0.1 is required for PyArrow backed") + msg = re.escape("pyarrow>=12.0.1 is required for PyArrow backed") with pytest.raises(ImportError, match=msg): StringDtype(storage="pyarrow") diff --git a/pandas/tests/copy_view/test_astype.py b/pandas/tests/copy_view/test_astype.py index 91f5badeb9728..90f662eeec5ca 100644 --- a/pandas/tests/copy_view/test_astype.py +++ b/pandas/tests/copy_view/test_astype.py @@ -4,7 +4,7 @@ import pytest from pandas.compat import HAS_PYARROW -from pandas.compat.pyarrow import pa_version_under12p0 +from pandas.compat.pyarrow import pa_version_under12p1 from pandas import ( DataFrame, @@ -196,7 +196,7 @@ def test_astype_arrow_timestamp(): ) result = df.astype("timestamp[ns][pyarrow]") assert not result._mgr._has_no_reference(0) - if pa_version_under12p0: + if pa_version_under12p1: assert not np.shares_memory( get_array(df, "a"), get_array(result, "a")._pa_array ) diff --git a/pandas/tests/extension/test_arrow.py b/pandas/tests/extension/test_arrow.py index e0632722df808..1bec5f7303355 100644 --- a/pandas/tests/extension/test_arrow.py +++ b/pandas/tests/extension/test_arrow.py @@ -39,7 +39,6 @@ PY312, is_ci_environment, is_platform_windows, - pa_version_under11p0, pa_version_under13p0, pa_version_under14p0, pa_version_under19p0, @@ -68,10 +67,7 @@ pa = pytest.importorskip("pyarrow") -from pandas.core.arrays.arrow.array import ( - ArrowExtensionArray, - get_unit_from_pa_dtype, -) +from pandas.core.arrays.arrow.array import ArrowExtensionArray from pandas.core.arrays.arrow.extension_types import ArrowPeriodType @@ -353,15 +349,6 @@ def test_from_sequence_of_strings_pa_array(self, data, request): reason="Nanosecond time parsing not supported.", ) ) - elif pa_version_under11p0 and ( - pa.types.is_duration(pa_dtype) or pa.types.is_decimal(pa_dtype) - ): - request.applymarker( - pytest.mark.xfail( - raises=pa.ArrowNotImplementedError, - reason=f"pyarrow doesn't support parsing {pa_dtype}", - ) - ) elif pa.types.is_timestamp(pa_dtype) and pa_dtype.tz is not None: _require_timezone_database(request) @@ -549,8 +536,7 @@ def _get_expected_reduction_dtype(self, arr, op_name: str, skipna: bool): elif pa.types.is_date(pa_type): cmp_dtype = ArrowDtype(pa.duration("s")) elif pa.types.is_time(pa_type): - unit = get_unit_from_pa_dtype(pa_type) - cmp_dtype = ArrowDtype(pa.duration(unit)) + cmp_dtype = ArrowDtype(pa.duration(pa_type.unit)) else: cmp_dtype = ArrowDtype(pa.duration(pa_type.unit)) else: @@ -3288,9 +3274,6 @@ def test_pow_missing_operand(): tm.assert_series_equal(result, expected) -@pytest.mark.skipif( - pa_version_under11p0, reason="Decimal128 to string cast implemented in pyarrow 11" -) def test_decimal_parse_raises(): # GH 56984 ser = pd.Series(["1.2345"], dtype=ArrowDtype(pa.string())) @@ -3300,9 +3283,6 @@ def test_decimal_parse_raises(): ser.astype(ArrowDtype(pa.decimal128(1, 0))) -@pytest.mark.skipif( - pa_version_under11p0, reason="Decimal128 to string cast implemented in pyarrow 11" -) def test_decimal_parse_succeeds(): # GH 56984 ser = pd.Series(["1.2345"], dtype=ArrowDtype(pa.string())) diff --git a/pandas/tests/groupby/test_groupby_dropna.py b/pandas/tests/groupby/test_groupby_dropna.py index 8c4ab42b7be7a..724ee0489f0a0 100644 --- a/pandas/tests/groupby/test_groupby_dropna.py +++ b/pandas/tests/groupby/test_groupby_dropna.py @@ -1,7 +1,7 @@ import numpy as np import pytest -from pandas.compat.pyarrow import pa_version_under10p1 +import pandas.util._test_decorators as td from pandas.core.dtypes.missing import na_value_for_dtype @@ -411,12 +411,7 @@ def test_groupby_drop_nan_with_multi_index(): "Float64", "category", "string", - pytest.param( - "string[pyarrow]", - marks=pytest.mark.skipif( - pa_version_under10p1, reason="pyarrow is not installed" - ), - ), + pytest.param("string[pyarrow]", marks=td.skip_if_no("pyarrow")), "datetime64[ns]", "period[D]", "Sparse[float]", diff --git a/pandas/tests/interchange/test_impl.py b/pandas/tests/interchange/test_impl.py index a41d7dec8b496..bf746a9eaa976 100644 --- a/pandas/tests/interchange/test_impl.py +++ b/pandas/tests/interchange/test_impl.py @@ -282,7 +282,7 @@ def test_empty_pyarrow(data): def test_multi_chunk_pyarrow() -> None: - pa = pytest.importorskip("pyarrow", "11.0.0") + pa = pytest.importorskip("pyarrow", "14.0.0") n_legs = pa.chunked_array([[2, 2, 4], [4, 5, 100]]) names = ["n_legs"] table = pa.table([n_legs], names=names) @@ -488,7 +488,7 @@ def test_pandas_nullable_with_missing_values( ) -> None: # https://github.com/pandas-dev/pandas/issues/57643 # https://github.com/pandas-dev/pandas/issues/57664 - pa = pytest.importorskip("pyarrow", "11.0.0") + pa = pytest.importorskip("pyarrow", "14.0.0") import pyarrow.interchange as pai if expected_dtype == "timestamp[us, tz=Asia/Kathmandu]": @@ -554,7 +554,7 @@ def test_pandas_nullable_without_missing_values( data: list, dtype: str, expected_dtype: str ) -> None: # https://github.com/pandas-dev/pandas/issues/57643 - pa = pytest.importorskip("pyarrow", "11.0.0") + pa = pytest.importorskip("pyarrow", "14.0.0") import pyarrow.interchange as pai if expected_dtype == "timestamp[us, tz=Asia/Kathmandu]": diff --git a/pandas/tests/io/test_parquet.py b/pandas/tests/io/test_parquet.py index fefed34894cf3..9f9304c8d1664 100644 --- a/pandas/tests/io/test_parquet.py +++ b/pandas/tests/io/test_parquet.py @@ -13,7 +13,6 @@ from pandas.compat import is_platform_windows from pandas.compat.pyarrow import ( - pa_version_under11p0, pa_version_under13p0, pa_version_under15p0, pa_version_under17p0, @@ -729,7 +728,7 @@ def test_to_bytes_without_path_or_buf_provided(self, pa, df_full): expected = df_full.copy() expected.loc[1, "string_with_nan"] = None - if pa_version_under11p0: + if pa_version_under13p0: expected["datetime_with_nat"] = expected["datetime_with_nat"].astype( "M8[ns]" ) @@ -980,15 +979,12 @@ def test_additional_extension_types(self, pa): def test_timestamp_nanoseconds(self, pa): # with version 2.6, pyarrow defaults to writing the nanoseconds, so - # this should work without error - # Note in previous pyarrows(<7.0.0), only the pseudo-version 2.0 was available + # this should work without error, even for pyarrow < 13 ver = "2.6" df = pd.DataFrame({"a": pd.date_range("2017-01-01", freq="1ns", periods=10)}) check_round_trip(df, pa, write_kwargs={"version": ver}) def test_timezone_aware_index(self, pa, timezone_aware_date_list): - pytest.importorskip("pyarrow", "11.0.0") - idx = 5 * [timezone_aware_date_list] df = pd.DataFrame(index=idx, data={"index_as_col": idx}) @@ -1003,7 +999,7 @@ def test_timezone_aware_index(self, pa, timezone_aware_date_list): # this use-case sets the resolution to 1 minute expected = df[:] - if pa_version_under11p0: + if pa_version_under13p0: expected.index = expected.index.as_unit("ns") if timezone_aware_date_list.tzinfo != datetime.timezone.utc: # pyarrow returns pytz.FixedOffset while pandas constructs datetime.timezone @@ -1140,7 +1136,6 @@ def test_string_inference(self, tmp_path, pa, using_infer_string): ) tm.assert_frame_equal(result, expected) - @pytest.mark.skipif(pa_version_under11p0, reason="not supported before 11.0") def test_roundtrip_decimal(self, tmp_path, pa): # GH#54768 import pyarrow as pa @@ -1189,7 +1184,7 @@ def test_infer_string_large_string_type(self, tmp_path, pa): def test_non_nanosecond_timestamps(self, temp_file): # GH#49236 - pa = pytest.importorskip("pyarrow", "11.0.0") + pa = pytest.importorskip("pyarrow", "13.0.0") pq = pytest.importorskip("pyarrow.parquet") arr = pa.array([datetime.datetime(1600, 1, 1)], type=pa.timestamp("us")) diff --git a/pandas/tests/series/accessors/test_list_accessor.py b/pandas/tests/series/accessors/test_list_accessor.py index bec8ca13a2f5f..3541592e7c51e 100644 --- a/pandas/tests/series/accessors/test_list_accessor.py +++ b/pandas/tests/series/accessors/test_list_accessor.py @@ -10,8 +10,6 @@ pa = pytest.importorskip("pyarrow") -from pandas.compat import pa_version_under11p0 - @pytest.mark.parametrize( "list_dtype", @@ -57,20 +55,14 @@ def test_list_getitem_slice(): index=[1, 3, 7], name="a", ) - if pa_version_under11p0: - with pytest.raises( - NotImplementedError, match="List slice not supported by pyarrow " - ): - ser.list[1:None:None] - else: - actual = ser.list[1:None:None] - expected = Series( - [[2, 3], [None, 5], None], - dtype=ArrowDtype(pa.list_(pa.int64())), - index=[1, 3, 7], - name="a", - ) - tm.assert_series_equal(actual, expected) + actual = ser.list[1:None:None] + expected = Series( + [[2, 3], [None, 5], None], + dtype=ArrowDtype(pa.list_(pa.int64())), + index=[1, 3, 7], + name="a", + ) + tm.assert_series_equal(actual, expected) def test_list_len(): @@ -105,14 +97,8 @@ def test_list_getitem_slice_invalid(): [[1, 2, 3], [4, None, 5], None], dtype=ArrowDtype(pa.list_(pa.int64())), ) - if pa_version_under11p0: - with pytest.raises( - NotImplementedError, match="List slice not supported by pyarrow " - ): - ser.list[1:None:0] - else: - with pytest.raises(pa.lib.ArrowInvalid, match=re.escape("`step` must be >= 1")): - ser.list[1:None:0] + with pytest.raises(pa.lib.ArrowInvalid, match=re.escape("`step` must be >= 1")): + ser.list[1:None:0] def test_list_accessor_non_list_dtype(): diff --git a/pandas/tests/series/accessors/test_struct_accessor.py b/pandas/tests/series/accessors/test_struct_accessor.py index 80aea75fda406..c1ef1b14ec3d0 100644 --- a/pandas/tests/series/accessors/test_struct_accessor.py +++ b/pandas/tests/series/accessors/test_struct_accessor.py @@ -2,10 +2,7 @@ import pytest -from pandas.compat.pyarrow import ( - pa_version_under11p0, - pa_version_under13p0, -) +from pandas.compat.pyarrow import pa_version_under13p0 from pandas import ( ArrowDtype, @@ -105,7 +102,6 @@ def test_struct_accessor_field_with_invalid_name_or_index(): ser.struct.field(1.1) -@pytest.mark.skipif(pa_version_under11p0, reason="pyarrow>=11.0.0 required") def test_struct_accessor_explode(): index = Index([-100, 42, 123]) ser = Series( diff --git a/pyproject.toml b/pyproject.toml index b17a1eacfa717..7582e2bce3879 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,15 +59,15 @@ matplotlib = "pandas:plotting._matplotlib" [project.optional-dependencies] test = ['hypothesis>=6.84.0', 'pytest>=7.3.2', 'pytest-xdist>=3.4.0'] -pyarrow = ['pyarrow>=10.0.1'] +pyarrow = ['pyarrow>=12.0.1'] performance = ['bottleneck>=1.3.6', 'numba>=0.59.0', 'numexpr>=2.9.0'] computation = ['scipy>=1.12.0', 'xarray>=2024.1.1'] fss = ['fsspec>=2023.12.2'] aws = ['s3fs>=2023.12.2'] gcp = ['gcsfs>=2023.12.2'] excel = ['odfpy>=1.4.1', 'openpyxl>=3.1.2', 'python-calamine>=0.1.7', 'pyxlsb>=1.0.10', 'xlrd>=2.0.1', 'xlsxwriter>=3.2.0'] -parquet = ['pyarrow>=10.0.1'] -feather = ['pyarrow>=10.0.1'] +parquet = ['pyarrow>=12.0.1'] +feather = ['pyarrow>=12.0.1'] iceberg = ['pyiceberg>=0.7.1'] hdf5 = ['tables>=3.8.0'] spss = ['pyreadstat>=1.2.6'] @@ -98,7 +98,7 @@ all = ['adbc-driver-postgresql>=0.10.0', 'odfpy>=1.4.1', 'openpyxl>=3.1.2', 'psycopg2>=2.9.6', - 'pyarrow>=10.0.1', + 'pyarrow>=12.0.1', 'pyiceberg>=0.7.1', 'pymysql>=1.1.0', 'PyQt5>=5.15.9', diff --git a/requirements-dev.txt b/requirements-dev.txt index 64a9ecdacfb45..b0f8819befbe9 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -32,7 +32,7 @@ numexpr>=2.9.0 openpyxl>=3.1.2 odfpy>=1.4.1 psycopg2-binary>=2.9.6 -pyarrow>=10.0.1 +pyarrow>=12.0.1 pyiceberg>=0.7.1 pymysql>=1.1.0 pyreadstat>=1.2.6 diff --git a/scripts/validate_unwanted_patterns.py b/scripts/validate_unwanted_patterns.py index d804e15f6d48f..8475747a80367 100755 --- a/scripts/validate_unwanted_patterns.py +++ b/scripts/validate_unwanted_patterns.py @@ -53,6 +53,7 @@ "_get_option", "_fill_limit_area_1d", "_make_block", + "_DatetimeTZBlock", } From b91fa1d729cf54c6cd3a19f8d7b8041edf44387b Mon Sep 17 00:00:00 2001 From: jbrockmendel Date: Thu, 3 Jul 2025 09:15:59 -0700 Subject: [PATCH 04/51] DEPR: object inference in to_stata (#56536) * DEPR: object inference in to_stata * Whatsnew * Fix broken test * alphabetize --- doc/source/whatsnew/v3.0.0.rst | 1 + pandas/io/stata.py | 12 ++++++++++-- pandas/tests/io/test_stata.py | 8 +++++++- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/doc/source/whatsnew/v3.0.0.rst b/doc/source/whatsnew/v3.0.0.rst index 57dce003c2846..94e375615d122 100644 --- a/doc/source/whatsnew/v3.0.0.rst +++ b/doc/source/whatsnew/v3.0.0.rst @@ -456,6 +456,7 @@ Other Deprecations - Deprecated allowing non-keyword arguments in :meth:`Series.to_string` except ``buf``. (:issue:`57280`) - Deprecated behavior of :meth:`.DataFrameGroupBy.groups` and :meth:`.SeriesGroupBy.groups`, in a future version ``groups`` by one element list will return tuple instead of scalar. (:issue:`58858`) - Deprecated behavior of :meth:`Series.dt.to_pytimedelta`, in a future version this will return a :class:`Series` containing python ``datetime.timedelta`` objects instead of an ``ndarray`` of timedelta; this matches the behavior of other :meth:`Series.dt` properties. (:issue:`57463`) +- Deprecated converting object-dtype columns of ``datetime.datetime`` objects to datetime64 when writing to stata (:issue:`56536`) - Deprecated lowercase strings ``d``, ``b`` and ``c`` denoting frequencies in :class:`Day`, :class:`BusinessDay` and :class:`CustomBusinessDay` in favour of ``D``, ``B`` and ``C`` (:issue:`58998`) - Deprecated lowercase strings ``w``, ``w-mon``, ``w-tue``, etc. denoting frequencies in :class:`Week` in favour of ``W``, ``W-MON``, ``W-TUE``, etc. (:issue:`58998`) - Deprecated parameter ``method`` in :meth:`DataFrame.reindex_like` / :meth:`Series.reindex_like` (:issue:`58667`) diff --git a/pandas/io/stata.py b/pandas/io/stata.py index 092c24f0d31c3..08177e76ee237 100644 --- a/pandas/io/stata.py +++ b/pandas/io/stata.py @@ -393,14 +393,22 @@ def parse_dates_safe( d["days"] = np.asarray(diff).astype("m8[D]").view("int64") elif infer_dtype(dates, skipna=False) == "datetime": + warnings.warn( + # GH#56536 + "Converting object-dtype columns of datetimes to datetime64 when " + "writing to stata is deprecated. Call " + "`df=df.infer_objects(copy=False)` before writing to stata instead.", + FutureWarning, + stacklevel=find_stack_level(), + ) if delta: delta = dates._values - stata_epoch def f(x: timedelta) -> float: - return US_PER_DAY * x.days + 1000000 * x.seconds + x.microseconds + return US_PER_DAY * x.days + 1_000_000 * x.seconds + x.microseconds v = np.vectorize(f) - d["delta"] = v(delta) + d["delta"] = v(delta) // 1_000 # convert back to ms if year: year_month = dates.apply(lambda x: 100 * x.year + x.month) d["year"] = year_month._values // 100 diff --git a/pandas/tests/io/test_stata.py b/pandas/tests/io/test_stata.py index b155c0cca4aa6..90fda2c10962b 100644 --- a/pandas/tests/io/test_stata.py +++ b/pandas/tests/io/test_stata.py @@ -1030,7 +1030,13 @@ def test_big_dates(self, datapath, temp_file): # {c : c[-2:] for c in columns} path = temp_file expected.index.name = "index" - expected.to_stata(path, convert_dates=date_conversion) + msg = ( + "Converting object-dtype columns of datetimes to datetime64 " + "when writing to stata is deprecated" + ) + exp_object = expected.astype(object) + with tm.assert_produces_warning(FutureWarning, match=msg): + exp_object.to_stata(path, convert_dates=date_conversion) written_and_read_again = self.read_dta(path) tm.assert_frame_equal( From 9dcce6330f22f48e51fc3f8301d23b6c3e784f6a Mon Sep 17 00:00:00 2001 From: Marc Garcia Date: Thu, 3 Jul 2025 19:02:50 +0200 Subject: [PATCH 05/51] ENH: Allow third-party packages to register IO engines (#61642) --- doc/source/development/extending.rst | 63 +++++++++++ doc/source/whatsnew/v3.0.0.rst | 1 + pandas/core/frame.py | 11 +- pandas/io/common.py | 152 +++++++++++++++++++++++++++ pandas/io/iceberg.py | 8 ++ pandas/tests/io/test_io_engines.py | 105 ++++++++++++++++++ web/pandas/community/ecosystem.md | 13 +++ 7 files changed, 352 insertions(+), 1 deletion(-) create mode 100644 pandas/tests/io/test_io_engines.py diff --git a/doc/source/development/extending.rst b/doc/source/development/extending.rst index e67829b8805eb..e08f35d8748b1 100644 --- a/doc/source/development/extending.rst +++ b/doc/source/development/extending.rst @@ -489,6 +489,69 @@ registers the default "matplotlib" backend as follows. More information on how to implement a third-party plotting backend can be found at https://github.com/pandas-dev/pandas/blob/main/pandas/plotting/__init__.py#L1. +.. _extending.io-engines: + +IO engines +----------- + +pandas provides several IO connectors such as :func:`read_csv` or :meth:`DataFrame.to_parquet`, and many +of those support multiple engines. For example, :func:`read_csv` supports the ``python``, ``c`` +and ``pyarrow`` engines, each with its advantages and disadvantages, making each more appropriate +for certain use cases. + +Third-party package developers can implement engines for any of the pandas readers and writers. +When a ``pandas.read_*`` function or ``DataFrame.to_*`` method are called with an ``engine=""`` +that is not known to pandas, pandas will look into the entry points registered in the group +``pandas.io_engine`` by the packages in the environment, and will call the corresponding method. + +An engine is a simple Python class which implements one or more of the pandas readers and writers +as class methods: + +.. code-block:: python + + class EmptyDataEngine: + @classmethod + def read_json(cls, path_or_buf=None, **kwargs): + return pd.DataFrame() + + @classmethod + def to_json(cls, path_or_buf=None, **kwargs): + with open(path_or_buf, "w") as f: + f.write() + + @classmethod + def read_clipboard(cls, sep='\\s+', dtype_backend=None, **kwargs): + return pd.DataFrame() + +A single engine can support multiple readers and writers. When possible, it is a good practice for +a reader to provide both a reader and writer for the supported formats. But it is possible to +provide just one of them. + +The package implementing the engine needs to create an entry point for pandas to be able to discover +it. This is done in ``pyproject.toml``: + +```toml +[project.entry-points."pandas.io_engine"] +empty = empty_data:EmptyDataEngine +``` + +The first line should always be the same, creating the entry point in the ``pandas.io_engine`` group. +In the second line, ``empty`` is the name of the engine, and ``empty_data:EmptyDataEngine`` is where +to find the engine class in the package (``empty_data`` is the module name in this case). + +If a user has the package of the example installed, them it would be possible to use: + +.. code-block:: python + + pd.read_json("myfile.json", engine="empty") + +When pandas detects that no ``empty`` engine exists for the ``read_json`` reader in pandas, it will +look at the entry points, will find the ``EmptyDataEngine`` engine, and will call the ``read_json`` +method on it with the arguments provided by the user (except the ``engine`` parameter). + +To avoid conflicts in the names of engines, we keep an "IO engines" section in our +`Ecosystem page `_. + .. _extending.pandas_priority: Arithmetic with 3rd party types diff --git a/doc/source/whatsnew/v3.0.0.rst b/doc/source/whatsnew/v3.0.0.rst index 94e375615d122..788c83ffe6a68 100644 --- a/doc/source/whatsnew/v3.0.0.rst +++ b/doc/source/whatsnew/v3.0.0.rst @@ -94,6 +94,7 @@ Other enhancements - Support passing a :class:`Iterable[Hashable]` input to :meth:`DataFrame.drop_duplicates` (:issue:`59237`) - Support reading Stata 102-format (Stata 1) dta files (:issue:`58978`) - Support reading Stata 110-format (Stata 7) dta files (:issue:`47176`) +- Third-party packages can now register engines that can be used in pandas I/O operations :func:`read_iceberg` and :meth:`DataFrame.to_iceberg` (:issue:`61584`) .. --------------------------------------------------------------------------- .. _whatsnew_300.notable_bug_fixes: diff --git a/pandas/core/frame.py b/pandas/core/frame.py index 8053c17437c5e..9215e6f1ceadf 100644 --- a/pandas/core/frame.py +++ b/pandas/core/frame.py @@ -188,7 +188,10 @@ nargsort, ) -from pandas.io.common import get_handle +from pandas.io.common import ( + allow_third_party_engines, + get_handle, +) from pandas.io.formats import ( console, format as fmt, @@ -3547,6 +3550,7 @@ def to_xml( return xml_formatter.write_output() + @allow_third_party_engines def to_iceberg( self, table_identifier: str, @@ -3556,6 +3560,7 @@ def to_iceberg( location: str | None = None, append: bool = False, snapshot_properties: dict[str, str] | None = None, + engine: str | None = None, ) -> None: """ Write a DataFrame to an Apache Iceberg table. @@ -3580,6 +3585,10 @@ def to_iceberg( If ``True``, append data to the table, instead of replacing the content. snapshot_properties : dict of {str: str}, optional Custom properties to be added to the snapshot summary + engine : str, optional + The engine to use. Engines can be installed via third-party packages. For an + updated list of existing pandas I/O engines check the I/O engines section of + the pandas Ecosystem page. See Also -------- diff --git a/pandas/io/common.py b/pandas/io/common.py index 1a9e6b472463d..3ec9e094fb118 100644 --- a/pandas/io/common.py +++ b/pandas/io/common.py @@ -9,6 +9,7 @@ import codecs from collections import defaultdict from collections.abc import ( + Callable, Hashable, Mapping, Sequence, @@ -16,6 +17,7 @@ import dataclasses import functools import gzip +from importlib.metadata import entry_points from io import ( BufferedIOBase, BytesIO, @@ -90,6 +92,10 @@ from pandas import MultiIndex +# registry of I/O engines. It is populated the first time a non-core +# pandas engine is used +_io_engines: dict[str, Any] | None = None + @dataclasses.dataclass class IOArgs: @@ -1282,3 +1288,149 @@ def dedup_names( counts[col] = cur_count + 1 return names + + +def _get_io_engine(name: str) -> Any: + """ + Return an I/O engine by its name. + + pandas I/O engines can be registered via entry points. The first time this + function is called it will register all the entry points of the "pandas.io_engine" + group and cache them in the global `_io_engines` variable. + + Engines are implemented as classes with the `read_` and `to_` + methods (classmethods) for the formats they wish to provide. This function will + return the method from the engine and format being requested. + + Parameters + ---------- + name : str + The engine name provided by the user in `engine=`. + + Examples + -------- + An engine is implemented with a class like: + + >>> class DummyEngine: + ... @classmethod + ... def read_csv(cls, filepath_or_buffer, **kwargs): + ... # the engine signature must match the pandas method signature + ... return pd.DataFrame() + + It must be registered as an entry point with the engine name: + + ``` + [project.entry-points."pandas.io_engine"] + dummy = "pandas:io.dummy.DummyEngine" + + ``` + + Then the `read_csv` method of the engine can be used with: + + >>> _get_io_engine(engine_name="dummy").read_csv("myfile.csv") # doctest: +SKIP + + This is used internally to dispatch the next pandas call to the engine caller: + + >>> df = read_csv("myfile.csv", engine="dummy") # doctest: +SKIP + """ + global _io_engines + + if _io_engines is None: + _io_engines = {} + for entry_point in entry_points().select(group="pandas.io_engine"): + if entry_point.dist: + package_name = entry_point.dist.metadata["Name"] + else: + package_name = None + if entry_point.name in _io_engines: + _io_engines[entry_point.name]._packages.append(package_name) + else: + _io_engines[entry_point.name] = entry_point.load() + _io_engines[entry_point.name]._packages = [package_name] + + try: + engine = _io_engines[name] + except KeyError as err: + raise ValueError( + f"'{name}' is not a known engine. Some engines are only available " + "after installing the package that provides them." + ) from err + + if len(engine._packages) > 1: + msg = ( + f"The engine '{name}' has been registered by the package " + f"'{engine._packages[0]}' and will be used. " + ) + if len(engine._packages) == 2: + msg += ( + f"The package '{engine._packages[1]}' also tried to register " + "the engine, but it couldn't because it was already registered." + ) + else: + msg += ( + "The packages {str(engine._packages[1:]}[1:-1] also tried to register " + "the engine, but they couldn't because it was already registered." + ) + warnings.warn(msg, RuntimeWarning, stacklevel=find_stack_level()) + + return engine + + +def allow_third_party_engines( + skip_engines: list[str] | Callable | None = None, +) -> Callable: + """ + Decorator to avoid boilerplate code when allowing readers and writers to use + third-party engines. + + The decorator will introspect the function to know which format should be obtained, + and to know if it's a reader or a writer. Then it will check if the engine has been + registered, and if it has, it will dispatch the execution to the engine with the + arguments provided by the user. + + Parameters + ---------- + skip_engines : list of str, optional + For engines that are implemented in pandas, we want to skip them for this engine + dispatching system. They should be specified in this parameter. + + Examples + -------- + The decorator works both with the `skip_engines` parameter, or without: + + >>> class DataFrame: + ... @allow_third_party_engines(["python", "c", "pyarrow"]) + ... def read_csv(filepath_or_buffer, **kwargs): + ... pass + ... + ... @allow_third_party_engines + ... def read_sas(filepath_or_buffer, **kwargs): + ... pass + """ + + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> Any: + if callable(skip_engines) or skip_engines is None: + skip_engine = False + else: + skip_engine = kwargs["engine"] in skip_engines + + if "engine" in kwargs and not skip_engine: + engine_name = kwargs.pop("engine") + engine = _get_io_engine(engine_name) + try: + return getattr(engine, func.__name__)(*args, **kwargs) + except AttributeError as err: + raise ValueError( + f"The engine '{engine_name}' does not provide a " + f"'{func.__name__}' function" + ) from err + else: + return func(*args, **kwargs) + + return wrapper + + if callable(skip_engines): + return decorator(skip_engines) + return decorator diff --git a/pandas/io/iceberg.py b/pandas/io/iceberg.py index dcb675271031e..c778a95809f97 100644 --- a/pandas/io/iceberg.py +++ b/pandas/io/iceberg.py @@ -6,7 +6,10 @@ from pandas import DataFrame +from pandas.io.common import allow_third_party_engines + +@allow_third_party_engines def read_iceberg( table_identifier: str, catalog_name: str | None = None, @@ -18,6 +21,7 @@ def read_iceberg( snapshot_id: int | None = None, limit: int | None = None, scan_properties: dict[str, Any] | None = None, + engine: str | None = None, ) -> DataFrame: """ Read an Apache Iceberg table into a pandas DataFrame. @@ -52,6 +56,10 @@ def read_iceberg( scan_properties : dict of {str: obj}, optional Additional Table properties as a dictionary of string key value pairs to use for this scan. + engine : str, optional + The engine to use. Engines can be installed via third-party packages. For an + updated list of existing pandas I/O engines check the I/O engines section of + our Ecosystem page. Returns ------- diff --git a/pandas/tests/io/test_io_engines.py b/pandas/tests/io/test_io_engines.py new file mode 100644 index 0000000000000..ccf4bff6533e8 --- /dev/null +++ b/pandas/tests/io/test_io_engines.py @@ -0,0 +1,105 @@ +from types import SimpleNamespace + +import pytest + +import pandas._testing as tm + +from pandas.io import common + + +class _MockIoEngine: + @classmethod + def read_foo(cls, fname): + return "third-party" + + +@pytest.fixture +def patch_engine(monkeypatch): + monkeypatch.setattr(common, "_get_io_engine", lambda name: _MockIoEngine) + + +@pytest.fixture +def patch_entry_points(monkeypatch): + class MockEntryPoint: + name = "myengine" + dist = SimpleNamespace(metadata={"Name": "mypackage"}) + + @staticmethod + def load(): + return _MockIoEngine + + class MockDuplicate1: + name = "duplicate" + dist = SimpleNamespace(metadata={"Name": "package1"}) + + @staticmethod + def load(): + return SimpleNamespace(read_foo=lambda fname: "dup1") + + class MockDuplicate2: + name = "duplicate" + dist = SimpleNamespace(metadata={"Name": "package2"}) + + @staticmethod + def load(): + return SimpleNamespace(read_foo=lambda fname: "dup1") + + monkeypatch.setattr(common, "_io_engines", None) + monkeypatch.setattr( + common, + "entry_points", + lambda: SimpleNamespace( + select=lambda group: [MockEntryPoint, MockDuplicate1, MockDuplicate2] + ), + ) + + +class TestIoEngines: + def test_decorator_with_no_engine(self, patch_engine): + @common.allow_third_party_engines + def read_foo(fname, engine=None): + return "default" + + result = read_foo("myfile.foo") + assert result == "default" + + def test_decorator_with_skipped_engine(self, patch_engine): + @common.allow_third_party_engines(skip_engines=["c"]) + def read_foo(fname, engine=None): + return "default" + + result = read_foo("myfile.foo", engine="c") + assert result == "default" + + def test_decorator_with_third_party_engine(self, patch_engine): + @common.allow_third_party_engines + def read_foo(fname, engine=None): + return "default" + + result = read_foo("myfile.foo", engine="third-party") + assert result == "third-party" + + def test_decorator_with_third_party_engine_but_no_method(self, patch_engine): + @common.allow_third_party_engines + def read_bar(fname, engine=None): + return "default" + + msg = "'third-party' does not provide a 'read_bar'" + with pytest.raises(ValueError, match=msg): + read_bar("myfile.foo", engine="third-party") + + def test_correct_io_engine(self, patch_entry_points): + result = common._get_io_engine("myengine") + assert result is _MockIoEngine + + def test_unknown_io_engine(self, patch_entry_points): + with pytest.raises(ValueError, match="'unknown' is not a known engine"): + common._get_io_engine("unknown") + + def test_duplicate_engine(self, patch_entry_points): + with tm.assert_produces_warning( + RuntimeWarning, + match="'duplicate' has been registered by the package 'package1'", + ): + result = common._get_io_engine("duplicate") + assert hasattr(result, "read_foo") diff --git a/web/pandas/community/ecosystem.md b/web/pandas/community/ecosystem.md index 78c239ac4f690..2ebb1b062a507 100644 --- a/web/pandas/community/ecosystem.md +++ b/web/pandas/community/ecosystem.md @@ -158,6 +158,19 @@ Plotly can be used as a pandas plotting backend via: pd.set_option("plotting.backend", "plotly") ``` +### IO engines + +Table with the third-party [IO engines](https://pandas.pydata.org/docs/development/extending.html#io-engines) +available to `read_*` functions and `DataFrame.to_*` methods. + + | Engine name | Library | Supported formats | + | ----------------|------------------------------------------------------ | ------------------------------- | + | | | | + +IO engines can be used by specifying the engine when calling a reader or writer +(e.g. `pd.read_csv("myfile.csv", engine="myengine")`). + + ## Domain specific pandas extensions #### [Geopandas](https://github.com/geopandas/geopandas) From 391107a9545b10ead39b1857636ab7630dea8575 Mon Sep 17 00:00:00 2001 From: jbrockmendel Date: Thu, 3 Jul 2025 10:07:38 -0700 Subject: [PATCH 06/51] Revert "ENH: Allow third-party packages to register IO engines" (#61767) Revert "ENH: Allow third-party packages to register IO engines (#61642)" This reverts commit 9dcce6330f22f48e51fc3f8301d23b6c3e784f6a. --- doc/source/development/extending.rst | 63 ----------- doc/source/whatsnew/v3.0.0.rst | 1 - pandas/core/frame.py | 11 +- pandas/io/common.py | 152 --------------------------- pandas/io/iceberg.py | 8 -- pandas/tests/io/test_io_engines.py | 105 ------------------ web/pandas/community/ecosystem.md | 13 --- 7 files changed, 1 insertion(+), 352 deletions(-) delete mode 100644 pandas/tests/io/test_io_engines.py diff --git a/doc/source/development/extending.rst b/doc/source/development/extending.rst index e08f35d8748b1..e67829b8805eb 100644 --- a/doc/source/development/extending.rst +++ b/doc/source/development/extending.rst @@ -489,69 +489,6 @@ registers the default "matplotlib" backend as follows. More information on how to implement a third-party plotting backend can be found at https://github.com/pandas-dev/pandas/blob/main/pandas/plotting/__init__.py#L1. -.. _extending.io-engines: - -IO engines ------------ - -pandas provides several IO connectors such as :func:`read_csv` or :meth:`DataFrame.to_parquet`, and many -of those support multiple engines. For example, :func:`read_csv` supports the ``python``, ``c`` -and ``pyarrow`` engines, each with its advantages and disadvantages, making each more appropriate -for certain use cases. - -Third-party package developers can implement engines for any of the pandas readers and writers. -When a ``pandas.read_*`` function or ``DataFrame.to_*`` method are called with an ``engine=""`` -that is not known to pandas, pandas will look into the entry points registered in the group -``pandas.io_engine`` by the packages in the environment, and will call the corresponding method. - -An engine is a simple Python class which implements one or more of the pandas readers and writers -as class methods: - -.. code-block:: python - - class EmptyDataEngine: - @classmethod - def read_json(cls, path_or_buf=None, **kwargs): - return pd.DataFrame() - - @classmethod - def to_json(cls, path_or_buf=None, **kwargs): - with open(path_or_buf, "w") as f: - f.write() - - @classmethod - def read_clipboard(cls, sep='\\s+', dtype_backend=None, **kwargs): - return pd.DataFrame() - -A single engine can support multiple readers and writers. When possible, it is a good practice for -a reader to provide both a reader and writer for the supported formats. But it is possible to -provide just one of them. - -The package implementing the engine needs to create an entry point for pandas to be able to discover -it. This is done in ``pyproject.toml``: - -```toml -[project.entry-points."pandas.io_engine"] -empty = empty_data:EmptyDataEngine -``` - -The first line should always be the same, creating the entry point in the ``pandas.io_engine`` group. -In the second line, ``empty`` is the name of the engine, and ``empty_data:EmptyDataEngine`` is where -to find the engine class in the package (``empty_data`` is the module name in this case). - -If a user has the package of the example installed, them it would be possible to use: - -.. code-block:: python - - pd.read_json("myfile.json", engine="empty") - -When pandas detects that no ``empty`` engine exists for the ``read_json`` reader in pandas, it will -look at the entry points, will find the ``EmptyDataEngine`` engine, and will call the ``read_json`` -method on it with the arguments provided by the user (except the ``engine`` parameter). - -To avoid conflicts in the names of engines, we keep an "IO engines" section in our -`Ecosystem page `_. - .. _extending.pandas_priority: Arithmetic with 3rd party types diff --git a/doc/source/whatsnew/v3.0.0.rst b/doc/source/whatsnew/v3.0.0.rst index 788c83ffe6a68..94e375615d122 100644 --- a/doc/source/whatsnew/v3.0.0.rst +++ b/doc/source/whatsnew/v3.0.0.rst @@ -94,7 +94,6 @@ Other enhancements - Support passing a :class:`Iterable[Hashable]` input to :meth:`DataFrame.drop_duplicates` (:issue:`59237`) - Support reading Stata 102-format (Stata 1) dta files (:issue:`58978`) - Support reading Stata 110-format (Stata 7) dta files (:issue:`47176`) -- Third-party packages can now register engines that can be used in pandas I/O operations :func:`read_iceberg` and :meth:`DataFrame.to_iceberg` (:issue:`61584`) .. --------------------------------------------------------------------------- .. _whatsnew_300.notable_bug_fixes: diff --git a/pandas/core/frame.py b/pandas/core/frame.py index 9215e6f1ceadf..8053c17437c5e 100644 --- a/pandas/core/frame.py +++ b/pandas/core/frame.py @@ -188,10 +188,7 @@ nargsort, ) -from pandas.io.common import ( - allow_third_party_engines, - get_handle, -) +from pandas.io.common import get_handle from pandas.io.formats import ( console, format as fmt, @@ -3550,7 +3547,6 @@ def to_xml( return xml_formatter.write_output() - @allow_third_party_engines def to_iceberg( self, table_identifier: str, @@ -3560,7 +3556,6 @@ def to_iceberg( location: str | None = None, append: bool = False, snapshot_properties: dict[str, str] | None = None, - engine: str | None = None, ) -> None: """ Write a DataFrame to an Apache Iceberg table. @@ -3585,10 +3580,6 @@ def to_iceberg( If ``True``, append data to the table, instead of replacing the content. snapshot_properties : dict of {str: str}, optional Custom properties to be added to the snapshot summary - engine : str, optional - The engine to use. Engines can be installed via third-party packages. For an - updated list of existing pandas I/O engines check the I/O engines section of - the pandas Ecosystem page. See Also -------- diff --git a/pandas/io/common.py b/pandas/io/common.py index 3ec9e094fb118..1a9e6b472463d 100644 --- a/pandas/io/common.py +++ b/pandas/io/common.py @@ -9,7 +9,6 @@ import codecs from collections import defaultdict from collections.abc import ( - Callable, Hashable, Mapping, Sequence, @@ -17,7 +16,6 @@ import dataclasses import functools import gzip -from importlib.metadata import entry_points from io import ( BufferedIOBase, BytesIO, @@ -92,10 +90,6 @@ from pandas import MultiIndex -# registry of I/O engines. It is populated the first time a non-core -# pandas engine is used -_io_engines: dict[str, Any] | None = None - @dataclasses.dataclass class IOArgs: @@ -1288,149 +1282,3 @@ def dedup_names( counts[col] = cur_count + 1 return names - - -def _get_io_engine(name: str) -> Any: - """ - Return an I/O engine by its name. - - pandas I/O engines can be registered via entry points. The first time this - function is called it will register all the entry points of the "pandas.io_engine" - group and cache them in the global `_io_engines` variable. - - Engines are implemented as classes with the `read_` and `to_` - methods (classmethods) for the formats they wish to provide. This function will - return the method from the engine and format being requested. - - Parameters - ---------- - name : str - The engine name provided by the user in `engine=`. - - Examples - -------- - An engine is implemented with a class like: - - >>> class DummyEngine: - ... @classmethod - ... def read_csv(cls, filepath_or_buffer, **kwargs): - ... # the engine signature must match the pandas method signature - ... return pd.DataFrame() - - It must be registered as an entry point with the engine name: - - ``` - [project.entry-points."pandas.io_engine"] - dummy = "pandas:io.dummy.DummyEngine" - - ``` - - Then the `read_csv` method of the engine can be used with: - - >>> _get_io_engine(engine_name="dummy").read_csv("myfile.csv") # doctest: +SKIP - - This is used internally to dispatch the next pandas call to the engine caller: - - >>> df = read_csv("myfile.csv", engine="dummy") # doctest: +SKIP - """ - global _io_engines - - if _io_engines is None: - _io_engines = {} - for entry_point in entry_points().select(group="pandas.io_engine"): - if entry_point.dist: - package_name = entry_point.dist.metadata["Name"] - else: - package_name = None - if entry_point.name in _io_engines: - _io_engines[entry_point.name]._packages.append(package_name) - else: - _io_engines[entry_point.name] = entry_point.load() - _io_engines[entry_point.name]._packages = [package_name] - - try: - engine = _io_engines[name] - except KeyError as err: - raise ValueError( - f"'{name}' is not a known engine. Some engines are only available " - "after installing the package that provides them." - ) from err - - if len(engine._packages) > 1: - msg = ( - f"The engine '{name}' has been registered by the package " - f"'{engine._packages[0]}' and will be used. " - ) - if len(engine._packages) == 2: - msg += ( - f"The package '{engine._packages[1]}' also tried to register " - "the engine, but it couldn't because it was already registered." - ) - else: - msg += ( - "The packages {str(engine._packages[1:]}[1:-1] also tried to register " - "the engine, but they couldn't because it was already registered." - ) - warnings.warn(msg, RuntimeWarning, stacklevel=find_stack_level()) - - return engine - - -def allow_third_party_engines( - skip_engines: list[str] | Callable | None = None, -) -> Callable: - """ - Decorator to avoid boilerplate code when allowing readers and writers to use - third-party engines. - - The decorator will introspect the function to know which format should be obtained, - and to know if it's a reader or a writer. Then it will check if the engine has been - registered, and if it has, it will dispatch the execution to the engine with the - arguments provided by the user. - - Parameters - ---------- - skip_engines : list of str, optional - For engines that are implemented in pandas, we want to skip them for this engine - dispatching system. They should be specified in this parameter. - - Examples - -------- - The decorator works both with the `skip_engines` parameter, or without: - - >>> class DataFrame: - ... @allow_third_party_engines(["python", "c", "pyarrow"]) - ... def read_csv(filepath_or_buffer, **kwargs): - ... pass - ... - ... @allow_third_party_engines - ... def read_sas(filepath_or_buffer, **kwargs): - ... pass - """ - - def decorator(func: Callable) -> Callable: - @functools.wraps(func) - def wrapper(*args: Any, **kwargs: Any) -> Any: - if callable(skip_engines) or skip_engines is None: - skip_engine = False - else: - skip_engine = kwargs["engine"] in skip_engines - - if "engine" in kwargs and not skip_engine: - engine_name = kwargs.pop("engine") - engine = _get_io_engine(engine_name) - try: - return getattr(engine, func.__name__)(*args, **kwargs) - except AttributeError as err: - raise ValueError( - f"The engine '{engine_name}' does not provide a " - f"'{func.__name__}' function" - ) from err - else: - return func(*args, **kwargs) - - return wrapper - - if callable(skip_engines): - return decorator(skip_engines) - return decorator diff --git a/pandas/io/iceberg.py b/pandas/io/iceberg.py index c778a95809f97..dcb675271031e 100644 --- a/pandas/io/iceberg.py +++ b/pandas/io/iceberg.py @@ -6,10 +6,7 @@ from pandas import DataFrame -from pandas.io.common import allow_third_party_engines - -@allow_third_party_engines def read_iceberg( table_identifier: str, catalog_name: str | None = None, @@ -21,7 +18,6 @@ def read_iceberg( snapshot_id: int | None = None, limit: int | None = None, scan_properties: dict[str, Any] | None = None, - engine: str | None = None, ) -> DataFrame: """ Read an Apache Iceberg table into a pandas DataFrame. @@ -56,10 +52,6 @@ def read_iceberg( scan_properties : dict of {str: obj}, optional Additional Table properties as a dictionary of string key value pairs to use for this scan. - engine : str, optional - The engine to use. Engines can be installed via third-party packages. For an - updated list of existing pandas I/O engines check the I/O engines section of - our Ecosystem page. Returns ------- diff --git a/pandas/tests/io/test_io_engines.py b/pandas/tests/io/test_io_engines.py deleted file mode 100644 index ccf4bff6533e8..0000000000000 --- a/pandas/tests/io/test_io_engines.py +++ /dev/null @@ -1,105 +0,0 @@ -from types import SimpleNamespace - -import pytest - -import pandas._testing as tm - -from pandas.io import common - - -class _MockIoEngine: - @classmethod - def read_foo(cls, fname): - return "third-party" - - -@pytest.fixture -def patch_engine(monkeypatch): - monkeypatch.setattr(common, "_get_io_engine", lambda name: _MockIoEngine) - - -@pytest.fixture -def patch_entry_points(monkeypatch): - class MockEntryPoint: - name = "myengine" - dist = SimpleNamespace(metadata={"Name": "mypackage"}) - - @staticmethod - def load(): - return _MockIoEngine - - class MockDuplicate1: - name = "duplicate" - dist = SimpleNamespace(metadata={"Name": "package1"}) - - @staticmethod - def load(): - return SimpleNamespace(read_foo=lambda fname: "dup1") - - class MockDuplicate2: - name = "duplicate" - dist = SimpleNamespace(metadata={"Name": "package2"}) - - @staticmethod - def load(): - return SimpleNamespace(read_foo=lambda fname: "dup1") - - monkeypatch.setattr(common, "_io_engines", None) - monkeypatch.setattr( - common, - "entry_points", - lambda: SimpleNamespace( - select=lambda group: [MockEntryPoint, MockDuplicate1, MockDuplicate2] - ), - ) - - -class TestIoEngines: - def test_decorator_with_no_engine(self, patch_engine): - @common.allow_third_party_engines - def read_foo(fname, engine=None): - return "default" - - result = read_foo("myfile.foo") - assert result == "default" - - def test_decorator_with_skipped_engine(self, patch_engine): - @common.allow_third_party_engines(skip_engines=["c"]) - def read_foo(fname, engine=None): - return "default" - - result = read_foo("myfile.foo", engine="c") - assert result == "default" - - def test_decorator_with_third_party_engine(self, patch_engine): - @common.allow_third_party_engines - def read_foo(fname, engine=None): - return "default" - - result = read_foo("myfile.foo", engine="third-party") - assert result == "third-party" - - def test_decorator_with_third_party_engine_but_no_method(self, patch_engine): - @common.allow_third_party_engines - def read_bar(fname, engine=None): - return "default" - - msg = "'third-party' does not provide a 'read_bar'" - with pytest.raises(ValueError, match=msg): - read_bar("myfile.foo", engine="third-party") - - def test_correct_io_engine(self, patch_entry_points): - result = common._get_io_engine("myengine") - assert result is _MockIoEngine - - def test_unknown_io_engine(self, patch_entry_points): - with pytest.raises(ValueError, match="'unknown' is not a known engine"): - common._get_io_engine("unknown") - - def test_duplicate_engine(self, patch_entry_points): - with tm.assert_produces_warning( - RuntimeWarning, - match="'duplicate' has been registered by the package 'package1'", - ): - result = common._get_io_engine("duplicate") - assert hasattr(result, "read_foo") diff --git a/web/pandas/community/ecosystem.md b/web/pandas/community/ecosystem.md index 2ebb1b062a507..78c239ac4f690 100644 --- a/web/pandas/community/ecosystem.md +++ b/web/pandas/community/ecosystem.md @@ -158,19 +158,6 @@ Plotly can be used as a pandas plotting backend via: pd.set_option("plotting.backend", "plotly") ``` -### IO engines - -Table with the third-party [IO engines](https://pandas.pydata.org/docs/development/extending.html#io-engines) -available to `read_*` functions and `DataFrame.to_*` methods. - - | Engine name | Library | Supported formats | - | ----------------|------------------------------------------------------ | ------------------------------- | - | | | | - -IO engines can be used by specifying the engine when calling a reader or writer -(e.g. `pd.read_csv("myfile.csv", engine="myengine")`). - - ## Domain specific pandas extensions #### [Geopandas](https://github.com/geopandas/geopandas) From 51763f94fe47491f4c68d74febb4f493e10b8a04 Mon Sep 17 00:00:00 2001 From: jbrockmendel Date: Thu, 3 Jul 2025 15:49:57 -0700 Subject: [PATCH 07/51] BUG: NA.__and__, __or__, __xor__ with np.bool_ objects (#61768) --- doc/source/whatsnew/v3.0.0.rst | 1 + pandas/_libs/missing.pyx | 10 +++++++++- pandas/tests/scalar/test_na_scalar.py | 18 ++++++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/doc/source/whatsnew/v3.0.0.rst b/doc/source/whatsnew/v3.0.0.rst index 94e375615d122..4154942f92907 100644 --- a/doc/source/whatsnew/v3.0.0.rst +++ b/doc/source/whatsnew/v3.0.0.rst @@ -762,6 +762,7 @@ Indexing Missing ^^^^^^^ - Bug in :meth:`DataFrame.fillna` and :meth:`Series.fillna` that would ignore the ``limit`` argument on :class:`.ExtensionArray` dtypes (:issue:`58001`) +- Bug in :meth:`NA.__and__`, :meth:`NA.__or__` and :meth:`NA.__xor__` when operating with ``np.bool_`` objects (:issue:`58427`) - MultiIndex diff --git a/pandas/_libs/missing.pyx b/pandas/_libs/missing.pyx index 390a527c22bbb..c7f905c4d0be0 100644 --- a/pandas/_libs/missing.pyx +++ b/pandas/_libs/missing.pyx @@ -471,6 +471,10 @@ class NAType(C_NAType): return False elif other is True or other is C_NA: return NA + elif util.is_bool_object(other): + if not other: + return False + return NA return NotImplemented __rand__ = __and__ @@ -480,12 +484,16 @@ class NAType(C_NAType): return True elif other is False or other is C_NA: return NA + elif util.is_bool_object(other): + if not other: + return NA + return True return NotImplemented __ror__ = __or__ def __xor__(self, other): - if other is False or other is True or other is C_NA: + if util.is_bool_object(other) or other is C_NA: return NA return NotImplemented diff --git a/pandas/tests/scalar/test_na_scalar.py b/pandas/tests/scalar/test_na_scalar.py index 287b7557f50f9..d2bc8f521d7bb 100644 --- a/pandas/tests/scalar/test_na_scalar.py +++ b/pandas/tests/scalar/test_na_scalar.py @@ -167,6 +167,12 @@ def test_logical_and(): assert False & NA is False assert NA & NA is NA + # GH#58427 + assert NA & np.bool_(True) is NA + assert np.bool_(True) & NA is NA + assert NA & np.bool_(False) is False + assert np.bool_(False) & NA is False + msg = "unsupported operand type" with pytest.raises(TypeError, match=msg): NA & 5 @@ -179,6 +185,12 @@ def test_logical_or(): assert False | NA is NA assert NA | NA is NA + # GH#58427 + assert NA | np.bool_(True) is True + assert np.bool_(True) | NA is True + assert NA | np.bool_(False) is NA + assert np.bool_(False) | NA is NA + msg = "unsupported operand type" with pytest.raises(TypeError, match=msg): NA | 5 @@ -191,6 +203,12 @@ def test_logical_xor(): assert False ^ NA is NA assert NA ^ NA is NA + # GH#58427 + assert NA ^ np.bool_(True) is NA + assert np.bool_(True) ^ NA is NA + assert NA ^ np.bool_(False) is NA + assert np.bool_(False) ^ NA is NA + msg = "unsupported operand type" with pytest.raises(TypeError, match=msg): NA ^ 5 From e5a1c102237947450871433c1d7ebd0bb607cd64 Mon Sep 17 00:00:00 2001 From: David Krych Date: Mon, 7 Jul 2025 03:41:22 -0400 Subject: [PATCH 08/51] BUG: Fix unpickling of string dtypes of legacy pandas versions (#61770) --- doc/source/whatsnew/v2.3.1.rst | 2 +- pandas/core/arrays/string_.py | 7 +++++++ .../1.5.3/1.5.3_x86_64_win_3.11.13.pickle | Bin 0 -> 127243 bytes .../2.0.3/2.0.3_AMD64_windows_3.11.12.pickle | Bin 0 -> 83129 bytes .../2.1.4/2.1.4_AMD64_windows_3.11.12.pickle | Bin 0 -> 83302 bytes .../2.2.3/2.2.3_AMD64_windows_3.11.12.pickle | Bin 0 -> 83365 bytes .../tests/io/generate_legacy_storage_files.py | 8 ++++++++ 7 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 pandas/tests/io/data/legacy_pickle/1.5.3/1.5.3_x86_64_win_3.11.13.pickle create mode 100644 pandas/tests/io/data/legacy_pickle/2.0.3/2.0.3_AMD64_windows_3.11.12.pickle create mode 100644 pandas/tests/io/data/legacy_pickle/2.1.4/2.1.4_AMD64_windows_3.11.12.pickle create mode 100644 pandas/tests/io/data/legacy_pickle/2.2.3/2.2.3_AMD64_windows_3.11.12.pickle diff --git a/doc/source/whatsnew/v2.3.1.rst b/doc/source/whatsnew/v2.3.1.rst index 64e5c1510e1da..eb3ad72f6a59f 100644 --- a/doc/source/whatsnew/v2.3.1.rst +++ b/doc/source/whatsnew/v2.3.1.rst @@ -59,6 +59,7 @@ Bug fixes - Bug in :meth:`.DataFrameGroupBy.min`, :meth:`.DataFrameGroupBy.max`, :meth:`.Resampler.min`, :meth:`.Resampler.max` where all NA values of string dtype would return float instead of string dtype (:issue:`60810`) - Bug in :meth:`DataFrame.sum` with ``axis=1``, :meth:`.DataFrameGroupBy.sum` or :meth:`.SeriesGroupBy.sum` with ``skipna=True``, and :meth:`.Resampler.sum` with all NA values of :class:`StringDtype` resulted in ``0`` instead of the empty string ``""`` (:issue:`60229`) - Fixed bug in :meth:`DataFrame.explode` and :meth:`Series.explode` where methods would fail with ``dtype="str"`` (:issue:`61623`) +- Fixed bug in unpickling objects pickled in pandas versions pre-2.3.0 that used :class:`StringDtype` (:issue:`61763`). .. _whatsnew_231.regressions: @@ -72,7 +73,6 @@ Fixed regressions Bug fixes ~~~~~~~~~ -- .. --------------------------------------------------------------------------- .. _whatsnew_231.other: diff --git a/pandas/core/arrays/string_.py b/pandas/core/arrays/string_.py index 6087e42cf273d..f52b709a59de9 100644 --- a/pandas/core/arrays/string_.py +++ b/pandas/core/arrays/string_.py @@ -72,6 +72,8 @@ from pandas.io.formats import printing if TYPE_CHECKING: + from collections.abc import MutableMapping + import pyarrow from pandas._typing import ( @@ -218,6 +220,11 @@ def __eq__(self, other: object) -> bool: return self.storage == other.storage and self.na_value is other.na_value return False + def __setstate__(self, state: MutableMapping[str, Any]) -> None: + # back-compat for pandas < 2.3, where na_value did not yet exist + self.storage = state.pop("storage", "python") + self._na_value = state.pop("_na_value", libmissing.NA) + def __hash__(self) -> int: # need to override __hash__ as well because of overriding __eq__ return super().__hash__() diff --git a/pandas/tests/io/data/legacy_pickle/1.5.3/1.5.3_x86_64_win_3.11.13.pickle b/pandas/tests/io/data/legacy_pickle/1.5.3/1.5.3_x86_64_win_3.11.13.pickle new file mode 100644 index 0000000000000000000000000000000000000000..d12fc5929ea5b7a7b1800ed24f45fa298000fb4b GIT binary patch literal 127243 zcmdqq1zZ%(~1%|r>c?Em<`-O$ez10f{ z_7C){7#QNy#@+f;vt=jFEzG8rF+c6=*TFB`#oH^~&unt^4EAg9Sv^?(OupZgDz_5v^;Z2S#8_^9=Lz2?-952#fTI&=0}Z@u#2Bmq%=HG9;BJX-Mv2Na3a!Qo7k1QbnClSF_13ze|C_E(NTK z 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 Yf0h4r1Xum@ACweR?|=6vAtC$!1OAHrY5)KL literal 0 HcmV?d00001 diff --git a/pandas/tests/io/data/legacy_pickle/2.1.4/2.1.4_AMD64_windows_3.11.12.pickle b/pandas/tests/io/data/legacy_pickle/2.1.4/2.1.4_AMD64_windows_3.11.12.pickle new file mode 100644 index 0000000000000000000000000000000000000000..6f838839c29371d653f085ebb0a841831c8ec331 GIT binary patch literal 83302 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 PloS>9zk8LCkmLUW;Q}#y literal 0 HcmV?d00001 diff --git a/pandas/tests/io/data/legacy_pickle/2.2.3/2.2.3_AMD64_windows_3.11.12.pickle b/pandas/tests/io/data/legacy_pickle/2.2.3/2.2.3_AMD64_windows_3.11.12.pickle new file mode 100644 index 0000000000000000000000000000000000000000..f98766fd4e05def41ca15ffeae68ae63f25030a9 GIT binary patch literal 83365 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 h*q?w4Q**hdrqPQ@h~XflGlmO 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 W{x=X(< Date: Mon, 7 Jul 2025 13:08:56 +0200 Subject: [PATCH 09/51] DOC: add pandas 3.0 migration guide for the string dtype (#61705) Co-authored-by: Simon Hawkins Co-authored-by: jbrockmendel --- doc/source/user_guide/index.rst | 1 + doc/source/user_guide/migration-3-strings.rst | 386 ++++++++++++++++++ 2 files changed, 387 insertions(+) create mode 100644 doc/source/user_guide/migration-3-strings.rst diff --git a/doc/source/user_guide/index.rst b/doc/source/user_guide/index.rst index 230b2b86b2ffd..85e91859b90d0 100644 --- a/doc/source/user_guide/index.rst +++ b/doc/source/user_guide/index.rst @@ -87,5 +87,6 @@ Guides enhancingperf scale sparse + migration-3-strings gotchas cookbook diff --git a/doc/source/user_guide/migration-3-strings.rst b/doc/source/user_guide/migration-3-strings.rst new file mode 100644 index 0000000000000..c415f8f43d3c8 --- /dev/null +++ b/doc/source/user_guide/migration-3-strings.rst @@ -0,0 +1,386 @@ +{{ header }} + +.. _string_migration_guide: + +========================================================= +Migration guide for the new string data type (pandas 3.0) +========================================================= + +The upcoming pandas 3.0 release introduces a new, default string data type. This +will most likely cause some work when upgrading to pandas 3.0, and this page +provides an overview of the issues you might run into and gives guidance on how +to address them. + +This new dtype is already available in the pandas 2.3 release, and you can +enable it with: + +.. code-block:: python + + pd.options.future.infer_string = True + +This allows you to test your code before the final 3.0 release. + +Background +---------- + +Historically, pandas has always used the NumPy ``object`` dtype as the default +to store text data. This has two primary drawbacks. First, ``object`` dtype is +not specific to strings: any Python object can be stored in an ``object``-dtype +array, not just strings, and seeing ``object`` as the dtype for a column with +strings is confusing for users. Second, this is not always very efficient (both +performance wise and for memory usage). + +Since pandas 1.0, an opt-in string data type has been available, but this has +not yet been made the default, and uses the ``pd.NA`` scalar to represent +missing values. + +Pandas 3.0 changes the default dtype for strings to a new string data type, +a variant of the existing optional string data type but using ``NaN`` as the +missing value indicator, to be consistent with the other default data types. + +To improve performance, the new string data type will use the ``pyarrow`` +package by default, if installed (and otherwise it uses object dtype under the +hood as a fallback). + +See `PDEP-14: Dedicated string data type for pandas 3.0 `__ +for more background and details. + +.. - brief primer on the new dtype + +.. - Main characteristics: +.. - inferred by default (Default inference of a string dtype) +.. - only strings (setitem with non string fails) +.. - missing values sentinel is always NaN and uses NaN semantics + +.. - Breaking changes: +.. - dtype is no longer object dtype +.. - None gets coerced to NaN +.. - setitem raises an error for non-string data + +Brief introduction to the new default string dtype +-------------------------------------------------- + +By default, pandas will infer this new string dtype instead of object dtype for +string data (when creating pandas objects, such as in constructors or IO +functions). + +Being a default dtype means that the string dtype will be used in IO methods or +constructors when the dtype is being inferred and the input is inferred to be +string data: + +.. code-block:: python + + >>> pd.Series(["a", "b", None]) + 0 a + 1 b + 2 NaN + dtype: str + +It can also be specified explicitly using the ``"str"`` alias: + +.. code-block:: python + + >>> pd.Series(["a", "b", None], dtype="str") + 0 a + 1 b + 2 NaN + dtype: str + +Similarly, functions like :func:`read_csv`, :func:`read_parquet`, and others +will now use the new string dtype when reading string data. + +In contrast to the current object dtype, the new string dtype will only store +strings. This also means that it will raise an error if you try to store a +non-string value in it (see below for more details). + +Missing values with the new string dtype are always represented as ``NaN`` (``np.nan``), +and the missing value behavior is similar to other default dtypes. + +This new string dtype should otherwise behave the same as the existing +``object`` dtype users are used to. For example, all string-specific methods +through the ``str`` accessor will work the same: + +.. code-block:: python + + >>> ser = pd.Series(["a", "b", None], dtype="str") + >>> ser.str.upper() + 0 A + 1 B + 2 NaN + dtype: str + +.. note:: + + The new default string dtype is an instance of the :class:`pandas.StringDtype` + class. The dtype can be constructed as ``pd.StringDtype(na_value=np.nan)``, + but for general usage we recommend to use the shorter ``"str"`` alias. + +Overview of behavior differences and how to address them +--------------------------------------------------------- + +The dtype is no longer object dtype +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +When inferring or reading string data, the data type of the resulting DataFrame +column or Series will silently start being the new ``"str"`` dtype instead of +``"object"`` dtype, and this can have some impact on your code. + +Checking the dtype +^^^^^^^^^^^^^^^^^^ + +When checking the dtype, code might currently do something like: + +.. code-block:: python + + >>> ser = pd.Series(["a", "b", "c"]) + >>> ser.dtype == "object" + +to check for columns with string data (by checking for the dtype being +``"object"``). This will no longer work in pandas 3+, since ``ser.dtype`` will +now be ``"str"`` with the new default string dtype, and the above check will +return ``False``. + +To check for columns with string data, you should instead use: + +.. code-block:: python + + >>> ser.dtype == "str" + +**How to write compatible code** + +For code that should work on both pandas 2.x and 3.x, you can use the +:func:`pandas.api.types.is_string_dtype` function: + +.. code-block:: python + + >>> pd.api.types.is_string_dtype(ser.dtype) + True + +This will return ``True`` for both the object dtype and the string dtypes. + +Hardcoded use of object dtype +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +If you have code where the dtype is hardcoded in constructors, like + +.. code-block:: python + + >>> pd.Series(["a", "b", "c"], dtype="object") + +this will keep using the object dtype. You will want to update this code to +ensure you get the benefits of the new string dtype. + +**How to write compatible code?** + +First, in many cases it can be sufficient to remove the specific data type, and +let pandas do the inference. But if you want to be specific, you can specify the +``"str"`` dtype: + +.. code-block:: python + + >>> pd.Series(["a", "b", "c"], dtype="str") + +This is actually compatible with pandas 2.x as well, since in pandas < 3, +``dtype="str"`` was essentially treated as an alias for object dtype. + +The missing value sentinel is now always NaN +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +When using object dtype, multiple possible missing value sentinels are +supported, including ``None`` and ``np.nan``. With the new default string dtype, +the missing value sentinel is always NaN (``np.nan``): + +.. code-block:: python + + # with object dtype, None is preserved as None and seen as missing + >>> ser = pd.Series(["a", "b", None], dtype="object") + >>> ser + 0 a + 1 b + 2 None + dtype: object + >>> print(ser[2]) + None + + # with the new string dtype, any missing value like None is coerced to NaN + >>> ser = pd.Series(["a", "b", None], dtype="str") + >>> ser + 0 a + 1 b + 2 NaN + dtype: str + >>> print(ser[2]) + nan + +Generally this should be no problem when relying on missing value behavior in +pandas methods (for example, ``ser.isna()`` will give the same result as before). +But when you relied on the exact value of ``None`` being present, that can +impact your code. + +**How to write compatible code?** + +When checking for a missing value, instead of checking for the exact value of +``None`` or ``np.nan``, you should use the :func:`pandas.isna` function. This is +the most robust way to check for missing values, as it will work regardless of +the dtype and the exact missing value sentinel: + +.. code-block:: python + + >>> pd.isna(ser[2]) + True + +One caveat: this function works both on scalars and on array-likes, and in the +latter case it will return an array of bools. When using it in a Boolean context +(for example, ``if pd.isna(..): ..``) be sure to only pass a scalar to it. + +"setitem" operations will now raise an error for non-string data +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +With the new string dtype, any attempt to set a non-string value in a Series or +DataFrame will raise an error: + +.. code-block:: python + + >>> ser = pd.Series(["a", "b", None], dtype="str") + >>> ser[1] = 2.5 + --------------------------------------------------------------------------- + TypeError Traceback (most recent call last) + ... + TypeError: Invalid value '2.5' for dtype 'str'. Value should be a string or missing value, got 'float' instead. + +If you relied on the flexible nature of object dtype being able to hold any +Python object, but your initial data was inferred as strings, your code might be +impacted by this change. + +**How to write compatible code?** + +You can update your code to ensure you only set string values in such columns, +or otherwise you can explicitly ensure the column has object dtype first. This +can be done by specifying the dtype explicitly in the constructor, or by using +the :meth:`~pandas.Series.astype` method: + +.. code-block:: python + + >>> ser = pd.Series(["a", "b", None], dtype="str") + >>> ser = ser.astype("object") + >>> ser[1] = 2.5 + +This ``astype("object")`` call will be redundant when using pandas 2.x, but +this code will work for all versions. + +Invalid unicode input +~~~~~~~~~~~~~~~~~~~~~ + +Python allows to have a built-in ``str`` object that represents invalid unicode +data. And since the ``object`` dtype can hold any Python object, you can have a +pandas Series with such invalid unicode data: + +.. code-block:: python + + >>> ser = pd.Series(["\u2600", "\ud83d"], dtype=object) + >>> ser + 0 ☀ + 1 \ud83d + dtype: object + +However, when using the string dtype using ``pyarrow`` under the hood, this can +only store valid unicode data, and otherwise it will raise an error: + +.. code-block:: python + + >>> ser = pd.Series(["\u2600", "\ud83d"]) + --------------------------------------------------------------------------- + UnicodeEncodeError Traceback (most recent call last) + ... + UnicodeEncodeError: 'utf-8' codec can't encode character '\ud83d' in position 0: surrogates not allowed + +If you want to keep the previous behaviour, you can explicitly specify +``dtype=object`` to keep working with object dtype. + +When you have byte data that you want to convert to strings using ``decode()``, +the :meth:`~pandas.Series.str.decode` method now has a ``dtype`` parameter to be +able to specify object dtype instead of the default of string dtype for this use +case. + +Notable bug fixes +~~~~~~~~~~~~~~~~~ + +``astype(str)`` preserving missing values +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +This is a long standing "bug" or misfeature, as discussed in https://github.com/pandas-dev/pandas/issues/25353. + +With pandas < 3, when using ``astype(str)`` (using the built-in :func:`str`, not +``astype("str")``!), the operation would convert every element to a string, +including the missing values: + +.. code-block:: python + + # OLD behavior in pandas < 3 + >>> ser = pd.Series(["a", np.nan], dtype=object) + >>> ser + 0 a + 1 NaN + dtype: object + >>> ser.astype(str) + 0 a + 1 nan + dtype: object + >>> ser.astype(str).to_numpy() + array(['a', 'nan'], dtype=object) + +Note how ``NaN`` (``np.nan``) was converted to the string ``"nan"``. This was +not the intended behavior, and it was inconsistent with how other dtypes handled +missing values. + +With pandas 3, this behavior has been fixed, and now ``astype(str)`` is an alias +for ``astype("str")``, i.e. casting to the new string dtype, which will preserve +the missing values: + +.. code-block:: python + + # NEW behavior in pandas 3 + >>> pd.options.future.infer_string = True + >>> ser = pd.Series(["a", np.nan], dtype=object) + >>> ser.astype(str) + 0 a + 1 NaN + dtype: str + >>> ser.astype(str).values + array(['a', nan], dtype=object) + +If you want to preserve the old behaviour of converting every object to a +string, you can use ``ser.map(str)`` instead. + + +``prod()`` raising for string data +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +In pandas < 3, calling the :meth:`~pandas.Series.prod` method on a Series with +string data would generally raise an error, except when the Series was empty or +contained only a single string (potentially with missing values): + +.. code-block:: python + + >>> ser = pd.Series(["a", None], dtype=object) + >>> ser.prod() + 'a' + +When the Series contains multiple strings, it will raise a ``TypeError``. This +behaviour stays the same in pandas 3 when using the flexible ``object`` dtype. +But by virtue of using the new string dtype, this will generally consistently +raise an error regardless of the number of strings: + +.. code-block:: python + + >>> ser = pd.Series(["a", None], dtype="str") + >>> ser.prod() + --------------------------------------------------------------------------- + TypeError Traceback (most recent call last) + ... + TypeError: Cannot perform reduction 'prod' with string dtype + +.. For existing users of the nullable ``StringDtype`` +.. -------------------------------------------------- + +.. TODO From 0faaf5ca51e402156864237ec4877faa4fa3d36e Mon Sep 17 00:00:00 2001 From: Joris Van den Bossche Date: Mon, 7 Jul 2025 14:15:54 +0200 Subject: [PATCH 10/51] DOC: add section about upcoming pandas 3.0 changes (string dtype, CoW) to 2.3 whatsnew notes (#61795) Co-authored-by: Simon Hawkins --- doc/source/whatsnew/v2.3.0.rst | 98 ++++++++++++++++++++++++++++++++++ doc/source/whatsnew/v2.3.1.rst | 2 +- 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/doc/source/whatsnew/v2.3.0.rst b/doc/source/whatsnew/v2.3.0.rst index 8ca6c0006a604..bf9b2ae2333c0 100644 --- a/doc/source/whatsnew/v2.3.0.rst +++ b/doc/source/whatsnew/v2.3.0.rst @@ -10,6 +10,104 @@ including other versions of pandas. .. --------------------------------------------------------------------------- +.. _whatsnew_230.upcoming_changes: + +Upcoming changes in pandas 3.0 +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +pandas 3.0 will bring two bigger changes to the default behavior of pandas. + +Dedicated string data type by default +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Historically, pandas represented string columns with NumPy ``object`` data type. +This representation has numerous problems: it is not specific to strings (any +Python object can be stored in an ``object``-dtype array, not just strings) and +it is often not very efficient (both performance wise and for memory usage). + +Starting with the upcoming pandas 3.0 release, a dedicated string data type will +be enabled by default (backed by PyArrow under the hood, if installed, otherwise +falling back to NumPy). This means that pandas will start inferring columns +containing string data as the new ``str`` data type when creating pandas +objects, such as in constructors or IO functions. + +Old behavior: + +.. code-block:: python + + >>> ser = pd.Series(["a", "b"]) + 0 a + 1 b + dtype: object + +New behavior: + +.. code-block:: python + + >>> ser = pd.Series(["a", "b"]) + 0 a + 1 b + dtype: str + +The string data type that is used in these scenarios will mostly behave as NumPy +object would, including missing value semantics and general operations on these +columns. + +However, the introduction of a new default dtype will also have some breaking +consequences to your code (for example when checking for the ``.dtype`` being +object dtype). To allow testing it in advance of the pandas 3.0 release, this +future dtype inference logic can be enabled in pandas 2.3 with: + +.. code-block:: python + + pd.options.future.infer_string = True + +See the :ref:`string_migration_guide` for more details on the behaviour changes +and how to adapt your code to the new default. + +Copy-on-Write +^^^^^^^^^^^^^ + +The currently optional mode Copy-on-Write will be enabled by default in pandas 3.0. There +won't be an option to retain the legacy behavior. + +In summary, the new "copy-on-write" behaviour will bring changes in behavior in +how pandas operates with respect to copies and views. + +1. The result of *any* indexing operation (subsetting a DataFrame or Series in any way, + i.e. including accessing a DataFrame column as a Series) or any method returning a + new DataFrame or Series, always *behaves as if* it were a copy in terms of user + API. +2. As a consequence, if you want to modify an object (DataFrame or Series), the only way + to do this is to directly modify that object itself. + +Because every single indexing step now behaves as a copy, this also means that +"chained assignment" (updating a DataFrame with multiple setitem steps) will +stop working. Because this now consistently never works, the +``SettingWithCopyWarning`` will be removed. + +The new behavioral semantics are explained in more detail in the +:ref:`user guide about Copy-on-Write `. + +The new behavior can be enabled since pandas 2.0 with the following option: + +.. code-block:: python + + pd.options.mode.copy_on_write = True + +Some of the behaviour changes allow a clear deprecation, like the changes in +chained assignment. Other changes are more subtle and thus, the warnings are +hidden behind an option that can be enabled since pandas 2.2: + +.. code-block:: python + + pd.options.mode.copy_on_write = "warn" + +This mode will warn in many different scenarios that aren't actually relevant to +most queries. We recommend exploring this mode, but it is not necessary to get rid +of all of these warnings. The :ref:`migration guide ` +explains the upgrade process in more detail. + .. _whatsnew_230.enhancements: Enhancements diff --git a/doc/source/whatsnew/v2.3.1.rst b/doc/source/whatsnew/v2.3.1.rst index eb3ad72f6a59f..7ad76e9d82c9c 100644 --- a/doc/source/whatsnew/v2.3.1.rst +++ b/doc/source/whatsnew/v2.3.1.rst @@ -44,7 +44,7 @@ correctly, rather than defaulting to ``object`` dtype. For example: .. code-block:: python - >>> pd.options.mode.infer_string = True + >>> pd.options.future.infer_string = True >>> df = pd.DataFrame() >>> df.columns.dtype dtype('int64') # default RangeIndex for empty columns From cf1a11c1b49d040f7827f30a1a16154c80c552a7 Mon Sep 17 00:00:00 2001 From: jbrockmendel Date: Mon, 7 Jul 2025 06:15:03 -0700 Subject: [PATCH 11/51] BUG[string]: incorrect index downcast in DataFrame.join (#61771) Co-authored-by: Joris Van den Bossche --- doc/source/whatsnew/v2.3.1.rst | 1 + pandas/core/reshape/merge.py | 6 +++--- pandas/tests/copy_view/test_functions.py | 16 ++++------------ 3 files changed, 8 insertions(+), 15 deletions(-) diff --git a/doc/source/whatsnew/v2.3.1.rst b/doc/source/whatsnew/v2.3.1.rst index 7ad76e9d82c9c..ba03f0757c2b5 100644 --- a/doc/source/whatsnew/v2.3.1.rst +++ b/doc/source/whatsnew/v2.3.1.rst @@ -57,6 +57,7 @@ correctly, rather than defaulting to ``object`` dtype. For example: Bug fixes ^^^^^^^^^ - Bug in :meth:`.DataFrameGroupBy.min`, :meth:`.DataFrameGroupBy.max`, :meth:`.Resampler.min`, :meth:`.Resampler.max` where all NA values of string dtype would return float instead of string dtype (:issue:`60810`) +- Bug in :meth:`DataFrame.join` incorrectly downcasting object-dtype indexes (:issue:`61771`) - Bug in :meth:`DataFrame.sum` with ``axis=1``, :meth:`.DataFrameGroupBy.sum` or :meth:`.SeriesGroupBy.sum` with ``skipna=True``, and :meth:`.Resampler.sum` with all NA values of :class:`StringDtype` resulted in ``0`` instead of the empty string ``""`` (:issue:`60229`) - Fixed bug in :meth:`DataFrame.explode` and :meth:`Series.explode` where methods would fail with ``dtype="str"`` (:issue:`61623`) - Fixed bug in unpickling objects pickled in pandas versions pre-2.3.0 that used :class:`StringDtype` (:issue:`61763`). diff --git a/pandas/core/reshape/merge.py b/pandas/core/reshape/merge.py index f762695eedb3d..285256ac7b16a 100644 --- a/pandas/core/reshape/merge.py +++ b/pandas/core/reshape/merge.py @@ -1328,13 +1328,13 @@ def _maybe_add_join_keys( # if we have an all missing left_indexer # make sure to just use the right values or vice-versa if left_indexer is not None and (left_indexer == -1).all(): - key_col = Index(rvals) + key_col = Index(rvals, dtype=rvals.dtype, copy=False) result_dtype = rvals.dtype elif right_indexer is not None and (right_indexer == -1).all(): - key_col = Index(lvals) + key_col = Index(lvals, dtype=lvals.dtype, copy=False) result_dtype = lvals.dtype else: - key_col = Index(lvals) + key_col = Index(lvals, dtype=lvals.dtype, copy=False) if left_indexer is not None: mask_left = left_indexer == -1 key_col = key_col.where(~mask_left, rvals) diff --git a/pandas/tests/copy_view/test_functions.py b/pandas/tests/copy_view/test_functions.py index 32fea794975b6..d23263835c615 100644 --- a/pandas/tests/copy_view/test_functions.py +++ b/pandas/tests/copy_view/test_functions.py @@ -1,10 +1,6 @@ import numpy as np import pytest -from pandas._config import using_string_dtype - -from pandas.compat import HAS_PYARROW - from pandas import ( DataFrame, Index, @@ -247,13 +243,9 @@ def test_merge_copy_keyword(): assert np.shares_memory(get_array(df2, "b"), get_array(result, "b")) -@pytest.mark.xfail( - using_string_dtype() and HAS_PYARROW, - reason="TODO(infer_string); result.index infers str dtype while both " - "df1 and df2 index are object.", -) -def test_join_on_key(): - df_index = Index(["a", "b", "c"], name="key", dtype=object) +@pytest.mark.parametrize("dtype", [object, "str"]) +def test_join_on_key(dtype): + df_index = Index(["a", "b", "c"], name="key", dtype=dtype) df1 = DataFrame({"a": [1, 2, 3]}, index=df_index.copy(deep=True)) df2 = DataFrame({"b": [4, 5, 6]}, index=df_index.copy(deep=True)) @@ -265,7 +257,7 @@ def test_join_on_key(): assert np.shares_memory(get_array(result, "a"), get_array(df1, "a")) assert np.shares_memory(get_array(result, "b"), get_array(df2, "b")) - assert np.shares_memory(get_array(result.index), get_array(df1.index)) + assert tm.shares_memory(get_array(result.index), get_array(df1.index)) assert not np.shares_memory(get_array(result.index), get_array(df2.index)) result.iloc[0, 0] = 0 From ebca3c56c1f9454ef5d2de5bb19ce138e1619504 Mon Sep 17 00:00:00 2001 From: Joris Van den Bossche Date: Mon, 7 Jul 2025 15:41:24 +0200 Subject: [PATCH 12/51] TST: update expected dtype for sum of decimals with pyarrow 21+ (#61799) --- pandas/compat/__init__.py | 2 ++ pandas/compat/pyarrow.py | 2 ++ pandas/tests/extension/test_arrow.py | 6 +++++- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/pandas/compat/__init__.py b/pandas/compat/__init__.py index 8ed19f97958b9..d5dbcb74d29e4 100644 --- a/pandas/compat/__init__.py +++ b/pandas/compat/__init__.py @@ -35,6 +35,7 @@ pa_version_under18p0, pa_version_under19p0, pa_version_under20p0, + pa_version_under21p0, ) if TYPE_CHECKING: @@ -168,4 +169,5 @@ def is_ci_environment() -> bool: "pa_version_under18p0", "pa_version_under19p0", "pa_version_under20p0", + "pa_version_under21p0", ] diff --git a/pandas/compat/pyarrow.py b/pandas/compat/pyarrow.py index 569d702592982..1e1989b276eb6 100644 --- a/pandas/compat/pyarrow.py +++ b/pandas/compat/pyarrow.py @@ -18,6 +18,7 @@ pa_version_under18p0 = _palv < Version("18.0.0") pa_version_under19p0 = _palv < Version("19.0.0") pa_version_under20p0 = _palv < Version("20.0.0") + pa_version_under21p0 = _palv < Version("21.0.0") HAS_PYARROW = _palv >= Version("12.0.1") except ImportError: pa_version_under12p1 = True @@ -30,4 +31,5 @@ pa_version_under18p0 = True pa_version_under19p0 = True pa_version_under20p0 = True + pa_version_under21p0 = True HAS_PYARROW = False diff --git a/pandas/tests/extension/test_arrow.py b/pandas/tests/extension/test_arrow.py index 1bec5f7303355..8db837b176fe9 100644 --- a/pandas/tests/extension/test_arrow.py +++ b/pandas/tests/extension/test_arrow.py @@ -43,6 +43,7 @@ pa_version_under14p0, pa_version_under19p0, pa_version_under20p0, + pa_version_under21p0, ) from pandas.core.dtypes.dtypes import ( @@ -542,7 +543,10 @@ def _get_expected_reduction_dtype(self, arr, op_name: str, skipna: bool): else: cmp_dtype = arr.dtype elif arr.dtype.name == "decimal128(7, 3)[pyarrow]": - if op_name not in ["median", "var", "std", "sem", "skew"]: + if op_name == "sum" and not pa_version_under21p0: + # https://github.com/apache/arrow/pull/44184 + cmp_dtype = ArrowDtype(pa.decimal128(38, 3)) + elif op_name not in ["median", "var", "std", "sem", "skew"]: cmp_dtype = arr.dtype else: cmp_dtype = "float64[pyarrow]" From b9d57323ce6b8a640e98db8345fac1a076c139be Mon Sep 17 00:00:00 2001 From: "Christine P. Chai" Date: Mon, 7 Jul 2025 09:21:14 -0700 Subject: [PATCH 13/51] DOC: Add link to WebGL in pandas ecosystem (#61790) --- web/pandas/community/ecosystem.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/pandas/community/ecosystem.md b/web/pandas/community/ecosystem.md index 78c239ac4f690..752b7b89c799b 100644 --- a/web/pandas/community/ecosystem.md +++ b/web/pandas/community/ecosystem.md @@ -141,7 +141,7 @@ pd.set_option("plotting.backend", "hvplot") [Plotly's](https://plot.ly/) [Python API](https://plot.ly/python/) enables interactive figures and web shareability. Maps, 2D, 3D, and -live-streaming graphs are rendered with WebGL and +live-streaming graphs are rendered with [WebGL](https://www.khronos.org/webgl/) and [D3.js](https://d3js.org/). The library supports plotting directly from a pandas DataFrame and cloud-based collaboration. Users of [matplotlib, ggplot for Python, and From be2cb8c0017b05eeb3b94407504a0207cab96be6 Mon Sep 17 00:00:00 2001 From: ChiLin Chiu Date: Tue, 8 Jul 2025 00:29:16 +0800 Subject: [PATCH 14/51] CLN: remove and udpate for outdated _item_cache (#61789) * CLN: remove and udpate for outdated _item_cache * CLN: remove outdated _item_cache in comment * CLN: rollback unittest unralted to _item_cache --- pandas/core/generic.py | 1 - pandas/core/internals/managers.py | 4 --- pandas/tests/frame/indexing/test_insert.py | 15 ---------- pandas/tests/frame/methods/test_cov_corr.py | 14 --------- pandas/tests/frame/methods/test_quantile.py | 16 ---------- .../tests/frame/methods/test_sort_values.py | 15 ---------- .../frame/methods/test_to_dict_of_blocks.py | 22 -------------- pandas/tests/frame/test_block_internals.py | 27 ----------------- pandas/tests/indexing/test_at.py | 23 --------------- .../indexing/test_chaining_and_caching.py | 29 ------------------- pandas/tests/internals/test_internals.py | 2 -- 11 files changed, 168 deletions(-) diff --git a/pandas/core/generic.py b/pandas/core/generic.py index 66188d9e91232..7f1ccc482f70f 100644 --- a/pandas/core/generic.py +++ b/pandas/core/generic.py @@ -236,7 +236,6 @@ class NDFrame(PandasObject, indexing.IndexingMixin): _internal_names: list[str] = [ "_mgr", - "_item_cache", "_cache", "_name", "_metadata", diff --git a/pandas/core/internals/managers.py b/pandas/core/internals/managers.py index cb290fde7095c..67d7ffa80462a 100644 --- a/pandas/core/internals/managers.py +++ b/pandas/core/internals/managers.py @@ -1898,10 +1898,6 @@ def _consolidate_check(self) -> None: self._known_consolidated = True def _consolidate_inplace(self) -> None: - # In general, _consolidate_inplace should only be called via - # DataFrame._consolidate_inplace, otherwise we will fail to invalidate - # the DataFrame's _item_cache. The exception is for newly-created - # BlockManager objects not yet attached to a DataFrame. if not self.is_consolidated(): self.blocks = _consolidate(self.blocks) self._is_consolidated = True diff --git a/pandas/tests/frame/indexing/test_insert.py b/pandas/tests/frame/indexing/test_insert.py index b530cb98ef46c..761daf0e985cc 100644 --- a/pandas/tests/frame/indexing/test_insert.py +++ b/pandas/tests/frame/indexing/test_insert.py @@ -7,8 +7,6 @@ import numpy as np import pytest -from pandas.errors import PerformanceWarning - from pandas import ( DataFrame, Index, @@ -72,19 +70,6 @@ def test_insert_with_columns_dups(self): ) tm.assert_frame_equal(df, exp) - def test_insert_item_cache(self, performance_warning): - df = DataFrame(np.random.default_rng(2).standard_normal((4, 3))) - ser = df[0] - expected_warning = PerformanceWarning if performance_warning else None - - with tm.assert_produces_warning(expected_warning): - for n in range(100): - df[n + 3] = df[1] * n - - ser.iloc[0] = 99 - assert df.iloc[0, 0] == df[0][0] - assert df.iloc[0, 0] != 99 - def test_insert_EA_no_warning(self): # PerformanceWarning about fragmented frame should not be raised when # using EAs (https://github.com/pandas-dev/pandas/issues/44098) diff --git a/pandas/tests/frame/methods/test_cov_corr.py b/pandas/tests/frame/methods/test_cov_corr.py index 304638a3a7dcf..a5ed2e86283e9 100644 --- a/pandas/tests/frame/methods/test_cov_corr.py +++ b/pandas/tests/frame/methods/test_cov_corr.py @@ -207,20 +207,6 @@ def test_corr_nullable_integer(self, nullable_column, other_column, method): expected = DataFrame(np.ones((2, 2)), columns=["a", "b"], index=["a", "b"]) tm.assert_frame_equal(result, expected) - def test_corr_item_cache(self): - # Check that corr does not lead to incorrect entries in item_cache - - df = DataFrame({"A": range(10)}) - df["B"] = range(10)[::-1] - - ser = df["A"] # populate item_cache - assert len(df._mgr.blocks) == 2 - - _ = df.corr(numeric_only=True) - - ser.iloc[0] = 99 - assert df.loc[0, "A"] == 0 - @pytest.mark.parametrize("length", [2, 20, 200, 2000]) def test_corr_for_constant_columns(self, length): # GH: 37448 diff --git a/pandas/tests/frame/methods/test_quantile.py b/pandas/tests/frame/methods/test_quantile.py index d7baac7264a1d..631742d43263f 100644 --- a/pandas/tests/frame/methods/test_quantile.py +++ b/pandas/tests/frame/methods/test_quantile.py @@ -721,22 +721,6 @@ def test_quantile_empty_no_columns(self, interp_method): expected.columns.name = "captain tightpants" tm.assert_frame_equal(result, expected) - def test_quantile_item_cache(self, interp_method): - # previous behavior incorrect retained an invalid _item_cache entry - interpolation, method = interp_method - df = DataFrame( - np.random.default_rng(2).standard_normal((4, 3)), columns=["A", "B", "C"] - ) - df["D"] = df["A"] * 2 - ser = df["A"] - assert len(df._mgr.blocks) == 2 - - df.quantile(numeric_only=False, interpolation=interpolation, method=method) - - ser.iloc[0] = 99 - assert df.iloc[0, 0] == df["A"][0] - assert df.iloc[0, 0] != 99 - def test_invalid_method(self): with pytest.raises(ValueError, match="Invalid method: foo"): DataFrame(range(1)).quantile(0.5, method="foo") diff --git a/pandas/tests/frame/methods/test_sort_values.py b/pandas/tests/frame/methods/test_sort_values.py index 9a628c2ee9f73..9abe0c97c3260 100644 --- a/pandas/tests/frame/methods/test_sort_values.py +++ b/pandas/tests/frame/methods/test_sort_values.py @@ -592,21 +592,6 @@ def test_sort_values_nat_na_position_default(self): result = expected.sort_values(["A", "date"]) tm.assert_frame_equal(result, expected) - def test_sort_values_item_cache(self): - # previous behavior incorrect retained an invalid _item_cache entry - df = DataFrame( - np.random.default_rng(2).standard_normal((4, 3)), columns=["A", "B", "C"] - ) - df["D"] = df["A"] * 2 - ser = df["A"] - assert len(df._mgr.blocks) == 2 - - df.sort_values(by="A") - - ser.iloc[0] = 99 - assert df.iloc[0, 0] == df["A"][0] - assert df.iloc[0, 0] != 99 - def test_sort_values_reshaping(self): # GH 39426 values = list(range(21)) diff --git a/pandas/tests/frame/methods/test_to_dict_of_blocks.py b/pandas/tests/frame/methods/test_to_dict_of_blocks.py index 4f621b4643b70..a6b99a70d6ecd 100644 --- a/pandas/tests/frame/methods/test_to_dict_of_blocks.py +++ b/pandas/tests/frame/methods/test_to_dict_of_blocks.py @@ -1,14 +1,8 @@ -import numpy as np -import pytest - -from pandas._config import using_string_dtype - from pandas import ( DataFrame, MultiIndex, ) import pandas._testing as tm -from pandas.core.arrays import NumpyExtensionArray class TestToDictOfBlocks: @@ -27,22 +21,6 @@ def test_no_copy_blocks(self, float_frame): assert _last_df is not None and not _last_df[column].equals(df[column]) -@pytest.mark.xfail(using_string_dtype(), reason="TODO(infer_string)") -def test_to_dict_of_blocks_item_cache(): - # Calling to_dict_of_blocks should not poison item_cache - df = DataFrame({"a": [1, 2, 3, 4], "b": ["a", "b", "c", "d"]}) - df["c"] = NumpyExtensionArray(np.array([1, 2, None, 3], dtype=object)) - mgr = df._mgr - assert len(mgr.blocks) == 3 # i.e. not consolidated - - ser = df["b"] # populations item_cache["b"] - - df._to_dict_of_blocks() - - with pytest.raises(ValueError, match="read-only"): - ser.values[0] = "foo" - - def test_set_change_dtype_slice(): # GH#8850 cols = MultiIndex.from_tuples([("1st", "a"), ("2nd", "b"), ("3rd", "c")]) diff --git a/pandas/tests/frame/test_block_internals.py b/pandas/tests/frame/test_block_internals.py index 6fdbfac8f4e0a..f084d16e387a8 100644 --- a/pandas/tests/frame/test_block_internals.py +++ b/pandas/tests/frame/test_block_internals.py @@ -381,30 +381,3 @@ def test_update_inplace_sets_valid_block_values(): # check we haven't put a Series into any block.values assert isinstance(df._mgr.blocks[0].values, Categorical) - - -def test_nonconsolidated_item_cache_take(): - # https://github.com/pandas-dev/pandas/issues/35521 - - # create non-consolidated dataframe with object dtype columns - df = DataFrame( - { - "col1": Series(["a"], dtype=object), - } - ) - df["col2"] = Series([0], dtype=object) - assert not df._mgr.is_consolidated() - - # access column (item cache) - df["col1"] == "A" - # take operation - # (regression was that this consolidated but didn't reset item cache, - # resulting in an invalid cache and the .at operation not working properly) - df[df["col2"] == 0] - - # now setting value should update actual dataframe - df.at[0, "col1"] = "A" - - expected = DataFrame({"col1": ["A"], "col2": [0]}, dtype=object) - tm.assert_frame_equal(df, expected) - assert df.at[0, "col1"] == "A" diff --git a/pandas/tests/indexing/test_at.py b/pandas/tests/indexing/test_at.py index e80acc230a320..d24d343332669 100644 --- a/pandas/tests/indexing/test_at.py +++ b/pandas/tests/indexing/test_at.py @@ -49,29 +49,6 @@ def test_selection_methods_of_assigned_col(): class TestAtSetItem: - def test_at_setitem_item_cache_cleared(self): - # GH#22372 Note the multi-step construction is necessary to trigger - # the original bug. pandas/issues/22372#issuecomment-413345309 - df = DataFrame(index=[0]) - df["x"] = 1 - df["cost"] = 2 - - # accessing df["cost"] adds "cost" to the _item_cache - df["cost"] - - # This loc[[0]] lookup used to call _consolidate_inplace at the - # BlockManager level, which failed to clear the _item_cache - df.loc[[0]] - - df.at[0, "x"] = 4 - df.at[0, "cost"] = 789 - - expected = DataFrame({"x": [4], "cost": 789}, index=[0]) - tm.assert_frame_equal(df, expected) - - # And in particular, check that the _item_cache has updated correctly. - tm.assert_series_equal(df["cost"], expected["cost"]) - def test_at_setitem_mixed_index_assignment(self): # GH#19860 ser = Series([1, 2, 3, 4, 5], index=["a", "b", "c", 1, 2]) diff --git a/pandas/tests/indexing/test_chaining_and_caching.py b/pandas/tests/indexing/test_chaining_and_caching.py index 64d8068fa9291..266e35ac9088f 100644 --- a/pandas/tests/indexing/test_chaining_and_caching.py +++ b/pandas/tests/indexing/test_chaining_and_caching.py @@ -18,23 +18,6 @@ class TestCaching: - def test_slice_consolidate_invalidate_item_cache(self): - # this is chained assignment, but will 'work' - with option_context("chained_assignment", None): - # #3970 - df = DataFrame({"aa": np.arange(5), "bb": [2.2] * 5}) - - # Creates a second float block - df["cc"] = 0.0 - - # caches a reference to the 'bb' series - df["bb"] - - # Assignment to wrong series - with tm.raises_chained_assignment_error(): - df["bb"].iloc[0] = 0.17 - tm.assert_almost_equal(df["bb"][0], 2.2) - @pytest.mark.parametrize("do_ref", [True, False]) def test_setitem_cache_updating(self, do_ref): # GH 5424 @@ -89,18 +72,6 @@ def test_setitem_cache_updating_slices(self): tm.assert_frame_equal(out, expected) tm.assert_series_equal(out["A"], expected["A"]) - def test_altering_series_clears_parent_cache(self): - # GH #33675 - df = DataFrame([[1, 2], [3, 4]], index=["a", "b"], columns=["A", "B"]) - ser = df["A"] - - # Adding a new entry to ser swaps in a new array, so "A" needs to - # be removed from df._item_cache - ser["c"] = 5 - assert len(ser) == 3 - assert df["A"] is not ser - assert len(df["A"]) == 2 - class TestChaining: def test_setitem_chained_setfault(self): diff --git a/pandas/tests/internals/test_internals.py b/pandas/tests/internals/test_internals.py index ac8ac0766f04d..11e6b99204aee 100644 --- a/pandas/tests/internals/test_internals.py +++ b/pandas/tests/internals/test_internals.py @@ -735,8 +735,6 @@ def test_reindex_items(self): mgr = create_mgr("a: f8; b: i8; c: f8; d: i8; e: f8; f: bool; g: f8-2") reindexed = mgr.reindex_axis(["g", "c", "a", "d"], axis=0) - # reindex_axis does not consolidate_inplace, as that risks failing to - # invalidate _item_cache assert not reindexed.is_consolidated() tm.assert_index_equal(reindexed.items, Index(["g", "c", "a", "d"])) From ff8a60733b059382d6f56a090beb95ab6d741482 Mon Sep 17 00:00:00 2001 From: Joris Van den Bossche Date: Mon, 7 Jul 2025 18:36:05 +0200 Subject: [PATCH 15/51] DOC: prepare 2.3.1 whatsnew notes for release (#61794) --- doc/source/whatsnew/v2.3.0.rst | 2 +- doc/source/whatsnew/v2.3.1.rst | 31 +++++++++---------------------- 2 files changed, 10 insertions(+), 23 deletions(-) diff --git a/doc/source/whatsnew/v2.3.0.rst b/doc/source/whatsnew/v2.3.0.rst index bf9b2ae2333c0..a174b3bc0bea2 100644 --- a/doc/source/whatsnew/v2.3.0.rst +++ b/doc/source/whatsnew/v2.3.0.rst @@ -176,4 +176,4 @@ Other Contributors ~~~~~~~~~~~~ -.. contributors:: v2.2.3..v2.3.0|HEAD +.. contributors:: v2.2.3..v2.3.0 diff --git a/doc/source/whatsnew/v2.3.1.rst b/doc/source/whatsnew/v2.3.1.rst index ba03f0757c2b5..52408fa50d11a 100644 --- a/doc/source/whatsnew/v2.3.1.rst +++ b/doc/source/whatsnew/v2.3.1.rst @@ -1,6 +1,6 @@ .. _whatsnew_231: -What's new in 2.3.1 (Month XX, 2025) +What's new in 2.3.1 (July 7, 2025) ------------------------------------ These are the changes in pandas 2.3.1. See :ref:`release` for a full changelog @@ -14,12 +14,16 @@ including other versions of pandas. Improvements and fixes for the StringDtype ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Most changes in this release are related to :class:`StringDtype` which will +become the default string dtype in pandas 3.0. See +:ref:`whatsnew_230.upcoming_changes` for more details. + .. _whatsnew_231.string_fixes.string_comparisons: Comparisons between different string dtypes ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -In previous versions, comparing :class:`Series` of different string dtypes (e.g. ``pd.StringDtype("pyarrow", na_value=pd.NA)`` against ``pd.StringDtype("python", na_value=np.nan)``) would result in inconsistent resulting dtype or incorrectly raise. pandas will now use the hierarchy +In previous versions, comparing :class:`Series` of different string dtypes (e.g. ``pd.StringDtype("pyarrow", na_value=pd.NA)`` against ``pd.StringDtype("python", na_value=np.nan)``) would result in inconsistent resulting dtype or incorrectly raise (:issue:`60639`). pandas will now use the hierarchy object < (python, NaN) < (pyarrow, NaN) < (python, NA) < (pyarrow, NA) @@ -60,30 +64,13 @@ Bug fixes - Bug in :meth:`DataFrame.join` incorrectly downcasting object-dtype indexes (:issue:`61771`) - Bug in :meth:`DataFrame.sum` with ``axis=1``, :meth:`.DataFrameGroupBy.sum` or :meth:`.SeriesGroupBy.sum` with ``skipna=True``, and :meth:`.Resampler.sum` with all NA values of :class:`StringDtype` resulted in ``0`` instead of the empty string ``""`` (:issue:`60229`) - Fixed bug in :meth:`DataFrame.explode` and :meth:`Series.explode` where methods would fail with ``dtype="str"`` (:issue:`61623`) -- Fixed bug in unpickling objects pickled in pandas versions pre-2.3.0 that used :class:`StringDtype` (:issue:`61763`). - - -.. _whatsnew_231.regressions: +- Fixed bug in unpickling objects pickled in pandas versions pre-2.3.0 that used :class:`StringDtype` (:issue:`61763`) -Fixed regressions -~~~~~~~~~~~~~~~~~ -- - -.. --------------------------------------------------------------------------- -.. _whatsnew_231.bug_fixes: - -Bug fixes -~~~~~~~~~ - -.. --------------------------------------------------------------------------- -.. _whatsnew_231.other: - -Other -~~~~~ -- .. --------------------------------------------------------------------------- .. _whatsnew_231.contributors: Contributors ~~~~~~~~~~~~ + +.. contributors:: v2.3.0..v2.3.1|HEAD From d21ad1a375a76bf45305c8747f4cb196c14b0260 Mon Sep 17 00:00:00 2001 From: jbrockmendel Date: Mon, 7 Jul 2025 09:39:58 -0700 Subject: [PATCH 16/51] PERF: avoid object-dtype path in ArrowEA._explode (#61786) * PERF: avoid object-dtype path in ArrowEA._explode * typo fixup --- pandas/core/arrays/arrow/array.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pandas/core/arrays/arrow/array.py b/pandas/core/arrays/arrow/array.py index b4e60819b033f..8c90427f07d28 100644 --- a/pandas/core/arrays/arrow/array.py +++ b/pandas/core/arrays/arrow/array.py @@ -1908,8 +1908,10 @@ def _explode(self): fill_value = pa.scalar([None], type=self._pa_array.type) mask = counts == 0 if mask.any(): - values = values.copy() - values[mask] = fill_value + # pc.if_else here is similar to `values[mask] = fill_value` + # but this avoids an object-dtype round-trip. + pa_values = pc.if_else(~mask, values._pa_array, fill_value) + values = type(self)(pa_values) counts = counts.copy() counts[mask] = 1 values = values.fillna(fill_value) From 16fd2082a6694628a3a796ffc6b58629c6356133 Mon Sep 17 00:00:00 2001 From: jbrockmendel Date: Mon, 7 Jul 2025 09:42:38 -0700 Subject: [PATCH 17/51] TST: option_context bug on Mac GH#58055 (#61779) --- pandas/tests/io/formats/test_ipython_compat.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pandas/tests/io/formats/test_ipython_compat.py b/pandas/tests/io/formats/test_ipython_compat.py index 8512f41396906..df202dbd7d9fb 100644 --- a/pandas/tests/io/formats/test_ipython_compat.py +++ b/pandas/tests/io/formats/test_ipython_compat.py @@ -22,7 +22,8 @@ def test_publishes(self, ip): last_obj = None for obj, expected in zip(objects, expected_keys): last_obj = obj - with opt: + with cf.option_context("display.html.table_schema", True): + # Can't reuse opt on all systems GH#58055 formatted = ipython.display_formatter.format(obj) assert set(formatted[0].keys()) == expected From b5e441eb728d46d040cc268e083b79098c20c25f Mon Sep 17 00:00:00 2001 From: jbrockmendel Date: Mon, 7 Jul 2025 09:54:30 -0700 Subject: [PATCH 18/51] =?UTF-8?q?BUG:=20Decimal(NaN)=20incorrectly=20allow?= =?UTF-8?q?ed=20in=20ArrowEA=20constructor=20with=20tim=E2=80=A6=20(#61773?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * BUG: Decimal(NaN) incorrectly allowed in ArrowEA constructor with timestamp type * GH ref * BUG: ArrowEA constructor with timestamp type * mypy fixup * mypy fixup --- doc/source/whatsnew/v3.0.0.rst | 2 ++ pandas/core/arrays/arrow/array.py | 28 +++++++++++++++++++++++ pandas/tests/extension/test_arrow.py | 34 +++++++++++++++++++++++++--- pandas/tests/io/test_sql.py | 7 ++++++ 4 files changed, 68 insertions(+), 3 deletions(-) diff --git a/doc/source/whatsnew/v3.0.0.rst b/doc/source/whatsnew/v3.0.0.rst index 4154942f92907..10fb9503ffb3d 100644 --- a/doc/source/whatsnew/v3.0.0.rst +++ b/doc/source/whatsnew/v3.0.0.rst @@ -706,6 +706,8 @@ Datetimelike - Bug in :meth:`to_datetime` reports incorrect index in case of any failure scenario. (:issue:`58298`) - Bug in :meth:`to_datetime` with ``format="ISO8601"`` and ``utc=True`` where naive timestamps incorrectly inherited timezone offset from previous timestamps in a series. (:issue:`61389`) - Bug in :meth:`to_datetime` wrongly converts when ``arg`` is a ``np.datetime64`` object with unit of ``ps``. (:issue:`60341`) +- Bug in constructing arrays with :class:`ArrowDtype` with ``timestamp`` type incorrectly allowing ``Decimal("NaN")`` (:issue:`61773`) +- Bug in constructing arrays with a timezone-aware :class:`ArrowDtype` from timezone-naive datetime objects incorrectly treating those as UTC times instead of wall times like :class:`DatetimeTZDtype` (:issue:`61775`) - Bug in setting scalar values with mismatched resolution into arrays with non-nanosecond ``datetime64``, ``timedelta64`` or :class:`DatetimeTZDtype` incorrectly truncating those scalars (:issue:`56410`) Timedelta diff --git a/pandas/core/arrays/arrow/array.py b/pandas/core/arrays/arrow/array.py index 8c90427f07d28..919453b29b7f9 100644 --- a/pandas/core/arrays/arrow/array.py +++ b/pandas/core/arrays/arrow/array.py @@ -63,6 +63,7 @@ from pandas.core.arrays.masked import BaseMaskedArray from pandas.core.arrays.string_ import StringDtype import pandas.core.common as com +from pandas.core.construction import extract_array from pandas.core.indexers import ( check_array_indexer, unpack_tuple_and_ellipses, @@ -500,6 +501,33 @@ def _box_pa_array( value = to_timedelta(value, unit=pa_type.unit).as_unit(pa_type.unit) value = value.to_numpy() + if pa_type is not None and pa.types.is_timestamp(pa_type): + # Use DatetimeArray to exclude Decimal(NaN) (GH#61774) and + # ensure constructor treats tznaive the same as non-pyarrow + # dtypes (GH#61775) + from pandas.core.arrays.datetimes import ( + DatetimeArray, + tz_to_dtype, + ) + + pass_dtype = tz_to_dtype(tz=pa_type.tz, unit=pa_type.unit) + value = extract_array(value, extract_numpy=True) + if isinstance(value, DatetimeArray): + dta = value + else: + dta = DatetimeArray._from_sequence( + value, copy=copy, dtype=pass_dtype + ) + dta_mask = dta.isna() + value_i8 = cast("npt.NDArray", dta.view("i8")) + if not value_i8.flags["WRITEABLE"]: + # e.g. test_setitem_frame_2d_values + value_i8 = value_i8.copy() + dta = DatetimeArray._from_sequence(value_i8, dtype=dta.dtype) + value_i8[dta_mask] = 0 # GH#61776 avoid __sub__ overflow + pa_array = pa.array(dta._ndarray, type=pa_type, mask=dta_mask) + return pa_array + try: pa_array = pa.array(value, type=pa_type, from_pandas=True) except (pa.ArrowInvalid, pa.ArrowTypeError): diff --git a/pandas/tests/extension/test_arrow.py b/pandas/tests/extension/test_arrow.py index 8db837b176fe9..7e7cd8fb13456 100644 --- a/pandas/tests/extension/test_arrow.py +++ b/pandas/tests/extension/test_arrow.py @@ -2686,6 +2686,7 @@ def test_dt_tz_localize_unsupported_tz_options(): ser.dt.tz_localize("UTC", nonexistent="NaT") +@pytest.mark.xfail(reason="Converts to UTC before localizing GH#61780") def test_dt_tz_localize_none(): ser = pd.Series( [datetime(year=2023, month=1, day=2, hour=3), None], @@ -2693,7 +2694,7 @@ def test_dt_tz_localize_none(): ) result = ser.dt.tz_localize(None) expected = pd.Series( - [datetime(year=2023, month=1, day=2, hour=3), None], + [ser[0].tz_localize(None), None], dtype=ArrowDtype(pa.timestamp("ns")), ) tm.assert_series_equal(result, expected) @@ -2753,7 +2754,7 @@ def test_dt_tz_convert_none(): ) result = ser.dt.tz_convert(None) expected = pd.Series( - [datetime(year=2023, month=1, day=2, hour=3), None], + [ser[0].tz_convert(None), None], dtype=ArrowDtype(pa.timestamp("ns")), ) tm.assert_series_equal(result, expected) @@ -2767,7 +2768,7 @@ def test_dt_tz_convert(unit): ) result = ser.dt.tz_convert("US/Eastern") expected = pd.Series( - [datetime(year=2023, month=1, day=2, hour=3), None], + [ser[0].tz_convert("US/Eastern"), None], dtype=ArrowDtype(pa.timestamp(unit, "US/Eastern")), ) tm.assert_series_equal(result, expected) @@ -3548,3 +3549,30 @@ def test_arrow_json_type(): dtype = ArrowDtype(pa.json_(pa.string())) result = dtype.type assert result == str + + +def test_timestamp_dtype_disallows_decimal(): + # GH#61773 constructing with pyarrow timestamp dtype should disallow + # Decimal NaN, just like pd.to_datetime + vals = [pd.Timestamp("2016-01-02 03:04:05"), Decimal("NaN")] + + msg = " is not convertible to datetime" + with pytest.raises(TypeError, match=msg): + # Check that the non-pyarrow version raises as expected + pd.to_datetime(vals) + + with pytest.raises(TypeError, match=msg): + pd.array(vals, dtype=ArrowDtype(pa.timestamp("us"))) + + +def test_timestamp_dtype_matches_to_datetime(): + # GH#61775 + dtype1 = "datetime64[ns, US/Eastern]" + dtype2 = "timestamp[ns, US/Eastern][pyarrow]" + + ts = pd.Timestamp("2025-07-03 18:10") + + result = pd.Series([ts], dtype=dtype2) + expected = pd.Series([ts], dtype=dtype1).convert_dtypes(dtype_backend="pyarrow") + + tm.assert_series_equal(result, expected) diff --git a/pandas/tests/io/test_sql.py b/pandas/tests/io/test_sql.py index 4a6a5635eb68c..6f4c1602a5e64 100644 --- a/pandas/tests/io/test_sql.py +++ b/pandas/tests/io/test_sql.py @@ -8,6 +8,7 @@ time, timedelta, ) +from decimal import Decimal from io import StringIO from pathlib import Path import sqlite3 @@ -1038,6 +1039,12 @@ def test_dataframe_to_sql_arrow_dtypes(conn, request): def test_dataframe_to_sql_arrow_dtypes_missing(conn, request, nulls_fixture): # GH 52046 pytest.importorskip("pyarrow") + if isinstance(nulls_fixture, Decimal): + pytest.skip( + # GH#61773 + reason="Decimal('NaN') not supported in constructor for timestamp dtype" + ) + df = DataFrame( { "datetime": pd.array( From fea4f5b0312e74f5eb7fa054dec41b7a9bf540e2 Mon Sep 17 00:00:00 2001 From: jbrockmendel Date: Mon, 7 Jul 2025 10:09:59 -0700 Subject: [PATCH 19/51] REF: remove unreachable, stronger typing in parsers.pyx (#61785) * REF: remove unreachable, stronger typing in parsers.pyx * mypy fixup --- pandas/_libs/parsers.pyx | 79 +++++++++++------------ pandas/io/parsers/readers.py | 2 +- pandas/tests/io/parser/test_textreader.py | 68 +++++++++++++------ 3 files changed, 88 insertions(+), 61 deletions(-) diff --git a/pandas/_libs/parsers.pyx b/pandas/_libs/parsers.pyx index c29cdbcf5975e..43670abca2fac 100644 --- a/pandas/_libs/parsers.pyx +++ b/pandas/_libs/parsers.pyx @@ -358,7 +358,7 @@ cdef class TextReader: int64_t leading_cols, table_width object delimiter # bytes or str object converters - object na_values + object na_values # dict[hashable, set[str]] | list[str] list header # list[list[non-negative integers]] object index_col object skiprows @@ -390,8 +390,8 @@ cdef class TextReader: usecols=None, on_bad_lines=ERROR, bint na_filter=True, - na_values=None, - na_fvalues=None, + na_values=None, # dict[hashable, set[str]] | set[str] + na_fvalues=None, # dict[hashable, set[float]] | set[float] bint keep_default_na=True, true_values=None, false_values=None, @@ -486,9 +486,17 @@ cdef class TextReader: self.delimiter = delimiter + # na_fvalues is created from user-provided na_value in _clean_na_values + # which ensures that either + # a) na_values is set[str] and na_fvalues is set[float] + # b) na_values is dict[Hashable, set[str]] and + # na_fvalues is dict[Hashable, set[float]] + # (tests for this case are in test_na_values.py) + if not isinstance(na_values, dict): + # i.e. it must be a set + na_values = list(na_values) + self.na_values = na_values - if na_fvalues is None: - na_fvalues = set() self.na_fvalues = na_fvalues self.true_values = _maybe_encode(true_values) + _true_values @@ -929,7 +937,8 @@ cdef class TextReader: int nused kh_str_starts_t *na_hashset = NULL int64_t start, end - object name, na_flist, col_dtype = None + object name, col_dtype = None + set na_fset bint na_filter = 0 int64_t num_cols dict results @@ -1021,18 +1030,15 @@ cdef class TextReader: results[i] = _apply_converter(conv, self.parser, i, start, end) continue - # Collect the list of NaN values associated with the column. + # Collect the set of NaN values associated with the column. # If we aren't supposed to do that, or none are collected, # we set `na_filter` to `0` (`1` otherwise). - na_flist = set() + na_fset = set() if self.na_filter: - na_list, na_flist = self._get_na_list(i, name) - if na_list is None: - na_filter = 0 - else: - na_filter = 1 - na_hashset = kset_from_list(na_list) + na_list, na_fset = self._get_na_list(i, name) + na_filter = 1 + na_hashset = kset_from_list(na_list) else: na_filter = 0 @@ -1041,7 +1047,7 @@ cdef class TextReader: try: col_res, na_count = self._convert_tokens( i, start, end, name, na_filter, na_hashset, - na_flist, col_dtype) + na_fset, col_dtype) finally: # gh-21353 # @@ -1075,12 +1081,12 @@ cdef class TextReader: cdef _convert_tokens(self, Py_ssize_t i, int64_t start, int64_t end, object name, bint na_filter, kh_str_starts_t *na_hashset, - object na_flist, object col_dtype): + set na_fset, object col_dtype): if col_dtype is not None: col_res, na_count = self._convert_with_dtype( col_dtype, i, start, end, na_filter, - 1, na_hashset, na_flist) + 1, na_hashset, na_fset) # Fallback on the parse (e.g. we requested int dtype, # but its actually a float). @@ -1094,7 +1100,7 @@ cdef class TextReader: for dt in self.dtype_cast_order: try: col_res, na_count = self._convert_with_dtype( - dt, i, start, end, na_filter, 0, na_hashset, na_flist) + dt, i, start, end, na_filter, 0, na_hashset, na_fset) except ValueError: # This error is raised from trying to convert to uint64, # and we discover that we cannot convert to any numerical @@ -1102,11 +1108,11 @@ cdef class TextReader: # column AS IS with object dtype. col_res, na_count = self._convert_with_dtype( np.dtype("object"), i, start, end, 0, - 0, na_hashset, na_flist) + 0, na_hashset, na_fset) except OverflowError: col_res, na_count = self._convert_with_dtype( np.dtype("object"), i, start, end, na_filter, - 0, na_hashset, na_flist) + 0, na_hashset, na_fset) if col_res is not None: break @@ -1154,7 +1160,7 @@ cdef class TextReader: bint na_filter, bint user_dtype, kh_str_starts_t *na_hashset, - object na_flist): + set na_fset): if isinstance(dtype, CategoricalDtype): # TODO: I suspect that _categorical_convert could be # optimized when dtype is an instance of CategoricalDtype @@ -1212,7 +1218,7 @@ cdef class TextReader: elif dtype.kind == "f": result, na_count = _try_double(self.parser, i, start, end, - na_filter, na_hashset, na_flist) + na_filter, na_hashset, na_fset) if result is not None and dtype != "float64": result = result.astype(dtype) @@ -1272,10 +1278,6 @@ cdef class TextReader: return self.converters.get(i) cdef _get_na_list(self, Py_ssize_t i, name): - # Note: updates self.na_values, self.na_fvalues - if self.na_values is None: - return None, set() - if isinstance(self.na_values, dict): key = None values = None @@ -1300,11 +1302,6 @@ cdef class TextReader: return _ensure_encoded(values), fvalues else: - if not isinstance(self.na_values, list): - self.na_values = list(self.na_values) - if not isinstance(self.na_fvalues, set): - self.na_fvalues = set(self.na_fvalues) - return _ensure_encoded(self.na_values), self.na_fvalues cdef _free_na_set(self, kh_str_starts_t *table): @@ -1622,27 +1619,27 @@ cdef: # -> tuple[ndarray[float64_t], int] | tuple[None, None] cdef _try_double(parser_t *parser, int64_t col, int64_t line_start, int64_t line_end, - bint na_filter, kh_str_starts_t *na_hashset, object na_flist): + bint na_filter, kh_str_starts_t *na_hashset, set na_fset): cdef: int error, na_count = 0 Py_ssize_t lines float64_t *data float64_t NA = na_values[np.float64] - kh_float64_t *na_fset + kh_float64_t *na_fhashset ndarray[float64_t] result - bint use_na_flist = len(na_flist) > 0 + bint use_na_flist = len(na_fset) > 0 lines = line_end - line_start result = np.empty(lines, dtype=np.float64) data = result.data - na_fset = kset_float64_from_list(na_flist) + na_fhashset = kset_float64_from_set(na_fset) with nogil: error = _try_double_nogil(parser, parser.double_converter, col, line_start, line_end, na_filter, na_hashset, use_na_flist, - na_fset, NA, data, &na_count) + na_fhashset, NA, data, &na_count) - kh_destroy_float64(na_fset) + kh_destroy_float64(na_fhashset) if error != 0: return None, None return result, na_count @@ -1655,7 +1652,7 @@ cdef int _try_double_nogil(parser_t *parser, int64_t col, int64_t line_start, int64_t line_end, bint na_filter, kh_str_starts_t *na_hashset, bint use_na_flist, - const kh_float64_t *na_flist, + const kh_float64_t *na_fhashset, float64_t NA, float64_t *data, int *na_count) nogil: cdef: @@ -1694,8 +1691,8 @@ cdef int _try_double_nogil(parser_t *parser, else: return 1 if use_na_flist: - k64 = kh_get_float64(na_flist, data[0]) - if k64 != na_flist.n_buckets: + k64 = kh_get_float64(na_fhashset, data[0]) + if k64 != na_fhashset.n_buckets: na_count[0] += 1 data[0] = NA data += 1 @@ -1977,7 +1974,7 @@ cdef kh_str_starts_t* kset_from_list(list values) except NULL: return table -cdef kh_float64_t* kset_float64_from_list(values) except NULL: +cdef kh_float64_t* kset_float64_from_set(set values) except NULL: # caller takes responsibility for freeing the hash table cdef: kh_float64_t *table diff --git a/pandas/io/parsers/readers.py b/pandas/io/parsers/readers.py index 67193f930b4dc..4fbd71ed03662 100644 --- a/pandas/io/parsers/readers.py +++ b/pandas/io/parsers/readers.py @@ -1666,7 +1666,7 @@ def _clean_na_values(na_values, keep_default_na: bool = True, floatify: bool = T return na_values, na_fvalues -def _floatify_na_values(na_values): +def _floatify_na_values(na_values) -> set[float]: # create float versions of the na_values result = set() for v in na_values: diff --git a/pandas/tests/io/parser/test_textreader.py b/pandas/tests/io/parser/test_textreader.py index eeb783f1957b7..ea44564e3f3e1 100644 --- a/pandas/tests/io/parser/test_textreader.py +++ b/pandas/tests/io/parser/test_textreader.py @@ -24,6 +24,12 @@ ) from pandas.io.parsers.c_parser_wrapper import ensure_dtype_objs +# The only non-test way that TextReader gets called has na_valuess and na_fvalues +# either both-sets or both dicts, and the code assumes this is the case. +# But the default argument in its __init__ is None, so we have to pass these +# explicitly in tests. +_na_value_kwargs: dict[str, set] = {"na_values": set(), "na_fvalues": set()} + class TestTextReader: @pytest.fixture @@ -32,20 +38,20 @@ def csv_path(self, datapath): def test_file_handle(self, csv_path): with open(csv_path, "rb") as f: - reader = TextReader(f) + reader = TextReader(f, **_na_value_kwargs) reader.read() def test_file_handle_mmap(self, csv_path): # this was never using memory_map=True with open(csv_path, "rb") as f: - reader = TextReader(f, header=None) + reader = TextReader(f, header=None, **_na_value_kwargs) reader.read() def test_StringIO(self, csv_path): with open(csv_path, "rb") as f: text = f.read() src = BytesIO(text) - reader = TextReader(src, header=None) + reader = TextReader(src, header=None, **_na_value_kwargs) reader.read() def test_encoding_mismatch_warning(self, csv_path): @@ -58,14 +64,16 @@ def test_encoding_mismatch_warning(self, csv_path): def test_string_factorize(self): # should this be optional? data = "a\nb\na\nb\na" - reader = TextReader(StringIO(data), header=None) + reader = TextReader(StringIO(data), header=None, **_na_value_kwargs) result = reader.read() assert len(set(map(id, result[0]))) == 2 def test_skipinitialspace(self): data = "a, b\na, b\na, b\na, b" - reader = TextReader(StringIO(data), skipinitialspace=True, header=None) + reader = TextReader( + StringIO(data), skipinitialspace=True, header=None, **_na_value_kwargs + ) result = reader.read() tm.assert_numpy_array_equal( @@ -78,7 +86,7 @@ def test_skipinitialspace(self): def test_parse_booleans(self): data = "True\nFalse\nTrue\nTrue" - reader = TextReader(StringIO(data), header=None) + reader = TextReader(StringIO(data), header=None, **_na_value_kwargs) result = reader.read() assert result[0].dtype == np.bool_ @@ -86,7 +94,9 @@ def test_parse_booleans(self): def test_delimit_whitespace(self): data = 'a b\na\t\t "b"\n"a"\t \t b' - reader = TextReader(StringIO(data), delim_whitespace=True, header=None) + reader = TextReader( + StringIO(data), delim_whitespace=True, header=None, **_na_value_kwargs + ) result = reader.read() tm.assert_numpy_array_equal( @@ -99,7 +109,7 @@ def test_delimit_whitespace(self): def test_embedded_newline(self): data = 'a\n"hello\nthere"\nthis' - reader = TextReader(StringIO(data), header=None) + reader = TextReader(StringIO(data), header=None, **_na_value_kwargs) result = reader.read() expected = np.array(["a", "hello\nthere", "this"], dtype=np.object_) @@ -108,7 +118,9 @@ def test_embedded_newline(self): def test_euro_decimal(self): data = "12345,67\n345,678" - reader = TextReader(StringIO(data), delimiter=":", decimal=",", header=None) + reader = TextReader( + StringIO(data), delimiter=":", decimal=",", header=None, **_na_value_kwargs + ) result = reader.read() expected = np.array([12345.67, 345.678]) @@ -117,7 +129,13 @@ def test_euro_decimal(self): def test_integer_thousands(self): data = "123,456\n12,500" - reader = TextReader(StringIO(data), delimiter=":", thousands=",", header=None) + reader = TextReader( + StringIO(data), + delimiter=":", + thousands=",", + header=None, + **_na_value_kwargs, + ) result = reader.read() expected = np.array([123456, 12500], dtype=np.int64) @@ -138,7 +156,9 @@ def test_skip_bad_lines(self): # too many lines, see #2430 for why data = "a:b:c\nd:e:f\ng:h:i\nj:k:l:m\nl:m:n\no:p:q:r" - reader = TextReader(StringIO(data), delimiter=":", header=None) + reader = TextReader( + StringIO(data), delimiter=":", header=None, **_na_value_kwargs + ) msg = r"Error tokenizing data\. C error: Expected 3 fields in line 4, saw 4" with pytest.raises(parser.ParserError, match=msg): reader.read() @@ -148,6 +168,7 @@ def test_skip_bad_lines(self): delimiter=":", header=None, on_bad_lines=2, # Skip + **_na_value_kwargs, ) result = reader.read() expected = { @@ -163,13 +184,14 @@ def test_skip_bad_lines(self): delimiter=":", header=None, on_bad_lines=1, # Warn + **_na_value_kwargs, ) reader.read() def test_header_not_enough_lines(self): data = "skip this\nskip this\na,b,c\n1,2,3\n4,5,6" - reader = TextReader(StringIO(data), delimiter=",", header=2) + reader = TextReader(StringIO(data), delimiter=",", header=2, **_na_value_kwargs) header = reader.header expected = [["a", "b", "c"]] assert header == expected @@ -185,7 +207,13 @@ def test_header_not_enough_lines(self): def test_escapechar(self): data = '\\"hello world"\n\\"hello world"\n\\"hello world"' - reader = TextReader(StringIO(data), delimiter=",", header=None, escapechar="\\") + reader = TextReader( + StringIO(data), + delimiter=",", + header=None, + escapechar="\\", + **_na_value_kwargs, + ) result = reader.read() expected = {0: np.array(['"hello world"'] * 3, dtype=object)} assert_array_dicts_equal(result, expected) @@ -208,7 +236,9 @@ def test_numpy_string_dtype(self): def _make_reader(**kwds): if "dtype" in kwds: kwds["dtype"] = ensure_dtype_objs(kwds["dtype"]) - return TextReader(StringIO(data), delimiter=",", header=None, **kwds) + return TextReader( + StringIO(data), delimiter=",", header=None, **kwds, **_na_value_kwargs + ) reader = _make_reader(dtype="S5,i4") result = reader.read() @@ -237,7 +267,7 @@ def test_pass_dtype(self): def _make_reader(**kwds): if "dtype" in kwds: kwds["dtype"] = ensure_dtype_objs(kwds["dtype"]) - return TextReader(StringIO(data), delimiter=",", **kwds) + return TextReader(StringIO(data), delimiter=",", **kwds, **_na_value_kwargs) reader = _make_reader(dtype={"one": "u1", 1: "S1"}) result = reader.read() @@ -263,7 +293,7 @@ def test_usecols(self): 10,11,12""" def _make_reader(**kwds): - return TextReader(StringIO(data), delimiter=",", **kwds) + return TextReader(StringIO(data), delimiter=",", **kwds, **_na_value_kwargs) reader = _make_reader(usecols=(1, 2)) result = reader.read() @@ -296,14 +326,14 @@ def _make_reader(**kwds): ) def test_cr_delimited(self, text, kwargs): nice_text = text.replace("\r", "\r\n") - result = TextReader(StringIO(text), **kwargs).read() - expected = TextReader(StringIO(nice_text), **kwargs).read() + result = TextReader(StringIO(text), **kwargs, **_na_value_kwargs).read() + expected = TextReader(StringIO(nice_text), **kwargs, **_na_value_kwargs).read() assert_array_dicts_equal(result, expected) def test_empty_field_eof(self): data = "a,b,c\n1,2,3\n4,," - result = TextReader(StringIO(data), delimiter=",").read() + result = TextReader(StringIO(data), delimiter=",", **_na_value_kwargs).read() expected = { 0: np.array([1, 4], dtype=np.int64), From 7c2796d134e74f613cbfd85137d6809f5abf39a4 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 7 Jul 2025 11:09:15 -0700 Subject: [PATCH 20/51] [pre-commit.ci] pre-commit autoupdate (#61802) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [pre-commit.ci] pre-commit autoupdate updates: - [github.com/astral-sh/ruff-pre-commit: v0.11.12 → v0.12.2](https://github.com/astral-sh/ruff-pre-commit/compare/v0.11.12...v0.12.2) - [github.com/MarcoGorelli/cython-lint: v0.16.6 → v0.16.7](https://github.com/MarcoGorelli/cython-lint/compare/v0.16.6...v0.16.7) - [github.com/pre-commit/mirrors-clang-format: v20.1.5 → v20.1.7](https://github.com/pre-commit/mirrors-clang-format/compare/v20.1.5...v20.1.7) - [github.com/trim21/pre-commit-mirror-meson: v1.8.1 → v1.8.2](https://github.com/trim21/pre-commit-mirror-meson/compare/v1.8.1...v1.8.2) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Rename method * ignore PLW0177 * Noqa test --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Matthew Roeschke <10647082+mroeschke@users.noreply.github.com> --- .pre-commit-config.yaml | 8 +++--- asv_bench/benchmarks/gil.py | 30 +++++++++++----------- pandas/core/frame.py | 2 +- pandas/core/indexes/base.py | 2 +- pandas/io/parsers/base_parser.py | 2 +- pandas/io/parsers/python_parser.py | 2 +- pandas/tests/arithmetic/test_datetime64.py | 3 +-- pandas/util/_tester.py | 2 +- pyproject.toml | 2 ++ 9 files changed, 27 insertions(+), 26 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b5856810b749e..8174c5515af1f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -19,7 +19,7 @@ ci: skip: [pyright, mypy] repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.11.12 + rev: v0.12.2 hooks: - id: ruff args: [--exit-non-zero-on-fix] @@ -47,7 +47,7 @@ repos: types_or: [python, rst, markdown, cython, c] additional_dependencies: [tomli] - repo: https://github.com/MarcoGorelli/cython-lint - rev: v0.16.6 + rev: v0.16.7 hooks: - id: cython-lint - id: double-quote-cython-strings @@ -95,14 +95,14 @@ repos: - id: sphinx-lint args: ["--enable", "all", "--disable", "line-too-long"] - repo: https://github.com/pre-commit/mirrors-clang-format - rev: v20.1.5 + rev: v20.1.7 hooks: - id: clang-format files: ^pandas/_libs/src|^pandas/_libs/include args: [-i] types_or: [c, c++] - repo: https://github.com/trim21/pre-commit-mirror-meson - rev: v1.8.1 + rev: v1.8.2 hooks: - id: meson-fmt args: ['--inplace'] diff --git a/asv_bench/benchmarks/gil.py b/asv_bench/benchmarks/gil.py index a0c4189c72d0e..a2f1db9ef6b87 100644 --- a/asv_bench/benchmarks/gil.py +++ b/asv_bench/benchmarks/gil.py @@ -36,7 +36,7 @@ from .pandas_vb_common import BaseIO # isort:skip -def test_parallel(num_threads=2, kwargs_list=None): +def run_parallel(num_threads=2, kwargs_list=None): """ Decorator to run the same function multiple times in parallel. @@ -95,7 +95,7 @@ def setup(self, threads, method): {"key": np.random.randint(0, ngroups, size=N), "data": np.random.randn(N)} ) - @test_parallel(num_threads=threads) + @run_parallel(num_threads=threads) def parallel(): getattr(df.groupby("key")["data"], method)() @@ -123,7 +123,7 @@ def setup(self, threads): ngroups = 10**3 data = Series(np.random.randint(0, ngroups, size=size)) - @test_parallel(num_threads=threads) + @run_parallel(num_threads=threads) def get_groups(): data.groupby(data).groups @@ -142,7 +142,7 @@ def setup(self, dtype): df = DataFrame({"col": np.arange(N, dtype=dtype)}) indexer = np.arange(100, len(df) - 100) - @test_parallel(num_threads=2) + @run_parallel(num_threads=2) def parallel_take1d(): take_nd(df["col"].values, indexer) @@ -163,7 +163,7 @@ def setup(self): k = 5 * 10**5 kwargs_list = [{"arr": np.random.randn(N)}, {"arr": np.random.randn(N)}] - @test_parallel(num_threads=2, kwargs_list=kwargs_list) + @run_parallel(num_threads=2, kwargs_list=kwargs_list) def parallel_kth_smallest(arr): algos.kth_smallest(arr, k) @@ -180,42 +180,42 @@ def setup(self): self.period = self.dti.to_period("D") def time_datetime_field_year(self): - @test_parallel(num_threads=2) + @run_parallel(num_threads=2) def run(dti): dti.year run(self.dti) def time_datetime_field_day(self): - @test_parallel(num_threads=2) + @run_parallel(num_threads=2) def run(dti): dti.day run(self.dti) def time_datetime_field_daysinmonth(self): - @test_parallel(num_threads=2) + @run_parallel(num_threads=2) def run(dti): dti.days_in_month run(self.dti) def time_datetime_field_normalize(self): - @test_parallel(num_threads=2) + @run_parallel(num_threads=2) def run(dti): dti.normalize() run(self.dti) def time_datetime_to_period(self): - @test_parallel(num_threads=2) + @run_parallel(num_threads=2) def run(dti): dti.to_period("s") run(self.dti) def time_period_to_datetime(self): - @test_parallel(num_threads=2) + @run_parallel(num_threads=2) def run(period): period.to_timestamp() @@ -232,7 +232,7 @@ def setup(self, method): if hasattr(DataFrame, "rolling"): df = DataFrame(arr).rolling(win) - @test_parallel(num_threads=2) + @run_parallel(num_threads=2) def parallel_rolling(): getattr(df, method)() @@ -249,7 +249,7 @@ def parallel_rolling(): "std": rolling_std, } - @test_parallel(num_threads=2) + @run_parallel(num_threads=2) def parallel_rolling(): rolling[method](arr, win) @@ -286,7 +286,7 @@ def setup(self, dtype): self.fname = f"__test_{dtype}__.csv" df.to_csv(self.fname) - @test_parallel(num_threads=2) + @run_parallel(num_threads=2) def parallel_read_csv(): read_csv(self.fname) @@ -305,7 +305,7 @@ class ParallelFactorize: def setup(self, threads): strings = Index([f"i-{i}" for i in range(100000)], dtype=object) - @test_parallel(num_threads=threads) + @run_parallel(num_threads=threads) def parallel(): factorize(strings) diff --git a/pandas/core/frame.py b/pandas/core/frame.py index 8053c17437c5e..632ab12edd7e4 100644 --- a/pandas/core/frame.py +++ b/pandas/core/frame.py @@ -7235,7 +7235,7 @@ def sort_values( indexer = lexsort_indexer( keys_data, orders=ascending, na_position=na_position, key=key ) - elif len(by): + elif by: # len(by) == 1 k = self._get_label_or_level_values(by[0], axis=axis) diff --git a/pandas/core/indexes/base.py b/pandas/core/indexes/base.py index 2deaaae85e56b..e81eb3aef11f6 100644 --- a/pandas/core/indexes/base.py +++ b/pandas/core/indexes/base.py @@ -7635,7 +7635,7 @@ def ensure_index(index_like: Axes, copy: bool = False) -> Index: # check in clean_index_list index_like = list(index_like) - if len(index_like) and lib.is_all_arraylike(index_like): + if index_like and lib.is_all_arraylike(index_like): from pandas.core.indexes.multi import MultiIndex return MultiIndex.from_arrays(index_like) diff --git a/pandas/io/parsers/base_parser.py b/pandas/io/parsers/base_parser.py index c283f600eb971..23efc9c87e07c 100644 --- a/pandas/io/parsers/base_parser.py +++ b/pandas/io/parsers/base_parser.py @@ -243,7 +243,7 @@ def extract(r): names.insert(single_ic, single_ic) # Clean the column names (if we have an index_col). - if len(ic): + if ic: col_names = [ r[ic[0]] if ((r[ic[0]] is not None) and r[ic[0]] not in self.unnamed_cols) diff --git a/pandas/io/parsers/python_parser.py b/pandas/io/parsers/python_parser.py index 547d8c1fe3d19..70f0eefc55fd9 100644 --- a/pandas/io/parsers/python_parser.py +++ b/pandas/io/parsers/python_parser.py @@ -281,7 +281,7 @@ def read( index: Index | None columns: Sequence[Hashable] = list(self.orig_names) - if not len(content): # pragma: no cover + if not content: # pragma: no cover # DataFrame with the right metadata, even though it's length 0 # error: Cannot determine type of 'index_col' names = dedup_names( diff --git a/pandas/tests/arithmetic/test_datetime64.py b/pandas/tests/arithmetic/test_datetime64.py index 26dfcf088e74b..d439ff723b355 100644 --- a/pandas/tests/arithmetic/test_datetime64.py +++ b/pandas/tests/arithmetic/test_datetime64.py @@ -9,7 +9,6 @@ ) from itertools import ( product, - starmap, ) import operator @@ -2211,7 +2210,7 @@ def test_timedelta64_equal_timedelta_supported_ops(self, op, box_with_array): def timedelta64(*args): # see casting notes in NumPy gh-12927 - return np.sum(list(starmap(np.timedelta64, zip(args, intervals)))) + return np.sum(list(map(np.timedelta64, args, intervals))) for d, h, m, s, us in product(*([range(2)] * 5)): nptd = timedelta64(d, h, m, s, us) diff --git a/pandas/util/_tester.py b/pandas/util/_tester.py index c0e9756372f47..f455e06dde8bb 100644 --- a/pandas/util/_tester.py +++ b/pandas/util/_tester.py @@ -12,7 +12,7 @@ PKG = os.path.dirname(os.path.dirname(__file__)) -def test(extra_args: list[str] | None = None, run_doctests: bool = False) -> None: +def test(extra_args: list[str] | None = None, run_doctests: bool = False) -> None: # noqa: PT028 """ Run the pandas test suite using pytest. diff --git a/pyproject.toml b/pyproject.toml index 7582e2bce3879..877df4835c07c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -318,6 +318,8 @@ ignore = [ "ISC001", # if-stmt-min-max "PLR1730", + # nan-comparison + "PLW0177", ### TODO: Enable gradually # Useless statement From d1a245c1bf58f503403c23e64c641f85045534b9 Mon Sep 17 00:00:00 2001 From: Matthew Roeschke <10647082+mroeschke@users.noreply.github.com> Date: Tue, 8 Jul 2025 08:44:41 -0700 Subject: [PATCH 21/51] DEPS: Bump NumPy and tzdata (#61806) * Bump numpy * Bump numpy * Bump tzdata * ignore pytables usage, update xfail condition --- ci/deps/actions-310-minimum_versions.yaml | 4 +-- ci/deps/actions-310.yaml | 2 +- ci/deps/actions-311-downstream_compat.yaml | 2 +- ci/deps/actions-311-numpydev.yaml | 2 +- ci/deps/actions-311-pyarrownightly.yaml | 2 +- ci/deps/actions-312.yaml | 2 +- ci/deps/actions-313-freethreading.yaml | 2 +- ci/deps/actions-313.yaml | 2 +- ci/deps/actions-pypy-39.yaml | 2 +- ci/meta.yaml | 2 +- doc/source/getting_started/install.rst | 4 +-- doc/source/whatsnew/v3.0.0.rst | 16 +++++----- environment.yml | 2 +- pandas/compat/_optional.py | 1 - pandas/compat/numpy/__init__.py | 11 ++----- pandas/core/common.py | 9 +----- .../apply/test_frame_apply_relabeling.py | 10 ++---- pandas/tests/extension/test_period.py | 3 +- pandas/tests/frame/methods/test_compare.py | 4 +-- pandas/tests/frame/test_unary.py | 13 +++----- pandas/tests/indexes/test_common.py | 13 +++----- pandas/tests/io/parser/test_c_parser_only.py | 4 +-- pandas/tests/io/pytables/test_complex.py | 3 ++ pandas/tests/io/pytables/test_timezones.py | 6 ++++ .../plotting/frame/test_frame_subplots.py | 5 ++- pandas/tests/plotting/test_series.py | 5 ++- pandas/tests/reshape/test_pivot.py | 9 ------ pandas/tests/scalar/test_nat.py | 31 ++----------------- pandas/tests/series/indexing/test_setitem.py | 11 ++----- pandas/tests/series/methods/test_describe.py | 4 +-- pyproject.toml | 5 ++- requirements-dev.txt | 2 +- scripts/generate_pip_deps_from_conda.py | 2 +- scripts/tests/data/deps_minimum.toml | 4 +-- 34 files changed, 66 insertions(+), 133 deletions(-) diff --git a/ci/deps/actions-310-minimum_versions.yaml b/ci/deps/actions-310-minimum_versions.yaml index a9ea6a639043b..ee2d083ffc56f 100644 --- a/ci/deps/actions-310-minimum_versions.yaml +++ b/ci/deps/actions-310-minimum_versions.yaml @@ -22,7 +22,7 @@ dependencies: # required dependencies - python-dateutil=2.8.2 - - numpy=1.23.5 + - numpy=1.26.0 # optional dependencies - beautifulsoup4=4.12.3 @@ -62,4 +62,4 @@ dependencies: - pip: - adbc-driver-postgresql==0.10.0 - adbc-driver-sqlite==0.8.0 - - tzdata==2022.7 + - tzdata==2023.3 diff --git a/ci/deps/actions-310.yaml b/ci/deps/actions-310.yaml index 4904140f2e70b..83386f07b631c 100644 --- a/ci/deps/actions-310.yaml +++ b/ci/deps/actions-310.yaml @@ -60,4 +60,4 @@ dependencies: - pip: - adbc-driver-postgresql>=0.10.0 - adbc-driver-sqlite>=0.8.0 - - tzdata>=2022.7 + - tzdata>=2023.3 diff --git a/ci/deps/actions-311-downstream_compat.yaml b/ci/deps/actions-311-downstream_compat.yaml index 1fc8a9ed21777..f96e148c41e6d 100644 --- a/ci/deps/actions-311-downstream_compat.yaml +++ b/ci/deps/actions-311-downstream_compat.yaml @@ -73,4 +73,4 @@ dependencies: - pip: - adbc-driver-postgresql>=0.10.0 - adbc-driver-sqlite>=0.8.0 - - tzdata>=2022.7 + - tzdata>=2023.3 diff --git a/ci/deps/actions-311-numpydev.yaml b/ci/deps/actions-311-numpydev.yaml index 99cbe0415b4f9..f8a84441ddb3b 100644 --- a/ci/deps/actions-311-numpydev.yaml +++ b/ci/deps/actions-311-numpydev.yaml @@ -24,4 +24,4 @@ dependencies: - "--extra-index-url https://pypi.anaconda.org/scientific-python-nightly-wheels/simple" - "--pre" - "numpy" - - "tzdata>=2022.7" + - "tzdata>=2023.3" diff --git a/ci/deps/actions-311-pyarrownightly.yaml b/ci/deps/actions-311-pyarrownightly.yaml index da0cecda0fb46..5c74c243f0f6c 100644 --- a/ci/deps/actions-311-pyarrownightly.yaml +++ b/ci/deps/actions-311-pyarrownightly.yaml @@ -22,7 +22,7 @@ dependencies: - pip - pip: - - "tzdata>=2022.7" + - "tzdata>=2023.3" - "--extra-index-url https://pypi.anaconda.org/scientific-python-nightly-wheels/simple" - "--prefer-binary" - "--pre" diff --git a/ci/deps/actions-312.yaml b/ci/deps/actions-312.yaml index 97b582b80fb8f..5a24b0c1077d0 100644 --- a/ci/deps/actions-312.yaml +++ b/ci/deps/actions-312.yaml @@ -60,4 +60,4 @@ dependencies: - pip: - adbc-driver-postgresql>=0.10.0 - adbc-driver-sqlite>=0.8.0 - - tzdata>=2022.7 + - tzdata>=2023.3 diff --git a/ci/deps/actions-313-freethreading.yaml b/ci/deps/actions-313-freethreading.yaml index 14e3ade976b01..e118080bc4c40 100644 --- a/ci/deps/actions-313-freethreading.yaml +++ b/ci/deps/actions-313-freethreading.yaml @@ -25,5 +25,5 @@ dependencies: - pip: # No free-threaded coveragepy (with the C-extension) on conda-forge yet - pytest-cov - - "tzdata>=2022.7" + - tzdata>=2023.3 - "--extra-index-url https://pypi.anaconda.org/scientific-python-nightly-wheels/simple" diff --git a/ci/deps/actions-313.yaml b/ci/deps/actions-313.yaml index 4bc363dc4a27e..ffca09b901852 100644 --- a/ci/deps/actions-313.yaml +++ b/ci/deps/actions-313.yaml @@ -60,4 +60,4 @@ dependencies: - pip: - adbc-driver-postgresql>=0.10.0 - adbc-driver-sqlite>=0.8.0 - - tzdata>=2022.7 + - tzdata>=2023.3 diff --git a/ci/deps/actions-pypy-39.yaml b/ci/deps/actions-pypy-39.yaml index e0ddc6954e4a4..da1e2bc2f934f 100644 --- a/ci/deps/actions-pypy-39.yaml +++ b/ci/deps/actions-pypy-39.yaml @@ -23,4 +23,4 @@ dependencies: - numpy - python-dateutil - pip: - - tzdata>=2022.7 + - tzdata>=2023.3 diff --git a/ci/meta.yaml b/ci/meta.yaml index a4c9e8189f082..853c3093fa5bc 100644 --- a/ci/meta.yaml +++ b/ci/meta.yaml @@ -37,7 +37,7 @@ requirements: - numpy >=1.21.6 # [py<311] - numpy >=1.23.2 # [py>=311] - python-dateutil >=2.8.2 - - python-tzdata >=2022.7 + - python-tzdata >=2023.3 test: imports: diff --git a/doc/source/getting_started/install.rst b/doc/source/getting_started/install.rst index ed0c8bd05098d..8bb93406f617d 100644 --- a/doc/source/getting_started/install.rst +++ b/doc/source/getting_started/install.rst @@ -148,9 +148,9 @@ pandas requires the following dependencies. ================================================================ ========================== Package Minimum supported version ================================================================ ========================== -`NumPy `__ 1.23.5 +`NumPy `__ 1.26.0 `python-dateutil `__ 2.8.2 -`tzdata `__ 2022.7 +`tzdata `__ 2023.3 ================================================================ ========================== .. _install.optional_dependencies: diff --git a/doc/source/whatsnew/v3.0.0.rst b/doc/source/whatsnew/v3.0.0.rst index 10fb9503ffb3d..5ab3f35c9cc92 100644 --- a/doc/source/whatsnew/v3.0.0.rst +++ b/doc/source/whatsnew/v3.0.0.rst @@ -306,13 +306,15 @@ pandas 3.0.0 supports Python 3.10 and higher. Increased minimum versions for dependencies ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Some minimum supported versions of dependencies were updated. -If installed, we now require: - -+-----------------+-----------------+----------+---------+ -| Package | Minimum Version | Required | Changed | -+=================+=================+==========+=========+ -| numpy | 1.23.5 | X | X | -+-----------------+-----------------+----------+---------+ +The following required dependencies were updated: + ++-----------------+----------------------+ +| Package | New Minimum Version | ++=================+======================+ +| numpy | 1.26.0 | ++-----------------+----------------------+ +| tzdata | 2023.3 | ++-----------------+----------------------+ For `optional libraries `_ the general recommendation is to use the latest version. The following table lists the lowest version per library that is currently being tested throughout the development of pandas. diff --git a/environment.yml b/environment.yml index d89a788827109..2a566773b884a 100644 --- a/environment.yml +++ b/environment.yml @@ -123,4 +123,4 @@ dependencies: - adbc-driver-postgresql>=0.10.0 - adbc-driver-sqlite>=0.8.0 - typing_extensions; python_version<"3.11" - - tzdata>=2022.7 + - tzdata>=2023.3 diff --git a/pandas/compat/_optional.py b/pandas/compat/_optional.py index c2a232d55d8e2..7e882bc242394 100644 --- a/pandas/compat/_optional.py +++ b/pandas/compat/_optional.py @@ -54,7 +54,6 @@ "xlrd": "2.0.1", "xlsxwriter": "3.2.0", "zstandard": "0.22.0", - "tzdata": "2022.7", "qtpy": "2.3.0", "pyqt5": "5.15.9", } diff --git a/pandas/compat/numpy/__init__.py b/pandas/compat/numpy/__init__.py index e95b44c879940..f9368f7d119d0 100644 --- a/pandas/compat/numpy/__init__.py +++ b/pandas/compat/numpy/__init__.py @@ -9,19 +9,15 @@ # numpy versioning _np_version = np.__version__ _nlv = Version(_np_version) -np_version_gte1p24 = _nlv >= Version("1.24") -np_version_gte1p24p3 = _nlv >= Version("1.24.3") -np_version_gte1p25 = _nlv >= Version("1.25") np_version_gt2 = _nlv >= Version("2.0.0") is_numpy_dev = _nlv.dev is not None -_min_numpy_ver = "1.23.5" +_min_numpy_ver = "1.26.0" if _nlv < Version(_min_numpy_ver): raise ImportError( - f"this version of pandas is incompatible with numpy < {_min_numpy_ver}\n" - f"your numpy version is {_np_version}.\n" - f"Please upgrade numpy to >= {_min_numpy_ver} to use this pandas version" + f"Please upgrade numpy to >= {_min_numpy_ver} to use this pandas version.\n" + f"Your numpy version is {_np_version}." ) @@ -49,5 +45,4 @@ __all__ = [ "_np_version", "is_numpy_dev", - "np", ] diff --git a/pandas/core/common.py b/pandas/core/common.py index 75f8a56aac5db..f4e971c4b4bd4 100644 --- a/pandas/core/common.py +++ b/pandas/core/common.py @@ -29,12 +29,10 @@ cast, overload, ) -import warnings import numpy as np from pandas._libs import lib -from pandas.compat.numpy import np_version_gte1p24 from pandas.core.dtypes.cast import construct_1d_object_array_from_listlike from pandas.core.dtypes.common import ( @@ -243,12 +241,7 @@ def asarray_tuplesafe(values: Iterable, dtype: NpDtype | None = None) -> ArrayLi return construct_1d_object_array_from_listlike(values) try: - with warnings.catch_warnings(): - # Can remove warning filter once NumPy 1.24 is min version - if not np_version_gte1p24: - # np.VisibleDeprecationWarning only in np.exceptions in 2.0 - warnings.simplefilter("ignore", np.VisibleDeprecationWarning) # type: ignore[attr-defined] - result = np.asarray(values, dtype=dtype) + result = np.asarray(values, dtype=dtype) except ValueError: # Using try/except since it's more performant than checking is_list_like # over each element diff --git a/pandas/tests/apply/test_frame_apply_relabeling.py b/pandas/tests/apply/test_frame_apply_relabeling.py index 57c109abba304..86918ec09aa97 100644 --- a/pandas/tests/apply/test_frame_apply_relabeling.py +++ b/pandas/tests/apply/test_frame_apply_relabeling.py @@ -1,7 +1,4 @@ import numpy as np -import pytest - -from pandas.compat.numpy import np_version_gte1p25 import pandas as pd import pandas._testing as tm @@ -45,7 +42,6 @@ def test_agg_relabel_multi_columns_multi_methods(): tm.assert_frame_equal(result, expected) -@pytest.mark.xfail(np_version_gte1p25, reason="name of min now equals name of np.min") def test_agg_relabel_partial_functions(): # GH 26513, test on partial, functools or more complex cases df = pd.DataFrame({"A": [1, 2, 1, 2], "B": [1, 2, 3, 4], "C": [3, 4, 5, 6]}) @@ -57,7 +53,7 @@ def test_agg_relabel_partial_functions(): result = df.agg( foo=("A", min), - bar=("A", np.min), + bar=("B", np.min), cat=("B", max), dat=("C", "min"), f=("B", np.sum), @@ -65,8 +61,8 @@ def test_agg_relabel_partial_functions(): ) expected = pd.DataFrame( { - "A": [1.0, 1.0, np.nan, np.nan, np.nan, np.nan], - "B": [np.nan, np.nan, 4.0, np.nan, 10.0, 1.0], + "A": [1.0, np.nan, np.nan, np.nan, np.nan, np.nan], + "B": [np.nan, 1.0, 4.0, np.nan, 10.0, 1.0], "C": [np.nan, np.nan, np.nan, 3.0, np.nan, np.nan], }, index=pd.Index(["foo", "bar", "cat", "dat", "f", "kk"]), diff --git a/pandas/tests/extension/test_period.py b/pandas/tests/extension/test_period.py index 142bad6db4f95..2e6fe12cbbd13 100644 --- a/pandas/tests/extension/test_period.py +++ b/pandas/tests/extension/test_period.py @@ -26,7 +26,6 @@ iNaT, ) from pandas.compat import is_platform_windows -from pandas.compat.numpy import np_version_gte1p24 from pandas.core.dtypes.dtypes import PeriodDtype @@ -104,7 +103,7 @@ def check_reduce(self, ser: pd.Series, op_name: str, skipna: bool): @pytest.mark.parametrize("periods", [1, -2]) def test_diff(self, data, periods): - if is_platform_windows() and np_version_gte1p24: + if is_platform_windows(): with tm.assert_produces_warning(RuntimeWarning, check_stacklevel=False): super().test_diff(data, periods) else: diff --git a/pandas/tests/frame/methods/test_compare.py b/pandas/tests/frame/methods/test_compare.py index 2ffc3f933e246..aea1a24097206 100644 --- a/pandas/tests/frame/methods/test_compare.py +++ b/pandas/tests/frame/methods/test_compare.py @@ -1,8 +1,6 @@ import numpy as np import pytest -from pandas.compat.numpy import np_version_gte1p25 - import pandas as pd import pandas._testing as tm @@ -270,7 +268,7 @@ def test_compare_ea_and_np_dtype(val1, val2): # GH#18463 TODO: is this really the desired behavior? expected.loc[1, ("a", "self")] = np.nan - if val1 is pd.NA and np_version_gte1p25: + if val1 is pd.NA: # can't compare with numpy array if it contains pd.NA with pytest.raises(TypeError, match="boolean value of NA is ambiguous"): result = df1.compare(df2, keep_shape=True) diff --git a/pandas/tests/frame/test_unary.py b/pandas/tests/frame/test_unary.py index 652f52bd226af..034a43ac40bba 100644 --- a/pandas/tests/frame/test_unary.py +++ b/pandas/tests/frame/test_unary.py @@ -3,8 +3,6 @@ import numpy as np import pytest -from pandas.compat.numpy import np_version_gte1p25 - import pandas as pd import pandas._testing as tm @@ -123,13 +121,10 @@ def test_pos_object(self, df_data): def test_pos_object_raises(self): # GH#21380 df = pd.DataFrame({"a": ["a", "b"]}) - if np_version_gte1p25: - with pytest.raises( - TypeError, match=r"^bad operand type for unary \+: \'str\'$" - ): - tm.assert_frame_equal(+df, df) - else: - tm.assert_series_equal(+df["a"], df["a"]) + with pytest.raises( + TypeError, match=r"^bad operand type for unary \+: \'str\'$" + ): + tm.assert_frame_equal(+df, df) def test_pos_raises(self): df = pd.DataFrame({"a": pd.to_datetime(["2017-01-22", "1970-01-01"])}) diff --git a/pandas/tests/indexes/test_common.py b/pandas/tests/indexes/test_common.py index bf16554871efc..a842d174a4894 100644 --- a/pandas/tests/indexes/test_common.py +++ b/pandas/tests/indexes/test_common.py @@ -14,7 +14,6 @@ import pytest from pandas.compat import IS64 -from pandas.compat.numpy import np_version_gte1p25 from pandas.core.dtypes.common import ( is_integer_dtype, @@ -381,13 +380,11 @@ def test_astype_preserves_name(self, index, dtype): else: index.name = "idx" - warn = None - if index.dtype.kind == "c" and dtype in ["float64", "int64", "uint64"]: - # imaginary components discarded - if np_version_gte1p25: - warn = np.exceptions.ComplexWarning - else: - warn = np.ComplexWarning + warn = ( + np.exceptions.ComplexWarning + if index.dtype.kind == "c" and dtype in ["float64", "int64", "uint64"] + else None + ) is_pyarrow_str = str(index.dtype) == "string[pyarrow]" and dtype == "category" try: diff --git a/pandas/tests/io/parser/test_c_parser_only.py b/pandas/tests/io/parser/test_c_parser_only.py index 11a30a26f91ef..469fe84a80dcd 100644 --- a/pandas/tests/io/parser/test_c_parser_only.py +++ b/pandas/tests/io/parser/test_c_parser_only.py @@ -19,7 +19,6 @@ import pytest from pandas.compat import WASM -from pandas.compat.numpy import np_version_gte1p24 from pandas.errors import ( ParserError, ParserWarning, @@ -90,10 +89,9 @@ def test_dtype_and_names_error(c_parser_only): 3.0 3 """ # fallback casting, but not castable - warning = RuntimeWarning if np_version_gte1p24 else None if not WASM: # no fp exception support in wasm with pytest.raises(ValueError, match="cannot safely convert"): - with tm.assert_produces_warning(warning, check_stacklevel=False): + with tm.assert_produces_warning(RuntimeWarning, check_stacklevel=False): parser.read_csv( StringIO(data), sep=r"\s+", diff --git a/pandas/tests/io/pytables/test_complex.py b/pandas/tests/io/pytables/test_complex.py index c5cac5a5caf09..80e7664d1969e 100644 --- a/pandas/tests/io/pytables/test_complex.py +++ b/pandas/tests/io/pytables/test_complex.py @@ -82,6 +82,9 @@ def test_complex_mixed_fixed(tmp_path, setup_path): tm.assert_frame_equal(df, reread) +@pytest.mark.filterwarnings( + "ignore:`alltrue` is deprecated as of NumPy 1.25.0:DeprecationWarning" +) def test_complex_mixed_table(tmp_path, setup_path): complex64 = np.array( [1.0 + 1.0j, 1.0 + 1.0j, 1.0 + 1.0j, 1.0 + 1.0j], dtype=np.complex64 diff --git a/pandas/tests/io/pytables/test_timezones.py b/pandas/tests/io/pytables/test_timezones.py index 9192804e49bd1..7bfc392af55f8 100644 --- a/pandas/tests/io/pytables/test_timezones.py +++ b/pandas/tests/io/pytables/test_timezones.py @@ -42,6 +42,9 @@ def _compare_with_tz(a, b): gettz_pytz = lambda x: x +@pytest.mark.filterwarnings( + "ignore:`alltrue` is deprecated as of NumPy 1.25.0:DeprecationWarning" +) @pytest.mark.parametrize("gettz", [gettz_dateutil, gettz_pytz]) def test_append_with_timezones(setup_path, gettz): # as columns @@ -332,6 +335,9 @@ def test_dst_transitions(setup_path): tm.assert_frame_equal(result, df) +@pytest.mark.filterwarnings( + "ignore:`alltrue` is deprecated as of NumPy 1.25.0:DeprecationWarning" +) def test_read_with_where_tz_aware_index(tmp_path, setup_path): # GH 11926 periods = 10 diff --git a/pandas/tests/plotting/frame/test_frame_subplots.py b/pandas/tests/plotting/frame/test_frame_subplots.py index b44725a01fe23..7f4009bdb5e66 100644 --- a/pandas/tests/plotting/frame/test_frame_subplots.py +++ b/pandas/tests/plotting/frame/test_frame_subplots.py @@ -6,7 +6,6 @@ import pytest from pandas.compat import is_platform_linux -from pandas.compat.numpy import np_version_gte1p24 import pandas as pd from pandas import ( @@ -423,7 +422,7 @@ def test_subplots_dup_columns_secondary_y_no_subplot(self): assert len(ax.right_ax.lines) == 5 @pytest.mark.xfail( - np_version_gte1p24 and is_platform_linux(), + is_platform_linux(), reason="Weird rounding problems", strict=False, ) @@ -438,7 +437,7 @@ def test_bar_log_no_subplots(self): tm.assert_numpy_array_equal(ax.yaxis.get_ticklocs(), expected) @pytest.mark.xfail( - np_version_gte1p24 and is_platform_linux(), + is_platform_linux(), reason="Weird rounding problems", strict=False, ) diff --git a/pandas/tests/plotting/test_series.py b/pandas/tests/plotting/test_series.py index 98e70f770896c..eb1b4f7d85a68 100644 --- a/pandas/tests/plotting/test_series.py +++ b/pandas/tests/plotting/test_series.py @@ -7,7 +7,6 @@ import pytest from pandas.compat import is_platform_linux -from pandas.compat.numpy import np_version_gte1p24 import pandas.util._test_decorators as td import pandas as pd @@ -277,7 +276,7 @@ def test_line_use_index_false_diff_var(self): assert label2 == "" @pytest.mark.xfail( - np_version_gte1p24 and is_platform_linux(), + is_platform_linux(), reason="Weird rounding problems", strict=False, ) @@ -290,7 +289,7 @@ def test_bar_log(self, axis, meth): tm.assert_numpy_array_equal(getattr(ax, axis).get_ticklocs(), expected) @pytest.mark.xfail( - np_version_gte1p24 and is_platform_linux(), + is_platform_linux(), reason="Weird rounding problems", strict=False, ) diff --git a/pandas/tests/reshape/test_pivot.py b/pandas/tests/reshape/test_pivot.py index 2a58815c1cece..e46134df73dba 100644 --- a/pandas/tests/reshape/test_pivot.py +++ b/pandas/tests/reshape/test_pivot.py @@ -11,8 +11,6 @@ from pandas._config import using_string_dtype -from pandas.compat.numpy import np_version_gte1p25 - import pandas as pd from pandas import ( ArrowDtype, @@ -2134,13 +2132,6 @@ def test_pivot_string_func_vs_func(self, f, f_numpy, data): data = data.drop(columns="C") result = pivot_table(data, index="A", columns="B", aggfunc=f) expected = pivot_table(data, index="A", columns="B", aggfunc=f_numpy) - - if not np_version_gte1p25 and isinstance(f_numpy, list): - # Prior to 1.25, np.min/np.max would come through as amin and amax - mapper = {"amin": "min", "amax": "max", "sum": "sum", "mean": "mean"} - expected.columns = expected.columns.map( - lambda x: (mapper[x[0]], x[1], x[2]) - ) tm.assert_frame_equal(result, expected) @pytest.mark.slow diff --git a/pandas/tests/scalar/test_nat.py b/pandas/tests/scalar/test_nat.py index b20df43dd49a6..b7a73da7d58cd 100644 --- a/pandas/tests/scalar/test_nat.py +++ b/pandas/tests/scalar/test_nat.py @@ -9,7 +9,6 @@ import pytest from pandas._libs.tslibs import iNaT -from pandas.compat.numpy import np_version_gte1p24p3 from pandas import ( DatetimeIndex, @@ -537,24 +536,10 @@ def test_to_numpy_alias(): [ Timedelta(0), Timedelta(0).to_pytimedelta(), - pytest.param( - Timedelta(0).to_timedelta64(), - marks=pytest.mark.xfail( - not np_version_gte1p24p3, - reason="td64 doesn't return NotImplemented, see numpy#17017", - # When this xfail is fixed, test_nat_comparisons_numpy - # can be removed. - ), - ), + Timedelta(0).to_timedelta64(), Timestamp(0), Timestamp(0).to_pydatetime(), - pytest.param( - Timestamp(0).to_datetime64(), - marks=pytest.mark.xfail( - not np_version_gte1p24p3, - reason="dt64 doesn't return NotImplemented, see numpy#17017", - ), - ), + Timestamp(0).to_datetime64(), Timestamp(0).tz_localize("UTC"), NaT, ], @@ -570,18 +555,6 @@ def test_nat_comparisons(compare_operators_no_eq_ne, other): assert op(other, NaT) is False -@pytest.mark.parametrize("other", [np.timedelta64(0, "ns"), np.datetime64("now", "ns")]) -def test_nat_comparisons_numpy(other): - # Once numpy#17017 is fixed and the xfailed cases in test_nat_comparisons - # pass, this test can be removed - assert not NaT == other - assert NaT != other - assert not NaT < other - assert not NaT > other - assert not NaT <= other - assert not NaT >= other - - @pytest.mark.parametrize("other_and_type", [("foo", "str"), (2, "int"), (2.0, "float")]) @pytest.mark.parametrize( "symbol_and_op", diff --git a/pandas/tests/series/indexing/test_setitem.py b/pandas/tests/series/indexing/test_setitem.py index ae3b0d10214e3..f894005296781 100644 --- a/pandas/tests/series/indexing/test_setitem.py +++ b/pandas/tests/series/indexing/test_setitem.py @@ -4,12 +4,11 @@ datetime, ) from decimal import Decimal -import os import numpy as np import pytest -from pandas.compat.numpy import np_version_gte1p24 +from pandas.compat.numpy import np_version_gt2 from pandas.errors import IndexingError from pandas.core.dtypes.common import is_list_like @@ -1441,13 +1440,7 @@ def obj(self): np.float32, False, marks=pytest.mark.xfail( - ( - not np_version_gte1p24 - or ( - np_version_gte1p24 - and os.environ.get("NPY_PROMOTION_STATE", "weak") != "weak" - ) - ), + not np_version_gt2, reason="np.float32(1.1) ends up as 1.100000023841858, so " "np_can_hold_element raises and we cast to float64", ), diff --git a/pandas/tests/series/methods/test_describe.py b/pandas/tests/series/methods/test_describe.py index 79ec11feb5308..c3246fd35227c 100644 --- a/pandas/tests/series/methods/test_describe.py +++ b/pandas/tests/series/methods/test_describe.py @@ -1,8 +1,6 @@ import numpy as np import pytest -from pandas.compat.numpy import np_version_gte1p25 - from pandas.core.dtypes.common import ( is_complex_dtype, is_extension_array_dtype, @@ -167,7 +165,7 @@ def test_numeric_result_dtype(self, any_numeric_dtype): dtype = "complex128" if is_complex_dtype(any_numeric_dtype) else None ser = Series([0, 1], dtype=any_numeric_dtype) - if dtype == "complex128" and np_version_gte1p25: + if dtype == "complex128": with pytest.raises( TypeError, match=r"^a must be an array of real numbers$" ): diff --git a/pyproject.toml b/pyproject.toml index 877df4835c07c..e013222f8fe79 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,10 +27,9 @@ authors = [ license = {file = 'LICENSE'} requires-python = '>=3.10' dependencies = [ - "numpy>=1.23.5; python_version<'3.12'", - "numpy>=1.26.0; python_version>='3.12'", + "numpy>=1.26.0", "python-dateutil>=2.8.2", - "tzdata>=2022.7" + "tzdata>=2023.3" ] classifiers = [ 'Development Status :: 5 - Production/Stable', diff --git a/requirements-dev.txt b/requirements-dev.txt index b0f8819befbe9..3e2e637927389 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -86,4 +86,4 @@ jupyterlite-pyodide-kernel adbc-driver-postgresql>=0.10.0 adbc-driver-sqlite>=0.8.0 typing_extensions; python_version<"3.11" -tzdata>=2022.7 +tzdata>=2023.3 diff --git a/scripts/generate_pip_deps_from_conda.py b/scripts/generate_pip_deps_from_conda.py index a57876902ad36..68cda68e26001 100755 --- a/scripts/generate_pip_deps_from_conda.py +++ b/scripts/generate_pip_deps_from_conda.py @@ -24,7 +24,7 @@ import yaml EXCLUDE = {"python", "c-compiler", "cxx-compiler"} -REMAP_VERSION = {"tzdata": "2022.7"} +REMAP_VERSION = {"tzdata": "2023.3"} CONDA_TO_PIP = { "versioneer": "versioneer[toml]", "meson": "meson[ninja]", diff --git a/scripts/tests/data/deps_minimum.toml b/scripts/tests/data/deps_minimum.toml index 21c269f573b3d..e6135ca088772 100644 --- a/scripts/tests/data/deps_minimum.toml +++ b/scripts/tests/data/deps_minimum.toml @@ -55,7 +55,7 @@ matplotlib = "pandas:plotting._matplotlib" [project.optional-dependencies] test = ['hypothesis>=6.34.2', 'pytest>=7.3.2', 'pytest-xdist>=3.4.0'] performance = ['bottleneck>=1.3.2', 'numba>=0.53.1', 'numexpr>=2.7.1'] -timezone = ['tzdata>=2022.1'] +timezone = ['tzdata>=2023.3'] computation = ['scipy>=1.7.1', 'xarray>=0.21.0'] fss = ['fsspec>=2021.07.0'] aws = ['s3fs>=2021.08.0'] @@ -103,7 +103,7 @@ all = ['beautifulsoup4>=5.9.3', 'SQLAlchemy>=1.4.16', 'tables>=3.6.1', 'tabulate>=0.8.9', - 'tzdata>=2022.1', + 'tzdata>=2023.3', 'xarray>=0.21.0', 'xlrd>=2.0.1', 'xlsxwriter>=1.4.3', From d5f97ed21a872c2ea0bbe2a1de8b4242ec6a58d1 Mon Sep 17 00:00:00 2001 From: Pedro Diogo Date: Tue, 8 Jul 2025 16:48:22 +0100 Subject: [PATCH 22/51] feature #49580: support new-style float_format string in to_csv (#61650) * feature #49580: support new-style float_format string in to_csv feat(to_csv): support new-style float_format strings using str.format Detect and process new-style format strings (e.g., "{:,.2f}") in the float_format parameter of to_csv. - Check if float_format is a string and matches new-style pattern - Convert it to a callable (e.g., lambda x: float_format.format(x)) - Ensure compatibility with NaN values and mixed data types - Improves formatting output for floats when exporting to CSV Example: df = pd.DataFrame([1234.56789, 9876.54321]) df.to_csv(float_format="{:,.2f}") # now outputs formatted values like 1,234.57 Co-authored-by: Pedro Santos * update benchmark test * fixed pre commit * fixed offsets.pyx * fixed tests to windows * Update pandas/io/formats/format.py Co-authored-by: Matthew Roeschke <10647082+mroeschke@users.noreply.github.com> * Update pandas/io/formats/format.py Co-authored-by: Matthew Roeschke <10647082+mroeschke@users.noreply.github.com> * Update pandas/io/formats/format.py Co-authored-by: Matthew Roeschke <10647082+mroeschke@users.noreply.github.com> * updated v3.0.0.rst and fixed tm.assert_produces_warning * fixed test_new_style_with_mixed_types_in_column added match to assert_produces_warning * Update doc/source/whatsnew/v3.0.0.rst (removed reference to this PR) Co-authored-by: Simon Hawkins * fixed pre-commit * removed tm.assert_produces_warning * fixed space * fixed pre-commit --------- Co-authored-by: Pedro Santos Co-authored-by: Matthew Roeschke <10647082+mroeschke@users.noreply.github.com> Co-authored-by: Simon Hawkins --- asv_bench/benchmarks/io/csv.py | 19 ++++ doc/source/whatsnew/v3.0.0.rst | 1 + pandas/io/formats/format.py | 25 ++++- pandas/tests/io/formats/test_to_csv.py | 137 +++++++++++++++++++++++++ 4 files changed, 181 insertions(+), 1 deletion(-) diff --git a/asv_bench/benchmarks/io/csv.py b/asv_bench/benchmarks/io/csv.py index 3a15f754ae523..9ee867260aa39 100644 --- a/asv_bench/benchmarks/io/csv.py +++ b/asv_bench/benchmarks/io/csv.py @@ -53,6 +53,25 @@ def time_frame(self, kind): self.df.to_csv(self.fname) +class ToCSVFloatFormatVariants(BaseIO): + fname = "__test__.csv" + + def setup(self): + self.df = DataFrame(np.random.default_rng(seed=42).random((1000, 1000))) + + def time_old_style_percent_format(self): + self.df.to_csv(self.fname, float_format="%.6f") + + def time_new_style_brace_format(self): + self.df.to_csv(self.fname, float_format="{:.6f}") + + def time_new_style_thousands_format(self): + self.df.to_csv(self.fname, float_format="{:,.2f}") + + def time_callable_format(self): + self.df.to_csv(self.fname, float_format=lambda x: f"{x:.6f}") + + class ToCSVMultiIndexUnusedLevels(BaseIO): fname = "__test__.csv" diff --git a/doc/source/whatsnew/v3.0.0.rst b/doc/source/whatsnew/v3.0.0.rst index 5ab3f35c9cc92..4e0e497379fa2 100644 --- a/doc/source/whatsnew/v3.0.0.rst +++ b/doc/source/whatsnew/v3.0.0.rst @@ -76,6 +76,7 @@ Other enhancements - :meth:`.DataFrameGroupBy.transform`, :meth:`.SeriesGroupBy.transform`, :meth:`.DataFrameGroupBy.agg`, :meth:`.SeriesGroupBy.agg`, :meth:`.SeriesGroupBy.apply`, :meth:`.DataFrameGroupBy.apply` now support ``kurt`` (:issue:`40139`) - :meth:`DataFrame.apply` supports using third-party execution engines like the Bodo.ai JIT compiler (:issue:`60668`) - :meth:`DataFrame.iloc` and :meth:`Series.iloc` now support boolean masks in ``__getitem__`` for more consistent indexing behavior (:issue:`60994`) +- :meth:`DataFrame.to_csv` and :meth:`Series.to_csv` now support Python's new-style format strings (e.g., ``"{:.6f}"``) for the ``float_format`` parameter, in addition to old-style ``%`` format strings and callables. This allows for more flexible and modern formatting of floating point numbers when exporting to CSV. (:issue:`49580`) - :meth:`DataFrameGroupBy.transform`, :meth:`SeriesGroupBy.transform`, :meth:`DataFrameGroupBy.agg`, :meth:`SeriesGroupBy.agg`, :meth:`RollingGroupby.apply`, :meth:`ExpandingGroupby.apply`, :meth:`Rolling.apply`, :meth:`Expanding.apply`, :meth:`DataFrame.apply` with ``engine="numba"`` now supports positional arguments passed as kwargs (:issue:`58995`) - :meth:`Rolling.agg`, :meth:`Expanding.agg` and :meth:`ExponentialMovingWindow.agg` now accept :class:`NamedAgg` aggregations through ``**kwargs`` (:issue:`28333`) - :meth:`Series.map` can now accept kwargs to pass on to func (:issue:`59814`) diff --git a/pandas/io/formats/format.py b/pandas/io/formats/format.py index 097e508d4889a..7e0900f64b6bf 100644 --- a/pandas/io/formats/format.py +++ b/pandas/io/formats/format.py @@ -454,7 +454,7 @@ def __init__( self.na_rep = na_rep self.formatters = self._initialize_formatters(formatters) self.justify = self._initialize_justify(justify) - self.float_format = float_format + self.float_format = self._validate_float_format(float_format) self.sparsify = self._initialize_sparsify(sparsify) self.show_index_names = index_names self.decimal = decimal @@ -849,6 +849,29 @@ def _get_column_name_list(self) -> list[Hashable]: names.append("" if columns.name is None else columns.name) return names + def _validate_float_format( + self, fmt: FloatFormatType | None + ) -> FloatFormatType | None: + """ + Validates and processes the float_format argument. + Converts new-style format strings to callables. + """ + if fmt is None or callable(fmt): + return fmt + + if isinstance(fmt, str): + if "%" in fmt: + # Keeps old-style format strings as they are (C code handles them) + return fmt + else: + try: + _ = fmt.format(1.0) # Test with an arbitrary float + return fmt.format + except (ValueError, KeyError, IndexError) as e: + raise ValueError(f"Invalid new-style format string {fmt!r}") from e + + raise ValueError("float_format must be a string or callable") + class DataFrameRenderer: """Class for creating dataframe output in multiple formats. diff --git a/pandas/tests/io/formats/test_to_csv.py b/pandas/tests/io/formats/test_to_csv.py index 6d762fdeb8d79..dd2d85c4755af 100644 --- a/pandas/tests/io/formats/test_to_csv.py +++ b/pandas/tests/io/formats/test_to_csv.py @@ -741,3 +741,140 @@ def test_to_csv_iterative_compression_buffer(compression): pd.read_csv(buffer, compression=compression, index_col=0), df ) assert not buffer.closed + + +def test_new_style_float_format_basic(): + df = DataFrame({"A": [1234.56789, 9876.54321]}) + result = df.to_csv(float_format="{:.2f}", lineterminator="\n") + expected = ",A\n0,1234.57\n1,9876.54\n" + assert result == expected + + +def test_new_style_float_format_thousands(): + df = DataFrame({"A": [1234.56789, 9876.54321]}) + result = df.to_csv(float_format="{:,.2f}", lineterminator="\n") + expected = ',A\n0,"1,234.57"\n1,"9,876.54"\n' + assert result == expected + + +def test_new_style_scientific_format(): + df = DataFrame({"A": [0.000123, 0.000456]}) + result = df.to_csv(float_format="{:.2e}", lineterminator="\n") + expected = ",A\n0,1.23e-04\n1,4.56e-04\n" + assert result == expected + + +def test_new_style_with_nan(): + df = DataFrame({"A": [1.23, np.nan, 4.56]}) + result = df.to_csv(float_format="{:.2f}", na_rep="NA", lineterminator="\n") + expected = ",A\n0,1.23\n1,NA\n2,4.56\n" + assert result == expected + + +def test_new_style_with_mixed_types(): + df = DataFrame({"A": [1.23, 4.56], "B": ["x", "y"]}) + result = df.to_csv(float_format="{:.2f}", lineterminator="\n") + expected = ",A,B\n0,1.23,x\n1,4.56,y\n" + assert result == expected + + +def test_new_style_with_mixed_types_in_column(): + df = DataFrame({"A": [1.23, "text", 4.56]}) + result = df.to_csv(float_format="{:.2f}", lineterminator="\n") + expected = ",A\n0,1.23\n1,text\n2,4.56\n" + assert result == expected + + +def test_invalid_new_style_format_missing_brace(): + df = DataFrame({"A": [1.23]}) + with pytest.raises(ValueError, match="Invalid new-style format string '{:.2f"): + df.to_csv(float_format="{:.2f") + + +def test_invalid_new_style_format_specifier(): + df = DataFrame({"A": [1.23]}) + with pytest.raises(ValueError, match="Invalid new-style format string '{:.2z}'"): + df.to_csv(float_format="{:.2z}") + + +def test_old_style_format_compatibility(): + df = DataFrame({"A": [1234.56789, 9876.54321]}) + result = df.to_csv(float_format="%.2f", lineterminator="\n") + expected = ",A\n0,1234.57\n1,9876.54\n" + assert result == expected + + +def test_callable_float_format_compatibility(): + df = DataFrame({"A": [1234.56789, 9876.54321]}) + result = df.to_csv(float_format=lambda x: f"{x:,.2f}", lineterminator="\n") + expected = ',A\n0,"1,234.57"\n1,"9,876.54"\n' + assert result == expected + + +def test_no_float_format(): + df = DataFrame({"A": [1.23, 4.56]}) + result = df.to_csv(float_format=None, lineterminator="\n") + expected = ",A\n0,1.23\n1,4.56\n" + assert result == expected + + +def test_large_numbers(): + df = DataFrame({"A": [1e308, 2e308]}) + result = df.to_csv(float_format="{:.2e}", lineterminator="\n") + expected = ",A\n0,1.00e+308\n1,inf\n" + assert result == expected + + +def test_zero_and_negative(): + df = DataFrame({"A": [0.0, -1.23456]}) + result = df.to_csv(float_format="{:+.2f}", lineterminator="\n") + expected = ",A\n0,+0.00\n1,-1.23\n" + assert result == expected + + +def test_unicode_format(): + df = DataFrame({"A": [1.23, 4.56]}) + result = df.to_csv(float_format="{:.2f}€", encoding="utf-8", lineterminator="\n") + expected = ",A\n0,1.23€\n1,4.56€\n" + assert result == expected + + +def test_empty_dataframe(): + df = DataFrame({"A": []}) + result = df.to_csv(float_format="{:.2f}", lineterminator="\n") + expected = ",A\n" + assert result == expected + + +def test_multi_column_float(): + df = DataFrame({"A": [1.23, 4.56], "B": [7.89, 0.12]}) + result = df.to_csv(float_format="{:.2f}", lineterminator="\n") + expected = ",A,B\n0,1.23,7.89\n1,4.56,0.12\n" + assert result == expected + + +def test_invalid_float_format_type(): + df = DataFrame({"A": [1.23]}) + with pytest.raises(ValueError, match="float_format must be a string or callable"): + df.to_csv(float_format=123) + + +def test_new_style_with_inf(): + df = DataFrame({"A": [1.23, np.inf, -np.inf]}) + result = df.to_csv(float_format="{:.2f}", na_rep="NA", lineterminator="\n") + expected = ",A\n0,1.23\n1,inf\n2,-inf\n" + assert result == expected + + +def test_new_style_with_precision_edge(): + df = DataFrame({"A": [1.23456789]}) + result = df.to_csv(float_format="{:.10f}", lineterminator="\n") + expected = ",A\n0,1.2345678900\n" + assert result == expected + + +def test_new_style_with_template(): + df = DataFrame({"A": [1234.56789]}) + result = df.to_csv(float_format="Value: {:,.2f}", lineterminator="\n") + expected = ',A\n0,"Value: 1,234.57"\n' + assert result == expected From f94b430126efbe4a3078f95e90846a4d6161448a Mon Sep 17 00:00:00 2001 From: Matthew Roeschke <10647082+mroeschke@users.noreply.github.com> Date: Wed, 9 Jul 2025 14:47:41 -0700 Subject: [PATCH 23/51] CI: Remove PyPy references in CI testing (#61814) --- .github/workflows/unit-tests.yml | 8 -------- .github/workflows/wheels.yml | 1 - ci/deps/actions-pypy-39.yaml | 26 ------------------------ scripts/validate_min_versions_in_sync.py | 9 -------- 4 files changed, 44 deletions(-) delete mode 100644 ci/deps/actions-pypy-39.yaml diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index d908a68a8be4a..b501c2ea394bd 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -75,11 +75,6 @@ jobs: env_file: actions-311.yaml pandas_future_infer_string: "1" platform: ubuntu-24.04 - - name: "Pypy" - env_file: actions-pypy-39.yaml - pattern: "not slow and not network and not single_cpu" - test_args: "--max-worker-restart 0" - platform: ubuntu-24.04 - name: "Numpy Dev" env_file: actions-311-numpydev.yaml pattern: "not slow and not network and not single_cpu" @@ -169,12 +164,9 @@ jobs: with: # xref https://github.com/cython/cython/issues/6870 werror: ${{ matrix.name != 'Freethreading' }} - # TODO: Re-enable once Pypy has Pypy 3.10 on conda-forge - if: ${{ matrix.name != 'Pypy' }} - name: Test (not single_cpu) uses: ./.github/actions/run-tests - if: ${{ matrix.name != 'Pypy' }} env: # Set pattern to not single_cpu if not already set PATTERN: ${{ env.PATTERN == '' && 'not single_cpu' || matrix.pattern }} diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 4de7aec4f551a..a38ec5ee359d9 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -101,7 +101,6 @@ jobs: - [macos-14, macosx_arm64] - [windows-2022, win_amd64] - [windows-11-arm, win_arm64] - # TODO: support PyPy? python: [["cp310", "3.10"], ["cp311", "3.11"], ["cp312", "3.12"], ["cp313", "3.13"], ["cp313t", "3.13"]] include: # Build Pyodide wheels and upload them to Anaconda.org diff --git a/ci/deps/actions-pypy-39.yaml b/ci/deps/actions-pypy-39.yaml deleted file mode 100644 index da1e2bc2f934f..0000000000000 --- a/ci/deps/actions-pypy-39.yaml +++ /dev/null @@ -1,26 +0,0 @@ -name: pandas-dev -channels: - - conda-forge -dependencies: - # TODO: Add the rest of the dependencies in here - # once the other plentiful failures/segfaults - # with base pandas has been dealt with - - python=3.9[build=*_pypy] - - # build dependencies - - versioneer - - cython<4.0.0a0 - - meson=1.2.1 - - meson-python=0.13.1 - - # test dependencies - - pytest>=7.3.2 - - pytest-cov - - pytest-xdist>=3.4.0 - - hypothesis>=6.84.0 - - # required - - numpy - - python-dateutil - - pip: - - tzdata>=2023.3 diff --git a/scripts/validate_min_versions_in_sync.py b/scripts/validate_min_versions_in_sync.py index 7908aaef3d890..a45791f6c05cd 100755 --- a/scripts/validate_min_versions_in_sync.py +++ b/scripts/validate_min_versions_in_sync.py @@ -37,7 +37,6 @@ YAML_PATH = pathlib.Path("ci/deps") ENV_PATH = pathlib.Path("environment.yml") EXCLUDE_DEPS = {"tzdata", "pyqt", "pyqt5"} -EXCLUSION_LIST = frozenset(["python=3.8[build=*_pypy]"]) # pandas package is not available # in pre-commit environment sys.path.append("pandas/compat") @@ -111,7 +110,6 @@ def get_yaml_map_from( for dependency in yaml_dic: if ( isinstance(dependency, dict) - or dependency in EXCLUSION_LIST or dependency in yaml_map ): continue @@ -124,11 +122,6 @@ def get_yaml_map_from( yaml_package, yaml_version2 = yaml_dependency.split(operator) yaml_version2 = operator + yaml_version2 yaml_map[yaml_package] = [yaml_version1, yaml_version2] - elif "[build=*_pypy]" in dependency: - search_text = search_text.replace("[build=*_pypy]", "") - yaml_package, yaml_version = search_text.split(operator) - yaml_version = operator + yaml_version - yaml_map[yaml_package] = [yaml_version] elif operator is not None: yaml_package, yaml_version = search_text.split(operator) yaml_version = operator + yaml_version @@ -164,8 +157,6 @@ def pin_min_versions_to_yaml_file( ) -> str: data = yaml_file_data for yaml_package, yaml_versions in yaml_map.items(): - if yaml_package in EXCLUSION_LIST: - continue old_dep = yaml_package if yaml_versions is not None: old_dep = old_dep + ", ".join(yaml_versions) From e635c3e4c8bc817fb33c3005649648b9d7c22599 Mon Sep 17 00:00:00 2001 From: jbrockmendel Date: Thu, 10 Jul 2025 09:47:37 -0700 Subject: [PATCH 24/51] TST[string]: update expecteds for using_string_dtype to fix xfails (#61727) * TST: update expecteds for using_string_dtype to fix xfails * Update to_dict_of_blocks test to hardcode object dtype * Comment * Split test, update expected, targeted xfails * Update json test * revert commented-out --- .../arrays/categorical/test_constructors.py | 13 +-- pandas/tests/arrays/categorical/test_repr.py | 27 +++-- pandas/tests/frame/methods/test_astype.py | 12 +-- pandas/tests/groupby/test_timegrouper.py | 12 +-- .../tests/indexes/base_class/test_formats.py | 17 ++-- .../indexes/categorical/test_category.py | 1 + .../tests/indexes/categorical/test_formats.py | 99 ++++++++++++++++--- pandas/tests/io/formats/test_format.py | 44 +++++++-- pandas/tests/io/json/test_pandas.py | 10 +- 9 files changed, 172 insertions(+), 63 deletions(-) diff --git a/pandas/tests/arrays/categorical/test_constructors.py b/pandas/tests/arrays/categorical/test_constructors.py index d7eb6800e5d07..cf2de894cc0c0 100644 --- a/pandas/tests/arrays/categorical/test_constructors.py +++ b/pandas/tests/arrays/categorical/test_constructors.py @@ -6,10 +6,6 @@ import numpy as np import pytest -from pandas._config import using_string_dtype - -from pandas.compat import HAS_PYARROW - from pandas.core.dtypes.common import ( is_float_dtype, is_integer_dtype, @@ -444,13 +440,12 @@ def test_constructor_str_unknown(self): with pytest.raises(ValueError, match="Unknown dtype"): Categorical([1, 2], dtype="foo") - @pytest.mark.xfail( - using_string_dtype() and HAS_PYARROW, reason="Can't be NumPy strings" - ) def test_constructor_np_strs(self): # GH#31499 Hashtable.map_locations needs to work on np.str_ objects - cat = Categorical(["1", "0", "1"], [np.str_("0"), np.str_("1")]) - assert all(isinstance(x, np.str_) for x in cat.categories) + # We can't pass all-strings because the constructor would cast + # those to StringDtype post-PDEP14 + cat = Categorical(["1", "0", "1", 2], [np.str_("0"), np.str_("1"), 2]) + assert all(isinstance(x, (np.str_, int)) for x in cat.categories) def test_constructor_from_categorical_with_dtype(self): dtype = CategoricalDtype(["a", "b", "c"], ordered=True) diff --git a/pandas/tests/arrays/categorical/test_repr.py b/pandas/tests/arrays/categorical/test_repr.py index 3a2c489920eb0..a82ba24a2c732 100644 --- a/pandas/tests/arrays/categorical/test_repr.py +++ b/pandas/tests/arrays/categorical/test_repr.py @@ -1,7 +1,4 @@ import numpy as np -import pytest - -from pandas._config import using_string_dtype from pandas import ( Categorical, @@ -77,17 +74,19 @@ def test_print_none_width(self): with option_context("display.width", None): assert exp == repr(a) - @pytest.mark.skipif( - using_string_dtype(), - reason="Change once infer_string is set to True by default", - ) - def test_unicode_print(self): + def test_unicode_print(self, using_infer_string): c = Categorical(["aaaaa", "bb", "cccc"] * 20) expected = """\ ['aaaaa', 'bb', 'cccc', 'aaaaa', 'bb', ..., 'bb', 'cccc', 'aaaaa', 'bb', 'cccc'] Length: 60 Categories (3, object): ['aaaaa', 'bb', 'cccc']""" + if using_infer_string: + expected = expected.replace( + "(3, object): ['aaaaa', 'bb', 'cccc']", + "(3, str): [aaaaa, bb, cccc]", + ) + assert repr(c) == expected c = Categorical(["ああああ", "いいいいい", "ううううううう"] * 20) @@ -96,6 +95,12 @@ def test_unicode_print(self): Length: 60 Categories (3, object): ['ああああ', 'いいいいい', 'ううううううう']""" # noqa: E501 + if using_infer_string: + expected = expected.replace( + "(3, object): ['ああああ', 'いいいいい', 'ううううううう']", + "(3, str): [ああああ, いいいいい, ううううううう]", + ) + assert repr(c) == expected # unicode option should not affect to Categorical, as it doesn't care @@ -106,6 +111,12 @@ def test_unicode_print(self): Length: 60 Categories (3, object): ['ああああ', 'いいいいい', 'ううううううう']""" # noqa: E501 + if using_infer_string: + expected = expected.replace( + "(3, object): ['ああああ', 'いいいいい', 'ううううううう']", + "(3, str): [ああああ, いいいいい, ううううううう]", + ) + assert repr(c) == expected def test_categorical_repr(self): diff --git a/pandas/tests/frame/methods/test_astype.py b/pandas/tests/frame/methods/test_astype.py index eb1ee4e7b2970..c428bd1820cb1 100644 --- a/pandas/tests/frame/methods/test_astype.py +++ b/pandas/tests/frame/methods/test_astype.py @@ -3,8 +3,6 @@ import numpy as np import pytest -from pandas._config import using_string_dtype - import pandas.util._test_decorators as td import pandas as pd @@ -745,10 +743,7 @@ def test_astype_tz_object_conversion(self, tz): result = result.astype({"tz": "datetime64[ns, Europe/London]"}) tm.assert_frame_equal(result, expected) - @pytest.mark.xfail(using_string_dtype(), reason="TODO(infer_string) GH#60639") - def test_astype_dt64_to_string( - self, frame_or_series, tz_naive_fixture, using_infer_string - ): + def test_astype_dt64_to_string(self, frame_or_series, tz_naive_fixture): # GH#41409 tz = tz_naive_fixture @@ -766,10 +761,7 @@ def test_astype_dt64_to_string( item = result.iloc[0] if frame_or_series is DataFrame: item = item.iloc[0] - if using_infer_string: - assert item is np.nan - else: - assert item is pd.NA + assert item is pd.NA # For non-NA values, we should match what we get for non-EA str alt = obj.astype(str) diff --git a/pandas/tests/groupby/test_timegrouper.py b/pandas/tests/groupby/test_timegrouper.py index 550efe9187fe8..a64b15c211908 100644 --- a/pandas/tests/groupby/test_timegrouper.py +++ b/pandas/tests/groupby/test_timegrouper.py @@ -11,8 +11,6 @@ import numpy as np import pytest -from pandas._config import using_string_dtype - import pandas as pd from pandas import ( DataFrame, @@ -76,10 +74,7 @@ def groupby_with_truncated_bingrouper(frame_for_truncated_bingrouper): class TestGroupBy: - # TODO(infer_string) resample sum introduces 0's - # https://github.com/pandas-dev/pandas/issues/60229 - @pytest.mark.xfail(using_string_dtype(), reason="TODO(infer_string)") - def test_groupby_with_timegrouper(self): + def test_groupby_with_timegrouper(self, using_infer_string): # GH 4161 # TimeGrouper requires a sorted index # also verifies that the resultant index has the correct name @@ -116,8 +111,11 @@ def test_groupby_with_timegrouper(self): {"Buyer": 0, "Quantity": 0}, index=exp_dti, ) - # Cast to object to avoid implicit cast when setting entry to "CarlCarlCarl" + # Cast to object/str to avoid implicit cast when setting + # entry to "CarlCarlCarl" expected = expected.astype({"Buyer": object}) + if using_infer_string: + expected = expected.astype({"Buyer": "str"}) expected.iloc[0, 0] = "CarlCarlCarl" expected.iloc[6, 0] = "CarlCarl" expected.iloc[18, 0] = "Joe" diff --git a/pandas/tests/indexes/base_class/test_formats.py b/pandas/tests/indexes/base_class/test_formats.py index 260b4203a4f04..2368b8bce2d9e 100644 --- a/pandas/tests/indexes/base_class/test_formats.py +++ b/pandas/tests/indexes/base_class/test_formats.py @@ -1,7 +1,6 @@ import numpy as np import pytest -from pandas._config import using_string_dtype import pandas._config.config as cf from pandas import Index @@ -16,7 +15,6 @@ def test_repr_is_valid_construction_code(self): res = eval(repr(idx)) tm.assert_index_equal(res, idx) - @pytest.mark.xfail(using_string_dtype(), reason="repr different") @pytest.mark.parametrize( "index,expected", [ @@ -77,11 +75,13 @@ def test_repr_is_valid_construction_code(self): ), ], ) - def test_string_index_repr(self, index, expected): + def test_string_index_repr(self, index, expected, using_infer_string): result = repr(index) + if using_infer_string: + expected = expected.replace("dtype='object'", "dtype='str'") + assert result == expected - @pytest.mark.xfail(using_string_dtype(), reason="repr different") @pytest.mark.parametrize( "index,expected", [ @@ -121,11 +121,16 @@ def test_string_index_repr(self, index, expected): ), ], ) - def test_string_index_repr_with_unicode_option(self, index, expected): + def test_string_index_repr_with_unicode_option( + self, index, expected, using_infer_string + ): # Enable Unicode option ----------------------------------------- with cf.option_context("display.unicode.east_asian_width", True): result = repr(index) - assert result == expected + + if using_infer_string: + expected = expected.replace("dtype='object'", "dtype='str'") + assert result == expected def test_repr_summary(self): with cf.option_context("display.max_seq_items", 10): diff --git a/pandas/tests/indexes/categorical/test_category.py b/pandas/tests/indexes/categorical/test_category.py index d9c9fdc62b0bc..262b043adaf58 100644 --- a/pandas/tests/indexes/categorical/test_category.py +++ b/pandas/tests/indexes/categorical/test_category.py @@ -199,6 +199,7 @@ def test_unique(self, data, categories, expected_data, ordered): expected = CategoricalIndex(expected_data, dtype=dtype) tm.assert_index_equal(idx.unique(), expected) + # TODO(3.0): remove this test once using_string_dtype() is always True @pytest.mark.xfail(using_string_dtype(), reason="repr doesn't roundtrip") def test_repr_roundtrip(self): ci = CategoricalIndex(["a", "b"], categories=["a", "b"], ordered=True) diff --git a/pandas/tests/indexes/categorical/test_formats.py b/pandas/tests/indexes/categorical/test_formats.py index b1361b3e8106e..b100740b064ce 100644 --- a/pandas/tests/indexes/categorical/test_formats.py +++ b/pandas/tests/indexes/categorical/test_formats.py @@ -10,78 +10,132 @@ from pandas import CategoricalIndex -class TestCategoricalIndexRepr: - @pytest.mark.xfail(using_string_dtype(), reason="repr different") - def test_string_categorical_index_repr(self): +class TestCategoricalIndexReprStringCategories: + def test_string_categorical_index_repr(self, using_infer_string): # short idx = CategoricalIndex(["a", "bb", "ccc"]) expected = """CategoricalIndex(['a', 'bb', 'ccc'], categories=['a', 'bb', 'ccc'], ordered=False, dtype='category')""" # noqa: E501 + if using_infer_string: + expected = expected.replace( + "categories=['a', 'bb', 'ccc']", + "categories=[a, bb, ccc]", + ) assert repr(idx) == expected + @pytest.mark.xfail(using_string_dtype(), reason="Different padding on multi-line") + def test_categorical_index_repr_multiline(self, using_infer_string): # multiple lines idx = CategoricalIndex(["a", "bb", "ccc"] * 10) expected = """CategoricalIndex(['a', 'bb', 'ccc', 'a', 'bb', 'ccc', 'a', 'bb', 'ccc', 'a', 'bb', 'ccc', 'a', 'bb', 'ccc', 'a', 'bb', 'ccc', 'a', 'bb', 'ccc', 'a', 'bb', 'ccc', 'a', 'bb', 'ccc', 'a', 'bb', 'ccc'], categories=['a', 'bb', 'ccc'], ordered=False, dtype='category')""" # noqa: E501 - + if using_infer_string: + expected = expected.replace( + "categories=['a', 'bb', 'ccc']", + "categories=[a, bb, ccc]", + ) assert repr(idx) == expected + @pytest.mark.xfail(using_string_dtype(), reason="Different padding on multi-line") + def test_categorical_index_repr_truncated(self, using_infer_string): # truncated idx = CategoricalIndex(["a", "bb", "ccc"] * 100) expected = """CategoricalIndex(['a', 'bb', 'ccc', 'a', 'bb', 'ccc', 'a', 'bb', 'ccc', 'a', ... 'ccc', 'a', 'bb', 'ccc', 'a', 'bb', 'ccc', 'a', 'bb', 'ccc'], categories=['a', 'bb', 'ccc'], ordered=False, dtype='category', length=300)""" # noqa: E501 - + if using_infer_string: + expected = expected.replace( + "categories=['a', 'bb', 'ccc']", + "categories=[a, bb, ccc]", + ) assert repr(idx) == expected + def test_categorical_index_repr_many_categories(self, using_infer_string): # larger categories idx = CategoricalIndex(list("abcdefghijklmmo")) expected = """CategoricalIndex(['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'm', 'o'], categories=['a', 'b', 'c', 'd', ..., 'k', 'l', 'm', 'o'], ordered=False, dtype='category')""" # noqa: E501 - + if using_infer_string: + expected = expected.replace( + "categories=['a', 'b', 'c', 'd', ..., 'k', 'l', 'm', 'o']", + "categories=[a, b, c, d, ..., k, l, m, o]", + ) assert repr(idx) == expected + def test_categorical_index_repr_unicode(self, using_infer_string): # short idx = CategoricalIndex(["あ", "いい", "ううう"]) expected = """CategoricalIndex(['あ', 'いい', 'ううう'], categories=['あ', 'いい', 'ううう'], ordered=False, dtype='category')""" # noqa: E501 + if using_infer_string: + expected = expected.replace( + "categories=['あ', 'いい', 'ううう']", + "categories=[あ, いい, ううう]", + ) assert repr(idx) == expected + @pytest.mark.xfail(using_string_dtype(), reason="Different padding on multi-line") + def test_categorical_index_repr_unicode_multiline(self, using_infer_string): # multiple lines idx = CategoricalIndex(["あ", "いい", "ううう"] * 10) expected = """CategoricalIndex(['あ', 'いい', 'ううう', 'あ', 'いい', 'ううう', 'あ', 'いい', 'ううう', 'あ', 'いい', 'ううう', 'あ', 'いい', 'ううう', 'あ', 'いい', 'ううう', 'あ', 'いい', 'ううう', 'あ', 'いい', 'ううう', 'あ', 'いい', 'ううう', 'あ', 'いい', 'ううう'], categories=['あ', 'いい', 'ううう'], ordered=False, dtype='category')""" # noqa: E501 - + if using_infer_string: + expected = expected.replace( + "categories=['あ', 'いい', 'ううう']", + "categories=[あ, いい, ううう]", + ) assert repr(idx) == expected + @pytest.mark.xfail(using_string_dtype(), reason="Different padding on multi-line") + def test_categorical_index_repr_unicode_truncated(self, using_infer_string): # truncated idx = CategoricalIndex(["あ", "いい", "ううう"] * 100) expected = """CategoricalIndex(['あ', 'いい', 'ううう', 'あ', 'いい', 'ううう', 'あ', 'いい', 'ううう', 'あ', ... 'ううう', 'あ', 'いい', 'ううう', 'あ', 'いい', 'ううう', 'あ', 'いい', 'ううう'], categories=['あ', 'いい', 'ううう'], ordered=False, dtype='category', length=300)""" # noqa: E501 - + if using_infer_string: + expected = expected.replace( + "categories=['あ', 'いい', 'ううう']", + "categories=[あ, いい, ううう]", + ) assert repr(idx) == expected + def test_categorical_index_repr_unicode_many_categories(self, using_infer_string): # larger categories idx = CategoricalIndex(list("あいうえおかきくけこさしすせそ")) expected = """CategoricalIndex(['あ', 'い', 'う', 'え', 'お', 'か', 'き', 'く', 'け', 'こ', 'さ', 'し', 'す', 'せ', 'そ'], categories=['あ', 'い', 'う', 'え', ..., 'し', 'す', 'せ', 'そ'], ordered=False, dtype='category')""" # noqa: E501 - + if using_infer_string: + expected = expected.replace( + "categories=['あ', 'い', 'う', 'え', ..., 'し', 'す', 'せ', 'そ']", + "categories=[あ, い, う, え, ..., し, す, せ, そ]", + ) assert repr(idx) == expected - # Enable Unicode option ----------------------------------------- + def test_categorical_index_repr_east_asian_width(self, using_infer_string): with cf.option_context("display.unicode.east_asian_width", True): # short idx = CategoricalIndex(["あ", "いい", "ううう"]) expected = """CategoricalIndex(['あ', 'いい', 'ううう'], categories=['あ', 'いい', 'ううう'], ordered=False, dtype='category')""" # noqa: E501 + if using_infer_string: + expected = expected.replace( + "categories=['あ', 'いい', 'ううう']", + "categories=[あ, いい, ううう]", + ) assert repr(idx) == expected + @pytest.mark.xfail(using_string_dtype(), reason="Different padding on multi-line") + def test_categorical_index_repr_east_asian_width_multiline( + self, using_infer_string + ): + with cf.option_context("display.unicode.east_asian_width", True): # multiple lines idx = CategoricalIndex(["あ", "いい", "ううう"] * 10) expected = """CategoricalIndex(['あ', 'いい', 'ううう', 'あ', 'いい', 'ううう', 'あ', 'いい', @@ -90,8 +144,18 @@ def test_string_categorical_index_repr(self): 'ううう', 'あ', 'いい', 'ううう', 'あ', 'いい', 'ううう'], categories=['あ', 'いい', 'ううう'], ordered=False, dtype='category')""" # noqa: E501 + if using_infer_string: + expected = expected.replace( + "categories=['あ', 'いい', 'ううう']", + "categories=[あ, いい, ううう]", + ) assert repr(idx) == expected + @pytest.mark.xfail(using_string_dtype(), reason="Different padding on multi-line") + def test_categorical_index_repr_east_asian_width_truncated( + self, using_infer_string + ): + with cf.option_context("display.unicode.east_asian_width", True): # truncated idx = CategoricalIndex(["あ", "いい", "ううう"] * 100) expected = """CategoricalIndex(['あ', 'いい', 'ううう', 'あ', 'いい', 'ううう', 'あ', 'いい', @@ -101,12 +165,25 @@ def test_string_categorical_index_repr(self): 'あ', 'いい', 'ううう'], categories=['あ', 'いい', 'ううう'], ordered=False, dtype='category', length=300)""" # noqa: E501 + if using_infer_string: + expected = expected.replace( + "categories=['あ', 'いい', 'ううう']", + "categories=[あ, いい, ううう]", + ) assert repr(idx) == expected - # larger categories + def test_categorical_index_repr_east_asian_width_many_categories( + self, using_infer_string + ): + with cf.option_context("display.unicode.east_asian_width", True): idx = CategoricalIndex(list("あいうえおかきくけこさしすせそ")) expected = """CategoricalIndex(['あ', 'い', 'う', 'え', 'お', 'か', 'き', 'く', 'け', 'こ', 'さ', 'し', 'す', 'せ', 'そ'], categories=['あ', 'い', 'う', 'え', ..., 'し', 'す', 'せ', 'そ'], ordered=False, dtype='category')""" # noqa: E501 + if using_infer_string: + expected = expected.replace( + "categories=['あ', 'い', 'う', 'え', ..., 'し', 'す', 'せ', 'そ']", + "categories=[あ, い, う, え, ..., し, す, せ, そ]", + ) assert repr(idx) == expected diff --git a/pandas/tests/io/formats/test_format.py b/pandas/tests/io/formats/test_format.py index 86682e8160762..a485578b139dc 100644 --- a/pandas/tests/io/formats/test_format.py +++ b/pandas/tests/io/formats/test_format.py @@ -11,8 +11,6 @@ import numpy as np import pytest -from pandas._config import using_string_dtype - import pandas as pd from pandas import ( DataFrame, @@ -1395,8 +1393,7 @@ def test_unicode_name_in_footer(self): sf = fmt.SeriesFormatter(s, name="\u05e2\u05d1\u05e8\u05d9\u05ea") sf._get_footer() # should not raise exception - @pytest.mark.xfail(using_string_dtype(), reason="Fixup when arrow is default") - def test_east_asian_unicode_series(self): + def test_east_asian_unicode_series(self, using_infer_string): # not aligned properly because of east asian width # unicode index @@ -1409,6 +1406,8 @@ def test_east_asian_unicode_series(self): "ええええ D\ndtype: object", ] ) + if using_infer_string: + expected = expected.replace("dtype: object", "dtype: str") assert repr(s) == expected # unicode values @@ -1422,7 +1421,8 @@ def test_east_asian_unicode_series(self): "dtype: object", ] ) - + if using_infer_string: + expected = expected.replace("dtype: object", "dtype: str") assert repr(s) == expected # both @@ -1439,7 +1439,8 @@ def test_east_asian_unicode_series(self): "dtype: object", ] ) - + if using_infer_string: + expected = expected.replace("dtype: object", "dtype: str") assert repr(s) == expected # unicode footer @@ -1452,6 +1453,8 @@ def test_east_asian_unicode_series(self): "ああ あ\nいいいい いい\nう ううう\n" "えええ ええええ\nName: おおおおおおお, dtype: object" ) + if using_infer_string: + expected = expected.replace("dtype: object", "dtype: str") assert repr(s) == expected # MultiIndex @@ -1495,6 +1498,8 @@ def test_east_asian_unicode_series(self): "3 ええええ\n" "Name: おおおおおおお, Length: 4, dtype: object" ) + if using_infer_string: + expected = expected.replace("dtype: object", "dtype: str") assert repr(s) == expected s.index = ["ああ", "いいいい", "う", "えええ"] @@ -1503,6 +1508,8 @@ def test_east_asian_unicode_series(self): "えええ ええええ\n" "Name: おおおおおおお, Length: 4, dtype: object" ) + if using_infer_string: + expected = expected.replace("dtype: object", "dtype: str") assert repr(s) == expected # Enable Unicode option ----------------------------------------- @@ -1516,6 +1523,8 @@ def test_east_asian_unicode_series(self): "あ a\nいい bb\nううう CCC\n" "ええええ D\ndtype: object" ) + if using_infer_string: + expected = expected.replace("dtype: object", "dtype: str") assert repr(s) == expected # unicode values @@ -1527,6 +1536,8 @@ def test_east_asian_unicode_series(self): "a あ\nbb いい\nc ううう\n" "ddd ええええ\ndtype: object" ) + if using_infer_string: + expected = expected.replace("dtype: object", "dtype: str") assert repr(s) == expected # both s = Series( @@ -1539,6 +1550,8 @@ def test_east_asian_unicode_series(self): "う ううう\n" "えええ ええええ\ndtype: object" ) + if using_infer_string: + expected = expected.replace("dtype: object", "dtype: str") assert repr(s) == expected # unicode footer @@ -1554,6 +1567,8 @@ def test_east_asian_unicode_series(self): "えええ ええええ\n" "Name: おおおおおおお, dtype: object" ) + if using_infer_string: + expected = expected.replace("dtype: object", "dtype: str") assert repr(s) == expected # MultiIndex @@ -1599,6 +1614,8 @@ def test_east_asian_unicode_series(self): "3 ええええ\n" "Name: おおおおおおお, Length: 4, dtype: object" ) + if using_infer_string: + expected = expected.replace("dtype: object", "dtype: str") assert repr(s) == expected s.index = ["ああ", "いいいい", "う", "えええ"] @@ -1608,6 +1625,8 @@ def test_east_asian_unicode_series(self): "えええ ええええ\n" "Name: おおおおおおお, Length: 4, dtype: object" ) + if using_infer_string: + expected = expected.replace("dtype: object", "dtype: str") assert repr(s) == expected # ambiguous unicode @@ -1621,6 +1640,8 @@ def test_east_asian_unicode_series(self): "¡¡ ううう\n" "えええ ええええ\ndtype: object" ) + if using_infer_string: + expected = expected.replace("dtype: object", "dtype: str") assert repr(s) == expected def test_float_trim_zeros(self): @@ -1770,27 +1791,34 @@ def chck_ncols(self, s): ncolsizes = len({len(line.strip()) for line in lines}) assert ncolsizes == 1 - @pytest.mark.xfail(using_string_dtype(), reason="change when arrow is default") - def test_format_explicit(self): + def test_format_explicit(self, using_infer_string): test_sers = gen_series_formatting() with option_context("display.max_rows", 4, "display.show_dimensions", False): res = repr(test_sers["onel"]) exp = "0 a\n1 a\n ..\n98 a\n99 a\ndtype: object" + if using_infer_string: + exp = exp.replace("dtype: object", "dtype: str") assert exp == res res = repr(test_sers["twol"]) exp = "0 ab\n1 ab\n ..\n98 ab\n99 ab\ndtype: object" + if using_infer_string: + exp = exp.replace("dtype: object", "dtype: str") assert exp == res res = repr(test_sers["asc"]) exp = ( "0 a\n1 ab\n ... \n4 abcde\n5 " "abcdef\ndtype: object" ) + if using_infer_string: + exp = exp.replace("dtype: object", "dtype: str") assert exp == res res = repr(test_sers["desc"]) exp = ( "5 abcdef\n4 abcde\n ... \n1 ab\n0 " "a\ndtype: object" ) + if using_infer_string: + exp = exp.replace("dtype: object", "dtype: str") assert exp == res def test_ncols(self): diff --git a/pandas/tests/io/json/test_pandas.py b/pandas/tests/io/json/test_pandas.py index 98f437e757e31..d895fd6e6770c 100644 --- a/pandas/tests/io/json/test_pandas.py +++ b/pandas/tests/io/json/test_pandas.py @@ -1566,11 +1566,8 @@ def test_from_json_to_json_table_dtypes(self): result = read_json(StringIO(dfjson), orient="table") tm.assert_frame_equal(result, expected) - # TODO: We are casting to string which coerces None to NaN before casting back - # to object, ending up with incorrect na values - @pytest.mark.xfail(using_string_dtype(), reason="incorrect na conversion") @pytest.mark.parametrize("orient", ["split", "records", "index", "columns"]) - def test_to_json_from_json_columns_dtypes(self, orient): + def test_to_json_from_json_columns_dtypes(self, orient, using_infer_string): # GH21892 GH33205 expected = DataFrame.from_dict( { @@ -1591,6 +1588,11 @@ def test_to_json_from_json_columns_dtypes(self, orient): with tm.assert_produces_warning(FutureWarning, match=msg): dfjson = expected.to_json(orient=orient) + if using_infer_string: + # When this is read back in it is inferred to "str" dtype which + # uses NaN instead of None. + expected.loc[0, "Object"] = np.nan + result = read_json( StringIO(dfjson), orient=orient, From b876c676e792d2dc98e48d5ce82d336d2f977141 Mon Sep 17 00:00:00 2001 From: SALCAN <68040183+sanggon6107@users.noreply.github.com> Date: Fri, 11 Jul 2025 05:58:43 +0900 Subject: [PATCH 25/51] BUG: Fix Index.equals between object and string (#61541) --- doc/source/whatsnew/v3.0.0.rst | 1 + pandas/core/indexes/base.py | 6 +----- pandas/tests/frame/test_arithmetic.py | 17 +++++------------ pandas/tests/indexes/test_base.py | 25 +++++++++++++++++++++++++ 4 files changed, 32 insertions(+), 17 deletions(-) diff --git a/doc/source/whatsnew/v3.0.0.rst b/doc/source/whatsnew/v3.0.0.rst index 4e0e497379fa2..e8ce478bd7d42 100644 --- a/doc/source/whatsnew/v3.0.0.rst +++ b/doc/source/whatsnew/v3.0.0.rst @@ -758,6 +758,7 @@ Indexing - Bug in :meth:`DataFrame.from_records` throwing a ``ValueError`` when passed an empty list in ``index`` (:issue:`58594`) - Bug in :meth:`DataFrame.loc` and :meth:`DataFrame.iloc` returning incorrect dtype when selecting from a :class:`DataFrame` with mixed data types. (:issue:`60600`) - Bug in :meth:`DataFrame.loc` with inconsistent behavior of loc-set with 2 given indexes to Series (:issue:`59933`) +- Bug in :meth:`Index.equals` when comparing between :class:`Series` with string dtype :class:`Index` (:issue:`61099`) - Bug in :meth:`Index.get_indexer` and similar methods when ``NaN`` is located at or after position 128 (:issue:`58924`) - Bug in :meth:`MultiIndex.insert` when a new value inserted to a datetime-like level gets cast to ``NaT`` and fails indexing (:issue:`60388`) - Bug in :meth:`Series.__setitem__` when assigning boolean series with boolean indexer will raise ``LossySetitemError`` (:issue:`57338`) diff --git a/pandas/core/indexes/base.py b/pandas/core/indexes/base.py index e81eb3aef11f6..0b719ae21d5b9 100644 --- a/pandas/core/indexes/base.py +++ b/pandas/core/indexes/base.py @@ -5481,11 +5481,7 @@ def equals(self, other: Any) -> bool: # quickly return if the lengths are different return False - if ( - isinstance(self.dtype, StringDtype) - and self.dtype.na_value is np.nan - and other.dtype != self.dtype - ): + if isinstance(self.dtype, StringDtype) and other.dtype != self.dtype: # TODO(infer_string) can we avoid this special case? # special case for object behavior return other.equals(self.astype(object)) diff --git a/pandas/tests/frame/test_arithmetic.py b/pandas/tests/frame/test_arithmetic.py index bc69ec388bf0c..a9a98a5005bb3 100644 --- a/pandas/tests/frame/test_arithmetic.py +++ b/pandas/tests/frame/test_arithmetic.py @@ -11,8 +11,6 @@ import numpy as np import pytest -from pandas.compat import HAS_PYARROW - import pandas as pd from pandas import ( DataFrame, @@ -2183,19 +2181,14 @@ def test_enum_column_equality(): tm.assert_series_equal(result, expected) -def test_mixed_col_index_dtype(using_infer_string): +def test_mixed_col_index_dtype(string_dtype_no_object): # GH 47382 df1 = DataFrame(columns=list("abc"), data=1.0, index=[0]) df2 = DataFrame(columns=list("abc"), data=0.0, index=[0]) - df1.columns = df2.columns.astype("string") + df1.columns = df2.columns.astype(string_dtype_no_object) result = df1 + df2 expected = DataFrame(columns=list("abc"), data=1.0, index=[0]) - if using_infer_string: - # df2.columns.dtype will be "str" instead of object, - # so the aligned result will be "string", not object - if HAS_PYARROW: - dtype = "string[pyarrow]" - else: - dtype = "string" - expected.columns = expected.columns.astype(dtype) + + expected.columns = expected.columns.astype(string_dtype_no_object) + tm.assert_frame_equal(result, expected) diff --git a/pandas/tests/indexes/test_base.py b/pandas/tests/indexes/test_base.py index 5b75bd9afd6df..e734878e6a102 100644 --- a/pandas/tests/indexes/test_base.py +++ b/pandas/tests/indexes/test_base.py @@ -40,6 +40,7 @@ ensure_index, ensure_index_from_sequences, ) +from pandas.testing import assert_series_equal class TestIndex: @@ -1717,3 +1718,27 @@ def test_is_monotonic_pyarrow_list_type(): idx = Index([[1], [2, 3]], dtype=pd.ArrowDtype(pa.list_(pa.int64()))) assert not idx.is_monotonic_increasing assert not idx.is_monotonic_decreasing + + +def test_index_equals_different_string_dtype(string_dtype_no_object): + # GH 61099 + idx_obj = Index(["a", "b", "c"]) + idx_str = Index(["a", "b", "c"], dtype=string_dtype_no_object) + + assert idx_obj.equals(idx_str) + assert idx_str.equals(idx_obj) + + +def test_index_comparison_different_string_dtype(string_dtype_no_object): + # GH 61099 + idx = Index(["a", "b", "c"]) + s_obj = Series([1, 2, 3], index=idx) + s_str = Series([4, 5, 6], index=idx.astype(string_dtype_no_object)) + + expected = Series([True, True, True], index=["a", "b", "c"]) + result = s_obj < s_str + assert_series_equal(result, expected) + + result = s_str > s_obj + expected.index = idx.astype(string_dtype_no_object) + assert_series_equal(result, expected) From 9da2c8f69da707eaec10a85e78a9889a79d8d344 Mon Sep 17 00:00:00 2001 From: microslaw <91637238+microslaw@users.noreply.github.com> Date: Fri, 11 Jul 2025 04:27:14 +0200 Subject: [PATCH 26/51] BUG: Require sample weights to sum to less than 1 when replace = True (#61582) --- doc/source/user_guide/indexing.rst | 4 +-- doc/source/whatsnew/v0.16.1.rst | 4 +-- doc/source/whatsnew/v3.0.0.rst | 1 + pandas/core/generic.py | 7 ++++++ pandas/core/sample.py | 8 ++++++ pandas/tests/frame/methods/test_sample.py | 30 ++++++++++++++++++++--- 6 files changed, 47 insertions(+), 7 deletions(-) diff --git a/doc/source/user_guide/indexing.rst b/doc/source/user_guide/indexing.rst index 605f9501c5b23..47ff92c163b01 100644 --- a/doc/source/user_guide/indexing.rst +++ b/doc/source/user_guide/indexing.rst @@ -700,7 +700,7 @@ to have different probabilities, you can pass the ``sample`` function sampling w s = pd.Series([0, 1, 2, 3, 4, 5]) example_weights = [0, 0, 0.2, 0.2, 0.2, 0.4] - s.sample(n=3, weights=example_weights) + s.sample(n=2, weights=example_weights) # Weights will be re-normalized automatically example_weights2 = [0.5, 0, 0, 0, 0, 0] @@ -714,7 +714,7 @@ as a string. df2 = pd.DataFrame({'col1': [9, 8, 7, 6], 'weight_column': [0.5, 0.4, 0.1, 0]}) - df2.sample(n=3, weights='weight_column') + df2.sample(n=2, weights='weight_column') ``sample`` also allows users to sample columns instead of rows using the ``axis`` argument. diff --git a/doc/source/whatsnew/v0.16.1.rst b/doc/source/whatsnew/v0.16.1.rst index b376530358f53..c15f56ba61447 100644 --- a/doc/source/whatsnew/v0.16.1.rst +++ b/doc/source/whatsnew/v0.16.1.rst @@ -196,7 +196,7 @@ facilitate replication. (:issue:`2419`) # weights are accepted. example_weights = [0, 0, 0.2, 0.2, 0.2, 0.4] - example_series.sample(n=3, weights=example_weights) + example_series.sample(n=2, weights=example_weights) # weights will also be normalized if they do not sum to one, # and missing values will be treated as zeros. @@ -210,7 +210,7 @@ when sampling from rows. .. ipython:: python df = pd.DataFrame({"col1": [9, 8, 7, 6], "weight_column": [0.5, 0.4, 0.1, 0]}) - df.sample(n=3, weights="weight_column") + df.sample(n=2, weights="weight_column") .. _whatsnew_0161.enhancements.string: diff --git a/doc/source/whatsnew/v3.0.0.rst b/doc/source/whatsnew/v3.0.0.rst index e8ce478bd7d42..6d5620d87de21 100644 --- a/doc/source/whatsnew/v3.0.0.rst +++ b/doc/source/whatsnew/v3.0.0.rst @@ -911,6 +911,7 @@ Other - Bug in :meth:`DataFrame.query` where using duplicate column names led to a ``TypeError``. (:issue:`59950`) - Bug in :meth:`DataFrame.query` which raised an exception or produced incorrect results when expressions contained backtick-quoted column names containing the hash character ``#``, backticks, or characters that fall outside the ASCII range (U+0001..U+007F). (:issue:`59285`) (:issue:`49633`) - Bug in :meth:`DataFrame.query` which raised an exception when querying integer column names using backticks. (:issue:`60494`) +- Bug in :meth:`DataFrame.sample` with ``replace=False`` and ``(n * max(weights) / sum(weights)) > 1``, the method would return biased results. Now raises ``ValueError``. (:issue:`61516`) - Bug in :meth:`DataFrame.shift` where passing a ``freq`` on a DataFrame with no columns did not shift the index correctly. (:issue:`60102`) - Bug in :meth:`DataFrame.sort_index` when passing ``axis="columns"`` and ``ignore_index=True`` and ``ascending=False`` not returning a :class:`RangeIndex` columns (:issue:`57293`) - Bug in :meth:`DataFrame.sort_values` where sorting by a column explicitly named ``None`` raised a ``KeyError`` instead of sorting by the column as expected. (:issue:`61512`) diff --git a/pandas/core/generic.py b/pandas/core/generic.py index 7f1ccc482f70f..8708de68c0860 100644 --- a/pandas/core/generic.py +++ b/pandas/core/generic.py @@ -5814,6 +5814,8 @@ def sample( If weights do not sum to 1, they will be normalized to sum to 1. Missing values in the weights column will be treated as zero. Infinite values not allowed. + When replace = False will not allow ``(n * max(weights) / sum(weights)) > 1`` + in order to avoid biased results. See the Notes below for more details. random_state : int, array-like, BitGenerator, np.random.RandomState, np.random.Generator, optional If int, array-like, or BitGenerator, seed for random number generator. If np.random.RandomState or np.random.Generator, use as given. @@ -5850,6 +5852,11 @@ def sample( ----- If `frac` > 1, `replacement` should be set to `True`. + When replace = False will not allow ``(n * max(weights) / sum(weights)) > 1``, + since that would cause results to be biased. E.g. sampling 2 items without replacement + with weights [100, 1, 1] would yield two last items in 1/2 of cases, instead of 1/102. + This is similar to specifying `n=4` without replacement on a Series with 3 elements. + Examples -------- >>> df = pd.DataFrame( diff --git a/pandas/core/sample.py b/pandas/core/sample.py index 4f12563e3c5e2..4f476540cf406 100644 --- a/pandas/core/sample.py +++ b/pandas/core/sample.py @@ -150,6 +150,14 @@ def sample( else: raise ValueError("Invalid weights: weights sum to zero") + assert weights is not None # for mypy + if not replace and size * weights.max() > 1: + raise ValueError( + "Weighted sampling cannot be achieved with replace=False. Either " + "set replace=True or use smaller weights. See the docstring of " + "sample for details." + ) + return random_state.choice(obj_len, size=size, replace=replace, p=weights).astype( np.intp, copy=False ) diff --git a/pandas/tests/frame/methods/test_sample.py b/pandas/tests/frame/methods/test_sample.py index a9d56cbfd2b46..9b6660778508e 100644 --- a/pandas/tests/frame/methods/test_sample.py +++ b/pandas/tests/frame/methods/test_sample.py @@ -113,9 +113,6 @@ def test_sample_invalid_weight_lengths(self, obj): with pytest.raises(ValueError, match=msg): obj.sample(n=3, weights=[0.5] * 11) - with pytest.raises(ValueError, match="Fewer non-zero entries in p than size"): - obj.sample(n=4, weights=Series([0, 0, 0.2])) - def test_sample_negative_weights(self, obj): # Check won't accept negative weights bad_weights = [-0.1] * 10 @@ -137,6 +134,33 @@ def test_sample_inf_weights(self, obj): with pytest.raises(ValueError, match=msg): obj.sample(n=3, weights=weights_with_ninf) + def test_sample_unit_probabilities_raises(self, obj): + # GH#61516 + high_variance_weights = [1] * 10 + high_variance_weights[0] = 100 + msg = ( + "Weighted sampling cannot be achieved with replace=False. Either " + "set replace=True or use smaller weights. See the docstring of " + "sample for details." + ) + with pytest.raises(ValueError, match=msg): + obj.sample(n=2, weights=high_variance_weights, replace=False) + + def test_sample_unit_probabilities_edge_case_do_not_raise(self, obj): + # GH#61516 + # edge case, n*max(weights)/sum(weights) == 1 + edge_variance_weights = [1] * 10 + edge_variance_weights[0] = 9 + # should not raise + obj.sample(n=2, weights=edge_variance_weights, replace=False) + + def test_sample_unit_normal_probabilities_do_not_raise(self, obj): + # GH#61516 + low_variance_weights = [1] * 10 + low_variance_weights[0] = 8 + # should not raise + obj.sample(n=2, weights=low_variance_weights, replace=False) + def test_sample_zero_weights(self, obj): # All zeros raises errors From d785a3d9dd627ee763406a3b57507b0a5d176341 Mon Sep 17 00:00:00 2001 From: "Christine P. Chai" Date: Fri, 11 Jul 2025 09:21:29 -0700 Subject: [PATCH 27/51] DOC: Update link to pytz documentation (#61821) * DOC: Update link to pytz documentation * Update the pytz link per the suggestion --- doc/source/user_guide/timeseries.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/source/user_guide/timeseries.rst b/doc/source/user_guide/timeseries.rst index ac0fc9e53ee94..66b560ea2b902 100644 --- a/doc/source/user_guide/timeseries.rst +++ b/doc/source/user_guide/timeseries.rst @@ -2541,7 +2541,7 @@ Fold is supported only for constructing from naive ``datetime.datetime`` or for constructing from components (see below). Only ``dateutil`` timezones are supported (see `dateutil documentation `__ for ``dateutil`` methods that deal with ambiguous datetimes) as ``pytz`` -timezones do not support fold (see `pytz documentation `__ +timezones do not support fold (see `pytz documentation `__ for details on how ``pytz`` deals with ambiguous datetimes). To localize an ambiguous datetime with ``pytz``, please use :meth:`Timestamp.tz_localize`. In general, we recommend to rely on :meth:`Timestamp.tz_localize` when localizing ambiguous datetimes if you need direct From 337d5fe39d7fafe1952f5d31b4e1a742bd492363 Mon Sep 17 00:00:00 2001 From: jbrockmendel Date: Fri, 11 Jul 2025 09:32:39 -0700 Subject: [PATCH 28/51] REF: separate out helpers in libparser (#61832) --- pandas/_libs/parsers.pyx | 86 ++++++++++++++++++++++------------------ 1 file changed, 47 insertions(+), 39 deletions(-) diff --git a/pandas/_libs/parsers.pyx b/pandas/_libs/parsers.pyx index 43670abca2fac..5b94f45490da4 100644 --- a/pandas/_libs/parsers.pyx +++ b/pandas/_libs/parsers.pyx @@ -340,7 +340,7 @@ cdef class TextReader: cdef: parser_t *parser object na_fvalues - object true_values, false_values + list true_values, false_values object handle object orig_header bint na_filter, keep_default_na, has_usecols, has_mi_columns @@ -942,6 +942,7 @@ cdef class TextReader: bint na_filter = 0 int64_t num_cols dict results + bint is_default_dict_dtype start = self.parser_start @@ -957,26 +958,7 @@ cdef class TextReader: self.parser.line_fields[i] + \ (num_cols >= self.parser.line_fields[i]) * num_cols - usecols_not_callable_and_exists = not callable(self.usecols) and self.usecols - names_larger_num_cols = (self.names and - len(self.names) - self.leading_cols > num_cols) - - if self.table_width - self.leading_cols > num_cols: - if (usecols_not_callable_and_exists - and self.table_width - self.leading_cols < len(self.usecols) - or names_larger_num_cols): - raise ParserError(f"Too many columns specified: expected " - f"{self.table_width - self.leading_cols} " - f"and found {num_cols}") - - if (usecols_not_callable_and_exists and - all(isinstance(u, int) for u in self.usecols)): - missing_usecols = [col for col in self.usecols if col >= num_cols] - if missing_usecols: - raise ParserError( - "Defining usecols with out-of-bounds indices is not allowed. " - f"{missing_usecols} are out of bounds.", - ) + self._validate_usecols_and_names(num_cols) results = {} nused = 0 @@ -1004,22 +986,7 @@ cdef class TextReader: nused += 1 conv = self._get_converter(i, name) - - col_dtype = None - if self.dtype is not None: - if isinstance(self.dtype, dict): - if name in self.dtype: - col_dtype = self.dtype[name] - elif i in self.dtype: - col_dtype = self.dtype[i] - elif is_default_dict_dtype: - col_dtype = self.dtype[name] - else: - if self.dtype.names: - # structured array - col_dtype = np.dtype(self.dtype.descr[i][1]) - else: - col_dtype = self.dtype + col_dtype = self._get_col_dtype(i, is_default_dict_dtype, name) if conv: if col_dtype is not None: @@ -1267,6 +1234,47 @@ cdef class TextReader: return _string_box_utf8(self.parser, i, start, end, na_filter, na_hashset, self.encoding_errors) + cdef void _validate_usecols_and_names(self, int num_cols): + usecols_not_callable_and_exists = not callable(self.usecols) and self.usecols + names_larger_num_cols = (self.names and + len(self.names) - self.leading_cols > num_cols) + + if self.table_width - self.leading_cols > num_cols: + if (usecols_not_callable_and_exists + and self.table_width - self.leading_cols < len(self.usecols) + or names_larger_num_cols): + raise ParserError(f"Too many columns specified: expected " + f"{self.table_width - self.leading_cols} " + f"and found {num_cols}") + + if (usecols_not_callable_and_exists and + all(isinstance(u, int) for u in self.usecols)): + missing_usecols = [col for col in self.usecols if col >= num_cols] + if missing_usecols: + raise ParserError( + "Defining usecols with out-of-bounds indices is not allowed. " + f"{missing_usecols} are out of bounds.", + ) + + # -> DtypeObj + cdef object _get_col_dtype(self, int64_t i, bint is_default_dict_dtype, name): + col_dtype = None + if self.dtype is not None: + if isinstance(self.dtype, dict): + if name in self.dtype: + col_dtype = self.dtype[name] + elif i in self.dtype: + col_dtype = self.dtype[i] + elif is_default_dict_dtype: + col_dtype = self.dtype[name] + else: + if self.dtype.names: + # structured array + col_dtype = np.dtype(self.dtype.descr[i][1]) + else: + col_dtype = self.dtype + return col_dtype + def _get_converter(self, i: int, name): if self.converters is None: return None @@ -1347,8 +1355,8 @@ cdef _close(TextReader reader): cdef: - object _true_values = [b"True", b"TRUE", b"true"] - object _false_values = [b"False", b"FALSE", b"false"] + list _true_values = [b"True", b"TRUE", b"true"] + list _false_values = [b"False", b"FALSE", b"false"] def _ensure_encoded(list lst): From 688e2a0e692731654009fe0f6bf64ca02d9282f7 Mon Sep 17 00:00:00 2001 From: Arthur Laureus Wigo <126365160+arthurlw@users.noreply.github.com> Date: Fri, 11 Jul 2025 23:35:44 +0700 Subject: [PATCH 29/51] TST: Fix `test_mask_stringdtype` (#61830) Fix test --- pandas/tests/frame/indexing/test_mask.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/pandas/tests/frame/indexing/test_mask.py b/pandas/tests/frame/indexing/test_mask.py index ac6f0a1ac0f73..e4036efeab7ff 100644 --- a/pandas/tests/frame/indexing/test_mask.py +++ b/pandas/tests/frame/indexing/test_mask.py @@ -105,7 +105,7 @@ def test_mask_stringdtype(frame_or_series): {"A": ["this", "that"]}, index=["id2", "id3"], dtype=StringDtype() ) expected = DataFrame( - {"A": [NA, "this", "that", NA]}, + {"A": ["foo", "this", "that", NA]}, index=["id1", "id2", "id3", "id4"], dtype=StringDtype(), ) @@ -114,7 +114,10 @@ def test_mask_stringdtype(frame_or_series): filtered_obj = filtered_obj["A"] expected = expected["A"] - filter_ser = Series([False, True, True, False]) + filter_ser = Series( + [False, True, True, False], + index=["id1", "id2", "id3", "id4"], + ) result = obj.mask(filter_ser, filtered_obj) tm.assert_equal(result, expected) From e1328fc8eca543bcdd75e39e017d6bf9d61640ef Mon Sep 17 00:00:00 2001 From: jbrockmendel Date: Fri, 11 Jul 2025 09:41:58 -0700 Subject: [PATCH 30/51] TST: enable 2D tests for MaskedArrays, fix+test shift (#61826) --- pandas/core/arrays/masked.py | 12 ++++++++++++ pandas/tests/extension/base/dim2.py | 10 ++++++++++ pandas/tests/extension/test_masked.py | 11 +++++++---- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/pandas/core/arrays/masked.py b/pandas/core/arrays/masked.py index e7a6b207363c3..fefd70fef35c9 100644 --- a/pandas/core/arrays/masked.py +++ b/pandas/core/arrays/masked.py @@ -59,6 +59,7 @@ masked_reductions, ) from pandas.core.array_algos.quantile import quantile_with_mask +from pandas.core.array_algos.transforms import shift from pandas.core.arraylike import OpsMixin from pandas.core.arrays._utils import to_numpy_dtype_inference from pandas.core.arrays.base import ExtensionArray @@ -361,6 +362,17 @@ def ravel(self, *args, **kwargs) -> Self: mask = self._mask.ravel(*args, **kwargs) return type(self)(data, mask) + def shift(self, periods: int = 1, fill_value=None) -> Self: + # NB: shift is always along axis=0 + axis = 0 + if fill_value is None: + new_data = shift(self._data, periods, axis, 0) + new_mask = shift(self._mask, periods, axis, True) + else: + new_data = shift(self._data, periods, axis, fill_value) + new_mask = shift(self._mask, periods, axis, False) + return type(self)(new_data, new_mask) + @property def T(self) -> Self: return self._simple_new(self._data.T, self._mask.T) diff --git a/pandas/tests/extension/base/dim2.py b/pandas/tests/extension/base/dim2.py index 8c7d8ff491cd3..890766acbd610 100644 --- a/pandas/tests/extension/base/dim2.py +++ b/pandas/tests/extension/base/dim2.py @@ -32,6 +32,16 @@ def skip_if_doesnt_support_2d(self, dtype, request): # TODO: is there a less hacky way of checking this? pytest.skip(f"{dtype} does not support 2D.") + def test_shift_2d(self, data): + arr2d = data.repeat(2).reshape(-1, 2) + + for n in [1, -2]: + for fill_value in [None, data[0]]: + result = arr2d.shift(n, fill_value=fill_value) + expected_col = data.shift(n, fill_value=fill_value) + tm.assert_extension_array_equal(result[:, 0], expected_col) + tm.assert_extension_array_equal(result[:, 1], expected_col) + def test_transpose(self, data): arr2d = data.repeat(2).reshape(-1, 2) shape = arr2d.shape diff --git a/pandas/tests/extension/test_masked.py b/pandas/tests/extension/test_masked.py index 3b9079d06e231..c7fe9e99ec6e5 100644 --- a/pandas/tests/extension/test_masked.py +++ b/pandas/tests/extension/test_masked.py @@ -168,6 +168,13 @@ def data_for_grouping(dtype): class TestMaskedArrays(base.ExtensionTests): + @pytest.fixture(autouse=True) + def skip_if_doesnt_support_2d(self, dtype, request): + # Override the fixture so that we run these tests. + assert not dtype._supports_2d + # If dtype._supports_2d is ever changed to True, then this fixture + # override becomes unnecessary. + @pytest.mark.parametrize("na_action", [None, "ignore"]) def test_map(self, data_missing, na_action): result = data_missing.map(lambda x: x, na_action=na_action) @@ -402,7 +409,3 @@ def check_accumulate(self, ser: pd.Series, op_name: str, skipna: bool): else: raise NotImplementedError(f"{op_name} not supported") - - -class Test2DCompat(base.Dim2CompatTests): - pass From fd7bfaa75072f3d8ff7d746c182dbb338e92a7c2 Mon Sep 17 00:00:00 2001 From: HeoHeo Date: Sat, 12 Jul 2025 04:08:56 +0900 Subject: [PATCH 31/51] BUG: Fix infer_dtype result for float with embedded pd.NA (#61624) --- doc/source/whatsnew/v3.0.0.rst | 1 + pandas/_libs/lib.pyi | 2 +- pandas/_libs/lib.pyx | 8 +++++--- pandas/core/dtypes/cast.py | 5 +---- pandas/tests/dtypes/test_inference.py | 9 +++++++++ pandas/tests/extension/test_arrow.py | 2 +- 6 files changed, 18 insertions(+), 9 deletions(-) diff --git a/doc/source/whatsnew/v3.0.0.rst b/doc/source/whatsnew/v3.0.0.rst index 6d5620d87de21..837202a6a62e1 100644 --- a/doc/source/whatsnew/v3.0.0.rst +++ b/doc/source/whatsnew/v3.0.0.rst @@ -725,6 +725,7 @@ Timezones Numeric ^^^^^^^ +- Bug in :func:`api.types.infer_dtype` returning "mixed-integer-float" for float and ``pd.NA`` mix (:issue:`61621`) - Bug in :meth:`DataFrame.corr` where numerical precision errors resulted in correlations above ``1.0`` (:issue:`61120`) - Bug in :meth:`DataFrame.cov` raises a ``TypeError`` instead of returning potentially incorrect results or other errors (:issue:`53115`) - Bug in :meth:`DataFrame.quantile` where the column type was not preserved when ``numeric_only=True`` with a list-like ``q`` produced an empty result (:issue:`59035`) diff --git a/pandas/_libs/lib.pyi b/pandas/_libs/lib.pyi index 331233f37f63d..310cd3c3d76ec 100644 --- a/pandas/_libs/lib.pyi +++ b/pandas/_libs/lib.pyi @@ -60,7 +60,7 @@ def is_time_array(values: np.ndarray, skipna: bool = ...): ... def is_date_array(values: np.ndarray, skipna: bool = ...): ... def is_datetime_array(values: np.ndarray, skipna: bool = ...): ... def is_string_array(values: np.ndarray, skipna: bool = ...): ... -def is_float_array(values: np.ndarray): ... +def is_float_array(values: np.ndarray, skipna: bool = ...): ... def is_integer_array(values: np.ndarray, skipna: bool = ...): ... def is_bool_array(values: np.ndarray, skipna: bool = ...): ... def fast_multiget( diff --git a/pandas/_libs/lib.pyx b/pandas/_libs/lib.pyx index 3b7d659c2150e..6bb8e8ab46e59 100644 --- a/pandas/_libs/lib.pyx +++ b/pandas/_libs/lib.pyx @@ -1752,7 +1752,7 @@ def infer_dtype(value: object, skipna: bool = True) -> str: return "complex" elif util.is_float_object(val): - if is_float_array(values): + if is_float_array(values, skipna=skipna): return "floating" elif is_integer_float_array(values, skipna=skipna): if is_integer_na_array(values, skipna=skipna): @@ -1954,9 +1954,11 @@ cdef class FloatValidator(Validator): # Note: only python-exposed for tests -cpdef bint is_float_array(ndarray values): +cpdef bint is_float_array(ndarray values, bint skipna=True): cdef: - FloatValidator validator = FloatValidator(values.size, values.dtype) + FloatValidator validator = FloatValidator(values.size, + values.dtype, + skipna=skipna) return validator.validate(values) diff --git a/pandas/core/dtypes/cast.py b/pandas/core/dtypes/cast.py index 47e52f36ad121..20fe9b92b4677 100644 --- a/pandas/core/dtypes/cast.py +++ b/pandas/core/dtypes/cast.py @@ -1086,10 +1086,7 @@ def convert_dtypes( elif ( infer_objects and input_array.dtype == object - and ( - isinstance(inferred_dtype, str) - and inferred_dtype == "mixed-integer-float" - ) + and (isinstance(inferred_dtype, str) and inferred_dtype == "floating") ): inferred_dtype = pandas_dtype_func("Float64") diff --git a/pandas/tests/dtypes/test_inference.py b/pandas/tests/dtypes/test_inference.py index 7fd0395009adb..6e08ebda88420 100644 --- a/pandas/tests/dtypes/test_inference.py +++ b/pandas/tests/dtypes/test_inference.py @@ -1396,6 +1396,15 @@ def test_infer_dtype_period_with_na(self, na_value): arr = np.array([na_value, Period("2011-01", freq="D"), na_value]) assert lib.infer_dtype(arr, skipna=True) == "period" + @pytest.mark.parametrize("na_value", [pd.NA, np.nan]) + def test_infer_dtype_numeric_with_na(self, na_value): + # GH61621 + ser = Series([1, 2, na_value], dtype=object) + assert lib.infer_dtype(ser, skipna=True) == "integer" + + ser = Series([1.0, 2.0, na_value], dtype=object) + assert lib.infer_dtype(ser, skipna=True) == "floating" + def test_infer_dtype_all_nan_nat_like(self): arr = np.array([np.nan, np.nan]) assert lib.infer_dtype(arr, skipna=True) == "floating" diff --git a/pandas/tests/extension/test_arrow.py b/pandas/tests/extension/test_arrow.py index 7e7cd8fb13456..4d766d6664218 100644 --- a/pandas/tests/extension/test_arrow.py +++ b/pandas/tests/extension/test_arrow.py @@ -3081,7 +3081,7 @@ def test_infer_dtype_pyarrow_dtype(data, request): res = lib.infer_dtype(data) assert res != "unknown-array" - if data._hasna and res in ["floating", "datetime64", "timedelta64"]: + if data._hasna and res in ["datetime64", "timedelta64"]: mark = pytest.mark.xfail( reason="in infer_dtype pd.NA is not ignored in these cases " "even with skipna=True in the list(data) check below" From e83b820a69bf724276feb94d033c95df37d6f620 Mon Sep 17 00:00:00 2001 From: Maaz Bin Asif Date: Sat, 12 Jul 2025 03:50:19 +0500 Subject: [PATCH 32/51] DOC: Correct error message in AbstractMethodError for methodtype argument (#61827) * DOC: Correct error message in AbstractMethodError for methodtype argument * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- pandas/errors/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pandas/errors/__init__.py b/pandas/errors/__init__.py index 2b5bc450e41d6..d1ca056ffcb19 100644 --- a/pandas/errors/__init__.py +++ b/pandas/errors/__init__.py @@ -379,7 +379,7 @@ def __init__(self, class_instance, methodtype: str = "method") -> None: types = {"method", "classmethod", "staticmethod", "property"} if methodtype not in types: raise ValueError( - f"methodtype must be one of {methodtype}, got {types} instead." + f"methodtype must be one of {types}, got {methodtype} instead." ) self.methodtype = methodtype self.class_instance = class_instance From da7f2beac327ba356f87a9e79362377ef29d775e Mon Sep 17 00:00:00 2001 From: "W. H. Wang" Date: Sun, 13 Jul 2025 02:36:28 +0800 Subject: [PATCH 33/51] DOC: rm excessive backtick (#61839) fix(doc): rm excessive backtick --- doc/source/whatsnew/v0.4.x.rst | 2 +- doc/source/whatsnew/v2.0.0.rst | 4 ++-- doc/source/whatsnew/v2.0.3.rst | 2 +- doc/source/whatsnew/v2.1.0.rst | 2 +- doc/source/whatsnew/v3.0.0.rst | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/doc/source/whatsnew/v0.4.x.rst b/doc/source/whatsnew/v0.4.x.rst index 83f6a6907f33c..631a6a6411440 100644 --- a/doc/source/whatsnew/v0.4.x.rst +++ b/doc/source/whatsnew/v0.4.x.rst @@ -11,7 +11,7 @@ New features - Added Python 3 support using 2to3 (:issue:`200`) - :ref:`Added ` ``name`` attribute to ``Series``, now prints as part of ``Series.__repr__`` -- :meth:`Series.isnull`` and :meth:`Series.notnull` (:issue:`209`, :issue:`203`) +- :meth:`Series.isnull` and :meth:`Series.notnull` (:issue:`209`, :issue:`203`) - :ref:`Added ` ``Series.align`` method for aligning two series with choice of join method (ENH56_) - :ref:`Added ` method ``get_level_values`` to diff --git a/doc/source/whatsnew/v2.0.0.rst b/doc/source/whatsnew/v2.0.0.rst index ddcd69c3fd962..9fb592d24d54c 100644 --- a/doc/source/whatsnew/v2.0.0.rst +++ b/doc/source/whatsnew/v2.0.0.rst @@ -984,7 +984,7 @@ Removal of prior version deprecations/changes - Removed :meth:`Series.str.__iter__` (:issue:`28277`) - Removed ``pandas.SparseArray`` in favor of :class:`arrays.SparseArray` (:issue:`30642`) - Removed ``pandas.SparseSeries`` and ``pandas.SparseDataFrame``, including pickle support. (:issue:`30642`) -- Enforced disallowing passing an integer ``fill_value`` to :meth:`DataFrame.shift` and :meth:`Series.shift`` with datetime64, timedelta64, or period dtypes (:issue:`32591`) +- Enforced disallowing passing an integer ``fill_value`` to :meth:`DataFrame.shift` and :meth:`Series.shift` with datetime64, timedelta64, or period dtypes (:issue:`32591`) - Enforced disallowing a string column label into ``times`` in :meth:`DataFrame.ewm` (:issue:`43265`) - Enforced disallowing passing ``True`` and ``False`` into ``inclusive`` in :meth:`Series.between` in favor of ``"both"`` and ``"neither"`` respectively (:issue:`40628`) - Enforced disallowing using ``usecols`` with out of bounds indices for ``read_csv`` with ``engine="c"`` (:issue:`25623`) @@ -1045,7 +1045,7 @@ Removal of prior version deprecations/changes - Enforced deprecation of silently dropping columns that raised a ``TypeError`` in :class:`Series.transform` and :class:`DataFrame.transform` when used with a list or dictionary (:issue:`43740`) - Changed behavior of :meth:`DataFrame.apply` with list-like so that any partial failure will raise an error (:issue:`43740`) - Changed behaviour of :meth:`DataFrame.to_latex` to now use the Styler implementation via :meth:`.Styler.to_latex` (:issue:`47970`) -- Changed behavior of :meth:`Series.__setitem__` with an integer key and a :class:`Float64Index` when the key is not present in the index; previously we treated the key as positional (behaving like ``series.iloc[key] = val``), now we treat it is a label (behaving like ``series.loc[key] = val``), consistent with :meth:`Series.__getitem__`` behavior (:issue:`33469`) +- Changed behavior of :meth:`Series.__setitem__` with an integer key and a :class:`Float64Index` when the key is not present in the index; previously we treated the key as positional (behaving like ``series.iloc[key] = val``), now we treat it is a label (behaving like ``series.loc[key] = val``), consistent with :meth:`Series.__getitem__` behavior (:issue:`33469`) - Removed ``na_sentinel`` argument from :func:`factorize`, :meth:`.Index.factorize`, and :meth:`.ExtensionArray.factorize` (:issue:`47157`) - Changed behavior of :meth:`Series.diff` and :meth:`DataFrame.diff` with :class:`ExtensionDtype` dtypes whose arrays do not implement ``diff``, these now raise ``TypeError`` rather than casting to numpy (:issue:`31025`) - Enforced deprecation of calling numpy "ufunc"s on :class:`DataFrame` with ``method="outer"``; this now raises ``NotImplementedError`` (:issue:`36955`) diff --git a/doc/source/whatsnew/v2.0.3.rst b/doc/source/whatsnew/v2.0.3.rst index 26e34e0c823ce..e0b795165fd93 100644 --- a/doc/source/whatsnew/v2.0.3.rst +++ b/doc/source/whatsnew/v2.0.3.rst @@ -13,7 +13,7 @@ including other versions of pandas. Fixed regressions ~~~~~~~~~~~~~~~~~ -- Bug in :meth:`Timestamp.weekday`` was returning incorrect results before ``'0000-02-29'`` (:issue:`53738`) +- Bug in :meth:`Timestamp.weekday` was returning incorrect results before ``'0000-02-29'`` (:issue:`53738`) - Fixed performance regression in merging on datetime-like columns (:issue:`53231`) - Fixed regression when :meth:`DataFrame.to_string` creates extra space for string dtypes (:issue:`52690`) diff --git a/doc/source/whatsnew/v2.1.0.rst b/doc/source/whatsnew/v2.1.0.rst index 495c8244142f9..2817945c55a86 100644 --- a/doc/source/whatsnew/v2.1.0.rst +++ b/doc/source/whatsnew/v2.1.0.rst @@ -721,7 +721,7 @@ Conversion Strings ^^^^^^^ - Bug in :meth:`Series.str` that did not raise a ``TypeError`` when iterated (:issue:`54173`) -- Bug in ``repr`` for :class:`DataFrame`` with string-dtype columns (:issue:`54797`) +- Bug in ``repr`` for :class:`DataFrame` with string-dtype columns (:issue:`54797`) Interval ^^^^^^^^ diff --git a/doc/source/whatsnew/v3.0.0.rst b/doc/source/whatsnew/v3.0.0.rst index 837202a6a62e1..3e58be09372a3 100644 --- a/doc/source/whatsnew/v3.0.0.rst +++ b/doc/source/whatsnew/v3.0.0.rst @@ -784,7 +784,7 @@ MultiIndex I/O ^^^ -- Bug in :class:`DataFrame` and :class:`Series` ``repr`` of :py:class:`collections.abc.Mapping`` elements. (:issue:`57915`) +- Bug in :class:`DataFrame` and :class:`Series` ``repr`` of :py:class:`collections.abc.Mapping` elements. (:issue:`57915`) - Bug in :meth:`.DataFrame.to_json` when ``"index"`` was a value in the :attr:`DataFrame.column` and :attr:`Index.name` was ``None``. Now, this will fail with a ``ValueError`` (:issue:`58925`) - Bug in :meth:`.io.common.is_fsspec_url` not recognizing chained fsspec URLs (:issue:`48978`) - Bug in :meth:`DataFrame._repr_html_` which ignored the ``"display.float_format"`` option (:issue:`59876`) From 4f2aa4d2be949a4b96c071b1e2213b46ba47a11a Mon Sep 17 00:00:00 2001 From: Sivasweatha Umamaheswaran Date: Sun, 13 Jul 2025 00:19:35 +0530 Subject: [PATCH 34/51] DOC: Update README.md to reference issues related to 'good first issue' and 'Docs' properly (#61836) * DOC: Update README.md to proper link to issues related to Docs * DOC: Update README.md to proper link to issues related to 'good first issue' --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ebab2e6016850..895cfb69e5edd 100644 --- a/README.md +++ b/README.md @@ -175,7 +175,7 @@ All contributions, bug reports, bug fixes, documentation improvements, enhanceme A detailed overview on how to contribute can be found in the **[contributing guide](https://pandas.pydata.org/docs/dev/development/contributing.html)**. -If you are simply looking to start working with the pandas codebase, navigate to the [GitHub "issues" tab](https://github.com/pandas-dev/pandas/issues) and start looking through interesting issues. There are a number of issues listed under [Docs](https://github.com/pandas-dev/pandas/issues?labels=Docs&sort=updated&state=open) and [good first issue](https://github.com/pandas-dev/pandas/issues?labels=good+first+issue&sort=updated&state=open) where you could start out. +If you are simply looking to start working with the pandas codebase, navigate to the [GitHub "issues" tab](https://github.com/pandas-dev/pandas/issues) and start looking through interesting issues. There are a number of issues listed under [Docs](https://github.com/pandas-dev/pandas/issues?q=is%3Aissue%20state%3Aopen%20label%3ADocs%20sort%3Aupdated-desc) and [good first issue](https://github.com/pandas-dev/pandas/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22good%20first%20issue%22%20sort%3Aupdated-desc) where you could start out. You can also triage issues which may include reproducing bug reports, or asking for vital information such as version numbers or reproduction instructions. If you would like to start triaging issues, one easy way to get started is to [subscribe to pandas on CodeTriage](https://www.codetriage.com/pandas-dev/pandas). From a2315af1df30ec3648786502457eb544d002c71d Mon Sep 17 00:00:00 2001 From: Abhinav <61010675+iabhi4@users.noreply.github.com> Date: Sun, 13 Jul 2025 05:10:05 -0700 Subject: [PATCH 35/51] BUG: Fix pivot_table margins to include NaN groups when dropna=False (#61524) --- doc/source/whatsnew/v3.0.0.rst | 1 + pandas/core/reshape/pivot.py | 23 +++++++++++++------- pandas/tests/reshape/test_crosstab.py | 10 ++++----- pandas/tests/reshape/test_pivot.py | 30 +++++++++++++++++++++++++++ 4 files changed, 52 insertions(+), 12 deletions(-) diff --git a/doc/source/whatsnew/v3.0.0.rst b/doc/source/whatsnew/v3.0.0.rst index 3e58be09372a3..977186d808e81 100644 --- a/doc/source/whatsnew/v3.0.0.rst +++ b/doc/source/whatsnew/v3.0.0.rst @@ -869,6 +869,7 @@ Reshaping - Bug in :meth:`DataFrame.merge` when merging two :class:`DataFrame` on ``intc`` or ``uintc`` types on Windows (:issue:`60091`, :issue:`58713`) - Bug in :meth:`DataFrame.pivot_table` incorrectly subaggregating results when called without an ``index`` argument (:issue:`58722`) - Bug in :meth:`DataFrame.pivot_table` incorrectly ignoring the ``values`` argument when also supplied to the ``index`` or ``columns`` parameters (:issue:`57876`, :issue:`61292`) +- Bug in :meth:`DataFrame.pivot_table` where ``margins=True`` did not correctly include groups with ``NaN`` values in the index or columns when ``dropna=False`` was explicitly passed. (:issue:`61509`) - Bug in :meth:`DataFrame.stack` with the new implementation where ``ValueError`` is raised when ``level=[]`` (:issue:`60740`) - Bug in :meth:`DataFrame.unstack` producing incorrect results when manipulating empty :class:`DataFrame` with an :class:`ExtentionDtype` (:issue:`59123`) - Bug in :meth:`concat` where concatenating DataFrame and Series with ``ignore_index = True`` drops the series name (:issue:`60723`, :issue:`56257`) diff --git a/pandas/core/reshape/pivot.py b/pandas/core/reshape/pivot.py index ac89f19b80a0f..c80ee69047ea1 100644 --- a/pandas/core/reshape/pivot.py +++ b/pandas/core/reshape/pivot.py @@ -396,6 +396,7 @@ def __internal_pivot_table( observed=dropna, margins_name=margins_name, fill_value=fill_value, + dropna=dropna, ) # discard the top level @@ -422,6 +423,7 @@ def _add_margins( observed: bool, margins_name: Hashable = "All", fill_value=None, + dropna: bool = True, ): if not isinstance(margins_name, str): raise ValueError("margins_name argument must be a string") @@ -461,6 +463,7 @@ def _add_margins( kwargs, observed, margins_name, + dropna, ) if not isinstance(marginal_result_set, tuple): return marginal_result_set @@ -469,7 +472,7 @@ def _add_margins( # no values, and table is a DataFrame assert isinstance(table, ABCDataFrame) marginal_result_set = _generate_marginal_results_without_values( - table, data, rows, cols, aggfunc, kwargs, observed, margins_name + table, data, rows, cols, aggfunc, kwargs, observed, margins_name, dropna ) if not isinstance(marginal_result_set, tuple): return marginal_result_set @@ -538,6 +541,7 @@ def _generate_marginal_results( kwargs, observed: bool, margins_name: Hashable = "All", + dropna: bool = True, ): margin_keys: list | Index if len(cols) > 0: @@ -551,7 +555,7 @@ def _all_key(key): if len(rows) > 0: margin = ( data[rows + values] - .groupby(rows, observed=observed) + .groupby(rows, observed=observed, dropna=dropna) .agg(aggfunc, **kwargs) ) cat_axis = 1 @@ -567,7 +571,7 @@ def _all_key(key): else: margin = ( data[cols[:1] + values] - .groupby(cols[:1], observed=observed) + .groupby(cols[:1], observed=observed, dropna=dropna) .agg(aggfunc, **kwargs) .T ) @@ -610,7 +614,9 @@ def _all_key(key): if len(cols) > 0: row_margin = ( - data[cols + values].groupby(cols, observed=observed).agg(aggfunc, **kwargs) + data[cols + values] + .groupby(cols, observed=observed, dropna=dropna) + .agg(aggfunc, **kwargs) ) row_margin = row_margin.stack() @@ -633,6 +639,7 @@ def _generate_marginal_results_without_values( kwargs, observed: bool, margins_name: Hashable = "All", + dropna: bool = True, ): margin_keys: list | Index if len(cols) > 0: @@ -645,7 +652,7 @@ def _all_key(): return (margins_name,) + ("",) * (len(cols) - 1) if len(rows) > 0: - margin = data.groupby(rows, observed=observed)[rows].apply( + margin = data.groupby(rows, observed=observed, dropna=dropna)[rows].apply( aggfunc, **kwargs ) all_key = _all_key() @@ -654,7 +661,9 @@ def _all_key(): margin_keys.append(all_key) else: - margin = data.groupby(level=0, observed=observed).apply(aggfunc, **kwargs) + margin = data.groupby(level=0, observed=observed, dropna=dropna).apply( + aggfunc, **kwargs + ) all_key = _all_key() table[all_key] = margin result = table @@ -665,7 +674,7 @@ def _all_key(): margin_keys = table.columns if len(cols): - row_margin = data.groupby(cols, observed=observed)[cols].apply( + row_margin = data.groupby(cols, observed=observed, dropna=dropna)[cols].apply( aggfunc, **kwargs ) else: diff --git a/pandas/tests/reshape/test_crosstab.py b/pandas/tests/reshape/test_crosstab.py index 070c756e8c928..1482da8a074eb 100644 --- a/pandas/tests/reshape/test_crosstab.py +++ b/pandas/tests/reshape/test_crosstab.py @@ -289,7 +289,7 @@ def test_margin_dropna4(self): # GH: 10772: Keep np.nan in result with dropna=False df = DataFrame({"a": [1, 2, 2, 2, 2, np.nan], "b": [3, 3, 4, 4, 4, 4]}) actual = crosstab(df.a, df.b, margins=True, dropna=False) - expected = DataFrame([[1, 0, 1.0], [1, 3, 4.0], [0, 1, np.nan], [2, 4, 6.0]]) + expected = DataFrame([[1, 0, 1], [1, 3, 4], [0, 1, 1], [2, 4, 6]]) expected.index = Index([1.0, 2.0, np.nan, "All"], name="a") expected.columns = Index([3, 4, "All"], name="b") tm.assert_frame_equal(actual, expected) @@ -301,11 +301,11 @@ def test_margin_dropna5(self): ) actual = crosstab(df.a, df.b, margins=True, dropna=False) expected = DataFrame( - [[1, 0, 0, 1.0], [0, 1, 0, 1.0], [0, 3, 1, np.nan], [1, 4, 0, 6.0]] + [[1, 0, 0, 1.0], [0, 1, 0, 1.0], [0, 3, 1, 4.0], [1, 4, 1, 6.0]] ) expected.index = Index([1.0, 2.0, np.nan, "All"], name="a") expected.columns = Index([3.0, 4.0, np.nan, "All"], name="b") - tm.assert_frame_equal(actual, expected) + tm.assert_frame_equal(actual, expected, check_dtype=False) def test_margin_dropna6(self): # GH: 10772: Keep np.nan in result with dropna=False @@ -326,7 +326,7 @@ def test_margin_dropna6(self): names=["b", "c"], ) expected = DataFrame( - [[1, 0, 1, 0, 0, 0, 2], [2, 0, 1, 1, 0, 1, 5], [3, 0, 2, 1, 0, 0, 7]], + [[1, 0, 1, 0, 0, 0, 2], [2, 0, 1, 1, 0, 1, 5], [3, 0, 2, 1, 0, 1, 7]], columns=m, ) expected.index = Index(["bar", "foo", "All"], name="a") @@ -349,7 +349,7 @@ def test_margin_dropna6(self): [0, 0, np.nan], [2, 0, 2.0], [1, 1, 2.0], - [0, 1, np.nan], + [0, 1, 1.0], [5, 2, 7.0], ], index=m, diff --git a/pandas/tests/reshape/test_pivot.py b/pandas/tests/reshape/test_pivot.py index e46134df73dba..a0ad91e679bcc 100644 --- a/pandas/tests/reshape/test_pivot.py +++ b/pandas/tests/reshape/test_pivot.py @@ -2585,6 +2585,36 @@ def test_pivot_table_values_as_two_params( expected = DataFrame(data=e_data, index=e_index, columns=e_cols) tm.assert_frame_equal(result, expected) + def test_pivot_table_margins_include_nan_groups(self): + # GH#61509 + df = DataFrame( + { + "i": [1, 2, 3], + "g1": ["a", "b", "b"], + "g2": ["x", None, None], + } + ) + + result = df.pivot_table( + index="g1", + columns="g2", + values="i", + aggfunc="count", + dropna=False, + margins=True, + ) + + expected = DataFrame( + { + "x": {"a": 1.0, "b": np.nan, "All": 1.0}, + np.nan: {"a": np.nan, "b": 2.0, "All": 2.0}, + "All": {"a": 1.0, "b": 2.0, "All": 3.0}, + } + ) + expected.index.name = "g1" + expected.columns.name = "g2" + tm.assert_frame_equal(result, expected, check_dtype=False) + class TestPivot: def test_pivot(self): From bc6ad140daf230c470fef92bec598831d4f94a16 Mon Sep 17 00:00:00 2001 From: Peter Nguyen Date: Mon, 14 Jul 2025 08:48:25 -0700 Subject: [PATCH 36/51] Remove incorrect line in Series init docstring (#61849) --- pandas/core/series.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pandas/core/series.py b/pandas/core/series.py index 7a26be875e7b5..ce5b2e5ed8de5 100644 --- a/pandas/core/series.py +++ b/pandas/core/series.py @@ -263,7 +263,6 @@ class Series(base.IndexOpsMixin, NDFrame): # type: ignore[misc] Data type for the output Series. If not specified, this will be inferred from `data`. See the :ref:`user guide ` for more usages. - If ``data`` is Series then is ignored. name : Hashable, default None The name to give to the Series. copy : bool, default False From 1d153bb1a4c6549958a20e04508967e2ed45159f Mon Sep 17 00:00:00 2001 From: Richard Shadrach <45562402+rhshadrach@users.noreply.github.com> Date: Tue, 15 Jul 2025 12:34:51 -0400 Subject: [PATCH 37/51] TST(string dtype): Resolve xfails in test_from_dummies (#60694) --- pandas/core/reshape/encoding.py | 4 ++- pandas/tests/reshape/test_from_dummies.py | 32 ++++++++++++++++++++--- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/pandas/core/reshape/encoding.py b/pandas/core/reshape/encoding.py index ad4a5db441b89..67fb075110f0d 100644 --- a/pandas/core/reshape/encoding.py +++ b/pandas/core/reshape/encoding.py @@ -390,7 +390,9 @@ def from_dummies( The default category is the implied category when a value has none of the listed categories specified with a one, i.e. if all dummies in a row are zero. Can be a single value for all variables or a dict directly mapping - the default categories to a prefix of a variable. + the default categories to a prefix of a variable. The default category + will be coerced to the dtype of ``data.columns`` if such coercion is + lossless, and will raise otherwise. Returns ------- diff --git a/pandas/tests/reshape/test_from_dummies.py b/pandas/tests/reshape/test_from_dummies.py index c7b7992a78232..dfb691c785404 100644 --- a/pandas/tests/reshape/test_from_dummies.py +++ b/pandas/tests/reshape/test_from_dummies.py @@ -333,9 +333,7 @@ def test_no_prefix_string_cats_default_category( ): dummies = DataFrame({"a": [1, 0, 0], "b": [0, 1, 0]}) result = from_dummies(dummies, default_category=default_category) - expected = DataFrame(expected) - if using_infer_string: - expected[""] = expected[""].astype("str") + expected = DataFrame(expected, dtype=dummies.columns.dtype) tm.assert_frame_equal(result, expected) @@ -449,3 +447,31 @@ def test_maintain_original_index(): result = from_dummies(df) expected = DataFrame({"": list("abca")}, index=list("abcd")) tm.assert_frame_equal(result, expected) + + +def test_int_columns_with_float_default(): + # https://github.com/pandas-dev/pandas/pull/60694 + df = DataFrame( + { + 3: [1, 0, 0], + 4: [0, 1, 0], + }, + ) + with pytest.raises(ValueError, match="Trying to coerce float values to integers"): + from_dummies(df, default_category=0.5) + + +def test_object_dtype_preserved(): + # https://github.com/pandas-dev/pandas/pull/60694 + # When the input has object dtype, the result should as + # well even when infer_string is True. + df = DataFrame( + { + "x": [1, 0, 0], + "y": [0, 1, 0], + }, + ) + df.columns = df.columns.astype("object") + result = from_dummies(df, default_category="z") + expected = DataFrame({"": ["x", "y", "z"]}, dtype="object") + tm.assert_frame_equal(result, expected) From 43711d555845ad9ee36a6f30cde334b52120021a Mon Sep 17 00:00:00 2001 From: jbrockmendel Date: Wed, 16 Jul 2025 09:26:06 -0700 Subject: [PATCH 38/51] API: np.isinf on Index return Index[bool] (#61874) --- doc/source/whatsnew/v3.0.0.rst | 1 + pandas/core/indexes/base.py | 8 ++------ pandas/tests/indexes/test_numpy_compat.py | 14 +++++++------- 3 files changed, 10 insertions(+), 13 deletions(-) diff --git a/doc/source/whatsnew/v3.0.0.rst b/doc/source/whatsnew/v3.0.0.rst index 977186d808e81..f2650a64d2c59 100644 --- a/doc/source/whatsnew/v3.0.0.rst +++ b/doc/source/whatsnew/v3.0.0.rst @@ -414,6 +414,7 @@ Other API changes - Index set operations (like union or intersection) will now ignore the dtype of an empty ``RangeIndex`` or empty ``Index`` with object dtype when determining the dtype of the resulting Index (:issue:`60797`) +- Numpy functions like ``np.isinf`` that return a bool dtype when called on a :class:`Index` object now return a bool-dtype :class:`Index` instead of ``np.ndarray`` (:issue:`52676`) .. --------------------------------------------------------------------------- .. _whatsnew_300.deprecations: diff --git a/pandas/core/indexes/base.py b/pandas/core/indexes/base.py index 0b719ae21d5b9..fb395f4f7bb1a 100644 --- a/pandas/core/indexes/base.py +++ b/pandas/core/indexes/base.py @@ -965,12 +965,8 @@ def __array_wrap__(self, result, context=None, return_scalar=False): Gets called after a ufunc and other functions e.g. np.split. """ result = lib.item_from_zerodim(result) - if (not isinstance(result, Index) and is_bool_dtype(result.dtype)) or np.ndim( - result - ) > 1: - # exclude Index to avoid warning from is_bool_dtype deprecation; - # in the Index case it doesn't matter which path we go down. - # reached in plotting tests with e.g. np.nonzero(index) + if np.ndim(result) > 1: + # Reached in plotting tests with e.g. np.nonzero(index) return result return Index(result, name=self.name) diff --git a/pandas/tests/indexes/test_numpy_compat.py b/pandas/tests/indexes/test_numpy_compat.py index ace78d77350cb..86d0ca1280596 100644 --- a/pandas/tests/indexes/test_numpy_compat.py +++ b/pandas/tests/indexes/test_numpy_compat.py @@ -2,6 +2,7 @@ import pytest from pandas import ( + BooleanDtype, CategoricalIndex, DatetimeIndex, Index, @@ -14,7 +15,6 @@ is_complex_dtype, is_numeric_dtype, ) -from pandas.core.arrays import BooleanArray from pandas.core.indexes.datetimelike import DatetimeIndexOpsMixin @@ -111,11 +111,10 @@ def test_numpy_ufuncs_other(index, func): if func in (np.isfinite, np.isinf, np.isnan): # numpy 1.18 changed isinf and isnan to not raise on dt64/td64 result = func(index) - assert isinstance(result, np.ndarray) out = np.empty(index.shape, dtype=bool) func(index, out=out) - tm.assert_numpy_array_equal(out, result) + tm.assert_index_equal(Index(out), result) else: with tm.external_error_raised(TypeError): func(index) @@ -129,19 +128,20 @@ def test_numpy_ufuncs_other(index, func): ): # Results in bool array result = func(index) + assert isinstance(result, Index) if not isinstance(index.dtype, np.dtype): # e.g. Int64 we expect to get BooleanArray back - assert isinstance(result, BooleanArray) + assert isinstance(result.dtype, BooleanDtype) else: - assert isinstance(result, np.ndarray) + assert isinstance(result.dtype, np.dtype) out = np.empty(index.shape, dtype=bool) func(index, out=out) if not isinstance(index.dtype, np.dtype): - tm.assert_numpy_array_equal(out, result._data) + tm.assert_index_equal(result, Index(out, dtype="boolean")) else: - tm.assert_numpy_array_equal(out, result) + tm.assert_index_equal(result, Index(out)) elif len(index) == 0: pass From 2c89a91c7530fdc9526fd849f31acb5ae4727021 Mon Sep 17 00:00:00 2001 From: tisjayy <141962343+tisjayy@users.noreply.github.com> Date: Wed, 16 Jul 2025 12:29:47 -0400 Subject: [PATCH 39/51] DOC: Add Raises section to to_numeric docstring (#61868) --- pandas/core/tools/numeric.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pandas/core/tools/numeric.py b/pandas/core/tools/numeric.py index bc45343d6e2d3..c2ffe17adbac5 100644 --- a/pandas/core/tools/numeric.py +++ b/pandas/core/tools/numeric.py @@ -114,6 +114,14 @@ def to_numeric( Numeric if parsing succeeded. Return type depends on input. Series if Series, otherwise ndarray. + Raises + ------ + ValueError + If the input contains non-numeric values and `errors='raise'`. + TypeError + If the input is not list-like, 1D, or scalar convertible to numeric, + such as nested lists or unsupported input types (e.g., dict). + See Also -------- DataFrame.astype : Cast argument to a specified dtype. From 13bba346611553653131282a8a9074975b95ec08 Mon Sep 17 00:00:00 2001 From: Joris Van den Bossche Date: Wed, 16 Jul 2025 19:07:20 +0200 Subject: [PATCH 40/51] String dtype: turn on by default (#61722) --- .github/workflows/docbuild-and-upload.yml | 2 ++ .github/workflows/unit-tests.yml | 17 ++++++++--------- ci/code_checks.sh | 4 +++- pandas/core/config_init.py | 2 +- 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/.github/workflows/docbuild-and-upload.yml b/.github/workflows/docbuild-and-upload.yml index ba9e30e088c66..e982b18ec7274 100644 --- a/.github/workflows/docbuild-and-upload.yml +++ b/.github/workflows/docbuild-and-upload.yml @@ -57,6 +57,8 @@ jobs: run: python web/pandas_web.py web/pandas --target-path=web/build - name: Build documentation + # TEMP don't let errors fail the build until all string dtype changes are fixed + continue-on-error: true run: doc/make.py --warnings-are-errors - name: Build the interactive terminal diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index b501c2ea394bd..d2899e3838683 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -30,7 +30,7 @@ jobs: env_file: [actions-310.yaml, actions-311.yaml, actions-312.yaml, actions-313.yaml] # Prevent the include jobs from overriding other jobs pattern: [""] - pandas_future_infer_string: ["0"] + pandas_future_infer_string: ["1"] include: - name: "Downstream Compat" env_file: actions-311-downstream_compat.yaml @@ -45,6 +45,10 @@ jobs: env_file: actions-313-freethreading.yaml pattern: "not slow and not network and not single_cpu" platform: ubuntu-24.04 + - name: "Without PyArrow" + env_file: actions-312.yaml + pattern: "not slow and not network and not single_cpu" + platform: ubuntu-24.04 - name: "Locale: it_IT" env_file: actions-311.yaml pattern: "not slow and not network and not single_cpu" @@ -67,13 +71,9 @@ jobs: # It will be temporarily activated during tests with locale.setlocale extra_loc: "zh_CN" platform: ubuntu-24.04 - - name: "Future infer strings" + - name: "Past no infer strings" env_file: actions-312.yaml - pandas_future_infer_string: "1" - platform: ubuntu-24.04 - - name: "Future infer strings (without pyarrow)" - env_file: actions-311.yaml - pandas_future_infer_string: "1" + pandas_future_infer_string: "0" platform: ubuntu-24.04 - name: "Numpy Dev" env_file: actions-311-numpydev.yaml @@ -83,7 +83,6 @@ jobs: - name: "Pyarrow Nightly" env_file: actions-311-pyarrownightly.yaml pattern: "not slow and not network and not single_cpu" - pandas_future_infer_string: "1" platform: ubuntu-24.04 fail-fast: false name: ${{ matrix.name || format('{0} {1}', matrix.platform, matrix.env_file) }} @@ -98,7 +97,7 @@ jobs: PYTEST_TARGET: ${{ matrix.pytest_target || 'pandas' }} # Clipboard tests QT_QPA_PLATFORM: offscreen - REMOVE_PYARROW: ${{ matrix.name == 'Future infer strings (without pyarrow)' && '1' || '0' }} + REMOVE_PYARROW: ${{ matrix.name == 'Without PyArrow' && '1' || '0' }} concurrency: # https://github.community/t/concurrecy-not-work-for-push/183068/7 group: ${{ github.event_name == 'push' && github.run_number || github.ref }}-${{ matrix.env_file }}-${{ matrix.pattern }}-${{ matrix.extra_apt || '' }}-${{ matrix.pandas_future_infer_string }}-${{ matrix.platform }} diff --git a/ci/code_checks.sh b/ci/code_checks.sh index a0d23aa0478d2..a310b71d59da6 100755 --- a/ci/code_checks.sh +++ b/ci/code_checks.sh @@ -58,7 +58,9 @@ if [[ -z "$CHECK" || "$CHECK" == "doctests" ]]; then MSG='Python and Cython Doctests' ; echo "$MSG" python -c 'import pandas as pd; pd.test(run_doctests=True)' - RET=$(($RET + $?)) ; echo "$MSG" "DONE" + # TEMP don't let doctests fail the build until all string dtype changes are fixed + # RET=$(($RET + $?)) ; echo "$MSG" "DONE" + echo "$MSG" "DONE" fi diff --git a/pandas/core/config_init.py b/pandas/core/config_init.py index 20fe8cbab1c9f..bf7e8fb02b58e 100644 --- a/pandas/core/config_init.py +++ b/pandas/core/config_init.py @@ -880,7 +880,7 @@ def register_converter_cb(key: str) -> None: with cf.config_prefix("future"): cf.register_option( "infer_string", - True if os.environ.get("PANDAS_FUTURE_INFER_STRING", "0") == "1" else False, + False if os.environ.get("PANDAS_FUTURE_INFER_STRING", "1") == "0" else True, "Whether to infer sequence of str objects as pyarrow string " "dtype, which will be the default in pandas 3.0 " "(at which point this option will be deprecated).", From 598b7d19abb5b0d44dd55f8d8e71bf61aeaa533b Mon Sep 17 00:00:00 2001 From: Joris Van den Bossche Date: Wed, 16 Jul 2025 23:27:45 +0200 Subject: [PATCH 41/51] DOC: show Parquet examples with default engine (without explicit pyarrow/fastparquet engine keyword) (#61877) --- doc/source/user_guide/io.rst | 25 ++++++------------------- 1 file changed, 6 insertions(+), 19 deletions(-) diff --git a/doc/source/user_guide/io.rst b/doc/source/user_guide/io.rst index 34c469bfc535b..eb0a9c6f60efb 100644 --- a/doc/source/user_guide/io.rst +++ b/doc/source/user_guide/io.rst @@ -5280,24 +5280,21 @@ Write to a parquet file. .. ipython:: python - df.to_parquet("example_pa.parquet", engine="pyarrow") - df.to_parquet("example_fp.parquet", engine="fastparquet") + # specify engine="pyarrow" or engine="fastparquet" to use a specific engine + df.to_parquet("example.parquet") Read from a parquet file. .. ipython:: python - result = pd.read_parquet("example_fp.parquet", engine="fastparquet") - result = pd.read_parquet("example_pa.parquet", engine="pyarrow") - + result = pd.read_parquet("example.parquet") result.dtypes By setting the ``dtype_backend`` argument you can control the default dtypes used for the resulting DataFrame. .. ipython:: python - result = pd.read_parquet("example_pa.parquet", engine="pyarrow", dtype_backend="pyarrow") - + result = pd.read_parquet("example.parquet", dtype_backend="pyarrow") result.dtypes .. note:: @@ -5309,24 +5306,14 @@ Read only certain columns of a parquet file. .. ipython:: python - result = pd.read_parquet( - "example_fp.parquet", - engine="fastparquet", - columns=["a", "b"], - ) - result = pd.read_parquet( - "example_pa.parquet", - engine="pyarrow", - columns=["a", "b"], - ) + result = pd.read_parquet("example.parquet", columns=["a", "b"]) result.dtypes .. ipython:: python :suppress: - os.remove("example_pa.parquet") - os.remove("example_fp.parquet") + os.remove("example.parquet") Handling indexes From 88cb1524824d255a4d36b970993de60444b5c159 Mon Sep 17 00:00:00 2001 From: Joris Van den Bossche Date: Wed, 16 Jul 2025 23:29:00 +0200 Subject: [PATCH 42/51] DOC: update Parquet IO user guide on index handling and type support across engines (#61878) --- doc/source/user_guide/io.rst | 38 ++++++++++++++++++++---------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/doc/source/user_guide/io.rst b/doc/source/user_guide/io.rst index eb0a9c6f60efb..52038ad4b66c1 100644 --- a/doc/source/user_guide/io.rst +++ b/doc/source/user_guide/io.rst @@ -5228,33 +5228,32 @@ languages easy. Parquet can use a variety of compression techniques to shrink th while still maintaining good read performance. Parquet is designed to faithfully serialize and de-serialize ``DataFrame`` s, supporting all of the pandas -dtypes, including extension dtypes such as datetime with tz. +dtypes, including extension dtypes such as datetime with timezone. Several caveats. * Duplicate column names and non-string columns names are not supported. -* The ``pyarrow`` engine always writes the index to the output, but ``fastparquet`` only writes non-default - indexes. This extra column can cause problems for non-pandas consumers that are not expecting it. You can - force including or omitting indexes with the ``index`` argument, regardless of the underlying engine. +* The DataFrame index is written as separate column(s) when it is a non-default range index. + This extra column can cause problems for non-pandas consumers that are not expecting it. You can + force including or omitting indexes with the ``index`` argument. * Index level names, if specified, must be strings. * In the ``pyarrow`` engine, categorical dtypes for non-string types can be serialized to parquet, but will de-serialize as their primitive dtype. -* The ``pyarrow`` engine preserves the ``ordered`` flag of categorical dtypes with string types. ``fastparquet`` does not preserve the ``ordered`` flag. -* Non supported types include ``Interval`` and actual Python object types. These will raise a helpful error message - on an attempt at serialization. ``Period`` type is supported with pyarrow >= 0.16.0. +* The ``pyarrow`` engine supports the ``Period`` and ``Interval`` dtypes. ``fastparquet`` does not support those. +* Non supported types include actual Python object types. These will raise a helpful error message + on an attempt at serialization. * The ``pyarrow`` engine preserves extension data types such as the nullable integer and string data - type (requiring pyarrow >= 0.16.0, and requiring the extension type to implement the needed protocols, + type (this can also work for external extension types, requiring the extension type to implement the needed protocols, see the :ref:`extension types documentation `). You can specify an ``engine`` to direct the serialization. This can be one of ``pyarrow``, or ``fastparquet``, or ``auto``. If the engine is NOT specified, then the ``pd.options.io.parquet.engine`` option is checked; if this is also ``auto``, -then ``pyarrow`` is tried, and falling back to ``fastparquet``. +then ``pyarrow`` is used when installed, and falling back to ``fastparquet``. See the documentation for `pyarrow `__ and `fastparquet `__. .. note:: - These engines are very similar and should read/write nearly identical parquet format files. - ``pyarrow>=8.0.0`` supports timedelta data, ``fastparquet>=0.1.4`` supports timezone aware datetimes. + These engines are very similar and should read/write nearly identical parquet format files for most cases. These libraries differ by having different underlying dependencies (``fastparquet`` by using ``numba``, while ``pyarrow`` uses a c-library). .. ipython:: python @@ -5320,17 +5319,22 @@ Handling indexes '''''''''''''''' Serializing a ``DataFrame`` to parquet may include the implicit index as one or -more columns in the output file. Thus, this code: +more columns in the output file. For example, this code: .. ipython:: python - df = pd.DataFrame({"a": [1, 2], "b": [3, 4]}) + df = pd.DataFrame({"a": [1, 2], "b": [3, 4]}, index=[1, 2]) df.to_parquet("test.parquet", engine="pyarrow") -creates a parquet file with *three* columns if you use ``pyarrow`` for serialization: -``a``, ``b``, and ``__index_level_0__``. If you're using ``fastparquet``, the -index `may or may not `_ -be written to the file. +creates a parquet file with *three* columns (``a``, ``b``, and +``__index_level_0__`` when using the ``pyarrow`` engine, or ``index``, ``a``, +and ``b`` when using the ``fastparquet`` engine) because the index in this case +is not a default range index. In general, the index *may or may not* be written +to the file (see the +`preserve_index keyword for pyarrow `__ +or the +`write_index keyword for fastparquet `__ +to check the default behaviour). This unexpected extra column causes some databases like Amazon Redshift to reject the file, because that column doesn't exist in the target table. From 042ac7802855b7d33180609e7480fbfbf5d3b00f Mon Sep 17 00:00:00 2001 From: jbrockmendel Date: Wed, 16 Jul 2025 14:30:15 -0700 Subject: [PATCH 43/51] ERR: improve exception message from timedelta64-datetime64 (#61876) --- pandas/core/arrays/datetimelike.py | 11 +++++++++-- pandas/tests/arithmetic/test_datetime64.py | 19 +++++++++++-------- pandas/tests/arithmetic/test_timedelta64.py | 2 +- 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/pandas/core/arrays/datetimelike.py b/pandas/core/arrays/datetimelike.py index 9a723a88941b6..f8d4dd4c78bcb 100644 --- a/pandas/core/arrays/datetimelike.py +++ b/pandas/core/arrays/datetimelike.py @@ -1486,7 +1486,8 @@ def __rsub__(self, other): # GH#19959 datetime - datetime is well-defined as timedelta, # but any other type - datetime is not well-defined. raise TypeError( - f"cannot subtract {type(self).__name__} from {type(other).__name__}" + f"cannot subtract {type(self).__name__} from " + f"{type(other).__name__}[{other.dtype}]" ) elif isinstance(self.dtype, PeriodDtype) and lib.is_np_dtype(other_dtype, "m"): # TODO: Can we simplify/generalize these cases at all? @@ -1495,8 +1496,14 @@ def __rsub__(self, other): self = cast("TimedeltaArray", self) return (-self) + other + flipped = self - other + if flipped.dtype.kind == "M": + # GH#59571 give a more helpful exception message + raise TypeError( + f"cannot subtract {type(self).__name__} from {type(other).__name__}" + ) # We get here with e.g. datetime objects - return -(self - other) + return -flipped def __iadd__(self, other) -> Self: result = self + other diff --git a/pandas/tests/arithmetic/test_datetime64.py b/pandas/tests/arithmetic/test_datetime64.py index d439ff723b355..5d831e0ec25a1 100644 --- a/pandas/tests/arithmetic/test_datetime64.py +++ b/pandas/tests/arithmetic/test_datetime64.py @@ -955,7 +955,12 @@ def test_dt64arr_add_sub_td64ndarray(self, tz_naive_fixture, box_with_array): result = dtarr - tdarr tm.assert_equal(result, expected) - msg = "cannot subtract|(bad|unsupported) operand type for unary" + msg = "|".join( + [ + "cannot subtract DatetimeArray from ndarray", + "cannot subtract a datelike from a TimedeltaArray", + ] + ) with pytest.raises(TypeError, match=msg): tdarr - dtarr @@ -1272,7 +1277,7 @@ def test_dt64arr_series_sub_tick_DateOffset(self, box_with_array): result2 = -pd.offsets.Second(5) + ser tm.assert_equal(result2, expected) - msg = "(bad|unsupported) operand type for unary" + msg = "cannot subtract DatetimeArray from Second" with pytest.raises(TypeError, match=msg): pd.offsets.Second(5) - ser @@ -1317,9 +1322,7 @@ def test_dti_add_tick_tzaware(self, tz_aware_fixture, box_with_array): roundtrip = offset - scalar tm.assert_equal(roundtrip, dates) - msg = "|".join( - ["bad operand type for unary -", "cannot subtract DatetimeArray"] - ) + msg = "cannot subtract DatetimeArray from" with pytest.raises(TypeError, match=msg): scalar - dates @@ -1378,7 +1381,7 @@ def test_dt64arr_add_sub_relativedelta_offsets(self, box_with_array, unit): expected = DatetimeIndex([x - off for x in vec_items]).as_unit(exp_unit) expected = tm.box_expected(expected, box_with_array) tm.assert_equal(expected, vec - off) - msg = "(bad|unsupported) operand type for unary" + msg = "cannot subtract DatetimeArray from" with pytest.raises(TypeError, match=msg): off - vec @@ -1494,7 +1497,7 @@ def test_dt64arr_add_sub_DateOffsets( expected = DatetimeIndex([offset + x for x in vec_items]).as_unit(unit) expected = tm.box_expected(expected, box_with_array) tm.assert_equal(expected, offset + vec) - msg = "(bad|unsupported) operand type for unary" + msg = "cannot subtract DatetimeArray from" with pytest.raises(TypeError, match=msg): offset - vec @@ -1983,7 +1986,7 @@ def test_operators_datetimelike_with_timezones(self): result = dt1 - td1[0] exp = (dt1.dt.tz_localize(None) - td1[0]).dt.tz_localize(tz) tm.assert_series_equal(result, exp) - msg = "(bad|unsupported) operand type for unary" + msg = "cannot subtract DatetimeArray from" with pytest.raises(TypeError, match=msg): td1[0] - dt1 diff --git a/pandas/tests/arithmetic/test_timedelta64.py b/pandas/tests/arithmetic/test_timedelta64.py index 87e085fb22878..642420713aeba 100644 --- a/pandas/tests/arithmetic/test_timedelta64.py +++ b/pandas/tests/arithmetic/test_timedelta64.py @@ -320,7 +320,7 @@ def test_subtraction_ops(self): with pytest.raises(TypeError, match=msg): td - dt - msg = "(bad|unsupported) operand type for unary" + msg = "cannot subtract DatetimeArray from Timedelta" with pytest.raises(TypeError, match=msg): td - dti From 3e9237cfb92275e148a6c7af357852522f73e37f Mon Sep 17 00:00:00 2001 From: jbrockmendel Date: Wed, 16 Jul 2025 14:40:22 -0700 Subject: [PATCH 44/51] BUG: Timedelta with invalid keyword (#61883) --- doc/source/whatsnew/v3.0.0.rst | 1 + pandas/_libs/tslibs/timedeltas.pyx | 24 ++++++++++++++---------- pandas/tests/tslibs/test_timedeltas.py | 4 ++++ 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/doc/source/whatsnew/v3.0.0.rst b/doc/source/whatsnew/v3.0.0.rst index f2650a64d2c59..fdc851399c13c 100644 --- a/doc/source/whatsnew/v3.0.0.rst +++ b/doc/source/whatsnew/v3.0.0.rst @@ -717,6 +717,7 @@ Datetimelike Timedelta ^^^^^^^^^ - Accuracy improvement in :meth:`Timedelta.to_pytimedelta` to round microseconds consistently for large nanosecond based Timedelta (:issue:`57841`) +- Bug in :class:`Timedelta` constructor failing to raise when passed an invalid keyword (:issue:`53801`) - Bug in :meth:`DataFrame.cumsum` which was raising ``IndexError`` if dtype is ``timedelta64[ns]`` (:issue:`57956`) Timezones diff --git a/pandas/_libs/tslibs/timedeltas.pyx b/pandas/_libs/tslibs/timedeltas.pyx index 222a6070016e0..6c76e05471577 100644 --- a/pandas/_libs/tslibs/timedeltas.pyx +++ b/pandas/_libs/tslibs/timedeltas.pyx @@ -2006,6 +2006,20 @@ class Timedelta(_Timedelta): "milliseconds", "microseconds", "nanoseconds"} def __new__(cls, object value=_no_input, unit=None, **kwargs): + unsupported_kwargs = set(kwargs) + unsupported_kwargs.difference_update(cls._req_any_kwargs_new) + if unsupported_kwargs or ( + value is _no_input and + not cls._req_any_kwargs_new.intersection(kwargs) + ): + raise ValueError( + # GH#53801 + "cannot construct a Timedelta from the passed arguments, " + "allowed keywords are " + "[weeks, days, hours, minutes, seconds, " + "milliseconds, microseconds, nanoseconds]" + ) + if value is _no_input: if not len(kwargs): raise ValueError("cannot construct a Timedelta without a " @@ -2014,16 +2028,6 @@ class Timedelta(_Timedelta): kwargs = {key: _to_py_int_float(kwargs[key]) for key in kwargs} - unsupported_kwargs = set(kwargs) - unsupported_kwargs.difference_update(cls._req_any_kwargs_new) - if unsupported_kwargs or not cls._req_any_kwargs_new.intersection(kwargs): - raise ValueError( - "cannot construct a Timedelta from the passed arguments, " - "allowed keywords are " - "[weeks, days, hours, minutes, seconds, " - "milliseconds, microseconds, nanoseconds]" - ) - # GH43764, convert any input to nanoseconds first and then # create the timedelta. This ensures that any potential # nanosecond contributions from kwargs parsed as floats diff --git a/pandas/tests/tslibs/test_timedeltas.py b/pandas/tests/tslibs/test_timedeltas.py index 4784a6d0d600d..8e27acdd7af75 100644 --- a/pandas/tests/tslibs/test_timedeltas.py +++ b/pandas/tests/tslibs/test_timedeltas.py @@ -104,6 +104,10 @@ def test_kwarg_assertion(kwargs): with pytest.raises(ValueError, match=re.escape(err_message)): Timedelta(**kwargs) + with pytest.raises(ValueError, match=re.escape(err_message)): + # GH#53801 'unit' misspelled as 'units' + Timedelta(1, units="hours") + class TestArrayToTimedelta64: def test_array_to_timedelta64_string_with_unit_2d_raises(self): From d5eab1b0907971dbf2af97e40a0c8870be1ddc02 Mon Sep 17 00:00:00 2001 From: jbrockmendel Date: Wed, 16 Jul 2025 16:50:25 -0700 Subject: [PATCH 45/51] API: Index.__cmp__(Series) return NotImplemented (#61884) --- doc/source/whatsnew/v3.0.0.rst | 1 + pandas/core/ops/common.py | 16 +++++----------- pandas/tests/arithmetic/common.py | 9 ++------- pandas/tests/arithmetic/test_datetime64.py | 11 +++++++++-- pandas/tests/indexes/multi/test_equivalence.py | 4 ++-- pandas/tests/indexes/test_old_base.py | 4 ++-- 6 files changed, 21 insertions(+), 24 deletions(-) diff --git a/doc/source/whatsnew/v3.0.0.rst b/doc/source/whatsnew/v3.0.0.rst index fdc851399c13c..be7a07dface0a 100644 --- a/doc/source/whatsnew/v3.0.0.rst +++ b/doc/source/whatsnew/v3.0.0.rst @@ -414,6 +414,7 @@ Other API changes - Index set operations (like union or intersection) will now ignore the dtype of an empty ``RangeIndex`` or empty ``Index`` with object dtype when determining the dtype of the resulting Index (:issue:`60797`) +- Comparison operations between :class:`Index` and :class:`Series` now consistently return :class:`Series` regardless of which object is on the left or right (:issue:`36759`) - Numpy functions like ``np.isinf`` that return a bool dtype when called on a :class:`Index` object now return a bool-dtype :class:`Index` instead of ``np.ndarray`` (:issue:`52676`) .. --------------------------------------------------------------------------- diff --git a/pandas/core/ops/common.py b/pandas/core/ops/common.py index 5cbe1c421e05a..e0aa4f44fe2be 100644 --- a/pandas/core/ops/common.py +++ b/pandas/core/ops/common.py @@ -56,20 +56,14 @@ def _unpack_zerodim_and_defer(method: F, name: str) -> F: ------- method """ - stripped_name = name.removeprefix("__").removesuffix("__") - is_cmp = stripped_name in {"eq", "ne", "lt", "le", "gt", "ge"} @wraps(method) def new_method(self, other): - if is_cmp and isinstance(self, ABCIndex) and isinstance(other, ABCSeries): - # For comparison ops, Index does *not* defer to Series - pass - else: - prio = getattr(other, "__pandas_priority__", None) - if prio is not None: - if prio > self.__pandas_priority__: - # e.g. other is DataFrame while self is Index/Series/EA - return NotImplemented + prio = getattr(other, "__pandas_priority__", None) + if prio is not None: + if prio > self.__pandas_priority__: + # e.g. other is DataFrame while self is Index/Series/EA + return NotImplemented other = item_from_zerodim(other) diff --git a/pandas/tests/arithmetic/common.py b/pandas/tests/arithmetic/common.py index 0730729e2fd94..7ea9d2b0ee23a 100644 --- a/pandas/tests/arithmetic/common.py +++ b/pandas/tests/arithmetic/common.py @@ -111,24 +111,19 @@ def xbox2(x): return x.astype(bool) return x - # rev_box: box to use for reversed comparisons - rev_box = xbox - if isinstance(right, Index) and isinstance(left, Series): - rev_box = np.array - result = xbox2(left == right) expected = xbox(np.zeros(result.shape, dtype=np.bool_)) tm.assert_equal(result, expected) result = xbox2(right == left) - tm.assert_equal(result, rev_box(expected)) + tm.assert_equal(result, xbox(expected)) result = xbox2(left != right) tm.assert_equal(result, ~expected) result = xbox2(right != left) - tm.assert_equal(result, rev_box(~expected)) + tm.assert_equal(result, xbox(~expected)) msg = "|".join( [ diff --git a/pandas/tests/arithmetic/test_datetime64.py b/pandas/tests/arithmetic/test_datetime64.py index 5d831e0ec25a1..9251841bdb82f 100644 --- a/pandas/tests/arithmetic/test_datetime64.py +++ b/pandas/tests/arithmetic/test_datetime64.py @@ -770,11 +770,18 @@ def test_dti_cmp_tdi_tzawareness(self, other): result = dti == other expected = np.array([False] * 10) - tm.assert_numpy_array_equal(result, expected) + if isinstance(other, Series): + tm.assert_series_equal(result, Series(expected, index=other.index)) + else: + tm.assert_numpy_array_equal(result, expected) result = dti != other expected = np.array([True] * 10) - tm.assert_numpy_array_equal(result, expected) + if isinstance(other, Series): + tm.assert_series_equal(result, Series(expected, index=other.index)) + else: + tm.assert_numpy_array_equal(result, expected) + msg = "Invalid comparison between" with pytest.raises(TypeError, match=msg): dti < other diff --git a/pandas/tests/indexes/multi/test_equivalence.py b/pandas/tests/indexes/multi/test_equivalence.py index 9babbd5b8d56d..ca155b0e3639d 100644 --- a/pandas/tests/indexes/multi/test_equivalence.py +++ b/pandas/tests/indexes/multi/test_equivalence.py @@ -64,8 +64,8 @@ def test_equals_op(idx): with pytest.raises(ValueError, match="Lengths must match"): index_a == series_b - tm.assert_numpy_array_equal(index_a == series_a, expected1) - tm.assert_numpy_array_equal(index_a == series_c, expected2) + tm.assert_series_equal(index_a == series_a, Series(expected1)) + tm.assert_series_equal(index_a == series_c, Series(expected2)) # cases where length is 1 for one of them with pytest.raises(ValueError, match="Lengths must match"): diff --git a/pandas/tests/indexes/test_old_base.py b/pandas/tests/indexes/test_old_base.py index 5f36b8c3f5dbf..3ba19b2a4b254 100644 --- a/pandas/tests/indexes/test_old_base.py +++ b/pandas/tests/indexes/test_old_base.py @@ -560,8 +560,8 @@ def test_equals_op(self, simple_index): with pytest.raises(ValueError, match=msg): index_a == series_b - tm.assert_numpy_array_equal(index_a == series_a, expected1) - tm.assert_numpy_array_equal(index_a == series_c, expected2) + tm.assert_series_equal(index_a == series_a, Series(expected1)) + tm.assert_series_equal(index_a == series_c, Series(expected2)) # cases where length is 1 for one of them with pytest.raises(ValueError, match="Lengths must match"): From 90b1c5d54544d1d22116b60aa5273e9696c757e4 Mon Sep 17 00:00:00 2001 From: Joris Van den Bossche Date: Thu, 17 Jul 2025 10:31:07 +0200 Subject: [PATCH 46/51] DOC: make doc build run with string dtype enabled (#61864) --- .github/workflows/docbuild-and-upload.yml | 2 - doc/source/user_guide/basics.rst | 2 +- doc/source/whatsnew/v0.13.0.rst | 4 +- doc/source/whatsnew/v0.15.0.rst | 47 ++++++++++++++++++----- 4 files changed, 41 insertions(+), 14 deletions(-) diff --git a/.github/workflows/docbuild-and-upload.yml b/.github/workflows/docbuild-and-upload.yml index e982b18ec7274..ba9e30e088c66 100644 --- a/.github/workflows/docbuild-and-upload.yml +++ b/.github/workflows/docbuild-and-upload.yml @@ -57,8 +57,6 @@ jobs: run: python web/pandas_web.py web/pandas --target-path=web/build - name: Build documentation - # TEMP don't let errors fail the build until all string dtype changes are fixed - continue-on-error: true run: doc/make.py --warnings-are-errors - name: Build the interactive terminal diff --git a/doc/source/user_guide/basics.rst b/doc/source/user_guide/basics.rst index 8155aa0ae03fa..3fdd15462b51e 100644 --- a/doc/source/user_guide/basics.rst +++ b/doc/source/user_guide/basics.rst @@ -590,7 +590,7 @@ arguments. The special value ``all`` can also be used: .. ipython:: python - frame.describe(include=["object"]) + frame.describe(include=["str"]) frame.describe(include=["number"]) frame.describe(include="all") diff --git a/doc/source/whatsnew/v0.13.0.rst b/doc/source/whatsnew/v0.13.0.rst index 8e323d8aac5e3..30f7f4dc97e63 100644 --- a/doc/source/whatsnew/v0.13.0.rst +++ b/doc/source/whatsnew/v0.13.0.rst @@ -184,7 +184,7 @@ API changes .. ipython:: python :okwarning: - dfc.loc[0]['A'] = 1111 + dfc.loc[0]['B'] = 1111 :: @@ -198,7 +198,7 @@ API changes .. ipython:: python - dfc.loc[0, 'A'] = 11 + dfc.loc[0, 'B'] = 1111 dfc - ``Panel.reindex`` has the following call signature ``Panel.reindex(items=None, major_axis=None, minor_axis=None, **kwargs)`` diff --git a/doc/source/whatsnew/v0.15.0.rst b/doc/source/whatsnew/v0.15.0.rst index 1ee7c5cbc6b9e..4e745f042d5c8 100644 --- a/doc/source/whatsnew/v0.15.0.rst +++ b/doc/source/whatsnew/v0.15.0.rst @@ -1025,20 +1025,49 @@ Other: - :func:`describe` on mixed-types DataFrames is more flexible. Type-based column filtering is now possible via the ``include``/``exclude`` arguments. See the :ref:`docs ` (:issue:`8164`). - .. ipython:: python + .. code-block:: python - df = pd.DataFrame({'catA': ['foo', 'foo', 'bar'] * 8, - 'catB': ['a', 'b', 'c', 'd'] * 6, - 'numC': np.arange(24), - 'numD': np.arange(24.) + .5}) - df.describe(include=["object"]) - df.describe(include=["number", "object"], exclude=["float"]) + >>> df = pd.DataFrame({'catA': ['foo', 'foo', 'bar'] * 8, + ... 'catB': ['a', 'b', 'c', 'd'] * 6, + ... 'numC': np.arange(24), + ... 'numD': np.arange(24.) + .5}) + >>> df.describe(include=["object"]) + catA catB + count 24 24 + unique 2 4 + top foo a + freq 16 6 + >>> df.describe(include=["number", "object"], exclude=["float"]) + catA catB numC + count 24 24 24.000000 + unique 2 4 NaN + top foo a NaN + freq 16 6 NaN + mean NaN NaN 11.500000 + std NaN NaN 7.071068 + min NaN NaN 0.000000 + 25% NaN NaN 5.750000 + 50% NaN NaN 11.500000 + 75% NaN NaN 17.250000 + max NaN NaN 23.000000 Requesting all columns is possible with the shorthand 'all' - .. ipython:: python + .. code-block:: python - df.describe(include='all') + >>> df.describe(include='all') + catA catB numC numD + count 24 24 24.000000 24.000000 + unique 2 4 NaN NaN + top foo a NaN NaN + freq 16 6 NaN NaN + mean NaN NaN 11.500000 12.000000 + std NaN NaN 7.071068 7.071068 + min NaN NaN 0.000000 0.500000 + 25% NaN NaN 5.750000 6.250000 + 50% NaN NaN 11.500000 12.000000 + 75% NaN NaN 17.250000 17.750000 + max NaN NaN 23.000000 23.500000 Without those arguments, ``describe`` will behave as before, including only numerical columns or, if none are, only categorical columns. See also the :ref:`docs ` From 6537afe3701f832a0d29e1598a05c471d789f172 Mon Sep 17 00:00:00 2001 From: Joris Van den Bossche Date: Thu, 17 Jul 2025 16:59:03 +0200 Subject: [PATCH 47/51] DOC: fix doctests for string dtype changes (top-level) (#61887) --- pandas/core/arrays/categorical.py | 10 +++++----- pandas/core/dtypes/dtypes.py | 2 +- pandas/core/dtypes/missing.py | 12 ++++++------ pandas/core/frame.py | 5 ++--- pandas/core/groupby/groupby.py | 14 +++++++------- pandas/core/indexes/base.py | 4 ++-- pandas/core/interchange/from_dataframe.py | 2 +- pandas/core/reshape/concat.py | 8 ++++---- 8 files changed, 28 insertions(+), 29 deletions(-) diff --git a/pandas/core/arrays/categorical.py b/pandas/core/arrays/categorical.py index 3d2ad109a55ba..4595bc16ef336 100644 --- a/pandas/core/arrays/categorical.py +++ b/pandas/core/arrays/categorical.py @@ -794,28 +794,28 @@ def categories(self) -> Index: >>> ser = pd.Series(["a", "b", "c", "a"], dtype="category") >>> ser.cat.categories - Index(['a', 'b', 'c'], dtype='object') + Index(['a', 'b', 'c'], dtype='str') >>> raw_cat = pd.Categorical(["a", "b", "c", "a"], categories=["b", "c", "d"]) >>> ser = pd.Series(raw_cat) >>> ser.cat.categories - Index(['b', 'c', 'd'], dtype='object') + Index(['b', 'c', 'd'], dtype='str') For :class:`pandas.Categorical`: >>> cat = pd.Categorical(["a", "b"], ordered=True) >>> cat.categories - Index(['a', 'b'], dtype='object') + Index(['a', 'b'], dtype='str') For :class:`pandas.CategoricalIndex`: >>> ci = pd.CategoricalIndex(["a", "c", "b", "a", "c", "b"]) >>> ci.categories - Index(['a', 'b', 'c'], dtype='object') + Index(['a', 'b', 'c'], dtype='str') >>> ci = pd.CategoricalIndex(["a", "c"], categories=["c", "b", "a"]) >>> ci.categories - Index(['c', 'b', 'a'], dtype='object') + Index(['c', 'b', 'a'], dtype='str') """ return self.dtype.categories diff --git a/pandas/core/dtypes/dtypes.py b/pandas/core/dtypes/dtypes.py index 3986392774f28..912421dff1026 100644 --- a/pandas/core/dtypes/dtypes.py +++ b/pandas/core/dtypes/dtypes.py @@ -647,7 +647,7 @@ def categories(self) -> Index: -------- >>> cat_type = pd.CategoricalDtype(categories=["a", "b"], ordered=True) >>> cat_type.categories - Index(['a', 'b'], dtype='object') + Index(['a', 'b'], dtype='str') """ return self._categories diff --git a/pandas/core/dtypes/missing.py b/pandas/core/dtypes/missing.py index 71fe0f6e4feb0..408c2858aa876 100644 --- a/pandas/core/dtypes/missing.py +++ b/pandas/core/dtypes/missing.py @@ -158,9 +158,9 @@ def isna(obj: object) -> bool | npt.NDArray[np.bool_] | NDFrame: >>> df = pd.DataFrame([["ant", "bee", "cat"], ["dog", None, "fly"]]) >>> df - 0 1 2 - 0 ant bee cat - 1 dog None fly + 0 1 2 + 0 ant bee cat + 1 dog NaN fly >>> pd.isna(df) 0 1 2 0 False False False @@ -373,9 +373,9 @@ def notna(obj: object) -> bool | npt.NDArray[np.bool_] | NDFrame: >>> df = pd.DataFrame([["ant", "bee", "cat"], ["dog", None, "fly"]]) >>> df - 0 1 2 - 0 ant bee cat - 1 dog None fly + 0 1 2 + 0 ant bee cat + 1 dog NaN fly >>> pd.notna(df) 0 1 2 0 True True True diff --git a/pandas/core/frame.py b/pandas/core/frame.py index 632ab12edd7e4..48a5596e00061 100644 --- a/pandas/core/frame.py +++ b/pandas/core/frame.py @@ -1015,8 +1015,7 @@ def axes(self) -> list[Index]: -------- >>> df = pd.DataFrame({"col1": [1, 2], "col2": [3, 4]}) >>> df.axes - [RangeIndex(start=0, stop=2, step=1), Index(['col1', 'col2'], - dtype='object')] + [RangeIndex(start=0, stop=2, step=1), Index(['col1', 'col2'], dtype='str')] """ return [self.index, self.columns] @@ -14070,7 +14069,7 @@ def values(self) -> np.ndarray: ... columns=("name", "max_speed", "rank"), ... ) >>> df2.dtypes - name object + name str max_speed float64 rank object dtype: object diff --git a/pandas/core/groupby/groupby.py b/pandas/core/groupby/groupby.py index f29423ce5e77c..74497ca723edb 100644 --- a/pandas/core/groupby/groupby.py +++ b/pandas/core/groupby/groupby.py @@ -4628,13 +4628,13 @@ def ngroup(self, ascending: bool = True): -------- >>> df = pd.DataFrame({"color": ["red", None, "red", "blue", "blue", "red"]}) >>> df - color - 0 red - 1 None - 2 red - 3 blue - 4 blue - 5 red + color + 0 red + 1 NaN + 2 red + 3 blue + 4 blue + 5 red >>> df.groupby("color").ngroup() 0 1.0 1 NaN diff --git a/pandas/core/indexes/base.py b/pandas/core/indexes/base.py index fb395f4f7bb1a..fd3db9b9c7ec7 100644 --- a/pandas/core/indexes/base.py +++ b/pandas/core/indexes/base.py @@ -368,7 +368,7 @@ class Index(IndexOpsMixin, PandasObject): Index([1, 2, 3], dtype='int64') >>> pd.Index(list("abc")) - Index(['a', 'b', 'c'], dtype='object') + Index(['a', 'b', 'c'], dtype='str') >>> pd.Index([1, 2, 3], dtype="uint8") Index([1, 2, 3], dtype='uint8') @@ -7599,7 +7599,7 @@ def ensure_index(index_like: Axes, copy: bool = False) -> Index: Examples -------- >>> ensure_index(["a", "b"]) - Index(['a', 'b'], dtype='object') + Index(['a', 'b'], dtype='str') >>> ensure_index([("a", "a"), ("b", "c")]) Index([('a', 'a'), ('b', 'c')], dtype='object') diff --git a/pandas/core/interchange/from_dataframe.py b/pandas/core/interchange/from_dataframe.py index c2fbef1089d5a..daef8287e3263 100644 --- a/pandas/core/interchange/from_dataframe.py +++ b/pandas/core/interchange/from_dataframe.py @@ -77,7 +77,7 @@ def from_dataframe(df, allow_copy: bool = True) -> pd.DataFrame: >>> df_not_necessarily_pandas = pd.DataFrame({"A": [1, 2], "B": [3, 4]}) >>> interchange_object = df_not_necessarily_pandas.__dataframe__() >>> interchange_object.column_names() - Index(['A', 'B'], dtype='object') + Index(['A', 'B'], dtype='str') >>> df_pandas = pd.api.interchange.from_dataframe( ... interchange_object.select_columns_by_name(["A"]) ... ) diff --git a/pandas/core/reshape/concat.py b/pandas/core/reshape/concat.py index cd7cc33e9ae7f..ef7949b778ff7 100644 --- a/pandas/core/reshape/concat.py +++ b/pandas/core/reshape/concat.py @@ -258,7 +258,7 @@ def concat( 1 b 0 c 1 d - dtype: object + dtype: str Clear the existing index and reset it in the result by setting the ``ignore_index`` option to ``True``. @@ -268,7 +268,7 @@ def concat( 1 b 2 c 3 d - dtype: object + dtype: str Add a hierarchical index at the outermost level of the data with the ``keys`` option. @@ -278,7 +278,7 @@ def concat( 1 b s2 0 c 1 d - dtype: object + dtype: str Label the index keys you create with the ``names`` option. @@ -288,7 +288,7 @@ def concat( 1 b s2 0 c 1 d - dtype: object + dtype: str Combine two ``DataFrame`` objects with identical columns. From 6fca1169832380b0f4b97086bdba20ff8ecee991 Mon Sep 17 00:00:00 2001 From: jbrockmendel Date: Thu, 17 Jul 2025 12:21:20 -0700 Subject: [PATCH 48/51] BUG: disallow exotic np.datetime64 unit (#61882) --- doc/source/whatsnew/v3.0.0.rst | 1 + pandas/_libs/tslibs/conversion.pyx | 9 +++++++++ pandas/tests/scalar/timestamp/test_constructors.py | 7 +++++++ 3 files changed, 17 insertions(+) diff --git a/doc/source/whatsnew/v3.0.0.rst b/doc/source/whatsnew/v3.0.0.rst index be7a07dface0a..4b7c2c0baa52e 100644 --- a/doc/source/whatsnew/v3.0.0.rst +++ b/doc/source/whatsnew/v3.0.0.rst @@ -694,6 +694,7 @@ Datetimelike - Bug in :attr:`is_year_start` where a DateTimeIndex constructed via a date_range with frequency 'MS' wouldn't have the correct year or quarter start attributes (:issue:`57377`) - Bug in :class:`DataFrame` raising ``ValueError`` when ``dtype`` is ``timedelta64`` and ``data`` is a list containing ``None`` (:issue:`60064`) - Bug in :class:`Timestamp` constructor failing to raise when ``tz=None`` is explicitly specified in conjunction with timezone-aware ``tzinfo`` or data (:issue:`48688`) +- Bug in :class:`Timestamp` constructor failing to raise when given a ``np.datetime64`` object with non-standard unit (:issue:`25611`) - Bug in :func:`date_range` where the last valid timestamp would sometimes not be produced (:issue:`56134`) - Bug in :func:`date_range` where using a negative frequency value would not include all points between the start and end values (:issue:`56147`) - Bug in :func:`tseries.api.guess_datetime_format` would fail to infer time format when "%Y" == "%H%M" (:issue:`57452`) diff --git a/pandas/_libs/tslibs/conversion.pyx b/pandas/_libs/tslibs/conversion.pyx index 45552108f8c15..2a080bcb19ae9 100644 --- a/pandas/_libs/tslibs/conversion.pyx +++ b/pandas/_libs/tslibs/conversion.pyx @@ -5,6 +5,7 @@ import numpy as np cimport numpy as cnp from libc.math cimport log10 from numpy cimport ( + PyDatetimeScalarObject, float64_t, int32_t, int64_t, @@ -358,6 +359,7 @@ cdef _TSObject convert_to_tsobject(object ts, tzinfo tz, str unit, cdef: _TSObject obj NPY_DATETIMEUNIT reso + int64_t num obj = _TSObject() @@ -367,6 +369,13 @@ cdef _TSObject convert_to_tsobject(object ts, tzinfo tz, str unit, if checknull_with_nat_and_na(ts): obj.value = NPY_NAT elif cnp.is_datetime64_object(ts): + num = (ts).obmeta.num + if num != 1: + raise ValueError( + # GH#25611 + "np.datetime64 objects with units containing a multiplier are " + "not supported" + ) reso = get_supported_reso(get_datetime64_unit(ts)) obj.creso = reso obj.value = get_datetime64_nanos(ts, reso) diff --git a/pandas/tests/scalar/timestamp/test_constructors.py b/pandas/tests/scalar/timestamp/test_constructors.py index 2c97c4a32e0aa..09ca5a71503ad 100644 --- a/pandas/tests/scalar/timestamp/test_constructors.py +++ b/pandas/tests/scalar/timestamp/test_constructors.py @@ -478,6 +478,13 @@ def test_now_today_unit(self, method): class TestTimestampConstructors: + def test_disallow_dt64_with_weird_unit(self): + # GH#25611 + dt64 = np.datetime64(1, "500m") + msg = "np.datetime64 objects with units containing a multiplier" + with pytest.raises(ValueError, match=msg): + Timestamp(dt64) + def test_weekday_but_no_day_raises(self): # GH#52659 msg = "Parsing datetimes with weekday but no day information is not supported" From 4b18266b6d8c09d3675809fd5a8237a531ea2f5c Mon Sep 17 00:00:00 2001 From: jbrockmendel Date: Thu, 17 Jul 2025 17:53:23 -0700 Subject: [PATCH 49/51] API: IncompatibleFrequency subclass TypeError (#61875) --- ci/code_checks.sh | 1 + doc/source/reference/testing.rst | 1 + doc/source/whatsnew/v3.0.0.rst | 1 + pandas/_libs/tslibs/period.pyi | 2 +- pandas/_libs/tslibs/period.pyx | 6 +++++- pandas/core/arrays/datetimelike.py | 4 ++-- pandas/core/indexes/base.py | 3 +-- pandas/errors/__init__.py | 2 ++ pandas/tests/indexes/period/test_indexing.py | 2 +- pandas/tests/indexes/period/test_join.py | 10 ++++------ pandas/tests/indexes/period/test_period.py | 4 +++- pandas/tests/series/test_arithmetic.py | 5 ----- 12 files changed, 22 insertions(+), 19 deletions(-) diff --git a/ci/code_checks.sh b/ci/code_checks.sh index a310b71d59da6..3a941deb2c68d 100755 --- a/ci/code_checks.sh +++ b/ci/code_checks.sh @@ -74,6 +74,7 @@ if [[ -z "$CHECK" || "$CHECK" == "docstrings" ]]; then -i "pandas.Series.dt PR01" `# Accessors are implemented as classes, but we do not document the Parameters section` \ -i "pandas.Period.freq GL08" \ -i "pandas.Period.ordinal GL08" \ + -i "pandas.errors.IncompatibleFrequency SA01,SS06,EX01" \ -i "pandas.core.groupby.DataFrameGroupBy.plot PR02" \ -i "pandas.core.groupby.SeriesGroupBy.plot PR02" \ -i "pandas.core.resample.Resampler.quantile PR01,PR07" \ diff --git a/doc/source/reference/testing.rst b/doc/source/reference/testing.rst index 1f164d1aa98b4..2c9c2dcae0f69 100644 --- a/doc/source/reference/testing.rst +++ b/doc/source/reference/testing.rst @@ -36,6 +36,7 @@ Exceptions and warnings errors.DuplicateLabelError errors.EmptyDataError errors.IncompatibilityWarning + errors.IncompatibleFrequency errors.IndexingError errors.InvalidColumnName errors.InvalidComparison diff --git a/doc/source/whatsnew/v3.0.0.rst b/doc/source/whatsnew/v3.0.0.rst index 4b7c2c0baa52e..1383202154f04 100644 --- a/doc/source/whatsnew/v3.0.0.rst +++ b/doc/source/whatsnew/v3.0.0.rst @@ -414,6 +414,7 @@ Other API changes - Index set operations (like union or intersection) will now ignore the dtype of an empty ``RangeIndex`` or empty ``Index`` with object dtype when determining the dtype of the resulting Index (:issue:`60797`) +- :class:`IncompatibleFrequency` now subclasses ``TypeError`` instead of ``ValueError``. As a result, joins with mismatched frequencies now cast to object like other non-comparable joins, and arithmetic with indexes with mismatched frequencies align (:issue:`55782`) - Comparison operations between :class:`Index` and :class:`Series` now consistently return :class:`Series` regardless of which object is on the left or right (:issue:`36759`) - Numpy functions like ``np.isinf`` that return a bool dtype when called on a :class:`Index` object now return a bool-dtype :class:`Index` instead of ``np.ndarray`` (:issue:`52676`) diff --git a/pandas/_libs/tslibs/period.pyi b/pandas/_libs/tslibs/period.pyi index 22f3bdbe668de..5cb9f891b312a 100644 --- a/pandas/_libs/tslibs/period.pyi +++ b/pandas/_libs/tslibs/period.pyi @@ -15,7 +15,7 @@ from pandas._typing import ( INVALID_FREQ_ERR_MSG: str DIFFERENT_FREQ: str -class IncompatibleFrequency(ValueError): ... +class IncompatibleFrequency(TypeError): ... def periodarr_to_dt64arr( periodarr: npt.NDArray[np.int64], # const int64_t[:] diff --git a/pandas/_libs/tslibs/period.pyx b/pandas/_libs/tslibs/period.pyx index 350216cf89ce4..df5c17745b8a4 100644 --- a/pandas/_libs/tslibs/period.pyx +++ b/pandas/_libs/tslibs/period.pyx @@ -1625,7 +1625,11 @@ DIFFERENT_FREQ = ("Input has different freq={other_freq} " "from {cls}(freq={own_freq})") -class IncompatibleFrequency(ValueError): +class IncompatibleFrequency(TypeError): + """ + Raised when trying to compare or operate between Periods with different + frequencies. + """ pass diff --git a/pandas/core/arrays/datetimelike.py b/pandas/core/arrays/datetimelike.py index f8d4dd4c78bcb..50fecc96f8186 100644 --- a/pandas/core/arrays/datetimelike.py +++ b/pandas/core/arrays/datetimelike.py @@ -544,7 +544,7 @@ def _validate_comparison_value(self, other): other = self._scalar_type(other) try: self._check_compatible_with(other) - except (TypeError, IncompatibleFrequency) as err: + except TypeError as err: # e.g. tzawareness mismatch raise InvalidComparison(other) from err @@ -558,7 +558,7 @@ def _validate_comparison_value(self, other): try: other = self._validate_listlike(other, allow_object=True) self._check_compatible_with(other) - except (TypeError, IncompatibleFrequency) as err: + except TypeError as err: if is_object_dtype(getattr(other, "dtype", None)): # We will have to operate element-wise pass diff --git a/pandas/core/indexes/base.py b/pandas/core/indexes/base.py index fd3db9b9c7ec7..fef0c68d90576 100644 --- a/pandas/core/indexes/base.py +++ b/pandas/core/indexes/base.py @@ -38,7 +38,6 @@ no_default, ) from pandas._libs.tslibs import ( - IncompatibleFrequency, OutOfBoundsDatetime, Timestamp, tz_compare, @@ -3139,7 +3138,7 @@ def _union(self, other: Index, sort: bool | None): # test_union_same_value_duplicated_in_both fails) try: return self._outer_indexer(other)[0] - except (TypeError, IncompatibleFrequency): + except TypeError: # incomparable objects; should only be for object dtype value_list = list(lvals) diff --git a/pandas/errors/__init__.py b/pandas/errors/__init__.py index d1ca056ffcb19..a60a75369d0b4 100644 --- a/pandas/errors/__init__.py +++ b/pandas/errors/__init__.py @@ -9,6 +9,7 @@ from pandas._config.config import OptionError from pandas._libs.tslibs import ( + IncompatibleFrequency, OutOfBoundsDatetime, OutOfBoundsTimedelta, ) @@ -917,6 +918,7 @@ class InvalidComparison(Exception): "DuplicateLabelError", "EmptyDataError", "IncompatibilityWarning", + "IncompatibleFrequency", "IndexingError", "IntCastingNaNError", "InvalidColumnName", diff --git a/pandas/tests/indexes/period/test_indexing.py b/pandas/tests/indexes/period/test_indexing.py index 00e8262ddfa4c..75382cb735288 100644 --- a/pandas/tests/indexes/period/test_indexing.py +++ b/pandas/tests/indexes/period/test_indexing.py @@ -502,7 +502,7 @@ def test_get_indexer2(self): ) msg = "Input has different freq=None from PeriodArray\\(freq=h\\)" - with pytest.raises(ValueError, match=msg): + with pytest.raises(libperiod.IncompatibleFrequency, match=msg): idx.get_indexer(target, "nearest", tolerance="1 minute") tm.assert_numpy_array_equal( diff --git a/pandas/tests/indexes/period/test_join.py b/pandas/tests/indexes/period/test_join.py index 3e659c1a63266..9f733b358f772 100644 --- a/pandas/tests/indexes/period/test_join.py +++ b/pandas/tests/indexes/period/test_join.py @@ -1,7 +1,4 @@ import numpy as np -import pytest - -from pandas._libs.tslibs import IncompatibleFrequency from pandas import ( DataFrame, @@ -51,8 +48,9 @@ def test_join_does_not_recur(self): tm.assert_index_equal(res, expected) def test_join_mismatched_freq_raises(self): + # pre-GH#55782 this raises IncompatibleFrequency index = period_range("1/1/2000", "1/20/2000", freq="D") index3 = period_range("1/1/2000", "1/20/2000", freq="2D") - msg = r".*Input has different freq=2D from Period\(freq=D\)" - with pytest.raises(IncompatibleFrequency, match=msg): - index.join(index3) + result = index.join(index3) + expected = index.astype(object).join(index3.astype(object)) + tm.assert_index_equal(result, expected) diff --git a/pandas/tests/indexes/period/test_period.py b/pandas/tests/indexes/period/test_period.py index 77b8e76894647..d465225da7f24 100644 --- a/pandas/tests/indexes/period/test_period.py +++ b/pandas/tests/indexes/period/test_period.py @@ -1,6 +1,8 @@ import numpy as np import pytest +from pandas.errors import IncompatibleFrequency + from pandas import ( Index, NaT, @@ -198,7 +200,7 @@ def test_maybe_convert_timedelta(): offset = offsets.BusinessDay() msg = r"Input has different freq=B from PeriodIndex\(freq=D\)" - with pytest.raises(ValueError, match=msg): + with pytest.raises(IncompatibleFrequency, match=msg): pi._maybe_convert_timedelta(offset) diff --git a/pandas/tests/series/test_arithmetic.py b/pandas/tests/series/test_arithmetic.py index e7d284bd47e21..35a9742d653db 100644 --- a/pandas/tests/series/test_arithmetic.py +++ b/pandas/tests/series/test_arithmetic.py @@ -10,7 +10,6 @@ import pytest from pandas._libs import lib -from pandas._libs.tslibs import IncompatibleFrequency import pandas as pd from pandas import ( @@ -172,10 +171,6 @@ def test_add_series_with_period_index(self): result = ts + _permute(ts[::2]) tm.assert_series_equal(result, expected) - msg = "Input has different freq=D from Period\\(freq=Y-DEC\\)" - with pytest.raises(IncompatibleFrequency, match=msg): - ts + ts.asfreq("D", how="end") - @pytest.mark.parametrize( "target_add,input_value,expected_value", [ From 6a6a1bab4e0dccddf0c2e241c0add138e75d4a84 Mon Sep 17 00:00:00 2001 From: Khemkaran Sevta <168984037+khemkaran10@users.noreply.github.com> Date: Fri, 18 Jul 2025 07:48:16 +0530 Subject: [PATCH 50/51] BUG: If both index and axis are passed to DataFrame.drop, raise a clear error (#61855) Co-authored-by: Khemkaran --- pandas/core/generic.py | 2 ++ pandas/tests/frame/methods/test_drop.py | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/pandas/core/generic.py b/pandas/core/generic.py index 8708de68c0860..6424589843d76 100644 --- a/pandas/core/generic.py +++ b/pandas/core/generic.py @@ -4569,6 +4569,8 @@ def drop( axis_name = self._get_axis_name(axis) axes = {axis_name: labels} elif index is not None or columns is not None: + if axis == 1: + raise ValueError("Cannot specify both 'axis' and 'index'/'columns'") axes = {"index": index} if self.ndim == 2: axes["columns"] = columns diff --git a/pandas/tests/frame/methods/test_drop.py b/pandas/tests/frame/methods/test_drop.py index d9668ce46c943..48c5d3a2e982b 100644 --- a/pandas/tests/frame/methods/test_drop.py +++ b/pandas/tests/frame/methods/test_drop.py @@ -346,6 +346,18 @@ def test_drop_multiindex_other_level_nan(self): ) tm.assert_frame_equal(result, expected) + def test_drop_raise_with_both_axis_and_index(self): + # GH#61823 + df = DataFrame( + [[1, 2, 3], [3, 4, 5], [5, 6, 7]], + index=["a", "b", "c"], + columns=["d", "e", "f"], + ) + + msg = "Cannot specify both 'axis' and 'index'/'columns'" + with pytest.raises(ValueError, match=msg): + df.drop(index="b", axis=1) + def test_drop_nonunique(self): df = DataFrame( [ From 8de38e88fdfc85b04179b9993ef49ff21278d743 Mon Sep 17 00:00:00 2001 From: Joris Van den Bossche Date: Sat, 19 Jul 2025 12:34:27 +0200 Subject: [PATCH 51/51] BUG: fix padding for string categories in CategoricalIndex repr (#61894) --- pandas/core/indexes/base.py | 2 +- pandas/tests/indexes/categorical/test_formats.py | 9 --------- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/pandas/core/indexes/base.py b/pandas/core/indexes/base.py index fef0c68d90576..3efd601545212 100644 --- a/pandas/core/indexes/base.py +++ b/pandas/core/indexes/base.py @@ -1415,7 +1415,7 @@ def _format_data(self, name=None) -> str_t: is_justify = False elif isinstance(self.dtype, CategoricalDtype): self = cast("CategoricalIndex", self) - if is_object_dtype(self.categories.dtype): + if is_string_dtype(self.categories.dtype): is_justify = False elif isinstance(self, ABCRangeIndex): # We will do the relevant formatting via attrs diff --git a/pandas/tests/indexes/categorical/test_formats.py b/pandas/tests/indexes/categorical/test_formats.py index b100740b064ce..2308a62bc44a4 100644 --- a/pandas/tests/indexes/categorical/test_formats.py +++ b/pandas/tests/indexes/categorical/test_formats.py @@ -2,9 +2,6 @@ Tests for CategoricalIndex.__repr__ and related methods. """ -import pytest - -from pandas._config import using_string_dtype import pandas._config.config as cf from pandas import CategoricalIndex @@ -22,7 +19,6 @@ def test_string_categorical_index_repr(self, using_infer_string): ) assert repr(idx) == expected - @pytest.mark.xfail(using_string_dtype(), reason="Different padding on multi-line") def test_categorical_index_repr_multiline(self, using_infer_string): # multiple lines idx = CategoricalIndex(["a", "bb", "ccc"] * 10) @@ -37,7 +33,6 @@ def test_categorical_index_repr_multiline(self, using_infer_string): ) assert repr(idx) == expected - @pytest.mark.xfail(using_string_dtype(), reason="Different padding on multi-line") def test_categorical_index_repr_truncated(self, using_infer_string): # truncated idx = CategoricalIndex(["a", "bb", "ccc"] * 100) @@ -76,7 +71,6 @@ def test_categorical_index_repr_unicode(self, using_infer_string): ) assert repr(idx) == expected - @pytest.mark.xfail(using_string_dtype(), reason="Different padding on multi-line") def test_categorical_index_repr_unicode_multiline(self, using_infer_string): # multiple lines idx = CategoricalIndex(["あ", "いい", "ううう"] * 10) @@ -91,7 +85,6 @@ def test_categorical_index_repr_unicode_multiline(self, using_infer_string): ) assert repr(idx) == expected - @pytest.mark.xfail(using_string_dtype(), reason="Different padding on multi-line") def test_categorical_index_repr_unicode_truncated(self, using_infer_string): # truncated idx = CategoricalIndex(["あ", "いい", "ううう"] * 100) @@ -131,7 +124,6 @@ def test_categorical_index_repr_east_asian_width(self, using_infer_string): ) assert repr(idx) == expected - @pytest.mark.xfail(using_string_dtype(), reason="Different padding on multi-line") def test_categorical_index_repr_east_asian_width_multiline( self, using_infer_string ): @@ -151,7 +143,6 @@ def test_categorical_index_repr_east_asian_width_multiline( ) assert repr(idx) == expected - @pytest.mark.xfail(using_string_dtype(), reason="Different padding on multi-line") def test_categorical_index_repr_east_asian_width_truncated( self, using_infer_string ):