linklint 0.3.1__py3-none-any.whl
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/__init__.py +1 -0
- linklint/cli.py +64 -0
- linklint/dump.py +56 -0
- linklint/ext.py +60 -0
- linklint/linklint.py +239 -0
- linklint/regions.py +76 -0
- linklint/rsthelp.py +109 -0
- linklint/utils.py +58 -0
- linklint-0.3.1.dist-info/METADATA +111 -0
- linklint-0.3.1.dist-info/RECORD +13 -0
- linklint-0.3.1.dist-info/WHEEL +5 -0
- linklint-0.3.1.dist-info/entry_points.txt +2 -0
- linklint-0.3.1.dist-info/top_level.txt +1 -0
linklint/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.1"
|
linklint/cli.py
ADDED
|
@@ -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:]))
|
linklint/dump.py
ADDED
|
@@ -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)
|
linklint/ext.py
ADDED
|
@@ -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
|
+
}
|
linklint/linklint.py
ADDED
|
@@ -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
|
linklint/regions.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Iterable
|
|
3
|
+
|
|
4
|
+
from docutils import nodes
|
|
5
|
+
from sphinx import addnodes
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class Region:
|
|
10
|
+
# "module", "class", "function", etc.
|
|
11
|
+
kind: str
|
|
12
|
+
name: str
|
|
13
|
+
# Line number where the region starts.
|
|
14
|
+
start: int
|
|
15
|
+
# Line number where the main part of the region ends: the lines in this
|
|
16
|
+
# region and not in any nested subregion.
|
|
17
|
+
end_main: int
|
|
18
|
+
# Line number where the whole region ends: includes all nested subregions.
|
|
19
|
+
end_total: int
|
|
20
|
+
|
|
21
|
+
def __lt__(self, other):
|
|
22
|
+
return (self.start, self.name) < (other.start, other.name)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RegionFinder:
|
|
26
|
+
def __init__(self) -> None:
|
|
27
|
+
self.last_line = 0
|
|
28
|
+
|
|
29
|
+
def find_regions(self, node: nodes.Node) -> Iterable[Region]:
|
|
30
|
+
kind = None
|
|
31
|
+
end_main = None
|
|
32
|
+
|
|
33
|
+
match node:
|
|
34
|
+
case nodes.section():
|
|
35
|
+
section_names = set(node.get("names", []))
|
|
36
|
+
for section_id in node.get("ids", []):
|
|
37
|
+
if section_id.startswith("module-") and section_id not in section_names:
|
|
38
|
+
kind = "module"
|
|
39
|
+
assert node.line is not None
|
|
40
|
+
name_starts = [(section_id[len("module-") :], node.line - 1)]
|
|
41
|
+
break
|
|
42
|
+
|
|
43
|
+
case addnodes.desc():
|
|
44
|
+
kind = node.get("objtype")
|
|
45
|
+
name_starts = [
|
|
46
|
+
(kid.get("fullname"), kid.line)
|
|
47
|
+
for kid in node.children
|
|
48
|
+
if isinstance(kid, addnodes.desc_signature)
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
last_line = getattr(node, "line", None)
|
|
52
|
+
if last_line is not None:
|
|
53
|
+
self.last_line = last_line + node.astext().count("\n")
|
|
54
|
+
|
|
55
|
+
for child in node.children:
|
|
56
|
+
for subregion in self.find_regions(child):
|
|
57
|
+
if kind is not None and end_main is None:
|
|
58
|
+
# The main part of a region ends when the next region starts.
|
|
59
|
+
end_main = subregion.start - 1
|
|
60
|
+
yield subregion
|
|
61
|
+
|
|
62
|
+
if kind is not None:
|
|
63
|
+
for name, start in name_starts:
|
|
64
|
+
yield Region(
|
|
65
|
+
kind=kind,
|
|
66
|
+
name=name,
|
|
67
|
+
start=start,
|
|
68
|
+
end_main=end_main or self.last_line,
|
|
69
|
+
end_total=self.last_line,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def find_regions(doctree: nodes.document) -> Iterable[Region]:
|
|
74
|
+
"""Find regions in the doctree."""
|
|
75
|
+
finder = RegionFinder()
|
|
76
|
+
return finder.find_regions(doctree)
|
linklint/rsthelp.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Utilities for RST files."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import string
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from docutils import nodes
|
|
8
|
+
from sphinx.application import Sphinx
|
|
9
|
+
|
|
10
|
+
from linklint.dump import dump_doctree
|
|
11
|
+
from linklint.utils import SAVE_INTERMEDIATE, in_tempdir, slug_for_test
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def run_sphinx(content: str, buildername: str, extensions: list[str]) -> nodes.document:
|
|
15
|
+
# Create minimal conf.py
|
|
16
|
+
Path("conf.py").write_text(f"extensions = {extensions!r}\n", encoding="utf-8")
|
|
17
|
+
|
|
18
|
+
# Copy the RST file as index.rst
|
|
19
|
+
Path("index.rst").write_text(content, encoding="utf-8")
|
|
20
|
+
|
|
21
|
+
app = Sphinx(
|
|
22
|
+
srcdir=".",
|
|
23
|
+
confdir=".",
|
|
24
|
+
outdir="_build",
|
|
25
|
+
doctreedir="_build/.doctrees",
|
|
26
|
+
buildername=buildername,
|
|
27
|
+
freshenv=True,
|
|
28
|
+
status=None,
|
|
29
|
+
warning=None,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
app.build()
|
|
33
|
+
return app.env.get_doctree("index")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def parse_rst(content: str) -> nodes.document:
|
|
37
|
+
"""Parse RST content using Sphinx and return the doctree."""
|
|
38
|
+
with in_tempdir():
|
|
39
|
+
doctree = run_sphinx(content, buildername="dummy", extensions=[])
|
|
40
|
+
fix_node_lines(doctree)
|
|
41
|
+
if SAVE_INTERMEDIATE:
|
|
42
|
+
save_test_doctree(doctree)
|
|
43
|
+
return doctree
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def save_test_doctree(doctree: nodes.document) -> None:
|
|
47
|
+
dump_file = Path(f"tmp/doctree/{slug_for_test()}.txt")
|
|
48
|
+
dump_file.parent.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
with dump_file.open("w", encoding="utf-8") as f:
|
|
50
|
+
dump_doctree(doctree, f)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
BLOCK_NODES = (nodes.paragraph,)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def fix_node_lines(doctree: nodes.document) -> None:
|
|
57
|
+
"""Fix line numbers for inline nodes within block elements.
|
|
58
|
+
|
|
59
|
+
Doctree block nodes have correct line numbers, but their inline children
|
|
60
|
+
inherit the block's start line. Walk each block's descendants counting
|
|
61
|
+
newlines in text nodes to compute each inline node's actual line.
|
|
62
|
+
"""
|
|
63
|
+
for block in doctree.findall(lambda n: isinstance(n, BLOCK_NODES)):
|
|
64
|
+
if not block.line:
|
|
65
|
+
continue
|
|
66
|
+
newline_count = 0
|
|
67
|
+
for node in block.findall():
|
|
68
|
+
if node is block:
|
|
69
|
+
continue
|
|
70
|
+
node.line = block.line + newline_count
|
|
71
|
+
if isinstance(node, nodes.Text):
|
|
72
|
+
# References are trimmed, so we can miss a trailing newline.
|
|
73
|
+
# The parent might have better information.
|
|
74
|
+
if isinstance(node.parent, (nodes.inline, nodes.reference)):
|
|
75
|
+
newline_count += node.parent.rawsource.count("\n")
|
|
76
|
+
else:
|
|
77
|
+
newline_count += str(node).count("\n")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def is_header_line(line: str, text_line: str) -> bool:
|
|
81
|
+
"""Check if a line is a header underline/overline for `text_line`."""
|
|
82
|
+
stripped = line.rstrip()
|
|
83
|
+
return (
|
|
84
|
+
len(stripped) >= len(text_line.rstrip())
|
|
85
|
+
and len(set(stripped)) == 1
|
|
86
|
+
and stripped[0] in string.punctuation
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def replace_rst_line(lines: list[str], line_num: int, new_line: str) -> None:
|
|
91
|
+
"""Replace a line in the content lines, adjusting header lengths if needed."""
|
|
92
|
+
old_line = lines[line_num]
|
|
93
|
+
lines[line_num] = new_line
|
|
94
|
+
# Adjust adjacent lines if they are header lines.
|
|
95
|
+
for adj_line_num in [line_num - 1, line_num + 1]:
|
|
96
|
+
if adj_line_num in range(len(lines)):
|
|
97
|
+
adj_line = lines[adj_line_num]
|
|
98
|
+
if is_header_line(adj_line, old_line):
|
|
99
|
+
line_end = adj_line[len(adj_line.rstrip()) :]
|
|
100
|
+
lines[adj_line_num] = adj_line[0] * len(new_line.rstrip()) + line_end
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def resub_in_rst_line(lines: list[str], line_num: int, pat: str, repl: str, count=0) -> bool:
|
|
104
|
+
"""Replace a substring in a line, adjusting header lengths if needed."""
|
|
105
|
+
new_line = re.sub(pat, repl, lines[line_num], count=count)
|
|
106
|
+
changed = new_line != lines[line_num]
|
|
107
|
+
if changed:
|
|
108
|
+
replace_rst_line(lines, line_num, new_line)
|
|
109
|
+
return changed
|
linklint/utils.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
import tempfile
|
|
5
|
+
from typing import cast
|
|
6
|
+
|
|
7
|
+
from docutils import nodes
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# $set_env.py: LINKLINT_SAVE_INTERMEDIATE - Save intermediate data in files for debugging.
|
|
11
|
+
SAVE_INTERMEDIATE = os.getenv("LINKLINT_SAVE_INTERMEDIATE") == "1"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def slug_for_test() -> str: # pragma: debugging
|
|
15
|
+
"""Get the short name of the current test, possibly the param id."""
|
|
16
|
+
test_name = os.getenv("PYTEST_CURRENT_TEST", "unknown")
|
|
17
|
+
m = re.search(r"::(?P<test>[\w_]+)(?P<param>\[.+?\])?(?: \(\w+\))$", test_name)
|
|
18
|
+
assert m is not None
|
|
19
|
+
if param := m["param"]:
|
|
20
|
+
return re.sub(r"[^\w]", "-", param.strip("[]"))
|
|
21
|
+
return m["test"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@contextlib.contextmanager
|
|
25
|
+
def in_tempdir():
|
|
26
|
+
"""Make a temp directory and cd into it, then come back and delete it."""
|
|
27
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
28
|
+
with contextlib.chdir(tmpdir):
|
|
29
|
+
yield
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def plural(n: int, thing: str = "", things: str = "") -> str:
|
|
33
|
+
"""Pluralize a word.
|
|
34
|
+
|
|
35
|
+
If n is 1, return thing. Otherwise return things, or thing+s.
|
|
36
|
+
"""
|
|
37
|
+
if n == 1:
|
|
38
|
+
noun = thing
|
|
39
|
+
else:
|
|
40
|
+
noun = things or (thing + "s")
|
|
41
|
+
return f"{n} {noun}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def node_line_number(node: nodes.Node) -> int:
|
|
45
|
+
"""Find a line number for a node, looking at parents if needed."""
|
|
46
|
+
while node.line is None:
|
|
47
|
+
node = cast(nodes.Node, node.parent)
|
|
48
|
+
return node.line
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def node_traceback(node: nodes.Node) -> str: # pragma: debugging
|
|
52
|
+
"""Get a multi-line traceback for a node to pinpoint problems."""
|
|
53
|
+
lines = []
|
|
54
|
+
while node is not None:
|
|
55
|
+
lineno = str(node.line) if node.line is not None else ""
|
|
56
|
+
lines.append(f"{lineno:>4}: {node!r}")
|
|
57
|
+
node = cast(nodes.Node, node.parent)
|
|
58
|
+
return "\n".join(reversed(lines))
|
|
@@ -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,13 @@
|
|
|
1
|
+
linklint/__init__.py,sha256=r4xAFihOf72W9TD-lpMi6ntWSTKTP2SlzKP1ytkjRbI,22
|
|
2
|
+
linklint/cli.py,sha256=I88TJy_f0gdA-6wKjNCdr04nQzHmKbwk2t0PGxW3mjw,1970
|
|
3
|
+
linklint/dump.py,sha256=Hi3BivylMg5kfndu91Z0VcxP3Zjn2qkGIOMcvpfg8js,1513
|
|
4
|
+
linklint/ext.py,sha256=UjT9EyJAlnDbDapkOt7FzApHCB2wp_hmOxzcKZ4yXXY,1925
|
|
5
|
+
linklint/linklint.py,sha256=X5YI79HIaPaFFB8Glh2wtNcogvQ5rT8OsQFZV_1MB54,7738
|
|
6
|
+
linklint/regions.py,sha256=YBgV2OD7jFFfRVXkQ1LcpfJ1fGDlFujS1-GC-z2jpqQ,2517
|
|
7
|
+
linklint/rsthelp.py,sha256=pWdMTnBOc_44gy317DoAvc1U7x8NBt5syGF3rOvD2Yk,3806
|
|
8
|
+
linklint/utils.py,sha256=qSAXD6k3ByfXMfDdl_QZkQmxTDku4A5g-CqUIdP9GXs,1762
|
|
9
|
+
linklint-0.3.1.dist-info/METADATA,sha256=Rln69OAh_p6mYzRA7h6URAxVAR_XgaTipsyxoVS7RKg,3024
|
|
10
|
+
linklint-0.3.1.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
|
|
11
|
+
linklint-0.3.1.dist-info/entry_points.txt,sha256=Z5jIMpJFiKFENCUK5ixhwR7bWAYosN_6nWr1lQctc7w,47
|
|
12
|
+
linklint-0.3.1.dist-info/top_level.txt,sha256=HRQMpV9lwSiNhRvKE9BGZj7dRVRVlyIwm6AH1wvRzbU,9
|
|
13
|
+
linklint-0.3.1.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
linklint
|