From 853bd6649fd4080ff900eb071f52bfa3706b5b2a Mon Sep 17 00:00:00 2001 From: Tom Augspurger Date: Fri, 19 Jul 2019 13:43:20 -0500 Subject: [PATCH 1/7] API: Add entrypoint for plotting Libraries, including pandas, register backends via entrypoints. xref #26747 --- doc/source/development/extending.rst | 17 +++++++++++++++ doc/source/whatsnew/v0.25.1.rst | 2 +- pandas/plotting/_core.py | 29 ++++++++++++++++++++----- pandas/tests/plotting/test_backend.py | 31 +++++++++++++++++++++++++++ setup.py | 5 +++++ 5 files changed, 78 insertions(+), 6 deletions(-) diff --git a/doc/source/development/extending.rst b/doc/source/development/extending.rst index b492a4edd70a4..bc7cf72479f35 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( + ... + 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 6234bc0f7bd35..045bd8a701cea 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..9cc35d40563e4 100644 --- a/pandas/plotting/_core.py +++ b/pandas/plotting/_core.py @@ -1533,7 +1533,10 @@ 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) -def _get_plot_backend(backend=None): +_backends = {} + + +def _get_plot_backend(backend="matplotlib"): """ Return the plotting backend to use (e.g. `pandas.plotting._matplotlib`). @@ -1546,7 +1549,23 @@ 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) + import pkg_resources # Delay import for performance. + + if backend in _backends: + return _backends[backend] + + for entry_point in pkg_resources.iter_entry_points("pandas_plotting_backends"): + _backends[entry_point.name] = entry_point.load() + + try: + return _backends[backend] + except KeyError: + try: + module = importlib.import_module(backend) + except ImportError: + pass + else: + _backends[backend] = module + return module + + raise ValueError("No backend {}".format(backend)) diff --git a/pandas/tests/plotting/test_backend.py b/pandas/tests/plotting/test_backend.py index 51f2abb6cc2f4..4e850494c6840 100644 --- a/pandas/tests/plotting/test_backend.py +++ b/pandas/tests/plotting/test_backend.py @@ -1,3 +1,7 @@ +import sys +import types + +import pkg_resources import pytest import pandas @@ -36,3 +40,30 @@ def test_backend_is_correct(monkeypatch): pandas.set_option("plotting.backend", "matplotlib") except ImportError: pass + + +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 + + +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 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 ) From 3dc341a84f5cbe773a352977562337b6b306f599 Mon Sep 17 00:00:00 2001 From: Tom Augspurger Date: Sat, 20 Jul 2019 11:05:16 -0500 Subject: [PATCH 2/7] wip --- pandas/tests/plotting/test_backend.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pandas/tests/plotting/test_backend.py b/pandas/tests/plotting/test_backend.py index 4e850494c6840..a8e7348ea2c6e 100644 --- a/pandas/tests/plotting/test_backend.py +++ b/pandas/tests/plotting/test_backend.py @@ -4,6 +4,8 @@ import pkg_resources import pytest +import pandas.util._test_decorators as td + import pandas @@ -42,6 +44,7 @@ def test_backend_is_correct(monkeypatch): pass +@td.skip_if_no_mpl def test_register_entrypoint(): mod = types.ModuleType("my_backend") mod.plot = lambda *args, **kwargs: 1 @@ -60,6 +63,7 @@ def test_register_entrypoint(): assert result is mod +@td.skip_if_no_mpl def test_register_import(): mod = types.ModuleType("my_backend2") mod.plot = lambda *args, **kwargs: 1 @@ -67,3 +71,7 @@ def test_register_import(): result = pandas.plotting._core._get_plot_backend("my_backend2") assert result is mod + + +def test_no_matplotlib_ok(): + pass From d84b67218493ad96ed4f608ab7dcc87c2f6aeee8 Mon Sep 17 00:00:00 2001 From: Tom Augspurger Date: Sat, 20 Jul 2019 11:09:43 -0500 Subject: [PATCH 3/7] skip for no mpl --- pandas/plotting/_core.py | 6 ++++++ pandas/tests/plotting/test_backend.py | 10 +++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/pandas/plotting/_core.py b/pandas/plotting/_core.py index 9cc35d40563e4..84726aa08ec3c 100644 --- a/pandas/plotting/_core.py +++ b/pandas/plotting/_core.py @@ -1554,6 +1554,12 @@ def _get_plot_backend(backend="matplotlib"): if backend in _backends: return _backends[backend] + if backend == "matplotlib": + # Because matplotlib is an optional dependency and first party backend, + # we need to attempt an import here. Without this import, we get an + # AttributeError raised by pkg_resources. + import matplotlib # noqa + for entry_point in pkg_resources.iter_entry_points("pandas_plotting_backends"): _backends[entry_point.name] = entry_point.load() diff --git a/pandas/tests/plotting/test_backend.py b/pandas/tests/plotting/test_backend.py index a8e7348ea2c6e..7090c405f7971 100644 --- a/pandas/tests/plotting/test_backend.py +++ b/pandas/tests/plotting/test_backend.py @@ -74,4 +74,12 @@ def test_register_import(): def test_no_matplotlib_ok(): - pass + try: + import matplotlib # noqa + except ImportError: + pass + else: + raise pytest.skip("matplotlib installed.") + + with pytest.raises(ImportError): + pandas.plotting._core._get_plot_backend("matplotlib") From 60f2cce119801d48365b6893c827273b97d75f80 Mon Sep 17 00:00:00 2001 From: Tom Augspurger Date: Mon, 22 Jul 2019 08:09:44 -0500 Subject: [PATCH 4/7] fixups --- pandas/plotting/_core.py | 69 ++++++++++++++++++++------- pandas/tests/plotting/test_backend.py | 16 +++---- 2 files changed, 59 insertions(+), 26 deletions(-) diff --git a/pandas/plotting/_core.py b/pandas/plotting/_core.py index 84726aa08ec3c..8397942657627 100644 --- a/pandas/plotting/_core.py +++ b/pandas/plotting/_core.py @@ -1536,42 +1536,75 @@ def hexbin(self, x, y, C=None, reduce_C_function=None, gridsize=None, **kwargs): _backends = {} -def _get_plot_backend(backend="matplotlib"): +def _find_backend(backend: str): """ - Return the plotting backend to use (e.g. `pandas.plotting._matplotlib`). + Find a pandas plotting backend> - The plotting system of pandas has been using matplotlib, but the idea here - is that it can also work with other third-party backends. In the future, - this function will return the backend from a pandas option, and all the - rest of the code in this file will use the backend specified there for the - plotting. + Parameters + ---------- + backend : str + The identifier for the backend. Either an entrypoint item registered + with pkg_resources, or a module name. - The backend is imported lazily, as matplotlib is a soft dependency, and - pandas can be used without it being installed. + Notes + ----- + Modifies _backcends with imported backends as a side effect. + + Returns + ------- + backend : types.ModuleType + The imported backend. """ import pkg_resources # Delay import for performance. - if backend in _backends: - return _backends[backend] - - if backend == "matplotlib": - # Because matplotlib is an optional dependency and first party backend, - # we need to attempt an import here. Without this import, we get an - # AttributeError raised by pkg_resources. - import matplotlib # noqa - 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`). + + The plotting system of pandas has been using matplotlib, but the idea here + is that it can also work with other third-party backends. In the future, + this function will return the backend from a pandas option, and all the + rest of the code in this file will use the backend specified there for the + plotting. + + The backend is imported lazily, as matplotlib is a soft dependency, and + pandas can be used without it being installed. + """ + 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 7090c405f7971..e79e7b6239eb3 100644 --- a/pandas/tests/plotting/test_backend.py +++ b/pandas/tests/plotting/test_backend.py @@ -62,8 +62,14 @@ def test_register_entrypoint(): 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 + -@td.skip_if_no_mpl def test_register_import(): mod = types.ModuleType("my_backend2") mod.plot = lambda *args, **kwargs: 1 @@ -73,13 +79,7 @@ def test_register_import(): assert result is mod +@td.skip_if_mpl def test_no_matplotlib_ok(): - try: - import matplotlib # noqa - except ImportError: - pass - else: - raise pytest.skip("matplotlib installed.") - with pytest.raises(ImportError): pandas.plotting._core._get_plot_backend("matplotlib") From 466b96c1306b47db5e9f28d555436cdeab09cff3 Mon Sep 17 00:00:00 2001 From: Tom Augspurger Date: Mon, 22 Jul 2019 08:11:02 -0500 Subject: [PATCH 5/7] exclude setup.py --- Makefile | 2 +- ci/code_checks.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 From 9c5605446b32045f0bcc29d69784a8b634116509 Mon Sep 17 00:00:00 2001 From: Tom Augspurger Date: Mon, 22 Jul 2019 09:05:09 -0500 Subject: [PATCH 6/7] noqa --- doc/source/development/extending.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/source/development/extending.rst b/doc/source/development/extending.rst index bc7cf72479f35..e341dcb8318bc 100644 --- a/doc/source/development/extending.rst +++ b/doc/source/development/extending.rst @@ -448,8 +448,8 @@ registers the default "matplotlib" backend as follows. .. code-block:: python # in setup.py - setup( - ... + setup( # noqa: F821 + ..., entry_points={ "pandas_plotting_backends": [ "matplotlib = pandas:plotting._matplotlib", From 69c306948080ba851eb5e43bdd9b985e422e73af Mon Sep 17 00:00:00 2001 From: Tom Augspurger Date: Thu, 25 Jul 2019 09:45:07 -0500 Subject: [PATCH 7/7] docstring --- pandas/plotting/_core.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pandas/plotting/_core.py b/pandas/plotting/_core.py index 8397942657627..a3c1499845c2a 100644 --- a/pandas/plotting/_core.py +++ b/pandas/plotting/_core.py @@ -1548,11 +1548,11 @@ def _find_backend(backend: str): Notes ----- - Modifies _backcends with imported backends as a side effect. + Modifies _backends with imported backends as a side effect. Returns ------- - backend : types.ModuleType + types.ModuleType The imported backend. """ import pkg_resources # Delay import for performance.