pandas-dev/pandas · #71688
ENH: add an HTML repr for Series (GH#5563)
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"&", "<": r"<", ">": r">"}+ 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: <a&b>, 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