From b9b8cad9d66bdaeb85ffa24c51d06d0e31c6ceca Mon Sep 17 00:00:00 2001 From: Mark Sikora Date: Thu, 29 Sep 2022 14:22:12 -0400 Subject: [PATCH 1/6] Make Latex Styler use proper key:value css entries --- pandas/io/formats/style.py | 35 +++++++++------- .../tests/io/formats/style/test_to_latex.py | 42 +++++++++---------- 2 files changed, 41 insertions(+), 36 deletions(-) diff --git a/pandas/io/formats/style.py b/pandas/io/formats/style.py index a2a9079642344..61ee7dd88e7e3 100644 --- a/pandas/io/formats/style.py +++ b/pandas/io/formats/style.py @@ -873,10 +873,10 @@ def to_latex( .. code-block:: python set_table_styles([ - {"selector": "column_format", "props": f":{column_format};"}, - {"selector": "position", "props": f":{position};"}, - {"selector": "position_float", "props": f":{position_float};"}, - {"selector": "label", "props": f":{{{label.replace(':','§')}}};"} + {"selector": "column_format", "props": f"value:{column_format};"}, + {"selector": "position", "props": f"value:{position};"}, + {"selector": "position_float", "props": f"value:{position_float};"}, + {"selector": "label", "props": f"value:{{{label.replace(':','§')}}};"} ], overwrite=False) Exception is made for the ``hrules`` argument which, in fact, controls all three @@ -889,8 +889,8 @@ def to_latex( .. code-block:: python set_table_styles([ - {'selector': 'toprule', 'props': ':toprule;'}, - {'selector': 'bottomrule', 'props': ':hline;'}, + {'selector': 'toprule', 'props': 'value:toprule;'}, + {'selector': 'bottomrule', 'props': 'value:hline;'}, ], overwrite=False) If other ``commands`` are added to table styles they will be detected, and @@ -901,7 +901,7 @@ def to_latex( .. code-block:: python set_table_styles([ - {'selector': 'rowcolors', 'props': ':{1}{pink}{red};'} + {'selector': 'rowcolors', 'props': 'value:{1}{pink}{red};'} ], overwrite=False) A more comprehensive example using these arguments is as follows: @@ -1122,7 +1122,7 @@ def to_latex( if column_format is not None: # add more recent setting to table_styles obj.set_table_styles( - [{"selector": "column_format", "props": f":{column_format}"}], + [{"selector": "column_format", "props": f"value:{column_format}"}], overwrite=False, ) elif "column_format" in table_selectors: @@ -1142,13 +1142,13 @@ def to_latex( ("r" if not siunitx else "S") if ci in numeric_cols else "l" ) obj.set_table_styles( - [{"selector": "column_format", "props": f":{column_format}"}], + [{"selector": "column_format", "props": f"value:{column_format}"}], overwrite=False, ) if position: obj.set_table_styles( - [{"selector": "position", "props": f":{position}"}], + [{"selector": "position", "props": f"value:{position}"}], overwrite=False, ) @@ -1164,7 +1164,7 @@ def to_latex( f"got: '{position_float}'" ) obj.set_table_styles( - [{"selector": "position_float", "props": f":{position_float}"}], + [{"selector": "position_float", "props": f"value:{position_float}"}], overwrite=False, ) @@ -1172,16 +1172,21 @@ def to_latex( if hrules: obj.set_table_styles( [ - {"selector": "toprule", "props": ":toprule"}, - {"selector": "midrule", "props": ":midrule"}, - {"selector": "bottomrule", "props": ":bottomrule"}, + {"selector": "toprule", "props": "value:toprule"}, + {"selector": "midrule", "props": "value:midrule"}, + {"selector": "bottomrule", "props": "value:bottomrule"}, ], overwrite=False, ) if label: obj.set_table_styles( - [{"selector": "label", "props": f":{{{label.replace(':', '§')}}}"}], + [ + { + "selector": "label", + "props": f"value:{{{label.replace(':', '§')}}}", + } + ], overwrite=False, ) diff --git a/pandas/tests/io/formats/style/test_to_latex.py b/pandas/tests/io/formats/style/test_to_latex.py index b295c955a8967..08fbeab8bcd76 100644 --- a/pandas/tests/io/formats/style/test_to_latex.py +++ b/pandas/tests/io/formats/style/test_to_latex.py @@ -69,8 +69,8 @@ def test_tabular_hrules(styler): def test_tabular_custom_hrules(styler): styler.set_table_styles( [ - {"selector": "toprule", "props": ":hline"}, - {"selector": "bottomrule", "props": ":otherline"}, + {"selector": "toprule", "props": "value:hline"}, + {"selector": "bottomrule", "props": "value:otherline"}, ] ) # no midrule expected = dedent( @@ -89,10 +89,10 @@ def test_tabular_custom_hrules(styler): def test_column_format(styler): # default setting is already tested in `test_latex_minimal_tabular` - styler.set_table_styles([{"selector": "column_format", "props": ":cccc"}]) + styler.set_table_styles([{"selector": "column_format", "props": "value:cccc"}]) assert "\\begin{tabular}{rrrr}" in styler.to_latex(column_format="rrrr") - styler.set_table_styles([{"selector": "column_format", "props": ":r|r|cc"}]) + styler.set_table_styles([{"selector": "column_format", "props": "value:r|r|cc"}]) assert "\\begin{tabular}{r|r|cc}" in styler.to_latex() @@ -112,7 +112,7 @@ def test_siunitx_cols(styler): def test_position(styler): assert "\\begin{table}[h!]" in styler.to_latex(position="h!") assert "\\end{table}" in styler.to_latex(position="h!") - styler.set_table_styles([{"selector": "position", "props": ":b!"}]) + styler.set_table_styles([{"selector": "position", "props": "value:b!"}]) assert "\\begin{table}[b!]" in styler.to_latex() assert "\\end{table}" in styler.to_latex() @@ -120,7 +120,7 @@ def test_position(styler): @pytest.mark.parametrize("env", [None, "longtable"]) def test_label(styler, env): assert "\n\\label{text}" in styler.to_latex(label="text", environment=env) - styler.set_table_styles([{"selector": "label", "props": ":{more §text}"}]) + styler.set_table_styles([{"selector": "label", "props": "value:{more §text}"}]) assert "\n\\label{more :text}" in styler.to_latex(environment=env) @@ -159,8 +159,8 @@ def test_kwargs_combinations( def test_custom_table_styles(styler): styler.set_table_styles( [ - {"selector": "mycommand", "props": ":{myoptions}"}, - {"selector": "mycommand2", "props": ":{myoptions2}"}, + {"selector": "mycommand", "props": "value:{myoptions}"}, + {"selector": "mycommand2", "props": "value:{myoptions2}"}, ] ) expected = dedent( @@ -410,14 +410,14 @@ def test_comprehensive(df_ext, environment): stlr.set_caption("mycap") stlr.set_table_styles( [ - {"selector": "label", "props": ":{fig§item}"}, - {"selector": "position", "props": ":h!"}, - {"selector": "position_float", "props": ":centering"}, - {"selector": "column_format", "props": ":rlrlr"}, - {"selector": "toprule", "props": ":toprule"}, - {"selector": "midrule", "props": ":midrule"}, - {"selector": "bottomrule", "props": ":bottomrule"}, - {"selector": "rowcolors", "props": ":{3}{pink}{}"}, # custom command + {"selector": "label", "props": "value:{fig§item}"}, + {"selector": "position", "props": "value:h!"}, + {"selector": "position_float", "props": "value:centering"}, + {"selector": "column_format", "props": "value:rlrlr"}, + {"selector": "toprule", "props": "value:toprule"}, + {"selector": "midrule", "props": "value:midrule"}, + {"selector": "bottomrule", "props": "value:bottomrule"}, + {"selector": "rowcolors", "props": "value:{3}{pink}{}"}, # custom command ] ) stlr.highlight_max(axis=0, props="textbf:--rwrap;cellcolor:[rgb]{1,1,0.6}--rwrap") @@ -511,17 +511,17 @@ def test_parse_latex_header_span(): def test_parse_latex_table_wrapping(styler): styler.set_table_styles( [ - {"selector": "toprule", "props": ":value"}, - {"selector": "bottomrule", "props": ":value"}, - {"selector": "midrule", "props": ":value"}, - {"selector": "column_format", "props": ":value"}, + {"selector": "toprule", "props": "value:value"}, + {"selector": "bottomrule", "props": "value:value"}, + {"selector": "midrule", "props": "value:value"}, + {"selector": "column_format", "props": "value:value"}, ] ) assert _parse_latex_table_wrapping(styler.table_styles, styler.caption) is False assert _parse_latex_table_wrapping(styler.table_styles, "some caption") is True styler.set_table_styles( [ - {"selector": "not-ignored", "props": ":value"}, + {"selector": "not-ignored", "props": "value:value"}, ], overwrite=False, ) From 7454dc50e37d8ec384b3d69f75b7ebb24487d533 Mon Sep 17 00:00:00 2001 From: Mark Sikora Date: Thu, 29 Sep 2022 14:41:42 -0400 Subject: [PATCH 2/6] Replace custom css parsing code with tinycss2 --- ci/deps/actions-38-minimum_versions.yaml | 1 + doc/source/getting_started/install.rst | 1 + doc/source/whatsnew/v1.6.0.rst | 1 + pandas/compat/_optional.py | 1 + pandas/io/formats/css.py | 52 ++++++++++++++++++------ pandas/io/formats/excel.py | 20 +++++++++ pandas/io/formats/style_render.py | 39 +++++++++++------- pandas/tests/io/formats/test_to_excel.py | 6 ++- 8 files changed, 93 insertions(+), 28 deletions(-) diff --git a/ci/deps/actions-38-minimum_versions.yaml b/ci/deps/actions-38-minimum_versions.yaml index fd23080c2ab04..e0432b83f5ff6 100644 --- a/ci/deps/actions-38-minimum_versions.yaml +++ b/ci/deps/actions-38-minimum_versions.yaml @@ -49,6 +49,7 @@ dependencies: - scipy=1.7.1 - sqlalchemy=1.4.16 - tabulate=0.8.9 + - tinycss2=1.0.0 - tzdata=2022a - xarray=0.19.0 - xlrd=2.0.1 diff --git a/doc/source/getting_started/install.rst b/doc/source/getting_started/install.rst index 00251854e3ffa..87c9f36367c4f 100644 --- a/doc/source/getting_started/install.rst +++ b/doc/source/getting_started/install.rst @@ -296,6 +296,7 @@ Dependency Minimum Version Notes matplotlib 3.3.2 Plotting library Jinja2 3.0.0 Conditional formatting with DataFrame.style tabulate 0.8.9 Printing in Markdown-friendly format (see `tabulate`_) +tinycss2 1.0.0 Style formatting with DataFrame.style ========================= ================== ============================================================= Computation diff --git a/doc/source/whatsnew/v1.6.0.rst b/doc/source/whatsnew/v1.6.0.rst index 67e65cfc26764..a7d33e64c092d 100644 --- a/doc/source/whatsnew/v1.6.0.rst +++ b/doc/source/whatsnew/v1.6.0.rst @@ -29,6 +29,7 @@ enhancement2 Other enhancements ^^^^^^^^^^^^^^^^^^ - :meth:`Series.add_suffix`, :meth:`DataFrame.add_suffix`, :meth:`Series.add_prefix` and :meth:`DataFrame.add_prefix` support an ``axis`` argument. If ``axis`` is set, the default behaviour of which axis to consider can be overwritten (:issue:`47819`) +- Replace custom css parser with tinycss2 library (:issue:`48868`) - .. --------------------------------------------------------------------------- diff --git a/pandas/compat/_optional.py b/pandas/compat/_optional.py index 4f4291c338dd5..b8111911a21e2 100644 --- a/pandas/compat/_optional.py +++ b/pandas/compat/_optional.py @@ -42,6 +42,7 @@ "sqlalchemy": "1.4.16", "tables": "3.6.1", "tabulate": "0.8.9", + "tinycss2": "1.0.0", "xarray": "0.19.0", "xlrd": "2.0.1", "xlwt": "1.3.0", diff --git a/pandas/io/formats/css.py b/pandas/io/formats/css.py index e86a1b0bcd635..e5674995cffa3 100644 --- a/pandas/io/formats/css.py +++ b/pandas/io/formats/css.py @@ -13,9 +13,12 @@ ) import warnings +from pandas.compat._optional import import_optional_dependency from pandas.errors import CSSWarning from pandas.util._exceptions import find_stack_level +tinycss2 = import_optional_dependency("tinycss2") + def _side_expander(prop_fmt: str) -> Callable: """ @@ -377,8 +380,20 @@ def _error(): def atomize(self, declarations: Iterable) -> Generator[tuple[str, str], None, None]: for prop, value in declarations: - prop = prop.lower() - value = value.lower() + # Need to reparse the value here in case the prop was passed + # through as a dict from the styler rather than through this + # classes parse method + tokens = [] + for token in tinycss2.parse_component_value_list(value): + if token.type == "ident": + # The old css parser normalized all identifier values, do + # so here to keep backwards compatibility + token.value = token.lower_value + + tokens.append(token) + + value = tinycss2.serialize(tokens).strip() + if prop in self.CSS_EXPANSIONS: expand = self.CSS_EXPANSIONS[prop] yield from expand(self, prop, value) @@ -395,18 +410,29 @@ def parse(self, declarations_str: str) -> Iterator[tuple[str, str]]: ---------- declarations_str : str """ - for decl in declarations_str.split(";"): - if not decl.strip(): - continue - prop, sep, val = decl.partition(":") - prop = prop.strip().lower() - # TODO: don't lowercase case sensitive parts of values (strings) - val = val.strip().lower() - if sep: - yield prop, val - else: + declarations = tinycss2.parse_declaration_list( + declarations_str, skip_comments=True, skip_whitespace=True + ) + + for decl in declarations: + if decl.type == "error": warnings.warn( - f"Ill-formatted attribute: expected a colon in {repr(decl)}", + decl.message, CSSWarning, stacklevel=find_stack_level(inspect.currentframe()), ) + else: + tokens = [] + for token in decl.value: + if token.type == "ident": + # The old css parser normalized all identifier values, + # do so here to keep backwards compatibility + token.value = token.lower_value + + tokens.append(token) + + value = tinycss2.serialize(tokens).strip() + if decl.important: + value = f"{value} !important" + + yield decl.lower_name, value diff --git a/pandas/io/formats/excel.py b/pandas/io/formats/excel.py index c4ddac088d901..07922e2865e13 100644 --- a/pandas/io/formats/excel.py +++ b/pandas/io/formats/excel.py @@ -28,6 +28,7 @@ IndexLabel, StorageOptions, ) +from pandas.compat._optional import import_optional_dependency from pandas.util._decorators import doc from pandas.util._exceptions import find_stack_level @@ -54,6 +55,8 @@ from pandas.io.formats.format import get_level_lengths from pandas.io.formats.printing import pprint_thing +tinycss2 = import_optional_dependency("tinycss2") + class ExcelCell: __fields__ = ("row", "col", "val", "style", "mergestart", "mergeend") @@ -328,7 +331,24 @@ def build_fill(self, props: Mapping[str, str]): def build_number_format(self, props: Mapping[str, str]) -> dict[str, str | None]: fc = props.get("number-format") + + # Old work around for getting a literal ';' fc = fc.replace("§", ";") if isinstance(fc, str) else fc + + # If a quoted string is passed as the value to number format, get the + # real value of the string. This will allow passing all the characters + # that would otherwise break css if unquoted. + if isinstance(fc, str): + tokens = tinycss2.parse_component_value_list(fc) + + try: + (token,) = tokens + except ValueError: + pass + else: + if token.type == "string": + fc = token.value + return {"format_code": fc} def build_font( diff --git a/pandas/io/formats/style_render.py b/pandas/io/formats/style_render.py index 7631ae2405585..8c7bf931ef1d5 100644 --- a/pandas/io/formats/style_render.py +++ b/pandas/io/formats/style_render.py @@ -49,6 +49,8 @@ jinja2 = import_optional_dependency("jinja2", extra="DataFrame.style requires jinja2.") from markupsafe import escape as escape_html # markupsafe is jinja2 dependency +tinycss2 = import_optional_dependency("tinycss2") + BaseFormatter = Union[str, Callable] ExtFormatter = Union[BaseFormatter, Dict[Any, Optional[BaseFormatter]]] CSSPair = Tuple[str, Union[str, float]] @@ -1106,10 +1108,11 @@ def format( method to create `to_excel` permissible formatting. Note that semi-colons are CSS protected characters but used as separators in Excel's format string. Replace semi-colons with the section separator character (ASCII-245) when - defining the formatting here. + defining the formatting here, or wrap the entire string in quotes to + have the value passed directly through to the writter. >>> df = pd.DataFrame({"A": [1, 0, -1]}) - >>> pseudo_css = "number-format: 0§[Red](0)§-§@;" + >>> pseudo_css = "number-format: '0;[Red](0);-;@';" >>> df.style.applymap(lambda v: css).to_excel("formatted_file.xlsx") ... # doctest: +SKIP @@ -1853,18 +1856,26 @@ def maybe_convert_css_to_tuples(style: CSSProperties) -> CSSList: ('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." - ) + declarations = tinycss2.parse_declaration_list( + style, skip_comments=True, skip_whitespace=True + ) + + parsed_styles = [] + for decl in declarations: + if decl.type == "error": + raise ValueError( + "Styles supplied as string must follow CSS rule formats, " + f"for example 'attr: val;'. '{style}' was given. " + f"{decl.message}" + ) + + value = tinycss2.serialize(decl.value).strip() + if decl.important: + value = f"{value} !important" + + parsed_styles.append((decl.name, value)) + + return parsed_styles return style diff --git a/pandas/tests/io/formats/test_to_excel.py b/pandas/tests/io/formats/test_to_excel.py index 7481baaee94f6..67af45134c8e2 100644 --- a/pandas/tests/io/formats/test_to_excel.py +++ b/pandas/tests/io/formats/test_to_excel.py @@ -215,7 +215,11 @@ ("number-format: 0%", {"number_format": {"format_code": "0%"}}), ( "number-format: 0§[Red](0)§-§@;", - {"number_format": {"format_code": "0;[red](0);-;@"}}, # GH 46152 + {"number_format": {"format_code": "0;[Red](0);-;@"}}, # GH 46152 + ), + ( + "number-format: '#,##0_);[Red](#,##0)';", + {"number_format": {"format_code": "#,##0_);[Red](#,##0)"}}, ), ], ) From e00f5a721b0f7cf143c749f0a511b08d5d0ddaf2 Mon Sep 17 00:00:00 2001 From: Mark Sikora Date: Thu, 29 Sep 2022 14:43:10 -0400 Subject: [PATCH 3/6] Add test skip for new tinycss2 dependency --- pandas/tests/frame/test_api.py | 1 + pandas/tests/io/excel/test_openpyxl.py | 1 + pandas/tests/io/excel/test_style.py | 1 + pandas/tests/io/excel/test_writers.py | 68 ++++++++++++++++--- pandas/tests/io/excel/test_xlrd.py | 2 + pandas/tests/io/excel/test_xlsxwriter.py | 3 + pandas/tests/io/excel/test_xlwt.py | 8 +++ pandas/tests/io/formats/style/test_bar.py | 1 + .../tests/io/formats/style/test_deprecated.py | 1 + .../tests/io/formats/style/test_exceptions.py | 1 + pandas/tests/io/formats/style/test_format.py | 1 + .../tests/io/formats/style/test_highlight.py | 1 + pandas/tests/io/formats/style/test_html.py | 1 + .../tests/io/formats/style/test_matplotlib.py | 1 + .../tests/io/formats/style/test_non_unique.py | 1 + pandas/tests/io/formats/style/test_style.py | 1 + .../tests/io/formats/style/test_to_latex.py | 1 + .../tests/io/formats/style/test_to_string.py | 1 + pandas/tests/io/formats/style/test_tooltip.py | 1 + pandas/tests/io/formats/test_css.py | 1 + pandas/tests/io/formats/test_to_excel.py | 1 + pandas/tests/io/test_common.py | 46 +++++++------ pandas/tests/io/test_fsspec.py | 2 + pandas/tests/io/test_gcs.py | 1 + pandas/tests/series/test_api.py | 1 + 25 files changed, 116 insertions(+), 32 deletions(-) diff --git a/pandas/tests/frame/test_api.py b/pandas/tests/frame/test_api.py index cb97e2bfb6202..e9d49d29ce71f 100644 --- a/pandas/tests/frame/test_api.py +++ b/pandas/tests/frame/test_api.py @@ -378,6 +378,7 @@ def test_constructor_expanddim(self): df._constructor_expanddim(np.arange(27).reshape(3, 3, 3)) @skip_if_no("jinja2") + @skip_if_no("tinycss2") def test_inspect_getmembers(self): # GH38740 df = DataFrame() diff --git a/pandas/tests/io/excel/test_openpyxl.py b/pandas/tests/io/excel/test_openpyxl.py index 3b122c8572751..715840c896150 100644 --- a/pandas/tests/io/excel/test_openpyxl.py +++ b/pandas/tests/io/excel/test_openpyxl.py @@ -15,6 +15,7 @@ ) openpyxl = pytest.importorskip("openpyxl") +pytest.importorskip("tinycss2") pytestmark = pytest.mark.parametrize("ext", [".xlsx"]) diff --git a/pandas/tests/io/excel/test_style.py b/pandas/tests/io/excel/test_style.py index 00f6ccb96a905..eef6cb342826e 100644 --- a/pandas/tests/io/excel/test_style.py +++ b/pandas/tests/io/excel/test_style.py @@ -12,6 +12,7 @@ ) import pandas._testing as tm +pytest.importorskip("tinycss2") from pandas.io.excel import ExcelWriter from pandas.io.formats.excel import ExcelFormatter diff --git a/pandas/tests/io/excel/test_writers.py b/pandas/tests/io/excel/test_writers.py index 3f9ab78e720b9..d34b8ebfa7b7e 100644 --- a/pandas/tests/io/excel/test_writers.py +++ b/pandas/tests/io/excel/test_writers.py @@ -59,13 +59,39 @@ def set_engine(engine, ext): @pytest.mark.parametrize( "ext", [ - pytest.param(".xlsx", marks=[td.skip_if_no("openpyxl"), td.skip_if_no("xlrd")]), - pytest.param(".xlsm", marks=[td.skip_if_no("openpyxl"), td.skip_if_no("xlrd")]), - pytest.param(".xls", marks=[td.skip_if_no("xlwt"), td.skip_if_no("xlrd")]), pytest.param( - ".xlsx", marks=[td.skip_if_no("xlsxwriter"), td.skip_if_no("xlrd")] + ".xlsx", + marks=[ + td.skip_if_no("openpyxl"), + td.skip_if_no("xlrd"), + td.skip_if_no("tinycss2"), + ], + ), + pytest.param( + ".xlsm", + marks=[ + td.skip_if_no("openpyxl"), + td.skip_if_no("xlrd"), + td.skip_if_no("tinycss2"), + ], ), - pytest.param(".ods", marks=td.skip_if_no("odf")), + pytest.param( + ".xls", + marks=[ + td.skip_if_no("xlwt"), + td.skip_if_no("xlrd"), + td.skip_if_no("tinycss2"), + ], + ), + pytest.param( + ".xlsx", + marks=[ + td.skip_if_no("xlsxwriter"), + td.skip_if_no("xlrd"), + td.skip_if_no("tinycss2"), + ], + ), + pytest.param(".ods", marks=[td.skip_if_no("odf"), td.skip_if_no("tinycss2")]), ], ) class TestRoundTrip: @@ -312,22 +338,42 @@ def test_multiindex_interval_datetimes(self, ext): pytest.param( "openpyxl", ".xlsx", - marks=[td.skip_if_no("openpyxl"), td.skip_if_no("xlrd")], + marks=[ + td.skip_if_no("openpyxl"), + td.skip_if_no("xlrd"), + td.skip_if_no("tinycss2"), + ], ), pytest.param( "openpyxl", ".xlsm", - marks=[td.skip_if_no("openpyxl"), td.skip_if_no("xlrd")], + marks=[ + td.skip_if_no("openpyxl"), + td.skip_if_no("xlrd"), + td.skip_if_no("tinycss2"), + ], ), pytest.param( - "xlwt", ".xls", marks=[td.skip_if_no("xlwt"), td.skip_if_no("xlrd")] + "xlwt", + ".xls", + marks=[ + td.skip_if_no("xlwt"), + td.skip_if_no("xlrd"), + td.skip_if_no("tinycss2"), + ], ), pytest.param( "xlsxwriter", ".xlsx", - marks=[td.skip_if_no("xlsxwriter"), td.skip_if_no("xlrd")], + marks=[ + td.skip_if_no("xlsxwriter"), + td.skip_if_no("xlrd"), + td.skip_if_no("tinycss2"), + ], + ), + pytest.param( + "odf", ".ods", marks=[td.skip_if_no("odf"), td.skip_if_no("tinycss2")] ), - pytest.param("odf", ".ods", marks=td.skip_if_no("odf")), ], ) @pytest.mark.usefixtures("set_engine") @@ -1324,6 +1370,7 @@ def test_ExcelWriter_dispatch_raises(self): with pytest.raises(ValueError, match="No engine"): ExcelWriter("nothing") + @td.skip_if_no("tinycss2") def test_register_writer(self): class DummyClass(ExcelWriter): called_save = False @@ -1386,6 +1433,7 @@ def test_engine_kwargs_and_kwargs_raises(self, ext): @td.skip_if_no("xlrd") @td.skip_if_no("openpyxl") +@td.skip_if_no("tinycss2") class TestFSPath: def test_excelfile_fspath(self): with tm.ensure_clean("foo.xlsx") as path: diff --git a/pandas/tests/io/excel/test_xlrd.py b/pandas/tests/io/excel/test_xlrd.py index 86141f08f5f2d..0ef5b3137e663 100644 --- a/pandas/tests/io/excel/test_xlrd.py +++ b/pandas/tests/io/excel/test_xlrd.py @@ -3,6 +3,7 @@ import pytest from pandas.compat._optional import import_optional_dependency +import pandas.util._test_decorators as td import pandas as pd import pandas._testing as tm @@ -31,6 +32,7 @@ def read_ext_xlrd(request): return request.param +@td.skip_if_no("tinycss2") def test_read_xlrd_book(read_ext_xlrd, frame): df = frame diff --git a/pandas/tests/io/excel/test_xlsxwriter.py b/pandas/tests/io/excel/test_xlsxwriter.py index 82d47a13aefbc..f404bab0a1747 100644 --- a/pandas/tests/io/excel/test_xlsxwriter.py +++ b/pandas/tests/io/excel/test_xlsxwriter.py @@ -4,6 +4,8 @@ import pytest +import pandas.util._test_decorators as td + from pandas import DataFrame import pandas._testing as tm @@ -14,6 +16,7 @@ pytestmark = pytest.mark.parametrize("ext", [".xlsx"]) +@td.skip_if_no("tinycss2") def test_column_format(ext): # Test that column formats are applied to cells. Test for issue #9167. # Applicable to xlsxwriter only. diff --git a/pandas/tests/io/excel/test_xlwt.py b/pandas/tests/io/excel/test_xlwt.py index 3aa405eb1e275..95e40626e8c66 100644 --- a/pandas/tests/io/excel/test_xlwt.py +++ b/pandas/tests/io/excel/test_xlwt.py @@ -3,6 +3,8 @@ import numpy as np import pytest +import pandas.util._test_decorators as td + from pandas import ( DataFrame, MultiIndex, @@ -20,6 +22,7 @@ pytestmark = pytest.mark.parametrize("ext,", [".xls"]) +@td.skip_if_no("tinycss2") def test_excel_raise_error_on_multiindex_columns_and_no_index(ext): # MultiIndex as columns is not yet implemented 9794 cols = MultiIndex.from_tuples( @@ -36,6 +39,7 @@ def test_excel_raise_error_on_multiindex_columns_and_no_index(ext): df.to_excel(path, index=False) +@td.skip_if_no("tinycss2") def test_excel_multiindex_columns_and_index_true(ext): cols = MultiIndex.from_tuples( [("site", ""), ("2014", "height"), ("2014", "weight")] @@ -45,6 +49,7 @@ def test_excel_multiindex_columns_and_index_true(ext): df.to_excel(path, index=True) +@td.skip_if_no("tinycss2") def test_excel_multiindex_index(ext): # MultiIndex as index works so assert no error #9794 cols = MultiIndex.from_tuples( @@ -80,6 +85,7 @@ def test_write_append_mode_raises(ext): ExcelWriter(f, engine="xlwt", mode="a") +@td.skip_if_no("tinycss2") def test_to_excel_xlwt_warning(ext): # GH 26552 df = DataFrame(np.random.randn(3, 10)) @@ -101,6 +107,7 @@ def test_option_xls_writer_deprecated(ext): options.io.excel.xls.writer = "xlwt" +@td.skip_if_no("tinycss2") @pytest.mark.parametrize("style_compression", [0, 2]) def test_kwargs(ext, style_compression): # GH 42286 @@ -116,6 +123,7 @@ def test_kwargs(ext, style_compression): DataFrame().to_excel(writer) +@td.skip_if_no("tinycss2") @pytest.mark.parametrize("style_compression", [0, 2]) def test_engine_kwargs(ext, style_compression): # GH 42286 diff --git a/pandas/tests/io/formats/style/test_bar.py b/pandas/tests/io/formats/style/test_bar.py index 19884aaac86a7..fe2321d9eff5b 100644 --- a/pandas/tests/io/formats/style/test_bar.py +++ b/pandas/tests/io/formats/style/test_bar.py @@ -4,6 +4,7 @@ from pandas import DataFrame pytest.importorskip("jinja2") +pytest.importorskip("tinycss2") def bar_grad(a=None, b=None, c=None, d=None): diff --git a/pandas/tests/io/formats/style/test_deprecated.py b/pandas/tests/io/formats/style/test_deprecated.py index 863c31ed3cccd..42d27aa4df611 100644 --- a/pandas/tests/io/formats/style/test_deprecated.py +++ b/pandas/tests/io/formats/style/test_deprecated.py @@ -5,6 +5,7 @@ import pytest jinja2 = pytest.importorskip("jinja2") +jinja2 = pytest.importorskip("tinycss2") from pandas import ( DataFrame, diff --git a/pandas/tests/io/formats/style/test_exceptions.py b/pandas/tests/io/formats/style/test_exceptions.py index d52e3a37e7693..1588ea5436c36 100644 --- a/pandas/tests/io/formats/style/test_exceptions.py +++ b/pandas/tests/io/formats/style/test_exceptions.py @@ -1,6 +1,7 @@ import pytest jinja2 = pytest.importorskip("jinja2") +jinja2 = pytest.importorskip("tinycss2") from pandas import ( DataFrame, diff --git a/pandas/tests/io/formats/style/test_format.py b/pandas/tests/io/formats/style/test_format.py index 0b114ea128b0b..1e2b19aa86ad8 100644 --- a/pandas/tests/io/formats/style/test_format.py +++ b/pandas/tests/io/formats/style/test_format.py @@ -12,6 +12,7 @@ ) pytest.importorskip("jinja2") +pytest.importorskip("tinycss2") from pandas.io.formats.style import Styler from pandas.io.formats.style_render import _str_escape diff --git a/pandas/tests/io/formats/style/test_highlight.py b/pandas/tests/io/formats/style/test_highlight.py index 3d59719010ee0..4966bf4c6c4ea 100644 --- a/pandas/tests/io/formats/style/test_highlight.py +++ b/pandas/tests/io/formats/style/test_highlight.py @@ -8,6 +8,7 @@ ) pytest.importorskip("jinja2") +pytest.importorskip("tinyccs2") from pandas.io.formats.style import Styler diff --git a/pandas/tests/io/formats/style/test_html.py b/pandas/tests/io/formats/style/test_html.py index 46891863975ea..10d9561a562bc 100644 --- a/pandas/tests/io/formats/style/test_html.py +++ b/pandas/tests/io/formats/style/test_html.py @@ -10,6 +10,7 @@ ) jinja2 = pytest.importorskip("jinja2") +pytest.importorskip("tinycss2") from pandas.io.formats.style import Styler loader = jinja2.PackageLoader("pandas", "io/formats/templates") diff --git a/pandas/tests/io/formats/style/test_matplotlib.py b/pandas/tests/io/formats/style/test_matplotlib.py index 8d9f075d8674d..c5cf5e622b247 100644 --- a/pandas/tests/io/formats/style/test_matplotlib.py +++ b/pandas/tests/io/formats/style/test_matplotlib.py @@ -9,6 +9,7 @@ pytest.importorskip("matplotlib") pytest.importorskip("jinja2") +pytest.importorskip("tinycss2") import matplotlib as mpl diff --git a/pandas/tests/io/formats/style/test_non_unique.py b/pandas/tests/io/formats/style/test_non_unique.py index b719bf3372038..109030da1bd62 100644 --- a/pandas/tests/io/formats/style/test_non_unique.py +++ b/pandas/tests/io/formats/style/test_non_unique.py @@ -8,6 +8,7 @@ ) pytest.importorskip("jinja2") +pytest.importorskip("tinycss2") from pandas.io.formats.style import Styler diff --git a/pandas/tests/io/formats/style/test_style.py b/pandas/tests/io/formats/style/test_style.py index 192fec048a930..9255174b161fc 100644 --- a/pandas/tests/io/formats/style/test_style.py +++ b/pandas/tests/io/formats/style/test_style.py @@ -15,6 +15,7 @@ import pandas._testing as tm jinja2 = pytest.importorskip("jinja2") +pytest.importorskip("tinycss2") from pandas.io.formats.style import ( # isort:skip Styler, ) diff --git a/pandas/tests/io/formats/style/test_to_latex.py b/pandas/tests/io/formats/style/test_to_latex.py index 08fbeab8bcd76..696e89ca580f1 100644 --- a/pandas/tests/io/formats/style/test_to_latex.py +++ b/pandas/tests/io/formats/style/test_to_latex.py @@ -10,6 +10,7 @@ ) pytest.importorskip("jinja2") +pytest.importorskip("tinycss2") from pandas.io.formats.style import Styler from pandas.io.formats.style_render import ( _parse_latex_cell_styles, diff --git a/pandas/tests/io/formats/style/test_to_string.py b/pandas/tests/io/formats/style/test_to_string.py index fcac304b8c3bb..e66d1d30d61b7 100644 --- a/pandas/tests/io/formats/style/test_to_string.py +++ b/pandas/tests/io/formats/style/test_to_string.py @@ -5,6 +5,7 @@ from pandas import DataFrame pytest.importorskip("jinja2") +pytest.importorskip("tinycss2") from pandas.io.formats.style import Styler diff --git a/pandas/tests/io/formats/style/test_tooltip.py b/pandas/tests/io/formats/style/test_tooltip.py index c49a0e05c6700..0bc4ee56e1006 100644 --- a/pandas/tests/io/formats/style/test_tooltip.py +++ b/pandas/tests/io/formats/style/test_tooltip.py @@ -4,6 +4,7 @@ from pandas import DataFrame pytest.importorskip("jinja2") +pytest.importorskip("tinycss2") from pandas.io.formats.style import Styler diff --git a/pandas/tests/io/formats/test_css.py b/pandas/tests/io/formats/test_css.py index 70c91dd02751a..e9ca2762c34c3 100644 --- a/pandas/tests/io/formats/test_css.py +++ b/pandas/tests/io/formats/test_css.py @@ -4,6 +4,7 @@ import pandas._testing as tm +pytest.importorskip("tinycss2") from pandas.io.formats.css import CSSResolver diff --git a/pandas/tests/io/formats/test_to_excel.py b/pandas/tests/io/formats/test_to_excel.py index 67af45134c8e2..dad13ace7c661 100644 --- a/pandas/tests/io/formats/test_to_excel.py +++ b/pandas/tests/io/formats/test_to_excel.py @@ -11,6 +11,7 @@ import pandas._testing as tm +pytest.importorskip("tinycss2") from pandas.io.formats.excel import ( CssExcelCell, CSSToExcelConverter, diff --git a/pandas/tests/io/test_common.py b/pandas/tests/io/test_common.py index e9e99f6dd0ad7..11a27e01c1497 100644 --- a/pandas/tests/io/test_common.py +++ b/pandas/tests/io/test_common.py @@ -216,21 +216,22 @@ def test_read_non_existent(self, reader, module, error_class, fn_ext): reader(path) @pytest.mark.parametrize( - "method, module, error_class, fn_ext", + "method, modules, error_class, fn_ext", [ - (pd.DataFrame.to_csv, "os", OSError, "csv"), - (pd.DataFrame.to_html, "os", OSError, "html"), - (pd.DataFrame.to_excel, "xlrd", OSError, "xlsx"), - (pd.DataFrame.to_feather, "pyarrow", OSError, "feather"), - (pd.DataFrame.to_parquet, "pyarrow", OSError, "parquet"), - (pd.DataFrame.to_stata, "os", OSError, "dta"), - (pd.DataFrame.to_json, "os", OSError, "json"), - (pd.DataFrame.to_pickle, "os", OSError, "pickle"), + (pd.DataFrame.to_csv, [], OSError, "csv"), + (pd.DataFrame.to_html, [], OSError, "html"), + (pd.DataFrame.to_excel, ["xlrd", "tinycss2"], OSError, "xlsx"), + (pd.DataFrame.to_feather, ["pyarrow"], OSError, "feather"), + (pd.DataFrame.to_parquet, ["pyarrow"], OSError, "parquet"), + (pd.DataFrame.to_stata, [], OSError, "dta"), + (pd.DataFrame.to_json, [], OSError, "json"), + (pd.DataFrame.to_pickle, [], OSError, "pickle"), ], ) # NOTE: Missing parent directory for pd.DataFrame.to_hdf is handled by PyTables - def test_write_missing_parent_directory(self, method, module, error_class, fn_ext): - pytest.importorskip(module) + def test_write_missing_parent_directory(self, method, modules, error_class, fn_ext): + for module in modules: + pytest.importorskip(module) dummy_frame = pd.DataFrame({"a": [1, 2, 3], "b": [2, 3, 4], "c": [3, 4, 5]}) @@ -338,25 +339,26 @@ def test_read_fspath_all(self, reader, module, path, datapath): @pytest.mark.filterwarnings("ignore:In future versions `DataFrame.to_latex`") @pytest.mark.parametrize( - "writer_name, writer_kwargs, module", + "writer_name, writer_kwargs, modules", [ - ("to_csv", {}, "os"), - ("to_excel", {"engine": "xlwt"}, "xlwt"), - ("to_feather", {}, "pyarrow"), - ("to_html", {}, "os"), - ("to_json", {}, "os"), - ("to_latex", {}, "os"), - ("to_pickle", {}, "os"), - ("to_stata", {"time_stamp": pd.to_datetime("2019-01-01 00:00")}, "os"), + ("to_csv", {}, []), + ("to_excel", {"engine": "xlwt"}, ["xlwt", "tinycss2"]), + ("to_feather", {}, ["pyarrow"]), + ("to_html", {}, []), + ("to_json", {}, []), + ("to_latex", {}, []), + ("to_pickle", {}, []), + ("to_stata", {"time_stamp": pd.to_datetime("2019-01-01 00:00")}, []), ], ) - def test_write_fspath_all(self, writer_name, writer_kwargs, module): + def test_write_fspath_all(self, writer_name, writer_kwargs, modules): p1 = tm.ensure_clean("string") p2 = tm.ensure_clean("fspath") df = pd.DataFrame({"A": [1, 2]}) with p1 as string, p2 as fspath: - pytest.importorskip(module) + for module in modules: + pytest.importorskip(module) mypath = CustomFSPath(fspath) writer = getattr(df, writer_name) diff --git a/pandas/tests/io/test_fsspec.py b/pandas/tests/io/test_fsspec.py index 4f033fd63f978..0eac8491f0570 100644 --- a/pandas/tests/io/test_fsspec.py +++ b/pandas/tests/io/test_fsspec.py @@ -76,6 +76,7 @@ def test_to_csv(cleared_fs, df1): @pytest.mark.parametrize("ext", ["xls", "xlsx"]) def test_to_excel(cleared_fs, ext, df1): + pytest.importorskip("tinycss2") if ext == "xls": pytest.importorskip("xlwt") else: @@ -138,6 +139,7 @@ def test_read_table_options(fsspectest): @pytest.mark.parametrize("extension", ["xlsx", "xls"]) def test_excel_options(fsspectest, extension): + pytest.importorskip("tinycss2") if extension == "xls": pytest.importorskip("xlwt") else: diff --git a/pandas/tests/io/test_gcs.py b/pandas/tests/io/test_gcs.py index 6907d8978e603..5e61c304a4f9d 100644 --- a/pandas/tests/io/test_gcs.py +++ b/pandas/tests/io/test_gcs.py @@ -73,6 +73,7 @@ def test_to_read_gcs(gcs_buffer, format): df1.to_csv(path, index=True) df2 = read_csv(path, parse_dates=["dt"], index_col=0) elif format == "excel": + pytest.importorskip("tinycss2") path = "gs://test/test.xls" df1.to_excel(path) df2 = read_excel(path, parse_dates=["dt"], index_col=0) diff --git a/pandas/tests/series/test_api.py b/pandas/tests/series/test_api.py index 0aab381d6e076..10667eb9ee584 100644 --- a/pandas/tests/series/test_api.py +++ b/pandas/tests/series/test_api.py @@ -168,6 +168,7 @@ def test_attrs(self): assert result.attrs == {"version": 1} @skip_if_no("jinja2") + @skip_if_no("tinycss2") def test_inspect_getmembers(self): # GH38782 ser = Series(dtype=object) From 285960131518d8825d50607d879f988eca1ef384 Mon Sep 17 00:00:00 2001 From: Mark Sikora Date: Thu, 29 Sep 2022 15:09:26 -0400 Subject: [PATCH 4/6] Fix doc typo --- pandas/io/formats/style_render.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pandas/io/formats/style_render.py b/pandas/io/formats/style_render.py index 8c7bf931ef1d5..db2af763671a8 100644 --- a/pandas/io/formats/style_render.py +++ b/pandas/io/formats/style_render.py @@ -1109,7 +1109,7 @@ def format( CSS protected characters but used as separators in Excel's format string. Replace semi-colons with the section separator character (ASCII-245) when defining the formatting here, or wrap the entire string in quotes to - have the value passed directly through to the writter. + have the value passed directly through to the writer. >>> df = pd.DataFrame({"A": [1, 0, -1]}) >>> pseudo_css = "number-format: '0;[Red](0);-;@';" From f7d4832c3a618dc5b781cf1886b86553f88ab435 Mon Sep 17 00:00:00 2001 From: Mark Sikora Date: Thu, 29 Sep 2022 17:27:34 -0400 Subject: [PATCH 5/6] Add tinycss2 to environment --- environment.yml | 1 + requirements-dev.txt | 1 + 2 files changed, 2 insertions(+) diff --git a/environment.yml b/environment.yml index 5655b0514717a..ad3b1b6ce1359 100644 --- a/environment.yml +++ b/environment.yml @@ -49,6 +49,7 @@ dependencies: - scipy - sqlalchemy - tabulate + - tinycss2 - tzdata>=2022a - xarray - xlrd diff --git a/requirements-dev.txt b/requirements-dev.txt index a3e837d13e066..cfdd4626fc9ca 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -40,6 +40,7 @@ s3fs>=2021.08.0 scipy sqlalchemy tabulate +tinycss2 tzdata>=2022.1 xarray xlrd From 63886620c0135c1a4f21ee21f7b3725d8d1a460a Mon Sep 17 00:00:00 2001 From: Mark Sikora Date: Thu, 29 Sep 2022 17:27:59 -0400 Subject: [PATCH 6/6] Set tinycss2 to minimum version in conda-forge --- ci/deps/actions-38-minimum_versions.yaml | 2 +- pandas/compat/_optional.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ci/deps/actions-38-minimum_versions.yaml b/ci/deps/actions-38-minimum_versions.yaml index e0432b83f5ff6..ccbb2c205762b 100644 --- a/ci/deps/actions-38-minimum_versions.yaml +++ b/ci/deps/actions-38-minimum_versions.yaml @@ -49,7 +49,7 @@ dependencies: - scipy=1.7.1 - sqlalchemy=1.4.16 - tabulate=0.8.9 - - tinycss2=1.0.0 + - tinycss2=1.0.2 - tzdata=2022a - xarray=0.19.0 - xlrd=2.0.1 diff --git a/pandas/compat/_optional.py b/pandas/compat/_optional.py index b8111911a21e2..4b509b32d9769 100644 --- a/pandas/compat/_optional.py +++ b/pandas/compat/_optional.py @@ -42,7 +42,7 @@ "sqlalchemy": "1.4.16", "tables": "3.6.1", "tabulate": "0.8.9", - "tinycss2": "1.0.0", + "tinycss2": "1.0.2", "xarray": "0.19.0", "xlrd": "2.0.1", "xlwt": "1.3.0",