Repository: pandas-dev/pandas
Pull request: BUG: Incomplete Styler copy methods fix (#39708)
- [x] closes #39708
- [x] tests added / passed
- [x] Ensure all linting tests pass, see [here](https://pandas.pydata.org/pandas-docs/dev/development/contributing.html#code-standards) for how to run them
- [x] whatsnew entry

I haven't made so many pull requests before, so I hope this is going into the right direction. 

Problem statement:
BUG: Styler `copy` methods not updated for recent additions
- [x] I have checked that this issue has not already been reported.

- [x] I have confirmed this bug exists on the latest version of pandas.

- [x] (optional) I have confirmed this bug exists on the master branch of pandas.

---

Recent additions (1.2.0) (1.3.0) to `Styler` have introduced additional class attributes.

The existing `copy` mechanisms catered for older attributes (although not all), but now needs to be reviewed.
Tests should probably be added.

```
def _copy(self, deepcopy: bool = False) -> Styler:
        styler = Styler(
            self.data,
            precision=self.precision,
            caption=self.caption,
            uuid=self.uuid,
            table_styles=self.table_styles,
            na_rep=self.na_rep,
        )
        if deepcopy:
            styler.ctx = copy.deepcopy(self.ctx)
            styler._todo = copy.deepcopy(self._todo)
        else:
            styler.ctx = self.ctx
            styler._todo = self._todo
        return styler
```

And it might also be worth reviewing the `export` and `use` styles methods in case something has been missed.

Hints from issue discussion:
take

Commit message: BUG: Incomplete Styler copy methods fix (#39708)

Patch to review:
diff --git a/doc/source/whatsnew/v1.3.0.rst b/doc/source/whatsnew/v1.3.0.rst
index d5177075afda5..bc83062bb0185 100644
--- a/doc/source/whatsnew/v1.3.0.rst
+++ b/doc/source/whatsnew/v1.3.0.rst
@@ -482,6 +482,7 @@ Other
 - Bug in :class:`Styler` where rendered HTML was missing a column class identifier for certain header cells (:issue:`39716`)
 - Bug in :meth:`Styler.background_gradient` where text-color was not determined correctly (:issue:`39888`)
 - Bug in :meth:`DataFrame.equals`, :meth:`Series.equals`, :meth:`Index.equals` with object-dtype containing ``np.datetime64("NaT")`` or ``np.timedelta64("NaT")`` (:issue:`39650`)
+- Bug in :class:`Styler` copy methods which were not updated for recently introduced attributes (:issue:`39708`)
 
 
 .. ---------------------------------------------------------------------------
diff --git a/pandas/io/formats/style.py b/pandas/io/formats/style.py
index 3b0d857217d43..806b20373eb68 100644
--- a/pandas/io/formats/style.py
+++ b/pandas/io/formats/style.py
@@ -783,15 +783,29 @@ def _copy(self, deepcopy: bool = False) -> Styler:
             precision=self.precision,
             caption=self.caption,
             uuid=self.uuid,
-            table_styles=self.table_styles,
+            table_attributes=self.table_attributes,
+            cell_ids=self.cell_ids,
             na_rep=self.na_rep,
+            uuid_len=self.uuid_len,
         )
+
+        styler.hidden_index = self.hidden_index
+
         if deepcopy:
             styler.ctx = copy.deepcopy(self.ctx)
             styler._todo = copy.deepcopy(self._todo)
+            styler.table_styles = copy.deepcopy(self.table_styles)
+            styler.hidden_columns = copy.deepcopy(self.hidden_columns)
+            styler.cell_context = copy.deepcopy(self.cell_context)
+            styler.tooltips = copy.deepcopy(self.tooltips)
         else:
             styler.ctx = self.ctx
             styler._todo = self._todo
+            styler.table_styles = self.table_styles
+            styler.hidden_columns = self.hidden_columns
+            styler.cell_context = self.cell_context
+            styler.tooltips = self.tooltips
+
         return styler
 
     def __copy__(self) -> Styler:
diff --git a/pandas/tests/io/formats/test_style.py b/pandas/tests/io/formats/test_style.py
index 66dc1cd4adddd..eedc559e538a3 100644
--- a/pandas/tests/io/formats/test_style.py
+++ b/pandas/tests/io/formats/test_style.py
@@ -87,28 +87,131 @@ def test_update_ctx_flatten_multi_and_trailing_semi(self):
         }
         assert self.styler.ctx == expected
 
-    def test_copy(self):
+    @pytest.mark.parametrize("do_changes", [True, False])
+    @pytest.mark.parametrize("do_render", [True, False])
+    def test_copy(self, do_changes, do_render):
+        # Updated in GH39708
+        # Change some defaults (to check later if they are copied)
+        if do_changes:
+            style = [{"selector": "th", "props": [("foo", "bar")]}]
+            self.styler.set_table_styles(style)
+            attributes = 'class="foo" data-bar'
+            self.styler.set_table_attributes(attributes)
+            self.styler.uuid_len += 1
+            self.styler.hidden_index = not self.styler.hidden_index
+            self.styler.hide_columns("A")
+            classes = pd.DataFrame(
+                [["favorite-val red", ""], [None, "blue my-val"]],
+                index=self.df.index,
+                columns=self.df.columns,
+            )
+            self.styler.set_td_classes(classes)
+            ttips = pd.DataFrame(
+                data=[["Favorite", ""], [np.nan, "my"]],
+                columns=self.df.columns,
+                index=self.df.index,
+            )
+            self.styler.set_tooltips(ttips)
+            self.styler.cell_ids = not self.styler.cell_ids
+            self.styler.format("{:.2%}")
+
+        if do_render:
+            self.styler.render()
+
         s2 = copy.copy(self.styler)
+
+        # Check for identity
         assert self.styler is not s2
         assert self.styler.ctx is s2.ctx  # shallow
         assert self.styler._todo is s2._todo
-
+        assert self.styler.table_styles is s2.table_styles
+        assert self.styler.hidden_columns is s2.hidden_columns
+        assert self.styler.cell_context is s2.cell_context
+        assert self.styler.tooltips is s2.tooltips
+        if do_changes:  # self.styler.tooltips is not None
+            assert self.styler.tooltips.tt_data is s2.tooltips.tt_data
+
+        # Check for equality (and changes in referenced objects)
         self.styler._update_ctx(self.attrs)
         self.styler.highlight_max()
         assert self.styler.ctx == s2.ctx
         assert self.styler._todo == s2._todo
+        assert self.styler.table_styles == s2.table_styles
+        assert self.styler.table_attributes == s2.table_attributes
+        assert self.styler.cell_ids == s2.cell_ids
+        assert self.styler.uuid_len == s2.uuid_len
+        assert self.styler.hidden_index == s2.hidden_index
+        assert self.styler.hidden_columns == s2.hidden_columns
+        assert self.styler.cell_context == s2.cell_context
+        if do_changes:  # self.styler.table_style is not None
+            self.styler.table_styles[0]["selector"] = "ti"
+            assert self.styler.table_styles == s2.table_styles
+        if do_changes:  # self.styler.tooltips is not None
+            tm.assert_frame_equal(self.styler.tooltips.tt_data, s2.tooltips.tt_data)
+
+    @pytest.mark.parametrize("do_changes", [True, False])
+    @pytest.mark.parametrize("do_render", [True, False])
+    def test_deepcopy(self, do_changes, do_render):
+        # Updated in GH39708
+        # Change some defaults (to check later if they are copied)
+        if do_changes:
+            style = [{"selector": "th", "props": [("foo", "bar")]}]
+            self.styler.set_table_styles(style)
+            attributes = 'class="foo" data-bar'
+            self.styler.set_table_attributes(attributes)
+            self.styler.uuid_len += 1
+            self.styler.hidden_index = not self.styler.hidden_index
+            self.styler.hide_columns("A")
+            classes = pd.DataFrame(
+                [["favorite-val red", ""], [None, "blue my-val"]],
+                index=self.df.index,
+                columns=self.df.columns,
+            )
+            self.styler.set_td_classes(classes)
+            ttips = pd.DataFrame(
+                data=[["Favorite", ""], [np.nan, "my"]],
+                columns=self.df.columns,
+                index=self.df.index,
+            )
+            self.styler.set_tooltips(ttips)
+            self.styler.cell_ids = not self.styler.cell_ids
+            self.styler.format("{:.2%}")
+
+        if do_render:
+            self.styler.render()
 
-    def test_deepcopy(self):
         s2 = copy.deepcopy(self.styler)
+
+        # Check for non-identity
         assert self.styler is not s2
         assert self.styler.ctx is not s2.ctx
         assert self.styler._todo is not s2._todo
-
+        assert self.styler.hidden_columns is not s2.hidden_columns
+        assert self.styler.cell_context is not s2.cell_context
+        if do_changes:  # self.styler.table_style is not None
+            assert self.styler.table_styles is not s2.table_styles
+        if do_changes:  # self.styler.tooltips is not None
+            assert self.styler.tooltips is not s2.tooltips
+            assert self.styler.tooltips.tt_data is not s2.tooltips.tt_data
+
+        # Check for equality (and changes in original objects)
         self.styler._update_ctx(self.attrs)
         self.styler.highlight_max()
         assert self.styler.ctx != s2.ctx
         assert s2._todo == []
         assert self.styler._todo != s2._todo
+        assert self.styler.table_styles == s2.table_styles
+        assert self.styler.table_attributes == s2.table_attributes
+        assert self.styler.cell_ids == s2.cell_ids
+        assert self.styler.uuid_len == s2.uuid_len
+        assert self.styler.hidden_index == s2.hidden_index
+        assert self.styler.hidden_columns == s2.hidden_columns
+        assert self.styler.cell_context == s2.cell_context
+        if do_changes:  # self.styler.table_style is not None
+            self.styler.table_styles[0]["selector"] = "ti"
+            assert self.styler.table_styles != s2.table_styles
+        if do_changes:  # self.styler.tooltips is not None
+            tm.assert_frame_equal(self.styler.tooltips.tt_data, s2.tooltips.tt_data)
 
     def test_clear(self):
         # updated in GH 39396


Code review comments:
diff_hunk: @@ -783,15 +783,29 @@ def _copy(self, deepcopy: bool = False) -> Styler:
             precision=self.precision,
             caption=self.caption,
             uuid=self.uuid,
-            table_styles=self.table_styles,
+            table_attributes=self.table_attributes,
+            cell_ids=self.cell_ids,
             na_rep=self.na_rep,
+            uuid_len=self.uuid_len,
path: pandas/io/formats/style.py
text: @user1:
ignore the `uuid_len` since the `uuid` is explicitly provided

diff_hunk: @@ -87,28 +87,131 @@ def test_update_ctx_flatten_multi_and_trailing_semi(self):
         }
         assert self.styler.ctx == expected
 
-    def test_copy(self):
+    @pytest.mark.parametrize("do_changes", [True, False])
+    @pytest.mark.parametrize("do_render", [True, False])
+    def test_copy(self, do_changes, do_render):
+        # Updated in GH39708
+        # Change some defaults (to check later if they are copied)
+        if do_changes:
+            style = [{"selector": "th", "props": [("foo", "bar")]}]
+            self.styler.set_table_styles(style)
+            attributes = 'class="foo" data-bar'
+            self.styler.set_table_attributes(attributes)
+            self.styler.uuid_len += 1
+            self.styler.hidden_index = not self.styler.hidden_index
+            self.styler.hide_columns("A")
+            classes = pd.DataFrame(
+                [["favorite-val red", ""], [None, "blue my-val"]],
+                index=self.df.index,
+                columns=self.df.columns,
+            )
+            self.styler.set_td_classes(classes)
+            ttips = pd.DataFrame(
+                data=[["Favorite", ""], [np.nan, "my"]],
+                columns=self.df.columns,
+                index=self.df.index,
+            )
+            self.styler.set_tooltips(ttips)
+            self.styler.cell_ids = not self.styler.cell_ids
+            self.styler.format("{:.2%}")
+
+        if do_render:
+            self.styler.render()
+
         s2 = copy.copy(self.styler)
+
+        # Check for identity
         assert self.styler is not s2
         assert self.styler.ctx is s2.ctx  # shallow
         assert self.styler._todo is s2._todo
-
+        assert self.styler.table_styles is s2.table_styles
+        assert self.styler.hidden_columns is s2.hidden_columns
+        assert self.styler.cell_context is s2.cell_context
+        assert self.styler.tooltips is s2.tooltips
+        if do_changes:  # self.styler.tooltips is not None
+            assert self.styler.tooltips.tt_data is s2.tooltips.tt_data
+
+        # Check for equality (and changes in referenced objects)
         self.styler._update_ctx(self.attrs)
         self.styler.highlight_max()
         assert self.styler.ctx == s2.ctx
         assert self.styler._todo == s2._todo
+        assert self.styler.table_styles == s2.table_styles
+        assert self.styler.table_attributes == s2.table_attributes
+        assert self.styler.cell_ids == s2.cell_ids
+        assert self.styler.uuid_len == s2.uuid_len
+        assert self.styler.hidden_index == s2.hidden_index
+        assert self.styler.hidden_columns == s2.hidden_columns
+        assert self.styler.cell_context == s2.cell_context
+        if do_changes:  # self.styler.table_style is not None
+            self.styler.table_styles[0]["selector"] = "ti"
+            assert self.styler.table_styles == s2.table_styles
+        if do_changes:  # self.styler.tooltips is not None
+            tm.assert_frame_equal(self.styler.tooltips.tt_data, s2.tooltips.tt_data)
+
+    @pytest.mark.parametrize("do_changes", [True, False])
+    @pytest.mark.parametrize("do_render", [True, False])
+    def test_deepcopy(self, do_changes, do_render):
+        # Updated in GH39708
+        # Change some defaults (to check later if they are copied)
+        if do_changes:
+            style = [{"selector": "th", "props": [("foo", "bar")]}]
+            self.styler.set_table_styles(style)
+            attributes = 'class="foo" data-bar'
+            self.styler.set_table_attributes(attributes)
+            self.styler.uuid_len += 1
+            self.styler.hidden_index = not self.styler.hidden_index
+            self.styler.hide_columns("A")
+            classes = pd.DataFrame(
+                [["favorite-val red", ""], [None, "blue my-val"]],
+                index=self.df.index,
+                columns=self.df.columns,
+            )
+            self.styler.set_td_classes(classes)
+            ttips = pd.DataFrame(
+                data=[["Favorite", ""], [np.nan, "my"]],
+                columns=self.df.columns,
+                index=self.df.index,
+            )
+            self.styler.set_tooltips(ttips)
+            self.styler.cell_ids = not self.styler.cell_ids
+            self.styler.format("{:.2%}")
+
+        if do_render:
+            self.styler.render()
 
-    def test_deepcopy(self):
         s2 = copy.deepcopy(self.styler)
+
+        # Check for non-identity
         assert self.styler is not s2
         assert self.styler.ctx is not s2.ctx
         assert self.styler._todo is not s2._todo
-
+        assert self.styler.hidden_columns is not s2.hidden_columns
+        assert self.styler.cell_context is not s2.cell_context
+        if do_changes:  # self.styler.table_style is not None
+            assert self.styler.table_styles is not s2.table_styles
+        if do_changes:  # self.styler.tooltips is not None
+            assert self.styler.tooltips is not s2.tooltips
+            assert self.styler.tooltips.tt_data is not s2.tooltips.tt_data
+
+        # Check for equality (and changes in original objects)
         self.styler._update_ctx(self.attrs)
         self.styler.highlight_max()
         assert self.styler.ctx != s2.ctx
         assert s2._todo == []
         assert self.styler._todo != s2._todo
+        assert self.styler.table_styles == s2.table_styles
+        assert self.styler.table_attributes == s2.table_attributes
+        assert self.styler.cell_ids == s2.cell_ids
+        assert self.styler.uuid_len == s2.uuid_len
+        assert self.styler.hidden_index == s2.hidden_index
+        assert self.styler.hidden_columns == s2.hidden_columns
+        assert self.styler.cell_context == s2.cell_context
+        if do_changes:  # self.styler.table_style is not None
+            self.styler.table_styles[0]["selector"] = "ti"
+            assert self.styler.table_styles != s2.table_styles
+        if do_changes:  # self.styler.tooltips is not None
+            tm.assert_frame_equal(self.styler.tooltips.tt_data, s2.tooltips.tt_data)
path: pandas/tests/io/formats/test_style.py
text: @user1:
possible to wrap the two test cases copy and deepcopy so that dont repeat so much code?

diff_hunk: @@ -87,28 +87,131 @@ def test_update_ctx_flatten_multi_and_trailing_semi(self):
         }
         assert self.styler.ctx == expected
 
-    def test_copy(self):
+    @pytest.mark.parametrize("do_changes", [True, False])
+    @pytest.mark.parametrize("do_render", [True, False])
+    def test_copy(self, do_changes, do_render):
+        # Updated in GH39708
+        # Change some defaults (to check later if they are copied)
+        if do_changes:
+            style = [{"selector": "th", "props": [("foo", "bar")]}]
+            self.styler.set_table_styles(style)
+            attributes = 'class="foo" data-bar'
+            self.styler.set_table_attributes(attributes)
+            self.styler.uuid_len += 1
+            self.styler.hidden_index = not self.styler.hidden_index
+            self.styler.hide_columns("A")
+            classes = pd.DataFrame(
+                [["favorite-val red", ""], [None, "blue my-val"]],
+                index=self.df.index,
+                columns=self.df.columns,
+            )
+            self.styler.set_td_classes(classes)
+            ttips = pd.DataFrame(
+                data=[["Favorite", ""], [np.nan, "my"]],
+                columns=self.df.columns,
+                index=self.df.index,
+            )
+            self.styler.set_tooltips(ttips)
+            self.styler.cell_ids = not self.styler.cell_ids
+            self.styler.format("{:.2%}")
+
+        if do_render:
+            self.styler.render()
+
         s2 = copy.copy(self.styler)
+
+        # Check for identity
         assert self.styler is not s2
         assert self.styler.ctx is s2.ctx  # shallow
         assert self.styler._todo is s2._todo
-
+        assert self.styler.table_styles is s2.table_styles
+        assert self.styler.hidden_columns is s2.hidden_columns
+        assert self.styler.cell_context is s2.cell_context
path: pandas/tests/io/formats/test_style.py
text: @user1:
can you group the shallow variables in test to make it clearer?

diff_hunk: @@ -87,28 +87,131 @@ def test_update_ctx_flatten_multi_and_trailing_semi(self):
         }
         assert self.styler.ctx == expected
 
-    def test_copy(self):
+    @pytest.mark.parametrize("do_changes", [True, False])
+    @pytest.mark.parametrize("do_render", [True, False])
+    def test_copy(self, do_changes, do_render):
+        # Updated in GH39708
+        # Change some defaults (to check later if they are copied)
+        if do_changes:
+            style = [{"selector": "th", "props": [("foo", "bar")]}]
+            self.styler.set_table_styles(style)
+            attributes = 'class="foo" data-bar'
+            self.styler.set_table_attributes(attributes)
+            self.styler.uuid_len += 1
+            self.styler.hidden_index = not self.styler.hidden_index
+            self.styler.hide_columns("A")
+            classes = pd.DataFrame(
+                [["favorite-val red", ""], [None, "blue my-val"]],
+                index=self.df.index,
+                columns=self.df.columns,
+            )
+            self.styler.set_td_classes(classes)
+            ttips = pd.DataFrame(
+                data=[["Favorite", ""], [np.nan, "my"]],
+                columns=self.df.columns,
+                index=self.df.index,
+            )
+            self.styler.set_tooltips(ttips)
+            self.styler.cell_ids = not self.styler.cell_ids
+            self.styler.format("{:.2%}")
+
+        if do_render:
+            self.styler.render()
+
         s2 = copy.copy(self.styler)
+
+        # Check for identity
         assert self.styler is not s2
         assert self.styler.ctx is s2.ctx  # shallow
         assert self.styler._todo is s2._todo
-
+        assert self.styler.table_styles is s2.table_styles
+        assert self.styler.hidden_columns is s2.hidden_columns
+        assert self.styler.cell_context is s2.cell_context
+        assert self.styler.tooltips is s2.tooltips
+        if do_changes:  # self.styler.tooltips is not None
+            assert self.styler.tooltips.tt_data is s2.tooltips.tt_data
+
+        # Check for equality (and changes in referenced objects)
         self.styler._update_ctx(self.attrs)
         self.styler.highlight_max()
         assert self.styler.ctx == s2.ctx
         assert self.styler._todo == s2._todo
+        assert self.styler.table_styles == s2.table_styles
+        assert self.styler.table_attributes == s2.table_attributes
+        assert self.styler.cell_ids == s2.cell_ids
+        assert self.styler.uuid_len == s2.uuid_len
path: pandas/tests/io/formats/test_style.py
text: @user1:
`uuid` is the key variable, `uuid_len` is only used in construction of `uuid` test for that instead.

diff_hunk: @@ -87,28 +87,131 @@ def test_update_ctx_flatten_multi_and_trailing_semi(self):
         }
         assert self.styler.ctx == expected
 
-    def test_copy(self):
+    @pytest.mark.parametrize("do_changes", [True, False])
+    @pytest.mark.parametrize("do_render", [True, False])
+    def test_copy(self, do_changes, do_render):
+        # Updated in GH39708
+        # Change some defaults (to check later if they are copied)
+        if do_changes:
+            style = [{"selector": "th", "props": [("foo", "bar")]}]
+            self.styler.set_table_styles(style)
+            attributes = 'class="foo" data-bar'
+            self.styler.set_table_attributes(attributes)
+            self.styler.uuid_len += 1
+            self.styler.hidden_index = not self.styler.hidden_index
+            self.styler.hide_columns("A")
+            classes = pd.DataFrame(
+                [["favorite-val red", ""], [None, "blue my-val"]],
+                index=self.df.index,
+                columns=self.df.columns,
+            )
+            self.styler.set_td_classes(classes)
+            ttips = pd.DataFrame(
+                data=[["Favorite", ""], [np.nan, "my"]],
+                columns=self.df.columns,
+                index=self.df.index,
+            )
+            self.styler.set_tooltips(ttips)
+            self.styler.cell_ids = not self.styler.cell_ids
+            self.styler.format("{:.2%}")
path: pandas/tests/io/formats/test_style.py
text: @user1:
not making any changes to _todo[]. maybe add `.apply(..)`

diff_hunk: @@ -783,15 +783,29 @@ def _copy(self, deepcopy: bool = False) -> Styler:
             precision=self.precision,
             caption=self.caption,
             uuid=self.uuid,
-            table_styles=self.table_styles,
+            table_attributes=self.table_attributes,
+            cell_ids=self.cell_ids,
             na_rep=self.na_rep,
+            uuid_len=self.uuid_len,
         )
+
+        styler.hidden_index = self.hidden_index
+
         if deepcopy:
             styler.ctx = copy.deepcopy(self.ctx)
             styler._todo = copy.deepcopy(self._todo)
+            styler.table_styles = copy.deepcopy(self.table_styles)
+            styler.hidden_columns = copy.deepcopy(self.hidden_columns)
path: pandas/io/formats/style.py
text: @user1:
think `hidden_columns` is a list, does it need deepcopy?

diff_hunk: @@ -783,15 +783,29 @@ def _copy(self, deepcopy: bool = False) -> Styler:
             precision=self.precision,
             caption=self.caption,
             uuid=self.uuid,
path: pandas/io/formats/style.py
text: @user1:
the `uuid` is suffixed with "_" so need to trim this off here `self.uuid[:-1]`
edit: actually it might be cleaner and always work if the `uuid` is manually overwritten after class instantiation.