Repository: numpy/numpydoc

Issue:
Align pre-commit and Sphinx rule override syntax
See https://github.com/numpy/numpydoc/pull/454

/cc @stefmolin


Discussion:
stefmolin: I think I have an issue with the type hints I added &ndash; I will comment when this is ready for review since I can't mark it as a draft.
stefmolin: Issue has been solved. Ready for review.

Patch:
diff --git a/.circleci/config.yml b/.circleci/config.yml
index b2269f4..66a60c8 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -2,7 +2,7 @@ version: 2
 jobs:
   build_docs:
     docker:
-      - image: "cimg/python:3.10"
+      - image: "cimg/python:3.11"
     steps:
       - checkout
       - run:
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..918c3a5
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,8 @@
+version: 2
+updates:
+  - package-ecosystem: "github-actions"
+    directory: "/"
+    schedule:
+      interval: "monthly"
+    labels:
+      - "type: Maintenance"
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
deleted file mode 100644
index be57ed9..0000000
--- a/.github/workflows/lint.yml
+++ /dev/null
@@ -1,27 +0,0 @@
-name: style
-
-on: [push, pull_request]
-
-jobs:
-  pre-commit:
-    runs-on: ubuntu-latest
-    strategy:
-      matrix:
-        python-version: ["3.11"]
-
-    steps:
-      - uses: actions/checkout@v3
-
-      - name: Set up Python ${{ matrix.python-version }}
-        uses: actions/setup-python@v4
-        with:
-          python-version: ${{ matrix.python-version }}
-
-      - name: Install packages
-        run: |
-          pip install --upgrade pip
-          pip install -r requirements/developer.txt
-          pip list
-
-      - name: Lint
-        run: pre-commit run --all-files --show-diff-on-failure --color always
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index bd9d7e4..09c67c4 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -3,7 +3,7 @@
 
 repos:
   - repo: https://github.com/pre-commit/pre-commit-hooks
-    rev: v4.4.0
+    rev: f71fa2c1f9cf5cb705f73dffe4b21f7c61470ba9 # frozen: v4.4.0
     hooks:
       - id: trailing-whitespace
       - id: end-of-file-fixer
@@ -17,32 +17,41 @@ repos:
       - id: check-added-large-files
 
   - repo: https://github.com/psf/black
-    rev: 23.3.0
+    rev: 193ee766ca496871f93621d6b58d57a6564ff81b # frozen: 23.7.0
     hooks:
       - id: black
 
   - repo: https://github.com/pre-commit/mirrors-prettier
-    rev: v2.7.1
+    rev: 672abe14d309fc28817d45b6be25ea976a92222a # frozen: v3.0.2
     hooks:
       - id: prettier
         files: \.(html|md|toml|yml|yaml)
         args: [--prose-wrap=preserve]
 
   - repo: https://github.com/adamchainz/blacken-docs
-    rev: 1.14.0
+    rev: 960ead214cd1184149d366c6d27ca6c369ce46b6 # frozen: 1.16.0
     hooks:
       - id: blacken-docs
 
   - repo: https://github.com/asottile/pyupgrade
-    rev: v3.7.0
+    rev: b3e813e5d60a472ba815a45d107bbdf106405973 # frozen: v3.10.1
     hooks:
       - id: pyupgrade
         args: [--py38-plus]
 
   - repo: local
     hooks:
-      - id: pyproject.toml
-        name: pyproject.toml
+      - id: generate_requirements.py
+        name: generate_requirements.py
         language: system
-        entry: python tools/generate_pyproject.toml.py
-        files: "pyproject.toml|requirements/.*\\.txt|tools/.*pyproject.*"
+        entry: python tools/generate_requirements.py
+        files: "pyproject.toml|requirements/.*\\.txt|tools/generate_requirements.py"
+
+ci:
+  # This ensures that pr's aren't autofixed by the bot, rather you call
+  # the bot to make the fix
+  autofix_prs: false
+  autofix_commit_msg: |
+    '[pre-commit.ci 🤖] Apply code format tools to PR'
+  # Update hook versions every month (so we don't get hit with weekly update pr's)
+  autoupdate_schedule: monthly
diff --git a/doc/install.rst b/doc/install.rst
index 963819b..480bd59 100644
--- a/doc/install.rst
+++ b/doc/install.rst
@@ -138,3 +138,17 @@ numpydoc_validation_exclude : set
     validation.
     Only has an effect when docstring validation is activated, i.e.
     ``numpydoc_validation_checks`` is not an empty set.
+numpydoc_validation_overrides : dict
+    A dictionary mapping :ref:`validation checks <validation_checks>` to a
+    container of strings using :py:mod:`re` syntax specifying patterns to
+    ignore for docstring validation.
+    For example, the following skips the ``SS02`` check for docstrings
+    starting with the word ``Process``::
+
+        numpydoc_validation_overrides = {"SS02": [r'^Process ']}
+
+    The default is an empty dictionary meaning no overrides.
+    Only has an effect when docstring validation is activated, i.e.
+    ``numpydoc_validation_checks`` is not an empty set. Use
+    :ref:`inline ignore comments <inline_ignore_comments>` to turn off
+    specific checks for parts of your code.
diff --git a/doc/release_notes.rst b/doc/release_notes.rst
index 430ba45..705fad4 100644
--- a/doc/release_notes.rst
+++ b/doc/release_notes.rst
@@ -17,10 +17,10 @@ Release notes
    For release notes (sparsely) kept prior to 1.0.0, look at the `releases page
    on GitHub <https://github.com/numpy/numpydoc/releases>`__.
 
-1.6.0rc0
+1.6.0rc1
 --------
 
-Release date: 29 June 2023
+Release date: 7 July 2023
 
 Requires Python 3.8+ and Sphinx 5+.
 
diff --git a/doc/validation.rst b/doc/validation.rst
index 09196ea..93bd9b0 100644
--- a/doc/validation.rst
+++ b/doc/validation.rst
@@ -2,6 +2,8 @@
 Validation
 ==========
 
+.. _pre_commit_hook:
+
 Docstring Validation using Pre-Commit Hook
 ------------------------------------------
 
@@ -22,44 +24,55 @@ command line options for this hook:
 
     $ python -m numpydoc.hooks.validate_docstrings --help
 
-Using a config file provides additional customization. Both
-``pyproject.toml`` and ``setup.cfg`` are supported; however, if the
-project contains both you must use the ``pyproject.toml`` file.
-The example below configures the pre-commit hook to ignore three checks
-and specifies exceptions to the checks ``SS05`` (allow docstrings to
-start with "Process ", "Assess ", or "Access ") and ``GL08`` (allow
-the class/method/function with name "__init__" to not have a docstring).
+Using a config file provides additional customization. Both ``pyproject.toml``
+and ``setup.cfg`` are supported; however, if the project contains both
+you must use the ``pyproject.toml`` file. The example below configures
+the pre-commit hook as follows:
+
+* ``checks``: Report findings on all checks except ``EX01``, ``SA01``, and
+  ``ES01`` (using the same logic as the :ref:`validation during Sphinx build
+  <validation_during_sphinx_build>` for ``numpydoc_validation_checks``).
+* ``exclude``: Don't report issues on objects matching any of the regular
+  regular expressions ``\.undocumented_method$`` or ``\.__repr__$``. This
+  maps to ``numpydoc_validation_exclude`` from the
+  :ref:`Sphinx build configuration <validation_during_sphinx_build>`.
+* ``override_SS05``: Allow docstrings to start with "Process ", "Assess ",
+  or "Access ". To override different checks, add a field for each code in
+  the form of ``override_<code>`` with a collection of regular expression(s)
+  to search for in the contents of a docstring, not the object name. This
+  maps to ``numpydoc_validation_overrides`` from the
+  :ref:`Sphinx build configuration <validation_during_sphinx_build>`.
 
 ``pyproject.toml``::
 
     [tool.numpydoc_validation]
-    ignore = [
+    checks = [
+        "all",   # report on all checks, except the below
         "EX01",
         "SA01",
         "ES01",
     ]
-    override_SS05 = '^((Process|Assess|Access) )'
-    override_GL08 = '^(__init__)$'
+    exclude = [  # don't report on objects that match any of these regex
+        '\.undocumented_method$',
+        '\.__repr__$',
+    ]
+    override_SS05 = [  # override SS05 to allow docstrings starting with these words
+        '^Process ',
+        '^Assess ',
+        '^Access ',
+    ]
 
 ``setup.cfg``::
 
     [tool:numpydoc_validation]
-    ignore = EX01,SA01,ES01
-    override_SS05 = ^((Process|Assess|Access) )
-    override_GL08 = ^(__init__)$
-
-For more fine-tuned control, you can also include inline comments to tell the
-validation hook to ignore certain checks:
+    checks = all,EX01,SA01,ES01
+    exclude = \.undocumented_method$,\.__repr__$
+    override_SS05 = ^Process ,^Assess ,^Access ,
 
-.. code-block:: python
-
-    class SomeClass:  # numpydoc ignore=EX01,SA01,ES01
-        """This is the docstring for SomeClass."""
+In addition to the above, :ref:`inline ignore comments <inline_ignore_comments>`
+can be used to ignore findings on a case by case basis.
 
-        def __init__(self):  # numpydoc ignore=GL08
-            pass
-
-If any issues are found when commiting, a report is printed out and the
+If any issues are found when commiting, a report is printed out, and the
 commit is halted:
 
 .. code-block:: output
@@ -77,7 +90,9 @@ commit is halted:
     | src/pkg/module.py:33 | module.MyClass.parse | RT03    | Return value has no description      |
     +----------------------+----------------------+---------+--------------------------------------+
 
-See below for a full listing of checks.
+See :ref:`below <validation_checks>` for a full listing of checks.
+
+.. _validation_via_cli:
 
 Docstring Validation using Python
 ---------------------------------
@@ -94,12 +109,16 @@ This will validate that the docstring can be built.
 For an exhaustive validation of the formatting of the docstring, use the
 ``--validate`` parameter. This will report the errors detected, such as
 incorrect capitalization, wrong order of the sections, and many other
-issues.
+issues. Note that this will honor :ref:`inline ignore comments <inline_ignore_comments>`,
+but will not look for any configuration like the :ref:`pre-commit hook <pre_commit_hook>`
+or :ref:`Sphinx extension <validation_during_sphinx_build>` do.
+
+.. _validation_during_sphinx_build:
 
 Docstring Validation during Sphinx Build
 ----------------------------------------
 
-It is also possible to run docstring validation as part of the sphinx build
+It is also possible to run docstring validation as part of the Sphinx build
 process.
 This behavior is controlled by the ``numpydoc_validation_checks`` configuration
 parameter in ``conf.py``.
@@ -109,7 +128,7 @@ following line to ``conf.py``::
 
     numpydoc_validation_checks = {"PR01"}
 
-This will cause a sphinx warning to be raised for any (non-module) docstring
+This will cause a Sphinx warning to be raised for any (non-module) docstring
 that has undocumented parameters in the signature.
 The full set of validation checks can be activated by::
 
@@ -125,6 +144,48 @@ special keyword ``"all"``::
     # Report warnings for all validation checks except GL01, GL02, and GL05
     numpydoc_validation_checks = {"all", "GL01", "GL02", "GL05"}
 
+In addition, you can exclude any findings on certain objects with
+``numpydoc_validation_exclude``, which maps to ``exclude`` in the
+:ref:`pre-commit hook setup <pre_commit_hook>`::
+
+    # don't report on objects that match any of these regex
+    numpydoc_validation_exclude = [
+        '\.undocumented_method$',
+        '\.__repr__$',
+    ]
+
+Overrides based on docstring contents are also supported, but the structure
+is slightly different than the :ref:`pre-commit hook setup <pre_commit_hook>`::
+
+    numpydoc_validation_overrides = {
+        "SS02": [  # override SS05 to allow docstrings starting with these words
+            '^Process ',
+            '^Assess ',
+            '^Access ',
+        ]
+    }
+
+.. _inline_ignore_comments:
+
+Ignoring Validation Checks with Inline Comments
+-----------------------------------------------
+
+Sometimes you only want to ignore a specific check or set of checks for a
+specific piece of code. This level of fine-tuned control is provided via
+inline comments:
+
+.. code-block:: python
+
+    class SomeClass:  # numpydoc ignore=EX01,SA01,ES01
+        """This is the docstring for SomeClass."""
+
+        def __init__(self):  # numpydoc ignore=GL08
+            pass
+
+This is supported by the :ref:`CLI <validation_via_cli>`,
+:ref:`pre-commit hook <pre_commit_hook>`, and
+:ref:`Sphinx extension <validation_during_sphinx_build>`.
+
 .. _validation_checks:
 
 Built-in Validation Checks
diff --git a/numpydoc/_version.py b/numpydoc/_version.py
index 1b65ab9..1da1ef8 100644
--- a/numpydoc/_version.py
+++ b/numpydoc/_version.py
@@ -1,1 +1,1 @@
-__version__ = "1.6.0rc1.dev0"
+__version__ = "1.6.0rc2.dev0"
diff --git a/numpydoc/hooks/validate_docstrings.py b/numpydoc/hooks/validate_docstrings.py
index 9570ba1..db8141d 100644
--- a/numpydoc/hooks/validate_docstrings.py
+++ b/numpydoc/hooks/validate_docstrings.py
@@ -6,7 +6,6 @@ import configparser
 import os
 import re
 import sys
-import tokenize
 
 try:
     import tomllib
@@ -22,10 +21,6 @@ from .. import docscrape, validate
 from .utils import find_project_root
 
 
-# inline comments that can suppress individual checks per line
-IGNORE_COMMENT_PATTERN = re.compile("(?:.* numpydoc ignore[=|:] ?)(.+)")
-
-
 class AstValidator(validate.Validator):
     """
     Overrides the :class:`Validator` to work entirely with the AST.
@@ -138,19 +133,14 @@ class DocstringVisitor(ast.NodeVisitor):
         The absolute or relative path to the file to inspect.
     config : dict
         Configuration options for reviewing flagged issues.
-    numpydoc_ignore_comments : dict
-        A mapping of line number to checks to ignore.
-        Derived from comments in the source code.
     """
 
     def __init__(
         self,
         filepath: str,
         config: dict,
-        numpydoc_ignore_comments: dict,
     ) -> None:
         self.config: dict = config
-        self.numpydoc_ignore_comments = numpydoc_ignore_comments
         self.filepath: str = filepath
         self.module_name: str = Path(self.filepath).stem
         self.stack: list[str] = []
@@ -170,30 +160,18 @@ class DocstringVisitor(ast.NodeVisitor):
         Return
         ------
         bool
-            Whether the issue should be exluded from the report.
+            Whether the issue should be excluded from the report.
         """
-        if check in self.config["exclusions"]:
+        if check not in self.config["checks"]:
             return True
 
         if self.config["overrides"]:
             try:
-                if check == "GL08":
-                    pattern = self.config["overrides"].get("GL08")
-                    if pattern and re.match(pattern, node.name):
-                        return True
-            except AttributeError:  # ast.Module nodes don't have a name
-                pass
-
-            if check == "SS05":
-                pattern = self.config["overrides"].get("SS05")
-                if pattern and re.match(pattern, ast.get_docstring(node)) is not None:
+                pattern = self.config["overrides"][check]
+                if re.search(pattern, ast.get_docstring(node)) is not None:
                     return True
-
-        try:
-            if check in self.numpydoc_ignore_comments[getattr(node, "lineno", 1)]:
-                return True
-        except KeyError:
-            pass
+            except KeyError:
+                pass
 
         return False
 
@@ -233,7 +211,13 @@ class DocstringVisitor(ast.NodeVisitor):
             self.stack.append(
                 self.module_name if isinstance(node, ast.Module) else node.name
             )
-            self._get_numpydoc_issues(node)
+
+            if not (
+                self.config["exclude"]
+                and re.search(self.config["exclude"], ".".join(self.stack))
+            ):
+                self._get_numpydoc_issues(node)
+
             self.generic_visit(node)
             _ = self.stack.pop()
 
@@ -261,47 +245,74 @@ def parse_config(dir_path: os.PathLike = None) -> dict:
     dict
         Config options for the numpydoc validation hook.
     """
-    options = {"exclusions": [], "overrides": {}}
+    options = {"checks": {"all"}, "exclude": set(), "overrides": {}}
     dir_path = Path(dir_path).expanduser().resolve()
 
     toml_path = dir_path / "pyproject.toml"
     cfg_path = dir_path / "setup.cfg"
 
+    def compile_regex(expressions):
+        return (
+            re.compile(r"|".join(exp for exp in expressions if exp))
+            if expressions
+            else None
+        )
+
+    def extract_check_overrides(options, config_items):
+        for option, value in config_items:
+            if option.startswith("override_"):
+                _, check = option.split("_")
+                if value:
+                    options["overrides"][check.upper()] = compile_regex(value)
+
     if toml_path.is_file():
         with open(toml_path, "rb") as toml_file:
             pyproject_toml = tomllib.load(toml_file)
             config = pyproject_toml.get("tool", {}).get("numpydoc_validation", {})
-            options["exclusions"] = config.get("ignore", [])
-            for check in ["SS05", "GL08"]:
-                regex = config.get(f"override_{check}")
-                if regex:
-                    options["overrides"][check] = re.compile(regex)
+            options["checks"] = set(config.get("checks", options["checks"]))
+
+            global_exclusions = config.get("exclude", options["exclude"])
+            options["exclude"] = set(
+                global_exclusions
+                if not isinstance(global_exclusions, str)
+                else [global_exclusions]
+            )
+
+            extract_check_overrides(options, config.items())
+
     elif cfg_path.is_file():
         config = configparser.ConfigParser()
         config.read(cfg_path)
         numpydoc_validation_config_section = "tool:numpydoc_validation"
         try:
             try:
-                options["exclusions"] = config.get(
-                    numpydoc_validation_config_section, "ignore"
-                ).split(",")
-            except configparser.NoOptionError:
-                pass
-            try:
-                options["overrides"]["SS05"] = re.compile(
-                    config.get(numpydoc_validation_config_section, "override_SS05")
+                options["checks"] = set(
+                    config.get(numpydoc_validation_config_section, "checks")
+                    .rstrip(",")
+                    .split(",")
+                    or options["checks"]
                 )
             except configparser.NoOptionError:
                 pass
             try:
-                options["overrides"]["GL08"] = re.compile(
-                    config.get(numpydoc_validation_config_section, "override_GL08")
+                options["exclude"] = set(
+                    config.get(numpydoc_validation_config_section, "exclude")
+                    .rstrip(",")
+                    .split(",")
+                    or options["exclude"]
                 )
             except configparser.NoOptionError:
                 pass
+
+            extract_check_overrides(
+                options, config.items(numpydoc_validation_config_section)
+            )
+
         except configparser.NoSectionError:
             pass
 
+    options["checks"] = validate.get_validation_checks(options["checks"])
+    options["exclude"] = compile_regex(options["exclude"])
     return options
 
 
@@ -324,24 +335,7 @@ def process_file(filepath: os.PathLike, config: dict) -> "list[list[str]]":
     with open(filepath) as file:
         module_node = ast.parse(file.read(), filepath)
 
-    with open(filepath) as file:
-        numpydoc_ignore_comments = {}
-        last_declaration = 1
-        declarations = ["def", "class"]
-        for token in tokenize.generate_tokens(file.readline):
-            if token.type == tokenize.NAME and token.string in declarations:
-                last_declaration = token.start[0]
-            if token.type == tokenize.COMMENT:
-                match = re.match(IGNORE_COMMENT_PATTERN, token.string)
-                if match:
-                    rules = match.group(1).split(",")
-                    numpydoc_ignore_comments[last_declaration] = rules
-
-    docstring_visitor = DocstringVisitor(
-        filepath=str(filepath),
-        config=config,
-        numpydoc_ignore_comments=numpydoc_ignore_comments,
-    )
+    docstring_visitor = DocstringVisitor(filepath=str(filepath), config=config)
     docstring_visitor.visit(module_node)
 
     return docstring_visitor.findings
@@ -357,7 +351,7 @@ def main(argv: Union[Sequence[str], None] = None) -> int:
         + "\n  ".join(
             [
                 f"- {check}: {validate.ERROR_MSGS[check]}"
-                for check in config_options["exclusions"]
+                for check in set(validate.ERROR_MSGS.keys()) - config_options["checks"]
             ]
         )
         + "\n"
@@ -391,7 +385,7 @@ def main(argv: Union[Sequence[str], None] = None) -> int:
                 ' Currently ignoring the following from '
                 f'{Path(project_root_from_cwd) / config_file}: {ignored_checks}'
                 'Values provided here will be in addition to the above, unless an alternate config is provided.'
-                if config_options["exclusions"] else ''
+                if config_options["checks"] else ''
             }"""
         ),
     )
@@ -399,7 +393,7 @@ def main(argv: Union[Sequence[str], None] = None) -> int:
     args = parser.parse_args(argv)
     project_root, _ = find_project_root(args.files)
     config_options = parse_config(args.config or project_root)
-    config_options["exclusions"].extend(args.ignore or [])
+    config_options["checks"] -= set(args.ignore or [])
 
     findings = []
     for file in args.files:
diff --git a/numpydoc/numpydoc.py b/numpydoc/numpydoc.py
index 629fa04..284c7da 100644
--- a/numpydoc/numpydoc.py
+++ b/numpydoc/numpydoc.py
@@ -34,7 +34,7 @@ if sphinx.__version__ < "5":
     raise RuntimeError("Sphinx 5 or newer is required")
 
 from .docscrape_sphinx import get_doc_object
-from .validate import validate, ERROR_MSGS
+from .validate import validate, ERROR_MSGS, get_validation_checks
 from .xref import DEFAULT_LINKS
 from . import __version__
 
@@ -207,7 +207,19 @@ def mangle_docstrings(app, what, name, obj, options, lines):
                 # TODO: Currently, all validation checks are run and only those
                 # selected via config are reported. It would be more efficient to
                 # only run the selected checks.
-                errors = validate(doc)["errors"]
+                report = validate(doc)
+                errors = [
+                    err
+                    for err in report["errors"]
+                    if not (
+                        (
+                            overrides := app.config.numpydoc_validation_overrides.get(
+                                err[0]
+                            )
+                        )
+                        and re.search(overrides, report["docstring"])
+                    )
+                ]
                 if {err[0] for err in errors} & app.config.numpydoc_validation_checks:
                     msg = (
                         f"[numpydoc] Validation warnings while processing "
@@ -285,6 +297,7 @@ def setup(app, get_doc_object_=get_doc_object):
     app.add_config_value("numpydoc_xref_ignore", set(), True)
     app.add_config_value("numpydoc_validation_checks", set(), True)
     app.add_config_value("numpydoc_validation_exclude", set(), False)
+    app.add_config_value("numpydoc_validation_overrides", dict(), False)
 
     # Extra mangling domains
     app.add_domain(NumpyPythonDomain)
@@ -310,17 +323,9 @@ def update_config(app, config=None):
 
     # Processing to determine whether numpydoc_validation_checks is treated
     # as a blocklist or allowlist
-    valid_error_codes = set(ERROR_MSGS.keys())
-    if "all" in config.numpydoc_validation_checks:
-        block = deepcopy(config.numpydoc_validation_checks)
-        config.numpydoc_validation_checks = valid_error_codes - block
-    # Ensure that the validation check set contains only valid error codes
-    invalid_error_codes = config.numpydoc_validation_checks - valid_error_codes
-    if invalid_error_codes:
-        raise ValueError(
-            f"Unrecognized validation code(s) in numpydoc_validation_checks "
-            f"config value: {invalid_error_codes}"
-        )
+    config.numpydoc_validation_checks = get_validation_checks(
+        config.numpydoc_validation_checks
+    )
 
     # Generate the regexp for docstrings to ignore during validation
     if isinstance(config.numpydoc_validation_exclude, str):
@@ -335,6 +340,11 @@ def update_config(app, config=None):
         )
         config.numpydoc_validation_excluder = exclude_expr
 
+    for check, patterns in config.numpydoc_validation_overrides.items():
+        config.numpydoc_validation_overrides[check] = re.compile(
+            r"|".join(exp for exp in patterns)
+        )
+
 
 # ------------------------------------------------------------------------------
 # Docstring-mangling domains
diff --git a/numpydoc/validate.py b/numpydoc/validate.py
index 8c7f9b8..481b309 100644
--- a/numpydoc/validate.py
+++ b/numpydoc/validate.py
@@ -5,13 +5,19 @@ Analyze docstrings to detect errors.
 Call ``validate(object_name_to_validate)`` to get a dictionary
 with all the detected errors.
 """
+
+from copy import deepcopy
+from typing import Dict, List, Set
 import ast
 import collections
 import importlib
 import inspect
+import os
 import pydoc
 import re
 import textwrap
+import tokenize
+
 from .docscrape import get_doc_object
 
 
@@ -101,6 +107,72 @@ ERROR_MSGS = {
 # Ignore these when evaluating end-of-line-"." checks
 IGNORE_STARTS = (" ", "* ", "- ")
 
+# inline comments that can suppress individual checks per line
+IGNORE_COMMENT_PATTERN = re.compile("(?:.* numpydoc ignore[=|:] ?)(.+)")
+
+
+def extract_ignore_validation_comments(filepath: os.PathLike) -> Dict[int, List[str]]:
+    """
+    Extract inline comments indicating certain validation checks should be ignored.
+
+    Parameters
+    ----------
+    filepath : os.PathLike
+        Path to the file being inspected.
+
+    Returns
+    -------
+    dict[int, list[str]]
+        Mapping of line number to a list of checks to ignore.
+    """
+    with open(filepath) as file:
+        numpydoc_ignore_comments = {}
+        last_declaration = 1
+        declarations = ["def", "class"]
+        for token in tokenize.generate_tokens(file.readline):
+            if token.type == tokenize.NAME and token.string in declarations:
+                last_declaration = token.start[0]
+            if token.type == tokenize.COMMENT:
+                match = re.match(IGNORE_COMMENT_PATTERN, token.string)
+                if match:
+                    rules = match.group(1).split(",")
+                    numpydoc_ignore_comments[last_declaration] = rules
+    return numpydoc_ignore_comments
+
+
+def get_validation_checks(validation_checks: Set[str]) -> Set[str]:
+    """
+    Get the set of validation checks to report on.
+
+    Parameters
+    ----------
+    validation_checks : set[str]
+        A set of validation checks to report on. If the set is ``{"all"}``,
+        all checks will be reported. If the set contains just specific checks,
+        only those will be reported on. If the set contains both ``"all"`` and
+        specific checks, all checks except those included in the set will be
+        reported on.
+
+    Returns
+    -------
+    set[str]
+        The set of validation checks to report on.
+    """
+    valid_error_codes = set(ERROR_MSGS.keys())
+    if "all" in validation_checks:
+        block = deepcopy(validation_checks)
+        validation_checks = valid_error_codes - block
+
+    # Ensure that the validation check set contains only valid error codes
+    invalid_error_codes = validation_checks - valid_error_codes
+    if invalid_error_codes:
+        raise ValueError(
+            f"Unrecognized validation code(s) in numpydoc_validation_checks "
+            f"config value: {invalid_error_codes}"
+        )
+
+    return validation_checks
+
 
 def error(code, **kwargs):
     """
@@ -506,9 +578,16 @@ def validate(obj_name, validator_cls=None, **validator_kwargs):
     else:
         doc = validator_cls(obj_name=obj_name, **validator_kwargs)
 
+    # lineno is only 0 if we have a module docstring in the file and we are
+    # validating that, so we change to 1 for readability of the output
+    ignore_validation_comments = extract_ignore_validation_comments(
+        doc.source_file_name
+    ).get(doc.source_file_def_line or 1, [])
+
     errs = []
     if not doc.raw_doc:
-        errs.append(error("GL08"))
+        if "GL08" not in ignore_validation_comments:
+            errs.append(error("GL08"))
         return {
             "type": doc.type,
             "docstring": doc.clean_doc,
@@ -630,6 +709,9 @@ def validate(obj_name, validator_cls=None, **validator_kwargs):
 
     if not doc.examples:
         errs.append(error("EX01"))
+
+    errs = [err for err in errs if err[0] not in ignore_validation_comments]
+
     return {
         "type": doc.type,
         "docstring": doc.clean_doc,
diff --git a/pyproject.toml b/pyproject.toml
index 7c90367..374008b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,13 +1,3 @@
-################################################################################
-# DO NOT EDIT
-# AUTOGENERATED BY
-#
-#     $ python tools/generate_pyproject.toml.py
-#
-# EDIT tools/pyproject.toml.in AND RUN THAT SCRIPT.
-#
-################################################################################
-
 [build-system]
 build-backend = 'setuptools.build_meta'
 requires = ['setuptools>=61.2']
@@ -50,13 +40,18 @@ file = 'LICENSE.txt'
 
 [project.urls]
 Homepage = 'https://numpydoc.readthedocs.io'
+Source = 'https://github.com/numpy/numpydoc/'
 
 [project.optional-dependencies]
+developer = [
+    'pre-commit>=3.3',
+    "tomli; python_version < '3.11'",
+]
 doc = [
     'numpy>=1.22',
     'matplotlib>=3.5',
-    'pydata-sphinx-theme>=0.13',
-    'sphinx>=6',
+    'pydata-sphinx-theme>=0.13.3',
+    'sphinx>=7',
 ]
 test = [
     'pytest',
@@ -66,12 +61,15 @@ test = [
 
 [project.scripts]
 validate-docstrings = 'numpydoc.hooks.validate_docstrings:main'
+
 [tool.setuptools]
 include-package-data = false
 packages = [
     'numpydoc',
     'numpydoc.hooks',
 ]
+[tool.setuptools.dynamic.version]
+attr = 'numpydoc.__version__'
 
 [tool.setuptools.package-data]
 numpydoc = [
@@ -81,6 +79,7 @@ numpydoc = [
     'tests/tinybuild/*.py',
     'templates/*.rst',
 ]
+
 [tool.pytest.ini_options]
 addopts = '''
 --showlocals --doctest-modules -ra --cov-report= --cov=numpydoc
diff --git a/requirements/default.txt b/requirements/default.txt
new file mode 100644
index 0000000..1b80561
--- /dev/null
+++ b/requirements/default.txt
@@ -0,0 +1,6 @@
+# Generated via tools/generate_requirements.py and pre-commit hook.
+# Do not edit this file; modify pyproject.toml instead.
+sphinx>=5
+Jinja2>=2.10
+tabulate>=0.8.10
+tomli>=1.1.0;python_version<'3.11'
diff --git a/requirements/developer.txt b/requirements/developer.txt
index 832c8cb..dbeefe5 100644
--- a/requirements/developer.txt
+++ b/requirements/developer.txt
@@ -1,2 +1,4 @@
+# Generated via tools/generate_requirements.py and pre-commit hook.
+# Do not edit this file; modify pyproject.toml instead.
 pre-commit>=3.3
-rtoml
+tomli; python_version < '3.11'
diff --git a/requirements/doc.txt b/requirements/doc.txt
index 0cdb577..f3d9b05 100644
--- a/requirements/doc.txt
+++ b/requirements/doc.txt
@@ -1,4 +1,6 @@
+# Generated via tools/generate_requirements.py and pre-commit hook.
+# Do not edit this file; modify pyproject.toml instead.
 numpy>=1.22
 matplotlib>=3.5
-pydata-sphinx-theme>=0.13
-sphinx>=6
+pydata-sphinx-theme>=0.13.3
+sphinx>=7
diff --git a/tools/generate_pyproject.toml.py b/tools/generate_pyproject.toml.py
deleted file mode 100755
index bc7d967..0000000
--- a/tools/generate_pyproject.toml.py
+++ /dev/null
@@ -1,52 +0,0 @@
-#!/usr/bin/env python
-
-import os
-import sys
-
-try:
-    import rtoml as toml
-except ImportError:
-    print("Please install `rtoml` first: `pip install rtoml`")
-    sys.exit(1)
-
-
-if not os.path.isfile("pyproject.toml"):
-    print("Please run this script from the skimage repository root:")
-    print("  python tools/generate_pyproject.toml.py")
-    sys.exit(1)
-
-
-def parse_requirements_file(filename):
-    with open(filename) as fid:
-        requires = [l.strip() for l in fid.readlines() if not l.startswith("#")]
-        requires = [l for l in requires if l]
-
-    return requires
-
-
-with open("tools/pyproject.toml.in") as f:
-    pyproject = toml.load(f)
-
-# pyproject['project']['dependencies'] = \
-#   parse_requirements_file("requirements/default.txt")
-
-pyproject["project"]["optional-dependencies"] = {
-    dep: parse_requirements_file(f"requirements/{dep}.txt") for dep in ["doc", "test"]
-}
-
-banner = f"""\
-{"#" * 80}
-# DO NOT EDIT
-# AUTOGENERATED BY
-#
-#     $ python tools/generate_pyproject.toml.py
-#
-# EDIT tools/pyproject.toml.in AND RUN THAT SCRIPT.
-#
-{"#" * 80}
-
-"""
-
-with open("pyproject.toml", "w") as f:
-    f.write(banner)
-    toml.dump(pyproject, f, pretty=True)
diff --git a/tools/generate_requirements.py b/tools/generate_requirements.py
new file mode 100755
index 0000000..388d2cc
--- /dev/null
+++ b/tools/generate_requirements.py
@@ -0,0 +1,39 @@
+#!/usr/bin/env python
+"""Generate requirements/*.txt files from pyproject.toml."""
+
+import sys
+from pathlib import Path
+
+try:  # standard module since Python 3.11
+    import tomllib as toml
+except ImportError:
+    try:  # available for older Python via pip
+        import tomli as toml
+    except ImportError:
+        sys.exit("Please install `tomli` first: `pip install tomli`")
+
+script_pth = Path(__file__)
+repo_dir = script_pth.parent.parent
+script_relpth = script_pth.relative_to(repo_dir)
+header = [
+    f"# Generated via {script_relpth.as_posix()} and pre-commit hook.",
+    "# Do not edit this file; modify pyproject.toml instead.",
+]
+
+
+def generate_requirement_file(name: str, req_list: list[str]) -> None:
+    req_fname = repo_dir / "requirements" / f"{name}.txt"
+    req_fname.write_text("\n".join(header + req_list) + "\n")
+
+
+def main() -> None:
+    pyproject = toml.loads((repo_dir / "pyproject.toml").read_text())
+
+    generate_requirement_file("default", pyproject["project"]["dependencies"])
+
+    for key, opt_list in pyproject["project"]["optional-dependencies"].items():
+        generate_requirement_file(key, opt_list)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/tools/pyproject.toml.in b/tools/pyproject.toml.in
deleted file mode 100644
index 7b43e02..0000000
--- a/tools/pyproject.toml.in
+++ /dev/null
@@ -1,67 +0,0 @@
-[build-system]
-requires = [
-    "setuptools>=61.2",
-]
-build-backend = "setuptools.build_meta"
-
-[project]
-name = "numpydoc"
-description = "Sphinx extension to support docstrings in Numpy format"
-license = {file = "LICENSE.txt"}
-dynamic = ['version']
-keywords = [
-    "sphinx",
-    "numpy",
-]
-readme = "README.rst"
-classifiers = [
-    "Development Status :: 4 - Beta",
-    "Environment :: Plugins",
-    "License :: OSI Approved :: BSD License",
-    "Topic :: Documentation",
-    "Programming Language :: Python",
-    "Programming Language :: Python :: 3",
-    "Programming Language :: Python :: 3.8",
-    "Programming Language :: Python :: 3.9",
-    "Programming Language :: Python :: 3.10",
-    "Programming Language :: Python :: 3.11",
-]
-requires-python = ">=3.8"
-dependencies = [
-    "sphinx>=5",
-    "Jinja2>=2.10",
-    "tabulate>=0.8.10",
-    "tomli>=1.1.0;python_version<'3.11'",
-]
-
-[[project.authors]]
-name = "Pauli Virtanen and others"
-email = "pav@iki.fi"
-
-[project.urls]
-Homepage = "https://numpydoc.readthedocs.io"
-
-[project.optional-dependencies]
-
-[project.scripts]
-validate-docstrings = "numpydoc.hooks.validate_docstrings:main"
-
-[tool.setuptools]
-packages = [
-    "numpydoc",
-    "numpydoc.hooks",
-]
-include-package-data = false
-
-[tool.setuptools.package-data]
-numpydoc = [
-    "tests/test_*.py",
-    "tests/tinybuild/Makefile",
-    "tests/tinybuild/index.rst",
-    "tests/tinybuild/*.py",
-    "templates/*.rst",
-]
-
-[tool.pytest.ini_options]
-addopts = "--showlocals --doctest-modules -ra --cov-report= --cov=numpydoc\n--junit-xml=junit-results.xml --ignore=doc/ --ignore=tools/"
-junit_family = "xunit2"


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 104d989..7329099 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -65,7 +65,6 @@ jobs:
         run: |
           sudo apt-get update
           sudo apt install texlive texlive-latex-extra latexmk dvipng
-          pip install "sphinx<6"
 
       - name: Build documentation
         run: |
@@ -117,7 +116,6 @@ jobs:
         run: |
           sudo apt-get update
           sudo apt install texlive texlive-latex-extra latexmk dvipng
-          pip install "sphinx<6"
 
       - name: Build documentation
         run: |
diff --git a/numpydoc/tests/hooks/example_module.py b/numpydoc/tests/hooks/example_module.py
index 6cfa4d4..b36f519 100644
--- a/numpydoc/tests/hooks/example_module.py
+++ b/numpydoc/tests/hooks/example_module.py
@@ -28,3 +28,7 @@ class MyClass:
     def process(self):
         """Process stuff."""
         pass
+
+
+class NewClass:
+    pass
diff --git a/numpydoc/tests/hooks/test_validate_hook.py b/numpydoc/tests/hooks/test_validate_hook.py
index 7c8b899..5c635df 100644
--- a/numpydoc/tests/hooks/test_validate_hook.py
+++ b/numpydoc/tests/hooks/test_validate_hook.py
@@ -67,6 +67,8 @@ def test_validate_hook(example_module, config, capsys):
         +-------------------------------------------+-------------------------------------+---------+----------------------------------------------------+
         | numpydoc/tests/hooks/example_module.py:28 | example_module.MyClass.process      | EX01    | No examples section found                          |
         +-------------------------------------------+-------------------------------------+---------+----------------------------------------------------+
+        | numpydoc/tests/hooks/example_module.py:33 | example_module.NewClass             | GL08    | The object does not have a docstring               |
+        +-------------------------------------------+-------------------------------------+---------+----------------------------------------------------+
         """
     )
 
@@ -102,6 +104,8 @@ def test_validate_hook_with_ignore(example_module, capsys):
         |                                           |                                     |         | person (e.g. use "Generate" instead of             |
         |                                           |                                     |         | "Generates")                                       |
         +-------------------------------------------+-------------------------------------+---------+----------------------------------------------------+
+        | numpydoc/tests/hooks/example_module.py:33 | example_module.NewClass             | GL08    | The object does not have a docstring               |
+        +-------------------------------------------+-------------------------------------+---------+----------------------------------------------------+
         """
     )
 
@@ -121,13 +125,18 @@ def test_validate_hook_with_toml_config(example_module, tmp_path, capsys):
             inspect.cleandoc(
                 """
                 [tool.numpydoc_validation]
-                ignore = [
+                checks = [
+                    "all",
                     "EX01",
                     "SA01",
                     "ES01",
                 ]
-                override_SS05 = '^((Process|Assess|Access) )'
-                override_GL08 = '^(__init__)$'
+                exclude = '\\.__init__$'
+                override_SS05 = [
+                    '^Process',
+                    '^Assess',
+                    '^Access',
+                ]
                 """
             )
         )
@@ -143,6 +152,8 @@ def test_validate_hook_with_toml_config(example_module, tmp_path, capsys):
         +-------------------------------------------+-------------------------------------+---------+----------------------------------------+
         | numpydoc/tests/hooks/example_module.py:18 | example_module.MyClass.do_something | PR07    | Parameter "*args" has no description   |
         +-------------------------------------------+-------------------------------------+---------+----------------------------------------+
+        | numpydoc/tests/hooks/example_module.py:33 | example_module.NewClass             | GL08    | The object does not have a docstring   |
+        +-------------------------------------------+-------------------------------------+---------+----------------------------------------+
         """
     )
 
@@ -162,9 +173,9 @@ def test_validate_hook_with_setup_cfg(example_module, tmp_path, capsys):
             inspect.cleandoc(
                 """
                 [tool:numpydoc_validation]
-                ignore = EX01,SA01,ES01
-                override_SS05 = ^((Process|Assess|Access) )
-                override_GL08 = ^(__init__)$
+                checks = all,EX01,SA01,ES01
+                exclude = \\.__init__$
+                override_SS05 = ^Process,^Assess,^Access
                 """
             )
         )
@@ -180,6 +191,8 @@ def test_validate_hook_with_setup_cfg(example_module, tmp_path, capsys):
         +-------------------------------------------+-------------------------------------+---------+----------------------------------------+
         | numpydoc/tests/hooks/example_module.py:18 | example_module.MyClass.do_something | PR07    | Parameter "*args" has no description   |
         +-------------------------------------------+-------------------------------------+---------+----------------------------------------+
+        | numpydoc/tests/hooks/example_module.py:33 | example_module.NewClass             | GL08    | The object does not have a docstring   |
+        +-------------------------------------------+-------------------------------------+---------+----------------------------------------+
         """
     )
 
@@ -198,3 +211,87 @@ def test_validate_hook_help(capsys):
     out = capsys.readouterr().out
     assert "--ignore" in out
     assert "--config" in out
+
+
+def test_validate_hook_exclude_option_pyproject(example_module, tmp_path, capsys):
+    """
+    Test that a file is correctly processed with the config coming from
+    a pyproject.toml file and exclusions provided.
+    """
+
+    with open(tmp_path / "pyproject.toml", "w") as config_file:
+        config_file.write(
+            inspect.cleandoc(
+                r"""
+                [tool.numpydoc_validation]
+                checks = [
+                    "all",
+                    "EX01",
+                    "SA01",
+                    "ES01",
+                ]
+                exclude = [
+                    '\.do_something$',
+                    '\.__init__$',
+                ]
+                override_SS05 = [
+                    '^Process',
+                    '^Assess',
+                    '^Access',
+                ]
+                """
+            )
+        )
+
+    expected = inspect.cleandoc(
+        """
+        +-------------------------------------------+------------------------------+---------+--------------------------------------+
+        | file                                      | item                         | check   | description                          |
+        +===========================================+==============================+=========+======================================+
+        | numpydoc/tests/hooks/example_module.py:4  | example_module.some_function | PR01    | Parameters {'name'} not documented   |
+        +-------------------------------------------+------------------------------+---------+--------------------------------------+
+        | numpydoc/tests/hooks/example_module.py:33 | example_module.NewClass      | GL08    | The object does not have a docstring |
+        +-------------------------------------------+------------------------------+---------+--------------------------------------+
+        """
+    )
+
+    return_code = main([example_module, "--config", str(tmp_path)])
+    assert return_code == 1
+    assert capsys.readouterr().err.rstrip() == expected
+
+
+def test_validate_hook_exclude_option_setup_cfg(example_module, tmp_path, capsys):
+    """
+    Test that a file is correctly processed with the config coming from
+    a setup.cfg file and exclusions provided.
+    """
+
+    with open(tmp_path / "setup.cfg", "w") as config_file:
+        config_file.write(
+            inspect.cleandoc(
+                """
+                [tool:numpydoc_validation]
+                checks = all,EX01,SA01,ES01
+                exclude = \\.NewClass$,\\.__init__$
+                override_SS05 = ^Process,^Assess,^Access
+                """
+            )
+        )
+
+    expected = inspect.cleandoc(
+        """
+        +-------------------------------------------+-------------------------------------+---------+----------------------------------------+
+        | file                                      | item                                | check   | description                            |
+        +===========================================+=====================================+=========+========================================+
+        | numpydoc/tests/hooks/example_module.py:4  | example_module.some_function        | PR01    | Parameters {'name'} not documented     |
+        +-------------------------------------------+-------------------------------------+---------+----------------------------------------+
+        | numpydoc/tests/hooks/example_module.py:18 | example_module.MyClass.do_something | PR01    | Parameters {'**kwargs'} not documented |
+        +-------------------------------------------+-------------------------------------+---------+----------------------------------------+
+        | numpydoc/tests/hooks/example_module.py:18 | example_module.MyClass.do_something | PR07    | Parameter "*args" has no description   |
+        +-------------------------------------------+-------------------------------------+---------+----------------------------------------+
+        """
+    )
+
+    return_code = main([example_module, "--config", str(tmp_path)])
+    assert return_code == 1
+    assert capsys.readouterr().err.rstrip() == expected
diff --git a/numpydoc/tests/test_docscrape.py b/numpydoc/tests/test_docscrape.py
index 4d340b1..1e70008 100644
--- a/numpydoc/tests/test_docscrape.py
+++ b/numpydoc/tests/test_docscrape.py
@@ -1576,6 +1576,7 @@ def test_xref():
             # numpydoc.update_config fails if this config option not present
             self.numpydoc_validation_checks = set()
             self.numpydoc_validation_exclude = set()
+            self.numpydoc_validation_overrides = dict()
 
     xref_aliases_complete = deepcopy(DEFAULT_LINKS)
     for key in xref_aliases:
diff --git a/numpydoc/tests/test_numpydoc.py b/numpydoc/tests/test_numpydoc.py
index 1629078..f92bd82 100644
--- a/numpydoc/tests/test_numpydoc.py
+++ b/numpydoc/tests/test_numpydoc.py
@@ -23,6 +23,7 @@ class MockConfig:
     numpydoc_attributes_as_param_list = True
     numpydoc_validation_checks = set()
     numpydoc_validation_exclude = set()
+    numpydoc_validation_overrides = dict()
 
 
 class MockBuilder:
@@ -212,6 +213,39 @@ def test_mangle_docstring_validation_exclude():
     assert warning.getvalue() == ""
 
 
+@pytest.mark.parametrize("overrides", [{}, {"SS02"}, {"SS02", "SS03"}])
+def test_mangle_docstrings_overrides(overrides):
+    def process_something_noop_function():
+        """Process something."""
+
+    app = MockApp()
+    app.config.numpydoc_validation_checks = {"all"}
+    app.config.numpydoc_validation_overrides = {
+        check: [r"^Process "]  # overrides are regex on docstring content
+        for check in overrides
+    }
+    update_config(app)
+
+    # Setup for catching warnings
+    status, warning = StringIO(), StringIO()
+    logging.setup(app, status, warning)
+
+    # Run mangle docstrings on process_something_noop_function
+    mangle_docstrings(
+        app,
+        "function",
+        process_something_noop_function.__name__,
+        process_something_noop_function,
+        None,
+        process_something_noop_function.__doc__.split("\n"),
+    )
+
+    findings = warning.getvalue()
+    assert " EX01: " in findings  # should always be there
+    for check in overrides:
+        assert f" {check}: " not in findings
+
+
 def test_update_config_invalid_validation_set():
     app = MockApp()
     # Results in {'a', 'l'} instead of {"all"}
diff --git a/numpydoc/tests/test_validate.py b/numpydoc/tests/test_validate.py
index f01cde5..0191e82 100644
--- a/numpydoc/tests/test_validate.py
+++ b/numpydoc/tests/test_validate.py
@@ -1,10 +1,75 @@
 import pytest
 import warnings
-import numpydoc.validate
+
+from numpydoc import validate
 import numpydoc.tests
 
 
-validate_one = numpydoc.validate.validate
+validate_one = validate.validate
+
+ALL_CHECKS = set(validate.ERROR_MSGS.keys())
+
+
+@pytest.mark.parametrize(
+    ["checks", "expected"],
+    [
+        [{"all"}, ALL_CHECKS],
+        [set(), set()],
+        [{"EX01"}, {"EX01"}],
+        [{"EX01", "SA01"}, {"EX01", "SA01"}],
+        [{"all", "EX01", "SA01"}, ALL_CHECKS - {"EX01", "SA01"}],
+        [{"all", "PR01"}, ALL_CHECKS - {"PR01"}],
+    ],
+)
+def test_utils_get_validation_checks(checks, expected):
+    """Ensure check selection is working."""
+    assert validate.get_validation_checks(checks) == expected
+
+
+@pytest.mark.parametrize(
+    "checks",
+    [
+        {"every"},
+        {None},
+        {"SM10"},
+        {"EX01", "SM10"},
+    ],
+)
+def test_get_validation_checks_validity(checks):
+    """Ensure that invalid checks are flagged."""
+    with pytest.raises(ValueError, match="Unrecognized validation code"):
+        _ = validate.get_validation_checks(checks)
+
+
+@pytest.mark.parametrize(
+    ["file_contents", "expected"],
+    [
+        ["class MyClass:\n    pass", {}],
+        ["class MyClass:  # numpydoc ignore=EX01\n    pass", {1: ["EX01"]}],
+        [
+            "class MyClass:  # numpydoc ignore= EX01,SA01\n    pass",
+            {1: ["EX01", "SA01"]},
+        ],
+        [
+            "class MyClass:\n    def my_method():  # numpydoc ignore:EX01\n        pass",
+            {2: ["EX01"]},
+        ],
+        [
+            "class MyClass:\n    def my_method():  # numpydoc ignore: EX01,PR01\n        pass",
+            {2: ["EX01", "PR01"]},
+        ],
+        [
+            "class MyClass:  # numpydoc ignore=GL08\n    def my_method():  # numpydoc ignore:EX01,PR01\n        pass",
+            {1: ["GL08"], 2: ["EX01", "PR01"]},
+        ],
+    ],
+)
+def test_extract_ignore_validation_comments(tmp_path, file_contents, expected):
+    """Test that extraction of validation ignore comments is working."""
+    filepath = tmp_path / "ignore_comments.py"
+    with open(filepath, "w") as file:
+        file.write(file_contents)
+    assert validate.extract_ignore_validation_comments(filepath) == expected
 
 
 class GoodDocStrings:
diff --git a/requirements/test.txt b/requirements/test.txt
index 5f58f03..2a9e670 100644
--- a/requirements/test.txt
+++ b/requirements/test.txt
@@ -1,3 +1,5 @@
+# Generated via tools/generate_requirements.py and pre-commit hook.
+# Do not edit this file; modify pyproject.toml instead.
 pytest
 pytest-cov
 matplotlib