Skip to content

Commit b88f5ee

Browse files
authored
Constructor checking for AST validator (#622)
* fix(validate.py): Considers subclass nesting when checking GL08 constructor Introduced GL08 checking for constructors (i.e. __init__) but didn't traverse the parent class heireacy when importing the __init__ parent from a module. * test(validate.py): Added a test to check nested class docstring when checking for initialisation docstring Test written due to bug missed in #592. * fix(validate.py): Allows the validator to check AST constructor docstrings compliance with parent class The abstract syntax tree doesn't provide any link to a parent class at node level, so special traversal is required to check constructor parent implementation of docstrings. * test(test_validate_hook.py,-example_module.py): Wrote new example_module tests for AST (AbstractSyntaxTree) discovery of constructor method parent class. * ci(test.yml): Added --pre option to prerelease job to ensure pre-release installation, and changed pytest invocation to use `numpydoc` instead of `.`, for consistency with `test` job In response to discussion on #591 * refactor(tests): Remove `__init__.py` module status of `tests\hooks\` to match `tests\` directory * ci(test.yml): Added explicit call to hook tests to see if included in pytest execution * test(tests\hooks\test_validate_hook.py): Changed constructor validation to use AST ancestry, fixed hook tests and added Windows support for test_utils * ci(test.yml): Added file existance check for hook tests Workflows do not seem to be running hook pytests. * ci(test.yml): Correct the workflow task name/version * ci(test.yml): Added explicit pytest call to the hooks directory * ci(test.yml): Removed file existance test, after explicit call to hooks verifies execution * fix(validate.py): switched conditional GL08 check order to avoid property class failure for Validator.name Solves Issue #638, though lacks property support for property class attributes. * test(test_validate.py): add coverage for existing / expected functionality of property and dataclass objects * test(test_validate_hook.py): modify test strings and remove unused os.sep replacement Updates to make this PR compatible with the recent merge from #653
1 parent eb831f4 commit b88f5ee

File tree

6 files changed

+247
-13
lines changed

6 files changed

+247
-13
lines changed

.github/workflows/test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ jobs:
4848
4949
- name: Run test suite
5050
run: |
51-
pytest -v --pyargs numpydoc
51+
pytest -v --pyargs .
5252
5353
- name: Test coverage
5454
run: |

numpydoc/tests/hooks/__init__.py

Lines changed: 0 additions & 1 deletion
This file was deleted.

numpydoc/tests/hooks/example_module.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,35 @@ def create(self):
2828

2929

3030
class NewClass:
31-
pass
31+
class GoodConstructor:
32+
"""
33+
A nested class to test constructors via AST hook.
34+
35+
Implements constructor via class docstring.
36+
37+
Parameters
38+
----------
39+
name : str
40+
The name of the new class.
41+
"""
42+
43+
def __init__(self, name):
44+
self.name = name
45+
46+
class BadConstructor:
47+
"""
48+
A nested class to test constructors via AST hook.
49+
50+
Implements a bad constructor docstring despite having a good class docstring.
51+
52+
Parameters
53+
----------
54+
name : str
55+
The name of the new class.
56+
"""
57+
58+
def __init__(self, name):
59+
"""
60+
A failing constructor implementation without parameters.
61+
"""
62+
self.name = name

numpydoc/tests/hooks/test_validate_hook.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,22 @@ def test_validate_hook(example_module, config, capsys):
6262
{example_module!s}:26: EX01 No examples section found
6363
6464
{example_module!s}:30: GL08 The object does not have a docstring
65+
66+
{example_module!s}:31: SA01 See Also section not found
67+
68+
{example_module!s}:31: EX01 No examples section found
69+
70+
{example_module!s}:46: SA01 See Also section not found
71+
72+
{example_module!s}:46: EX01 No examples section found
73+
74+
{example_module!s}:58: ES01 No extended summary found
75+
76+
{example_module!s}:58: PR01 Parameters {{'name'}} not documented
77+
78+
{example_module!s}:58: SA01 See Also section not found
79+
80+
{example_module!s}:58: EX01 No examples section found
6581
"""
6682
)
6783

@@ -89,6 +105,8 @@ def test_validate_hook_with_ignore(example_module, capsys):
89105
{example_module!s}:26: SS05 Summary must start with infinitive verb, not third person (e.g. use "Generate" instead of "Generates")
90106
91107
{example_module!s}:30: GL08 The object does not have a docstring
108+
109+
{example_module!s}:58: PR01 Parameters {{'name'}} not documented
92110
"""
93111
)
94112

numpydoc/tests/test_validate.py

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import warnings
22
from contextlib import nullcontext
3+
from dataclasses import dataclass
34
from functools import cached_property, partial, wraps
45
from inspect import getsourcefile, getsourcelines
56

@@ -1305,6 +1306,109 @@ def __init__(self, param1: int):
13051306
pass
13061307

13071308

1309+
class ConstructorDocumentedinEmbeddedClass: # ignore Gl08, ES01
1310+
"""
1311+
Class to test the initialisation behaviour of a embedded class.
1312+
"""
1313+
1314+
class EmbeddedClass1: # ignore GL08, ES01
1315+
"""
1316+
An additional level for embedded class documentation checking.
1317+
"""
1318+
1319+
class EmbeddedClass2:
1320+
"""
1321+
This is an embedded class.
1322+
1323+
Extended summary.
1324+
1325+
Parameters
1326+
----------
1327+
param1 : int
1328+
Description of param1.
1329+
1330+
See Also
1331+
--------
1332+
otherclass : A class that does something else.
1333+
1334+
Examples
1335+
--------
1336+
This is an example of how to use EmbeddedClass.
1337+
"""
1338+
1339+
def __init__(self, param1: int) -> None:
1340+
pass
1341+
1342+
1343+
class IncompleteConstructorDocumentedinEmbeddedClass:
1344+
"""
1345+
Class to test the initialisation behaviour of a embedded class.
1346+
"""
1347+
1348+
class EmbeddedClass1:
1349+
"""
1350+
An additional level for embedded class documentation checking.
1351+
"""
1352+
1353+
class EmbeddedClass2:
1354+
"""
1355+
This is an embedded class.
1356+
1357+
Extended summary.
1358+
1359+
See Also
1360+
--------
1361+
otherclass : A class that does something else.
1362+
1363+
Examples
1364+
--------
1365+
This is an example of how to use EmbeddedClass.
1366+
"""
1367+
1368+
def __init__(self, param1: int) -> None:
1369+
pass
1370+
1371+
1372+
@dataclass
1373+
class DataclassWithDocstring:
1374+
"""
1375+
A class decorated by `dataclass`.
1376+
1377+
To check the functionality of `dataclass` objects do not break the Validator.
1378+
As param1 is not documented this class should also raise PR01.
1379+
"""
1380+
1381+
param1: int
1382+
1383+
1384+
class ClassWithPropertyObject:
1385+
"""
1386+
A class with a `property`.
1387+
1388+
To check the functionality of `property` objects do not break the Validator.
1389+
1390+
Parameters
1391+
----------
1392+
param1 : int
1393+
Description of param1.
1394+
"""
1395+
1396+
def __init__(self, param1: int) -> None:
1397+
self._param1 = param1
1398+
1399+
@property
1400+
def param1(self) -> int:
1401+
"""
1402+
Get the value of param1.
1403+
1404+
Returns
1405+
-------
1406+
int
1407+
The value of param1.
1408+
"""
1409+
return self._param1
1410+
1411+
13081412
class TestValidator:
13091413
def _import_path(self, klass=None, func=None):
13101414
"""
@@ -1657,6 +1761,18 @@ def test_bad_docstrings(self, capsys, klass, func, msgs):
16571761
tuple(),
16581762
("PR01"), # Parameter not documented in class constructor
16591763
),
1764+
(
1765+
"ConstructorDocumentedinEmbeddedClass.EmbeddedClass1.EmbeddedClass2",
1766+
tuple(),
1767+
("GL08",),
1768+
tuple(),
1769+
),
1770+
(
1771+
"IncompleteConstructorDocumentedinEmbeddedClass.EmbeddedClass1.EmbeddedClass2",
1772+
("GL08",),
1773+
tuple(),
1774+
("PR01",),
1775+
),
16601776
],
16611777
)
16621778
def test_constructor_docstrings(
@@ -1674,6 +1790,39 @@ def test_constructor_docstrings(
16741790
for code in exc_init_codes:
16751791
assert code not in " ".join(err[0] for err in result["errors"])
16761792

1793+
if klass == "ConstructorDocumentedinEmbeddedClass":
1794+
raise NotImplementedError(
1795+
"Test for embedded class constructor docstring not implemented yet."
1796+
)
1797+
1798+
def test_dataclass_object(self):
1799+
# Test validator methods complete execution on dataclass objects and methods
1800+
# Test case ought to be removed if dataclass objects properly supported.
1801+
result = validate_one(self._import_path(klass="DataclassWithDocstring"))
1802+
# Check codes match as expected for dataclass objects.
1803+
errs = ["ES01", "SA01", "EX01", "PR01"]
1804+
for error in result["errors"]:
1805+
assert error[0] in errs
1806+
errs.remove(error[0])
1807+
1808+
# Test initialisation method (usually undocumented in dataclass) raises any errors.
1809+
init_fn = self._import_path(klass="DataclassWithDocstring", func="__init__")
1810+
result = validate_one(init_fn)
1811+
# Check that __init__ raises GL08 when the class docstring doesn't document params.
1812+
assert result["errors"][0][0] == "GL08"
1813+
1814+
def test_property_object(self):
1815+
# Test validator methods complete execution on class property objects
1816+
# Test case ought to be removed if property objects properly supported.
1817+
result = validate_one(
1818+
self._import_path(klass="ClassWithPropertyObject", func="param1")
1819+
)
1820+
# Check codes match as expected for property objects.
1821+
errs = ["ES01", "SA01", "EX01"]
1822+
for error in result["errors"]:
1823+
assert error[0] in errs
1824+
errs.remove(error[0])
1825+
16771826

16781827
def decorator(x):
16791828
"""Test decorator."""

numpydoc/validate.py

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -572,6 +572,14 @@ def _check_desc(desc, code_no_desc, code_no_upper, code_no_period, **kwargs):
572572
return errs
573573

574574

575+
def _find_class_node(module_node: ast.AST, cls_name) -> ast.ClassDef:
576+
# Find the class node within a module, when checking constructor docstrings.
577+
for node in ast.walk(module_node):
578+
if isinstance(node, ast.ClassDef) and node.name == cls_name:
579+
return node
580+
raise ValueError(f"Could not find class node {cls_name}")
581+
582+
575583
def validate(obj_name, validator_cls=None, **validator_kwargs):
576584
"""
577585
Validate the docstring.
@@ -639,20 +647,49 @@ def validate(obj_name, validator_cls=None, **validator_kwargs):
639647
report_GL08: bool = True
640648
# Check if the object is a class and has a docstring in the constructor
641649
# Also check if code_obj is defined, as undefined for the AstValidator in validate_docstrings.py.
642-
if (
643-
doc.name.endswith(".__init__")
644-
and doc.is_function_or_method
645-
and hasattr(doc, "code_obj")
646-
):
647-
cls_name = doc.code_obj.__qualname__.split(".")[0]
648-
cls = Validator._load_obj(f"{doc.code_obj.__module__}.{cls_name}")
649-
# cls = Validator._load_obj(f"{doc.name[:-9]}.{cls_name}") ## Alternative
650-
cls_doc = Validator(get_doc_object(cls))
650+
if doc.is_function_or_method and doc.name.endswith(".__init__"):
651+
# Import here at runtime to avoid circular import as
652+
# AstValidator is a subclass of Validator class without `doc_obj` attribute.
653+
from numpydoc.hooks.validate_docstrings import (
654+
AstValidator, # Support abstract syntax tree hook.
655+
)
656+
657+
if hasattr(doc, "code_obj"): # All Validator objects have this attr.
658+
cls_name = ".".join(
659+
doc.code_obj.__qualname__.split(".")[:-1]
660+
) # Collect all class depths before the constructor.
661+
cls = Validator._load_obj(f"{doc.code_obj.__module__}.{cls_name}")
662+
# cls = Validator._load_obj(f"{doc.name[:-9]}.{cls_name}") ## Alternative
663+
cls_doc = Validator(get_doc_object(cls))
664+
elif isinstance(doc, AstValidator): # Supports class traversal for ASTs.
665+
ancestry = doc.ancestry
666+
if len(ancestry) > 2: # e.g. module.class.__init__
667+
parent = doc.ancestry[-1] # Get the parent
668+
cls_name = ".".join(
669+
[
670+
getattr(node, "name", node.__module__)
671+
for node in doc.ancestry
672+
]
673+
)
674+
cls_doc = AstValidator(
675+
ast_node=parent,
676+
filename=doc.source_file_name,
677+
obj_name=cls_name,
678+
ancestry=doc.ancestry[:-1],
679+
)
680+
else:
681+
# Ignore edge case: __init__ functions that don't belong to a class.
682+
cls_doc = None
683+
else:
684+
raise TypeError(
685+
f"Cannot load {doc.name} as a usable Validator object (Validator does not have `doc_obj` attr or type `AstValidator`)."
686+
)
651687

652688
# Parameter_mismatches, PR01, PR02, PR03 are checked for the class docstring.
653689
# If cls_doc has PR01, PR02, PR03 errors, i.e. invalid class docstring,
654690
# then we also report missing constructor docstring, GL08.
655-
report_GL08 = len(cls_doc.parameter_mismatches) > 0
691+
if cls_doc:
692+
report_GL08 = len(cls_doc.parameter_mismatches) > 0
656693

657694
# Check if GL08 is to be ignored:
658695
if "GL08" in ignore_validation_comments:

0 commit comments

Comments
 (0)