From 589d97d8adc5f04fd212601be2e2adad29aa264d Mon Sep 17 00:00:00 2001 From: William Ayd Date: Wed, 1 Nov 2023 16:49:52 -0400 Subject: [PATCH 1/7] fix segfault with tz_localize_to_utc (#55726) * fix segfault with tz_localize_to_utc * refactor * added TODO --- pandas/_libs/tslibs/tzconversion.pyx | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/pandas/_libs/tslibs/tzconversion.pyx b/pandas/_libs/tslibs/tzconversion.pyx index 9c8865fbdf428..e77a385113e93 100644 --- a/pandas/_libs/tslibs/tzconversion.pyx +++ b/pandas/_libs/tslibs/tzconversion.pyx @@ -332,13 +332,16 @@ timedelta-like} # Shift the delta_idx by if the UTC offset of # the target tz is greater than 0 and we're moving forward # or vice versa - first_delta = info.deltas[0] - if (shift_forward or shift_delta > 0) and first_delta > 0: - delta_idx_offset = 1 - elif (shift_backward or shift_delta < 0) and first_delta < 0: - delta_idx_offset = 1 - else: - delta_idx_offset = 0 + # TODO: delta_idx_offset and info.deltas are needed for zoneinfo timezones, + # but are not applicable for all timezones. Setting the former to 0 and + # length checking the latter avoids UB, but this could use a larger refactor + delta_idx_offset = 0 + if len(info.deltas): + first_delta = info.deltas[0] + if (shift_forward or shift_delta > 0) and first_delta > 0: + delta_idx_offset = 1 + elif (shift_backward or shift_delta < 0) and first_delta < 0: + delta_idx_offset = 1 for i in range(n): val = vals[i] From d1b2c44761e96cbab6413897119f113dc7d657b9 Mon Sep 17 00:00:00 2001 From: jbrockmendel Date: Wed, 1 Nov 2023 15:18:22 -0700 Subject: [PATCH 2/7] ENH/POC: infer resolution in array_strptime (#55778) * ENH/POC: infer resolution in array_strptime * creso_changed->creso_ever_changed --- pandas/_libs/tslibs/strptime.pxd | 3 + pandas/_libs/tslibs/strptime.pyx | 103 +++++++++++++++++++++++---- pandas/tests/tslibs/test_strptime.py | 61 ++++++++++++++++ 3 files changed, 152 insertions(+), 15 deletions(-) create mode 100644 pandas/tests/tslibs/test_strptime.py diff --git a/pandas/_libs/tslibs/strptime.pxd b/pandas/_libs/tslibs/strptime.pxd index 32a8edc9ee4a3..64db2b59dfcff 100644 --- a/pandas/_libs/tslibs/strptime.pxd +++ b/pandas/_libs/tslibs/strptime.pxd @@ -14,5 +14,8 @@ cdef class DatetimeParseState: cdef: bint found_tz bint found_naive + bint creso_ever_changed + NPY_DATETIMEUNIT creso cdef tzinfo process_datetime(self, datetime dt, tzinfo tz, bint utc_convert) + cdef bint update_creso(self, NPY_DATETIMEUNIT item_reso) noexcept diff --git a/pandas/_libs/tslibs/strptime.pyx b/pandas/_libs/tslibs/strptime.pyx index 69466511a67fd..6b744ce4c8cdb 100644 --- a/pandas/_libs/tslibs/strptime.pyx +++ b/pandas/_libs/tslibs/strptime.pyx @@ -49,6 +49,10 @@ from numpy cimport ( from pandas._libs.missing cimport checknull_with_nat_and_na from pandas._libs.tslibs.conversion cimport get_datetime64_nanos +from pandas._libs.tslibs.dtypes cimport ( + get_supported_reso, + npy_unit_to_abbrev, +) from pandas._libs.tslibs.nattype cimport ( NPY_NAT, c_nat_strings as nat_strings, @@ -57,6 +61,7 @@ from pandas._libs.tslibs.np_datetime cimport ( NPY_DATETIMEUNIT, NPY_FR_ns, check_dts_bounds, + get_datetime64_unit, import_pandas_datetime, npy_datetimestruct, npy_datetimestruct_to_datetime, @@ -232,9 +237,21 @@ cdef _get_format_regex(str fmt): cdef class DatetimeParseState: - def __cinit__(self): + def __cinit__(self, NPY_DATETIMEUNIT creso=NPY_DATETIMEUNIT.NPY_FR_ns): self.found_tz = False self.found_naive = False + self.creso = creso + self.creso_ever_changed = False + + cdef bint update_creso(self, NPY_DATETIMEUNIT item_reso) noexcept: + # Return a bool indicating whether we bumped to a higher resolution + if self.creso == NPY_DATETIMEUNIT.NPY_FR_GENERIC: + self.creso = item_reso + elif item_reso > self.creso: + self.creso = item_reso + self.creso_ever_changed = True + return True + return False cdef tzinfo process_datetime(self, datetime dt, tzinfo tz, bint utc_convert): if dt.tzinfo is not None: @@ -268,6 +285,7 @@ def array_strptime( bint exact=True, errors="raise", bint utc=False, + NPY_DATETIMEUNIT creso=NPY_FR_ns, ): """ Calculates the datetime structs represented by the passed array of strings @@ -278,6 +296,8 @@ def array_strptime( fmt : string-like regex exact : matches must be exact if True, search if False errors : string specifying error handling, {'raise', 'ignore', 'coerce'} + creso : NPY_DATETIMEUNIT, default NPY_FR_ns + Set to NPY_FR_GENERIC to infer a resolution. """ cdef: @@ -291,17 +311,22 @@ def array_strptime( bint is_coerce = errors=="coerce" tzinfo tz_out = None bint iso_format = format_is_iso(fmt) - NPY_DATETIMEUNIT out_bestunit + NPY_DATETIMEUNIT out_bestunit, item_reso int out_local = 0, out_tzoffset = 0 bint string_to_dts_succeeded = 0 - DatetimeParseState state = DatetimeParseState() + bint infer_reso = creso == NPY_DATETIMEUNIT.NPY_FR_GENERIC + DatetimeParseState state = DatetimeParseState(creso) assert is_raise or is_ignore or is_coerce _validate_fmt(fmt) format_regex, locale_time = _get_format_regex(fmt) - result = np.empty(n, dtype="M8[ns]") + if infer_reso: + abbrev = "ns" + else: + abbrev = npy_unit_to_abbrev(creso) + result = np.empty(n, dtype=f"M8[{abbrev}]") iresult = result.view("i8") result_timezone = np.empty(n, dtype="object") @@ -318,20 +343,32 @@ def array_strptime( iresult[i] = NPY_NAT continue elif PyDateTime_Check(val): + if isinstance(val, _Timestamp): + item_reso = val._creso + else: + item_reso = NPY_DATETIMEUNIT.NPY_FR_us + state.update_creso(item_reso) tz_out = state.process_datetime(val, tz_out, utc) if isinstance(val, _Timestamp): - iresult[i] = val.tz_localize(None).as_unit("ns")._value + val = (<_Timestamp>val)._as_creso(state.creso) + iresult[i] = val.tz_localize(None)._value else: - iresult[i] = pydatetime_to_dt64(val.replace(tzinfo=None), &dts) - check_dts_bounds(&dts) + iresult[i] = pydatetime_to_dt64( + val.replace(tzinfo=None), &dts, reso=state.creso + ) + check_dts_bounds(&dts, state.creso) result_timezone[i] = val.tzinfo continue elif PyDate_Check(val): - iresult[i] = pydate_to_dt64(val, &dts) - check_dts_bounds(&dts) + item_reso = NPY_DATETIMEUNIT.NPY_FR_s + state.update_creso(item_reso) + iresult[i] = pydate_to_dt64(val, &dts, reso=state.creso) + check_dts_bounds(&dts, state.creso) continue elif is_datetime64_object(val): - iresult[i] = get_datetime64_nanos(val, NPY_FR_ns) + item_reso = get_supported_reso(get_datetime64_unit(val)) + state.update_creso(item_reso) + iresult[i] = get_datetime64_nanos(val, state.creso) continue elif ( (is_integer_object(val) or is_float_object(val)) @@ -355,7 +392,9 @@ def array_strptime( if string_to_dts_succeeded: # No error reported by string_to_dts, pick back up # where we left off - value = npy_datetimestruct_to_datetime(NPY_FR_ns, &dts) + item_reso = get_supported_reso(out_bestunit) + state.update_creso(item_reso) + value = npy_datetimestruct_to_datetime(state.creso, &dts) if out_local == 1: # Store the out_tzoffset in seconds # since we store the total_seconds of @@ -368,7 +407,9 @@ def array_strptime( check_dts_bounds(&dts) continue - if parse_today_now(val, &iresult[i], utc, NPY_FR_ns): + if parse_today_now(val, &iresult[i], utc, state.creso): + item_reso = NPY_DATETIMEUNIT.NPY_FR_us + state.update_creso(item_reso) continue # Some ISO formats can't be parsed by string_to_dts @@ -380,9 +421,10 @@ def array_strptime( raise ValueError(f"Time data {val} is not ISO8601 format") tz = _parse_with_format( - val, fmt, exact, format_regex, locale_time, &dts + val, fmt, exact, format_regex, locale_time, &dts, &item_reso ) - iresult[i] = npy_datetimestruct_to_datetime(NPY_FR_ns, &dts) + state.update_creso(item_reso) + iresult[i] = npy_datetimestruct_to_datetime(state.creso, &dts) check_dts_bounds(&dts) result_timezone[i] = tz @@ -403,11 +445,34 @@ def array_strptime( raise return values, [] + if infer_reso: + if state.creso_ever_changed: + # We encountered mismatched resolutions, need to re-parse with + # the correct one. + return array_strptime( + values, + fmt=fmt, + exact=exact, + errors=errors, + utc=utc, + creso=state.creso, + ) + + # Otherwise we can use the single reso that we encountered and avoid + # a second pass. + abbrev = npy_unit_to_abbrev(state.creso) + result = iresult.base.view(f"M8[{abbrev}]") return result, result_timezone.base cdef tzinfo _parse_with_format( - str val, str fmt, bint exact, format_regex, locale_time, npy_datetimestruct* dts + str val, + str fmt, + bint exact, + format_regex, + locale_time, + npy_datetimestruct* dts, + NPY_DATETIMEUNIT* item_reso, ): # Based on https://github.com/python/cpython/blob/main/Lib/_strptime.py#L293 cdef: @@ -441,6 +506,8 @@ cdef tzinfo _parse_with_format( f"time data \"{val}\" doesn't match format \"{fmt}\"" ) + item_reso[0] = NPY_DATETIMEUNIT.NPY_FR_s + iso_year = -1 year = 1900 month = day = 1 @@ -527,6 +594,12 @@ cdef tzinfo _parse_with_format( elif parse_code == 10: # e.g. val='10:10:10.100'; fmt='%H:%M:%S.%f' s = found_dict["f"] + if len(s) <= 3: + item_reso[0] = NPY_DATETIMEUNIT.NPY_FR_ms + elif len(s) <= 6: + item_reso[0] = NPY_DATETIMEUNIT.NPY_FR_us + else: + item_reso[0] = NPY_DATETIMEUNIT.NPY_FR_ns # Pad to always return nanoseconds s += "0" * (9 - len(s)) us = long(s) diff --git a/pandas/tests/tslibs/test_strptime.py b/pandas/tests/tslibs/test_strptime.py new file mode 100644 index 0000000000000..0992eecf0eedd --- /dev/null +++ b/pandas/tests/tslibs/test_strptime.py @@ -0,0 +1,61 @@ +from datetime import ( + datetime, + timezone, +) + +import numpy as np +import pytest + +from pandas._libs.tslibs.dtypes import NpyDatetimeUnit +from pandas._libs.tslibs.strptime import array_strptime + +from pandas import Timestamp +import pandas._testing as tm + +creso_infer = NpyDatetimeUnit.NPY_FR_GENERIC.value + + +class TestArrayStrptimeResolutionInference: + @pytest.mark.parametrize("tz", [None, timezone.utc]) + def test_array_strptime_resolution_inference_homogeneous_strings(self, tz): + dt = datetime(2016, 1, 2, 3, 4, 5, 678900, tzinfo=tz) + + fmt = "%Y-%m-%d %H:%M:%S" + dtstr = dt.strftime(fmt) + arr = np.array([dtstr] * 3, dtype=object) + expected = np.array([dt.replace(tzinfo=None)] * 3, dtype="M8[s]") + + res, _ = array_strptime(arr, fmt=fmt, utc=False, creso=creso_infer) + tm.assert_numpy_array_equal(res, expected) + + fmt = "%Y-%m-%d %H:%M:%S.%f" + dtstr = dt.strftime(fmt) + arr = np.array([dtstr] * 3, dtype=object) + expected = np.array([dt.replace(tzinfo=None)] * 3, dtype="M8[us]") + + res, _ = array_strptime(arr, fmt=fmt, utc=False, creso=creso_infer) + tm.assert_numpy_array_equal(res, expected) + + fmt = "ISO8601" + res, _ = array_strptime(arr, fmt=fmt, utc=False, creso=creso_infer) + tm.assert_numpy_array_equal(res, expected) + + @pytest.mark.parametrize("tz", [None, timezone.utc]) + def test_array_strptime_resolution_mixed(self, tz): + dt = datetime(2016, 1, 2, 3, 4, 5, 678900, tzinfo=tz) + + ts = Timestamp(dt).as_unit("ns") + + arr = np.array([dt, ts], dtype=object) + expected = np.array( + [Timestamp(dt).as_unit("ns").asm8, ts.asm8], + dtype="M8[ns]", + ) + + fmt = "%Y-%m-%d %H:%M:%S" + res, _ = array_strptime(arr, fmt=fmt, utc=False, creso=creso_infer) + tm.assert_numpy_array_equal(res, expected) + + fmt = "ISO8601" + res, _ = array_strptime(arr, fmt=fmt, utc=False, creso=creso_infer) + tm.assert_numpy_array_equal(res, expected) From 2f689ff56627eabe981a14ea848a290cba5d7077 Mon Sep 17 00:00:00 2001 From: jbrockmendel Date: Wed, 1 Nov 2023 17:51:15 -0700 Subject: [PATCH 3/7] CLN: __repr__ tests (#55798) * misplaced tests * CLN: repr smoke tests * misplaced testS * parametrize smoke tests * CLN: smoke test repr * misplaced tests --- pandas/tests/frame/indexing/test_getitem.py | 7 --- pandas/tests/frame/indexing/test_indexing.py | 1 - pandas/tests/frame/indexing/test_insert.py | 2 - pandas/tests/frame/indexing/test_setitem.py | 4 -- pandas/tests/frame/methods/test_rename.py | 2 - pandas/tests/frame/test_arithmetic.py | 2 - pandas/tests/frame/test_nonunique_indexes.py | 51 +++++++------------ pandas/tests/frame/test_repr.py | 38 ++++++++++++++ .../tests/indexes/base_class/test_formats.py | 7 +++ .../indexes/categorical/test_category.py | 6 --- .../tests/indexes/datetimes/test_formats.py | 39 ++++---------- pandas/tests/indexes/datetimes/test_ops.py | 1 - pandas/tests/indexes/interval/test_formats.py | 10 +--- pandas/tests/indexes/multi/test_integrity.py | 1 - pandas/tests/indexes/period/test_formats.py | 7 +-- pandas/tests/indexes/ranges/test_range.py | 1 + .../tests/indexes/timedeltas/test_formats.py | 1 + pandas/tests/indexes/timedeltas/test_join.py | 1 - .../multiindex/test_chaining_and_caching.py | 7 ++- .../indexing/test_chaining_and_caching.py | 3 -- pandas/tests/indexing/test_iloc.py | 21 ++------ pandas/tests/indexing/test_indexing.py | 2 - pandas/tests/indexing/test_partial.py | 4 -- pandas/tests/io/formats/test_format.py | 41 +-------------- pandas/tests/io/formats/test_printing.py | 36 +++++++++---- pandas/tests/io/parser/test_parse_dates.py | 4 +- pandas/tests/reshape/test_crosstab.py | 3 -- pandas/tests/reshape/test_pivot.py | 1 - pandas/tests/series/test_constructors.py | 8 --- pandas/tests/series/test_repr.py | 23 +++------ 30 files changed, 120 insertions(+), 214 deletions(-) diff --git a/pandas/tests/frame/indexing/test_getitem.py b/pandas/tests/frame/indexing/test_getitem.py index 9d9324f557c8d..ecd8d1e988fd8 100644 --- a/pandas/tests/frame/indexing/test_getitem.py +++ b/pandas/tests/frame/indexing/test_getitem.py @@ -42,9 +42,6 @@ def test_getitem_periodindex(self): ts = df[rng[0]] tm.assert_series_equal(ts, df.iloc[:, 0]) - # GH#1211; smoketest unrelated to the rest of this test - repr(df) - ts = df["1/1/2000"] tm.assert_series_equal(ts, df.iloc[:, 0]) @@ -372,8 +369,6 @@ def test_getitem_boolean_series_with_duplicate_columns(self, df_dup_cols): result = df[df.C > 6] tm.assert_frame_equal(result, expected) - result.dtypes - str(result) def test_getitem_boolean_frame_with_duplicate_columns(self, df_dup_cols): # where @@ -388,8 +383,6 @@ def test_getitem_boolean_frame_with_duplicate_columns(self, df_dup_cols): result = df[df > 6] tm.assert_frame_equal(result, expected) - result.dtypes - str(result) def test_getitem_empty_frame_with_boolean(self): # Test for issue GH#11859 diff --git a/pandas/tests/frame/indexing/test_indexing.py b/pandas/tests/frame/indexing/test_indexing.py index ba8d713df5787..58581941509e8 100644 --- a/pandas/tests/frame/indexing/test_indexing.py +++ b/pandas/tests/frame/indexing/test_indexing.py @@ -511,7 +511,6 @@ def test_setitem_None(self, float_frame): float_frame.loc[:, None], float_frame["A"], check_names=False ) tm.assert_series_equal(float_frame[None], float_frame["A"], check_names=False) - repr(float_frame) def test_loc_setitem_boolean_mask_allfalse(self): # GH 9596 diff --git a/pandas/tests/frame/indexing/test_insert.py b/pandas/tests/frame/indexing/test_insert.py index 12229c28e0a80..7e702bdc993bd 100644 --- a/pandas/tests/frame/indexing/test_insert.py +++ b/pandas/tests/frame/indexing/test_insert.py @@ -51,14 +51,12 @@ def test_insert_column_bug_4032(self): df.insert(0, "a", [1, 2]) result = df.rename(columns={}) - str(result) expected = DataFrame([[1, 1.1], [2, 2.2]], columns=["a", "b"]) tm.assert_frame_equal(result, expected) df.insert(0, "c", [1.3, 2.3]) result = df.rename(columns={}) - str(result) expected = DataFrame([[1.3, 1, 1.1], [2.3, 2, 2.2]], columns=["c", "a", "b"]) tm.assert_frame_equal(result, expected) diff --git a/pandas/tests/frame/indexing/test_setitem.py b/pandas/tests/frame/indexing/test_setitem.py index 1317e3767efba..49dd7a3c4df9b 100644 --- a/pandas/tests/frame/indexing/test_setitem.py +++ b/pandas/tests/frame/indexing/test_setitem.py @@ -868,8 +868,6 @@ def test_setitem_with_expansion_categorical_dtype(self): # setting with a Categorical df["D"] = cat - str(df) - result = df.dtypes expected = Series( [np.dtype("int32"), CategoricalDtype(categories=labels, ordered=False)], @@ -879,8 +877,6 @@ def test_setitem_with_expansion_categorical_dtype(self): # setting with a Series df["E"] = ser - str(df) - result = df.dtypes expected = Series( [ diff --git a/pandas/tests/frame/methods/test_rename.py b/pandas/tests/frame/methods/test_rename.py index f35cb21f378bb..b965a5d973fb6 100644 --- a/pandas/tests/frame/methods/test_rename.py +++ b/pandas/tests/frame/methods/test_rename.py @@ -387,8 +387,6 @@ def test_rename_with_duplicate_columns(self): # TODO: can we construct this without merge? k = merge(df4, df5, how="inner", left_index=True, right_index=True) result = k.rename(columns={"TClose_x": "TClose", "TClose_y": "QT_Close"}) - str(result) - result.dtypes expected = DataFrame( [[0.0454, 22.02, 0.0422, 20130331, 600809, "饡驦", 30.01]], diff --git a/pandas/tests/frame/test_arithmetic.py b/pandas/tests/frame/test_arithmetic.py index b0e4bc95b3fb8..da5a2c21023b7 100644 --- a/pandas/tests/frame/test_arithmetic.py +++ b/pandas/tests/frame/test_arithmetic.py @@ -689,8 +689,6 @@ def test_arithmetic_with_duplicate_columns(self, op): df.columns = ["A", "A"] result = getattr(df, op)(df) tm.assert_frame_equal(result, expected) - str(result) - result.dtypes @pytest.mark.parametrize("level", [0, None]) def test_broadcast_multiindex(self, level): diff --git a/pandas/tests/frame/test_nonunique_indexes.py b/pandas/tests/frame/test_nonunique_indexes.py index 8f4b7d27e45f3..12aeede2560b8 100644 --- a/pandas/tests/frame/test_nonunique_indexes.py +++ b/pandas/tests/frame/test_nonunique_indexes.py @@ -10,13 +10,6 @@ import pandas._testing as tm -def check(result, expected=None): - if expected is not None: - tm.assert_frame_equal(result, expected) - result.dtypes - str(result) - - class TestDataFrameNonuniqueIndexes: def test_setattr_columns_vs_construct_with_columns(self): # assignment @@ -26,7 +19,7 @@ def test_setattr_columns_vs_construct_with_columns(self): df = DataFrame(arr, columns=["A", "A"]) df.columns = idx expected = DataFrame(arr, columns=idx) - check(df, expected) + tm.assert_frame_equal(df, expected) def test_setattr_columns_vs_construct_with_columns_datetimeindx(self): idx = date_range("20130101", periods=4, freq="QE-NOV") @@ -35,7 +28,7 @@ def test_setattr_columns_vs_construct_with_columns_datetimeindx(self): ) df.columns = idx expected = DataFrame([[1, 1, 1, 5], [1, 1, 2, 5], [2, 1, 3, 5]], columns=idx) - check(df, expected) + tm.assert_frame_equal(df, expected) def test_insert_with_duplicate_columns(self): # insert @@ -48,7 +41,7 @@ def test_insert_with_duplicate_columns(self): [[1, 1, 1, 5, "bah"], [1, 1, 2, 5, "bah"], [2, 1, 3, 5, "bah"]], columns=["foo", "bar", "foo", "hello", "string"], ) - check(df, expected) + tm.assert_frame_equal(df, expected) with pytest.raises(ValueError, match="Length of value"): df.insert(0, "AnotherColumn", range(len(df.index) - 1)) @@ -58,7 +51,7 @@ def test_insert_with_duplicate_columns(self): [[1, 1, 1, 5, "bah", 3], [1, 1, 2, 5, "bah", 3], [2, 1, 3, 5, "bah", 3]], columns=["foo", "bar", "foo", "hello", "string", "foo2"], ) - check(df, expected) + tm.assert_frame_equal(df, expected) # set (non-dup) df["foo2"] = 4 @@ -66,7 +59,7 @@ def test_insert_with_duplicate_columns(self): [[1, 1, 1, 5, "bah", 4], [1, 1, 2, 5, "bah", 4], [2, 1, 3, 5, "bah", 4]], columns=["foo", "bar", "foo", "hello", "string", "foo2"], ) - check(df, expected) + tm.assert_frame_equal(df, expected) df["foo2"] = 3 # delete (non dup) @@ -75,7 +68,7 @@ def test_insert_with_duplicate_columns(self): [[1, 1, 5, "bah", 3], [1, 2, 5, "bah", 3], [2, 3, 5, "bah", 3]], columns=["foo", "foo", "hello", "string", "foo2"], ) - check(df, expected) + tm.assert_frame_equal(df, expected) # try to delete again (its not consolidated) del df["hello"] @@ -83,7 +76,7 @@ def test_insert_with_duplicate_columns(self): [[1, 1, "bah", 3], [1, 2, "bah", 3], [2, 3, "bah", 3]], columns=["foo", "foo", "string", "foo2"], ) - check(df, expected) + tm.assert_frame_equal(df, expected) # consolidate df = df._consolidate() @@ -91,7 +84,7 @@ def test_insert_with_duplicate_columns(self): [[1, 1, "bah", 3], [1, 2, "bah", 3], [2, 3, "bah", 3]], columns=["foo", "foo", "string", "foo2"], ) - check(df, expected) + tm.assert_frame_equal(df, expected) # insert df.insert(2, "new_col", 5.0) @@ -99,7 +92,7 @@ def test_insert_with_duplicate_columns(self): [[1, 1, 5.0, "bah", 3], [1, 2, 5.0, "bah", 3], [2, 3, 5.0, "bah", 3]], columns=["foo", "foo", "new_col", "string", "foo2"], ) - check(df, expected) + tm.assert_frame_equal(df, expected) # insert a dup with pytest.raises(ValueError, match="cannot insert"): @@ -114,7 +107,7 @@ def test_insert_with_duplicate_columns(self): ], columns=["foo", "foo", "new_col", "new_col", "string", "foo2"], ) - check(df, expected) + tm.assert_frame_equal(df, expected) # delete (dup) del df["foo"] @@ -130,18 +123,17 @@ def test_dup_across_dtypes(self): [[1, 1, 1.0, 5], [1, 1, 2.0, 5], [2, 1, 3.0, 5]], columns=["foo", "bar", "foo", "hello"], ) - check(df) df["foo2"] = 7.0 expected = DataFrame( [[1, 1, 1.0, 5, 7.0], [1, 1, 2.0, 5, 7.0], [2, 1, 3.0, 5, 7.0]], columns=["foo", "bar", "foo", "hello", "foo2"], ) - check(df, expected) + tm.assert_frame_equal(df, expected) result = df["foo"] expected = DataFrame([[1, 1.0], [1, 2.0], [2, 3.0]], columns=["foo", "foo"]) - check(result, expected) + tm.assert_frame_equal(result, expected) # multiple replacements df["foo"] = "string" @@ -153,13 +145,13 @@ def test_dup_across_dtypes(self): ], columns=["foo", "bar", "foo", "hello", "foo2"], ) - check(df, expected) + tm.assert_frame_equal(df, expected) del df["foo"] expected = DataFrame( [[1, 5, 7.0], [1, 5, 7.0], [1, 5, 7.0]], columns=["bar", "hello", "foo2"] ) - check(df, expected) + tm.assert_frame_equal(df, expected) def test_column_dups_indexes(self): # check column dups with index equal and not equal to df's index @@ -176,7 +168,7 @@ def test_column_dups_indexes(self): columns=["A", "B", "A"], ) this_df["A"] = index - check(this_df, expected_df) + tm.assert_frame_equal(this_df, expected_df) def test_changing_dtypes_with_duplicate_columns(self): # multiple assignments that change dtypes @@ -188,7 +180,7 @@ def test_changing_dtypes_with_duplicate_columns(self): expected = DataFrame(1.0, index=range(5), columns=["that", "that"]) df["that"] = 1.0 - check(df, expected) + tm.assert_frame_equal(df, expected) df = DataFrame( np.random.default_rng(2).random((5, 2)), columns=["that", "that"] @@ -196,7 +188,7 @@ def test_changing_dtypes_with_duplicate_columns(self): expected = DataFrame(1, index=range(5), columns=["that", "that"]) df["that"] = 1 - check(df, expected) + tm.assert_frame_equal(df, expected) def test_dup_columns_comparisons(self): # equality @@ -231,7 +223,7 @@ def test_mixed_column_selection(self): ) expected = pd.concat([dfbool["one"], dfbool["three"], dfbool["one"]], axis=1) result = dfbool[["one", "three", "one"]] - check(result, expected) + tm.assert_frame_equal(result, expected) def test_multi_axis_dups(self): # multi-axis dups @@ -251,7 +243,7 @@ def test_multi_axis_dups(self): ) z = df[["A", "C", "A"]] result = z.loc[["a", "c", "a"]] - check(result, expected) + tm.assert_frame_equal(result, expected) def test_columns_with_dups(self): # GH 3468 related @@ -259,13 +251,11 @@ def test_columns_with_dups(self): # basic df = DataFrame([[1, 2]], columns=["a", "a"]) df.columns = ["a", "a.1"] - str(df) expected = DataFrame([[1, 2]], columns=["a", "a.1"]) tm.assert_frame_equal(df, expected) df = DataFrame([[1, 2, 3]], columns=["b", "a", "a"]) df.columns = ["b", "a", "a.1"] - str(df) expected = DataFrame([[1, 2, 3]], columns=["b", "a", "a.1"]) tm.assert_frame_equal(df, expected) @@ -273,7 +263,6 @@ def test_columns_with_dup_index(self): # with a dup index df = DataFrame([[1, 2]], columns=["a", "a"]) df.columns = ["b", "b"] - str(df) expected = DataFrame([[1, 2]], columns=["b", "b"]) tm.assert_frame_equal(df, expected) @@ -284,7 +273,6 @@ def test_multi_dtype(self): columns=["a", "a", "b", "b", "d", "c", "c"], ) df.columns = list("ABCDEFG") - str(df) expected = DataFrame( [[1, 2, 1.0, 2.0, 3.0, "foo", "bar"]], columns=list("ABCDEFG") ) @@ -293,7 +281,6 @@ def test_multi_dtype(self): def test_multi_dtype2(self): df = DataFrame([[1, 2, "foo", "bar"]], columns=["a", "a", "a", "a"]) df.columns = ["a", "a.1", "a.2", "a.3"] - str(df) expected = DataFrame([[1, 2, "foo", "bar"]], columns=["a", "a.1", "a.2", "a.3"]) tm.assert_frame_equal(df, expected) diff --git a/pandas/tests/frame/test_repr.py b/pandas/tests/frame/test_repr.py index 7c0e374c50bab..cb6713ae0f73c 100644 --- a/pandas/tests/frame/test_repr.py +++ b/pandas/tests/frame/test_repr.py @@ -10,7 +10,9 @@ from pandas import ( NA, Categorical, + CategoricalIndex, DataFrame, + IntervalIndex, MultiIndex, NaT, PeriodIndex, @@ -26,6 +28,21 @@ class TestDataFrameRepr: + def test_repr_should_return_str(self): + # https://docs.python.org/3/reference/datamodel.html#object.__repr__ + # "...The return value must be a string object." + + # (str on py2.x, str (unicode) on py3) + + data = [8, 5, 3, 5] + index1 = ["\u03c3", "\u03c4", "\u03c5", "\u03c6"] + cols = ["\u03c8"] + df = DataFrame(data, columns=cols, index=index1) + assert type(df.__repr__()) is str # noqa: E721 + + ser = df[cols[0]] + assert type(ser.__repr__()) is str # noqa: E721 + def test_repr_bytes_61_lines(self): # GH#12857 lets = list("ACDEFGHIJKLMNOP") @@ -291,6 +308,27 @@ def test_latex_repr(self): # GH 12182 assert df._repr_latex_() is None + def test_repr_with_datetimeindex(self): + df = DataFrame({"A": [1, 2, 3]}, index=date_range("2000", periods=3)) + result = repr(df) + expected = " A\n2000-01-01 1\n2000-01-02 2\n2000-01-03 3" + assert result == expected + + def test_repr_with_intervalindex(self): + # https://github.com/pandas-dev/pandas/pull/24134/files + df = DataFrame( + {"A": [1, 2, 3, 4]}, index=IntervalIndex.from_breaks([0, 1, 2, 3, 4]) + ) + result = repr(df) + expected = " A\n(0, 1] 1\n(1, 2] 2\n(2, 3] 3\n(3, 4] 4" + assert result == expected + + def test_repr_with_categorical_index(self): + df = DataFrame({"A": [1, 2, 3]}, index=CategoricalIndex(["a", "b", "c"])) + result = repr(df) + expected = " A\na 1\nb 2\nc 3" + assert result == expected + def test_repr_categorical_dates_periods(self): # normal DataFrame dt = date_range("2011-01-01 09:00", freq="h", periods=5, tz="US/Eastern") diff --git a/pandas/tests/indexes/base_class/test_formats.py b/pandas/tests/indexes/base_class/test_formats.py index 20f94010f56f8..379aea8826414 100644 --- a/pandas/tests/indexes/base_class/test_formats.py +++ b/pandas/tests/indexes/base_class/test_formats.py @@ -8,6 +8,13 @@ class TestIndexRendering: + def test_repr_is_valid_construction_code(self): + # for the case of Index, where the repr is traditional rather than + # stylized + idx = Index(["a", "b"]) + res = eval(repr(idx)) + tm.assert_index_equal(res, idx) + @pytest.mark.parametrize( "index,expected", [ diff --git a/pandas/tests/indexes/categorical/test_category.py b/pandas/tests/indexes/categorical/test_category.py index 87facbf529411..7af4f6809ec64 100644 --- a/pandas/tests/indexes/categorical/test_category.py +++ b/pandas/tests/indexes/categorical/test_category.py @@ -257,12 +257,6 @@ def test_ensure_copied_data(self): result = CategoricalIndex(index.values, copy=False) assert result._data._codes is index._data._codes - def test_frame_repr(self): - df = pd.DataFrame({"A": [1, 2, 3]}, index=CategoricalIndex(["a", "b", "c"])) - result = repr(df) - expected = " A\na 1\nb 2\nc 3" - assert result == expected - class TestCategoricalIndex2: def test_view_i8(self): diff --git a/pandas/tests/indexes/datetimes/test_formats.py b/pandas/tests/indexes/datetimes/test_formats.py index a181c38e2102a..4bf8df986801b 100644 --- a/pandas/tests/indexes/datetimes/test_formats.py +++ b/pandas/tests/indexes/datetimes/test_formats.py @@ -271,37 +271,16 @@ def test_dti_summary(self): result = idx._summary() assert result == expected - def test_dti_business_repr(self): + @pytest.mark.parametrize("tz", [None, pytz.utc, dateutil.tz.tzutc()]) + @pytest.mark.parametrize("freq", ["B", "C"]) + def test_dti_business_repr_etc_smoke(self, tz, freq): # only really care that it works - repr(pd.bdate_range(datetime(2009, 1, 1), datetime(2010, 1, 1))) - - def test_dti_business_summary(self): - rng = pd.bdate_range(datetime(2009, 1, 1), datetime(2010, 1, 1)) - rng._summary() - rng[2:2]._summary() - - def test_dti_business_summary_pytz(self): - pd.bdate_range("1/1/2005", "1/1/2009", tz=pytz.utc)._summary() - - def test_dti_business_summary_dateutil(self): - pd.bdate_range("1/1/2005", "1/1/2009", tz=dateutil.tz.tzutc())._summary() - - def test_dti_custom_business_repr(self): - # only really care that it works - repr(pd.bdate_range(datetime(2009, 1, 1), datetime(2010, 1, 1), freq="C")) - - def test_dti_custom_business_summary(self): - rng = pd.bdate_range(datetime(2009, 1, 1), datetime(2010, 1, 1), freq="C") - rng._summary() - rng[2:2]._summary() - - def test_dti_custom_business_summary_pytz(self): - pd.bdate_range("1/1/2005", "1/1/2009", freq="C", tz=pytz.utc)._summary() - - def test_dti_custom_business_summary_dateutil(self): - pd.bdate_range( - "1/1/2005", "1/1/2009", freq="C", tz=dateutil.tz.tzutc() - )._summary() + dti = pd.bdate_range( + datetime(2009, 1, 1), datetime(2010, 1, 1), tz=tz, freq=freq + ) + repr(dti) + dti._summary() + dti[2:2]._summary() class TestFormat: diff --git a/pandas/tests/indexes/datetimes/test_ops.py b/pandas/tests/indexes/datetimes/test_ops.py index 30c510864ce68..5db0aa5cf510f 100644 --- a/pandas/tests/indexes/datetimes/test_ops.py +++ b/pandas/tests/indexes/datetimes/test_ops.py @@ -37,7 +37,6 @@ def test_comparison(self, rng): def test_copy(self, rng): cp = rng.copy() - repr(cp) tm.assert_index_equal(cp, rng) def test_identical(self, rng): diff --git a/pandas/tests/indexes/interval/test_formats.py b/pandas/tests/indexes/interval/test_formats.py index d080516917892..bf335d154e186 100644 --- a/pandas/tests/indexes/interval/test_formats.py +++ b/pandas/tests/indexes/interval/test_formats.py @@ -14,15 +14,7 @@ class TestIntervalIndexRendering: - def test_frame_repr(self): - # https://github.com/pandas-dev/pandas/pull/24134/files - df = DataFrame( - {"A": [1, 2, 3, 4]}, index=IntervalIndex.from_breaks([0, 1, 2, 3, 4]) - ) - result = repr(df) - expected = " A\n(0, 1] 1\n(1, 2] 2\n(2, 3] 3\n(3, 4] 4" - assert result == expected - + # TODO: this is a test for DataFrame/Series, not IntervalIndex @pytest.mark.parametrize( "constructor,expected", [ diff --git a/pandas/tests/indexes/multi/test_integrity.py b/pandas/tests/indexes/multi/test_integrity.py index 45dd484eff4c6..26ef732635d1c 100644 --- a/pandas/tests/indexes/multi/test_integrity.py +++ b/pandas/tests/indexes/multi/test_integrity.py @@ -241,7 +241,6 @@ def test_rangeindex_fallback_coercion_bug(): ) df.index.names = ["fizz", "buzz"] - str(df) expected = pd.DataFrame( {"df2": np.arange(100), "df1": np.arange(100)}, index=MultiIndex.from_product([range(10), range(10)], names=["fizz", "buzz"]), diff --git a/pandas/tests/indexes/period/test_formats.py b/pandas/tests/indexes/period/test_formats.py index 888d814ac7eea..81c79f7d18f2f 100644 --- a/pandas/tests/indexes/period/test_formats.py +++ b/pandas/tests/indexes/period/test_formats.py @@ -65,12 +65,6 @@ def test_format_empty(self): with tm.assert_produces_warning(FutureWarning, match=msg): assert empty_idx.format(name=True) == [""] - def test_frame_repr(self): - df = pd.DataFrame({"A": [1, 2, 3]}, index=pd.date_range("2000", periods=3)) - result = repr(df) - expected = " A\n2000-01-01 1\n2000-01-02 2\n2000-01-03 3" - assert result == expected - @pytest.mark.parametrize("method", ["__repr__", "__str__"]) def test_representation(self, method): # GH#7601 @@ -118,6 +112,7 @@ def test_representation(self, method): result = getattr(idx, method)() assert result == expected + # TODO: These are Series.__repr__ tests def test_representation_to_series(self): # GH#10971 idx1 = PeriodIndex([], freq="D") diff --git a/pandas/tests/indexes/ranges/test_range.py b/pandas/tests/indexes/ranges/test_range.py index 95756b04bca69..ffb2dac840198 100644 --- a/pandas/tests/indexes/ranges/test_range.py +++ b/pandas/tests/indexes/ranges/test_range.py @@ -247,6 +247,7 @@ def test_cache(self): df = pd.DataFrame({"a": range(10)}, index=idx) + # df.__repr__ should not populate index cache str(df) assert idx._cache == {} diff --git a/pandas/tests/indexes/timedeltas/test_formats.py b/pandas/tests/indexes/timedeltas/test_formats.py index ee090bd0aaf0a..607336060cbbc 100644 --- a/pandas/tests/indexes/timedeltas/test_formats.py +++ b/pandas/tests/indexes/timedeltas/test_formats.py @@ -51,6 +51,7 @@ def test_representation(self, method): result = getattr(idx, method)() assert result == expected + # TODO: this is a Series.__repr__ test def test_representation_to_series(self): idx1 = TimedeltaIndex([], freq="D") idx2 = TimedeltaIndex(["1 days"], freq="D") diff --git a/pandas/tests/indexes/timedeltas/test_join.py b/pandas/tests/indexes/timedeltas/test_join.py index f3b12aa22bab0..89579d0c86f20 100644 --- a/pandas/tests/indexes/timedeltas/test_join.py +++ b/pandas/tests/indexes/timedeltas/test_join.py @@ -34,7 +34,6 @@ def test_does_not_convert_mixed_integer(self): r_idx_type="i", c_idx_type="td", ) - str(df) cols = df.columns.join(df.index, how="outer") joined = cols.join(df.columns) diff --git a/pandas/tests/indexing/multiindex/test_chaining_and_caching.py b/pandas/tests/indexing/multiindex/test_chaining_and_caching.py index c3a2c582854f3..2914bf4a3be05 100644 --- a/pandas/tests/indexing/multiindex/test_chaining_and_caching.py +++ b/pandas/tests/indexing/multiindex/test_chaining_and_caching.py @@ -75,10 +75,9 @@ def test_indexer_caching(): # make sure that indexers are in the _internal_names_set n = 1000001 index = MultiIndex.from_arrays([np.arange(n), np.arange(n)]) - s = Series(np.zeros(n), index=index) - str(s) + ser = Series(np.zeros(n), index=index) # setitem expected = Series(np.ones(n), index=index) - s[s == 0] = 1 - tm.assert_series_equal(s, expected) + ser[ser == 0] = 1 + tm.assert_series_equal(ser, expected) diff --git a/pandas/tests/indexing/test_chaining_and_caching.py b/pandas/tests/indexing/test_chaining_and_caching.py index 562638f7058c6..6d70a6c59aa6b 100644 --- a/pandas/tests/indexing/test_chaining_and_caching.py +++ b/pandas/tests/indexing/test_chaining_and_caching.py @@ -44,9 +44,6 @@ def test_slice_consolidate_invalidate_item_cache(self, using_copy_on_write): # caches a reference to the 'bb' series df["bb"] - # repr machinery triggers consolidation - repr(df) - # Assignment to wrong series if using_copy_on_write: with tm.raises_chained_assignment_error(): diff --git a/pandas/tests/indexing/test_iloc.py b/pandas/tests/indexing/test_iloc.py index bc7604330695f..558ad7ded5619 100644 --- a/pandas/tests/indexing/test_iloc.py +++ b/pandas/tests/indexing/test_iloc.py @@ -229,17 +229,12 @@ def test_iloc_exceeds_bounds(self): tm.assert_series_equal(result, expected) # doc example - def check(result, expected): - str(result) - result.dtypes - tm.assert_frame_equal(result, expected) - dfl = DataFrame( np.random.default_rng(2).standard_normal((5, 2)), columns=list("AB") ) - check(dfl.iloc[:, 2:3], DataFrame(index=dfl.index, columns=[])) - check(dfl.iloc[:, 1:3], dfl.iloc[:, [1]]) - check(dfl.iloc[4:6], dfl.iloc[[4]]) + tm.assert_frame_equal(dfl.iloc[:, 2:3], DataFrame(index=dfl.index, columns=[])) + tm.assert_frame_equal(dfl.iloc[:, 1:3], dfl.iloc[:, [1]]) + tm.assert_frame_equal(dfl.iloc[4:6], dfl.iloc[[4]]) msg = "positional indexers are out-of-bounds" with pytest.raises(IndexError, match=msg): @@ -644,8 +639,6 @@ def test_iloc_getitem_doc_issue(self, using_array_manager): df.describe() result = df.iloc[3:5, 0:2] - str(result) - result.dtypes expected = DataFrame(arr[3:5, 0:2], index=index[3:5], columns=columns[0:2]) tm.assert_frame_equal(result, expected) @@ -653,8 +646,6 @@ def test_iloc_getitem_doc_issue(self, using_array_manager): # for dups df.columns = list("aaaa") result = df.iloc[3:5, 0:2] - str(result) - result.dtypes expected = DataFrame(arr[3:5, 0:2], index=index[3:5], columns=list("aa")) tm.assert_frame_equal(result, expected) @@ -668,8 +659,6 @@ def test_iloc_getitem_doc_issue(self, using_array_manager): if not using_array_manager: df._mgr.blocks[0].mgr_locs result = df.iloc[1:5, 2:4] - str(result) - result.dtypes expected = DataFrame(arr[1:5, 2:4], index=index[1:5], columns=columns[2:4]) tm.assert_frame_equal(result, expected) @@ -795,8 +784,8 @@ def test_iloc_mask(self): else: accessor = df answer = str(bin(accessor[mask]["nums"].sum())) - except (ValueError, IndexingError, NotImplementedError) as e: - answer = str(e) + except (ValueError, IndexingError, NotImplementedError) as err: + answer = str(err) key = ( idx, diff --git a/pandas/tests/indexing/test_indexing.py b/pandas/tests/indexing/test_indexing.py index 54e204c43dadd..dfbf30d06e82c 100644 --- a/pandas/tests/indexing/test_indexing.py +++ b/pandas/tests/indexing/test_indexing.py @@ -250,8 +250,6 @@ def test_dups_fancy_indexing(self): def test_dups_fancy_indexing_across_dtypes(self): # across dtypes df = DataFrame([[1, 2, 1.0, 2.0, 3.0, "foo", "bar"]], columns=list("aaaaaaa")) - df.head() - str(df) result = DataFrame([[1, 2, 1.0, 2.0, 3.0, "foo", "bar"]]) result.columns = list("aaaaaaa") # GH#3468 diff --git a/pandas/tests/indexing/test_partial.py b/pandas/tests/indexing/test_partial.py index 8f499644f1013..d4004ade02318 100644 --- a/pandas/tests/indexing/test_partial.py +++ b/pandas/tests/indexing/test_partial.py @@ -147,14 +147,10 @@ def test_partial_set_empty_frame_no_index(self): df = DataFrame(columns=["A", "B"]) df[0] = Series(1, index=range(4)) - df.dtypes - str(df) tm.assert_frame_equal(df, expected) df = DataFrame(columns=["A", "B"]) df.loc[:, 0] = Series(1, index=range(4)) - df.dtypes - str(df) tm.assert_frame_equal(df, expected) def test_partial_set_empty_frame_row(self): diff --git a/pandas/tests/io/formats/test_format.py b/pandas/tests/io/formats/test_format.py index 8901eb99b7612..b3b718a81ccf5 100644 --- a/pandas/tests/io/formats/test_format.py +++ b/pandas/tests/io/formats/test_format.py @@ -226,38 +226,6 @@ def test_repr_chop_threshold_column_below(self): "3 40.0 0.000000e+00" ) - def test_repr_obeys_max_seq_limit(self): - with option_context("display.max_seq_items", 2000): - assert len(printing.pprint_thing(list(range(1000)))) > 1000 - - with option_context("display.max_seq_items", 5): - assert len(printing.pprint_thing(list(range(1000)))) < 100 - - with option_context("display.max_seq_items", 1): - assert len(printing.pprint_thing(list(range(1000)))) < 9 - - def test_repr_set(self): - assert printing.pprint_thing({1}) == "{1}" - - def test_repr_is_valid_construction_code(self): - # for the case of Index, where the repr is traditional rather than - # stylized - idx = Index(["a", "b"]) - res = eval("pd." + repr(idx)) - tm.assert_series_equal(Series(res), Series(idx)) - - def test_repr_should_return_str(self): - # https://docs.python.org/3/reference/datamodel.html#object.__repr__ - # "...The return value must be a string object." - - # (str on py2.x, str (unicode) on py3) - - data = [8, 5, 3, 5] - index1 = ["\u03c3", "\u03c4", "\u03c5", "\u03c6"] - cols = ["\u03c8"] - df = DataFrame(data, columns=cols, index=index1) - assert isinstance(df.__repr__(), str) - def test_repr_no_backslash(self): with option_context("mode.sim_interactive", True): df = DataFrame(np.random.default_rng(2).standard_normal((10, 4))) @@ -913,6 +881,7 @@ def test_truncate_with_different_dtypes(self): result = str(s) assert "object" in result + def test_truncate_with_different_dtypes2(self): # 12045 df = DataFrame({"text": ["some words"] + [None] * 9}) @@ -1401,14 +1370,6 @@ def gen_series_formatting(): class TestSeriesFormatting: - def test_repr_unicode(self): - s = Series(["\u03c3"] * 10) - repr(s) - - a = Series(["\u05d0"] * 1000) - a.name = "title1" - repr(a) - def test_freq_name_separation(self): s = Series( np.random.default_rng(2).standard_normal(10), diff --git a/pandas/tests/io/formats/test_printing.py b/pandas/tests/io/formats/test_printing.py index e2b65b1fdc40a..acf2bc72c687d 100644 --- a/pandas/tests/io/formats/test_printing.py +++ b/pandas/tests/io/formats/test_printing.py @@ -16,17 +16,31 @@ def test_adjoin(): assert adjoined == expected -def test_repr_binary_type(): - letters = string.ascii_letters - try: - raw = bytes(letters, encoding=cf.get_option("display.encoding")) - except TypeError: - raw = bytes(letters) - b = str(raw.decode("utf-8")) - res = printing.pprint_thing(b, quote_strings=True) - assert res == repr(b) - res = printing.pprint_thing(b, quote_strings=False) - assert res == b +class TestPPrintThing: + def test_repr_binary_type(self): + letters = string.ascii_letters + try: + raw = bytes(letters, encoding=cf.get_option("display.encoding")) + except TypeError: + raw = bytes(letters) + b = str(raw.decode("utf-8")) + res = printing.pprint_thing(b, quote_strings=True) + assert res == repr(b) + res = printing.pprint_thing(b, quote_strings=False) + assert res == b + + def test_repr_obeys_max_seq_limit(self): + with cf.option_context("display.max_seq_items", 2000): + assert len(printing.pprint_thing(list(range(1000)))) > 1000 + + with cf.option_context("display.max_seq_items", 5): + assert len(printing.pprint_thing(list(range(1000)))) < 100 + + with cf.option_context("display.max_seq_items", 1): + assert len(printing.pprint_thing(list(range(1000)))) < 9 + + def test_repr_set(self): + assert printing.pprint_thing({1}) == "{1}" class TestFormatBase: diff --git a/pandas/tests/io/parser/test_parse_dates.py b/pandas/tests/io/parser/test_parse_dates.py index d546d1275441d..2fd389772ca4f 100644 --- a/pandas/tests/io/parser/test_parse_dates.py +++ b/pandas/tests/io/parser/test_parse_dates.py @@ -1891,8 +1891,8 @@ def _helper_hypothesis_delimited_date(call, date_string, **kwargs): msg, result = None, None try: result = call(date_string, **kwargs) - except ValueError as er: - msg = str(er) + except ValueError as err: + msg = str(err) return msg, result diff --git a/pandas/tests/reshape/test_crosstab.py b/pandas/tests/reshape/test_crosstab.py index 2b6ebded3d325..c0e9b266b0d06 100644 --- a/pandas/tests/reshape/test_crosstab.py +++ b/pandas/tests/reshape/test_crosstab.py @@ -887,7 +887,4 @@ def test_categoricals(a_dtype, b_dtype): if not a_is_cat: expected = expected.loc[[0, 2, "All"]] expected["All"] = expected["All"].astype("int64") - repr(result) - repr(expected) - repr(expected.loc[[0, 2, "All"]]) tm.assert_frame_equal(result, expected) diff --git a/pandas/tests/reshape/test_pivot.py b/pandas/tests/reshape/test_pivot.py index 2d41b6d355ead..1f97d7cb605cf 100644 --- a/pandas/tests/reshape/test_pivot.py +++ b/pandas/tests/reshape/test_pivot.py @@ -2497,7 +2497,6 @@ def test_pivot_integer_bug(self): df = DataFrame(data=[("A", "1", "A1"), ("B", "2", "B2")]) result = df.pivot(index=1, columns=0, values=2) - repr(result) tm.assert_index_equal(result.columns, Index(["A", "B"], name=0)) def test_pivot_index_none(self): diff --git a/pandas/tests/series/test_constructors.py b/pandas/tests/series/test_constructors.py index 8d9b46508a25f..195c50969ffae 100644 --- a/pandas/tests/series/test_constructors.py +++ b/pandas/tests/series/test_constructors.py @@ -412,8 +412,6 @@ def test_constructor_categorical_with_coercion(self): s = Series(factor, name="A") assert s.dtype == "category" assert len(s) == len(factor) - str(s.values) - str(s) # in a frame df = DataFrame({"A": factor}) @@ -422,15 +420,11 @@ def test_constructor_categorical_with_coercion(self): result = df.iloc[:, 0] tm.assert_series_equal(result, s) assert len(df) == len(factor) - str(df.values) - str(df) df = DataFrame({"A": s}) result = df["A"] tm.assert_series_equal(result, s) assert len(df) == len(factor) - str(df.values) - str(df) # multiples df = DataFrame({"A": s, "B": s, "C": 1}) @@ -440,8 +434,6 @@ def test_constructor_categorical_with_coercion(self): tm.assert_series_equal(result2, s, check_names=False) assert result2.name == "B" assert len(df) == len(factor) - str(df.values) - str(df) def test_constructor_categorical_with_coercion2(self): # GH8623 diff --git a/pandas/tests/series/test_repr.py b/pandas/tests/series/test_repr.py index 86addb9dadfad..17b28b7f1ab57 100644 --- a/pandas/tests/series/test_repr.py +++ b/pandas/tests/series/test_repr.py @@ -160,11 +160,6 @@ def test_empty_int64(self, name, expected): s = Series([], dtype=np.int64, name=name) assert repr(s) == expected - def test_tidy_repr(self): - a = Series(["\u05d0"] * 1000) - a.name = "title1" - repr(a) # should not raise exception - def test_repr_bool_fails(self, capsys): s = Series( [ @@ -188,17 +183,6 @@ def test_repr_name_iterable_indexable(self): s.name = ("\u05d0",) * 2 repr(s) - def test_repr_should_return_str(self): - # https://docs.python.org/3/reference/datamodel.html#object.__repr__ - # ...The return value must be a string object. - - # (str on py2.x, str (unicode) on py3) - - data = [8, 5, 3, 5] - index1 = ["\u03c3", "\u03c4", "\u03c5", "\u03c6"] - df = Series(data, index=index1) - assert type(df.__repr__() == str) # both py2 / 3 - def test_repr_max_rows(self): # GH 6863 with option_context("display.max_rows", None): @@ -208,6 +192,13 @@ def test_unicode_string_with_unicode(self): df = Series(["\u05d0"], name="\u05d1") str(df) + ser = Series(["\u03c3"] * 10) + repr(ser) + + ser2 = Series(["\u05d0"] * 1000) + ser2.name = "title1" + repr(ser2) + def test_str_to_bytes_raises(self): # GH 26447 df = Series(["abc"], name="abc") From f3b9309b750985191a7b2fceaef449439e481655 Mon Sep 17 00:00:00 2001 From: Joris Van den Bossche Date: Wed, 1 Nov 2023 23:23:57 -0400 Subject: [PATCH 4/7] PDEP-7: Consistent copy/view semantics in pandas with Copy-on-Write (#51463) --- web/pandas/pdeps/0007-copy-on-write.md | 589 +++++++++++++++++++++++++ 1 file changed, 589 insertions(+) create mode 100644 web/pandas/pdeps/0007-copy-on-write.md diff --git a/web/pandas/pdeps/0007-copy-on-write.md b/web/pandas/pdeps/0007-copy-on-write.md new file mode 100644 index 0000000000000..e45fbaf555bc1 --- /dev/null +++ b/web/pandas/pdeps/0007-copy-on-write.md @@ -0,0 +1,589 @@ +# PDEP-7: Consistent copy/view semantics in pandas with Copy-on-Write + +- Created: July 2021 +- Status: Accepted +- Discussion: [#36195](https://github.com/pandas-dev/pandas/issues/36195) +- Author: [Joris Van den Bossche](https://github.com/jorisvandenbossche) +- Revision: 1 + +## Abstract + +Short summary of the proposal: + +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. We implement Copy-on-Write (as implementation detail). This way, we can actually use + views as much as possible under the hood, while ensuring the user API behaves as a + copy. +3. 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 . + +This addresses multiple aspects: 1) a clear and consistent user API (a clear rule: _any_ +subset or returned series/dataframe **always** behaves as a copy of the original, and +thus never modifies the original) and 2) improving performance by avoiding excessive +copies (e.g. a chained method workflow would no longer return an actual data copy at each +step). + +Because every single indexing step behaves as a copy, this also means that with this +proposal, "chained assignment" (with multiple setitem steps) will _never_ work and +the `SettingWithCopyWarning` can be removed. + +## Background + +pandas' current behavior on whether indexing returns a view or copy is confusing. Even +for experienced users, it's hard to tell whether a view or copy will be returned (see +below for a summary). We'd like to provide an API that is consistent and sensible about +returning views vs. copies. + +We also care about performance. Returning views from indexing operations is faster and +reduces memory usage. The same is true for several methods that don't modify the data +such as setting/resetting the index, renaming columns, etc. that can be used in a method +chaining workflow and currently return a new copy at each step. + +Finally, there are API / usability issues around views. It can be challenging to know +the user's intent in operations that modify a subset of a DataFrame (column and/or row +selection), like: + +```python +>>> df = pd.DataFrame({"A": [1, 2], "B": [3, 4], "C": [5, 6]}) +>>> df2 = df[["A", "B"]] +>>> df2.loc[df2["A"] > 1, "A"] = 1 +``` + +Did the user intend to modify `df` when they modified `df2` (setting aside issues with +the current implementation)? In other words, if we had a perfectly consistent world +where indexing the columns always returned views or always returned a copy, does the +code above imply that the user wants to mutate `df`? + +There are two possible behaviours the user might intend: + +1. Case 1: I know my subset might be a view of the original and I want to modify the + original as well. +2. Case 2: I just want to modify the subset without modifying the original. + +Today, pandas' inconsistency means _neither_ of these workflows is really possible. The +first is difficult, because indexing operations often (though not always) return copies, +and even when a view is returned you sometimes get a `SettingWithCopyWarning` when +mutating. The second is somewhat possible, but requires many defensive copies (to avoid +`SettingWithCopyWarning`, or to ensure that you have a copy when a view _was_ returned). + +## Proposal + +For these reasons (consistency, performance, code clarity), this PDEP proposes the +following changes: + +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. We implement Copy-on-Write. This way, we can actually use views as much as possible + under the hood, while ensuring the user API behaves as a copy. + +The intent is to capture the performance benefits of views as much as possible, while +providing consistent and clear behaviour to the user. This essentially makes returning +views an internal optimization, without the user needing to know if the specific +indexing operation would return a view or a copy. The new rule would be simple: any +series/dataframe derived from another series/dataframe, through an indexing operation or +a method, always behaves as a copy of the original series/dataframe. + +The mechanism to ensure this consistent behaviour, Copy-on-Write, would entail the +following: the setitem operation (i.e. `df[..] = ..` or `df.loc[..] = ..` or +`df.iloc[..] = ..`, or equivalent for Series) would check if the data that is being +modified is a view on another dataframe (or is being viewed by another dataframe). If it +is, then we would copy the data before mutating. + +Taking the example from above, if the user wishes to not mutate the parent, we no longer +require a defensive copy just to avoid a `SettingWithCopyWarning`. + +```python +# Case 2: The user does not want mutating df2 to mutate the parent df, via CoW +>>> df = pd.DataFrame({"A": [1, 2], "B": [3, 4], "C": [5, 6]}) +>>> df2 = df[["A", "B"]] +>>> df2.loc[df2["A"] > 1, "A"] = 1 +>>> df.iloc[1, 0] # df was not mutated +2 +``` + +On the other hand, if the user actually wants to modify the original df, they can no +longer rely on the fact that `df2` could be a view, as mutating a subset would now never +mutate the parent. The only way to modify the original df is by combining all indexing +steps in a single indexing operation on the original (no "chained" setitem): + +```python +# Case 1: user wants mutations of df2 to be reflected in df -> no longer possible +>>> df = pd.DataFrame({"A": [1, 2], "B": [3, 4], "C": [5, 6]}) +>>> df2 = df[["A", "B"]] +>>> df2.loc[df2["A"] > 1, "A"] = 1 # mutating df2 will not mutate df +>>> df.loc[df["A"] > 1, "A"] = 1 # need to directly mutate df instead +``` + +### This proposal also extends to methods + +In principle, there's nothing special about indexing when it comes to defensive copying. +_Any_ method that returns a new series/dataframe without altering existing data (rename, +set_index, assign, dropping columns, etc.) currently returns a copy by default and is a +candidate for returning a view: + +```python +>>> df2 = df.rename(columns=str.lower) +>>> df3 = df2.set_index("a") +``` + +Now, generally, pandas users won't expect `df2` or `df3` to be a view such that mutating +`df2` or `df3` would mutate `df`. Copy-on-Write allows us to also avoid +unnecessary copies in methods such as the above (or in the variant using method chaining +like `df.rename(columns=str.lower).set_index("a")`). + +### Propagating mutation forwards + +Thus far we have considered the (more common) case of taking a subset, mutating the +subset, and how that should affect the parent. What about the other direction, where the +parent is mutated? + +```python +>>> df = pd.DataFrame({"A": [1, 2], "B": [3, 4]}) +>>> df2 = df[["A"]] +>>> df.iloc[0, 0] = 10 +>>> df2.iloc[0, 0] # what is this value? +``` + +Given that `df2` is _considered_ as a copy of df under this proposal (i.e. behaves as a +copy), also mutating the parent `df` will not mutate the subset `df2`. + +### When do mutations propagate to other objects and when not? + +This proposal basically means that mutations _never_ propagate to _other_ objects (as +would happen with views). The only way to modify a DataFrame or Series is to modify the +object itself directly. + +But let's illustrate this in Python terms. Consider that we have a DataFrame `df1`, and we +assign that to another name `df2`: + +```python +>>> df1 = pd.DataFrame({"A": [1, 2], "B": [3, 4]}) +>>> df2 = df1 +``` + +Although we have now two variables (`df1` and `df2`), this assignment follows the standard +python semantics, and both names are pointing to the same object ("df1 and df2 are +_identical_"): + +```python +>>> id(df1) == id(df2) # or: df1 is df2 +True +``` + +Thus, if you modify DataFrame `df2`, this is also reflected in the other variable `df1`, and +the other way around (since it's the same object): + +```python +>>> df1.iloc[0, 0] +1 +>>> df2.iloc[0, 0] = 10 +>>> df1.iloc[0, 0] +10 +``` + +In summary, modifications are only "propagated" between _identical_ objects (not just +equal (`==`), but identical (`is`) in python terms, see +[docs](https://docs.python.org/3/reference/expressions.html#is)). Propagation is not +really the proper term, since there is only one object that was modified. + +However, when in some way creating a new object (even though it might be a DataFrame +with the same data, and thus be an "equal" DataFrame): + +```python +>>> df1 = pd.DataFrame({"A": [1, 2], "B": [3, 4]}) +>>> df2 = df1[:] # or df1.loc[...] with some indexer +``` + +Those objects are no longer identical: + +```python +>>> id(df1) == id(df2) # or df1 is df2 +False +``` + +And thus modifications to one will not propagate to the other: + +```python +>>> df1.iloc[0, 0] +1 +>>> df2.iloc[0, 0] = 10 +>>> df1.iloc[0, 0] # not changed +1 +``` + +Currently, any getitem indexing operation returns _new_ objects, and also almost all +DataFrame/Series methods return a _new_ object (except with `inplace=True` in some +cases), and thus follow the above logic of never modifying its parent/child DataFrame or +Series (using the lazy Copy-on-Write mechanism where possible). + +## Copy / view behaviour in NumPy versus pandas + +NumPy has the concept of "views" (an array that shares data with another array, viewing +the same memory, see e.g. +[this explanation](https://scipy-cookbook.readthedocs.io/items/ViewsVsCopies.html) for +more details). Typically you create views as a slice of another array. But other +indexing methods, often called "fancy indexing", do not return views but copies: using a +list of indices or a boolean mask. + +Pandas, being built on NumPy, uses those concepts, and also exposes the behaviour +consequences to its users. This basically means that pandas users, to understand the +details of how indexing works, also need to understand those view / fancy indexing +concepts of numpy. + +However, because DataFrames are not an array, the copy/view rules still differ from +NumPy's rules with current pandas. Slicing rows generally gives a view (following +NumPy), but slicing columns doesn't always give a view (this could be changed to match +NumPy however, see "Alternatives" 1b below). Fancy indexing rows (e.g. with a list of +(positional) labels) gives a copy, but fancy indexing columns _could_ give a view +(currently this gives a copy as well, but one of the "Alternatives" (1b) is to have this +always return a view). + +The proposal in this document is to decouple the pandas user-facing behaviour from those +NumPy concepts. Creating a subset of a DataFrame with a slice or with a mask would +behave in a similar way for the user (both return a new object and behave as a copy of +the original). We still use the concept of views internally in pandas to optimize the +implementation, but this becomes hidden from the user. + +## Alternatives + +The [original document](https://docs.google.com/document/d/1csGE4qigPR2vzmU2--jwURn3sK5k5eVewinxd8OUPk0/edit) and GitHub issue ([Proposal for future copy / view semantics in indexing operations - #36195](https://github.com/pandas-dev/pandas/issues/36195)) discussed several options for making the copy/view situation more consistent and clear: + +1. **Well-Defined copy/view rules:** ensure we have more consistent rules about which + operations result in a copy and which in a view, and then views result in mutating + the parent, copies not. + a. A minimal change would be to officialize the current behaviour. This comes down to + fixing some bugs and clearly documenting and testing which operations are views, + and which are copies. + b. An alternative would be to simplify the set of rules. For example: selecting + columns is always a view, subsetting rows is always a copy. Or: selecting columns + is always a view, subsetting rows as a slice is a view otherwise always a copy. + +2. **Copy-on-Write**: The setitem operation would check if it's a view on another + dataframe. If it is, then we would copy our data before mutating. (i.e. this + proposal) + +3. **Error-on-Write**: The setitem operation would check if it's a subset of another + dataframe (both view of copy). Only rather than copying in case of a view we would + raise an exception telling the user to either copy the data with + ``.copy_if_needed()`` (name TBD) or mark the frame as "a mutable view" with + ``.as_mutable_view()`` (name TBD). + +This document basically proposes an extended version of option 2 (Copy-on-Write). Some +arguments in favor of Copy-on-Write compared to the other options: + +* Copy-on-Write will improve the copy/view efficiency of _methods_ (e.g. rename, + (re)set_index, drop columns, etc. See section above). This will result in + lower memory usage and better performance. + +* This proposal can also be seen as a clear "well-defined rule". Using Copy-on-Write + under the hood is an implementation detail to delay the actual copy until it is + needed. The rule of "always copy" is the simplest "well-defined rule" we can get. + + Other "well-defined rule" ideas above would always include some specific cases (and + deviations from the NumPy rules). And even with clear rules a user still needs to know + the details of those rules to understand that `df['a'][df['b'] < 0] = 0` or + `df[df['b'] < 0]['a'] = 0` does something differently (switched order of column/row + indexing: the first mutates df (if selecting a column is a view) and the second + doesn't). While with the "always copy" rule with Copy-on-Write, neither of those + examples will work to update `df`. + +On the other hand, the proposal in this document does not give the user control over +whether a subset should be a view (when possible) that mutates the parent when being +mutated. The only way to modify the parent dataframe is with a direct indexing operation +on this dataframe itself. + +See the GitHub comment with some more detailed argumentation: +[https://github.com/pandas-dev/pandas/issues/36195#issuecomment-786654449](https://github.com/pandas-dev/pandas/issues/36195#issuecomment-786654449) + +## Disadvantages + +Other than the fact that this proposal would result in a backwards incompatible, +breaking change in behaviour (see next section), there are some other potential +disadvantages: + +* Deviation from NumPy: NumPy uses the copy and view concepts, while in this proposal + views would basically not exist anymore in pandas (for the user, at least; we would + still use it internally as an implementation detail) + * But as a counter argument: many pandas users are probably not familiar with those + concepts, and pandas already deviates from the exact rules in NumPy. +* Performance cost of indexing and methods becomes harder to predict: because the copy + of the data doesn't happen at the moment when actually creating the new object, but + can happen at a later stage when modifying either the parent or child object, it + becomes less transparent about when pandas copies data (but in general we should copy + less often). This is somewhat mitigated because Copy-on-Write will only copy the columns + that are mutated. Unrelated columns won't get copied. +* Increased memory usage for some use cases: while the majority of use cases will + see an improvement in memory usage with this proposal, there are a few use + cases where this might not be the case. Specifically in cases where pandas currently + does return a view (e.g. slicing rows) and in the case you are fine with (or don't care + about) the current behaviour of it being a view when mutating that subset (i.e. + mutating the sliced subset also mutates the parent dataframe), in such a case the + proposal would introduce a new copy compared to the current behaviour. There is a + workaround for this though: the copy is not needed if the previous object goes out + of scope, e.g. the variable is reassigned to something else. + +## Backward compatibility + +The proposal in this document is clearly a backwards incompatible change that breaks +existing behaviour. Because of the current inconsistencies and subtleties around views +vs. copies and mutation, it would be difficult to change anything without breaking +changes. The current proposal is not the proposal with the minimal changes, though. A +change like this will in any case need to be accompanied with a major version bump (for +example pandas 3.0). + +Doing a traditional deprecation cycle that lives in several minor feature releases will +be too noisy. Indexing is too common an operation to include a warning (even if we limit +it to just those operations that previously returned views). However, this proposal is +already implemented and thus available. Users can opt-in and test their code (this is +possible starting with version 1.5 with `pd.options.mode.copy_on_write = True`). + +Further we will add a warning mode for pandas 2.2 that raises warnings for all cases that +will change behaviour under the Copy-on-Write proposal. We can +provide a clearly documented upgrade path to first enable the warnings, fix all +warnings, and then enable the Copy-on-Write mode and ensure your code is still working, +and then finally upgrade to the new major release. + +## Implementation + +The implementation is available since pandas 1.5 (and significantly improved starting +with pandas 2.0). It uses weakrefs to keep track of whether the +data of a Dataframe/Series are viewing the data of another (pandas) object or are being +viewed by another object. This way, whenever the series/dataframe gets modified, we can +check if its data first needs to be copied before mutating it +(see [here](https://pandas.pydata.org/docs/development/copy_on_write.html)). + +To test the implementation and experiment with the new behaviour, you can +enable it with the following option: + +```python +>>> pd.options.mode.copy_on_write = True +``` + +after importing pandas (or setting the `PANDAS_COPY_ON_WRITE=1` environment variable +before importing pandas). + +## Concrete examples + +### Chained assignment + +Consider a "classic" case of chained indexing, which was the original motivation for the SettingWithCopy warning: + +```python +>>> df[df['B'] > 3]['B'] = 10 +``` + +That is roughly equivalent to + +```python +>>> df2 = df[df['B'] > 3] # Copy under NumPy's rules +>>> df2['B'] = 10 # Update (the copy) df2, df not changed +>>> del df2 # All references to df2 are lost, goes out of scope +``` + +And so `df` is not modified. For this reason, the SettingWithCopyWarning was introduced. + +_With this proposal_, any result of an indexing operation behaves as a copy +(Copy-on-Write), and thus chained assignment will _never_ work. Given that there is then +no ambiguity, the idea is to drop the warning. + +The above example is a case where chained assignment doesn't work with current pandas. +But there are of course also patterns with chained assignment that currently _do_ work +and are used. _With this proposal_, any chained assignment will not work, and so those +cases will stop working (e.g. the case above but switching the order): + +```python +>>> df['B'][df['B'] > 3] = 10 +# or +>>> df['B'][0:5] = 10 +``` + +These cases will raise a warning ``ChainedAssignmentError``, because they can never +accomplish what the user intended. There will be false-positive cases when these +operations are triggered from Cython, because Cython uses a different reference counting +mechanism. These cases should be rare, since calling pandas code from Cython does not +have any performance benefits. + +### Filtered dataframe + +A typical example where the current SettingWithCopyWarning becomes annoying is when +filtering a DataFrame (which always already returns a copy): + +```python +>>> df = pd.DataFrame({"A": [1, 2], "B": [3, 4], "C": [5, 6]}) +>>> df_filtered = df[df["A"] > 1] +>>> df_filtered["new_column"] = 1 +SettingWithCopyWarning: +A value is trying to be set on a copy of a slice from a DataFrame. +Try using .loc[row_indexer,col_indexer] = value instead +``` + +If you then modify your filtered dataframe (e.g. adding a column), you get the +unnecessary SettingWithCopyWarning (with confusing message). The only way to get rid of +the warning is by doing a defensive copy (`df_filtered = df[df["A"] > 1].copy()`, which +results in copying the data twice in the current implementation, Copy-on-Write would +not require ``.copy()`` anymore). + +_With this proposal_, the filtered dataframe is never a view and the above +workflow would work as expected without warning (and thus without needing the extra +copy). + +### Modifying a Series (from DataFrame column) + +_Currently_, accessing a column of a DataFrame as a Series is one of the few cases that +is actually guaranteed to always be a view: + +```python +>>> df = pd.DataFrame({"A": [1, 2], "B": [3, 4], "C": [5, 6]}) +>>> s = df["A"] +>>> s.loc[0] = 0 # will also modify df (but no longer with this proposal) +``` + +_With this proposal_, any indexing operation results in a copy, so also accessing a +column as a Series (in practice, it will still be a view of course, but behave as a copy +through Copy-on-Write). In the above example, mutating `s` will no longer modify the +parent `df`. + +This situation is similar as the "chained assignment" case above, except with +an explicit intermediate variable. To actually change the original DataFrame, +the solution is the same: mutate directly the DataFrame in a single step. +For example: + +```python +>>> df.loc[0, "A"] = 0 +``` + +### "Shallow" copies + +_Currently_, it is possible to create a "shallow" copy of a DataFrame with +`copy(deep=False)`. This creates a new DataFrame object but without copying the +underlying index and data. Any changes to the data of the original will be reflected in +the shallow copy (and vice versa). See the +[docs](https://pandas.pydata.org/pandas-docs/version/1.5/reference/api/pandas.DataFrame.copy.html). + +```python +>>> df = pd.DataFrame({"A": [1, 2], "B": [3, 4], "C": [5, 6]}) +>>> df2 = df.copy(deep=False) +>>> df2.iloc[0, 0] = 0 # will also modify df (but no longer with this proposal) +``` + +_With this proposal_, this kind of shallow copy is no longer possible. Only "identical" +objects (in Python terms: `df2 is df`) can share data without triggering Copy-on-Write. +A shallow copy will rather become a "delayed" copy through Copy-on-Write. + +See +[#36195 (comment)](https://github.com/pandas-dev/pandas/issues/36195#issuecomment-830579242) +for a more detailed comment on this. + +### Methods returning a new DataFrame with the same data + +This example is already shown above as well, but so _currently_ almost all methods on a +Series/DataFrame by default return a new object that is a copy of the original data: + +```python +>>> df2 = df.rename(columns=str.lower) +>>> df3 = df2.set_index("a") +``` + +In the above example, df2 holds a copy of the data of df, and df3 holds a copy of the +data of df2. Mutating any of those DataFrames would not modify the parent dataframe. + +_With this proposal_, those methods would continue to return new objects, but would use +the shallow copy mechanism with Copy-on-Write so that in practice, those methods don't +need to copy the data at each step, while preserving the current behaviour. + +### Series and DataFrame constructors + +_Currently_, the Series and DataFrame constructors don't always copy the input +(depending on the type of the input). For example: + +```python +>>> s = pd.Series([1, 2, 3]) +>>> s2 = pd.Series(s) +>>> s2.iloc[0] = 0 # will also modify the parent Series s +>>> s +0 0 # <-- modified +1 2 +2 3 +dtype: int64 +``` + +_With this proposal_, we can also use the shallow copy with Copy-on-Write approach _by +default_ in the constructors. This would mean that by default, a new Series or DataFrame +(like `s2` in the above example) would not modify the data from which it is being +constructed (when being modified itself), honoring the proposed rules. + +## More background: Current behaviour of views vs copy + +To the best of our knowledge, indexing operations currently return views in the +following cases: + +* Selecting a single column (as a Series) out of a DataFrame is always a view + (``df['a']``) +* Slicing columns from a DataFrame creating a subset DataFrame (``df[['a':'b']]`` or + ``df.loc[:, 'a': 'b']``) is a view _if_ the the original DataFrame consists of a + single block (single dtype, consolidated) and _if_ you are slicing (so not a list + selection). In all other cases, getting a subset is always a copy. +* Selecting rows _can_ return a view, when the row indexer is a `slice` object. + +Remaining operations (subsetting rows with a list indexer or boolean mask) in practice +return a copy, and we will raise a SettingWithCopyWarning when the user tries to modify +the subset. + +## More background: Previous attempts + +We've discussed this general issue before. [https://github.com/pandas-dev/pandas/issues/10954](https://github.com/pandas-dev/pandas/issues/10954) and a few pull requests ([https://github.com/pandas-dev/pandas/pull/12036](https://github.com/pandas-dev/pandas/pull/12036), [https://github.com/pandas-dev/pandas/pull/11207](https://github.com/pandas-dev/pandas/pull/11207), [https://github.com/pandas-dev/pandas/pull/11500](https://github.com/pandas-dev/pandas/pull/11500)). + +## Comparison with other languages / libraries + +### R + +For the user, R has somewhat similar behaviour. Most R objects can be considered +immutable, through "copy-on-modify" +([https://adv-r.hadley.nz/names-values.html#copy-on-modify](https://adv-r.hadley.nz/names-values.html#copy-on-modify)). +But in contrast to Python, in R this is a language feature, and any assignment (binding +a variable to a new name) or passing as function argument will essentially create a +"copy" (when mutating such an object, at that point the actual data get copied and +rebind to the name): + +```r +x <- c(1, 2, 3) +y <- x +y[[1]] <- 10 # does not modify x +``` + +While if you would do the above example in Python with a list, x and y are "identical" +and mutating one will also mutate the other. + +As a consequence of this language behaviour, modifying a `data.frame` will not modify +other data.frames that might share memory (before being copied with "copy-on-modify"). + +### Polars + +Polars ([https://github.com/pola-rs/polars](https://github.com/pola-rs/polars)) is a +DataFrame library with a Python interface, mainly written in Rust on top of Arrow. It +explicitly +[mentions](https://pola-rs.github.io/polars-book/user-guide/introduction.html#current-status) +"Copy-on-Write" semantics as one its features. + +Based on some experiments, the user-facing behaviour of Polars seems similar to the behaviour +described in this proposal (mutating a DataFrame/Series never mutates a parent/child +object, and so chained assignment also doesn't work) + + +## PDEP-7 History + +- July 2021: Initial version +- February 2023: Converted into a PDEP + +Note: this proposal has been discussed before it was turned into a PDEP. The main +discussion happened in [GH-36195](https://github.com/pandas-dev/pandas/issues/36195). +This document is modified from the original document discussing different options for +clear copy/view semantics started by Tom Augspurger +([google doc](https://docs.google.com/document/d/1csGE4qigPR2vzmU2--jwURn3sK5k5eVewinxd8OUPk0/edit)). + +Related mailing list discussion: [https://mail.python.org/pipermail/pandas-dev/2021-July/001358.html](https://t.co/vT5dOMhNjV?amp=1) From ba4322431d1261fa7f4203aafad74621d6f8bc72 Mon Sep 17 00:00:00 2001 From: jbrockmendel Date: Wed, 1 Nov 2023 22:06:09 -0700 Subject: [PATCH 5/7] BUG: to_datetime with mixed-string-and-numeric (#55780) * BUG: to_datetime with mixed-string-and-numeric * GH ref * update astype test --- doc/source/whatsnew/v2.2.0.rst | 2 ++ pandas/_libs/tslib.pyx | 12 +++++------ pandas/core/arrays/datetimelike.py | 4 +++- pandas/tests/dtypes/test_missing.py | 20 +++++++++++++------ pandas/tests/frame/test_constructors.py | 4 ++-- .../indexes/datetimes/test_constructors.py | 16 +++++++++++++-- pandas/tests/series/methods/test_astype.py | 7 +++++-- pandas/tests/tools/test_to_datetime.py | 14 +++++++++++++ 8 files changed, 60 insertions(+), 19 deletions(-) diff --git a/doc/source/whatsnew/v2.2.0.rst b/doc/source/whatsnew/v2.2.0.rst index 16d279bb0d52c..7e6d31fde389d 100644 --- a/doc/source/whatsnew/v2.2.0.rst +++ b/doc/source/whatsnew/v2.2.0.rst @@ -321,7 +321,9 @@ Categorical Datetimelike ^^^^^^^^^^^^ +- Bug in :class:`DatetimeIndex` when passing an object-dtype ndarray of float objects and a ``tz`` incorrectly localizing the result (:issue:`55780`) - Bug in :func:`concat` raising ``AttributeError`` when concatenating all-NA DataFrame with :class:`DatetimeTZDtype` dtype DataFrame. (:issue:`52093`) +- Bug in :func:`to_datetime` and :class:`DatetimeIndex` when passing a list of mixed-string-and-numeric types incorrectly raising (:issue:`55780`) - Bug in :meth:`DatetimeIndex.union` returning object dtype for tz-aware indexes with the same timezone but different units (:issue:`55238`) - Bug in :meth:`Index.is_monotonic_increasing` and :meth:`Index.is_monotonic_decreasing` always caching :meth:`Index.is_unique` as ``True`` when first value in index is ``NaT`` (:issue:`55755`) - Bug in :meth:`Index.view` to a datetime64 dtype with non-supported resolution incorrectly raising (:issue:`55710`) diff --git a/pandas/_libs/tslib.pyx b/pandas/_libs/tslib.pyx index 94a984c9db594..3c694ab26d912 100644 --- a/pandas/_libs/tslib.pyx +++ b/pandas/_libs/tslib.pyx @@ -700,15 +700,15 @@ def array_to_datetime_with_tz(ndarray values, tzinfo tz, NPY_DATETIMEUNIT creso) ival = NPY_NAT else: - ts = Timestamp(item) + if PyDateTime_Check(item) and item.tzinfo is not None: + # We can't call Timestamp constructor with a tz arg, have to + # do 2-step + ts = Timestamp(item).tz_convert(tz) + else: + ts = Timestamp(item, tz=tz) if ts is NaT: ival = NPY_NAT else: - if ts.tzinfo is not None: - ts = ts.tz_convert(tz) - else: - # datetime64, tznaive pydatetime, int, float - ts = ts.tz_localize(tz) ts = (<_Timestamp>ts)._as_creso(creso) ival = ts._value diff --git a/pandas/core/arrays/datetimelike.py b/pandas/core/arrays/datetimelike.py index 8091543df8e79..33b2f65340a3b 100644 --- a/pandas/core/arrays/datetimelike.py +++ b/pandas/core/arrays/datetimelike.py @@ -81,6 +81,7 @@ ) from pandas.util._exceptions import find_stack_level +from pandas.core.dtypes.cast import construct_1d_object_array_from_listlike from pandas.core.dtypes.common import ( is_all_strings, is_integer_dtype, @@ -2358,7 +2359,8 @@ def ensure_arraylike_for_datetimelike(data, copy: bool, cls_name: str): if not isinstance(data, (list, tuple)) and np.ndim(data) == 0: # i.e. generator data = list(data) - data = np.asarray(data) + + data = construct_1d_object_array_from_listlike(data) copy = False elif isinstance(data, ABCMultiIndex): raise TypeError(f"Cannot create a {cls_name} from a MultiIndex.") diff --git a/pandas/tests/dtypes/test_missing.py b/pandas/tests/dtypes/test_missing.py index 451ac2afd1d91..b995dc591c749 100644 --- a/pandas/tests/dtypes/test_missing.py +++ b/pandas/tests/dtypes/test_missing.py @@ -418,12 +418,10 @@ def test_array_equivalent(dtype_equal): assert not array_equivalent( Index([0, np.nan]), Index([1, np.nan]), dtype_equal=dtype_equal ) - assert array_equivalent( - DatetimeIndex([0, np.nan]), DatetimeIndex([0, np.nan]), dtype_equal=dtype_equal - ) - assert not array_equivalent( - DatetimeIndex([0, np.nan]), DatetimeIndex([1, np.nan]), dtype_equal=dtype_equal - ) + + +@pytest.mark.parametrize("dtype_equal", [True, False]) +def test_array_equivalent_tdi(dtype_equal): assert array_equivalent( TimedeltaIndex([0, np.nan]), TimedeltaIndex([0, np.nan]), @@ -435,6 +433,16 @@ def test_array_equivalent(dtype_equal): dtype_equal=dtype_equal, ) + +@pytest.mark.parametrize("dtype_equal", [True, False]) +def test_array_equivalent_dti(dtype_equal): + assert array_equivalent( + DatetimeIndex([0, np.nan]), DatetimeIndex([0, np.nan]), dtype_equal=dtype_equal + ) + assert not array_equivalent( + DatetimeIndex([0, np.nan]), DatetimeIndex([1, np.nan]), dtype_equal=dtype_equal + ) + dti1 = DatetimeIndex([0, np.nan], tz="US/Eastern") dti2 = DatetimeIndex([0, np.nan], tz="CET") dti3 = DatetimeIndex([1, np.nan], tz="US/Eastern") diff --git a/pandas/tests/frame/test_constructors.py b/pandas/tests/frame/test_constructors.py index 5530fa336971c..ff4fb85fa615a 100644 --- a/pandas/tests/frame/test_constructors.py +++ b/pandas/tests/frame/test_constructors.py @@ -3154,9 +3154,9 @@ def test_from_scalar_datetimelike_mismatched(self, constructor, cls): dtype = {np.datetime64: "m8[ns]", np.timedelta64: "M8[ns]"}[cls] if cls is np.datetime64: - msg1 = r"dtype datetime64\[ns\] cannot be converted to timedelta64\[ns\]" + msg1 = "Invalid type for timedelta scalar: " else: - msg1 = r"dtype timedelta64\[ns\] cannot be converted to datetime64\[ns\]" + msg1 = " is not convertible to datetime" msg = "|".join(["Cannot cast", msg1]) with pytest.raises(TypeError, match=msg): diff --git a/pandas/tests/indexes/datetimes/test_constructors.py b/pandas/tests/indexes/datetimes/test_constructors.py index 22353da57de73..bbb64bdd27c45 100644 --- a/pandas/tests/indexes/datetimes/test_constructors.py +++ b/pandas/tests/indexes/datetimes/test_constructors.py @@ -1054,8 +1054,11 @@ def test_dti_constructor_with_non_nano_dtype(self, tz): # to 2 microseconds vals = [ts, "2999-01-02 03:04:05.678910", 2500] result = DatetimeIndex(vals, dtype=dtype) - exp_arr = np.array([ts.asm8, vals[1], 2], dtype="M8[us]") - expected = DatetimeIndex(exp_arr, dtype="M8[us]").tz_localize(tz) + exp_vals = [Timestamp(x, tz=tz).as_unit("us").asm8 for x in vals] + exp_arr = np.array(exp_vals, dtype="M8[us]") + expected = DatetimeIndex(exp_arr, dtype="M8[us]") + if tz is not None: + expected = expected.tz_localize("UTC").tz_convert(tz) tm.assert_index_equal(result, expected) result2 = DatetimeIndex(np.array(vals, dtype=object), dtype=dtype) @@ -1080,6 +1083,15 @@ def test_dti_constructor_with_non_nano_now_today(self): assert diff1 >= pd.Timedelta(0) assert diff1 < tolerance + def test_dti_constructor_object_float_matches_float_dtype(self): + # GH#55780 + arr = np.array([0, np.nan], dtype=np.float64) + arr2 = arr.astype(object) + + dti1 = DatetimeIndex(arr, tz="CET") + dti2 = DatetimeIndex(arr2, tz="CET") + tm.assert_index_equal(dti1, dti2) + class TestTimeSeries: def test_dti_constructor_preserve_dti_freq(self): diff --git a/pandas/tests/series/methods/test_astype.py b/pandas/tests/series/methods/test_astype.py index 2434290616618..a1a74f9986ada 100644 --- a/pandas/tests/series/methods/test_astype.py +++ b/pandas/tests/series/methods/test_astype.py @@ -120,8 +120,11 @@ def test_astype_object_to_dt64_non_nano(self, tz): ser = Series(vals, dtype=object) result = ser.astype(dtype) - exp_arr = np.array([ts.asm8, vals[1], 2], dtype="M8[us]") - expected = Series(exp_arr, dtype="M8[us]").dt.tz_localize(tz) + exp_vals = [Timestamp(x, tz=tz).as_unit("us").asm8 for x in vals] + exp_arr = np.array(exp_vals, dtype="M8[us]") + expected = Series(exp_arr, dtype="M8[us]") + if tz is not None: + expected = expected.dt.tz_localize("UTC").dt.tz_convert(tz) tm.assert_series_equal(result, expected) def test_astype_mixed_object_to_dt64tz(self): diff --git a/pandas/tests/tools/test_to_datetime.py b/pandas/tests/tools/test_to_datetime.py index ac58d312619fe..bbbf10e7d4adc 100644 --- a/pandas/tests/tools/test_to_datetime.py +++ b/pandas/tests/tools/test_to_datetime.py @@ -603,6 +603,20 @@ def test_to_datetime_mixed_datetime_and_string(self): expected = to_datetime([d1, d2]).tz_convert(timezone(timedelta(minutes=-60))) tm.assert_index_equal(res, expected) + def test_to_datetime_mixed_string_and_numeric(self): + # GH#55780 np.array(vals) would incorrectly cast the number to str + vals = ["2016-01-01", 0] + expected = DatetimeIndex([Timestamp(x) for x in vals]) + result = to_datetime(vals, format="mixed") + result2 = to_datetime(vals[::-1], format="mixed")[::-1] + result3 = DatetimeIndex(vals) + result4 = DatetimeIndex(vals[::-1])[::-1] + + tm.assert_index_equal(result, expected) + tm.assert_index_equal(result2, expected) + tm.assert_index_equal(result3, expected) + tm.assert_index_equal(result4, expected) + @pytest.mark.parametrize( "format", ["%Y-%m-%d", "%Y-%d-%m"], ids=["ISO8601", "non-ISO8601"] ) From 4e5576d13ab1929aaab9bcc45689939f6d82e178 Mon Sep 17 00:00:00 2001 From: jbrockmendel Date: Thu, 2 Nov 2023 09:35:29 -0700 Subject: [PATCH 6/7] DEPR: errors='ignore' (#55734) * DEPR: errors='ignore' * update docs * code-block --- doc/source/user_guide/basics.rst | 17 -------- doc/source/user_guide/timeseries.rst | 6 --- doc/source/whatsnew/v0.17.0.rst | 5 ++- doc/source/whatsnew/v2.2.0.rst | 2 + pandas/core/reshape/melt.py | 6 ++- pandas/core/tools/datetimes.py | 17 ++++---- pandas/core/tools/numeric.py | 22 ++++++++--- pandas/core/tools/timedeltas.py | 11 ++++++ pandas/core/tools/times.py | 11 ++++++ pandas/io/parsers/base_parser.py | 52 +++++++++++++++---------- pandas/io/sql.py | 6 +++ pandas/tests/test_algos.py | 4 +- pandas/tests/tools/test_to_datetime.py | 24 +++++++++--- pandas/tests/tools/test_to_numeric.py | 21 ++++++++-- pandas/tests/tools/test_to_time.py | 4 +- pandas/tests/tools/test_to_timedelta.py | 30 +++++++++----- 16 files changed, 159 insertions(+), 79 deletions(-) diff --git a/doc/source/user_guide/basics.rst b/doc/source/user_guide/basics.rst index d3c9d83b943ce..33e9f9cabb46d 100644 --- a/doc/source/user_guide/basics.rst +++ b/doc/source/user_guide/basics.rst @@ -2261,23 +2261,6 @@ non-conforming elements intermixed that you want to represent as missing: m = ["apple", pd.Timedelta("1day")] pd.to_timedelta(m, errors="coerce") -The ``errors`` parameter has a third option of ``errors='ignore'``, which will simply return the passed in data if it -encounters any errors with the conversion to a desired data type: - -.. ipython:: python - :okwarning: - - import datetime - - m = ["apple", datetime.datetime(2016, 3, 2)] - pd.to_datetime(m, errors="ignore") - - m = ["apple", 2, 3] - pd.to_numeric(m, errors="ignore") - - m = ["apple", pd.Timedelta("1day")] - pd.to_timedelta(m, errors="ignore") - In addition to object conversion, :meth:`~pandas.to_numeric` provides another argument ``downcast``, which gives the option of downcasting the newly (or already) numeric data to a smaller dtype, which can conserve memory: diff --git a/doc/source/user_guide/timeseries.rst b/doc/source/user_guide/timeseries.rst index c78921655eb05..df3d0d2643949 100644 --- a/doc/source/user_guide/timeseries.rst +++ b/doc/source/user_guide/timeseries.rst @@ -294,12 +294,6 @@ The default behavior, ``errors='raise'``, is to raise when unparsable: pd.to_datetime(['2009/07/31', 'asd'], errors='raise') -Pass ``errors='ignore'`` to return the original input when unparsable: - -.. ipython:: python - - pd.to_datetime(["2009/07/31", "asd"], errors="ignore") - Pass ``errors='coerce'`` to convert unparsable data to ``NaT`` (not a time): .. ipython:: python diff --git a/doc/source/whatsnew/v0.17.0.rst b/doc/source/whatsnew/v0.17.0.rst index fa7b43ba1652b..ec441688fc91e 100644 --- a/doc/source/whatsnew/v0.17.0.rst +++ b/doc/source/whatsnew/v0.17.0.rst @@ -632,9 +632,10 @@ Of course you can coerce this as well. To keep the previous behavior, you can use ``errors='ignore'``: -.. ipython:: python +.. code-block:: ipython - pd.to_datetime(["2009-07-31", "asd"], errors="ignore") + In [4]: pd.to_datetime(["2009-07-31", "asd"], errors="ignore") + Out[4]: Index(['2009-07-31', 'asd'], dtype='object') Furthermore, ``pd.to_timedelta`` has gained a similar API, of ``errors='raise'|'ignore'|'coerce'``, and the ``coerce`` keyword has been deprecated in favor of ``errors='coerce'``. diff --git a/doc/source/whatsnew/v2.2.0.rst b/doc/source/whatsnew/v2.2.0.rst index 7e6d31fde389d..26b5705a1f3db 100644 --- a/doc/source/whatsnew/v2.2.0.rst +++ b/doc/source/whatsnew/v2.2.0.rst @@ -284,11 +284,13 @@ Other Deprecations - Deprecated strings ``H``, ``S``, ``U``, and ``N`` denoting units in :func:`to_timedelta` (:issue:`52536`) - Deprecated strings ``H``, ``T``, ``S``, ``L``, ``U``, and ``N`` denoting units in :class:`Timedelta` (:issue:`52536`) - Deprecated strings ``T``, ``S``, ``L``, ``U``, and ``N`` denoting frequencies in :class:`Minute`, :class:`Second`, :class:`Milli`, :class:`Micro`, :class:`Nano` (:issue:`52536`) +- Deprecated the ``errors="ignore"`` option in :func:`to_datetime`, :func:`to_timedelta`, and :func:`to_numeric`; explicitly catch exceptions instead (:issue:`54467`) - Deprecated the ``fastpath`` keyword in the :class:`Series` constructor (:issue:`20110`) - Deprecated the extension test classes ``BaseNoReduceTests``, ``BaseBooleanReduceTests``, and ``BaseNumericReduceTests``, use ``BaseReduceTests`` instead (:issue:`54663`) - Deprecated the option ``mode.data_manager`` and the ``ArrayManager``; only the ``BlockManager`` will be available in future versions (:issue:`55043`) - Deprecated the previous implementation of :class:`DataFrame.stack`; specify ``future_stack=True`` to adopt the future version (:issue:`53515`) - Deprecating downcasting the results of :meth:`DataFrame.fillna`, :meth:`Series.fillna`, :meth:`DataFrame.ffill`, :meth:`Series.ffill`, :meth:`DataFrame.bfill`, :meth:`Series.bfill` in object-dtype cases. To opt in to the future version, use ``pd.set_option("future.no_silent_downcasting", True)`` (:issue:`54261`) +- .. --------------------------------------------------------------------------- .. _whatsnew_220.performance: diff --git a/pandas/core/reshape/melt.py b/pandas/core/reshape/melt.py index 387d43f47fe9b..03423a85db853 100644 --- a/pandas/core/reshape/melt.py +++ b/pandas/core/reshape/melt.py @@ -498,7 +498,11 @@ def melt_stub(df, stub: str, i, j, value_vars, sep: str): newdf[j] = newdf[j].str.replace(re.escape(stub + sep), "", regex=True) # GH17627 Cast numerics suffixes to int/float - newdf[j] = to_numeric(newdf[j], errors="ignore") + try: + newdf[j] = to_numeric(newdf[j]) + except (TypeError, ValueError, OverflowError): + # TODO: anything else to catch? + pass return newdf.set_index(i + [j]) diff --git a/pandas/core/tools/datetimes.py b/pandas/core/tools/datetimes.py index 95328d10c9d31..33ac5a169b08d 100644 --- a/pandas/core/tools/datetimes.py +++ b/pandas/core/tools/datetimes.py @@ -980,16 +980,9 @@ def to_datetime( **Non-convertible date/times** - If a date does not meet the `timestamp limitations - `_, passing ``errors='ignore'`` - will return the original input instead of raising any exception. - Passing ``errors='coerce'`` will force an out-of-bounds date to :const:`NaT`, in addition to forcing non-dates (or non-parseable dates) to :const:`NaT`. - >>> pd.to_datetime('13000101', format='%Y%m%d', errors='ignore') - '13000101' >>> pd.to_datetime('13000101', format='%Y%m%d', errors='coerce') NaT @@ -1079,6 +1072,16 @@ def to_datetime( "You can safely remove this argument.", stacklevel=find_stack_level(), ) + if errors == "ignore": + # GH#54467 + warnings.warn( + "errors='ignore' is deprecated and will raise in a future version. " + "Use to_datetime without passing `errors` and catch exceptions " + "explicitly instead", + FutureWarning, + stacklevel=find_stack_level(), + ) + if arg is None: return None diff --git a/pandas/core/tools/numeric.py b/pandas/core/tools/numeric.py index 7a445ad7ac2b2..8a6ef41b2a540 100644 --- a/pandas/core/tools/numeric.py +++ b/pandas/core/tools/numeric.py @@ -4,10 +4,12 @@ TYPE_CHECKING, Literal, ) +import warnings import numpy as np from pandas._libs import lib +from pandas.util._exceptions import find_stack_level from pandas.util._validators import check_dtype_backend from pandas.core.dtypes.cast import maybe_downcast_numeric @@ -68,6 +70,11 @@ def to_numeric( - If 'raise', then invalid parsing will raise an exception. - If 'coerce', then invalid parsing will be set as NaN. - If 'ignore', then invalid parsing will return the input. + + .. versionchanged:: 2.2 + + "ignore" is deprecated. Catch exceptions explicitly instead. + downcast : str, default None Can be 'integer', 'signed', 'unsigned', or 'float'. If not None, and if the data has been successfully cast to a @@ -134,12 +141,6 @@ def to_numeric( 2 -3 dtype: int8 >>> s = pd.Series(['apple', '1.0', '2', -3]) - >>> pd.to_numeric(s, errors='ignore') - 0 apple - 1 1.0 - 2 2 - 3 -3 - dtype: object >>> pd.to_numeric(s, errors='coerce') 0 NaN 1 1.0 @@ -167,6 +168,15 @@ def to_numeric( if errors not in ("ignore", "raise", "coerce"): raise ValueError("invalid error value specified") + if errors == "ignore": + # GH#54467 + warnings.warn( + "errors='ignore' is deprecated and will raise in a future version. " + "Use to_numeric without passing `errors` and catch exceptions " + "explicitly instead", + FutureWarning, + stacklevel=find_stack_level(), + ) check_dtype_backend(dtype_backend) diff --git a/pandas/core/tools/timedeltas.py b/pandas/core/tools/timedeltas.py index 587946aba5041..8909776d91369 100644 --- a/pandas/core/tools/timedeltas.py +++ b/pandas/core/tools/timedeltas.py @@ -7,6 +7,7 @@ TYPE_CHECKING, overload, ) +import warnings import numpy as np @@ -20,6 +21,7 @@ disallow_ambiguous_unit, parse_timedelta_unit, ) +from pandas.util._exceptions import find_stack_level from pandas.core.dtypes.common import is_list_like from pandas.core.dtypes.dtypes import ArrowDtype @@ -183,6 +185,15 @@ def to_timedelta( if errors not in ("ignore", "raise", "coerce"): raise ValueError("errors must be one of 'ignore', 'raise', or 'coerce'.") + if errors == "ignore": + # GH#54467 + warnings.warn( + "errors='ignore' is deprecated and will raise in a future version. " + "Use to_timedelta without passing `errors` and catch exceptions " + "explicitly instead", + FutureWarning, + stacklevel=find_stack_level(), + ) if arg is None: return arg diff --git a/pandas/core/tools/times.py b/pandas/core/tools/times.py index 1b3a3ae1be5f0..d77bcc91df709 100644 --- a/pandas/core/tools/times.py +++ b/pandas/core/tools/times.py @@ -5,10 +5,12 @@ time, ) from typing import TYPE_CHECKING +import warnings import numpy as np from pandas._libs.lib import is_list_like +from pandas.util._exceptions import find_stack_level from pandas.core.dtypes.generic import ( ABCIndex, @@ -52,6 +54,15 @@ def to_time( ------- datetime.time """ + if errors == "ignore": + # GH#54467 + warnings.warn( + "errors='ignore' is deprecated and will raise in a future version. " + "Use to_time without passing `errors` and catch exceptions " + "explicitly instead", + FutureWarning, + stacklevel=find_stack_level(), + ) def _convert_listlike(arg, format): if isinstance(arg, (list, tuple)): diff --git a/pandas/io/parsers/base_parser.py b/pandas/io/parsers/base_parser.py index 6b1daa96782a0..86ec62d2b19b6 100644 --- a/pandas/io/parsers/base_parser.py +++ b/pandas/io/parsers/base_parser.py @@ -1150,14 +1150,19 @@ def converter(*date_cols, col: Hashable): ".*parsing datetimes with mixed time zones will raise an error", category=FutureWarning, ) - result = tools.to_datetime( - ensure_object(strs), - format=date_fmt, - utc=False, - dayfirst=dayfirst, - errors="ignore", - cache=cache_dates, - ) + str_objs = ensure_object(strs) + try: + result = tools.to_datetime( + str_objs, + format=date_fmt, + utc=False, + dayfirst=dayfirst, + cache=cache_dates, + ) + except (ValueError, TypeError): + # test_usecols_with_parse_dates4 + return str_objs + if isinstance(result, DatetimeIndex): arr = result.to_numpy() arr.flags.writeable = True @@ -1172,17 +1177,22 @@ def converter(*date_cols, col: Hashable): "will raise an error", category=FutureWarning, ) - result = tools.to_datetime( - date_parser( - *(unpack_if_single_element(arg) for arg in date_cols) - ), - errors="ignore", - cache=cache_dates, + pre_parsed = date_parser( + *(unpack_if_single_element(arg) for arg in date_cols) ) + try: + result = tools.to_datetime( + pre_parsed, + cache=cache_dates, + ) + except (ValueError, TypeError): + # test_read_csv_with_custom_date_parser + result = pre_parsed if isinstance(result, datetime.datetime): raise Exception("scalar parser") return result except Exception: + # e.g. test_datetime_fractional_seconds with warnings.catch_warnings(): warnings.filterwarnings( "ignore", @@ -1190,13 +1200,15 @@ def converter(*date_cols, col: Hashable): "will raise an error", category=FutureWarning, ) - return tools.to_datetime( - parsing.try_parse_dates( - parsing.concat_date_cols(date_cols), - parser=date_parser, - ), - errors="ignore", + pre_parsed = parsing.try_parse_dates( + parsing.concat_date_cols(date_cols), + parser=date_parser, ) + try: + return tools.to_datetime(pre_parsed) + except (ValueError, TypeError): + # TODO: not reached in tests 2023-10-27; needed? + return pre_parsed return converter diff --git a/pandas/io/sql.py b/pandas/io/sql.py index b4675513a99c2..c77567214a692 100644 --- a/pandas/io/sql.py +++ b/pandas/io/sql.py @@ -105,6 +105,12 @@ def _handle_date_column( # Format can take on custom to_datetime argument values such as # {"errors": "coerce"} or {"dayfirst": True} error: DateTimeErrorChoices = format.pop("errors", None) or "ignore" + if error == "ignore": + try: + return to_datetime(col, **format) + except (TypeError, ValueError): + # TODO: not reached 2023-10-27; needed? + return col return to_datetime(col, errors=error, **format) else: # Allow passing of formatting string for integers diff --git a/pandas/tests/test_algos.py b/pandas/tests/test_algos.py index 918353c9c7181..fd5d92dc35249 100644 --- a/pandas/tests/test_algos.py +++ b/pandas/tests/test_algos.py @@ -1278,7 +1278,9 @@ def test_value_counts_datetime_outofbounds(self): tm.assert_series_equal(res, exp) # GH 12424 # TODO: belongs elsewhere - res = to_datetime(Series(["2362-01-01", np.nan]), errors="ignore") + msg = "errors='ignore' is deprecated" + with tm.assert_produces_warning(FutureWarning, match=msg): + res = to_datetime(Series(["2362-01-01", np.nan]), errors="ignore") exp = Series(["2362-01-01", np.nan], dtype=object) tm.assert_series_equal(res, exp) diff --git a/pandas/tests/tools/test_to_datetime.py b/pandas/tests/tools/test_to_datetime.py index bbbf10e7d4adc..224f219abf512 100644 --- a/pandas/tests/tools/test_to_datetime.py +++ b/pandas/tests/tools/test_to_datetime.py @@ -57,6 +57,10 @@ r"alongside this." ) +pytestmark = pytest.mark.filterwarnings( + "ignore:errors='ignore' is deprecated:FutureWarning" +) + @pytest.fixture(params=[True, False]) def cache(request): @@ -1228,7 +1232,9 @@ def test_to_datetime_tz_mixed(self, cache): with pytest.raises(ValueError, match=msg): to_datetime(arr, cache=cache) - result = to_datetime(arr, cache=cache, errors="ignore") + depr_msg = "errors='ignore' is deprecated" + with tm.assert_produces_warning(FutureWarning, match=depr_msg): + result = to_datetime(arr, cache=cache, errors="ignore") expected = Index( [ Timestamp("2013-01-01 13:00:00-08:00"), @@ -1474,11 +1480,15 @@ def test_datetime_invalid_index(self, values, format): warn = UserWarning else: warn = None - with tm.assert_produces_warning(warn, match="Could not infer format"): + with tm.assert_produces_warning( + warn, match="Could not infer format", raise_on_extra_warnings=False + ): res = to_datetime(values, errors="ignore", format=format) tm.assert_index_equal(res, Index(values)) - with tm.assert_produces_warning(warn, match="Could not infer format"): + with tm.assert_produces_warning( + warn, match="Could not infer format", raise_on_extra_warnings=False + ): res = to_datetime(values, errors="coerce", format=format) tm.assert_index_equal(res, DatetimeIndex([NaT] * len(values))) @@ -1493,7 +1503,9 @@ def test_datetime_invalid_index(self, values, format): ] ) with pytest.raises(ValueError, match=msg): - with tm.assert_produces_warning(warn, match="Could not infer format"): + with tm.assert_produces_warning( + warn, match="Could not infer format", raise_on_extra_warnings=False + ): to_datetime(values, errors="raise", format=format) @pytest.mark.parametrize("utc", [True, None]) @@ -1662,7 +1674,9 @@ def test_to_datetime_malformed_no_raise(self, errors, expected): # GH 28299 # GH 48633 ts_strings = ["200622-12-31", "111111-24-11"] - with tm.assert_produces_warning(UserWarning, match="Could not infer format"): + with tm.assert_produces_warning( + UserWarning, match="Could not infer format", raise_on_extra_warnings=False + ): result = to_datetime(ts_strings, errors=errors) tm.assert_index_equal(result, expected) diff --git a/pandas/tests/tools/test_to_numeric.py b/pandas/tests/tools/test_to_numeric.py index da8e2fe9abc16..d6b085b7954db 100644 --- a/pandas/tests/tools/test_to_numeric.py +++ b/pandas/tests/tools/test_to_numeric.py @@ -112,6 +112,7 @@ def test_error(data, msg): @pytest.mark.parametrize( "errors,exp_data", [("ignore", [1, -3.14, "apple"]), ("coerce", [1, -3.14, np.nan])] ) +@pytest.mark.filterwarnings("ignore:errors='ignore' is deprecated:FutureWarning") def test_ignore_error(errors, exp_data): ser = Series([1, -3.14, "apple"]) result = to_numeric(ser, errors=errors) @@ -129,6 +130,7 @@ def test_ignore_error(errors, exp_data): ("coerce", [1.0, 0.0, np.nan]), ], ) +@pytest.mark.filterwarnings("ignore:errors='ignore' is deprecated:FutureWarning") def test_bool_handling(errors, exp): ser = Series([True, False, "apple"]) @@ -229,6 +231,7 @@ def test_all_nan(): tm.assert_series_equal(result, expected) +@pytest.mark.filterwarnings("ignore:errors='ignore' is deprecated:FutureWarning") def test_type_check(errors): # see gh-11776 df = DataFrame({"a": [1, -3.14, 7], "b": ["4", "5", "6"]}) @@ -243,6 +246,7 @@ def test_scalar(val, signed, transform): assert to_numeric(transform(val)) == float(val) +@pytest.mark.filterwarnings("ignore:errors='ignore' is deprecated:FutureWarning") def test_really_large_scalar(large_val, signed, transform, errors): # see gh-24910 kwargs = {"errors": errors} if errors is not None else {} @@ -260,6 +264,7 @@ def test_really_large_scalar(large_val, signed, transform, errors): tm.assert_almost_equal(to_numeric(val, **kwargs), expected) +@pytest.mark.filterwarnings("ignore:errors='ignore' is deprecated:FutureWarning") def test_really_large_in_arr(large_val, signed, transform, multiple_elts, errors): # see gh-24910 kwargs = {"errors": errors} if errors is not None else {} @@ -299,6 +304,7 @@ def test_really_large_in_arr(large_val, signed, transform, multiple_elts, errors tm.assert_almost_equal(result, np.array(expected, dtype=exp_dtype)) +@pytest.mark.filterwarnings("ignore:errors='ignore' is deprecated:FutureWarning") def test_really_large_in_arr_consistent(large_val, signed, multiple_elts, errors): # see gh-24910 # @@ -337,6 +343,7 @@ def test_really_large_in_arr_consistent(large_val, signed, multiple_elts, errors ("coerce", lambda x: np.isnan(x)), ], ) +@pytest.mark.filterwarnings("ignore:errors='ignore' is deprecated:FutureWarning") def test_scalar_fail(errors, checker): scalar = "fail" @@ -412,6 +419,7 @@ def test_period(request, transform_assert_equal): ("coerce", Series([np.nan, 1.0, np.nan])), ], ) +@pytest.mark.filterwarnings("ignore:errors='ignore' is deprecated:FutureWarning") def test_non_hashable(errors, expected): # see gh-13324 ser = Series([[10.0, 2], 1.0, "apple"]) @@ -496,7 +504,9 @@ def test_ignore_downcast_invalid_data(): data = ["foo", 2, 3] expected = np.array(data, dtype=object) - res = to_numeric(data, errors="ignore", downcast="unsigned") + msg = "errors='ignore' is deprecated" + with tm.assert_produces_warning(FutureWarning, match=msg): + res = to_numeric(data, errors="ignore", downcast="unsigned") tm.assert_numpy_array_equal(res, expected) @@ -629,6 +639,7 @@ def test_coerce_uint64_conflict(data, exp_data): ("raise", "Unable to parse string"), ], ) +@pytest.mark.filterwarnings("ignore:errors='ignore' is deprecated:FutureWarning") def test_non_coerce_uint64_conflict(errors, exp): # see gh-17007 and gh-17125 # @@ -755,7 +766,9 @@ def test_to_numeric_from_nullable_string_ignore(nullable_string_dtype): values = ["a", "1"] ser = Series(values, dtype=nullable_string_dtype) expected = ser.copy() - result = to_numeric(ser, errors="ignore") + msg = "errors='ignore' is deprecated" + with tm.assert_produces_warning(FutureWarning, match=msg): + result = to_numeric(ser, errors="ignore") tm.assert_series_equal(result, expected) @@ -925,7 +938,9 @@ def test_to_numeric_dtype_backend_error(dtype_backend): with pytest.raises(ValueError, match="Unable to parse string"): to_numeric(ser, dtype_backend=dtype_backend) - result = to_numeric(ser, dtype_backend=dtype_backend, errors="ignore") + msg = "errors='ignore' is deprecated" + with tm.assert_produces_warning(FutureWarning, match=msg): + result = to_numeric(ser, dtype_backend=dtype_backend, errors="ignore") tm.assert_series_equal(result, expected) result = to_numeric(ser, dtype_backend=dtype_backend, errors="coerce") diff --git a/pandas/tests/tools/test_to_time.py b/pandas/tests/tools/test_to_time.py index 5046fd9d0edc1..b673bd9c2ec71 100644 --- a/pandas/tests/tools/test_to_time.py +++ b/pandas/tests/tools/test_to_time.py @@ -54,7 +54,9 @@ def test_arraylike(self): assert to_time(arg, infer_time_format=True) == expected_arr assert to_time(arg, format="%I:%M%p", errors="coerce") == [None, None] - res = to_time(arg, format="%I:%M%p", errors="ignore") + msg = "errors='ignore' is deprecated" + with tm.assert_produces_warning(FutureWarning, match=msg): + res = to_time(arg, format="%I:%M%p", errors="ignore") tm.assert_numpy_array_equal(res, np.array(arg, dtype=np.object_)) msg = "Cannot convert.+to a time with given format" diff --git a/pandas/tests/tools/test_to_timedelta.py b/pandas/tests/tools/test_to_timedelta.py index 120b5322adf3e..c4c9b41c218a0 100644 --- a/pandas/tests/tools/test_to_timedelta.py +++ b/pandas/tests/tools/test_to_timedelta.py @@ -92,6 +92,7 @@ def test_to_timedelta_oob_non_nano(self): "arg", [np.arange(10).reshape(2, 5), pd.DataFrame(np.arange(10).reshape(2, 5))] ) @pytest.mark.parametrize("errors", ["ignore", "raise", "coerce"]) + @pytest.mark.filterwarnings("ignore:errors='ignore' is deprecated:FutureWarning") def test_to_timedelta_dataframe(self, arg, errors): # GH 11776 with pytest.raises(TypeError, match="1-d array"): @@ -137,22 +138,29 @@ def test_to_timedelta_bad_value_coerce(self): def test_to_timedelta_invalid_errors_ignore(self): # gh-13613: these should not error because errors='ignore' + msg = "errors='ignore' is deprecated" invalid_data = "apple" - assert invalid_data == to_timedelta(invalid_data, errors="ignore") + + with tm.assert_produces_warning(FutureWarning, match=msg): + result = to_timedelta(invalid_data, errors="ignore") + assert invalid_data == result invalid_data = ["apple", "1 days"] - tm.assert_numpy_array_equal( - np.array(invalid_data, dtype=object), - to_timedelta(invalid_data, errors="ignore"), - ) + expected = np.array(invalid_data, dtype=object) + + with tm.assert_produces_warning(FutureWarning, match=msg): + result = to_timedelta(invalid_data, errors="ignore") + tm.assert_numpy_array_equal(expected, result) invalid_data = pd.Index(["apple", "1 days"]) - tm.assert_index_equal(invalid_data, to_timedelta(invalid_data, errors="ignore")) + with tm.assert_produces_warning(FutureWarning, match=msg): + result = to_timedelta(invalid_data, errors="ignore") + tm.assert_index_equal(invalid_data, result) invalid_data = Series(["apple", "1 days"]) - tm.assert_series_equal( - invalid_data, to_timedelta(invalid_data, errors="ignore") - ) + with tm.assert_produces_warning(FutureWarning, match=msg): + result = to_timedelta(invalid_data, errors="ignore") + tm.assert_series_equal(invalid_data, result) @pytest.mark.parametrize( "val, errors", @@ -239,7 +247,9 @@ def test_to_timedelta_coerce_strings_unit(self): def test_to_timedelta_ignore_strings_unit(self): arr = np.array([1, 2, "error"], dtype=object) - result = to_timedelta(arr, unit="ns", errors="ignore") + msg = "errors='ignore' is deprecated" + with tm.assert_produces_warning(FutureWarning, match=msg): + result = to_timedelta(arr, unit="ns", errors="ignore") tm.assert_numpy_array_equal(result, arr) @pytest.mark.parametrize( From 0cdb37c445fc0a0a2144f981fd1acbf4f07d88ee Mon Sep 17 00:00:00 2001 From: jbrockmendel Date: Thu, 2 Nov 2023 15:13:58 -0700 Subject: [PATCH 7/7] CI: parametrize and xfail (#55804) --- .../indexes/datetimes/test_constructors.py | 48 +++++++++---------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/pandas/tests/indexes/datetimes/test_constructors.py b/pandas/tests/indexes/datetimes/test_constructors.py index bbb64bdd27c45..ab4328788507f 100644 --- a/pandas/tests/indexes/datetimes/test_constructors.py +++ b/pandas/tests/indexes/datetimes/test_constructors.py @@ -1013,35 +1013,35 @@ def test_dti_convert_datetime_list(self, tzstr): dr2 = DatetimeIndex(list(dr), name="foo", freq="D") tm.assert_index_equal(dr, dr2) - def test_dti_ambiguous_matches_timestamp(self): + @pytest.mark.parametrize( + "tz", + [ + pytz.timezone("US/Eastern"), + gettz("US/Eastern"), + ], + ) + @pytest.mark.parametrize("use_str", [True, False]) + @pytest.mark.parametrize("box_cls", [Timestamp, DatetimeIndex]) + def test_dti_ambiguous_matches_timestamp(self, tz, use_str, box_cls, request): # GH#47471 check that we get the same raising behavior in the DTI # constructor and Timestamp constructor dtstr = "2013-11-03 01:59:59.999999" - dtobj = Timestamp(dtstr).to_pydatetime() - - tz = pytz.timezone("US/Eastern") - with pytest.raises(pytz.AmbiguousTimeError, match=dtstr): - Timestamp(dtstr, tz=tz) - with pytest.raises(pytz.AmbiguousTimeError, match=dtstr): - Timestamp(dtobj, tz=tz) - with pytest.raises(pytz.AmbiguousTimeError, match=dtstr): - DatetimeIndex([dtstr], tz=tz) - with pytest.raises(pytz.AmbiguousTimeError, match=dtstr): - DatetimeIndex([dtobj], tz=tz) + item = dtstr + if not use_str: + item = Timestamp(dtstr).to_pydatetime() + if box_cls is not Timestamp: + item = [item] + + if not use_str and isinstance(tz, dateutil.tz.tzfile): + # FIXME: The Timestamp constructor here behaves differently than all + # the other cases bc with dateutil/zoneinfo tzinfos we implicitly + # get fold=0. Having this raise is not important, but having the + # behavior be consistent across cases is. + mark = pytest.mark.xfail(reason="We implicitly get fold=0.") + request.applymarker(mark) - tz2 = gettz("US/Eastern") - with pytest.raises(pytz.AmbiguousTimeError, match=dtstr): - Timestamp(dtstr, tz=tz2) - # FIXME: The Timestamp constructor here behaves differently than all - # the other cases bc with dateutil/zoneinfo tzinfos we implicitly - # get fold=0. Having this raise is not important, but having the - # behavior be consistent across cases is. - # with pytest.raises(pytz.AmbiguousTimeError, match=dtstr): - # Timestamp(dtobj, tz=tz2) - with pytest.raises(pytz.AmbiguousTimeError, match=dtstr): - DatetimeIndex([dtstr], tz=tz2) with pytest.raises(pytz.AmbiguousTimeError, match=dtstr): - DatetimeIndex([dtobj], tz=tz2) + box_cls(item, tz=tz) @pytest.mark.parametrize("tz", [None, "UTC", "US/Pacific"]) def test_dti_constructor_with_non_nano_dtype(self, tz):