Repository: pandas-dev/pandas
Pull request: ENH: Add Styler.pipe() method (#23229)
Added `Styler.pipe()` method.  This allows users to easily apply and compose functions that operate on Styler objects, just like the `DataFrame.pipe()` method does for dataframes.

- [x] closes #23229
- [x] tests added / passed
- [x] passes `git diff upstream/master -u -- "*.py" | flake8 --diff`
- [x] whatsnew entry

Problem statement:
Styler class should have a `pipe()` method, akin to DataFrame.pipe()
#### Problem description

Users may want to create functions that apply customized styling to a dataframe or styler object.

In order to make these functions composable, a convenient signature is `f(styler) -> Styler`, so that a user can chain multiple such functions together, in a "mix-and-match" fashion.  (N.B. Such functions can obtain the underlying dataframe using the `styler.data` attribute.)

A method `Styler.pipe()` would provide an ergonomic way to apply these functions sequentially, after a series of Dataframe-related manipulations.

Usage example:
```
def right_align(styler):
    return styler.set_properties(**{'text-align': 'right'})

def highlight_rows_gt(styler, colname, threshold):
    return ...        # implementation omitted

>>> (df.groupby('A')[['B', 'C']].sum()
...        .assign(D=lambda x: x['B'] / x['C'])
...        .style
...        .format('{:.2f}')
...        .pipe(highlight_rows_gt, colname='D', threshold=0.95)
...        .pipe(right_align))
```

Hints from issue discussion:

Commit message: Add whatsnew entry for `Styler.pipe()`

Patch to review:
diff --git a/doc/source/api.rst b/doc/source/api.rst
index 1ec2a56dcd094..0d27ef16b3e71 100644
--- a/doc/source/api.rst
+++ b/doc/source/api.rst
@@ -2410,6 +2410,7 @@ Style Application
    Styler.set_properties
    Styler.set_uuid
    Styler.clear
+   Styler.pipe
 
 Builtin Styles
 ~~~~~~~~~~~~~~
diff --git a/doc/source/whatsnew/v0.24.0.txt b/doc/source/whatsnew/v0.24.0.txt
index 47fef83d3015d..e63fa91400d18 100644
--- a/doc/source/whatsnew/v0.24.0.txt
+++ b/doc/source/whatsnew/v0.24.0.txt
@@ -214,6 +214,7 @@ Other Enhancements
 - Compatibility with Matplotlib 3.0 (:issue:`22790`).
 - Added :meth:`Interval.overlaps`, :meth:`IntervalArray.overlaps`, and :meth:`IntervalIndex.overlaps` for determining overlaps between interval-like objects (:issue:`21998`)
 - :meth:`Timestamp.tz_localize`, :meth:`DatetimeIndex.tz_localize`, and :meth:`Series.tz_localize` have gained the ``nonexistent`` argument for alternative handling of nonexistent times. See :ref:`timeseries.timezone_nonexsistent` (:issue:`8917`)
+- :meth:`Styler.pipe` method added, to simplify application of user-defined functions that operate on stylers.
 
 .. _whatsnew_0240.api_breaking:
 
diff --git a/pandas/io/formats/style.py b/pandas/io/formats/style.py
index 3b3238586b310..8c43487596897 100644
--- a/pandas/io/formats/style.py
+++ b/pandas/io/formats/style.py
@@ -1222,6 +1222,35 @@ class MyStyler(cls):
 
         return MyStyler
 
+    def pipe(self, func, *args, **kwargs):
+        """
+        Apply func(self, *args, **kwargs)
+
+        Parameters
+        ----------
+        func : function
+            function to apply to the Styler.
+            ``args``, and ``kwargs`` are passed into ``func``.
+            Alternatively a ``(callable, data_keyword)`` tuple where
+            ``data_keyword`` is a string indicating the keyword of
+            ``callable`` that expects the Styler.
+        args : iterable, optional
+            positional arguments passed into ``func``.
+        kwargs : mapping, optional
+            a dictionary of keyword arguments passed into ``func``.
+
+        Returns
+        -------
+        result : the value returned by ``func``.
+
+        See Also
+        --------
+        Styler.apply
+        Styler.applymap
+        pandas.DataFrame.pipe
+        """
+        return com._pipe(self, func, *args, **kwargs)
+
 
 def _is_visible(idx_row, idx_col, lengths):
     """
diff --git a/pandas/tests/io/formats/test_style.py b/pandas/tests/io/formats/test_style.py
index e407573c9a462..1c5cbc172a8f0 100644
--- a/pandas/tests/io/formats/test_style.py
+++ b/pandas/tests/io/formats/test_style.py
@@ -1173,6 +1173,25 @@ def test_hide_columns_mult_levels(self):
         assert ctx['body'][1][2]['is_visible']
         assert ctx['body'][1][2]['display_value'] == 3
 
+    def test_pipe(self):
+        def set_caption_from_template(styler, a, b):
+            return styler.set_caption(
+                'Dataframe with a = {a} and b = {b}'.format(a=a, b=b))
+        styler = self.df.style.pipe(set_caption_from_template, 'A', b='B')
+        assert 'Dataframe with a = A and b = B' in styler.render()
+
+        def f(s, *args, **kwargs):
+            return s, args, kwargs
+
+        result = self.df.style.pipe(f, 0, a=1)
+        assert result[1] == (0,)
+        assert result[2] == dict(a=1)
+
+        def g(**kwargs):
+            assert 'styler' in kwargs
+            return kwargs['styler'].data
+        assert self.df.style.pipe((g, 'styler')) is self.df
+
 
 @td.skip_if_no_mpl
 class TestStylerMatplotlibDep(object):


Code review comments:
diff_hunk: @@ -1173,6 +1173,25 @@ def test_hide_columns_mult_levels(self):
         assert ctx['body'][1][2]['is_visible']
         assert ctx['body'][1][2]['display_value'] == 3
 
+    def test_pipe(self):
+        def set_caption_from_template(styler, a, b):
+            return styler.set_caption(
+                'Dataframe with a = {a} and b = {b}'.format(a=a, b=b))
+        styler = self.df.style.pipe(set_caption_from_template, 'A', b='B')
+        assert 'Dataframe with a = A and b = B' in styler.render()
+
+        def f(s, *args, **kwargs):
+            return s, args, kwargs
+
+        result = self.df.style.pipe(f, 0, a=1)
+        assert result[1] == (0,)
+        assert result[2] == dict(a=1)
+
+        def g(**kwargs):
+            assert 'styler' in kwargs
+            return kwargs['styler'].data
+        assert self.df.style.pipe((g, 'styler')) is self.df
path: pandas/tests/io/formats/test_style.py
text: @user1:
Can you comment here explaining why you're using `is` instead of `tm.assert_frame_equal` ?

@user1:
Also, add a comment explaining why you're creating this `g` function in the first place (perhaps a more descriptive function name is needed).

@author:
Yes, I can rewrite this a bit to simplify it.  Essentially, I wanted to test that when `pipe()` is called with a (callable, string) tuple, it works as advertised.