Skip to content

REF: Remove side-effects from importing Styler #52995

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 11 additions & 14 deletions pandas/io/formats/style.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
81 changes: 69 additions & 12 deletions pandas/io/formats/style_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]]]
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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

Expand Down
8 changes: 5 additions & 3 deletions pandas/tests/io/formats/style/test_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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():
Expand Down
19 changes: 19 additions & 0 deletions pandas/tests/io/formats/style/test_style.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import contextlib
import copy
import re
import subprocess
import sys
from textwrap import dedent

import numpy as np
Expand Down Expand Up @@ -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",
[
Expand Down