diff --git a/ultraplot/axes/geo.py b/ultraplot/axes/geo.py index 4df8ff08f..15f74d8ce 100644 --- a/ultraplot/axes/geo.py +++ b/ultraplot/axes/geo.py @@ -121,10 +121,9 @@ nsteps : int, default: :rc:`grid.nsteps` *For cartopy axes only.* The number of interpolation steps used to draw gridlines. -lonlines, latlines : optional - Aliases for `lonlocator`, `latlocator`. lonlocator, latlocator : locator-spec, optional Used to determine the longitude and latitude gridline locations. + Aliases: ``lonlines`` and ``latlines``, respectively. Passed to the `~ultraplot.constructor.Locator` constructor. Can be string, float, list of float, or `matplotlib.ticker.Locator` instance. @@ -134,16 +133,15 @@ For cartopy >= 0.18, the defaults are ``'dmslon'`` and ``'dmslat'``, which uses the same locators with ``dms=True``. This selects gridlines at nice degree-minute-second intervals when the map extent is very small. -lonlines_kw, latlines_kw : optional - Aliases for `lonlocator_kw`, `latlocator_kw`. lonlocator_kw, latlocator_kw : dict-like, optional Keyword arguments passed to the `matplotlib.ticker.Locator` class. -lonminorlocator, latminorlocator, lonminorlines, latminorlines : optional + Aliases: ``lonlines_kw`` and ``latlines_kw``, respectively. +lonminorlocator, latminorlocator : optional As with `lonlocator` and `latlocator` but for the "minor" gridlines. -lonminorlines_kw, latminorlines_kw : optional - Aliases for `lonminorlocator_kw`, `latminorlocator_kw`. + Aliases: ``lonminorlines`` and ``latminorlines``, respectively. lonminorlocator_kw, latminorlocator_kw : optional As with `lonlocator_kw`, and `latlocator_kw` but for the "minor" gridlines. + Aliases: ``lonminorlines_kw`` and ``latminorlines_kw``, respectively. lonlabels, latlabels, labels : str, bool, or sequence, :rc:`grid.labels` Whether to add non-inline longitude and latitude gridline labels, and on which sides of the map. Use the keyword `labels` to set both at once. The diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 2f404bdc4..ed2955e99 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -30,6 +30,7 @@ from . import legend as plegend from .config import rc, rc_matplotlib from .internals import ( + _alias_kwargs, _not_none, _pop_params, _pop_rc, @@ -724,21 +725,23 @@ def __repr__(self): @warnings._rename_kwargs( "0.7.0", axpad="innerpad", autoformat="uplt.rc.autoformat = {}" ) + @_alias_kwargs( + refnum=("ref",), + refaspect=("aspect",), + refwidth=("axwidth",), + refheight=("axheight",), + figwidth=("width",), + figheight=("height",), + ) def __init__( self, *, refnum=None, - ref=None, refaspect=None, - aspect=None, refwidth=None, refheight=None, - axwidth=None, - axheight=None, figwidth=None, figheight=None, - width=None, - height=None, journal=None, sharex=None, sharey=None, @@ -789,13 +792,10 @@ def __init__( ultraplot.ui.subplots matplotlib.figure.Figure """ - # Resolve aliases - refnum = _not_none(refnum=refnum, ref=ref, default=1) - refaspect = _not_none(refaspect=refaspect, aspect=aspect) - refwidth = _not_none(refwidth=refwidth, axwidth=axwidth) - refheight = _not_none(refheight=refheight, axheight=axheight) - figwidth = _not_none(figwidth=figwidth, width=width) - figheight = _not_none(figheight=figheight, height=height) + # NOTE: Keyword aliases (ref, aspect, axwidth, axheight, width, height) are + # folded into their canonical names by the @_alias_kwargs decorator. Only + # refnum keeps an explicit default, so an omitted or None value maps to 1. + refnum = _not_none(refnum, default=1) # Initialize sections figwidth, figheight = self._init_figure_size( diff --git a/ultraplot/internals/__init__.py b/ultraplot/internals/__init__.py index 16bdd4501..a49943b07 100644 --- a/ultraplot/internals/__init__.py +++ b/ultraplot/internals/__init__.py @@ -4,8 +4,6 @@ """ # Import statements -import functools -import inspect from importlib import import_module from numbers import Integral, Real @@ -18,32 +16,22 @@ from . import warnings - -def _not_none(*args, default=None, **kwargs): - """ - Return the first non-``None`` value. This is used with keyword arg aliases and - for setting default values. Use `kwargs` to issue warnings when multiple passed. - """ - first = default - if args and kwargs: - raise ValueError("_not_none can only be used with args or kwargs.") - elif args: - for arg in args: - if arg is not None: - first = arg - break - elif kwargs: - for name, arg in list(kwargs.items()): - if arg is not None: - first = arg - break - kwargs = {name: arg for name, arg in kwargs.items() if arg is not None} - if len(kwargs) > 1: - warnings._warn_ultraplot( - f"Got conflicting or duplicate keyword arguments: {kwargs}. " - "Using the first keyword argument." - ) - return first +# Keyword-argument and alias resolution helpers live in ``kwargs.py``. Re-export +# them here so that the many ``from ..internals import _not_none`` (and friends) +# imports throughout the package keep working unchanged. +from .kwargs import ( # noqa: F401 + _alias_kwargs, + _alias_maps, + _get_aliases, + _get_signature, + _kwargs_to_args, + _not_none, + _pop_kwargs, + _pop_params, + _pop_props, + _signature_cached, + _INTERNAL_POP_PARAMS, +) def _get_rc_matplotlib(): @@ -52,144 +40,6 @@ def _get_rc_matplotlib(): return rc_matplotlib -# Style aliases. We use this rather than matplotlib's normalize_kwargs and _alias_maps. -# NOTE: We add aliases 'edgewidth' and 'fillcolor' for patch edges and faces -# NOTE: Alias cannot appear as key or else _translate_kwargs will overwrite with None! -_alias_maps = { - "rgba": { - "red": ("r",), - "green": ("g",), - "blue": ("b",), - "alpha": ("a",), - }, - "hsla": { - "hue": ("h",), - "saturation": ("s", "c", "chroma"), - "luminance": ("l",), - "alpha": ("a",), - }, - "patch": { - "alpha": ( - "a", - "alphas", - "fa", - "facealpha", - "facealphas", - "fillalpha", - "fillalphas", - ), # noqa: E501 - "color": ("c", "colors"), - "edgecolor": ("ec", "edgecolors"), - "facecolor": ("fc", "facecolors", "fillcolor", "fillcolors"), - "hatch": ("h", "hatching"), - "linestyle": ("ls", "linestyles"), - "linewidth": ("lw", "linewidths", "ew", "edgewidth", "edgewidths"), - "zorder": ("z", "zorders"), - }, - "line": { # copied from lines.py but expanded to include plurals - "alpha": ("a", "alphas"), - "color": ("c", "colors"), - "dashes": ("d", "dash"), - "drawstyle": ("ds", "drawstyles"), - "fillstyle": ("fs", "fillstyles", "mfs", "markerfillstyle", "markerfillstyles"), - "linestyle": ("ls", "linestyles"), - "linewidth": ("lw", "linewidths"), - "marker": ("m", "markers"), - "markersize": ("s", "ms", "markersizes"), # WARNING: no 'sizes' here for barb - "markeredgewidth": ("ew", "edgewidth", "edgewidths", "mew", "markeredgewidths"), - "markeredgecolor": ("ec", "edgecolor", "edgecolors", "mec", "markeredgecolors"), - "markerfacecolor": ( - "fc", - "facecolor", - "facecolors", - "fillcolor", - "fillcolors", - "mc", - "markercolor", - "markercolors", - "mfc", - "markerfacecolors", - ), - "zorder": ("z", "zorders"), - }, - "collection": { # WARNING: face color ignored for line collections - "alpha": ("a", "alphas"), # WARNING: collections and contours use singular! - "colors": ("c", "color"), - "edgecolors": ("ec", "edgecolor", "mec", "markeredgecolor", "markeredgecolors"), - "facecolors": ( - "fc", - "facecolor", - "fillcolor", - "fillcolors", - "mc", - "markercolor", - "markercolors", - "mfc", - "markerfacecolor", - "markerfacecolors", # noqa: E501 - ), - "linestyles": ("ls", "linestyle"), - "linewidths": ( - "lw", - "linewidth", - "ew", - "edgewidth", - "edgewidths", - "mew", - "markeredgewidth", - "markeredgewidths", - ), # noqa: E501 - "marker": ("m", "markers"), - "sizes": ("s", "ms", "markersize", "markersizes"), - "zorder": ("z", "zorders"), - }, - "text": { - "color": ("c", "fontcolor"), # NOTE: see text.py source code - "fontfamily": ("family", "name", "fontname"), - "fontsize": ("size",), - "fontstretch": ("stretch",), - "fontstyle": ("style",), - "fontvariant": ("variant",), - "fontweight": ("weight",), - "fontproperties": ("fp", "font", "font_properties"), - "zorder": ("z", "zorders"), - }, -} - - -_INTERNAL_POP_PARAMS = frozenset( - { - "default_cmap", - "default_discrete", - "inbounds", - "plot_contours", - "plot_lines", - "skip_autolev", - "to_centers", - } -) - - -@functools.lru_cache(maxsize=256) -def _signature_cached(func): - """ - Cache inspect.signature lookups for hot utility paths. - """ - return inspect.signature(func) - - -def _get_signature(func): - """ - Return a signature, normalizing bound methods to their underlying function. - """ - key = getattr(func, "__func__", func) - try: - return _signature_cached(key) - except TypeError: - # Some callable objects may be unhashable for lru_cache keys. - return inspect.signature(func) - - _LAZY_ATTRS = { "benchmarks": ("benchmarks", None), "context": ("context", None), @@ -207,117 +57,6 @@ def _get_signature(func): } -def _get_aliases(category, *keys): - """ - Get all available aliases. - """ - aliases = [] - for key in keys: - aliases.append(key) - aliases.extend(_alias_maps[category][key]) - return tuple(aliases) - - -def _kwargs_to_args(options, *args, allow_extra=False, **kwargs): - """ - Translate keyword arguments to positional arguments. Permit omitted - arguments so that plotting functions can infer values. - """ - nargs, nopts = len(args), len(options) - if nargs > nopts and not allow_extra: - raise ValueError(f"Expected up to {nopts} positional arguments. Got {nargs}.") - args = list(args) # WARNING: Axes.text() expects return type of list - args.extend(None for _ in range(nopts - nargs)) # fill missing args - for idx, keys in enumerate(options): - if isinstance(keys, str): - keys = (keys,) - opts = {} - if args[idx] is not None: # positional args have first priority - opts[keys[0] + "_positional"] = args[idx] - for key in keys: # keyword args - opts[key] = kwargs.pop(key, None) - args[idx] = _not_none(**opts) # may reassign None - return args, kwargs - - -def _pop_kwargs(kwargs, *keys, **aliases): - """ - Pop the input properties and return them in a new dictionary. - """ - output = {} - aliases.update({key: () for key in keys}) - for key, aliases in aliases.items(): - aliases = (aliases,) if isinstance(aliases, str) else aliases - opts = {key: kwargs.pop(key, None) for key in (key, *aliases)} - value = _not_none(**opts) - if value is not None: - output[key] = value - return output - - -def _pop_params(kwargs, *funcs, ignore_internal=False): - """ - Pop parameters of the input functions or methods. - """ - output = {} - for func in funcs: - if isinstance(func, inspect.Signature): - sig = func - elif callable(func): - sig = _get_signature(func) - elif func is None: - continue - else: - raise RuntimeError(f"Internal error. Invalid function {func!r}.") - for key in sig.parameters: - value = kwargs.pop(key, None) - if ignore_internal and key in _INTERNAL_POP_PARAMS: - continue - if value is not None: - output[key] = value - return output - - -def _pop_props(input, *categories, prefix=None, ignore=None, skip=None): - """ - Pop the registered properties and return them in a new dictionary. - """ - output = {} - skip = skip or () - ignore = ignore or () - if isinstance(skip, str): # e.g. 'sizes' for barbs() input - skip = (skip,) - if isinstance(ignore, str): # e.g. 'marker' to ignore marker properties - ignore = (ignore,) - prefix = prefix or "" # e.g. 'box' for boxlw, boxlinewidth, etc. - for category in categories: - for key, aliases in _alias_maps[category].items(): - if isinstance(aliases, str): - aliases = (aliases,) - opts = { - prefix + alias: input.pop(prefix + alias, None) - for alias in (key, *aliases) - if alias not in skip - } - prop = _not_none(**opts) - if prop is None: - continue - if any(string in key for string in ignore): - warnings._warn_ultraplot(f"Ignoring property {key}={prop!r}.") - continue - if isinstance(prop, str): # ad-hoc unit conversion - if key in ("fontsize",): - from ..utils import _fontsize_to_pt - - prop = _fontsize_to_pt(prop) - if key in ("linewidth", "linewidths", "markersize"): - from ..utils import units - - prop = units(prop, "pt") - output[key] = prop - return output - - def _pop_rc(src, *, ignore_conflicts=True): """ Pop the rc setting names and mode for a `~Configurator.context` block. diff --git a/ultraplot/internals/docstring.py b/ultraplot/internals/docstring.py index 13bdff625..205e92bbf 100644 --- a/ultraplot/internals/docstring.py +++ b/ultraplot/internals/docstring.py @@ -179,62 +179,72 @@ def __setitem__(self, key, value): _snippet_manager["units.in"] = _units_docstring.format(units="inches") _snippet_manager["units.em"] = _units_docstring.format(units="em-widths") + # Style docstrings # NOTE: These are needed in a few different places -_line_docstring = """ -lw, linewidth, linewidths : unit-spec, default: :rc:`lines.linewidth` - The width of the line(s). +def _aliases_note(*names): + """ + Render a compact ``Aliases: ...`` note for a style parameter. The canonical + name leads the numpydoc field; the common documented synonyms go here so the + parameter reads cleanly instead of opening with a pile of alias names. + """ + return "Aliases: " + ", ".join(f"``{name}``" for name in names) + "." + + +_line_docstring = f""" +linewidth : unit-spec, default: :rc:`lines.linewidth` + The width of the line(s). {_aliases_note("lw", "linewidths")} %(units.pt)s -ls, linestyle, linestyles : str, default: :rc:`lines.linestyle` - The style of the line(s). -c, color, colors : color-spec, optional - The color of the line(s). The property `cycle` is used by default. -a, alpha, alphas : float, optional - The opacity of the line(s). Inferred from `color` by default. +linestyle : str, default: :rc:`lines.linestyle` + The style of the line(s). {_aliases_note("ls", "linestyles")} +color : color-spec, optional + The color of the line(s). The property `cycle` is used by default. {_aliases_note("c", "colors")} +alpha : float, optional + The opacity of the line(s). Inferred from `color` by default. {_aliases_note("a", "alphas")} """ -_patch_docstring = """ -lw, linewidth, linewidths : unit-spec, default: :rc:`patch.linewidth` - The edge width of the patch(es). +_patch_docstring = f""" +linewidth : unit-spec, default: :rc:`patch.linewidth` + The edge width of the patch(es). {_aliases_note("lw", "linewidths")} %(units.pt)s -ls, linestyle, linestyles : str, default: '-' - The edge style of the patch(es). -ec, edgecolor, edgecolors : color-spec, default: '{edgecolor}' - The edge color of the patch(es). -fc, facecolor, facecolors, fillcolor, fillcolors : color-spec, optional - The face color of the patch(es). The property `cycle` is used by default. -a, alpha, alphas : float, optional - The opacity of the patch(es). Inferred from `facecolor` and `edgecolor` by default. +linestyle : str, default: '-' + The edge style of the patch(es). {_aliases_note("ls", "linestyles")} +edgecolor : color-spec, default: '{{edgecolor}}' + The edge color of the patch(es). {_aliases_note("ec", "edgecolors")} +facecolor : color-spec, optional + The face color of the patch(es). The property `cycle` is used by default. {_aliases_note("fc", "facecolors", "fillcolor", "fillcolors")} +alpha : float, optional + The opacity of the patch(es). Inferred from `facecolor` and `edgecolor` by default. {_aliases_note("a", "alphas")} """ -_pcolor_collection_docstring = """ -lw, linewidth, linewidths : unit-spec, default: 0.3 - The width of lines between grid boxes. +_pcolor_collection_docstring = f""" +linewidths : unit-spec, default: 0.3 + The width of lines between grid boxes. {_aliases_note("lw", "linewidth")} %(units.pt)s -ls, linestyle, linestyles : str, default: '-' - The style of lines between grid boxes. -ec, edgecolor, edgecolors : color-spec, default: 'k' - The color of lines between grid boxes. -a, alpha, alphas : float, optional - The opacity of the grid boxes. Inferred from `cmap` by default. +linestyles : str, default: '-' + The style of lines between grid boxes. {_aliases_note("ls", "linestyle")} +edgecolors : color-spec, default: 'k' + The color of lines between grid boxes. {_aliases_note("ec", "edgecolor")} +alpha : float, optional + The opacity of the grid boxes. Inferred from `cmap` by default. {_aliases_note("a", "alphas")} """ -_contour_collection_docstring = """ -lw, linewidth, linewidths : unit-spec, default: 0.3 or :rc:`lines.linewidth` +_contour_collection_docstring = f""" +linewidths : unit-spec, default: 0.3 or :rc:`lines.linewidth` The width of the line contours. Default is ``0.3`` when adding to filled contours - or :rc:`lines.linewidth` otherwise. %(units.pt)s -ls, linestyle, linestyles : str, default: '-' or :rc:`contour.negative_linestyle` + or :rc:`lines.linewidth` otherwise. {_aliases_note("lw", "linewidth")} %(units.pt)s +linestyles : str, default: '-' or :rc:`contour.negative_linestyle` The style of the line contours. Default is ``'-'`` for positive contours and - :rcraw:`contour.negative_linestyle` for negative contours. -ec, edgecolor, edgecolors : color-spec, default: 'k' or inferred + :rcraw:`contour.negative_linestyle` for negative contours. {_aliases_note("ls", "linestyle")} +edgecolors : color-spec, default: 'k' or inferred The color of the line contours. Default is ``'k'`` when adding to filled contours - or inferred from `color` or `cmap` otherwise. -a, alpha, alpha : float, optional - The opacity of the contours. Inferred from `edgecolor` by default. + or inferred from `color` or `cmap` otherwise. {_aliases_note("ec", "edgecolor")} +alpha : float, optional + The opacity of the contours. Inferred from `edgecolors` by default. {_aliases_note("a", "alphas")} """ -_text_docstring = """ -name, fontname, family, fontfamily : str, optional +_text_docstring = f""" +fontfamily : str, optional The font typeface name (e.g., ``'Fira Math'``) or font family name (e.g., - ``'serif'``). Matplotlib falls back to the system default if not found. -size, fontsize : unit-spec or str, optional - The font size. %(units.pt)s + ``'serif'``). Matplotlib falls back to the system default if not found. {_aliases_note("family", "name", "fontname")} +fontsize : unit-spec or str, optional + The font size. {_aliases_note("size")} %(units.pt)s This can also be a string indicating some scaling relative to :rcraw:`font.size`. The sizes and scalings are shown below. The scalings ``'med'``, ``'med-small'``, and ``'med-large'`` are diff --git a/ultraplot/internals/kwargs.py b/ultraplot/internals/kwargs.py new file mode 100644 index 000000000..82396b837 --- /dev/null +++ b/ultraplot/internals/kwargs.py @@ -0,0 +1,345 @@ +#!/usr/bin/env python3 +""" +Keyword-argument and alias resolution utilities. + +These helpers centralize how ultraplot resolves keyword aliases, folds synonym +keywords into canonical names, and pops parameters/properties out of ``**kwargs``. +They live in their own module (rather than the ``internals`` grab-bag) because +they form a single cohesive concern and are imported throughout the package. +""" + +import functools +import inspect + +from . import warnings + +__all__ = [ + "_not_none", + "_alias_kwargs", + "_alias_maps", + "_get_aliases", + "_kwargs_to_args", + "_pop_kwargs", + "_pop_params", + "_pop_props", +] + + +def _not_none(*args, default=None, **kwargs): + """ + Return the first non-``None`` value. This is used with keyword arg aliases and + for setting default values. Use `kwargs` to issue warnings when multiple passed. + """ + first = default + if args and kwargs: + raise ValueError("_not_none can only be used with args or kwargs.") + elif args: + for arg in args: + if arg is not None: + first = arg + break + elif kwargs: + for name, arg in list(kwargs.items()): + if arg is not None: + first = arg + break + kwargs = {name: arg for name, arg in kwargs.items() if arg is not None} + if len(kwargs) > 1: + warnings._warn_ultraplot( + f"Got conflicting or duplicate keyword arguments: {kwargs}. " + "Using the first keyword argument." + ) + return first + + +def _alias_kwargs(**aliases): + """ + Fold keyword-argument aliases into their canonical names before a call. + + Each keyword maps a canonical parameter name to a tuple of accepted synonyms, + e.g. ``@_alias_kwargs(figwidth=("width",), refnum=("ref",))``. A synonym passed + by the caller is renamed to its canonical name. Passing a canonical together + with a synonym (or two synonyms) warns and keeps the canonical / first value, + matching the precedence and warning of `_not_none`. This replaces the repetitive + ``x = _not_none(x=x, y=y)`` boilerplate at the top of aliased functions. + + This handles keyword aliases only: a canonical argument passed *positionally* + is not deduplicated against its synonyms, and a synonym must not shadow a + different real parameter of the wrapped function. + """ + # Map each synonym to its canonical name; synonyms are tried in declared order + # so the first non-``None`` one wins, exactly like `_not_none`. + lookup = {syn: canon for canon, syns in aliases.items() for syn in syns} + + def decorator(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + for syn, canon in lookup.items(): + if syn not in kwargs: + continue + value = kwargs.pop(syn) + if value is None: + continue + if kwargs.get(canon) is None: + kwargs[canon] = value + else: + # ``canon`` already holds an earlier value (from the canonical + # keyword or a prior synonym); keep it and drop this synonym. + warnings._warn_ultraplot( + f"Got conflicting or duplicate values for {canon!r} " + f"(ignoring alias {syn!r}). Using the first value." + ) + return func(*args, **kwargs) + + return wrapper + + return decorator + + +# Style aliases. We use this rather than matplotlib's normalize_kwargs and _alias_maps. +# NOTE: We add aliases 'edgewidth' and 'fillcolor' for patch edges and faces +# NOTE: Alias cannot appear as key or else _translate_kwargs will overwrite with None! +_alias_maps = { + "rgba": { + "red": ("r",), + "green": ("g",), + "blue": ("b",), + "alpha": ("a",), + }, + "hsla": { + "hue": ("h",), + "saturation": ("s", "c", "chroma"), + "luminance": ("l",), + "alpha": ("a",), + }, + "patch": { + "alpha": ( + "a", + "alphas", + "fa", + "facealpha", + "facealphas", + "fillalpha", + "fillalphas", + ), # noqa: E501 + "color": ("c", "colors"), + "edgecolor": ("ec", "edgecolors"), + "facecolor": ("fc", "facecolors", "fillcolor", "fillcolors"), + "hatch": ("h", "hatching"), + "linestyle": ("ls", "linestyles"), + "linewidth": ("lw", "linewidths", "ew", "edgewidth", "edgewidths"), + "zorder": ("z", "zorders"), + }, + "line": { # copied from lines.py but expanded to include plurals + "alpha": ("a", "alphas"), + "color": ("c", "colors"), + "dashes": ("d", "dash"), + "drawstyle": ("ds", "drawstyles"), + "fillstyle": ("fs", "fillstyles", "mfs", "markerfillstyle", "markerfillstyles"), + "linestyle": ("ls", "linestyles"), + "linewidth": ("lw", "linewidths"), + "marker": ("m", "markers"), + "markersize": ("s", "ms", "markersizes"), # WARNING: no 'sizes' here for barb + "markeredgewidth": ("ew", "edgewidth", "edgewidths", "mew", "markeredgewidths"), + "markeredgecolor": ("ec", "edgecolor", "edgecolors", "mec", "markeredgecolors"), + "markerfacecolor": ( + "fc", + "facecolor", + "facecolors", + "fillcolor", + "fillcolors", + "mc", + "markercolor", + "markercolors", + "mfc", + "markerfacecolors", + ), + "zorder": ("z", "zorders"), + }, + "collection": { # WARNING: face color ignored for line collections + "alpha": ("a", "alphas"), # WARNING: collections and contours use singular! + "colors": ("c", "color"), + "edgecolors": ("ec", "edgecolor", "mec", "markeredgecolor", "markeredgecolors"), + "facecolors": ( + "fc", + "facecolor", + "fillcolor", + "fillcolors", + "mc", + "markercolor", + "markercolors", + "mfc", + "markerfacecolor", + "markerfacecolors", # noqa: E501 + ), + "linestyles": ("ls", "linestyle"), + "linewidths": ( + "lw", + "linewidth", + "ew", + "edgewidth", + "edgewidths", + "mew", + "markeredgewidth", + "markeredgewidths", + ), # noqa: E501 + "marker": ("m", "markers"), + "sizes": ("s", "ms", "markersize", "markersizes"), + "zorder": ("z", "zorders"), + }, + "text": { + "color": ("c", "fontcolor"), # NOTE: see text.py source code + "fontfamily": ("family", "name", "fontname"), + "fontsize": ("size",), + "fontstretch": ("stretch",), + "fontstyle": ("style",), + "fontvariant": ("variant",), + "fontweight": ("weight",), + "fontproperties": ("fp", "font", "font_properties"), + "zorder": ("z", "zorders"), + }, +} + + +_INTERNAL_POP_PARAMS = frozenset( + { + "default_cmap", + "default_discrete", + "inbounds", + "plot_contours", + "plot_lines", + "skip_autolev", + "to_centers", + } +) + + +@functools.lru_cache(maxsize=256) +def _signature_cached(func): + """ + Cache inspect.signature lookups for hot utility paths. + """ + return inspect.signature(func) + + +def _get_signature(func): + """ + Return a signature, normalizing bound methods to their underlying function. + """ + key = getattr(func, "__func__", func) + try: + return _signature_cached(key) + except TypeError: + # Some callable objects may be unhashable for lru_cache keys. + return inspect.signature(func) + + +def _get_aliases(category, *keys): + """ + Get all available aliases. + """ + aliases = [] + for key in keys: + aliases.append(key) + aliases.extend(_alias_maps[category][key]) + return tuple(aliases) + + +def _kwargs_to_args(options, *args, allow_extra=False, **kwargs): + """ + Translate keyword arguments to positional arguments. Permit omitted + arguments so that plotting functions can infer values. + """ + nargs, nopts = len(args), len(options) + if nargs > nopts and not allow_extra: + raise ValueError(f"Expected up to {nopts} positional arguments. Got {nargs}.") + args = list(args) # WARNING: Axes.text() expects return type of list + args.extend(None for _ in range(nopts - nargs)) # fill missing args + for idx, keys in enumerate(options): + if isinstance(keys, str): + keys = (keys,) + opts = {} + if args[idx] is not None: # positional args have first priority + opts[keys[0] + "_positional"] = args[idx] + for key in keys: # keyword args + opts[key] = kwargs.pop(key, None) + args[idx] = _not_none(**opts) # may reassign None + return args, kwargs + + +def _pop_kwargs(kwargs, *keys, **aliases): + """ + Pop the input properties and return them in a new dictionary. + """ + output = {} + aliases.update({key: () for key in keys}) + for key, aliases in aliases.items(): + aliases = (aliases,) if isinstance(aliases, str) else aliases + opts = {key: kwargs.pop(key, None) for key in (key, *aliases)} + value = _not_none(**opts) + if value is not None: + output[key] = value + return output + + +def _pop_params(kwargs, *funcs, ignore_internal=False): + """ + Pop parameters of the input functions or methods. + """ + output = {} + for func in funcs: + if isinstance(func, inspect.Signature): + sig = func + elif callable(func): + sig = _get_signature(func) + elif func is None: + continue + else: + raise RuntimeError(f"Internal error. Invalid function {func!r}.") + for key in sig.parameters: + value = kwargs.pop(key, None) + if ignore_internal and key in _INTERNAL_POP_PARAMS: + continue + if value is not None: + output[key] = value + return output + + +def _pop_props(input, *categories, prefix=None, ignore=None, skip=None): + """ + Pop the registered properties and return them in a new dictionary. + """ + output = {} + skip = skip or () + ignore = ignore or () + if isinstance(skip, str): # e.g. 'sizes' for barbs() input + skip = (skip,) + if isinstance(ignore, str): # e.g. 'marker' to ignore marker properties + ignore = (ignore,) + prefix = prefix or "" # e.g. 'box' for boxlw, boxlinewidth, etc. + for category in categories: + for key, aliases in _alias_maps[category].items(): + if isinstance(aliases, str): + aliases = (aliases,) + opts = { + prefix + alias: input.pop(prefix + alias, None) + for alias in (key, *aliases) + if alias not in skip + } + prop = _not_none(**opts) + if prop is None: + continue + if any(string in key for string in ignore): + warnings._warn_ultraplot(f"Ignoring property {key}={prop!r}.") + continue + if isinstance(prop, str): # ad-hoc unit conversion + if key in ("fontsize",): + from ..utils import _fontsize_to_pt + + prop = _fontsize_to_pt(prop) + if key in ("linewidth", "linewidths", "markersize"): + from ..utils import units + + prop = units(prop, "pt") + output[key] = prop + return output diff --git a/ultraplot/tests/test_docstring_helpers.py b/ultraplot/tests/test_docstring_helpers.py new file mode 100644 index 000000000..17d826d49 --- /dev/null +++ b/ultraplot/tests/test_docstring_helpers.py @@ -0,0 +1,63 @@ +"""Tests for the shared style docstrings in ``ultraplot.internals.docstring``.""" + +import ultraplot as uplt +from ultraplot.internals import docstring + + +def test_style_snippets_lead_with_canonical_name() -> None: + # The shared style fields should lead with the canonical parameter name and + # relegate synonyms to a trailing "Aliases:" note, rather than opening the + # numpydoc field with a pile of alias names. + line = docstring._snippet_manager["artist.line"] + assert line.lstrip().startswith("linewidth : unit-spec") + assert "Aliases: ``lw``, ``linewidths``." in line + assert "The color of the line(s)" in line + assert "Aliases: ``c``, ``colors``." in line + # The old alias-pile header must be gone. + assert "lw, linewidth, linewidths :" not in line + + +def test_collection_snippets_lead_with_registry_canonical_names() -> None: + for name in ("artist.collection_pcolor", "artist.collection_contour"): + snippet = docstring._snippet_manager[name] + assert snippet.lstrip().startswith("linewidths : unit-spec") + assert "\nlinestyles : str" in snippet + assert "\nedgecolors : color-spec" in snippet + assert "Aliases: ``lw``, ``linewidth``." in snippet + assert "Aliases: ``ls``, ``linestyle``." in snippet + assert "Aliases: ``ec``, ``edgecolor``." in snippet + + +def test_contour_alpha_alias_typo_fixed() -> None: + # Previously the contour snippet listed ``a, alpha, alpha`` (duplicate typo). + contour = docstring._snippet_manager["artist.collection_contour"] + assert "``a``, ``alphas``." in contour + assert "a, alpha, alpha" not in contour + + +def test_patch_edgecolor_placeholder_still_fills() -> None: + # The patch snippet keeps its ``{edgecolor}`` placeholder for the later + # ``.format(...)`` call; both registered variants must resolve it. + assert "default: 'none'" in docstring._snippet_manager["artist.patch"] + assert "default: 'black'" in docstring._snippet_manager["artist.patch_black"] + + +def test_method_docstring_fully_substituted() -> None: + # A plotting method that pulls in %(artist.line)s must render without any + # leftover unfilled snippet markers. + doc = uplt.axes.PlotAxes.line.__doc__ or "" + assert "linewidth : unit-spec" in doc + assert "Aliases: ``lw``" in doc + assert "%(artist" not in doc + + +def test_geo_format_folds_alias_entries() -> None: + # The geo format docstring folded its standalone "Aliases for ..." blocks + # into trailing notes on the canonical locator entries. + geo = docstring._snippet_manager["geo.format"] + assert "Aliases for" not in geo + assert "lonlocator, latlocator : locator-spec" in geo + assert "Aliases: ``lonlines`` and ``latlines``, respectively." in geo + assert ( + "Aliases: ``lonminorlines_kw`` and ``latminorlines_kw``, respectively." in geo + ) diff --git a/ultraplot/tests/test_figure.py b/ultraplot/tests/test_figure.py index e5e917cf5..08257045d 100644 --- a/ultraplot/tests/test_figure.py +++ b/ultraplot/tests/test_figure.py @@ -922,6 +922,36 @@ def test_refaspect_as_tuple(): uplt.close(fig) +def test_figure_keyword_aliases() -> None: + """Figure.__init__ aliases are folded by the @_alias_kwargs decorator.""" + # ref -> refnum, with the canonical default (1) preserved. + assert uplt.figure(ref=3)._refnum == 3 + assert uplt.figure(refnum=2)._refnum == 2 + assert uplt.figure()._refnum == 1 + # An explicit None must still collapse to the default of 1 (as _not_none did). + assert uplt.figure(refnum=None)._refnum == 1 + + # width/height -> figwidth/figheight. + np.testing.assert_allclose(uplt.figure(width=6, height=3).get_size_inches(), (6, 3)) + np.testing.assert_allclose( + uplt.figure(figwidth=6, figheight=3).get_size_inches(), (6, 3) + ) + + # axwidth -> refwidth still builds a laid-out figure. + fig, ax = uplt.subplots(axwidth=2) + fig.canvas.draw() + uplt.close("all") + + +def test_figure_alias_conflict_warns() -> None: + with warnings.catch_warnings(record=True) as record: + warnings.simplefilter("always") + fig = uplt.figure(refnum=1, ref=5) + assert fig._refnum == 1 # canonical wins + assert any("conflicting" in str(w.message).lower() for w in record) + uplt.close(fig) + + def test_clear_drops_subplot_state(): """ clear() must forget the subplots it destroyed. Otherwise the figure keeps diff --git a/ultraplot/tests/test_kwargs_helpers.py b/ultraplot/tests/test_kwargs_helpers.py new file mode 100644 index 000000000..853131d78 --- /dev/null +++ b/ultraplot/tests/test_kwargs_helpers.py @@ -0,0 +1,80 @@ +"""Tests for the keyword-argument / alias helpers in ``ultraplot.internals.kwargs``.""" + +import warnings + +from ultraplot import internals +from ultraplot.internals import kwargs as ikwargs + + +def test_kwargs_helpers_reexported_from_package() -> None: + # Moving the helpers into internals/kwargs.py must not change the import + # surface: the package still re-exports the same objects. + for name in ( + "_not_none", + "_alias_kwargs", + "_alias_maps", + "_get_aliases", + "_kwargs_to_args", + "_pop_kwargs", + "_pop_params", + "_pop_props", + ): + assert getattr(internals, name) is getattr(ikwargs, name) + + +def test_not_none_first_non_none() -> None: + assert ikwargs._not_none(None, None, 3, 4) == 3 + assert ikwargs._not_none(default=7) == 7 + assert ikwargs._not_none(a=None, b=5) == 5 + + +def test_not_none_warns_on_conflicting_kwargs() -> None: + with warnings.catch_warnings(record=True) as record: + warnings.simplefilter("always") + value = ikwargs._not_none(a=1, b=2) + assert value == 1 # first non-None wins + assert any("conflicting" in str(w.message).lower() for w in record) + + +def test_alias_kwargs_folds_synonym_to_canonical() -> None: + @ikwargs._alias_kwargs(figwidth=("width",), refnum=("ref",)) + def func(*, refnum=1, figwidth=None, **kwargs): + return refnum, figwidth, kwargs + + assert func() == (1, None, {}) # signature defaults untouched + assert func(width=5) == (1, 5, {}) # synonym folded to canonical + assert func(ref=2, figwidth=3) == (2, 3, {}) # mix of alias + canonical + assert func(other=9) == (1, None, {"other": 9}) # unrelated kwargs pass through + + +def test_alias_kwargs_none_synonym_defers_to_default() -> None: + @ikwargs._alias_kwargs(figwidth=("width",)) + def func(*, figwidth=42): + return figwidth + + # Explicitly passing the synonym as None must not override the default. + assert func(width=None) == 42 + + +def test_alias_kwargs_conflict_keeps_canonical_and_warns() -> None: + @ikwargs._alias_kwargs(figwidth=("width",)) + def func(*, figwidth=None): + return figwidth + + with warnings.catch_warnings(record=True) as record: + warnings.simplefilter("always") + value = func(figwidth=1, width=2) + assert value == 1 # canonical wins, matching _not_none precedence + assert any("conflicting" in str(w.message).lower() for w in record) + + +def test_alias_kwargs_multiple_synonyms_first_wins() -> None: + @ikwargs._alias_kwargs(saturation=("s", "c", "chroma")) + def func(*, saturation=None): + return saturation + + assert func(chroma=0.5) == 0.5 + with warnings.catch_warnings(record=True): + warnings.simplefilter("always") + # Two synonyms: the first one encountered in declaration order wins. + assert func(s=0.1, chroma=0.9) == 0.1