From 21565f9ac9d5c66b3154aa35d4b40aa863a46f39 Mon Sep 17 00:00:00 2001 From: munechika-koyo Date: Tue, 6 Oct 2026 11:13:08 +0200 Subject: [PATCH 1/2] Generate readable multiline docstrings in distribution artifacts --- tools/ci/check_installed_docstrings.py | 92 +++++++++++++++++++++++- tools/expand_docstrings.py | 41 +++++++++-- ultraplot/tests/test_docstrings.py | 98 +++++++++++++++++++++++++- 3 files changed, 219 insertions(+), 12 deletions(-) diff --git a/tools/ci/check_installed_docstrings.py b/tools/ci/check_installed_docstrings.py index 34199281d..64f1ab57f 100644 --- a/tools/ci/check_installed_docstrings.py +++ b/tools/ci/check_installed_docstrings.py @@ -3,14 +3,46 @@ from __future__ import annotations import ast +import inspect import re +from collections.abc import Iterator from importlib.metadata import distribution from pathlib import Path PLACEHOLDER = re.compile(r"%\(([^)]+)\)s") +_NUMPY_DOCSTRINGS = { + "ui.py": { + "figure": (("Parameters", "Other parameters"), ("refnum", "refaspect")), + "subplot": (("Other parameters", "Returns"), ("refnum", "refaspect")), + "subplots": ( + ("Parameters", "Other parameters", "Returns"), + ("array", "order", "projection", "refnum", "refaspect"), + ), + }, + "figure.py": { + "Figure.__init__": ( + ("Parameters", "Other parameters"), + ("refnum", "refaspect"), + ), + "Figure.subplots": ( + ("Parameters", "Other parameters", "Returns"), + ("array", "order", "projection", "refnum", "refaspect"), + ), + "Figure.add_subplots": ( + ("Parameters", "Other parameters", "Returns"), + ("array", "order", "projection", "refnum", "refaspect"), + ), + }, + "gridspec.py": { + "GridSpec.__init__": ( + ("Parameters", "Other parameters"), + ("nrows", "ncols", "left, right, top, bottom"), + ), + }, +} -def _iter_docstrings(tree): +def _iter_docstrings(tree: ast.AST) -> Iterator[tuple[ast.AST, str]]: for node in ast.walk(tree): if isinstance( node, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef) @@ -20,16 +52,71 @@ def _iter_docstrings(tree): yield node, doc +def _check_numpy_docstring( + doc: str, sections: tuple[str, ...], parameters: tuple[str, ...] +) -> None: + """Check literal structure before runtime decorators can normalize it.""" + lines = inspect.cleandoc(doc).splitlines() + for section in sections: + assert section in lines, f"Missing or indented section: {section}" + index = lines.index(section) + assert lines[index + 1 : index + 2] == ["-" * len(section)], section + for parameter in parameters: + declarations = [ + i for i, line in enumerate(lines) if line.startswith(parameter + " :") + ] + assert len(declarations) == 1, f"Missing or indented parameter: {parameter}" + index = declarations[0] + assert lines[index].split(" :", 1)[1].strip(), f"Missing type: {parameter}" + description = lines[index + 1 : index + 2] + assert ( + description and description[0].startswith(" ") and description[0].strip() + ), f"Missing or unindented description: {parameter}" + if "projection" in parameters: + assert ( + "projection : str, `cartopy.crs.Projection`, " + "or `~mpl_toolkits.basemap.Basemap`, optional" + ) in lines, "Malformed projection declaration" + assert ( + "Whether subplots are numbered in row-major (``'C'``) " + "or column-major (``'F'``)" + ) in "\n".join(lines), "Incorrect C/F ordering documentation" + + +def _check_numpy_docstrings(filename: str, tree: ast.Module) -> None: + for name, (sections, parameters) in _NUMPY_DOCSTRINGS.get(filename, {}).items(): + node: ast.Module | ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef = tree + for part in name.split("."): + node = next( + child + for child in node.body + if isinstance( + child, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef) + ) + and child.name == part + ) + doc = ast.get_docstring(node, clean=False) + assert doc, f"Missing docstring: {filename}:{name}" + try: + _check_numpy_docstring(doc, sections, parameters) + except AssertionError as exc: + raise AssertionError(f"{filename}:{name}: {exc}") from exc + + 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" + 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)) + if path.parent == package: + _check_numpy_docstrings(path.name, tree) for node, doc in _iter_docstrings(tree): for key in PLACEHOLDER.findall(doc): # This one is syntax documentation inside the manager itself. @@ -39,7 +126,6 @@ def main() -> None: f"{path.relative_to(package)}:{getattr(node, 'lineno', 1)}: {key}" ) - assert not unresolved, "Unexpanded installed docstrings:\n" + "\n".join(unresolved) diff --git a/tools/expand_docstrings.py b/tools/expand_docstrings.py index b9e6a0423..65ac18383 100644 --- a/tools/expand_docstrings.py +++ b/tools/expand_docstrings.py @@ -7,12 +7,13 @@ import inspect import re import sys +from collections.abc import Iterator, Mapping from pathlib import Path _PLACEHOLDER = re.compile(r"%\(([^)]+)\)s") -def _iter_docstring_literals(node): +def _iter_docstring_literals(node: ast.AST) -> Iterator[ast.Constant]: """Yield string literal nodes that are actual Python docstrings.""" body = getattr(node, "body", ()) if body: @@ -37,11 +38,11 @@ def _module_name(package_root: Path, path: Path) -> str: return ".".join((package_root.name, *parts)) -def _expand(text: str, snippets) -> tuple[str, bool]: +def _expand(text: str, snippets: Mapping[str, object]) -> tuple[str, bool]: """Expand registered placeholders and report whether unknown keys remain.""" missing = False - def replace(match): + def replace(match: re.Match[str]) -> str: nonlocal missing try: return str(snippets[match.group(1)]) @@ -52,7 +53,27 @@ def replace(match): return _PLACEHOLDER.sub(replace, text), missing -def _rewrite_file(path: Path, package_root: Path, snippets) -> int: +def _docstring_literal(text: str, indent: str) -> bytes: + """Render readable source without interpreting backslashes or quote sequences.""" + # Without a later nonblank line, cleandoc would retain the closing-line indent. + if not any(line.strip() for line in text.splitlines()[1:]): + return repr(text).encode("utf-8") + escaped = ( + text.replace("\\", "\\\\") + .replace('"""', '\\"\\"\\"') + .replace("\r", "\\r") + .replace("\x00", "\\x00") + ) + lines = escaped.split("\n") + body = lines[0] + "".join( + f"\n{indent}{line}" if line else "\n" for line in lines[1:] + ) + return f'"""{body}\n{indent}"""'.encode() + + +def _rewrite_file( + path: Path, package_root: Path, snippets: Mapping[str, object] +) -> int: source = path.read_bytes() tree = ast.parse(source, filename=str(path)) literals = [ @@ -68,7 +89,9 @@ def _rewrite_file(path: Path, package_root: Path, snippets) -> int: # 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(inspect.cleandoc(node.value), snippets) for node in literals] + expanded = [ + _expand(inspect.cleandoc(node.value), snippets) for node in literals + ] lines = source.splitlines(keepends=True) offsets = [] @@ -83,7 +106,9 @@ def _rewrite_file(path: Path, package_root: Path, snippets) -> int: 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"))) + line = lines[node.lineno - 1] + indent = line[: len(line) - len(line.lstrip())].decode("utf-8") + replacements.append((start, end, _docstring_literal(text, indent))) for start, end, replacement in reversed(replacements): source = source[:start] + replacement + source[end:] @@ -103,7 +128,9 @@ def expand_package(package_root: Path) -> int: del sys.modules[name] sys.path.insert(0, str(build_root)) try: - snippets = importlib.import_module("ultraplot.internals.docstring")._snippet_manager + snippets = importlib.import_module( + "ultraplot.internals.docstring" + )._snippet_manager changed = 0 for path in sorted(package_root.rglob("*.py")): if "__pycache__" in path.parts: diff --git a/ultraplot/tests/test_docstrings.py b/ultraplot/tests/test_docstrings.py index 8d9856c2a..00c92d6f8 100644 --- a/ultraplot/tests/test_docstrings.py +++ b/ultraplot/tests/test_docstrings.py @@ -1,11 +1,17 @@ #!/usr/bin/env python3 """Tests for shared and build-time materialized docstrings.""" +import ast import inspect import runpy +import shutil +from collections.abc import Callable from pathlib import Path +import pytest + import ultraplot as uplt +from ultraplot.internals import docstring def _load_docstring_expander(): @@ -13,7 +19,7 @@ def _load_docstring_expander(): return runpy.run_path(str(path)) -def test_build_docstring_expansion_preserves_numpy_indentation(tmp_path): +def test_build_docstring_expansion_preserves_numpy_indentation(tmp_path: Path) -> None: """Build-time expansion should match runtime normalize-before-substitute semantics.""" rewrite_file = _load_docstring_expander()["_rewrite_file"] package_root = tmp_path / "pkg" @@ -35,6 +41,11 @@ def test_build_docstring_expansion_preserves_numpy_indentation(tmp_path): } assert rewrite_file(path, package_root, snippets) == 1 + source = path.read_text() + assert ' """Summary.\n' in source + assert " first : int\n First value.\n" in source + assert "\\n" not in source + assert all(line == line.rstrip() for line in source.splitlines()) namespace = {} exec(compile(path.read_text(), str(path), "exec"), namespace) @@ -50,7 +61,90 @@ def test_build_docstring_expansion_preserves_numpy_indentation(tmp_path): ) -def test_subplots_parameter_docstrings_are_numpy_style(): +@pytest.mark.parametrize( + ("filename", "objects"), + [ + ( + "ui.py", + {"figure": uplt.figure, "subplot": uplt.subplot, "subplots": uplt.subplots}, + ), + ( + "figure.py", + { + "Figure.__init__": uplt.Figure.__init__, + "Figure.subplots": uplt.Figure.subplots, + "Figure.add_subplots": uplt.Figure.add_subplots, + }, + ), + ("gridspec.py", {"GridSpec.__init__": uplt.GridSpec.__init__}), + ], +) +def test_materialized_public_docstrings_match_runtime( + tmp_path: Path, filename: str, objects: dict[str, Callable[..., object]] +) -> None: + """Exercise actual source templates, including methods that contain only a snippet.""" + rewrite_file = _load_docstring_expander()["_rewrite_file"] + source_root = Path(uplt.__file__).resolve().parent + package_root = tmp_path / "ultraplot" + package_root.mkdir() + path = package_root / filename + shutil.copyfile(source_root / filename, path) + assert rewrite_file(path, package_root, docstring._snippet_manager) > 0 + tree = ast.parse(path.read_text()) + for name, obj in objects.items(): + node = tree + for part in name.split("."): + node = next( + child for child in node.body if getattr(child, "name", None) == part + ) + assert ast.get_docstring(node) == inspect.getdoc(obj) + # A second build pass must leave the already materialized source untouched. + source = path.read_bytes() + assert rewrite_file(path, package_root, docstring._snippet_manager) == 0 + assert path.read_bytes() == source + + +@pytest.mark.parametrize( + "text", + [ + 'Résumé with """quotes""", backslashes \\n and \\path, and 100%.\n\n Indented text with \r and \x00.', + 'A single line with """quotes""" and a trailing quote "', + "Summary.\n\n", + ], +) +def test_materialized_docstrings_preserve_literal_characters( + tmp_path: Path, text: str +) -> None: + rewrite_file = _load_docstring_expander()["_rewrite_file"] + path = tmp_path / "example.py" + path.write_text('def example():\n """%(example)s"""\n') + assert rewrite_file(path, tmp_path, {"example": text}) == 1 + namespace = {} + exec(compile(path.read_text(), str(path), "exec"), namespace) + assert inspect.getdoc(namespace["example"]) == inspect.cleandoc(text) + + +@pytest.mark.parametrize( + "doc", + [ + "Summary.\n\n Parameters\n ----------\n first : int\n Description.\nsecond : str\n Second.", + "Summary.\n\nParameters\n----------\nfirst : int\nDescription.", + "Summary.\n\nParameters\n----------\nfirst :\nint\n Description.", + ], +) +def test_installed_docstring_check_rejects_malformed_parameters(doc: str) -> None: + path = ( + Path(__file__).resolve().parents[2] + / "tools" + / "ci" + / "check_installed_docstrings.py" + ) + check_docstring = runpy.run_path(str(path))["_check_numpy_docstring"] + with pytest.raises(AssertionError): + check_docstring(doc, ("Parameters",), ("first",)) + + +def test_subplots_parameter_docstrings_are_numpy_style() -> None: """Shared subplot parameter snippets should render as valid NumPy-style entries.""" doc = inspect.getdoc(uplt.subplots) or "" assert ( From 90b40a200dda8079b722d8051380b3099d88e5b7 Mon Sep 17 00:00:00 2001 From: munechika-koyo Date: Tue, 6 Oct 2026 16:55:29 +0200 Subject: [PATCH 2/2] Refactor docstrings for consistency and clarity across modules - Updated "Note" sections to "Notes" in various files for uniformity. - Changed "Example" sections to "Examples" in multiple locations to align with Numpy style. - Improved parameter documentation formatting in `ticker.py`, `scale.py`, `legend.py`, and others. - Ensured consistent use of parameter descriptions and examples throughout the codebase. - Enhanced docstring structure in `cartesian.py`, `geo.py`, `plot.py`, `colors.py`, `config.py`, `gridspec.py`, and `ticker.py`. --- environment.yml | 1 + pyproject.toml | 1 + tools/ci/check_installed_docstrings.py | 49 ++++++++ ultraplot/animation.py | 4 - ultraplot/axes/base.py | 161 ++++++++++++++----------- ultraplot/axes/cartesian.py | 8 +- ultraplot/axes/geo.py | 4 +- ultraplot/axes/plot.py | 140 ++++++++++----------- ultraplot/colors.py | 16 +-- ultraplot/config.py | 12 +- ultraplot/gridspec.py | 8 +- ultraplot/legend.py | 95 ++++++++++----- ultraplot/scale.py | 6 +- ultraplot/tests/test_docstrings.py | 143 ++++++++++++++++++++++ ultraplot/ticker.py | 21 ++-- 15 files changed, 459 insertions(+), 210 deletions(-) diff --git a/environment.yml b/environment.yml index 1377b4f28..93bd199a3 100644 --- a/environment.yml +++ b/environment.yml @@ -16,6 +16,7 @@ dependencies: - pytest-mpl - pytest-cov - pytest-xdist + - numpydoc - pip - pint - pre-commit diff --git a/pyproject.toml b/pyproject.toml index 3fa2fc6e4..f962d2f5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,6 +77,7 @@ stats = [ typing = [ "basedpyright==1.40.1", + "numpydoc", ] [build-system] diff --git a/tools/ci/check_installed_docstrings.py b/tools/ci/check_installed_docstrings.py index 64f1ab57f..6ab3b87e3 100644 --- a/tools/ci/check_installed_docstrings.py +++ b/tools/ci/check_installed_docstrings.py @@ -5,10 +5,13 @@ import ast import inspect import re +import warnings from collections.abc import Iterator from importlib.metadata import distribution from pathlib import Path +from numpydoc.docscrape import NumpyDocString + PLACEHOLDER = re.compile(r"%\(([^)]+)\)s") _NUMPY_DOCSTRINGS = { "ui.py": { @@ -103,6 +106,44 @@ def _check_numpy_docstrings(filename: str, tree: ast.Module) -> None: raise AssertionError(f"{filename}:{name}: {exc}") from exc +def _check_numpy_structure(doc: str) -> None: + """Reject malformed sections and entries across all owned package docstrings.""" + doc = inspect.cleandoc(doc) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + parsed = NumpyDocString(doc) + # Napoleon, used by our Sphinx build, supports Important as an admonition. + unexpected = [ + str(warning.message).splitlines()[0] + for warning in caught + if str(warning.message).splitlines()[0] != "Unknown section Important" + ] + assert not unexpected, "; ".join(unexpected) + for section in ("Parameters", "Other Parameters", "Returns", "Yields"): + for entry in parsed[section]: + assert ( + entry.desc + ), f"Missing description in {section}: {entry.name or entry.type}" + + headings = {name.lower() for name in parsed.keys()} + lines = doc.splitlines() + section = "" + for index, (line, underline) in enumerate(zip(lines, lines[1:])): + if not underline.strip() or set(underline.strip()) != {"-"}: + continue + heading = line.strip().lower() + if heading not in headings and heading != "important": + continue + # Code samples may themselves contain a nested NumPy-style docstring. + if section == "examples" and line.startswith(" "): + continue + assert line == line.lstrip(), f"Indented section: {line.strip()}" + assert ( + index == 0 or not lines[index - 1].strip() + ), f"Missing blank line before section: {line}" + section = heading + + def main() -> None: package = Path(distribution("ultraplot").locate_file("ultraplot")) assert package.is_dir(), f"installed package not found: {package}" @@ -118,6 +159,14 @@ def main() -> None: if path.parent == package: _check_numpy_docstrings(path.name, tree) for node, doc in _iter_docstrings(tree): + if not {"tests", "externals"}.intersection(path.relative_to(package).parts): + try: + _check_numpy_structure(doc) + except (AssertionError, ValueError) as exc: + location = ( + f"{path.relative_to(package)}:{getattr(node, 'lineno', 1)}" + ) + raise AssertionError(f"{location}: {exc}") from exc for key in PLACEHOLDER.findall(doc): # This one is syntax documentation inside the manager itself. if path.name == "docstring.py" and key == "name": diff --git a/ultraplot/animation.py b/ultraplot/animation.py index 07f39ae97..3f7e0381c 100644 --- a/ultraplot/animation.py +++ b/ultraplot/animation.py @@ -593,10 +593,6 @@ def save( update function, so anything else changed per frame (titles, ticks, axes limits) will not appear. Pass ``False`` to redraw everything. - Other Parameters - ---------------- - See `matplotlib.animation.Animation.save`. - See also -------- matplotlib.animation.Animation.save diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py index 5aea24ca1..6bca700fa 100644 --- a/ultraplot/axes/base.py +++ b/ultraplot/axes/base.py @@ -172,7 +172,7 @@ This is similar to `matplotlib.axes.Axes.inset_axes`. Parameters ------------ +---------- bounds : 4-tuple of float or (4-tuple, transform) The (left, bottom, width, height) coordinates for the axes. To specify the coordinate system alongside the coordinates, pass ``(bounds, transform)``. @@ -192,17 +192,17 @@ Passed to `~Axes.indicate_inset_zoom`. Other parameters ------------------ +---------------- **kwargs Passed to `ultraplot.axes.Axes`. Returns --------- +------- ultraplot.axes.Axes The inset axes. See also ---------- +-------- Axes.indicate_inset_zoom matplotlib.axes.Axes.inset_axes matplotlib.axes.Axes.indicate_inset @@ -213,18 +213,18 @@ This will replace previously drawn zoom indicators. Parameters ------------ +---------- %(artist.patch)s zorder : float, default: 3.5 The `zorder `__ of the indicators. Should be greater than the zorder of elements in the parent axes. Other parameters ------------------ +---------------- **kwargs Passed to `~matplotlib.patches.Patch`. -Note +Notes ----- This command must be called from the inset axes rather than the parent axes. It is called automatically when ``zoom=True`` is passed to `~Axes.inset_axes` @@ -232,7 +232,7 @@ limits even if they are later changed). See also ---------- +-------- matplotlib.axes.Axes.indicate_inset matplotlib.axes.Axes.indicate_inset_zoom """ @@ -256,7 +256,7 @@ Add a panel axes. Parameters ------------ +---------- side : str, optional The panel location. Valid location keys are as follows. @@ -294,13 +294,13 @@ is determined by figure-wide `sharex` and `sharey` settings. Other parameters ------------------ +---------------- **kwargs Passed to `ultraplot.axes.CartesianAxes`. Supports all valid `~ultraplot.axes.CartesianAxes.format` keywords. Returns --------- +------- ultraplot.axes.CartesianAxes The panel axes. """ @@ -367,13 +367,11 @@ abctitlepad : float, default: :rc:`abc.titlepad` The horizontal padding between a-b-c labels and titles in the same location. %(units.pt)s -lefttitle, centertitle, righttitle, upperlefttitle, uppercentertitle, upperrighttitle : str or sequence, optional -lowerlefttitle, lowercentertitle, lowerrighttitle : str or sequence, optional +lefttitle, centertitle, righttitle, upperlefttitle, uppercentertitle, upperrighttitle, lowerlefttitle, lowercentertitle, lowerrighttitle : str or sequence, optional Additional titles in specific positions (see `title` for details). This works as an alternative to the ``ax.format(title='Title', titleloc=loc)`` workflow and permits adding more than one title-like label for a single axes. -a, alpha, fc, facecolor, ec, edgecolor, lw, linewidth, ls, linestyle : default: - :rc:`axes.alpha` (default: 1.0), :rc:`axes.facecolor` (default: white), :rc:`axes.edgecolor` (default: black), :rc:`axes.linewidth` (default: 0.6), - +a, alpha, fc, facecolor, ec, edgecolor, lw, linewidth, ls, linestyle : default: :rc:`axes.alpha` (default: 1.0), :rc:`axes.facecolor` (default: white), :rc:`axes.edgecolor` (default: black), :rc:`axes.linewidth` (default: 0.6) Additional settings applied to the background patch, and their shorthands. Their defaults values are the ``'axes'`` properties. """ @@ -382,12 +380,10 @@ Labels for the subplots lying along the left, top, right, and bottom edges of the figure. The length of each list must match the number of subplots along the corresponding edge. -leftlabelpad, toplabelpad, rightlabelpad, bottomlabelpad : float or unit-spec, default -: :rc:`leftlabel.pad`, :rc:`toplabel.pad`, :rc:`rightlabel.pad`, :rc:`bottomlabel.pad` +leftlabelpad, toplabelpad, rightlabelpad, bottomlabelpad : float or unit-spec, default: :rc:`leftlabel.pad`, :rc:`toplabel.pad`, :rc:`rightlabel.pad`, :rc:`bottomlabel.pad` The padding between the labels and the axes content. %(units.pt)s -leftlabelsharedpad, toplabelsharedpad, rightlabelsharedpad, bottomlabelsharedpad : float or unit-spec, default -: :rc:`leftlabel.sharedpad`, :rc:`toplabel.sharedpad`, :rc:`rightlabel.sharedpad`, :rc:`bottomlabel.sharedpad` +leftlabelsharedpad, toplabelsharedpad, rightlabelsharedpad, bottomlabelsharedpad : float or unit-spec, default: :rc:`leftlabel.sharedpad`, :rc:`toplabel.sharedpad`, :rc:`rightlabel.sharedpad`, :rc:`bottomlabel.sharedpad` The padding between side labels and a shared spanning axis label on the same side. The spanning label is placed outside the side labels. %(units.pt)s @@ -510,11 +506,11 @@ and `~ultraplot.ticker.DiscreteLocator` is used for discrete color levels. locator_kw : dict-like, optional Keyword arguments passed to `matplotlib.ticker.Locator` class. -minorticks +minorticks : locator-spec, optional As with `ticks` but for the minor ticks. By default `~matplotlib.ticker.AutoMinorLocator` is used for continuous color levels and `~ultraplot.ticker.DiscreteLocator` is used for discrete color levels. -minorlocator_kw +minorlocator_kw : dict-like, optional As with `locator_kw`, but for the minor ticks. format : formatter-spec, optional The tick label format. Passed to the `~ultraplot.constructor.Formatter` @@ -547,7 +543,7 @@ labellocation : {'bottom', 'top', 'left', 'right'} The colorbar label location. Inherits from `tickloc` by default. Default is toward the outside of the subplot for outer colorbars and ``'bottom'`` for inset colorbars. -labelcolor, labelsize, labelweight: default: :rc:`label.color`, :rc:`label.size`, and :rc:`label.weight`. +labelcolor, labelsize, labelweight : default: :rc:`label.color`, :rc:`label.size`, and :rc:`label.weight`. The font color, size, and weight for the colorbar label. a, alpha, framealpha, fc, facecolor, framecolor, ec, edgecolor, ew, edgewidth : default: :rc:`colorbar.framealpha`, :rc:`colorbar.framecolor` For inset colorbars only. Controls the transparency and color of @@ -560,15 +556,16 @@ Whether to rasterize the colorbar solids. The matplotlib default was ``True`` but ultraplot changes this to ``False`` since rasterization can cause misalignment between the color patches and the colorbar outline. -outline : bool, None default : None +outline : bool, optional, default None Controls the visibility of the outer colorbar outline. When set to False, the spines of the colorbar are hidden. If set to `None` it uses the `rc['colorbar.outline']` value. labelrotation : str, float, default: None - Controls the rotation of the colorbar label. When set to None it takes on the value of `rc["colorbar.labelrotation"]`. When set to auto it produces a sensible default where the rotation is adjusted to where the colorbar is located. For example, a horizontal colorbar with a label to the left or right will match the horizontal alignment and rotate the label to 0 degrees. Users can provide a float to rotate to any arbitrary angle. - - - + Controls the rotation of the colorbar label. + When set to None it takes on the value of `rc["colorbar.labelrotation"]`. + When set to auto it produces a sensible default where the rotation is adjusted to where the colorbar is located. + For example, a horizontal colorbar with a label to the left or right will match the horizontal alignment and rotate the label to 0 degrees. + Users can provide a float to rotate to any arbitrary angle. **kwargs Passed to `~matplotlib.figure.Figure.colorbar`. """ @@ -607,7 +604,7 @@ from the artists in the tuple (if there are multiple unique labels in the tuple 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 + on matplotlib legend handlers and tuple groups, see the matplotlib `legend guide \ `__. """ _legend_kwargs_docstring = """ @@ -640,7 +637,8 @@ borderpad, borderaxespad, handlelength, handleheight, handletextpad, labelspacing, columnspacing : unit-spec, optional Various matplotlib `~matplotlib.axes.Axes.legend` spacing arguments. %(units.em)s -a, alpha, framealpha, fc, facecolor, framecolor, ec, edgecolor, ew, edgewidth: default: :rc:`legend.framealpha`, :rc:`legend.facecolor`, :rc:`legend.edgecolor`, :rc:`axes.linewidth` The opacity, face color, edge color, and edge width for the legend frame. +a, alpha, framealpha, fc, facecolor, framecolor, ec, edgecolor, ew, edgewidth : default: :rc:`legend.framealpha`, :rc:`legend.facecolor`, :rc:`legend.edgecolor`, :rc:`axes.linewidth` + The opacity, face color, edge color, and edge width for the legend frame. c, color, lw, linewidth, m, marker, ls, linestyle, dashes, ms, markersize : optional Properties used to override the legend handles. For example, for a legend describing variations in line style ignoring variations @@ -3187,7 +3185,7 @@ def _update_share_labels(self, axes=None, target="x"): axes : list of int or list of Axes, optional The axes indices or Axes objects to share labels between target : {'x', 'y'}, optional - Which axis labels to share ('x' for x-axis, 'y' for y-axis) + Which axis labels to share ('x' for x-axis, 'y' for y-axis) """ if axes is False: self.figure._clear_share_label_groups([self], target=target) @@ -3607,47 +3605,48 @@ def colorbar(self, mappable, values=None, loc=None, **kwargs): Parameters ---------- %(axes.colorbar_args)s - loc : int or str, default: :rc:`colorbar.loc` - The colorbar location. Valid location keys are shown in the below table. - - .. _colorbar_table: - - ================== ======================================= - Location Valid keys - ================== ======================================= - outer left ``'left'``, ``'l'`` - outer right ``'right'``, ``'r'`` - outer bottom ``'bottom'``, ``'b'`` - outer top ``'top'``, ``'t'`` - default inset ``'best'``, ``'inset'``, ``'i'``, ``0`` - upper right inset ``'upper right'``, ``'ur'``, ``1`` - upper left inset ``'upper left'``, ``'ul'``, ``2`` - lower left inset ``'lower left'``, ``'ll'``, ``3`` - lower right inset ``'lower right'``, ``'lr'``, ``4`` - "filled" ``'fill'`` - ================== ======================================= - - length : float or unit-spec, default: :rc:`colorbar.length` or :rc:`colorbar.insetlength` - The colorbar length (also accepted as ``shrink``). For outer colorbars, - units are relative to the axes width or height (default is - :rcraw:`colorbar.length`). For inset - colorbars, floats interpreted as em-widths and strings interpreted - by `~ultraplot.utils.units` (default is :rcraw:`colorbar.insetlength`). - width : unit-spec, default: :rc:`colorbar.width` or :rc:`colorbar.insetwidth` - The colorbar width. For outer colorbars, floats are interpreted as inches - (default is :rcraw:`colorbar.width`). For inset colorbars, floats are - interpreted as em-widths (default is :rcraw:`colorbar.insetwidth`). - Strings are interpreted by `~ultraplot.utils.units`. - %(axes.colorbar_space)s - Has no visible effect if `length` is ``1``. - bbox_to_anchor : 2-tuple, 4-tuple, or `matplotlib.transforms.Bbox`, optional - For inset colorbars, anchor the full colorbar footprint using the - same semantics as `~matplotlib.axes.Axes.legend`. The colorbar - `loc` selects the corresponding anchor corner. Outer colorbar - placement is unchanged. - Other parameters - ---------------- - %(axes.colorbar_kwargs)s + loc : int or str, default: :rc:`colorbar.loc` + The colorbar location. Valid location keys are shown in the below table. + + .. _colorbar_table: + + ================== ======================================= + Location Valid keys + ================== ======================================= + outer left ``'left'``, ``'l'`` + outer right ``'right'``, ``'r'`` + outer bottom ``'bottom'``, ``'b'`` + outer top ``'top'``, ``'t'`` + default inset ``'best'``, ``'inset'``, ``'i'``, ``0`` + upper right inset ``'upper right'``, ``'ur'``, ``1`` + upper left inset ``'upper left'``, ``'ul'``, ``2`` + lower left inset ``'lower left'``, ``'ll'``, ``3`` + lower right inset ``'lower right'``, ``'lr'``, ``4`` + "filled" ``'fill'`` + ================== ======================================= + + length : float or unit-spec, default: :rc:`colorbar.length` or :rc:`colorbar.insetlength` + The colorbar length (also accepted as ``shrink``). For outer colorbars, + units are relative to the axes width or height (default is + :rcraw:`colorbar.length`). For inset + colorbars, floats interpreted as em-widths and strings interpreted + by `~ultraplot.utils.units` (default is :rcraw:`colorbar.insetlength`). + width : unit-spec, default: :rc:`colorbar.width` or :rc:`colorbar.insetwidth` + The colorbar width. For outer colorbars, floats are interpreted as inches + (default is :rcraw:`colorbar.width`). For inset colorbars, floats are + interpreted as em-widths (default is :rcraw:`colorbar.insetwidth`). + Strings are interpreted by `~ultraplot.utils.units`. + %(axes.colorbar_space)s + Has no visible effect if `length` is ``1``. + bbox_to_anchor : 2-tuple, 4-tuple, or `matplotlib.transforms.Bbox`, optional + For inset colorbars, anchor the full colorbar footprint using the + same semantics as `~matplotlib.axes.Axes.legend`. The colorbar + `loc` selects the corresponding anchor corner. Outer colorbar + placement is unchanged. + + Other parameters + ---------------- + %(axes.colorbar_kwargs)s See also -------- @@ -3818,11 +3817,16 @@ def catlegend(self, categories, **kwargs): Whether to render connector lines through the markers. Falls back to :rc:`legend.cat.line`. Setting a non-default ``linestyle`` implicitly enables this. + Other parameters ---------------- %(legend.semantic_style_kwargs)s %(legend.semantic_handle_kw)s + Notes + ----- + %(legend.semantic_style_notes)s + See also -------- Axes.entrylegend @@ -3847,11 +3851,16 @@ def entrylegend(self, entries, **kwargs): :rc:`legend.cat.line`. marker, color %(legend.semantic_style_arg)s + Other parameters ---------------- %(legend.semantic_style_kwargs)s %(legend.semantic_handle_kw)s + Notes + ----- + %(legend.semantic_style_notes)s + See also -------- Axes.catlegend @@ -3915,6 +3924,10 @@ def sizelegend(self, levels, **kwargs): %(legend.semantic_style_kwargs)s %(legend.semantic_handle_kw)s + Notes + ----- + %(legend.semantic_style_notes)s + See also -------- Axes.catlegend @@ -3960,6 +3973,10 @@ def numlegend(self, levels=None, **kwargs): %(legend.semantic_num_style_kwargs)s %(legend.semantic_handle_kw)s + Notes + ----- + %(legend.semantic_num_style_notes)s + See also -------- Axes.sizelegend @@ -4010,6 +4027,8 @@ def geolegend(self, entries, labels=None, **kwargs): Notes ----- + %(legend.semantic_num_style_notes)s + Geometry legend entries use normalized patch proxies inside the legend handle box rather than reusing the original map artist directly. This preserves the general geometry shape and copied patch styling, but very diff --git a/ultraplot/axes/cartesian.py b/ultraplot/axes/cartesian.py index 3e12ae1e0..6ccef3c17 100644 --- a/ultraplot/axes/cartesian.py +++ b/ultraplot/axes/cartesian.py @@ -264,8 +264,8 @@ ultraplot.axes.CartesianAxes The resulting axes. -Note ----- +Notes +----- This enforces the following default settings: * Places the old {x} axis on the {x1} and the new {x} @@ -1709,8 +1709,8 @@ def format( ultraplot.figure.Figure.format ultraplot.config.Configurator.context - Note - ---- + Notes + ----- If you plot something with a `datetime64 \ `__, `pandas.Timestamp`, `pandas.DatetimeIndex`, `datetime.date`, `datetime.time`, diff --git a/ultraplot/axes/geo.py b/ultraplot/axes/geo.py index 7deeff7be..6f8d06ef6 100644 --- a/ultraplot/axes/geo.py +++ b/ultraplot/axes/geo.py @@ -1539,8 +1539,8 @@ class GeoAxes(shared._SharedAxes, plot.PlotAxes): Axes subclass for plotting in geographic projections. Uses either cartopy or basemap as a "backend". - Note - ---- + Notes + ----- This subclass uses longitude and latitude as the default coordinate system for all plotting commands by internally passing ``transform=cartopy.crs.PlateCarree()`` to cartopy commands and ``latlon=True`` to basemap commands. Also, when using basemap diff --git a/ultraplot/axes/plot.py b/ultraplot/axes/plot.py index b90420e45..d92a5b7b2 100644 --- a/ultraplot/axes/plot.py +++ b/ultraplot/axes/plot.py @@ -298,8 +298,8 @@ **kwargs Patch properties passed to `matplotlib.sankey.Sankey.add` in Matplotlib mode. -Layered defaults ----------------- +Notes +----- Layered mode uses :rc:`sankey.nodepad`, :rc:`sankey.nodewidth`, :rc:`sankey.margin`, :rc:`sankey.flow.alpha`, :rc:`sankey.flow.curvature`, and :rc:`sankey.node.facecolor` when not set explicitly. @@ -556,11 +556,9 @@ The cap size for thin error bars in points. barz, barzorder, boxz, boxzorder : float, default: 2.5 The "zorder" for the thin and thick error bars. -barc, barcolor, boxc, boxcolor \ -: color-spec, default: :rc:`boxplot.whiskerprops.color` +barc, barcolor, boxc, boxcolor : color-spec, default: :rc:`boxplot.whiskerprops.color` Colors for the thin and thick error bars. -barlw, barlinewidth, boxlw, boxlinewidth \ -: float, default: :rc:`boxplot.whiskerprops.linewidth` +barlw, barlinewidth, boxlw, boxlinewidth : float, default: :rc:`boxplot.whiskerprops.linewidth` Line widths for the thin and thick error bars, in points. The default for boxes is 4 times :rcraw:`boxplot.whiskerprops.linewidth`. boxm, boxmarker : bool or marker-spec, default: 'o' @@ -991,8 +989,7 @@ 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 is processed by `~ultraplot.utils.units` and represents the width rather than area. -c, color, colors, mc, markercolor, markercolors, fc, facecolor, facecolors \ -: array-like or color-spec, optional +c, color, colors, mc, markercolor, markercolors, fc, facecolor, facecolors : array-like or color-spec, optional The marker color(s). If this is an array matching the shape of `x` and `y`, the colors are generated using `cmap`, `norm`, `vmin`, and `vmax`. Otherwise, this should be a valid matplotlib color. To pass explicit RGB(A) colors, @@ -1022,11 +1019,9 @@ %(plot.levels_manual)s %(plot.levels_auto)s %(plot.cycle)s -lw, linewidth, linewidths, mew, markeredgewidth, markeredgewidths \ -: float or sequence, optional +lw, linewidth, linewidths, mew, markeredgewidth, markeredgewidths : float or sequence, optional The marker edge width(s). -edgecolors, markeredgecolor, markeredgecolors \ -: color-spec or sequence, optional +edgecolors, markeredgecolor, markeredgecolors : color-spec or sequence, optional The marker edge color(s). %(plot.error_means_{y})s %(plot.error_bars)s @@ -1051,65 +1046,62 @@ Parameters ---------- -data: array-like +data : array-like The data to be plotted. It is assumed the shape of `data` is (N, M) where N is the number of points and M is the number of features. -levels: array-like, optional +levels : array-like, optional The levels to use for the beeswarm plot. If not provided, the levels are automatically determined based on the data. -n_bins: int or array-like, default: 50 +n_bins : int or array-like, default: 50 Number of bins to use to reduce the overlap between points. Bins are used to determine how crowded the points are for each level of the `y` coordinate. - 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 - is processed by `~ultraplot.utils.units` and represents the width rather than area. - c, color, colors, mc, markercolor, markercolors, fc, facecolor, facecolors \ - : array-like or color-spec, optional - The marker color(s). If this is an array matching the shape of `x` and `y`, - the colors are generated using `cmap`, `norm`, `vmin`, and `vmax`. Otherwise, - this should be a valid matplotlib color. - smin, smax : float, optional - The minimum and maximum marker size area in units ``points ** 2``. Ignored - if `absolute_size` is ``True``. Default value for `smin` is ``1`` and for - `smax` is the square of :rc:`lines.markersize`. - area_size : bool, default: True - Whether the marker sizes `s` are scaled by area or by radius. The default - ``True`` is consistent with matplotlib. When `absolute_size` is ``True``, - the `s` units are ``points ** 2`` if `area_size` is ``True`` and ``points`` - if `area_size` is ``False``. - absolute_size : bool, default: True or False - Whether `s` should be taken to represent "absolute" marker sizes in units - ``points`` or ``points ** 2`` or "relative" marker sizes scaled by `smin` - and `smax`. Default is ``True`` if `s` is scalar and ``False`` if `s` is - array-like or `smin` or `smax` were passed. - %(plot.vmin_vmax)s - %(plot.args_1d_shared)s - - Other parameters - ---------------- - %(plot.cmap_norm)s - %(plot.levels_manual)s - %(plot.levels_auto)s - %(plot.cycle)s - lw, linewidth, linewidths, mew, markeredgewidth, markeredgewidths \ - : float or sequence, optional - The marker edge width(s). - edgecolors, markeredgecolor, markeredgecolors \ - : color-spec or sequence, optional - The marker edge color(s). - %(plot.error_means_{y})s - %(plot.error_bars)s - %(plot.error_shading)s - %(plot.inbounds)s - %(plot.labels_1d)s - %(plot.guide)s - **kwargs - Passed to `~matplotlib.axes.Axes.scatter`. - - See also - -------- - PlotAxes.scatter - PlotAxes.scatterx - matplotlib.axes.Axes.scatter +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 + is processed by `~ultraplot.utils.units` and represents the width rather than area. +c, color, colors, mc, markercolor, markercolors, fc, facecolor, facecolors : array-like or color-spec, optional + The marker color(s). If this is an array matching the shape of `x` and `y`, + the colors are generated using `cmap`, `norm`, `vmin`, and `vmax`. Otherwise, + this should be a valid matplotlib color. +smin, smax : float, optional + The minimum and maximum marker size area in units ``points ** 2``. Ignored + if `absolute_size` is ``True``. Default value for `smin` is ``1`` and for + `smax` is the square of :rc:`lines.markersize`. +area_size : bool, default: True + Whether the marker sizes `s` are scaled by area or by radius. The default + ``True`` is consistent with matplotlib. When `absolute_size` is ``True``, + the `s` units are ``points ** 2`` if `area_size` is ``True`` and ``points`` + if `area_size` is ``False``. +absolute_size : bool, default: True or False + Whether `s` should be taken to represent "absolute" marker sizes in units + ``points`` or ``points ** 2`` or "relative" marker sizes scaled by `smin` + and `smax`. Default is ``True`` if `s` is scalar and ``False`` if `s` is + array-like or `smin` or `smax` were passed. +%(plot.vmin_vmax)s +%(plot.args_1d_shared)s + +Other parameters +---------------- +%(plot.cmap_norm)s +%(plot.levels_manual)s +%(plot.levels_auto)s +%(plot.cycle)s +lw, linewidth, linewidths, mew, markeredgewidth, markeredgewidths : float or sequence, optional + The marker edge width(s). +edgecolors, markeredgecolor, markeredgecolors : color-spec or sequence, optional + The marker edge color(s). +%(plot.error_means_{y})s +%(plot.error_bars)s +%(plot.error_shading)s +%(plot.inbounds)s +%(plot.labels_1d)s +%(plot.guide)s +**kwargs + Passed to `~matplotlib.axes.Axes.scatter`. + +See also +-------- +PlotAxes.scatter +PlotAxes.scatterx +matplotlib.axes.Axes.scatter """ docstring._snippet_manager["plot.beeswarm"] = _beeswarm_docstring.format(y="y") @@ -1231,10 +1223,16 @@ **kwargs Passed to `~matplotlib.axes.Axes.scatter`. -See for more info on the grouping behavior :func:`~ultraplot.PlotAxes.bar`, and for formatting :func:`~ultraplot.PlotAxes.scatter`. Returns ------- -List of ~matplotlib.collections.PatchCollection, and a ~matplotlib.collections.LineCollection +tuple + The list of marker collections and the stem line collection. The stems are + horizontal when calling `~PlotAxes.lollipoph`. + +See also +-------- +PlotAxes.bar : More information on grouping behavior. +PlotAxes.scatter : More information on marker formatting. """ docstring._snippet_manager["plot.lollipop"] = _lollipop_docstring.format(which="x") docstring._snippet_manager["plot.lollipoph"] = _lollipop_docstring.format(which="y") @@ -1711,9 +1709,11 @@ font color, background color, alignment, etc (see :func:`networkx.drawing.nx_pylab.draw_networkx_labels`). rescale : bool, None, default: None. When set to none it checks for `rc["graph.rescale"]` which defaults to `True`. This performs a rescale such that the node position is within a [0, 1] x [0, 1] box. + Returns ------- -Nodes, edges, labels output from the networkx drawing functions. +tuple + Nodes, edges, and labels returned by the networkx drawing functions. See also -------- diff --git a/ultraplot/colors.py b/ultraplot/colors.py index 0604b715b..70aee06c4 100644 --- a/ultraplot/colors.py +++ b/ultraplot/colors.py @@ -2017,8 +2017,8 @@ def __init__( **kwargs Passed to `matploitlib.colors.LinearSegmentedColormap`. - Example - ------- + Examples + -------- The below example generates a `PerceptualColormap` from a `segmentdata` dictionary that uses color names for the hue data, instead of channel values between ``0`` and ``360``. @@ -2508,8 +2508,8 @@ def __init__( Default tick labels to use for colorbars drawn with this normalizer. This is set to values when drawing on-the-fly colorbars. - Note - ---- + Notes + ----- This normalizer makes sure that levels always span the full range of colors in the colormap, whether `extend` is set to ``'min'``, ``'max'``, ``'neither'``, or ``'both'``. In matplotlib, when `extend` is not ``'both'``, @@ -2685,15 +2685,15 @@ def __init__(self, levels, vmin=None, vmax=None, clip=False): ultraplot.constructor.Norm ultraplot.colors.DiscreteNorm - Note - ---- + Notes + ----- The algorithm this normalizer uses to select normalized values in-between level list indices is adapted from the algorithm `~matplotlib.colors.LinearSegmentedColormap` uses to select channel values in-between segment data points (hence the name `SegmentedNorm`). - Example - ------- + Examples + -------- In the below example, unevenly spaced levels are passed to `~matplotlib.axes.Axes.contourf`, resulting in the automatic application of `SegmentedNorm`. diff --git a/ultraplot/config.py b/ultraplot/config.py index 600f002ee..2da76105a 100644 --- a/ultraplot/config.py +++ b/ultraplot/config.py @@ -218,8 +218,8 @@ should be valid ``matplotlib`` rc setting names, and the values will be applied to the ``rc_matplotlib`` object. - Example - ------- + Examples + -------- >>> def _cycle_handler(value): ... # ... logic to create a cycler object from the value ... ... return {'axes.prop_cycle': new_cycler} @@ -1513,8 +1513,8 @@ def context(self, *args, mode=0, file=None, **kwargs): `rc_ultraplot` settings are only returned if they are local to the "with as" block. This is used internally when formatting axes. - Note - ---- + Notes + ----- Context "modes" are primarily used internally but may also be useful for power users. Mode ``1`` is used when `~ultraplot.axes.Axes.format` is called during axes instantiation, and mode ``2`` is used when `~ultraplot.axes.Axes.format` @@ -1522,8 +1522,8 @@ def context(self, *args, mode=0, file=None, **kwargs): `~ultraplot.axes.Axes.format` from constantly looking up and re-applying unchanged settings and significantly increasing the runtime. - Example - ------- + Examples + -------- The below applies settings to axes in a specific figure using `~Configurator.context`. diff --git a/ultraplot/gridspec.py b/ultraplot/gridspec.py index 2d89f5f4b..983c83061 100644 --- a/ultraplot/gridspec.py +++ b/ultraplot/gridspec.py @@ -1615,8 +1615,8 @@ def get_grid_positions(self, figure=None): spacing and using physical units for the spacing terms. The resulting positions include "hidden" panel rows and columns. - Note - ---- + Notes + ----- The physical units for positioning grid cells are converted from em-widths to inches when the `GridSpec` is instantiated. This means that subsequent changes to :rcraw:`font.size` will have no effect on the spaces. This is consistent @@ -1954,8 +1954,8 @@ def __getitem__( The axes. If the index included slices then another `SubplotGrid` is returned. - Example - ------- + Examples + -------- >>> import ultraplot as uplt >>> fig, axs = uplt.subplots(nrows=3, ncols=3) >>> axs[5] # the subplot in the second row, third column diff --git a/ultraplot/legend.py b/ultraplot/legend.py index b5c51dcde..6e67efdae 100644 --- a/ultraplot/legend.py +++ b/ultraplot/legend.py @@ -1,6 +1,7 @@ from collections.abc import Mapping from dataclasses import dataclass from functools import lru_cache +import inspect from typing import Any, Iterable, Optional, Tuple, Union import matplotlib.patches as mpatches @@ -998,12 +999,17 @@ def _style_lookup(style, key, index, default=None, *, prop=None): Parameters ---------- - style : the style value (scalar, list, dict) - key : dict key when `style` is a mapping (typically a label) - index : list index when `style` is a sequence - default : fallback value - prop : optional attribute name; if it belongs to _COLOR_KEYS, - the function treats color-like sequences as single colors. + style : scalar, sequence, or mapping + The style value to resolve. + key : object + Dictionary key when `style` is a mapping (typically a label). + index : int + List index when `style` is a sequence. + default : object, optional + The fallback value. + prop : str, optional + Attribute name. If it belongs to ``_COLOR_KEYS``, treat color-like + sequences as single colors. """ if style is None: return default @@ -1804,49 +1810,66 @@ def _normalize_em_kwargs(kwargs: dict[str, Any], *, fontsize: float) -> dict[str ``color=(0.5, 0.5, 0.5)`` behave the same.""" _semantic_style_kwargs_docstring = """\ -Common style keywords accepted via ``handle_kw`` or ``**kwargs``: - -``color`` / ``c`` +color, c : color-spec or sequence or mapping, optional Marker (and line, when ``line=True``) color. ``c`` is the short alias. -``marker`` / ``m`` +marker, m : marker-spec or sequence or mapping, optional Marker spec. Set to ``None`` or ``""`` to suppress the marker. -``markersize`` / ``ms``, ``markeredgewidth`` / ``mew`` - Marker dimensions. ``markersize`` / ``ms`` denote marker diameter in points. -``s`` / ``size`` / ``sizes`` +markersize, ms : float or sequence or mapping, optional + Marker diameter in points. +markeredgewidth, mew : float or sequence or mapping, optional + Marker edge width in points. +s, size, sizes : float or sequence or mapping, optional Scatter-style marker areas, converted to marker diameters for the legend handle. Use ``markersize`` / ``ms`` when specifying diameters directly. -``markerfacecolor`` / ``mfc``, ``markeredgecolor`` / ``mec``, ``markerfacecoloralt`` / ``mfcalt`` +markerfacecolor, mfc, markeredgecolor, mec, markerfacecoloralt, mfcalt : color-spec or sequence or mapping, optional Marker fills and edges. -``linestyle`` / ``ls``, ``linewidth`` / ``lw`` +linestyle, ls : str or tuple or sequence or mapping, optional Connector line styling. Setting a non-default ``linestyle`` implicitly enables ``line=True``. -``alpha``, ``antialiased`` / ``aa``, ``fillstyle`` / ``fs`` - Generic appearance. -``marker_capstyle``, ``marker_joinstyle``, ``marker_transform`` - Advanced ``MarkerStyle`` properties; wrapped into the rendered marker. +linewidth, lw : float or sequence or mapping, optional + Connector line width in points. +alpha : float or sequence or mapping, optional + Opacity of the handles. +antialiased, aa : bool or sequence or mapping, optional + Whether the handles are antialiased. +fillstyle, fs : str or sequence or mapping, optional + Marker fill style. +marker_capstyle, marker_joinstyle : str or sequence or mapping, optional + Cap and join styles for the rendered ``MarkerStyle``. +marker_transform : `~matplotlib.transforms.Transform` or sequence or mapping, optional + Transform applied to the rendered ``MarkerStyle``.""" + +_semantic_style_notes_docstring = """\ +Common style keywords can be passed directly or through ``handle_kw``. Plural forms (``colors``, ``markers``, ``edgecolors``, ``facecolors``, ``linestyles``, ``linewidths``) are accepted as synonyms for the singular per-entry form for backward compatibility. ``sizes`` is accepted as a scatter-style area alias. -Each value accepts the scalar / sequence / mapping forms described in -``%(legend.semantic_style_arg)s``.""" +""" _semantic_num_style_kwargs_docstring = """\ -Patch-style keywords accepted via ``handle_kw`` or ``**kwargs``: - -``facecolor`` / ``fc``, ``edgecolor`` / ``ec``, ``color`` / ``c`` +facecolor, fc, edgecolor, ec, color, c : color-spec or sequence or mapping, optional Patch fills and edges. -``linewidth`` / ``lw``, ``linestyle`` / ``ls`` - Patch outline styling. -``alpha``, ``antialiased`` / ``aa``, ``hatch``, ``fill``, -``joinstyle``, ``capstyle`` - Generic patch appearance. +linewidth, lw : float or sequence or mapping, optional + Patch outline width in points. +linestyle, ls : str or tuple or sequence or mapping, optional + Patch outline style. +alpha : float or sequence or mapping, optional + Patch opacity. +antialiased, aa, fill : bool or sequence or mapping, optional + Whether the patches are antialiased or filled. +hatch : str or sequence or mapping, optional + Patch hatch pattern. +joinstyle, capstyle : str or sequence or mapping, optional + Join and cap styles for the patch outlines.""" + +_semantic_num_style_notes_docstring = """\ +Patch-style keywords can be passed directly or through ``handle_kw``. Plural collection forms (``colors``, ``facecolors``, ``edgecolors``, ``linestyles``, ``linewidths``) map to the singular per-entry form. -Each value accepts the scalar / sequence / mapping forms described in -``%(legend.semantic_style_arg)s``.""" +""" _semantic_handle_kw_docstring = """\ handle_kw : dict, optional @@ -1869,6 +1892,16 @@ def _normalize_em_kwargs(kwargs: dict[str, Any], *, fontsize: float) -> dict[str docstring._snippet_manager["legend.semantic_num_style_kwargs"] = ( _semantic_num_style_kwargs_docstring ) +docstring._snippet_manager["legend.semantic_style_notes"] = ( + _semantic_style_notes_docstring + + "\n" + + inspect.cleandoc(_semantic_style_arg_docstring) +) +docstring._snippet_manager["legend.semantic_num_style_notes"] = ( + _semantic_num_style_notes_docstring + + "\n" + + inspect.cleandoc(_semantic_style_arg_docstring) +) docstring._snippet_manager["legend.semantic_handle_kw"] = _semantic_handle_kw_docstring diff --git a/ultraplot/scale.py b/ultraplot/scale.py index 970473f5a..751cf157b 100644 --- a/ultraplot/scale.py +++ b/ultraplot/scale.py @@ -204,6 +204,7 @@ def __init__(self, **kwargs): Default *minor* tick locations are on these multiples of each power of the base. For example, ``subs=(1, 2, 5)`` draws ticks on 1, 2, 5, 10, 20, 50, 100, 200, 500, etc. + See also -------- ultraplot.constructor.Scale @@ -245,6 +246,7 @@ def __init__(self, **kwargs): Default *minor* tick locations are on these multiples of each power of the base. For example, ``subs=(1, 2, 5)`` draws ticks on 1, 2, 5, 10, 20, 50, 100, 200, 500, etc. + See also -------- ultraplot.constructor.Scale @@ -792,8 +794,8 @@ def __init__(self, *args): -------- ultraplot.constructor.Scale - Example - ------- + Examples + -------- >>> import ultraplot as uplt >>> import numpy as np >>> scale = uplt.CutoffScale(10, 0.5) # move slower above 10 diff --git a/ultraplot/tests/test_docstrings.py b/ultraplot/tests/test_docstrings.py index 00c92d6f8..7d1f80b3c 100644 --- a/ultraplot/tests/test_docstrings.py +++ b/ultraplot/tests/test_docstrings.py @@ -77,6 +77,20 @@ def test_build_docstring_expansion_preserves_numpy_indentation(tmp_path: Path) - }, ), ("gridspec.py", {"GridSpec.__init__": uplt.GridSpec.__init__}), + ( + "ticker.py", + { + f"{name}.__init__": getattr(uplt, name).__init__ + for name in ( + "DegreeLocator", + "LongitudeLocator", + "LatitudeLocator", + "DegreeFormatter", + "LongitudeFormatter", + "LatitudeFormatter", + ) + }, + ), ], ) def test_materialized_public_docstrings_match_runtime( @@ -156,3 +170,132 @@ def test_subplots_parameter_docstrings_are_numpy_style() -> None: "Whether subplots are numbered in row-major (``'C'``) " "or column-major (``'F'``)" ) in doc + + +def _load_installed_docstring_checker() -> dict: + path = ( + Path(__file__).resolve().parents[2] + / "tools" + / "ci" + / "check_installed_docstrings.py" + ) + return runpy.run_path(str(path)) + + +def test_package_docstring_structure() -> None: + """Expand every owned source docstring and check its actual parser output.""" + expand = _load_docstring_expander()["_expand"] + checker = _load_installed_docstring_checker() + package = Path(uplt.__file__).resolve().parent + for path in package.rglob("*.py"): + if {"tests", "externals"}.intersection(path.relative_to(package).parts): + continue + tree = ast.parse(path.read_bytes()) + for node, doc in checker["_iter_docstrings"](tree): + expanded, _ = expand(inspect.cleandoc(doc), docstring._snippet_manager) + try: + checker["_check_numpy_structure"](expanded) + except (AssertionError, ValueError) as exc: + raise AssertionError( + f"{path.name}:{getattr(node, 'lineno', 1)}: {exc}" + ) from exc + + +@pytest.mark.parametrize( + "doc", + [ + "Parameters\n----------\nfirst : int\n First.\n\nParameters\n----------\nsecond : int\n Second.", + "Parameters\n----------\nfirst : int\n First.\nReturns\n-------\nint\n Result.", + "Parameters\n-----------\nfirst : int\n First.", + "Parameters\n----------\nfirst : int\nsecond : int\n Second.", + "Summary.\n\n Other parameters\n ----------------\n first : int\n First.\n\nNotes\n-----\nA note.", + ], +) +def test_package_docstring_check_rejects_structural_errors(doc: str) -> None: + check = _load_installed_docstring_checker()["_check_numpy_structure"] + with pytest.raises((AssertionError, ValueError)): + check(doc) + + +@pytest.mark.parametrize( + ("obj", "section", "expected"), + [ + (uplt.LongitudeLocator.__init__, "Parameters", {"dms", "lon0"}), + (uplt.LongitudeFormatter.__init__, "Parameters", {"dms", "lon0"}), + ( + uplt.Figure.__init__, + "Other Parameters", + { + "leftlabelpad, toplabelpad, rightlabelpad, bottomlabelpad", + "leftlabelsharedpad, toplabelsharedpad, rightlabelsharedpad, bottomlabelsharedpad", + }, + ), + ( + uplt.Axes.colorbar, + "Parameters", + {"loc", "length", "width", "bbox_to_anchor"}, + ), + ( + uplt.PlotAxes.beeswarm, + "Parameters", + {"s, size, ms, markersize", "area_size", "absolute_size"}, + ), + (uplt.PlotAxes.beeswarm, "Other Parameters", {"norm", "**kwargs"}), + (uplt.Axes.catlegend, "Other Parameters", {"handle_kw", "add", "**kwargs"}), + ], +) +def test_shared_parameters_remain_separate_entries( + obj: Callable[..., object], section: str, expected: set[str] +) -> None: + parse = _load_installed_docstring_checker()["NumpyDocString"] + parsed = parse(inspect.getdoc(obj)) + entries = {entry.name: entry for entry in parsed[section]} + assert expected <= entries.keys() + assert all(entries[name].desc for name in expected) + + +@pytest.mark.parametrize( + "name", ["catlegend", "entrylegend", "sizelegend", "numlegend", "geolegend"] +) +def test_semantic_legend_styles_are_documented_as_parameters(name: str) -> None: + parse = _load_installed_docstring_checker()["NumpyDocString"] + parsed = parse(inspect.getdoc(getattr(uplt.Axes, name))) + entries = {entry.name: entry for entry in parsed["Other Parameters"]} + assert {"handle_kw", "add", "**kwargs"} <= entries.keys() + style_entries = { + key: entry + for key, entry in entries.items() + if key not in {"handle_kw", "add", "**kwargs"} + } + keywords = {keyword.strip() for key in style_entries for keyword in key.split(",")} + assert {"linewidth", "linestyle", "alpha", "antialiased"} <= keywords + if name in {"catlegend", "entrylegend", "sizelegend"}: + assert { + "markersize", + "markeredgewidth", + "s", + "fillstyle", + "marker_transform", + } <= keywords + else: + assert { + "facecolor", + "edgecolor", + "hatch", + "fill", + "joinstyle", + "capstyle", + } <= keywords + assert all(entry.type and entry.desc for entry in style_entries.values()) + notes = "\n".join(parsed["Notes"]) + assert "keywords can be passed directly or through" in notes + assert "A style value resolved per legend entry" in notes + assert "Plural" in notes + + +def test_package_docstring_check_preserves_sphinx_admonitions() -> None: + from sphinx.ext.napoleon.docstring import NumpyDocstring + + doc = "Important\n---------\nKeep this note visible." + _load_installed_docstring_checker()["_check_numpy_structure"](doc) + assert ".. important:: Keep this note visible." in str(NumpyDocstring(doc)) diff --git a/ultraplot/ticker.py b/ultraplot/ticker.py index a64830786..f4036cd34 100644 --- a/ultraplot/ticker.py +++ b/ultraplot/ticker.py @@ -98,8 +98,6 @@ docstring._snippet_manager["ticker.call"] = _formatter_call _dms_docstring = """ -Parameters ----------- dms : bool, default: False Locate the ticks on clean degree-minute-second intervals and format the ticks with minutes and seconds instead of decimals. @@ -283,6 +281,8 @@ class DegreeLocator(mticker.MaxNLocator): @docstring._snippet_manager def __init__(self, *args, **kwargs): """ + Parameters + ---------- %(ticker.dms)s """ super().__init__(*args, **kwargs) @@ -321,10 +321,9 @@ class LongitudeLocator(DegreeLocator): @docstring._snippet_manager def __init__(self, lon0=0, *args, **kwargs): """ - %(ticker.dms)s - Parameters ---------- + %(ticker.dms)s lon0 : float, default=0 The central longitude around which the longitude labels are centered. This parameter adjusts the alignment of the longitude gridlines and @@ -342,6 +341,8 @@ class LatitudeLocator(DegreeLocator): @docstring._snippet_manager def __init__(self, *args, **kwargs): """ + Parameters + ---------- %(ticker.dms)s """ super().__init__(*args, **kwargs) @@ -394,8 +395,8 @@ def __init__( ultraplot.constructor.Formatter ultraplot.ticker.SimpleFormatter - Note - ---- + Notes + ----- `matplotlib.ticker.ScalarFormatter` determines the number of significant digits based on the axis limits, and therefore may truncate digits while formatting ticks on highly non-linear axis @@ -799,8 +800,8 @@ def __init__(self, symbol="", number=1): number : float, default: 1 The constant value, e.g. `numpy.pi`. - Note - ---- + Notes + ----- The fractions shown by this formatter are resolved using the builtin `fractions.Fraction` class and `fractions.Fraction.limit_denominator`. @@ -1352,6 +1353,8 @@ class DegreeFormatter(_CartopyFormatter, _PlateCarreeFormatter): @docstring._snippet_manager def __init__(self, *args, **kwargs): """ + Parameters + ---------- %(ticker.dms)s """ super().__init__(*args, **kwargs) @@ -1393,6 +1396,8 @@ class LatitudeFormatter(_CartopyFormatter, LatitudeFormatter): @docstring._snippet_manager def __init__(self, *args, **kwargs): """ + Parameters + ---------- %(ticker.dms)s """ super().__init__(*args, **kwargs)