linklint 0.3.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- linklint-0.3.1/PKG-INFO +111 -0
- linklint-0.3.1/README.rst +93 -0
- linklint-0.3.1/pyproject.toml +55 -0
- linklint-0.3.1/setup.cfg +4 -0
- linklint-0.3.1/src/linklint/__init__.py +1 -0
- linklint-0.3.1/src/linklint/cli.py +64 -0
- linklint-0.3.1/src/linklint/dump.py +56 -0
- linklint-0.3.1/src/linklint/ext.py +60 -0
- linklint-0.3.1/src/linklint/linklint.py +239 -0
- linklint-0.3.1/src/linklint/regions.py +76 -0
- linklint-0.3.1/src/linklint/rsthelp.py +109 -0
- linklint-0.3.1/src/linklint/utils.py +58 -0
- linklint-0.3.1/src/linklint.egg-info/PKG-INFO +111 -0
- linklint-0.3.1/src/linklint.egg-info/SOURCES.txt +20 -0
- linklint-0.3.1/src/linklint.egg-info/dependency_links.txt +1 -0
- linklint-0.3.1/src/linklint.egg-info/entry_points.txt +2 -0
- linklint-0.3.1/src/linklint.egg-info/requires.txt +8 -0
- linklint-0.3.1/src/linklint.egg-info/top_level.txt +1 -0
- linklint-0.3.1/tests/test_extension.py +64 -0
- linklint-0.3.1/tests/test_linklint.py +718 -0
- linklint-0.3.1/tests/test_regions.py +137 -0
- linklint-0.3.1/tests/test_rsthelp.py +21 -0
linklint-0.3.1/PKG-INFO
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: linklint
|
|
3
|
+
Version: 0.3.1
|
|
4
|
+
Summary: A Sphinx extension to remove needless links
|
|
5
|
+
Author-email: Ned Batchelder <ned@nedbatchelder.com>
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Classifier: Development Status :: 4 - Beta
|
|
8
|
+
Classifier: Programming Language :: Python
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Description-Content-Type: text/x-rst
|
|
11
|
+
Requires-Dist: Sphinx
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: build; extra == "dev"
|
|
14
|
+
Requires-Dist: coverage; extra == "dev"
|
|
15
|
+
Requires-Dist: pytest; extra == "dev"
|
|
16
|
+
Requires-Dist: ruff; extra == "dev"
|
|
17
|
+
Requires-Dist: ty; extra == "dev"
|
|
18
|
+
|
|
19
|
+
========
|
|
20
|
+
Linklint
|
|
21
|
+
========
|
|
22
|
+
|
|
23
|
+
Linklint checks .rst files for excessive links to references.
|
|
24
|
+
|
|
25
|
+
It also can be used as a Sphinx extension to automatically unlink references
|
|
26
|
+
that should not be links.
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
Checks
|
|
30
|
+
======
|
|
31
|
+
|
|
32
|
+
Linklint has two different checks:
|
|
33
|
+
|
|
34
|
+
- ``self``: find references that link to their own section. For example, in the
|
|
35
|
+
description of a class, use `:class:` referring to itself. These should not
|
|
36
|
+
be links since they will not take you someplace new.
|
|
37
|
+
|
|
38
|
+
- ``paradup``: find multiple identical references within a single paragraph.
|
|
39
|
+
The first should be a link, but subsequent references don't need to be links,
|
|
40
|
+
they are just distractions.
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
Sphinx extension
|
|
44
|
+
================
|
|
45
|
+
|
|
46
|
+
To use linklint as a Sphinx extension, add it to the ``extensions`` list in
|
|
47
|
+
your ``conf.py`` file:
|
|
48
|
+
|
|
49
|
+
.. code:: python
|
|
50
|
+
|
|
51
|
+
extensions = [
|
|
52
|
+
# .. probably other extensions are already here..
|
|
53
|
+
"linklint.ext",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
During the build process, linklint will run all its checks and unlink any
|
|
57
|
+
reference it considers excessive.
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
Command-line use
|
|
61
|
+
================
|
|
62
|
+
|
|
63
|
+
You can use linklint as a command-line linter::
|
|
64
|
+
|
|
65
|
+
% linklint --help
|
|
66
|
+
usage: linklint [-h] [--check CHECK] [--fix] files [files ...]
|
|
67
|
+
|
|
68
|
+
positional arguments:
|
|
69
|
+
files RST files to lint
|
|
70
|
+
|
|
71
|
+
options:
|
|
72
|
+
-h, --help show this help message and exit
|
|
73
|
+
--check CHECK comma-separated checks to run (self, paradup, all)
|
|
74
|
+
--fix Fix the issues in place
|
|
75
|
+
|
|
76
|
+
This can be useful to see what linklint considers excessive, or to modify .rst
|
|
77
|
+
files to unlink excessive references. Linklint unlinks references by changing
|
|
78
|
+
``:func:`foo``` to ``:func:`!foo```.
|
|
79
|
+
|
|
80
|
+
If you agree with linklint's decisions, the Sphinx extension is a better
|
|
81
|
+
option, since it doesn't require changing the source files, and doesn't
|
|
82
|
+
hard-code the decisions.
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
Changes
|
|
86
|
+
=======
|
|
87
|
+
|
|
88
|
+
v0.3.1 (2026-03-01)
|
|
89
|
+
-------------------
|
|
90
|
+
|
|
91
|
+
Published to PyPI.
|
|
92
|
+
|
|
93
|
+
v0.3.0 (2026-02-28)
|
|
94
|
+
-------------------
|
|
95
|
+
|
|
96
|
+
Methods are associated with classes properly in a number of ways.
|
|
97
|
+
|
|
98
|
+
The Sphinx extension now displays the number of references that were unlinked.
|
|
99
|
+
The CPython docs report 3612 references unlinked.
|
|
100
|
+
|
|
101
|
+
v0.2.0 (2026-02-22)
|
|
102
|
+
-------------------
|
|
103
|
+
|
|
104
|
+
Now available as a Sphinx extension. Instead of changing .rst source files,
|
|
105
|
+
the excessive links are automatically unlinked in the generated documentation.
|
|
106
|
+
|
|
107
|
+
v0.1.0 (2026-02-21)
|
|
108
|
+
-------------------
|
|
109
|
+
|
|
110
|
+
First version: works as a linter with ``--check`` and ``--fix`` to change .rst
|
|
111
|
+
source files.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
========
|
|
2
|
+
Linklint
|
|
3
|
+
========
|
|
4
|
+
|
|
5
|
+
Linklint checks .rst files for excessive links to references.
|
|
6
|
+
|
|
7
|
+
It also can be used as a Sphinx extension to automatically unlink references
|
|
8
|
+
that should not be links.
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
Checks
|
|
12
|
+
======
|
|
13
|
+
|
|
14
|
+
Linklint has two different checks:
|
|
15
|
+
|
|
16
|
+
- ``self``: find references that link to their own section. For example, in the
|
|
17
|
+
description of a class, use `:class:` referring to itself. These should not
|
|
18
|
+
be links since they will not take you someplace new.
|
|
19
|
+
|
|
20
|
+
- ``paradup``: find multiple identical references within a single paragraph.
|
|
21
|
+
The first should be a link, but subsequent references don't need to be links,
|
|
22
|
+
they are just distractions.
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
Sphinx extension
|
|
26
|
+
================
|
|
27
|
+
|
|
28
|
+
To use linklint as a Sphinx extension, add it to the ``extensions`` list in
|
|
29
|
+
your ``conf.py`` file:
|
|
30
|
+
|
|
31
|
+
.. code:: python
|
|
32
|
+
|
|
33
|
+
extensions = [
|
|
34
|
+
# .. probably other extensions are already here..
|
|
35
|
+
"linklint.ext",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
During the build process, linklint will run all its checks and unlink any
|
|
39
|
+
reference it considers excessive.
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
Command-line use
|
|
43
|
+
================
|
|
44
|
+
|
|
45
|
+
You can use linklint as a command-line linter::
|
|
46
|
+
|
|
47
|
+
% linklint --help
|
|
48
|
+
usage: linklint [-h] [--check CHECK] [--fix] files [files ...]
|
|
49
|
+
|
|
50
|
+
positional arguments:
|
|
51
|
+
files RST files to lint
|
|
52
|
+
|
|
53
|
+
options:
|
|
54
|
+
-h, --help show this help message and exit
|
|
55
|
+
--check CHECK comma-separated checks to run (self, paradup, all)
|
|
56
|
+
--fix Fix the issues in place
|
|
57
|
+
|
|
58
|
+
This can be useful to see what linklint considers excessive, or to modify .rst
|
|
59
|
+
files to unlink excessive references. Linklint unlinks references by changing
|
|
60
|
+
``:func:`foo``` to ``:func:`!foo```.
|
|
61
|
+
|
|
62
|
+
If you agree with linklint's decisions, the Sphinx extension is a better
|
|
63
|
+
option, since it doesn't require changing the source files, and doesn't
|
|
64
|
+
hard-code the decisions.
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
Changes
|
|
68
|
+
=======
|
|
69
|
+
|
|
70
|
+
v0.3.1 (2026-03-01)
|
|
71
|
+
-------------------
|
|
72
|
+
|
|
73
|
+
Published to PyPI.
|
|
74
|
+
|
|
75
|
+
v0.3.0 (2026-02-28)
|
|
76
|
+
-------------------
|
|
77
|
+
|
|
78
|
+
Methods are associated with classes properly in a number of ways.
|
|
79
|
+
|
|
80
|
+
The Sphinx extension now displays the number of references that were unlinked.
|
|
81
|
+
The CPython docs report 3612 references unlinked.
|
|
82
|
+
|
|
83
|
+
v0.2.0 (2026-02-22)
|
|
84
|
+
-------------------
|
|
85
|
+
|
|
86
|
+
Now available as a Sphinx extension. Instead of changing .rst source files,
|
|
87
|
+
the excessive links are automatically unlinked in the generated documentation.
|
|
88
|
+
|
|
89
|
+
v0.1.0 (2026-02-21)
|
|
90
|
+
-------------------
|
|
91
|
+
|
|
92
|
+
First version: works as a linter with ``--check`` and ``--fix`` to change .rst
|
|
93
|
+
source files.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "linklint"
|
|
3
|
+
description = "A Sphinx extension to remove needless links"
|
|
4
|
+
readme = "README.rst"
|
|
5
|
+
authors = [
|
|
6
|
+
{name = "Ned Batchelder", email = "ned@nedbatchelder.com"},
|
|
7
|
+
]
|
|
8
|
+
license = "Apache-2.0"
|
|
9
|
+
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 4 - Beta",
|
|
12
|
+
"Programming Language :: Python",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
requires-python = ">= 3.12"
|
|
16
|
+
|
|
17
|
+
dependencies = [
|
|
18
|
+
"Sphinx",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
dynamic = ["version"]
|
|
22
|
+
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
dev = [
|
|
25
|
+
"build",
|
|
26
|
+
"coverage",
|
|
27
|
+
"pytest",
|
|
28
|
+
"ruff",
|
|
29
|
+
"ty",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.scripts]
|
|
33
|
+
linklint = "linklint.cli:main"
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.dynamic]
|
|
36
|
+
version.attr = "linklint.__version__"
|
|
37
|
+
|
|
38
|
+
[build-system]
|
|
39
|
+
requires = ["setuptools>=80"]
|
|
40
|
+
build-backend = "setuptools.build_meta"
|
|
41
|
+
|
|
42
|
+
[tool.ruff]
|
|
43
|
+
line-length = 100
|
|
44
|
+
|
|
45
|
+
[tool.coverage.run]
|
|
46
|
+
branch = true
|
|
47
|
+
source = ["src", "tests"]
|
|
48
|
+
|
|
49
|
+
[tool.coverage.report]
|
|
50
|
+
exclude_also = [
|
|
51
|
+
"if __name__ == .__main__.:",
|
|
52
|
+
"pragma: only failure",
|
|
53
|
+
"pragma: debugging",
|
|
54
|
+
"if SAVE_INTERMEDIATE:",
|
|
55
|
+
]
|
linklint-0.3.1/setup.cfg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.1"
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from linklint.linklint import CHECKS, LintIssue, lint_content
|
|
6
|
+
from linklint.utils import plural
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def lint_file(filepath: str, fix: bool, checks: set[str]) -> list[LintIssue]:
|
|
10
|
+
"""Lint a single RST file.
|
|
11
|
+
|
|
12
|
+
Returns a list of LintIssue objects.
|
|
13
|
+
"""
|
|
14
|
+
# print(filepath)
|
|
15
|
+
path = Path(filepath)
|
|
16
|
+
content = path.read_text(encoding="utf-8")
|
|
17
|
+
result = lint_content(content, fix, checks)
|
|
18
|
+
if fix and result.fixed:
|
|
19
|
+
path.write_text(result.content, encoding="utf-8")
|
|
20
|
+
return result.issues
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def linklint(argv: list[str]) -> int:
|
|
24
|
+
parser = argparse.ArgumentParser()
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"--check",
|
|
27
|
+
help=f"comma-separated checks to run ({', '.join(CHECKS)}, all)",
|
|
28
|
+
default="all",
|
|
29
|
+
)
|
|
30
|
+
parser.add_argument("--fix", help="Fix the issues in place", action="store_true")
|
|
31
|
+
parser.add_argument("files", nargs="+", help="RST files to lint")
|
|
32
|
+
args = parser.parse_args(argv)
|
|
33
|
+
|
|
34
|
+
checks = set(args.check.split(","))
|
|
35
|
+
unknown = checks - set(CHECKS.keys()) - {"all"}
|
|
36
|
+
if unknown:
|
|
37
|
+
print(f"Unknown checks: {', '.join(unknown)}", file=sys.stderr)
|
|
38
|
+
return 2
|
|
39
|
+
if "all" in checks:
|
|
40
|
+
checks = set(CHECKS.keys())
|
|
41
|
+
|
|
42
|
+
issues = 0
|
|
43
|
+
fixed = 0
|
|
44
|
+
for filepath in args.files:
|
|
45
|
+
# This runs Sphinx on each file separately, which seems slow, but is
|
|
46
|
+
# faster than running it once on all the files.
|
|
47
|
+
for issue in lint_file(filepath, args.fix, checks):
|
|
48
|
+
fixed_suffix = ""
|
|
49
|
+
if issue.fixed:
|
|
50
|
+
fixed_suffix = " (fixed)"
|
|
51
|
+
fixed += 1
|
|
52
|
+
print(f"{filepath}:{issue.line}: {issue.message}{fixed_suffix}")
|
|
53
|
+
issues += 1
|
|
54
|
+
|
|
55
|
+
summary = f"Checked {plural(len(args.files), 'file')}, found {plural(issues, 'issue')}"
|
|
56
|
+
if args.fix:
|
|
57
|
+
summary += f", fixed {fixed}"
|
|
58
|
+
print(f"{summary}.")
|
|
59
|
+
|
|
60
|
+
return issues > 0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def main():
|
|
64
|
+
sys.exit(linklint(sys.argv[1:]))
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from typing import TextIO
|
|
3
|
+
|
|
4
|
+
from docutils import nodes
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
INTERESTING_KEYS = [
|
|
8
|
+
"ids",
|
|
9
|
+
"names",
|
|
10
|
+
"reftype",
|
|
11
|
+
"reftarget",
|
|
12
|
+
"refuri",
|
|
13
|
+
"domain",
|
|
14
|
+
"objtype",
|
|
15
|
+
"desctype",
|
|
16
|
+
"class",
|
|
17
|
+
"fullname",
|
|
18
|
+
"module",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
# INTERESTING_ATTRS = [
|
|
22
|
+
# "rawsource",
|
|
23
|
+
# ]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def dump_doctree(node: nodes.Node, fp: TextIO, indent: int = 0) -> None: # pragma: debugging
|
|
27
|
+
"""Print a nicely formatted tree of a docutils doctree."""
|
|
28
|
+
prefix = " " * indent
|
|
29
|
+
if isinstance(node, nodes.Text):
|
|
30
|
+
text = node.astext()
|
|
31
|
+
print(f"{prefix}Text: {text!r}", file=fp)
|
|
32
|
+
else:
|
|
33
|
+
tag = node.__class__.__name__
|
|
34
|
+
attrs = []
|
|
35
|
+
for key in INTERESTING_KEYS:
|
|
36
|
+
if val := node.get(key): # type: ignore
|
|
37
|
+
attrs.append(f"{key}={val!r}")
|
|
38
|
+
# for attr in INTERESTING_ATTRS:
|
|
39
|
+
# if val := getattr(node, attr, None):
|
|
40
|
+
# attrs.append(f".{attr}={val!r}")
|
|
41
|
+
attr_str = f" {{{', '.join(attrs)}}}" if attrs else ""
|
|
42
|
+
if tag == "reference":
|
|
43
|
+
print(vars(node), file=fp)
|
|
44
|
+
line = node.line
|
|
45
|
+
line_str = f" @{line}" if line else ""
|
|
46
|
+
print(f"{prefix}{tag}{attr_str}{line_str}", file=fp)
|
|
47
|
+
# print(f"{prefix}{node.attributes}", file=fp)
|
|
48
|
+
for child in node.children:
|
|
49
|
+
dump_doctree(child, fp, indent + 1)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
if __name__ == "__main__":
|
|
53
|
+
from linklint.linklint import parse_rst
|
|
54
|
+
|
|
55
|
+
with open(sys.argv[1]) as f:
|
|
56
|
+
dump_doctree(parse_rst(f.read()), sys.stdout)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from typing import cast
|
|
2
|
+
|
|
3
|
+
from docutils import nodes
|
|
4
|
+
from sphinx.application import Sphinx
|
|
5
|
+
from sphinx.environment import BuildEnvironment
|
|
6
|
+
from sphinx.util import logging
|
|
7
|
+
from sphinx.util.typing import ExtensionMetadata
|
|
8
|
+
|
|
9
|
+
import linklint
|
|
10
|
+
from linklint.linklint import find_duplicate_refs, find_self_refs
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def init_data(app: Sphinx) -> None:
|
|
16
|
+
app.env.linklint_counts: dict[str, int] = {}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def process_reference_nodes(app, doctree):
|
|
20
|
+
"""Find references that shouldn't be links, and un-reference them."""
|
|
21
|
+
# Change <reference><literal></reference> to just <literal>.
|
|
22
|
+
|
|
23
|
+
count = 0
|
|
24
|
+
for finder in (find_self_refs, find_duplicate_refs):
|
|
25
|
+
for ref in finder(doctree):
|
|
26
|
+
cast(nodes.Element, ref.parent).replace(ref, ref.children[0])
|
|
27
|
+
count += 1
|
|
28
|
+
app.env.linklint_counts[app.env.docname] = count
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def merge_data(
|
|
32
|
+
app: Sphinx,
|
|
33
|
+
env: BuildEnvironment,
|
|
34
|
+
docnames: list[str],
|
|
35
|
+
other: BuildEnvironment,
|
|
36
|
+
) -> None:
|
|
37
|
+
# Env's start as copies of other envs, so we have to be careful to only
|
|
38
|
+
# merge data for the docnames that are actually being merged.
|
|
39
|
+
for docname in docnames:
|
|
40
|
+
if docname in other.linklint_counts: # type: ignore
|
|
41
|
+
env.linklint_counts[docname] = other.linklint_counts[docname] # type: ignore
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def display_results(app: Sphinx, exception: Exception | None) -> None:
|
|
45
|
+
if not exception:
|
|
46
|
+
total = sum(app.env.linklint_counts.values()) # type: ignore
|
|
47
|
+
logger.info(f"Linklint: unlinked {total} references")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def setup(app: Sphinx) -> ExtensionMetadata:
|
|
51
|
+
app.connect("builder-inited", init_data)
|
|
52
|
+
app.connect("doctree-read", process_reference_nodes)
|
|
53
|
+
app.connect("env-merge-info", merge_data)
|
|
54
|
+
app.connect("build-finished", display_results)
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
"version": linklint.__version__,
|
|
58
|
+
"parallel_read_safe": True,
|
|
59
|
+
"parallel_write_safe": True,
|
|
60
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Linter to find link problems in RST files."""
|
|
2
|
+
|
|
3
|
+
import collections
|
|
4
|
+
import re
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Iterable
|
|
8
|
+
|
|
9
|
+
from docutils import nodes
|
|
10
|
+
from sphinx import addnodes
|
|
11
|
+
|
|
12
|
+
from linklint.regions import Region, find_regions
|
|
13
|
+
from linklint.rsthelp import parse_rst, resub_in_rst_line
|
|
14
|
+
from linklint.utils import node_line_number, node_traceback
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Resolver:
|
|
18
|
+
# Build a map from reference roles to object types.
|
|
19
|
+
|
|
20
|
+
# Dummy lambda's so that the object_types dict can be identical to the
|
|
21
|
+
# code in sphinx/sphinx/domains/python/__init__.py.
|
|
22
|
+
ObjType = lambda _, *refs: refs # noqa: E731
|
|
23
|
+
_ = lambda s: 0 # noqa: E731
|
|
24
|
+
|
|
25
|
+
# object_types map from sphinx
|
|
26
|
+
# sphinx/sphinx/domains/python/__init__.py:725
|
|
27
|
+
object_types = {
|
|
28
|
+
"function": ObjType(_("function"), "func", "obj"),
|
|
29
|
+
"data": ObjType(_("data"), "data", "obj"),
|
|
30
|
+
"class": ObjType(_("class"), "class", "exc", "obj"),
|
|
31
|
+
"exception": ObjType(_("exception"), "exc", "class", "obj"),
|
|
32
|
+
"method": ObjType(_("method"), "meth", "obj"),
|
|
33
|
+
"classmethod": ObjType(_("class method"), "meth", "obj"),
|
|
34
|
+
"staticmethod": ObjType(_("static method"), "meth", "obj"),
|
|
35
|
+
"attribute": ObjType(_("attribute"), "attr", "obj"),
|
|
36
|
+
"property": ObjType(_("property"), "attr", "_prop", "obj"),
|
|
37
|
+
"type": ObjType(_("type alias"), "type", "class", "obj"),
|
|
38
|
+
"module": ObjType(_("module"), "mod", "obj"),
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
reftype_to_objtype = collections.defaultdict(list)
|
|
42
|
+
for objtype, names in object_types.items():
|
|
43
|
+
for name in names:
|
|
44
|
+
reftype_to_objtype[name].append(objtype)
|
|
45
|
+
|
|
46
|
+
def __init__(self, doctree: nodes.document) -> None:
|
|
47
|
+
self.region_map = {(r.kind, r.name): r for r in find_regions(doctree)}
|
|
48
|
+
|
|
49
|
+
def find_region(self, reftype: str, target: str) -> Region | None:
|
|
50
|
+
for objtype in self.reftype_to_objtype[reftype]:
|
|
51
|
+
region = self.region_map.get((objtype, target))
|
|
52
|
+
if region is not None:
|
|
53
|
+
return region
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class LintIssue:
|
|
59
|
+
line: int
|
|
60
|
+
message: str
|
|
61
|
+
fixed: bool = False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class LintWork:
|
|
66
|
+
doctree: nodes.document
|
|
67
|
+
content_lines: list[str]
|
|
68
|
+
fix: bool
|
|
69
|
+
fixed: bool
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
CHECKS = {}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def check(name: str):
|
|
76
|
+
"""Decorator to register a lint check function."""
|
|
77
|
+
|
|
78
|
+
def decorator(func):
|
|
79
|
+
CHECKS[name] = func
|
|
80
|
+
return func
|
|
81
|
+
|
|
82
|
+
return decorator
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# pat/repl pairs for references styles.
|
|
86
|
+
REF_FIXES = [
|
|
87
|
+
(
|
|
88
|
+
# :class:`MyClass` -> :class:`!MyClass`
|
|
89
|
+
r":{reftype}:`[~.]?{target}`",
|
|
90
|
+
r":{reftype}:`!{target}`",
|
|
91
|
+
),
|
|
92
|
+
(
|
|
93
|
+
# :class:`Some Class <mymodule.MyClass>` -> :class:`!Some Class`
|
|
94
|
+
r":{reftype}:`([^<]+?)\s*<{target}>`",
|
|
95
|
+
r":{reftype}:`!\1`",
|
|
96
|
+
),
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@check("self")
|
|
101
|
+
def check_self_links(work: LintWork) -> Iterable[LintIssue]:
|
|
102
|
+
for ref in find_self_refs(work.doctree):
|
|
103
|
+
line = node_line_number(ref)
|
|
104
|
+
reftype = ref.get("reftype") # type: ignore
|
|
105
|
+
target = ref.get("reftarget") # type: ignore
|
|
106
|
+
fixed = False
|
|
107
|
+
if work.fix:
|
|
108
|
+
for pat, repl in REF_FIXES:
|
|
109
|
+
fixed = resub_in_rst_line(
|
|
110
|
+
lines=work.content_lines,
|
|
111
|
+
line_num=line - 1,
|
|
112
|
+
pat=pat.format(reftype=reftype, target=re.escape(target)),
|
|
113
|
+
repl=repl.format(reftype=reftype, target=target),
|
|
114
|
+
count=1,
|
|
115
|
+
)
|
|
116
|
+
if fixed:
|
|
117
|
+
break
|
|
118
|
+
work.fixed |= fixed
|
|
119
|
+
if not fixed:
|
|
120
|
+
print(f"Line {line}: Couldn't fix self-link to :{reftype}:`{target}`")
|
|
121
|
+
print(f"Line was: {work.content_lines[line - 1]!r}")
|
|
122
|
+
yield LintIssue(line, f"self-link to :{reftype}:`{target}`", fixed=fixed)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class RefFinder(nodes.SparseNodeVisitor):
|
|
126
|
+
"""Visitor for nodes to track class context and find self-references."""
|
|
127
|
+
|
|
128
|
+
def __init__(self, doctree: nodes.document) -> None:
|
|
129
|
+
super().__init__(doctree)
|
|
130
|
+
self.resolver = Resolver(doctree)
|
|
131
|
+
self.self_refs: list[nodes.Node] = []
|
|
132
|
+
self.class_stack: list[str] = []
|
|
133
|
+
self.pushed_class: list[bool] = []
|
|
134
|
+
|
|
135
|
+
def visit_pending_xref(self, node: addnodes.pending_xref) -> None:
|
|
136
|
+
line = node_line_number(node)
|
|
137
|
+
reftype = node.get("reftype")
|
|
138
|
+
target = node.get("reftarget")
|
|
139
|
+
|
|
140
|
+
if reftype == "meth" and "." not in target:
|
|
141
|
+
if self.class_stack:
|
|
142
|
+
target = f"{self.class_stack[-1]}.{target}"
|
|
143
|
+
else:
|
|
144
|
+
# Method reference without class context: can't resolve, so skip.
|
|
145
|
+
target = None
|
|
146
|
+
|
|
147
|
+
if target:
|
|
148
|
+
region = self.resolver.find_region(reftype, target)
|
|
149
|
+
if region is not None and region.start <= line <= region.end_total:
|
|
150
|
+
self.self_refs.append(node)
|
|
151
|
+
|
|
152
|
+
def visit_desc(self, node: addnodes.desc) -> None:
|
|
153
|
+
objtype = node.get("objtype")
|
|
154
|
+
pushed = False
|
|
155
|
+
if objtype == "class":
|
|
156
|
+
self.class_stack.append(node.children[0].get("fullname"))
|
|
157
|
+
pushed = True
|
|
158
|
+
elif objtype == "method" and not self.class_stack:
|
|
159
|
+
self.class_stack.append(node.children[0].get("class"))
|
|
160
|
+
pushed = True
|
|
161
|
+
self.pushed_class.append(pushed)
|
|
162
|
+
|
|
163
|
+
def depart_desc(self, node: addnodes.desc) -> None:
|
|
164
|
+
if self.pushed_class.pop():
|
|
165
|
+
self.class_stack.pop()
|
|
166
|
+
|
|
167
|
+
# Have to explicitly pass on unknown nodes, and Python docs have a bunch.
|
|
168
|
+
def unknown_visit(self, node): # pragma: no cover
|
|
169
|
+
pass
|
|
170
|
+
|
|
171
|
+
def unknown_departure(self, node): # pragma: no cover
|
|
172
|
+
pass
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def find_self_refs(doctree: nodes.document) -> Iterable[nodes.Node]:
|
|
176
|
+
"""Find references that point to the same region they are in."""
|
|
177
|
+
|
|
178
|
+
finder = RefFinder(doctree)
|
|
179
|
+
doctree.walkabout(finder)
|
|
180
|
+
return finder.self_refs
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@check("paradup")
|
|
184
|
+
def check_duplicate_refs_in_paragraph(work: LintWork) -> Iterable[LintIssue]:
|
|
185
|
+
"""Check references that appear more than once in the same paragraph."""
|
|
186
|
+
if work.fix:
|
|
187
|
+
raise Exception("Fixing is not available for --check=paradup")
|
|
188
|
+
|
|
189
|
+
for ref in find_duplicate_refs(work.doctree):
|
|
190
|
+
line = node_line_number(ref)
|
|
191
|
+
reftype = ref.get("reftype") # type: ignore
|
|
192
|
+
target = ref.get("reftarget") # type: ignore
|
|
193
|
+
yield LintIssue(line, f"duplicate :{reftype}:`{target}` in paragraph")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def find_duplicate_refs(doctree: nodes.document) -> Iterable[nodes.Node]:
|
|
197
|
+
"""Find references that appear more than once in the same paragraph."""
|
|
198
|
+
for para in doctree.findall(nodes.paragraph):
|
|
199
|
+
refs_by_target = defaultdict(list)
|
|
200
|
+
for ref in para.findall(addnodes.pending_xref):
|
|
201
|
+
reftype = ref.get("reftype")
|
|
202
|
+
target = ref.get("reftarget")
|
|
203
|
+
assert reftype and target, (
|
|
204
|
+
f"Reference missing reftype or target: {ref}\n{node_traceback(ref)}"
|
|
205
|
+
)
|
|
206
|
+
refs_by_target[(reftype, target)].append(ref)
|
|
207
|
+
|
|
208
|
+
for refs in refs_by_target.values():
|
|
209
|
+
if len(refs) > 1:
|
|
210
|
+
yield from refs[1:]
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@dataclass
|
|
214
|
+
class LintResult:
|
|
215
|
+
content: str
|
|
216
|
+
issues: list[LintIssue]
|
|
217
|
+
fixed: bool
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def lint_content(content: str, fix: bool, checks: set[str]) -> LintResult:
|
|
221
|
+
doctree = parse_rst(content)
|
|
222
|
+
work = LintWork(
|
|
223
|
+
content_lines=content.splitlines(keepends=True),
|
|
224
|
+
doctree=doctree,
|
|
225
|
+
fix=fix,
|
|
226
|
+
fixed=False,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
issues = []
|
|
230
|
+
for check_name in checks:
|
|
231
|
+
issues.extend(CHECKS[check_name](work))
|
|
232
|
+
|
|
233
|
+
result = LintResult(
|
|
234
|
+
content="".join(work.content_lines),
|
|
235
|
+
issues=issues,
|
|
236
|
+
fixed=work.fixed,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
return result
|