Skip to content

Commit baeb217

Browse files
MAINT: Add script for numpydoc linting of Public API (#11121)
1 parent f87f0b2 commit baeb217

3 files changed

Lines changed: 184 additions & 0 deletions

File tree

ci/numpydoc-public-api.py

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
#!/usr/bin/env python
2+
"""A script that can be quickly run that explores the public API of Xarray
3+
and validates docstrings along the way according to the numpydoc conventions."""
4+
5+
import functools
6+
import importlib
7+
import logging
8+
import sys
9+
import types
10+
from pathlib import Path
11+
12+
from numpydoc.validate import validate
13+
14+
logger = logging.getLogger("numpydoc-public-api")
15+
handler = logging.StreamHandler()
16+
handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
17+
logger.addHandler(handler)
18+
19+
PROJECT_ROOT = (Path(__file__).parent / "..").resolve()
20+
PUBLIC_MODULES = ["xarray"]
21+
ROOT_PACKAGE = "xarray"
22+
23+
# full list of numpydoc error codes: https://numpydoc.readthedocs.io/en/latest/validation.html
24+
SKIP_ERRORS = [ # TODO: Curate these for Xarray
25+
"GL01", # parcels is fine with the summary line starting directly after `"""`, or on the next line.
26+
"SA01", # Parcels doesn't require the "See also" section
27+
"SA04",
28+
"ES01", # We don't require the extended summary for all docstrings
29+
"EX01", # We don't require the "Examples" section for all docstrings
30+
"SS06", # Not possible to make all summaries one line
31+
#
32+
# To be fixed up
33+
"GL02", # Closing quotes should be placed in the line after the last text in the docstring (do not close the quotes in the same line as the text, or leave a blank line between the last text and the quotes)
34+
"GL03", # Double line break found; please use only one blank line to separate sections or paragraphs, and do not leave blank lines at the end of docstrings
35+
"GL07", # Sections are in the wrong order. Correct order is: {correct_sections}
36+
"GL08", # The object does not have a docstring
37+
"SS01", # No summary found (a short summary in a single line should be present at the beginning of the docstring)
38+
"SS02", # Summary does not start with a capital letter
39+
"SS03", # Summary does not end with a period
40+
"SS04", # Summary contains heading whitespaces
41+
"SS05", # Summary must start with infinitive verb, not third person (e.g. use "Generate" instead of "Generates")
42+
"PR01", # Parameters {missing_params} not documented
43+
"PR02", # Unknown parameters {unknown_params}
44+
"PR03", # Wrong parameters order. Actual: {actual_params}. Documented: {documented_params}
45+
"SA02", # Missing period at end of description for See Also "{reference_name}" reference
46+
"SA03", # Description should be capitalized for See Also
47+
#
48+
# TODO consider whether to continue ignoring the following
49+
"GL09", # Deprecation warning should precede extended summary
50+
"GL10", # reST directives {directives} must be followed by two colons
51+
"PR04", # Parameter "{param_name}" has no type
52+
"PR05", # Parameter "{param_name}" type should not finish with "."
53+
"PR06", # Parameter "{param_name}" type should use "{right_type}" instead of "{wrong_type}"
54+
"PR07", # Parameter "{param_name}" has no description
55+
"PR08", # Parameter "{param_name}" description should start with a capital letter
56+
"PR09", # Parameter "{param_name}" description should finish with "."
57+
"PR10", # Parameter "{param_name}" requires a space before the colon separating the parameter name and type
58+
"RT01", # No Returns section found
59+
"RT02", # The first line of the Returns section should contain only the type, unless multiple values are being returned
60+
"RT03", # Return value has no description
61+
"RT04", # Return value description should start with a capital letter
62+
"RT05", # Return value description should finish with "."
63+
"YD01", # No Yields section found
64+
]
65+
66+
67+
def is_built_in(type_or_instance: type | object):
68+
if isinstance(type_or_instance, type):
69+
return type_or_instance.__module__ == "builtins"
70+
else:
71+
return type_or_instance.__class__.__module__ == "builtins"
72+
73+
74+
def walk_module(module_str: str, public_api: list[str] | None = None) -> list[str]:
75+
if public_api is None:
76+
public_api = []
77+
78+
module = importlib.import_module(module_str)
79+
try:
80+
all_ = module.__all__
81+
except AttributeError:
82+
print(f"No __all__ variable found in public module {module_str!r}")
83+
return public_api
84+
85+
if module_str not in public_api:
86+
public_api.append(module_str)
87+
for item_str in all_:
88+
item = getattr(module, item_str)
89+
if isinstance(item, types.ModuleType):
90+
walk_module(f"{module_str}.{item_str}", public_api)
91+
if isinstance(item, (types.FunctionType,)):
92+
public_api.append(f"{module_str}.{item_str}")
93+
elif is_built_in(item):
94+
print(f"Found builtin at '{module_str}.{item_str}' of type {type(item)}")
95+
continue
96+
elif isinstance(item, type):
97+
public_api.append(f"{module_str}.{item_str}")
98+
walk_class(module_str, item, public_api)
99+
else:
100+
logger.info(
101+
f"Encountered unexpected public object at '{module_str}.{item_str}' of {item!r} in public API. Don't know how to handle with numpydoc - ignoring."
102+
)
103+
104+
return public_api
105+
106+
107+
def get_public_class_attrs(class_: type) -> set[str]:
108+
return {a for a in dir(class_) if not a.startswith("_")}
109+
110+
111+
def walk_class(module_str: str, class_: type, public_api: list[str]) -> list[str]:
112+
class_str = class_.__name__
113+
114+
# attributes that were introduced by this class specifically - not from inheritance
115+
attrs = get_public_class_attrs(class_) - functools.reduce(
116+
set.union, (get_public_class_attrs(base) for base in class_.__bases__)
117+
)
118+
119+
public_api.extend([f"{module_str}.{class_str}.{attr_str}" for attr_str in attrs])
120+
return public_api
121+
122+
123+
def main():
124+
import argparse
125+
126+
parser = argparse.ArgumentParser(
127+
description="Validate numpydoc docstrings in the public API"
128+
)
129+
parser.add_argument(
130+
"-v",
131+
"--verbose",
132+
action="count",
133+
default=0,
134+
help="Increase verbosity (can be repeated)",
135+
)
136+
args = parser.parse_args()
137+
138+
# Set logging level based on verbosity: 0=WARNING, 1=INFO, 2+=DEBUG
139+
if args.verbose == 0:
140+
log_level = logging.WARNING
141+
elif args.verbose == 1:
142+
log_level = logging.INFO
143+
else:
144+
log_level = logging.DEBUG
145+
146+
logger.setLevel(log_level)
147+
148+
public_api = []
149+
for module in PUBLIC_MODULES:
150+
public_api += walk_module(module)
151+
152+
errors = 0
153+
for item in public_api:
154+
logger.info(f"Processing validating {item}")
155+
try:
156+
res = validate(item)
157+
except (AttributeError, StopIteration, ValueError) as e:
158+
if (
159+
isinstance(e, ValueError) and "Error parsing See Also entry" in str(e)
160+
): # TODO: Fix later https://github.com/pydata/xarray/issues/8596#issuecomment-3832443795
161+
logger.info(f"Skipping See Also parsing error for {item!r}.")
162+
continue
163+
logger.warning(f"Could not process {item!r}. Encountered error. {e!r}")
164+
continue
165+
if res["type"] in ("module", "float", "int", "dict"):
166+
continue
167+
for err in res["errors"]:
168+
if err[0] not in SKIP_ERRORS:
169+
print(f"{item}: {err}")
170+
errors += 1
171+
sys.exit(errors)
172+
173+
174+
if __name__ == "__main__":
175+
main()

doc/whats-new.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,8 @@ Performance
134134

135135
Internal Changes
136136
~~~~~~~~~~~~~~~~
137+
- Add script for linting of public docstrings according to numpydoc (:pull:`11121`).
138+
By `Nick Hodgskin <https://github.com/VeckoTheGecko>`_.
137139

138140
- Add stubtest configuration and allowlist for validating type annotations against
139141
runtime behavior. This enables CI integration for type stub validation and helps

pixi.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,12 @@ rst-to-myst = { version = "*", extras = ["sphinx"] }
346346
[feature.tools.tasks]
347347
rst2myst = { cmd = "rst2myst", description = "Convert rst to myst markdown" }
348348

349+
[feature.numpydoc-lint.dependencies]
350+
numpydoc = "*"
351+
352+
[feature.numpydoc-lint.tasks]
353+
numpydoc-lint = { cmd = "python ci/numpydoc-public-api.py" }
354+
349355
[environments]
350356
# Testing
351357
# test-just-xarray = { features = ["test"] } # https://github.com/pydata/xarray/pull/10888/files#r2511336147
@@ -458,4 +464,5 @@ default = { features = [
458464
"dev",
459465
] }
460466
policy = { features = ["policy"], no-default-feature = true }
467+
numpydoc-lint = { features = ["numpydoc-lint"] }
461468
tools = { features = ["tools"], no-default-feature = true }

0 commit comments

Comments
 (0)