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.
@@ -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
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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