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..32114a639 --- /dev/null +++ b/docs/lazy_loading.rst @@ -0,0 +1,54 @@ +.. _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 +: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. + +**Convention-Based Loading** + +The automated system follows these rules: + +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 + :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:`ultraplot.utils`. + +**Adding New Modules** + +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 + 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 +: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: + +* 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 2a2db3bd1..9f382f187 100644 --- a/ultraplot/__init__.py +++ b/ultraplot/__init__.py @@ -2,7 +2,14 @@ """ A succinct matplotlib wrapper for making beautiful, publication-quality graphics. """ -# SCM versioning +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Optional + +from ._lazy import LazyLoader, install_module_proxy + name = "ultraplot" try: @@ -12,106 +19,219 @@ 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 +_EAGER_DONE = 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 +_LAZY_LOADING_EXCEPTIONS = { + "constructor": ("constructor", 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), + "Proj": ("constructor", "Proj"), + "tests": ("tests", None), + "rcsetup": ("internals", "rcsetup"), + "warnings": ("internals", "warnings"), + "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"), +} + + +def _setup(): + global _SETUP_DONE, _SETUP_RUNNING + if _SETUP_DONE or _SETUP_RUNNING: + return + _SETUP_RUNNING = True + success = False 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: - continue + from .config import ( + rc, + 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 + rcsetup.VALIDATE_REGISTERED_COLORS = True + + 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 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: + 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() + return _REGISTRY_ATTRS.get(name) if _REGISTRY_ATTRS else None + + +_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): + # 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 2: Core metadata + if name in {"__version__", "version", "name", "__all__"}: + if name == "__all__": + val = _LOADER.load_all(globals()) + globals()["__all__"] = val + return val + return globals().get(name) + + # 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: - _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") + 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 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 + + # Priority 4: External dependencies + if name == "pyplot": + import matplotlib.pyplot as plt + + 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()) + + +def __dir__(): + return _LOADER.iter_dir_names(globals()) + + +# Prevent "import ultraplot.figure" from clobbering the top-level callable. +install_module_proxy(sys.modules.get(__name__)) diff --git a/ultraplot/_lazy.py b/ultraplot/_lazy.py new file mode 100644 index 000000000..502c811d9 --- /dev/null +++ b/ultraplot/_lazy.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +""" +Helpers for lazy attribute loading in :mod:`ultraplot`. +""" +from __future__ import annotations + +import ast +import importlib.util +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) + # 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) + 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"}) + 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: + 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 + # 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(): + 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": + 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) + + +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 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/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..487fef87a 100644 --- a/ultraplot/internals/__init__.py +++ b/ultraplot/internals/__init__.py @@ -4,17 +4,17 @@ """ # 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 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): @@ -44,22 +44,10 @@ def _not_none(*args, default=None, **kwargs): 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): @@ -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 @@ -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 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..f7ba6e2e0 --- /dev/null +++ b/ultraplot/tests/test_imports.py @@ -0,0 +1,148 @@ +import importlib.util +import json +import os +import subprocess +import sys + +import pytest + + +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" + + +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" + + +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_figure_submodule_does_not_clobber_callable(): + import ultraplot as uplt + + assert isinstance(uplt.figure(), uplt.Figure) + + +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"] 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 diff --git a/ultraplot/ui.py b/ultraplot/ui.py index 7fb66334e..aebc0cad2 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",