diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 75cdcc1..42fd25d 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,9 +2,12 @@ v1.2.2 ====== Added ----- +* You can now save and load presets for the formatoptions of a project which + applies the formatoptions that you stored in a file to a specific plot method, + see `#24 `__ * the ``rcParams`` do now have a ``catch`` method that allows a temporary change of formatoptions. - + Usage:: rcParams['some_key'] = 0 @@ -21,11 +24,11 @@ Added `check_data` method of the various plotmethods now also accept a `decoder` parameter, see `#22 `__ * ``psyplot.data.open_dataset`` now decodes grid_mappings attributes, -see `#17 `__ + see `#17 `__ * psyplot projects now support the with syntax, e.g. something like:: - with psy.plot.mapplot('file.nc') as sp: - sp.export('output.png') + with psy.plot.mapplot('file.nc') as sp: + sp.export('output.png') sp will be closed automatically (see `#18 `__) * the update to variables with other dimensions works now as well diff --git a/docs/conf.py b/docs/conf.py index d1ee210..c7ad9b4 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -234,14 +234,14 @@ # Example configuration for intersphinx: refer to the Python standard library. intersphinx_mapping = { - 'pandas': ('http://pandas.pydata.org/pandas-docs/stable/', None), - 'numpy': ('https://docs.scipy.org/doc/numpy/', None), + 'pandas': ('https://pandas.pydata.org/pandas-docs/stable/', None), + 'numpy': ('https://numpy.org/doc/stable/', None), 'matplotlib': ('https://matplotlib.org/', None), 'seaborn': ('http://seaborn.pydata.org/', None), - 'sphinx': ('http://www.sphinx-doc.org/en/stable/', None), + 'sphinx': ('https://www.sphinx-doc.org/en/master/', None), 'xarray': ('http://xarray.pydata.org/en/stable/', None), 'cartopy': ('https://scitools.org.uk/cartopy/docs/latest/', None), - 'mpl_toolkits': ('http://matplotlib.org/basemap/', None), + 'mpl_toolkits': ('https://matplotlib.org/basemap/', None), 'sphinx_nbexamples': ('https://sphinx-nbexamples.readthedocs.io/en/latest/', None), 'psy_maps': ( @@ -254,7 +254,7 @@ 'https://psyplot.readthedocs.io/projects/psyplot-gui/en/latest/', None), } if six.PY3: - intersphinx_mapping['python'] = ('https://docs.python.org/3.6/', None) + intersphinx_mapping['python'] = ('https://docs.python.org/3.8/', None) else: intersphinx_mapping['python'] = ('https://docs.python.org/2.7/', None) diff --git a/docs/getting_started.rst b/docs/getting_started.rst index d0c107c..99647af 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -656,6 +656,54 @@ To restore your project, simply use the :ref:`own-scripts`) +.. _presets: + +Using presets +------------- +You can save and load presets to reuse the formatoption settings. For instance, +let's say temperature should always use a ``'Reds'`` cmap, the colorbar label +should show the long name and the title should be ``'time'``. This is of course +possible via + +.. ipython:: + + @savefig docs_presets_1.png width=4in + In [34]: sp = psy.plot.mapplot( + ....: 'demo.nc', name='t2m', cmap="Reds", clabel="%(long_name)s", + ....: title='%(time)s') + +But instead of writing this all the time, you can also save it as a preset + +.. ipython:: + + In [35]: sp.save_preset("t2m-preset") + + @suppress + In [35]: psy.close('all') + +and reload this preset either via the `preset` keyword + +.. ipython:: + + @savefig docs_presets_2.png width=4in + In [36]: sp = psy.plot.mapplot('demo.nc', name='t2m', preset='t2m-preset') + +or the :meth:`~psyplot.project.Project.load_preset` method + +.. ipython:: + + In [37]: sp.load_preset('t2m-preset') + +You can list the available presets from the command line + +.. ipython:: + + In [38]: !psyplot --list-presets + + @suppress + In [37]: !rm {sp._resolve_preset_path('t2m-preset')} + ....: psy.close('all') + .. _own-scripts: Adding your own script: The :attr:`~psyplot.plotter.Plotter.post` formatoption diff --git a/psyplot/__main__.py b/psyplot/__main__.py index fdb2f57..dc2beca 100644 --- a/psyplot/__main__.py +++ b/psyplot/__main__.py @@ -1,9 +1,11 @@ # -*- coding: utf-8 -*- import os +import os.path as osp import sys import argparse import pickle import six +import glob from itertools import chain from collections import defaultdict import yaml @@ -53,7 +55,7 @@ def make_plot(fnames=[], name=[], dims=None, plot_method=None, tight=False, rc_file=None, encoding=None, enable_post=False, seaborn_style=None, output_project=None, concat_dim=get_default_value(xr.open_mfdataset, 'concat_dim'), - chname={}): + chname={}, preset=None): """ Eventually start the QApplication or only make a plot @@ -107,6 +109,11 @@ def make_plot(fnames=[], name=[], dims=None, plot_method=None, chname: dict A mapping from variable names in the project to variable names in the datasets that should be used instead + preset: str + The filename or identifier of a preset. If the given `preset` is + the path to an existing yaml file, it will be loaded. Otherwise we + look up the `preset` in the psyplot configuration directory (see + :func:`~psyplot.config.rcsetup.get_configdir`). """ if project is not None and (name != [] or dims is not None): warn('The `name` and `dims` parameter are ignored if the `project`' @@ -141,6 +148,8 @@ def make_plot(fnames=[], name=[], dims=None, plot_method=None, project, alternative_paths=alternative_paths, engine=engine, encoding=encoding, enable_post=enable_post, chname=chname) + if preset: + p.load_preset(preset) if formatoptions is not None: p.update(fmt=formatoptions) p.export(output, tight=tight) @@ -150,7 +159,7 @@ def make_plot(fnames=[], name=[], dims=None, plot_method=None, raise ValueError("Unknown plot method %s!" % plot_method) kwargs = {'name': name} if name else {} p = pm( - fnames, dims=dims or {}, engine=engine, + fnames, dims=dims or {}, engine=engine, preset=preset, fmt=formatoptions or {}, mf_mode=True, concat_dim=concat_dim, **kwargs) p.export(output, tight=tight) @@ -237,6 +246,10 @@ def get_parser(create=True): action=ListDsNamesAction, if_existent=False, group=info_grp, help="""List the used dataset names in the given `project`.""") + parser.update_arg( + 'list_presets', short='lps', long='list-presets', + action=ListPresetsAction, if_existent=False, group=info_grp) + parser.setup_args(make_plot) output_grp = parser.add_argument_group( @@ -334,6 +347,28 @@ def __call__(self, parser, namespace, values, option_string=None): sys.exit(0) +class ListPresetsAction(argparse.Action): + + def __init__(self, option_strings, dest=argparse.SUPPRESS, nargs=None, + default=argparse.SUPPRESS, **kwargs): + if nargs is not None: + raise ValueError("nargs not allowed") + kwargs['help'] = ("Print available presets and exit") + if not _on_rtd: + kwargs['default'] = default + super().__init__(option_strings, nargs=0, dest=dest, **kwargs) + + def __call__(self, parser, namespace, values, option_string=None): + from psyplot.config.rcsetup import get_configdir + presets_dir = osp.join(get_configdir(), 'presets') + if not osp.exists(presets_dir): + sys.exit(0) + else: + presets = {osp.splitext(osp.basename(fname))[0]: fname + for fname in glob.glob(osp.join(presets_dir, '*.yml'))} + print('\n'.join(map(': '.join, presets.items()))) + sys.exit(0) + class ListPluginsAction(argparse.Action): def __init__(self, option_strings, dest=argparse.SUPPRESS, nargs=None, diff --git a/psyplot/plotter.py b/psyplot/plotter.py index 7d8587b..aee7645 100755 --- a/psyplot/plotter.py +++ b/psyplot/plotter.py @@ -1781,7 +1781,7 @@ def _get_formatoptions(cls, include_bases=True): Iterator over formatoptions This class method returns an iterator that contains all the - formatoptions descriptors that are in this class and that are defined + formatoption keys that are in this class and that are defined in the base classes Notes @@ -1790,7 +1790,7 @@ def _get_formatoptions(cls, include_bases=True): initialization, since all formatoptions are in the plotter itself. Just type:: - >>> list(plotter) + >>> list(plotter) to get the formatoptions. diff --git a/psyplot/project.py b/psyplot/project.py index 50cdfcf..2cf9820 100755 --- a/psyplot/project.py +++ b/psyplot/project.py @@ -8,6 +8,8 @@ Furthermore this module contains an easy pyplot-like API to the current subproject.""" import os +import os.path as osp +import yaml import sys import six from copy import deepcopy as _deepcopy @@ -27,6 +29,7 @@ import psyplot from psyplot import rcParams, get_versions import psyplot.utils as utils +from psyplot.config.rcsetup import get_configdir from psyplot.warning import warn, critical from psyplot.docstring import docstrings, dedent, safe_modulo import psyplot.data as psyd @@ -396,6 +399,162 @@ def __enter__(self): def __exit__(self, exc_type, exc_val, exc_tb): self.close(True, True, True) + @staticmethod + @docstrings.get_sectionsf('Project._load_preset', + sections=["Parameters", "Notes"]) + def _load_preset(preset: str): + """Load a preset from disk + + Parameters + ---------- + preset: str or dict + The filename or identifier of a preset. If the given `preset` is + the path to an existing yaml file, it will be loaded. Otherwise we + look up the `preset` in the psyplot configuration directory (see + :func:`~psyplot.config.rcsetup.get_configdir`). + If a dictionary is provided, we assume that this is the preset + + Returns + ------- + dict + The loaded preset + + Notes + ----- + An identifier is the filename without extension. If you want to list + the available presets, run ``psyplot -lp`` from the command-line""" + if isinstance(preset, dict): + config = preset + else: + path = Project._resolve_preset_path(preset) + with open(path) as f: + config = yaml.load(f, yaml.Loader) + return config + + @staticmethod + def _resolve_preset_path(preset, if_exists=True): + if osp.exists(preset): + return preset + else: + confdir = get_configdir() + presets_dir = osp.join(confdir, 'presets') + if osp.exists(osp.join(presets_dir, preset)): + return osp.join(presets_dir, preset) + elif osp.exists(osp.join(presets_dir, preset + '.yml')): + return osp.join(presets_dir, preset + '.yml') + else: + if if_exists: + raise ValueError( + f"Could not find a preset with name {preset}") + else: + if not preset.endswith('.yml'): + return osp.join(presets_dir, preset + '.yml') + return preset + + @docstrings.dedent + def load_preset(self, preset: str, **kwargs): + """Load a preset from disk and apply it to the open project. + + This method loads a preset and updates the corresponding plots + + Parameters + ---------- + %(Project._load_preset.parameters)s + ``**kwargs`` + Any other parameter that shall be passed to the + :meth:`~psyplot.data.ArrayList.update` method + + Notes + ----- + %(Project._load_preset.notes)s + """ + config = self._load_preset(preset) + plotmethods = self.plot._plot_methods + pm_config, defaults = utils.sort_kwargs(config, plotmethods) + with self.no_auto_update: + for pm in plotmethods: + method = getattr(self.plot, pm) + if method.is_imported: + sp = getattr(self, pm) + if sp: + valid = list(method.plotter_cls._get_formatoptions()) + fmts = {key: val for key, val in defaults.items() + if key in valid} + fmts.update(pm_config.get(pm, {})) + sp.update(fmt=fmts, **kwargs) + self.start_update() + + @staticmethod + def extract_fmts_from_preset(preset: str, plotmethod: str): + """Extract the formatoptions for a plotmethod from a given preset + + This method takes the preset and extracts the formatoptions valid for + the given plotmethod + + Parameters + ---------- + %(Project._load_preset.parameters)s + plotmethod: str + The plotmethod to use""" + preset = Project._load_preset(preset) + try: + plotmethod._method + except AttributeError: + method = getattr(plot, plotmethod) + else: + method = plotmethod + plotmethod = method._method + + plotmethods = plot._plot_methods + pm_config, defaults = utils.sort_kwargs(preset, plotmethods) + valid = list(method.plotter_cls._get_formatoptions()) + fmts = {key: val for key, val in defaults.items() + if key in valid} + fmts.update(pm_config.get(plotmethod, {})) + return fmts + + + def save_preset(self, fname=None, include_defaults=False, update=False): + """Save the formatoptions of this project as a preset + + This method takes the formatoptions in the plotters of this project and + saves it as a preset file""" + + def include(fmto, plotters): + key = fmto.key + for plotter in plotters: + if fmto.diff(plotter[key]): + return False + return True if include_defaults else fmto.changed + + if update: + with open(f) as f: + preset = yaml.load(f, yaml.Loader) + else: + preset = {} + plotters = self.plotters + + for fmto in self._fmtos: + if include(fmto, plotters): + preset[fmto.key] = fmto.value + + for pm in self.plot._plot_methods: + method = getattr(self.plot, pm) + if method.is_imported: + sp = getattr(self, pm) + plotters = sp.plotters + for fmto in sp._fmtos: + if fmto.key not in preset and include(fmto, plotters): + preset.setdefault(pm, {}) + preset[pm][fmto.key] = fmto.value + if fname is not None: + fname = self._resolve_preset_path(fname, False) + os.makedirs(osp.dirname(fname), exist_ok=True) + with open(fname, 'w') as f: + yaml.dump(preset, f) + else: + return preset + @_first_main def extend(self, *args, **kwargs): len0 = len(self) @@ -484,6 +643,7 @@ def close(self, figs=True, data=False, ds=False, remove_only=False): docstrings.delete_kwargs('ArrayList.from_dataset.other_parameters', kwargs='kwargs') docstrings.keep_params('xarray.open_mfdataset.parameters', 'concat_dim') + docstrings.keep_params('Project._load_preset.parameters', 'preset') @_only_main @docstrings.get_sectionsf('Project._add_data', @@ -598,6 +758,7 @@ def _add_data(self, plotter_cls, filename_or_obj, fmt={}, make_plot=True, else: axes = iter(ax) clear = clear or (isinstance(ax, tuple) and proj is not None) + for arr in sub_project: plotter_cls(arr, make_plot=(not bool(share) and make_plot), draw=False, ax=next(axes), clear=clear, @@ -1702,6 +1863,11 @@ def _logger(self): self._method) return logging.getLogger(name) + @property + def is_imported(self): + """True if the module for this plot method has been imported already""" + return self.module in sys.modules + @property def plotter_cls(self): """The plotter class""" @@ -1750,6 +1916,7 @@ def __call__(self, *args, **kwargs): Parameters ---------- %(ProjectPlotter._add_data.parameters.no_plotter_cls)s + %(Project._load_preset.parameters.preset)s Other Parameters ---------------- @@ -1760,6 +1927,20 @@ def __call__(self, *args, **kwargs): ------- %(ProjectPlotter._add_data.returns)s """ + preset = kwargs.pop('preset', None) + if preset: + preset = self._project_plotter.project._load_preset(preset) + if len(args) >= 2: + fmt = args[1] + else: + fmt = kwargs.setdefault('fmt', {}) + for key, val in preset.get(self._method, {}).items(): + fmt.setdefault(key, val) + valid = list(self.plotter_cls._get_formatoptions()) + for key, val in preset.items(): + if key in valid: + fmt.setdefault(key, val) + return self._project_plotter._add_data( self.plotter_cls, *args, **dict(chain( [('prefer_list', self._prefer_list), diff --git a/tests/test_project.py b/tests/test_project.py index 210cebb..ff92c3d 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -3,7 +3,9 @@ import os.path as osp import shutil import six +import pytest import unittest +import yaml from itertools import chain import _base_testing as bt import test_data as td @@ -13,6 +15,7 @@ import psyplot.plotter as psyp import psyplot.project as psy import matplotlib.pyplot as plt +from psyplot.config.rcsetup import get_configdir try: from cdo import Cdo @@ -25,6 +28,57 @@ remove_temp_files = True +@pytest.fixture +def project(): + try: + psy.register_plotter('test_plotter', import_plotter=True, + module='test_plotter', plotter_name='TestPlotter') + except ValueError: + pass + yield psy.Project() + for identifier in list(psy.registered_plotters): + psy.unregister_plotter(identifier) + +@pytest.mark.parametrize( + "preset,path", [("test", osp.join(get_configdir(), 'presets', 'test.yml')), + ("test.yml", osp.join(get_configdir(), 'presets', + 'test.yml')), + ("test.yml", "test.yml")]) +def test_load_preset(project, preset, path): + if osp.dirname(path): + os.makedirs(osp.dirname(path), exist_ok=True) + with open(path, 'w') as f: + yaml.dump({"fmt1": "test", "fmt2": "this should be ignored"}, f) + try: + sp = project.plot.test_plotter(xr.Dataset({"x": (('a'), [1])})) + sp.load_preset(preset) + plotter = sp.plotters[0] + assert plotter.fmt1.value == 'test' + finally: + os.remove(path) + + +def test_extract_preset(project): + preset = {"fmt1": "test1", "test_plotter": {"fmt2": "test2"}, + "not_existent": 1} + fmts = project.extract_fmts_from_preset(preset, "test_plotter") + assert fmts == {"fmt1": "test1", "fmt2": "test2"} + + +def test_save_preset(project): + sp = project.plot.test_plotter(xr.Dataset({"x": (('a'), [1])}), + name=['x', 'x']) + assert sp.save_preset() == {} + assert sp.save_preset(include_defaults=True)['fmt1'] == \ + sp.plotters[0]['fmt1'] + + sp[1].psy.update(fmt1='changed') + assert sp.save_preset() == {} + + sp[0].psy.update(fmt1='changed') + assert sp.save_preset() == {'fmt1': 'changed'} + + class TestProject(td.TestArrayList): """Testclass for the :class:`psyplot.project.Project` class"""