From bde518771dbdb53324fd2a29ba26b0adbbf26c3e Mon Sep 17 00:00:00 2001 From: Anderson Banihirwe Date: Tue, 17 Dec 2019 17:57:11 -0700 Subject: [PATCH 1/4] Add entrypoint for plotting backends --- setup.py | 1 + xarray/core/options.py | 7 +++ xarray/plot/facetgrid.py | 6 +-- xarray/plot/plot.py | 4 +- xarray/plot/utils.py | 95 ++++++++++++++++++++++++++++++++++++---- 5 files changed, 99 insertions(+), 14 deletions(-) diff --git a/setup.py b/setup.py index cba0c74aa3a..fa9d584c7f2 100755 --- a/setup.py +++ b/setup.py @@ -107,4 +107,5 @@ package_data={ "xarray": ["py.typed", "tests/data/*", "static/css/*", "static/html/*"] }, + entry_points={"xarray_plotting_backends": ["matplotlib = xarray.plot"]}, ) diff --git a/xarray/core/options.py b/xarray/core/options.py index 72f9ad8e1fa..3991ac09df2 100644 --- a/xarray/core/options.py +++ b/xarray/core/options.py @@ -9,6 +9,7 @@ CMAP_DIVERGENT = "cmap_divergent" KEEP_ATTRS = "keep_attrs" DISPLAY_STYLE = "display_style" +PLOTTING_BACKEND = "plotting_backend" OPTIONS = { @@ -21,6 +22,7 @@ CMAP_DIVERGENT: "RdBu_r", KEEP_ATTRS: "default", DISPLAY_STYLE: "text", + PLOTTING_BACKEND: "matplotlib", } _JOIN_OPTIONS = frozenset(["inner", "outer", "left", "right", "exact"]) @@ -39,6 +41,7 @@ def _positive_integer(value): WARN_FOR_UNCLOSED_FILES: lambda value: isinstance(value, bool), KEEP_ATTRS: lambda choice: choice in [True, False, "default"], DISPLAY_STYLE: _DISPLAY_OPTIONS.__contains__, + PLOTTING_BACKEND: lambda value: isinstance(value, str), } @@ -104,6 +107,10 @@ class set_options: Default: ``'default'``. - ``display_style``: display style to use in jupyter for xarray objects. Default: ``'text'``. Other options are ``'html'``. + - ``plotting_backend``: The name of plotting backend to use. Backends can be implemented + as third-party libraries implementing the xarray plotting API. They can use other + plotting libraries like Bokeh, Holoviews, Hvplot, Altair, etc. + Default: ``'matplotlib'`` You can use ``set_options`` either as a context manager: diff --git a/xarray/plot/facetgrid.py b/xarray/plot/facetgrid.py index 4f3268c1203..98b55d479b2 100644 --- a/xarray/plot/facetgrid.py +++ b/xarray/plot/facetgrid.py @@ -8,7 +8,7 @@ from .utils import ( _infer_xy_labels, _process_cmap_cbar_kwargs, - import_matplotlib_pyplot, + _get_plot_backend, label_from_attrs, ) @@ -113,7 +113,7 @@ def __init__( """ - plt = import_matplotlib_pyplot() + plt = _get_plot_backend() # Handle corner case of nonunique coordinates rep_col = col is not None and not data[col].to_index().is_unique @@ -572,7 +572,7 @@ def map(self, func, *args, **kwargs): self : FacetGrid object """ - plt = import_matplotlib_pyplot() + plt = _get_plot_backend() for ax, namedict in zip(self.axes.flat, self.name_dicts.flat): if namedict is not None: diff --git a/xarray/plot/plot.py b/xarray/plot/plot.py index d38c9765352..d4d15c063da 100644 --- a/xarray/plot/plot.py +++ b/xarray/plot/plot.py @@ -25,7 +25,7 @@ _update_axes, _valid_other_type, get_axis, - import_matplotlib_pyplot, + _get_plot_backend, label_from_attrs, ) @@ -651,7 +651,7 @@ def newplotfunc( allargs["plotfunc"] = globals()[plotfunc.__name__] return _easy_facetgrid(darray, kind="dataarray", **allargs) - plt = import_matplotlib_pyplot() + plt = _get_plot_backend() rgb = kwargs.pop("rgb", None) if rgb is not None and plotfunc.__name__ != "imshow": diff --git a/xarray/plot/utils.py b/xarray/plot/utils.py index 3b739197fea..3aebb2aa5d4 100644 --- a/xarray/plot/utils.py +++ b/xarray/plot/utils.py @@ -52,14 +52,6 @@ def register_pandas_datetime_converter_if_needed(): _registered = True -def import_matplotlib_pyplot(): - """Import pyplot as register appropriate converters.""" - register_pandas_datetime_converter_if_needed() - import matplotlib.pyplot as plt - - return plt - - def _determine_extend(calc_data, vmin, vmax): extend_min = calc_data.min() < vmin extend_max = calc_data.max() > vmax @@ -533,7 +525,7 @@ def _is_numeric(arr): def _add_colorbar(primitive, ax, cbar_ax, cbar_kwargs, cmap_params): - plt = import_matplotlib_pyplot() + plt = _get_plot_backend() cbar_kwargs.setdefault("extend", cmap_params["extend"]) if cbar_ax is None: cbar_kwargs.setdefault("ax", ax) @@ -742,3 +734,88 @@ def _process_cmap_cbar_kwargs( cmap_params = _determine_cmap_params(**cmap_kwargs) return cmap_params, cbar_kwargs + + +_backends = {} + + +def _find_backend(backend): + """ + Find an xarray 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. + import importlib + + for entry_point in pkg_resources.iter_entry_points("xarray_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: + 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): + """ + Return the plotting backend to use + """ + + backend = backend or OPTIONS["plotting_backend"] + + if backend == "matplotlib": + try: + register_pandas_datetime_converter_if_needed() + import matplotlib.pyplot as module + except ImportError: + raise ImportError( + "matplotlib is required for plotting when the " + 'default backend "matplotlib" is selected.' + ) from None + + _backends["matplotlib"] = module + + if backend in _backends: + return _backends[backend] + + module = _find_backend(backend) + _backends[backend] = module + return module From 58c8d75137cdd07472997ef1d947b42f096f52d3 Mon Sep 17 00:00:00 2001 From: Anderson Banihirwe Date: Thu, 19 Dec 2019 09:21:16 -0700 Subject: [PATCH 2/4] Add tests --- ci/requirements/py37.yml | 1 + xarray/plot/facetgrid.py | 6 ++++-- xarray/plot/plot.py | 4 +++- xarray/plot/utils.py | 3 +-- xarray/tests/__init__.py | 2 ++ xarray/tests/test_options.py | 12 ++++++++++++ xarray/tests/test_plot.py | 11 +++++++++++ 7 files changed, 34 insertions(+), 5 deletions(-) diff --git a/ci/requirements/py37.yml b/ci/requirements/py37.yml index 4a7aaf7d32b..336f6dc5e91 100644 --- a/ci/requirements/py37.yml +++ b/ci/requirements/py37.yml @@ -17,6 +17,7 @@ dependencies: - h5netcdf - h5py - hdf5 + - hvplot - hypothesis - iris - lxml # Optional dep of pydap diff --git a/xarray/plot/facetgrid.py b/xarray/plot/facetgrid.py index 98b55d479b2..a8eb6953c46 100644 --- a/xarray/plot/facetgrid.py +++ b/xarray/plot/facetgrid.py @@ -12,6 +12,8 @@ label_from_attrs, ) +from ..core.options import OPTIONS + # Overrides axes.labelsize, xtick.major.size, ytick.major.size # from mpl.rcParams _FONTSIZE = "small" @@ -113,7 +115,7 @@ def __init__( """ - plt = _get_plot_backend() + plt = _get_plot_backend(OPTIONS["plotting_backend"]) # Handle corner case of nonunique coordinates rep_col = col is not None and not data[col].to_index().is_unique @@ -572,7 +574,7 @@ def map(self, func, *args, **kwargs): self : FacetGrid object """ - plt = _get_plot_backend() + plt = _get_plot_backend(OPTIONS["plotting_backend"]) for ax, namedict in zip(self.axes.flat, self.name_dicts.flat): if namedict is not None: diff --git a/xarray/plot/plot.py b/xarray/plot/plot.py index d4d15c063da..4902558c8e6 100644 --- a/xarray/plot/plot.py +++ b/xarray/plot/plot.py @@ -29,6 +29,8 @@ label_from_attrs, ) +from ..core.options import OPTIONS + def _infer_line_data(darray, x, y, hue): error_msg = "must be either None or one of ({:s})".format( @@ -651,7 +653,7 @@ def newplotfunc( allargs["plotfunc"] = globals()[plotfunc.__name__] return _easy_facetgrid(darray, kind="dataarray", **allargs) - plt = _get_plot_backend() + plt = _get_plot_backend(OPTIONS["plotting_backend"]) rgb = kwargs.pop("rgb", None) if rgb is not None and plotfunc.__name__ != "imshow": diff --git a/xarray/plot/utils.py b/xarray/plot/utils.py index 3aebb2aa5d4..6e6fa7b8dde 100644 --- a/xarray/plot/utils.py +++ b/xarray/plot/utils.py @@ -525,13 +525,12 @@ def _is_numeric(arr): def _add_colorbar(primitive, ax, cbar_ax, cbar_kwargs, cmap_params): - plt = _get_plot_backend() + plt = _get_plot_backend(OPTIONS["plotting_backend"]) cbar_kwargs.setdefault("extend", cmap_params["extend"]) if cbar_ax is None: cbar_kwargs.setdefault("ax", ax) else: cbar_kwargs.setdefault("cax", cbar_ax) - cbar = plt.colorbar(primitive, **cbar_kwargs) return cbar diff --git a/xarray/tests/__init__.py b/xarray/tests/__init__.py index 6592360cdf2..ff376e818bc 100644 --- a/xarray/tests/__init__.py +++ b/xarray/tests/__init__.py @@ -85,6 +85,8 @@ def LooseVersion(vstring): has_seaborn = False requires_seaborn = pytest.mark.skipif(not has_seaborn, reason="requires seaborn") +has_hvplot, requires_hvplot = _importorskip("hvplot") + # change some global options for tests set_options(warn_for_unclosed_files=True) diff --git a/xarray/tests/test_options.py b/xarray/tests/test_options.py index f155acbf494..ac859617568 100644 --- a/xarray/tests/test_options.py +++ b/xarray/tests/test_options.py @@ -77,6 +77,18 @@ def test_display_style(): assert OPTIONS["display_style"] == original +def test_plotting_backend(): + original = "matplotlib" + assert OPTIONS["plotting_backend"] == original + with pytest.raises(ValueError): + xarray.set_options(plotting_backend=5) + + with xarray.set_options(plotting_backend="holoviews"): + assert OPTIONS["plotting_backend"] == "holoviews" + + assert OPTIONS["plotting_backend"] == original + + def create_test_dataset_attrs(seed=0): ds = create_test_data(seed) ds.attrs = {"attr1": 5, "attr2": "history", "attr3": {"nested": "more_info"}} diff --git a/xarray/tests/test_plot.py b/xarray/tests/test_plot.py index a5402d88f3e..00b3cb6a96f 100644 --- a/xarray/tests/test_plot.py +++ b/xarray/tests/test_plot.py @@ -27,6 +27,7 @@ requires_matplotlib, requires_nc_time_axis, requires_seaborn, + requires_hvplot, ) # import mpl and change the backend before other mpl imports @@ -2199,3 +2200,13 @@ def test_plot_transposes_properly(plotfunc): # pcolormesh returns 1D array but imshow returns a 2D array so it is necessary # to ravel() on the LHS assert np.all(hdl.get_array().ravel() == da.to_masked_array().ravel()) + + +@requires_matplotlib +@requires_hvplot +@pytest.mark.parametrize("plotting_backend", ["matplotlib", "hvplot.plotting"]) +def test_plotting_backend(plotting_backend): + air = xr.tutorial.open_dataset("air_temperature").load().air + + with xr.set_options(plotting_backend=plotting_backend): + air.isel(time=500).plot(add_colorbar=False) From 8ac1ce7aabcd1531bcab9bc67e0eaba1b443c41a Mon Sep 17 00:00:00 2001 From: Anderson Banihirwe Date: Thu, 30 Jan 2020 07:45:33 -0700 Subject: [PATCH 3/4] Add entry_points to setup.cfg --- setup.cfg | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index e336f46e68c..ccccfebc81b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -90,6 +90,10 @@ xarray = static/css/* static/html/* +[options.entry_points] +xarray_plotting_backends = + matplotlib = xarray.plot + [tool:pytest] python_files = test_*.py testpaths = xarray/tests properties @@ -197,4 +201,4 @@ ignore_errors = True test = pytest [pytest-watch] -nobeep = True \ No newline at end of file +nobeep = True From 2866d184c096a31f44ae30962486e4fed7fea219 Mon Sep 17 00:00:00 2001 From: Anderson Banihirwe Date: Thu, 30 Jan 2020 12:28:04 -0700 Subject: [PATCH 4/4] Update entry point --- setup.cfg | 2 +- xarray/core/options.py | 7 +++++++ xarray/plot/facetgrid.py | 8 +++----- xarray/plot/plot.py | 5 ++--- xarray/plot/utils.py | 13 ++++++++++--- 5 files changed, 23 insertions(+), 12 deletions(-) diff --git a/setup.cfg b/setup.cfg index ccccfebc81b..5fb749259c9 100644 --- a/setup.cfg +++ b/setup.cfg @@ -92,7 +92,7 @@ xarray = [options.entry_points] xarray_plotting_backends = - matplotlib = xarray.plot + matplotlib = xarray:plot [tool:pytest] python_files = test_*.py diff --git a/xarray/core/options.py b/xarray/core/options.py index 3991ac09df2..81f0bf6f513 100644 --- a/xarray/core/options.py +++ b/xarray/core/options.py @@ -59,9 +59,16 @@ def _warn_on_setting_enable_cftimeindex(enable_cftimeindex): ) +def _set_plotting_backend(backend): + from ..plot.utils import _get_plot_backend + + return _get_plot_backend(backend) + + _SETTERS = { FILE_CACHE_MAXSIZE: _set_file_cache_maxsize, ENABLE_CFTIMEINDEX: _warn_on_setting_enable_cftimeindex, + PLOTTING_BACKEND: _set_plotting_backend, } diff --git a/xarray/plot/facetgrid.py b/xarray/plot/facetgrid.py index a8eb6953c46..4f3268c1203 100644 --- a/xarray/plot/facetgrid.py +++ b/xarray/plot/facetgrid.py @@ -8,12 +8,10 @@ from .utils import ( _infer_xy_labels, _process_cmap_cbar_kwargs, - _get_plot_backend, + import_matplotlib_pyplot, label_from_attrs, ) -from ..core.options import OPTIONS - # Overrides axes.labelsize, xtick.major.size, ytick.major.size # from mpl.rcParams _FONTSIZE = "small" @@ -115,7 +113,7 @@ def __init__( """ - plt = _get_plot_backend(OPTIONS["plotting_backend"]) + plt = import_matplotlib_pyplot() # Handle corner case of nonunique coordinates rep_col = col is not None and not data[col].to_index().is_unique @@ -574,7 +572,7 @@ def map(self, func, *args, **kwargs): self : FacetGrid object """ - plt = _get_plot_backend(OPTIONS["plotting_backend"]) + plt = import_matplotlib_pyplot() for ax, namedict in zip(self.axes.flat, self.name_dicts.flat): if namedict is not None: diff --git a/xarray/plot/plot.py b/xarray/plot/plot.py index c9fd6cd2860..98131887e28 100644 --- a/xarray/plot/plot.py +++ b/xarray/plot/plot.py @@ -11,12 +11,10 @@ import numpy as np import pandas as pd -from ..core.options import OPTIONS from .facetgrid import _easy_facetgrid from .utils import ( _add_colorbar, _ensure_plottable, - _get_plot_backend, _infer_interval_breaks, _infer_xy_labels, _process_cmap_cbar_kwargs, @@ -25,6 +23,7 @@ _resolve_intervals_2dplot, _update_axes, get_axis, + import_matplotlib_pyplot, label_from_attrs, ) @@ -633,7 +632,7 @@ def newplotfunc( allargs["plotfunc"] = globals()[plotfunc.__name__] return _easy_facetgrid(darray, kind="dataarray", **allargs) - plt = _get_plot_backend(OPTIONS["plotting_backend"]) + plt = import_matplotlib_pyplot() rgb = kwargs.pop("rgb", None) if rgb is not None and plotfunc.__name__ != "imshow": diff --git a/xarray/plot/utils.py b/xarray/plot/utils.py index fee65cfbef4..10c4e050d06 100644 --- a/xarray/plot/utils.py +++ b/xarray/plot/utils.py @@ -52,6 +52,14 @@ def register_pandas_datetime_converter_if_needed(): _registered = True +def import_matplotlib_pyplot(): + """Import pyplot as register appropriate converters.""" + register_pandas_datetime_converter_if_needed() + import matplotlib.pyplot as plt + + return plt + + def _determine_extend(calc_data, vmin, vmax): extend_min = calc_data.min() < vmin extend_max = calc_data.max() > vmax @@ -561,7 +569,7 @@ def _is_numeric(arr): def _add_colorbar(primitive, ax, cbar_ax, cbar_kwargs, cmap_params): - plt = _get_plot_backend(OPTIONS["plotting_backend"]) + plt = import_matplotlib_pyplot() cbar_kwargs.setdefault("extend", cmap_params["extend"]) if cbar_ax is None: cbar_kwargs.setdefault("ax", ax) @@ -832,8 +840,7 @@ def _get_plot_backend(backend=None): if backend == "matplotlib": try: - register_pandas_datetime_converter_if_needed() - import matplotlib.pyplot as module + import xarray.plot as module except ImportError: raise ImportError( "matplotlib is required for plotting when the "