diff --git a/docs/conf.py b/docs/conf.py index 931374fee..ee55bc632 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -195,8 +195,17 @@ f.write(HtmlFormatter(style=style).get_style_defs('.highlight')) # Create sample .proplotrc file -from proplot.rctools import _write_defaults # noqa: E402 -_write_defaults(os.path.join('_static', 'proplotrc'), comment=False) +from proplot.rctools import _write_default_rc_file # noqa: E402 +_write_default_rc_file(os.path.join('_static', 'proplotrc'), comment=False) + +# Create params_long and params_short tables +from proplot.rctools import _write_default_rst_table # noqa: E402 +_write_default_rst_table( + os.path.join('_static', 'rcParamsShort.rst'), cat='short', table=True +) +_write_default_rst_table( + os.path.join('_static', 'rcParamsLong.rst'), cat='short', table=True +) # Role # default family is py, but can also set default role so don't need diff --git a/docs/configuration.rst b/docs/configuration.rst index d899952bd..cd9d250a7 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -41,49 +41,7 @@ ProPlot settings at once, as shorthands for settings with longer names, or for special options. For example, :rcraw:`ticklen` changes the tick length for the *x* and *y* axes in one go. -================ ============================================================================================================================================================================================================================================== -Key Description -================ ============================================================================================================================================================================================================================================== -``abc`` Boolean, whether to draw a-b-c labels by default. -``align`` Whether to align axis labels during draw. See `aligning labels `__. -``alpha`` The opacity of the background axes patch. -``autoreload`` If not empty or ``0``, passed to `%autoreload `__. -``autosave`` If not empty or ``0``, passed to `%autosave `__. -``borders`` Boolean, toggles country border lines on and off. -``cmap`` The default colormap. -``coast`` Boolean, toggles coastline lines on and off. -``color`` The color of axis spines, tick marks, tick labels, and labels. -``cycle`` The default color cycle name, used e.g. for lines. -``facecolor`` The color of the background axes patch. -``fontname`` Name of font used for all text in the figure. The default is Helvetica Neue. See `~proplot.fonttools` for details. -``geogrid`` Boolean, toggles meridian and parallel gridlines on and off. -``grid`` Boolean, toggles major grid lines on and off. -``gridminor`` Boolean, toggles minor grid lines on and off. -``gridratio`` Ratio of minor gridline width to major gridline width. -``inlinefmt`` The inline backend figure format or list thereof. Valid formats include ``'svg'``, ``'pdf'``, ``'retina'``, ``'png'``, and ``jpeg``. -``innerborders`` Boolean, toggles internal border lines on and off, e.g. for states and provinces. -``lakes`` Boolean, toggles lake patches on and off. -``land`` Boolean, toggles land patches on and off. -``large`` Font size for titles, "super" titles, and a-b-c subplot labels. -``linewidth`` Thickness of axes spines and major tick lines. -``lut`` The number of colors to put in the colormap lookup table. -``margin`` The margin of space between axes edges and objects plotted inside the axes, if ``xlim`` and ``ylim`` are unset. -``matplotlib`` If not empty, passed to `%matplotlib `__. If ``'auto'`` (the default) uses ``'inline'`` for notebooks and ``'osx'`` or ``'qt'`` for other ipython sessions. -``ocean`` Boolean, toggles ocean patches on and off. -``reso`` Resolution of geographic features, one of ``'lo'``, ``'med'``, or ``'hi'`` -``rgbcycle`` If ``True``, and ``colorblind`` is the current cycle, this registers the ``colorblind`` colors as ``'r'``, ``'b'``, ``'g'``, etc., like in `seaborn `__. -``rivers`` Boolean, toggles river lines on and off. -``share`` The axis sharing level, one of ``0``, ``1``, ``2``, or ``3``. See `~proplot.subplots.subplots` for details. -``small`` Font size for legend text, tick labels, axis labels, and text generated with `~matplotlib.axes.Axes.text`. -``span`` Boolean, toggles spanning axis labels. See `~proplot.subplots.subplots` for details. -``tickdir`` Major and minor tick direction. Must be one of ``out``, ``in``, or ``inout``. -``ticklen`` Length of major ticks in points. -``ticklenratio`` Ratio of minor tickline length to major tickline length. -``tickpad`` Padding between ticks and tick labels in points. -``titlepad`` Padding between the axes and the title, alias for :rcraw:`axes.titlepad`. -``tickratio`` Ratio of minor tickline width to major tickline width. -``tight`` Boolean, indicates whether to auto-adjust figure bounds and subplot spacings. -================ ============================================================================================================================================================================================================================================== +.. include:: _static/rcParamsShort.rst rcParamsLong ------------ @@ -102,91 +60,14 @@ There are two new additions to the ``image`` category, and the new `~proplot.axes.Axes.colorbar` properties. The new ``gridminor`` category controls minor gridline settings, and the new ``geogrid`` category controls meridian and parallel line settings -for `~proplot.axes.ProjAxes`. Note that when a ``grid`` property is changed, -it also changed the corresponding ``gridminor`` property. +for `~proplot.axes.ProjAxes`. Finally, the ``geoaxes``, ``land``, ``ocean``, ``rivers``, ``lakes``, ``borders``, and ``innerborders`` categories control various `~proplot.axes.ProjAxes` settings. These are used when the boolean toggles for the corresponding :ref:`rcParamsShort` settings are turned on. -=============================== ========================================================================================================================================================================================================================================================= -Key(s) Description -=============================== ========================================================================================================================================================================================================================================================= -``abc.style`` a-b-c label style. For options, see `~proplot.axes.Axes.format`. -``abc.loc`` a-b-c label position. For options, see `~proplot.axes.Axes.format`. -``abc.border`` Boolean, indicates whether to draw a white border around a-b-c labels inside an axes. -``abc.linewidth`` Width of the white border around a-b-c labels. -``abc.color`` a-b-c label color. -``abc.size`` a-b-c label font size. -``abc.weight`` a-b-c label font weight. -``axes.formatter.zerotrim`` Boolean, indicates whether trailing decimal zeros are trimmed on tick labels. -``axes.formatter.timerotation`` Float, indicates the default *x* axis tick label rotation for datetime tick labels. -``borders.color`` Line color for country borders. -``borders.linewidth`` Line width for country borders. -``bottomlabel.color`` Font color for column labels on the bottom of the figure. -``bottomlabel.size`` Font size for column labels on the bottom of the figure. -``bottomlabel.weight`` Font weight for column labels on the bottom of the figure. -``colorbar.loc`` Inset colorbar location, options are listed in `~proplot.axes.Axes.colorbar`. -``colorbar.grid`` Boolean, indicates whether to draw borders between each level of the colorbar. -``colorbar.frameon`` Boolean, indicates whether to draw a frame behind inset colorbars. -``colorbar.framealpha`` Opacity for inset colorbar frames. -``colorbar.length`` Length of outer colorbars. -``colorbar.insetlength`` Length of inset colorbars. Units are interpreted by `~proplot.utils.units`. -``colorbar.width`` Width of outer colorbars. Units are interpreted by `~proplot.utils.units`. -``colorbar.insetwidth`` Width of inset colorbars. Units are interpreted by `~proplot.utils.units`. -``colorbar.axespad`` Padding between axes edge and inset colorbars. Units are interpreted by `~proplot.utils.units`. -``colorbar.extend`` Length of rectangular or triangular "extensions" for panel colorbars. Units are interpreted by `~proplot.utils.units`. -``colorbar.insetextend`` Length of rectangular or triangular "extensions" for inset colorbars. Units are interpreted by `~proplot.utils.units`. -``geoaxes.facecolor`` Face color for the map outline patch. -``geoaxes.edgecolor`` Edge color for the map outline patch. -``geoaxes.linewidth`` Edge width for the map outline patch. -``geogrid.labels`` Boolean, indicates whether to label the parallels and meridians. -``geogrid.labelsize`` Font size for latitude and longitude labels. Inherits from ``small``. -``geogrid.latmax`` Absolute latitude in degrees, poleward of which meridian gridlines are cut off. -``geogrid.lonstep`` Default interval for meridian gridlines in degrees. -``geogrid.latstep`` Default interval for parallel gridlines in degrees. -``gridminor.linewidth`` Minor gridline width. -``gridminor.linestyle`` Minor gridline style. -``gridminor.alpha`` Minor gridline transparency. -``gridminor.color`` Minor gridline color. -``image.levels`` Default number of levels for ``pcolormesh`` and ``contourf`` plots. -``image.edgefix`` Whether to fix the `white-lines-between-filled-contours `__ and `white-lines-between-pcolor-rectangles `__ issues. This slows down figure rendering a bit. -``innerborders.color`` Line color for internal border lines. -``innerborders.linewidth`` Line width for internal border lines. -``land.color`` Face color for land patches. -``lakes.color`` Face color for lake patches. -``leftlabel.color`` Font color for row labels on the left-hand side. -``leftlabel.size`` Font size for row labels on the left-hand side. -``leftlabel.weight`` Font weight for row labels on the left-hand side. -``ocean.color`` Face color for ocean patches. -``rightlabel.color`` Font color for row labels on the right-hand side. -``rightlabel.size`` Font size for row labels on the right-hand side. -``rightlabel.weight`` Font weight for row labels on the right-hand side. -``rivers.color`` Line color for river lines. -``rivers.linewidth`` Line width for river lines. -``subplots.axwidth`` Default width of each axes. Units are interpreted by `~proplot.utils.units`. -``subplots.panelwidth`` Width of side panels. Units are interpreted by `~proplot.utils.units`. -``subplots.pad`` Padding around figure edge. Units are interpreted by `~proplot.utils.units`. -``subplots.axpad`` Padding between adjacent subplots. Units are interpreted by `~proplot.utils.units`. -``subplots.panelpad`` Padding between subplots and panels, and between stacked panels. Units are interpreted by `~proplot.utils.units`. -``suptitle.color`` Figure title color. -``suptitle.size`` Figure title font size. -``suptitle.weight`` Figure title font weight. -``tick.color`` Axis tick label color. Mirrors the *axis* label :rcraw:`axes.labelcolor` setting. -``tick.size`` Axis tick label font size. Mirrors the *axis* label :rcraw:`axes.labelsize` setting. -``tick.weight`` Axis tick label font weight. Mirrors the *axis* label :rcraw:`axes.labelweight` setting. -``title.loc`` Title position. For options, see `~proplot.axes.Axes.format`. -``title.border`` Boolean, indicates whether to draw a white border around titles inside an axes. -``title.linewidth`` Width of the white border around titles. -``title.pad`` The title offset in arbitrary units. Alias for :rcraw:`axes.titlepad`. -``title.color`` Axes title color. -``title.size`` Axes title font size. -``title.weight`` Axes title font weight. -``toplabel.color`` Font color for column labels on the top of the figure. -``toplabel.size`` Font size for column labels on the top of the figure. -``toplabel.weight`` Font weight for column labels on the top of the figure. -=============================== ========================================================================================================================================================================================================================================================= +.. include:: _static/rcParamsLong.rst The .proplotrc file ------------------- diff --git a/docs/sphinxext/custom_roles.py b/docs/sphinxext/custom_roles.py index af3a8bc59..21dd608b6 100644 --- a/docs/sphinxext/custom_roles.py +++ b/docs/sphinxext/custom_roles.py @@ -1,10 +1,10 @@ from docutils import nodes from os.path import sep -from proplot import rc +from proplot.rctools import rc, rcParamsShort, rcParamsLong def get_nodes(rawtext, text, inliner): - rctext = (f"rc['{text}']" if '.' in text else f'rc.{text}') + rctext = f"rc['{text}']" if '.' in text else f'rc.{text}' rendered = nodes.Text(rctext) source = inliner.document.attributes['source'].replace(sep, '/') relsource = source.split('/docs/', 1) @@ -13,7 +13,7 @@ def get_nodes(rawtext, text, inliner): levels = relsource[1].count('/') # distance to 'docs' folder refuri = ( '../' * levels - + f'en/latest/configuration.html?highlight={text}#' + + f'configuration.html?highlight={text}#' + ('rcparamslong' if '.' in text else 'rcparamsshort') ) ref = nodes.reference(rawtext, rendered, refuri=refuri) diff --git a/proplot/__init__.py b/proplot/__init__.py index 9b9e69b0c..40a414c2f 100644 --- a/proplot/__init__.py +++ b/proplot/__init__.py @@ -4,9 +4,10 @@ # the fontManager is loaded by other modules (requiring a rebuild) import os as _os import pkg_resources as _pkg -from .utils import _benchmark -from .utils import * # noqa: F401 F403 +from .cbook import _benchmark with _benchmark('total time'): + with _benchmark('utils'): + from .utils import * # noqa: F401 F403 with _benchmark('styletools'): from .styletools import * # noqa: F401 F403 with _benchmark('rctools'): diff --git a/proplot/axes.py b/proplot/axes.py index 666652b51..32c1aceec 100644 --- a/proplot/axes.py +++ b/proplot/axes.py @@ -4,7 +4,7 @@ """ import numpy as np import functools -from numbers import Integral, Number +from numbers import Integral import matplotlib.projections as mproj import matplotlib.axes as maxes import matplotlib.dates as mdates @@ -15,8 +15,14 @@ import matplotlib.gridspec as mgridspec import matplotlib.transforms as mtransforms import matplotlib.collections as mcollections -from . import projs, axistools -from .utils import _warn_proplot, _notNone, units, arange, edges +from . import axistools, projs, utils, validators +from .cbook import ( + _notNone, + _warn_proplot, + _validate_title_loc, + _validate_legend_loc, + _validate_colorbar_loc +) from .rctools import rc, _rc_nodots from .wrappers import ( _get_transform, _norecurse, _redirect, @@ -47,38 +53,11 @@ # Translator for inset colorbars and legends ABC_STRING = 'abcdefghijklmnopqrstuvwxyz' -SIDE_TRANSLATE = { - 'l': 'left', - 'r': 'right', - 'b': 'bottom', - 't': 'top', -} -LOC_TRANSLATE = { - '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', +SIDES_MAP = { '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', } @@ -148,7 +127,7 @@ def __init__(self, *args, number=None, main=False, **kwargs): # Properties self._abc_loc = None self._abc_text = None - self._titles_dict = {} # dictionary of titles and locs + self._abc_titles_dict = {} # dictionary of titles and locs self._title_loc = None # location of main title self._title_pad = rc['axes.titlepad'] # format() can overwrite self._title_above_panel = True # TODO: add rc prop? @@ -239,36 +218,31 @@ def _get_title_props(self, abc=False, loc=None): """Return the standardized location name, position keyword arguments, and setting keyword arguments for the relevant title or a-b-c label at location `loc`.""" - # Location string and position coordinates - context = True + # Compare against previous location + # NOTE: We need to respect context() here -- only "apply" the location + # on first run, or if changed by the user during format() call. Thus + # since the "default" can be None, need to pass sentinel to validator. + sentinel = object() # prevent validator raising error prefix = 'abc' if abc else 'title' - loc = _notNone(loc, rc.get(f'{prefix}.loc', context=True)) - loc_prev = getattr( - self, '_' + ('abc' if abc else 'title') - + '_loc') # old - if loc is None: - loc = loc_prev - elif loc_prev is not None and loc != loc_prev: - context = False - try: - loc = self._loc_translate(loc) - except KeyError: - raise ValueError(f'Invalid title or abc loc {loc!r}.') - else: - if loc in ('top', 'bottom', 'best') or not isinstance(loc, str): - raise ValueError(f'Invalid title or abc loc {loc!r}.') + loc = _validate_title_loc(loc, default=sentinel) + if loc is sentinel: + loc = rc.get(prefix + '.loc', context=True) + prevloc = getattr(self, '_' + ('abc' if abc else 'title') + '_loc') + context = loc is None or prevloc is None or loc == prevloc + + # Existing builtin or custom location + loc = loc or prevloc + if loc == 'center': + obj = self.title + elif loc in ('left', 'right'): + obj = getattr(self, '_' + loc + '_title') + elif loc in self._abc_titles_dict: + obj = self._abc_titles_dict[loc] - # Existing object - if loc in ('left', 'right', 'center'): - if loc == 'center': - obj = self.title - else: - obj = getattr(self, '_' + loc + '_title') - elif loc in self._titles_dict: - obj = self._titles_dict[loc] # New object + # NOTE: Should always have context=False if we get here, because if loc + # was passed and is same as previous, will be in _abc_titles_dict else: - context = False width, height = self.get_size_inches() if loc in ('upper center', 'lower center'): x, ha = 0.5, 'center' @@ -278,16 +252,16 @@ def _get_title_props(self, abc=False, loc=None): elif loc in ('upper right', 'lower right'): xpad = rc['axes.titlepad'] / (72 * width) x, ha = 1 - 1.5 * xpad, 'right' - else: - raise RuntimeError # should be impossible + else: # should never happen + raise ValueError(f'Unknown location {loc!r}.') if loc in ('upper left', 'upper right', 'upper center'): ypad = rc['axes.titlepad'] / (72 * height) y, va = 1 - 1.5 * ypad, 'top' elif loc in ('lower left', 'lower right', 'lower center'): ypad = rc['axes.titlepad'] / (72 * height) y, va = 1.5 * ypad, 'bottom' - else: - raise RuntimeError # should be impossible + else: # should never happen + raise ValueError(f'Unknown location {loc!r}.') obj = self.text(x, y, '', ha=ha, va=va, transform=self.transAxes) obj.set_transform(self.transAxes) @@ -320,27 +294,6 @@ def _iter_panels(self, sides='lrbt'): axs.append(ax) return axs - @staticmethod - def _loc_translate(loc, default=None): - """Return the location string `loc` translated into a standardized - form.""" - if loc in (None, True): - loc = default - elif isinstance(loc, (str, Integral)): - if loc in LOC_TRANSLATE.values(): # full name - pass - else: - try: - loc = LOC_TRANSLATE[loc] - except KeyError: - raise KeyError(f'Invalid location {loc!r}.') - elif np.iterable(loc) and len(loc) == 2 and all( - isinstance(l, Number) for l in loc): - loc = np.array(loc) - else: - raise KeyError(f'Invalid location {loc!r}.') - return loc - def _make_inset_locator(self, bounds, trans): """Return a locator that determines inset axes bounds.""" def inset_locator(ax, renderer): @@ -383,8 +336,9 @@ def _reassign_suplabel(self, side): # Place column and row labels on panels instead of axes -- works when # this is called on the main axes *or* on the relevant panel itself # TODO: Mixed figure panels with super labels? How does that work? + # TODO: Simplify this when EdgeStack implemented s = side[0] - side = SIDE_TRANSLATE[s] + side = SIDES_MAP[s] if s == self._panel_side: ax = self._panel_parent else: @@ -415,6 +369,7 @@ def _reassign_title(self): # called on the main axes *or* on the top panel itself. This is # critical for bounding box calcs; not always clear whether draw() and # get_tightbbox() are called on the main axes or panel first + # TODO: Simplify this when EdgeStack implemented if self._panel_side == 'top' and self._panel_parent: ax, taxs = self._panel_parent, [self] else: @@ -424,16 +379,17 @@ def _reassign_title(self): else: tax = taxs[0] tax._title_pad = ax._title_pad - for loc, obj in ax._titles_dict.items(): + for loc, obj in ax._abc_titles_dict.items(): if not obj.get_text() or loc not in ( - 'left', 'center', 'right'): + 'left', 'center', 'right' + ): continue kw = {} loc, tobj, _ = tax._get_title_props(loc=loc) for key in ('text', 'color', 'fontproperties'): # add to this? kw[key] = getattr(obj, 'get_' + key)() tobj.update(kw) - tax._titles_dict[loc] = tobj + tax._abc_titles_dict[loc] = tobj obj.set_text('') # Push title above tick marks -- this is known matplotlib problem, @@ -665,7 +621,7 @@ def format( ltitle, rtitle, ultitle, uctitle, urtitle, lltitle, lctitle, lrtitle \ : str, optional Axes titles in particular positions. This lets you specify multiple - "titles" for each subplots. See the `abcloc` keyword. + "titles" in each subplot. See the `abcloc` keyword. top : bool, optional Whether to try to put title and a-b-c label above the top subplot panel (if it exists), or to always put them on the main subplot. @@ -765,9 +721,10 @@ def format( fig._update_labels(self, side, labels, **kw) # A-b-c labels - titles_dict = self._titles_dict + titles_dict = self._abc_titles_dict if not self._panel_side: # Location and text + # NOTE: abcstyle already validated abcstyle = rc.get('abc.style', context=True) # 1st run, or changed if 'abcformat' in kwargs: # super sophisticated deprecation system abcstyle = kwargs.pop('abcformat') @@ -776,12 +733,6 @@ def format( f'Please use "abcstyle".' ) if abcstyle and self.number is not None: - if not isinstance(abcstyle, str) or ( - abcstyle.count('a') != 1 and abcstyle.count('A') != 1): - raise ValueError( - f'Invalid abcstyle {abcstyle!r}. ' - 'Must include letter "a" or "A".' - ) abcedges = abcstyle.split('a' if 'a' in abcstyle else 'A') text = abcedges[0] + _abc(self.number - 1) + abcedges[-1] if 'A' in abcstyle: @@ -925,11 +876,7 @@ def colorbar( # TODO: add option to pad inset away from axes edge! kwargs.update({'edgecolor': edgecolor, 'linewidth': linewidth}) if loc != '_fill': - loc = self._loc_translate(loc, rc['colorbar.loc']) - if not isinstance(loc, str): # e.g. 2-tuple or ndarray - raise ValueError(f'Invalid colorbar location {loc!r}.') - if loc == 'best': # white lie - loc = 'lower right' + loc = _validate_colorbar_loc(loc, default=rc['colorbar.loc']) # Generate panel if loc in ('left', 'right', 'top', 'bottom'): @@ -1022,16 +969,16 @@ def colorbar( # Default props cbwidth, cblength = width, length width, height = self.get_size_inches() - extend = units(_notNone( + extend = utils.units(_notNone( kwargs.get('extendsize', None), rc['colorbar.insetextend'] )) - cbwidth = units(_notNone( + cbwidth = utils.units(_notNone( cbwidth, rc['colorbar.insetwidth'] )) / height - cblength = units(_notNone( + cblength = utils.units(_notNone( cblength, rc['colorbar.insetlength'] )) / width - pad = units(_notNone(pad, rc['colorbar.insetpad'])) + pad = utils.units(_notNone(pad, rc['colorbar.insetpad'])) xpad, ypad = pad / width, pad / height # Get location in axes-relative coordinates @@ -1158,7 +1105,7 @@ def legend(self, *args, loc=None, width=None, space=None, **kwargs): Passed to `~proplot.wrappers.legend_wrapper`. """ if loc != '_fill': - loc = self._loc_translate(loc, rc['legend.loc']) + loc = _validate_legend_loc(loc, default=rc['legend.loc']) if isinstance(loc, np.ndarray): loc = loc.tolist() @@ -1437,8 +1384,10 @@ def parametric( if len(args) not in (1, 2): raise ValueError(f'Requires 1-2 arguments, got {len(args)}.') y = np.array(args[-1]).squeeze() - x = np.arange( - y.shape[-1]) if len(args) == 1 else np.array(args[0]).squeeze() + x = ( + np.arange(y.shape[-1]) if len(args) == 1 + else np.array(args[0]).squeeze() + ) values = np.array(values).squeeze() if x.ndim != 1 or y.ndim != 1 or values.ndim != 1: raise ValueError( @@ -1469,7 +1418,7 @@ def parametric( vorig[j], vorig[j + 1], interp + 2)[idx].flat) x, y, values = np.array(x), np.array(y), np.array(values) coords = [] - levels = edges(values) + levels = utils.edges(values) for j in range(y.shape[0]): # Get x/y coordinates and values for points to the 'left' and # 'right' of each joint @@ -2394,7 +2343,7 @@ def _grid_dict(grid): else: kw_ticks.pop('visible', None) # invalid setting if ticklen is not None: - kw_ticks['size'] = units(ticklen, 'pt') + kw_ticks['size'] = utils.units(ticklen, 'pt') if which == 'minor': kw_ticks['size'] *= rc['ticklenratio'] # Grid style and toggling @@ -3126,7 +3075,9 @@ def format( self.projection.lonmin / base) + 180 # central longitude if lonlines is not None: if not np.iterable(lonlines): - lonlines = arange(lon_0 - 180, lon_0 + 180, lonlines) + lonlines = utils.arange( + lon_0 - 180, lon_0 + 180, lonlines + ) lonlines = lonlines.astype(np.float64) lonlines[-1] -= 1e-10 # make sure appears on *right* lonlines = [*lonlines] @@ -3147,9 +3098,9 @@ def format( # Get tick locations if not np.iterable(latlines): if (ilatmax % latlines) == (-ilatmax % latlines): - latlines = arange(-ilatmax, ilatmax, latlines) + latlines = utils.arange(-ilatmax, ilatmax, latlines) else: - latlines = arange(0, ilatmax, latlines) + latlines = utils.arange(0, ilatmax, latlines) if latlines[-1] != ilatmax: latlines = np.concatenate((latlines, [ilatmax])) latlines = np.concatenate( @@ -3478,14 +3429,11 @@ def _format_apply( # NOTE: The e.g. cfeature.COASTLINE features are just for convenience, # hi res versions. Use cfeature.COASTLINE.name to see how it can be # looked up with NaturalEarthFeature. - reso = rc['reso'] - if reso not in ('lo', 'med', 'hi'): - raise ValueError(f'Invalid resolution {reso!r}.') reso = { 'lo': '110m', 'med': '50m', 'hi': '10m', - }.get(reso) + }.get(rc['reso']) # already validated features = { 'land': ('physical', 'land'), 'ocean': ('physical', 'ocean'), diff --git a/proplot/axistools.py b/proplot/axistools.py index 0d046a726..64f2e3d34 100644 --- a/proplot/axistools.py +++ b/proplot/axistools.py @@ -5,7 +5,7 @@ with a shorthand syntax. """ import re -from .utils import _warn_proplot, _notNone +from .cbook import _notNone, _warn_proplot from .rctools import rc from numbers import Number from fractions import Fraction diff --git a/proplot/cbook/__init__.py b/proplot/cbook/__init__.py new file mode 100755 index 000000000..89fbfe2b0 --- /dev/null +++ b/proplot/cbook/__init__.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +""" +Various utilities used internally. +""" +import time +import functools +import warnings + +# Change this constant to turn on benchmarking +BENCHMARK = False + + +class _benchmark(object): + """Context object for timing arbitrary blocks of code.""" + def __init__(self, message): + self.message = message + + def __enter__(self): + if BENCHMARK: + self.time = time.perf_counter() + + def __exit__(self, *args): + if BENCHMARK: + print(f'{self.message}: {time.perf_counter() - self.time}s') + + +class _setstate(object): + """Temporarily modify attribute(s) for an arbitrary object.""" + def __init__(self, obj, **kwargs): + self._obj = obj + self._kwargs = kwargs + self._kwargs_orig = { + key: getattr(obj, key) for key in kwargs if hasattr(obj, key) + } + + def __enter__(self): + for key, value in self._kwargs.items(): + setattr(self._obj, key, value) + + def __exit__(self, *args): + for key in self._kwargs.keys(): + if key in self._kwargs_orig: + setattr(self._obj, key, self._kwargs_orig[key]) + else: + delattr(self._obj, key) + + +def _counter(func): + """A decorator that counts and prints the cumulative time a function + has benn running. See `this link \ +`__.""" + @functools.wraps(func) + def decorator(*args, **kwargs): + if BENCHMARK: + t = time.perf_counter() + res = func(*args, **kwargs) + if BENCHMARK: + decorator.time += (time.perf_counter() - t) + decorator.count += 1 + print(f'{func.__name__}() cumulative time: {decorator.time}s ' + f'({decorator.count} calls)') + return res + decorator.time = 0 + decorator.count = 0 # initialize + return decorator + + +def _timer(func): + """Decorator that prints the time a function takes to execute. + See: https://stackoverflow.com/a/1594484/4970632""" + @functools.wraps(func) + def decorator(*args, **kwargs): + if BENCHMARK: + t = time.perf_counter() + res = func(*args, **kwargs) + if BENCHMARK: + print(f'{func.__name__}() time: {time.perf_counter()-t}s') + return res + return decorator + + +def _warn_format(message, category, filename, lineno, line=None): + """Simple format for warnings issued by ProPlot. See the + `internal warning call signature \ +`__ + and the `default warning source code \ +`__.""" + return f'{filename}:{lineno}: ProPlotWarning: {message}\n' # needs newline + + +def _warn_proplot(message): + """*Temporarily* apply the `_warn_format` monkey patch and emit the + warning. Do not want to affect warnings emitted by other modules.""" + with _setstate(warnings, formatwarning=_warn_format): + warnings.warn(message) + + +def _notNone(*args, names=None): + """Return the first non-``None`` value. This is used with keyword arg + aliases and for setting default values. Ugly name but clear purpose. Pass + the `names` keyword arg to issue warning if multiple args were passed. Must + be list of non-empty strings.""" + if names is None: + for arg in args: + if arg is not None: + return arg + return arg # last one + else: + first = None + kwargs = {} + if len(names) != len(args) - 1: + raise ValueError( + f'Need {len(args)+1} names for {len(args)} args, ' + f'but got {len(names)} names.' + ) + names = [*names, ''] + for name, arg in zip(names, args): + if arg is not None: + if first is None: + first = arg + if name: + kwargs[name] = arg + if len(kwargs) > 1: + warnings.warn( + f'Got conflicting or duplicate keyword args: {kwargs}. ' + 'Using the first one.' + ) + return first diff --git a/proplot/colors/crayola.txt b/proplot/colors/crayola.txt index 57cbea589..4cb779566 100644 --- a/proplot/colors/crayola.txt +++ b/proplot/colors/crayola.txt @@ -1,3 +1,5 @@ +# Crayola crayon colors +# https://en.wikipedia.org/wiki/List_of_Crayola_crayon_colors almond: #efdecd antique brass: #cd9575 apricot: #fdd9b5 diff --git a/proplot/colors/opencolor.txt b/proplot/colors/opencolor.txt index 6f0e25106..b1c18ad7a 100644 --- a/proplot/colors/opencolor.txt +++ b/proplot/colors/opencolor.txt @@ -1,3 +1,5 @@ +# Open-color colors +# https://yeun.github.io/open-color/ gray0: #f8f9fa gray1: #f1f3f5 gray2: #e9ecef diff --git a/proplot/colors/xkcd.txt b/proplot/colors/xkcd.txt index b2e1dc648..81b5475a4 100644 --- a/proplot/colors/xkcd.txt +++ b/proplot/colors/xkcd.txt @@ -1,3 +1,5 @@ +# XKCD color survey colors +# https://blog.xkcd.com/2010/05/03/color-survey-results/ purple: #7e1e9c green: #15b01a blue: #0343df diff --git a/proplot/projs.py b/proplot/projs.py index c5707f97d..02e6c2ace 100644 --- a/proplot/projs.py +++ b/proplot/projs.py @@ -4,7 +4,7 @@ for generating `~mpl_toolkits.basemap.Basemap` and cartopy `~cartopy.crs.Projection` classes. """ -from .utils import _warn_proplot +from .cbook import _warn_proplot try: # use this for debugging instead of print()! from icecream import ic except ImportError: # graceful fallback if IceCream isn't installed diff --git a/proplot/rctools.py b/proplot/rctools.py index a4e03e6ec..4c5c47528 100644 --- a/proplot/rctools.py +++ b/proplot/rctools.py @@ -12,8 +12,9 @@ import cycler import matplotlib.colors as mcolors import matplotlib.cm as mcm +from collections.abc import MutableMapping from numbers import Number -from matplotlib import style, rcParams +from matplotlib import style, rcsetup, rcParams try: # use this for debugging instead of print()! from icecream import ic except ImportError: # graceful fallback if IceCream isn't installed @@ -21,10 +22,15 @@ try: import IPython from IPython import get_ipython -except ModuleNotFoundError: +except ImportError: def get_ipython(): return -from .utils import _warn_proplot, _counter, _benchmark, units +from . import utils +from .cbook import _benchmark, _counter, _warn_proplot +from .validators import ( + _validate_abcstyle, _validate_colorbar_loc, _validate_fontweight, + _validate_reso, _validate_title_loc, _validate_units, +) # Disable mathtext "missing glyph" warnings import matplotlib.mathtext # noqa @@ -37,363 +43,6 @@ def get_ipython(): 'ipython_autoreload', 'ipython_matplotlib', ] -# Dictionaries used to track custom proplot settings -rcParamsShort = {} -rcParamsLong = {} - -# Dictionaries containing default settings -defaultParamsShort = { - 'abc': False, - 'align': False, - 'alpha': 1, - 'autoreload': 2, - 'autosave': 30, - 'borders': False, - 'cmap': 'fire', - 'coast': False, - 'color': 'k', - 'cycle': 'colorblind', - 'facecolor': 'w', - 'fontname': 'sans-serif', - 'inlinefmt': 'retina', - 'geogrid': True, - 'grid': True, - 'gridminor': False, - 'gridratio': 0.5, - 'innerborders': False, - 'lakes': False, - 'land': False, - 'large': 10, - 'linewidth': 0.6, - 'lut': 256, - 'margin': 0.0, - 'matplotlib': 'auto', - 'ocean': False, - 'reso': 'lo', - 'rgbcycle': False, - 'rivers': False, - 'share': 3, - 'small': 9, - 'span': True, - 'tickdir': 'out', - 'ticklen': 4.0, - 'ticklenratio': 0.5, - 'tickminor': True, - 'tickpad': 2.0, - 'tickratio': 0.8, - 'tight': True, -} -defaultParamsLong = { - 'abc.border': True, - 'abc.color': 'k', - 'abc.linewidth': 1.5, - 'abc.loc': 'l', # left side above the axes - 'abc.size': None, # = large - 'abc.style': 'a', - 'abc.weight': 'bold', - 'axes.facealpha': None, # if empty, depends on 'savefig.transparent' - 'axes.formatter.timerotation': 90, - 'axes.formatter.zerotrim': True, - 'axes.geogrid': True, - 'axes.gridminor': True, - 'borders.color': 'k', - 'borders.linewidth': 0.6, - 'bottomlabel.color': 'k', - 'bottomlabel.size': None, # = large - 'bottomlabel.weight': 'bold', - 'coast.color': 'k', - 'coast.linewidth': 0.6, - 'colorbar.extend': '1.3em', - 'colorbar.framealpha': 0.8, - 'colorbar.frameon': True, - 'colorbar.grid': False, - 'colorbar.insetextend': '1em', - 'colorbar.insetlength': '8em', - 'colorbar.insetpad': '0.5em', - 'colorbar.insetwidth': '1.2em', - 'colorbar.length': 1, - 'colorbar.loc': 'right', - 'colorbar.width': '1.5em', - 'geoaxes.edgecolor': None, # = color - 'geoaxes.facealpha': None, # = alpha - 'geoaxes.facecolor': None, # = facecolor - 'geoaxes.linewidth': None, # = linewidth - 'geogrid.alpha': 0.5, - 'geogrid.color': 'k', - 'geogrid.labels': False, - 'geogrid.labelsize': None, # = small - 'geogrid.latmax': 90, - 'geogrid.latstep': 20, - 'geogrid.linestyle': ':', - 'geogrid.linewidth': 1.0, - 'geogrid.lonstep': 30, - 'gridminor.alpha': None, # = grid.alpha - 'gridminor.color': None, # = grid.color - 'gridminor.linestyle': None, # = grid.linewidth - 'gridminor.linewidth': None, # = grid.linewidth x gridratio - 'image.edgefix': True, - 'image.levels': 11, - 'innerborders.color': 'k', - 'innerborders.linewidth': 0.6, - 'lakes.color': 'w', - 'land.color': 'k', - 'leftlabel.color': 'k', - 'leftlabel.size': None, # = large - 'leftlabel.weight': 'bold', - 'ocean.color': 'w', - 'rightlabel.color': 'k', - 'rightlabel.size': None, # = large - 'rightlabel.weight': 'bold', - 'rivers.color': 'k', - 'rivers.linewidth': 0.6, - 'subplots.axpad': '1em', - 'subplots.axwidth': '18em', - 'subplots.pad': '0.5em', - 'subplots.panelpad': '0.5em', - 'subplots.panelwidth': '4em', - 'suptitle.color': 'k', - 'suptitle.size': None, # = large - 'suptitle.weight': 'bold', - 'tick.labelcolor': None, # = color - 'tick.labelsize': None, # = small - 'tick.labelweight': 'normal', - 'title.border': True, - 'title.color': 'k', - 'title.linewidth': 1.5, - 'title.loc': 'c', # centered above the axes - 'title.pad': 3.0, # copy - 'title.size': None, # = large - 'title.weight': 'normal', - 'toplabel.color': 'k', - 'toplabel.size': None, # = large - 'toplabel.weight': 'bold', -} -defaultParams = { - 'axes.grid': True, - 'axes.labelpad': 3.0, - 'axes.titlepad': 3.0, - 'axes.titleweight': 'normal', - 'axes.xmargin': 0.0, - 'axes.ymargin': 0.0, - 'figure.autolayout': False, - 'figure.facecolor': '#f2f2f2', - 'figure.max_open_warning': 0, - 'figure.titleweight': 'bold', - 'font.serif': ( - 'TeX Gyre Schola', # Century lookalike - 'TeX Gyre Bonum', # Bookman lookalike - 'TeX Gyre Termes', # Times New Roman lookalike - 'TeX Gyre Pagella', # Palatino lookalike - 'DejaVu Serif', - 'Bitstream Vera Serif', - 'Computer Modern Roman', - 'Bookman', - 'Century Schoolbook L', - 'Charter', - 'ITC Bookman', - 'New Century Schoolbook', - 'Nimbus Roman No9 L', - 'Palatino', - 'Times New Roman', - 'Times', - 'Utopia', - 'serif' - ), - 'font.sans-serif': ( - 'TeX Gyre Heros', # Helvetica lookalike - 'DejaVu Sans', - 'Bitstream Vera Sans', - 'Computer Modern Sans Serif', - 'Arial', - 'Avenir', - 'Fira Math', - 'Frutiger', - 'Geneva', - 'Gill Sans', - 'Helvetica', - 'Lucid', - 'Lucida Grande', - 'Myriad Pro', - 'Noto Sans', - 'Roboto', - 'Source Sans Pro', - 'Tahoma', - 'Trebuchet MS', - 'Ubuntu', - 'Univers', - 'Verdana', - 'sans-serif' - ), - 'font.monospace': ( - 'TeX Gyre Cursor', # Courier lookalike - 'DejaVu Sans Mono', - 'Bitstream Vera Sans Mono', - 'Computer Modern Typewriter', - 'Andale Mono', - 'Courier New', - 'Courier', - 'Fixed', - 'Nimbus Mono L', - 'Terminal', - 'monospace' - ), - 'font.cursive': ( - 'TeX Gyre Chorus', # Chancery lookalike - 'Apple Chancery', - 'Felipa', - 'Sand', - 'Script MT', - 'Textile', - 'Zapf Chancery', - 'cursive' - ), - 'font.fantasy': ( - 'TeX Gyre Adventor', # Avant Garde lookalike - 'Avant Garde', - 'Charcoal', - 'Chicago', - 'Comic Sans MS', - 'Futura', - 'Humor Sans', - 'Impact', - 'Optima', - 'Western', - 'xkcd', - 'fantasy' - ), - 'grid.alpha': 0.1, - 'grid.color': 'k', - 'grid.linestyle': '-', - 'grid.linewidth': 0.6, - 'hatch.color': 'k', - 'hatch.linewidth': 0.6, - 'legend.borderaxespad': 0, - 'legend.borderpad': 0.5, - 'legend.columnspacing': 1.0, - 'legend.fancybox': False, - 'legend.framealpha': 0.8, - 'legend.frameon': True, - 'legend.handlelength': 1.5, - 'legend.handletextpad': 0.5, - 'legend.labelspacing': 0.5, - 'lines.linewidth': 1.3, - 'lines.markersize': 3.0, - 'mathtext.fontset': 'custom', - 'mathtext.default': 'regular', - 'savefig.bbox': 'standard', - 'savefig.directory': '', - 'savefig.dpi': 300, - 'savefig.facecolor': 'white', - 'savefig.format': 'pdf', - 'savefig.pad_inches': 0.0, - 'savefig.transparent': True, - 'text.usetex': False, - 'xtick.minor.visible': True, - 'ytick.minor.visible': True, -} - -# "Global" settings and the lower-level settings they change -_rc_children = { - 'cmap': ( - 'image.cmap', - ), - 'lut': ( - 'image.lut', - ), - 'alpha': ( # this is a custom setting - 'axes.facealpha', 'geoaxes.facealpha', - ), - 'facecolor': ( - 'axes.facecolor', 'geoaxes.facecolor' - ), - 'fontname': ( - 'font.family', - ), - 'color': ( # change the 'color' of an axes - 'axes.edgecolor', 'geoaxes.edgecolor', 'axes.labelcolor', - 'tick.labelcolor', 'hatch.color', 'xtick.color', 'ytick.color' - ), - 'small': ( # the 'small' fonts - 'font.size', 'tick.labelsize', 'xtick.labelsize', 'ytick.labelsize', - 'axes.labelsize', 'legend.fontsize', 'geogrid.labelsize' - ), - 'large': ( # the 'large' fonts - 'abc.size', 'figure.titlesize', - 'axes.titlesize', 'suptitle.size', 'title.size', - 'leftlabel.size', 'toplabel.size', - 'rightlabel.size', 'bottomlabel.size' - ), - 'linewidth': ( - 'axes.linewidth', 'geoaxes.linewidth', 'hatch.linewidth', - 'xtick.major.width', 'ytick.major.width' - ), - 'margin': ( - 'axes.xmargin', 'axes.ymargin' - ), - 'grid': ( - 'axes.grid', - ), - 'gridminor': ( - 'axes.gridminor', - ), - 'geogrid': ( - 'axes.geogrid', - ), - 'ticklen': ( - 'xtick.major.size', 'ytick.major.size' - ), - 'tickdir': ( - 'xtick.direction', 'ytick.direction' - ), - 'labelpad': ( - 'axes.labelpad', - ), - 'titlepad': ( - 'axes.titlepad', - ), - 'tickpad': ( - 'xtick.major.pad', 'xtick.minor.pad', - 'ytick.major.pad', 'ytick.minor.pad' - ), - 'grid.color': ( - 'gridminor.color', - ), - 'grid.linewidth': ( - 'gridminor.linewidth', - ), - 'grid.linestyle': ( - 'gridminor.linestyle', - ), - 'grid.alpha': ( - 'gridminor.alpha', - ), -} - -# Mapping of settings without "dots" to their full names. This lets us pass -# all settings as kwargs, e.g. ax.format(landcolor='b') instead of the much -# more verbose ax.format(rc_kw={'land.color':'b'}). -# WARNING: rcParamsShort has to be in here because Axes.format() only checks -# _rc_nodots to filter out the rc kwargs! -_rc_nodots = { - name.replace('.', ''): name - for names in (defaultParamsShort, defaultParamsLong, rcParams) - for name in names.keys() -} - -# Category names, used for returning dicts of subcategory properties -_rc_categories = { - *( - re.sub(r'\.[^.]*$', '', name) - for names in (defaultParamsLong, rcParams) - for name in names.keys() - ), - *( - re.sub(r'\..*$', '', name) - for names in (defaultParamsLong, rcParams) - for name in names.keys() - ) -} - def _get_config_paths(): """Return a list of configuration file paths in reverse order of @@ -417,66 +66,43 @@ def _get_config_paths(): return paths -def _get_synced_params(key, value): +def _sync_params(key, value): """Return dictionaries for updating the `rcParamsShort`, `rcParamsLong`, and `rcParams` properties associated with this key.""" kw = {} # builtin properties that global setting applies to kw_long = {} # custom properties that global setting applies to kw_short = {} # short name properties + # Convert units + # NOTE: Ideal method would be to have rc *lookups* convert to numeric + # units. For now, proplot code lets 'subplots' and 'colorbar' params + # sit in unconverted state (they have no rcParams children), but all + # other params are converted immediately. This lets us set e.g. + # titlepad and labelpad in 'em' or small in 'px'. + value_num = value + if key in ( + 'labelpad' + 'large', + 'linewidth', + 'small', + 'ticklen', + 'tickpad', + 'titlepad', + ): + value_num = utils.units(value, 'pt') + # Skip full name keys key = _sanitize_key(key) - if '.' in key: - pass - - # Cycler - elif key in ('cycle', 'rgbcycle'): - if key == 'rgbcycle': - cycle, rgbcycle = rcParamsShort['cycle'], value - else: - cycle, rgbcycle = value, rcParamsShort['rgbcycle'] - try: - colors = mcm.cmap_d[cycle].colors - except (KeyError, AttributeError): - cycles = sorted( - name for name, cmap in mcm.cmap_d.items() - if isinstance(cmap, mcolors.ListedColormap) - ) - raise ValueError( - f'Invalid cycle name {cycle!r}. Options are: ' - ', '.join(map(repr, cycles)) + '.' - ) - if rgbcycle and cycle.lower() == 'colorblind': - regcolors = colors + [(0.1, 0.1, 0.1)] - elif mcolors.to_rgb('r') != (1.0, 0.0, 0.0): # reset - regcolors = [ - (0.0, 0.0, 1.0), - (1.0, 0.0, 0.0), - (0.0, 1.0, 0.0), - (0.75, 0.75, 0.0), - (0.75, 0.75, 0.0), - (0.0, 0.75, 0.75), - (0.0, 0.0, 0.0) - ] - else: - regcolors = [] # no reset necessary - for code, color in zip('brgmyck', regcolors): - rgb = mcolors.to_rgb(color) - mcolors.colorConverter.colors[code] = rgb - mcolors.colorConverter.cache[code] = rgb - kw['patch.facecolor'] = colors[0] - kw['axes.prop_cycle'] = cycler.cycler('color', colors) # Zero linewidth almost always means zero tick length - elif key == 'linewidth' and _to_points(key, value) == 0: - _, ikw_long, ikw = _get_synced_params('ticklen', 0) - kw.update(ikw) - kw_long.update(ikw_long) + if key == 'linewidth' and value_num == 0: + kw['xtick.major.size'] = kw['ytick.major.size'] \ + = kw['xtick.minor.size'] = kw['ytick.minor.size'] = 0 # Tick length/major-minor tick length ratio elif key in ('ticklen', 'ticklenratio'): if key == 'ticklen': - ticklen = _to_points(key, value) + ticklen = value_num ratio = rcParamsShort['ticklenratio'] else: ticklen = rcParamsShort['ticklen'] @@ -487,7 +113,7 @@ def _get_synced_params(key, value): # Spine width/major-minor tick width ratio elif key in ('linewidth', 'tickratio'): if key == 'linewidth': - tickwidth = _to_points(key, value) + tickwidth = value_num ratio = rcParamsShort['tickratio'] else: tickwidth = rcParamsShort['linewidth'] @@ -498,7 +124,7 @@ def _get_synced_params(key, value): # Gridline width elif key in ('grid.linewidth', 'gridratio'): if key == 'grid.linewidth': - gridwidth = _to_points(key, value) + gridwidth = value_num ratio = rcParamsShort['gridratio'] else: gridwidth = rcParams['grid.linewidth'] @@ -543,22 +169,60 @@ def _get_synced_params(key, value): kw['axes.grid'] = value kw['axes.grid.which'] = which - # Now update linked settings - value = _to_points(key, value) - if key in rcParamsShort: - kw_short[key] = value - elif key in rcParamsLong: - kw_long[key] = value - elif key in rcParams: - kw[key] = value - else: - raise KeyError(f'Invalid key {key!r}.') - for name in _rc_children.get(key, ()): - if name in rcParamsLong: - kw_long[name] = value - else: - kw[name] = value - return kw_short, kw_long, kw + # Cycler + elif key in ('cycle', 'rgbcycle'): + if key == 'rgbcycle': + cycle, rgbcycle = rcParamsShort['cycle'], value + else: + cycle, rgbcycle = value, rcParamsShort['rgbcycle'] + try: + colors = mcm.cmap_d[cycle].colors + except (KeyError, AttributeError): + cycles = sorted( + name for name, cmap in mcm.cmap_d.items() + if isinstance(cmap, mcolors.ListedColormap) + ) + raise ValueError( + f'Invalid cycle name {cycle!r}. Options are: ' + ', '.join(map(repr, cycles)) + '.' + ) + if rgbcycle and cycle.lower() == 'colorblind': + regcolors = colors + [(0.1, 0.1, 0.1)] + elif mcolors.to_rgb('r') != (1.0, 0.0, 0.0): # reset + regcolors = [ + (0.0, 0.0, 1.0), + (1.0, 0.0, 0.0), + (0.0, 1.0, 0.0), + (0.75, 0.75, 0.0), + (0.75, 0.75, 0.0), + (0.0, 0.75, 0.75), + (0.0, 0.0, 0.0) + ] + else: + regcolors = [] # no reset necessary + for code, color in zip('brgmyck', regcolors): + rgb = mcolors.to_rgb(color) + mcolors.colorConverter.colors[code] = rgb + mcolors.colorConverter.cache[code] = rgb + kw['patch.facecolor'] = colors[0] + kw['axes.prop_cycle'] = cycler.cycler('color', colors) + + # Update output dictionaries + if key in rcParamsShort: + kw_short[key] = value + _, _, _, *children = defaultParamsShort[key] + for name in children: + if name in rcParamsLong: + kw_long[name] = value_num + else: + kw[name] = value_num + elif key in rcParamsLong: + kw_long[key] = value_num + elif key in rcParams: + kw[key] = value_num + else: + raise KeyError(f'Invalid key {key!r}.') + return kw_short, kw_long, kw def _sanitize_key(key): @@ -570,20 +234,6 @@ def _sanitize_key(key): return key.lower() -def _to_points(key, value): - """Convert certain rc keys to the units "points".""" - # TODO: Incorporate into more sophisticated validation system - # See: https://matplotlib.org/users/customizing.html, all props matching - # the below strings use the units 'points', except custom categories! - if ( - isinstance(value, str) - and key.split('.')[0] not in ('colorbar', 'subplots') - and re.match('^.*(width|space|size|pad|len|small|large)$', key) - ): - value = units(value, 'pt') - return value - - def _update_from_file(file): """ Apply updates from a file. This is largely copied from matplotlib. @@ -593,10 +243,10 @@ def _update_from_file(file): file : str The path. """ - cnt = 0 file = os.path.expanduser(file) added = set() with open(file, 'r') as fd: + cnt = 0 for line in fd: # Read file cnt += 1 @@ -606,7 +256,7 @@ def _update_from_file(file): pair = stripped.split(':', 1) if len(pair) != 2: _warn_proplot( - f'Illegal line #{cnt} in file {file!r}:\n{line!r}"' + f'Illegal line #{cnt} in file {file!r}:\n{line!r}' ) continue key, value = pair @@ -636,7 +286,7 @@ def _update_from_file(file): # Add to dictionaries try: - rc_short, rc_long, rc = _get_synced_params(key, value) + rc_short, rc_long, rc = _sync_params(key, value) except KeyError: _warn_proplot( f'Invalid key {key!r} on line #{cnt} in file {file!r}.' @@ -647,7 +297,7 @@ def _update_from_file(file): rcParams.update(rc) -def _write_defaults(filename, comment=True, overwrite=False): +def _write_default_rc_file(filename, comment=True): """ Save a file to the specified path containing the default `rc` settings. @@ -657,15 +307,19 @@ def _write_defaults(filename, comment=True, overwrite=False): The path. comment : bool, optional Whether to "comment out" each setting. - overwrite : bool, optional - Whether to overwrite existing files. """ - def _tabulate(rcdict): - string = '' + # Function for tabulating an input dictionary + def _tabulate_dict(rcdict, descrip=False): prefix = '# ' if comment else '' - maxlen = max(map(len, rcdict)) + suffix = '' + string = '' + keylen = max(map(len, rcdict.keys())) NoneType = type(None) for key, value in rcdict.items(): + if descrip: + print(key, value) + value, _, suffix, *_ = value + suffix = ' # ' + suffix if isinstance(value, cycler.Cycler): # special case! value = repr(value) elif isinstance(value, (str, Number, NoneType)): @@ -680,8 +334,8 @@ def _tabulate(rcdict): 'Must be string, number, or list or tuple thereof, ' 'or None or a cycler.' ) - space = ' ' * (maxlen - len(key) + 1) - string += f'{prefix}{key}:{space}{value}\n' + spaces = ' ' * (keylen - len(key) + 1) + string += f'{prefix}{key}:{spaces}{value}{suffix}\n' return string.strip() # Fill empty defaultParamsLong values with rcDefaultParamsShort @@ -690,11 +344,11 @@ def _tabulate(rcdict): # None in a .proplotrc file, may trigger error down the line. rc_parents = { child: parent - for parent, children in _rc_children.items() + for parent, (_, _, _, *children) in defaultParamsShort.items() for child in children } defaultParamsLong_filled = defaultParamsLong.copy() - for key, value in defaultParamsLong.items(): + for key, (value, converter, descrip, *_) in defaultParamsLong.items(): if value is None: try: parent = rc_parents[key] @@ -704,31 +358,117 @@ def _tabulate(rcdict): 'but has no rcParmsShort parent!' ) if parent in defaultParamsShort: - value = defaultParamsShort[parent] - elif parent in defaultParams: + value, *_ = defaultParamsShort[parent] + elif parent in defaultParams: # slight speedup maybe? value = defaultParams[parent] else: value = rcParams[parent] - defaultParamsLong_filled[key] = value + defaultParamsLong_filled[key] = (value, converter, descrip) - with open(filename, 'w') as f: - f.write(f""" + # Write the result + string = f""" #--------------------------------------------------------------------- # Use this file to change the default proplot and matplotlib settings # The syntax is mostly the same as for matplotlibrc files -# For descriptions of each setting see: +# For details see the proplot and matplotlib docs: # https://proplot.readthedocs.io/en/latest/configuration.html # https://matplotlib.org/3.1.1/tutorials/introductory/customizing.html #--------------------------------------------------------------------- # ProPlot short name settings -{_tabulate(defaultParamsShort)} +{_tabulate_dict(defaultParamsShort, descrip=True)} # ProPlot long name settings -{_tabulate(defaultParamsLong_filled)} +{_tabulate_dict(defaultParamsLong_filled, descrip=True)} # Matplotlib settings -{_tabulate(defaultParams)} -""".strip()) +{_tabulate_dict(defaultParams)} +""".strip() + with open(filename, 'w') as f: + f.write(string) + + +def _write_default_rst_table(filename, which='all'): + """ + Write an RST file containing the defaults from the dictionary. + + Parameters + ---------- + filename : str + The path. + which : {'all', 'short, 'long'} + The dictionary to write. + """ + if which == 'all': + rcdict = {**defaultParamsShort, **defaultParamsLong} + elif which == 'short': + rcdict = defaultParamsShort + elif which == 'long': + rcdict = defaultParamsLong + else: + raise ValueError(f'Invalid which {which!r}.') + + # Generate the RST table + keylen = max(map(len, rcdict.keys())) + descriplen = max(map( + len, + (descrip for _, _, descrip, *_ in rcdict.values()) + )) + string = '' + for key, (_, _, descrip, *_) in rcdict.items(): + spaces = ' ' * (keylen - len(key) - 4 + 1) + string += f'``{key}``{spaces} {descrip}\n' + + # Write the table to input path + border = '=' * (keylen + 4) + ' ' + '=' * descriplen + header = 'Key' + ' ' * (keylen + 4 - 3) + 'Description' + string = '\n'.join((border, header, border, string.strip(), border)) + with open(filename, 'w') as f: + f.write(string) + + +class RcParams(MutableMapping, dict): + """A dictionary object with validated assignments.""" + def __init__(self, default_dict): + """ + Parameters + ---------- + default_dict : dict + The dictionary of default values. Each value should be a + 3-tuple containing the default value, the converter function, + and the description. + """ + self.validate = { + key: converter + for key, (value, converter, descrip, *_) in default_dict.items() + } + self.update({ + key: value + for key, (value, converter, descrip, *_) in default_dict.items() + }) + + def __setitem__(self, key, value): + """ + Item assignment with validation. + """ + try: + converter = self.validate[key] + except KeyError: + raise KeyError( + f'{key!r} is not a valid rc parameter. ' + 'See rcParams.keys() for a list of valid parameters.' + ) + try: + print(key, value, converter) + value_converted = converter(value) + except ValueError as err: + raise ValueError(f'Key {key}: {err}') + dict.__setitem__(self, key, value_converted) + + def copy(self): + """ + Return a raw dictionary. + """ + return dict(self) class rc_configurator(object): @@ -781,7 +521,7 @@ def __init__(self, local=True): rcParamsShort.update(defaultParamsShort) for rcdict in (rcParamsShort, rcParamsLong): for key, value in rcdict.items(): - _, rc_long, rc = _get_synced_params(key, value) + _, rc_long, rc = _sync_params(key, value) rcParamsLong.update(rc_long) rcParams.update(rc) @@ -806,7 +546,7 @@ def _update(rcdict, newdict): restore[key] = rcdict[key] rcdict[key] = cache[key] = value for key, value in kwargs.items(): - rc_short, rc_long, rc = _get_synced_params(key, value) + rc_short, rc_long, rc = _sync_params(key, value) _update(rcParamsShort, rc_short) _update(rcParamsLong, rc_long) _update(rcParams, rc) @@ -819,7 +559,7 @@ def __exit__(self, *args): ) *_, restore = self._context[-1] for key, value in restore.items(): - rc_short, rc_long, rc = _get_synced_params(key, value) + rc_short, rc_long, rc = _sync_params(key, value) rcParamsShort.update(rc_short) rcParamsLong.update(rc_long) rcParams.update(rc) @@ -867,7 +607,7 @@ def __setitem__(self, key, value): return ipython_autosave(value) elif key == 'autoreload': return ipython_autoreload(value) - rc_short, rc_long, rc = _get_synced_params(key, value) + rc_short, rc_long, rc = _sync_params(key, value) rcParamsShort.update(rc_short) rcParamsLong.update(rc_long) rcParams.update(rc) @@ -1264,10 +1004,876 @@ def ipython_autosave(autosave=None): pass +# Dictionaries containing default settings +# Optional third entry contains children +# NOTE: Why not include these in the defaultParams dictionaries? Because +# if we do that, and implement _sync_params on RcParams, the +# dictionaries are no longer *isolated* from one another. The configurator +# plays the role of the "meta" dictionary and more straightforward to keep it +# that way. +defaultParamsShort = { + 'abc': ( + False, + _validate_title_loc, + 'Boolean. Whether to draw a-b-c labels by default.' + ), + 'align': ( + False, + rcsetup.validate_bool, + 'Whether to align axis labels during draw. See `aligning labels ' + '`__.' # noqa + ), + 'alpha': ( + 1, + rcsetup.validate_float, + 'The opacity of the background axes patch.', + 'axes.facealpha', + 'geoaxes.facealpha', + ), + 'autoreload': ( + 2, + rcsetup.validate_int, + 'If not empty or ``0``, passed to `%autoreload ' + '`__.' # noqa + ), + 'autosave': ( + 30, + rcsetup.validate_int, + 'If not empty or ``0``, passed to `%autosave ' + '`__.' # noqa + ), + 'borders': ( + False, + rcsetup.validate_bool, + 'Boolean. Toggles country border lines on and off.' + ), + 'cmap': ( + 'fire', + rcsetup.validate_string, + 'The default colormap.', + 'image.cmap', + ), + 'coast': ( + False, + rcsetup.validate_bool, + 'Boolean. Toggles coastline lines on and off.' + ), + 'color': ( + 'k', + rcsetup.validate_color, + 'The color of axis spines, tick marks, tick labels, and labels.', + 'axes.edgecolor', 'geoaxes.edgecolor', 'axes.labelcolor', + 'tick.labelcolor', 'hatch.color', 'xtick.color', 'ytick.color', + ), + 'cycle': ( + 'colorblind', + rcsetup.validate_string, + 'The default color cycle name, used e.g. for lines.' + ), + 'facecolor': ( + 'w', + rcsetup.validate_color, + 'The color of the background axes patch.', + 'axes.facecolor', 'geoaxes.facecolor', + ), + 'fontname': ( + 'sans-serif', + rcsetup.validate_string, + 'Alias for :rcraw:`font.family`. The default is sans-serif.', + 'font.family', + ), + 'inlinefmt': ( + 'retina', + rcsetup.validate_string, + 'The inline backend figure format or list thereof. Valid formats ' + "include ``'svg'``, ``'pdf'``, ``'retina'``, ``'png'``, and ``jpeg``." + ), + 'geogrid': ( + True, + rcsetup.validate_bool, + 'Boolean. Toggles meridian and parallel gridlines on and off.' + 'axes.geogrid', + ), + 'grid': ( + True, + rcsetup.validate_bool, + 'Boolean. Toggles major grid lines on and off.', + 'axes.grid', + ), + 'gridminor': ( + False, + rcsetup.validate_bool, + 'Boolean. Toggles minor grid lines on and off.', + 'axes.gridminor', + ), + 'gridratio': ( + 0.5, + rcsetup.validate_float, + 'Ratio of minor gridline width to major gridline width.', + ), + 'innerborders': ( + False, + rcsetup.validate_bool, + 'Boolean. Toggles internal border lines on and off. ' + 'e.g. for states and provinces.' + ), + 'labelpad': ( + 3.0, # copy + rcsetup.validate_float, + 'The *x* and *y* axis label offset. Alias for :rcraw:`axes.titlepad`. ' + 'Units are interpreted by `~proplot.utils.units` (default is points).', + 'axes.labelpad', + ), + 'lakes': ( + False, + rcsetup.validate_bool, + 'Boolean. Toggles lake patches on and off.' + ), + 'land': ( + False, + rcsetup.validate_bool, + 'Boolean. Toggles land patches on and off.' + ), + 'large': ( + 10, + rcsetup.validate_float, + 'Font size for titles, figure titles, and a-b-c subplot labels. ' + 'Units are interpreted by `~proplot.utils.units` (default is points).', + 'abc.size', 'figure.titlesize', + 'axes.titlesize', 'suptitle.size', 'title.size', + 'leftlabel.size', 'toplabel.size', + 'rightlabel.size', 'bottomlabel.size', + ), + 'linewidth': ( + 0.6, + rcsetup.validate_float, + 'Thickness of axes spines and major tick lines. ' + 'Units are interpreted by `~proplot.utils.units` (default is points).', + 'axes.linewidth', 'geoaxes.linewidth', 'hatch.linewidth', + 'xtick.major.width', 'ytick.major.width', + ), + 'lut': ( + 256, + rcsetup.validate_int, + 'The number of colors to put in the colormap lookup table.', + 'image.lut', + ), + 'margin': ( + 0.0, + rcsetup.validate_float, + 'The margin of space between axes edges and objects plotted ' + 'inside the axes, if ``xlim`` and ``ylim`` are unset.', + 'axes.xmargin', 'axes.ymargin', + ), + 'matplotlib': ( + 'auto', + rcsetup.validate_string, + 'If not empty, passed to `%matplotlib ' + '`__. ' # noqa + "If ``'auto'`` (the default) then ``'inline'`` is used for notebooks " + "and ``'qt'`` is used for other ipython sessions." # noqa + ), + 'ocean': ( + False, + rcsetup.validate_bool, + 'Boolean. Toggles ocean patches on and off.' + ), + 'reso': ( + 'lo', + _validate_reso, + 'Resolution of geographic features, one of ' + "``'lo'``, ``'med'``, or ``'hi'``" + ), + 'rgbcycle': ( + False, + rcsetup.validate_bool, + 'If ``True``, and ``colorblind`` is the current cycle, this registers ' + "the ``colorblind`` colors as ``'r'``, ``'b'``, ``'g'``, etc., like " + 'in `seaborn ' + '`__.' + ), + 'rivers': ( + False, + rcsetup.validate_bool, + 'Boolean. Toggles river lines on and off.' + ), + 'share': ( + 3, + rcsetup.validate_int, + 'The axis sharing level, one of ``0``, ``1``, ``2``, or ``3``. ' + 'See `~proplot.subplots.subplots` for details.' + ), + 'small': ( + 9, + rcsetup.validate_float, + 'Font size for legend text, tick labels, axis labels, and ' + 'text generated with `~matplotlib.axes.Axes.text`.' + 'Units are interpreted by `~proplot.utils.units` (default is points).', + 'font.size', 'tick.labelsize', + 'xtick.labelsize', 'ytick.labelsize', + 'axes.labelsize', 'legend.fontsize', 'geogrid.labelsize', + ), + 'span': ( + True, + rcsetup.validate_bool, + 'Boolean. Toggles spanning axis labels. See ' + '`~proplot.subplots.subplots` for details.' + ), + 'tickdir': ( + 'out', + rcsetup.validate_string, + 'Major and minor tick direction. ' + 'Must be one of ``out``, ``in``, or ``inout``.', + 'xtick.direction', 'ytick.direction', + ), + 'ticklen': ( + 4.0, + rcsetup.validate_float, + 'Length of major ticks. ' + 'Units are interpreted by `~proplot.utils.units` (default is points).', + 'xtick.major.size', 'ytick.major.size', + ), + 'ticklenratio': ( + 0.5, + rcsetup.validate_float, + 'Ratio of minor tickline length to major tickline length.' + ), + 'tickminor': ( + True, + rcsetup.validate_bool, + 'Padding between ticks and tick labels in points.' + ), + 'tickpad': ( + 2.0, + rcsetup.validate_float, + 'Alias for :rcraw:`axes.titlepad`. ' + 'The padding between the axes and the title. ' + 'Units are interpreted by `~proplot.utils.units` (default is points).', + 'xtick.major.pad', 'xtick.minor.pad', + 'ytick.major.pad', 'ytick.minor.pad', + ), + 'tickratio': ( + 0.8, + rcsetup.validate_float, + 'Ratio of minor tickline width to major tickline width.' + ), + 'tight': ( + True, + rcsetup.validate_bool, + 'Boolean. Indicates whether to auto-adjust figure bounds ' + 'and subplot spacings.' + ), + 'titlepad': ( + 3.0, # copy + rcsetup.validate_float, + 'The title offset. Alias for :rcraw:`axes.titlepad`. ' + 'Units are interpreted by `~proplot.utils.units` (default is points).', + 'axes.titlepad', + ), +} + +defaultParamsLong = { + 'abc.border': ( + True, + rcsetup.validate_bool, + 'Boolean. Indicates whether to draw a white border around ' + 'a-b-c labels with "inner" locations.' + ), + 'abc.color': ( + 'k', + rcsetup.validate_color, + 'a-b-c label color.' + ), + 'abc.linewidth': ( + 1.5, + rcsetup.validate_float, + 'Width of the white border around a-b-c labels.' + ), + 'abc.loc': ( + 'l', + _validate_title_loc, + 'a-b-c label position. For options, see `~proplot.axes.Axes.format`.' + ), + 'abc.size': ( + None, # = large + rcsetup.validate_fontsize, + 'a-b-c label font size.' + ), + 'abc.style': ( + 'a', + _validate_abcstyle, + 'a-b-c label style. For options, see `~proplot.axes.Axes.format`.' + ), + 'abc.weight': ( + 'bold', + _validate_fontweight, + 'a-b-c label font weight.' + ), + 'axes.facealpha': ( + None, # if empty, depends on 'savefig.transparent' + rcsetup.validate_float, + 'Face transparency for the axes background patch. ' + 'This can be overridden when saving figures by passing ' + '``transparent=True`` to `~matplotlib.figure.Figure.savefig`.' + ), + 'axes.formatter.timerotation': ( + 90, + rcsetup.validate_float, + 'Float, indicates the default *x* axis tick label rotation for ' + 'datetime tick labels.' + ), + 'axes.formatter.zerotrim': ( + True, + rcsetup.validate_bool, + 'Boolean. Indicates whether trailing decimal zeros are trimmed ' + 'on tick labels.' + ), + 'axes.geogrid': ( + True, + rcsetup.validate_bool, + 'Toggles longitude and latitude gridlines. Analogous to ' + ':rcraw:`axes.geogrid`.' + ), + 'axes.gridminor': ( + True, + rcsetup.validate_bool, + 'Toggles longitude and latitude gridlines. Analogous to ' + ':rcraw:`axes.gridminor`.' + ), + 'borders.color': ( + 'k', + rcsetup.validate_color, + 'Line color for country borders.' + ), + 'borders.linewidth': ( + 0.6, + rcsetup.validate_float, + 'Line width for country borders.' + ), + 'bottomlabel.color': ( + 'k', + rcsetup.validate_color, + 'Font color for column labels on the bottom of the figure.' + ), + 'bottomlabel.size': ( + None, # = large + rcsetup.validate_fontsize, + 'Font size for column labels on the bottom of the figure.' + ), + 'bottomlabel.weight': ( + 'bold', + _validate_fontweight, + 'Font weight for column labels on the bottom of the figure.' + ), + 'coast.color': ( + 'k', + rcsetup.validate_color, + 'Line color for coast lines.' + ), + 'coast.linewidth': ( + 0.6, + rcsetup.validate_float, + 'Line width for coast lines.' + ), + 'colorbar.extend': ( + '1.3em', + _validate_units, + 'Length of triangular/rectangular "extensions" for panel colorbars. ' + 'Units are interpreted by `~proplot.utils.units` (default is inches)' + ), + 'colorbar.framealpha': ( + 0.8, + rcsetup.validate_float, + 'Opacity for inset colorbar frames.' + ), + 'colorbar.frameon': ( + True, + rcsetup.validate_bool, + 'Boolean. Indicates whether to draw a frame behind inset colorbars.' + ), + 'colorbar.grid': ( + False, + rcsetup.validate_bool, + 'Boolean. Indicates whether to draw borders between ' + 'each level of the colorbar.' + ), + 'colorbar.insetextend': ( + '1em', + _validate_units, + 'Length of triangular/rectangular "extensions" for inset colorbars. ' + 'Units are interpreted by `~proplot.utils.units` (default is inches)' + ), + 'colorbar.insetlength': ( + '8em', + _validate_units, + 'Length of inset colorbars. ' + 'Units are interpreted by `~proplot.utils.units` (default is inches)' + ), + 'colorbar.insetpad': ( + '0.5em', + _validate_units, + 'Padding between axes edge and inset colorbars. ' + 'Units are interpreted by `~proplot.utils.units` (default is inches)' + ), + 'colorbar.insetwidth': ( + '1.2em', + _validate_units, + 'Width of inset colorbars. ' + 'Units are interpreted by `~proplot.utils.units` (default is inches)' + ), + 'colorbar.length': ( + 1, + _validate_units, + 'Length of outer colorbars.' + ), + 'colorbar.loc': ( + 'right', + _validate_colorbar_loc, + 'Inset colorbar location. ' + 'Options are listed in `~proplot.axes.Axes.colorbar`.' + ), + 'colorbar.width': ( + '1.5em', + _validate_units, + 'Width of outer colorbars. ' + 'Units are interpreted by `~proplot.utils.units` (default is inches)' + ), + 'geoaxes.edgecolor': ( + None, # = color + rcsetup.validate_color, + 'Edge color for the map outline patch.' + ), + 'geoaxes.facealpha': ( + None, # = alpha + rcsetup.validate_float, + 'Face transparency for the map background patch.' + ), + 'geoaxes.facecolor': ( + None, # = facecolor + rcsetup.validate_color, + 'Face color for the map background patch.' + ), + 'geoaxes.linewidth': ( + None, # = linewidth + rcsetup.validate_float, + 'Edge width for the map outline patch.' + ), + 'geogrid.alpha': ( + 0.5, + rcsetup.validate_float, + 'Latitude longitude gridline transparency.' + ), + 'geogrid.color': ( + 'k', + rcsetup.validate_color, + 'Latitude longitude gridline color.' + ), + 'geogrid.labels': ( + False, + rcsetup.validate_bool, + 'Boolean. Indicates whether to label the latitude and longitude ' + 'gridlines.' + ), + 'geogrid.labelsize': ( + None, # = small + rcsetup.validate_fontsize, + 'Font size for latitude and longitude gridline labels. ' + 'Inherits from ``small``.' + ), + 'geogrid.latmax': ( + 90, + rcsetup.validate_float, + 'Absolute latitude in degrees, poleward of which longitude gridlines ' + 'are cut off.' + ), + 'geogrid.latstep': ( + 20, + rcsetup.validate_float, + 'Default interval for latitude gridlines in degrees.' + ), + 'geogrid.linestyle': ( + ':', + rcsetup._validate_linestyle, + 'Latitude longitude gridline style.' + ), + 'geogrid.linewidth': ( + 1.0, + rcsetup.validate_float, + 'Latitude longitude gridline width.' + ), + 'geogrid.lonstep': ( + 30, + rcsetup.validate_float, + 'Default interval for longitude gridlines in degrees.' + ), + 'gridminor.alpha': ( + 0.1, + rcsetup.validate_float, + 'Minor gridline transparency.' + ), + 'gridminor.color': ( + 'k', + rcsetup.validate_color, + 'Minor gridline color.' + ), + 'gridminor.linestyle': ( + '-', + rcsetup._validate_linestyle, + 'Minor gridline style.' + ), + 'gridminor.linewidth': ( + 0.3, # = grid.linewidth x gridratio + rcsetup.validate_float, + 'Minor gridline width.' + ), + 'image.edgefix': ( + True, + rcsetup.validate_bool, + 'Whether to fix the `white-lines-between-filled-contours ' + '`__ and ' + '`white-lines-between-pcolor-rectangles ' + '`__ issues. ' + 'This slows down figure rendering a bit.' + ), + 'image.levels': ( + 11, + rcsetup.validate_int, + 'Default number of levels for ``pcolormesh`` and ``contourf`` plots.' + ), + 'innerborders.color': ( + 'k', + rcsetup.validate_color, + 'Line color for internal border lines.' + ), + 'innerborders.linewidth': ( + 0.6, + rcsetup.validate_float, + 'Line width for internal border lines.' + ), + 'lakes.color': ( + 'w', + rcsetup.validate_color, + 'Face color for land patches.' + ), + 'land.color': ( + 'k', + rcsetup.validate_color, + 'Face color for lake patches.' + ), + 'leftlabel.color': ( + 'k', + rcsetup.validate_color, + 'Font color for row labels on the left-hand side.' + ), + 'leftlabel.size': ( + None, # = large + rcsetup.validate_fontsize, + 'Font size for row labels on the left-hand side.' + ), + 'leftlabel.weight': ( + 'bold', + _validate_fontweight, + 'Font weight for row labels on the left-hand side.' + ), + 'ocean.color': ( + 'w', + rcsetup.validate_color, + 'Face color for ocean patches.' + ), + 'rightlabel.color': ( + 'k', + rcsetup.validate_color, + 'Font color for row labels on the right-hand side.' + ), + 'rightlabel.size': ( + None, # = large + rcsetup.validate_fontsize, + 'Font size for row labels on the right-hand side.' + ), + 'rightlabel.weight': ( + 'bold', + _validate_fontweight, + 'Font weight for row labels on the right-hand side.' + ), + 'rivers.color': ( + 'k', + rcsetup.validate_color, + 'Line color for river lines.' + ), + 'rivers.linewidth': ( + 0.6, + rcsetup.validate_float, + 'Line width for river lines.' + ), + 'subplots.axpad': ( + '1em', + _validate_units, + 'Padding between adjacent subplots. ' + 'Units are interpreted by `~proplot.utils.units` (default is inches)' + ), + 'subplots.axwidth': ( + '18em', + _validate_units, + 'Default width of each axes. ' + 'Units are interpreted by `~proplot.utils.units` (default is inches)' + ), + 'subplots.pad': ( + '0.5em', + _validate_units, + 'Padding around figure edge. ' + 'Units are interpreted by `~proplot.utils.units` (default is inches)' + ), + 'subplots.panelpad': ( + '0.5em', + _validate_units, + 'Padding between subplots and panels, and between stacked panels. ' + 'Units are interpreted by `~proplot.utils.units` (default is inches)' + ), + 'subplots.panelwidth': ( + '4em', + _validate_units, + 'Width of side panels. ' + 'Units are interpreted by `~proplot.utils.units` (default is inches).' + ), + 'suptitle.color': ( + 'k', + rcsetup.validate_color, + 'Figure title color.' + ), + 'suptitle.size': ( + None, # = large + rcsetup.validate_fontsize, + 'Figure title font size.' + ), + 'suptitle.weight': ( + 'bold', + _validate_fontweight, + 'Figure title font weight.' + ), + 'tick.labelcolor': ( + None, # = color + rcsetup.validate_color, + 'Axis tick label color. ' + 'Mirrors the *axis* label :rcraw:`axes.labelcolor` setting.' + ), + 'tick.labelsize': ( + None, # = small + rcsetup.validate_fontsize, + 'Axis tick label font size. ' + 'Mirrors the *axis* label :rcraw:`axes.labelsize` setting.' + ), + 'tick.labelweight': ( + 'normal', + _validate_fontweight, + 'Axis tick label font weight. ' + 'Mirrors the *axis* label :rcraw:`axes.labelweight` setting.' + ), + 'title.border': ( + True, + rcsetup.validate_bool, + 'Boolean. Indicates whether to draw a white border around titles ' + 'with "inner" locations.' + ), + 'title.color': ( + 'k', + rcsetup.validate_color, + 'Axes title color.' + ), + 'title.linewidth': ( + 1.5, + rcsetup.validate_float, + 'Width of the white border around titles.' + ), + 'title.loc': ( + 'c', + _validate_title_loc, + 'Title position. For options, see `~proplot.axes.Axes.format`.' + ), + 'title.size': ( + None, # = large + rcsetup.validate_fontsize, + 'Axes title font size.' + ), + 'title.weight': ( + 'normal', + _validate_fontweight, + 'Axes title font weight.' + ), + 'toplabel.color': ( + 'k', + rcsetup.validate_color, + 'Font color for column labels on the top of the figure.' + ), + 'toplabel.size': ( + None, # = large + rcsetup.validate_fontsize, + 'Font size for column labels on the top of the figure.' + ), + 'toplabel.weight': ( + 'bold', + _validate_fontweight, + 'Font weight for column labels on the top of the figure.' + ), +} + +defaultParams = { + 'axes.grid': True, + 'axes.labelpad': 3.0, + 'axes.titlepad': 3.0, + 'axes.titleweight': 'normal', + 'axes.xmargin': 0.0, + 'axes.ymargin': 0.0, + 'figure.autolayout': False, + 'figure.facecolor': '#f2f2f2', + 'figure.max_open_warning': 0, + 'figure.titleweight': 'bold', + 'font.serif': ( + 'TeX Gyre Schola', # Century lookalike + 'TeX Gyre Bonum', # Bookman lookalike + 'TeX Gyre Termes', # Times New Roman lookalike + 'TeX Gyre Pagella', # Palatino lookalike + 'DejaVu Serif', + 'Bitstream Vera Serif', + 'Computer Modern Roman', + 'Bookman', + 'Century Schoolbook L', + 'Charter', + 'ITC Bookman', + 'New Century Schoolbook', + 'Nimbus Roman No9 L', + 'Palatino', + 'Times New Roman', + 'Times', + 'Utopia', + 'serif' + ), + 'font.sans-serif': ( + 'TeX Gyre Heros', # Helvetica lookalike + 'DejaVu Sans', + 'Bitstream Vera Sans', + 'Computer Modern Sans Serif', + 'Arial', + 'Avenir', + 'Fira Math', + 'Frutiger', + 'Geneva', + 'Gill Sans', + 'Helvetica', + 'Lucid', + 'Lucida Grande', + 'Myriad Pro', + 'Noto Sans', + 'Roboto', + 'Source Sans Pro', + 'Tahoma', + 'Trebuchet MS', + 'Ubuntu', + 'Univers', + 'Verdana', + 'sans-serif' + ), + 'font.monospace': ( + 'TeX Gyre Cursor', # Courier lookalike + 'DejaVu Sans Mono', + 'Bitstream Vera Sans Mono', + 'Computer Modern Typewriter', + 'Andale Mono', + 'Courier New', + 'Courier', + 'Fixed', + 'Nimbus Mono L', + 'Terminal', + 'monospace' + ), + 'font.cursive': ( + 'TeX Gyre Chorus', # Chancery lookalike + 'Apple Chancery', + 'Felipa', + 'Sand', + 'Script MT', + 'Textile', + 'Zapf Chancery', + 'cursive' + ), + 'font.fantasy': ( + 'TeX Gyre Adventor', # Avant Garde lookalike + 'Avant Garde', + 'Charcoal', + 'Chicago', + 'Comic Sans MS', + 'Futura', + 'Humor Sans', + 'Impact', + 'Optima', + 'Western', + 'xkcd', + 'fantasy' + ), + 'grid.alpha': 0.1, + 'grid.color': 'k', + 'grid.linestyle': '-', + 'grid.linewidth': 0.6, + 'hatch.color': 'k', + 'hatch.linewidth': 0.6, + 'legend.borderaxespad': 0, + 'legend.borderpad': 0.5, + 'legend.columnspacing': 1.0, + 'legend.fancybox': False, + 'legend.framealpha': 0.8, + 'legend.frameon': True, + 'legend.handlelength': 1.5, + 'legend.handletextpad': 0.5, + 'legend.labelspacing': 0.5, + 'lines.linewidth': 1.3, + 'lines.markersize': 3.0, + 'mathtext.fontset': 'custom', + 'mathtext.default': 'regular', + 'savefig.bbox': 'standard', + 'savefig.directory': '', + 'savefig.dpi': 300, + 'savefig.facecolor': 'white', + 'savefig.format': 'pdf', + 'savefig.pad_inches': 0.0, + 'savefig.transparent': True, + 'text.usetex': False, + 'xtick.minor.visible': True, + 'ytick.minor.visible': True, +} + +# Mapping of settings without "dots" to their full names. This lets us pass +# all settings as kwargs, e.g. ax.format(landcolor='b') instead of the much +# more verbose ax.format(rc_kw={'land.color':'b'}). +# WARNING: rcParamsShort has to be in here because Axes.format() only checks +# _rc_nodots to filter out the rc kwargs! +_rc_nodots = { + name.replace('.', ''): name + for names in (defaultParamsShort, defaultParamsLong, rcParams) + for name in names.keys() +} + +# Category names, used for returning dicts of subcategory properties +_rc_categories = { + *( + re.sub(r'\.[^.]*$', '', name) + for names in (defaultParamsLong, rcParams) + for name in names.keys() + ), + *( + re.sub(r'\..*$', '', name) + for names in (defaultParamsLong, rcParams) + for name in names.keys() + ) +} + + # Write defaults _user_rc_file = os.path.join(os.path.expanduser('~'), '.proplotrc') if not os.path.exists(_user_rc_file): - _write_defaults(_user_rc_file) + _write_default_rc_file(_user_rc_file) + +# Dictionaries used to track custom proplot settings +rcParamsShort = RcParams(defaultParamsShort) +rcParamsLong = RcParams(defaultParamsLong) #: Instance of `rc_configurator`. This is used to change global settings. #: See :ref:`Configuring proplot` for details. diff --git a/proplot/styletools.py b/proplot/styletools.py index 1c469ec67..1a382d98f 100644 --- a/proplot/styletools.py +++ b/proplot/styletools.py @@ -21,7 +21,7 @@ import numpy.ma as ma import matplotlib.colors as mcolors import matplotlib.cm as mcm -from .utils import _warn_proplot, _notNone, _timer +from .cbook import _notNone, _timer, _warn_proplot from .external import hsluv try: # use this for debugging instead of print()! from icecream import ic @@ -3047,16 +3047,25 @@ def register_colors(nmax=np.inf): for file in paths: cat, _ = os.path.splitext(os.path.basename(file)) with open(file, 'r') as f: - pairs = [ - tuple(item.strip() for item in line.split(':')) - for line in f.readlines() - if line.strip() and line.strip()[0] != '#' - ] - if not all(len(pair) == 2 for pair in pairs): - raise RuntimeError( - f'Invalid color names file {file!r}. ' - f'Every line must be formatted as "name: color".' + cnt = 0 + hex = re.compile( + r'\A#(?:[0-9a-fA-F]{3}){1,2}\Z' # ?: prevents capture ) + pairs = [] + for line in f.readlines(): + cnt += 1 + stripped = line.strip() + if not stripped or stripped[0] == '#': + continue + pair = tuple(item.strip() for item in line.split(':')) + if len(pair) != 2 or not hex.match(pair[1]): + _warn_proplot( + f'Illegal line #{cnt} in file {file!r}:\n' + f'{line!r}\n' + f'Lines must be formatted as "name: hexcolor".' + ) + continue + pairs.append(pair) # Categories for which we add *all* colors if cat == 'opencolor' or i == 1: diff --git a/proplot/subplots.py b/proplot/subplots.py index c53643aa5..65c92f1a8 100644 --- a/proplot/subplots.py +++ b/proplot/subplots.py @@ -15,8 +15,8 @@ import matplotlib.gridspec as mgridspec from numbers import Integral from .rctools import rc -from .utils import _warn_proplot, _notNone, _counter, _setstate, units # noqa -from . import projs, axes +from .cbook import _counter, _setstate, _notNone, _warn_proplot +from . import axes, projs, utils try: # use this for debugging instead of print()! from icecream import ic except ImportError: # graceful fallback if IceCream isn't installed @@ -28,7 +28,7 @@ ] # Translation -SIDE_TRANSLATE = { +SIDES_MAP = { 'l': 'left', 'r': 'right', 'b': 'bottom', @@ -562,7 +562,7 @@ def _get_panelargs( s = side[0] if s not in 'lrbt': raise ValueError(f'Invalid panel spec {side!r}.') - space = space_user = units(space) + space = space_user = utils.units(space) if share is None: share = (not filled) if width is None: @@ -570,7 +570,7 @@ def _get_panelargs( width = rc['colorbar.width'] else: width = rc['subplots.panelwidth'] - width = units(width) + width = utils.units(width) if space is None: key = ('wspace' if s in 'lr' else 'hspace') pad = (rc['subplots.axpad'] if figure else rc['subplots.panelpad']) @@ -581,29 +581,39 @@ def _get_panelargs( def _get_space(key, share=0, pad=None): """Return suitable default spacing given a shared axes setting.""" if key == 'left': - space = units(_notNone(pad, rc['subplots.pad'])) + ( - rc['ytick.major.size'] + rc['ytick.labelsize'] - + rc['ytick.major.pad'] + rc['axes.labelsize']) / 72 + space = utils.units(_notNone(pad, rc['subplots.pad'])) + ( + rc['ytick.major.size'] + + rc['ytick.labelsize'] + + rc['ytick.major.pad'] + + rc['axes.labelsize'] + ) / 72 elif key == 'right': - space = units(_notNone(pad, rc['subplots.pad'])) + space = utils.units(_notNone(pad, rc['subplots.pad'])) elif key == 'bottom': - space = units(_notNone(pad, rc['subplots.pad'])) + ( - rc['xtick.major.size'] + rc['xtick.labelsize'] - + rc['xtick.major.pad'] + rc['axes.labelsize']) / 72 + space = utils.units(_notNone(pad, rc['subplots.pad'])) + ( + rc['xtick.major.size'] + + rc['xtick.labelsize'] + + rc['xtick.major.pad'] + + rc['axes.labelsize'] + ) / 72 elif key == 'top': - space = units(_notNone(pad, rc['subplots.pad'])) + ( - rc['axes.titlepad'] + rc['axes.titlesize']) / 72 + space = utils.units(_notNone(pad, rc['subplots.pad'])) + ( + rc['axes.titlepad'] + rc['axes.titlesize'] + ) / 72 elif key == 'wspace': - space = (units(_notNone(pad, rc['subplots.axpad'])) - + rc['ytick.major.size'] / 72) + space = utils.units(_notNone(pad, rc['subplots.axpad'])) + ( + rc['ytick.major.size'] + ) / 72 if share < 3: space += (rc['ytick.labelsize'] + rc['ytick.major.pad']) / 72 if share < 1: space += rc['axes.labelsize'] / 72 elif key == 'hspace': - space = units(_notNone(pad, rc['subplots.axpad'])) + ( - rc['axes.titlepad'] + rc['axes.titlesize'] - + rc['xtick.major.size']) / 72 + space = utils.units(_notNone(pad, rc['subplots.axpad'])) + ( + rc['axes.titlepad'] + + rc['axes.titlesize'] + + rc['xtick.major.size'] + ) / 72 if share < 3: space += (rc['xtick.labelsize'] + rc['xtick.major.pad']) / 72 if share < 0: @@ -720,7 +730,7 @@ def _subplots_geometry(**kwargs): auto_height = (height is None and width is not None) if width is None and height is None: # get stuff directly from axes if axwidth is None and axheight is None: - axwidth = units(rc['subplots.axwidth']) + axwidth = utils.units(rc['subplots.axwidth']) if axheight is not None: auto_width = True axheight_all = (nrows_main * (axheight - rhspace)) / (dy * rhratio) @@ -945,9 +955,11 @@ def __init__( gridspec_kw = gridspec_kw or {} gridspec = GridSpec(self, **gridspec_kw) nrows, ncols = gridspec.get_active_geometry() - self._pad = units(_notNone(pad, rc['subplots.pad'])) - self._axpad = units(_notNone(axpad, rc['subplots.axpad'])) - self._panelpad = units(_notNone(panelpad, rc['subplots.panelpad'])) + self._pad = utils.units(_notNone(pad, rc['subplots.pad'])) + self._axpad = utils.units(_notNone(axpad, rc['subplots.axpad'])) + self._panelpad = utils.units(_notNone( + panelpad, rc['subplots.panelpad'] + )) self._auto_format = autoformat self._auto_tight = _notNone(tight, rc['tight']) self._include_panels = includepanels @@ -978,7 +990,7 @@ def _add_axes_panel(self, ax, side, filled=False, **kwargs): if s not in 'lrbt': raise ValueError(f'Invalid side {side!r}.') ax = ax._panel_parent or ax # redirect to main axes - side = SIDE_TRANSLATE[s] + side = SIDES_MAP[s] share, width, space, space_orig = _get_panelargs( s, filled=filled, figure=False, **kwargs ) @@ -1037,7 +1049,7 @@ def _add_figure_panel( s = side[0] if s not in 'lrbt': raise ValueError(f'Invalid side {side!r}.') - side = SIDE_TRANSLATE[s] + side = SIDES_MAP[s] _, width, space, space_orig = _get_panelargs( s, filled=True, figure=True, **kwargs ) @@ -1548,7 +1560,7 @@ def _insert_row_column( exists = (idx not in (-1, len(panels)) and panels[idx] == entry) if exists: # already exists! if spaces_orig[idx_space] is None: - spaces_orig[idx_space] = units(space_orig) + spaces_orig[idx_space] = utils.units(space_orig) spaces[idx_space] = _notNone(spaces_orig[idx_space], space) # Make room for new panel slot else: @@ -2300,14 +2312,14 @@ def subplots( ) # Standardized dimensions - width, height = units(width), units(height) - axwidth, axheight = units(axwidth), units(axheight) + width, height = utils.units(width), utils.units(height) + axwidth, axheight = utils.units(axwidth), utils.units(axheight) # Standardized user input border spaces - left, right = units(left), units(right) - bottom, top = units(bottom), units(top) + left, right = utils.units(left), utils.units(right) + bottom, top = utils.units(bottom), utils.units(top) # Standardized user input spaces - wspace = np.atleast_1d(units(_notNone(wspace, space))) - hspace = np.atleast_1d(units(_notNone(hspace, space))) + wspace = np.atleast_1d(utils.units(_notNone(wspace, space))) + hspace = np.atleast_1d(utils.units(_notNone(hspace, space))) if len(wspace) == 1: wspace = np.repeat(wspace, (ncols - 1,)) if len(wspace) != ncols - 1: diff --git a/proplot/utils.py b/proplot/utils.py index 0173765f8..f010fe509 100644 --- a/proplot/utils.py +++ b/proplot/utils.py @@ -1,14 +1,11 @@ #!/usr/bin/env python3 """ -Simple tools used in various places across this package. +Simple tools that may be useful in the context of plotting. They are also +used internally throughout this package. """ -import re -import time -import functools -import warnings +from .validators import _validate_units +from numbers import Integral import numpy as np -from matplotlib import rcParams -from numbers import Number, Integral try: # use this for debugging instead of print()! from icecream import ic except ImportError: # graceful fallback if IceCream isn't installed @@ -16,127 +13,6 @@ __all__ = ['arange', 'edges', 'edges2d', 'units'] -BENCHMARK = False # change this to turn on benchmarking -NUMBER = re.compile('^([-+]?[0-9._]+([eE][-+]?[0-9_]+)?)(.*)$') - - -class _benchmark(object): - """Context object for timing arbitrary blocks of code.""" - def __init__(self, message): - self.message = message - - def __enter__(self): - if BENCHMARK: - self.time = time.perf_counter() - - def __exit__(self, *args): - if BENCHMARK: - print(f'{self.message}: {time.perf_counter() - self.time}s') - - -class _setstate(object): - """Temporarily modify attribute(s) for an arbitrary object.""" - def __init__(self, obj, **kwargs): - self._obj = obj - self._kwargs = kwargs - self._kwargs_orig = { - key: getattr(obj, key) for key in kwargs if hasattr(obj, key) - } - - def __enter__(self): - for key, value in self._kwargs.items(): - setattr(self._obj, key, value) - - def __exit__(self, *args): - for key in self._kwargs.keys(): - if key in self._kwargs_orig: - setattr(self._obj, key, self._kwargs_orig[key]) - else: - delattr(self._obj, key) - - -def _counter(func): - """A decorator that counts and prints the cumulative time a function - has benn running. See `this link \ -`__.""" - @functools.wraps(func) - def decorator(*args, **kwargs): - if BENCHMARK: - t = time.perf_counter() - res = func(*args, **kwargs) - if BENCHMARK: - decorator.time += (time.perf_counter() - t) - decorator.count += 1 - print(f'{func.__name__}() cumulative time: {decorator.time}s ' - f'({decorator.count} calls)') - return res - decorator.time = 0 - decorator.count = 0 # initialize - return decorator - - -def _timer(func): - """Decorator that prints the time a function takes to execute. - See: https://stackoverflow.com/a/1594484/4970632""" - @functools.wraps(func) - def decorator(*args, **kwargs): - if BENCHMARK: - t = time.perf_counter() - res = func(*args, **kwargs) - if BENCHMARK: - print(f'{func.__name__}() time: {time.perf_counter()-t}s') - return res - return decorator - - -def _format_warning(message, category, filename, lineno, line=None): - """Simple format for warnings issued by ProPlot. See the - `internal warning call signature \ -`__ - and the `default warning source code \ -`__.""" - return f'{filename}:{lineno}: ProPlotWarning: {message}\n' # needs newline - - -def _warn_proplot(message): - """*Temporarily* apply the `_format_warning` monkey patch and emit the - warning. Do not want to affect warnings emitted by other modules.""" - with _setstate(warnings, formatwarning=_format_warning): - warnings.warn(message) - - -def _notNone(*args, names=None): - """Return the first non-``None`` value. This is used with keyword arg - aliases and for setting default values. Ugly name but clear purpose. Pass - the `names` keyword arg to issue warning if multiple args were passed. Must - be list of non-empty strings.""" - if names is None: - for arg in args: - if arg is not None: - return arg - return arg # last one - else: - first = None - kwargs = {} - if len(names) != len(args) - 1: - raise ValueError( - f'Need {len(args)+1} names for {len(args)} args, ' - f'but got {len(names)} names.' - ) - names = [*names, ''] - for name, arg in zip(names, args): - if arg is not None: - if first is None: - first = arg - if name: - kwargs[name] = arg - if len(kwargs) > 1: - warnings.warn( - f'Got conflicting or duplicate keyword args: {kwargs}. ' - 'Using the first one.' - ) - return first - def arange(min_, *args): """Identical to `numpy.arange` but with inclusive endpoints. For @@ -239,7 +115,7 @@ def edges2d(Z): return Zb -def units(value, units='in', axes=None, figure=None, width=True): +def units(value, out='in', **kwargs): """ Convert values and lists of values between arbitrary physical units. This is used internally all over ProPlot, permitting flexible units for various @@ -274,7 +150,7 @@ def units(value, units='in', axes=None, figure=None, width=True): ``'ly'`` Light years ;) ========= ========================================================================================= - units : str, optional + out : str, optional The destination units. Default is inches, i.e. ``'in'``. axes : `~matplotlib.axes.Axes`, optional The axes to use for scaling units that look like ``0.1ax``. @@ -285,85 +161,27 @@ def units(value, units='in', axes=None, figure=None, width=True): Whether to use the width or height for the axes and figure relative coordinates. """ # noqa - # Font unit scales - # NOTE: Delay font_manager import, because want to avoid rebuilding font - # cache, which means import must come after TTFPATH added to environ - # by styletools.register_fonts()! - small = rcParams['font.size'] # must be absolute - large = rcParams['axes.titlesize'] - if isinstance(large, str): - import matplotlib.font_manager as mfonts - # error will be raised somewhere else if string name is invalid! - scale = mfonts.font_scalings.get(large, 1) - large = small * scale - - # Scales for converting physical units to inches - unit_dict = { - 'in': 1.0, - 'm': 39.37, - 'ft': 12.0, - 'cm': 0.3937, - 'mm': 0.03937, - 'pt': 1 / 72.0, - 'pc': 1 / 6.0, - 'em': small / 72.0, - 'en': 0.5 * small / 72.0, - 'Em': large / 72.0, - 'En': 0.5 * large / 72.0, - 'ly': 3.725e+17, - } - # Scales for converting display units to inches - # WARNING: In ipython shell these take the value 'figure' - if not isinstance(rcParams['figure.dpi'], str): - # once generated by backend - unit_dict['px'] = 1 / rcParams['figure.dpi'] - if not isinstance(rcParams['savefig.dpi'], str): - # once 'printed' i.e. saved - unit_dict['pp'] = 1 / rcParams['savefig.dpi'] - # Scales relative to axes and figure objects - if axes is not None and hasattr(axes, 'get_size_inches'): # proplot axes - unit_dict['ax'] = axes.get_size_inches()[1 - int(width)] - if figure is None: - figure = getattr(axes, 'figure', None) - if figure is not None and hasattr( - figure, 'get_size_inches'): # proplot axes - unit_dict['fig'] = figure.get_size_inches()[1 - int(width)] # Scale for converting inches to arbitrary other unit - try: - scale = unit_dict[units] - except KeyError: - raise ValueError( - f'Invalid destination units {units!r}. Valid units are ' - + ', '.join(map(repr, unit_dict.keys())) + '.' - ) + scale = _validate_units( + '1' + out if isinstance(out, str) else out, + prefix='Invalid destination units {out!r}. ', + convert=True, + **kwargs + ) # Convert units for each value in list - result = [] - singleton = (not np.iterable(value) or isinstance(value, str)) - for val in ((value,) if singleton else value): - if val is None or isinstance(val, Number): - result.append(val) - continue - elif not isinstance(val, str): - raise ValueError( - f'Size spec must be string or number or list thereof. ' - f'Got {value!r}.' - ) - regex = NUMBER.match(val) - if not regex: - raise ValueError( - f'Invalid size spec {val!r}. Valid units are ' - + ', '.join(map(repr, unit_dict.keys())) + '.' - ) - number, _, units = regex.groups() # second group is exponential - try: - result.append( - float(number) * (unit_dict[units] / scale if units else 1)) - except (KeyError, ValueError): - raise ValueError( - f'Invalid size spec {val!r}. Valid units are ' - + ', '.join(map(repr, unit_dict.keys())) + '.' - ) - if singleton: - result = result[0] - return result + prefix = ( + f'Invalid size spec {value!r}. ' + 'Must be number or string or list thereof. ' + ) + if not np.iterable(value) or isinstance(value, str): + return _validate_units( + value, prefix=prefix, convert=True, **kwargs + ) / scale + else: + return [ + _validate_units( + val, prefix=prefix, convert=True, **kwargs + ) / scale + for val in value + ] diff --git a/proplot/validators/__init__.py b/proplot/validators/__init__.py new file mode 100644 index 000000000..5f881024c --- /dev/null +++ b/proplot/validators/__init__.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +""" +Various custom validators. +""" +import re +import numpy as np +from numbers import Integral, Number +from matplotlib import rcParams + +LOCS_MAP = { + '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', +} + +UNITS_MAP = { + 'in': 1.0, + 'm': 39.37, + 'ft': 12.0, + 'cm': 0.3937, + 'mm': 0.03937, + 'pt': 1 / 72.0, + 'pc': 1 / 6.0, +} + +REGEX_NUMBER = re.compile( + r'\A([-+]?[0-9._]+(?:[eE][-+]?[0-9_]+)?)(.*)\Z' +) + + +def _get_units_map(axes=None, figure=None, width=True): + """ + Return units dictionary from the current params. + """ + # Font unit scales + # NOTE: Delay font_manager import, because want to avoid rebuilding font + # cache, which means import must come after TTFPATH added to environ + # by styletools.register_fonts()! + small = rcParams['font.size'] # must be absolute + large = rcParams['axes.titlesize'] + if isinstance(large, str): + # Scaling must be valid because rcParams assignments are validated! + import matplotlib.font_manager as mfonts + scale = mfonts.font_scalings[large] + large = small * scale + + # Scales for converting physical units to inches + units_map = { + 'em': small / 72.0, + 'en': 0.5 * small / 72.0, + 'Em': large / 72.0, + 'En': 0.5 * large / 72.0, + 'ly': 3.725e+17, + } + + # Scales for converting display units to inches + # WARNING: In ipython shell these take the value 'figure' + if not isinstance(rcParams['figure.dpi'], str): + # once generated by backend + units_map['px'] = 1 / rcParams['figure.dpi'] + if not isinstance(rcParams['savefig.dpi'], str): + # once 'printed' i.e. saved + units_map['pp'] = 1 / rcParams['savefig.dpi'] + + # Scales relative to axes and figure objects + if axes is not None and hasattr(axes, 'get_size_inches'): # proplot axes + units_map['ax'] = axes.get_size_inches()[1 - int(width)] + if figure is None: + figure = getattr(axes, 'figure', None) + if figure is not None and hasattr( + figure, 'get_size_inches'): # proplot axes + units_map['fig'] = figure.get_size_inches()[1 - int(width)] + return units_map + + +def _validate_abcstyle(s): + """Validate the a-b-c style.""" + if not isinstance(abcstyle, str): + raise ValueError(f'Invalid abcstyle {abcstyle!r}. Must be string.') + acount = abcstyle.count('a') + Acount = abcstyle.count('A') + if acount > 1 or Acount > 1: + raise ValueError(f'Invalid abcstyle {abcstyle!r}. Too many a\'s.') + elif acount == 0 and Acount == 0: + raise ValueError( + f'Invalid abcstyle {abcstyle!r}. ' + 'Must include letter "a" or "A".' + ) + return s + + +def _validate_loc(s, default=None, invalid=None, allow_custom=False): + """ + Validate the requested location for a title, a-b-c label, colorbar, or + legend. + + Parameters + ---------- + s : None, str, or int, optional + The location. + default : optional + The default location used if `s` is ``None``. If this is also ``None`` + an error will be raised. + allow_custom : bool, optional + Whether to allow custom locations. This is currently only possible + for legends. + invalid : list of str, optional + Additional invalid locations. This is used to interpret title and + a-b-c label locations. + """ + # Apply filter and get message + invalid = invalid or () + locs = { + key: value for key, value in LOCS_MAP.items() if value not in invalid + } + msg = ( + f'Invalid location {s!r}. Options are: ' + ', '.join(map(repr, set(locs.keys()) | set(locs.values()))) + '.' + ) + + # Convert location to standardized form + if s in (None, True): + if default is None: # occurs during rc assignments + raise ValueError(msg) + else: + return default # should be sentinel, or already validated value + elif isinstance(s, (str, Integral)): + if s in locs.values(): # full name + pass + else: + try: + s = locs[s] + except KeyError: + raise ValueError(msg) + elif allow_custom and np.iterable(s) and len(s) == 2 and all( + isinstance(l, Number) for l in s + ): + s = np.array(s) + else: + raise ValueError(msg) + return s + + +def _validate_legend_loc(s, **kwargs): + """Validate legend location.""" + return _validate_loc(s) + + +def _validate_colorbar_loc(s, **kwargs): + """Validate colorbar location.""" + kwargs.setdefault('allow_custom', False) + kwargs.setdefault('invalid', ( + 'center', 'best' + 'center right', 'center left', 'lower center', 'upper center' + )) + return _validate_loc(s, **kwargs) + + +def _validate_title_loc(s, **kwargs): + """Validate colorbar location.""" + kwargs.setdefault('allow_custom', False) + kwargs.setdefault('invalid', ( + 'center', 'best' + 'center right', 'center left', 'lower center', 'upper center' + )) + return _validate_loc(s, **kwargs) + + +def _validate_units(s, prefix=None, convert=False, **kwargs): + """ + Validate arguments interpreted by units function. Optionally delay + conversion until later in case the units depend on rcParams. + + Parameters + ---------- + s : str or float, optional + The value. + prefix : str, optional + The error message prefix. This is overridden by `units`. + convert : bool, optional + Whether to convert the result. + **kwargs + Passed to `_get_units_map`. + """ + # Numbers or None + # TODO: Reconsider permitting None input for all units() calls? + prefix = prefix or f'Invalid size spec {s!r}.' + msg = '{prefix} Must be string or number.' + if s is None and not convert: # None only allowed for units() calls + raise ValueError(msg) + elif s is None or isinstance(s, Number): + return s + elif not isinstance(s, str): + raise ValueError(msg) + + # Interpret units + regex = REGEX_NUMBER.match(s) + units_map = _get_units_map(**kwargs) + msg = ( + f'{prefix} Valid units are: ' + + ', '.join(map(repr, units_map.keys())) + '.' + ) + if not regex: + raise ValueError(msg) + number, units = regex.groups() + try: + value = float(number) * (units_map[units] if units else 1) + except (KeyError, ValueError): + raise ValueError(msg) + + # Delay conversion until later for custom rcparams so they are affected + # by e.g. font size changes + if convert: + return value + else: + return s + +def _ValidateInList(options): + """Verify an object is in some list.""" + def _validator(s, options=options): + if isinstance(s, str): + s = s.lower() + if s in options: + return s + else: + raise ValueError( + f'Invalid value {s!r}. Options are: ' + + ', '.join(map(repr, options)) + '.' + ) + return _validator + +_validate_reso = _ValidateInList(( + 'lo', 'med', 'hi' +)) + +_validate_fontweight = _ValidateInList(( + 'ultralight', 'light', 'normal', 'regular', 'book', + 'medium', 'roman', 'semibold', 'demibold', 'demi', + 'bold', 'heavy', 'extra bold', 'black', + 100, 200, 400, 400, 400, 500, 500, 600, 600, 600, 700, 800, 800, 900 +)) diff --git a/proplot/wrappers.py b/proplot/wrappers.py index 14f46cc5c..5214faccc 100644 --- a/proplot/wrappers.py +++ b/proplot/wrappers.py @@ -8,8 +8,10 @@ import numpy as np import numpy.ma as ma import functools -from . import styletools, axistools -from .utils import _warn_proplot, _notNone, edges, edges2d, units +from . import axistools, styletools, utils +from .cbook import ( + _notNone, _warn_proplot, _validate_colorbar_loc, _validate_legend_loc, +) import matplotlib.axes as maxes import matplotlib.contour as mcontour import matplotlib.ticker as mticker @@ -522,15 +524,15 @@ def standardize_2d(self, func, *args, order='C', globe=False, **kwargs): elif Z.shape[1] == xlen and Z.shape[0] == ylen: if all(z.ndim == 1 and z.size > 1 and z.dtype != 'object' for z in (x, y)): - x = edges(x) - y = edges(y) + x = utils.edges(x) + y = utils.edges(y) else: if (x.ndim == 2 and x.shape[0] > 1 and x.shape[1] > 1 and x.dtype != 'object'): - x = edges2d(x) + x = utils.edges2d(x) if (y.ndim == 2 and y.shape[0] > 1 and y.shape[1] > 1 and y.dtype != 'object'): - y = edges2d(y) + y = utils.edges2d(y) elif Z.shape[1] != xlen - 1 or Z.shape[0] != ylen - 1: raise ValueError( f'Input shapes x {x.shape} and y {y.shape} must match ' @@ -1376,8 +1378,10 @@ def text_wrapper( # More flexible keyword args and more helpful warning if invalid font # is specified - fontname = _notNone(fontfamily, fontname, None, - names=('fontfamily', 'fontname')) + fontname = _notNone( + fontfamily, fontname, None, + names=('fontfamily', 'fontname') + ) if fontname is not None: if not isinstance(fontname, str) and np.iterable( fontname) and len(fontname) == 1: @@ -1391,7 +1395,7 @@ def text_wrapper( ) size = _notNone(fontsize, size, None, names=('fontsize', 'size')) if size is not None: - kwargs['fontsize'] = units(size, 'pt') + kwargs['fontsize'] = utils.units(size, 'pt') # text.color is ignored sometimes unless we apply this kwargs.setdefault('color', rc['text.color']) obj = func(self, x, y, text, transform=transform, **kwargs) @@ -1407,8 +1411,9 @@ def text_wrapper( obj.update({ 'color': facecolor, 'zorder': 100, - 'path_effects': - [mpatheffects.Stroke(**kwargs), mpatheffects.Normal()] + 'path_effects': [ + mpatheffects.Stroke(**kwargs), mpatheffects.Normal() + ] }) return obj @@ -1649,12 +1654,7 @@ def cycle_changer( # Add colorbar and/or legend if colorbar: # Add handles - loc = self._loc_translate(colorbar) - if not isinstance(loc, str): - raise ValueError( - f'Invalid on-the-fly location {loc!r}. ' - 'Must be a preset location. See Axes.colorbar' - ) + loc = _validate_colorbar_loc(colorbar, default=rc['colorbar.loc']) if loc not in self._auto_colorbar: self._auto_colorbar[loc] = ([], {}) self._auto_colorbar[loc][0].extend(objs) @@ -1666,12 +1666,7 @@ def cycle_changer( self._auto_colorbar[loc][1].update(colorbar_kw) if legend: # Add handles - loc = self._loc_translate(legend) - if not isinstance(loc, str): - raise ValueError( - f'Invalid on-the-fly location {loc!r}. ' - 'Must be a preset location. See Axes.legend' - ) + loc = _validate_legend_loc(legend, default=rc['legend.loc']) if loc not in self._auto_legend: self._auto_legend[loc] = ([], {}) self._auto_legend[loc][0].extend(objs) @@ -1912,12 +1907,12 @@ def cmap_changer( for i, val in enumerate(values): levels.append(2 * val - levels[-1]) if any(np.diff(levels) <= 0): # algorithm failed - levels = edges(values) + levels = utils.edges(values) # Generate levels by finding in-between points in the # normalized numeric space else: inorm = styletools.Norm(norm, **norm_kw) - levels = inorm.inverse(edges(inorm(values))) + levels = inorm.inverse(utils.edges(inorm(values))) if name in ('parametric',): kwargs['values'] = values else: @@ -2143,12 +2138,7 @@ def cmap_changer( # Add colorbar if colorbar: - loc = self._loc_translate(colorbar) - if not isinstance(loc, str): - raise ValueError( - f'Invalid on-the-fly location {loc!r}. ' - f'Must be a preset location. See Axes.colorbar.' - ) + loc = _validate_colorbar_loc(colorbar, default=rc['colorbar.loc']) if 'label' not in colorbar_kw and self.figure._auto_format: _, label = _auto_label(args[-1]) # last one is data, we assume if label: @@ -2856,7 +2846,7 @@ def colorbar_wrapper( scale = width * abs(self.get_position().width) else: scale = height * abs(self.get_position().height) - extendsize = units(_notNone(extendsize, rc['colorbar.extend'])) + extendsize = utils.units(_notNone(extendsize, rc['colorbar.extend'])) extendsize = extendsize / (scale - 2 * extendsize) kwargs.update({ 'ticks': locator,