From 4c9fbb75519a337cf88c651e98f931e1e805b931 Mon Sep 17 00:00:00 2001 From: Luke Davis Date: Mon, 27 Feb 2023 22:01:46 -0700 Subject: [PATCH 1/2] Specify matplotlib version requirements --- requirements.txt | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 0dea9eb2c..6971bf539 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ # Just matplotlib :) -matplotlib +matplotlib>=3.3.0,<3.5.0 diff --git a/setup.cfg b/setup.cfg index d55350b23..ebddc697b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -26,6 +26,6 @@ project_urls = [options] packages = proplot setup_requires = setuptools>=44; toml; setuptools_scm>=3.4.3 -install_requires = matplotlib +install_requires = matplotlib>=3.3.0,<3.5.0 include_package_data = True python_requires = >=3.6.0 From 2f41463c3e09a59520e242e70cb3782f7f30e0c5 Mon Sep 17 00:00:00 2001 From: Luke Davis Date: Mon, 27 Feb 2023 23:12:48 -0700 Subject: [PATCH 2/2] Update outdated numpy type references --- proplot/axes/plot.py | 2 +- proplot/colors.py | 4 +- proplot/figure.py | 2 +- proplot/internals/process.py | 20 +- proplot/internals/properties.py | 250 ++++++++++++++++++ proplot/internals/settings.py | 348 +++++++++++++++++++++++++ proplot/internals/styles.py | 150 +++++++++++ proplot/internals/validate.py | 447 ++++++++++++++++++++++++++++++++ 8 files changed, 1209 insertions(+), 14 deletions(-) create mode 100644 proplot/internals/properties.py create mode 100644 proplot/internals/settings.py create mode 100644 proplot/internals/styles.py create mode 100644 proplot/internals/validate.py diff --git a/proplot/axes/plot.py b/proplot/axes/plot.py index bb917c9ed..8d3e4270a 100644 --- a/proplot/axes/plot.py +++ b/proplot/axes/plot.py @@ -3169,7 +3169,7 @@ def _parse_markersize( else: s = s.copy() s.flat[:] = utils.units(s.flat, 'pt') - s = s.astype(np.float) ** 2 + s = s.astype(float) ** 2 if absolute_size is None: if _default_absolute(): absolute_size = True diff --git a/proplot/colors.py b/proplot/colors.py index dc6a288b7..04637a6ff 100644 --- a/proplot/colors.py +++ b/proplot/colors.py @@ -1235,8 +1235,8 @@ def save(self, path=None, alpha=True): -------- DiscreteColormap.save """ - # NOTE We sanitize segmentdata before saving to json. Convert np.float to - # builtin float, np.array to list of lists, and callable to list of lists. + # NOTE: We sanitize segmentdata before saving to json. Convert numpy float + # to builtin float, np.array to list of lists, and callable to list of lists. # We tried encoding func.__code__ with base64 and marshal instead, but when # cmap.append() embeds functions as keyword arguments, this seems to make it # *impossible* to load back up the function with FunctionType (error message: diff --git a/proplot/figure.py b/proplot/figure.py index 2e1e083f9..5a58340e7 100644 --- a/proplot/figure.py +++ b/proplot/figure.py @@ -1321,7 +1321,7 @@ def _axes_dict(naxs, input, kw=False, default=None): array = array.reshape((nrows, ncols), order=order) array = np.atleast_1d(array) array[array == None] = 0 # None or 0 both valid placeholders # noqa: E711 - array = array.astype(np.int) + array = array.astype(int) if array.ndim == 1: # interpret as single row or column array = array[None, :] if order == 'C' else array[:, None] elif array.ndim != 2: diff --git a/proplot/internals/process.py b/proplot/internals/process.py index a9e37ade3..b31745028 100644 --- a/proplot/internals/process.py +++ b/proplot/internals/process.py @@ -70,7 +70,7 @@ def _is_numeric(data): array = _to_numpy_array(data) return len(data) and ( np.issubdtype(array.dtype, np.number) - or np.issubdtype(array.dtype, np.object) + or np.issubdtype(array.dtype, object) and all(isinstance(_, np.number) for _ in array.flat) ) @@ -81,9 +81,9 @@ def _is_categorical(data): """ array = _to_numpy_array(data) return len(data) and ( - np.issubdtype(array.dtype, np.str) - or np.issubdtype(array.dtype, np.object) - and any(isinstance(_, np.str) for _ in array.flat) + np.issubdtype(array.dtype, str) + or np.issubdtype(array.dtype, object) + and any(isinstance(_, str) for _ in array.flat) ) @@ -141,8 +141,8 @@ def _to_masked_array(data, *, copy=False): if ndarray is not Quantity and isinstance(data, Quantity): data, units = data.magnitude, data.units data = ma.masked_invalid(data, copy=copy) - if np.issubdtype(data.dtype, np.integer): - data = data.astype(np.float) + if np.issubdtype(data.dtype, int): + data = data.astype(float) if np.issubdtype(data.dtype, np.number): data.fill_value *= np.nan # default float fill_value is 1e+20 or 1e+20 + 0j else: @@ -492,16 +492,16 @@ def _safe_range(data, lo=0, hi=100): min_ = max_ = None if data.size: min_ = np.min(data) if lo <= 0 else np.percentile(data, lo) - if np.issubdtype(min_.dtype, np.integer): - min_ = np.float(min_) + if np.issubdtype(min_.dtype, int): + min_ = float(min_) if not np.isfinite(min_): min_ = None elif units is not None: min_ *= units if data.size: max_ = np.max(data) if hi >= 100 else np.percentile(data, hi) - if np.issubdtype(max_.dtype, np.integer): - max_ = np.float(max_) + if np.issubdtype(max_.dtype, int): + max_ = float(max_) if not np.isfinite(max_): max_ = None elif units is not None: diff --git a/proplot/internals/properties.py b/proplot/internals/properties.py new file mode 100644 index 000000000..3f9f4ee42 --- /dev/null +++ b/proplot/internals/properties.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python3 +""" +Utilities for artist properties. +""" +import matplotlib.colors as mcolors +import numpy as np + +from . import _not_none, docstring, warnings + +# Artist property aliases. Use this rather than normalize_kwargs and _alias_maps. +# NOTE: We add the aliases 'edgewidth' and 'fillcolor' for patch edges and faces +# NOTE: Alias cannot appear as key or else _translate_kwargs will overwrite with None! +_alias_maps = { + 'rgba': { + 'red': ('r',), + 'green': ('g',), + 'blue': ('b',), + 'alpha': ('a',), + }, + 'hsla': { + 'hue': ('h',), + 'saturation': ('s', 'c', 'chroma'), + 'luminance': ('l',), + 'alpha': ('a',), + }, + 'patch': { + 'alpha': ( # secretly applies to both face and edge consistent with matplotlib + 'a', 'alphas', 'ea', 'edgealpha', 'edgealphas', + 'fa', 'facealpha', 'facealphas', 'fillalpha', 'fillalphas' + ), + 'color': ('c', 'colors'), + 'edgecolor': ('ec', 'edgecolors'), + 'facecolor': ('fc', 'facecolors', 'fillcolor', 'fillcolors'), + 'hatch': ('h', 'hatches', 'hatching'), + 'linestyle': ('ls', 'linestyles'), + 'linewidth': ('lw', 'linewidths', 'ew', 'edgewidth', 'edgewidths'), + 'zorder': ('z', 'zorders'), + }, + 'line': { # copied from lines.py but expanded to include plurals + 'alpha': ('a', 'alphas'), + 'color': ('c', 'colors'), + 'dashes': ('d', 'dash'), + 'drawstyle': ('ds', 'drawstyles'), + 'fillstyle': ('fs', 'fillstyles', 'mfs', 'markerfillstyle', 'markerfillstyles'), + 'linestyle': ('ls', 'linestyles'), + 'linewidth': ('lw', 'linewidths'), + 'marker': ('m', 'markers'), + 'markersize': ('s', 'ms', 'markersizes'), # WARNING: no 'sizes' here for barb + 'markeredgewidth': ('ew', 'edgewidth', 'edgewidths', 'mew', 'markeredgewidths'), + 'markeredgecolor': ('ec', 'edgecolor', 'edgecolors', 'mec', 'markeredgecolors'), + 'markerfacecolor': ( + 'fc', 'facecolor', 'facecolors', 'fillcolor', 'fillcolors', + 'mc', 'markercolor', 'markercolors', 'mfc', 'markerfacecolors' + ), + 'zorder': ('z', 'zorders'), + }, + 'collection': { # NOTE: collections ignore facecolor and need singular 'alpha' + 'alpha': ('a', 'alphas'), + 'colors': ('c', 'color'), + 'edgecolors': ('ec', 'edgecolor', 'mec', 'markeredgecolor', 'markeredgecolors'), + 'facecolors': ( + 'fc', 'facecolor', 'fillcolor', 'fillcolors', + 'mc', 'markercolor', 'markercolors', 'mfc', 'markerfacecolor', 'markerfacecolors' # noqa: E501 + ), + 'linestyles': ('ls', 'linestyle'), + 'linewidths': ('lw', 'linewidth', 'ew', 'edgewidth', 'edgewidths', 'mew', 'markeredgewidth', 'markeredgewidths'), # noqa: E501 + 'marker': ('m', 'markers'), + 'sizes': ('s', 'size', 'ms', 'markersize', 'markersizes'), + 'zorder': ('z', 'zorders'), + }, + 'text': { + 'color': ('c', 'fontcolor'), # NOTE: see text.py source code + 'fontfamily': ('family', 'name', 'fontname'), + 'fontsize': ('size',), + 'fontstretch': ('stretch',), + 'fontstyle': ('style',), + 'fontvariant': ('variant',), + 'fontweight': ('weight',), + 'fontproperties': ('fp', 'font', 'font_properties'), + 'zorder': ('z', 'zorders'), + }, +} + + +# Unit docstrings +# NOTE: Try to fit this into a single line. Cannot break up with newline as that will +# mess up docstring indentation since this is placed in indented param lines. +_units_docstring = 'If float, units are {units}. If string, interpreted by `~proplot.utils.units`.' # noqa: E501 +docstring._snippet_manager['units.pt'] = _units_docstring.format(units='points') +docstring._snippet_manager['units.in'] = _units_docstring.format(units='inches') +docstring._snippet_manager['units.em'] = _units_docstring.format(units='em-widths') + + +# Artist property docstrings +# NOTE: These are needed in a few different places +_line_docstring = """ +lw, linewidth, linewidths : unit-spec, default: :rc:`lines.linewidth` + The width of the line(s). + %(units.pt)s +ls, linestyle, linestyles : str, default: :rc:`lines.linestyle` + The style of the line(s). +c, color, colors : color-spec, optional + The color of the line(s). The property `cycle` is used by default. +a, alpha, alphas : float, optional + The opacity of the line(s). Inferred from `color` by default. +""" +_patch_docstring = """ +lw, linewidth, linewidths : unit-spec, default: :rc:`patch.linewidth` + The edge width of the patch(es). + %(units.pt)s +ls, linestyle, linestyles : str, default: '-' + The edge style of the patch(es). +ec, edgecolor, edgecolors : color-spec, default: '{edgecolor}' + The edge color of the patch(es). +fc, facecolor, facecolors, fillcolor, fillcolors : color-spec, optional + The face color of the patch(es). The property `cycle` is used by default. +a, alpha, alphas : float, optional + The opacity of the patch(es). Inferred from `facecolor` and `edgecolor` by default. +""" +_pcolor_collection_docstring = """ +lw, linewidth, linewidths : unit-spec, default: 0.3 + The width of lines between grid boxes. + %(units.pt)s +ls, linestyle, linestyles : str, default: '-' + The style of lines between grid boxes. +ec, edgecolor, edgecolors : color-spec, default: 'k' + The color of lines between grid boxes. +a, alpha, alphas : float, optional + The opacity of the grid boxes. Inferred from `cmap` by default. +""" +_contour_collection_docstring = """ +lw, linewidth, linewidths : unit-spec, default: 0.3 or :rc:`lines.linewidth` + The width of the line contours. Default is ``0.3`` when adding to filled contours + or :rc:`lines.linewidth` otherwise. %(units.pt)s +ls, linestyle, linestyles : str, default: '-' or :rc:`contour.negative_linestyle` + The style of the line contours. Default is ``'-'`` for positive contours and + :rcraw:`contour.negative_linestyle` for negative contours. +ec, edgecolor, edgecolors : color-spec, default: 'k' or inferred + The color of the line contours. Default is ``'k'`` when adding to filled contours + or inferred from `color` or `cmap` otherwise. +a, alpha, alpha : float, optional + The opacity of the contours. Inferred from `edgecolor` by default. +""" +_text_docstring = """ +name, fontname, family, fontfamily : str, optional + The font typeface name (e.g., ``'Fira Math'``) or font family name (e.g., + ``'serif'``). Matplotlib falls back to the system default if not found. +size, fontsize : unit-spec or str, optional + The font size. %(units.pt)s + This can also be a string indicating some scaling relative to + :rcraw:`font.size`. The sizes and scalings are shown below. The + scalings ``'med'``, ``'med-small'``, and ``'med-large'`` are + added by proplot while the rest are native matplotlib sizes. + + .. _font_table: + + ========================== ===== + Size Scale + ========================== ===== + ``'xx-small'`` 0.579 + ``'x-small'`` 0.694 + ``'small'``, ``'smaller'`` 0.833 + ``'med-small'`` 0.9 + ``'med'``, ``'medium'`` 1.0 + ``'med-large'`` 1.1 + ``'large'``, ``'larger'`` 1.2 + ``'x-large'`` 1.440 + ``'xx-large'`` 1.728 + ``'larger'`` 1.2 + ========================== ===== + +""" +docstring._snippet_manager['artist.line'] = _line_docstring +docstring._snippet_manager['artist.text'] = _text_docstring +docstring._snippet_manager['artist.patch'] = _patch_docstring.format(edgecolor='none') +docstring._snippet_manager['artist.patch_black'] = _patch_docstring.format(edgecolor='black') # noqa: E501 +docstring._snippet_manager['artist.collection_pcolor'] = _pcolor_collection_docstring +docstring._snippet_manager['artist.collection_contour'] = _contour_collection_docstring + + +def _get_aliases(category, *keys): + """ + Get all available property aliases. + """ + aliases = [] + for key in keys: + aliases.append(key) + aliases.extend(_alias_maps[category][key]) + return tuple(aliases) + + +def _list_properties(count, **kwargs): + """ + Convert line or patch propeties to lists of values. + """ + for key, arg in kwargs.items(): + if arg is None: + pass + elif ( + isinstance(arg, str) + or not np.iterable(arg) + or 'color' in key and mcolors.is_color_like(arg) + ): + arg = [arg] * count + else: + arg = list(arg) + if len(arg) != count: + raise ValueError( + f'Length of {key!r} properties ({len(arg)}) does ' + f'not match the number of input arrays ({count}).' + ) + yield arg + + +def _pop_properties(input, *categories, prefix=None, ignore=None, skip=None, **kwargs): + """ + Pop the registered properties and return them in a new dictionary. + """ + output = {} + skip = skip or () + ignore = ignore or () + if isinstance(skip, str): # e.g. 'sizes' for barbs() input + skip = (skip,) + if isinstance(ignore, str): # e.g. 'marker' to ignore marker properties + ignore = (ignore,) + prefix = prefix or '' # e.g. 'box' for boxlw, boxlinewidth, etc. + for category in categories: + for key, aliases in _alias_maps[category].items(): + if isinstance(aliases, str): + aliases = (aliases,) + opts = { + prefix + alias: input.pop(prefix + alias, None) + for alias in (key, *aliases) + if alias not in skip + } + prop = _not_none(**opts) + if prop is None: + continue + if any(string in key for string in ignore): + warnings._warn_proplot(f'Ignoring property {key}={prop!r}.') + continue + if isinstance(prop, str): # ad-hoc unit conversion + if key in ('fontsize',): + from ..utils import _fontsize_to_pt + prop = _fontsize_to_pt(prop, **kwargs) + if key in ('linewidth', 'linewidths', 'markersize'): + from ..utils import units + prop = units(prop, 'pt', **kwargs) + output[key] = prop + return output diff --git a/proplot/internals/settings.py b/proplot/internals/settings.py new file mode 100644 index 000000000..0a519fdb0 --- /dev/null +++ b/proplot/internals/settings.py @@ -0,0 +1,348 @@ +#!/usr/bin/env python3 +""" +Utilities for global settings. +""" +from collections.abc import MutableMapping +from numbers import Integral, Real + +import numpy as np +from cycler import Cycler +from matplotlib import RcParams +from matplotlib import rcParams as _rc_matplotlib +from matplotlib import rcParamsDefault as _rc_matplotlib_default + +from . import ic # noqa: F401 +from . import _not_none, validate, warnings +from .defaults import ( # noqa: F401 + _rc_aliases, + _rc_children, + _rc_matplotlib_override, + _rc_proplot_definition, + _rc_proplot_removed, + _rc_proplot_renamed, +) + + +def _get_default_param(key): + """ + Get the default proplot rc parameter. + """ + # NOTE: This is used for the :rc: role when compiling docs and when saving + # proplotrc files. Includes custom proplot params and proplot overrides. + sentinel = object() + for dict_ in ( + _rc_proplot_default, + _rc_matplotlib_override, # imposed defaults + _rc_matplotlib_default, # native defaults + ): + value = dict_.get(key, sentinel) + if value is not sentinel: + return value + raise KeyError(f'Invalid key {key!r}.') + + +def _get_param_repr(value): + """ + Translate setting to a string suitable for saving. + """ + # NOTE: Never safe hex strings with leading '#'. In both matplotlibrc + # and proplotrc this will be read as comment character. + if value is None or isinstance(value, (str, bool, Integral)): + value = str(value) + if value[:1] == '#': # i.e. a HEX string + value = value[1:] + elif isinstance(value, Real): + value = str(round(value, 6)) # truncate decimals + elif isinstance(value, Cycler): + value = repr(value) # special case! + elif isinstance(value, (list, tuple, np.ndarray)): + value = ', '.join(map(_get_param_repr, value)) # sexy recursion + else: + value = None + return value + + +def _generate_rst_table(): + """ + Return the setting names and descriptions in an RST-style table. + """ + # Get descriptions + descrips = { + key: descrip for key, (_, _, descrip) in _rc_proplot_definition.items() + } + + # Get header and divider + keylen = 4 + len(max((*_rc_proplot_definition, 'Key'), key=len)) + vallen = len(max((*descrips.values(), 'Description'), key=len)) + spaces = 2 * ' ' # spaces between each table column + prefix = '.. rst-class:: proplot-rctable\n\n' + header = 'Key' + spaces + ' ' * (keylen - 3) + 'Description\n' + divider = '=' * keylen + spaces + '=' * vallen + '\n' + + # Combine components + string = prefix + divider + header + divider + for key, descrip in descrips.items(): + line = '``' + key + '``' + spaces + ' ' * (keylen - len(key) - 4) + descrip + string += line + '\n' + string = string + divider.strip() + + return string + + +def _generate_yaml_section(kw, comment=True, description=False): + """ + Return the settings as a nicely tabulated YAML-style table. + """ + prefix = '# ' if comment else '' + data = [] + for key, args in kw.items(): + # Possibly append description + includes_descrip = isinstance(args, tuple) and len(args) == 3 + if not description: + descrip = '' + value = args[0] if includes_descrip else args + elif includes_descrip: + value, validator, descrip = args + descrip = '# ' + descrip # skip the validator + else: + raise ValueError(f'Unexpected input {key}={args!r}.') + + # Translate object to string + value = _get_param_repr(value) + if value is not None: + data.append((key, value, descrip)) + else: + warnings._warn_proplot( + f'Failed to write rc setting {key} = {value!r}. Must be None, bool, ' + 'string, int, float, a list or tuple thereof, or a property cycler.' + ) + + # Generate string + string = '' + keylen = len(max(kw, key=len)) + vallen = len(max((tup[1] for tup in data), key=len)) + for key, value, descrip in data: + space1 = ' ' * (keylen - len(key) + 1) + space2 = ' ' * (vallen - len(value) + 2) if descrip else '' + string += f'{prefix}{key}:{space1}{value}{space2}{descrip}\n' + return string.strip() + + +def _generate_yaml_table(changed=None, comment=None, description=False): + """ + Return the settings as a nicely tabulated YAML-style table. + """ + parts = [ + '#--------------------------------------------------------------------', + '# Use this file to change the default proplot and matplotlib settings.', + '# The syntax is identical to matplotlibrc syntax. For details see:', + '# https://proplot.readthedocs.io/en/latest/configuration.html', + '# https://matplotlib.org/stable/tutorials/introductory/customizing.html', + '#--------------------------------------------------------------------', + ] + + # User settings + if changed is not None: # add always-uncommented user settings + table = _generate_yaml_section(changed, comment=False) + parts.extend(('# Changed settings', table, '')) + + # Proplot settings + kw = _rc_proplot_definition if description else _rc_proplot_default + table = _generate_yaml_section(kw, description=description, comment=comment) + parts.extend(('# Proplot settings', table, '')) + + # Matplotlib settings + kw = _rc_matplotlib_override + table = _generate_yaml_section(kw, comment=comment) + parts.extend(('# Matplotlib settings', table, '')) + return '\n'.join(parts) + + +def _convert_grid_param(b, key): + """ + Translate an instruction to turn either major or minor gridlines on or off into a + boolean and string applied to :rcraw:`axes.grid` and :rcraw:`axes.grid.which`. + """ + ob = _rc_matplotlib['axes.grid'] + owhich = _rc_matplotlib['axes.grid.which'] + if b: + # Gridlines are already both on, or they are off only for the + # ones that we want to turn on. Turn on gridlines for both. + if ( + owhich == 'both' + or key == 'grid' and owhich == 'minor' + or key == 'gridminor' and owhich == 'major' + ): + which = 'both' + # Gridlines are off for both, or off for the ones that we + # don't want to turn on. We can just turn on these ones. + else: + which = owhich + else: + # Gridlines are already off, or they are on for the particular + # ones that we want to turn off. Instruct to turn both off. + if ( + not ob + or key == 'grid' and owhich == 'major' + or key == 'gridminor' and owhich == 'minor' + ): + which = 'both' # disable both sides + # Gridlines are currently on for major and minor ticks, so we + # instruct to turn on gridlines for the one we *don't* want off + elif owhich == 'both': # and ob is True, as already tested + # if gridminor=False, enable major, and vice versa + b = True + which = 'major' if key == 'gridminor' else 'minor' + # Gridlines are on for the ones that we *didn't* instruct to + # turn off, and off for the ones we do want to turn off. This + # just re-asserts the ones that are already on. + else: + b = True + which = owhich + return b, which + + +def _pop_settings(src, *, ignore_conflicts=True): + """ + Pop the rc setting names and mode for a `~Configurator.context` block. + """ + # NOTE: By default must ignore settings also present as function parameters + # and include deprecated settings in the list. + # NOTE: rc_mode == 2 applies only the updated params. A power user + # could use ax.format(rc_mode=0) to re-apply all the current settings + conflict_params = ( + 'backend', + 'alpha', # deprecated + 'color', # deprecated + 'facecolor', # deprecated + 'edgecolor', # deprecated + 'linewidth', # deprecated + 'basemap', # deprecated + 'share', # deprecated + 'span', # deprecated + 'tight', # deprecated + 'span', # deprecated + ) + kw = src.pop('rc_kw', None) or {} + if 'mode' in src: + src['rc_mode'] = src.pop('mode') + warnings._warn_proplot( + "Keyword 'mode' was deprecated in v0.6. Please use 'rc_mode' instead." + ) + mode = src.pop('rc_mode', None) + mode = _not_none(mode, 2) # only apply updated params by default + for key, value in tuple(src.items()): + name = _rc_nodots.get(key, None) + if ignore_conflicts and name in conflict_params: + name = None # former renamed settings + if name is not None: + kw[name] = src.pop(key) + return kw, mode + + +class _RcParams(MutableMapping, dict): + """ + A simple dictionary with locked inputs and validated assignments. + """ + # NOTE: By omitting __delitem__ in MutableMapping we effectively + # disable mutability. Also disables deleting items with pop(). + def __init__(self, source, validate): + self._validate = validate + for key, value in source.items(): + self.__setitem__(key, value) # trigger validation + + def __repr__(self): + return RcParams.__repr__(self) + + def __str__(self): + return RcParams.__repr__(self) + + def __len__(self): + return dict.__len__(self) + + def __iter__(self): + # NOTE: Proplot doesn't add deprecated args to dictionary so + # we don't have to suppress warning messages here. + yield from sorted(dict.__iter__(self)) + + def __getitem__(self, key): + key, _ = self._check_key(key) + return dict.__getitem__(self, key) + + def __setitem__(self, key, value): + key, value = self._check_key(key, value) + if key not in self._validate: + raise KeyError(f'Invalid rc key {key!r}.') + try: + value = self._validate[key](value) + except (ValueError, TypeError) as error: + raise ValueError(f'Key {key!r}: {error}') + if key is not None: + dict.__setitem__(self, key, value) + + @staticmethod + def _check_key(key, value=None): + # NOTE: If we assigned from the Configurator then the deprecated key will + # still propagate to the same 'children' as the new key. + # NOTE: This also translates values for special cases of renamed keys. + # Currently the special cases are 'basemap' and 'cartopy.autoextent'. + if key in _rc_proplot_renamed: + key_new, version = _rc_proplot_renamed[key] + warnings._warn_proplot( + f'The rc setting {key!r} was deprecated in version {version} and may be ' # noqa: E501 + f'removed in {warnings._next_release()}. Please use {key_new!r} instead.' # noqa: E501 + ) + if key == 'basemap': # special case + value = ('cartopy', 'basemap')[int(bool(value))] + if key == 'cartopy.autoextent': + value = ('globe', 'auto')[int(bool(value))] + key = key_new + if key in _rc_proplot_removed: + info, version = _rc_proplot_removed[key] + raise KeyError( + f'The rc setting {key!r} was removed in version {version}.' + + (info and ' ' + info) + ) + return key, value + + def copy(self): + source = {key: dict.__getitem__(self, key) for key in self} + return _RcParams(source, self._validate) + + +# Validate the default settings dictionaries using a custom proplot _RcParams and the +# original matplotlib RcParams. Also surreptitiously add proplot font settings to the +# font keys list (beoolean below always evalutes to True) font keys list during init. +_rc_proplot_default = { + key: value + for key, (value, _, _) in _rc_proplot_definition.items() +} +_rc_proplot_validate = { + key: validator + for key, (_, validator, _) in _rc_proplot_definition.items() + if not (validator is validate._validate_fontsize and validate.FONT_KEYS.add(key)) +} +_rc_proplot_default = _RcParams(_rc_proplot_default, _rc_proplot_validate) +_rc_matplotlib_override = RcParams(_rc_matplotlib_override) + +# Important joint matplotlib proplot constants +# NOTE: The 'nodots' dictionary should include removed and renamed settings +_rc_categories = { + '.'.join(name.split('.')[:i + 1]) + for dict_ in ( + _rc_proplot_default, + _rc_matplotlib_default + ) + for name in dict_ + for i in range(len(name.split('.')) - 1) +} +_rc_nodots = { + name.replace('.', ''): name + for dict_ in ( + _rc_proplot_definition, + _rc_proplot_renamed, + _rc_proplot_removed, + _rc_matplotlib_default, + ) + for name in dict_.keys() +} diff --git a/proplot/internals/styles.py b/proplot/internals/styles.py new file mode 100644 index 000000000..28a7d9179 --- /dev/null +++ b/proplot/internals/styles.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +""" +Utilities for global styles and stylesheets. +""" +import os + +import matplotlib as mpl +import matplotlib.style as mstyle +from matplotlib import RcParams +from matplotlib import rcParamsDefault as _rc_matplotlib_default +from matplotlib import rcParamsOrig as _rc_matplotlib_original + +from . import ic # noqa: F401 +from . import defaults, warnings + +# Matplotlib stylesheets +# NOTE: The 'proplot' style is not registered in matplotlib but instead is specially +# handled by proplot.config.use_style (similar to 'default' in matplotlib.style.use) +STYLE_ALIASES = { + None: 'proplot', + '538': 'fivethirtyeight', + 'mpl15': 'classic', + 'mpl20': 'default', + 'matplotlib': 'default', +} + + +def _filter_style_dict(kw, warn_blacklisted=True): + """ + Filter out blacklisted style parameters. + """ + # NOTE: This implements bugfix https://github.com/matplotlib/matplotlib/pull/17252 + # critical for proplot because we always run style.use() when the configurator is + # initialized. Without fix backend resets every time you import proplot. Otherwise + # this is just a copy of _remove_blacklisted_params in matplotlib.style.core. + kw_filtered = {} + for key in kw: + if key in mstyle.core.STYLE_BLACKLIST: + if warn_blacklisted: + warnings._warn_proplot( + f'Dictionary includes a parameter, {key!r}, that is ' + 'not related to style. Ignoring.' + ) + else: + kw_filtered[key] = kw[key] + return kw_filtered + + +def _get_style_dict(style, **kwargs): + """ + Get the default or original rc dictionary with deprecated parameters filtered. + """ + # WARNING: Some deprecated rc params remain in dictionary as None in matplotlib + # < 3.5 (?) so manually filter them out (_deprecated_set is matplotlib < 3.4 and + # examples.directory was handled specially inside RcParams in matplotlib < 3.2). + style = STYLE_ALIASES.get(style, style) + extras = ('default', 'original', 'proplot', 'texgyre', *STYLE_ALIASES) + if style in ('default', 'original', 'proplot'): + kw = _rc_matplotlib_original if style == 'original' else _rc_matplotlib_default + kw = _filter_style_dict(kw, warn_blacklisted=False) + with warnings.catch_warnings(): + warnings.simplefilter('ignore', mpl.MatplotlibDeprecationWarning) + kw = dict(RcParams(kw)) # filter and translate deprecations + for attr in ('_deprecated_set', '_deprecated_remain_as_none'): + deprecated = getattr(mpl, attr, ()) + for key in deprecated: + kw.pop(key, None) # remove + kw.pop('examples.directory', None) # special case for matplotlib < 3.2 + elif style == 'texgyre': + kw = { + 'font.' + family: defaults._rc_matplotlib_override['font.' + family] + for family in ('serif', 'sans-serif', 'monospace', 'cursive', 'fantasy') + } + elif style in mstyle.library: + kw = mstyle.library[style] + else: + try: + kw = mpl.rc_params_from_file(style, use_default_template=False) + except IOError: + raise IOError( + f'Style {style!r} not found in the style library and input ' + 'is not a valid URL or file path. Available styles are: ' + + ', '.join(map(repr, (*extras, *mstyle.library))) + + '.' + ) + return _filter_style_dict(kw, **kwargs) + + +def _infer_style_dict(kw): + """ + Infer proplot parameter values from matploglib stylesheet parameters. + """ + # NOTE: This helps make native matplotlib stylesheets extensible to proplot + # figures without crazy font size and color differences. + kw_proplot = {} + mpl_to_proplot = { + 'xtick.labelsize': ( + 'tick.labelsize', 'grid.labelsize', + ), + 'ytick.labelsize': ( + 'tick.labelsize', 'grid.labelsize', + ), + 'axes.titlesize': ( + 'abc.size', 'suptitle.size', 'title.size', + 'leftlabel.size', 'rightlabel.size', + 'toplabel.size', 'bottomlabel.size', + ), + 'text.color': ( + 'abc.color', 'suptitle.color', 'title.color', + 'tick.labelcolor', 'grid.labelcolor', + 'leftlabel.color', 'rightlabel.color', + 'toplabel.color', 'bottomlabel.color', + ), + } + for key, params in mpl_to_proplot.items(): + if key in kw: + value = kw[key] + for param in params: + kw_proplot[param] = value + return kw_proplot + + +def _parse_style_spec(styles, allow_dictionary=True, **kwargs): + """ + Validate the matplotlib style or style list. + """ + # NOTE: Hold off on filtering out non-style parameters. Will issue warnings + # on subsequent assignment inside 'use_style'. + # NOTE: Unlike matplotlib this begins all styles from the custom base state of + # the matplotlib settings + proplot fonts. + kw_matplotlib = {} + if styles is None or isinstance(styles, (str, dict, os.PathLike)): + styles = [styles] + else: + styles = list(styles) + if 'classic' in styles: + styles = ['default', *styles] + elif any(s in styles for s in ('default', 'original', 'proplot')): + pass + else: + styles = ['default', 'texgyre', *styles] + for style in styles: + if allow_dictionary and isinstance(style, dict): + kw = style + else: + kw = _get_style_dict(style, **kwargs) + kw_matplotlib.update(kw) + kw_proplot = _infer_style_dict(kw_matplotlib) + kw_proplot['style'] = tuple(styles) + return kw_matplotlib, kw_proplot diff --git a/proplot/internals/validate.py b/proplot/internals/validate.py new file mode 100644 index 000000000..3a0dc03ba --- /dev/null +++ b/proplot/internals/validate.py @@ -0,0 +1,447 @@ +#!/usr/bin/env python3 +""" +Configure validators for global settings. +""" +import functools +import re +from numbers import Integral, Real + +import matplotlib.rcsetup as msetup +import numpy as np +from matplotlib import MatplotlibDeprecationWarning, RcParams +from matplotlib.colors import Colormap +from matplotlib.font_manager import font_scalings +from matplotlib.fontconfig_pattern import parse_fontconfig_pattern + +from . import ic # noqa: F401 +from . import styles, warnings + +# Regex for "probable" unregistered named colors. Try to retain warning message for +# colors that were most likely a failed literal string evaluation during startup. +REGEX_NAMED_COLOR = re.compile(r'\A[a-zA-Z0-9:_ -]*\Z') + +# Configurable validation settings +# NOTE: These are set to True inside __init__.py +# NOTE: We really cannot delay creation of 'rc' until after registration because +# colormap creation depends on rc['cmap.lut'] and rc['cmap.listedthresh']. +# And anyway to revoke that dependence would require other uglier kludges. +VALIDATE_REGISTERED_CMAPS = False +VALIDATE_REGISTERED_COLORS = False + +# Matplotlib setting categories +EM_KEYS = ( # em-width units + 'legend.borderpad', + 'legend.labelspacing', + 'legend.handlelength', + 'legend.handleheight', + 'legend.handletextpad', + 'legend.borderaxespad', + 'legend.columnspacing', +) +PT_KEYS = ( + 'font.size', # special case + 'xtick.major.size', + 'xtick.minor.size', + 'ytick.major.size', + 'ytick.minor.size', + 'xtick.major.pad', + 'xtick.minor.pad', + 'ytick.major.pad', + 'ytick.minor.pad', + 'xtick.major.width', + 'xtick.minor.width', + 'ytick.major.width', + 'ytick.minor.width', + 'axes.labelpad', + 'axes.titlepad', + 'axes.linewidth', + 'grid.linewidth', + 'patch.linewidth', + 'hatch.linewidth', + 'lines.linewidth', + 'contour.linewidth', +) +FONT_KEYS = set() # dynamically add to this below + +# Preset legend locations and aliases +# TODO: Add additional inset colorbar locations. +LOCS_LEGEND = { + 'fill': 'fill', + 'inset': 'best', + 'i': 'best', + 0: 'best', + 1: 'upper right', + 2: 'upper left', + 3: 'lower left', + 4: 'lower right', + 5: 'center left', + 6: 'center right', + 7: 'lower center', + 8: 'upper center', + 9: 'center', + 'l': 'left', + 'r': 'right', + 'b': 'bottom', + 't': 'top', + 'c': 'center', + 'ur': 'upper right', + 'ul': 'upper left', + 'll': 'lower left', + 'lr': 'lower right', + 'cr': 'center right', + 'cl': 'center left', + 'uc': 'upper center', + 'lc': 'lower center', +} +LOCS_ALIGN = { + key: val for key, val in LOCS_LEGEND.items() + if isinstance(key, str) + and val in ('left', 'right', 'top', 'bottom', 'center') +} +LOCS_PANEL = { + key: val for key, val in LOCS_LEGEND.items() + if isinstance(key, str) + and val in ('left', 'right', 'top', 'bottom') +} +LOCS_COLORBAR = { + key: val for key, val in LOCS_LEGEND.items() + if val in ( + 'fill', 'best', 'left', 'right', 'top', 'bottom', + 'upper left', 'upper right', 'lower left', 'lower right', + ) +} +LOCS_TEXT = { + key: val for key, val in LOCS_LEGEND.items() + if isinstance(key, str) and val in ( + 'left', 'center', 'right', + 'upper left', 'upper center', 'upper right', + 'lower left', 'lower center', 'lower right', + ) +} + + +def _validate_abc(value): + """ + Validate a-b-c setting. + """ + try: + if np.iterable(value): + return all(map(_validate_bool, value)) + else: + return _validate_bool(value) + except ValueError: + pass + if isinstance(value, str): + if 'a' in value.lower(): + return value + else: + if all(isinstance(_, str) for _ in value): + return tuple(value) + raise ValueError( + "A-b-c setting must be string containing 'a' or 'A' or sequence of strings." + ) + + +def _validate_belongs(*options, ignorecase=True): + """ + Return a validator ensuring the item belongs in the list. + """ + def _validate_belongs(value): # noqa: E306 + for opt in options: + if isinstance(value, str) and isinstance(opt, str): + if ignorecase: + if value.lower() == opt.lower(): + return opt + else: + if value == opt: + return opt + elif value is True or value is False or value is None: + if value is opt: + return opt + elif value == opt: + return opt + raise ValueError( + f'Invalid value {value!r}. Options are: ' + + ', '.join(map(repr, options)) + + '.' + ) + return _validate_belongs + + +def _validate_cmap(subtype): + """ + Validate the colormap or cycle. Possibly skip name registration check + and assign the colormap name rather than a colormap instance. + """ + def _validate_cmap(value): + name = value + if isinstance(value, str): + if VALIDATE_REGISTERED_CMAPS: + from ..colors import _get_cmap_subtype + _get_cmap_subtype(name, subtype) # may trigger useful error message + return name + elif isinstance(value, Colormap): + name = getattr(value, 'name', None) + if isinstance(name, str): + from ..colors import _cmap_database # avoid circular imports + _cmap_database[name] = value + return name + raise ValueError(f'Invalid colormap or color cycle name {name!r}.') + return _validate_cmap + + +def _validate_color(value, alternative=None): + """ + Validate the color. Possibly skip name registration check. + """ + if alternative and isinstance(value, str) and value.lower() == alternative: + return value # e.g. 'auto' + try: + return msetup.validate_color(value) + except ValueError: + if ( + VALIDATE_REGISTERED_COLORS + or not isinstance(value, str) + or not REGEX_NAMED_COLOR.match(value) + ): + raise ValueError(f'{value!r} is not a valid color arg.') from None + return value + except Exception as error: + raise error + + +def _validate_fontprops(s): + """ + Parse font property with support for ``'regular'`` placeholder. + """ + b = s.startswith('regular') + if b: + s = s.replace('regular', 'sans', 1) + parse_fontconfig_pattern(s) + if b: + s = s.replace('sans', 'regular', 1) + return s + + +def _validate_fontsize(value): + """ + Validate font size with new scalings and permitting other units. + """ + if value is None and None in font_scalings: # has it always been this way? + return + if isinstance(value, str): + value = value.lower() + if value in font_scalings: + return value + try: + return _validate_pt(value) # note None is also a valid font size! + except ValueError: + pass + raise ValueError( + f'Invalid font size {value!r}. Can be points or one of the preset scalings: ' + + ', '.join(map(repr, font_scalings)) + + '.' + ) + + +def _validate_labels(labels, lon=True): + """ + Convert labels argument to length-4 boolean array. + """ + if labels is None: + return [None] * 4 + which = 'lon' if lon else 'lat' + if isinstance(labels, str): + labels = (labels,) + array = np.atleast_1d(labels).tolist() + if all(isinstance(_, str) for _ in array): + bool_ = [False] * 4 + opts = ('left', 'right', 'bottom', 'top') + for string in array: + if string in opts: + string = string[0] + elif set(string) - set('lrbt'): + raise ValueError( + f'Invalid {which}label string {string!r}. Must be one of ' + + ', '.join(map(repr, opts)) + + " or a string of single-letter characters like 'lr'." + ) + for char in string: + bool_['lrbt'.index(char)] = True + array = bool_ + if len(array) == 1: + array.append(False) # default is to label bottom or left + if len(array) == 2: + if lon: + array = [False, False, *array] + else: + array = [*array, False, False] + if len(array) != 4 or any(isinstance(_, str) for _ in array): + raise ValueError(f'Invalid {which}label spec: {labels}.') + return array + + +def _validate_loc(loc, mode, **kwargs): + """ + Validate the location specification. + """ + # Create specific options dictionary + if mode == 'align': + loc_dict = LOCS_ALIGN + elif mode == 'panel': + loc_dict = LOCS_PANEL + elif mode == 'legend': + loc_dict = LOCS_LEGEND + elif mode == 'colorbar': + loc_dict = LOCS_COLORBAR + elif mode == 'text': + loc_dict = LOCS_TEXT + else: + raise ValueError(f'Invalid mode {mode!r}.') + loc_dict = loc_dict.copy() + loc_dict.update(kwargs) + loc_dict.update({val: val for val in loc_dict.values()}) + + # Translate the location + # TODO: Implement 'best' colorbar location. Currently rely on a kludge. + sentinel = object() + default = kwargs.pop('default', sentinel) + if default is not sentinel and (loc is None or loc is True): + loc = default + elif isinstance(loc, (str, Integral)): + try: + loc = loc_dict[loc] + except KeyError: + raise KeyError( + f'Invalid {mode} location {loc!r}. Options are: ' + + ', '.join(map(repr, loc_dict)) + + '.' + ) + if mode == 'colorbar' and loc == 'best': + loc = 'lower right' + elif ( + mode == 'legend' + and np.iterable(loc) + and len(loc) == 2 + and all(isinstance(l, Real) for l in loc) + ): + loc = tuple(loc) + else: + raise KeyError(f'Invalid {mode} location {loc!r}.') + + return loc + + +def _validate_or_none(validator): + """ + Allow none otherwise pass to the input validator. + """ + @functools.wraps(validator) + def _validate_or_none(value): + if value is None: + return + if isinstance(value, str) and value.lower() == 'none': + return + return validator(value) + _validate_or_none.__name__ = validator.__name__ + '_or_none' + return _validate_or_none + + +def _validate_rotation(value): + """ + Validate rotation arguments. + """ + if isinstance(value, str) and value.lower() in ('horizontal', 'vertical'): + return value + return _validate_float(value) + + +def _validate_style(style): + """ + Validate the style or stylesheet. + """ + # NOTE: Important to ignore deprecation and blacklisted param warnings here. Only + # show warnings once when we 'sync' the style in Configurator._get_item_dicts() + with warnings.catch_warnings(): + warnings.simplefilter('ignore', MatplotlibDeprecationWarning) + _, kw = styles._parse_style_spec( + style, warn_blacklisted=False, allow_dictionary=False + ) + return kw.pop('style') # pull out from the returned 'rc_proplot' dictionary + + +def _validate_units(dest): + """ + Validate the input using the units function. + """ + def _validate_units(value): + if isinstance(value, str): + from ..utils import units # avoid circular imports + value = units(value, dest) # validation happens here + return _validate_float(value) + return _validate_units + + +# Borrow validators from matplotlib and construct some new ones +# WARNING: Instead of validate_fontweight matplotlib used validate_string +# until version 3.1.2. So use that as backup here. +# WARNING: We create custom 'or none' validators since their +# availability seems less consistent across matplotlib versions. +_validate_pt = _validate_units('pt') +_validate_em = _validate_units('em') +_validate_in = _validate_units('in') +_validate_bool = msetup.validate_bool +_validate_int = msetup.validate_int +_validate_float = msetup.validate_float +_validate_string = msetup.validate_string +_validate_fontname = msetup.validate_stringlist # same as 'font.family' +_validate_fontweight = getattr(msetup, 'validate_fontweight', _validate_string) + +# Special style validators +# See: https://matplotlib.org/stable/api/_as_gen/matplotlib.patches.FancyBboxPatch.html +_validate_boxstyle = _validate_belongs( + 'square', 'circle', 'round', 'round4', 'sawtooth', 'roundtooth', +) +if hasattr(msetup, '_validate_linestyle'): # fancy validation including dashes + _validate_linestyle = msetup._validate_linestyle +else: # no dashes allowed then but no big deal + _validate_linestyle = _validate_belongs( + '-', ':', '--', '-.', 'solid', 'dashed', 'dashdot', 'dotted', 'none', ' ', '', + ) + +# Patch existing matplotlib validators. +# NOTE: validate_fontsizelist is unused in recent matplotlib versions and +# validate_colorlist is only used with prop cycle eval (which we don't care about) +font_scalings['med'] = 1.0 # consistent shorthand +font_scalings['med-small'] = 0.9 # add scaling +font_scalings['med-large'] = 1.1 # add scaling +if not hasattr(RcParams, 'validate'): # not mission critical so skip + warnings._warn_proplot('Failed to update matplotlib rcParams validators.') +else: + _validate = RcParams.validate + _validate['image.cmap'] = _validate_cmap('continuous') + _validate['legend.loc'] = functools.partial(_validate_loc, mode='legend') + for _key, _validator in _validate.items(): + if _validator is getattr(msetup, 'validate_fontsize', None): # should exist + FONT_KEYS.add(_key) + _validate[_key] = _validate_fontsize + if _validator is getattr(msetup, 'validate_fontsize_None', None): + FONT_KEYS.add(_key) + _validate[_key] = _validate_or_none(_validate_fontsize) + if _validator is getattr(msetup, 'validate_font_properties', None): + _validate[_key] = _validate_fontprops + if _validator is getattr(msetup, 'validate_color', None): # should exist + _validate[_key] = _validate_color + if _validator is getattr(msetup, 'validate_color_or_auto', None): + _validate[_key] = functools.partial(_validate_color, alternative='auto') + if _validator is getattr(msetup, 'validate_color_or_inherit', None): + _validate[_key] = functools.partial(_validate_color, alternative='inherit') + for _keys, _validator_replace in ((EM_KEYS, _validate_em), (PT_KEYS, _validate_pt)): + for _key in _keys: + _validator = _validate.get(_key, None) + if _validator is None: + continue + if _validator is msetup.validate_float: + _validate[_key] = _validator_replace + if _validator is getattr(msetup, 'validate_float_or_None'): + _validate[_key] = _validate_or_none(_validator_replace)