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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ linklint = linklint.cli:main
@@ -0,0 +1 @@
1
+ linklint