From f47fdc794e761a2104b5351302af295a1fcb89ab Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 30 Dec 2025 15:18:35 +1000 Subject: [PATCH 01/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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(): From a307ef991f93eaa8184c6c079653c87114276b75 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 8 Jan 2026 20:01:34 +1000 Subject: [PATCH 20/21] mv docs --- CONTRIBUTING.rst | 301 +----------------------------------------- docs/contributing.rst | 301 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 301 insertions(+), 301 deletions(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index d455ff729..2c73e857a 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -1,300 +1 @@ -.. _contrib: - -================== -How to contribute? -================== - -Contributions of any size are greatly appreciated! You can -make a significant impact on UltraPlot by just using it and -reporting `issues `__. - -The following sections cover some general guidelines -regarding UltraPlot development for new contributors. Feel -free to suggest improvements or changes to this workflow. - -.. _contrib_features: - -Feature requests -================ - -We are eager to hear your requests for new features and -suggestions regarding the current API. You can submit these as -`issues `__ on Github. -Please make sure to explain in detail how the feature should work and keep the scope as -narrow as possible. This will make it easier to implement in small pull requests. - -If you are feeling inspired, feel free to add the feature yourself and -submit a pull request! - -.. _contrib_bugs: - -Report bugs -=========== - -Bugs should be reported using the Github -`issues `__ page. When reporting a -bug, please follow the template message and include copy-pasteable code that -reproduces the issue. This is critical for contributors to fix the bug quickly. - -If you can figure out how to fix the bug yourself, feel free to submit -a pull request. - -.. _contrib_tets: - -Write tests -=========== - -Most modern python packages have ``test_*.py`` scripts that are run by `pytest` -via continuous integration services like `Travis `__ -whenever commits are pushed to the repository. Currently, UltraPlot's continuous -integration includes only the examples that appear on the website User Guide (see -`.travis.yml`), and `Casper van Elteren ` runs additional tests -manually. This approach leaves out many use cases and leaves the project more -vulnerable to bugs. Improving ultraplot's continuous integration using `pytest` -and `pytest-mpl` is a *critical* item on our to-do list. - -If you can think of a useful test for ultraplot, feel free to submit a pull request. -Your test will be used in the future. - -.. _contrib_docs: - -Write documentation -=================== - -Documentation can always be improved. For minor changes, you can edit docstrings and -documentation files directly in the GitHub web interface without using a local copy. - -* The docstrings are written in - `reStructuredText `__ - with `numpydoc `__ style headers. - They are embedded in the :ref:`API reference` section using a - `fork of sphinx-automodapi `__. -* Other sections are written using ``.rst`` files and ``.py`` files in the ``docs`` - folder. The ``.py`` files are translated to python notebooks via - `jupytext `__ then embedded in - the User Guide using `nbsphinx `__. -* The `default ReST role `__ - is ``py:obj``. Please include ``py:obj`` links whenever discussing particular - functions or classes -- for example, if you are discussing the - :func:`~ultraplot.axes.Axes.format` method, please write - ```:func:`~ultraplot.axes.Axes.format` ``` rather than ``format``. ultraplot also uses - `intersphinx `__ - so you can link to external packages like matplotlib and cartopy. - -To build the documentation locally, use the following commands: - -.. code:: bash - - cd docs - # Install dependencies to the base conda environment.. - conda env update -f environment.yml - # ...or create a new conda environment - # conda env create -n ultraplot-dev --file docs/environment.yml - # source activate ultraplot-dev - # Create HTML documentation - make html - -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 -======================= - -New features and bug fixes should be addressed using pull requests. -Here is a quick guide for submitting pull requests: - -#. Fork the - `ultraplot GitHub repository `__. It's - fine to keep "ultraplot" as the fork repository name because it will live - under your account. - -#. Clone your fork locally using `git `__, connect your - repository to the upstream (main project), and create a branch as follows: - - .. code-block:: bash - - git clone git@github.com:YOUR_GITHUB_USERNAME/ultraplot.git - cd ultraplot - git remote add upstream git@github.com:ultraplot/ultraplot.git - git checkout -b your-branch-name master - - If you need some help with git, follow the - `quick start guide `__. - -#. Make an editable install of ultraplot by running: - - .. code-block:: bash - - pip install -e . - - This way ``import ultraplot`` imports your local copy, - rather than the stable version you last downloaded from PyPi. - You can ``import ultraplot; print(ultraplot.__file__)`` to verify your - local copy has been imported. - -#. Install `pre-commit `__ and its hook on the - ``ultraplot`` repo as follows: - - .. code-block:: bash - - pip install --user pre-commit - pre-commit install - - Afterwards ``pre-commit`` will run whenever you commit. - `pre-commit `__ is a framework for managing and - maintaining multi-language pre-commit hooks to - ensure code-style and code formatting is consistent. - -#. You can now edit your local working copy as necessary. Please follow - the `PEP8 style guide `__. - and try to generally adhere to the - `black `__ subset of the PEP8 style - (we may automatically enforce the "black" style in the future). - When committing, ``pre-commit`` will modify the files as needed, - or will generally be clear about what you need to do to pass the pre-commit test. - - Please break your edits up into reasonably sized commits: - - - .. code-block:: bash - - git commit -a -m "" - git push -u - - The commit messages should be short, sweet, and use the imperative mood, - e.g. "Fix bug" instead of "Fixed bug". - - .. - #. Run all the tests. Now running tests is as simple as issuing this command: - .. code-block:: bash - coverage run --source ultraplot -m py.test - This command will run tests via the ``pytest`` tool against Python 3.7. - -#. If you intend to make changes or add examples to the user guide, you may want to - open the ``docs/*.py`` files as - `jupyter notebooks `__. - This can be done by - `installing jupytext `__, - starting a jupyter session, and opening the ``.py`` files from the ``Files`` page. - -#. When you're finished, create a new changelog entry in ``CHANGELOG.rst``. - The entry should be entered as: - - .. code-block:: - - * (:pr:``) by ``_. - - where ```` is the description of the PR related to the change, - ```` is the pull request number, and ```` is your first - and last name. Make sure to add yourself to the list of authors at the end of - ``CHANGELOG.rst`` and the list of contributors in ``docs/authors.rst``. - Also make sure to add the changelog entry under one of the valid - ``.. rubric:: `` headings listed at the top of ``CHANGELOG.rst``. - -#. Finally, submit a pull request through the GitHub website using this data: - - .. code-block:: - - head-fork: YOUR_GITHUB_USERNAME/ultraplot - compare: your-branch-name - - base-fork: ultraplot/ultraplot - base: master - -Note that you can create the pull request before you're finished with your -feature addition or bug fix. The PR will update as you add more commits. UltraPlot -developers and contributors can then review your code and offer suggestions. - -.. _contrib_release: - -Release procedure -================= -Ultraplot follows EffVer (`Effectual Versioning `_). Changes to the version number ``X.Y.Z`` will reflect the effect on users: the major version ``X`` will be incremented for changes that require user attention (like breaking changes), the minor version ``Y`` will be incremented for safe feature additions, and the patch number ``Z`` will be incremented for changes users can safely ignore. - -While version 1.0 has been released, we are still in the process of ensuring proplot is fully replaced by ultraplot as we continue development under the ultraplot name. During this transition, the versioning scheme reflects both our commitment to stable APIs and the ongoing work to complete this transition. The minor version number is incremented when changes require user attention (like deprecations or style changes), and the patch number is incremented for additions and fixes that users can safely adopt. - -For now, `Casper van Eltern `__ is the only one who can -publish releases on PyPi, but this will change in the future. Releases should -be carried out as follows: - -#. Create a new branch ``release-vX.Y.Z`` with the version for the release. - -#. Make sure to update ``CHANGELOG.rst`` and that all new changes are reflected - in the documentation: - - .. code-block:: bash - - git add CHANGELOG.rst - git commit -m 'Update changelog' - -#. Open a new pull request for this branch targeting ``master``. - -#. After all tests pass and the pull request has been approved, merge into - ``master``. - -#. Get the latest version of the master branch: - - .. code-block:: bash - - git checkout master - git pull - -#. Tag the current commit and push to github: - - .. code-block:: bash - - git tag -a vX.Y.Z -m "Version X.Y.Z" - git push origin master --tags - -#. Build and publish release on PyPI: - - .. code-block:: bash - - # Remove previous build products and build the package - rm -r dist build *.egg-info - python setup.py sdist bdist_wheel - # Check the source and upload to the test repository - twine check dist/* - twine upload --repository-url https://test.pypi.org/legacy/ dist/* - # Go to https://test.pypi.org/project/ultraplot/ and make sure everything looks ok - # Then make sure the package is installable - pip install --index-url https://test.pypi.org/simple/ ultraplot - # Register and push to pypi - twine upload dist/* +.. include:: docs/contributing.rst diff --git a/docs/contributing.rst b/docs/contributing.rst index 3bdd7dc21..d455ff729 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -1 +1,300 @@ -.. include:: ../CONTRIBUTING.rst \ No newline at end of file +.. _contrib: + +================== +How to contribute? +================== + +Contributions of any size are greatly appreciated! You can +make a significant impact on UltraPlot by just using it and +reporting `issues `__. + +The following sections cover some general guidelines +regarding UltraPlot development for new contributors. Feel +free to suggest improvements or changes to this workflow. + +.. _contrib_features: + +Feature requests +================ + +We are eager to hear your requests for new features and +suggestions regarding the current API. You can submit these as +`issues `__ on Github. +Please make sure to explain in detail how the feature should work and keep the scope as +narrow as possible. This will make it easier to implement in small pull requests. + +If you are feeling inspired, feel free to add the feature yourself and +submit a pull request! + +.. _contrib_bugs: + +Report bugs +=========== + +Bugs should be reported using the Github +`issues `__ page. When reporting a +bug, please follow the template message and include copy-pasteable code that +reproduces the issue. This is critical for contributors to fix the bug quickly. + +If you can figure out how to fix the bug yourself, feel free to submit +a pull request. + +.. _contrib_tets: + +Write tests +=========== + +Most modern python packages have ``test_*.py`` scripts that are run by `pytest` +via continuous integration services like `Travis `__ +whenever commits are pushed to the repository. Currently, UltraPlot's continuous +integration includes only the examples that appear on the website User Guide (see +`.travis.yml`), and `Casper van Elteren ` runs additional tests +manually. This approach leaves out many use cases and leaves the project more +vulnerable to bugs. Improving ultraplot's continuous integration using `pytest` +and `pytest-mpl` is a *critical* item on our to-do list. + +If you can think of a useful test for ultraplot, feel free to submit a pull request. +Your test will be used in the future. + +.. _contrib_docs: + +Write documentation +=================== + +Documentation can always be improved. For minor changes, you can edit docstrings and +documentation files directly in the GitHub web interface without using a local copy. + +* The docstrings are written in + `reStructuredText `__ + with `numpydoc `__ style headers. + They are embedded in the :ref:`API reference` section using a + `fork of sphinx-automodapi `__. +* Other sections are written using ``.rst`` files and ``.py`` files in the ``docs`` + folder. The ``.py`` files are translated to python notebooks via + `jupytext `__ then embedded in + the User Guide using `nbsphinx `__. +* The `default ReST role `__ + is ``py:obj``. Please include ``py:obj`` links whenever discussing particular + functions or classes -- for example, if you are discussing the + :func:`~ultraplot.axes.Axes.format` method, please write + ```:func:`~ultraplot.axes.Axes.format` ``` rather than ``format``. ultraplot also uses + `intersphinx `__ + so you can link to external packages like matplotlib and cartopy. + +To build the documentation locally, use the following commands: + +.. code:: bash + + cd docs + # Install dependencies to the base conda environment.. + conda env update -f environment.yml + # ...or create a new conda environment + # conda env create -n ultraplot-dev --file docs/environment.yml + # source activate ultraplot-dev + # Create HTML documentation + make html + +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 +======================= + +New features and bug fixes should be addressed using pull requests. +Here is a quick guide for submitting pull requests: + +#. Fork the + `ultraplot GitHub repository `__. It's + fine to keep "ultraplot" as the fork repository name because it will live + under your account. + +#. Clone your fork locally using `git `__, connect your + repository to the upstream (main project), and create a branch as follows: + + .. code-block:: bash + + git clone git@github.com:YOUR_GITHUB_USERNAME/ultraplot.git + cd ultraplot + git remote add upstream git@github.com:ultraplot/ultraplot.git + git checkout -b your-branch-name master + + If you need some help with git, follow the + `quick start guide `__. + +#. Make an editable install of ultraplot by running: + + .. code-block:: bash + + pip install -e . + + This way ``import ultraplot`` imports your local copy, + rather than the stable version you last downloaded from PyPi. + You can ``import ultraplot; print(ultraplot.__file__)`` to verify your + local copy has been imported. + +#. Install `pre-commit `__ and its hook on the + ``ultraplot`` repo as follows: + + .. code-block:: bash + + pip install --user pre-commit + pre-commit install + + Afterwards ``pre-commit`` will run whenever you commit. + `pre-commit `__ is a framework for managing and + maintaining multi-language pre-commit hooks to + ensure code-style and code formatting is consistent. + +#. You can now edit your local working copy as necessary. Please follow + the `PEP8 style guide `__. + and try to generally adhere to the + `black `__ subset of the PEP8 style + (we may automatically enforce the "black" style in the future). + When committing, ``pre-commit`` will modify the files as needed, + or will generally be clear about what you need to do to pass the pre-commit test. + + Please break your edits up into reasonably sized commits: + + + .. code-block:: bash + + git commit -a -m "" + git push -u + + The commit messages should be short, sweet, and use the imperative mood, + e.g. "Fix bug" instead of "Fixed bug". + + .. + #. Run all the tests. Now running tests is as simple as issuing this command: + .. code-block:: bash + coverage run --source ultraplot -m py.test + This command will run tests via the ``pytest`` tool against Python 3.7. + +#. If you intend to make changes or add examples to the user guide, you may want to + open the ``docs/*.py`` files as + `jupyter notebooks `__. + This can be done by + `installing jupytext `__, + starting a jupyter session, and opening the ``.py`` files from the ``Files`` page. + +#. When you're finished, create a new changelog entry in ``CHANGELOG.rst``. + The entry should be entered as: + + .. code-block:: + + * (:pr:``) by ``_. + + where ```` is the description of the PR related to the change, + ```` is the pull request number, and ```` is your first + and last name. Make sure to add yourself to the list of authors at the end of + ``CHANGELOG.rst`` and the list of contributors in ``docs/authors.rst``. + Also make sure to add the changelog entry under one of the valid + ``.. rubric:: `` headings listed at the top of ``CHANGELOG.rst``. + +#. Finally, submit a pull request through the GitHub website using this data: + + .. code-block:: + + head-fork: YOUR_GITHUB_USERNAME/ultraplot + compare: your-branch-name + + base-fork: ultraplot/ultraplot + base: master + +Note that you can create the pull request before you're finished with your +feature addition or bug fix. The PR will update as you add more commits. UltraPlot +developers and contributors can then review your code and offer suggestions. + +.. _contrib_release: + +Release procedure +================= +Ultraplot follows EffVer (`Effectual Versioning `_). Changes to the version number ``X.Y.Z`` will reflect the effect on users: the major version ``X`` will be incremented for changes that require user attention (like breaking changes), the minor version ``Y`` will be incremented for safe feature additions, and the patch number ``Z`` will be incremented for changes users can safely ignore. + +While version 1.0 has been released, we are still in the process of ensuring proplot is fully replaced by ultraplot as we continue development under the ultraplot name. During this transition, the versioning scheme reflects both our commitment to stable APIs and the ongoing work to complete this transition. The minor version number is incremented when changes require user attention (like deprecations or style changes), and the patch number is incremented for additions and fixes that users can safely adopt. + +For now, `Casper van Eltern `__ is the only one who can +publish releases on PyPi, but this will change in the future. Releases should +be carried out as follows: + +#. Create a new branch ``release-vX.Y.Z`` with the version for the release. + +#. Make sure to update ``CHANGELOG.rst`` and that all new changes are reflected + in the documentation: + + .. code-block:: bash + + git add CHANGELOG.rst + git commit -m 'Update changelog' + +#. Open a new pull request for this branch targeting ``master``. + +#. After all tests pass and the pull request has been approved, merge into + ``master``. + +#. Get the latest version of the master branch: + + .. code-block:: bash + + git checkout master + git pull + +#. Tag the current commit and push to github: + + .. code-block:: bash + + git tag -a vX.Y.Z -m "Version X.Y.Z" + git push origin master --tags + +#. Build and publish release on PyPI: + + .. code-block:: bash + + # Remove previous build products and build the package + rm -r dist build *.egg-info + python setup.py sdist bdist_wheel + # Check the source and upload to the test repository + twine check dist/* + twine upload --repository-url https://test.pypi.org/legacy/ dist/* + # Go to https://test.pypi.org/project/ultraplot/ and make sure everything looks ok + # Then make sure the package is installable + pip install --index-url https://test.pypi.org/simple/ ultraplot + # Register and push to pypi + twine upload dist/* From c7ad117a3579743fea1e51b6464609239a5e8909 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 15 Jan 2026 20:47:14 +1000 Subject: [PATCH 21/21] Update lazy-loading contributor docs --- docs/contributing.rst | 53 ++++++++++++++++++++----------------------- 1 file changed, 24 insertions(+), 29 deletions(-) diff --git a/docs/contributing.rst b/docs/contributing.rst index d455ff729..e1aa270f6 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -45,13 +45,12 @@ Write tests =========== Most modern python packages have ``test_*.py`` scripts that are run by `pytest` -via continuous integration services like `Travis `__ -whenever commits are pushed to the repository. Currently, UltraPlot's continuous -integration includes only the examples that appear on the website User Guide (see -`.travis.yml`), and `Casper van Elteren ` runs additional tests -manually. This approach leaves out many use cases and leaves the project more -vulnerable to bugs. Improving ultraplot's continuous integration using `pytest` -and `pytest-mpl` is a *critical* item on our to-do list. +via continuous integration whenever commits are pushed to the repository. +Currently, UltraPlot's automated checks focus on the examples that appear on the +website User Guide, and `Casper van Elteren ` runs +additional tests manually. This approach leaves out many use cases and leaves the +project more vulnerable to bugs. Improving ultraplot's continuous integration using +`pytest` and `pytest-mpl` is a *critical* item on our to-do list. If you can think of a useful test for ultraplot, feel free to submit a pull request. Your test will be used in the future. @@ -103,33 +102,29 @@ 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`. +`__getattr__` function in `ultraplot/__init__.py` and the `LazyLoader` helper in +`ultraplot/_lazy.py`. -When adding a new submodule, you need to make sure it's compatible with the lazy -loader. Here's how to do it: +When adding a new submodule, make sure it is compatible with the lazy loader: -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. +1. **Add the module file or package:** Place your new module in `ultraplot/` as + `my_module.py`, or as a package directory with an `__init__.py`. -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. +2. **Expose public names via `__all__` (optional):** The lazy loader inspects + `__all__` in modules and packages to know which attributes to expose at the + top level. If you want `uplt.MyClass` or `uplt.my_function` to resolve + directly, include them in `__all__` in your module. If `__all__` is not + present, the lazy loader will still expose the module itself as + `uplt.my_module`. -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. +3. **Add explicit exceptions when needed:** If a top-level name should map to a + different module or attribute (or needs special handling), add it to + `_LAZY_LOADING_EXCEPTIONS` in `ultraplot/__init__.py`. This mapping controls + explicit name-to-module lookups that should override the default discovery + behavior. - * 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. +By following these steps, your module will integrate cleanly with the lazy loading +system without requiring manual registry updates. .. _contrib_pr: