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..dee28adb85edb 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(): + # GH#52995 + 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", [