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/docs/sphinxext/custom_roles.py b/docs/sphinxext/custom_roles.py index f625d0835..05c2e781f 100644 --- a/docs/sphinxext/custom_roles.py +++ b/docs/sphinxext/custom_roles.py @@ -22,8 +22,8 @@ def _node_list(rawtext, text, inliner): refuri = "https://matplotlib.org/stable/tutorials/introductory/customizing.html" refuri = f"{refuri}?highlight={text}#the-matplotlibrc-file" else: - path = "../" * relsource[1].count("/") + "en/stable" - refuri = f"{path}/configuration.html?highlight={text}#table-of-settings" + path = "../" * relsource[1].count("/") + refuri = f"{path}configuration.html?highlight={text}#table-of-settings" node = nodes.Text(f"rc[{text!r}]" if "." in text else f"rc.{text}") ref = nodes.reference(rawtext, node, refuri=refuri) return [nodes.literal("", "", ref)] diff --git a/ultraplot/__init__.py b/ultraplot/__init__.py index 2a2db3bd1..396cadd21 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,313 @@ 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 + +# Exceptions to the automated lazy loading +_LAZY_LOADING_EXCEPTIONS = { + "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"), + "figure": ("figure", "Figure"), +} + -with _benchmark("pyplot"): - from matplotlib import pyplot # noqa: F401 -with _benchmark("cartopy"): +def _import_module(module_name): + return import_module(f".{module_name}", __name__) + + +def _parse_all(path): try: - import cartopy # noqa: F401 - except ImportError: - pass -with _benchmark("basemap"): + 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(): + 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) + 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 = _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: + 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(): + global _EAGER_DONE + _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() + if _REGISTRY_ATTRS: + names.update(_REGISTRY_ATTRS) + names.update({"__version__", "version", "name"}) + _EAGER_DONE = True + return sorted(names) + + +def _get_rc_eager(): 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 + from .config import rc + except Exception: + return 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 + 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 __getattr__(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 == "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 _LAZY_LOADING_EXCEPTIONS: + _setup() + _maybe_eager_import() + return _resolve_extra(name) + + _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 + + 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}") + + +def __dir__(): + _discover_modules() + names = set(globals()) + if _ATTR_MAP: + names.update(_ATTR_MAP) + names.update(_LAZY_LOADING_EXCEPTIONS) + 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..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..7ea37d729 --- /dev/null +++ b/ultraplot/tests/test_imports.py @@ -0,0 +1,142 @@ +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_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/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))