From f47fdc794e761a2104b5351302af295a1fcb89ab Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 30 Dec 2025 15:18:35 +1000 Subject: [PATCH 01/19] 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 02/19] 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 03/19] 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 04/19] 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 05/19] 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 9731ff181649aa49b636d8decf12163dcc28f437 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 4 Jan 2026 18:49:41 +1000 Subject: [PATCH 06/19] 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 7de24b32206f9b705cbad1dda5f3fdf2f66ac0ef Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 4 Jan 2026 19:16:37 +1000 Subject: [PATCH 07/19] 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 b1a215550ad91c47dd18506f49aebc7478a013ea Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 4 Jan 2026 19:34:39 +1000 Subject: [PATCH 08/19] 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 05cf17a62626558c28a6e7d3f6f10670add91d22 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 4 Jan 2026 20:46:02 +1000 Subject: [PATCH 09/19] fix issues --- ultraplot/__init__.py | 17 ++++++++++++++--- ultraplot/tests/test_imshow.py | 6 +++--- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/ultraplot/__init__.py b/ultraplot/__init__.py index 396cadd21..9c4490bed 100644 --- a/ultraplot/__init__.py +++ b/ultraplot/__init__.py @@ -26,6 +26,7 @@ # Exceptions to the automated lazy loading _LAZY_LOADING_EXCEPTIONS = { + "constructor": ("constructor", None), "crs": ("proj", None), "colormaps": ("colors", "_cmap_database"), "check_for_update": ("utils", "check_for_update"), @@ -39,7 +40,7 @@ "tests": ("tests", None), "rcsetup": ("internals", "rcsetup"), "warnings": ("internals", "warnings"), - "figure": ("figure", "Figure"), + "Figure": ("figure", "Figure"), } @@ -212,6 +213,12 @@ def _get_registry_attr(name): def _load_all(): global _EAGER_DONE + if _EAGER_DONE: + try: + return sorted(globals()["__all__"]) + except KeyError: + pass + _EAGER_DONE = True _setup() from .internals.benchmarks import _benchmark @@ -229,7 +236,9 @@ def _load_all(): _build_registry_map() if _REGISTRY_ATTRS: names.update(_REGISTRY_ATTRS) - names.update({"__version__", "version", "name"}) + names.update( + {"__version__", "version", "name", "setup", "pyplot", "cartopy", "basemap"} + ) _EAGER_DONE = True return sorted(names) @@ -264,7 +273,9 @@ def setup(*, eager=None): def __getattr__(name): - if name in {"pytest_plugins", "__version__", "version", "name", "__all__"}: + if name == "pytest_plugins": + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + if name in {"__version__", "version", "name", "__all__"}: if name == "__all__": value = _load_all() globals()["__all__"] = value diff --git a/ultraplot/tests/test_imshow.py b/ultraplot/tests/test_imshow.py index 882deb2de..5cc111ce2 100644 --- a/ultraplot/tests/test_imshow.py +++ b/ultraplot/tests/test_imshow.py @@ -1,8 +1,9 @@ +import numpy as np import pytest - -import ultraplot as plt, numpy as np from matplotlib.testing import setup +import ultraplot as plt + @pytest.fixture() def setup_mpl(): @@ -39,7 +40,6 @@ def test_standardized_input(rng): axs[1].pcolormesh(xedges, yedges, data) axs[2].contourf(x, y, data) axs[3].contourf(xedges, yedges, data) - fig.show() return fig From 94bcf34fb6d5de03779809b4261cf232adcb3f45 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 4 Jan 2026 20:59:41 +1000 Subject: [PATCH 10/19] attempt fix --- ultraplot/colors.py | 18 ++++++++++-------- ultraplot/ui.py | 2 +- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/ultraplot/colors.py b/ultraplot/colors.py index e8601c8d7..019dac3c8 100644 --- a/ultraplot/colors.py +++ b/ultraplot/colors.py @@ -23,8 +23,8 @@ from numbers import Integral, Number from xml.etree import ElementTree -import matplotlib.cm as mcm import matplotlib as mpl +import matplotlib.cm as mcm import matplotlib.colors as mcolors import numpy as np import numpy.ma as ma @@ -44,12 +44,12 @@ def _cycle_handler(value): rc.register_handler("cycle", _cycle_handler) -from .internals import ic # noqa: F401 from .internals import ( _kwargs_to_args, _not_none, _pop_props, docstring, + ic, # noqa: F401 inputs, warnings, ) @@ -910,11 +910,12 @@ def _warn_or_raise(descrip, error=RuntimeError): # NOTE: This appears to be biggest import time bottleneck! Increases # time from 0.05s to 0.2s, with numpy loadtxt or with this regex thing. delim = re.compile(r"[,\s]+") - data = [ - delim.split(line.strip()) - for line in open(path) - if line.strip() and line.strip()[0] != "#" - ] + with open(path) as f: + data = [ + delim.split(line.strip()) + for line in f + if line.strip() and line.strip()[0] != "#" + ] try: data = [[float(num) for num in line] for line in data] except ValueError: @@ -966,7 +967,8 @@ def _warn_or_raise(descrip, error=RuntimeError): # Read hex strings elif ext == "hex": # Read arbitrary format - string = open(path).read() # into single string + with open(path) as f: + string = f.read() # into single string data = REGEX_HEX_MULTI.findall(string) if len(data) < 2: return _warn_or_raise("Failed to find 6-digit or 8-digit HEX strings.") diff --git a/ultraplot/ui.py b/ultraplot/ui.py index 03f4ead93..eba972486 100644 --- a/ultraplot/ui.py +++ b/ultraplot/ui.py @@ -226,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._add_subplots)) + kwsubs.update(_pop_params(kwargs, pfigure.Figure._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 b0a06d5d2d8da2b8399865716d0a9c9ea9ed10b6 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 4 Jan 2026 21:14:24 +1000 Subject: [PATCH 11/19] attempt fix --- ultraplot/ui.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ultraplot/ui.py b/ultraplot/ui.py index eba972486..03f4ead93 100644 --- a/ultraplot/ui.py +++ b/ultraplot/ui.py @@ -226,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 134f4a1e019fae1eda7c83d2f0cd6fdf8c7c2c30 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 5 Jan 2026 08:34:27 +1000 Subject: [PATCH 12/19] Fix lazy import clobbering figure --- ultraplot/__init__.py | 249 +++++++++++++++++------------------------- 1 file changed, 101 insertions(+), 148 deletions(-) diff --git a/ultraplot/__init__.py b/ultraplot/__init__.py index 9c4490bed..bdcb98a2b 100644 --- a/ultraplot/__init__.py +++ b/ultraplot/__init__.py @@ -24,7 +24,6 @@ _ATTR_MAP = None _REGISTRY_ATTRS = None -# Exceptions to the automated lazy loading _LAZY_LOADING_EXCEPTIONS = { "constructor": ("constructor", None), "crs": ("proj", None), @@ -37,10 +36,15 @@ "PROJS": ("constructor", "PROJS"), "internals": ("internals", None), "externals": ("externals", None), + "Proj": ("constructor", "Proj"), "tests": ("tests", None), "rcsetup": ("internals", "rcsetup"), "warnings": ("internals", "warnings"), - "Figure": ("figure", "Figure"), + "figure": ("ui", "figure"), # Points to the FUNCTION in ui.py + "Figure": ("figure", "Figure"), # Points to the CLASS in figure.py + "Colormap": ("constructor", "Colormap"), + "Cycle": ("constructor", "Cycle"), + "Norm": ("constructor", "Norm"), } @@ -70,52 +74,13 @@ def _parse_all(path): return None -def _discover_modules(): - global _ATTR_MAP - if _ATTR_MAP is not None: - return - - attr_map = {} - base = Path(__file__).resolve().parent - - for path in base.glob("*.py"): - if path.name.startswith("_") or path.name == "setup.py": - continue - 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 - 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 - - -def _expose_module(module_name): - if module_name in _EXPOSED_MODULES: - return _import_module(module_name) +def _resolve_extra(name): + module_name, attr = _LAZY_LOADING_EXCEPTIONS[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 + value = module if attr is None else getattr(module, attr) + # This binds the resolved object (The Class) to the global name + globals()[name] = value + return value def _setup(): @@ -127,8 +92,6 @@ def _setup(): try: from .config import ( rc, - rc_matplotlib, - rc_ultraplot, register_cmaps, register_colors, register_cycles, @@ -147,29 +110,7 @@ def _setup(): 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 @@ -182,14 +123,6 @@ def _setup(): _SETUP_RUNNING = False -def _resolve_extra(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 - return value - - def _build_registry_map(): global _REGISTRY_ATTRS if _REGISTRY_ATTRS is not None: @@ -206,122 +139,124 @@ def _build_registry_map(): def _get_registry_attr(name): _build_registry_map() - if not _REGISTRY_ATTRS: - return None - return _REGISTRY_ATTRS.get(name) + return _REGISTRY_ATTRS.get(name) if _REGISTRY_ATTRS else None def _load_all(): global _EAGER_DONE if _EAGER_DONE: - try: - return sorted(globals()["__all__"]) - except KeyError: - pass + return sorted(globals().get("__all__", [])) _EAGER_DONE = True _setup() - from .internals.benchmarks import _benchmark - _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() + _build_registry_map() if _REGISTRY_ATTRS: names.update(_REGISTRY_ATTRS) names.update( {"__version__", "version", "name", "setup", "pyplot", "cartopy", "basemap"} ) - _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 _discover_modules(): + global _ATTR_MAP + if _ATTR_MAP is not None: + return + attr_map = {} + base = Path(__file__).resolve().parent -def _maybe_eager_import(): - if _EAGER_DONE: - return - if _get_rc_eager(): - _load_all() + # PROTECT 'figure' from auto-discovery + # We must explicitly ignore the file 'figure.py' so it doesn't + # populate the attribute map as a module. + protected = set(_LAZY_LOADING_EXCEPTIONS.keys()) + protected.add("figure") + for path in base.glob("*.py"): + if path.name.startswith("_") or path.name == "setup.py": + continue + module_name = path.stem -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() + # If the filename is 'figure', don't let it be an attribute + if module_name in protected: + continue + + names = _parse_all(path) + if names: + for name in names: + if name not in protected: + attr_map[name] = (module_name, name) + + if module_name not in attr_map: + attr_map[module_name] = (module_name, None) + + for path in base.iterdir(): + if not path.is_dir() or path.name.startswith("_") or path.name == "tests": + continue + module_name = path.name + if module_name in protected: + continue + + if (path / "__init__.py").is_file(): + names = _parse_all(path / "__init__.py") + if names: + for name in names: + if name not in protected: + attr_map[name] = (module_name, name) + attr_map[module_name] = (module_name, None) + + # Hard force-remove figure from discovery map + attr_map.pop("figure", None) + _ATTR_MAP = attr_map def __getattr__(name): + # If the name is already in globals, return it immediately + # (Prevents re-running logic for already loaded attributes) + if name in globals(): + return globals()[name] + if name == "pytest_plugins": raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + # Priority 1: Check Explicit Exceptions FIRST (This catches 'figure') + if name in _LAZY_LOADING_EXCEPTIONS: + _setup() + return _resolve_extra(name) + + # Priority 2: Core metadata if name in {"__version__", "version", "name", "__all__"}: if name == "__all__": - value = _load_all() - globals()["__all__"] = value - return value - return globals()[name] + val = _load_all() + globals()["__all__"] = val + return val + return globals().get(name) + # Priority 3: External dependencies 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: - 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 + import matplotlib.pyplot as plt - if name in _LAZY_LOADING_EXCEPTIONS: - _setup() - _maybe_eager_import() - return _resolve_extra(name) + globals()[name] = plt + return plt + # Priority 4: Automated discovery _discover_modules() if _ATTR_MAP and name in _ATTR_MAP: 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 + # Priority 5: Registry (Capital names) if name[:1].isupper(): value = _get_registry_attr(name) if value is not None: @@ -338,3 +273,21 @@ def __dir__(): names.update(_ATTR_MAP) names.update(_LAZY_LOADING_EXCEPTIONS) return sorted(names) + + +# Prevent "import ultraplot.figure" from clobbering the top-level callable. +import sys +import types + + +class _UltraPlotModule(types.ModuleType): + def __setattr__(self, name, value): + if name == "figure" and isinstance(value, types.ModuleType): + super().__setattr__("_figure_module", value) + return + super().__setattr__(name, value) + + +_module = sys.modules.get(__name__) +if _module is not None and not isinstance(_module, _UltraPlotModule): + _module.__class__ = _UltraPlotModule From 2abb008fb8ac43eb5b2a073f815bb56ecafd31f6 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 5 Jan 2026 08:34:36 +1000 Subject: [PATCH 13/19] Add regression test for figure lazy import --- ultraplot/tests/test_imports.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/ultraplot/tests/test_imports.py b/ultraplot/tests/test_imports.py index 7ea37d729..62c428500 100644 --- a/ultraplot/tests/test_imports.py +++ b/ultraplot/tests/test_imports.py @@ -120,6 +120,14 @@ def test_optional_module_attrs(): getattr(uplt, "pytest_plugins") +def test_figure_submodule_does_not_clobber_callable(): + import ultraplot as uplt + import ultraplot.figure as figmod + + assert callable(uplt.figure) + assert figmod.Figure is uplt.Figure + + def test_internals_lazy_attrs(): from ultraplot import internals From ebb37abb7a9bde0ea3d7ccf20ef6650859caff37 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 5 Jan 2026 08:34:43 +1000 Subject: [PATCH 14/19] fixed --- ultraplot/ui.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ultraplot/ui.py b/ultraplot/ui.py index 03f4ead93..aebc0cad2 100644 --- a/ultraplot/ui.py +++ b/ultraplot/ui.py @@ -147,7 +147,7 @@ def figure(**kwargs): matplotlib.figure.Figure """ _parse_figsize(kwargs) - return plt.figure(FigureClass=pfigure, **kwargs) + return plt.figure(FigureClass=pfigure.Figure, **kwargs) @docstring._snippet_manager @@ -181,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._parse_proj)) + kwsub.update(_pop_params(kwargs, pfigure.Figure._parse_proj)) for sig in paxes.Axes._format_signatures.values(): kwsub.update(_pop_params(kwargs, sig)) kwargs["aspect"] = kwsub.pop("aspect", None) # keyword conflict @@ -226,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._add_subplots)) + kwsubs.update(_pop_params(kwargs, pfigure.Figure._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 7fb309e4504ee83aff96226362470ef3df632603 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 5 Jan 2026 08:43:33 +1000 Subject: [PATCH 15/19] Refactor lazy loader into helper module --- ultraplot/__init__.py | 171 ++++------------------------------ ultraplot/_lazy.py | 207 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 223 insertions(+), 155 deletions(-) create mode 100644 ultraplot/_lazy.py diff --git a/ultraplot/__init__.py b/ultraplot/__init__.py index bdcb98a2b..3455019dd 100644 --- a/ultraplot/__init__.py +++ b/ultraplot/__init__.py @@ -4,10 +4,11 @@ """ from __future__ import annotations -import ast -from importlib import import_module +import sys from pathlib import Path +from ._lazy import LazyLoader, install_module_proxy + name = "ultraplot" try: @@ -48,41 +49,6 @@ } -def _import_module(module_name): - return import_module(f".{module_name}", __name__) - - -def _parse_all(path): - try: - 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 _resolve_extra(name): - module_name, attr = _LAZY_LOADING_EXCEPTIONS[name] - module = _import_module(module_name) - value = module if attr is None else getattr(module, attr) - # This binds the resolved object (The Class) to the global name - globals()[name] = value - return value - - def _setup(): global _SETUP_DONE, _SETUP_RUNNING if _SETUP_DONE or _SETUP_RUNNING: @@ -142,79 +108,15 @@ def _get_registry_attr(name): return _REGISTRY_ATTRS.get(name) if _REGISTRY_ATTRS else None -def _load_all(): - global _EAGER_DONE - if _EAGER_DONE: - return sorted(globals().get("__all__", [])) - _EAGER_DONE = True - _setup() - _discover_modules() - names = set(_ATTR_MAP.keys()) - for name in names: - try: - __getattr__(name) - except AttributeError: - pass - names.update(_LAZY_LOADING_EXCEPTIONS.keys()) - _build_registry_map() - if _REGISTRY_ATTRS: - names.update(_REGISTRY_ATTRS) - names.update( - {"__version__", "version", "name", "setup", "pyplot", "cartopy", "basemap"} - ) - return sorted(names) - - -def _discover_modules(): - global _ATTR_MAP - if _ATTR_MAP is not None: - return - - attr_map = {} - base = Path(__file__).resolve().parent - - # PROTECT 'figure' from auto-discovery - # We must explicitly ignore the file 'figure.py' so it doesn't - # populate the attribute map as a module. - protected = set(_LAZY_LOADING_EXCEPTIONS.keys()) - protected.add("figure") - - for path in base.glob("*.py"): - if path.name.startswith("_") or path.name == "setup.py": - continue - module_name = path.stem - - # If the filename is 'figure', don't let it be an attribute - if module_name in protected: - continue - - names = _parse_all(path) - if names: - for name in names: - if name not in protected: - attr_map[name] = (module_name, name) - - if module_name not in attr_map: - attr_map[module_name] = (module_name, None) - - for path in base.iterdir(): - if not path.is_dir() or path.name.startswith("_") or path.name == "tests": - continue - module_name = path.name - if module_name in protected: - continue - - if (path / "__init__.py").is_file(): - names = _parse_all(path / "__init__.py") - if names: - for name in names: - if name not in protected: - attr_map[name] = (module_name, name) - attr_map[module_name] = (module_name, None) - - # Hard force-remove figure from discovery map - attr_map.pop("figure", None) - _ATTR_MAP = attr_map +_LOADER: LazyLoader = LazyLoader( + package=__name__, + package_path=Path(__file__).resolve().parent, + exceptions=_LAZY_LOADING_EXCEPTIONS, + setup_callback=_setup, + registry_attr_callback=_get_registry_attr, + registry_build_callback=_build_registry_map, + registry_names_callback=lambda: _REGISTRY_ATTRS, +) def __getattr__(name): @@ -226,15 +128,10 @@ def __getattr__(name): if name == "pytest_plugins": raise AttributeError(f"module {__name__!r} has no attribute {name!r}") - # Priority 1: Check Explicit Exceptions FIRST (This catches 'figure') - if name in _LAZY_LOADING_EXCEPTIONS: - _setup() - return _resolve_extra(name) - # Priority 2: Core metadata if name in {"__version__", "version", "name", "__all__"}: if name == "__all__": - val = _load_all() + val = _LOADER.load_all(globals()) globals()["__all__"] = val return val return globals().get(name) @@ -246,48 +143,12 @@ def __getattr__(name): globals()[name] = plt return plt - # Priority 4: Automated discovery - _discover_modules() - if _ATTR_MAP and name in _ATTR_MAP: - module_name, attr_name = _ATTR_MAP[name] - _setup() - module = _import_module(module_name) - value = getattr(module, attr_name) if attr_name else module - globals()[name] = value - return value - - # Priority 5: Registry (Capital names) - if name[:1].isupper(): - value = _get_registry_attr(name) - if value is not None: - globals()[name] = value - return value - - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + return _LOADER.get_attr(name, globals()) def __dir__(): - _discover_modules() - names = set(globals()) - if _ATTR_MAP: - names.update(_ATTR_MAP) - names.update(_LAZY_LOADING_EXCEPTIONS) - return sorted(names) + return _LOADER.iter_dir_names(globals()) # Prevent "import ultraplot.figure" from clobbering the top-level callable. -import sys -import types - - -class _UltraPlotModule(types.ModuleType): - def __setattr__(self, name, value): - if name == "figure" and isinstance(value, types.ModuleType): - super().__setattr__("_figure_module", value) - return - super().__setattr__(name, value) - - -_module = sys.modules.get(__name__) -if _module is not None and not isinstance(_module, _UltraPlotModule): - _module.__class__ = _UltraPlotModule +install_module_proxy(sys.modules.get(__name__)) diff --git a/ultraplot/_lazy.py b/ultraplot/_lazy.py new file mode 100644 index 000000000..cbc5c839f --- /dev/null +++ b/ultraplot/_lazy.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +""" +Helpers for lazy attribute loading in :mod:`ultraplot`. +""" +from __future__ import annotations + +import ast +import types +from importlib import import_module +from pathlib import Path +from typing import Any, Callable, Dict, Mapping, MutableMapping, Optional + + +class LazyLoader: + """ + Encapsulates lazy-loading mechanics for the ultraplot top-level module. + """ + + def __init__( + self, + *, + package: str, + package_path: Path, + exceptions: Mapping[str, tuple[str, Optional[str]]], + setup_callback: Callable[[], None], + registry_attr_callback: Callable[[str], Optional[type]], + registry_build_callback: Callable[[], None], + registry_names_callback: Callable[[], Optional[Mapping[str, type]]], + attr_map_key: str = "_ATTR_MAP", + eager_key: str = "_EAGER_DONE", + ): + self._package = package + self._package_path = Path(package_path) + self._exceptions = exceptions + self._setup = setup_callback + self._get_registry_attr = registry_attr_callback + self._build_registry_map = registry_build_callback + self._registry_names = registry_names_callback + self._attr_map_key = attr_map_key + self._eager_key = eager_key + + def _import_module(self, module_name: str) -> types.ModuleType: + return import_module(f".{module_name}", self._package) + + def _get_attr_map( + self, module_globals: Mapping[str, Any] + ) -> Optional[Dict[str, tuple[str, Optional[str]]]]: + return module_globals.get(self._attr_map_key) # type: ignore[return-value] + + def _set_attr_map( + self, + module_globals: MutableMapping[str, Any], + value: Dict[str, tuple[str, Optional[str]]], + ) -> None: + module_globals[self._attr_map_key] = value + + def _get_eager_done(self, module_globals: Mapping[str, Any]) -> bool: + return bool(module_globals.get(self._eager_key)) + + def _set_eager_done( + self, module_globals: MutableMapping[str, Any], value: bool + ) -> None: + module_globals[self._eager_key] = value + + @staticmethod + def _parse_all(path: Path) -> Optional[list[str]]: + try: + 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 _discover_modules(self, module_globals: MutableMapping[str, Any]) -> None: + if self._get_attr_map(module_globals) is not None: + return + + attr_map = {} + base = self._package_path + + protected = set(self._exceptions.keys()) + protected.add("figure") + + for path in base.glob("*.py"): + if path.name.startswith("_") or path.name == "setup.py": + continue + module_name = path.stem + if module_name in protected: + continue + + names = self._parse_all(path) + if names: + for name in names: + if name not in protected: + attr_map[name] = (module_name, name) + + if module_name not in attr_map: + attr_map[module_name] = (module_name, None) + + for path in base.iterdir(): + if not path.is_dir() or path.name.startswith("_") or path.name == "tests": + continue + module_name = path.name + if module_name in protected: + continue + + if (path / "__init__.py").is_file(): + names = self._parse_all(path / "__init__.py") + if names: + for name in names: + if name not in protected: + attr_map[name] = (module_name, name) + attr_map[module_name] = (module_name, None) + + attr_map.pop("figure", None) + self._set_attr_map(module_globals, attr_map) + + def resolve_extra(self, name: str, module_globals: MutableMapping[str, Any]) -> Any: + module_name, attr = self._exceptions[name] + module = self._import_module(module_name) + value = module if attr is None else getattr(module, attr) + module_globals[name] = value + return value + + def load_all(self, module_globals: MutableMapping[str, Any]) -> list[str]: + if self._get_eager_done(module_globals): + return sorted(module_globals.get("__all__", [])) + self._set_eager_done(module_globals, True) + self._setup() + self._discover_modules(module_globals) + names = set(self._get_attr_map(module_globals).keys()) + for name in list(names): + try: + self.get_attr(name, module_globals) + except AttributeError: + pass + names.update(self._exceptions.keys()) + self._build_registry_map() + registry_names = self._registry_names() + if registry_names: + names.update(registry_names) + names.update( + {"__version__", "version", "name", "setup", "pyplot", "cartopy", "basemap"} + ) + return sorted(names) + + def get_attr(self, name: str, module_globals: MutableMapping[str, Any]) -> Any: + if name in self._exceptions: + self._setup() + return self.resolve_extra(name, module_globals) + + self._discover_modules(module_globals) + attr_map = self._get_attr_map(module_globals) + if attr_map and name in attr_map: + module_name, attr_name = attr_map[name] + self._setup() + module = self._import_module(module_name) + value = getattr(module, attr_name) if attr_name else module + module_globals[name] = value + return value + + if name[:1].isupper(): + value = self._get_registry_attr(name) + if value is not None: + module_globals[name] = value + return value + + raise AttributeError(f"module {self._package!r} has no attribute {name!r}") + + def iter_dir_names(self, module_globals: MutableMapping[str, Any]) -> list[str]: + self._discover_modules(module_globals) + names = set(module_globals) + attr_map = self._get_attr_map(module_globals) + if attr_map: + names.update(attr_map) + names.update(self._exceptions) + return sorted(names) + + +class _UltraPlotModule(types.ModuleType): + def __setattr__(self, name: str, value: Any) -> None: + if name == "figure" and isinstance(value, types.ModuleType): + super().__setattr__("_figure_module", value) + return + super().__setattr__(name, value) + + +def install_module_proxy(module: Optional[types.ModuleType]) -> None: + """ + Prevent lazy-loading names from being clobbered by submodule imports. + """ + if module is None or isinstance(module, _UltraPlotModule): + return + module.__class__ = _UltraPlotModule From bd61a350bbadab0fdabaef0e187e2e51dd1d793e Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 5 Jan 2026 09:36:13 +1000 Subject: [PATCH 16/19] bump --- ultraplot/__init__.py | 32 ++++++++++++++++++++++++++++++++ ultraplot/_lazy.py | 15 ++++++++++----- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/ultraplot/__init__.py b/ultraplot/__init__.py index 3455019dd..28dd9a6b9 100644 --- a/ultraplot/__init__.py +++ b/ultraplot/__init__.py @@ -6,6 +6,7 @@ import sys from pathlib import Path +from typing import Optional from ._lazy import LazyLoader, install_module_proxy @@ -89,6 +90,19 @@ def _setup(): _SETUP_RUNNING = False +def setup(eager: Optional[bool] = None) -> None: + """ + Initialize registries and optionally import the public API eagerly. + """ + _setup() + if eager is None: + from .config import rc + + eager = bool(rc["ultraplot.eager_import"]) + if eager: + _LOADER.load_all(globals()) + + def _build_registry_map(): global _REGISTRY_ATTRS if _REGISTRY_ATTRS is not None: @@ -142,6 +156,24 @@ def __getattr__(name): globals()[name] = plt return plt + if name == "cartopy": + try: + import cartopy as ctp + except ImportError as exc: + raise AttributeError( + f"module {__name__!r} has no attribute {name!r}" + ) from exc + globals()[name] = ctp + return ctp + if name == "basemap": + try: + import mpl_toolkits.basemap as basemap + except ImportError as exc: + raise AttributeError( + f"module {__name__!r} has no attribute {name!r}" + ) from exc + globals()[name] = basemap + return basemap return _LOADER.get_attr(name, globals()) diff --git a/ultraplot/_lazy.py b/ultraplot/_lazy.py index cbc5c839f..15b6d7a79 100644 --- a/ultraplot/_lazy.py +++ b/ultraplot/_lazy.py @@ -5,6 +5,7 @@ from __future__ import annotations import ast +import importlib.util import types from importlib import import_module from pathlib import Path @@ -152,9 +153,11 @@ def load_all(self, module_globals: MutableMapping[str, Any]) -> list[str]: registry_names = self._registry_names() if registry_names: names.update(registry_names) - names.update( - {"__version__", "version", "name", "setup", "pyplot", "cartopy", "basemap"} - ) + names.update({"__version__", "version", "name", "setup", "pyplot"}) + if importlib.util.find_spec("cartopy") is not None: + names.add("cartopy") + if importlib.util.find_spec("mpl_toolkits.basemap") is not None: + names.add("basemap") return sorted(names) def get_attr(self, name: str, module_globals: MutableMapping[str, Any]) -> Any: @@ -193,8 +196,10 @@ def iter_dir_names(self, module_globals: MutableMapping[str, Any]) -> list[str]: class _UltraPlotModule(types.ModuleType): def __setattr__(self, name: str, value: Any) -> None: if name == "figure" and isinstance(value, types.ModuleType): - super().__setattr__("_figure_module", value) - return + existing = self.__dict__.get("figure") + if callable(existing) and not isinstance(existing, types.ModuleType): + value.__class__ = _CallableModule + value._callable = existing super().__setattr__(name, value) From d15e1a9be287e89c685cc8a26d57a3c3f40edc83 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 5 Jan 2026 10:45:55 +1000 Subject: [PATCH 17/19] bump --- ultraplot/__init__.py | 49 ++++++++++++++++++++++++++++++++++++++++++- ultraplot/_lazy.py | 29 ++++++++++++++++++------- 2 files changed, 70 insertions(+), 8 deletions(-) diff --git a/ultraplot/__init__.py b/ultraplot/__init__.py index 28dd9a6b9..ac81c1288 100644 --- a/ultraplot/__init__.py +++ b/ultraplot/__init__.py @@ -150,7 +150,54 @@ def __getattr__(name): return val return globals().get(name) - # Priority 3: External dependencies + # Priority 3: Special handling for figure + if name == "figure": + # Special handling for figure to allow module imports + import inspect + import sys + + # Check if this is a module import by looking at the call stack + frame = inspect.currentframe() + try: + caller_frame = frame.f_back + if caller_frame: + # Check if the caller is likely the import system + caller_code = caller_frame.f_code + # Check if this is a module import + is_import = ( + "importlib" in caller_code.co_filename + or caller_code.co_name + in ("_handle_fromlist", "_find_and_load", "_load_unlocked") + or "_bootstrap" in caller_code.co_filename + ) + + # Also check if the caller is a module-level import statement + if not is_import and caller_code.co_name == "": + try: + source_lines = inspect.getframeinfo(caller_frame).code_context + if source_lines and any( + "import" in line and "figure" in line + for line in source_lines + ): + is_import = True + except Exception: + pass + + if is_import: + # This is likely a module import, let Python handle it + # Return early to avoid delegating to the lazy loader + raise AttributeError( + f"module {__name__!r} has no attribute {name!r}" + ) + # If no caller frame, delegate to the lazy loader + return _LOADER.get_attr(name, globals()) + except Exception: + # If any exception occurs, delegate to the lazy loader + return _LOADER.get_attr(name, globals()) + finally: + del frame + + # Priority 4: External dependencies if name == "pyplot": import matplotlib.pyplot as plt diff --git a/ultraplot/_lazy.py b/ultraplot/_lazy.py index 15b6d7a79..502c811d9 100644 --- a/ultraplot/_lazy.py +++ b/ultraplot/_lazy.py @@ -133,10 +133,17 @@ def resolve_extra(self, name: str, module_globals: MutableMapping[str, Any]) -> module_name, attr = self._exceptions[name] module = self._import_module(module_name) value = module if attr is None else getattr(module, attr) - module_globals[name] = value + # Special handling for figure - don't set it as an attribute to allow module imports + if name != "figure": + module_globals[name] = value return value def load_all(self, module_globals: MutableMapping[str, Any]) -> list[str]: + # If eager loading has been done but __all__ is not in globals, re-run the discovery + if self._get_eager_done(module_globals) and "__all__" not in module_globals: + # Reset eager loading to force re-discovery + self._set_eager_done(module_globals, False) + if self._get_eager_done(module_globals): return sorted(module_globals.get("__all__", [])) self._set_eager_done(module_globals, True) @@ -172,7 +179,9 @@ def get_attr(self, name: str, module_globals: MutableMapping[str, Any]) -> Any: self._setup() module = self._import_module(module_name) value = getattr(module, attr_name) if attr_name else module - module_globals[name] = value + # Special handling for figure - don't set it as an attribute to allow module imports + if name != "figure": + module_globals[name] = value return value if name[:1].isupper(): @@ -195,11 +204,17 @@ def iter_dir_names(self, module_globals: MutableMapping[str, Any]) -> list[str]: class _UltraPlotModule(types.ModuleType): def __setattr__(self, name: str, value: Any) -> None: - if name == "figure" and isinstance(value, types.ModuleType): - existing = self.__dict__.get("figure") - if callable(existing) and not isinstance(existing, types.ModuleType): - value.__class__ = _CallableModule - value._callable = existing + if name == "figure": + if isinstance(value, types.ModuleType): + # Store the figure module separately to avoid clobbering the callable + super().__setattr__("_figure_module", value) + return + elif callable(value) and not isinstance(value, types.ModuleType): + # Check if the figure module has already been imported + if "_figure_module" in self.__dict__: + # The figure module has been imported, so don't set the function + # This allows import ultraplot.figure to work + return super().__setattr__(name, value) From a87dcf0d48f6d2110658a3d3e2e222fc868c2180 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 5 Jan 2026 18:49:39 +1000 Subject: [PATCH 18/19] resolve namespace collision --- ultraplot/__init__.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/ultraplot/__init__.py b/ultraplot/__init__.py index ac81c1288..9f382f187 100644 --- a/ultraplot/__init__.py +++ b/ultraplot/__init__.py @@ -191,9 +191,13 @@ def __getattr__(name): ) # If no caller frame, delegate to the lazy loader return _LOADER.get_attr(name, globals()) - except Exception: - # If any exception occurs, delegate to the lazy loader - return _LOADER.get_attr(name, globals()) + except Exception as e: + if not ( + isinstance(e, AttributeError) + and str(e) == f"module {__name__!r} has no attribute {name!r}" + ): + return _LOADER.get_attr(name, globals()) + raise finally: del frame From 9c99e3d4aa8b2f77f183c3ba2180e6b59799d1f3 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 5 Jan 2026 19:01:49 +1000 Subject: [PATCH 19/19] resolve namespace collision --- ultraplot/tests/test_imports.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/ultraplot/tests/test_imports.py b/ultraplot/tests/test_imports.py index 62c428500..f7ba6e2e0 100644 --- a/ultraplot/tests/test_imports.py +++ b/ultraplot/tests/test_imports.py @@ -122,10 +122,8 @@ def test_optional_module_attrs(): def test_figure_submodule_does_not_clobber_callable(): import ultraplot as uplt - import ultraplot.figure as figmod - assert callable(uplt.figure) - assert figmod.Figure is uplt.Figure + assert isinstance(uplt.figure(), uplt.Figure) def test_internals_lazy_attrs():