diff --git a/pandas/io/formats/style.py b/pandas/io/formats/style.py
index 267606461f003..587a167708468 100644
--- a/pandas/io/formats/style.py
+++ b/pandas/io/formats/style.py
@@ -3,31 +3,19 @@
"""
from __future__ import annotations
-from collections import defaultdict
from contextlib import contextmanager
import copy
from functools import partial
from typing import (
Any,
Callable,
- DefaultDict,
- Dict,
Hashable,
- List,
- Optional,
Sequence,
- Tuple,
- Union,
- cast,
)
-from uuid import uuid4
import warnings
import numpy as np
-from pandas._config import get_option
-
-from pandas._libs import lib
from pandas._typing import (
Axis,
FrameOrSeries,
@@ -37,8 +25,6 @@
from pandas.compat._optional import import_optional_dependency
from pandas.util._decorators import doc
-from pandas.core.dtypes.generic import ABCSeries
-
import pandas as pd
from pandas.api.types import is_list_like
from pandas.core import generic
@@ -48,20 +34,17 @@
Series,
)
from pandas.core.generic import NDFrame
-from pandas.core.indexes.api import Index
jinja2 = import_optional_dependency("jinja2", extra="DataFrame.style requires jinja2.")
-from markupsafe import escape as escape_func # markupsafe is jinja2 dependency
-
-BaseFormatter = Union[str, Callable]
-ExtFormatter = Union[BaseFormatter, Dict[Any, Optional[BaseFormatter]]]
-CSSPair = Tuple[str, Union[str, int, float]]
-CSSList = List[CSSPair]
-CSSProperties = Union[str, CSSList]
-CSSStyles = List[Dict[str, CSSProperties]] # = List[CSSDict]
-# class CSSDict(TypedDict): # available when TypedDict is valid in pandas
-# selector: str
-# props: CSSProperties
+
+from pandas.io.formats.style_render import (
+ CSSProperties,
+ CSSStyles,
+ StylerRenderer,
+ Tooltips,
+ maybe_convert_css_to_tuples,
+ non_reducing_slice,
+)
try:
from matplotlib import colors
@@ -81,7 +64,7 @@ def _mpl(func: Callable):
raise ImportError(no_mpl_message.format(func.__name__))
-class Styler:
+class Styler(StylerRenderer):
"""
Helps style a DataFrame or Series according to the data with HTML and CSS.
@@ -164,10 +147,6 @@ class Styler:
* Data cells include ``data``
"""
- loader = jinja2.PackageLoader("pandas", "io/formats/templates")
- env = jinja2.Environment(loader=loader, trim_blocks=True)
- template = env.get_template("html.tpl")
-
def __init__(
self,
data: FrameOrSeriesUnion,
@@ -181,36 +160,17 @@ def __init__(
uuid_len: int = 5,
escape: bool = False,
):
+ super().__init__(
+ data=data,
+ uuid=uuid,
+ uuid_len=uuid_len,
+ table_styles=table_styles,
+ table_attributes=table_attributes,
+ caption=caption,
+ cell_ids=cell_ids,
+ )
+
# validate ordered args
- if isinstance(data, Series):
- data = data.to_frame()
- if not isinstance(data, DataFrame):
- raise TypeError("``data`` must be a Series or DataFrame")
- if not data.index.is_unique or not data.columns.is_unique:
- raise ValueError("style is not supported for non-unique indices.")
- self.data: DataFrame = data
- self.index: Index = data.index
- self.columns: Index = data.columns
- self.table_styles = table_styles
- if not isinstance(uuid_len, int) or not uuid_len >= 0:
- raise TypeError("``uuid_len`` must be an integer in range [0, 32].")
- self.uuid_len = min(32, uuid_len)
- self.uuid = (uuid or uuid4().hex[: self.uuid_len]) + "_"
- self.caption = caption
- self.table_attributes = table_attributes
- self.cell_ids = cell_ids
-
- # assign additional default vars
- self.hidden_index: bool = False
- self.hidden_columns: Sequence[int] = []
- self.ctx: DefaultDict[tuple[int, int], CSSList] = defaultdict(list)
- self.cell_context: DefaultDict[tuple[int, int], str] = defaultdict(str)
- self._todo: list[tuple[Callable, tuple, dict]] = []
- self.tooltips: _Tooltips | None = None
- def_precision = get_option("display.precision")
- self._display_funcs: DefaultDict[ # maps (row, col) -> formatting function
- tuple[int, int], Callable[[Any], str]
- ] = defaultdict(lambda: partial(_default_formatter, precision=def_precision))
self.precision = precision # can be removed on set_precision depr cycle
self.na_rep = na_rep # can be removed on set_na_rep depr cycle
self.format(formatter=None, precision=precision, na_rep=na_rep, escape=escape)
@@ -303,7 +263,7 @@ def set_tooltips(
"Tooltips can only render with 'cell_ids' is True."
)
if self.tooltips is None: # create a default instance if necessary
- self.tooltips = _Tooltips()
+ self.tooltips = Tooltips()
self.tooltips.tt_data = ttips
if props:
self.tooltips.class_properties = props
@@ -359,347 +319,6 @@ def to_excel(
engine=engine,
)
- def _translate(self):
- """
- Convert the DataFrame in `self.data` and the attrs from `_build_styles`
- into a dictionary of {head, body, uuid, cellstyle}.
- """
- ROW_HEADING_CLASS = "row_heading"
- COL_HEADING_CLASS = "col_heading"
- INDEX_NAME_CLASS = "index_name"
-
- DATA_CLASS = "data"
- BLANK_CLASS = "blank"
- BLANK_VALUE = " "
-
- # mapping variables
- ctx = self.ctx # td css styles from apply() and applymap()
- cell_context = self.cell_context # td css classes from set_td_classes()
- cellstyle_map: DefaultDict[tuple[CSSPair, ...], list[str]] = defaultdict(list)
-
- # copied attributes
- hidden_index = self.hidden_index
- hidden_columns = self.hidden_columns
-
- # construct render dict
- d = {
- "uuid": self.uuid,
- "table_styles": _format_table_styles(self.table_styles or []),
- "caption": self.caption,
- }
-
- # for sparsifying a MultiIndex
- idx_lengths = _get_level_lengths(self.index)
- col_lengths = _get_level_lengths(self.columns, hidden_columns)
-
- n_rlvls = self.data.index.nlevels
- n_clvls = self.data.columns.nlevels
- rlabels = self.data.index.tolist()
- clabels = self.data.columns.tolist()
-
- if n_rlvls == 1:
- rlabels = [[x] for x in rlabels]
- if n_clvls == 1:
- clabels = [[x] for x in clabels]
- clabels = list(zip(*clabels))
-
- head = []
- for r in range(n_clvls):
- # Blank for Index columns...
- row_es = [
- {
- "type": "th",
- "value": BLANK_VALUE,
- "display_value": BLANK_VALUE,
- "is_visible": not hidden_index,
- "class": " ".join([BLANK_CLASS]),
- }
- ] * (n_rlvls - 1)
-
- # ... except maybe the last for columns.names
- name = self.data.columns.names[r]
- cs = [
- BLANK_CLASS if name is None else INDEX_NAME_CLASS,
- f"level{r}",
- ]
- name = BLANK_VALUE if name is None else name
- row_es.append(
- {
- "type": "th",
- "value": name,
- "display_value": name,
- "class": " ".join(cs),
- "is_visible": not hidden_index,
- }
- )
-
- if clabels:
- for c, value in enumerate(clabels[r]):
- es = {
- "type": "th",
- "value": value,
- "display_value": value,
- "class": f"{COL_HEADING_CLASS} level{r} col{c}",
- "is_visible": _is_visible(c, r, col_lengths),
- }
- colspan = col_lengths.get((r, c), 0)
- if colspan > 1:
- es["attributes"] = f'colspan="{colspan}"'
- row_es.append(es)
- head.append(row_es)
-
- if (
- self.data.index.names
- and com.any_not_none(*self.data.index.names)
- and not hidden_index
- ):
- index_header_row = []
-
- for c, name in enumerate(self.data.index.names):
- cs = [INDEX_NAME_CLASS, f"level{c}"]
- name = "" if name is None else name
- index_header_row.append(
- {"type": "th", "value": name, "class": " ".join(cs)}
- )
-
- index_header_row.extend(
- [
- {
- "type": "th",
- "value": BLANK_VALUE,
- "class": " ".join([BLANK_CLASS, f"col{c}"]),
- }
- for c in range(len(clabels[0]))
- if c not in hidden_columns
- ]
- )
-
- head.append(index_header_row)
- d.update({"head": head})
-
- body = []
- for r, row_tup in enumerate(self.data.itertuples()):
- row_es = []
- for c, value in enumerate(rlabels[r]):
- rid = [
- ROW_HEADING_CLASS,
- f"level{c}",
- f"row{r}",
- ]
- es = {
- "type": "th",
- "is_visible": (_is_visible(r, c, idx_lengths) and not hidden_index),
- "value": value,
- "display_value": value,
- "id": "_".join(rid[1:]),
- "class": " ".join(rid),
- }
- rowspan = idx_lengths.get((c, r), 0)
- if rowspan > 1:
- es["attributes"] = f'rowspan="{rowspan}"'
- row_es.append(es)
-
- for c, value in enumerate(row_tup[1:]):
- formatter = self._display_funcs[(r, c)]
- row_dict = {
- "type": "td",
- "value": value,
- "display_value": formatter(value),
- "is_visible": (c not in hidden_columns),
- "attributes": "",
- }
-
- # only add an id if the cell has a style
- props: CSSList = []
- if self.cell_ids or (r, c) in ctx:
- row_dict["id"] = f"row{r}_col{c}"
- props.extend(ctx[r, c])
-
- # add custom classes from cell context
- cls = ""
- if (r, c) in cell_context:
- cls = " " + cell_context[r, c]
- row_dict["class"] = f"{DATA_CLASS} row{r} col{c}{cls}"
-
- row_es.append(row_dict)
- if props: # (), [] won't be in cellstyle_map, cellstyle respectively
- cellstyle_map[tuple(props)].append(f"row{r}_col{c}")
- body.append(row_es)
- d.update({"body": body})
-
- cellstyle: list[dict[str, CSSList | list[str]]] = [
- {"props": list(props), "selectors": selectors}
- for props, selectors in cellstyle_map.items()
- ]
- d.update({"cellstyle": cellstyle})
-
- table_attr = self.table_attributes
- use_mathjax = get_option("display.html.use_mathjax")
- if not use_mathjax:
- table_attr = table_attr or ""
- if 'class="' in table_attr:
- table_attr = table_attr.replace('class="', 'class="tex2jax_ignore ')
- else:
- table_attr += ' class="tex2jax_ignore"'
- d.update({"table_attributes": table_attr})
-
- if self.tooltips:
- d = self.tooltips._translate(self.data, self.uuid, d)
-
- return d
-
- def format(
- self,
- formatter: ExtFormatter | None = None,
- subset: slice | Sequence[Any] | None = None,
- na_rep: str | None = None,
- precision: int | None = None,
- escape: bool = False,
- ) -> Styler:
- """
- Format the text display value of cells.
-
- Parameters
- ----------
- formatter : str, callable, dict or None
- Object to define how values are displayed. See notes.
- subset : IndexSlice
- An argument to ``DataFrame.loc`` that restricts which elements
- ``formatter`` is applied to.
- na_rep : str, optional
- Representation for missing values.
- If ``na_rep`` is None, no special formatting is applied.
-
- .. versionadded:: 1.0.0
-
- precision : int, optional
- Floating point precision to use for display purposes, if not determined by
- the specified ``formatter``.
-
- .. versionadded:: 1.3.0
-
- escape : bool, default False
- Replace the characters ``&``, ``<``, ``>``, ``'``, and ``"`` in cell display
- string with HTML-safe sequences. Escaping is done before ``formatter``.
-
- .. versionadded:: 1.3.0
-
- Returns
- -------
- self : Styler
-
- Notes
- -----
- This method assigns a formatting function, ``formatter``, to each cell in the
- DataFrame. If ``formatter`` is ``None``, then the default formatter is used.
- If a callable then that function should take a data value as input and return
- a displayable representation, such as a string. If ``formatter`` is
- given as a string this is assumed to be a valid Python format specification
- and is wrapped to a callable as ``string.format(x)``. If a ``dict`` is given,
- keys should correspond to column names, and values should be string or
- callable, as above.
-
- The default formatter currently expresses floats and complex numbers with the
- pandas display precision unless using the ``precision`` argument here. The
- default formatter does not adjust the representation of missing values unless
- the ``na_rep`` argument is used.
-
- The ``subset`` argument defines which region to apply the formatting function
- to. If the ``formatter`` argument is given in dict form but does not include
- all columns within the subset then these columns will have the default formatter
- applied. Any columns in the formatter dict excluded from the subset will
- raise a ``KeyError``.
-
- When using a ``formatter`` string the dtypes must be compatible, otherwise a
- `ValueError` will be raised.
-
- Examples
- --------
- Using ``na_rep`` and ``precision`` with the default ``formatter``
-
- >>> df = pd.DataFrame([[np.nan, 1.0, 'A'], [2.0, np.nan, 3.0]])
- >>> df.style.format(na_rep='MISS', precision=3)
- 0 1 2
- 0 MISS 1.000 A
- 1 2.000 MISS 3.000
-
- Using a ``formatter`` specification on consistent column dtypes
-
- >>> df.style.format('{:.2f}', na_rep='MISS', subset=[0,1])
- 0 1 2
- 0 MISS 1.00 A
- 1 2.00 MISS 3.000000
-
- Using the default ``formatter`` for unspecified columns
-
- >>> df.style.format({0: '{:.2f}', 1: '£ {:.1f}'}, na_rep='MISS', precision=1)
- 0 1 2
- 0 MISS £ 1.0 A
- 1 2.00 MISS 3.0
-
- Multiple ``na_rep`` or ``precision`` specifications under the default
- ``formatter``.
-
- >>> df.style.format(na_rep='MISS', precision=1, subset=[0])
- ... .format(na_rep='PASS', precision=2, subset=[1, 2])
- 0 1 2
- 0 MISS 1.00 A
- 1 2.0 PASS 3.00
-
- Using a callable ``formatter`` function.
-
- >>> func = lambda s: 'STRING' if isinstance(s, str) else 'FLOAT'
- >>> df.style.format({0: '{:.1f}', 2: func}, precision=4, na_rep='MISS')
- 0 1 2
- 0 MISS 1.0000 STRING
- 1 2.0 MISS FLOAT
-
- Using a ``formatter`` with HTML ``escape`` and ``na_rep``.
-
- >>> df = pd.DataFrame([['
', '"A&B"', None]])
- >>> s = df.style.format('{0}', escape=True, na_rep="NA")
- >>> s.render()
- ...
- <div></div> |
- "A&B" |
- NA |
- ...
- """
- if all(
- (
- formatter is None,
- subset is None,
- precision is None,
- na_rep is None,
- escape is False,
- )
- ):
- self._display_funcs.clear()
- return self # clear the formatter / revert to default and avoid looping
-
- subset = slice(None) if subset is None else subset
- subset = _non_reducing_slice(subset)
- data = self.data.loc[subset]
-
- columns = data.columns
- if not isinstance(formatter, dict):
- formatter = {col: formatter for col in columns}
-
- for col in columns:
- try:
- format_func = formatter[col]
- except KeyError:
- format_func = None
- format_func = _maybe_wrap_formatter(
- format_func, na_rep=na_rep, precision=precision, escape=escape
- )
-
- for row, value in data[[col]].itertuples():
- i, j = self.index.get_loc(row), self.columns.get_loc(col)
- self._display_funcs[(i, j)] = format_func
-
- return self
-
def set_td_classes(self, classes: DataFrame) -> Styler:
"""
Set the DataFrame of strings added to the ``class`` attribute of ````
@@ -772,49 +391,6 @@ def set_td_classes(self, classes: DataFrame) -> Styler:
return self
- def render(self, **kwargs) -> str:
- """
- Render the ``Styler`` including all applied styles to HTML.
-
- Parameters
- ----------
- **kwargs
- Any additional keyword arguments are passed
- through to ``self.template.render``.
- This is useful when you need to provide
- additional variables for a custom template.
-
- Returns
- -------
- rendered : str
- The rendered HTML.
-
- Notes
- -----
- Styler objects have defined the ``_repr_html_`` method
- which automatically calls ``self.render()`` when it's the
- last item in a Notebook cell. When calling ``Styler.render()``
- directly, wrap the result in ``IPython.display.HTML`` to view
- the rendered HTML in the notebook.
-
- Pandas uses the following keys in render. Arguments passed
- in ``**kwargs`` take precedence, so think carefully if you want
- to override them:
-
- * head
- * cellstyle
- * body
- * uuid
- * table_styles
- * caption
- * table_attributes
- """
- self._compute()
- # TODO: namespace all the pandas keys
- d = self._translate()
- d.update(kwargs)
- return self.template.render(**d)
-
def _update_ctx(self, attrs: DataFrame) -> None:
"""
Update the state of the ``Styler`` for data cells.
@@ -832,7 +408,7 @@ def _update_ctx(self, attrs: DataFrame) -> None:
for rn, c in attrs[[cn]].itertuples():
if not c:
continue
- css_list = _maybe_convert_css_to_tuples(c)
+ css_list = maybe_convert_css_to_tuples(c)
i, j = self.index.get_loc(rn), self.columns.get_loc(cn)
self.ctx[(i, j)].extend(css_list)
@@ -891,21 +467,6 @@ def clear(self) -> None:
# self.format and self.table_styles may be dependent on user
# input in self.__init__()
- def _compute(self):
- """
- Execute the style functions built up in `self._todo`.
-
- Relies on the conventions that all style functions go through
- .apply or .applymap. The append styles to apply as tuples of
-
- (application method, *args, **kwargs)
- """
- self.ctx.clear()
- r = self
- for func, args, kwargs in self._todo:
- r = func(self)(*args, **kwargs)
- return r
-
def _apply(
self,
func: Callable[..., Styler],
@@ -914,7 +475,7 @@ def _apply(
**kwargs,
) -> Styler:
subset = slice(None) if subset is None else subset
- subset = _non_reducing_slice(subset)
+ subset = non_reducing_slice(subset)
data = self.data.loc[subset]
if axis is not None:
result = data.apply(func, axis=axis, result_type="expand", **kwargs)
@@ -1021,7 +582,7 @@ def _applymap(self, func: Callable, subset=None, **kwargs) -> Styler:
func = partial(func, **kwargs) # applymap doesn't take kwargs?
if subset is None:
subset = pd.IndexSlice[:]
- subset = _non_reducing_slice(subset)
+ subset = non_reducing_slice(subset)
result = self.data.loc[subset].applymap(func)
self._update_ctx(result)
return self
@@ -1120,7 +681,7 @@ def where(
lambda val: value if cond(val) else other, subset=subset, **kwargs
)
- def set_precision(self, precision: int) -> Styler:
+ def set_precision(self, precision: int) -> StylerRenderer:
"""
Set the precision used to display values.
@@ -1346,7 +907,7 @@ def set_table_styles(
table_styles = [
{
"selector": str(s["selector"]) + idf + str(obj.get_loc(key)),
- "props": _maybe_convert_css_to_tuples(s["props"]),
+ "props": maybe_convert_css_to_tuples(s["props"]),
}
for key, styles in table_styles.items()
for s in styles
@@ -1355,7 +916,7 @@ def set_table_styles(
table_styles = [
{
"selector": s["selector"],
- "props": _maybe_convert_css_to_tuples(s["props"]),
+ "props": maybe_convert_css_to_tuples(s["props"]),
}
for s in table_styles
]
@@ -1366,7 +927,7 @@ def set_table_styles(
self.table_styles = table_styles
return self
- def set_na_rep(self, na_rep: str) -> Styler:
+ def set_na_rep(self, na_rep: str) -> StylerRenderer:
"""
Set the missing data representation on a ``Styler``.
@@ -1419,7 +980,7 @@ def hide_columns(self, subset) -> Styler:
-------
self : Styler
"""
- subset = _non_reducing_slice(subset)
+ subset = non_reducing_slice(subset)
hidden_df = self.data.loc[subset]
hcols = self.columns.get_indexer_for(hidden_df.columns)
# error: Incompatible types in assignment (expression has type
@@ -1998,363 +1559,6 @@ def pipe(self, func: Callable, *args, **kwargs):
return com.pipe(self, func, *args, **kwargs)
-class _Tooltips:
- """
- An extension to ``Styler`` that allows for and manipulates tooltips on hover
- of `` | `` cells in the HTML result.
-
- Parameters
- ----------
- css_name: str, default "pd-t"
- Name of the CSS class that controls visualisation of tooltips.
- css_props: list-like, default; see Notes
- List of (attr, value) tuples defining properties of the CSS class.
- tooltips: DataFrame, default empty
- DataFrame of strings aligned with underlying Styler data for tooltip
- display.
-
- Notes
- -----
- The default properties for the tooltip CSS class are:
-
- - visibility: hidden
- - position: absolute
- - z-index: 1
- - background-color: black
- - color: white
- - transform: translate(-20px, -20px)
-
- Hidden visibility is a key prerequisite to the hover functionality, and should
- always be included in any manual properties specification.
- """
-
- def __init__(
- self,
- css_props: CSSProperties = [
- ("visibility", "hidden"),
- ("position", "absolute"),
- ("z-index", 1),
- ("background-color", "black"),
- ("color", "white"),
- ("transform", "translate(-20px, -20px)"),
- ],
- css_name: str = "pd-t",
- tooltips: DataFrame = DataFrame(),
- ):
- self.class_name = css_name
- self.class_properties = css_props
- self.tt_data = tooltips
- self.table_styles: CSSStyles = []
-
- @property
- def _class_styles(self):
- """
- Combine the ``_Tooltips`` CSS class name and CSS properties to the format
- required to extend the underlying ``Styler`` `table_styles` to allow
- tooltips to render in HTML.
-
- Returns
- -------
- styles : List
- """
- return [
- {
- "selector": f".{self.class_name}",
- "props": _maybe_convert_css_to_tuples(self.class_properties),
- }
- ]
-
- def _pseudo_css(self, uuid: str, name: str, row: int, col: int, text: str):
- """
- For every table data-cell that has a valid tooltip (not None, NaN or
- empty string) must create two pseudo CSS entries for the specific
- | element id which are added to overall table styles:
- an on hover visibility change and a content change
- dependent upon the user's chosen display string.
-
- For example:
- [{"selector": "T__row1_col1:hover .pd-t",
- "props": [("visibility", "visible")]},
- {"selector": "T__row1_col1 .pd-t::after",
- "props": [("content", "Some Valid Text String")]}]
-
- Parameters
- ----------
- uuid: str
- The uuid of the Styler instance
- name: str
- The css-name of the class used for styling tooltips
- row : int
- The row index of the specified tooltip string data
- col : int
- The col index of the specified tooltip string data
- text : str
- The textual content of the tooltip to be displayed in HTML.
-
- Returns
- -------
- pseudo_css : List
- """
- selector_id = "#T_" + uuid + "row" + str(row) + "_col" + str(col)
- return [
- {
- "selector": selector_id + f":hover .{name}",
- "props": [("visibility", "visible")],
- },
- {
- "selector": selector_id + f" .{name}::after",
- "props": [("content", f'"{text}"')],
- },
- ]
-
- def _translate(self, styler_data: FrameOrSeriesUnion, uuid: str, d: dict):
- """
- Mutate the render dictionary to allow for tooltips:
-
- - Add ```` HTML element to each data cells ``display_value``. Ignores
- headers.
- - Add table level CSS styles to control pseudo classes.
-
- Parameters
- ----------
- styler_data : DataFrame
- Underlying ``Styler`` DataFrame used for reindexing.
- uuid : str
- The underlying ``Styler`` uuid for CSS id.
- d : dict
- The dictionary prior to final render
-
- Returns
- -------
- render_dict : Dict
- """
- self.tt_data = self.tt_data.reindex_like(styler_data)
-
- if self.tt_data.empty:
- return d
-
- name = self.class_name
-
- mask = (self.tt_data.isna()) | (self.tt_data.eq("")) # empty string = no ttip
- self.table_styles = [
- style
- for sublist in [
- self._pseudo_css(uuid, name, i, j, str(self.tt_data.iloc[i, j]))
- for i in range(len(self.tt_data.index))
- for j in range(len(self.tt_data.columns))
- if not mask.iloc[i, j]
- ]
- for style in sublist
- ]
-
- if self.table_styles:
- # add span class to every cell only if at least 1 non-empty tooltip
- for row in d["body"]:
- for item in row:
- if item["type"] == "td":
- item["display_value"] = (
- str(item["display_value"])
- + f''
- )
- d["table_styles"].extend(self._class_styles)
- d["table_styles"].extend(self.table_styles)
-
- return d
-
-
-def _is_visible(idx_row, idx_col, lengths) -> bool:
- """
- Index -> {(idx_row, idx_col): bool}).
- """
- return (idx_col, idx_row) in lengths
-
-
-def _get_level_lengths(index, hidden_elements=None):
- """
- Given an index, find the level length for each element.
-
- Optional argument is a list of index positions which
- should not be visible.
-
- Result is a dictionary of (level, initial_position): span
- """
- if isinstance(index, pd.MultiIndex):
- levels = index.format(sparsify=lib.no_default, adjoin=False)
- else:
- levels = index.format()
-
- if hidden_elements is None:
- hidden_elements = []
-
- lengths = {}
- if index.nlevels == 1:
- for i, value in enumerate(levels):
- if i not in hidden_elements:
- lengths[(0, i)] = 1
- return lengths
-
- for i, lvl in enumerate(levels):
- for j, row in enumerate(lvl):
- if not get_option("display.multi_sparse"):
- lengths[(i, j)] = 1
- elif (row is not lib.no_default) and (j not in hidden_elements):
- last_label = j
- lengths[(i, last_label)] = 1
- elif row is not lib.no_default:
- # even if its hidden, keep track of it in case
- # length >1 and later elements are visible
- last_label = j
- lengths[(i, last_label)] = 0
- elif j not in hidden_elements:
- lengths[(i, last_label)] += 1
-
- non_zero_lengths = {
- element: length for element, length in lengths.items() if length >= 1
- }
-
- return non_zero_lengths
-
-
-def _default_formatter(x: Any, precision: int) -> Any:
- """
- Format the display of a value
-
- Parameters
- ----------
- x : Any
- Input variable to be formatted
- precision : Int
- Floating point precision used if ``x`` is float or complex.
-
- Returns
- -------
- value : Any
- Matches input type, or string if input is float or complex.
- """
- if isinstance(x, (float, complex)):
- return f"{x:.{precision}f}"
- return x
-
-
-def _maybe_wrap_formatter(
- formatter: BaseFormatter | None = None,
- na_rep: str | None = None,
- precision: int | None = None,
- escape: bool = False,
-) -> Callable:
- """
- Allows formatters to be expressed as str, callable or None, where None returns
- a default formatting function. wraps with na_rep, and precision where they are
- available.
- """
- if isinstance(formatter, str):
- formatter_func = lambda x: formatter.format(x)
- elif callable(formatter):
- formatter_func = formatter
- elif formatter is None:
- precision = get_option("display.precision") if precision is None else precision
- formatter_func = partial(_default_formatter, precision=precision)
- else:
- raise TypeError(f"'formatter' expected str or callable, got {type(formatter)}")
-
- def _str_escape(x, escape: bool):
- """if escaping: only use on str, else return input"""
- if escape and isinstance(x, str):
- return escape_func(x)
- else:
- return x
-
- display_func = lambda x: formatter_func(partial(_str_escape, escape=escape)(x))
-
- if na_rep is None:
- return display_func
- else:
- return lambda x: na_rep if pd.isna(x) else display_func(x)
-
-
-def _maybe_convert_css_to_tuples(style: CSSProperties) -> CSSList:
- """
- Convert css-string to sequence of tuples format if needed.
- 'color:red; border:1px solid black;' -> [('color', 'red'),
- ('border','1px solid red')]
- """
- if isinstance(style, str):
- s = style.split(";")
- try:
- return [
- (x.split(":")[0].strip(), x.split(":")[1].strip())
- for x in s
- if x.strip() != ""
- ]
- except IndexError:
- raise ValueError(
- "Styles supplied as string must follow CSS rule formats, "
- f"for example 'attr: val;'. '{style}' was given."
- )
- return style
-
-
-def _format_table_styles(styles: CSSStyles) -> CSSStyles:
- """
- looks for multiple CSS selectors and separates them:
- [{'selector': 'td, th', 'props': 'a:v;'}]
- ---> [{'selector': 'td', 'props': 'a:v;'},
- {'selector': 'th', 'props': 'a:v;'}]
- """
- return [
- item
- for sublist in [
- [ # this is a CSSDict when TypedDict is available to avoid cast.
- {"selector": x, "props": style["props"]}
- for x in cast(str, style["selector"]).split(",")
- ]
- for style in styles
- ]
- for item in sublist
- ]
-
-
-def _non_reducing_slice(slice_):
- """
- Ensure that a slice doesn't reduce to a Series or Scalar.
-
- Any user-passed `subset` should have this called on it
- to make sure we're always working with DataFrames.
- """
- # default to column slice, like DataFrame
- # ['A', 'B'] -> IndexSlices[:, ['A', 'B']]
- kinds = (ABCSeries, np.ndarray, Index, list, str)
- if isinstance(slice_, kinds):
- slice_ = pd.IndexSlice[:, slice_]
-
- def pred(part) -> bool:
- """
- Returns
- -------
- bool
- True if slice does *not* reduce,
- False if `part` is a tuple.
- """
- # true when slice does *not* reduce, False when part is a tuple,
- # i.e. MultiIndex slice
- if isinstance(part, tuple):
- # GH#39421 check for sub-slice:
- return any((isinstance(s, slice) or is_list_like(s)) for s in part)
- else:
- return isinstance(part, slice) or is_list_like(part)
-
- if not is_list_like(slice_):
- if not isinstance(slice_, slice):
- # a 1-d slice, like df.loc[1]
- slice_ = [[slice_]]
- else:
- # slice(a, b, c)
- slice_ = [slice_] # to tuplize later
- else:
- slice_ = [part if pred(part) else [part] for part in slice_]
- return tuple(slice_)
-
-
def _validate_apply_axis_arg(
arg: FrameOrSeries | Sequence | np.ndarray,
arg_name: str,
diff --git a/pandas/io/formats/style_render.py b/pandas/io/formats/style_render.py
new file mode 100644
index 0000000000000..82f57b71caebf
--- /dev/null
+++ b/pandas/io/formats/style_render.py
@@ -0,0 +1,859 @@
+from __future__ import annotations
+
+from collections import defaultdict
+from functools import partial
+from typing import (
+ Any,
+ Callable,
+ DefaultDict,
+ Dict,
+ List,
+ Optional,
+ Sequence,
+ Tuple,
+ Union,
+ cast,
+)
+from uuid import uuid4
+
+import numpy as np
+
+from pandas._config import get_option
+
+from pandas._libs import lib
+from pandas._typing import FrameOrSeriesUnion
+from pandas.compat._optional import import_optional_dependency
+
+from pandas.core.dtypes.generic import ABCSeries
+
+from pandas import (
+ DataFrame,
+ Index,
+ IndexSlice,
+ MultiIndex,
+ Series,
+ isna,
+)
+from pandas.api.types import is_list_like
+import pandas.core.common as com
+
+jinja2 = import_optional_dependency("jinja2", extra="DataFrame.style requires jinja2.")
+from markupsafe import escape as escape_func # markupsafe is jinja2 dependency
+
+BaseFormatter = Union[str, Callable]
+ExtFormatter = Union[BaseFormatter, Dict[Any, Optional[BaseFormatter]]]
+CSSPair = Tuple[str, Union[str, int, float]]
+CSSList = List[CSSPair]
+CSSProperties = Union[str, CSSList]
+CSSStyles = List[Dict[str, CSSProperties]] # = List[CSSDict]
+# class CSSDict(TypedDict): # available when TypedDict is valid in pandas
+# selector: str
+# props: CSSProperties
+
+
+class StylerRenderer:
+ """
+ Base class to process rendering a Styler with a specified jinja2 template.
+ """
+
+ loader = jinja2.PackageLoader("pandas", "io/formats/templates")
+ env = jinja2.Environment(loader=loader, trim_blocks=True)
+ template = env.get_template("html.tpl")
+
+ def __init__(
+ self,
+ data: FrameOrSeriesUnion,
+ uuid: str | None = None,
+ uuid_len: int = 5,
+ table_styles: CSSStyles | None = None,
+ table_attributes: str | None = None,
+ caption: str | None = None,
+ cell_ids: bool = True,
+ ):
+
+ # validate ordered args
+ if isinstance(data, Series):
+ data = data.to_frame()
+ if not isinstance(data, DataFrame):
+ raise TypeError("``data`` must be a Series or DataFrame")
+ if not data.index.is_unique or not data.columns.is_unique:
+ raise ValueError("style is not supported for non-unique indices.")
+ self.data: DataFrame = data
+ self.index: Index = data.index
+ self.columns: Index = data.columns
+ if not isinstance(uuid_len, int) or not uuid_len >= 0:
+ raise TypeError("``uuid_len`` must be an integer in range [0, 32].")
+ self.uuid_len = min(32, uuid_len)
+ self.uuid = (uuid or uuid4().hex[: self.uuid_len]) + "_"
+ self.table_styles = table_styles
+ self.table_attributes = table_attributes
+ self.caption = caption
+ self.cell_ids = cell_ids
+
+ # add rendering variables
+ self.hidden_index: bool = False
+ self.hidden_columns: Sequence[int] = []
+ self.ctx: DefaultDict[tuple[int, int], CSSList] = defaultdict(list)
+ self.cell_context: DefaultDict[tuple[int, int], str] = defaultdict(str)
+ self._todo: list[tuple[Callable, tuple, dict]] = []
+ self.tooltips: Tooltips | None = None
+ def_precision = get_option("display.precision")
+ self._display_funcs: DefaultDict[ # maps (row, col) -> formatting function
+ tuple[int, int], Callable[[Any], str]
+ ] = defaultdict(lambda: partial(_default_formatter, precision=def_precision))
+
+ def render(self, **kwargs) -> str:
+ """
+ Render the ``Styler`` including all applied styles to HTML.
+
+ Parameters
+ ----------
+ **kwargs
+ Any additional keyword arguments are passed
+ through to ``self.template.render``.
+ This is useful when you need to provide
+ additional variables for a custom template.
+
+ Returns
+ -------
+ rendered : str
+ The rendered HTML.
+
+ Notes
+ -----
+ Styler objects have defined the ``_repr_html_`` method
+ which automatically calls ``self.render()`` when it's the
+ last item in a Notebook cell. When calling ``Styler.render()``
+ directly, wrap the result in ``IPython.display.HTML`` to view
+ the rendered HTML in the notebook.
+
+ Pandas uses the following keys in render. Arguments passed
+ in ``**kwargs`` take precedence, so think carefully if you want
+ to override them:
+
+ * head
+ * cellstyle
+ * body
+ * uuid
+ * table_styles
+ * caption
+ * table_attributes
+ """
+ self._compute()
+ # TODO: namespace all the pandas keys
+ d = self._translate()
+ d.update(kwargs)
+ return self.template.render(**d)
+
+ def _compute(self):
+ """
+ Execute the style functions built up in `self._todo`.
+
+ Relies on the conventions that all style functions go through
+ .apply or .applymap. The append styles to apply as tuples of
+
+ (application method, *args, **kwargs)
+ """
+ self.ctx.clear()
+ r = self
+ for func, args, kwargs in self._todo:
+ r = func(self)(*args, **kwargs)
+ return r
+
+ def _translate(self):
+ """
+ Convert the DataFrame in `self.data` and the attrs from `_build_styles`
+ into a dictionary of {head, body, uuid, cellstyle}.
+ """
+ ROW_HEADING_CLASS = "row_heading"
+ COL_HEADING_CLASS = "col_heading"
+ INDEX_NAME_CLASS = "index_name"
+
+ DATA_CLASS = "data"
+ BLANK_CLASS = "blank"
+ BLANK_VALUE = " "
+
+ # mapping variables
+ ctx = self.ctx # td css styles from apply() and applymap()
+ cell_context = self.cell_context # td css classes from set_td_classes()
+ cellstyle_map: DefaultDict[tuple[CSSPair, ...], list[str]] = defaultdict(list)
+
+ # copied attributes
+ hidden_index = self.hidden_index
+ hidden_columns = self.hidden_columns
+
+ # construct render dict
+ d = {
+ "uuid": self.uuid,
+ "table_styles": _format_table_styles(self.table_styles or []),
+ "caption": self.caption,
+ }
+
+ # for sparsifying a MultiIndex
+ idx_lengths = _get_level_lengths(self.index)
+ col_lengths = _get_level_lengths(self.columns, hidden_columns)
+
+ n_rlvls = self.data.index.nlevels
+ n_clvls = self.data.columns.nlevels
+ rlabels = self.data.index.tolist()
+ clabels = self.data.columns.tolist()
+
+ if n_rlvls == 1:
+ rlabels = [[x] for x in rlabels]
+ if n_clvls == 1:
+ clabels = [[x] for x in clabels]
+ clabels = list(zip(*clabels))
+
+ head = []
+ for r in range(n_clvls):
+ # Blank for Index columns...
+ row_es = [
+ {
+ "type": "th",
+ "value": BLANK_VALUE,
+ "display_value": BLANK_VALUE,
+ "is_visible": not hidden_index,
+ "class": " ".join([BLANK_CLASS]),
+ }
+ ] * (n_rlvls - 1)
+
+ # ... except maybe the last for columns.names
+ name = self.data.columns.names[r]
+ cs = [
+ BLANK_CLASS if name is None else INDEX_NAME_CLASS,
+ f"level{r}",
+ ]
+ name = BLANK_VALUE if name is None else name
+ row_es.append(
+ {
+ "type": "th",
+ "value": name,
+ "display_value": name,
+ "class": " ".join(cs),
+ "is_visible": not hidden_index,
+ }
+ )
+
+ if clabels:
+ for c, value in enumerate(clabels[r]):
+ es = {
+ "type": "th",
+ "value": value,
+ "display_value": value,
+ "class": f"{COL_HEADING_CLASS} level{r} col{c}",
+ "is_visible": _is_visible(c, r, col_lengths),
+ }
+ colspan = col_lengths.get((r, c), 0)
+ if colspan > 1:
+ es["attributes"] = f'colspan="{colspan}"'
+ row_es.append(es)
+ head.append(row_es)
+
+ if (
+ self.data.index.names
+ and com.any_not_none(*self.data.index.names)
+ and not hidden_index
+ ):
+ index_header_row = []
+
+ for c, name in enumerate(self.data.index.names):
+ cs = [INDEX_NAME_CLASS, f"level{c}"]
+ name = "" if name is None else name
+ index_header_row.append(
+ {"type": "th", "value": name, "class": " ".join(cs)}
+ )
+
+ index_header_row.extend(
+ [
+ {
+ "type": "th",
+ "value": BLANK_VALUE,
+ "class": " ".join([BLANK_CLASS, f"col{c}"]),
+ }
+ for c in range(len(clabels[0]))
+ if c not in hidden_columns
+ ]
+ )
+
+ head.append(index_header_row)
+ d.update({"head": head})
+
+ body = []
+ for r, row_tup in enumerate(self.data.itertuples()):
+ row_es = []
+ for c, value in enumerate(rlabels[r]):
+ rid = [
+ ROW_HEADING_CLASS,
+ f"level{c}",
+ f"row{r}",
+ ]
+ es = {
+ "type": "th",
+ "is_visible": (_is_visible(r, c, idx_lengths) and not hidden_index),
+ "value": value,
+ "display_value": value,
+ "id": "_".join(rid[1:]),
+ "class": " ".join(rid),
+ }
+ rowspan = idx_lengths.get((c, r), 0)
+ if rowspan > 1:
+ es["attributes"] = f'rowspan="{rowspan}"'
+ row_es.append(es)
+
+ for c, value in enumerate(row_tup[1:]):
+ formatter = self._display_funcs[(r, c)]
+ row_dict = {
+ "type": "td",
+ "value": value,
+ "display_value": formatter(value),
+ "is_visible": (c not in hidden_columns),
+ "attributes": "",
+ }
+
+ # only add an id if the cell has a style
+ props: CSSList = []
+ if self.cell_ids or (r, c) in ctx:
+ row_dict["id"] = f"row{r}_col{c}"
+ props.extend(ctx[r, c])
+
+ # add custom classes from cell context
+ cls = ""
+ if (r, c) in cell_context:
+ cls = " " + cell_context[r, c]
+ row_dict["class"] = f"{DATA_CLASS} row{r} col{c}{cls}"
+
+ row_es.append(row_dict)
+ if props: # (), [] won't be in cellstyle_map, cellstyle respectively
+ cellstyle_map[tuple(props)].append(f"row{r}_col{c}")
+ body.append(row_es)
+ d.update({"body": body})
+
+ cellstyle: list[dict[str, CSSList | list[str]]] = [
+ {"props": list(props), "selectors": selectors}
+ for props, selectors in cellstyle_map.items()
+ ]
+ d.update({"cellstyle": cellstyle})
+
+ table_attr = self.table_attributes
+ use_mathjax = get_option("display.html.use_mathjax")
+ if not use_mathjax:
+ table_attr = table_attr or ""
+ if 'class="' in table_attr:
+ table_attr = table_attr.replace('class="', 'class="tex2jax_ignore ')
+ else:
+ table_attr += ' class="tex2jax_ignore"'
+ d.update({"table_attributes": table_attr})
+
+ if self.tooltips:
+ d = self.tooltips._translate(self.data, self.uuid, d)
+
+ return d
+
+ def format(
+ self,
+ formatter: ExtFormatter | None = None,
+ subset: slice | Sequence[Any] | None = None,
+ na_rep: str | None = None,
+ precision: int | None = None,
+ escape: bool = False,
+ ) -> StylerRenderer:
+ """
+ Format the text display value of cells.
+
+ Parameters
+ ----------
+ formatter : str, callable, dict or None
+ Object to define how values are displayed. See notes.
+ subset : IndexSlice
+ An argument to ``DataFrame.loc`` that restricts which elements
+ ``formatter`` is applied to.
+ na_rep : str, optional
+ Representation for missing values.
+ If ``na_rep`` is None, no special formatting is applied.
+
+ .. versionadded:: 1.0.0
+
+ precision : int, optional
+ Floating point precision to use for display purposes, if not determined by
+ the specified ``formatter``.
+
+ .. versionadded:: 1.3.0
+
+ escape : bool, default False
+ Replace the characters ``&``, ``<``, ``>``, ``'``, and ``"`` in cell display
+ string with HTML-safe sequences. Escaping is done before ``formatter``.
+
+ .. versionadded:: 1.3.0
+
+ Returns
+ -------
+ self : Styler
+
+ Notes
+ -----
+ This method assigns a formatting function, ``formatter``, to each cell in the
+ DataFrame. If ``formatter`` is ``None``, then the default formatter is used.
+ If a callable then that function should take a data value as input and return
+ a displayable representation, such as a string. If ``formatter`` is
+ given as a string this is assumed to be a valid Python format specification
+ and is wrapped to a callable as ``string.format(x)``. If a ``dict`` is given,
+ keys should correspond to column names, and values should be string or
+ callable, as above.
+
+ The default formatter currently expresses floats and complex numbers with the
+ pandas display precision unless using the ``precision`` argument here. The
+ default formatter does not adjust the representation of missing values unless
+ the ``na_rep`` argument is used.
+
+ The ``subset`` argument defines which region to apply the formatting function
+ to. If the ``formatter`` argument is given in dict form but does not include
+ all columns within the subset then these columns will have the default formatter
+ applied. Any columns in the formatter dict excluded from the subset will
+ raise a ``KeyError``.
+
+ When using a ``formatter`` string the dtypes must be compatible, otherwise a
+ `ValueError` will be raised.
+
+ Examples
+ --------
+ Using ``na_rep`` and ``precision`` with the default ``formatter``
+
+ >>> df = pd.DataFrame([[np.nan, 1.0, 'A'], [2.0, np.nan, 3.0]])
+ >>> df.style.format(na_rep='MISS', precision=3)
+ 0 1 2
+ 0 MISS 1.000 A
+ 1 2.000 MISS 3.000
+
+ Using a ``formatter`` specification on consistent column dtypes
+
+ >>> df.style.format('{:.2f}', na_rep='MISS', subset=[0,1])
+ 0 1 2
+ 0 MISS 1.00 A
+ 1 2.00 MISS 3.000000
+
+ Using the default ``formatter`` for unspecified columns
+
+ >>> df.style.format({0: '{:.2f}', 1: '£ {:.1f}'}, na_rep='MISS', precision=1)
+ 0 1 2
+ 0 MISS £ 1.0 A
+ 1 2.00 MISS 3.0
+
+ Multiple ``na_rep`` or ``precision`` specifications under the default
+ ``formatter``.
+
+ >>> df.style.format(na_rep='MISS', precision=1, subset=[0])
+ ... .format(na_rep='PASS', precision=2, subset=[1, 2])
+ 0 1 2
+ 0 MISS 1.00 A
+ 1 2.0 PASS 3.00
+
+ Using a callable ``formatter`` function.
+
+ >>> func = lambda s: 'STRING' if isinstance(s, str) else 'FLOAT'
+ >>> df.style.format({0: '{:.1f}', 2: func}, precision=4, na_rep='MISS')
+ 0 1 2
+ 0 MISS 1.0000 STRING
+ 1 2.0 MISS FLOAT
+
+ Using a ``formatter`` with HTML ``escape`` and ``na_rep``.
+
+ >>> df = pd.DataFrame([['', '"A&B"', None]])
+ >>> s = df.style.format('{0}', escape=True, na_rep="NA")
+ >>> s.render()
+ ...
+ | <div></div> |
+ "A&B" |
+ NA |
+ ...
+ """
+ if all(
+ (
+ formatter is None,
+ subset is None,
+ precision is None,
+ na_rep is None,
+ escape is False,
+ )
+ ):
+ self._display_funcs.clear()
+ return self # clear the formatter / revert to default and avoid looping
+
+ subset = slice(None) if subset is None else subset
+ subset = non_reducing_slice(subset)
+ data = self.data.loc[subset]
+
+ columns = data.columns
+ if not isinstance(formatter, dict):
+ formatter = {col: formatter for col in columns}
+
+ for col in columns:
+ try:
+ format_func = formatter[col]
+ except KeyError:
+ format_func = None
+ format_func = _maybe_wrap_formatter(
+ format_func, na_rep=na_rep, precision=precision, escape=escape
+ )
+
+ for row, value in data[[col]].itertuples():
+ i, j = self.index.get_loc(row), self.columns.get_loc(col)
+ self._display_funcs[(i, j)] = format_func
+
+ return self
+
+
+def _get_level_lengths(index, hidden_elements=None):
+ """
+ Given an index, find the level length for each element.
+
+ Optional argument is a list of index positions which
+ should not be visible.
+
+ Result is a dictionary of (level, initial_position): span
+ """
+ if isinstance(index, MultiIndex):
+ levels = index.format(sparsify=lib.no_default, adjoin=False)
+ else:
+ levels = index.format()
+
+ if hidden_elements is None:
+ hidden_elements = []
+
+ lengths = {}
+ if index.nlevels == 1:
+ for i, value in enumerate(levels):
+ if i not in hidden_elements:
+ lengths[(0, i)] = 1
+ return lengths
+
+ for i, lvl in enumerate(levels):
+ for j, row in enumerate(lvl):
+ if not get_option("display.multi_sparse"):
+ lengths[(i, j)] = 1
+ elif (row is not lib.no_default) and (j not in hidden_elements):
+ last_label = j
+ lengths[(i, last_label)] = 1
+ elif row is not lib.no_default:
+ # even if its hidden, keep track of it in case
+ # length >1 and later elements are visible
+ last_label = j
+ lengths[(i, last_label)] = 0
+ elif j not in hidden_elements:
+ lengths[(i, last_label)] += 1
+
+ non_zero_lengths = {
+ element: length for element, length in lengths.items() if length >= 1
+ }
+
+ return non_zero_lengths
+
+
+def _is_visible(idx_row, idx_col, lengths) -> bool:
+ """
+ Index -> {(idx_row, idx_col): bool}).
+ """
+ return (idx_col, idx_row) in lengths
+
+
+def _format_table_styles(styles: CSSStyles) -> CSSStyles:
+ """
+ looks for multiple CSS selectors and separates them:
+ [{'selector': 'td, th', 'props': 'a:v;'}]
+ ---> [{'selector': 'td', 'props': 'a:v;'},
+ {'selector': 'th', 'props': 'a:v;'}]
+ """
+ return [
+ item
+ for sublist in [
+ [ # this is a CSSDict when TypedDict is available to avoid cast.
+ {"selector": x, "props": style["props"]}
+ for x in cast(str, style["selector"]).split(",")
+ ]
+ for style in styles
+ ]
+ for item in sublist
+ ]
+
+
+def _default_formatter(x: Any, precision: int) -> Any:
+ """
+ Format the display of a value
+
+ Parameters
+ ----------
+ x : Any
+ Input variable to be formatted
+ precision : Int
+ Floating point precision used if ``x`` is float or complex.
+
+ Returns
+ -------
+ value : Any
+ Matches input type, or string if input is float or complex.
+ """
+ if isinstance(x, (float, complex)):
+ return f"{x:.{precision}f}"
+ return x
+
+
+def _maybe_wrap_formatter(
+ formatter: BaseFormatter | None = None,
+ na_rep: str | None = None,
+ precision: int | None = None,
+ escape: bool = False,
+) -> Callable:
+ """
+ Allows formatters to be expressed as str, callable or None, where None returns
+ a default formatting function. wraps with na_rep, and precision where they are
+ available.
+ """
+ if isinstance(formatter, str):
+ formatter_func = lambda x: formatter.format(x)
+ elif callable(formatter):
+ formatter_func = formatter
+ elif formatter is None:
+ precision = get_option("display.precision") if precision is None else precision
+ formatter_func = partial(_default_formatter, precision=precision)
+ else:
+ raise TypeError(f"'formatter' expected str or callable, got {type(formatter)}")
+
+ def _str_escape(x, escape: bool):
+ """if escaping: only use on str, else return input"""
+ if escape and isinstance(x, str):
+ return escape_func(x)
+ else:
+ return x
+
+ display_func = lambda x: formatter_func(partial(_str_escape, escape=escape)(x))
+
+ if na_rep is None:
+ return display_func
+ else:
+ return lambda x: na_rep if isna(x) else display_func(x)
+
+
+def non_reducing_slice(slice_):
+ """
+ Ensure that a slice doesn't reduce to a Series or Scalar.
+
+ Any user-passed `subset` should have this called on it
+ to make sure we're always working with DataFrames.
+ """
+ # default to column slice, like DataFrame
+ # ['A', 'B'] -> IndexSlices[:, ['A', 'B']]
+ kinds = (ABCSeries, np.ndarray, Index, list, str)
+ if isinstance(slice_, kinds):
+ slice_ = IndexSlice[:, slice_]
+
+ def pred(part) -> bool:
+ """
+ Returns
+ -------
+ bool
+ True if slice does *not* reduce,
+ False if `part` is a tuple.
+ """
+ # true when slice does *not* reduce, False when part is a tuple,
+ # i.e. MultiIndex slice
+ if isinstance(part, tuple):
+ # GH#39421 check for sub-slice:
+ return any((isinstance(s, slice) or is_list_like(s)) for s in part)
+ else:
+ return isinstance(part, slice) or is_list_like(part)
+
+ if not is_list_like(slice_):
+ if not isinstance(slice_, slice):
+ # a 1-d slice, like df.loc[1]
+ slice_ = [[slice_]]
+ else:
+ # slice(a, b, c)
+ slice_ = [slice_] # to tuplize later
+ else:
+ slice_ = [part if pred(part) else [part] for part in slice_]
+ return tuple(slice_)
+
+
+def maybe_convert_css_to_tuples(style: CSSProperties) -> CSSList:
+ """
+ Convert css-string to sequence of tuples format if needed.
+ 'color:red; border:1px solid black;' -> [('color', 'red'),
+ ('border','1px solid red')]
+ """
+ if isinstance(style, str):
+ s = style.split(";")
+ try:
+ return [
+ (x.split(":")[0].strip(), x.split(":")[1].strip())
+ for x in s
+ if x.strip() != ""
+ ]
+ except IndexError:
+ raise ValueError(
+ "Styles supplied as string must follow CSS rule formats, "
+ f"for example 'attr: val;'. '{style}' was given."
+ )
+ return style
+
+
+class Tooltips:
+ """
+ An extension to ``Styler`` that allows for and manipulates tooltips on hover
+ of ```` cells in the HTML result.
+
+ Parameters
+ ----------
+ css_name: str, default "pd-t"
+ Name of the CSS class that controls visualisation of tooltips.
+ css_props: list-like, default; see Notes
+ List of (attr, value) tuples defining properties of the CSS class.
+ tooltips: DataFrame, default empty
+ DataFrame of strings aligned with underlying Styler data for tooltip
+ display.
+
+ Notes
+ -----
+ The default properties for the tooltip CSS class are:
+
+ - visibility: hidden
+ - position: absolute
+ - z-index: 1
+ - background-color: black
+ - color: white
+ - transform: translate(-20px, -20px)
+
+ Hidden visibility is a key prerequisite to the hover functionality, and should
+ always be included in any manual properties specification.
+ """
+
+ def __init__(
+ self,
+ css_props: CSSProperties = [
+ ("visibility", "hidden"),
+ ("position", "absolute"),
+ ("z-index", 1),
+ ("background-color", "black"),
+ ("color", "white"),
+ ("transform", "translate(-20px, -20px)"),
+ ],
+ css_name: str = "pd-t",
+ tooltips: DataFrame = DataFrame(),
+ ):
+ self.class_name = css_name
+ self.class_properties = css_props
+ self.tt_data = tooltips
+ self.table_styles: CSSStyles = []
+
+ @property
+ def _class_styles(self):
+ """
+ Combine the ``_Tooltips`` CSS class name and CSS properties to the format
+ required to extend the underlying ``Styler`` `table_styles` to allow
+ tooltips to render in HTML.
+
+ Returns
+ -------
+ styles : List
+ """
+ return [
+ {
+ "selector": f".{self.class_name}",
+ "props": maybe_convert_css_to_tuples(self.class_properties),
+ }
+ ]
+
+ def _pseudo_css(self, uuid: str, name: str, row: int, col: int, text: str):
+ """
+ For every table data-cell that has a valid tooltip (not None, NaN or
+ empty string) must create two pseudo CSS entries for the specific
+ | element id which are added to overall table styles:
+ an on hover visibility change and a content change
+ dependent upon the user's chosen display string.
+
+ For example:
+ [{"selector": "T__row1_col1:hover .pd-t",
+ "props": [("visibility", "visible")]},
+ {"selector": "T__row1_col1 .pd-t::after",
+ "props": [("content", "Some Valid Text String")]}]
+
+ Parameters
+ ----------
+ uuid: str
+ The uuid of the Styler instance
+ name: str
+ The css-name of the class used for styling tooltips
+ row : int
+ The row index of the specified tooltip string data
+ col : int
+ The col index of the specified tooltip string data
+ text : str
+ The textual content of the tooltip to be displayed in HTML.
+
+ Returns
+ -------
+ pseudo_css : List
+ """
+ selector_id = "#T_" + uuid + "row" + str(row) + "_col" + str(col)
+ return [
+ {
+ "selector": selector_id + f":hover .{name}",
+ "props": [("visibility", "visible")],
+ },
+ {
+ "selector": selector_id + f" .{name}::after",
+ "props": [("content", f'"{text}"')],
+ },
+ ]
+
+ def _translate(self, styler_data: FrameOrSeriesUnion, uuid: str, d: dict):
+ """
+ Mutate the render dictionary to allow for tooltips:
+
+ - Add ```` HTML element to each data cells ``display_value``. Ignores
+ headers.
+ - Add table level CSS styles to control pseudo classes.
+
+ Parameters
+ ----------
+ styler_data : DataFrame
+ Underlying ``Styler`` DataFrame used for reindexing.
+ uuid : str
+ The underlying ``Styler`` uuid for CSS id.
+ d : dict
+ The dictionary prior to final render
+
+ Returns
+ -------
+ render_dict : Dict
+ """
+ self.tt_data = self.tt_data.reindex_like(styler_data)
+
+ if self.tt_data.empty:
+ return d
+
+ name = self.class_name
+
+ mask = (self.tt_data.isna()) | (self.tt_data.eq("")) # empty string = no ttip
+ self.table_styles = [
+ style
+ for sublist in [
+ self._pseudo_css(uuid, name, i, j, str(self.tt_data.iloc[i, j]))
+ for i in range(len(self.tt_data.index))
+ for j in range(len(self.tt_data.columns))
+ if not mask.iloc[i, j]
+ ]
+ for style in sublist
+ ]
+
+ if self.table_styles:
+ # add span class to every cell only if at least 1 non-empty tooltip
+ for row in d["body"]:
+ for item in row:
+ if item["type"] == "td":
+ item["display_value"] = (
+ str(item["display_value"])
+ + f''
+ )
+ d["table_styles"].extend(self._class_styles)
+ d["table_styles"].extend(self.table_styles)
+
+ return d
diff --git a/pandas/tests/io/formats/style/test_style.py b/pandas/tests/io/formats/style/test_style.py
index 3422eb9dc64b7..1715993b7385c 100644
--- a/pandas/tests/io/formats/style/test_style.py
+++ b/pandas/tests/io/formats/style/test_style.py
@@ -12,9 +12,11 @@
jinja2 = pytest.importorskip("jinja2")
from pandas.io.formats.style import ( # isort:skip
Styler,
+)
+from pandas.io.formats.style_render import (
_get_level_lengths,
- _maybe_convert_css_to_tuples,
- _non_reducing_slice,
+ maybe_convert_css_to_tuples,
+ non_reducing_slice,
)
@@ -675,15 +677,15 @@ def test_table_styles_multiple(self):
def test_maybe_convert_css_to_tuples(self):
expected = [("a", "b"), ("c", "d e")]
- assert _maybe_convert_css_to_tuples("a:b;c:d e;") == expected
- assert _maybe_convert_css_to_tuples("a: b ;c: d e ") == expected
+ assert maybe_convert_css_to_tuples("a:b;c:d e;") == expected
+ assert maybe_convert_css_to_tuples("a: b ;c: d e ") == expected
expected = []
- assert _maybe_convert_css_to_tuples("") == expected
+ assert maybe_convert_css_to_tuples("") == expected
def test_maybe_convert_css_to_tuples_err(self):
msg = "Styles supplied as string must follow CSS rule formats"
with pytest.raises(ValueError, match=msg):
- _maybe_convert_css_to_tuples("err")
+ maybe_convert_css_to_tuples("err")
def test_table_attributes(self):
attributes = 'class="foo" data-bar'
@@ -1261,7 +1263,7 @@ def test_w3_html_format(self):
def test_non_reducing_slice(self, slc):
df = DataFrame([[0, 1], [2, 3]])
- tslice_ = _non_reducing_slice(slc)
+ tslice_ = non_reducing_slice(slc)
assert isinstance(df.loc[tslice_], DataFrame)
@pytest.mark.parametrize("box", [list, pd.Series, np.array])
@@ -1272,7 +1274,7 @@ def test_list_slice(self, box):
df = DataFrame({"A": [1, 2], "B": [3, 4]}, index=["A", "B"])
expected = pd.IndexSlice[:, ["A"]]
- result = _non_reducing_slice(subset)
+ result = non_reducing_slice(subset)
tm.assert_frame_equal(df.loc[result], df.loc[expected])
def test_non_reducing_slice_on_multiindex(self):
@@ -1286,7 +1288,7 @@ def test_non_reducing_slice_on_multiindex(self):
df = DataFrame(dic, index=[0, 1])
idx = pd.IndexSlice
slice_ = idx[:, idx["b", "d"]]
- tslice_ = _non_reducing_slice(slice_)
+ tslice_ = non_reducing_slice(slice_)
result = df.loc[tslice_]
expected = DataFrame({("b", "d"): [4, 1]})
@@ -1325,7 +1327,7 @@ def test_non_reducing_multi_slice_on_multiindex(self, slice_):
df = DataFrame(np.arange(64).reshape(8, 8), columns=cols, index=idxs)
expected = df.loc[slice_]
- result = df.loc[_non_reducing_slice(slice_)]
+ result = df.loc[non_reducing_slice(slice_)]
tm.assert_frame_equal(result, expected)
|