From a3fd5d3a64b01fe5981481bb00c5bd1a382a9058 Mon Sep 17 00:00:00 2001 From: richard Date: Fri, 28 Apr 2023 22:38:01 -0400 Subject: [PATCH 1/5] REF: Remove side-effects from importing Styler --- pandas/io/formats/style.py | 25 +++--- pandas/io/formats/style_render.py | 81 +++++++++++++++++--- pandas/tests/io/formats/style/test_format.py | 8 +- pandas/tests/io/formats/style/test_style.py | 19 +++++ 4 files changed, 104 insertions(+), 29 deletions(-) diff --git a/pandas/io/formats/style.py b/pandas/io/formats/style.py index e2c5ed2ea92b6..ccbc47c64ca91 100644 --- a/pandas/io/formats/style.py +++ b/pandas/io/formats/style.py @@ -41,9 +41,6 @@ from pandas.core.shared_docs import _shared_docs from pandas.io.formats.format import save_to_buffer - -jinja2 = import_optional_dependency("jinja2", extra="DataFrame.style requires jinja2.") - from pandas.io.formats.style_render import ( CSSProperties, CSSStyles, @@ -75,22 +72,18 @@ from pandas import ExcelWriter -try: - import matplotlib as mpl - import matplotlib.pyplot as plt - - has_mpl = True -except ImportError: - has_mpl = False - @contextmanager def _mpl(func: Callable) -> Generator[tuple[Any, Any], None, None]: - if has_mpl: - yield plt, mpl - else: + try: + import matplotlib as mpl + import matplotlib.pyplot as plt + + except ImportError: raise ImportError(f"{func.__name__} requires matplotlib.") + yield plt, mpl + #### # Shared Doc Strings @@ -3424,6 +3417,10 @@ def from_custom_template( Has the correct ``env``,``template_html``, ``template_html_table`` and ``template_html_style`` class attributes set. """ + jinja2 = import_optional_dependency( + "jinja2", extra="DataFrame.style requires jinja2." + ) + loader = jinja2.ChoiceLoader([jinja2.FileSystemLoader(searchpath), cls.loader]) # mypy doesn't like dynamically-defined classes diff --git a/pandas/io/formats/style_render.py b/pandas/io/formats/style_render.py index 5b608089945a2..e6e0733a6f4ae 100644 --- a/pandas/io/formats/style_render.py +++ b/pandas/io/formats/style_render.py @@ -48,8 +48,6 @@ Axis, Level, ) -jinja2 = import_optional_dependency("jinja2", extra="DataFrame.style requires jinja2.") -from markupsafe import escape as escape_html # markupsafe is jinja2 dependency BaseFormatter = Union[str, Callable] ExtFormatter = Union[BaseFormatter, Dict[Any, Optional[BaseFormatter]]] @@ -72,13 +70,14 @@ 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_html = env.get_template("html.tpl") - template_html_table = env.get_template("html_table.tpl") - template_html_style = env.get_template("html_style.tpl") - template_latex = env.get_template("latex.tpl") - template_string = env.get_template("string.tpl") + # For cached class properties defined below + _loader = None + _env = None + _template_html = None + _template_html_table = None + _template_html_style = None + _template_latex = None + _template_string = None def __init__( self, @@ -147,6 +146,61 @@ def __init__( tuple[int, int], Callable[[Any], str] ] = defaultdict(lambda: partial(_default_formatter, precision=precision)) + @classmethod + @property + def loader(cls): + if cls._loader is None: + jinja2 = import_optional_dependency( + "jinja2", extra="DataFrame.style requires jinja2." + ) + cls._loader = jinja2.PackageLoader("pandas", "io/formats/templates") + return cls._loader + + @classmethod + @property + def env(cls): + if cls._env is None: + jinja2 = import_optional_dependency( + "jinja2", extra="DataFrame.style requires jinja2." + ) + cls._env = jinja2.Environment(loader=cls.loader, trim_blocks=True) + return cls._env + + @classmethod + @property + def template_html(cls): + if cls._template_html is None: + cls._template_html = cls.env.get_template("html.tpl") + return cls._template_html + + @classmethod + @property + def template_html_table(cls): + if cls._template_html_table is None: + cls._template_html_table = cls.env.get_template("html_table.tpl") + return cls._template_html_table + + @classmethod + @property + def template_html_style(cls): + if cls._template_html_style is None: + cls._template_html_style = cls.env.get_template("html_style.tpl") + return cls._template_html_style + + @classmethod + @property + def template_latex(cls): + if cls._template_latex is None: + cls._template_latex = cls.env.get_template("latex.tpl") + return cls._template_latex + + @classmethod + @property + def template_string(cls): + if cls._template_string is None: + cls._template_string = cls.env.get_template("string.tpl") + return cls._template_string + def _render( self, sparse_index: bool, @@ -1778,11 +1832,11 @@ def wrapper(x): return wrapper -def _str_escape(x, escape): +def _str_escape(x, escape, markupsafe): """if escaping: only use on str, else return input""" if isinstance(x, str): if escape == "html": - return escape_html(x) + return markupsafe.escape(x) elif escape == "latex": return _escape_latex(x) elif escape == "latex-math": @@ -1840,7 +1894,10 @@ def _maybe_wrap_formatter( # Replace chars if escaping if escape is not None: - func_1 = lambda x: func_0(_str_escape(x, escape=escape)) + markupsafe = import_optional_dependency( + "markupsafe", extra="DataFrame.style requires markupsafe." + ) + func_1 = lambda x: func_0(_str_escape(x, escape=escape, markupsafe=markupsafe)) else: func_1 = func_0 diff --git a/pandas/tests/io/formats/style/test_format.py b/pandas/tests/io/formats/style/test_format.py index c6e981c684044..570982d30ba00 100644 --- a/pandas/tests/io/formats/style/test_format.py +++ b/pandas/tests/io/formats/style/test_format.py @@ -12,6 +12,8 @@ ) pytest.importorskip("jinja2") +import markupsafe + from pandas.io.formats.style import Styler from pandas.io.formats.style_render import _str_escape @@ -408,12 +410,12 @@ def test_format_decimal(formatter, thousands, precision, func, col): def test_str_escape_error(): msg = "`escape` only permitted in {'html', 'latex', 'latex-math'}, got " with pytest.raises(ValueError, match=msg): - _str_escape("text", "bad_escape") + _str_escape("text", "bad_escape", markupsafe) with pytest.raises(ValueError, match=msg): - _str_escape("text", []) + _str_escape("text", [], markupsafe) - _str_escape(2.00, "bad_escape") # OK since dtype is float + _str_escape(2.00, "bad_escape", markupsafe) # OK since dtype is float def test_format_options(): diff --git a/pandas/tests/io/formats/style/test_style.py b/pandas/tests/io/formats/style/test_style.py index 0abe4b82e8848..fd4b2cc676a77 100644 --- a/pandas/tests/io/formats/style/test_style.py +++ b/pandas/tests/io/formats/style/test_style.py @@ -1,6 +1,8 @@ import contextlib import copy import re +import subprocess +import sys from textwrap import dedent import numpy as np @@ -99,6 +101,23 @@ def styler(df): return Styler(df) +def test_import_styler_no_side_effects(): + # Check that Matplotlib converters are properly reset (see issue #27481) + code = ( + "import matplotlib.units as units; " + "import matplotlib.dates as mdates; " + "n_conv = len(units.registry); " + "import pandas as pd; " + "from pandas.io.formats.style import Styler; " + "assert len(units.registry) == n_conv; " + # Ensure jinja2 was not imported + "import sys; " + "assert 'jinja2' not in sys.modules; " + ) + call = [sys.executable, "-c", code] + subprocess.check_output(call) + + @pytest.mark.parametrize( "sparse_columns, exp_cols", [ From 648eb4df00ea5957da7296d2f1385f1b684a17fb Mon Sep 17 00:00:00 2001 From: richard Date: Fri, 28 Apr 2023 22:45:12 -0400 Subject: [PATCH 2/5] GH# --- pandas/tests/io/formats/style/test_style.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pandas/tests/io/formats/style/test_style.py b/pandas/tests/io/formats/style/test_style.py index fd4b2cc676a77..dee28adb85edb 100644 --- a/pandas/tests/io/formats/style/test_style.py +++ b/pandas/tests/io/formats/style/test_style.py @@ -102,7 +102,7 @@ def styler(df): def test_import_styler_no_side_effects(): - # Check that Matplotlib converters are properly reset (see issue #27481) + # GH#52995 code = ( "import matplotlib.units as units; " "import matplotlib.dates as mdates; " From ebb716a57c34e0f41ecc1f8eab1aa05748e70696 Mon Sep 17 00:00:00 2001 From: "JHM Darbyshire (iMac)" Date: Sun, 28 May 2023 15:45:03 +0200 Subject: [PATCH 3/5] Assign template locations as string --- pandas/io/formats/style_render.py | 66 ++++++++----------------------- 1 file changed, 17 insertions(+), 49 deletions(-) diff --git a/pandas/io/formats/style_render.py b/pandas/io/formats/style_render.py index e6e0733a6f4ae..fc2c9a3b6cca6 100644 --- a/pandas/io/formats/style_render.py +++ b/pandas/io/formats/style_render.py @@ -73,11 +73,11 @@ class StylerRenderer: # For cached class properties defined below _loader = None _env = None - _template_html = None - _template_html_table = None - _template_html_style = None - _template_latex = None - _template_string = None + template_html = "html.tpl" + template_html_table = "html_table.tpl" + template_html_style = "html_style.tpl" + template_latex = "latex.tpl" + template_string = "string.tpl" def __init__( self, @@ -166,41 +166,6 @@ def env(cls): cls._env = jinja2.Environment(loader=cls.loader, trim_blocks=True) return cls._env - @classmethod - @property - def template_html(cls): - if cls._template_html is None: - cls._template_html = cls.env.get_template("html.tpl") - return cls._template_html - - @classmethod - @property - def template_html_table(cls): - if cls._template_html_table is None: - cls._template_html_table = cls.env.get_template("html_table.tpl") - return cls._template_html_table - - @classmethod - @property - def template_html_style(cls): - if cls._template_html_style is None: - cls._template_html_style = cls.env.get_template("html_style.tpl") - return cls._template_html_style - - @classmethod - @property - def template_latex(cls): - if cls._template_latex is None: - cls._template_latex = cls.env.get_template("latex.tpl") - return cls._template_latex - - @classmethod - @property - def template_string(cls): - if cls._template_string is None: - cls._template_string = cls.env.get_template("string.tpl") - return cls._template_string - def _render( self, sparse_index: bool, @@ -260,10 +225,11 @@ def _render_html( """ d = self._render(sparse_index, sparse_columns, max_rows, max_cols, " ") d.update(kwargs) - return self.template_html.render( + template_html = self.env.get_template(self.template_html) + return template_html.render( **d, - html_table_tpl=self.template_html_table, - html_style_tpl=self.template_html_style, + html_table_tpl=self.env.get_template(self.template_html_table), + html_style_tpl=self.env.get_template(self.template_html_style), ) def _render_latex( @@ -273,13 +239,14 @@ def _render_latex( Render a Styler in latex format """ d = self._render(sparse_index, sparse_columns, None, None) + template_latex = self.env.get_template(self.template_latex) self._translate_latex(d, clines=clines) - self.template_latex.globals["parse_wrap"] = _parse_latex_table_wrapping - self.template_latex.globals["parse_table"] = _parse_latex_table_styles - self.template_latex.globals["parse_cell"] = _parse_latex_cell_styles - self.template_latex.globals["parse_header"] = _parse_latex_header_span + template_latex.globals["parse_wrap"] = _parse_latex_table_wrapping + template_latex.globals["parse_table"] = _parse_latex_table_styles + template_latex.globals["parse_cell"] = _parse_latex_cell_styles + template_latex.globals["parse_header"] = _parse_latex_header_span d.update(kwargs) - return self.template_latex.render(**d) + return template_latex.render(**d) def _render_string( self, @@ -294,7 +261,8 @@ def _render_string( """ d = self._render(sparse_index, sparse_columns, max_rows, max_cols) d.update(kwargs) - return self.template_string.render(**d) + template_string = self.env.get_template(self.template_string) + return template_string.render(**d) def _compute(self): """ From fbba5f01bbf2b34857b4a38f2e2091d36b132c57 Mon Sep 17 00:00:00 2001 From: "JHM Darbyshire (iMac)" Date: Sun, 28 May 2023 21:52:53 +0200 Subject: [PATCH 4/5] Allow template customisation --- pandas/io/formats/style_render.py | 14 +++++++++++++- .../io/formats/style/custom_templates/myhtml.tpl | 5 +++++ pandas/tests/io/formats/style/test_style.py | 10 ++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 pandas/tests/io/formats/style/custom_templates/myhtml.tpl diff --git a/pandas/io/formats/style_render.py b/pandas/io/formats/style_render.py index 3e6df2e5c6ddf..b15a2c7a65a94 100644 --- a/pandas/io/formats/style_render.py +++ b/pandas/io/formats/style_render.py @@ -73,6 +73,7 @@ class StylerRenderer: # For cached class properties defined below _loader = None _env = None + custom_template_directory = None template_html = "html.tpl" template_html_table = "html_table.tpl" template_html_style = "html_style.tpl" @@ -163,7 +164,18 @@ def env(cls): jinja2 = import_optional_dependency( "jinja2", extra="DataFrame.style requires jinja2." ) - cls._env = jinja2.Environment(loader=cls.loader, trim_blocks=True) + if cls.custom_template_directory is None: + cls._env = jinja2.Environment(loader=cls.loader, trim_blocks=True) + else: + cls._env = jinja2.Environment( + loader=jinja2.ChoiceLoader( + [ + jinja2.FileSystemLoader(cls.custom_template_directory), + cls.loader, + ] + ), + trim_blocks=True, + ) return cls._env def _render( diff --git a/pandas/tests/io/formats/style/custom_templates/myhtml.tpl b/pandas/tests/io/formats/style/custom_templates/myhtml.tpl new file mode 100644 index 0000000000000..1e204d0bd4568 --- /dev/null +++ b/pandas/tests/io/formats/style/custom_templates/myhtml.tpl @@ -0,0 +1,5 @@ +{% extends "html_table.tpl" %} +{% block table %} +

{{ table_title|default("My Table") }}

+{{ super() }} +{% endblock table %} diff --git a/pandas/tests/io/formats/style/test_style.py b/pandas/tests/io/formats/style/test_style.py index b8adddc5c3ab3..f40a0063a2b53 100644 --- a/pandas/tests/io/formats/style/test_style.py +++ b/pandas/tests/io/formats/style/test_style.py @@ -1607,3 +1607,13 @@ def test_output_buffer(mi_styler, format): # gh 47053 with tm.ensure_clean(f"delete_me.{format}") as f: getattr(mi_styler, f"to_{format}")(f) + + +def test_custom_template(df): + class MyStyler(Styler): + custom_template_directory = "custom_templates/" + template_html = "myhtml.tpl" + + styler = MyStyler(df) + result = styler.to_html() + assert "

My Table

" in result From 728283fd904b80cba6028853e9202aacff21f6cd Mon Sep 17 00:00:00 2001 From: "JHM Darbyshire (iMac)" Date: Sun, 28 May 2023 22:36:16 +0200 Subject: [PATCH 5/5] Allow template customisation --- pandas/tests/io/formats/style/test_style.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/pandas/tests/io/formats/style/test_style.py b/pandas/tests/io/formats/style/test_style.py index f40a0063a2b53..a778fca33891b 100644 --- a/pandas/tests/io/formats/style/test_style.py +++ b/pandas/tests/io/formats/style/test_style.py @@ -1609,9 +1609,13 @@ def test_output_buffer(mi_styler, format): getattr(mi_styler, f"to_{format}")(f) -def test_custom_template(df): +def test_custom_template_path(df): + import os + + path = os.path.abspath(os.path.dirname(__file__)) + class MyStyler(Styler): - custom_template_directory = "custom_templates/" + custom_template_directory = os.path.join(path, "custom_templates") template_html = "myhtml.tpl" styler = MyStyler(df)