From c33b97f85044ef3adfa8a3fadaeaa6ff2796ea4c Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 17 Jul 2026 21:54:28 +1000 Subject: [PATCH 1/6] Extract keyword/alias helpers into internals.kwargs and add _alias_kwargs The internals package __init__ had grown into a grab-bag mixing keyword/alias resolution with rc, location, and lazy-import helpers. Move the cohesive keyword-argument cluster (_not_none, _alias_maps, the _pop_* poppers, and the signature helpers) into a dedicated internals/kwargs.py, and re-export them from the package so every existing 'from ..internals import _not_none' keeps working unchanged. Add a new _alias_kwargs decorator that folds synonym keywords into their canonical names with the same precedence and conflict warning as _not_none, replacing the repetitive 'x = _not_none(x=x, y=y)' boilerplate at the top of aliased functions. --- ultraplot/internals/__init__.py | 293 ++------------------- ultraplot/internals/kwargs.py | 339 +++++++++++++++++++++++++ ultraplot/tests/test_kwargs_helpers.py | 80 ++++++ 3 files changed, 435 insertions(+), 277 deletions(-) create mode 100644 ultraplot/internals/kwargs.py create mode 100644 ultraplot/tests/test_kwargs_helpers.py 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/kwargs.py b/ultraplot/internals/kwargs.py new file mode 100644 index 000000000..00331da66 --- /dev/null +++ b/ultraplot/internals/kwargs.py @@ -0,0 +1,339 @@ +#!/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. + """ + # 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: + warnings._warn_ultraplot( + f"Got conflicting or duplicate keyword arguments " + f"{canon!r} and alias {syn!r}. Using {canon!r}." + ) + 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_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 From a91b1184ad0f2acdc82ac95676c155d111510692 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 17 Jul 2026 21:58:13 +1000 Subject: [PATCH 2/6] Use @_alias_kwargs for Figure.__init__ keyword aliases Replace the six-line block of _not_none alias resolutions at the top of Figure.__init__ with a single @_alias_kwargs table and drop the redundant alias parameters (ref, aspect, axwidth, axheight, width, height) from the signature; they are now folded into their canonical names before the body runs. Behavior is unchanged, including the conflict warning and the refnum default of 1. This is the first real adopter of the new decorator and the pattern for retiring the scattered _not_none alias boilerplate elsewhere. --- ultraplot/figure.py | 27 ++++++++++++--------------- ultraplot/tests/test_figure.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 15 deletions(-) diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 2f404bdc4..683d5c91f 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, + refnum=1, 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,15 +792,9 @@ 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) - # Initialize sections + # NOTE: Keyword aliases (ref, aspect, axwidth, axheight, width, height) are + # folded into their canonical names by the @_alias_kwargs decorator. figwidth, figheight = self._init_figure_size( refnum, refaspect, refwidth, refheight, figwidth, figheight, journal ) diff --git a/ultraplot/tests/test_figure.py b/ultraplot/tests/test_figure.py index e5e917cf5..d049464fc 100644 --- a/ultraplot/tests/test_figure.py +++ b/ultraplot/tests/test_figure.py @@ -922,6 +922,34 @@ 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 + + # 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 From a060074beb999cb86571b82844a214db5c9dc510 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 17 Jul 2026 22:14:45 +1000 Subject: [PATCH 3/6] Preserve refnum=None default and clarify _alias_kwargs conflicts Adversarial review found that dropping the six _not_none lines let an explicit Figure(refnum=None) survive as None instead of collapsing to 1 the way _not_none(refnum=refnum, ref=ref, default=1) did, which loses the reference axes and changes the figure size. Restore the canonical None default and coerce it with a single _not_none call, and cover the case in the tests. Also reword the decorator's conflict warning so it no longer implies the canonical keyword was passed when two synonyms collide, and document that it handles keyword aliases only. --- ultraplot/figure.py | 9 ++++++--- ultraplot/internals/kwargs.py | 10 ++++++++-- ultraplot/tests/test_figure.py | 2 ++ 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 683d5c91f..ed2955e99 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -736,7 +736,7 @@ def __repr__(self): def __init__( self, *, - refnum=1, + refnum=None, refaspect=None, refwidth=None, refheight=None, @@ -792,9 +792,12 @@ def __init__( ultraplot.ui.subplots matplotlib.figure.Figure """ - # Initialize sections # NOTE: Keyword aliases (ref, aspect, axwidth, axheight, width, height) are - # folded into their canonical names by the @_alias_kwargs decorator. + # 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( refnum, refaspect, refwidth, refheight, figwidth, figheight, journal ) diff --git a/ultraplot/internals/kwargs.py b/ultraplot/internals/kwargs.py index 00331da66..82396b837 100644 --- a/ultraplot/internals/kwargs.py +++ b/ultraplot/internals/kwargs.py @@ -62,6 +62,10 @@ def _alias_kwargs(**aliases): 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`. @@ -79,9 +83,11 @@ def wrapper(*args, **kwargs): 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 keyword arguments " - f"{canon!r} and alias {syn!r}. Using {canon!r}." + f"Got conflicting or duplicate values for {canon!r} " + f"(ignoring alias {syn!r}). Using the first value." ) return func(*args, **kwargs) diff --git a/ultraplot/tests/test_figure.py b/ultraplot/tests/test_figure.py index d049464fc..08257045d 100644 --- a/ultraplot/tests/test_figure.py +++ b/ultraplot/tests/test_figure.py @@ -928,6 +928,8 @@ def test_figure_keyword_aliases() -> None: 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)) From 2b789f10bf2f471b6f4e8fda1d2f8a76cbcad69f Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 17 Jul 2026 22:49:45 +1000 Subject: [PATCH 4/6] Lead shared style docstrings with the canonical parameter name The reusable style-parameter snippets (line, patch, pcolor/contour collections, text) opened each numpydoc field with a pile of alias names like 'lw, linewidth, linewidths :', which every plotting method inherited and which made the parameter tables hard to scan. Lead each field with the canonical name and move the common documented synonyms into a short trailing 'Aliases:' note, built through a small _aliases_note helper. This also fixes a drifted typo in the contour snippet ('a, alpha, alpha' -> 'a, alpha, alphas'). Behavior is unchanged; all synonyms are still accepted at runtime via _pop_props. --- ultraplot/internals/docstring.py | 96 +++++++++++++---------- ultraplot/tests/test_docstring_helpers.py | 40 ++++++++++ 2 files changed, 93 insertions(+), 43 deletions(-) create mode 100644 ultraplot/tests/test_docstring_helpers.py diff --git a/ultraplot/internals/docstring.py b/ultraplot/internals/docstring.py index 13bdff625..52f35cd00 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""" +linewidth : unit-spec, default: 0.3 + The width of lines between grid boxes. {_aliases_note("lw", "linewidths")} %(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. +linestyle : str, default: '-' + The style of lines between grid boxes. {_aliases_note("ls", "linestyles")} +edgecolor : color-spec, default: 'k' + The color of lines between grid boxes. {_aliases_note("ec", "edgecolors")} +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""" +linewidth : 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", "linewidths")} %(units.pt)s +linestyle : 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", "linestyles")} +edgecolor : 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", "edgecolors")} +alpha : float, optional + The opacity of the contours. Inferred from `edgecolor` 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/tests/test_docstring_helpers.py b/ultraplot/tests/test_docstring_helpers.py new file mode 100644 index 000000000..30bc5d992 --- /dev/null +++ b/ultraplot/tests/test_docstring_helpers.py @@ -0,0 +1,40 @@ +"""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_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 From 6626cc969fe8937f4bc3883167ad16a6f450ad05 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 17 Jul 2026 22:55:01 +1000 Subject: [PATCH 5/6] Fold geo format alias entries into canonical-first notes The geo format docstring documented gridline-locator aliases as separate 'lonlines, latlines : optional / Aliases for lonlocator, latlocator' blocks sitting next to the canonical entries, doubling the number of parameter entries a reader scans. Fold each alias set into a trailing 'Aliases:' note on its canonical entry (lonlocator, lonlocator_kw, lonminorlocator, lonminorlocator_kw), matching the shared style-snippet presentation. Documentation only; the alias keywords are still accepted. --- ultraplot/axes/geo.py | 12 +++++------- ultraplot/tests/test_docstring_helpers.py | 10 ++++++++++ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/ultraplot/axes/geo.py b/ultraplot/axes/geo.py index 4df8ff08f..8870e107c 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``, ``latlines``. 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``, ``latlines_kw``. +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``, ``latminorlines``. lonminorlocator_kw, latminorlocator_kw : optional As with `lonlocator_kw`, and `latlocator_kw` but for the "minor" gridlines. + Aliases: ``lonminorlines_kw``, ``latminorlines_kw``. 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/tests/test_docstring_helpers.py b/ultraplot/tests/test_docstring_helpers.py index 30bc5d992..51a82bf37 100644 --- a/ultraplot/tests/test_docstring_helpers.py +++ b/ultraplot/tests/test_docstring_helpers.py @@ -38,3 +38,13 @@ def test_method_docstring_fully_substituted() -> None: 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``, ``latlines``." in geo + assert "Aliases: ``lonminorlines_kw``, ``latminorlines_kw``." in geo From 79ded61169cf15befac5d063714658e3ad39bb1b Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 17 Jul 2026 23:15:33 +1000 Subject: [PATCH 6/6] Correct canonical names in shared collection docstrings --- ultraplot/axes/geo.py | 8 +++---- ultraplot/internals/docstring.py | 26 +++++++++++------------ ultraplot/tests/test_docstring_helpers.py | 17 +++++++++++++-- 3 files changed, 32 insertions(+), 19 deletions(-) diff --git a/ultraplot/axes/geo.py b/ultraplot/axes/geo.py index 8870e107c..15f74d8ce 100644 --- a/ultraplot/axes/geo.py +++ b/ultraplot/axes/geo.py @@ -123,7 +123,7 @@ The number of interpolation steps used to draw gridlines. lonlocator, latlocator : locator-spec, optional Used to determine the longitude and latitude gridline locations. - Aliases: ``lonlines``, ``latlines``. + Aliases: ``lonlines`` and ``latlines``, respectively. Passed to the `~ultraplot.constructor.Locator` constructor. Can be string, float, list of float, or `matplotlib.ticker.Locator` instance. @@ -135,13 +135,13 @@ at nice degree-minute-second intervals when the map extent is very small. lonlocator_kw, latlocator_kw : dict-like, optional Keyword arguments passed to the `matplotlib.ticker.Locator` class. - Aliases: ``lonlines_kw``, ``latlines_kw``. + Aliases: ``lonlines_kw`` and ``latlines_kw``, respectively. lonminorlocator, latminorlocator : optional As with `lonlocator` and `latlocator` but for the "minor" gridlines. - Aliases: ``lonminorlines``, ``latminorlines``. + 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``, ``latminorlines_kw``. + 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/internals/docstring.py b/ultraplot/internals/docstring.py index 52f35cd00..205e92bbf 100644 --- a/ultraplot/internals/docstring.py +++ b/ultraplot/internals/docstring.py @@ -216,28 +216,28 @@ def _aliases_note(*names): The opacity of the patch(es). Inferred from `facecolor` and `edgecolor` by default. {_aliases_note("a", "alphas")} """ _pcolor_collection_docstring = f""" -linewidth : unit-spec, default: 0.3 - The width of lines between grid boxes. {_aliases_note("lw", "linewidths")} +linewidths : unit-spec, default: 0.3 + The width of lines between grid boxes. {_aliases_note("lw", "linewidth")} %(units.pt)s -linestyle : str, default: '-' - The style of lines between grid boxes. {_aliases_note("ls", "linestyles")} -edgecolor : color-spec, default: 'k' - The color of lines between grid boxes. {_aliases_note("ec", "edgecolors")} +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 = f""" -linewidth : unit-spec, default: 0.3 or :rc:`lines.linewidth` +linewidths : unit-spec, default: 0.3 or :rc:`lines.linewidth` The width of the line contours. Default is ``0.3`` when adding to filled contours - or :rc:`lines.linewidth` otherwise. {_aliases_note("lw", "linewidths")} %(units.pt)s -linestyle : 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. {_aliases_note("ls", "linestyles")} -edgecolor : 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. {_aliases_note("ec", "edgecolors")} + or inferred from `color` or `cmap` otherwise. {_aliases_note("ec", "edgecolor")} alpha : float, optional - The opacity of the contours. Inferred from `edgecolor` by default. {_aliases_note("a", "alphas")} + The opacity of the contours. Inferred from `edgecolors` by default. {_aliases_note("a", "alphas")} """ _text_docstring = f""" fontfamily : str, optional diff --git a/ultraplot/tests/test_docstring_helpers.py b/ultraplot/tests/test_docstring_helpers.py index 51a82bf37..17d826d49 100644 --- a/ultraplot/tests/test_docstring_helpers.py +++ b/ultraplot/tests/test_docstring_helpers.py @@ -17,6 +17,17 @@ def test_style_snippets_lead_with_canonical_name() -> None: 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"] @@ -46,5 +57,7 @@ def test_geo_format_folds_alias_entries() -> None: geo = docstring._snippet_manager["geo.format"] assert "Aliases for" not in geo assert "lonlocator, latlocator : locator-spec" in geo - assert "Aliases: ``lonlines``, ``latlines``." in geo - assert "Aliases: ``lonminorlines_kw``, ``latminorlines_kw``." in geo + assert "Aliases: ``lonlines`` and ``latlines``, respectively." in geo + assert ( + "Aliases: ``lonminorlines_kw`` and ``latminorlines_kw``, respectively." in geo + )