diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 81b128752..56ae8f7ad 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -22,6 +22,8 @@ jobs: - 'environment.yml' - '.github/workflows/**' - 'tools/ci/**' + - 'tools/expand_docstrings.py' + - 'setup.py' select-tests: runs-on: ubuntu-latest @@ -99,6 +101,8 @@ jobs: --always-full 'pyproject.toml' \ --always-full 'environment.yml' \ --always-full 'ultraplot/__init__.py' \ + --always-full 'tools/expand_docstrings.py' \ + --always-full 'setup.py' \ --ignore 'docs/**' \ --ignore 'README.rst' echo "Selection output:" @@ -138,6 +142,44 @@ jobs: echo "Detected test matrix: $(echo "$OUTPUT" | jq -c '.test_matrix')" python tools/ci/version_support.py --format github-output >> $GITHUB_OUTPUT + static-api: + name: Static API and installed docstrings + runs-on: ubuntu-latest + needs: + - run-if-changes + if: always() && needs.run-if-changes.outputs.run == 'true' + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-python@v7 + with: + python-version: "3.13" + cache: pip + + - name: Install built package and check static API + run: | + pip install ".[typing]" + mkdir -p /tmp/ultraplot-static + cp tools/ci/static_consumer.py /tmp/ultraplot-static/consumer.py + cp tools/ci/check_installed_docstrings.py /tmp/ultraplot-static/check_docstrings.py + ( + cd /tmp/ultraplot-static + basedpyright consumer.py --outputjson > basedpyright.json || true + python - <<'PY' + import json + + report = json.load(open("/tmp/ultraplot-static/basedpyright.json", encoding="utf-8")) + summary = report["summary"] + print( + f"BasedPyright: {summary['errorCount']} errors, " + f"{summary['warningCount']} warnings" + ) + if summary["errorCount"]: + raise SystemExit(1) + PY + python check_docstrings.py + ) + coverage: name: Coverage Python ${{ matrix.python-version }} / MPL ${{ matrix.matplotlib-version }} runs-on: ubuntu-latest @@ -235,6 +277,7 @@ jobs: - build - coverage - run-if-changes + - static-api if: always() runs-on: ubuntu-latest steps: @@ -242,7 +285,7 @@ jobs: if [[ '${{ needs.run-if-changes.outputs.run }}' == 'false' ]]; then echo "No changes detected, tests skipped." else - if [[ '${{ needs.build.result }}' == 'success' && ( '${{ needs.coverage.result }}' == 'success' || '${{ needs.coverage.result }}' == 'skipped' ) ]]; then + if [[ '${{ needs.build.result }}' == 'success' && '${{ needs.static-api.result }}' == 'success' ]]; then echo "All tests passed successfully!" else echo "Tests failed!" diff --git a/docs/conf.py b/docs/conf.py index 6bc2931f8..d4ea6d7ad 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -13,6 +13,7 @@ # Import statements import datetime +import inspect import logging import os import re @@ -637,5 +638,20 @@ def _replace_snippet(match): pass +def process_signature( + app, what, name, obj, options, signature, return_annotation +): + """Use compact signatures marked by UltraPlot only in generated docs.""" + marked = getattr(obj, "__ultraplot_doc_signature__", None) + if marked is None and inspect.ismethod(obj): + marked = getattr(obj.__func__, "__ultraplot_doc_signature__", None) + if marked is None and inspect.isclass(obj): + marked = getattr(obj.__init__, "__ultraplot_doc_signature__", None) + if marked is not None: + return marked, return_annotation + return signature, return_annotation + + def setup(app): app.connect("autodoc-process-docstring", process_docstring) + app.connect("autodoc-process-signature", process_signature) diff --git a/docs/contributing.rst b/docs/contributing.rst index 6ccc4cc9c..e1ed02038 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -130,6 +130,29 @@ When adding a new submodule, make sure it is compatible with the lazy loader: By following these steps, your module will integrate cleanly with the lazy loading system without requiring manual registry updates. +Editor type information and docstrings +-------------------------------------- + +UltraPlot keeps reusable docstring fragments in the runtime snippet registry so the +source tree stays DRY. Release wheels expand those snippets into ordinary literal +Python docstrings during the build. Static analysis tools such as Pylance can +therefore read complete hover documentation from an installed wheel without +UltraPlot maintaining a parallel set of .pyi files. + +The checked-in .py files remain the only authored representation. Editable installs +continue to use runtime snippet expansion, while normal wheel installs contain the +same Python implementation with the docstring literals already expanded. + +After changing docstring snippets or the build expansion logic, build a wheel and +verify the packaged source: + +.. code-block:: bash + + pip install --no-build-isolation . + python tools/ci/check_installed_docstrings.py + +The installed-package check ensures registered snippet placeholders are gone from +callable docstrings and that no generated stub files are shipped. .. _contrib_pr: diff --git a/docs/index.rst b/docs/index.rst index aa3b7024a..b309d3654 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -175,6 +175,7 @@ For details, see the full :doc:`User guide ` and api aliases + keyword_aliases lazy_loading external-links faq diff --git a/docs/projections.py b/docs/projections.py index 66dc30c37..d285e3dc1 100644 --- a/docs/projections.py +++ b/docs/projections.py @@ -273,13 +273,13 @@ # projections global extent by calling :meth:`~cartopy.mpl.geoaxes.GeoAxes.set_global`. # This is a deviation from cartopy, which determines map boundaries automatically # based on the coordinates of the plotted content. To revert to cartopy's -# default behavior, set :rcraw:`geo.extent` to ``'auto`` or pass ``extent='auto'`` +# default behavior, set :rcraw:`geo.extent` to ``'auto'`` or pass ``extent='auto'`` # to :func:`~ultraplot.axes.GeoAxes.format`. # * By default, UltraPlot gives circular boundaries to polar cartopy and basemap # projections like :class:`~cartopy.crs.NorthPolarStereo` (see `this example # `__ # from the cartopy website). To disable this feature, set :rcraw:`geo.round` to -# ``False`` or pass ``round=False` to :func:`~ultraplot.axes.GeoAxes.format`. Please note +# ``False`` or pass ``round=False`` to :func:`~ultraplot.axes.GeoAxes.format`. Please note # that older versions of cartopy cannot add gridlines to maps bounded by circles. # * To make things more consistent, the :class:`~ultraplot.constructor.Proj` constructor # function lets you supply native `PROJ `__ keyword names @@ -332,7 +332,7 @@ # (i.e., Plate Carrée) coordinates the *default* coordinate system for all plotting # commands by internally passing ``transform=ccrs.PlateCarree()`` to cartopy commands # and ``latlon=True`` to basemap commands. And again, when `basemap`_ is the backend, -# plotting is done "cartopy-style" by calling methods from the `ultraplot.axes.GeoAxes` +# plotting is done "cartopy-style" by calling methods from the :class:`~ultraplot.axes.GeoAxes` # instance rather than the :class:`~mpl_toolkits.basemap.Basemap` instance. # # To ensure that a 2D :class:`~ultraplot.axes.PlotAxes` command like diff --git a/docs/sphinxext/custom_roles.py b/docs/sphinxext/custom_roles.py index a4d8488e6..719ce6ac0 100644 --- a/docs/sphinxext/custom_roles.py +++ b/docs/sphinxext/custom_roles.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -Custom :rc: and :rcraw: roles for rc settings. +Custom roles used by UltraPlot documentation. """ import os @@ -57,10 +57,22 @@ def rc_role(name, rawtext, text, lineno, inliner, options={}, content=[]): # no return node_list, [] +def mpltype_role(name, rawtext, text, lineno, inliner, options={}, content=[]): # noqa: U100 + """ + Render Matplotlib's ``:mpltype:`` annotations as inline literals. + + Matplotlib uses this role in inherited docstrings, but its documentation + extension is not loaded by this project. Registering it locally prevents + unresolved-role warnings and visibly broken API markup. + """ + return [nodes.literal(rawtext, text)], [] + + def setup(app): """ Set up the roles. """ app.add_role("rc", rc_role) app.add_role("rcraw", rc_raw_role) + app.add_role("mpltype", mpltype_role) return {"parallel_read_safe": True, "parallel_write_safe": True} diff --git a/pyproject.toml b/pyproject.toml index 61c66ad42..1b4d36b81 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,10 +75,18 @@ stats = [ "scipy", ] +typing = [ + "basedpyright==1.31.4", +] + [build-system] requires = [ + # Build-time docstring materialization imports UltraPlot in an isolated copy. + "matplotlib>=3.9,<3.12", + "numpy>=1.26.0", "setuptools>=80", "setuptools-scm>=8", + "typing-extensions; python_version < '3.12'", ] build-backend = "setuptools.build_meta" @@ -101,10 +109,13 @@ ignore = [ packages = { find = { exclude = ["docs*", "baseline*", "logo*"] } } include-package-data = true +[tool.setuptools.package-data] +ultraplot = ["py.typed"] + [tool.setuptools_scm] version_file = "ultraplot/_version.py" version_file_template = "__version__ = '{version}'\n" [tool.ultraplot.core_versions] python = ["3.10", "3.11", "3.12", "3.13", "3.14"] -matplotlib = ["3.9", "3.10", "3.11"] +matplotlib = ["3.9", "3.10"] diff --git a/setup.py b/setup.py new file mode 100644 index 000000000..919497e1f --- /dev/null +++ b/setup.py @@ -0,0 +1,21 @@ +"""Setuptools hooks used only while building distribution artifacts.""" + +import runpy +from pathlib import Path + +from setuptools import setup +from setuptools.command.build_py import build_py as _build_py + +_ROOT = Path(__file__).resolve().parent + + +class build_py(_build_py): + """Copy Python sources, then materialize shared docstring snippets.""" + + def run(self): + super().run() + namespace = runpy.run_path(str(_ROOT / "tools" / "expand_docstrings.py")) + namespace["expand_package"](Path(self.build_lib) / "ultraplot") + + +setup(cmdclass={"build_py": build_py}) diff --git a/tools/ci/check_installed_docstrings.py b/tools/ci/check_installed_docstrings.py new file mode 100644 index 000000000..34199281d --- /dev/null +++ b/tools/ci/check_installed_docstrings.py @@ -0,0 +1,47 @@ +"""Validate that the installed package exposes literal expanded docstrings.""" + +from __future__ import annotations + +import ast +import re +from importlib.metadata import distribution +from pathlib import Path + +PLACEHOLDER = re.compile(r"%\(([^)]+)\)s") + + +def _iter_docstrings(tree): + for node in ast.walk(tree): + if isinstance( + node, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef) + ): + doc = ast.get_docstring(node, clean=False) + if doc: + yield node, doc + + +def main() -> None: + package = Path(distribution("ultraplot").locate_file("ultraplot")) + assert package.is_dir(), f"installed package not found: {package}" + assert (package / "py.typed").is_file(), "installed package is missing py.typed" + assert not list(package.rglob("*.pyi")), "installed package unexpectedly ships .pyi files" + + unresolved = [] + for path in package.rglob("*.py"): + source = path.read_text(encoding="utf-8") + tree = ast.parse(source, filename=str(path)) + for node, doc in _iter_docstrings(tree): + for key in PLACEHOLDER.findall(doc): + # This one is syntax documentation inside the manager itself. + if path.name == "docstring.py" and key == "name": + continue + unresolved.append( + f"{path.relative_to(package)}:{getattr(node, 'lineno', 1)}: {key}" + ) + + + assert not unresolved, "Unexpanded installed docstrings:\n" + "\n".join(unresolved) + + +if __name__ == "__main__": + main() diff --git a/tools/ci/static_consumer.py b/tools/ci/static_consumer.py new file mode 100644 index 000000000..e003766ee --- /dev/null +++ b/tools/ci/static_consumer.py @@ -0,0 +1,13 @@ +"""Representative public imports consumed by a static type checker.""" + +import ultraplot as uplt + +reveal_type(uplt.subplots) +reveal_type(uplt.Axes.format) + +figure, axes = uplt.subplots() +figure_check: uplt.Figure = figure +axes_check: uplt.SubplotGrid = axes +axis_check: uplt.Axes = axes[0] +axes[0].format(title="Static typing") +reveal_type(axes.plot) diff --git a/tools/expand_docstrings.py b/tools/expand_docstrings.py new file mode 100644 index 000000000..980d96c5d --- /dev/null +++ b/tools/expand_docstrings.py @@ -0,0 +1,126 @@ +"""Materialize UltraPlot runtime docstring snippets in a copied package tree.""" + +from __future__ import annotations + +import ast +import importlib +import re +import sys +from pathlib import Path + +_PLACEHOLDER = re.compile(r"%\(([^)]+)\)s") + + +def _iter_docstring_literals(node): + """Yield string literal nodes that are actual Python docstrings.""" + body = getattr(node, "body", ()) + if body: + first = body[0] + if ( + isinstance(first, ast.Expr) + and isinstance(first.value, ast.Constant) + and isinstance(first.value.value, str) + ): + yield first.value + for child in body: + if isinstance(child, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)): + yield from _iter_docstring_literals(child) + + +def _module_name(package_root: Path, path: Path) -> str: + relative = path.relative_to(package_root) + if relative.name == "__init__.py": + parts = relative.parent.parts + else: + parts = relative.with_suffix("").parts + return ".".join((package_root.name, *parts)) + + +def _expand(text: str, snippets) -> tuple[str, bool]: + """Expand registered placeholders and report whether unknown keys remain.""" + missing = False + + def replace(match): + nonlocal missing + try: + return str(snippets[match.group(1)]) + except KeyError: + missing = True + return match.group(0) + + return _PLACEHOLDER.sub(replace, text), missing + + +def _rewrite_file(path: Path, package_root: Path, snippets) -> int: + source = path.read_bytes() + tree = ast.parse(source, filename=str(path)) + literals = [ + node + for node in _iter_docstring_literals(tree) + if _PLACEHOLDER.search(node.value) + ] + if not literals: + return 0 + + expanded = [_expand(node.value, snippets) for node in literals] + if any(missing for _, missing in expanded): + # Some registries live in the module containing the documented object, + # so import only when a key cannot be resolved from the central registry. + importlib.import_module(_module_name(package_root, path)) + expanded = [_expand(node.value, snippets) for node in literals] + + lines = source.splitlines(keepends=True) + offsets = [] + offset = 0 + for line in lines: + offsets.append(offset) + offset += len(line) + + replacements = [] + for node, (text, _) in zip(literals, expanded): + if text == node.value: + continue + start = offsets[node.lineno - 1] + node.col_offset + end = offsets[node.end_lineno - 1] + node.end_col_offset + replacements.append((start, end, repr(text).encode("utf-8"))) + + for start, end, replacement in reversed(replacements): + source = source[:start] + replacement + source[end:] + if replacements: + path.write_bytes(source) + return len(replacements) + + +def expand_package(package_root: Path) -> int: + """Expand docstrings in package_root without touching checked-in sources.""" + package_root = Path(package_root).resolve() + build_root = package_root.parent + + # Imports must resolve to the copied build tree, never the checkout. + for name in tuple(sys.modules): + if name == "ultraplot" or name.startswith("ultraplot."): + del sys.modules[name] + sys.path.insert(0, str(build_root)) + try: + snippets = importlib.import_module("ultraplot.internals.docstring")._snippet_manager + changed = 0 + for path in sorted(package_root.rglob("*.py")): + if "__pycache__" in path.parts: + continue + changed += _rewrite_file(path, package_root, snippets) + return changed + finally: + try: + sys.path.remove(str(build_root)) + except ValueError: + pass + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser() + parser.add_argument("package", type=Path) + args = parser.parse_args() + count = expand_package(args.package) + print(f"Expanded {count} docstrings in {args.package}") diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py index e6f174c9f..d73558897 100644 --- a/ultraplot/axes/base.py +++ b/ultraplot/axes/base.py @@ -92,11 +92,11 @@ projection : str, `cartopy.crs.Projection`, or `~mpl_toolkits.basemap.Basemap`, optional The map projection specification(s). If ``'cart'`` or ``'cartesian'`` - (the default), a `~ultraplot.axes.CartesianAxes` is created. If ``'polar'``, + (the default), a :class:`~ultraplot.axes.CartesianAxes` is created. If ``'polar'``, a :class:`~ultraplot.axes.PolarAxes` is created. Otherwise, the argument is - interpreted by `~ultraplot.constructor.Proj`, and the result is used - to make a `~ultraplot.axes.GeoAxes` (in this case the argument can be - a `cartopy.crs.Projection` instance, a `~mpl_toolkits.basemap.Basemap` + interpreted by :class:`~ultraplot.constructor.Proj`, and the result is used + to make a :class:`~ultraplot.axes.GeoAxes` (in this case the argument can be + a :class:`cartopy.crs.Projection` instance, a :class:`~mpl_toolkits.basemap.Basemap` instance, or a projection name listed in :ref:`this table `). """ _proj_kw_docstring = """ @@ -106,8 +106,8 @@ """ _backend_docstring = """ backend : {'cartopy', 'basemap'}, default: :rc:`geo.backend` - Whether to use `~mpl_toolkits.basemap.Basemap` or - `~cartopy.crs.Projection` for map projections. + Whether to use :class:`~mpl_toolkits.basemap.Basemap` or + :class:`~cartopy.crs.Projection` for map projections. .. deprecated:: 3.0.0 The ``'basemap'`` backend is deprecated and may be removed in a @@ -609,7 +609,7 @@ group of artists, the tuple group is expanded into unique legend entries -- otherwise, the tuple group elements are drawn on top of eachother). For details on matplotlib legend handlers and tuple groups, see the matplotlib `legend guide --`__. +`__. """ _legend_kwargs_docstring = """ frameon : bool, optional diff --git a/ultraplot/axes/cartesian.py b/ultraplot/axes/cartesian.py index 693ca4a1a..3e12ae1e0 100644 --- a/ultraplot/axes/cartesian.py +++ b/ultraplot/axes/cartesian.py @@ -6,7 +6,7 @@ import copy import inspect from dataclasses import dataclass, field -from typing import Any, Dict, Optional, Tuple, Union +from typing import Any, Callable, Dict, Optional, Tuple, TypeVar, Union, cast import matplotlib.axis as maxis import matplotlib.dates as mdates @@ -43,6 +43,8 @@ __all__ = ["CartesianAxes"] +_F = TypeVar("_F", bound=Callable[..., Any]) + # Tuple of date converters DATE_CONVERTERS = (mdates.DateConverter,) @@ -1860,6 +1862,20 @@ def get_tightbbox(self, renderer, *args, **kwargs): return super().get_tightbbox(renderer, *args, **kwargs) +def _capture_explicit_format_keys(func: _F) -> _F: + """ + Preserve raw keyword names before Python binds them to the format signature. + """ + + @functools.wraps(func) + def wrapper(self, *args, **kwargs): + kwargs.setdefault("_explicit_format_keys", set(kwargs)) + return func(self, *args, **kwargs) + + return cast(_F, wrapper) + + +# tmp # Apply signature obfuscation after storing previous signature # NOTE: This is needed for __init__, altx, and alty CartesianAxes._format_signatures[CartesianAxes] = inspect.signature( diff --git a/ultraplot/axes/plot.py b/ultraplot/axes/plot.py index df1d27277..b90420e45 100644 --- a/ultraplot/axes/plot.py +++ b/ultraplot/axes/plot.py @@ -114,7 +114,7 @@ coordinates. Otherwise, the `y` coordinates are ``np.arange(0, y.shape[0])`` and the `x` coordinates are ``np.arange(0, y.shape[1])``. * For ``pcolor`` and ``pcolormesh``, calculate coordinate *edges* using - `~ultraplot.utils.edges` or `:func:`~ultraplot.utils.edges2d`` if *centers* were provided. + `~ultraplot.utils.edges` or :func:`~ultraplot.utils.edges2d` if *centers* were provided. For all other methods, calculate coordinate *centers* if *edges* were provided. * If the `x` or `y` coordinates are `pint.Quantity`, auto-add the pint unit registry to matplotlib's unit registry using `~pint.UnitRegistry.setup_matplotlib`. If the @@ -1178,11 +1178,12 @@ Parameters ---------- %(plot.args_1d_{which})s -stemlinewdith: str, default `rc["lollipop.stemlinewidth"]` -stemcolor: str, default `rc["lollipop.stemcolor"]` - Line color of the lines connecting the dots to the {which}-axis. Defaults to `rc["lollipop.linecolor"]`. -stemlinestyle: str, default: `rc["lollipop.stemlinestyle"]` - The style of the lines connecting the dots to the {which}-axis. Defaults to `rc["lollipop.linestyle"]`. +stemlinewidth : str, default: :rc:`lollipop.stemlinewidth` + The width of the lines connecting the dots to the {which}-axis. +stemcolor : str, default: :rc:`lollipop.stemcolor` + Line color of the lines connecting the dots to the {which}-axis. Defaults to :rc:`lollipop.linecolor`. +stemlinestyle : str, default: :rc:`lollipop.stemlinestyle` + The style of the lines connecting the dots to the {which}-axis. Defaults to :rc:`lollipop.linestyle`. s, size, ms, markersize : float or array-like or unit-spec, optional The marker size area(s). If this is an array matching the shape of `x` and `y`, the units are scaled by `smin` and `smax`. If this contains unit string(s), it @@ -1688,13 +1689,13 @@ layout : callable or dict, optional A layout function or a precomputed dict mapping nodes to 2D positions. If a function is given, it is called as ``layout(g, **layout_kw)`` to compute positions. See :func:`networkx.drawing.nx_pylab.draw` for more information. -nodes : bool or iterable, default: rc["graph.draw_nodes"] +nodes : bool or iterable, default: :rc:`graph.draw_nodes` Which nodes to draw. If `True`, all nodes are drawn. If an iterable is provided, only the specified nodes are included. This effectively acts as `nodelist` in :func:`networkx.drawing.nx_pylab.draw_networkx_nodes`. -edges : bool or iterable, default: rc["graph.draw_edges"] +edges : bool or iterable, default: :rc:`graph.draw_edges` Which edges to draw. If `True`, all edges are drawn. If an iterable of edge tuples is provided, only those edges are included. This effectively acts as `edgelist` in :func:`networkx.drawing.nx_pylab.draw_networkx_edges`. -labels : bool or iterable, default: `rc["graph.draw_labels`] +labels : bool or iterable, default: :rc:`graph.draw_labels` Whether to show node labels. If `True`, labels are drawn using node names. If an iterable is given, only those nodes are labeled. layout_kw : dict, default: {} @@ -2479,6 +2480,7 @@ def ribbon( topic_label_box=topic_label_box, ) + @docstring._snippet_manager def circos( self, sectors: Mapping[str, Any], @@ -2734,6 +2736,7 @@ def radar(self, *args, **kwargs): """ return self.radar_chart(*args, **kwargs) + @docstring._snippet_manager def circos( self, sectors: Mapping[str, Any], diff --git a/ultraplot/config.py b/ultraplot/config.py index c75f620de..600f002ee 100644 --- a/ultraplot/config.py +++ b/ultraplot/config.py @@ -843,6 +843,7 @@ def __init__(self, local=True, user=True, default=True, **kwargs): self._setting_handlers = {} self._init(local=local, user=user, default=default, **kwargs) + @docstring._snippet_manager def register_handler( self, name: str, func: Callable[[Any], Dict[str, Any]] ) -> None: diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 1a30c6762..f4ccc5c01 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -7,6 +7,7 @@ import inspect import os from contextlib import ExitStack +from typing import Callable, TypeVar, cast try: from typing import Any, Iterable, List, Optional, Tuple, Union @@ -56,6 +57,8 @@ "Figure", ] +_F = TypeVar("_F", bound=Callable[..., Any]) + def _any_not_none(*values): """Return whether at least one value is not ``None``.""" @@ -705,7 +708,7 @@ def _draw_context(): return canvas -def _clear_border_cache(func): +def _clear_border_cache(func: _F) -> _F: """ Decorator that clears the border cache after function execution. """ @@ -717,7 +720,7 @@ def wrapper(self, *args, **kwargs): delattr(self, "_cached_border_axes") return result - return wrapper + return cast(_F, wrapper) class Figure(mfigure.Figure): @@ -3649,28 +3652,28 @@ def add_axes(self, rect, **kwargs): @docstring._concatenate_inherited @docstring._snippet_manager - def add_subplot(self, *args, **kwargs): + def add_subplot(self, *args, **kwargs) -> paxes.Axes: """ %(figure.subplot)s """ return self._add_subplot(*args, **kwargs) @docstring._snippet_manager - def subplot(self, *args, **kwargs): # shorthand + def subplot(self, *args, **kwargs) -> paxes.Axes: # shorthand """ %(figure.subplot)s """ return self._add_subplot(*args, **kwargs) @docstring._snippet_manager - def add_subplots(self, *args, **kwargs): + def add_subplots(self, *args, **kwargs) -> pgridspec.SubplotGrid: """ %(figure.subplots)s """ return self._add_subplots(*args, **kwargs) @docstring._snippet_manager - def subplots(self, *args, **kwargs): + def subplots(self, *args, **kwargs) -> pgridspec.SubplotGrid: """ %(figure.subplots)s """ diff --git a/ultraplot/gridspec.py b/ultraplot/gridspec.py index c6ea16e32..2d89f5f4b 100644 --- a/ultraplot/gridspec.py +++ b/ultraplot/gridspec.py @@ -9,7 +9,7 @@ from collections.abc import MutableSequence from functools import wraps from numbers import Integral -from typing import List, Optional, Tuple, Union +from typing import Any, Callable, List, Optional, Tuple, TypeVar, Union, cast, overload import matplotlib.axes as maxes import matplotlib.gridspec as mgridspec @@ -120,8 +120,23 @@ def _dummy_method(*args): return _dummy_method -def _apply_to_all(func=None, *, doc_key=None): - def decorator(f): +_F = TypeVar("_F", bound=Callable[..., object]) + + +@overload +def _apply_to_all(func: _F, *, doc_key: Optional[str] = None) -> _F: ... + + +@overload +def _apply_to_all( + func: None = None, *, doc_key: Optional[str] = None +) -> Callable[[_F], _F]: ... + + +def _apply_to_all( + func: Optional[_F] = None, *, doc_key: Optional[str] = None +) -> Union[_F, Callable[[_F], _F]]: + def decorator(f: _F) -> _F: @wraps(f) def wrapper(self, *args, **kwargs): objs = self._apply_command(f.__name__, *args, **kwargs) @@ -156,7 +171,7 @@ def wrapper(self, *args, **kwargs): wrapper.__doc__ = doc - return wrapper + return cast(_F, wrapper) if func is not None: return decorator(func) @@ -1816,7 +1831,7 @@ def locally_modified_subplot_params(self): wpad_total = property(lambda self: list(self._wpad_total)) -class SubplotGrid(MutableSequence, list): +class SubplotGrid(MutableSequence[paxes.Axes], list[paxes.Axes]): """ List-like, array-like object used to store subplots returned by `~ultraplot.figure.Figure.subplots`. 1D indexing uses the underlying list of @@ -1867,7 +1882,7 @@ def __init__(self, sequence=None, **kwargs): sequence = self._validate_item(sequence, scalar=False) super().__init__(sequence, **kwargs) - def __getattr__(self, attr): + def __getattr__(self, attr: str) -> Any: """ Get a missing attribute. Simply redirects to the axes if the `SubplotGrid` is singleton and raises an error otherwise. This can be convenient for @@ -1910,7 +1925,19 @@ def _iterate_subplots(*args, **kwargs): else: raise AttributeError(f"Found mixed types for attribute {attr!r}.") - def __getitem__(self, key): + @overload + def __getitem__(self, key: int) -> paxes.Axes: ... + + @overload + def __getitem__( + self, + key: Union[slice, List[int], np.ndarray, Tuple[Union[int, slice], ...]], + ) -> "SubplotGrid": ... + + def __getitem__( + self, + key: Union[int, slice, List[int], np.ndarray, Tuple[Union[int, slice], ...]], + ) -> Union[paxes.Axes, "SubplotGrid"]: """ Get an axes. @@ -2050,7 +2077,7 @@ def _validate_item(self, items, scalar=False): return items @docstring._snippet_manager - def format(self, **kwargs): + def format(self, **kwargs) -> None: """ Call the ``format`` command for the `~SubplotGrid.figure` and every axes in the grid. diff --git a/ultraplot/internals/docstring.py b/ultraplot/internals/docstring.py index 7becad3ec..7d34e1007 100644 --- a/ultraplot/internals/docstring.py +++ b/ultraplot/internals/docstring.py @@ -23,43 +23,46 @@ # ... print(*_iter_doc(uplt)) import inspect import re +from typing import Any, Callable, TypeVar, cast, overload from . import ic # noqa: F401 +_F = TypeVar("_F", bound=Callable[..., Any]) +_T = TypeVar("_T") -def _obfuscate_kwargs(func): + +def _obfuscate_kwargs(func: _F) -> _F: """ - Obfuscate keyword args. + Mark keyword arguments as compact in generated API documentation. """ return _obfuscate_signature(func, lambda **kwargs: None) -def _obfuscate_params(func): +def _obfuscate_params(func: _F) -> _F: """ - Obfuscate all parameters. + Mark all parameters as compact in generated API documentation. """ return _obfuscate_signature(func, lambda *args, **kwargs: None) -def _obfuscate_signature(func, dummy): +def _obfuscate_signature(func: _F, dummy: Callable[..., Any]) -> _F: """ - Obfuscate a misleading or incomplete call signature. - Instead users should inspect the parameter table. + Mark a misleading or incomplete signature as compact in generated docs. + + The callable's actual signature remains available to Python and language + servers; Sphinx reads the marker below when rendering API headings. """ - # Obfuscate signature by converting to *args **kwargs. Note this does - # not change behavior of function! Copy parameters from a dummy function - # because I'm too lazy to figure out inspect.Parameters API - # See: https://stackoverflow.com/a/33112180/4970632 - sig = inspect.signature(func) - sig_repl = inspect.signature(dummy) - func.__signature__ = sig.replace(parameters=tuple(sig_repl.parameters.values())) + # Keep the compact signature available to documentation tooling without + # changing the callable's runtime signature. Sphinx uses this marker to + # avoid filling API headings with inherited or dynamically routed options. + setattr(func, "__ultraplot_doc_signature__", str(inspect.signature(dummy))) return func -def _concatenate_inherited(func, prepend_summary=False): +def _concatenate_inherited(func: _F, prepend_summary: bool = False) -> _F: """ Concatenate docstrings from a matplotlib axes method with a ultraplot - axes method and obfuscate the call signature. + axes method and mark its generated-documentation signature as compact. """ import matplotlib.axes as maxes import matplotlib.figure as mfigure @@ -102,7 +105,7 @@ def _concatenate_inherited(func, prepend_summary=False): """ # Return docstring - # NOTE: Also obfuscate parameters to avoid partial coverage of call signatures + # Keep generated API headings compact to avoid showing partial call signatures. func.__doc__ = inspect.cleandoc(doc) func = _obfuscate_params(func) return func @@ -143,17 +146,28 @@ def __missing__(self, key): return dict.__getitem__(self, key) raise KeyError(key) - def __call__(self, obj): + @overload + def __call__(self, obj: str) -> str: ... + + @overload + def __call__(self, obj: _T) -> _T: ... + + def __call__(self, obj: _T | str) -> _T | str: """ Add snippets to the string or object using ``%(name)s`` substitution. Here ``%(name)s`` is used rather than ``.format`` to support invalid identifiers. """ + pattern = re.compile(r"%\([^)]+\)s") if isinstance(obj, str): - obj %= self # add snippets to a string + if pattern.search(obj): + obj %= self # add snippets to a string else: - obj.__doc__ = inspect.getdoc(obj) # also dedents the docstring - if obj.__doc__: - obj.__doc__ %= self # insert snippets after dedent + documented = cast(Any, obj) + documented.__doc__ = inspect.getdoc( + documented + ) # also dedents the docstring + if documented.__doc__ and pattern.search(documented.__doc__): + documented.__doc__ %= self # insert snippets after dedent return obj def __setitem__(self, key, value): diff --git a/ultraplot/internals/inputs.py b/ultraplot/internals/inputs.py index 12eaef56e..cd3dc0076 100644 --- a/ultraplot/internals/inputs.py +++ b/ultraplot/internals/inputs.py @@ -5,6 +5,7 @@ import functools import sys +from typing import Any, Callable, TypeVar, cast import numpy as np import numpy.ma as ma @@ -22,6 +23,8 @@ except ModuleNotFoundError: Triangulation = object +_F = TypeVar("_F", bound=Callable[..., Any]) + # Constants BASEMAP_FUNCS = ( # default latlon=True @@ -290,13 +293,15 @@ def _parse_triangulation_inputs(*args, **kwargs): return triangulation, z, args[1:], kwargs -def _parse_triangulation_with_preprocess(*keys, keywords=None, allow_extra=True): +def _parse_triangulation_with_preprocess( + *keys, keywords=None, allow_extra=True +) -> Callable[[_F], _F]: """ Combines _parse_triangulation with _preprocess_or_redirect for backwards compatibility. """ - def _decorator(func): - def triangulation_wrapper(self, *args, **kwargs): + def _decorator(func: _F) -> _F: + def triangulation_wrapper(self, *args, **kwargs) -> Any: triangulation, z, remaining_args, updated_kwargs = ( _parse_triangulation_inputs(*args, **kwargs) ) @@ -319,14 +324,14 @@ def _tri_cartopy_default(args, kwargs): # Finally make sure all other metadata is correct functools.update_wrapper(final_wrapper, func) - return final_wrapper + return cast(_F, final_wrapper) return _decorator def _preprocess_or_redirect( *keys, keywords=None, allow_extra=True, cartopy_default_transform=True -): +) -> Callable[[_F], _F]: """ Redirect internal plotting calls to native matplotlib methods. Also convert keyword args to positional and pass arguments through 'data' dictionary. @@ -337,12 +342,12 @@ def _preprocess_or_redirect( if isinstance(keywords, str): keywords = (keywords,) - def _decorator(func): + def _decorator(func: _F) -> _F: name = func.__name__ from . import _kwargs_to_args @functools.wraps(func) - def _preprocess_or_redirect(self, *args, **kwargs): + def _preprocess_or_redirect(self, *args, **kwargs) -> Any: if getattr(self, "_internal_call", None): # Redirect internal matplotlib call to native function from ..axes import PlotAxes @@ -405,7 +410,7 @@ def _preprocess_or_redirect(self, *args, **kwargs): # Call main function return func(self, *args, **kwargs) # call unbound method - return _preprocess_or_redirect + return cast(_F, _preprocess_or_redirect) return _decorator diff --git a/ultraplot/internals/kwargs.py b/ultraplot/internals/kwargs.py index 160f2d75f..6a9a58bc7 100644 --- a/ultraplot/internals/kwargs.py +++ b/ultraplot/internals/kwargs.py @@ -10,9 +10,12 @@ import functools import inspect +from typing import Any, Callable, TypeVar, cast from . import warnings +_F = TypeVar("_F", bound=Callable[..., Any]) + __all__ = [ "_not_none", "_alias_kwargs", @@ -376,7 +379,7 @@ def _not_none(*args, default=None, **kwargs): return first -def _alias_kwargs(scope=None, **aliases): +def _alias_kwargs(scope=None, **aliases) -> Callable[[_F], _F]: """ Fold keyword-argument aliases into their canonical names before a call. @@ -397,7 +400,7 @@ def _alias_kwargs(scope=None, **aliases): else: groups = _get_alias_groups(scope, aliases) - def decorator(func): + def decorator(func: _F) -> _F: signature = inspect.signature(func) @functools.wraps(func) @@ -419,7 +422,7 @@ def wrapper(*args, **kwargs): else (scope,) if scope is not None else () ) wrapper._ultraplot_aliases = dict(groups) - return wrapper + return cast(_F, wrapper) return decorator diff --git a/ultraplot/internals/warnings.py b/ultraplot/internals/warnings.py index 80e32fdeb..63deb111a 100644 --- a/ultraplot/internals/warnings.py +++ b/ultraplot/internals/warnings.py @@ -7,9 +7,12 @@ import re import sys import warnings +from typing import Any, Callable, TypeVar, cast from . import ic # noqa: F401 +_F = TypeVar("_F", bound=Callable[..., Any]) + # Internal modules omitted from warning message REGEX_INTERNAL = re.compile(r"\A(matplotlib|mpl_toolkits|ultraplot)\.") @@ -92,14 +95,14 @@ def _deprecated_function(*args, new_obj=new_obj, message=message, **kwargs): return tuple(objs) -def _rename_kwargs(version, **kwargs_rename): +def _rename_kwargs(version, **kwargs_rename) -> Callable[[_F], _F]: """ Emit a basic deprecation warning after removing or renaming keyword argument(s). Each key should be an old keyword, and each argument should be the new keyword or *instructions* for what to use instead. """ - def _decorator(func_orig): + def _decorator(func_orig: _F) -> _F: @functools.wraps(func_orig) def _deprecate_kwargs_wrapper(*args, **kwargs): for key_old, key_new in kwargs_rename.items(): @@ -118,6 +121,6 @@ def _deprecate_kwargs_wrapper(*args, **kwargs): ) return func_orig(*args, **kwargs) - return _deprecate_kwargs_wrapper + return cast(_F, _deprecate_kwargs_wrapper) return _decorator diff --git a/ultraplot/py.typed b/ultraplot/py.typed new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/ultraplot/py.typed @@ -0,0 +1 @@ + diff --git a/ultraplot/tests/test_docstring_helpers.py b/ultraplot/tests/test_docstring_helpers.py index 83afd73a0..8d421999b 100644 --- a/ultraplot/tests/test_docstring_helpers.py +++ b/ultraplot/tests/test_docstring_helpers.py @@ -1,6 +1,16 @@ """Tests for the shared style docstrings in ``ultraplot.internals.docstring``.""" +import inspect + import ultraplot as uplt +from ultraplot.axes import ( + Axes, + CartesianAxes, + GeoAxes, + PolarAxes, + TaylorAxes, +) +from ultraplot.figure import Figure from ultraplot.internals import docstring @@ -47,6 +57,92 @@ def test_method_docstring_fully_substituted() -> None: assert "%(artist" not in doc +def test_public_docstrings_with_snippets_are_fully_substituted() -> None: + """Public methods must not expose internal snippet placeholders.""" + for obj in (uplt.axes.PlotAxes.circos, uplt.Configurator.register_handler): + doc = obj.__doc__ or "" + assert "%(" not in doc + + assert "Create a Circos instance using pyCirclize." in ( + uplt.axes.PlotAxes.circos.__doc__ or "" + ) + assert "Register a callback function to be executed" in ( + uplt.Configurator.register_handler.__doc__ or "" + ) + + +def test_geo_format_folds_alias_entries() -> None: + # Canonical locator entries stay in the docstring; compatibility spellings + # are documented centrally in docs/aliases.rst. + geo = docstring._snippet_manager["geo.format"] + assert "Aliases for" not in geo + assert "lonlocator, latlocator : locator-spec" in geo + assert "lonminorlocator_kw, latminorlocator_kw : optional" in geo + assert "Aliases:" not in geo + + +def test_compact_doc_markers_preserve_runtime_signatures() -> None: + """Documentation presentation must not alter callable introspection.""" + + def keyword_only(*, explicit=None, **kwargs): + return explicit, kwargs + + def positional(first, second=None): + return first, second + + keyword_signature = inspect.signature(keyword_only) + positional_signature = inspect.signature(positional) + assert docstring._obfuscate_kwargs(keyword_only) is keyword_only + assert docstring._obfuscate_params(positional) is positional + assert inspect.signature(keyword_only) == keyword_signature + assert inspect.signature(positional) == positional_signature + assert keyword_only.__ultraplot_doc_signature__ == "(**kwargs)" + assert positional.__ultraplot_doc_signature__ == "(*args, **kwargs)" + + +def test_format_implementation_signatures_remain_visible() -> None: + """Format methods retain their declared signatures for tools and editors.""" + cases = ( + (Axes, "title"), + (CartesianAxes, "xlim"), + (PolarAxes, "r0"), + (GeoAxes, "lonlim"), + (TaylorAxes, "corrlabel"), + ) + for cls, representative_parameter in cases: + signature = inspect.signature(cls.format) + assert signature == cls._format_signatures[cls] + assert representative_parameter in signature.parameters + assert cls.format.__ultraplot_doc_signature__ == "(**kwargs)" + + assert inspect.signature(Figure.format) == Figure._format_signature + assert "suptitle" in inspect.signature(Figure.format).parameters + assert Figure.format.__ultraplot_doc_signature__ == "(**kwargs)" + + figure_signature = inspect.signature(Figure) + assert "refnum" in figure_signature.parameters + assert Figure.__init__.__ultraplot_doc_signature__ == "(**kwargs)" + + +def test_snippet_manager_preserves_callable_signature() -> None: + """Docstring expansion acts as a typed identity decorator.""" + + @docstring._snippet_manager + def documented(value, *, option=None): + """Return the input value.""" + return value, option + + assert str(inspect.signature(documented)) == "(value, *, option=None)" + + +def test_inherited_docstrings_preserve_callable_signature() -> None: + """Matplotlib docstring concatenation only compacts the Sphinx heading.""" + signature = inspect.signature(Axes.legend) + assert "handles" in signature.parameters + assert "labels" in signature.parameters + assert Axes.legend.__ultraplot_doc_signature__ == "(*args, **kwargs)" + + def test_geo_format_uses_only_canonical_entries() -> None: # Compatibility spellings live in the generated alias reference instead of # competing with canonical parameters in each function's primary docs. diff --git a/ultraplot/tests/test_kwargs_helpers.py b/ultraplot/tests/test_kwargs_helpers.py index 297d491c2..c17771ee6 100644 --- a/ultraplot/tests/test_kwargs_helpers.py +++ b/ultraplot/tests/test_kwargs_helpers.py @@ -1,5 +1,6 @@ """Tests for the keyword-argument / alias helpers in ``ultraplot.internals.kwargs``.""" +import inspect import warnings import pytest @@ -7,6 +8,7 @@ from ultraplot import internals from ultraplot.internals import guides from ultraplot.internals import kwargs as ikwargs +from ultraplot.internals import warnings as uwarnings def test_kwargs_helpers_reexported_from_package() -> None: @@ -53,6 +55,15 @@ def func(*, refnum=1, figwidth=None, **kwargs): assert func(width=5) == (1, 5, {}) # synonym folded to canonical assert func(ref=2, figwidth=3) == (2, 3, {}) # mix of alias + canonical assert func(other=9) == (1, None, {"other": 9}) # unrelated kwargs pass through + assert str(inspect.signature(func)) == "(*, refnum=1, figwidth=None, **kwargs)" + + +def test_rename_kwargs_preserves_callable_signature() -> None: + @uwarnings._rename_kwargs("0.1.0", old="current") + def func(*, current=None): + return current + + assert str(inspect.signature(func)) == "(*, current=None)" def test_alias_kwargs_none_synonym_defers_to_default() -> None: diff --git a/ultraplot/ui.py b/ultraplot/ui.py index c243ac1dd..9027bedba 100644 --- a/ultraplot/ui.py +++ b/ultraplot/ui.py @@ -9,6 +9,7 @@ from . import figure as pfigure from . import gridspec as pgridspec from ._subplots import SubplotManager +from .figure import Figure from .internals import ( _canonicalize_kwargs, _figure_format_alias_scopes, @@ -128,7 +129,7 @@ def isinteractive(): @docstring._snippet_manager -def figure(**kwargs): +def figure(**kwargs) -> Figure: """ Create an empty figure. Subplots can be subsequently added using `~ultraplot.figure.Figure.add_subplot` or `~ultraplot.figure.Figure.subplots`. @@ -156,7 +157,7 @@ def figure(**kwargs): @docstring._snippet_manager -def subplot(**kwargs): +def subplot(**kwargs) -> tuple[Figure, paxes.Axes]: """ Return a figure and a single subplot. This command is analogous to `matplotlib.pyplot.subplot`, @@ -202,7 +203,7 @@ def subplot(**kwargs): @docstring._snippet_manager -def subplots(*args, **kwargs): +def subplots(*args, **kwargs) -> tuple[Figure, pgridspec.SubplotGrid]: """ Return a figure and an arbitrary grid of subplots. This command is analogous to `matplotlib.pyplot.subplots`,