pandas-dev/pandas · #69789

ENH: respect display.precision for complex and float Interval values (GH#25920)

jbrockmendel · merged Oct 1, 20267 files · 221 + / 29 −
doc/source/whatsnew/v3.2.0.rst1 + / 0 −
@@ -28,6 +28,7 @@ enhancement2  Other enhancements ^^^^^^^^^^^^^^^^^^+- Display formatting for complex numbers in ``object`` dtype and for ``interval`` dtype values with float endpoints now respects the ``display.precision`` and ``display.float_format`` options (:issue:`25920`). - The :class:`ExtensionArray` base tests in ``pandas.tests.extension.base`` now issue a :class:`errors.PerformanceWarning` when the array uses the default, possibly slow, implementation of ``unique``, ``factorize``, ``argsort``, ``argmin``, ``argmax``, or ``searchsorted`` (:issue:`24433`)  .. ---------------------------------------------------------------------------
pandas/io/formats/format.py96 + / 22 −
@@ -52,6 +52,7 @@ from pandas.util._exceptions import find_stack_level  from pandas.core.dtypes.common import (+    is_complex,     is_complex_dtype,     is_float,     is_integer,@@ -63,6 +64,7 @@     CategoricalDtype,     DatetimeTZDtype,     ExtensionDtype,+    IntervalDtype, ) from pandas.core.dtypes.missing import (     isna,@@ -73,6 +75,7 @@     Categorical,     DatetimeArray,     ExtensionArray,+    IntervalArray,     TimedeltaArray, ) from pandas.core.base import PandasObject@@ -241,7 +244,9 @@ def _get_footer(self) -> str:      def _get_formatted_values(self) -> list[str]:         return format_array(-            self.tr_series._values,+            _maybe_format_float_intervals(+                self.tr_series._values, None, self.float_format, "."+            ),             None,             float_format=self.float_format,             na_rep=self.na_rep,@@ -754,7 +759,9 @@ def format_col(self, i: int) -> list[str]:         frame = self.tr_frame         formatter = self._get_formatter(i)         return format_array(-            frame._get_column_array(i),+            _maybe_format_float_intervals(+                frame._get_column_array(i), formatter, self.float_format, self.decimal+            ),             formatter,             float_format=self.float_format,             na_rep=self.na_rep,@@ -1223,15 +1230,13 @@ def get_result(self) -> list[str]:         return result      def _format_strings(self) -> list[str]:-        if self.float_format is None:-            float_format = config["display"]["float_format"]-            if float_format is None:-                precision = config["display"]["precision"]-                float_format = lambda x: _trim_zeros_single_float(-                    f"{x: .{precision:d}f}"-                )+        user_float_format: Any = self.float_format+        if user_float_format is None:+            user_float_format = config["display"]["float_format"]+        if user_float_format is None:+            float_format = partial(printing.format_with_precision, sign=" ")         else:-            float_format = self.float_format+            float_format = user_float_format          if self.formatter is not None:             formatter = self.formatter@@ -1256,6 +1261,9 @@ def _format(x):                 return self.na_rep             elif isinstance(x, PandasObject):                 return str(x)+            elif self.formatter is None and is_complex(x):+                # GH#25920+                return _format_complex(x, user_float_format)             else:                 # object dtype                 return str(formatter(x))@@ -1569,6 +1577,84 @@ def _format_strings(self) -> list[str]:         return fmt_values  +def _format_complex(value: complex, float_format: FloatFormatType | None) -> str:+    """+    Format a complex scalar from an object-dtype array.++    ``float_format`` is applied to each part separately so that formats+    written for floats also work here.+    """+    if float_format is None:+        return printing.format_with_precision(value)+    if isinstance(float_format, str):+        # "%"-style strings reach here unconverted+        fmt_str = float_format+        float_format = lambda part: fmt_str % part+    real = str(float_format(value.real)).strip()+    imag = str(float_format(value.imag)).strip()+    if not imag.startswith("-"):+        imag = f"+{imag}"+    return f"({real}{imag}j)"+++def _maybe_format_float_intervals(+    values: ArrayLike,+    formatter: Callable | None,+    float_format: FloatFormatType | None,+    decimal: str,+) -> ArrayLike:+    """+    Pre-format float-endpoint Interval column values (GH#25920).++    Only column values go through here; index labels and Categorical+    categories keep ``str(Interval)``.+    """+    if (+        formatter is None+        and isinstance(values.dtype, IntervalDtype)+        and lib.is_np_dtype(values.dtype.subtype, "f")+    ):+        return _format_float_intervals(+            cast("IntervalArray", values), float_format=float_format, decimal=decimal+        )+    return values+++def _format_float_intervals(+    values: IntervalArray,+    float_format: FloatFormatType | None,+    decimal: str,+) -> np.ndarray:+    """+    Render Intervals with float endpoints as strings, leaving NA entries as-is.++    All endpoints are formatted together, so they follow the same rules as a+    float64 column, e.g. switching to scientific notation (GH#25920).+    """+    mask = values.isna()+    left = np.asarray(values.left)[~mask]+    right = np.asarray(values.right)[~mask]+    endpoints = format_array(+        np.concatenate([left, right]),+        None,+        float_format=float_format,+        decimal=decimal,+        leading_space=False,+    )+    endpoints = [endpoint.strip() for endpoint in endpoints]+    start = "[" if values.closed_left else "("+    end = "]" if values.closed_right else ")"+    result = np.asarray(values, dtype=object)+    n_valid = len(left)+    result[~mask] = [+        f"{start}{left_str}, {right_str}{end}"+        for left_str, right_str in zip(+            endpoints[:n_valid], endpoints[n_valid:], strict=True+        )+    ]+    return result++ def format_percentiles(     percentiles: np.ndarray | Sequence[float], ) -> list[str]:@@ -1811,18 +1897,6 @@ def _trim_zeros_complex(str_complexes: ArrayLike, decimal: str = ".") -> list[st     return padded  -def _trim_zeros_single_float(str_float: str) -> str:-    """-    Trims trailing zeros after a decimal point,-    leaving just one if necessary.-    """-    str_float = str_float.rstrip("0")-    if str_float.endswith("."):-        str_float += "0"--    return str_float-- _NUMBER_WITH_DECIMAL_RE = re.compile(r"^\s*[+-]?[0-9]+\.[0-9]*$")  
pandas/io/formats/printing.py27 + / 6 −
@@ -29,6 +29,7 @@     ABCNDFrame, ) from pandas.core.dtypes.inference import (+    is_complex,     is_float,     is_scalar, )@@ -140,12 +141,9 @@ def _pprint_seq(         if (max_items is not None) and (i >= max_items):             max_items_reached = True             break-        if is_float(item) and notna(item):-            # GH#60503-            from pandas.io.formats.format import _trim_zeros_single_float--            precision = config["display"]["precision"]-            item = _trim_zeros_single_float(f"{item:.{precision}f}")+        if (is_float(item) or is_complex(item)) and notna(item):+            # GH#60503, GH#25920+            item = format_with_precision(item)         r.append(pprint_thing(item, _nest_lvl + 1, max_seq_items=max_seq_items, **kwds))     body = ", ".join(r) @@ -157,6 +155,29 @@ def _pprint_seq(     return fmt.format(body=body)  +def format_with_precision(item: float | complex, sign: str = "") -> str:+    """+    Format a float or complex scalar using the ``display.precision`` option.++    Trailing zeros after the decimal point are trimmed, leaving at least one.++    Parameters+    ----------+    item : float or complex+    sign : {"", " ", "+"}, default ""+        Sign option of the format spec, applied to the real part.+    """+    precision = config["display"]["precision"]++    def _fmt(val: float, sign: str) -> str:+        str_float = f"{val:{sign}.{precision}f}".rstrip("0")+        return str_float + "0" if str_float.endswith(".") else str_float++    if is_float(item):+        return _fmt(item, sign)+    return f"({_fmt(item.real, sign)}{_fmt(item.imag, '+')}j)"++ def _pprint_dict(     seq: Mapping[Any, Any],     _nest_lvl: int = 0,
pandas/tests/arrays/interval/test_formats.py55 + / 0 −
@@ -1,3 +1,7 @@+import numpy as np+import pytest++import pandas as pd from pandas.core.arrays import IntervalArray  @@ -9,3 +13,54 @@ def test_repr():         "<IntervalArray>\n[(0, 1], (1, 2]]\nLength: 2, dtype: interval[int64, right]"     )     assert result == expected+++def test_series_repr_float_precision():+    # GH#25920+    arr = IntervalArray.from_arrays([0.55555555, np.nan], [1.55555555, np.nan])+    ser = pd.Series(arr)+    with pd.option_context("display.precision", 3):+        result = repr(ser)+    expected = "0    (0.556, 1.556]\n1               NaN\ndtype: interval"+    assert result == expected+++@pytest.mark.parametrize(+    "breaks, expected",+    [+        ([1e-9, 2e-9], "0    (1.000000e-09, 2.000000e-09]\ndtype: interval"),+        ([1e20, 2e20], "0    (1.000000e+20, 2.000000e+20]\ndtype: interval"),+        ([0.5, 1.25, 2.0], "0    (0.50, 1.25]\n1    (1.25, 2.00]\ndtype: interval"),+    ],+)+def test_series_repr_float_matches_float64(breaks, expected):+    # GH#25920 endpoints are formatted like a float64 Series+    ser = pd.Series(IntervalArray.from_breaks(breaks))+    assert repr(ser) == expected+++def test_series_repr_float_format():+    # GH#25920+    ser = pd.Series(IntervalArray.from_breaks([0.5, 1.5]))+    with pd.option_context("display.float_format", "{:.2e}".format):+        result = repr(ser)+    assert result == "0    (5.00e-01, 1.50e+00]\ndtype: interval"+++@pytest.mark.parametrize(+    "ser, expected",+    [+        (+            pd.Series([1, 2], index=pd.IntervalIndex.from_breaks([0.5, 1.25, 2.0])),+            "(0.5, 1.25]    1\n(1.25, 2.0]    2\ndtype: int64",+        ),+        (+            pd.Series(pd.Categorical(pd.IntervalIndex.from_breaks([0.5, 1.25, 2.0]))),+            "0    (0.5, 1.25]\n1    (1.25, 2.0]\ndtype: category\n"+            "Categories (2, interval[float64, right]): [(0.5, 1.25], (1.25, 2.0]]",+        ),+    ],+)+def test_series_repr_float_interval_labels_and_categories_unchanged(ser, expected):+    # GH#25920 only interval-dtype values are formatted like float64+    assert repr(ser) == expected
pandas/tests/io/formats/test_format.py37 + / 0 −
@@ -2010,6 +2010,43 @@ def test_to_html_column_index_named_zero():     assert "<th>0</th>" in df.to_html()  +def test_precision_complex_in_object_column():+    # GH#25920+    float_val = 0.55555555+    df = pd.DataFrame(+        [float_val, complex(float_val, -float_val), (float_val, float_val)]+    )+    with pd.option_context("display.precision", 3):+        result = repr(df)+    expected = (+        "                0\n0           0.556\n1  (0.556-0.556j)\n2  (0.556, 0.556)"+    )+    assert result == expected+++def test_float_format_complex_in_object_column():+    # GH#25920 float_format is applied to each part, so float-only callables work+    ser = pd.Series([0.5, 0.5 + 0.5j, 1 - 2j], dtype=object)+    with pd.option_context("display.float_format", lambda x: f"{float(x):.2f}"):+        result = repr(ser)+    expected = "0            0.50\n1    (0.50+0.50j)\n2    (1.00-2.00j)\ndtype: object"+    assert result == expected+++@pytest.mark.parametrize(+    "box, expected",+    [+        (pd.Series, "0    (1.00+1.00j)\n1               x"),+        (pd.DataFrame, "0  (1.00+1.00j)\n1             x"),+    ],+)+def test_float_format_percent_str_complex_in_object_column(box, expected):+    # GH#25920+    obj = box([1 + 1j, "x"], dtype=object)+    result = obj.to_string(float_format="%.2f", header=False)+    assert result == expected++ def _three_digit_exp():     return f"{1.7e8:.4g}" == "1.7e+008" 
pandas/tests/io/formats/test_printing.py5 + / 0 −
@@ -93,6 +93,11 @@ def test_repr_seq_float_precision(self):         with cf.option_context("display.precision", 3):             assert printing.pprint_thing([3.14159265, 3.14159265]) == "[3.142, 3.142]" +    def test_repr_seq_complex_precision(self):+        # GH#25920+        with cf.option_context("display.precision", 3):+            assert printing.pprint_thing([3.14159265 + 1j]) == "[(3.142+1.0j)]"+     def test_repr_0d_array(self):         # GH#64638 0-d arrays are not iterable and must fall through to str()         assert printing.pprint_thing(np.array(5)) == "5"
scripts/validate_unwanted_patterns.py0 + / 1 −
@@ -47,7 +47,6 @@     "_DatetimeTZBlock",     "_check_pyarrow_available",     "_parser",  # https://github.com/pandas-dev/pandas/issues/60833-    "_trim_zeros_single_float",     "_safe_fill_null", }