pandas-dev/pandas · #69789
ENH: respect display.precision for complex and float Interval values (GH#25920)
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) == expectedpandas/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", }