. We want to omit certain tags like that are used for styling but don't affect the structure. We want to ignore some tags and their contents. For links, we add a "self-link" class if the link points to an id that is currently "in scope", that is, clicking it would keep you in the same section of the page the link is in. """ def __init__(self) -> None: super().__init__() # The tag indentation level of the original HTML. self.indent = 0 # The printing indentation level. self.print_indent = 0 # Have we found role="main" yet? self.main = False # Are we ignoring all tags until we get back to the same indent level? self.ignoring = False self.ignoring_start_level = 0 # Indent levels of tags we'll omit, while still attending to their children. self.omit_levels = set() # Stack of tags we're currently inside. self.tags = [] # The ids for each element in our current stack. Some ids are moved # up to parent elements.
should be treated as if it # were
. self.element_ids = [] # The set of all ids we're inside. self.current_ids = set() self.output = [] def print(self, s: str, open: bool = False, close: bool = False) -> None: if close: self.print_indent -= 2 self.output.append(" " * self.print_indent) self.output.append(s) self.output.append("\n") if open: self.print_indent += 2 def summary(self) -> str: return "".join(self.output) def has_class(self, dattrs: dict[str, str | None], class_name: str) -> bool: return class_name in (dattrs.get("class") or "").split() def should_ignore(self, tag: str, dattrs: dict[str, str | None]) -> bool: """Return true if we should ignore this tag and all its contents.""" if tag == "a" and self.has_class(dattrs, "headerlink"): return True return False def should_omit(self, tag: str, dattrs: dict[str, str | None]) -> bool: """Return true if we should omit this tag but still attend to its contents.""" if tag in {"em", "span"}: return True return False def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: # Map of tags to the parent tag that should get their ids. ID_PARENTS = { "dt": "dl", } dattrs = dict(attrs) if self.main and not self.ignoring: if self.should_ignore(tag, dattrs): self.ignoring = True self.ignoring_start_level = self.indent elif self.should_omit(tag, dattrs): self.omit_levels.add(self.indent) else: tattrs = "".join(f' {k}="{v}"' for k, v in attrs if k in {"id", "href"}) href = dattrs.get("href") if href and href.startswith("#"): if href[1:] in self.current_ids: # pragma: only failure tattrs += ' class="self-link"' self.print(f"<{tag}{tattrs}>", open=True) id = dattrs.get("id") ids = [] if id: assert id not in self.current_ids, f"Duplicate id: {id}" self.current_ids.add(id) parent_to_id = ID_PARENTS.get(tag) if parent_to_id and self.tags and self.tags[-1] == parent_to_id: self.element_ids[-1].append(id) ids = [] self.element_ids.append(ids) self.tags.append(tag) self.indent += 1 if dattrs.get("role") == "main": self.main = True self.omit_levels.add(self.indent) self.indent += 1 def handle_endtag(self, tag: str) -> None: if self.main: self.indent -= 1 if self.indent in self.omit_levels: self.omit_levels.remove(self.indent) elif not self.ignoring: self.print(f"", close=True) self.tags.pop() ids = self.element_ids.pop() for id in ids: self.current_ids.remove(id) else: if self.indent == self.ignoring_start_level: self.ignoring = False if self.indent == 0: self.main = False def handle_data(self, data: str) -> None: if self.main and not self.ignoring: data = re.sub(r"\s+", " ", data.strip()) if data: self.print(data) def summarize_html_file(filename: str) -> str: parser = HtmlSummarizer() parser.feed(Path(filename).read_text(encoding="utf-8")) return parser.summary() if __name__ == "__main__": sys.stdout.write(summarize_html_file(sys.argv[1])) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1787745717.0320294 sphinx_linklint-2.0.1/tests/test_extension.py0000644000175100017510000000471515243552665021210 0ustar00runnerrunner""" Tests using .toml files in tests/data. Sphinx renders the rst to HTML, then we compare a summary of the HTML to the expected html from the .toml file. """ import shutil from pathlib import Path import pytest from helpers import DATA_DIR, read_toml from summarize_html import summarize_html_file from sphinx_linklint.rsthelp import run_sphinx, save_test_doctree from sphinx_linklint.utils import SAVE_INTERMEDIATE, in_tempdir PROJECT = Path(__file__).parent.parent @pytest.mark.parametrize( "toml_name", [p.stem for p in sorted(DATA_DIR.glob("*.toml"))], ) def test_summarize_html(toml_name: str) -> None: data = read_toml(toml_name) rst = data["rst"] with in_tempdir(): result = run_sphinx(rst, buildername="html", extensions=["sphinx_linklint.ext"]) summary = summarize_html_file("_build/index.html") if SAVE_INTERMEDIATE: # In case of needing to see what happened, copy the HTML etc to tmp. shutil.copytree("_build/_static", PROJECT / "tmp/html/_static", dirs_exist_ok=True) shutil.copyfile("_build/index.html", PROJECT / f"tmp/html/{toml_name}.html") (PROJECT / f"tmp/html/{toml_name}_summary.html").write_text(summary, encoding="utf-8") # Also run without the extension to understand Sphinx native behavior. run_sphinx(rst, buildername="html", extensions=[]) shutil.copyfile("_build/index.html", PROJECT / f"tmp/html/{toml_name}_nofix.html") nofix_summary = summarize_html_file("_build/index.html") (PROJECT / f"tmp/html/{toml_name}_summary_nofix.html").write_text( nofix_summary, encoding="utf-8" ) save_test_doctree(result.doctree) # Check the expected HTML output. assert 'class="self-link"' not in summary, f"Self-links found in {toml_name}" expected = data["html"] if summary != expected: # pragma: only failure if SAVE_INTERMEDIATE: print(f"Full HTML is at tmp/html/{toml_name}.html") print(f"Summary is at tmp/html/{toml_name}_summary.html") print(f"Update tests/data/{toml_name}.toml with the correct output") else: print("To see full HTML, set LINKLINT_SAVE_INTERMEDIATE=1 and re-run the test.") assert summary == expected # Check the expected status message output. output = "".join(ln for ln in result.status.splitlines(keepends=True) if "Linklint" in ln) assert output == data["output"] ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1787745717.0320294 sphinx_linklint-2.0.1/tests/test_linklint.py0000644000175100017510000010003115243552665021004 0ustar00runnerrunnerfrom difflib import Differ from textwrap import dedent from typing import Any import pytest from helpers import text_and_id from sphinx_linklint.linklint import LintIssue, lint_content def diff_lines(text1: str, text2: str) -> str: """Return a diff of just the lines that differ between text1 and text2.""" differ = Differ().compare( text1.splitlines(keepends=True), text2.splitlines(keepends=True), ) min_diff = "".join(line for line in differ if line.startswith(("-", "+"))) return min_diff def lint_test_case( *, rst: str, issues: list[LintIssue], diff: str = "", id: str = "", marks: Any = None, ): """Helper to create pytest parameters for linting tests.""" rst, id = text_and_id(text=rst, id=id) diff = diff.removeprefix("\n") return pytest.param(rst, issues, dedent(diff), id=id, marks=marks or []) SELF_TEST_CASES = [ # Check a self-link in the module description. lint_test_case( id="selflink", rst=""" My Module ========= .. module:: mymodule This is the :mod:`mymodule` documentation. Also check :mod:`othermodule` for related functionality. Other Section ============= This section references :mod:`mymodule` which is fine. """, issues=[ LintIssue(6, "self-link to :mod:`mymodule`", fixed=True), ], diff=""" - This is the :mod:`mymodule` documentation. + This is the :mod:`!mymodule` documentation. """, ), # Is the line-munging correct? lint_test_case( id="second-section", rst=""" Another ======= Look at :mod:`mymodule` for more info. My Module ========= .. module:: mymodule This is the :mod:`mymodule` documentation. Also check :mod:`othermodule` for related functionality. Other Section ============= This section references :mod:`mymodule` which is fine. """, issues=[ LintIssue(11, "self-link to :mod:`mymodule`", fixed=True), ], diff=""" - This is the :mod:`mymodule` documentation. + This is the :mod:`!mymodule` documentation. """, ), # Check that `.. _module-foo` isn't confused for a module section. lint_test_case( id="module-target", rst=""" .. _module-xyzzy: About Xyzzy ----------- Xyzzy is a magical word that does nothing in particular. See :mod:`xyzzy` for more info. """, issues=[], ), # Check that headers get fixed too. lint_test_case( id="header-selflink", rst=""" :mod:`mymodule` Module ====================== .. module:: mymodule This is a great module! """, issues=[ LintIssue(1, "self-link to :mod:`mymodule`", fixed=True), ], diff=""" - :mod:`mymodule` Module + :mod:`!mymodule` Module - ====================== + ======================= """, ), # Check that overline headers get fixed too. lint_test_case( id="overline", rst=""" *********************** This is :mod:`mymodule` *********************** .. module:: mymodule This is a great module! .. deprecated:: 3.8 Maybe it's not so great after all. """, issues=[ LintIssue(2, "self-link to :mod:`mymodule`", fixed=True), ], diff=""" - *********************** + ************************ - This is :mod:`mymodule` + This is :mod:`!mymodule` - *********************** + ************************ """, ), # Self-link on a continuation line inside a list item. lint_test_case( id="continuation-line", rst=""" My Module ========= .. module:: mymodule - First item talks about something. And continues with :mod:`mymodule` here. - Second item. """, issues=[ LintIssue(7, "self-link to :mod:`mymodule`", fixed=True), ], diff=""" - And continues with :mod:`mymodule` here. + And continues with :mod:`!mymodule` here. """, ), # Don't get confused about sub-modules. lint_test_case( id="submodule", rst=""" :mod:`dbm` --- Interfaces to Unix "databases" ============================================= .. module:: dbm :synopsis: Interfaces to various Unix "database" formats. **Source code:** :source:`Lib/dbm/__init__.py` -------------- :mod:`dbm` is a generic interface to variants of the DBM database: * :mod:`dbm.sqlite3` * :mod:`dbm.gnu` * :mod:`dbm.ndbm` If none of these modules are installed, the slow-but-simple implementation in module :mod:`dbm.dumb` will be used. There is a `third party interface `_ to the Oracle Berkeley DB. .. note:: None of the underlying modules will automatically shrink the disk space used by the database file. However, :mod:`dbm.sqlite3`, :mod:`dbm.gnu` and :mod:`dbm.dumb` provide a :meth:`!reorganize` method that can be used for this purpose. """, issues=[ LintIssue(line=1, message="self-link to :mod:`dbm`", fixed=True), LintIssue(line=11, message="self-link to :mod:`dbm`", fixed=True), ], diff=""" - :mod:`dbm` --- Interfaces to Unix "databases" + :mod:`!dbm` --- Interfaces to Unix "databases" - ============================================= + ============================================== - :mod:`dbm` is a generic interface to variants of the DBM database: + :mod:`!dbm` is a generic interface to variants of the DBM database: """, ), # Fix ~ references if needed. lint_test_case( id="tilde", rst=""" :mod:`email.encoders`: Encoders ---------------------------------- .. module:: email.encoders This module is deprecated in Python 3. The functions provided here should not be called explicitly since the :class:`~email.mime.text.MIMEText` class sets the content type and CTE header using the *_subtype* and *_charset* values passed during the instantiation of that class. The :mod:`email` package provides some convenient encoders in its :mod:`~email.encoders` module. These encoders are actually used by the :class:`~email.mime.audio.MIMEAudio` and :class:`~email.mime.image.MIMEImage` class constructors to provide default encodings. """, issues=[ LintIssue(line=1, message="self-link to :mod:`email.encoders`", fixed=True), LintIssue(line=12, message="self-link to :mod:`email.encoders`", fixed=True), ], diff=""" - :mod:`email.encoders`: Encoders + :mod:`!email.encoders`: Encoders - ---------------------------------- + -------------------------------- - :mod:`~email.encoders` module. These encoders are actually used by the + :mod:`!email.encoders` module. These encoders are actually used by the """, ), # Fix dotted references lint_test_case( id="dotted", rst=""" :mod:`!html.parser` --- Simple HTML and XHTML parser ==================================================== .. module:: html.parser :synopsis: A simple parser that can handle HTML and XHTML. -------------- This module defines a class :class:`HTMLParser` which serves as the basis for parsing text files formatted in HTML (HyperText Mark-up Language) and XHTML. .. class:: HTMLParser(*, convert_charrefs=True, scripting=False) Create a parser instance able to parse invalid markup. An :class:`.HTMLParser` instance is fed HTML data and calls handler methods when start tags, end tags, text, comments, and other markup elements are encountered. The user should subclass :class:`.HTMLParser` and override its methods to implement the desired behavior. """, issues=[ LintIssue(line=16, message="self-link to :class:`HTMLParser`", fixed=True), LintIssue(line=18, message="self-link to :class:`HTMLParser`", fixed=True), ], diff=""" - An :class:`.HTMLParser` instance is fed HTML data and calls handler methods + An :class:`!HTMLParser` instance is fed HTML data and calls handler methods - encountered. The user should subclass :class:`.HTMLParser` and override its + encountered. The user should subclass :class:`!HTMLParser` and override its """, ), # Class self-linking. lint_test_case( id="selflink-class", rst=""" Queue ===== .. class:: Queue(maxsize=0) A first in, first out (FIFO) queue. .. method:: shutdown(immediate=False) Put a :class:`Queue` instance into a shutdown mode. """, issues=[ LintIssue(line=10, message="self-link to :class:`Queue`", fixed=True), ], diff=""" - Put a :class:`Queue` instance into a shutdown mode. + Put a :class:`!Queue` instance into a shutdown mode. """, ), # Some implicit references have no line number? # Optional[Anchor] makes a reference to Anchor with no line number and # we can't fix it anyway. lint_test_case( id="implicit-ref", rst=""" :mod:`!importlib.resources` -- Package resource reading, opening and access --------------------------------------------------------------------------- .. module:: importlib.resources .. class:: Anchor Represents an anchor for resources, either a :class:`module object ` or a module name as a string. Defined as ``Union[str, ModuleType]``. .. function:: files(anchor: Optional[Anchor] = None) *anchor* is an optional :class:`Anchor`. If the anchor is a package, resources are resolved from that package. If a module, resources are resolved adjacent to that module (in the same package or the package root). If the anchor is omitted, the caller's module is used. """, issues=[], ), # Some directives had the wrong line number. lint_test_case( id="note", # https://github.com/sphinx-doc/sphinx/pull/14309 fixes the line numbers # for versionchanged directives, so our line numbers are off until that # is merged. marks=pytest.mark.xfail, rst=""" ZipFile objects --------------- .. class:: ZipFile(file, mode='r', compression=ZIP_STORED, allowZip64=True) Open a ZIP file, where *file* can be a path to a file (a string), a file-like object or a :term:`path-like object`. .. versionchanged:: 3.2 Added the ability to use :class:`ZipFile` as a context manager. Also did many other good things. .. note:: Added the ability to use :class:`ZipFile` as a context manager. Also did many other good things. """, issues=[ LintIssue(line=10, message="self-link to :class:`ZipFile`", fixed=True), LintIssue(line=14, message="self-link to :class:`ZipFile`", fixed=True), ], diff=""" - Added the ability to use :class:`ZipFile` as a context manager. + Added the ability to use :class:`!ZipFile` as a context manager. - Added the ability to use :class:`ZipFile` as a context manager. + Added the ability to use :class:`!ZipFile` as a context manager. """, ), # Sometimes there are two to fix in one line. lint_test_case( id="two-in-one-line", rst=""" ZipFile objects --------------- .. class:: ZipFile(file, mode='r', compression=ZIP_STORED, allowZip64=True) This is :class:`ZipFile` and also :class:`ZipFile` again. It's great. """, issues=[ LintIssue(line=6, message="self-link to :class:`ZipFile`", fixed=True), LintIssue(line=6, message="self-link to :class:`ZipFile`", fixed=True), ], diff=""" - This is :class:`ZipFile` and also :class:`ZipFile` again. + This is :class:`!ZipFile` and also :class:`!ZipFile` again. """, ), # References can be in a section, but still be forward references, so # they aren't linking to the section you are already reading. lint_test_case( id="case-sensitive", rst=""" :mod:`!uuid` --- UUID objects according to :rfc:`9562` ====================================================== .. module:: uuid This module provides immutable :class:`UUID` objects (the :class:`UUID` class) and :ref:`functions ` for generating UUIDs corresponding to a specific UUID version as specified in :rfc:`9562` (which supersedes :rfc:`4122`), for example, :func:`uuid1` for UUID version 1, :func:`uuid3` for UUID version 3, and so on. Note that UUID version 2 is deliberately omitted as it is outside the scope of the RFC. .. class:: UUID(hex=None, bytes=None, bytes_le=None, fields=None, int=None, version=None, *, is_safe=SafeUUID.unknown) Create a UUID from either a string of 32 hexadecimal digits, a string of 16 """, issues=[], ), # versionchanged directives needed fixing to get the right line numbers, # and the inline case needed extra fixing. lint_test_case( id="inline-versionchanged", # https://github.com/sphinx-doc/sphinx/pull/14309 fixes the line numbers # for versionchanged directives, so our line numbers are off until that # is merged. marks=pytest.mark.xfail, rst=""" :mod:`!collections` --- Container datatypes =========================================== .. module:: collections :class:`Counter` objects ------------------------ A counter tool is provided to support convenient and rapid tallies. .. class:: Counter([iterable-or-mapping]) A :class:`!Counter` is a :class:`dict` subclass for counting :term:`hashable` objects. .. versionchanged:: 3.7 As a :class:`dict` subclass, :class:`Counter` inherited the capability to remember insertion order. Counter objects support additional methods beyond those available for all dictionaries. """, issues=[ LintIssue(line=15, message="self-link to :class:`Counter`", fixed=True), ], diff=""" - .. versionchanged:: 3.7 As a :class:`dict` subclass, :class:`Counter` + .. versionchanged:: 3.7 As a :class:`dict` subclass, :class:`!Counter` """, ), # A newline at the end of a link is trimmed, so our line count was off. lint_test_case( id="newline-in-link", rst=""" .. function:: fwalk(top='.', topdown=True, onerror=None, *, follow_symlinks=False, dir_fd=None) This function always supports :ref:`paths relative to directory descriptors ` and :ref:`not following symlinks `. Note however that, unlike other functions, the :func:`fwalk` default value for *follow_symlinks* is ``False``. This function always supports :ref:`paths relative to directory descriptors `. Note however that the :func:`fwalk` default value for *follow_symlinks* is ``False``. .. function:: expm1(x) Return *e* raised to the power *x*, minus 1. Here *e* is the base of natural logarithms. For small floats *x*, the subtraction in ``exp(x) - 1`` can result in a `significant loss of precision `_; the :func:`expm1` function provides a way to compute this quantity to full precision. """, issues=[ LintIssue(line=5, message="self-link to :func:`fwalk`", fixed=True), LintIssue(line=9, message="self-link to :func:`fwalk`", fixed=True), LintIssue(line=17, message="self-link to :func:`expm1`", fixed=True), ], diff=""" - that, unlike other functions, the :func:`fwalk` default value for + that, unlike other functions, the :func:`!fwalk` default value for - `. Note however that the :func:`fwalk` default value for + `. Note however that the :func:`!fwalk` default value for - `_; the :func:`expm1` + `_; the :func:`!expm1` """, ), # Fully qualified references should be understood even if the full # qualification is redundant. Also, :meth: instead of :func: is understood # by Sphinx, so we should recognize those as unlinkable also. lint_test_case( id="extra-context", rst=""" :mod:`!getpass` --- Portable password input =========================================== .. module:: getpass :synopsis: Portable reading of passwords and retrieval of the userid. The :mod:`!getpass` module provides two functions: .. function:: getpass(prompt='Password: ', stream=None, *, echo_char=None) Prompt the user for a password without echoing. If echo-free input is unavailable :func:`~getpass.getpass` falls back to printing a warning message to *stream* and reading from :data:`sys.stdin` and issuing a :exc:`GetPassWarning`. If echo-free input is unavailable :func:`getpass` falls back to printing a warning message to *stream* and reading from :data:`sys.stdin` and issuing a :exc:`GetPassWarning`. .. note:: If you call :meth:`~getpass.getpass` from within IDLE, the input may be done in the terminal you launched IDLE from rather than the idle window itself. .. note:: If you call :meth:`getpass` from within IDLE, the input may be done in the terminal you launched IDLE from rather than the idle window itself. """, issues=[ LintIssue(line=13, message="self-link to :func:`getpass.getpass`", fixed=True), LintIssue(line=17, message="self-link to :func:`getpass`", fixed=True), LintIssue(line=22, message="self-link to :meth:`getpass.getpass`", fixed=True), LintIssue(line=26, message="self-link to :meth:`getpass`", fixed=True), ], diff=""" - If echo-free input is unavailable :func:`~getpass.getpass` falls back to printing + If echo-free input is unavailable :func:`!getpass.getpass` falls back to printing - If echo-free input is unavailable :func:`getpass` falls back to printing + If echo-free input is unavailable :func:`!getpass` falls back to printing - If you call :meth:`~getpass.getpass` from within IDLE, the input may be done in the + If you call :meth:`!getpass.getpass` from within IDLE, the input may be done in the - If you call :meth:`getpass` from within IDLE, the input may be done in the + If you call :meth:`!getpass` from within IDLE, the input may be done in the """, ), lint_test_case( id="stars", rst=r""" Process Management ------------------ These functions may be used to create and manage processes. The various :func:`exec\* ` functions take a list of arguments for the new program loaded into the process. .. function:: execl(path, arg0, arg1, ...) execle(path, arg0, arg1, ..., env) execlp(file, arg0, arg1, ...) execlpe(file, arg0, arg1, ..., env) execv(path, args) execve(path, args, env) execvp(file, args) execvpe(file, args, env) The current process is replaced immediately. Open file objects and descriptors are not flushed, so if there may be data buffered on these open files, you should flush them using :func:`sys.stdout.flush` or :func:`os.fsync` before calling an :func:`exec\* ` function. The "l" and "v" variants of the :func:`exec\* ` functions differ in how command-line arguments are passed. The "l" variants are perhaps the easiest to work with if the number of parameters is fixed when the code is written; the individual parameters simply become additional parameters to the :func:`!execl\*` functions. The "v" variants are good when the number of parameters is variable, with the arguments being passed in a list or tuple as the *args* parameter. In either case, the arguments to the child process should start with the name of the command being run, but this is not enforced. The variants which include a "p" near the end (:func:`execlp`, :func:`execlpe`, :func:`execvp`, and :func:`execvpe`) will use the :envvar:`PATH` environment variable to locate the program *file*. """, issues=[ LintIssue(line=22, message="self-link to :func:`execl`", fixed=True), LintIssue(line=24, message="self-link to :func:`execl`", fixed=True), LintIssue(line=33, message="self-link to :func:`execlp`", fixed=True), LintIssue(line=34, message="self-link to :func:`execlpe`", fixed=True), LintIssue(line=34, message="self-link to :func:`execvp`", fixed=True), LintIssue(line=34, message="self-link to :func:`execvpe`", fixed=True), ], diff=r""" - :func:`exec\* ` function. + :func:`!exec\*` function. - The "l" and "v" variants of the :func:`exec\* ` functions differ in how + The "l" and "v" variants of the :func:`!exec\*` functions differ in how - The variants which include a "p" near the end (:func:`execlp`, + The variants which include a "p" near the end (:func:`!execlp`, - :func:`execlpe`, :func:`execvp`, and :func:`execvpe`) will use the + :func:`!execlpe`, :func:`!execvp`, and :func:`!execvpe`) will use the """, ), # A class mention in a method signature creates a reference with no line number, which # used to cause an assertion. But also, there's no markup for us to fix, # so it creates a LintIssue with fixed=False. lint_test_case( id="class-in-signature", rst=""" Snapshot ^^^^^^^^ .. class:: Snapshot Snapshot of traces of memory blocks allocated by Python. The :func:`take_snapshot` function creates a snapshot instance. .. method:: compare_to(old_snapshot: Snapshot, key_type: str, cumulative: bool=False) Compute the differences with an old snapshot. Get statistics as a sorted list of :class:`StatisticDiff` instances grouped by *key_type*. """, issues=[ LintIssue(line=10, message="self-link to :class:`Snapshot`", fixed=False), ], ), lint_test_case( rst="classhier", issues=[ LintIssue(line=6, message="self-link to :class:`ClassA`", fixed=True), LintIssue(line=10, message="self-link to :class:`ClassA`", fixed=True), LintIssue(line=10, message="self-link to :meth:`methoda1`", fixed=True), LintIssue(line=14, message="self-link to :class:`ClassA`", fixed=True), LintIssue(line=18, message="self-link to :meth:`methodb1`", fixed=True), LintIssue(line=19, message="self-link to :class:`ClassA`", fixed=True), LintIssue(line=24, message="self-link to :class:`ClassA`", fixed=True), LintIssue(line=28, message="self-link to :meth:`methoda2`", fixed=True), ], diff=r""" - An outer class, called :class:`ClassA`. + An outer class, called :class:`!ClassA`. - A method of :class:`ClassA`, called :meth:`methoda1`. + A method of :class:`!ClassA`, called :meth:`!methoda1`. - A nested class. This is inside :class:`ClassA`. + A nested class. This is inside :class:`!ClassA`. - A method of :class:`ClassB`, called :meth:`methodb1`. + A method of :class:`ClassB`, called :meth:`!methodb1`. - We're still inside :class:`ClassA`. + We're still inside :class:`!ClassA`. - We're still inside :class:`ClassA`. + We're still inside :class:`!ClassA`. - A method of :class:`ClassA`, called :meth:`methoda2`. + A method of :class:`ClassA`, called :meth:`!methoda2`. """, ), lint_test_case( id="method-no-class", rst=""" Memory-mapped file objects behave like both :class:`bytearray` and like :term:`file objects `. You can use mmap objects in most places where :class:`bytearray` are expected; for example, you can use the :mod:`re` module to search through a memory-mapped file. You can also change a single byte by doing ``obj[index] = 97``, or change a subsequence by assigning to a slice: ``obj[i1:i2] = b'...'``. You can also read and write data starting at the current file position, and :meth:`seek` through the file to different positions. The easiest way to create a text stream is with :meth:`open`, optionally specifying an encoding. """, issues=[], ), lint_test_case( rst="frozenset", issues=[ LintIssue(line=11, message="self-link to :class:`frozenset`", fixed=True), ], diff=""" - represent sets of sets, the inner sets must be :class:`frozenset` + represent sets of sets, the inner sets must be :class:`!frozenset` """, ), ] @pytest.mark.parametrize("rst, issues, diff", SELF_TEST_CASES) def test_fix_self_link(rst: str, issues: list[LintIssue], diff: str) -> None: result = lint_content(rst, fix=True, checks={"self"}) assert result.issues == issues assert diff_lines(rst, result.content) == diff @pytest.mark.parametrize("rst, issues, diff", SELF_TEST_CASES) def test_nofix_self_link(rst: str, issues: list[LintIssue], diff: str) -> None: result = lint_content(rst, fix=False, checks={"self"}) issues = [LintIssue(issue.line, issue.message, fixed=False) for issue in issues] assert result.issues == issues assert rst == result.content PARADUP_TEST_CASES = [ lint_test_case( id="class-in-signature", rst=""" LogRecord Objects ----------------- :class:`LogRecord` instances are created automatically by the :class:`Logger` every time something is logged, and can be created manually via :func:`makeLogRecord` (for example, from a pickled event received over the wire). .. class:: LogRecord(name, level, pathname, lineno, msg, args, exc_info, func=None, sinfo=None) Contains all the information pertinent to the event being logged. :param args: Variable data to merge into the *msg* argument to obtain the event description. :type args: tuple | dict[str, typing.Any] :param exc_info: An exception tuple with the current exception information, as returned by :func:`sys.exc_info`, or ``None`` if no exception information is available. :type exc_info: tuple[type[BaseException], BaseException, types.TracebackType] | None :param func: The name of the function or method from which the logging call was invoked. :type func: str | None """, issues=[ # Strange: line 2 is the underline of the header, the :param: is miles away. LintIssue(line=2, message="duplicate :class:`BaseException` in paragraph", fixed=False) ], ), lint_test_case( id="paragraphs", rst=""" 1 Multiple links to :class:`Something` in the same paragraph about :class:`Something` are not a problem, but they do create multiple LintIssues on different lines about :class:`Something`. 5 If :class:`!Something` is referenced but not linked, then :class:`Something` could be the first link to :class:`Something` and the other links to :class:`Something` are duplicates. """, issues=[ LintIssue(line=2, message="duplicate :class:`Something` in paragraph", fixed=False), LintIssue(line=3, message="duplicate :class:`Something` in paragraph", fixed=False), LintIssue(line=7, message="duplicate :class:`Something` in paragraph", fixed=False), LintIssue(line=8, message="duplicate :class:`Something` in paragraph", fixed=False), ], ), lint_test_case( id="lists", rst=""" Some lists: - 3 Multiple links to :class:`Something` in the same paragraph about :class:`Something` are not a problem, but they do create multiple LintIssues on different lines about :class:`Something`. - 7 If :class:`!Something` is referenced but not linked, then :class:`Something` could be the first link to :class:`Something` and the other links to :class:`Something` are duplicates. """, issues=[ LintIssue(line=4, message="duplicate :class:`Something` in paragraph", fixed=False), LintIssue(line=5, message="duplicate :class:`Something` in paragraph", fixed=False), LintIssue(line=9, message="duplicate :class:`Something` in paragraph", fixed=False), LintIssue(line=10, message="duplicate :class:`Something` in paragraph", fixed=False), ], ), ] @pytest.mark.parametrize("rst, issues, diff", PARADUP_TEST_CASES) def test_paradup(rst: str, issues: list[LintIssue], diff: str) -> None: assert diff == "" result = lint_content(rst, fix=False, checks={"paradup"}) assert result.issues == issues ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1787745717.0320294 sphinx_linklint-2.0.1/tests/test_regions.py0000644000175100017510000001201015243552665020625 0ustar00runnerrunnerimport pytest from helpers import text_and_id from sphinx_linklint.regions import Region, find_regions from sphinx_linklint.rsthelp import parse_rst def region_test_case(*, rst: str, regions: list[Region], id: str = ""): """Helper to create pytest parameters for tests.""" rst, id = text_and_id(text=rst, id=id) return pytest.param(rst, regions, id=id) def region( kind: str, name: str, *, start: int, end: int = 0, end_main: int = 0, end_total: int = 0, ) -> Region: """Helper to create a Region with end_main and end_total defaulting to end.""" assert end or (end_main and end_total), ( "Either end or both end_main and end_total must be provided" ) if not end_main: end_main = end if not end_total: end_total = end return Region(kind=kind, name=name, start=start, end_main=end_main, end_total=end_total) TEST_CASES = [ # The `contents::` directive makes unnumbered paragraphs. region_test_case( id="unnumbered", rst=""" ====================== Design and History FAQ ====================== .. only:: html .. contents:: Why does Python use indentation for grouping of statements? ----------------------------------------------------------- Guido van Rossum believes that using indentation for grouping is extremely elegant and contributes a lot to the clarity of the average Python program. Most people learn to love this feature after a while. """, regions=[], ), region_test_case( id="mymodule", rst=""" 1My Module ========== .. module:: mymodule 6This is the :mod:`mymodule` documentation. 8Also check :mod:`othermodule` for related functionality. lorem ipsum quia dolor sit amet consectetur adipisci velit, sed quia non numquam eius modi tempora incidunt, ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam 12corporis suscipit laboriosam. 14more about this module ------------------------ 17lorem ipsum quia dolor sit amet consectetur adipisci velit, sed quia non numquam eius modi tempora incidunt. 20Other Section =============== 23This section references :mod:`mymodule` which is fine. """, regions=[ region("module", "mymodule", start=1, end=18), ], ), region_test_case( id="multiple-functions", rst=r""" The various :func:`exec\* ` functions take a list of arguments for the new program loaded into the process. .. function:: execl(path, arg0, arg1, ...) execle(path, arg0, arg1, ..., env) execlp(file, arg0, arg1, ...) execlpe(file, arg0, arg1, ..., env) This is a family of similar functions. """, regions=[ region("function", "execl", start=4, end=9), region("function", "execle", start=4, end=9), region("function", "execlp", start=4, end=9), region("function", "execlpe", start=4, end=9), ], ), region_test_case( rst="lzma", regions=[ region("exception", "LZMAError", start=26, end=29), region("function", "open", start=35, end=68), region("method", "LZMAFile.peek", start=108, end=117), region("attribute", "LZMAFile.mode", start=119, end=123), region("attribute", "LZMAFile.name", start=125, end=130), region("class", "LZMAFile", start=71, end_main=107, end_total=141), region("method", "LZMACompressor.compress", start=209, end=215), region("method", "LZMACompressor.flush", start=217, end=222), region("class", "LZMACompressor", start=147, end_main=208, end_total=222), region("method", "LZMADecompressor.decompress", start=254, end=279), region("attribute", "LZMADecompressor.check", start=281, end=285), region("attribute", "LZMADecompressor.eof", start=287, end=289), region("attribute", "LZMADecompressor.unused_data", start=291, end=295), region("attribute", "LZMADecompressor.needs_input", start=297, end=302), region("class", "LZMADecompressor", start=225, end_main=253, end_total=302), region("function", "compress", start=304, end=310), region("function", "decompress", start=313, end=322), region("function", "is_check_supported", start=328, end=335), region("module", "lzma", start=1, end_main=25, end_total=346), ], ), ] @pytest.mark.parametrize("rst, regions", TEST_CASES) def test_regions(rst: str, regions: list[Region]) -> None: assert sorted(find_regions(parse_rst(rst))) == sorted(regions) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1787745717.0320294 sphinx_linklint-2.0.1/tests/test_rsthelp.py0000644000175100017510000000116115243552665020645 0ustar00runnerrunnerimport pytest from sphinx_linklint.rsthelp import is_header_line @pytest.mark.parametrize( "hline, is_header", [ ("======", True), ("------", True), ("====== ", True), # Trailing space is ok ("======\n", True), # Newline is ok (" ====== ", False), # Can't be indented ("====", False), # Too short ("===========", True), # Longer is ok ("=====-", False), # Mixed chars ("123456", False), # Not punctuation ], ) def test_is_header_line(hline: str, is_header: bool) -> None: assert is_header_line(hline, "Header") == is_header ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1787745717.0320294 sphinx_linklint-2.0.1/tox.ini0000644000175100017510000000055215243552665015727 0ustar00runnerrunner[tox] requires = tox>=4.32 env_list = py3{12-15}-sphinx{8,9}, coverage [testenv] extras = dev pass_env = FORCE_COLOR LINKLINT_* deps = sphinx8: sphinx>8,<9 sphinx9: sphinx>9,<10 commands = coverage run -m pytest {posargs} [testenv:coverage] depends = py3* commands = coverage combine -q coverage report -m coverage html