Repository: pandas-dev/pandas
Pull request: Validate plot backend when setting.
```python
In [1]: import pandas as pd

In [2]: import sys

In [3]: import types

In [4]: module = types.ModuleType("foo")

In [5]: sys.modules['foo'] = module

In [6]: pd.set_option('plotting.backend', 'foo')
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
<ipython-input-6-123b6513deb2> in <module>
----> 1 pd.set_option('plotting.backend', 'foo')

...

~/sandbox/pandas/pandas/plotting/_core.py in _find_backend(backend)
   1588         "top-level `.plot` method."
   1589     )
-> 1590     raise ValueError(msg.format(name=backend))
   1591
   1592

ValueError: Could not find plotting backend 'foo'. Ensure that you've installed the package providing the 'foo' entrypoint, or that the package has atop-level `.plot` method.

```

Closes https://github.com/pandas-dev/pandas/issues/28163

Problem statement:
Setting the plot backend should validate `.plot` immediately
Right now a `pd.set_option('display.backend', name)` will

1. look for an entrypoint
2. fall back to importing the module `name` and setting that to the backend

In the second case, the backend's implementation isn't validated until a plot is actually requested. We should validate that `name.plot` is a thing when the backend is set.

```python
In [16]: module = types.ModuleType("foo")

In [17]: sys.modules['foo'] = module

In [18]: pd.set_option('plotting.backend', 'foo')

In [19]: df = pd.DataFrame({"A": [1, 2]})

In [20]: df.plot()
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
<ipython-input-20-848b80e64df8> in <module>
----> 1 df.plot()

~/sandbox/pandas/pandas/plotting/_core.py in __call__(self, *args, **kwargs)
    794                     data.columns = label_name
    795
--> 796         return plot_backend.plot(data, kind=kind, **kwargs)
    797
    798     def line(self, x=None, y=None, **kwargs):

AttributeError: module 'foo' has no attribute 'plot'
```

Rather than raising an `AttributeError` on `df.plot`, we should raise a `ValueError` on `pd.set_option('plotting.backend', 'foo')`.

Hints from issue discussion:
cc @datapythonista 
Makes sense to me, but I think it's trickier than it looks. My first implementation had some validations when setting the backend, but iirc @jreback preference was to not import the module until a plot is called. I think because matplotlib needs to be loaded even if plot() is never used, unless some hacky things are done.

Commit message: cleanup

Patch to review:
diff --git a/doc/source/whatsnew/v1.0.0.rst b/doc/source/whatsnew/v1.0.0.rst
index 7a10447e3ad40..36c3eeaf422ef 100644
--- a/doc/source/whatsnew/v1.0.0.rst
+++ b/doc/source/whatsnew/v1.0.0.rst
@@ -171,6 +171,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..97c0be04f5697 100644
--- a/pandas/tests/plotting/test_backend.py
+++ b/pandas/tests/plotting/test_backend.py
@@ -9,43 +9,43 @@
 import pandas
 
 
-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")
+
+
+@pytest.fixture
+def dummy_backend(restore_backend):
+    backend = types.ModuleType("pandas_dummy_backend")
+    backend.plot = lambda *args, **kwargs: None
+    sys.modules["pandas_dummy_backend"] = backend
+
+    yield backend
+
+    del sys.modules["pandas_dummy_backend"]
 
 
 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(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 +74,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()
 
 


Code review comments:
diff_hunk: @@ -9,43 +9,43 @@
 import pandas
 
 
-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")
+
+
+@pytest.fixture
+def dummy_backend(restore_backend):
+    backend = types.ModuleType("pandas_dummy_backend")
+    backend.plot = lambda *args, **kwargs: None
+    sys.modules["pandas_dummy_backend"] = backend
path: pandas/tests/plotting/test_backend.py
text: @user1:
Instead of using a fixture I think you could just use:

```python
with monkeypatch.setitem(sys.modules, "pandas_dummy_backend", backend) as m:
    ...
```

Which I think would be easier to read and localizes logic