Skip to content

Commit 679d554

Browse files
committed
Use antsibull-docutils 1.2.0 to simplify rst-yamllint checker.
1 parent efb5f3e commit 679d554

File tree

6 files changed

+133
-268
lines changed

6 files changed

+133
-268
lines changed

tests/checkers/rst-yamllint.py

Lines changed: 111 additions & 254 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,9 @@
66
import pathlib
77
import sys
88
import traceback
9+
import typing as t
910

10-
from docutils import nodes
11-
from docutils.core import Publisher
12-
from docutils.io import StringInput
13-
from docutils.parsers.rst import Directive
14-
from docutils.parsers.rst.directives import register_directive
15-
from docutils.parsers.rst.directives import unchanged as directive_param_unchanged
16-
from docutils.utils import Reporter, SystemMessage
11+
from antsibull_docutils.rst_code_finder import find_code_blocks
1712
from yamllint import linter
1813
from yamllint.config import YamlLintConfig
1914
from yamllint.linter import PROBLEM_LEVELS
@@ -44,215 +39,47 @@
4439
}
4540

4641

47-
class IgnoreDirective(Directive):
48-
has_content = True
49-
50-
def run(self) -> list:
51-
return []
52-
53-
54-
class CodeBlockDirective(Directive):
55-
has_content = True
56-
optional_arguments = 1
57-
58-
# These are all options Sphinx allows for code blocks.
59-
# We need to have them here so that docutils successfully parses this extension.
60-
option_spec = {
61-
"caption": directive_param_unchanged,
62-
"class": directive_param_unchanged,
63-
"dedent": directive_param_unchanged,
64-
"emphasize-lines": directive_param_unchanged,
65-
"name": directive_param_unchanged,
66-
"force": directive_param_unchanged,
67-
"linenos": directive_param_unchanged,
68-
"lineno-start": directive_param_unchanged,
69-
}
70-
71-
def run(self) -> list[nodes.literal_block]:
72-
code = "\n".join(self.content)
73-
literal = nodes.literal_block(code, code)
74-
literal["classes"].append("code-block")
75-
literal["ansible-code-language"] = self.arguments[0] if self.arguments else None
76-
literal["ansible-code-block"] = True
77-
literal["ansible-code-lineno"] = self.lineno
78-
return [literal]
79-
80-
81-
class YamlLintVisitor(nodes.SparseNodeVisitor):
82-
def __init__(
83-
self,
84-
document: nodes.document,
85-
path: str,
86-
results: list[dict],
87-
content: str,
88-
yamllint_config: YamlLintConfig,
89-
):
90-
super().__init__(document)
91-
self.__path = path
92-
self.__results = results
93-
self.__content_lines = content.splitlines()
94-
self.__yamllint_config = yamllint_config
95-
96-
def visit_system_message(self, node: nodes.system_message) -> None:
97-
raise nodes.SkipNode
98-
99-
def visit_error(self, node: nodes.error) -> None:
100-
raise nodes.SkipNode
101-
102-
def visit_literal_block(self, node: nodes.literal_block) -> None:
103-
if "ansible-code-block" not in node.attributes:
104-
if node.attributes["classes"]:
105-
self.__results.append(
106-
{
107-
"path": self.__path,
108-
"line": node.line or "unknown",
109-
"col": 0,
110-
"message": (
111-
"Warning: found unknown literal block! Check for double colons '::'."
112-
" If that is not the cause, please report this warning."
113-
" It might indicate a bug in the checker or an unsupported Sphinx directive."
114-
f" Node: {node!r}; attributes: {node.attributes}; content: {node.rawsource!r}"
115-
),
116-
}
117-
)
118-
else:
119-
allowed_languages = ", ".join(sorted(ALLOWED_LANGUAGES))
120-
self.__results.append(
121-
{
122-
"path": self.__path,
123-
"line": node.line or "unknown",
124-
"col": 0,
125-
"message": (
126-
"Warning: literal block (check for double colons '::')."
127-
" Please convert this to a regular code block with an appropriate language."
128-
f" Allowed languages: {allowed_languages}"
129-
),
130-
}
131-
)
132-
raise nodes.SkipNode
133-
134-
language = node.attributes["ansible-code-language"]
135-
lineno = node.attributes["ansible-code-lineno"]
136-
137-
# Ok, we have to find both the row and the column offset for the actual code content
138-
row_offset = lineno
139-
found_empty_line = False
140-
found_content_lines = False
141-
content_lines = node.rawsource.count("\n") + 1
142-
min_indent = None
143-
for offset, line in enumerate(self.__content_lines[lineno:]):
144-
stripped_line = line.strip()
145-
if not stripped_line:
146-
if not found_empty_line:
147-
row_offset = lineno + offset + 1
148-
found_empty_line = True
149-
elif not found_content_lines:
150-
found_content_lines = True
151-
row_offset = lineno + offset
152-
153-
if found_content_lines and content_lines > 0:
154-
if stripped_line:
155-
indent = len(line) - len(line.lstrip())
156-
if min_indent is None or min_indent > indent:
157-
min_indent = indent
158-
content_lines -= 1
159-
elif not content_lines:
160-
break
161-
162-
min_source_indent = None
163-
for line in node.rawsource.split("\n"):
164-
stripped_line = line.lstrip()
165-
if stripped_line:
166-
indent = len(line) - len(line.lstrip())
167-
if min_source_indent is None or min_source_indent > indent:
168-
min_source_indent = indent
169-
170-
col_offset = max(0, (min_indent or 0) - (min_source_indent or 0))
171-
172-
# Now that we have the offsets, we can actually do some processing...
173-
if language not in {"YAML", "yaml", "yaml+jinja", "YAML+Jinja"}:
174-
if language is None:
175-
allowed_languages = ", ".join(sorted(ALLOWED_LANGUAGES))
176-
self.__results.append(
177-
{
178-
"path": self.__path,
179-
"line": row_offset + 1,
180-
"col": col_offset + 1,
181-
"message": (
182-
"Literal block without language!"
183-
f" Allowed languages are: {allowed_languages}."
184-
),
185-
}
186-
)
187-
return
188-
if language not in ALLOWED_LANGUAGES:
189-
allowed_languages = ", ".join(sorted(ALLOWED_LANGUAGES))
190-
self.__results.append(
191-
{
192-
"path": self.__path,
193-
"line": row_offset + 1,
194-
"col": col_offset + 1,
195-
"message": (
196-
f"Warning: literal block with disallowed language: {language}."
197-
" If the language should be allowed, the checker needs to be updated."
198-
f" Currently allowed languages are: {allowed_languages}."
199-
),
200-
}
201-
)
202-
raise nodes.SkipNode
203-
204-
# So we have YAML. Let's lint it!
205-
try:
206-
problems = linter.run(
207-
io.StringIO(node.rawsource.rstrip() + "\n"),
208-
self.__yamllint_config,
209-
self.__path,
42+
def create_warn_unknown_block(
43+
results: list[dict[str, t.Any]], path: str
44+
) -> t.Callable[[int | str, int, str, bool], None]:
45+
def warn_unknown_block(
46+
line: int | str, col: int, content: str, unknown_directive: bool
47+
) -> None:
48+
if unknown_directive:
49+
results.append(
50+
{
51+
"path": path,
52+
"line": line,
53+
"col": col,
54+
"message": (
55+
"Warning: found unknown literal block! Check for double colons '::'."
56+
" If that is not the cause, please report this warning."
57+
" It might indicate a bug in the checker or an unsupported Sphinx directive."
58+
f" Content: {content!r}"
59+
),
60+
}
21061
)
211-
for problem in problems:
212-
if problem.level not in REPORT_LEVELS:
213-
continue
214-
msg = f"{problem.level}: {problem.desc}"
215-
if problem.rule:
216-
msg += f" ({problem.rule})"
217-
self.__results.append(
218-
{
219-
"path": self.__path,
220-
"line": row_offset + problem.line,
221-
"col": col_offset + problem.column,
222-
"message": msg,
223-
}
224-
)
225-
except Exception as exc:
226-
error = str(exc).replace("\n", " / ")
227-
self.__results.append(
62+
else:
63+
allowed_languages = ", ".join(sorted(ALLOWED_LANGUAGES))
64+
results.append(
22865
{
229-
"path": self.__path,
230-
"line": row_offset + 1,
231-
"col": col_offset + 1,
66+
"path": path,
67+
"line": line,
68+
"col": 0,
23269
"message": (
233-
f"Internal error while linting YAML: exception {type(exc)}:"
234-
f" {error}; traceback: {traceback.format_exc()!r}"
70+
"Warning: literal block (check for double colons '::')."
71+
" Please convert this to a regular code block with an appropriate language."
72+
f" Allowed languages: {allowed_languages}"
23573
),
23674
}
23775
)
23876

239-
raise nodes.SkipNode
77+
return warn_unknown_block
24078

24179

242-
def main():
80+
def main() -> None:
24381
paths = sys.argv[1:] or sys.stdin.read().splitlines()
244-
results = []
245-
246-
for directive in (
247-
"code",
248-
"code-block",
249-
"sourcecode",
250-
):
251-
register_directive(directive, CodeBlockDirective)
252-
253-
# The following docutils directives should better be ignored:
254-
for directive in ("parsed-literal",):
255-
register_directive(directive, IgnoreDirective)
82+
results: list[dict[str, t.Any]] = []
25683

25784
# TODO: should we handle the 'literalinclude' directive? maybe check file directly if right extension?
25885
# (https://www.sphinx-doc.org/en/master/usage/restructuredtext/directives.html#directive-literalinclude)
@@ -267,56 +94,86 @@ def main():
26794
with open(path, "rt", encoding="utf-8") as f:
26895
content = f.read()
26996

270-
# We create a Publisher only to have a mechanism which gives us the settings object.
271-
# Doing this more explicit is a bad idea since the classes used are deprecated and will
272-
# eventually get replaced. Publisher.get_settings() looks like a stable enough API that
273-
# we can 'just use'.
274-
publisher = Publisher(source_class=StringInput)
275-
publisher.set_components("standalone", "restructuredtext", "pseudoxml")
276-
override = {
277-
"root_prefix": docs_root,
278-
"input_encoding": "utf-8",
279-
"file_insertion_enabled": False,
280-
"raw_enabled": False,
281-
"_disable_config": True,
282-
"report_level": Reporter.ERROR_LEVEL,
283-
"warning_stream": io.StringIO(),
284-
}
285-
publisher.process_programmatic_settings(None, override, None)
286-
publisher.set_source(content, path)
287-
288-
# Parse the document
28997
try:
290-
doc = publisher.reader.read(
291-
publisher.source, publisher.parser, publisher.settings
292-
)
293-
except SystemMessage as exc:
294-
error = str(exc).replace("\n", " / ")
295-
results.append(
296-
{
297-
"path": path,
298-
"line": 0,
299-
"col": 0,
300-
"message": f"Cannot parse document: {error}",
301-
}
302-
)
303-
continue
304-
except Exception as exc:
305-
error = str(exc).replace("\n", " / ")
306-
results.append(
307-
{
308-
"path": path,
309-
"line": 0,
310-
"col": 0,
311-
"message": f"Cannot parse document, unexpected error {type(exc)}: {error}; traceback: {traceback.format_exc()!r}",
312-
}
313-
)
314-
continue
98+
for code_block in find_code_blocks(
99+
content,
100+
path=path,
101+
root_prefix=docs_root,
102+
warn_unknown_block_w_unknown_info=create_warn_unknown_block(
103+
results, path
104+
),
105+
):
106+
# Now that we have the offsets, we can actually do some processing...
107+
if code_block.language not in {
108+
"YAML",
109+
"yaml",
110+
"yaml+jinja",
111+
"YAML+Jinja",
112+
}:
113+
if code_block.language is None:
114+
allowed_languages = ", ".join(sorted(ALLOWED_LANGUAGES))
115+
results.append(
116+
{
117+
"path": path,
118+
"line": code_block.row_offset + 1,
119+
"col": code_block.col_offset + 1,
120+
"message": (
121+
"Literal block without language!"
122+
f" Allowed languages are: {allowed_languages}."
123+
),
124+
}
125+
)
126+
return
127+
if code_block.language not in ALLOWED_LANGUAGES:
128+
allowed_languages = ", ".join(sorted(ALLOWED_LANGUAGES))
129+
results.append(
130+
{
131+
"path": path,
132+
"line": code_block.row_offset + 1,
133+
"col": code_block.col_offset + 1,
134+
"message": (
135+
f"Warning: literal block with disallowed language: {code_block.language}."
136+
" If the language should be allowed, the checker needs to be updated."
137+
f" Currently allowed languages are: {allowed_languages}."
138+
),
139+
}
140+
)
141+
continue
315142

316-
# Process the document
317-
try:
318-
visitor = YamlLintVisitor(doc, path, results, content, yamllint_config)
319-
doc.walk(visitor)
143+
# So we have YAML. Let's lint it!
144+
try:
145+
problems = linter.run(
146+
io.StringIO(code_block.content),
147+
yamllint_config,
148+
path,
149+
)
150+
for problem in problems:
151+
if problem.level not in REPORT_LEVELS:
152+
continue
153+
msg = f"{problem.level}: {problem.desc}"
154+
if problem.rule:
155+
msg += f" ({problem.rule})"
156+
results.append(
157+
{
158+
"path": path,
159+
"line": code_block.row_offset + problem.line,
160+
"col": code_block.col_offset + problem.column,
161+
"message": msg,
162+
}
163+
)
164+
except Exception as exc:
165+
error = str(exc).replace("\n", " / ")
166+
results.append(
167+
{
168+
"path": path,
169+
"line": code_block.row_offset + 1,
170+
"col": code_block.col_offset + 1,
171+
"message": (
172+
f"Internal error while linting YAML: exception {type(exc)}:"
173+
f" {error}; traceback: {traceback.format_exc()!r}"
174+
),
175+
}
176+
)
320177
except Exception as exc:
321178
error = str(exc).replace("\n", " / ")
322179
results.append(

0 commit comments

Comments
 (0)