diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 6c2d1ae7a..2c73e857a 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -1,264 +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_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..e1aa270f6 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -1 +1,295 @@ -.. 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 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. + +.. _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` and the `LazyLoader` helper in +`ultraplot/_lazy.py`. + +When adding a new submodule, make sure it is compatible with 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. **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. **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. + +By following these steps, your module will integrate cleanly with the lazy loading +system without requiring manual registry updates. + + +.. _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/* 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",