Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions environment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ dependencies:
- pytest-mpl
- pytest-cov
- pytest-xdist
- numpydoc
- pip
- pint
- pre-commit
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ stats = [

typing = [
"basedpyright==1.40.1",
"numpydoc",
]

[build-system]
Expand Down
141 changes: 138 additions & 3 deletions tools/ci/check_installed_docstrings.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,49 @@
from __future__ import annotations

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": {
"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)
Expand All @@ -20,17 +55,118 @@ 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 _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}"
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):
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":
Expand All @@ -39,7 +175,6 @@ def main() -> None:
f"{path.relative_to(package)}:{getattr(node, 'lineno', 1)}: {key}"
)


assert not unresolved, "Unexpanded installed docstrings:\n" + "\n".join(unresolved)


Expand Down
41 changes: 34 additions & 7 deletions tools/expand_docstrings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)])
Expand All @@ -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 = [
Expand All @@ -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 = []
Expand All @@ -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:]
Expand All @@ -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:
Expand Down
4 changes: 0 additions & 4 deletions ultraplot/animation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure why this is removed?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Numpydoc doesn't allow this format in this area. “Other parameters” must follow the same style as “Parameters”.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should still have the linkback in the existing docs tho

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't it enough to show them only in the existing "See also" section?


See also
--------
matplotlib.animation.Animation.save
Expand Down
Loading
Loading