Skip to content

Commit 88f3dc8

Browse files
committed
Add units 'numeric' arg, add docs snippets, cleanup
1 parent 51d480d commit 88f3dc8

File tree

1 file changed

+140
-95
lines changed

1 file changed

+140
-95
lines changed

proplot/utils.py

Lines changed: 140 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,19 @@
22
"""
33
Various tools that may be useful while making plots.
44
"""
5+
# WARNING: Cannot import 'rc' anywhere in this file or we get circular import
6+
# issues. The rc param validators need functions in this file.
57
import re
6-
from numbers import Integral, Number
8+
from numbers import Integral, Real
79

810
import matplotlib.colors as mcolors
911
import matplotlib.font_manager as mfonts
1012
import numpy as np
11-
from matplotlib import rcParams
13+
from matplotlib import rcParams as rc_matplotlib
1214

1315
from .externals import hsluv
1416
from .internals import ic # noqa: F401
15-
from .internals import docstring, warnings
17+
from .internals import _not_none, docstring, warnings
1618

1719
__all__ = [
1820
'arange',
@@ -31,11 +33,14 @@
3133
'to_xyz',
3234
'to_rgba',
3335
'to_xyza',
36+
'units',
3437
'shade', # deprecated
3538
'saturate', # deprecated
3639
]
3740

38-
NUMBER = re.compile(r'\A([-+]?[0-9._]+(?:[eE][-+]?[0-9_]+)?)(.*)\Z')
41+
UNIT_REGEX = re.compile(
42+
r'\A([-+]?[0-9._]+(?:[eE][-+]?[0-9_]+)?)(.*)\Z' # float with trailing units
43+
)
3944
UNIT_DICT = {
4045
'in': 1.0,
4146
'ft': 12.0,
@@ -44,12 +49,24 @@
4449
'dm': 3.937,
4550
'cm': 0.3937,
4651
'mm': 0.03937,
47-
'pt': 1 / 72.0,
4852
'pc': 1 / 6.0,
53+
'pt': 1 / 72.0,
4954
'ly': 3.725e+17,
5055
}
5156

52-
# Shared parameters
57+
58+
# Unit docstrings
59+
# NOTE: Try to fit this into a single line. Cannot break up with newline as that will
60+
# mess up docstring indentation since this is placed in indented param lines.
61+
_units_docstring = (
62+
'If float, units are {units}. If string, interpreted by `~proplot.utils.units`.'
63+
)
64+
docstring.snippets['units.pt'] = _units_docstring.format(units='points')
65+
docstring.snippets['units.in'] = _units_docstring.format(units='inches')
66+
docstring.snippets['units.em'] = _units_docstring.format(units='em-widths')
67+
68+
69+
# Color docstrings
5370
docstring.snippets['param.rgba'] = """
5471
color : color-spec
5572
The color. Sanitized with `to_rgba`.
@@ -75,8 +92,6 @@
7592
The hue-saturation-luminance-like colorspace used to transform the color.
7693
Default is the perceptually uniform colorspace ``'hcl'``.
7794
"""
78-
79-
# Shared return values
8095
docstring.snippets['return.hex'] = """
8196
color : str
8297
A HEX string.
@@ -472,6 +487,35 @@ def set_alpha(color, alpha):
472487
return to_hex(color)
473488

474489

490+
def _translate_cycle_color(color, cycle=None):
491+
"""
492+
Parse the input cycle color.
493+
"""
494+
if isinstance(cycle, str):
495+
from .colors import _cmap_database
496+
try:
497+
cycle = _cmap_database[cycle].colors
498+
except (KeyError, AttributeError):
499+
cycles = sorted(
500+
name for name, cmap in _cmap_database.items()
501+
if isinstance(cmap, mcolors.ListedColormap)
502+
)
503+
raise ValueError(
504+
f'Invalid color cycle {cycle!r}. Options are: '
505+
+ ', '.join(map(repr, cycles)) + '.'
506+
)
507+
elif cycle is None:
508+
cycle = rc_matplotlib['axes.prop_cycle'].by_key()
509+
if 'color' not in cycle:
510+
cycle = ['k']
511+
else:
512+
cycle = cycle['color']
513+
else:
514+
raise ValueError(f'Invalid cycle {cycle!r}.')
515+
516+
return cycle[int(color[-1]) % len(cycle)]
517+
518+
475519
@docstring.add_snippets
476520
def to_hex(color, space='rgb', cycle=None, keep_alpha=True):
477521
"""
@@ -549,37 +593,16 @@ def to_rgba(color, space='rgb', cycle=None):
549593
to_xyz
550594
to_xyza
551595
"""
552-
# Convert color cycle strings
596+
# Translate color cycle strings
553597
if isinstance(color, str) and re.match(r'\AC[0-9]\Z', color):
554-
if isinstance(cycle, str):
555-
from .colors import _cmap_database
556-
try:
557-
cycle = _cmap_database[cycle].colors
558-
except (KeyError, AttributeError):
559-
cycles = sorted(
560-
name for name, cmap in _cmap_database.items()
561-
if isinstance(cmap, mcolors.ListedColormap)
562-
)
563-
raise ValueError(
564-
f'Invalid cycle {cycle!r}. Options are: '
565-
+ ', '.join(map(repr, cycles)) + '.'
566-
)
567-
elif cycle is None:
568-
cycle = rcParams['axes.prop_cycle'].by_key()
569-
if 'color' not in cycle:
570-
cycle = ['k']
571-
else:
572-
cycle = cycle['color']
573-
else:
574-
raise ValueError(f'Invalid cycle {cycle!r}.')
575-
color = cycle[int(color[-1]) % len(cycle)]
598+
color = _translate_cycle_color(color, cycle=cycle)
576599

577600
# Translate RGB strings and (colormap, index) tuples
578601
opacity = 1
579602
if isinstance(color, str) or np.iterable(color) and len(color) == 2:
580603
try:
581604
*color, opacity = mcolors.to_rgba(color) # ensure is valid color
582-
except (ValueError, TypeError):
605+
except (TypeError, ValueError):
583606
raise ValueError(f'Invalid RGB argument {color!r}.')
584607

585608
# Pull out alpha channel
@@ -594,7 +617,7 @@ def to_rgba(color, space='rgb', cycle=None):
594617
if any(c > 2 for c in color):
595618
color = [c / 255 for c in color] # scale to within 0-1
596619
color = tuple(color)
597-
except (ValueError, TypeError):
620+
except (TypeError, ValueError):
598621
raise ValueError(f'Invalid RGB argument {color!r}.')
599622
elif space == 'hsv':
600623
color = hsluv.hsl_to_rgb(*color)
@@ -681,8 +704,28 @@ def to_xyza(color, space='hcl'):
681704
return (*color, opacity)
682705

683706

707+
def _fontsize_to_pt(size):
708+
"""
709+
Translate font preset size or unit string to points.
710+
"""
711+
scalings = mfonts.font_scalings
712+
if not isinstance(size, str):
713+
return size
714+
if size in mfonts.font_scalings:
715+
return rc_matplotlib['font.size'] * scalings[size]
716+
try:
717+
return units(size, 'pt')
718+
except ValueError:
719+
raise KeyError(
720+
f'Invalid font size {size!r}. Can be points or one of the preset scalings: '
721+
+ ', '.join(f'{key!r} ({value})' for key, value in scalings.items()) + '.'
722+
)
723+
724+
684725
@warnings._rename_kwargs('0.6', units='dest')
685-
def units(value, dest='in', axes=None, figure=None, width=True):
726+
def units(
727+
value, numeric=None, dest=None, *, axes=None, figure=None, width=True, fontsize=None
728+
):
686729
"""
687730
Convert values and lists of values between arbitrary physical units. This
688731
is used internally all over ProPlot, permitting flexible units for various
@@ -691,10 +734,11 @@ def units(value, dest='in', axes=None, figure=None, width=True):
691734
Parameters
692735
----------
693736
value : float or str or list thereof
694-
A size specifier or *list thereof*. If numeric, nothing is done.
695-
If string, it is converted to the units `dest`. The string should look
696-
like ``'123.456unit'``, where the number is the magnitude and
697-
``'unit'`` is one of the following.
737+
A size specifier or *list thereof*. If numeric, units are converted from
738+
`numeric` to `dest`. If string, units are converted to `dest` according
739+
to the string specifier. The string should look like ``'123.456unit'``,
740+
where the number is the magnitude and ``'unit'`` matches a key in
741+
the below table.
698742
699743
.. _units_table ::
700744
@@ -708,8 +752,8 @@ def units(value, dest='in', axes=None, figure=None, width=True):
708752
``'yd'`` Yards
709753
``'ft'`` Feet
710754
``'in'`` Inches
711-
``'pt'`` `Points <pt_>`_ (1/72 inches)
712755
``'pc'`` `Pica <pc_>`_ (1/6 inches)
756+
``'pt'`` `Points <pt_>`_ (1/72 inches)
713757
``'px'`` Pixels on screen, using dpi of :rcraw:`figure.dpi`
714758
``'pp'`` Pixels once printed, using dpi of :rcraw:`savefig.dpi`
715759
``'em'`` `Em square <em_>`_ for :rcraw:`font.size`
@@ -726,28 +770,28 @@ def units(value, dest='in', axes=None, figure=None, width=True):
726770
.. _em: https://en.wikipedia.org/wiki/Em_(typography)
727771
.. _en: https://en.wikipedia.org/wiki/En_(typography)
728772
773+
numeric : str, optional
774+
The units associated with numeric input. Default is inches.
729775
dest : str, optional
730-
The destination units. Default is inches, i.e. ``'in'``.
776+
The destination units. Default is the same as `numeric`.
731777
axes : `~matplotlib.axes.Axes`, optional
732778
The axes to use for scaling units that look like ``'0.1ax'``.
733779
figure : `~matplotlib.figure.Figure`, optional
734-
The figure to use for scaling units that look like ``'0.1fig'``. If
735-
``None`` we try to get the figure from ``axes.figure``.
780+
The figure to use for scaling units that look like ``'0.1fig'``.
781+
If ``None`` we try to get the figure from ``axes.figure``.
736782
width : bool, optional
737-
Whether to use the width or height for the axes and figure relative
738-
coordinates.
739-
"""
740-
# Font unit scales
741-
# NOTE: Delay font_manager import, because want to avoid rebuilding font
742-
# cache, which means import must come after TTFPATH added to environ
743-
# by register_fonts()!
744-
fontsize_small = rcParams['font.size'] # must be absolute
745-
fontsize_large = rcParams['axes.titlesize']
746-
if isinstance(fontsize_large, str):
747-
scale = mfonts.font_scalings.get(fontsize_large, 1)
748-
fontsize_large = fontsize_small * scale
749-
783+
Whether to use the width or height for the axes and figure
784+
relative coordinates.
785+
fontsize : size-spec, optional
786+
The font size in points used for scaling. Default is
787+
:rcraw:`font.size` for ``em`` and ``en`` units and
788+
:rcraw:`axes.titlesize` for ``Em`` and ``En`` units.
789+
"""
750790
# Scales for converting physical units to inches
791+
fontsize_small = _not_none(fontsize, rc_matplotlib['font.size']) # always absolute
792+
fontsize_small = _fontsize_to_pt(fontsize_small)
793+
fontsize_large = _not_none(fontsize, rc_matplotlib['axes.titlesize'])
794+
fontsize_large = _fontsize_to_pt(fontsize_large)
751795
unit_dict = UNIT_DICT.copy()
752796
unit_dict.update({
753797
'em': fontsize_small / 72.0,
@@ -758,62 +802,63 @@ def units(value, dest='in', axes=None, figure=None, width=True):
758802

759803
# Scales for converting display units to inches
760804
# WARNING: In ipython shell these take the value 'figure'
761-
if not isinstance(rcParams['figure.dpi'], str):
762-
# once generated by backend
763-
unit_dict['px'] = 1 / rcParams['figure.dpi']
764-
if not isinstance(rcParams['savefig.dpi'], str):
765-
# once 'printed' i.e. saved
766-
unit_dict['pp'] = 1 / rcParams['savefig.dpi']
805+
if not isinstance(rc_matplotlib['figure.dpi'], str):
806+
unit_dict['px'] = 1 / rc_matplotlib['figure.dpi'] # once generated by backend
807+
if not isinstance(rc_matplotlib['savefig.dpi'], str):
808+
unit_dict['pp'] = 1 / rc_matplotlib['savefig.dpi'] # once 'printed' i.e. saved
767809

768810
# Scales relative to axes and figure objects
769-
if axes is not None and hasattr(axes, 'get_size_inches'): # proplot axes
770-
unit_dict['ax'] = axes.get_size_inches()[1 - int(width)]
811+
if axes is not None and hasattr(axes, '_get_size_inches'): # proplot axes
812+
unit_dict['ax'] = axes._get_size_inches()[1 - int(width)]
771813
if figure is None:
772814
figure = getattr(axes, 'figure', None)
773-
if figure is not None and hasattr(
774-
figure, 'get_size_inches'): # proplot axes
815+
if figure is not None and hasattr(figure, 'get_size_inches'):
775816
unit_dict['fig'] = figure.get_size_inches()[1 - int(width)]
776817

777818
# Scale for converting inches to arbitrary other unit
819+
if numeric is None and dest is None:
820+
numeric = dest = 'in'
821+
elif numeric is None:
822+
numeric = dest
823+
elif dest is None:
824+
dest = numeric
825+
options = 'Valid units are ' + ', '.join(map(repr, unit_dict)) + '.'
778826
try:
779-
scale = unit_dict[dest]
827+
nscale = unit_dict[numeric]
780828
except KeyError:
781-
raise ValueError(
782-
f'Invalid destination units {dest!r}. Valid units are '
783-
+ ', '.join(map(repr, unit_dict.keys())) + '.'
784-
)
829+
raise ValueError(f'Invalid numeric units {numeric!r}. ' + options)
830+
try:
831+
dscale = unit_dict[dest]
832+
except KeyError:
833+
raise ValueError(f'Invalid destination units {dest!r}. ' + options)
785834

786835
# Convert units for each value in list
787836
result = []
788837
singleton = not np.iterable(value) or isinstance(value, str)
789838
for val in ((value,) if singleton else value):
790-
if val is None or isinstance(val, Number):
839+
# Silently pass None
840+
if val is None:
791841
result.append(val)
792842
continue
793-
elif not isinstance(val, str):
794-
raise ValueError(
795-
f'Size spec must be string or number or list thereof. '
796-
f'Got {value!r}.'
797-
)
798-
regex = NUMBER.match(val)
799-
if not regex:
800-
raise ValueError(
801-
f'Invalid size spec {val!r}. Valid units are '
802-
+ ', '.join(map(repr, unit_dict.keys())) + '.'
803-
)
804-
number, units = regex.groups() # second group is exponential
805-
try:
806-
result.append(
807-
float(number) * (unit_dict[units] / scale if units else 1)
808-
)
809-
except (KeyError, ValueError):
810-
raise ValueError(
811-
f'Invalid size spec {val!r}. Valid units are '
812-
+ ', '.join(map(repr, unit_dict.keys())) + '.'
813-
)
814-
if singleton:
815-
result = result[0]
816-
return result
843+
# Get unit string
844+
if isinstance(val, Real):
845+
number, units = val, None
846+
elif isinstance(val, str):
847+
regex = UNIT_REGEX.match(val)
848+
if regex:
849+
number, units = regex.groups() # second group is exponential
850+
else:
851+
raise ValueError(f'Invalid unit size spec {val!r}.')
852+
else:
853+
raise ValueError(f'Invalid unit size spec {val!r}.')
854+
# Convert with units
855+
if not units:
856+
result.append(float(number) * nscale / dscale)
857+
elif units in unit_dict:
858+
result.append(float(number) * unit_dict[units] / dscale)
859+
else:
860+
raise ValueError(f'Invalid input units {units!r}. ' + options)
861+
return result[0] if singleton else result
817862

818863

819864
# Deprecations

0 commit comments

Comments
 (0)