diff --git a/doc/source/whatsnew/v1.0.0.rst b/doc/source/whatsnew/v1.0.0.rst index 3b6288146bdf2..6fe0b6a671222 100644 --- a/doc/source/whatsnew/v1.0.0.rst +++ b/doc/source/whatsnew/v1.0.0.rst @@ -172,6 +172,7 @@ Plotting - Bug in :meth:`DataFrame.plot` producing incorrect legend markers when plotting multiple series on the same axis (:issue:`18222`) - Bug in :meth:`DataFrame.plot` when ``kind='box'`` and data contains datetime or timedelta data. These types are now automatically dropped (:issue:`22799`) - Bug in :meth:`DataFrame.plot.line` and :meth:`DataFrame.plot.area` produce wrong xlim in x-axis (:issue:`27686`, :issue:`25160`, :issue:`24784`) +- :func:`set_option` now validates that the plot backend provided to ``'plotting.backend'`` implements the backend when the option is set, rather than when a plot is created (:issue:`28163`) Groupby/resample/rolling ^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/pandas/core/config_init.py b/pandas/core/config_init.py index 08dce6aca6e6d..dfc80140433f8 100644 --- a/pandas/core/config_init.py +++ b/pandas/core/config_init.py @@ -9,8 +9,6 @@ module is imported, register them here rather then in the module. """ -import importlib - import pandas._config.config as cf from pandas._config.config import ( is_bool, @@ -581,26 +579,12 @@ def use_inf_as_na_cb(key): def register_plotting_backend_cb(key): - backend_str = cf.get_option(key) - if backend_str == "matplotlib": - try: - import pandas.plotting._matplotlib # noqa - except ImportError: - raise ImportError( - "matplotlib is required for plotting when the " - 'default backend "matplotlib" is selected.' - ) - else: - return + if key == "matplotlib": + # We defer matplotlib validation, since it's the default + return + from pandas.plotting._core import _get_plot_backend - try: - importlib.import_module(backend_str) - except ImportError: - raise ValueError( - '"{}" does not seem to be an installed module. ' - "A pandas plotting backend must be a module that " - "can be imported".format(backend_str) - ) + _get_plot_backend(key) with cf.config_prefix("plotting"): @@ -608,8 +592,7 @@ def register_plotting_backend_cb(key): "backend", defval="matplotlib", doc=plotting_backend_doc, - validator=str, - cb=register_plotting_backend_cb, + validator=register_plotting_backend_cb, ) diff --git a/pandas/plotting/_core.py b/pandas/plotting/_core.py index 2e6a401b49efc..d3c9e8ccfa51c 100644 --- a/pandas/plotting/_core.py +++ b/pandas/plotting/_core.py @@ -1576,10 +1576,18 @@ def _find_backend(backend: str): # We re-raise later on. pass else: - _backends[backend] = module - return module - - raise ValueError("No backend {}".format(backend)) + if hasattr(module, "plot"): + # Validate that the interface is implemented when the option + # is set, rather than at plot time. + _backends[backend] = module + return module + + msg = ( + "Could not find plotting backend '{name}'. Ensure that you've installed the " + "package providing the '{name}' entrypoint, or that the package has a" + "top-level `.plot` method." + ) + raise ValueError(msg.format(name=backend)) def _get_plot_backend(backend=None): @@ -1600,7 +1608,13 @@ def _get_plot_backend(backend=None): if backend == "matplotlib": # Because matplotlib is an optional dependency and first-party backend, # we need to attempt an import here to raise an ImportError if needed. - import pandas.plotting._matplotlib as module + try: + import pandas.plotting._matplotlib as module + except ImportError: + raise ImportError( + "matplotlib is required for plotting when the " + 'default backend "matplotlib" is selected.' + ) from None _backends["matplotlib"] = module diff --git a/pandas/tests/plotting/test_backend.py b/pandas/tests/plotting/test_backend.py index d126407cfd823..6511d94aa4c09 100644 --- a/pandas/tests/plotting/test_backend.py +++ b/pandas/tests/plotting/test_backend.py @@ -8,44 +8,38 @@ import pandas +dummy_backend = types.ModuleType("pandas_dummy_backend") +dummy_backend.plot = lambda *args, **kwargs: None -def test_matplotlib_backend_error(): - msg = ( - "matplotlib is required for plotting when the default backend " - '"matplotlib" is selected.' - ) - try: - import matplotlib # noqa - except ImportError: - with pytest.raises(ImportError, match=msg): - pandas.set_option("plotting.backend", "matplotlib") + +@pytest.fixture +def restore_backend(): + """Restore the plotting backend to matplotlib""" + pandas.set_option("plotting.backend", "matplotlib") + yield + pandas.set_option("plotting.backend", "matplotlib") def test_backend_is_not_module(): - msg = ( - '"not_an_existing_module" does not seem to be an installed module. ' - "A pandas plotting backend must be a module that can be imported" - ) + msg = "Could not find plotting backend 'not_an_existing_module'." with pytest.raises(ValueError, match=msg): pandas.set_option("plotting.backend", "not_an_existing_module") + assert pandas.options.plotting.backend == "matplotlib" -def test_backend_is_correct(monkeypatch): - monkeypatch.setattr( - "pandas.core.config_init.importlib.import_module", lambda name: None - ) - pandas.set_option("plotting.backend", "correct_backend") - assert pandas.get_option("plotting.backend") == "correct_backend" - # Restore backend for other tests (matplotlib can be not installed) - try: - pandas.set_option("plotting.backend", "matplotlib") - except ImportError: - pass +def test_backend_is_correct(monkeypatch, restore_backend): + monkeypatch.setitem(sys.modules, "pandas_dummy_backend", dummy_backend) + + pandas.set_option("plotting.backend", "pandas_dummy_backend") + assert pandas.get_option("plotting.backend") == "pandas_dummy_backend" + assert ( + pandas.plotting._core._get_plot_backend("pandas_dummy_backend") is dummy_backend + ) @td.skip_if_no_mpl -def test_register_entrypoint(): +def test_register_entrypoint(restore_backend): dist = pkg_resources.get_distribution("pandas") if dist.module_path not in pandas.__file__: @@ -74,13 +68,18 @@ def test_register_entrypoint(): assert result is mod -def test_register_import(): - mod = types.ModuleType("my_backend2") - mod.plot = lambda *args, **kwargs: 1 - sys.modules["my_backend2"] = mod +def test_setting_backend_without_plot_raises(): + # GH-28163 + module = types.ModuleType("pandas_plot_backend") + sys.modules["pandas_plot_backend"] = module - result = pandas.plotting._core._get_plot_backend("my_backend2") - assert result is mod + assert pandas.options.plotting.backend == "matplotlib" + with pytest.raises( + ValueError, match="Could not find plotting backend 'pandas_plot_backend'." + ): + pandas.set_option("plotting.backend", "pandas_plot_backend") + + assert pandas.options.plotting.backend == "matplotlib" @td.skip_if_mpl diff --git a/pandas/tests/plotting/test_misc.py b/pandas/tests/plotting/test_misc.py index 6cb6f818d40fd..940cfef4058e0 100644 --- a/pandas/tests/plotting/test_misc.py +++ b/pandas/tests/plotting/test_misc.py @@ -21,7 +21,7 @@ def test_import_error_message(): # GH-19810 df = DataFrame({"A": [1, 2]}) - with pytest.raises(ImportError, match="No module named 'matplotlib'"): + with pytest.raises(ImportError, match="matplotlib is required for plotting"): df.plot()