From 05a4b4ad0de605a6c2524a8130a15cc32547c0a3 Mon Sep 17 00:00:00 2001 From: Vecko <36369090+VeckoTheGecko@users.noreply.github.com> Date: Thu, 15 Jan 2026 10:46:18 +0100 Subject: [PATCH 01/10] Remove version exclusion on numpydoc They have a new release --- pixi.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pixi.toml b/pixi.toml index f67712291..ab7879a39 100644 --- a/pixi.toml +++ b/pixi.toml @@ -75,7 +75,7 @@ gsw = "*" [feature.docs.dependencies] parcels = { path = "." } -numpydoc = "!=1.9.0" +numpydoc = "*" myst-nb = "*" ipython = "*" sphinx = "*" From 810d2fc13ba016a0f3368fe3ba6c46c169dae2ad Mon Sep 17 00:00:00 2001 From: Vecko <36369090+VeckoTheGecko@users.noreply.github.com> Date: Thu, 15 Jan 2026 11:08:52 +0100 Subject: [PATCH 02/10] Add numpydoc-lint task - Create tools/numpydoc-public-api.py script - Update pixi.toml to expose this script via a task numpydoc-lint - Update conf.py to work from the shared tool data --- docs/conf.py | 17 +++--- pixi.toml | 7 +++ tools/numpydoc-public-api.py | 105 +++++++++++++++++++++++++++++++++++ tools/tool-data.toml | 39 +++++++++++++ 4 files changed, 159 insertions(+), 9 deletions(-) create mode 100644 tools/numpydoc-public-api.py create mode 100644 tools/tool-data.toml diff --git a/docs/conf.py b/docs/conf.py index 6d044def4..ec0e3d570 100755 --- a/docs/conf.py +++ b/docs/conf.py @@ -15,7 +15,11 @@ import inspect import os import sys +import tomllib import warnings +from pathlib import Path + +PROJECT_ROOT = (Path(__file__).parent / "..").resolve() # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the @@ -181,15 +185,10 @@ numpydoc_class_members_toctree = False # https://stackoverflow.com/a/73294408 # full list of numpydoc error codes: https://numpydoc.readthedocs.io/en/latest/validation.html -numpydoc_validation_checks = { - "GL05", - "GL06", - "GL07", - "GL10", - "PR05", - "PR10", - "RT02", -} +with open(PROJECT_ROOT / "tools/tool-data.toml", "rb") as f: + numpydoc_skip_errors = tomllib.load(f)["numpydoc_skip_errors"] + +numpydoc_validation_checks = {"all"} + set(numpydoc_skip_errors) # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, diff --git a/pixi.toml b/pixi.toml index ab7879a39..3c040ceb7 100644 --- a/pixi.toml +++ b/pixi.toml @@ -97,6 +97,12 @@ pre_commit = "*" [feature.pre-commit.tasks] lint = "pre-commit run --all-files" +[feature.numpydoc.dependencies] +numpydoc = "*" + +[feature.numpydoc.tasks] +numpydoc-lint = "python tools/numpydoc-public-api.py" + [feature.typing.dependencies] mypy = "*" lxml = "*" # in CI @@ -112,6 +118,7 @@ default = { features = [ "notebooks", "typing", "pre-commit", + "numpydoc", ], solve-group = "main" } test = { features = ["test"], solve-group = "main" } test-minimum = { features = ["test", "minimum"] } diff --git a/tools/numpydoc-public-api.py b/tools/numpydoc-public-api.py new file mode 100644 index 000000000..4ff773dc8 --- /dev/null +++ b/tools/numpydoc-public-api.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python +"""A script that can be quickly run that explores the public API of Parcels +and validates docstrings along the way according to the numpydoc conventions. + +This script is a best attempt, and it meant as a first line of defence (compared +to the sphinx numpydoc integration which is the ground truth - as those are the +docstrings that end up in the documentation). +""" + +import functools +import importlib +import sys +import tomllib +import types +from pathlib import Path + +from numpydoc.validate import validate + +PROJECT_ROOT = (Path(__file__).parent / "..").resolve() +PUBLIC_MODULES = ["parcels", "parcels.interpolators"] +ROOT_PACKAGE = "parcels" + +with open(PROJECT_ROOT / "tools/tool-data.toml", "rb") as f: + skip_errors = tomllib.load(f)["numpydoc_skip_errors"] + + +def is_built_in(type_or_instance: type | object): + if isinstance(type_or_instance, type): + return type_or_instance.__module__ == "builtins" + else: + return type_or_instance.__class__.__module__ == "builtins" + + +def walk_module(module_str: str, public_api: list[str] | None = None) -> list[str]: + if public_api is None: + public_api = [] + + module = importlib.import_module(module_str) + try: + all_ = module.__all__ + except AttributeError: + print(f"No __all__ variable found in public module {module_str!r}") + return public_api + + if module_str not in public_api: + public_api.append(module_str) + for item_str in all_: + item = getattr(module, item_str) + if isinstance(item, types.ModuleType): + walk_module(f"{module_str}.{item_str}", public_api) + if isinstance(item, (types.FunctionType,)): + public_api.append(f"{module_str}.{item_str}") + elif is_built_in(item): + print(f"Found builtin at '{module_str}.{item_str}' of type {type(item)}") + continue + elif isinstance(item, type): + public_api.append(f"{module_str}.{item_str}") + walk_class(module_str, item, public_api) + else: + print( + 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." + ) + + return public_api + + +def get_public_class_attrs(class_: type) -> set[str]: + return {a for a in dir(class_) if not a.startswith("_")} + + +def walk_class(module_str: str, class_: type, public_api: list[str]) -> list[str]: + class_str = class_.__name__ + + # attributes that were introduced by this class specifically - not from inheritance + attrs = get_public_class_attrs(class_) - functools.reduce( + set.add, (get_public_class_attrs(base) for base in class_.__bases__) + ) + + public_api.extend([f"{module_str}.{class_str}.{attr_str}" for attr_str in attrs]) + return public_api + + +def main(): + public_api = [] + for module in PUBLIC_MODULES: + public_api += walk_module(module) + + public_api = filter(lambda x: x != ROOT_PACKAGE, public_api) # For some reason doesn't work on root package + errors = 0 + for item in public_api: + try: + res = validate(item) + except AttributeError: + continue + if res["type"] in ("module", "float", "int", "dict"): + continue + for err in res["errors"]: + if err[0] not in skip_errors: + print(f"{item}: {err}") + errors += 1 + sys.exit(errors) + + +if __name__ == "__main__": + main() diff --git a/tools/tool-data.toml b/tools/tool-data.toml new file mode 100644 index 000000000..5dbf37ba4 --- /dev/null +++ b/tools/tool-data.toml @@ -0,0 +1,39 @@ +numpydoc_skip_errors = [ + "GL01", # Docstring text (summary) should start in the line immediately after the opening quotes (not in the same line, or leaving a blank line in between) + "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) + "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 + "GL05", # Tabs found at the start of line "{line_with_tabs}", please use whitespace only + "GL06", # Found unknown section "{section}". Allowed sections are: {allowed_sections} + "GL07", # Sections are in the wrong order. Correct order is: {correct_sections} + "GL08", # The object does not have a docstring + "GL09", # Deprecation warning should precede extended summary + "GL10", # reST directives {directives} must be followed by two colons + "SS01", # No summary found (a short summary in a single line should be present at the beginning of the docstring) + "SS02", # Summary does not start with a capital letter + "SS03", # Summary does not end with a period + "SS04", # Summary contains heading whitespaces + "SS05", # Summary must start with infinitive verb, not third person (e.g. use "Generate" instead of "Generates") + "SS06", # Summary should fit in a single line + "ES01", # No extended summary found + "PR01", # Parameters {missing_params} not documented + "PR02", # Unknown parameters {unknown_params} + "PR03", # Wrong parameters order. Actual: {actual_params}. Documented: {documented_params} + "PR04", # Parameter "{param_name}" has no type + "PR05", # Parameter "{param_name}" type should not finish with "." + "PR06", # Parameter "{param_name}" type should use "{right_type}" instead of "{wrong_type}" + "PR07", # Parameter "{param_name}" has no description + "PR08", # Parameter "{param_name}" description should start with a capital letter + "PR09", # Parameter "{param_name}" description should finish with "." + "PR10", # Parameter "{param_name}" requires a space before the colon separating the parameter name and type + "RT01", # No Returns section found + "RT02", # The first line of the Returns section should contain only the type, unless multiple values are being returned + "RT03", # Return value has no description + "RT04", # Return value description should start with a capital letter + "RT05", # Return value description should finish with "." + "YD01", # No Yields section found + "SA01", # See Also section not found + "SA02", # Missing period at end of description for See Also "{reference_name}" reference + "SA03", # Description should be capitalized for See Also "{reference_name}" reference + "SA04", # Missing description for See Also "{reference_name}" reference + "EX01", # No examples section found +] From 1fafa3af4b9aba388993c0c4b651013647a053ca Mon Sep 17 00:00:00 2001 From: Vecko <36369090+VeckoTheGecko@users.noreply.github.com> Date: Thu, 15 Jan 2026 11:22:55 +0100 Subject: [PATCH 03/10] Disable logging-format ruff ruleset We should properly consider this later when we add logging to other parts of the code - but from the outset it doesn't seem like that useful a rule. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 29e6cb191..ec5508606 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,7 +80,7 @@ select = [ "UP", # pyupgrade "LOG", # logging "ICN", # import conventions - "G", # logging-format + # "G", # logging-format "RUF", # ruff "ISC001", # single-line-implicit-string-concatenation "TID", # flake8-tidy-imports From f8da2ca1bc861d592a3b585275c4d5da316e8560 Mon Sep 17 00:00:00 2001 From: Vecko <36369090+VeckoTheGecko@users.noreply.github.com> Date: Thu, 15 Jan 2026 11:24:59 +0100 Subject: [PATCH 04/10] Add some logging messages --- tools/numpydoc-public-api.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/tools/numpydoc-public-api.py b/tools/numpydoc-public-api.py index 4ff773dc8..34d36f9e6 100644 --- a/tools/numpydoc-public-api.py +++ b/tools/numpydoc-public-api.py @@ -9,6 +9,7 @@ import functools import importlib +import logging import sys import tomllib import types @@ -16,13 +17,12 @@ from numpydoc.validate import validate +logger = logging.getLogger("numpydoc-public-api") + PROJECT_ROOT = (Path(__file__).parent / "..").resolve() PUBLIC_MODULES = ["parcels", "parcels.interpolators"] ROOT_PACKAGE = "parcels" -with open(PROJECT_ROOT / "tools/tool-data.toml", "rb") as f: - skip_errors = tomllib.load(f)["numpydoc_skip_errors"] - def is_built_in(type_or_instance: type | object): if isinstance(type_or_instance, type): @@ -57,7 +57,7 @@ def walk_module(module_str: str, public_api: list[str] | None = None) -> list[st public_api.append(f"{module_str}.{item_str}") walk_class(module_str, item, public_api) else: - print( + logger.info( 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." ) @@ -81,6 +81,8 @@ def walk_class(module_str: str, class_: type, public_api: list[str]) -> list[str def main(): + with open(PROJECT_ROOT / "tools/tool-data.toml", "rb") as f: + skip_errors = tomllib.load(f)["numpydoc_skip_errors"] public_api = [] for module in PUBLIC_MODULES: public_api += walk_module(module) @@ -88,9 +90,11 @@ def main(): public_api = filter(lambda x: x != ROOT_PACKAGE, public_api) # For some reason doesn't work on root package errors = 0 for item in public_api: + logger.info(f"Processing validating {item}") try: res = validate(item) - except AttributeError: + except AttributeError as e: + logger.debug(f"Encountered error. {e!r}") continue if res["type"] in ("module", "float", "int", "dict"): continue From a80a034d3fe064fe4a92313616eabe1346224130 Mon Sep 17 00:00:00 2001 From: Vecko <36369090+VeckoTheGecko@users.noreply.github.com> Date: Thu, 15 Jan 2026 11:34:20 +0100 Subject: [PATCH 05/10] Update logging with configurable verbosity --- tools/numpydoc-public-api.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tools/numpydoc-public-api.py b/tools/numpydoc-public-api.py index 34d36f9e6..86a7fc039 100644 --- a/tools/numpydoc-public-api.py +++ b/tools/numpydoc-public-api.py @@ -18,6 +18,9 @@ from numpydoc.validate import validate logger = logging.getLogger("numpydoc-public-api") +handler = logging.StreamHandler() +handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s")) +logger.addHandler(handler) PROJECT_ROOT = (Path(__file__).parent / "..").resolve() PUBLIC_MODULES = ["parcels", "parcels.interpolators"] @@ -81,6 +84,22 @@ def walk_class(module_str: str, class_: type, public_api: list[str]) -> list[str def main(): + import argparse + + parser = argparse.ArgumentParser(description="Validate numpydoc docstrings in the public API") + parser.add_argument("-v", "--verbose", action="count", default=0, help="Increase verbosity (can be repeated)") + args = parser.parse_args() + + # Set logging level based on verbosity: 0=WARNING, 1=INFO, 2+=DEBUG + if args.verbose == 0: + log_level = logging.WARNING + elif args.verbose == 1: + log_level = logging.INFO + else: + log_level = logging.DEBUG + + logger.setLevel(log_level) + with open(PROJECT_ROOT / "tools/tool-data.toml", "rb") as f: skip_errors = tomllib.load(f)["numpydoc_skip_errors"] public_api = [] From dd14434839e43d31be70a06e84ec429fe7f8d55a Mon Sep 17 00:00:00 2001 From: Vecko <36369090+VeckoTheGecko@users.noreply.github.com> Date: Thu, 15 Jan 2026 11:39:29 +0100 Subject: [PATCH 06/10] Update warnings --- tools/numpydoc-public-api.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tools/numpydoc-public-api.py b/tools/numpydoc-public-api.py index 86a7fc039..b7aef228d 100644 --- a/tools/numpydoc-public-api.py +++ b/tools/numpydoc-public-api.py @@ -106,14 +106,13 @@ def main(): for module in PUBLIC_MODULES: public_api += walk_module(module) - public_api = filter(lambda x: x != ROOT_PACKAGE, public_api) # For some reason doesn't work on root package errors = 0 for item in public_api: logger.info(f"Processing validating {item}") try: res = validate(item) - except AttributeError as e: - logger.debug(f"Encountered error. {e!r}") + except (AttributeError, StopIteration) as e: + logger.warning(f"Could not process {item!r}. Encountered error. {e!r}") continue if res["type"] in ("module", "float", "int", "dict"): continue From 8ef35fa289c21e21ab0c103f4e40f7b07ccaad3c Mon Sep 17 00:00:00 2001 From: Vecko <36369090+VeckoTheGecko@users.noreply.github.com> Date: Thu, 15 Jan 2026 11:56:01 +0100 Subject: [PATCH 07/10] Fix set union --- docs/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index ec0e3d570..83a0e5d0a 100755 --- a/docs/conf.py +++ b/docs/conf.py @@ -188,7 +188,7 @@ with open(PROJECT_ROOT / "tools/tool-data.toml", "rb") as f: numpydoc_skip_errors = tomllib.load(f)["numpydoc_skip_errors"] -numpydoc_validation_checks = {"all"} + set(numpydoc_skip_errors) +numpydoc_validation_checks = {"all"} | set(numpydoc_skip_errors) # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, From 62c2efb26a630d3809434759c3e08b5561d332f5 Mon Sep 17 00:00:00 2001 From: Vecko <36369090+VeckoTheGecko@users.noreply.github.com> Date: Thu, 15 Jan 2026 13:26:18 +0100 Subject: [PATCH 08/10] Categorise rules --- docs/conf.py | 1 - tools/tool-data.toml | 29 ++++++++++++++++++----------- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 83a0e5d0a..99d74d645 100755 --- a/docs/conf.py +++ b/docs/conf.py @@ -184,7 +184,6 @@ # ---------------- numpydoc_class_members_toctree = False # https://stackoverflow.com/a/73294408 -# full list of numpydoc error codes: https://numpydoc.readthedocs.io/en/latest/validation.html with open(PROJECT_ROOT / "tools/tool-data.toml", "rb") as f: numpydoc_skip_errors = tomllib.load(f)["numpydoc_skip_errors"] diff --git a/tools/tool-data.toml b/tools/tool-data.toml index 5dbf37ba4..b7ecb94bc 100644 --- a/tools/tool-data.toml +++ b/tools/tool-data.toml @@ -1,23 +1,35 @@ +# full list of numpydoc error codes: https://numpydoc.readthedocs.io/en/latest/validation.html numpydoc_skip_errors = [ - "GL01", # Docstring text (summary) should start in the line immediately after the opening quotes (not in the same line, or leaving a blank line in between) - "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) + "SA01", # Parcels doesn't require the "See also" section + "SA04", # + "ES01", # We don't require the extended summary for all docstrings + "EX01", # We don't require the "Examples" section for all docstrings + "SS06", # Not possible to make all summaries one line + + # To be fixed up "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 "GL05", # Tabs found at the start of line "{line_with_tabs}", please use whitespace only "GL06", # Found unknown section "{section}". Allowed sections are: {allowed_sections} "GL07", # Sections are in the wrong order. Correct order is: {correct_sections} "GL08", # The object does not have a docstring - "GL09", # Deprecation warning should precede extended summary - "GL10", # reST directives {directives} must be followed by two colons "SS01", # No summary found (a short summary in a single line should be present at the beginning of the docstring) "SS02", # Summary does not start with a capital letter "SS03", # Summary does not end with a period "SS04", # Summary contains heading whitespaces "SS05", # Summary must start with infinitive verb, not third person (e.g. use "Generate" instead of "Generates") - "SS06", # Summary should fit in a single line - "ES01", # No extended summary found "PR01", # Parameters {missing_params} not documented "PR02", # Unknown parameters {unknown_params} "PR03", # Wrong parameters order. Actual: {actual_params}. Documented: {documented_params} + "SA02", # Missing period at end of description for See Also "{reference_name}" reference + "SA03", # Description should be capitalized for See Also + + #? Might conflict with Ruff rules. Needs more testing... Enable ignore if they conflict + # "GL01", # Docstring text (summary) should start in the line immediately after the opening quotes (not in the same line, or leaving a blank line in between) + # "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) + + # TODO consider whether to continue ignoring the following + "GL09", # Deprecation warning should precede extended summary + "GL10", # reST directives {directives} must be followed by two colons "PR04", # Parameter "{param_name}" has no type "PR05", # Parameter "{param_name}" type should not finish with "." "PR06", # Parameter "{param_name}" type should use "{right_type}" instead of "{wrong_type}" @@ -31,9 +43,4 @@ numpydoc_skip_errors = [ "RT04", # Return value description should start with a capital letter "RT05", # Return value description should finish with "." "YD01", # No Yields section found - "SA01", # See Also section not found - "SA02", # Missing period at end of description for See Also "{reference_name}" reference - "SA03", # Description should be capitalized for See Also "{reference_name}" reference - "SA04", # Missing description for See Also "{reference_name}" reference - "EX01", # No examples section found ] From 231dcd82ca9ade5fc6d271d7c33d7e5a03b7c263 Mon Sep 17 00:00:00 2001 From: Vecko <36369090+VeckoTheGecko@users.noreply.github.com> Date: Thu, 15 Jan 2026 13:28:56 +0100 Subject: [PATCH 09/10] Exclude dunders from numpydoc validation --- docs/conf.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/conf.py b/docs/conf.py index 99d74d645..13e77547b 100755 --- a/docs/conf.py +++ b/docs/conf.py @@ -188,6 +188,17 @@ numpydoc_skip_errors = tomllib.load(f)["numpydoc_skip_errors"] numpydoc_validation_checks = {"all"} | set(numpydoc_skip_errors) +numpydoc_validation_exclude = { # regex to ignore during docstring check + r"\.__getitem__", + r"\.__contains__", + r"\.__hash__", + r"\.__mul__", + r"\.__sub__", + r"\.__add__", + r"\.__iter__", + r"\.__div__", + r"\.__neg__", +} # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, From 0fb7ec1381a6ef0b89f99d5bed35b4358e158ab8 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 15 Jan 2026 12:37:40 +0000 Subject: [PATCH 10/10] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tools/tool-data.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/tool-data.toml b/tools/tool-data.toml index b7ecb94bc..b0345d144 100644 --- a/tools/tool-data.toml +++ b/tools/tool-data.toml @@ -1,7 +1,7 @@ # full list of numpydoc error codes: https://numpydoc.readthedocs.io/en/latest/validation.html numpydoc_skip_errors = [ "SA01", # Parcels doesn't require the "See also" section - "SA04", # + "SA04", # "ES01", # We don't require the extended summary for all docstrings "EX01", # We don't require the "Examples" section for all docstrings "SS06", # Not possible to make all summaries one line @@ -21,7 +21,7 @@ numpydoc_skip_errors = [ "PR02", # Unknown parameters {unknown_params} "PR03", # Wrong parameters order. Actual: {actual_params}. Documented: {documented_params} "SA02", # Missing period at end of description for See Also "{reference_name}" reference - "SA03", # Description should be capitalized for See Also + "SA03", # Description should be capitalized for See Also #? Might conflict with Ruff rules. Needs more testing... Enable ignore if they conflict # "GL01", # Docstring text (summary) should start in the line immediately after the opening quotes (not in the same line, or leaving a blank line in between)