From ef2f16bc4824ca2b10b7f2845baf4d313c9c0da1 Mon Sep 17 00:00:00 2001 From: David Smith Date: Sun, 1 Jun 2025 22:38:04 +0100 Subject: [PATCH] Refs #36485 -- Added sphinx-lint support and make lint rule for docs. This adds a `lint.py` script to run sphinx-lint on Django's docs files, a mathing `lint` target in the `docs/Makefile` and `docs/make.bat`, and updates `docs/requirements.txt` accordingly. --- docs/Makefile | 6 ++ docs/lint.py | 152 ++++++++++++++++++++++++++++++++++++++++++ docs/make.bat | 12 ++++ docs/requirements.txt | 1 + 4 files changed, 171 insertions(+) create mode 100644 docs/lint.py diff --git a/docs/Makefile b/docs/Makefile index 9935ad09e5..78cbccd00e 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -51,6 +51,7 @@ help: @echo " doctest to run all doctests embedded in the documentation (if enabled)" @echo " spelling to check for typos in documentation" @echo " black to apply the black formatting to code blocks in documentation" + @echo " lint to check for linting errors in documentation" clean: @@ -175,6 +176,11 @@ black: @echo @echo "Code blocks reformatted" +lint: + $(PYTHON) lint.py + @echo + @echo "Documentation lint complete." + check: spelling black @echo @echo "Style and spelling checks completed." diff --git a/docs/lint.py b/docs/lint.py new file mode 100644 index 0000000000..ccd0c7138b --- /dev/null +++ b/docs/lint.py @@ -0,0 +1,152 @@ +import re +import sys +from collections import Counter +from os.path import abspath, dirname, splitext +from unittest import mock + +from sphinxlint.checkers import ( + _is_long_interpreted_text, + _is_very_long_string_literal, + _starts_with_anonymous_hyperlink, + _starts_with_directive_or_hyperlink, +) +from sphinxlint.checkers import checker as sphinxlint_checker +from sphinxlint.sphinxlint import check_text +from sphinxlint.utils import PER_FILE_CACHES, hide_non_rst_blocks + + +def django_check_file(filename, checkers, options=None): + try: + for checker in checkers: + # Django docs use ".txt" for docs file extension. + if ".rst" in checker.suffixes: + checker.suffixes = (".txt",) + ext = splitext(filename)[1] + if not any(ext in checker.suffixes for checker in checkers): + return Counter() + try: + with open(filename, encoding="utf-8") as f: + text = f.read() + except OSError as err: + return [f"{filename}: cannot open: {err}"] + except UnicodeDecodeError as err: + return [f"{filename}: cannot decode as UTF-8: {err}"] + return check_text(filename, text, checkers, options) + finally: + for memoized_function in PER_FILE_CACHES: + memoized_function.cache_clear() + + +_TOCTREE_DIRECTIVE_RE = re.compile(r"^ *.. toctree::") +_PARSED_LITERAL_DIRECTIVE_RE = re.compile(r"^ *.. parsed-literal::") +_IS_METHOD_RE = re.compile(r"^ *([\w.]+)\([\w ,*]*\)\s*$") +# https://www.sphinx-doc.org/en/master/usage/restructuredtext/basics.html +# Use two trailing underscores when embedding the URL. Technically, a single +# underscore works as well, but that would create a named reference instead of +# an anonymous one. Named references typically do not have a benefit when the +# URL is embedded. Moreover, they have the disadvantage that you must make sure +# that you do not use the same “Link text” for another link in your document. +_HYPERLINK_DANGLING_RE = re.compile(r"^\s*]+>`__?[\.,;]?$") + + +@sphinxlint_checker(".rst", enabled=False, rst_only=True) +def check_line_too_long_django(file, lines, options=None): + """A modified version of Sphinx-lint's line-too-long check. + + Original: + https://github.com/sphinx-contrib/sphinx-lint/blob/main/sphinxlint/checkers.py + """ + + def is_multiline_block_to_exclude(line): + return _TOCTREE_DIRECTIVE_RE.match(line) or _PARSED_LITERAL_DIRECTIVE_RE.match( + line + ) + + # Ignore additional blocks from line length checks. + with mock.patch( + "sphinxlint.utils.is_multiline_non_rst_block", is_multiline_block_to_exclude + ): + lines = hide_non_rst_blocks(lines) + + table_rows = [] + for lno, line in enumerate(lines): + # Beware, in `line` we have the trailing newline. + if len(line) - 1 > options.max_line_length: + + # Sphinxlint default exceptions. + if line.lstrip()[0] in "+|": + continue # ignore wide tables + if _is_long_interpreted_text(line): + continue # ignore long interpreted text + if _starts_with_directive_or_hyperlink(line): + continue # ignore directives and hyperlink targets + if _starts_with_anonymous_hyperlink(line): + continue # ignore anonymous hyperlink targets + if _is_very_long_string_literal(line): + continue # ignore a very long literal string + + # Additional exceptions + try: + # Ignore headings + if len(set(lines[lno + 1].strip())) == 1 and len(line) == len( + lines[lno + 1] + ): + continue + except IndexError: + # End of file + continue + if len(set(line.strip())) == 1 and len(line) == len(lines[lno - 1]): + continue # Ignore heading underline + if lno in table_rows: + continue # Ignore lines in tables + if len(set(line.strip())) == 2 and " " in line: + # Ignore simple tables + borders = [lno_ for lno_, line_ in enumerate(lines) if line == line_] + table_rows.extend([n for n in range(min(borders), max(borders))]) + continue + if _HYPERLINK_DANGLING_RE.match(line): + continue # Ignore dangling long links inside a ``_ ref. + if match := _IS_METHOD_RE.match(line): + # Ignore second definition of function signature. + previous_line = lines[lno - 1] + if previous_line.startswith(".. method:: ") and ( + previous_line.find(match[1]) != -1 + ): + continue + yield lno + 1, f"Line too long ({len(line) - 1}/{options.max_line_length})" + + +import sphinxlint # noqa: E402 + +sphinxlint.check_file = django_check_file + +from sphinxlint.cli import main # noqa: E402 + +if __name__ == "__main__": + directory = dirname(abspath(__file__)) + params = sys.argv[1:] if len(sys.argv) > 1 else [] + + print(f"Running sphinxlint for: {directory} {params=}") + + sys.exit( + main( + [ + directory, + "--jobs", + "0", + "--ignore", + "_build", + "--ignore", + "_theme", + "--ignore", + "_ext", + "--enable", + "all", + "--disable", + "line-too-long", # Disable sphinx-lint version + "--max-line-length", + "80", + *params, + ] + ) + ) diff --git a/docs/make.bat b/docs/make.bat index 8376db57f0..27e3c1143d 100644 --- a/docs/make.bat +++ b/docs/make.bat @@ -36,6 +36,7 @@ if "%1" == "help" ( echo. doctest to run all doctests embedded in the documentation if enabled echo. spelling to check for typos in documentation echo. black to apply the black formatting to code blocks in documentation + echo. lint to check for linting errors in documentation goto end ) @@ -198,6 +199,11 @@ if "%1" == "black" ( goto end ) +if "%1" == "lint" ( + call :run_lint + goto end +) + if "%1" == "check" ( call :run_black call :run_spelling @@ -221,4 +227,10 @@ if "%1" == "check" ( echo.Code blocks reformatted exit /b +:run_lint + python lint.py + if errorlevel 1 exit /b 1 + echo. + echo.Documentation lint complete. + :end diff --git a/docs/requirements.txt b/docs/requirements.txt index e234972a3b..49fdc5408a 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -2,3 +2,4 @@ pyenchant Sphinx>=4.5.0 sphinxcontrib-spelling blacken-docs +sphinx-lint