From 89c7c1115afb06dc8969f46409346f659447ed34 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 30 Dec 2025 15:18:35 +1000 Subject: [PATCH 1/9] 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 54fd781910d1aa01a0ad1dab533751eb47a90c21 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 30 Dec 2025 15:24:07 +1000 Subject: [PATCH 2/9] 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 9bd69dde73d46f282430ef6f1ebcd6554bc1e57f Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 30 Dec 2025 15:33:37 +1000 Subject: [PATCH 3/9] 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 7b20c7beebd5590c05b0652264afd60422dabcba Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 30 Dec 2025 15:58:45 +1000 Subject: [PATCH 4/9] 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 641386e240fc93add310c9e37d87842e38becadc Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 30 Dec 2025 16:31:50 +1000 Subject: [PATCH 5/9] 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"] From d259a2cb603f018f6d992de55e4e3f2f9a90a665 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 4 Jan 2026 18:49:41 +1000 Subject: [PATCH 6/9] update docs --- CONTRIBUTING.rst | 36 ++++++++++++++++++++++++++++++++++++ docs/index.rst | 1 + docs/lazy_loading.rst | 35 +++++++++++++++++++++++++++++++++++ ultraplot/__init__.py | 5 ++++- ultraplot/ui.py | 16 +++++++++++----- 5 files changed, 87 insertions(+), 6 deletions(-) create mode 100644 docs/lazy_loading.rst diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 6c2d1ae7a..d455ff729 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -96,6 +96,42 @@ To build the documentation locally, use the following commands: The built documentation should be available in ``docs/_build/html``. +.. _contrib_lazy_loading: + +Lazy Loading and Adding New Modules +=================================== + +UltraPlot uses a lazy loading mechanism to improve import times. This means that +submodules are not imported until they are actually used. This is controlled by the +`__getattr__` function in `ultraplot/__init__.py`. + +When adding a new submodule, you need to make sure it's compatible with the lazy +loader. Here's how to do it: + +1. **Add the submodule to `_STAR_MODULES`:** In `ultraplot/__init__.py`, add the + name of your new submodule to the `_STAR_MODULES` tuple. This will make it + discoverable by the lazy loader. + +2. **Add the submodule to `_MODULE_SOURCES`:** Also in `ultraplot/__init__.py`, + add an entry to the `_MODULE_SOURCES` dictionary that maps the name of your + submodule to its source file. + +3. **Exposing Callables:** If you want to expose a function or class from your + submodule as a top-level attribute of the `ultraplot` package (e.g., + `uplt.my_function`), you need to add an entry to the `_EXTRA_ATTRS` + dictionary. + + * To expose a function or class `MyFunction` from `my_module.py` as + `uplt.my_function`, add the following to `_EXTRA_ATTRS`: + `"my_function": ("my_module", "MyFunction")`. + * If you want to expose the entire submodule as a top-level attribute + (e.g., `uplt.my_module`), you can add: + `"my_module": ("my_module", None)`. + +By following these steps, you can ensure that your new module is correctly +integrated into the lazy loading system. + + .. _contrib_pr: Preparing pull requests diff --git a/docs/index.rst b/docs/index.rst index bd55c3882..607df6d31 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -149,6 +149,7 @@ For more details, check the full :doc:`User guide ` and :doc:`API Referen :hidden: api + lazy_loading external-links whats_new contributing diff --git a/docs/lazy_loading.rst b/docs/lazy_loading.rst new file mode 100644 index 000000000..b1106b397 --- /dev/null +++ b/docs/lazy_loading.rst @@ -0,0 +1,35 @@ +.. _lazy_loading: + +=================================== +Lazy Loading and Adding New Modules +=================================== + +UltraPlot uses a lazy loading mechanism to improve import times. This means that +submodules are not imported until they are actually used. This is controlled by the +`__getattr__` function in `ultraplot/__init__.py`. + +When adding a new submodule, you need to make sure it's compatible with the lazy +loader. Here's how to do it: + +1. **Add the submodule to `_STAR_MODULES`:** In `ultraplot/__init__.py`, add the + name of your new submodule to the `_STAR_MODULES` tuple. This will make it + discoverable by the lazy loader. + +2. **Add the submodule to `_MODULE_SOURCES`:** Also in `ultraplot/__init__.py`, + add an entry to the `_MODULE_SOURCES` dictionary that maps the name of your + submodule to its source file. + +3. **Exposing Callables:** If you want to expose a function or class from your + submodule as a top-level attribute of the `ultraplot` package (e.g., + `uplt.my_function`), you need to add an entry to the `_EXTRA_ATTRS` + dictionary. + + * To expose a function or class `MyFunction` from `my_module.py` as + `uplt.my_function`, add the following to `_EXTRA_ATTRS`: + `"my_function": ("my_module", "MyFunction")`. + * If you want to expose the entire submodule as a top-level attribute + (e.g., `uplt.my_module`), you can add: + `"my_module": ("my_module", None)`. + +By following these steps, you can ensure that your new module is correctly +integrated into the lazy loading system. diff --git a/ultraplot/__init__.py b/ultraplot/__init__.py index de601a447..613721789 100644 --- a/ultraplot/__init__.py +++ b/ultraplot/__init__.py @@ -64,7 +64,10 @@ "legend": ("legend", None), "axes": ("axes", None), "gridspec": ("gridspec", None), - "figure": ("figure", None), + "figure": ( + "figure", + "Figure", + ), # have to rename to keep the api backwards compatible "constructor": ("constructor", None), "ui": ("ui", None), "demos": ("demos", None), diff --git a/ultraplot/ui.py b/ultraplot/ui.py index 7fb66334e..03f4ead93 100644 --- a/ultraplot/ui.py +++ b/ultraplot/ui.py @@ -7,8 +7,14 @@ from . import axes as paxes from . import figure as pfigure from . import gridspec as pgridspec -from .internals import ic # noqa: F401 -from .internals import _not_none, _pop_params, _pop_props, _pop_rc, docstring +from .internals import ( + _not_none, + _pop_params, + _pop_props, + _pop_rc, + docstring, + ic, # noqa: F401 +) __all__ = [ "figure", @@ -141,7 +147,7 @@ def figure(**kwargs): matplotlib.figure.Figure """ _parse_figsize(kwargs) - return plt.figure(FigureClass=pfigure.Figure, **kwargs) + return plt.figure(FigureClass=pfigure, **kwargs) @docstring._snippet_manager @@ -175,7 +181,7 @@ def subplot(**kwargs): _parse_figsize(kwargs) rc_kw, rc_mode = _pop_rc(kwargs) kwsub = _pop_props(kwargs, "patch") # e.g. 'color' - kwsub.update(_pop_params(kwargs, pfigure.Figure._parse_proj)) + kwsub.update(_pop_params(kwargs, pfigure._parse_proj)) for sig in paxes.Axes._format_signatures.values(): kwsub.update(_pop_params(kwargs, sig)) kwargs["aspect"] = kwsub.pop("aspect", None) # keyword conflict @@ -220,7 +226,7 @@ def subplots(*args, **kwargs): _parse_figsize(kwargs) rc_kw, rc_mode = _pop_rc(kwargs) kwsubs = _pop_props(kwargs, "patch") # e.g. 'color' - kwsubs.update(_pop_params(kwargs, pfigure.Figure._add_subplots)) + kwsubs.update(_pop_params(kwargs, pfigure._add_subplots)) kwsubs.update(_pop_params(kwargs, pgridspec.GridSpec._update_params)) for sig in paxes.Axes._format_signatures.values(): kwsubs.update(_pop_params(kwargs, sig)) From c861a21f59fdb5107d236cd31d736d92b5505a96 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 4 Jan 2026 19:16:37 +1000 Subject: [PATCH 7/9] refactor: Automate lazy loading and fix build error Refactored the lazy loading mechanism in ultraplot/__init__.py to be automated and convention-based. This simplifies the process of adding new modules and makes the system more maintainable. Fixed a documentation build error caused by the previous lazy loading implementation. Added documentation for the new lazy loading system in docs/lazy_loading.rst. --- docs/lazy_loading.rst | 61 +++++++++----- ultraplot/__init__.py | 190 +++++++++++++----------------------------- 2 files changed, 98 insertions(+), 153 deletions(-) diff --git a/docs/lazy_loading.rst b/docs/lazy_loading.rst index b1106b397..c861d9b32 100644 --- a/docs/lazy_loading.rst +++ b/docs/lazy_loading.rst @@ -6,30 +6,49 @@ Lazy Loading and Adding New Modules UltraPlot uses a lazy loading mechanism to improve import times. This means that submodules are not imported until they are actually used. This is controlled by the -`__getattr__` function in `ultraplot/__init__.py`. +:py:func:`__getattr__` function in `ultraplot/__init__.py`. -When adding a new submodule, you need to make sure it's compatible with the lazy -loader. Here's how to do it: +The lazy loading system is mostly automated. It works by scanning the `ultraplot` +directory for modules and exposing them based on conventions. -1. **Add the submodule to `_STAR_MODULES`:** In `ultraplot/__init__.py`, add the - name of your new submodule to the `_STAR_MODULES` tuple. This will make it - discoverable by the lazy loader. +**Convention-Based Loading** -2. **Add the submodule to `_MODULE_SOURCES`:** Also in `ultraplot/__init__.py`, - add an entry to the `_MODULE_SOURCES` dictionary that maps the name of your - submodule to its source file. +The automated system follows these rules: -3. **Exposing Callables:** If you want to expose a function or class from your - submodule as a top-level attribute of the `ultraplot` package (e.g., - `uplt.my_function`), you need to add an entry to the `_EXTRA_ATTRS` - dictionary. +1. **Single-Class Modules:** If a module `my_module.py` has an `__all__` + variable with a single class or function `MyCallable`, it will be exposed + at the top level as `uplt.my_module`. For example, since + `ultraplot/figure.py` has `__all__ = ['Figure']`, you can access the `Figure` + class with `uplt.figure`. - * To expose a function or class `MyFunction` from `my_module.py` as - `uplt.my_function`, add the following to `_EXTRA_ATTRS`: - `"my_function": ("my_module", "MyFunction")`. - * If you want to expose the entire submodule as a top-level attribute - (e.g., `uplt.my_module`), you can add: - `"my_module": ("my_module", None)`. +2. **Multi-Content Modules:** If a module has multiple items in `__all__` or no + `__all__`, the module itself will be exposed. For example, you can access + the `utils` module with :py:mod:`uplt.utils`. -By following these steps, you can ensure that your new module is correctly -integrated into the lazy loading system. +**Adding New Modules** + +When adding a new submodule, you usually don't need to modify `ultraplot/__init__.py`. +Simply follow these conventions: + +* If you want to expose a single class or function from your module as a + top-level attribute, set the `__all__` variable in your module to a list + containing just that callable's name. + +* If you want to expose the entire module, you can either use an `__all__` with + multiple items, or no `__all__` at all. + +**Handling Exceptions** + +For cases that don't fit the conventions, there is an exception-based +configuration. The `_LAZY_LOADING_EXCEPTIONS` dictionary in +`ultraplot/__init__.py` is used to manually map top-level attributes to +modules and their contents. + +You should only need to edit this dictionary if you are: + +* Creating an alias for a module (e.g., `crs` for `proj`). +* Exposing an internal variable (e.g., `colormaps` for `_cmap_database`). +* Exposing a submodule that doesn't follow the file/directory structure. + +By following these guidelines, your new module will be correctly integrated into +the lazy loading system. diff --git a/ultraplot/__init__.py b/ultraplot/__init__.py index 613721789..396cadd21 100644 --- a/ultraplot/__init__.py +++ b/ultraplot/__init__.py @@ -24,53 +24,8 @@ _ATTR_MAP = None _REGISTRY_ATTRS = None -_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", - "Figure", - ), # have to rename to keep the api backwards compatible - "constructor": ("constructor", None), - "ui": ("ui", None), - "demos": ("demos", None), +# Exceptions to the automated lazy loading +_LAZY_LOADING_EXCEPTIONS = { "crs": ("proj", None), "colormaps": ("colors", "_cmap_database"), "check_for_update": ("utils", "check_for_update"), @@ -84,41 +39,7 @@ "tests": ("tests", None), "rcsetup": ("internals", "rcsetup"), "warnings": ("internals", "warnings"), -} - -_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", - "colormaps", - "check_for_update", - "NORMS", - "LOCATORS", - "FORMATTERS", - "SCALES", - "PROJS", - "internals", - "externals", - "tests", - "rcsetup", - "warnings", - "pyplot", - "cartopy", - "basemap", - "legend", - "setup", + "figure": ("figure", "Figure"), } @@ -148,21 +69,38 @@ def _parse_all(path): return None -def _load_attr_map(): +def _discover_modules(): 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: + + for path in base.glob("*.py"): + if path.name.startswith("_") or path.name == "setup.py": continue - names = _parse_all(base / relpath) - if not names: + module_name = path.stem + names = _parse_all(path) + if names: + if len(names) == 1: + attr_map[module_name] = (module_name, names[0]) + else: + for name in names: + attr_map[name] = (module_name, name) + + for path in base.iterdir(): + if not path.is_dir() or path.name.startswith("_") or path.name == "tests": continue - for name in names: - attr_map[name] = module_name + if (path / "__init__.py").is_file(): + module_name = path.name + names = _parse_all(path / "__init__.py") + if names: + for name in names: + attr_map[name] = (module_name, name) + + attr_map[module_name] = (module_name, None) + _ATTR_MAP = attr_map @@ -244,7 +182,7 @@ def _setup(): def _resolve_extra(name): - module_name, attr = _EXTRA_ATTRS[name] + module_name, attr = _LAZY_LOADING_EXCEPTIONS[name] module = _import_module(module_name) value = module if attr is None else getattr(module, attr) globals()[name] = value @@ -277,15 +215,16 @@ def _load_all(): _setup() from .internals.benchmarks import _benchmark - names = set() - for module_name in _STAR_MODULES: - 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) + _discover_modules() + names = set(_ATTR_MAP.keys()) + + for name in names: + try: + __getattr__(name) + except AttributeError: + pass + + names.update(_LAZY_LOADING_EXCEPTIONS.keys()) with _benchmark("registries"): _build_registry_map() if _REGISTRY_ATTRS: @@ -324,23 +263,14 @@ def setup(*, eager=None): _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}") - if name in {"__version__", "version", "name"}: + if name in {"pytest_plugins", "__version__", "version", "name", "__all__"}: + if name == "__all__": + value = _load_all() + globals()["__all__"] = value + return value return globals()[name] - if name == "__all__": - value = _load_all() - globals()["__all__"] = value - return value + if name == "pyplot": import matplotlib.pyplot as pyplot @@ -364,23 +294,20 @@ def __getattr__(name): ) from err globals()[name] = basemap return basemap - if name in _EXTRA_ATTRS and name in _SETUP_SKIP: - return _resolve_extra(name) - if name in _EXTRA_ATTRS: - module_name, _ = _EXTRA_ATTRS[name] - if _needs_setup(name, module_name=module_name): - _setup() - _maybe_eager_import() + + if name in _LAZY_LOADING_EXCEPTIONS: + _setup() + _maybe_eager_import() return _resolve_extra(name) - _load_attr_map() + _discover_modules() if _ATTR_MAP and name in _ATTR_MAP: - 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) + module_name, attr_name = _ATTR_MAP[name] + _setup() + _maybe_eager_import() + + module = _import_module(module_name) + value = getattr(module, attr_name) if attr_name else module globals()[name] = value return value @@ -394,10 +321,9 @@ def __getattr__(name): def __dir__(): + _discover_modules() names = set(globals()) - _load_attr_map() if _ATTR_MAP: names.update(_ATTR_MAP) - names.update(_EXTRA_ATTRS) - names.update(_EXTRA_PUBLIC) + names.update(_LAZY_LOADING_EXCEPTIONS) return sorted(names) From 9f1e1ab60f261fc0665751f2e5752097781bb1c2 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 4 Jan 2026 19:34:39 +1000 Subject: [PATCH 8/9] update instructions --- docs/lazy_loading.rst | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/lazy_loading.rst b/docs/lazy_loading.rst index c861d9b32..32114a639 100644 --- a/docs/lazy_loading.rst +++ b/docs/lazy_loading.rst @@ -6,7 +6,7 @@ Lazy Loading and Adding New Modules UltraPlot uses a lazy loading mechanism to improve import times. This means that submodules are not imported until they are actually used. This is controlled by the -:py:func:`__getattr__` function in `ultraplot/__init__.py`. +:py:func:`ultraplot.__getattr__` function in :py:mod:`ultraplot`. The lazy loading system is mostly automated. It works by scanning the `ultraplot` directory for modules and exposing them based on conventions. @@ -15,33 +15,33 @@ directory for modules and exposing them based on conventions. The automated system follows these rules: -1. **Single-Class Modules:** If a module `my_module.py` has an `__all__` +1. **Single-Class Modules:** If a module `my_module.py` has an ``__all__`` variable with a single class or function `MyCallable`, it will be exposed - at the top level as `uplt.my_module`. For example, since - `ultraplot/figure.py` has `__all__ = ['Figure']`, you can access the `Figure` - class with `uplt.figure`. + at the top level as ``uplt.my_module``. For example, since + :py:mod:`ultraplot.figure` has ``__all__ = ['Figure']``, you can access the `Figure` + class with ``uplt.figure``. -2. **Multi-Content Modules:** If a module has multiple items in `__all__` or no - `__all__`, the module itself will be exposed. For example, you can access - the `utils` module with :py:mod:`uplt.utils`. +2. **Multi-Content Modules:** If a module has multiple items in ``__all__`` or no + ``__all__``, the module itself will be exposed. For example, you can access + the `utils` module with :py:mod:`ultraplot.utils`. **Adding New Modules** -When adding a new submodule, you usually don't need to modify `ultraplot/__init__.py`. +When adding a new submodule, you usually don't need to modify :py:mod:`ultraplot`. Simply follow these conventions: * If you want to expose a single class or function from your module as a - top-level attribute, set the `__all__` variable in your module to a list + top-level attribute, set the ``__all__`` variable in your module to a list containing just that callable's name. -* If you want to expose the entire module, you can either use an `__all__` with - multiple items, or no `__all__` at all. +* If you want to expose the entire module, you can either use an ``__all__`` with + multiple items, or no ``__all__`` at all. **Handling Exceptions** For cases that don't fit the conventions, there is an exception-based configuration. The `_LAZY_LOADING_EXCEPTIONS` dictionary in -`ultraplot/__init__.py` is used to manually map top-level attributes to +:py:mod:`ultraplot` is used to manually map top-level attributes to modules and their contents. You should only need to edit this dictionary if you are: From 93a90ae5134c76a7380ef98d9d5194cfd6ffa270 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 4 Jan 2026 19:53:22 +1000 Subject: [PATCH 9/9] fix: Correct broken links in documentation and refactor lazy loading Fixed broken links in the documentation by correcting the custom role. Refactored the lazy loading mechanism to be more automated and maintainable. Added documentation for the new lazy loading system. --- docs/sphinxext/custom_roles.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/sphinxext/custom_roles.py b/docs/sphinxext/custom_roles.py index f625d0835..05c2e781f 100644 --- a/docs/sphinxext/custom_roles.py +++ b/docs/sphinxext/custom_roles.py @@ -22,8 +22,8 @@ def _node_list(rawtext, text, inliner): refuri = "https://matplotlib.org/stable/tutorials/introductory/customizing.html" refuri = f"{refuri}?highlight={text}#the-matplotlibrc-file" else: - path = "../" * relsource[1].count("/") + "en/stable" - refuri = f"{path}/configuration.html?highlight={text}#table-of-settings" + path = "../" * relsource[1].count("/") + refuri = f"{path}configuration.html?highlight={text}#table-of-settings" node = nodes.Text(f"rc[{text!r}]" if "." in text else f"rc.{text}") ref = nodes.reference(rawtext, node, refuri=refuri) return [nodes.literal("", "", ref)]