diff --git a/Makefile b/Makefile index baceefe6d49ff..9e69eb7922925 100644 --- a/Makefile +++ b/Makefile @@ -15,7 +15,7 @@ lint-diff: git diff upstream/master --name-only -- "*.py" | xargs flake8 black: - black . --exclude '(asv_bench/env|\.egg|\.git|\.hg|\.mypy_cache|\.nox|\.tox|\.venv|_build|buck-out|build|dist)' + black . --exclude '(asv_bench/env|\.egg|\.git|\.hg|\.mypy_cache|\.nox|\.tox|\.venv|_build|buck-out|build|dist|setup.py)' develop: build python setup.py develop diff --git a/ci/code_checks.sh b/ci/code_checks.sh index 96a8440d85694..06d45e38bfcdb 100755 --- a/ci/code_checks.sh +++ b/ci/code_checks.sh @@ -56,7 +56,7 @@ if [[ -z "$CHECK" || "$CHECK" == "lint" ]]; then black --version MSG='Checking black formatting' ; echo $MSG - black . --check --exclude '(asv_bench/env|\.egg|\.git|\.hg|\.mypy_cache|\.nox|\.tox|\.venv|_build|buck-out|build|dist)' + black . --check --exclude '(asv_bench/env|\.egg|\.git|\.hg|\.mypy_cache|\.nox|\.tox|\.venv|_build|buck-out|build|dist|setup.py)' RET=$(($RET + $?)) ; echo $MSG "DONE" # `setup.cfg` contains the list of error codes that are being ignored in flake8 diff --git a/doc/source/development/extending.rst b/doc/source/development/extending.rst index b492a4edd70a4..e341dcb8318bc 100644 --- a/doc/source/development/extending.rst +++ b/doc/source/development/extending.rst @@ -441,5 +441,22 @@ This would be more or less equivalent to: The backend module can then use other visualization tools (Bokeh, Altair,...) to generate the plots. +Libraries implementing the plotting backend should use `entry points `__ +to make their backend discoverable to pandas. The key is ``"pandas_plotting_backends"``. For example, pandas +registers the default "matplotlib" backend as follows. + +.. code-block:: python + + # in setup.py + setup( # noqa: F821 + ..., + entry_points={ + "pandas_plotting_backends": [ + "matplotlib = pandas:plotting._matplotlib", + ], + }, + ) + + More information on how to implement a third-party plotting backend can be found at https://github.com/pandas-dev/pandas/blob/master/pandas/plotting/__init__.py#L1. diff --git a/doc/source/whatsnew/v0.25.1.rst b/doc/source/whatsnew/v0.25.1.rst index 9007d1c06f197..2cadd921cc386 100644 --- a/doc/source/whatsnew/v0.25.1.rst +++ b/doc/source/whatsnew/v0.25.1.rst @@ -113,7 +113,7 @@ I/O Plotting ^^^^^^^^ -- +- Added a pandas_plotting_backends entrypoint group for registering plot backends. See :ref:`extending.plotting-backends` for more (:issue:`26747`). - - diff --git a/pandas/plotting/_core.py b/pandas/plotting/_core.py index 0610780edb28d..a3c1499845c2a 100644 --- a/pandas/plotting/_core.py +++ b/pandas/plotting/_core.py @@ -1533,6 +1533,53 @@ def hexbin(self, x, y, C=None, reduce_C_function=None, gridsize=None, **kwargs): return self(kind="hexbin", x=x, y=y, C=C, **kwargs) +_backends = {} + + +def _find_backend(backend: str): + """ + Find a pandas plotting backend> + + Parameters + ---------- + backend : str + The identifier for the backend. Either an entrypoint item registered + with pkg_resources, or a module name. + + Notes + ----- + Modifies _backends with imported backends as a side effect. + + Returns + ------- + types.ModuleType + The imported backend. + """ + import pkg_resources # Delay import for performance. + + for entry_point in pkg_resources.iter_entry_points("pandas_plotting_backends"): + if entry_point.name == "matplotlib": + # matplotlib is an optional dependency. When + # missing, this would raise. + continue + _backends[entry_point.name] = entry_point.load() + + try: + return _backends[backend] + except KeyError: + # Fall back to unregisted, module name approach. + try: + module = importlib.import_module(backend) + except ImportError: + # We re-raise later on. + pass + else: + _backends[backend] = module + return module + + raise ValueError("No backend {}".format(backend)) + + def _get_plot_backend(backend=None): """ Return the plotting backend to use (e.g. `pandas.plotting._matplotlib`). @@ -1546,7 +1593,18 @@ def _get_plot_backend(backend=None): The backend is imported lazily, as matplotlib is a soft dependency, and pandas can be used without it being installed. """ - backend_str = backend or pandas.get_option("plotting.backend") - if backend_str == "matplotlib": - backend_str = "pandas.plotting._matplotlib" - return importlib.import_module(backend_str) + backend = backend or pandas.get_option("plotting.backend") + + 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 + + _backends["matplotlib"] = module + + if backend in _backends: + return _backends[backend] + + module = _find_backend(backend) + _backends[backend] = module + return module diff --git a/pandas/tests/plotting/test_backend.py b/pandas/tests/plotting/test_backend.py index 51f2abb6cc2f4..e79e7b6239eb3 100644 --- a/pandas/tests/plotting/test_backend.py +++ b/pandas/tests/plotting/test_backend.py @@ -1,5 +1,11 @@ +import sys +import types + +import pkg_resources import pytest +import pandas.util._test_decorators as td + import pandas @@ -36,3 +42,44 @@ def test_backend_is_correct(monkeypatch): pandas.set_option("plotting.backend", "matplotlib") except ImportError: pass + + +@td.skip_if_no_mpl +def test_register_entrypoint(): + mod = types.ModuleType("my_backend") + mod.plot = lambda *args, **kwargs: 1 + + backends = pkg_resources.get_entry_map("pandas") + my_entrypoint = pkg_resources.EntryPoint( + "pandas_plotting_backend", + mod.__name__, + dist=pkg_resources.get_distribution("pandas"), + ) + backends["pandas_plotting_backends"]["my_backend"] = my_entrypoint + # TODO: the docs recommend importlib.util.module_from_spec. But this works for now. + sys.modules["my_backend"] = mod + + result = pandas.plotting._core._get_plot_backend("my_backend") + assert result is mod + + # TODO: https://github.com/pandas-dev/pandas/issues/27517 + # Remove the td.skip_if_no_mpl + with pandas.option_context("plotting.backend", "my_backend"): + result = pandas.plotting._core._get_plot_backend() + + assert result is mod + + +def test_register_import(): + mod = types.ModuleType("my_backend2") + mod.plot = lambda *args, **kwargs: 1 + sys.modules["my_backend2"] = mod + + result = pandas.plotting._core._get_plot_backend("my_backend2") + assert result is mod + + +@td.skip_if_mpl +def test_no_matplotlib_ok(): + with pytest.raises(ImportError): + pandas.plotting._core._get_plot_backend("matplotlib") diff --git a/setup.py b/setup.py index 53e12da53cdeb..d2c6b18b892cd 100755 --- a/setup.py +++ b/setup.py @@ -830,5 +830,10 @@ def srcpath(name=None, suffix=".pyx", subdir="src"): "hypothesis>=3.58", ] }, + entry_points={ + "pandas_plotting_backends": [ + "matplotlib = pandas:plotting._matplotlib", + ], + }, **setuptools_kwargs )