Skip to content

Constructor checking for AST validator #622

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 17 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
d8f05e6
fix(validate.py): Considers subclass nesting when checking GL08 const…
mattgebert Apr 5, 2025
c7da072
test(validate.py): Added a test to check nested class docstring when …
mattgebert Apr 6, 2025
2703b22
Merge branch 'numpy:main' into ConstructorChecking_ASTValidator
mattgebert May 11, 2025
92d8305
fix(validate.py): Allows the validator to check AST constructor docst…
mattgebert May 11, 2025
4b09325
Merge branch 'ConstructorChecking_ASTValidator' of https://github.com…
mattgebert May 11, 2025
9f38b98
test(test_validate_hook.py,-example_module.py): Wrote new example_mod…
mattgebert May 11, 2025
af861c3
ci(test.yml): Added --pre option to prerelease job to ensure pre-rele…
mattgebert May 11, 2025
c9d2384
refactor(tests): Remove `__init__.py` module status of `tests\hooks\`…
mattgebert May 11, 2025
b62c21f
ci(test.yml): Added explicit call to hook tests to see if included in…
mattgebert May 11, 2025
af84d77
merge: Merge branch 'main' into ConstructorChecking_ASTValidator, ens…
mattgebert Jun 23, 2025
becbaeb
test(tests\hooks\test_validate_hook.py): Changed constructor validati…
mattgebert Jun 23, 2025
39544d2
ci(test.yml): Added file existance check for hook tests
mattgebert Jun 23, 2025
c14b2e8
ci(test.yml): Correct the workflow task name/version
mattgebert Jun 23, 2025
48f8974
ci(test.yml): Added explicit pytest call to the hooks directory
mattgebert Jun 23, 2025
c2d16fa
ci(test.yml): Removed file existance test, after explicit call to hoo…
mattgebert Jun 23, 2025
405ef2d
Merge branch 'numpy:main' into ConstructorChecking_ASTValidator
mattgebert Jun 24, 2025
9114b37
Merge branch 'numpy:main' into ConstructorChecking_ASTValidator
mattgebert Jun 26, 2025
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
6 changes: 4 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ jobs:
- name: Run test suite
run: |
pytest -v --pyargs numpydoc
pytest -v --pyargs numpydoc/tests/hooks

- name: Test coverage
run: |
Expand Down Expand Up @@ -95,12 +96,13 @@ jobs:

- name: Install
run: |
python -m pip install . --group test --group doc
python -m pip install . --pre --group test --group doc
pip list

- name: Run test suite
run: |
pytest -v --pyargs .
pytest -v --pyargs numpydoc
pytest -v --pyargs numpydoc/tests/hooks

- name: Test coverage
run: |
Expand Down
1 change: 0 additions & 1 deletion numpydoc/tests/hooks/__init__.py

This file was deleted.

33 changes: 32 additions & 1 deletion numpydoc/tests/hooks/example_module.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,35 @@ def create(self):


class NewClass:
pass
class GoodConstructor:
"""
A nested class to test constructors via AST hook.

Implements constructor via class docstring.

Parameters
----------
name : str
The name of the new class.
"""

def __init__(self, name):
self.name = name

class BadConstructor:
"""
A nested class to test constructors via AST hook.

Implements a bad constructor docstring despite having a good class docstring.

Parameters
----------
name : str
The name of the new class.
"""

def __init__(self, name):
"""
A failing constructor implementation without parameters.
"""
self.name = name
4 changes: 3 additions & 1 deletion numpydoc/tests/hooks/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ def test_find_project_root(tmp_path, request, reason_file, files, expected_reaso
(tmp_path / reason_file).touch()

if files:
expected_dir = Path("/") if expected_reason == "file system root" else tmp_path
expected_dir = (
Path(tmp_path.anchor) if expected_reason == "file system root" else tmp_path
)
for file in files:
(tmp_path / file).touch()
else:
Expand Down
34 changes: 28 additions & 6 deletions numpydoc/tests/hooks/test_validate_hook.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Test the numpydoc validate pre-commit hook."""

import inspect
import os
from pathlib import Path

import pytest
Expand Down Expand Up @@ -61,8 +62,24 @@ def test_validate_hook(example_module, config, capsys):
numpydoc/tests/hooks/example_module.py:26: EX01 No examples section found

numpydoc/tests/hooks/example_module.py:30: GL08 The object does not have a docstring

numpydoc/tests/hooks/example_module.py:31: SA01 See Also section not found

numpydoc/tests/hooks/example_module.py:31: EX01 No examples section found

numpydoc/tests/hooks/example_module.py:46: SA01 See Also section not found

numpydoc/tests/hooks/example_module.py:46: EX01 No examples section found

numpydoc/tests/hooks/example_module.py:58: ES01 No extended summary found

numpydoc/tests/hooks/example_module.py:58: PR01 Parameters {'name'} not documented

numpydoc/tests/hooks/example_module.py:58: SA01 See Also section not found

numpydoc/tests/hooks/example_module.py:58: EX01 No examples section found
"""
)
).replace("/", os.sep)

return_code = run_hook([example_module], config=config)
assert return_code == 1
Expand All @@ -88,8 +105,10 @@ def test_validate_hook_with_ignore(example_module, capsys):
numpydoc/tests/hooks/example_module.py:26: SS05 Summary must start with infinitive verb, not third person (e.g. use "Generate" instead of "Generates")

numpydoc/tests/hooks/example_module.py:30: GL08 The object does not have a docstring

numpydoc/tests/hooks/example_module.py:58: PR01 Parameters {'name'} not documented
"""
)
).replace("/", os.sep)

return_code = run_hook([example_module], ignore=["ES01", "SA01", "EX01"])

Expand Down Expand Up @@ -132,7 +151,7 @@ def test_validate_hook_with_toml_config(example_module, tmp_path, capsys):

numpydoc/tests/hooks/example_module.py:30: GL08 The object does not have a docstring
"""
)
).replace("/", os.sep)

return_code = run_hook([example_module], config=tmp_path)
assert return_code == 1
Expand Down Expand Up @@ -167,7 +186,7 @@ def test_validate_hook_with_setup_cfg(example_module, tmp_path, capsys):

numpydoc/tests/hooks/example_module.py:30: GL08 The object does not have a docstring
"""
)
).replace("/", os.sep)

return_code = run_hook([example_module], config=tmp_path)
assert return_code == 1
Expand Down Expand Up @@ -208,7 +227,7 @@ def test_validate_hook_exclude_option_pyproject(example_module, tmp_path, capsys

numpydoc/tests/hooks/example_module.py:30: GL08 The object does not have a docstring
"""
)
).replace("/", os.sep)

return_code = run_hook([example_module], config=tmp_path)
assert return_code == 1
Expand Down Expand Up @@ -241,8 +260,11 @@ def test_validate_hook_exclude_option_setup_cfg(example_module, tmp_path, capsys

numpydoc/tests/hooks/example_module.py:17: PR07 Parameter "*args" has no description
"""
)
).replace("/", os.sep)

return_code = run_hook([example_module], config=tmp_path)
assert return_code == 1
assert capsys.readouterr().err.strip() == expected


# def test_validate_hook_
80 changes: 80 additions & 0 deletions numpydoc/tests/test_validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -1305,6 +1305,69 @@ def __init__(self, param1: int):
pass


class ConstructorDocumentedinEmbeddedClass: # ignore Gl08, ES01
"""
Class to test the initialisation behaviour of a embedded class.
"""

class EmbeddedClass1: # ignore GL08, ES01
"""
An additional level for embedded class documentation checking.
"""

class EmbeddedClass2:
"""
This is an embedded class.

Extended summary.

Parameters
----------
param1 : int
Description of param1.

See Also
--------
otherclass : A class that does something else.

Examples
--------
This is an example of how to use EmbeddedClass.
"""

def __init__(self, param1: int) -> None:
pass


class IncompleteConstructorDocumentedinEmbeddedClass:
"""
Class to test the initialisation behaviour of a embedded class.
"""

class EmbeddedClass1:
"""
An additional level for embedded class documentation checking.
"""

class EmbeddedClass2:
"""
This is an embedded class.

Extended summary.

See Also
--------
otherclass : A class that does something else.

Examples
--------
This is an example of how to use EmbeddedClass.
"""

def __init__(self, param1: int) -> None:
pass


class TestValidator:
def _import_path(self, klass=None, func=None):
"""
Expand Down Expand Up @@ -1660,6 +1723,18 @@ def test_bad_docstrings(self, capsys, klass, func, msgs):
tuple(),
("PR01"), # Parameter not documented in class constructor
),
(
"ConstructorDocumentedinEmbeddedClass.EmbeddedClass1.EmbeddedClass2",
tuple(),
("GL08",),
tuple(),
),
(
"IncompleteConstructorDocumentedinEmbeddedClass.EmbeddedClass1.EmbeddedClass2",
("GL08",),
tuple(),
("PR01",),
),
],
)
def test_constructor_docstrings(
Expand All @@ -1677,6 +1752,11 @@ def test_constructor_docstrings(
for code in exc_init_codes:
assert code not in " ".join(err[0] for err in result["errors"])

if klass == "ConstructorDocumentedinEmbeddedClass":
raise NotImplementedError(
"Test for embedded class constructor docstring not implemented yet."
)


def decorator(x):
"""Test decorator."""
Expand Down
55 changes: 45 additions & 10 deletions numpydoc/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,14 @@ def _check_desc(desc, code_no_desc, code_no_upper, code_no_period, **kwargs):
return errs


def _find_class_node(module_node: ast.AST, cls_name) -> ast.ClassDef:
# Find the class node within a module, when checking constructor docstrings.
for node in ast.walk(module_node):
if isinstance(node, ast.ClassDef) and node.name == cls_name:
return node
raise ValueError(f"Could not find class node {cls_name}")


def validate(obj_name, validator_cls=None, **validator_kwargs):
"""
Validate the docstring.
Expand Down Expand Up @@ -639,20 +647,47 @@ def validate(obj_name, validator_cls=None, **validator_kwargs):
report_GL08: bool = True
# Check if the object is a class and has a docstring in the constructor
# Also check if code_obj is defined, as undefined for the AstValidator in validate_docstrings.py.
if (
doc.name.endswith(".__init__")
and doc.is_function_or_method
and hasattr(doc, "code_obj")
):
cls_name = doc.code_obj.__qualname__.split(".")[0]
cls = Validator._load_obj(f"{doc.code_obj.__module__}.{cls_name}")
# cls = Validator._load_obj(f"{doc.name[:-9]}.{cls_name}") ## Alternative
cls_doc = Validator(get_doc_object(cls))
if doc.name.endswith(".__init__") and doc.is_function_or_method:
from numpydoc.hooks.validate_docstrings import (
AstValidator, # Support abstract syntax tree hook.
)

if hasattr(doc, "code_obj"):
cls_name = ".".join(
doc.code_obj.__qualname__.split(".")[:-1]
) # Collect all class depths before the constructor.
cls = Validator._load_obj(f"{doc.code_obj.__module__}.{cls_name}")
# cls = Validator._load_obj(f"{doc.name[:-9]}.{cls_name}") ## Alternative
cls_doc = Validator(get_doc_object(cls))
elif isinstance(doc, AstValidator): # Supports class traversal for ASTs.
ancestry = doc.ancestry
if len(ancestry) > 2: # e.g. module.class.__init__
parent = doc.ancestry[-1] # Get the parent
cls_name = ".".join(
[
getattr(node, "name", node.__module__)
for node in doc.ancestry
]
)
cls_doc = AstValidator(
ast_node=parent,
filename=doc.source_file_name,
obj_name=cls_name,
ancestry=doc.ancestry[:-1],
)
else:
# Ignore edge case: __init__ functions that don't belong to a class.
cls_doc = None
else:
raise TypeError(
f"Cannot load {doc.name} as a usable Validator object (Validator does not have `doc_obj` attr or type `AstValidator`)."
)

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

# Check if GL08 is to be ignored:
if "GL08" in ignore_validation_comments:
Expand Down