From f47fdc794e761a2104b5351302af295a1fcb89ab Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 30 Dec 2025 15:18:35 +1000 Subject: [PATCH 1/5] Lazy-load top-level imports --- ultraplot/__init__.py | 425 +++++++++++++++++++++++-------- ultraplot/config.py | 17 +- ultraplot/internals/__init__.py | 165 ++++-------- ultraplot/internals/docstring.py | 133 +++++++++- 4 files changed, 508 insertions(+), 232 deletions(-) diff --git a/ultraplot/__init__.py b/ultraplot/__init__.py index 2a2db3bd1..88db4309d 100644 --- a/ultraplot/__init__.py +++ b/ultraplot/__init__.py @@ -2,7 +2,12 @@ """ A succinct matplotlib wrapper for making beautiful, publication-quality graphics. """ -# SCM versioning +from __future__ import annotations + +import ast +from importlib import import_module +from pathlib import Path + name = "ultraplot" try: @@ -12,106 +17,326 @@ version = __version__ -# Import dependencies early to isolate import times -from . import internals, externals, tests # noqa: F401 -from .internals.benchmarks import _benchmark +_SETUP_DONE = False +_SETUP_RUNNING = False +_EXPOSED_MODULES = set() +_ATTR_MAP = None +_REGISTRY_ATTRS = None -with _benchmark("pyplot"): - from matplotlib import pyplot # noqa: F401 -with _benchmark("cartopy"): - try: - import cartopy # noqa: F401 - except ImportError: - pass -with _benchmark("basemap"): - try: - from mpl_toolkits import basemap # noqa: F401 - except ImportError: - pass - -# Import everything to top level -with _benchmark("config"): - from .config import * # noqa: F401 F403 -with _benchmark("proj"): - from .proj import * # noqa: F401 F403 -with _benchmark("utils"): - from .utils import * # noqa: F401 F403 -with _benchmark("colors"): - from .colors import * # noqa: F401 F403 -with _benchmark("ticker"): - from .ticker import * # noqa: F401 F403 -with _benchmark("scale"): - from .scale import * # noqa: F401 F403 -with _benchmark("axes"): - from .axes import * # noqa: F401 F403 -with _benchmark("gridspec"): - from .gridspec import * # noqa: F401 F403 -with _benchmark("figure"): - from .figure import * # noqa: F401 F403 -with _benchmark("constructor"): - from .constructor import * # noqa: F401 F403 -with _benchmark("ui"): - from .ui import * # noqa: F401 F403 -with _benchmark("demos"): - from .demos import * # noqa: F401 F403 - -# Dynamically add registered classes to top-level namespace -from . import proj as crs # backwards compatibility # noqa: F401 -from .constructor import NORMS, LOCATORS, FORMATTERS, SCALES, PROJS - -_globals = globals() -for _src in (NORMS, LOCATORS, FORMATTERS, SCALES, PROJS): - for _key, _cls in _src.items(): - if isinstance(_cls, type): # i.e. not a scale preset - _globals[_cls.__name__] = _cls # may overwrite ultraplot names -# Register objects -from .config import register_cmaps, register_cycles, register_colors, register_fonts - -with _benchmark("cmaps"): - register_cmaps(default=True) -with _benchmark("cycles"): - register_cycles(default=True) -with _benchmark("colors"): - register_colors(default=True) -with _benchmark("fonts"): - register_fonts(default=True) - -# Validate colormap names and propagate 'cycle' to 'axes.prop_cycle' -# NOTE: cmap.sequential also updates siblings 'cmap' and 'image.cmap' -from .config import rc -from .internals import rcsetup, warnings - - -rcsetup.VALIDATE_REGISTERED_CMAPS = True -for _key in ( - "cycle", - "cmap.sequential", - "cmap.diverging", - "cmap.cyclic", - "cmap.qualitative", -): # noqa: E501 +_STAR_MODULES = ( + "config", + "proj", + "utils", + "colors", + "ticker", + "scale", + "axes", + "gridspec", + "figure", + "constructor", + "ui", + "demos", +) + +_MODULE_SOURCES = { + "config": "config.py", + "proj": "proj.py", + "utils": "utils.py", + "colors": "colors.py", + "ticker": "ticker.py", + "scale": "scale.py", + "axes": "axes/__init__.py", + "gridspec": "gridspec.py", + "figure": "figure.py", + "constructor": "constructor.py", + "ui": "ui.py", + "demos": "demos.py", +} + +_EXTRA_ATTRS = { + "config": ("config", None), + "proj": ("proj", None), + "utils": ("utils", None), + "colors": ("colors", None), + "ticker": ("ticker", None), + "scale": ("scale", None), + "legend": ("legend", None), + "axes": ("axes", None), + "gridspec": ("gridspec", None), + "figure": ("figure", None), + "constructor": ("constructor", None), + "ui": ("ui", None), + "demos": ("demos", None), + "crs": ("proj", None), + "colormaps": ("colors", "_cmap_database"), + "check_for_update": ("utils", "check_for_update"), + "NORMS": ("constructor", "NORMS"), + "LOCATORS": ("constructor", "LOCATORS"), + "FORMATTERS": ("constructor", "FORMATTERS"), + "SCALES": ("constructor", "SCALES"), + "PROJS": ("constructor", "PROJS"), + "internals": ("internals", None), + "externals": ("externals", None), + "tests": ("tests", None), + "rcsetup": ("internals", "rcsetup"), + "warnings": ("internals", "warnings"), +} + +_SETUP_SKIP = {"internals", "externals", "tests"} + +_EXTRA_PUBLIC = { + "crs", + "colormaps", + "check_for_update", + "NORMS", + "LOCATORS", + "FORMATTERS", + "SCALES", + "PROJS", + "internals", + "externals", + "tests", + "rcsetup", + "warnings", + "pyplot", + "cartopy", + "basemap", + "legend", +} + + +def _import_module(module_name): + return import_module(f".{module_name}", __name__) + + +def _parse_all(path): try: - rc[_key] = rc[_key] - except ValueError as err: - warnings._warn_ultraplot(f"Invalid user rc file setting: {err}") - rc[_key] = "Greys" # fill value - -# Validate color names now that colors are registered -# NOTE: This updates all settings with 'color' in name (harmless if it's not a color) -from .config import rc_ultraplot, rc_matplotlib - -rcsetup.VALIDATE_REGISTERED_COLORS = True -for _src in (rc_ultraplot, rc_matplotlib): - for _key in _src: # loop through unsynced properties - if "color" not in _key: + tree = ast.parse(path.read_text(encoding="utf-8")) + except (OSError, SyntaxError): + return None + for node in tree.body: + if not isinstance(node, ast.Assign): continue + for target in node.targets: + if isinstance(target, ast.Name) and target.id == "__all__": + try: + value = ast.literal_eval(node.value) + except Exception: + return None + if isinstance(value, (list, tuple)) and all( + isinstance(item, str) for item in value + ): + return list(value) + return None + return None + + +def _load_attr_map(): + global _ATTR_MAP + if _ATTR_MAP is not None: + return + attr_map = {} + base = Path(__file__).resolve().parent + for module_name in _STAR_MODULES: + relpath = _MODULE_SOURCES.get(module_name) + if not relpath: + continue + names = _parse_all(base / relpath) + if not names: + continue + for name in names: + attr_map[name] = module_name + _ATTR_MAP = attr_map + + +def _expose_module(module_name): + if module_name in _EXPOSED_MODULES: + return _import_module(module_name) + module = _import_module(module_name) + names = getattr(module, "__all__", None) + if names is None: + names = [name for name in dir(module) if not name.startswith("_")] + for name in names: + globals()[name] = getattr(module, name) + _EXPOSED_MODULES.add(module_name) + return module + + +def _setup(): + global _SETUP_DONE, _SETUP_RUNNING + if _SETUP_DONE or _SETUP_RUNNING: + return + _SETUP_RUNNING = True + success = False + try: + from .config import ( + rc, + rc_matplotlib, + rc_ultraplot, + register_cmaps, + register_colors, + register_cycles, + register_fonts, + ) + from .internals import rcsetup, warnings + from .internals.benchmarks import _benchmark + + with _benchmark("cmaps"): + register_cmaps(default=True) + with _benchmark("cycles"): + register_cycles(default=True) + with _benchmark("colors"): + register_colors(default=True) + with _benchmark("fonts"): + register_fonts(default=True) + + rcsetup.VALIDATE_REGISTERED_CMAPS = True + for key in ( + "cycle", + "cmap.sequential", + "cmap.diverging", + "cmap.cyclic", + "cmap.qualitative", + ): + try: + rc[key] = rc[key] + except ValueError as err: + warnings._warn_ultraplot(f"Invalid user rc file setting: {err}") + rc[key] = "Greys" + + rcsetup.VALIDATE_REGISTERED_COLORS = True + for src in (rc_ultraplot, rc_matplotlib): + for key in src: + if "color" not in key: + continue + try: + src[key] = src[key] + except ValueError as err: + warnings._warn_ultraplot(f"Invalid user rc file setting: {err}") + src[key] = "black" + + if rc["ultraplot.check_for_latest_version"]: + from .utils import check_for_update + + check_for_update("ultraplot") + success = True + finally: + if success: + _SETUP_DONE = True + _SETUP_RUNNING = False + + +def _resolve_extra(name): + module_name, attr = _EXTRA_ATTRS[name] + module = _import_module(module_name) + value = module if attr is None else getattr(module, attr) + globals()[name] = value + return value + + +def _build_registry_map(): + global _REGISTRY_ATTRS + if _REGISTRY_ATTRS is not None: + return + from .constructor import FORMATTERS, LOCATORS, NORMS, PROJS, SCALES + + registry = {} + for src in (NORMS, LOCATORS, FORMATTERS, SCALES, PROJS): + for _, cls in src.items(): + if isinstance(cls, type): + registry[cls.__name__] = cls + _REGISTRY_ATTRS = registry + + +def _get_registry_attr(name): + _build_registry_map() + if not _REGISTRY_ATTRS: + return None + return _REGISTRY_ATTRS.get(name) + + +def _load_all(): + _setup() + names = set() + for module_name in _STAR_MODULES: + module = _expose_module(module_name) + exports = getattr(module, "__all__", None) + if exports is None: + exports = [name for name in dir(module) if not name.startswith("_")] + names.update(exports) + names.update(_EXTRA_PUBLIC) + _build_registry_map() + if _REGISTRY_ATTRS: + names.update(_REGISTRY_ATTRS) + names.update({"__version__", "version", "name"}) + return sorted(names) + + +def __getattr__(name): + if name == "pytest_plugins": + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + if name in {"__version__", "version", "name"}: + return globals()[name] + if name == "__all__": + value = _load_all() + globals()["__all__"] = value + return value + if name == "pyplot": + import matplotlib.pyplot as pyplot + + globals()[name] = pyplot + return pyplot + if name == "cartopy": + try: + import cartopy + except ImportError as err: + raise AttributeError( + f"module {__name__!r} has no attribute {name!r}" + ) from err + globals()[name] = cartopy + return cartopy + if name == "basemap": try: - _src[_key] = _src[_key] - except ValueError as err: - warnings._warn_ultraplot(f"Invalid user rc file setting: {err}") - _src[_key] = "black" # fill value -from .colors import _cmap_database as colormaps -from .utils import check_for_update - -if rc["ultraplot.check_for_latest_version"]: - check_for_update("ultraplot") + from mpl_toolkits import basemap + except ImportError as err: + raise AttributeError( + f"module {__name__!r} has no attribute {name!r}" + ) from err + globals()[name] = basemap + return basemap + if name in _EXTRA_ATTRS and name in _SETUP_SKIP: + return _resolve_extra(name) + _setup() + if name in _EXTRA_ATTRS: + return _resolve_extra(name) + + _load_attr_map() + if _ATTR_MAP and name in _ATTR_MAP: + module = _expose_module(_ATTR_MAP[name]) + value = getattr(module, name) + globals()[name] = value + return value + + value = _get_registry_attr(name) + if value is not None: + globals()[name] = value + return value + + for module_name in _STAR_MODULES: + module = _expose_module(module_name) + if hasattr(module, name): + value = getattr(module, name) + globals()[name] = value + return value + + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +def __dir__(): + names = set(globals()) + _load_attr_map() + if _ATTR_MAP: + names.update(_ATTR_MAP) + names.update(_EXTRA_ATTRS) + names.update(_EXTRA_PUBLIC) + return sorted(names) diff --git a/ultraplot/config.py b/ultraplot/config.py index 388285bcc..a6c7c398e 100644 --- a/ultraplot/config.py +++ b/ultraplot/config.py @@ -17,7 +17,7 @@ from collections import namedtuple from collections.abc import MutableMapping from numbers import Real - +from typing import Any, Callable, Dict import cycler import matplotlib as mpl @@ -27,9 +27,7 @@ import matplotlib.style.core as mstyle import numpy as np from matplotlib import RcParams -from typing import Callable, Any, Dict -from .internals import ic # noqa: F401 from .internals import ( _not_none, _pop_kwargs, @@ -37,18 +35,11 @@ _translate_grid, _version_mpl, docstring, + ic, # noqa: F401 rcsetup, warnings, ) -try: - from IPython import get_ipython -except ImportError: - - def get_ipython(): - return - - # Suppress warnings emitted by mathtext.py (_mathtext.py in recent versions) # when when substituting dummy unavailable glyph due to fallback disabled. logging.getLogger("matplotlib.mathtext").setLevel(logging.ERROR) @@ -433,6 +424,10 @@ def config_inline_backend(fmt=None): Configurator """ # Note if inline backend is unavailable this will fail silently + try: + from IPython import get_ipython + except ImportError: + return ipython = get_ipython() if ipython is None: return diff --git a/ultraplot/internals/__init__.py b/ultraplot/internals/__init__.py index 7a7ea9381..3fa820666 100644 --- a/ultraplot/internals/__init__.py +++ b/ultraplot/internals/__init__.py @@ -4,10 +4,10 @@ """ # Import statements import inspect +from importlib import import_module from numbers import Integral, Real import numpy as np -from matplotlib import rcParams as rc_matplotlib try: # print debugging (used with internal modules) from icecream import ic @@ -37,29 +37,17 @@ def _not_none(*args, default=None, **kwargs): break kwargs = {name: arg for name, arg in kwargs.items() if arg is not None} if len(kwargs) > 1: - warnings._warn_ultraplot( + warns._warn_ultraplot( f"Got conflicting or duplicate keyword arguments: {kwargs}. " "Using the first keyword argument." ) return first -# Internal import statements -# WARNING: Must come after _not_none because this is leveraged inside other funcs -from . import ( # noqa: F401 - benchmarks, - context, - docstring, - fonts, - guides, - inputs, - labels, - rcsetup, - versions, - warnings, -) -from .versions import _version_mpl, _version_cartopy # noqa: F401 -from .warnings import UltraPlotWarning # noqa: F401 +def _get_rc_matplotlib(): + from matplotlib import rcParams as rc_matplotlib + + return rc_matplotlib # Style aliases. We use this rather than matplotlib's normalize_kwargs and _alias_maps. @@ -166,103 +154,21 @@ def _not_none(*args, default=None, **kwargs): }, } - -# Unit docstrings -# NOTE: Try to fit this into a single line. Cannot break up with newline as that will -# mess up docstring indentation since this is placed in indented param lines. -_units_docstring = "If float, units are {units}. If string, interpreted by `~ultraplot.utils.units`." # noqa: E501 -docstring._snippet_manager["units.pt"] = _units_docstring.format(units="points") -docstring._snippet_manager["units.in"] = _units_docstring.format(units="inches") -docstring._snippet_manager["units.em"] = _units_docstring.format(units="em-widths") - - -# 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). - %(units.pt)s -ls, linestyle, linestyles : str, default: :rc:`lines.linestyle` - The style of the line(s). -c, color, colors : color-spec, optional - The color of the line(s). The property `cycle` is used by default. -a, alpha, alphas : float, optional - The opacity of the line(s). Inferred from `color` by default. -""" -_patch_docstring = """ -lw, linewidth, linewidths : unit-spec, default: :rc:`patch.linewidth` - The edge width of the patch(es). - %(units.pt)s -ls, linestyle, linestyles : str, default: '-' - The edge style of the patch(es). -ec, edgecolor, edgecolors : color-spec, default: '{edgecolor}' - The edge color of the patch(es). -fc, facecolor, facecolors, fillcolor, fillcolors : color-spec, optional - The face color of the patch(es). The property `cycle` is used by default. -a, alpha, alphas : float, optional - The opacity of the patch(es). Inferred from `facecolor` and `edgecolor` by default. -""" -_pcolor_collection_docstring = """ -lw, linewidth, linewidths : unit-spec, default: 0.3 - The width of lines between grid boxes. - %(units.pt)s -ls, linestyle, linestyles : str, default: '-' - The style of lines between grid boxes. -ec, edgecolor, edgecolors : color-spec, default: 'k' - The color of lines between grid boxes. -a, alpha, alphas : float, optional - The opacity of the grid boxes. Inferred from `cmap` by default. -""" -_contour_collection_docstring = """ -lw, linewidth, linewidths : unit-spec, default: 0.3 or :rc:`lines.linewidth` - The width of the line contours. Default is ``0.3`` when adding to filled contours - or :rc:`lines.linewidth` otherwise. %(units.pt)s -ls, linestyle, linestyles : str, default: '-' or :rc:`contour.negative_linestyle` - The style of the line contours. Default is ``'-'`` for positive contours and - :rcraw:`contour.negative_linestyle` for negative contours. -ec, edgecolor, edgecolors : color-spec, default: 'k' or inferred - The color of the line contours. Default is ``'k'`` when adding to filled contours - or inferred from `color` or `cmap` otherwise. -a, alpha, alpha : float, optional - The opacity of the contours. Inferred from `edgecolor` by default. -""" -_text_docstring = """ -name, fontname, family, fontfamily : str, optional - The font typeface name (e.g., ``'Fira Math'``) or font family name (e.g., - ``'serif'``). Matplotlib falls back to the system default if not found. -size, fontsize : unit-spec or str, optional - The font size. %(units.pt)s - This can also be a string indicating some scaling relative to - :rcraw:`font.size`. The sizes and scalings are shown below. The - scalings ``'med'``, ``'med-small'``, and ``'med-large'`` are - added by ultraplot while the rest are native matplotlib sizes. - - .. _font_table: - - ========================== ===== - Size Scale - ========================== ===== - ``'xx-small'`` 0.579 - ``'x-small'`` 0.694 - ``'small'``, ``'smaller'`` 0.833 - ``'med-small'`` 0.9 - ``'med'``, ``'medium'`` 1.0 - ``'med-large'`` 1.1 - ``'large'``, ``'larger'`` 1.2 - ``'x-large'`` 1.440 - ``'xx-large'`` 1.728 - ``'larger'`` 1.2 - ========================== ===== - -""" -docstring._snippet_manager["artist.line"] = _line_docstring -docstring._snippet_manager["artist.text"] = _text_docstring -docstring._snippet_manager["artist.patch"] = _patch_docstring.format(edgecolor="none") -docstring._snippet_manager["artist.patch_black"] = _patch_docstring.format( - edgecolor="black" -) # noqa: E501 -docstring._snippet_manager["artist.collection_pcolor"] = _pcolor_collection_docstring -docstring._snippet_manager["artist.collection_contour"] = _contour_collection_docstring +_LAZY_ATTRS = { + "benchmarks": ("benchmarks", None), + "context": ("context", None), + "docstring": ("docstring", None), + "fonts": ("fonts", None), + "guides": ("guides", None), + "inputs": ("inputs", None), + "labels": ("labels", None), + "rcsetup": ("rcsetup", None), + "versions": ("versions", None), + "warnings": ("warnings", None), + "_version_mpl": ("versions", "_version_mpl"), + "_version_cartopy": ("versions", "_version_cartopy"), + "UltraPlotWarning": ("warnings", "UltraPlotWarning"), +} def _get_aliases(category, *keys): @@ -370,7 +276,7 @@ def _pop_props(input, *categories, prefix=None, ignore=None, skip=None): if prop is None: continue if any(string in key for string in ignore): - warnings._warn_ultraplot(f"Ignoring property {key}={prop!r}.") + warns._warn_ultraplot(f"Ignoring property {key}={prop!r}.") continue if isinstance(prop, str): # ad-hoc unit conversion if key in ("fontsize",): @@ -389,6 +295,8 @@ def _pop_rc(src, *, ignore_conflicts=True): """ Pop the rc setting names and mode for a `~Configurator.context` block. """ + from . import rcsetup + # NOTE: Must ignore deprected or conflicting rc params # NOTE: rc_mode == 2 applies only the updated params. A power user # could use ax.format(rc_mode=0) to re-apply all the current settings @@ -408,7 +316,7 @@ def _pop_rc(src, *, ignore_conflicts=True): kw = src.pop("rc_kw", None) or {} if "mode" in src: src["rc_mode"] = src.pop("mode") - warnings._warn_ultraplot( + warns._warn_ultraplot( "Keyword 'mode' was deprecated in v0.6. Please use 'rc_mode' instead." ) mode = src.pop("rc_mode", None) @@ -428,6 +336,8 @@ def _translate_loc(loc, mode, *, default=None, **kwargs): must be a string for which there is a :rcraw:`mode.loc` setting. Additional options can be added with keyword arguments. """ + from . import rcsetup + # Create specific options dictionary # NOTE: This is not inside validators.py because it is also used to # validate various user-input locations. @@ -481,6 +391,7 @@ def _translate_grid(b, key): Translate an instruction to turn either major or minor gridlines on or off into a boolean and string applied to :rcraw:`axes.grid` and :rcraw:`axes.grid.which`. """ + rc_matplotlib = _get_rc_matplotlib() ob = rc_matplotlib["axes.grid"] owhich = rc_matplotlib["axes.grid.which"] @@ -527,3 +438,23 @@ def _translate_grid(b, key): which = owhich return b, which + + +def _resolve_lazy(name): + module_name, attr = _LAZY_ATTRS[name] + module = import_module(f".{module_name}", __name__) + value = module if attr is None else getattr(module, attr) + globals()[name] = value + return value + + +def __getattr__(name): + if name in _LAZY_ATTRS: + return _resolve_lazy(name) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +def __dir__(): + names = set(globals()) + names.update(_LAZY_ATTRS) + return sorted(names) diff --git a/ultraplot/internals/docstring.py b/ultraplot/internals/docstring.py index f414942d4..650f7726e 100644 --- a/ultraplot/internals/docstring.py +++ b/ultraplot/internals/docstring.py @@ -23,10 +23,6 @@ import inspect import re -import matplotlib.axes as maxes -import matplotlib.figure as mfigure -from matplotlib import rcParams as rc_matplotlib - from . import ic # noqa: F401 @@ -64,6 +60,10 @@ def _concatenate_inherited(func, prepend_summary=False): Concatenate docstrings from a matplotlib axes method with a ultraplot axes method and obfuscate the call signature. """ + import matplotlib.axes as maxes + import matplotlib.figure as mfigure + from matplotlib import rcParams as rc_matplotlib + # Get matplotlib axes func # NOTE: Do not bother inheriting from cartopy GeoAxes. Cartopy completely # truncates the matplotlib docstrings (which is kind of not great). @@ -112,6 +112,35 @@ class _SnippetManager(dict): A simple database for handling documentation snippets. """ + _lazy_modules = { + "axes": "ultraplot.axes.base", + "cartesian": "ultraplot.axes.cartesian", + "polar": "ultraplot.axes.polar", + "geo": "ultraplot.axes.geo", + "plot": "ultraplot.axes.plot", + "figure": "ultraplot.figure", + "gridspec": "ultraplot.gridspec", + "ticker": "ultraplot.ticker", + "proj": "ultraplot.proj", + "colors": "ultraplot.colors", + "utils": "ultraplot.utils", + "config": "ultraplot.config", + "demos": "ultraplot.demos", + "rc": "ultraplot.axes.base", + } + + def __missing__(self, key): + """ + Attempt to import modules that populate missing snippet keys. + """ + prefix = key.split(".", 1)[0] + module_name = self._lazy_modules.get(prefix) + if module_name: + __import__(module_name) + if key in self: + return dict.__getitem__(self, key) + raise KeyError(key) + def __call__(self, obj): """ Add snippets to the string or object using ``%(name)s`` substitution. Here @@ -137,3 +166,99 @@ def __setitem__(self, key, value): # Initiate snippets database _snippet_manager = _SnippetManager() + +# Unit docstrings +# NOTE: Try to fit this into a single line. Cannot break up with newline as that will +# mess up docstring indentation since this is placed in indented param lines. +_units_docstring = ( + "If float, units are {units}. If string, interpreted by `~ultraplot.utils.units`." +) +_snippet_manager["units.pt"] = _units_docstring.format(units="points") +_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). + %(units.pt)s +ls, linestyle, linestyles : str, default: :rc:`lines.linestyle` + The style of the line(s). +c, color, colors : color-spec, optional + The color of the line(s). The property `cycle` is used by default. +a, alpha, alphas : float, optional + The opacity of the line(s). Inferred from `color` by default. +""" +_patch_docstring = """ +lw, linewidth, linewidths : unit-spec, default: :rc:`patch.linewidth` + The edge width of the patch(es). + %(units.pt)s +ls, linestyle, linestyles : str, default: '-' + The edge style of the patch(es). +ec, edgecolor, edgecolors : color-spec, default: '{edgecolor}' + The edge color of the patch(es). +fc, facecolor, facecolors, fillcolor, fillcolors : color-spec, optional + The face color of the patch(es). The property `cycle` is used by default. +a, alpha, alphas : float, optional + The opacity of the patch(es). Inferred from `facecolor` and `edgecolor` by default. +""" +_pcolor_collection_docstring = """ +lw, linewidth, linewidths : unit-spec, default: 0.3 + The width of lines between grid boxes. + %(units.pt)s +ls, linestyle, linestyles : str, default: '-' + The style of lines between grid boxes. +ec, edgecolor, edgecolors : color-spec, default: 'k' + The color of lines between grid boxes. +a, alpha, alphas : float, optional + The opacity of the grid boxes. Inferred from `cmap` by default. +""" +_contour_collection_docstring = """ +lw, linewidth, linewidths : unit-spec, default: 0.3 or :rc:`lines.linewidth` + The width of the line contours. Default is ``0.3`` when adding to filled contours + or :rc:`lines.linewidth` otherwise. %(units.pt)s +ls, linestyle, linestyles : str, default: '-' or :rc:`contour.negative_linestyle` + The style of the line contours. Default is ``'-'`` for positive contours and + :rcraw:`contour.negative_linestyle` for negative contours. +ec, edgecolor, edgecolors : color-spec, default: 'k' or inferred + The color of the line contours. Default is ``'k'`` when adding to filled contours + or inferred from `color` or `cmap` otherwise. +a, alpha, alpha : float, optional + The opacity of the contours. Inferred from `edgecolor` by default. +""" +_text_docstring = """ +name, fontname, family, fontfamily : str, optional + The font typeface name (e.g., ``'Fira Math'``) or font family name (e.g., + ``'serif'``). Matplotlib falls back to the system default if not found. +size, fontsize : unit-spec or str, optional + The font size. %(units.pt)s + This can also be a string indicating some scaling relative to + :rcraw:`font.size`. The sizes and scalings are shown below. The + scalings ``'med'``, ``'med-small'``, and ``'med-large'`` are + added by ultraplot while the rest are native matplotlib sizes. + + .. _font_table: + + ========================== ===== + Size Scale + ========================== ===== + ``'xx-small'`` 0.579 + ``'x-small'`` 0.694 + ``'small'``, ``'smaller'`` 0.833 + ``'med-small'`` 0.9 + ``'med'``, ``'medium'`` 1.0 + ``'med-large'`` 1.1 + ``'large'``, ``'larger'`` 1.2 + ``'x-large'`` 1.440 + ``'xx-large'`` 1.728 + ``'larger'`` 1.2 + ========================== ===== + +""" +_snippet_manager["artist.line"] = _line_docstring +_snippet_manager["artist.text"] = _text_docstring +_snippet_manager["artist.patch"] = _patch_docstring.format(edgecolor="none") +_snippet_manager["artist.patch_black"] = _patch_docstring.format(edgecolor="black") +_snippet_manager["artist.collection_pcolor"] = _pcolor_collection_docstring +_snippet_manager["artist.collection_contour"] = _contour_collection_docstring From 574483051eb05296c1218d5f14f06f46f6230a5a Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 30 Dec 2025 15:24:07 +1000 Subject: [PATCH 2/5] Use warnings module in internals --- ultraplot/internals/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ultraplot/internals/__init__.py b/ultraplot/internals/__init__.py index 3fa820666..487fef87a 100644 --- a/ultraplot/internals/__init__.py +++ b/ultraplot/internals/__init__.py @@ -14,7 +14,7 @@ except ImportError: # graceful fallback if IceCream isn't installed ic = lambda *args: print(*args) # noqa: E731 -from . import warnings as warns +from . import warnings def _not_none(*args, default=None, **kwargs): @@ -37,7 +37,7 @@ def _not_none(*args, default=None, **kwargs): break kwargs = {name: arg for name, arg in kwargs.items() if arg is not None} if len(kwargs) > 1: - warns._warn_ultraplot( + warnings._warn_ultraplot( f"Got conflicting or duplicate keyword arguments: {kwargs}. " "Using the first keyword argument." ) @@ -276,7 +276,7 @@ def _pop_props(input, *categories, prefix=None, ignore=None, skip=None): if prop is None: continue if any(string in key for string in ignore): - warns._warn_ultraplot(f"Ignoring property {key}={prop!r}.") + warnings._warn_ultraplot(f"Ignoring property {key}={prop!r}.") continue if isinstance(prop, str): # ad-hoc unit conversion if key in ("fontsize",): @@ -316,7 +316,7 @@ def _pop_rc(src, *, ignore_conflicts=True): kw = src.pop("rc_kw", None) or {} if "mode" in src: src["rc_mode"] = src.pop("mode") - warns._warn_ultraplot( + warnings._warn_ultraplot( "Keyword 'mode' was deprecated in v0.6. Please use 'rc_mode' instead." ) mode = src.pop("rc_mode", None) From eafd07701f66c69cfbb2125d230d03806dfa9a00 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 30 Dec 2025 15:33:37 +1000 Subject: [PATCH 3/5] Add eager import option and tests --- ultraplot/__init__.py | 76 ++++++++++++++++++++++++++++----- ultraplot/internals/rcsetup.py | 14 ++++-- ultraplot/tests/test_imports.py | 43 +++++++++++++++++++ 3 files changed, 119 insertions(+), 14 deletions(-) create mode 100644 ultraplot/tests/test_imports.py diff --git a/ultraplot/__init__.py b/ultraplot/__init__.py index 88db4309d..d9dd39832 100644 --- a/ultraplot/__init__.py +++ b/ultraplot/__init__.py @@ -19,6 +19,7 @@ _SETUP_DONE = False _SETUP_RUNNING = False +_EAGER_DONE = False _EXPOSED_MODULES = set() _ATTR_MAP = None _REGISTRY_ATTRS = None @@ -83,6 +84,18 @@ } _SETUP_SKIP = {"internals", "externals", "tests"} +_SETUP_ATTRS = {"rc", "rc_ultraplot", "rc_matplotlib", "colormaps"} +_SETUP_MODULES = { + "colors", + "ticker", + "scale", + "axes", + "gridspec", + "figure", + "constructor", + "ui", + "demos", +} _EXTRA_PUBLIC = { "crs", @@ -102,6 +115,7 @@ "cartopy", "basemap", "legend", + "setup", } @@ -256,6 +270,7 @@ def _get_registry_attr(name): def _load_all(): + global _EAGER_DONE _setup() names = set() for module_name in _STAR_MODULES: @@ -269,9 +284,47 @@ def _load_all(): if _REGISTRY_ATTRS: names.update(_REGISTRY_ATTRS) names.update({"__version__", "version", "name"}) + _EAGER_DONE = True return sorted(names) +def _get_rc_eager(): + try: + from .config import rc + except Exception: + return False + try: + return bool(rc["ultraplot.eager_import"]) + except Exception: + return False + + +def _maybe_eager_import(): + if _EAGER_DONE: + return + if _get_rc_eager(): + _load_all() + + +def setup(*, eager=None): + """ + Initialize ultraplot and optionally import the public API eagerly. + """ + _setup() + if eager is None: + eager = _get_rc_eager() + if eager: + _load_all() + + +def _needs_setup(name, module_name=None): + if name in _SETUP_ATTRS: + return True + if module_name in _SETUP_MODULES: + return True + return False + + def __getattr__(name): if name == "pytest_plugins": raise AttributeError(f"module {__name__!r} has no attribute {name!r}") @@ -306,26 +359,27 @@ def __getattr__(name): return basemap if name in _EXTRA_ATTRS and name in _SETUP_SKIP: return _resolve_extra(name) - _setup() if name in _EXTRA_ATTRS: + module_name, _ = _EXTRA_ATTRS[name] + if _needs_setup(name, module_name=module_name): + _setup() + _maybe_eager_import() return _resolve_extra(name) _load_attr_map() if _ATTR_MAP and name in _ATTR_MAP: - module = _expose_module(_ATTR_MAP[name]) + module_name = _ATTR_MAP[name] + if _needs_setup(name, module_name=module_name): + _setup() + _maybe_eager_import() + module = _expose_module(module_name) value = getattr(module, name) globals()[name] = value return value - value = _get_registry_attr(name) - if value is not None: - globals()[name] = value - return value - - for module_name in _STAR_MODULES: - module = _expose_module(module_name) - if hasattr(module, name): - value = getattr(module, name) + if name[:1].isupper(): + value = _get_registry_attr(name) + if value is not None: globals()[name] = value return value diff --git a/ultraplot/internals/rcsetup.py b/ultraplot/internals/rcsetup.py index 7439f35cf..fbb7ef5fe 100644 --- a/ultraplot/internals/rcsetup.py +++ b/ultraplot/internals/rcsetup.py @@ -3,10 +3,11 @@ Utilities for global configuration. """ import functools -import re, matplotlib as mpl +import re from collections.abc import MutableMapping from numbers import Integral, Real +import matplotlib as mpl import matplotlib.rcsetup as msetup import numpy as np from cycler import Cycler @@ -20,8 +21,10 @@ else: from matplotlib.fontconfig_pattern import parse_fontconfig_pattern -from . import ic # noqa: F401 -from . import warnings +from . import ( + ic, # noqa: F401 + warnings, +) from .versions import _version_mpl # Regex for "probable" unregistered named colors. Try to retain warning message for @@ -1958,6 +1961,11 @@ def copy(self): _validate_bool, "Whether to check for the latest version of UltraPlot on PyPI when importing", ), + "ultraplot.eager_import": ( + False, + _validate_bool, + "Whether to import the full public API during setup instead of lazily.", + ), } # Child settings. Changing the parent changes all the children, but diff --git a/ultraplot/tests/test_imports.py b/ultraplot/tests/test_imports.py new file mode 100644 index 000000000..983a3e583 --- /dev/null +++ b/ultraplot/tests/test_imports.py @@ -0,0 +1,43 @@ +import json +import os +import subprocess +import sys + + +def _run(code): + env = os.environ.copy() + proc = subprocess.run( + [sys.executable, "-c", code], + check=True, + capture_output=True, + text=True, + env=env, + ) + return proc.stdout.strip() + + +def test_import_is_lightweight(): + code = """ +import json +import sys +pre = set(sys.modules) +import ultraplot # noqa: F401 +post = set(sys.modules) +new = {name.split('.', 1)[0] for name in (post - pre)} +heavy = {"matplotlib", "IPython", "cartopy", "mpl_toolkits"} +print(json.dumps(sorted(new & heavy))) +""" + out = _run(code) + assert out == "[]" + + +def test_star_import_exposes_public_api(): + code = """ +from ultraplot import * # noqa: F403 +assert "rc" in globals() +assert "Figure" in globals() +assert "Axes" in globals() +print("ok") +""" + out = _run(code) + assert out == "ok" From d785584f7c6150b7bb5fc73bbbb9d35b164598c5 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 30 Dec 2025 15:58:45 +1000 Subject: [PATCH 4/5] Cover eager setup and benchmark imports --- ultraplot/__init__.py | 8 ++++++-- ultraplot/tests/test_imports.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/ultraplot/__init__.py b/ultraplot/__init__.py index d9dd39832..de601a447 100644 --- a/ultraplot/__init__.py +++ b/ultraplot/__init__.py @@ -272,15 +272,19 @@ def _get_registry_attr(name): def _load_all(): global _EAGER_DONE _setup() + from .internals.benchmarks import _benchmark + names = set() for module_name in _STAR_MODULES: - module = _expose_module(module_name) + with _benchmark(f"import {module_name}"): + module = _expose_module(module_name) exports = getattr(module, "__all__", None) if exports is None: exports = [name for name in dir(module) if not name.startswith("_")] names.update(exports) names.update(_EXTRA_PUBLIC) - _build_registry_map() + with _benchmark("registries"): + _build_registry_map() if _REGISTRY_ATTRS: names.update(_REGISTRY_ATTRS) names.update({"__version__", "version", "name"}) diff --git a/ultraplot/tests/test_imports.py b/ultraplot/tests/test_imports.py index 983a3e583..4dde78c46 100644 --- a/ultraplot/tests/test_imports.py +++ b/ultraplot/tests/test_imports.py @@ -41,3 +41,31 @@ def test_star_import_exposes_public_api(): """ out = _run(code) assert out == "ok" + + +def test_setup_eager_imports_modules(): + code = """ +import sys +import ultraplot as uplt +assert "ultraplot.axes" not in sys.modules +uplt.setup(eager=True) +assert "ultraplot.axes" in sys.modules +print("ok") +""" + out = _run(code) + assert out == "ok" + + +def test_setup_uses_rc_eager_import(): + code = """ +import sys +import ultraplot as uplt +uplt.setup(eager=False) +assert "ultraplot.axes" not in sys.modules +uplt.rc["ultraplot.eager_import"] = True +uplt.setup() +assert "ultraplot.axes" in sys.modules +print("ok") +""" + out = _run(code) + assert out == "ok" From 7ea91e6227e82fd0e83a3d0bfad10b389da1fb0c Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 30 Dec 2025 16:31:50 +1000 Subject: [PATCH 5/5] Add tests for lazy import coverage --- ultraplot/tests/test_imports.py | 71 +++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/ultraplot/tests/test_imports.py b/ultraplot/tests/test_imports.py index 4dde78c46..7ea37d729 100644 --- a/ultraplot/tests/test_imports.py +++ b/ultraplot/tests/test_imports.py @@ -1,8 +1,11 @@ +import importlib.util import json import os import subprocess import sys +import pytest + def _run(code): env = os.environ.copy() @@ -69,3 +72,71 @@ def test_setup_uses_rc_eager_import(): """ out = _run(code) assert out == "ok" + + +def test_dir_populates_attr_map(monkeypatch): + import ultraplot as uplt + + monkeypatch.setattr(uplt, "_ATTR_MAP", None, raising=False) + names = dir(uplt) + assert "close" in names + assert uplt._ATTR_MAP is not None + + +def test_extra_and_registry_accessors(monkeypatch): + import ultraplot as uplt + + monkeypatch.setattr(uplt, "_REGISTRY_ATTRS", None, raising=False) + assert hasattr(uplt.colormaps, "get_cmap") + assert uplt.internals.__name__.endswith("internals") + assert isinstance(uplt.LogNorm, type) + + +def test_all_triggers_eager_load(monkeypatch): + import ultraplot as uplt + + monkeypatch.delattr(uplt, "__all__", raising=False) + names = uplt.__all__ + assert "setup" in names + assert "pyplot" in names + + +def test_optional_module_attrs(): + import ultraplot as uplt + + if importlib.util.find_spec("cartopy") is None: + with pytest.raises(AttributeError): + _ = uplt.cartopy + else: + assert uplt.cartopy.__name__ == "cartopy" + + if importlib.util.find_spec("mpl_toolkits.basemap") is None: + with pytest.raises(AttributeError): + _ = uplt.basemap + else: + assert uplt.basemap.__name__.endswith("basemap") + + with pytest.raises(AttributeError): + getattr(uplt, "pytest_plugins") + + +def test_internals_lazy_attrs(): + from ultraplot import internals + + assert internals.__name__.endswith("internals") + assert "rcsetup" in dir(internals) + assert internals.rcsetup is not None + assert internals.warnings is not None + assert str(internals._version_mpl) + assert issubclass(internals.UltraPlotWarning, Warning) + rc_matplotlib = internals._get_rc_matplotlib() + assert "axes.grid" in rc_matplotlib + + +def test_docstring_missing_triggers_lazy_import(): + from ultraplot.internals import docstring + + with pytest.raises(KeyError): + docstring._snippet_manager["ticker.not_a_real_key"] + with pytest.raises(KeyError): + docstring._snippet_manager["does_not_exist.key"]