pandas-dev/pandas · #71688

ENH: add an HTML repr for Series (GH#5563)

jbrockmendel · merged Nov 2, 20265 files · 169 + / 8 −
doc/source/whatsnew/v3.2.0.rst1 + / 0 −
@@ -36,6 +36,7 @@ Other enhancements - Added :meth:`Categorical.to_dense` and :meth:`Series.cat.to_dense` to get the values with the dtype of the categories, including extension dtypes (:issue:`26410`) - Added :meth:`DatetimeIndex.set_freq` and :meth:`TimedeltaIndex.set_freq`, which return a new index with the given ``freq`` instead of modifying it in place (:issue:`61094`) - 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`).+- In Jupyter notebooks, a :class:`Series` is now displayed as an HTML table, with its name and dtype shown below the table as in its text repr (:issue:`5563`) - Printing a :class:`.Styler` now shows the underlying data instead of the object's address, so the table also appears in notebooks exported to PDF with ``nbconvert`` (:issue:`55908`) - 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/core/series.py35 + / 0 −
@@ -27,6 +27,8 @@  import numpy as np +from pandas._config import get_option+ from pandas._libs import (     lib,     properties,@@ -1618,6 +1620,39 @@ def __repr__(self) -> str:         repr_params = fmt.get_series_repr_params()         return self.to_string(**repr_params) +    def _repr_html_(self) -> str | None:+        """+        Return a html representation for a particular Series.++        Mainly for IPython notebook.+        """+        # circular import+        from pandas.io.formats.html import SeriesNotebookFormatter++        if not get_option("display.notebook_repr_html"):+            return None++        formatter = fmt.DataFrameFormatter(+            self.to_frame(),+            header=False,+            index_names=True,+            bold_rows=True,+            escape=True,+            float_format=get_option("display.float_format"),+            max_rows=get_option("display.max_rows"),+            min_rows=get_option("display.min_rows"),+            show_dimensions=False,+        )+        # truncate as the table does, so the footer agrees with it+        footer = fmt.SeriesFormatter(+            self,+            name=True,+            dtype=True,+            length=get_option("display.show_dimensions"),+            max_rows=formatter.max_rows_fitted,+        )._get_footer()+        return SeriesNotebookFormatter(formatter, footer=footer).to_string()+     @overload     def to_string(         self,
pandas/io/formats/html.py25 + / 2 −
@@ -94,15 +94,16 @@ def to_string(self) -> str:      def render(self) -> list[str]:         self._write_table()+        self._write_footer()+        return self.elements +    def _write_footer(self) -> None:         if self.should_show_dimensions:             by = chr(215)  # ×  # noqa: RUF003             self.write(                 f"<p>{len(self.frame)} rows {by} {len(self.frame.columns)} columns</p>"             ) -        return self.elements-     @property     def should_show_dimensions(self) -> bool:         return self.fmt.should_show_dimensions@@ -656,3 +657,25 @@ def render(self) -> list[str]:         super().render()         self.write("</div>")         return self.elements+++class SeriesNotebookFormatter(NotebookFormatter):+    """+    Internal class for Series._repr_html_().++    Renders ``series.to_frame()`` without a column header, followed by the+    footer of the Series text repr, so the result is visually distinct from+    a one-column DataFrame.+    """++    def __init__(self, formatter: DataFrameFormatter, footer: str) -> None:+        super().__init__(formatter)+        self.footer = footer++    def _write_footer(self) -> None:+        if self.footer:+            esc = {"&": r"&amp;", "<": r"&lt;", ">": r"&gt;"}+            lines = [+                pprint_thing(line, escape_chars=esc) for line in self.footer.split("\n")+            ]+            self.write(f"<p>{'<br>'.join(lines)}</p>")
pandas/tests/io/formats/test_ipython_compat.py2 + / 6 −
@@ -10,13 +10,9 @@ class TestTableSchemaRepr:     def test_publishes(self, ip):         ipython = ip.instance(config=ip.config)         df = pd.DataFrame({"A": [1, 2]})-        objects = [df["A"], df]  # dataframe / series-        expected_keys = [-            {"text/plain", "application/vnd.dataresource+json"},-            {"text/plain", "text/html", "application/vnd.dataresource+json"},-        ]+        expected = {"text/plain", "text/html", "application/vnd.dataresource+json"} -        for obj, expected in zip(objects, expected_keys, strict=True):+        for obj in [df["A"], df]:             with cf.option_context("display.html.table_schema", True):                 # Can't reuse an option_context on all systems GH#58055                 formatted = ipython.display_formatter.format(obj)
pandas/tests/io/formats/test_to_html.py106 + / 0 −
@@ -1194,3 +1194,109 @@ def test_to_html_empty_complex_array():         "</table>"     )     assert result == expected+++def test_series_repr_html():+    # GH#5563 no column header, so it is distinct from a one-column DataFrame+    ser = pd.Series([1, 2], index=pd.Index(["a", "b"], name="idx"), name="x")+    result = ser._repr_html_()+    expected = (+        '<table border="1" class="dataframe">\n'+        "  <thead>\n"+        "    <tr>\n"+        "      <th>idx</th>\n"+        "      <th></th>\n"+        "    </tr>\n"+        "  </thead>\n"+        "  <tbody>\n"+        "    <tr>\n"+        "      <th>a</th>\n"+        "      <td>1</td>\n"+        "    </tr>\n"+        "    <tr>\n"+        "      <th>b</th>\n"+        "      <td>2</td>\n"+        "    </tr>\n"+        "  </tbody>\n"+        "</table>\n"+        "<p>Name: x, dtype: int64</p>\n"+        "</div>"+    )+    assert result.startswith("<div>\n<style scoped>")+    assert result[result.index("<table") :] == expected+++@pytest.mark.parametrize(+    "ser, footer",+    [+        (pd.Series([1.5]), "dtype: float64"),+        (pd.Series([], dtype="int64"), "dtype: int64"),+        (+            pd.Series([1], dtype="category"),+            "dtype: category<br>Categories (1, int64): [1]",+        ),+        (+            pd.Series([1], index=pd.date_range("2020", periods=1, freq="D")),+            "Freq: D, dtype: int64",+        ),+        (pd.Series([1], name="<a&b>"), "Name: &lt;a&amp;b&gt;, dtype: int64"),+    ],+)+def test_series_repr_html_footer(ser, footer):+    # GH#5563 footer matches the text repr+    result = ser._repr_html_()+    assert f"</table>\n<p>{footer}</p>\n</div>" in result+++@pytest.mark.parametrize("show_dimensions", ["truncate", True, False])+@pytest.mark.parametrize("length, truncated", [(10, False), (11, True)])+def test_series_repr_html_truncated(length, truncated, show_dimensions):+    # GH#5563+    ser = pd.Series(range(length))+    with pd.option_context(+        "display.max_rows",+        10,+        "display.min_rows",+        4,+        "display.show_dimensions",+        show_dimensions,+    ):+        result = ser._repr_html_()+    assert ("<td>...</td>" in result) is truncated+    # min_rows=4 data rows plus the "..." row+    assert result.count("<tr>") == (5 if truncated else length)+    show_length = show_dimensions is True or (show_dimensions and truncated)+    assert (f"Length: {length}" in result) is show_length+++def test_series_repr_html_multiindex():+    # GH#5563 index names get a header row, values column has no label+    mi = pd.MultiIndex.from_tuples([("a", 1), ("a", 2)], names=["k", None])+    result = pd.Series([1, 2], index=mi, name="x")._repr_html_()+    expected = (+        "  <thead>\n"+        "    <tr>\n"+        "      <th>k</th>\n"+        "      <th></th>\n"+        "      <th></th>\n"+        "    </tr>\n"+        "  </thead>\n"+        "  <tbody>\n"+        "    <tr>\n"+        '      <th rowspan="2" valign="top">a</th>\n'+        "      <th>1</th>\n"+        "      <td>1</td>\n"+        "    </tr>\n"+        "    <tr>\n"+        "      <th>2</th>\n"+        "      <td>2</td>\n"+        "    </tr>\n"+        "  </tbody>\n"+    )+    assert expected in result+++def test_series_repr_html_disabled():+    # GH#5563+    with pd.option_context("display.notebook_repr_html", False):+        assert pd.Series([1])._repr_html_() is None