Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Configure (and autodiscover) directives #26

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ requires-python = ">= 3.7"
dependencies = [
"regex",
"polib",
"tomli>=2; python_version < '3.11'",
]
dynamic = ["version"]

Expand Down
12 changes: 12 additions & 0 deletions sphinxlint/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
from itertools import chain, starmap

from sphinxlint import check_file
from sphinxlint import rst
from sphinxlint.config import get_config
from sphinxlint.checkers import all_checkers
from sphinxlint.sphinxlint import CheckersOptions

Expand All @@ -15,6 +17,11 @@ def parse_args(argv=None):
"""Parse command line argument."""
if argv is None:
argv = sys.argv
if argv[1:2] == ["init", "directives"]:
from directivegetter import collect_directives

raise SystemExit(collect_directives(argv[2:]))

parser = argparse.ArgumentParser(description=__doc__)

enabled_checkers_names = {
Expand Down Expand Up @@ -113,6 +120,11 @@ def walk(path, ignore_list):


def main(argv=None):
config = get_config()

# Append extra directives
rst.DIRECTIVES_CONTAINING_ARBITRARY_CONTENT.extend(config.get("known_directives", []))

enabled_checkers, args = parse_args(argv)
options = CheckersOptions.from_argparse(args)
if args.list:
Expand Down
6 changes: 4 additions & 2 deletions sphinxlint/checkers.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,9 @@ def check_directive_with_three_dots(file, lines, options=None):
Bad: ... versionchanged:: 3.6
Good: .. versionchanged:: 3.6
"""
three_dot_directive_re = rst.three_dot_directive_re()
for lno, line in enumerate(lines, start=1):
if rst.THREE_DOT_DIRECTIVE_RE.search(line):
if three_dot_directive_re.search(line):
yield lno, "directive should start with two dots, not three."


Expand All @@ -148,8 +149,9 @@ def check_directive_missing_colons(file, lines, options=None):
Bad: .. versionchanged 3.6.
Good: .. versionchanged:: 3.6
"""
seems_directive_re = rst.seems_directive_re()
for lno, line in enumerate(lines, start=1):
if rst.SEEMS_DIRECTIVE_RE.search(line):
if seems_directive_re.search(line):
yield lno, "comment seems to be intended as a directive"


Expand Down
28 changes: 28 additions & 0 deletions sphinxlint/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import sys
from os.path import isfile
from typing import Any

if sys.version_info[:2] >= (3, 11):
import tomllib
else:
try:
import tomli as tomllib
except ImportError:
tomllib = None


def _read_toml(filename: str) -> dict[str, Any]:
if tomllib is None:
return {}
with open(filename, "rb") as f:
return tomllib.load(f)


def get_config() -> dict[str, Any]:
if isfile("sphinx.toml"):
table = _read_toml("sphinx.toml")
elif isfile("pyproject.toml"):
table = _read_toml("pyproject.toml")
else:
table = {}
return table.get("tool", {}).get("sphinx-lint", {})
126 changes: 126 additions & 0 deletions sphinxlint/directivegetter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
from __future__ import annotations

from pathlib import Path
import sys
from typing import TYPE_CHECKING

from docutils.parsers.rst.directives import _directive_registry, _directives
from docutils.parsers.rst.languages import en
from sphinx.builders.dummy import DummyBuilder

if TYPE_CHECKING:
from collections.abc import Iterator, Iterable

from sphinx.application import Sphinx

DOCUTILS_DIRECTIVES = frozenset(_directive_registry.keys() | en.directives.keys())
SPHINX_DIRECTIVES = frozenset({
# reStructuredText content:
# ~~~~~~~~~~~~~~~~~~~~~~~~~
# Added by Sphinx:
'acks', 'centered', 'codeauthor', 'default-domain', 'deprecated(?!-removed)',
'describe', 'highlight', 'hlist', 'index', 'literalinclude', 'moduleauthor',
'object', 'only', 'rst-class', 'sectionauthor', 'seealso', 'tabularcolumns',
'toctree', 'versionadded', 'versionchanged',
# Added by Sphinx (since removed):
'highlightlang', # removed in Sphinx 4.0
# Added by Sphinx (Standard domain):
'cmdoption', 'envvar', 'glossary', 'option', 'productionlist', 'program',
# Added by Sphinx (Python domain):
'py:attribute', 'py:class', 'py:classmethod', 'py:currentmodule', 'py:data',
'py:decorator', 'py:decoratormethod', 'py:exception', 'py:function',
'py:method', 'py:module', 'py:property', 'py:staticmethod',
'attribute', 'class', 'classmethod', 'currentmodule', 'data',
'decorator', 'decoratormethod', 'exception', 'function',
'method', 'module', 'property', 'staticmethod',
# Added by Sphinx (C domain):
'c:alias', 'c:enum', 'c:enumerator', 'c:function', 'c:macro', 'c:member',
'c:struct', 'c:type', 'c:union', 'c:var',
'cfunction', 'cmacro', 'cmember', 'ctype', 'cvar',
# Added by Sphinx (sphinx.ext.todo):
'todo', 'todolist',
# Added in Sphinx's own documentation only:
'confval', 'event',

# Arbitrary content:
# ~~~~~~~~~~~~~~~~~~
# Added by Sphinx (core):
'cssclass',
# Added by Sphinx (Standard domain):
'productionlist',
# Added by Sphinx (C domain):
'c:namespace', 'c:namespace-pop', 'c:namespace-push',
# Added by Sphinx (sphinx.ext.autodoc):
'autoattribute', 'autoclass', 'autodata', 'autodecorator', 'autoexception',
'autofunction', 'automethod', 'automodule', 'autonewtypedata',
'autonewvarattribute', 'autoproperty',
# Added by Sphinx (sphinx.ext.doctest):
'doctest', 'testcleanup', 'testcode', 'testoutput', 'testsetup',

})
CORE_DIRECTIVES = DOCUTILS_DIRECTIVES | SPHINX_DIRECTIVES


def tomlify_directives(directives: Iterable[str], comment: str) -> Iterator[str]:
yield f" # {comment}:"
yield from (f' "{directive}",' for directive in sorted(directives))


def write_directives(directives: Iterable[str]):
lines = [
"[tool.sphinx-lint]",
"known_directives = [",
tomlify_directives(directives, "Added by extensions or in conf.py"),
"]",
"", # final blank line
]
with open("sphinx.toml", "w", encoding="utf-8") as file:
file.write("\n".join(lines))


class DirectiveCollectorBuilder(DummyBuilder):
name = "directive_collector"

def get_outdated_docs(self) -> str:
return "nothing, just getting list of directives"

def read(self) -> list[str]:
write_directives({*_directives} - CORE_DIRECTIVES)
return []

def write(self, *args, **kwargs) -> None:
pass


def setup(app: Sphinx) -> dict[str, bool]:
"""Plugin for Sphinx"""
app.add_builder(DirectiveCollectorBuilder)
return {"parallel_read_safe": True, "parallel_write_safe": True}


def collect_directives(args=None):
from sphinx import application
from sphinx.application import Sphinx

try:
source_dir, build_dir, *opts = args or sys.argv[1:]
except ValueError:
raise RuntimeError("Two arguments (source dir and build dir) are required.")

application.builtin_extensions = (
*application.builtin_extensions,
"directivegetter" # set up this file as an extension
)
app = Sphinx(
str(Path(source_dir)),
str(Path(source_dir)),
str(Path(build_dir)),
str(Path(build_dir, "doctrees")),
"directive_collector",
)
app.build(force_all=True)
raise SystemExit(app.statuscode)


if __name__ == "__main__":
collect_directives()
104 changes: 72 additions & 32 deletions sphinxlint/rst.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,35 +58,68 @@

# fmt: off
DIRECTIVES_CONTAINING_RST = [
# standard docutils ones
# reStructuredText directives:
'admonition', 'attention', 'caution', 'class', 'compound', 'container',
'danger', 'epigraph', 'error', 'figure', 'footer', 'header', 'highlights',
'hint', 'image', 'important', 'include', 'line-block', 'list-table', 'meta',
'note', 'parsed-literal', 'pull-quote', 'replace', 'sidebar', 'tip', 'topic',
'warning',
# Sphinx and Python docs custom ones
'acks', 'attribute', 'autoattribute', 'autoclass', 'autodata',
'autoexception', 'autofunction', 'automethod', 'automodule',
'availability', 'centered', 'cfunction', 'class', 'classmethod', 'cmacro',
'cmdoption', 'cmember', 'confval', 'cssclass', 'ctype',
'currentmodule', 'cvar', 'data', 'decorator', 'decoratormethod',
'deprecated-removed', 'deprecated(?!-removed)', 'describe', 'directive',
'doctest', 'envvar', 'event', 'exception', 'function', 'glossary',
'highlight', 'highlightlang', 'impl-detail', 'index', 'literalinclude',
'method', 'miscnews', 'module', 'moduleauthor', 'opcode', 'pdbcommand',
'program', 'role', 'sectionauthor', 'seealso',
'sourcecode', 'staticmethod', 'tabularcolumns', 'testcode', 'testoutput',
'testsetup', 'toctree', 'todo', 'todolist', 'versionadded',
'versionchanged', 'c:function', 'coroutinefunction'
'hint', 'image', 'important', 'line-block', 'list-table', 'math', 'meta',
'note', 'parsed-literal', 'pull-quote', 'replace', 'sidebar', 'tip',
'topic', 'warning',
# Added by Sphinx:
'acks', 'centered', 'codeauthor', 'default-domain', 'deprecated(?!-removed)',
'describe', 'highlight', 'hlist', 'index', 'literalinclude', 'moduleauthor',
'object', 'only', 'rst-class', 'sectionauthor', 'seealso', 'tabularcolumns',
'toctree', 'versionadded', 'versionchanged',
# Added by Sphinx (since removed):
'highlightlang', # removed in Sphinx 4.0
# Added by Sphinx (Standard domain):
'cmdoption', 'envvar', 'glossary', 'option', 'productionlist', 'program',
# Added by Sphinx (Python domain):
'py:attribute', 'py:class', 'py:classmethod', 'py:currentmodule', 'py:data',
'py:decorator', 'py:decoratormethod', 'py:exception', 'py:function',
'py:method', 'py:module', 'py:property', 'py:staticmethod',
'attribute', 'class', 'classmethod', 'currentmodule', 'data',
'decorator', 'decoratormethod', 'exception', 'function',
'method', 'module', 'property', 'staticmethod',
# Added by Sphinx (C domain):
'c:alias', 'c:enum', 'c:enumerator', 'c:function', 'c:macro', 'c:member',
'c:struct', 'c:type', 'c:union', 'c:var',
'cfunction', 'cmacro', 'cmember', 'ctype', 'cvar',
# Added by Sphinx (sphinx.ext.todo):
'todo', 'todolist',
# Added in Sphinx's own documentation only:
'confval', 'event',
# Added in the Python documentation (directives):
'audit-event', 'audit-event-table', 'availability',
'deprecated-removed', 'impl-detail', 'miscnews',
# Added in the Python documentation (objects with implicit directives):
'2to3fixer', 'opcode', 'pdbcommand',
# Added in the Python documentation (Python domain):
'coroutinefunction', 'coroutinemethod', 'abstractmethod',
'awaitablefunction', 'awaitablemethod',
'py:coroutinefunction', 'py:coroutinemethod', 'py:abstractmethod',
'py:awaitablefunction', 'py:awaitablemethod',
]

DIRECTIVES_CONTAINING_ARBITRARY_CONTENT = [
# standard docutils ones
'contents', 'csv-table', 'date', 'default-role', 'include', 'raw',
'restructuredtext-test-directive', 'role', 'rubric', 'sectnum', 'table',
'target-notes', 'title', 'unicode',
# Sphinx and Python docs custom ones
'productionlist', 'code-block',
# reStructuredText directives:
'code', 'code-block', 'contents', 'csv-table', 'date', 'default-role',
'include', 'raw', 'restructuredtext-test-directive', 'role', 'rubric',
'section-numbering', 'sectnum', 'sourcecode', 'table', 'target-notes',
'title', 'unicode',
# Added by Sphinx (core):
'cssclass',
# Added by Sphinx (Standard domain):
'productionlist',
# Added by Sphinx (C domain):
'c:namespace', 'c:namespace-pop', 'c:namespace-push',
# Added by Sphinx (sphinx.ext.autodoc):
'autoattribute', 'autoclass', 'autodata', 'autodecorator', 'autoexception',
'autofunction', 'automethod', 'automodule', 'autonewtypedata',
'autonewvarattribute', 'autoproperty',
# Added by Sphinx (sphinx.ext.doctest):
'doctest', 'testcleanup', 'testcode', 'testoutput', 'testsetup',
# Added in the Python documentation:
'limited-api-list',
]

# fmt: on
Expand All @@ -99,12 +132,6 @@
r"^\s*\.\. (" + "|".join(DIRECTIVES_CONTAINING_RST) + ")::"
)

ALL_DIRECTIVES = (
"("
+ "|".join(DIRECTIVES_CONTAINING_RST + DIRECTIVES_CONTAINING_ARBITRARY_CONTENT)
+ ")"
)

QUOTE_PAIRS = [
"»»", # Swedish
"‘‚", # Albanian/Greek/Turkish
Expand Down Expand Up @@ -151,6 +178,14 @@
UNICODE_ALLOWED_AFTER_INLINE_MARKUP = r"[\p{Pe}\p{Pi}\p{Pf}\p{Pd}\p{Po}]"


def get_all_directives() -> str:
return (
"("
+ "|".join(DIRECTIVES_CONTAINING_RST + DIRECTIVES_CONTAINING_ARBITRARY_CONTENT)
+ ")"
)


def inline_markup_gen(start_string, end_string, extra_allowed_before=""):
"""Generate a regex matching an inline markup.

Expand Down Expand Up @@ -226,19 +261,24 @@ def inline_markup_gen(start_string, end_string, extra_allowed_before=""):
rf"(^|\s)`:{SIMPLENAME}:{INTERPRETED_TEXT_RE.pattern}", flags=re.VERBOSE | re.DOTALL
)


# Find comments that look like a directive, like:
# .. versionchanged 3.6
# or
# .. versionchanged: 3.6
# as it should be:
# .. versionchanged:: 3.6
SEEMS_DIRECTIVE_RE = re.compile(rf"^\s*(?<!\.)\.\. {ALL_DIRECTIVES}([^a-z:]|:(?!:))")
def seems_directive_re() -> re.Pattern[str]:
return re.compile(rf"^\s*(?<!\.)\.\. {get_all_directives()}([^a-z:]|:(?!:))")


# Find directive prefixed with three dots instead of two, like:
# ... versionchanged:: 3.6
# instead of:
# .. versionchanged:: 3.6
THREE_DOT_DIRECTIVE_RE = re.compile(rf"\.\.\. {ALL_DIRECTIVES}::")
def three_dot_directive_re() -> re.Pattern[str]:
return re.compile(rf"\.\.\. {get_all_directives()}::")


# Find role used with double backticks instead of simple backticks like:
# :const:``None``
Expand Down
2 changes: 1 addition & 1 deletion sphinxlint/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ def hide_non_rst_blocks(lines, hidden_block_cb=None):
def type_of_explicit_markup(line):
"""Tell apart various explicit markup blocks."""
line = line.lstrip()
if re.match(rf"\.\. {rst.ALL_DIRECTIVES}::", line):
if re.match(rf"\.\. {rst.get_all_directives()}::", line):
return "directive"
if re.match(r"\.\. \[[0-9]+\] ", line):
return "footnote"
Expand Down