diff --git a/doc/source/whatsnew/v1.3.0.rst b/doc/source/whatsnew/v1.3.0.rst index 60dc7096c9d1e..b1ee8ca03d3ee 100644 --- a/doc/source/whatsnew/v1.3.0.rst +++ b/doc/source/whatsnew/v1.3.0.rst @@ -136,7 +136,7 @@ which has been revised and improved (:issue:`39720`, :issue:`39317`, :issue:`404 - Many features of the :class:`.Styler` class are now either partially or fully usable on a DataFrame with a non-unique indexes or columns (:issue:`41143`) - One has greater control of the display through separate sparsification of the index or columns using the :ref:`new styler options `, which are also usable via :func:`option_context` (:issue:`41142`) - Added the option ``styler.render.max_elements`` to avoid browser overload when styling large DataFrames (:issue:`40712`) - - Added the method :meth:`.Styler.to_latex` (:issue:`21673`), which also allows some limited CSS conversion (:issue:`40731`) + - Added the method :meth:`.Styler.to_latex` (:issue:`21673`, :issue:`42320`), which also allows some limited CSS conversion (:issue:`40731`) - Added the method :meth:`.Styler.to_html` (:issue:`13379`) - Added the method :meth:`.Styler.set_sticky` to make index and column headers permanently visible in scrolling HTML frames (:issue:`29072`) diff --git a/pandas/io/formats/style.py b/pandas/io/formats/style.py index 279549a6379d1..0360b0f9307c5 100644 --- a/pandas/io/formats/style.py +++ b/pandas/io/formats/style.py @@ -709,6 +709,8 @@ def to_latex( 0 & {\bfseries}{\Huge{1}} \\ \end{tabular} """ + obj = self._copy(deepcopy=True) # manipulate table_styles on obj, not self + table_selectors = ( [style["selector"] for style in self.table_styles] if self.table_styles is not None @@ -717,7 +719,7 @@ def to_latex( if column_format is not None: # add more recent setting to table_styles - self.set_table_styles( + obj.set_table_styles( [{"selector": "column_format", "props": f":{column_format}"}], overwrite=False, ) @@ -735,13 +737,13 @@ def to_latex( column_format += ( ("r" if not siunitx else "S") if ci in numeric_cols else "l" ) - self.set_table_styles( + obj.set_table_styles( [{"selector": "column_format", "props": f":{column_format}"}], overwrite=False, ) if position: - self.set_table_styles( + obj.set_table_styles( [{"selector": "position", "props": f":{position}"}], overwrite=False, ) @@ -753,13 +755,13 @@ def to_latex( f"'raggedright', 'raggedleft', 'centering', " f"got: '{position_float}'" ) - self.set_table_styles( + obj.set_table_styles( [{"selector": "position_float", "props": f":{position_float}"}], overwrite=False, ) if hrules: - self.set_table_styles( + obj.set_table_styles( [ {"selector": "toprule", "props": ":toprule"}, {"selector": "midrule", "props": ":midrule"}, @@ -769,20 +771,20 @@ def to_latex( ) if label: - self.set_table_styles( + obj.set_table_styles( [{"selector": "label", "props": f":{{{label.replace(':', 'ยง')}}}"}], overwrite=False, ) if caption: - self.set_caption(caption) + obj.set_caption(caption) if sparse_index is None: sparse_index = get_option("styler.sparse.index") if sparse_columns is None: sparse_columns = get_option("styler.sparse.columns") - latex = self._render_latex( + latex = obj._render_latex( sparse_index=sparse_index, sparse_columns=sparse_columns, multirow_align=multirow_align, @@ -964,39 +966,60 @@ def _update_ctx(self, attrs: DataFrame) -> None: self.ctx[(i, j)].extend(css_list) def _copy(self, deepcopy: bool = False) -> Styler: - styler = Styler( - self.data, - precision=self.precision, - caption=self.caption, - table_attributes=self.table_attributes, - cell_ids=self.cell_ids, - na_rep=self.na_rep, - ) + """ + Copies a Styler, allowing for deepcopy or shallow copy - styler.uuid = self.uuid - styler.hide_index_ = self.hide_index_ + Copying a Styler aims to recreate a new Styler object which contains the same + data and styles as the original. - 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.copy(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 + Data dependent attributes [copied and NOT exported]: + - formatting (._display_funcs) + - hidden index values or column values (.hidden_rows, .hidden_columns) + - tooltips + - cell_context (cell css classes) + - ctx (cell css styles) + - caption + + Non-data dependent attributes [copied and exported]: + - hidden index state and hidden columns state (.hide_index_, .hide_columns_) + - table_attributes + - table_styles + - applied styles (_todo) + + """ + # GH 40675 + styler = Styler( + self.data, # populates attributes 'data', 'columns', 'index' as shallow + uuid_len=self.uuid_len, + ) + shallow = [ # simple string or boolean immutables + "hide_index_", + "hide_columns_", + "table_attributes", + "cell_ids", + "caption", + ] + deep = [ # nested lists or dicts + "_display_funcs", + "hidden_rows", + "hidden_columns", + "ctx", + "cell_context", + "_todo", + "table_styles", + "tooltips", + ] + + for attr in shallow: + setattr(styler, attr, getattr(self, attr)) + + for attr in deep: + val = getattr(self, attr) + setattr(styler, attr, copy.deepcopy(val) if deepcopy else val) return styler def __copy__(self) -> Styler: - """ - Deep copy by default. - """ return self._copy(deepcopy=False) def __deepcopy__(self, memo) -> Styler: diff --git a/pandas/tests/io/formats/style/test_style.py b/pandas/tests/io/formats/style/test_style.py index 0516aa6029487..f2c2f673909d4 100644 --- a/pandas/tests/io/formats/style/test_style.py +++ b/pandas/tests/io/formats/style/test_style.py @@ -38,6 +38,35 @@ def mi_styler(mi_df): return Styler(mi_df, uuid_len=0) +@pytest.fixture +def mi_styler_comp(mi_styler): + # comprehensively add features to mi_styler + mi_styler.uuid_len = 5 + mi_styler.uuid = "abcde_" + mi_styler.set_caption("capt") + mi_styler.set_table_styles([{"selector": "a", "props": "a:v;"}]) + mi_styler.hide_columns() + mi_styler.hide_columns([("c0", "c1_a")]) + mi_styler.hide_index() + mi_styler.hide_index([("i0", "i1_a")]) + mi_styler.set_table_attributes('class="box"') + mi_styler.format(na_rep="MISSING", precision=3) + mi_styler.highlight_max(axis=None) + mi_styler.set_td_classes( + DataFrame( + [["a", "b"], ["a", "c"]], index=mi_styler.index, columns=mi_styler.columns + ) + ) + mi_styler.set_tooltips( + DataFrame( + [["a2", "b2"], ["a2", "c2"]], + index=mi_styler.index, + columns=mi_styler.columns, + ) + ) + return mi_styler + + @pytest.mark.parametrize( "sparse_columns, exp_cols", [ @@ -156,6 +185,49 @@ def test_render_trimming_mi(): assert {"attributes": 'colspan="2"'}.items() <= ctx["head"][0][2].items() +@pytest.mark.parametrize("comprehensive", [True, False]) +@pytest.mark.parametrize("render", [True, False]) +@pytest.mark.parametrize("deepcopy", [True, False]) +def test_copy(comprehensive, render, deepcopy, mi_styler, mi_styler_comp): + styler = mi_styler_comp if comprehensive else mi_styler + styler.uuid_len = 5 + + s2 = copy.deepcopy(styler) if deepcopy else copy.copy(styler) # make copy and check + assert s2 is not styler + + if render: + styler.to_html() + + excl = ["na_rep", "precision", "uuid", "cellstyle_map"] # deprecated or special var + if not deepcopy: # check memory locations are equal for all included attributes + for attr in [a for a in styler.__dict__ if (not callable(a) and a not in excl)]: + assert id(getattr(s2, attr)) == id(getattr(styler, attr)) + else: # check memory locations are different for nested or mutable vars + shallow = [ + "data", + "columns", + "index", + "uuid_len", + "caption", + "cell_ids", + "hide_index_", + "hide_columns_", + "table_attributes", + ] + for attr in shallow: + assert id(getattr(s2, attr)) == id(getattr(styler, attr)) + + for attr in [ + a + for a in styler.__dict__ + if (not callable(a) and a not in excl and a not in shallow) + ]: + if getattr(s2, attr) is None: + assert id(getattr(s2, attr)) == id(getattr(styler, attr)) + else: + assert id(getattr(s2, attr)) != id(getattr(styler, attr)) + + class TestStyler: def setup_method(self, method): np.random.seed(24) @@ -211,102 +283,6 @@ def test_update_ctx_flatten_multi_and_trailing_semi(self): } assert self.styler.ctx == expected - @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 the new values are copied) - if do_changes: - self.styler.set_table_styles( - [{"selector": "th", "props": [("foo", "bar")]}] - ) - self.styler.set_table_attributes('class="foo" data-bar') - self.styler.hide_index_ = not self.styler.hide_index_ - self.styler.hide_columns("A") - classes = DataFrame( - [["favorite-val red", ""], [None, "blue my-val"]], - index=self.df.index, - columns=self.df.columns, - ) - self.styler.set_td_classes(classes) - ttips = 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 - - if do_render: - self.styler.render() - - s_copy = copy.copy(self.styler) - s_deepcopy = copy.deepcopy(self.styler) - - assert self.styler is not s_copy - assert self.styler is not s_deepcopy - - # Check for identity - assert self.styler.ctx is s_copy.ctx - assert self.styler._todo is s_copy._todo - assert self.styler.table_styles is s_copy.table_styles - assert self.styler.hidden_columns is s_copy.hidden_columns - assert self.styler.cell_context is s_copy.cell_context - assert self.styler.tooltips is s_copy.tooltips - if do_changes: # self.styler.tooltips is not None - assert self.styler.tooltips.tt_data is s_copy.tooltips.tt_data - assert ( - self.styler.tooltips.class_properties - is s_copy.tooltips.class_properties - ) - assert self.styler.tooltips.table_styles is s_copy.tooltips.table_styles - - # Check for non-identity - assert self.styler.ctx is not s_deepcopy.ctx - assert self.styler._todo is not s_deepcopy._todo - assert self.styler.hidden_columns is not s_deepcopy.hidden_columns - assert self.styler.cell_context is not s_deepcopy.cell_context - if do_changes: # self.styler.table_style is not None - assert self.styler.table_styles is not s_deepcopy.table_styles - if do_changes: # self.styler.tooltips is not None - assert self.styler.tooltips is not s_deepcopy.tooltips - assert self.styler.tooltips.tt_data is not s_deepcopy.tooltips.tt_data - assert ( - self.styler.tooltips.class_properties - is not s_deepcopy.tooltips.class_properties - ) - assert ( - self.styler.tooltips.table_styles - is not s_deepcopy.tooltips.table_styles - ) - - self.styler._update_ctx(self.attrs) - self.styler.highlight_max() - assert self.styler.ctx == s_copy.ctx - assert self.styler.ctx != s_deepcopy.ctx - assert self.styler._todo == s_copy._todo - assert self.styler._todo != s_deepcopy._todo - assert s_deepcopy._todo == [] - - equal_attributes = [ - "table_styles", - "table_attributes", - "cell_ids", - "hide_index_", - "hidden_columns", - "cell_context", - ] - for s2 in [s_copy, s_deepcopy]: - for att in equal_attributes: - assert self.styler.__dict__[att] == s2.__dict__[att] - if do_changes: # self.styler.tooltips is not None - tm.assert_frame_equal(self.styler.tooltips.tt_data, s2.tooltips.tt_data) - assert ( - self.styler.tooltips.class_properties - == s2.tooltips.class_properties - ) - assert self.styler.tooltips.table_styles == s2.tooltips.table_styles - def test_clear(self): # updated in GH 39396 tt = DataFrame({"A": [None, "tt"]}) diff --git a/pandas/tests/io/formats/style/test_to_latex.py b/pandas/tests/io/formats/style/test_to_latex.py index 91ac652e1f652..55b17dc37adda 100644 --- a/pandas/tests/io/formats/style/test_to_latex.py +++ b/pandas/tests/io/formats/style/test_to_latex.py @@ -489,3 +489,19 @@ def test_parse_latex_css_conversion_option(): expected = [("command", "option--wrap")] result = _parse_latex_css_conversion(css) assert result == expected + + +def test_styler_object_after_render(styler): + # GH 42320 + pre_render = styler._copy(deepcopy=True) + styler.to_latex( + column_format="rllr", + position="h", + position_float="centering", + hrules=True, + label="my lab", + caption="my cap", + ) + + assert pre_render.table_styles == styler.table_styles + assert pre_render.caption == styler.caption