diff --git a/.circleci/config.yml b/.circleci/config.yml index 66a60c8d..98cae9bc 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -20,7 +20,7 @@ jobs: python3 -m venv venv source venv/bin/activate python -m pip install --upgrade pip wheel setuptools - python -m pip install --upgrade -r requirements/doc.txt + python -m pip install --upgrade --group doc python -m pip list - save_cache: key: pip-cache diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 9984dc57..d873f107 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -1 +1,2 @@ 4b38c01e680cf2032acb09819bc0985e5dfc5ca8 +be316333f436519e0e2ab1379cbdedde79c9ac68 diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 918c3a5c..66c89692 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,3 +6,7 @@ updates: interval: "monthly" labels: - "type: Maintenance" + groups: + actions: + patterns: + - "*" diff --git a/.github/workflows/label-check.yml b/.github/workflows/label-check.yml index 5c4aee62..d6e97f22 100644 --- a/.github/workflows/label-check.yml +++ b/.github/workflows/label-check.yml @@ -4,7 +4,7 @@ on: pull_request: types: - opened - - repoened + - reopened - labeled - unlabeled - synchronize diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 7e2f5eea..ebed3deb 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -5,7 +5,7 @@ jobs: name: Run CircleCI artifacts redirector steps: - name: GitHub Action step - uses: larsoner/circleci-artifacts-redirector-action@master + uses: scientific-python/circleci-artifacts-redirector-action@839631420e45a08af893032e5a5e8843bf47e8ff # v1.2.0 with: repo-token: ${{ secrets.GITHUB_TOKEN }} api-token: ${{ secrets.CIRCLECI_ARTIFACT_REDIRECTOR_TOKEN }} diff --git a/.github/workflows/milestone-merged-prs.yml b/.github/workflows/milestone-merged-prs.yml index 71ae037c..f455839d 100644 --- a/.github/workflows/milestone-merged-prs.yml +++ b/.github/workflows/milestone-merged-prs.yml @@ -12,7 +12,7 @@ jobs: name: attach to PR runs-on: ubuntu-latest steps: - - uses: scientific-python/attach-next-milestone-action@bc07be829f693829263e57d5e8489f4e57d3d420 + - uses: scientific-python/attach-next-milestone-action@c9cfab10ad0c67fed91b01103db26b7f16634639 with: token: ${{ secrets.MILESTONE_LABELER_TOKEN }} force: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..3d77cdf9 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,47 @@ +name: Build Wheel and Release +on: + pull_request: + branches: + - main + push: + tags: + - v* + +jobs: + sdist_wheel: + name: sdist and wheels + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + fetch-depth: 0 + - uses: actions/setup-python@39cd14951b08e74b54015e9e001cdefcf80e669f # v5.1.1 + with: + python-version: "3.12" + - name: Build wheels + run: | + git clean -fxd + pip install -U build twine wheel + python -m build --sdist --wheel + - run: twine check --strict dist/* + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: dist + path: dist + + pypi-publish: + needs: sdist_wheel + name: upload release to PyPI + if: github.repository_owner == 'numpy' && startsWith(github.ref, 'refs/tags/v') && github.actor == 'jarrodmillman' && always() + runs-on: ubuntu-latest + # Specifying a GitHub environment is optional, but strongly encouraged + environment: release + permissions: + # IMPORTANT: this permission is mandatory for trusted publishing + id-token: write + steps: + - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + with: + name: dist + path: dist + - uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # v1.12.4 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1af5e7a5..b188cba4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,17 +16,18 @@ jobs: strategy: matrix: os: [Ubuntu] - python-version: ["3.8", "3.9", "3.10", "3.11"] + python-version: ["3.10", "3.11", "3.12", "3.13"] sphinx-version: - [ - "sphinx==5.0", - "sphinx==5.3", - "sphinx==6.0", - "sphinx==6.2", - "sphinx>=7.0", - ] + ["sphinx==6.0", "sphinx==6.2", "sphinx==7.0", "sphinx>=7.3"] + include: + - os: Windows + python-version: "3.12" + sphinx-version: "sphinx" # version shouldn't really matter here + defaults: + run: + shell: bash -eo pipefail {0} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Python setup uses: actions/setup-python@v5 @@ -36,14 +37,13 @@ jobs: - name: Setup environment run: | python -m pip install --upgrade pip wheel setuptools - python -m pip install -r requirements/test.txt -r requirements/doc.txt python -m pip install codecov - python -m pip install ${{ matrix.sphinx-version }} + python -m pip install "${{ matrix.sphinx-version }}" python -m pip list - name: Install run: | - python -m pip install . + python -m pip install . --group test --group doc pip list - name: Run test suite @@ -56,29 +56,31 @@ jobs: - name: Make sure CLI works run: | - python -m numpydoc numpydoc.tests.test_main._capture_stdout - echo '! python -m numpydoc numpydoc.tests.test_main._invalid_docstring' | bash - python -m numpydoc --validate numpydoc.tests.test_main._capture_stdout - echo '! python -m numpydoc --validate numpydoc.tests.test_main._docstring_with_errors' | bash + numpydoc render numpydoc.tests.test_main._capture_stdout + echo '! numpydoc render numpydoc.tests.test_main._invalid_docstring' | bash + numpydoc validate numpydoc.tests.test_main._capture_stdout + echo '! numpydoc validate numpydoc.tests.test_main._docstring_with_errors' | bash - name: Setup for doc build run: | sudo apt-get update sudo apt install texlive texlive-latex-extra latexmk dvipng + if: runner.os == 'Linux' - name: Build documentation run: | make -C doc html SPHINXOPTS="-nT" make -C doc latexpdf SPHINXOPTS="-nT" + if: runner.os == 'Linux' prerelease: runs-on: ${{ matrix.os }}-latest strategy: matrix: os: [ubuntu] - python-version: ["3.9", "3.10"] + python-version: ["3.11", "3.12", "3.13"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Python setup uses: actions/setup-python@v5 @@ -88,13 +90,12 @@ jobs: - name: Setup environment run: | python -m pip install --upgrade pip wheel setuptools - python -m pip install --pre -r requirements/test.txt -r requirements/doc.txt python -m pip install codecov python -m pip list - name: Install run: | - python -m pip install . + python -m pip install . --group test --group doc pip list - name: Run test suite @@ -107,10 +108,10 @@ jobs: - name: Make sure CLI works run: | - python -m numpydoc numpydoc.tests.test_main._capture_stdout - echo '! python -m numpydoc numpydoc.tests.test_main._invalid_docstring' | bash - python -m numpydoc --validate numpydoc.tests.test_main._capture_stdout - echo '! python -m numpydoc --validate numpydoc.tests.test_main._docstring_with_errors' | bash + numpydoc render numpydoc.tests.test_main._capture_stdout + echo '! numpydoc render numpydoc.tests.test_main._invalid_docstring' | bash + numpydoc validate numpydoc.tests.test_main._capture_stdout + echo '! numpydoc validate numpydoc.tests.test_main._docstring_with_errors' | bash - name: Setup for doc build run: | diff --git a/.gitignore b/.gitignore index 26f7400b..60696edd 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ doc/_build numpydoc/tests/tinybuild/_build numpydoc/tests/tinybuild/generated MANIFEST +node_modules diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 25b53f36..edb0146e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,56 +3,36 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: c4a0b883114b00d8d76b479c820ce7950211c99b # frozen: v4.5.0 + rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # frozen: v6.0.0 hooks: - - id: trailing-whitespace - - id: end-of-file-fixer - - id: debug-statements + - id: check-added-large-files - id: check-ast - - id: mixed-line-ending - - id: check-yaml - args: [--allow-multiple-documents] + - id: check-case-conflict - id: check-json - id: check-toml - - id: check-added-large-files - - - repo: https://github.com/psf/black - rev: ec91a2be3c44d88e1a3960a4937ad6ed3b63464e # frozen: 23.12.1 - hooks: - - id: black + - id: check-yaml + args: [--allow-multiple-documents] + - id: debug-statements + - id: end-of-file-fixer + - id: mixed-line-ending + - id: trailing-whitespace - repo: https://github.com/pre-commit/mirrors-prettier - rev: f12edd9c7be1c20cfa42420fd0e6df71e42b51ea # frozen: v4.0.0-alpha.8 + rev: f12edd9c7be1c20cfa42420fd0e6df71e42b51ea # frozen: v4.0.0-alpha.8 hooks: - id: prettier - entry: env PRETTIER_LEGACY_CLI=1 prettier # temporary fix for https://github.com/prettier/prettier/issues/15742 - files: \.(html|md|yml|yaml) + types_or: [yaml, toml, markdown, css, scss, javascript, json] args: [--prose-wrap=preserve] - - repo: https://github.com/adamchainz/blacken-docs - rev: 960ead214cd1184149d366c6d27ca6c369ce46b6 # frozen: 1.16.0 - hooks: - - id: blacken-docs - - - repo: https://github.com/asottile/pyupgrade - rev: 1bbebc88c6925a4e56fd5446b830b12c38c1c24a # frozen: v3.15.0 - hooks: - - id: pyupgrade - args: [--py38-plus] - - - repo: local + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: "9c89adb347f6b973f4905a4be0051eb2ecf85dea" # frozen: v0.13.3 hooks: - - id: generate_requirements.py - name: generate_requirements.py - language: system - entry: python tools/generate_requirements.py - files: "pyproject.toml|requirements/.*\\.txt|tools/generate_requirements.py" + - id: ruff + args: ["--fix", "--show-fixes", "--exit-non-zero-on-fix"] + - id: ruff-format 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 + autoupdate_schedule: quarterly diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index b88e9ed2..69493db7 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -1,7 +1,7 @@ - id: numpydoc-validation name: numpydoc-validation description: This hook validates that docstrings in committed files adhere to numpydoc standards. - entry: validate-docstrings - require_serial: true + entry: numpydoc lint + require_serial: false language: python types: [python] diff --git a/.readthedocs.yaml b/.readthedocs.yaml index a124b7ca..ebc00e82 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -6,29 +6,22 @@ version: 2 # Set the OS, Python version and other tools you might need build: - os: ubuntu-22.04 + os: ubuntu-24.04 tools: - python: "3.11" + python: "3.13" # You can also specify other tool versions: # nodejs: "19" # rust: "1.64" # golang: "1.19" + jobs: + install: + - python -m pip install --upgrade pip wheel setuptools + - python -m pip install . --group doc # Build documentation in the "doc/" directory with Sphinx sphinx: configuration: doc/conf.py - # Optionally build your docs in additional formats such as PDF and ePub # formats: # - pdf # - epub - -# Optional but recommended, declare the Python requirements required -# to build your documentation -# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html -python: - install: - - method: pip - path: . - extra_requirements: - - doc diff --git a/MANIFEST.in b/MANIFEST.in index d1508eee..0a6df3ac 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -2,7 +2,6 @@ include MANIFEST.in include *.txt include *.rst recursive-include doc * -recursive-include requirements * recursive-include numpydoc * # Exclude what we don't want to include diff --git a/README.rst b/README.rst index a5af9a00..fdd35942 100644 --- a/README.rst +++ b/README.rst @@ -4,7 +4,6 @@ numpydoc -- Numpy's Sphinx extensions .. image:: https://readthedocs.org/projects/numpydoc/badge/?version=latest :alt: Documentation Status - :scale: 100% :target: https://numpydoc.readthedocs.io/en/latest/ .. image:: https://codecov.io/gh/numpy/numpydoc/branch/main/graph/badge.svg @@ -18,7 +17,7 @@ docstrings formatted according to the NumPy documentation format. The extension also adds the code description directives ``np:function``, ``np-c:function``, etc. -numpydoc requires Python 3.8+ and sphinx 5+. +numpydoc requires Python 3.10+ and sphinx 6+. For usage information, please refer to the `documentation `_. diff --git a/RELEASE.rst b/RELEASE.rst index f4390f02..21781bc9 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -6,29 +6,41 @@ Introduction Example ``__version__`` -- 1.8.dev0 # development version of 1.8 (release candidate 1) -- 1.8rc1 # 1.8 release candidate 1 -- 1.8rc2.dev0 # development version of 1.8 (release candidate 2) +- 1.8rc0.dev0 # development version of 1.8 (first release candidate) +- 1.8rc0 # 1.8 release candidate 1 +- 1.8rc1.dev0 # development version of 1.8 (second release candidate) - 1.8 # 1.8 release -- 1.9.dev0 # development version of 1.9 (release candidate 1) +- 1.9rc0.dev0 # development version of 1.9 (first release candidate) Test release candidates on numpy, scipy, matplotlib, scikit-image, and networkx. Process ------- -- Review and update ``doc/release_notes.rst``. +- Set release variables:: + + export VERSION= + export PREVIOUS= + export ORG="numpy" + export REPO="numpydoc" + export LOG="doc/release/notes.rst" + +- Autogenerate release notes:: + + changelist ${ORG}/${REPO} v${PREVIOUS} main --version ${VERSION} --config pyproject.toml --format rst --out ${VERSION}.rst + changelist ${ORG}/${REPO} v${PREVIOUS} main --version ${VERSION} --config pyproject.toml --out ${VERSION}.md + cat ${VERSION}.rst | cat - ${LOG} > temp && mv temp ${LOG} && rm ${VERSION}.rst - Update ``__version__`` in ``numpydoc/_version.py``. - Commit changes:: - git add numpydoc/_version.py doc/release_notes.rst - git commit -m 'Designate release' + git add numpydoc/_version.py ${LOG} + git commit -m "Designate ${VERSION} release" - Add the version number (e.g., `v1.2.0`) as a tag in git:: - git tag -s [-u ] v -m 'signed tag' + git tag -s v${VERSION} -m "signed ${VERSION} tag" If you do not have a gpg key, use -u instead; it is important for Debian packaging that the tags are annotated @@ -39,16 +51,18 @@ Process where ``origin`` is the name of the ``github.com:numpy/numpydoc`` repository -- Review the github release page:: +- Create release from tag:: + + - go to https://github.com/numpy/numpydoc/releases/new?tag=v${VERSION} + - add v${VERSION} for the `Release title` + - paste contents (or upload) of ${VERSION}.md in the `Describe this release section` + - if pre-release check the box labelled `Set as a pre-release` - https://github.com/numpy/numpydoc/releases -- Publish on PyPi:: +- Update https://github.com/numpy/numpydoc/milestones:: - git clean -fxd - pip install --upgrade build wheel twine - python -m build --sdist --wheel - twine upload -s dist/* + - close old milestone + - ensure new milestone exists (perhaps setting due date) - Update ``__version__`` in ``numpydoc/_version.py``. diff --git a/doc/conf.py b/doc/conf.py index 315bac73..ca41a854 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -4,17 +4,17 @@ # list see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html -from datetime import date -import numpydoc - # -- Path setup -------------------------------------------------------------- - # 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 # documentation root, use os.path.abspath to make it absolute, like shown here. - import os import sys +from datetime import date + +from intersphinx_registry import get_intersphinx_mapping + +import numpydoc # for example.py sys.path.insert(0, os.path.abspath(".")) @@ -82,7 +82,7 @@ html_theme = "pydata_sphinx_theme" html_theme_options = { "show_prev_next": False, - "navbar_end": ["theme-switcher", "search-field.html", "navbar-icon-links.html"], + "navbar_end": ["theme-switcher", "navbar-icon-links.html"], "icon_links": [ { "name": "GitHub", @@ -138,9 +138,6 @@ # -- Intersphinx setup ---------------------------------------------------- -# Example configuration for intersphinx: refer to the Python standard library. -intersphinx_mapping = { - "python": ("https://docs.python.org/3/", None), - "numpy": ("https://numpy.org/devdocs/", None), - "sklearn": ("https://scikit-learn.org/stable/", None), -} +# Example configuration for intersphinx: refer to several Python libraries. + +intersphinx_mapping = get_intersphinx_mapping(packages=["python", "numpy", "sklearn"]) diff --git a/doc/example.py b/doc/example.py index 9d3eccaa..c8d4a85f 100644 --- a/doc/example.py +++ b/doc/example.py @@ -127,4 +127,3 @@ def foo(var1, var2, *args, long_var_name="hi", only_seldom_used_keyword=0, **kwa # separate following codes (according to PEP257). # But for function, method and module, there should be no blank lines # after closing the docstring. - pass diff --git a/doc/format.rst b/doc/format.rst index 2f9753c5..3b3693a4 100644 --- a/doc/format.rst +++ b/doc/format.rst @@ -175,8 +175,9 @@ respective types. y Description of parameter `y` (with type not specified). -Enclose variables in single backticks. The colon must be preceded -by a space, or omitted if the type is absent. +The colon must be preceded by a space, or omitted if the type is absent. +When referring to a parameter anywhere within the docstring, enclose its +name in single backticks. For the parameter types, be as precise as possible. Below are a few examples of parameters and their types. @@ -224,11 +225,11 @@ description, they can be combined:: Input arrays, description of `x1`, `x2`. When documenting variable length positional, or keyword arguments, leave the -leading star(s) in front of the name:: +leading star(s) in front of the name and do not specify a type:: - *args : tuple + *args Additional arguments should be passed as keyword arguments - **kwargs : dict, optional + **kwargs Extra arguments to `metric`: refer to each metric documentation for a list of all possible arguments. @@ -549,12 +550,16 @@ not explicitly imported, `.. plot::` can be used directly if Documenting classes ------------------- +.. _classdoc: + Class docstring ``````````````` Use the same sections as outlined above (all except :ref:`Returns ` are applicable). The constructor (``__init__``) should also be documented here, the :ref:`Parameters ` section of the docstring details the -constructor's parameters. +constructor's parameters. While repetition is unnecessary, a docstring for +the class constructor (``__init__``) can, optionally, be added to provide +detailed initialization documentation. An **Attributes** section, located below the :ref:`Parameters ` section, may be used to describe non-method attributes of the class:: @@ -562,10 +567,12 @@ section, may be used to describe non-method attributes of the class:: Attributes ---------- x : float - The X coordinate. + Description of attribute `x`. y : float - The Y coordinate. + Description of attribute `y`. +When referring to an attribute anywhere within the docstring, enclose its +name in single backticks. Attributes that are properties and have their own docstrings can be simply listed by name:: @@ -606,6 +613,8 @@ becomes useful to have an additional **Methods** section: """ +When referring to a method anywhere within the docstring, enclose its +name in single backticks. If it is necessary to explain a private method (use with care!), it can be referred to in the :ref:`Extended Summary ` or the :ref:`Notes ` section. @@ -690,11 +699,11 @@ belong in docstrings. Other points to keep in mind ---------------------------- * Equations : as discussed in the :ref:`Notes ` section above, LaTeX - formatting should be kept to a minimum. Often it's possible to show equations as - Python code or pseudo-code instead, which is much more readable in a - terminal. For inline display use double backticks (like ``y = np.sin(x)``). - For display with blank lines above and below, use a double colon and indent - the code, like:: + formatting should be kept to a minimum. Often it's possible to show + equations as Python code or pseudo-code instead, which is much more readable + in a terminal. For inline display of code, use double backticks + like ````y = np.sin(x)````. For display with blank lines above and below, + use a double colon and indent the code, like:: end of previous sentence:: @@ -717,9 +726,13 @@ Other points to keep in mind (i.e. scalar types, sequence types), those arguments can be documented with type `array_like`. -* Links : If you need to include hyperlinks in your docstring, note that - some docstring sections are not parsed as standard reST, and in these - sections, numpydoc may become confused by hyperlink targets such as:: +* Links : Depending on project settings, hyperlinks to documentation of + modules, classes, functions, methods, and attributes should automatically + be created if a recognized name is included within single backticks (e.g. + ```numpy``` renders as :any:`numpy`). If you need to include other + hyperlinks, note that some docstring sections are not parsed as standard + reST, and in these sections, numpydoc may become confused by hyperlink + targets such as:: .. _Example: http://www.example.com @@ -729,22 +742,36 @@ Other points to keep in mind `Example `_ - Common reST concepts -------------------- For paragraphs, indentation is significant and indicates indentation in the output. New paragraphs are marked with a blank line. -Use ``*italics*``, ``**bold**`` and ````monospace```` if needed in any -explanations -(but not for variable names and doctest code or multi-line code). -Variable, module, function, and class names should be written between -single back-ticks (```numpy```). +Use ``*italics*``, ``**bold**`` if needed in any explanations. + +Use of backticks in reST is a common point of confusion because it is different +from markdown. In most flavors of markdown, single backticks are used for +monospaced font; in reST, *double* backticks are for ``monospaced font``, +whereas the behavior of single backticks is defined by the default role. This +leads to the following style recommendations: + +- Module, class, function, method, and attribute names should render as + hyperlinks in monospaced font (e.g. :any:`numpy`); depending on project + settings, this may be accomplished simply be enclosing them in single + backticks. If the hyperlink does not render as intended, explicitly + include the appropriate role and/or namespace. +- This guide continues to recommended that parameter names be enclosed within + single backticks. Currently, this may cause parameter names to render + improperly and cause warnings, but numpydoc will soon release a feature + that causes them to render as monospaced hyperlinks to the parameter + documentation. +- All other text that is intended to render in ``monospaced`` font should be + enclosed within ````double backticks````. A more extensive example of reST markup can be found in `this example -document `_; +document `_; the `quick reference -`_ is +`_ is useful while editing. Line spacing and indentation are significant and should be carefully diff --git a/doc/index.rst b/doc/index.rst index 389e3cb1..240bb5b5 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -23,5 +23,5 @@ Sphinx, and adds the code description directives ``np:function``, install format validation - release_notes + release/index example diff --git a/doc/install.rst b/doc/install.rst index 480bd59a..5d61010f 100644 --- a/doc/install.rst +++ b/doc/install.rst @@ -5,10 +5,11 @@ Getting started Installation ============ -This extension requires Python 3.8+, sphinx 5+ and is available from: +This extension requires Python 3.10+, sphinx 6+ and is available from: * `numpydoc on PyPI `_ * `numpydoc on GitHub `_ +* `numpydoc on conda-forge `_ `'numpydoc'` should be added to the ``extensions`` option in your Sphinx ``conf.py``. ``'sphinx.ext.autosummary'`` will automatically be loaded @@ -138,6 +139,18 @@ 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_exclude_files : set + A container of strings using :py:mod:`re` syntax specifying path patterns to + ignore for docstring validation, relative to the package root. + For example, to skip docstring validation for all objects in + ``tests\``:: + + numpydoc_validation_exclude_files = {"^tests/.*$"} + + The default is an empty set meaning no paths are excluded from docstring + 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 ` to a container of strings using :py:mod:`re` syntax specifying patterns to diff --git a/doc/release/index.rst b/doc/release/index.rst new file mode 100644 index 00000000..de1bbfee --- /dev/null +++ b/doc/release/index.rst @@ -0,0 +1,13 @@ +Release notes +============= + +.. toctree:: + :maxdepth: 2 + + notes + old + +.. note:: + + For release notes (sparsely) kept prior to 1.0.0, look at the `releases page + on GitHub `__. diff --git a/doc/release/notes.rst b/doc/release/notes.rst new file mode 100644 index 00000000..88b052ba --- /dev/null +++ b/doc/release/notes.rst @@ -0,0 +1,220 @@ +1.9.0 +===== + +We're happy to announce the release of numpydoc 1.9.0! + +Enhancements +------------ + +- ignore some errors at module level (`#593 `_). +- Rework hook output to remove the table (`#611 `_). +- Switch to storing AST nodes on the stack for more accurate method signature check and easy access to parent nodes (`#623 `_). + +Bug Fixes +--------- + +- MAINT: Changed class constructor __init__ GL08 reporting (`#592 `_). +- BUG: Correct functionality of numpydoc SS05 (`#613 `_). +- Specity the types of ``numpydoc_xref_ignore`` option (`#631 `_). + +Documentation +------------- + +- DOC: Do not use types for ``*args``, ``**kwargs`` (`#585 `_). +- mention conda-forge in installation docs (`#595 `_). +- Fix typo in validation.rst (`#605 `_). +- Fix broken link in ``format.rst`` (`#628 `_). + +Maintenance +----------- + +- CI: use hashes for actions' versions in publishing job (`#579 `_). +- Bump the actions group with 2 updates (`#581 `_). +- Bump pypa/gh-action-pypi-publish from 1.10.0 to 1.10.2 in the actions group (`#582 `_). +- [pre-commit.ci] pre-commit autoupdate (`#583 `_). +- MAINT: Add _exception_on_warning to MockApp (`#586 `_). +- Bump the actions group across 1 directory with 2 updates (`#590 `_). +- don't pass maxsplit as positional arg (`#596 `_). +- [pre-commit.ci] pre-commit autoupdate (`#598 `_). +- Add Python 3.13 support (`#599 `_). +- Update readthedocs config (`#600 `_). +- Bump the actions group with 2 updates (`#603 `_). +- Bump the actions group with 3 updates (`#609 `_). +- [pre-commit.ci] pre-commit autoupdate (`#614 `_). +- Bump actions/download-artifact from 4.2.1 to 4.3.0 in the actions group (`#620 `_). +- Bump scientific-python/circleci-artifacts-redirector-action from 1.0.0 to 1.1.0 in the actions group (`#627 `_). +- Switch to dependency groups (`#626 `_). +- Fix pip setup command in github workflow (`#629 `_). + +Contributors +------------ + +13 authors added to this release (alphabetically): + +- Brigitta Sipőcz (`@bsipocz `_) +- Daniel McCloy (`@drammock `_) +- Eric Larson (`@larsoner `_) +- Gilles Peiffer (`@Peiffap `_) +- Jarrod Millman (`@jarrodmillman `_) +- Lucas Colley (`@lucascolley `_) +- Matt Gebert (`@mattgebert `_) +- Maxine Hartnett (`@maxinelasp `_) +- Ross Barnowski (`@rossbar `_) +- Stefan van der Walt (`@stefanv `_) +- Stefanie Molin (`@stefmolin `_) +- Tim Hoffmann (`@timhoffm `_) +- Yuki Kobayashi (`@koyuki7w `_) + +7 reviewers added to this release (alphabetically): + +- Charles Harris (`@charris `_) +- Eric Larson (`@larsoner `_) +- Jarrod Millman (`@jarrodmillman `_) +- Lucas Colley (`@lucascolley `_) +- Matt Gebert (`@mattgebert `_) +- Ross Barnowski (`@rossbar `_) +- Stefan van der Walt (`@stefanv `_) + +_These lists are automatically generated, and may not be complete or may contain duplicates._ + +1.8.0 +===== + +We're happy to announce the release of numpydoc 1.8.0! + +Enhancements +------------ + +- Unify CLIs (`#537 `_). +- Move "Attributes" and "Methods" below "Parameters" (`#571 `_). + +Bug Fixes +--------- + +- FIX: coroutines can have a return statement (`#542 `_). +- Unwrap decorated objects for YD01 validation check (`#541 `_). +- Fix bug with validation encoding (`#550 `_). + +Documentation +------------- + +- Classify development status as Production/Stable (`#548 `_). +- Add note about TOML regex; fix typo (`#552 `_). +- DOC: Clarify recommendations regarding use of backticks (`#525 `_). + +Maintenance +----------- + +- Fix typo in label-check.yml (`#538 `_). +- [pre-commit.ci] pre-commit autoupdate (`#539 `_). +- DEV: Rm xfails from pytest summary (`#540 `_). +- Drop Python 3.8 support (`#545 `_). +- Clean up old sphinx cruft (`#549 `_). +- Test on sphinx 7.3 (`#547 `_). +- Require GHA update grouping (`#553 `_). +- Update pre-commit config (`#554 `_). +- Use ruff for linting and formatting (`#555 `_). +- Use intersphinx registry to avoid out of date links (`#563 `_). +- Do not rely on requirements.txt in ci, use .[test,doc] (`#566 `_). +- CI: update action that got moved org (`#567 `_). +- Fix navbar for documentation pages (`#569 `_). +- [pre-commit.ci] pre-commit autoupdate (`#570 `_). +- docscrape: fixes from SciPy (`#576 `_). +- MAINT: Remove scale to work around PyPI bug (`#578 `_). + +Contributors +------------ + +10 authors added to this release (alphabetically): + +- Brigitta Sipőcz (`@bsipocz `_) +- Eric Larson (`@larsoner `_) +- Jarrod Millman (`@jarrodmillman `_) +- Lucas Colley (`@lucascolley `_) +- M Bussonnier (`@Carreau `_) +- Matt Haberland (`@mdhaber `_) +- Melissa Weber Mendonça (`@melissawm `_) +- Ross Barnowski (`@rossbar `_) +- Stefanie Molin (`@stefmolin `_) +- Thomas A Caswell (`@tacaswell `_) + +7 reviewers added to this release (alphabetically): + +- Eric Larson (`@larsoner `_) +- Jarrod Millman (`@jarrodmillman `_) +- M Bussonnier (`@Carreau `_) +- Matt Haberland (`@mdhaber `_) +- Ross Barnowski (`@rossbar `_) +- Stefan van der Walt (`@stefanv `_) +- Stefanie Molin (`@stefmolin `_) + +_These lists are automatically generated, and may not be complete or may contain duplicates._ + +1.7.0 +===== + +We're happy to announce the release of numpydoc 1.7.0! + +Enhancements +------------ + +- PERF: wrap inspect.getsourcelines with cache (`#532 `_). + +Bug Fixes +--------- + +- during tokenize, use UTF8 encoding on all platforms (`#510 `_). +- fix 'Alias for field number X' problem with NamedTuples (`#527 `_). + +Documentation +------------- + +- DOC: Fix typos found by codespell (`#514 `_). +- DOC: Update link to mailing list (`#518 `_). +- Add Python 3.12 to classifiers (`#529 `_). +- Update release process (`#534 `_). +- Update release process (`#535 `_). + +Maintenance +----------- + +- [pre-commit.ci] pre-commit autoupdate (`#508 `_). +- [pre-commit.ci] pre-commit autoupdate (`#513 `_). +- MAINT: apply refurb suggestion (`#515 `_). +- [pre-commit.ci] pre-commit autoupdate (`#516 `_). +- Bump actions/setup-python from 4 to 5 (`#520 `_). +- [pre-commit.ci] pre-commit autoupdate (`#521 `_). +- Filter ``DeprecationWarning`` in failing test for python 3.12 (`#523 `_). +- MAINT: Replace NameConstant with Constant (`#524 `_). +- [pre-commit.ci] pre-commit autoupdate (`#526 `_). +- Update precommit repos (`#531 `_). +- Require sphinx 6 (`#530 `_). +- Use trusted publisher (`#533 `_). + +Contributors +------------ + +8 authors added to this release (alphabetically): + +- Chiara Marmo (`@cmarmo `_) +- Daniel McCloy (`@drammock `_) +- Dimitri Papadopoulos Orfanos (`@DimitriPapadopoulos `_) +- Eric Larson (`@larsoner `_) +- Jarrod Millman (`@jarrodmillman `_) +- Niko Föhr (`@fohrloop `_) +- Philipp Hoffmann (`@dontgoto `_) +- Ross Barnowski (`@rossbar `_) + +9 reviewers added to this release (alphabetically): + +- Antoine Pitrou (`@pitrou `_) +- Charles Harris (`@charris `_) +- Daniel McCloy (`@drammock `_) +- Eric Larson (`@larsoner `_) +- GitHub Web Flow (`@web-flow `_) +- Jarrod Millman (`@jarrodmillman `_) +- Niko Föhr (`@fohrloop `_) +- Ross Barnowski (`@rossbar `_) +- Stefan van der Walt (`@stefanv `_) + +_These lists are automatically generated, and may not be complete or may contain duplicates._ diff --git a/doc/release_notes.rst b/doc/release/old.rst similarity index 97% rename from doc/release_notes.rst rename to doc/release/old.rst index 2f64f4cb..a75c129d 100644 --- a/doc/release_notes.rst +++ b/doc/release/old.rst @@ -1,22 +1,3 @@ -Release notes -============= - -.. roughly following https://sphinx-gallery.github.io/dev/maintainers.html, -.. 1.0.0 notes were generated by: -.. 1. tagging PRs as enhancement/bug/removed -.. 2. $ github_changelog_generator -u numpy -p numpydoc --since-tag=v0.9.2 -.. 3. $ pandoc CHANGELOG.md --wrap=none -o release_notes.rst -.. 4. adding a manual addition (CSS note), tweaking heading levels, adding TOC - -.. contents:: Page contents - :local: - :depth: 2 - -.. note:: - - For release notes (sparsely) kept prior to 1.0.0, look at the `releases page - on GitHub `__. - 1.6.0 ----- diff --git a/doc/validation.rst b/doc/validation.rst index 4ce89017..a0729b6b 100644 --- a/doc/validation.rst +++ b/doc/validation.rst @@ -22,7 +22,7 @@ command line options for this hook: .. code-block:: bash - $ python -m numpydoc.hooks.validate_docstrings --help + $ numpydoc lint --help Using a config file provides additional customization. Both ``pyproject.toml`` and ``setup.cfg`` are supported; however, if the project contains both @@ -33,9 +33,13 @@ the pre-commit hook as follows: ``ES01`` (using the same logic as the :ref:`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 + expressions ``\.undocumented_method$`` or ``\.__repr__$``. This maps to ``numpydoc_validation_exclude`` from the :ref:`Sphinx build configuration `. +* ``exclude_files``: Exclude file paths (relative to the package root) matching + the regular expressions ``^tests/.*$`` or ``^module/gui.*$``. This maps to + ``numpydoc_validation_exclude_files`` from the + :ref:`Sphinx build configuration `. * ``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_`` with a collection of regular expression(s) @@ -52,10 +56,15 @@ the pre-commit hook as follows: "SA01", "ES01", ] + # remember to use single quotes for regex in TOML exclude = [ # don't report on objects that match any of these regex '\.undocumented_method$', '\.__repr__$', ] + exclude_files = [ # don't process filepaths that match these regex + '^tests/.*', + '^module/gui.*', + ] override_SS05 = [ # override SS05 to allow docstrings starting with these words '^Process ', '^Assess ', @@ -102,12 +111,12 @@ can be called. For example, to do it for ``numpy.ndarray``, use: .. code-block:: bash - $ python -m numpydoc numpy.ndarray + $ numpydoc validate numpy.ndarray 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 +``validate`` subcommand. This will report the errors detected, such as incorrect capitalization, wrong order of the sections, and many other issues. Note that this will honor :ref:`inline ignore comments `, but will not look for any configuration like the :ref:`pre-commit hook ` @@ -182,6 +191,9 @@ inline comments: def __init__(self): # numpydoc ignore=GL08 pass +Note that a properly formatted :ref:`class ` docstring +silences ``GL08`` for an ``__init__`` constructor without a docstring. + This is supported by the :ref:`CLI `, :ref:`pre-commit hook `, and :ref:`Sphinx extension `. diff --git a/numpydoc/__init__.py b/numpydoc/__init__.py index 97f53053..5458b67f 100644 --- a/numpydoc/__init__.py +++ b/numpydoc/__init__.py @@ -2,6 +2,7 @@ This package provides the numpydoc Sphinx extension for handling docstrings formatted according to the NumPy documentation format. """ + from ._version import __version__ diff --git a/numpydoc/__main__.py b/numpydoc/__main__.py index 4a50da9b..8cd0943e 100644 --- a/numpydoc/__main__.py +++ b/numpydoc/__main__.py @@ -1,55 +1,7 @@ """ -Implementing `python -m numpydoc` functionality. -""" -import sys -import argparse -import ast - -from .docscrape_sphinx import get_doc_object -from .validate import validate, Validator - - -def render_object(import_path, config=None): - """Test numpydoc docstring generation for a given object""" - # TODO: Move Validator._load_obj to a better place than validate - print(get_doc_object(Validator._load_obj(import_path), config=dict(config or []))) - return 0 - - -def validate_object(import_path): - exit_status = 0 - results = validate(import_path) - for err_code, err_desc in results["errors"]: - exit_status += 1 - print(":".join([import_path, err_code, err_desc])) - return exit_status - - -if __name__ == "__main__": - ap = argparse.ArgumentParser(description=__doc__) - ap.add_argument("import_path", help="e.g. numpy.ndarray") - - def _parse_config(s): - key, _, value = s.partition("=") - value = ast.literal_eval(value) - return key, value - - ap.add_argument( - "-c", - "--config", - type=_parse_config, - action="append", - help="key=val where val will be parsed by literal_eval, " - "e.g. -c use_plots=True. Multiple -c can be used.", - ) - ap.add_argument( - "--validate", action="store_true", help="validate the object and report errors" - ) - args = ap.parse_args() +Implementing `python -m numpydoc` functionality +""" # '.' omitted at end of docstring for testing purposes! - if args.validate: - exit_code = validate_object(args.import_path) - else: - exit_code = render_object(args.import_path, args.config) +from .cli import main - sys.exit(exit_code) +raise SystemExit(main()) diff --git a/numpydoc/_version.py b/numpydoc/_version.py index 8b264c14..21565c81 100644 --- a/numpydoc/_version.py +++ b/numpydoc/_version.py @@ -1 +1 @@ -__version__ = "1.7.0rc0.dev0" +__version__ = "1.10.0rc0.dev0" diff --git a/numpydoc/cli.py b/numpydoc/cli.py new file mode 100644 index 00000000..30daa3f5 --- /dev/null +++ b/numpydoc/cli.py @@ -0,0 +1,130 @@ +"""The CLI for numpydoc.""" + +import argparse +import ast +from collections.abc import Sequence +from pathlib import Path +from typing import List + +from .docscrape_sphinx import get_doc_object +from .hooks import utils, validate_docstrings +from .validate import ERROR_MSGS, Validator, validate + + +def render_object(import_path: str, config: List[str] | None = None) -> int: + """Test numpydoc docstring generation for a given object.""" + # TODO: Move Validator._load_obj to a better place than validate + print(get_doc_object(Validator._load_obj(import_path), config=dict(config or []))) + return 0 + + +def validate_object(import_path: str) -> int: + """Run numpydoc docstring validation for a given object.""" + exit_status = 0 + results = validate(import_path) + for err_code, err_desc in results["errors"]: + exit_status += 1 + print(":".join([import_path, err_code, err_desc])) + return exit_status + + +def get_parser() -> argparse.ArgumentParser: + """ + Build an argument parser. + + Returns + ------- + argparse.ArgumentParser + The argument parser. + """ + ap = argparse.ArgumentParser(prog="numpydoc", description=__doc__) + subparsers = ap.add_subparsers(title="subcommands") + + def _parse_config(s): + key, _, value = s.partition("=") + value = ast.literal_eval(value) + return key, value + + render = subparsers.add_parser( + "render", + description="Generate an expanded RST-version of the docstring.", + help="generate the RST docstring with numpydoc", + ) + render.add_argument("import_path", help="e.g. numpy.ndarray") + render.add_argument( + "-c", + "--config", + type=_parse_config, + action="append", + help="key=val where val will be parsed by literal_eval, " + "e.g. -c use_plots=True. Multiple -c can be used.", + ) + render.set_defaults(func=render_object) + + validate = subparsers.add_parser( + "validate", + description="Validate an object's docstring against the numpydoc standard.", + help="validate the object's docstring and report errors", + ) + validate.add_argument("import_path", help="e.g. numpy.ndarray") + validate.set_defaults(func=validate_object) + + project_root_from_cwd, config_file = utils.find_project_root(["."]) + config_options = validate_docstrings.parse_config(project_root_from_cwd) + ignored_checks = [ + f"- {check}: {ERROR_MSGS[check]}" + for check in set(ERROR_MSGS.keys()) - config_options["checks"] + ] + ignored_checks_text = "\n " + "\n ".join(ignored_checks) + "\n" + + lint_parser = subparsers.add_parser( + "lint", + description="Run numpydoc validation on files with option to ignore individual checks.", + help="validate all docstrings in file(s) using the abstract syntax tree", + formatter_class=argparse.RawTextHelpFormatter, + ) + lint_parser.add_argument( + "files", type=str, nargs="+", help="File(s) to run numpydoc validation on." + ) + lint_parser.add_argument( + "--config", + type=str, + help=( + "Path to a directory containing a pyproject.toml or setup.cfg file.\n" + "The hook will look for it in the root project directory.\n" + "If both are present, only pyproject.toml will be used.\n" + "Options must be placed under\n" + " - [tool:numpydoc_validation] for setup.cfg files and\n" + " - [tool.numpydoc_validation] for pyproject.toml files." + ), + ) + lint_parser.add_argument( + "--ignore", + type=str, + nargs="*", + help=( + f"""Check codes to ignore.{ + " Currently ignoring the following from " + f"{Path(project_root_from_cwd) / config_file}: {ignored_checks_text}" + "Values provided here will be in addition to the above, unless an alternate config is provided." + if ignored_checks + else "" + }""" + ), + ) + lint_parser.set_defaults(func=validate_docstrings.run_hook) + + return ap + + +def main(argv: Sequence[str] | None = None) -> int: + """CLI for numpydoc.""" + ap = get_parser() + + args = vars(ap.parse_args(argv)) + + try: + func = args.pop("func") + return func(**args) + except KeyError: + ap.exit(status=2, message=ap.format_help()) diff --git a/numpydoc/docscrape.py b/numpydoc/docscrape.py index 315be6c3..26a08259 100644 --- a/numpydoc/docscrape.py +++ b/numpydoc/docscrape.py @@ -1,17 +1,15 @@ -"""Extract reference documentation from the NumPy source tree. +"""Extract reference documentation from the NumPy source tree.""" -""" +import copy import inspect -import textwrap -import re import pydoc -from warnings import warn +import re +import sys +import textwrap from collections import namedtuple from collections.abc import Callable, Mapping -import copy -import sys - from functools import cached_property +from warnings import warn def strip_blank_lines(l): @@ -122,17 +120,17 @@ class NumpyDocString(Mapping): "Summary": [""], "Extended Summary": [], "Parameters": [], + "Attributes": [], + "Methods": [], "Returns": [], "Yields": [], "Receives": [], + "Other Parameters": [], "Raises": [], "Warns": [], - "Other Parameters": [], - "Attributes": [], - "Methods": [], + "Warnings": [], "See Also": [], "Notes": [], - "Warnings": [], "References": "", "Examples": "", "index": {}, @@ -234,8 +232,7 @@ def _parse_param_list(self, content, single_element_is_type=False): # NOTE: param line with single element should never have a # a " :" before the description line, so this should probably # warn. - if header.endswith(" :"): - header = header[:-2] + header = header.removesuffix(" :") if single_element_is_type: arg_name, arg_type = "", header else: @@ -346,7 +343,7 @@ def parse_item_name(text): def _parse_index(self, section, content): """ - .. index: default + .. index:: default :refguide: something, else, and more """ @@ -393,12 +390,8 @@ def _parse(self): sections = list(self._read_sections()) section_names = {section for section, content in sections} - has_returns = "Returns" in section_names has_yields = "Yields" in section_names # We could do more tests, but we are not. Arbitrarily. - if has_returns and has_yields: - msg = "Docstring contains both a Returns and Yields section." - raise ValueError(msg) if not has_yields and "Receives" in section_names: msg = "Docstring contains a Receives section but not Yields." raise ValueError(msg) @@ -452,7 +445,7 @@ def _error_location(self, msg, error=True): if error: raise ValueError(msg) else: - warn(msg) + warn(msg, stacklevel=3) # string conversion routines @@ -555,8 +548,10 @@ def __str__(self, func_role=""): out += self._str_signature() out += self._str_summary() out += self._str_extended_summary() + out += self._str_param_list("Parameters") + for param_list in ("Attributes", "Methods"): + out += self._str_param_list(param_list) for param_list in ( - "Parameters", "Returns", "Yields", "Receives", @@ -569,8 +564,6 @@ def __str__(self, func_role=""): out += self._str_see_also(func_role) for s in ("Notes", "References", "Examples"): out += self._str_section(s) - for param_list in ("Attributes", "Methods"): - out += self._str_param_list(param_list) out += self._str_index() return "\n".join(out) @@ -604,7 +597,7 @@ def get_func(self): def __str__(self): out = "" - func, func_name = self.get_func() + _func, func_name = self.get_func() roles = {"func": "function", "meth": "method"} @@ -705,21 +698,34 @@ def properties(self): for name, func in inspect.getmembers(self._cls) if ( not name.startswith("_") + and not self._should_skip_member(name, self._cls) and ( func is None - or isinstance(func, (property, cached_property)) + or isinstance(func, property | cached_property) or inspect.isdatadescriptor(func) ) and self._is_show_member(name) ) ] + @staticmethod + def _should_skip_member(name, klass): + return ( + # Namedtuples should skip everything in their ._fields as the + # docstrings for each of the members is: "Alias for field number X" + issubclass(klass, tuple) + and hasattr(klass, "_asdict") + and hasattr(klass, "_fields") + and name in klass._fields + ) + def _is_show_member(self, name): - if self.show_inherited_members: - return True # show all class members - if name not in self._cls.__dict__: - return False # class member is inherited, we do not show it - return True + return ( + # show all class members + self.show_inherited_members + # or class member is not inherited + or name in self._cls.__dict__ + ) def get_doc_object( diff --git a/numpydoc/docscrape_sphinx.py b/numpydoc/docscrape_sphinx.py index 26a8e6b3..3b2f325a 100644 --- a/numpydoc/docscrape_sphinx.py +++ b/numpydoc/docscrape_sphinx.py @@ -1,20 +1,17 @@ -import re import inspect -import textwrap -import pydoc -from collections.abc import Callable import os +import pydoc +import re +import textwrap from jinja2 import FileSystemLoader from jinja2.sandbox import SandboxedEnvironment -import sphinx from sphinx.jinja2glue import BuiltinTemplateLoader -from .docscrape import NumpyDocString, FunctionDoc, ClassDoc, ObjDoc +from .docscrape import ClassDoc, FunctionDoc, NumpyDocString, ObjDoc from .docscrape import get_doc_object as get_doc_object_orig from .xref import make_xref - IMPORT_MATPLOTLIB_RE = r"\b(import +matplotlib|from +matplotlib +import)\b" @@ -163,7 +160,7 @@ def _process_param(self, param, desc, fake_autosummary): display_param = f":obj:`{param} <{link_prefix}{param}>`" if obj_doc: # Overwrite desc. Take summary logic of autosummary - desc = re.split(r"\n\s*\n", obj_doc.strip(), 1)[0] + desc = re.split(r"\n\s*\n", obj_doc.strip(), maxsplit=1)[0] # XXX: Should this have DOTALL? # It does not in autosummary m = re.search(r"^([A-Z].*?\.)(?:\s|$)", " ".join(desc.split())) @@ -332,7 +329,7 @@ def _str_references(self): out += [".. only:: latex", ""] items = [] for line in self["References"]: - m = re.match(r".. \[([a-z0-9._-]+)\]", line, re.I) + m = re.match(r".. \[([a-z0-9._-]+)\]", line, re.IGNORECASE) if m: items.append(m.group(1)) out += [" " + ", ".join([f"[{item}]_" for item in items]), ""] @@ -362,6 +359,12 @@ def __str__(self, indent=0, func_role="obj"): "summary": self._str_summary(), "extended_summary": self._str_extended_summary(), "parameters": self._str_param_list("Parameters"), + "attributes": ( + self._str_param_list("Attributes", fake_autosummary=True) + if self.attributes_as_param_list + else self._str_member_list("Attributes") + ), + "methods": self._str_member_list("Methods"), "returns": self._str_returns("Returns"), "yields": self._str_returns("Yields"), "receives": self._str_returns("Receives"), @@ -373,10 +376,6 @@ def __str__(self, indent=0, func_role="obj"): "notes": self._str_section("Notes"), "references": self._str_references(), "examples": self._str_examples(), - "attributes": self._str_param_list("Attributes", fake_autosummary=True) - if self.attributes_as_param_list - else self._str_member_list("Attributes"), - "methods": self._str_member_list("Methods"), } ns = {k: "\n".join(v) for k, v in ns.items()} diff --git a/numpydoc/hooks/utils.py b/numpydoc/hooks/utils.py index 4f1d82aa..6f40c69c 100644 --- a/numpydoc/hooks/utils.py +++ b/numpydoc/hooks/utils.py @@ -2,8 +2,8 @@ import itertools import os +from collections.abc import Sequence from pathlib import Path -from typing import Sequence def find_project_root(srcs: Sequence[str]): @@ -31,7 +31,7 @@ def find_project_root(srcs: Sequence[str]): `Black `_. """ if not srcs: - return Path(".").resolve(), "current directory" + return Path.cwd(), "current directory" common_path = Path( os.path.commonpath([Path(src).expanduser().resolve() for src in srcs]) diff --git a/numpydoc/hooks/validate_docstrings.py b/numpydoc/hooks/validate_docstrings.py index db8141d8..70823f10 100644 --- a/numpydoc/hooks/validate_docstrings.py +++ b/numpydoc/hooks/validate_docstrings.py @@ -1,6 +1,5 @@ """Run numpydoc validation on contents of a file.""" -import argparse import ast import configparser import os @@ -13,9 +12,7 @@ import tomli as tomllib from pathlib import Path -from typing import Sequence, Tuple, Union - -from tabulate import tabulate +from typing import Any, Dict, List, Tuple, Union from .. import docscrape, validate from .utils import find_project_root @@ -36,7 +33,12 @@ class AstValidator(validate.Validator): """ def __init__( - self, *, ast_node: ast.AST, filename: os.PathLike, obj_name: str + self, + *, + ast_node: ast.AST, + filename: os.PathLike, + obj_name: str, + ancestry: list[ast.AST], ) -> None: self.node: ast.AST = ast_node self.raw_doc: str = ast.get_docstring(self.node, clean=False) or "" @@ -49,6 +51,8 @@ def __init__( self.is_class: bool = isinstance(ast_node, ast.ClassDef) self.is_module: bool = isinstance(ast_node, ast.Module) + self.ancestry: list[ast.AST] = ancestry + @staticmethod def _load_obj(name): raise NotImplementedError("AstValidator does not support this method.") @@ -59,7 +63,11 @@ def name(self) -> str: @property def is_function_or_method(self) -> bool: - return isinstance(self.node, (ast.FunctionDef, ast.AsyncFunctionDef)) + return isinstance(self.node, ast.FunctionDef | ast.AsyncFunctionDef) + + @property + def is_mod(self) -> bool: + return self.is_module @property def is_generator_function(self) -> bool: @@ -90,7 +98,7 @@ def source_file_def_line(self) -> int: @property def signature_parameters(self) -> Tuple[str]: - def extract_signature(node): + def extract_signature(node, parent): args_node = node.args params = [] for arg_type in ["posonlyargs", "args", "vararg", "kwonlyargs", "kwarg"]: @@ -103,17 +111,21 @@ def extract_signature(node): else: params.extend([arg.arg for arg in entries]) params = tuple(params) - if params and params[0] in {"self", "cls"}: + if ( + params + and params[0] in {"self", "cls"} + and isinstance(parent, ast.ClassDef) + ): return params[1:] return params params = tuple() if self.is_function_or_method: - params = extract_signature(self.node) + params = extract_signature(self.node, self.ancestry[-1]) elif self.is_class: for child in self.node.body: if isinstance(child, ast.FunctionDef) and child.name == "__init__": - params = extract_signature(child) + params = extract_signature(child, self.node) return params @property @@ -143,9 +155,23 @@ def __init__( self.config: dict = config self.filepath: str = filepath self.module_name: str = Path(self.filepath).stem - self.stack: list[str] = [] + self.stack: list[ast.AST] = [] self.findings: list = [] + @property + def node_name(self) -> str: + """ + Get the full name of the current node in the stack. + + Returns + ------- + str + The full name of the current node in the stack. + """ + return ".".join( + [getattr(node, "name", self.module_name) for node in self.stack] + ) + def _ignore_issue(self, node: ast.AST, check: str) -> bool: """ Check whether the issue should be ignored. @@ -184,13 +210,17 @@ def _get_numpydoc_issues(self, node: ast.AST) -> None: node : ast.AST The node under inspection. """ - name = ".".join(self.stack) + name = self.node_name report = validate.validate( - name, AstValidator, ast_node=node, filename=self.filepath + name, + AstValidator, + ast_node=node, + filename=self.filepath, + ancestry=self.stack[:-1], ) self.findings.extend( [ - [f'{self.filepath}:{report["file_line"]}', name, check, description] + [f"{self.filepath}:{report['file_line']}", name, check, description] for check, description in report["errors"] if not self._ignore_issue(node, check) ] @@ -206,15 +236,13 @@ def visit(self, node: ast.AST) -> None: The node to visit. """ if isinstance( - node, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef) + node, ast.Module | ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef ): - self.stack.append( - self.module_name if isinstance(node, ast.Module) else node.name - ) + self.stack.append(node) if not ( self.config["exclude"] - and re.search(self.config["exclude"], ".".join(self.stack)) + and re.search(self.config["exclude"], self.node_name) ): self._get_numpydoc_issues(node) @@ -245,7 +273,12 @@ def parse_config(dir_path: os.PathLike = None) -> dict: dict Config options for the numpydoc validation hook. """ - options = {"checks": {"all"}, "exclude": set(), "overrides": {}} + options = { + "checks": {"all"}, + "exclude": set(), + "overrides": {}, + "exclude_files": set(), + } dir_path = Path(dir_path).expanduser().resolve() toml_path = dir_path / "pyproject.toml" @@ -278,6 +311,13 @@ def extract_check_overrides(options, config_items): else [global_exclusions] ) + file_exclusions = config.get("exclude_files", options["exclude_files"]) + options["exclude_files"] = set( + file_exclusions + if not isinstance(file_exclusions, str) + else [file_exclusions] + ) + extract_check_overrides(options, config.items()) elif cfg_path.is_file(): @@ -304,6 +344,16 @@ def extract_check_overrides(options, config_items): except configparser.NoOptionError: pass + try: + options["exclude_files"] = set( + config.get(numpydoc_validation_config_section, "exclude_files") + .rstrip(",") + .split(",") + or options["exclude_files"] + ) + except configparser.NoOptionError: + pass + extract_check_overrides( options, config.items(numpydoc_validation_config_section) ) @@ -313,6 +363,7 @@ def extract_check_overrides(options, config_items): options["checks"] = validate.get_validation_checks(options["checks"]) options["exclude"] = compile_regex(options["exclude"]) + options["exclude_files"] = compile_regex(options["exclude_files"]) return options @@ -341,77 +392,42 @@ def process_file(filepath: os.PathLike, config: dict) -> "list[list[str]]": return docstring_visitor.findings -def main(argv: Union[Sequence[str], None] = None) -> int: - """Run the numpydoc validation hook.""" - - project_root_from_cwd, config_file = find_project_root(["."]) - config_options = parse_config(project_root_from_cwd) - ignored_checks = ( - "\n " - + "\n ".join( - [ - f"- {check}: {validate.ERROR_MSGS[check]}" - for check in set(validate.ERROR_MSGS.keys()) - config_options["checks"] - ] - ) - + "\n" - ) - - parser = argparse.ArgumentParser( - description="Run numpydoc validation on files with option to ignore individual checks.", - formatter_class=argparse.RawTextHelpFormatter, - ) - parser.add_argument( - "files", type=str, nargs="+", help="File(s) to run numpydoc validation on." - ) - parser.add_argument( - "--config", - type=str, - help=( - "Path to a directory containing a pyproject.toml or setup.cfg file.\n" - "The hook will look for it in the root project directory.\n" - "If both are present, only pyproject.toml will be used.\n" - "Options must be placed under\n" - " - [tool:numpydoc_validation] for setup.cfg files and\n" - " - [tool.numpydoc_validation] for pyproject.toml files." - ), - ) - parser.add_argument( - "--ignore", - type=str, - nargs="*", - help=( - f"""Check codes to ignore.{ - ' 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["checks"] else '' - }""" - ), - ) - - args = parser.parse_args(argv) - project_root, _ = find_project_root(args.files) - config_options = parse_config(args.config or project_root) - config_options["checks"] -= set(args.ignore or []) - - findings = [] - for file in args.files: - findings.extend(process_file(file, config_options)) - - if findings: - print( - tabulate( - findings, - headers=["file", "item", "check", "description"], - tablefmt="grid", - maxcolwidths=50, - ), - file=sys.stderr, - ) - return 1 - return 0 +def run_hook( + files: List[str], + *, + config: Dict[str, Any] | None = None, + ignore: List[str] | None = None, +) -> int: + """ + Run the numpydoc validation hook. + Parameters + ---------- + files : list[str] + The absolute or relative paths to the files to inspect. + config : Union[dict[str, Any], None], optional + Configuration options for reviewing flagged issues. + ignore : Union[list[str], None], optional + Checks to ignore in the results. -if __name__ == "__main__": - raise SystemExit(main()) + Returns + ------- + int + The return status: 1 if issues were found, 0 otherwise. + """ + project_root, _ = find_project_root(files) + config_options = parse_config(config or project_root) + config_options["checks"] -= set(ignore or []) + exclude_re = config_options["exclude_files"] + + findings = False + for file in files: + if exclude_re and exclude_re.match(file): + continue + if file_issues := process_file(file, config_options): + findings = True + + for line, obj, check, description in file_issues: + print(f"\n{line}: {check} {description}", file=sys.stderr) + + return int(findings) diff --git a/numpydoc/numpydoc.py b/numpydoc/numpydoc.py index 335de5a8..2b9757d7 100644 --- a/numpydoc/numpydoc.py +++ b/numpydoc/numpydoc.py @@ -16,27 +16,27 @@ .. [1] https://github.com/numpy/numpydoc """ -from copy import deepcopy -import re -import pydoc -import inspect -from collections.abc import Callable + import hashlib +import importlib +import inspect import itertools +import pydoc +import re +import sys +from collections.abc import Callable +from copy import deepcopy +from pathlib import Path -from docutils.nodes import citation, Text, section, comment, reference, inline -import sphinx -from sphinx.addnodes import pending_xref, desc_content +from docutils.nodes import Text, citation, comment, inline, reference, section +from sphinx.addnodes import desc_content, pending_xref +from sphinx.application import Sphinx as SphinxApp from sphinx.util import logging -from sphinx.errors import ExtensionError - -if sphinx.__version__ < "5": - raise RuntimeError("Sphinx 5 or newer is required") +from . import __version__ from .docscrape_sphinx import get_doc_object -from .validate import validate, ERROR_MSGS, get_validation_checks +from .validate import get_validation_checks, validate from .xref import DEFAULT_LINKS -from . import __version__ logger = logging.getLogger(__name__) @@ -56,13 +56,15 @@ def _traverse_or_findall(node, condition, **kwargs): ) -def rename_references(app, what, name, obj, options, lines): +def rename_references(app: SphinxApp, what, name, obj, options, lines): # decorate reference numbers so that there are no duplicates # these are later undecorated in the doctree, in relabel_references references = set() for line in lines: line = line.strip() - m = re.match(r"^\.\. +\[(%s)\]" % app.config.numpydoc_citation_re, line, re.I) + m = re.match( + r"^\.\. +\[(%s)\]" % app.config.numpydoc_citation_re, line, re.IGNORECASE + ) if m: references.add(m.group(1)) @@ -86,7 +88,7 @@ def _is_cite_in_numpydoc_docstring(citation_node): section_node = citation_node.parent def is_docstring_section(node): - return isinstance(node, (section, desc_content)) + return isinstance(node, section | desc_content) while not is_docstring_section(section_node): section_node = section_node.parent @@ -116,7 +118,7 @@ def is_docstring_section(node): return False -def relabel_references(app, doc): +def relabel_references(app: SphinxApp, doc): # Change 'hash-ref' to 'ref' in label text for citation_node in _traverse_or_findall(doc, citation): if not _is_cite_in_numpydoc_docstring(citation_node): @@ -143,7 +145,7 @@ def matching_pending_xref(node): ref.replace(ref_text, new_text.copy()) -def clean_backrefs(app, doc, docname): +def clean_backrefs(app: SphinxApp, doc, docname): # only::latex directive has resulted in citation backrefs without reference known_ref_ids = set() for ref in _traverse_or_findall(doc, reference, descend=True): @@ -163,7 +165,7 @@ def clean_backrefs(app, doc, docname): DEDUPLICATION_TAG = " !! processed by numpydoc !!" -def mangle_docstrings(app, what, name, obj, options, lines): +def mangle_docstrings(app: SphinxApp, what, name, obj, options, lines): if DEDUPLICATION_TAG in lines: return show_inherited_class_members = app.config.numpydoc_show_inherited_class_members @@ -183,15 +185,51 @@ def mangle_docstrings(app, what, name, obj, options, lines): "xref_aliases": app.config.numpydoc_xref_aliases_complete, "xref_ignore": app.config.numpydoc_xref_ignore, } - - cfg.update(options or {}) + # TODO: Find a cleaner way to take care of this change away from dict + # https://github.com/sphinx-doc/sphinx/issues/13942 + try: + cfg.update(options or {}) + except TypeError: + cfg.update(options.__dict__ or {}) u_NL = "\n" if what == "module": # Strip top title pattern = "^\\s*[#*=]{4,}\\n[a-z0-9 -]+\\n[#*=]{4,}\\s*" - title_re = re.compile(pattern, re.I | re.S) + title_re = re.compile(pattern, re.IGNORECASE | re.DOTALL) lines[:] = title_re.sub("", u_NL.join(lines)).split(u_NL) else: + # Test the obj to find the module path, and skip the check if it's path is matched by + # numpydoc_validation_exclude_files + if ( + app.config.numpydoc_validation_exclude_files + and app.config.numpydoc_validation_checks + ): + excluder = app.config.numpydoc_validation_files_excluder + module = inspect.getmodule(obj) + try: + # Get the module relative path from the name + if module: + mod_path = Path(module.__file__) + package_rel_path = mod_path.parent.relative_to( + Path( + importlib.import_module( + module.__name__.split(".")[0] + ).__file__ + ).parent + ).as_posix() + module_file = mod_path.as_posix().replace( + mod_path.parent.as_posix(), "" + ) + path = package_rel_path + module_file + else: + path = None + except AttributeError as e: + path = None + + if path and excluder and excluder.search(path): + # Skip validation for this object. + return + try: doc = get_doc_object( obj, what, u_NL.join(lines), config=cfg, builder=app.builder @@ -241,7 +279,7 @@ def mangle_docstrings(app, what, name, obj, options, lines): lines += ["..", DEDUPLICATION_TAG] -def mangle_signature(app, what, name, obj, options, sig, retann): +def mangle_signature(app: SphinxApp, what, name, obj, options, sig, retann): # Do not try to inspect classes that don't define `__init__` if inspect.isclass(obj) and ( not hasattr(obj, "__init__") @@ -250,10 +288,10 @@ def mangle_signature(app, what, name, obj, options, sig, retann): return "", "" if not (isinstance(obj, Callable) or hasattr(obj, "__argspec_is_invalid_")): - return + return None if not hasattr(obj, "__doc__"): - return + return None doc = get_doc_object(obj, config={"show_class_members": False}) sig = doc["Signature"] or _clean_text_signature( getattr(obj, "__text_signature__", None) @@ -275,9 +313,9 @@ def _clean_text_signature(sig): return start_sig + sig + ")" -def setup(app, get_doc_object_=get_doc_object): +def setup(app: SphinxApp, get_doc_object_=get_doc_object): if not hasattr(app, "add_config_value"): - return # probably called by nose, better bail out + return None # probably called by nose, better bail out global get_doc_object get_doc_object = get_doc_object_ @@ -298,9 +336,10 @@ def setup(app, get_doc_object_=get_doc_object): app.add_config_value("numpydoc_attributes_as_param_list", True, True) app.add_config_value("numpydoc_xref_param_type", False, True) app.add_config_value("numpydoc_xref_aliases", dict(), True) - app.add_config_value("numpydoc_xref_ignore", set(), True) + app.add_config_value("numpydoc_xref_ignore", set(), True, types=[set, str]) app.add_config_value("numpydoc_validation_checks", set(), True) app.add_config_value("numpydoc_validation_exclude", set(), False) + app.add_config_value("numpydoc_validation_exclude_files", set(), False) app.add_config_value("numpydoc_validation_overrides", dict(), False) # Extra mangling domains @@ -311,7 +350,7 @@ def setup(app, get_doc_object_=get_doc_object): return metadata -def update_config(app, config=None): +def update_config(app: SphinxApp, config=None): """Update the configuration with default values.""" if config is None: # needed for testing and old Sphinx config = app.config @@ -344,6 +383,21 @@ def update_config(app, config=None): ) config.numpydoc_validation_excluder = exclude_expr + # Generate the regexp for files to ignore during validation + if isinstance(config.numpydoc_validation_exclude_files, str): + raise ValueError( + f"numpydoc_validation_exclude_files must be a container of strings, " + f"e.g. [{config.numpydoc_validation_exclude_files!r}]." + ) + + config.numpydoc_validation_files_excluder = None + if config.numpydoc_validation_exclude_files: + exclude_files_expr = re.compile( + r"|".join(exp for exp in config.numpydoc_validation_exclude_files) + ) + config.numpydoc_validation_files_excluder = exclude_files_expr + + # Generate the regexp for validation overrides for check, patterns in config.numpydoc_validation_overrides.items(): config.numpydoc_validation_overrides[check] = re.compile( r"|".join(exp for exp in patterns) @@ -419,12 +473,18 @@ def match_items(lines, content_old): Examples -------- - >>> lines = ['', 'A', '', 'B', ' ', '', 'C', 'D'] - >>> lines_old = ['a', '', '', 'b', '', 'c'] - >>> items_old = [('file1.py', 0), ('file1.py', 1), ('file1.py', 2), - ... ('file2.py', 0), ('file2.py', 1), ('file2.py', 2)] + >>> lines = ["", "A", "", "B", " ", "", "C", "D"] + >>> lines_old = ["a", "", "", "b", "", "c"] + >>> items_old = [ + ... ("file1.py", 0), + ... ("file1.py", 1), + ... ("file1.py", 2), + ... ("file2.py", 0), + ... ("file2.py", 1), + ... ("file2.py", 2), + ... ] >>> content_old = ViewList(lines_old, items=items_old) - >>> match_items(lines, content_old) # doctest: +NORMALIZE_WHITESPACE + >>> match_items(lines, content_old) # doctest: +NORMALIZE_WHITESPACE [('file1.py', 0), ('file1.py', 0), ('file2.py', 0), ('file2.py', 0), ('file2.py', 2), ('file2.py', 2), ('file2.py', 2), ('file2.py', 2)] >>> # first 2 ``lines`` are matched to 'a', second 2 to 'b', rest to 'c' diff --git a/numpydoc/templates/numpydoc_docstring.rst b/numpydoc/templates/numpydoc_docstring.rst index 79ab1f8e..f6b6a0ae 100644 --- a/numpydoc/templates/numpydoc_docstring.rst +++ b/numpydoc/templates/numpydoc_docstring.rst @@ -2,6 +2,8 @@ {{summary}} {{extended_summary}} {{parameters}} +{{attributes}} +{{methods}} {{returns}} {{yields}} {{receives}} @@ -13,5 +15,3 @@ {{notes}} {{references}} {{examples}} -{{attributes}} -{{methods}} diff --git a/numpydoc/tests/hooks/example_module.py b/numpydoc/tests/hooks/example_module.py index b36f519a..9f75bdf0 100644 --- a/numpydoc/tests/hooks/example_module.py +++ b/numpydoc/tests/hooks/example_module.py @@ -3,7 +3,6 @@ def some_function(name): """Welcome to some function.""" - pass class MyClass: @@ -23,11 +22,9 @@ def do_something(self, *args, **kwargs): ---------- *args """ - pass - def process(self): - """Process stuff.""" - pass + def create(self): + """Creates stuff.""" class NewClass: diff --git a/numpydoc/tests/hooks/test_validate_hook.py b/numpydoc/tests/hooks/test_validate_hook.py index 5c635dfb..b235313b 100644 --- a/numpydoc/tests/hooks/test_validate_hook.py +++ b/numpydoc/tests/hooks/test_validate_hook.py @@ -5,7 +5,7 @@ import pytest -from numpydoc.hooks.validate_docstrings import main +from numpydoc.hooks.validate_docstrings import run_hook @pytest.fixture @@ -26,59 +26,47 @@ def test_validate_hook(example_module, config, capsys): expected = inspect.cleandoc( """ - +-------------------------------------------+-------------------------------------+---------+----------------------------------------------------+ - | file | item | check | description | - +===========================================+=====================================+=========+====================================================+ - | numpydoc/tests/hooks/example_module.py:1 | example_module | EX01 | No examples section found | - +-------------------------------------------+-------------------------------------+---------+----------------------------------------------------+ - | numpydoc/tests/hooks/example_module.py:4 | example_module.some_function | ES01 | No extended summary found | - +-------------------------------------------+-------------------------------------+---------+----------------------------------------------------+ - | numpydoc/tests/hooks/example_module.py:4 | example_module.some_function | PR01 | Parameters {'name'} not documented | - +-------------------------------------------+-------------------------------------+---------+----------------------------------------------------+ - | numpydoc/tests/hooks/example_module.py:4 | example_module.some_function | SA01 | See Also section not found | - +-------------------------------------------+-------------------------------------+---------+----------------------------------------------------+ - | numpydoc/tests/hooks/example_module.py:4 | example_module.some_function | EX01 | No examples section found | - +-------------------------------------------+-------------------------------------+---------+----------------------------------------------------+ - | numpydoc/tests/hooks/example_module.py:9 | example_module.MyClass | ES01 | No extended summary found | - +-------------------------------------------+-------------------------------------+---------+----------------------------------------------------+ - | numpydoc/tests/hooks/example_module.py:9 | example_module.MyClass | SA01 | See Also section not found | - +-------------------------------------------+-------------------------------------+---------+----------------------------------------------------+ - | numpydoc/tests/hooks/example_module.py:9 | example_module.MyClass | EX01 | No examples section found | - +-------------------------------------------+-------------------------------------+---------+----------------------------------------------------+ - | numpydoc/tests/hooks/example_module.py:12 | example_module.MyClass.__init__ | GL08 | The object does not have a docstring | - +-------------------------------------------+-------------------------------------+---------+----------------------------------------------------+ - | numpydoc/tests/hooks/example_module.py:18 | example_module.MyClass.do_something | ES01 | No extended summary found | - +-------------------------------------------+-------------------------------------+---------+----------------------------------------------------+ - | 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 | - +-------------------------------------------+-------------------------------------+---------+----------------------------------------------------+ - | numpydoc/tests/hooks/example_module.py:18 | example_module.MyClass.do_something | SA01 | See Also section not found | - +-------------------------------------------+-------------------------------------+---------+----------------------------------------------------+ - | numpydoc/tests/hooks/example_module.py:18 | example_module.MyClass.do_something | EX01 | No examples section found | - +-------------------------------------------+-------------------------------------+---------+----------------------------------------------------+ - | numpydoc/tests/hooks/example_module.py:28 | example_module.MyClass.process | SS05 | Summary must start with infinitive verb, not third | - | | | | person (e.g. use "Generate" instead of | - | | | | "Generates") | - +-------------------------------------------+-------------------------------------+---------+----------------------------------------------------+ - | numpydoc/tests/hooks/example_module.py:28 | example_module.MyClass.process | ES01 | No extended summary found | - +-------------------------------------------+-------------------------------------+---------+----------------------------------------------------+ - | numpydoc/tests/hooks/example_module.py:28 | example_module.MyClass.process | SA01 | See Also section not found | - +-------------------------------------------+-------------------------------------+---------+----------------------------------------------------+ - | 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 | - +-------------------------------------------+-------------------------------------+---------+----------------------------------------------------+ + numpydoc/tests/hooks/example_module.py:4: ES01 No extended summary found + + numpydoc/tests/hooks/example_module.py:4: PR01 Parameters {'name'} not documented + + numpydoc/tests/hooks/example_module.py:4: SA01 See Also section not found + + numpydoc/tests/hooks/example_module.py:4: EX01 No examples section found + + numpydoc/tests/hooks/example_module.py:8: ES01 No extended summary found + + numpydoc/tests/hooks/example_module.py:8: SA01 See Also section not found + + numpydoc/tests/hooks/example_module.py:8: EX01 No examples section found + + numpydoc/tests/hooks/example_module.py:11: GL08 The object does not have a docstring + + numpydoc/tests/hooks/example_module.py:17: ES01 No extended summary found + + numpydoc/tests/hooks/example_module.py:17: PR01 Parameters {'**kwargs'} not documented + + numpydoc/tests/hooks/example_module.py:17: PR07 Parameter "*args" has no description + + numpydoc/tests/hooks/example_module.py:17: SA01 See Also section not found + + numpydoc/tests/hooks/example_module.py:17: EX01 No examples section found + + 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:26: ES01 No extended summary found + + numpydoc/tests/hooks/example_module.py:26: SA01 See Also section not found + + 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 """ ) - args = [example_module] - if config: - args.append(f"--{config=}") - - return_code = main(args) + return_code = run_hook([example_module], config=config) assert return_code == 1 - assert capsys.readouterr().err.rstrip() == expected + assert capsys.readouterr().err.strip() == expected def test_validate_hook_with_ignore(example_module, capsys): @@ -89,29 +77,24 @@ def test_validate_hook_with_ignore(example_module, capsys): 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:12 | example_module.MyClass.__init__ | GL08 | The object does not have a docstring | - +-------------------------------------------+-------------------------------------+---------+----------------------------------------------------+ - | 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 | - +-------------------------------------------+-------------------------------------+---------+----------------------------------------------------+ - | numpydoc/tests/hooks/example_module.py:28 | example_module.MyClass.process | SS05 | Summary must start with infinitive verb, not third | - | | | | 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 | - +-------------------------------------------+-------------------------------------+---------+----------------------------------------------------+ + numpydoc/tests/hooks/example_module.py:4: PR01 Parameters {'name'} not documented + + numpydoc/tests/hooks/example_module.py:11: GL08 The object does not have a docstring + + numpydoc/tests/hooks/example_module.py:17: PR01 Parameters {'**kwargs'} not documented + + numpydoc/tests/hooks/example_module.py:17: PR07 Parameter "*args" has no description + + 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 """ ) - return_code = main([example_module, "--ignore", "ES01", "SA01", "EX01"]) + return_code = run_hook([example_module], ignore=["ES01", "SA01", "EX01"]) + assert return_code == 1 - assert capsys.readouterr().err.rstrip() == expected + assert capsys.readouterr().err.strip() == expected def test_validate_hook_with_toml_config(example_module, tmp_path, capsys): @@ -133,9 +116,7 @@ def test_validate_hook_with_toml_config(example_module, tmp_path, capsys): ] exclude = '\\.__init__$' override_SS05 = [ - '^Process', - '^Assess', - '^Access', + '^Creates', ] """ ) @@ -143,23 +124,19 @@ def test_validate_hook_with_toml_config(example_module, tmp_path, capsys): 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 | - +-------------------------------------------+-------------------------------------+---------+----------------------------------------+ - | numpydoc/tests/hooks/example_module.py:33 | example_module.NewClass | GL08 | The object does not have a docstring | - +-------------------------------------------+-------------------------------------+---------+----------------------------------------+ + numpydoc/tests/hooks/example_module.py:4: PR01 Parameters {'name'} not documented + + numpydoc/tests/hooks/example_module.py:17: PR01 Parameters {'**kwargs'} not documented + + numpydoc/tests/hooks/example_module.py:17: PR07 Parameter "*args" has no description + + numpydoc/tests/hooks/example_module.py:30: GL08 The object does not have a docstring """ ) - return_code = main([example_module, "--config", str(tmp_path)]) + return_code = run_hook([example_module], config=tmp_path) assert return_code == 1 - assert capsys.readouterr().err.rstrip() == expected + assert capsys.readouterr().err.strip() == expected def test_validate_hook_with_setup_cfg(example_module, tmp_path, capsys): @@ -175,42 +152,26 @@ def test_validate_hook_with_setup_cfg(example_module, tmp_path, capsys): [tool:numpydoc_validation] checks = all,EX01,SA01,ES01 exclude = \\.__init__$ - override_SS05 = ^Process,^Assess,^Access + override_SS05 = ^Creates """ ) ) 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 | - +-------------------------------------------+-------------------------------------+---------+----------------------------------------+ - | 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 + numpydoc/tests/hooks/example_module.py:4: PR01 Parameters {'name'} not documented + numpydoc/tests/hooks/example_module.py:17: PR01 Parameters {'**kwargs'} not documented -def test_validate_hook_help(capsys): - """Test that help section is displaying.""" + numpydoc/tests/hooks/example_module.py:17: PR07 Parameter "*args" has no description - with pytest.raises(SystemExit): - return_code = main(["--help"]) - assert return_code == 0 + numpydoc/tests/hooks/example_module.py:30: GL08 The object does not have a docstring + """ + ) - out = capsys.readouterr().out - assert "--ignore" in out - assert "--config" in out + return_code = run_hook([example_module], config=tmp_path) + assert return_code == 1 + assert capsys.readouterr().err.strip() == expected def test_validate_hook_exclude_option_pyproject(example_module, tmp_path, capsys): @@ -235,9 +196,7 @@ def test_validate_hook_exclude_option_pyproject(example_module, tmp_path, capsys '\.__init__$', ] override_SS05 = [ - '^Process', - '^Assess', - '^Access', + '^Creates', ] """ ) @@ -245,19 +204,15 @@ def test_validate_hook_exclude_option_pyproject(example_module, tmp_path, capsys 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 | - +-------------------------------------------+------------------------------+---------+--------------------------------------+ + numpydoc/tests/hooks/example_module.py:4: PR01 Parameters {'name'} not documented + + numpydoc/tests/hooks/example_module.py:30: GL08 The object does not have a docstring """ ) - return_code = main([example_module, "--config", str(tmp_path)]) + return_code = run_hook([example_module], config=tmp_path) assert return_code == 1 - assert capsys.readouterr().err.rstrip() == expected + assert capsys.readouterr().err.strip() == expected def test_validate_hook_exclude_option_setup_cfg(example_module, tmp_path, capsys): @@ -273,25 +228,87 @@ def test_validate_hook_exclude_option_setup_cfg(example_module, tmp_path, capsys [tool:numpydoc_validation] checks = all,EX01,SA01,ES01 exclude = \\.NewClass$,\\.__init__$ - override_SS05 = ^Process,^Assess,^Access + override_SS05 = ^Creates """ ) ) 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 | - +-------------------------------------------+-------------------------------------+---------+----------------------------------------+ + numpydoc/tests/hooks/example_module.py:4: PR01 Parameters {'name'} not documented + + numpydoc/tests/hooks/example_module.py:17: PR01 Parameters {'**kwargs'} not documented + + numpydoc/tests/hooks/example_module.py:17: PR07 Parameter "*args" has no description """ ) - return_code = main([example_module, "--config", str(tmp_path)]) + return_code = run_hook([example_module], config=tmp_path) assert return_code == 1 - assert capsys.readouterr().err.rstrip() == expected + assert capsys.readouterr().err.strip() == expected + + +@pytest.mark.parametrize( + "regex, expected_code", + [(".*(/|\\\\)example.*\.py", 0), (".*/non_existent_match.*\.py", 1)], +) +def test_validate_hook_exclude_files_option_pyproject( + example_module, regex, expected_code, tmp_path +): + """ + Test that the hook correctly processes the toml config and either includes + or excludes files based on the `exclude_files` option. + """ + + with open(tmp_path / "pyproject.toml", "w") as config_file: + config_file.write( + inspect.cleandoc( + f""" + [tool.numpydoc_validation] + checks = [ + "all", + "EX01", + "SA01", + "ES01", + ] + exclude = '\\.__init__$' + override_SS05 = [ + '^Creates', + ] + exclude_files = [ + '{regex}', + ]""" + ) + ) + + return_code = run_hook([example_module], config=tmp_path) + assert return_code == expected_code # Should not-report/report findings. + + +@pytest.mark.parametrize( + "regex, expected_code", + [(".*(/|\\\\)example.*\.py", 0), (".*/non_existent_match.*\.py", 1)], +) +def test_validate_hook_exclude_files_option_setup_cfg( + example_module, regex, expected_code, tmp_path +): + """ + Test that the hook correctly processes the setup config and either includes + or excludes files based on the `exclude_files` option. + """ + + with open(tmp_path / "setup.cfg", "w") as config_file: + config_file.write( + inspect.cleandoc( + f""" + [tool:numpydoc_validation] + checks = all,EX01,SA01,ES01 + exclude = \\.NewClass$,\\.__init__$ + override_SS05 = ^Creates + exclude_files = {regex} + """ + ) + ) + + return_code = run_hook([example_module], config=tmp_path) + assert return_code == expected_code # Should not-report/report findings. diff --git a/numpydoc/tests/test_docscrape.py b/numpydoc/tests/test_docscrape.py index 5077c425..cf7c0de9 100644 --- a/numpydoc/tests/test_docscrape.py +++ b/numpydoc/tests/test_docscrape.py @@ -1,24 +1,22 @@ -from collections import namedtuple -from copy import deepcopy import re import textwrap import warnings +from collections import namedtuple +from copy import deepcopy import jinja2 +import pytest +from pytest import warns as assert_warns -from numpydoc.numpydoc import update_config -from numpydoc.xref import DEFAULT_LINKS -from numpydoc.docscrape import NumpyDocString, FunctionDoc, ClassDoc, ParseError +from numpydoc.docscrape import ClassDoc, FunctionDoc, NumpyDocString from numpydoc.docscrape_sphinx import ( - SphinxDocString, SphinxClassDoc, + SphinxDocString, SphinxFunctionDoc, get_doc_object, ) -import pytest -from pytest import raises as assert_raises -from pytest import warns as assert_warns - +from numpydoc.numpydoc import update_config +from numpydoc.xref import DEFAULT_LINKS doc_txt = """\ numpy.multivariate_normal(mean, cov, shape=None, spam=None) @@ -189,7 +187,9 @@ def test_extended_summary(doc): def test_parameters(doc): assert len(doc["Parameters"]) == 4 names = [n for n, _, _ in doc["Parameters"]] - assert all(a == b for a, b in zip(names, ["mean", "cov", "shape"])) + assert all( + a == b for a, b in zip(names, ["mean", "cov", "shape", "dtype"], strict=True) + ) arg, arg_type, desc = doc["Parameters"][1] assert arg_type == "(N, N) ndarray" @@ -211,7 +211,7 @@ def test_parameters(doc): def test_other_parameters(doc): assert len(doc["Other Parameters"]) == 1 assert [n for n, _, _ in doc["Other Parameters"]] == ["spam"] - arg, arg_type, desc = doc["Other Parameters"][0] + _arg, arg_type, desc = doc["Other Parameters"][0] assert arg_type == "parrot" assert desc[0].startswith("A parrot off its mortal coil") @@ -244,7 +244,9 @@ def test_yields(): ("b", "int", "bananas."), ("", "int", "unknowns."), ] - for (arg, arg_type, desc), (arg_, arg_type_, end) in zip(section, truth): + for (arg, arg_type, desc), (arg_, arg_type_, end) in zip( + section, truth, strict=True + ): assert arg == arg_ assert arg_type == arg_type_ assert desc[0].startswith("The number of") @@ -255,7 +257,9 @@ def test_sent(): section = doc_sent["Receives"] assert len(section) == 2 truth = [("b", "int", "bananas."), ("c", "int", "oranges.")] - for (arg, arg_type, desc), (arg_, arg_type_, end) in zip(section, truth): + for (arg, arg_type, desc), (arg_, arg_type_, end) in zip( + section, truth, strict=True + ): assert arg == arg_ assert arg_type == arg_type_ assert desc[0].startswith("The number of") @@ -279,7 +283,9 @@ def test_returnyield(): The number of bananas. """ - assert_raises(ValueError, NumpyDocString, doc_text) + doc = NumpyDocString(doc_text) + assert len(doc["Returns"]) == 1 + assert len(doc["Yields"]) == 2 def test_section_twice(): @@ -314,11 +320,9 @@ class Dummy: def spam(self, a, b): """Spam\n\nSpam spam.""" - pass def ham(self, c, d): """Cheese\n\nNo cheese.""" - pass def dummy_func(arg): """ @@ -376,7 +380,7 @@ def line_by_line_compare(a, b, n_lines=None): a = [l.rstrip() for l in _strip_blank_lines(a).split("\n")][:n_lines] b = [l.rstrip() for l in _strip_blank_lines(b).split("\n")][:n_lines] assert len(a) == len(b) - for ii, (aa, bb) in enumerate(zip(a, b)): + for ii, (aa, bb) in enumerate(zip(a, b, strict=True)): assert aa == bb @@ -895,8 +899,6 @@ class Dummy: func_d """ - pass - s = str(FunctionDoc(Dummy, role="func")) assert ":func:`func_a`, :func:`func_b`" in s assert " some relationship" in s @@ -939,8 +941,6 @@ class BadSection: This class has a nope section. """ - pass - with pytest.warns(UserWarning, match="Unknown section Mope") as record: NumpyDocString(doc_text) assert len(record) == 1 @@ -1083,11 +1083,9 @@ class Dummy: def spam(self, a, b): """Spam\n\nSpam spam.""" - pass def ham(self, c, d): """Cheese\n\nNo cheese.""" - pass @property def spammity(self): @@ -1097,8 +1095,6 @@ def spammity(self): class Ignorable: """local class, to be ignored""" - pass - for cls in (ClassDoc, SphinxClassDoc): doc = cls(Dummy, config=dict(show_class_members=False)) assert "Methods" not in str(doc), (cls, str(doc)) @@ -1126,11 +1122,9 @@ class SubDummy(Dummy): def ham(self, c, d): """Cheese\n\nNo cheese.\nOverloaded Dummy.ham""" - pass def bar(self, a, b): """Bar\n\nNo bar""" - pass for cls in (ClassDoc, SphinxClassDoc): doc = cls( @@ -1215,6 +1209,17 @@ def test_duplicate_signature(): b c + Other Parameters + ---------------- + + another parameter : str + This parameter is less important. + + Notes + ----- + + Some notes about the class. + Examples -------- For usage examples, see `ode`. @@ -1235,10 +1240,6 @@ def test_class_members_doc(): jac : callable ``jac(t, y, *jac_args)`` Bbb. - Examples - -------- - For usage examples, see `ode`. - Attributes ---------- t : float @@ -1263,6 +1264,19 @@ def test_class_members_doc(): b c + Other Parameters + ---------------- + another parameter : str + This parameter is less important. + + Notes + ----- + Some notes about the class. + + Examples + -------- + For usage examples, see `ode`. + """, ) @@ -1272,7 +1286,7 @@ class Foo: @property def an_attribute(self): """Test attribute""" - return None + return @property def no_docstring(self): @@ -1286,12 +1300,12 @@ def no_docstring2(self): def multiline_sentence(self): """This is a sentence. It spans multiple lines.""" - return None + return @property def midword_period(self): """The sentence for numpy.org.""" - return None + return @property def no_period(self): @@ -1300,7 +1314,7 @@ def no_period(self): Apparently. """ - return None + return doc = SphinxClassDoc(Foo, class_doc_txt) line_by_line_compare( @@ -1316,10 +1330,6 @@ def no_period(self): **jac** : callable ``jac(t, y, *jac_args)`` Bbb. - .. rubric:: Examples - - For usage examples, see `ode`. - :Attributes: **t** : float @@ -1357,6 +1367,19 @@ def no_period(self): **c** ===== ========== + :Other Parameters: + + **another parameter** : str + This parameter is less important. + + .. rubric:: Notes + + Some notes about the class. + + .. rubric:: Examples + + For usage examples, see `ode`. + """, ) @@ -1376,7 +1399,7 @@ class Foo: @property def an_attribute(self): """Test attribute""" - return None + return attr_doc = """:Attributes: @@ -1576,11 +1599,12 @@ def __init__(self, a, b): # numpydoc.update_config fails if this config option not present self.numpydoc_validation_checks = set() self.numpydoc_validation_exclude = set() + self.numpydoc_validation_exclude_files = set() self.numpydoc_validation_overrides = dict() xref_aliases_complete = deepcopy(DEFAULT_LINKS) - for key in xref_aliases: - xref_aliases_complete[key] = xref_aliases[key] + for key, val in xref_aliases.items(): + xref_aliases_complete[key] = val config = Config(xref_aliases, xref_aliases_complete) app = namedtuple("config", "config")(config) update_config(app) @@ -1641,6 +1665,63 @@ def val(self): assert class_docstring["Attributes"][0].name == "val" +def test_namedtuple_no_duplicate_attributes(): + """ + Ensure that attributes of namedtuples are not duplicated in the docstring. + + See gh-257 + """ + from collections import namedtuple + + foo = namedtuple("Foo", ("bar", "baz")) + + # Create the SphinxClassDoc object via get_doc_object + sds = get_doc_object(foo) + assert sds["Attributes"] == [] + + +def test_namedtuple_class_docstring(): + """Ensure that class docstring is preserved when inheriting from namedtuple. + + See gh-257 + """ + from collections import namedtuple + + foo = namedtuple("Foo", ("bar", "baz")) + + class MyFoo(foo): + """MyFoo's class docstring""" + + # Create the SphinxClassDoc object via get_doc_object + sds = get_doc_object(MyFoo) + assert sds["Summary"] == ["MyFoo's class docstring"] + + # Example dataclass where constructor params are documented explicit. + # Parameter names/descriptions should be included in the docstring, but + # should not result in a duplicated `Attributes` section + class MyFooWithParams(foo): + """ + MyFoo's class docstring + + Parameters + ---------- + bar : str + The bar attribute + baz : str + The baz attribute + """ + + bar: str + baz: str + + sds = get_doc_object(MyFooWithParams) + assert "MyFoo's class docstring" in sds["Summary"] + assert len(sds["Attributes"]) == 0 + assert len(sds["Parameters"]) == 2 + assert sds["Parameters"][0].desc[0] == "The bar attribute" + assert sds["Parameters"][1].desc[0] == "The baz attribute" + + if __name__ == "__main__": import pytest diff --git a/numpydoc/tests/test_full.py b/numpydoc/tests/test_full.py index c4ae1340..9ab241fa 100644 --- a/numpydoc/tests/test_full.py +++ b/numpydoc/tests/test_full.py @@ -1,13 +1,12 @@ import os.path as op import re import shutil -from packaging import version import pytest -import sphinx +from docutils import __version__ as docutils_version +from packaging import version from sphinx.application import Sphinx from sphinx.util.docutils import docutils_namespace -from docutils import __version__ as docutils_version # Test framework adapted from sphinx-gallery (BSD 3-clause) diff --git a/numpydoc/tests/test_main.py b/numpydoc/tests/test_main.py index 1f90b967..e07e7df2 100644 --- a/numpydoc/tests/test_main.py +++ b/numpydoc/tests/test_main.py @@ -1,8 +1,11 @@ -import sys +import inspect import io +import sys + import pytest + import numpydoc -import numpydoc.__main__ +import numpydoc.cli def _capture_stdout(func_name, *args, **kwargs): @@ -30,7 +33,7 @@ def _capture_stdout(func_name, *args, **kwargs): Examples -------- - >>> _capture_stdout(print, 'hello world') + >>> _capture_stdout(print, "hello world") 'hello world' """ f = io.StringIO() @@ -50,7 +53,6 @@ def _docstring_with_errors(): ---------- made_up_param : str """ - pass def _invalid_docstring(): @@ -61,45 +63,43 @@ def _invalid_docstring(): -------- : this is invalid """ - pass def test_renders_package_docstring(): - out = _capture_stdout(numpydoc.__main__.render_object, "numpydoc") + out = _capture_stdout(numpydoc.cli.render_object, "numpydoc") assert out.startswith("This package provides the numpydoc Sphinx") -def test_renders_module_docstring(): - out = _capture_stdout(numpydoc.__main__.render_object, "numpydoc.__main__") - assert out.startswith("Implementing `python -m numpydoc` functionality.") +def test_renders_module_docstring(capsys): + numpydoc.cli.main(["render", "numpydoc.cli"]) + out = capsys.readouterr().out.strip("\n\r") + assert out.startswith(numpydoc.cli.__doc__) def test_renders_function_docstring(): out = _capture_stdout( - numpydoc.__main__.render_object, "numpydoc.tests.test_main._capture_stdout" + numpydoc.cli.render_object, "numpydoc.tests.test_main._capture_stdout" ) assert out.startswith("Return stdout of calling") def test_render_object_returns_correct_exit_status(): - exit_status = numpydoc.__main__.render_object( - "numpydoc.tests.test_main._capture_stdout" - ) + exit_status = numpydoc.cli.render_object("numpydoc.tests.test_main._capture_stdout") assert exit_status == 0 with pytest.raises(ValueError): - numpydoc.__main__.render_object("numpydoc.tests.test_main._invalid_docstring") + numpydoc.cli.render_object("numpydoc.tests.test_main._invalid_docstring") def test_validate_detects_errors(): out = _capture_stdout( - numpydoc.__main__.validate_object, + numpydoc.cli.validate_object, "numpydoc.tests.test_main._docstring_with_errors", ) assert "SS02" in out assert "Summary does not start with a capital letter" in out - exit_status = numpydoc.__main__.validate_object( + exit_status = numpydoc.cli.validate_object( "numpydoc.tests.test_main._docstring_with_errors" ) assert exit_status > 0 @@ -107,11 +107,39 @@ def test_validate_detects_errors(): def test_validate_perfect_docstring(): out = _capture_stdout( - numpydoc.__main__.validate_object, "numpydoc.tests.test_main._capture_stdout" + numpydoc.cli.validate_object, "numpydoc.tests.test_main._capture_stdout" ) assert out == "" - exit_status = numpydoc.__main__.validate_object( + exit_status = numpydoc.cli.validate_object( "numpydoc.tests.test_main._capture_stdout" ) assert exit_status == 0 + + +@pytest.mark.parametrize("args", [[], ["--ignore", "SS03"]]) +def test_lint(capsys, args): + argv = ["lint", "numpydoc/__main__.py"] + args + if args: + expected = "" + expected_status = 0 + else: + expected = "numpydoc/__main__.py:1: SS03 Summary does not end with a period" + expected_status = 1 + + return_status = numpydoc.cli.main(argv) + err = capsys.readouterr().err.strip("\n\r") + assert err == expected + assert return_status == expected_status + + +def test_lint_help(capsys): + """Test that lint help section is displaying.""" + + with pytest.raises(SystemExit): + return_code = numpydoc.cli.main(["lint", "--help"]) + assert return_code == 0 + + out = capsys.readouterr().out + assert "--ignore" in out + assert "--config" in out diff --git a/numpydoc/tests/test_numpydoc.py b/numpydoc/tests/test_numpydoc.py index 8df2205c..f879d7fb 100644 --- a/numpydoc/tests/test_numpydoc.py +++ b/numpydoc/tests/test_numpydoc.py @@ -1,20 +1,20 @@ -import pytest from collections import defaultdict +from copy import deepcopy from io import StringIO from pathlib import PosixPath -from copy import deepcopy +import pytest from docutils import nodes +from sphinx.ext.autodoc import ALL +from sphinx.util import logging from numpydoc.numpydoc import ( - mangle_docstrings, _clean_text_signature, - update_config, clean_backrefs, + mangle_docstrings, + update_config, ) from numpydoc.xref import DEFAULT_LINKS -from sphinx.ext.autodoc import ALL -from sphinx.util import logging class MockConfig: @@ -31,11 +31,13 @@ class MockConfig: numpydoc_attributes_as_param_list = True numpydoc_validation_checks = set() numpydoc_validation_exclude = set() + numpydoc_validation_exclude_files = set() numpydoc_validation_overrides = dict() class MockBuilder: config = MockConfig() + _translator = None class MockApp: @@ -49,6 +51,7 @@ def __init__(self): self.verbosity = 2 self._warncount = 0 self.warningiserror = False + self._exception_on_warning = False def test_mangle_docstrings_basic(): @@ -150,7 +153,6 @@ def _function_without_seealso_and_examples(): Expect SA01 and EX01 errors if validation enabled. """ - pass return _function_without_seealso_and_examples @@ -286,6 +288,61 @@ def test_clean_backrefs(): assert "id1" in citation["backrefs"] +@pytest.mark.parametrize( + "exclude_files, has_warnings", + [ + ( + [ + r"^doesnt_match_any_file$", + ], + True, + ), + ( + [ + r"^.*test_numpydoc\.py$", + ], + False, + ), + ], +) +def test_mangle_skip_exclude_files(exclude_files, has_warnings): + """ + Check that the regex expressions in numpydoc_validation_files_exclude + are correctly used to skip checks on files that match the patterns. + """ + + def process_something_noop_function(): + """Process something.""" + + app = MockApp() + app.config.numpydoc_validation_checks = {"all"} + + # Class attributes for config persist - need to reset them to unprocessed states. + app.config.numpydoc_validation_exclude = set() # Reset to default... + app.config.numpydoc_validation_overrides = dict() # Reset to default... + + app.config.numpydoc_validation_exclude_files = exclude_files + update_config(app) + + # Setup for catching warnings + status, warning = StringIO(), StringIO() + logging.setup(app, status, warning) + + # Simulate a file that matches the exclude pattern + mangle_docstrings( + app, + "function", + process_something_noop_function.__name__, + process_something_noop_function, + None, + process_something_noop_function.__doc__.split("\n"), + ) + + # Are warnings generated? + print(warning.getvalue()) + assert bool(warning.getvalue()) is has_warnings + + if __name__ == "__main__": import pytest diff --git a/numpydoc/tests/test_validate.py b/numpydoc/tests/test_validate.py index d41e4bd0..b8e17f8d 100644 --- a/numpydoc/tests/test_validate.py +++ b/numpydoc/tests/test_validate.py @@ -1,13 +1,14 @@ -import pytest -import sys import warnings from contextlib import nullcontext -from functools import cached_property, partial -from inspect import getsourcelines, getsourcefile +from functools import cached_property, partial, wraps +from inspect import getsourcefile, getsourcelines -from numpydoc import validate -import numpydoc.tests +import pytest +import numpydoc.tests +from numpydoc import validate +from numpydoc.docscrape import get_doc_object +from numpydoc.validate import Validator validate_one = validate.validate @@ -149,7 +150,6 @@ class GoodDocStrings: def one_liner(self): """Allow one liner docstrings (including quotes).""" # This should fail, but not because of the position of the quotes - pass def plot(self, kind, color="blue", **kwargs): """ @@ -163,7 +163,7 @@ def plot(self, kind, color="blue", **kwargs): kind : str Kind of matplotlib plot, e.g.:: - 'foo' + "foo" color : str, default 'blue' Color name or rgb code. @@ -179,7 +179,6 @@ def plot(self, kind, color="blue", **kwargs): -------- >>> result = 1 + 1 """ - pass def swap(self, arr, i, j, *args, **kwargs): """ @@ -205,7 +204,6 @@ def swap(self, arr, i, j, *args, **kwargs): -------- >>> result = 1 + 1 """ - pass def sample(self): """ @@ -229,7 +227,6 @@ def sample(self): -------- >>> result = 1 + 1 """ - pass def random_letters(self): """ @@ -255,7 +252,6 @@ def random_letters(self): -------- >>> result = 1 + 1 """ - pass def sample_values(self): """ @@ -277,7 +273,6 @@ def sample_values(self): -------- >>> result = 1 + 1 """ - pass def head(self): """ @@ -413,7 +408,6 @@ def contains(self, pat, case=True, na=float("NaN")): >>> s * 2 50 """ - pass def mode(self, axis, numeric_only): """ @@ -445,7 +439,6 @@ def mode(self, axis, numeric_only): -------- >>> result = 1 + 1 """ - pass def good_imports(self): """ @@ -465,7 +458,6 @@ def good_imports(self): >>> datetime.MAXYEAR 9999 """ - pass def no_returns(self): """ @@ -482,7 +474,6 @@ def no_returns(self): -------- >>> result = 1 + 1 """ - pass def empty_returns(self): """ @@ -507,7 +498,7 @@ def say_hello(): if True: return else: - return None + return def warnings(self): """ @@ -528,7 +519,6 @@ def warnings(self): -------- >>> result = 1 + 1 """ - pass def multiple_variables_on_one_line(self, matrix, a, b, i, j): """ @@ -554,7 +544,6 @@ def multiple_variables_on_one_line(self, matrix, a, b, i, j): -------- >>> result = 1 + 1 """ - pass def other_parameters(self, param1, param2): """ @@ -581,7 +570,6 @@ def other_parameters(self, param1, param2): -------- >>> result = 1 + 1 """ - pass def valid_options_in_parameter_description_sets(self, bar): """ @@ -627,7 +615,6 @@ def parameters_with_trailing_underscores(self, str_): -------- >>> result = 1 + 1 """ - pass def parameter_with_wrong_types_as_substrings(self, a, b, c, d, e, f): r""" @@ -661,7 +648,6 @@ def parameter_with_wrong_types_as_substrings(self, a, b, c, d, e, f): -------- >>> result = 1 + 1 """ - pass class BadGenericDocStrings: @@ -692,7 +678,6 @@ def astype(self, dtype): Verb in third-person of the present simple, should be infinitive. """ - pass def astype1(self, dtype): """ @@ -700,7 +685,6 @@ def astype1(self, dtype): Does not start with verb. """ - pass def astype2(self, dtype): """ @@ -708,7 +692,6 @@ def astype2(self, dtype): Missing dot at the end. """ - pass def astype3(self, dtype): """ @@ -717,7 +700,6 @@ def astype3(self, dtype): Summary is too verbose and doesn't fit in a single line. """ - pass def two_linebreaks_between_sections(self, foo): """ @@ -731,7 +713,6 @@ def two_linebreaks_between_sections(self, foo): foo : str Description of foo parameter. """ - pass def linebreak_at_end_of_docstring(self, foo): """ @@ -745,7 +726,6 @@ def linebreak_at_end_of_docstring(self, foo): Description of foo parameter. """ - pass def plot(self, kind, **kwargs): """ @@ -769,7 +749,6 @@ def plot(self, kind, **kwargs): kind: str kind of matplotlib plot """ - pass def unknown_section(self): """ @@ -791,7 +770,7 @@ def sections_in_wrong_order(self): Examples -------- - >>> print('So far Examples is good, as it goes before Parameters') + >>> print("So far Examples is good, as it goes before Parameters") So far Examples is good, as it goes before Parameters See Also @@ -834,7 +813,6 @@ def directives_without_two_colons(self, first, second): .. deprecated 0.00.0 """ - pass class WarnGenericFormat: @@ -851,7 +829,6 @@ def too_short_header_underline(self, a, b): a, b : int Foo bar baz. """ - pass class BadSummaries: @@ -877,19 +854,16 @@ def wrong_line(self): """Quotes are on the wrong line. Both opening and closing.""" - pass def no_punctuation(self): """ Has the right line but forgets punctuation """ - pass def no_capitalization(self): """ provides a lowercase summary. """ - pass def no_infinitive(self): """ @@ -1021,7 +995,6 @@ def blank_lines(self, kind): kind : str Foo bar baz. """ - pass def integer_parameter(self, kind): """ @@ -1032,7 +1005,6 @@ def integer_parameter(self, kind): kind : integer Foo bar baz. """ - pass def string_parameter(self, kind): """ @@ -1043,7 +1015,6 @@ def string_parameter(self, kind): kind : string Foo bar baz. """ - pass def boolean_parameter(self, kind): """ @@ -1054,7 +1025,6 @@ def boolean_parameter(self, kind): kind : boolean Foo bar baz. """ - pass def list_incorrect_parameter_type(self, kind): """ @@ -1065,7 +1035,6 @@ def list_incorrect_parameter_type(self, kind): kind : list of boolean, integer, float or string Foo bar baz. """ - pass def bad_parameter_spacing(self, a, b): """ @@ -1076,7 +1045,6 @@ def bad_parameter_spacing(self, a, b): a, b : int Foo bar baz. """ - pass class BadReturns: @@ -1170,7 +1138,6 @@ def no_desc(self): -------- Series.tail """ - pass def desc_no_period(self): """ @@ -1182,7 +1149,6 @@ def desc_no_period(self): Series.iloc : Return a slice of the elements in the Series, which can also be used to return the first or last n """ - pass def desc_first_letter_lowercase(self): """ @@ -1194,7 +1160,6 @@ def desc_first_letter_lowercase(self): Series.iloc : Return a slice of the elements in the Series, which can also be used to return the first or last n. """ - pass def prefix_pandas(self): """ @@ -1205,7 +1170,6 @@ def prefix_pandas(self): pandas.Series.rename : Alter Series index labels or name. DataFrame.head : The first `n` rows of the caller object. """ - pass class BadExamples: @@ -1213,27 +1177,131 @@ def missing_whitespace_around_arithmetic_operator(self): """ Examples -------- - >>> 2+5 + >>> 2 + 5 7 """ - pass def indentation_is_not_a_multiple_of_four(self): """ Examples -------- >>> if 2 + 5: - ... pass + ... pass """ - pass def missing_whitespace_after_comma(self): """ Examples -------- >>> import datetime - >>> value = datetime.date(2019,1,1) + >>> value = datetime.date(2019, 1, 1) + """ + + +class ConstructorDocumentedInClassAndInit: + """ + Class to test constructor documented via class and constructor docstrings. + + A case where both the class docstring and the constructor docstring are + defined. + + Parameters + ---------- + param1 : int + Description of param1. + + See Also + -------- + otherclass : A class that does something else. + + Examples + -------- + This is an example of how to use ConstructorDocumentedInClassAndInit. + """ + + def __init__(self, param1: int) -> None: + """ + Constructor docstring with additional information. + + Extended information. + + Parameters + ---------- + param1 : int + Description of param1 with extra details. + + See Also + -------- + otherclass : A class that does something else. + + Examples + -------- + This is an example of how to use ConstructorDocumentedInClassAndInit. """ + + +class ConstructorDocumentedInClass: + """ + Class to test constructor documented via class docstring. + + Useful to ensure that validation of `__init__` does not signal GL08, + when the class docstring properly documents the `__init__` constructor. + + Parameters + ---------- + param1 : int + Description of param1. + + See Also + -------- + otherclass : A class that does something else. + + Examples + -------- + This is an example of how to use ConstructorDocumentedInClass. + """ + + def __init__(self, param1: int) -> None: + pass + + +class ConstructorDocumentedInClassWithNoParameters: + """ + Class to test constructor documented via class docstring with no parameters. + + Useful to ensure that validation of `__init__` does not signal GL08, + when the class docstring properly documents the `__init__` constructor. + + See Also + -------- + otherclass : A class that does something else. + + Examples + -------- + This is an example of how to use ConstructorDocumentedInClassWithNoParameters. + """ + + def __init__(self) -> None: + pass + + +class IncompleteConstructorDocumentedInClass: + """ + Class to test an incomplete constructor docstring. + + This class does not properly document parameters. + Unnecessary extended summary. + + See Also + -------- + otherclass : A class that does something else. + + Examples + -------- + This is an example of how to use IncompleteConstructorDocumentedInClass. + """ + + def __init__(self, param1: int): pass @@ -1323,11 +1391,8 @@ def test_bad_class(self, capsys): ], ) def test_bad_generic_functions(self, capsys, func): - with pytest.warns(UserWarning): - errors = validate_one( - self._import_path(klass="WarnGenericFormat", func=func) # noqa:F821 - ) - assert "is too short" in w.msg + with pytest.warns(UserWarning, match="is too short"): + validate_one(self._import_path(klass="WarnGenericFormat", func=func)) @pytest.mark.parametrize( "func", @@ -1343,7 +1408,7 @@ def test_bad_generic_functions(self, capsys, func): ) def test_bad_generic_functions(self, capsys, func): errors = validate_one( - self._import_path(klass="BadGenericDocStrings", func=func) # noqa:F821 + self._import_path(klass="BadGenericDocStrings", func=func) )["errors"] assert isinstance(errors, list) assert errors @@ -1575,6 +1640,40 @@ def test_bad_docstrings(self, capsys, klass, func, msgs): for msg in msgs: assert msg in " ".join(err[1] for err in result["errors"]) + @pytest.mark.parametrize( + "klass,exp_init_codes,exc_init_codes,exp_klass_codes", + [ + ("ConstructorDocumentedInClass", tuple(), ("GL08",), tuple()), + ("ConstructorDocumentedInClassAndInit", tuple(), ("GL08",), tuple()), + ( + "ConstructorDocumentedInClassWithNoParameters", + tuple(), + ("GL08",), + tuple(), + ), + ( + "IncompleteConstructorDocumentedInClass", + ("GL08",), + tuple(), + ("PR01"), # Parameter not documented in class constructor + ), + ], + ) + def test_constructor_docstrings( + self, klass, exp_init_codes, exc_init_codes, exp_klass_codes + ): + # First test the class docstring itself, checking expected_klass_codes match + result = validate_one(self._import_path(klass=klass)) + for err in result["errors"]: + assert err[0] in exp_klass_codes + + # Then test the constructor docstring + result = validate_one(self._import_path(klass=klass, func="__init__")) + for code in exp_init_codes: + assert code in " ".join(err[0] for err in result["errors"]) + for code in exc_init_codes: + assert code not in " ".join(err[0] for err in result["errors"]) + def decorator(x): """Test decorator.""" @@ -1599,24 +1698,20 @@ class DecoratorClass: def test_no_decorator(self): """Test method without decorators.""" - pass @property def test_property(self): """Test property method.""" - pass @cached_property def test_cached_property(self): """Test property method.""" - pass @decorator @decorator @decorator def test_three_decorators(self): """Test method with three decorators.""" - pass class TestValidatorClass: @@ -1636,15 +1731,12 @@ def test_raises_for_invalid_attribute_name(self, invalid_name): with pytest.raises(AttributeError, match=msg): numpydoc.validate.Validator._load_obj(invalid_name) - # inspect.getsourcelines does not return class decorators for Python 3.8. This was - # fixed starting with 3.9: https://github.com/python/cpython/issues/60060. @pytest.mark.parametrize( ["decorated_obj", "def_line"], [ [ "numpydoc.tests.test_validate.DecoratorClass", - getsourcelines(DecoratorClass)[-1] - + (2 if sys.version_info.minor > 8 else 0), + getsourcelines(DecoratorClass)[-1] + 2, ], [ "numpydoc.tests.test_validate.DecoratorClass.test_no_decorator", @@ -1692,3 +1784,43 @@ def test_source_file_name_with_properties(self, property, file_name): ) ) assert doc.source_file_name == file_name + + +def test_is_generator_validation_with_decorator(): + """Ensure that the check for a Yields section when an object is a generator + (YD01) works with decorated generators.""" + + def tinsel(f): + @wraps(f) + def wrapper(*args, **kwargs): + return f(*args, **kwargs) + + return wrapper + + def foo(): + """A simple generator""" + yield from range(10) + + @tinsel + def bar(): + """Generator wrapped once""" + yield from range(10) + + @tinsel + @tinsel + @tinsel + def baz(): + """Generator wrapped multiple times""" + yield from range(10) + + # foo without wrapper is a generator + v = Validator(get_doc_object(foo)) + assert v.is_generator_function + + # Wrapped once + v = Validator(get_doc_object(bar)) + assert v.is_generator_function + + # Wrapped multiple times + v = Validator(get_doc_object(baz)) + assert v.is_generator_function diff --git a/numpydoc/tests/test_xref.py b/numpydoc/tests/test_xref.py index b4b31252..2256f759 100644 --- a/numpydoc/tests/test_xref.py +++ b/numpydoc/tests/test_xref.py @@ -1,5 +1,6 @@ import pytest -from numpydoc.xref import make_xref, DEFAULT_LINKS + +from numpydoc.xref import DEFAULT_LINKS, make_xref # Use the default numpydoc link mapping xref_aliases = DEFAULT_LINKS @@ -99,7 +100,7 @@ dict[tuple(str, str), int] :class:`python:dict`\[:class:`python:tuple`\(:class:`python:str`, :class:`python:str`), :class:`python:int`] -""" # noqa: E501 +""" data_ignore_obj = r""" (...) array_like, float, optional @@ -194,7 +195,7 @@ dict[tuple(str, str), int] :class:`python:dict`\[:class:`python:tuple`\(:class:`python:str`, :class:`python:str`), :class:`python:int`] -""" # noqa: E501 +""" xref_ignore = {"or", "in", "of", "default", "optional"} diff --git a/numpydoc/tests/tinybuild/conf.py b/numpydoc/tests/tinybuild/conf.py index fb3b5283..a0719227 100644 --- a/numpydoc/tests/tinybuild/conf.py +++ b/numpydoc/tests/tinybuild/conf.py @@ -4,7 +4,7 @@ path = os.path.dirname(__file__) if path not in sys.path: sys.path.insert(0, path) -import numpydoc_test_module # noqa +import numpydoc_test_module extensions = [ "sphinx.ext.autodoc", @@ -15,7 +15,6 @@ autosummary_generate = True autodoc_default_options = {"inherited-members": None} source_suffix = ".rst" -master_doc = "index" # NOTE: will be changed to `root_doc` in sphinx 4 exclude_patterns = ["_build"] intersphinx_mapping = { "python": ("https://docs.python.org/3", None), diff --git a/numpydoc/tests/tinybuild/numpydoc_test_module.py b/numpydoc/tests/tinybuild/numpydoc_test_module.py index d303e9e1..d0415714 100644 --- a/numpydoc/tests/tinybuild/numpydoc_test_module.py +++ b/numpydoc/tests/tinybuild/numpydoc_test_module.py @@ -37,7 +37,6 @@ class MyClass: def example(self, x): """Example method.""" - pass def my_function(*args, **kwargs): @@ -61,4 +60,4 @@ def my_function(*args, **kwargs): ---------- .. [3] https://numpydoc.readthedocs.io """ - return None + return diff --git a/numpydoc/validate.py b/numpydoc/validate.py index 7275758b..18373b77 100644 --- a/numpydoc/validate.py +++ b/numpydoc/validate.py @@ -6,8 +6,6 @@ with all the detected errors. """ -from copy import deepcopy -from typing import Dict, List, Set, Optional import ast import collections import functools @@ -18,13 +16,14 @@ import re import textwrap import tokenize +from copy import deepcopy +from typing import Any, Dict, List, Set from .docscrape import get_doc_object - DIRECTIVES = ["versionadded", "versionchanged", "deprecated"] DIRECTIVE_PATTERN = re.compile( - r"^\s*\.\. ({})(?!::)".format("|".join(DIRECTIVES)), re.I | re.M + r"^\s*\.\. ({})(?!::)".format("|".join(DIRECTIVES)), re.IGNORECASE | re.MULTILINE ) ALLOWED_SECTIONS = [ "Parameters", @@ -81,8 +80,7 @@ "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", + "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", @@ -112,14 +110,21 @@ IGNORE_COMMENT_PATTERN = re.compile("(?:.* numpydoc ignore[=|:] ?)(.+)") +def _unwrap(obj): + """Iteratively traverse obj.__wrapped__ until first non-wrapped obj found.""" + while hasattr(obj, "__wrapped__"): + obj = obj.__wrapped__ + return obj + + # This function gets called once per function/method to be validated. # We have to balance memory usage with performance here. It shouldn't be too # bad to store these `dict`s (they should be rare), but to be safe let's keep # the limit low-ish. This was set by looking at scipy, numpy, matplotlib, -# and pandas and they had between ~500 and ~1300 .py files as of 2023-08-16. +# and pandas, and they had between ~500 and ~1300 .py files as of 2023-08-16. @functools.lru_cache(maxsize=2000) def extract_ignore_validation_comments( - filepath: Optional[os.PathLike], + filepath: os.PathLike | None, encoding: str = "utf-8", ) -> Dict[int, List[str]]: """ @@ -212,7 +217,7 @@ def error(code, **kwargs): message : str Error message with variables replaced. """ - return (code, ERROR_MSGS[code].format(**kwargs)) + return code, ERROR_MSGS[code].format(**kwargs) class Validator: @@ -245,10 +250,10 @@ def _load_obj(name): Examples -------- - >>> Validator._load_obj('datetime.datetime') + >>> Validator._load_obj("datetime.datetime") """ - for maxsplit in range(0, name.count(".") + 1): + for maxsplit in range(name.count(".") + 1): module, *func_parts = name.rsplit(".", maxsplit) try: obj = importlib.import_module(module) @@ -271,9 +276,13 @@ def type(self): def is_function_or_method(self): return inspect.isfunction(self.obj) + @property + def is_mod(self): + return inspect.ismodule(self.obj) + @property def is_generator_function(self): - return inspect.isgeneratorfunction(self.obj) + return inspect.isgeneratorfunction(_unwrap(self.obj)) @property def source_file_name(self): @@ -290,12 +299,18 @@ def source_file_name(self): except TypeError: # In some cases the object is something complex like a cython - # object that can't be easily introspected. An it's better to + # object that can't be easily introspected. And it's better to # return the source code file of the object as None, than crash pass else: return fname + # When calling validate, files are parsed twice + @staticmethod + @functools.lru_cache(maxsize=4000) + def _getsourcelines(obj: Any): + return inspect.getsourcelines(obj) + @property def source_file_def_line(self): """ @@ -303,11 +318,11 @@ def source_file_def_line(self): """ try: if isinstance(self.code_obj, property): - sourcelines = inspect.getsourcelines(self.code_obj.fget) + sourcelines = self._getsourcelines(self.code_obj.fget) elif isinstance(self.code_obj, functools.cached_property): - sourcelines = inspect.getsourcelines(self.code_obj.func) + sourcelines = self._getsourcelines(self.code_obj.func) else: - sourcelines = inspect.getsourcelines(self.code_obj) + sourcelines = self._getsourcelines(self.code_obj) # getsourcelines will return the line of the first decorator found for the # current function. We have to find the def declaration after that. def_line = next( @@ -320,7 +335,7 @@ def source_file_def_line(self): return sourcelines[-1] + def_line except (OSError, TypeError): # In some cases the object is something complex like a cython - # object that can't be easily introspected. An it's better to + # object that can't be easily introspected. And it's better to # return the line number as None, than crash pass @@ -521,9 +536,9 @@ def get_returns_not_on_nested_functions(node): if tree: returns = get_returns_not_on_nested_functions(tree[0]) return_values = [r.value for r in returns] - # Replace NameConstant nodes valued None for None. + # Replace Constant nodes valued None for None. for i, v in enumerate(return_values): - if isinstance(v, ast.NameConstant) and v.value is None: + if isinstance(v, ast.Constant) and v.value is None: return_values[i] = None return any(return_values) else: @@ -613,7 +628,7 @@ 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 + # 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 @@ -621,7 +636,29 @@ def validate(obj_name, validator_cls=None, **validator_kwargs): errs = [] if not doc.raw_doc: - if "GL08" not in ignore_validation_comments: + 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)) + + # 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 + + # Check if GL08 is to be ignored: + if "GL08" in ignore_validation_comments: + report_GL08 = False + # Add GL08 error? + if report_GL08: errs.append(error("GL08")) return { "type": doc.type, @@ -673,12 +710,18 @@ def validate(obj_name, validator_cls=None, **validator_kwargs): errs.append(error("SS03")) if doc.summary != doc.summary.lstrip(): errs.append(error("SS04")) - elif doc.is_function_or_method and doc.summary.split(" ")[0][-1] == "s": + # Heuristic to check for infinitive verbs - shouldn't end in "s" + elif ( + doc.is_function_or_method + and len(doc.summary.split(" ")[0]) > 1 + and doc.summary.split(" ")[0][-1] == "s" + and doc.summary.split(" ")[0][-2] != "s" + ): errs.append(error("SS05")) if doc.num_summary_lines > 1: errs.append(error("SS06")) - if not doc.extended_summary: + if not doc.is_mod and not doc.extended_summary: errs.append(("ES01", "No extended summary found")) # PR01: Parameters not documented @@ -730,20 +773,21 @@ def validate(obj_name, validator_cls=None, **validator_kwargs): if not doc.yields and doc.is_generator_function: errs.append(error("YD01")) - if not doc.see_also: - errs.append(error("SA01")) - else: - for rel_name, rel_desc in doc.see_also.items(): - if rel_desc: - if not rel_desc.endswith("."): - errs.append(error("SA02", reference_name=rel_name)) - if rel_desc[0].isalpha() and not rel_desc[0].isupper(): - errs.append(error("SA03", reference_name=rel_name)) - else: - errs.append(error("SA04", reference_name=rel_name)) + if not doc.is_mod: + if not doc.see_also: + errs.append(error("SA01")) + else: + for rel_name, rel_desc in doc.see_also.items(): + if rel_desc: + if not rel_desc.endswith("."): + errs.append(error("SA02", reference_name=rel_name)) + if rel_desc[0].isalpha() and not rel_desc[0].isupper(): + errs.append(error("SA03", reference_name=rel_name)) + else: + errs.append(error("SA04", reference_name=rel_name)) - if not doc.examples: - errs.append(error("EX01")) + if not doc.examples: + errs.append(error("EX01")) errs = [err for err in errs if err[0] not in ignore_validation_comments] diff --git a/numpydoc/xref.py b/numpydoc/xref.py index a0cc8a5d..f1b9d79f 100644 --- a/numpydoc/xref.py +++ b/numpydoc/xref.py @@ -16,11 +16,7 @@ QUALIFIED_NAME_RE = re.compile( # e.g int, numpy.array, ~numpy.array, .class_in_current_module - r"^" - r"[~\.]?" - r"[a-zA-Z_]\w*" - r"(?:\.[a-zA-Z_]\w*)*" - r"$" + r"^" r"[~\.]?" r"[a-zA-Z_]\w*" r"(?:\.[a-zA-Z_]\w*)*" r"$" ) CONTAINER_SPLIT_RE = re.compile( diff --git a/pyproject.toml b/pyproject.toml index 374008b5..b8911d85 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,28 +6,28 @@ requires = ['setuptools>=61.2'] name = 'numpydoc' description = 'Sphinx extension to support docstrings in Numpy format' readme = 'README.rst' -requires-python = '>=3.8' +requires-python = '>=3.10' dynamic = ['version'] keywords = [ 'sphinx', 'numpy', ] classifiers = [ - 'Development Status :: 4 - Beta', + 'Development Status :: 5 - Production/Stable', 'Environment :: Plugins', + 'Intended Audience :: Developers', 'License :: OSI Approved :: BSD License', - 'Topic :: Documentation', + 'Operating System :: OS Independent', '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', + 'Programming Language :: Python :: 3.12', + 'Programming Language :: Python :: 3.13', + 'Topic :: Documentation', ] dependencies = [ - 'sphinx>=5', - 'Jinja2>=2.10', - 'tabulate>=0.8.10', + 'sphinx>=6', "tomli>=1.1.0;python_version<'3.11'", ] @@ -42,16 +42,19 @@ file = 'LICENSE.txt' Homepage = 'https://numpydoc.readthedocs.io' Source = 'https://github.com/numpy/numpydoc/' -[project.optional-dependencies] -developer = [ +[dependency-groups] +dev = [ 'pre-commit>=3.3', "tomli; python_version < '3.11'", + { include-group = "doc" }, + { include-group = "test" } ] doc = [ 'numpy>=1.22', 'matplotlib>=3.5', 'pydata-sphinx-theme>=0.13.3', 'sphinx>=7', + 'intersphinx_registry', ] test = [ 'pytest', @@ -60,7 +63,99 @@ test = [ ] [project.scripts] -validate-docstrings = 'numpydoc.hooks.validate_docstrings:main' +numpydoc = 'numpydoc.cli:main' + +[tool.changelist] +title_template = "{version}" +# Profiles that are excluded from the contributor list. +ignored_user_logins = ["dependabot[bot]", "pre-commit-ci[bot]", "web-flow"] + +[tool.ruff.lint] +extend-select = [ + "B", # flake8-bugbear + "I", # isort + "ARG", # flake8-unused-arguments + "C4", # flake8-comprehensions + "EM", # flake8-errmsg + "ICN", # flake8-import-conventions + "G", # flake8-logging-format + "PGH", # pygrep-hooks + "PIE", # flake8-pie + "PL", # pylint + "PT", # flake8-pytest-style + "PTH", # flake8-use-pathlib + "RET", # flake8-return + "RUF", # Ruff-specific + "SIM", # flake8-simplify + "T20", # flake8-print + "UP", # pyupgrade + "YTT", # flake8-2020 + "EXE", # flake8-executable + "NPY", # NumPy specific rules + "PD", # pandas-vet + "FURB", # refurb + "PYI", # flake8-pyi +] +ignore = [ + "PLR09", # Too many <...> + "PLR2004", # Magic value used in comparison + "PLC0415", # Imports not at top of file (we often nest intentionally) + "ISC001", # Conflicts with formatter + "ARG001", # FIXME: consider removing this and the following rules from this list + "ARG002", + "B004", + "B007", + "B023", + "B028", + "B034", + "C408", + "E402", + "E741", + "EM101", + "EM102", + "EXE001", + "F401", + "F811", + "F821", + "F841", + "PIE810", + "PLW0603", + "PLW2901", + "PLW3301", + "PT006", + "PT007", + "PT011", + "PT012", + "PT013", + "PTH100", + "PTH118", + "PTH120", + "PTH123", + "PYI024", + "RET503", + "RET504", + "RET505", + "RET506", + "RET507", + "RET508", + "RUF005", + "RUF012", + "RUF013", + "SIM102", + "SIM105", + "SIM108", + "SIM115", + "T201", + "UP006", + "UP031", + "UP035", +] + +[tool.ruff.lint.per-file-ignores] +"doc/example.py" = ["ARG001", "F401", "I001"] + +[tool.ruff.format] +docstring-code-format = true [tool.setuptools] include-package-data = false @@ -82,6 +177,6 @@ numpydoc = [ [tool.pytest.ini_options] addopts = ''' ---showlocals --doctest-modules -ra --cov-report= --cov=numpydoc +--showlocals --doctest-modules --cov-report= --cov=numpydoc --junit-xml=junit-results.xml --ignore=doc/ --ignore=tools/''' junit_family = 'xunit2' diff --git a/requirements/default.txt b/requirements/default.txt deleted file mode 100644 index 1b80561c..00000000 --- a/requirements/default.txt +++ /dev/null @@ -1,6 +0,0 @@ -# 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 deleted file mode 100644 index dbeefe57..00000000 --- a/requirements/developer.txt +++ /dev/null @@ -1,4 +0,0 @@ -# Generated via tools/generate_requirements.py and pre-commit hook. -# Do not edit this file; modify pyproject.toml instead. -pre-commit>=3.3 -tomli; python_version < '3.11' diff --git a/requirements/doc.txt b/requirements/doc.txt deleted file mode 100644 index f3d9b058..00000000 --- a/requirements/doc.txt +++ /dev/null @@ -1,6 +0,0 @@ -# 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.3 -sphinx>=7 diff --git a/requirements/test.txt b/requirements/test.txt deleted file mode 100644 index 2a9e670f..00000000 --- a/requirements/test.txt +++ /dev/null @@ -1,5 +0,0 @@ -# Generated via tools/generate_requirements.py and pre-commit hook. -# Do not edit this file; modify pyproject.toml instead. -pytest -pytest-cov -matplotlib diff --git a/tools/generate_requirements.py b/tools/generate_requirements.py deleted file mode 100755 index 388d2ccc..00000000 --- a/tools/generate_requirements.py +++ /dev/null @@ -1,39 +0,0 @@ -#!/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()