Skip to content

Commit d491847

Browse files
committed
Use antsibull-docutils 1.2.0 to simplify rst-yamllint checker.
1 parent 21a526c commit d491847

File tree

6 files changed

+124
-264
lines changed

6 files changed

+124
-264
lines changed

tests/checkers/rst-yamllint.py

Lines changed: 101 additions & 249 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,201 +39,30 @@
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-
raise nodes.SkipNode
119-
120-
language = node.attributes["ansible-code-language"]
121-
lineno = node.attributes["ansible-code-lineno"]
122-
123-
# Ok, we have to find both the row and the column offset for the actual code content
124-
row_offset = lineno
125-
found_empty_line = False
126-
found_content_lines = False
127-
content_lines = node.rawsource.count("\n") + 1
128-
min_indent = None
129-
for offset, line in enumerate(self.__content_lines[lineno:]):
130-
stripped_line = line.strip()
131-
if not stripped_line:
132-
if not found_empty_line:
133-
row_offset = lineno + offset + 1
134-
found_empty_line = True
135-
elif not found_content_lines:
136-
found_content_lines = True
137-
row_offset = lineno + offset
138-
139-
if found_content_lines and content_lines > 0:
140-
if stripped_line:
141-
indent = len(line) - len(line.lstrip())
142-
if min_indent is None or min_indent > indent:
143-
min_indent = indent
144-
content_lines -= 1
145-
elif not content_lines:
146-
break
147-
148-
min_source_indent = None
149-
for line in node.rawsource.split("\n"):
150-
stripped_line = line.lstrip()
151-
if stripped_line:
152-
indent = len(line) - len(line.lstrip())
153-
if min_source_indent is None or min_source_indent > indent:
154-
min_source_indent = indent
155-
156-
col_offset = max(0, (min_indent or 0) - (min_source_indent or 0))
157-
158-
# Now that we have the offsets, we can actually do some processing...
159-
if language not in {"YAML", "yaml", "yaml+jinja", "YAML+Jinja"}:
160-
if language is None:
161-
allowed_languages = ", ".join(sorted(ALLOWED_LANGUAGES))
162-
self.__results.append(
163-
{
164-
"path": self.__path,
165-
"line": row_offset + 1,
166-
"col": col_offset + 1,
167-
"message": (
168-
"Literal block without language!"
169-
f" Allowed languages are: {allowed_languages}."
170-
),
171-
}
172-
)
173-
return
174-
if language not in ALLOWED_LANGUAGES:
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-
f"Warning: literal block with disallowed language: {language}."
183-
" If the language should be allowed, the checker needs to be updated."
184-
f" Currently allowed languages are: {allowed_languages}."
185-
),
186-
}
187-
)
188-
raise nodes.SkipNode
189-
190-
# So we have YAML. Let's lint it!
191-
try:
192-
problems = linter.run(
193-
io.StringIO(node.rawsource.rstrip() + "\n"),
194-
self.__yamllint_config,
195-
self.__path,
196-
)
197-
for problem in problems:
198-
if problem.level not in REPORT_LEVELS:
199-
continue
200-
msg = f"{problem.level}: {problem.desc}"
201-
if problem.rule:
202-
msg += f" ({problem.rule})"
203-
self.__results.append(
204-
{
205-
"path": self.__path,
206-
"line": row_offset + problem.line,
207-
"col": col_offset + problem.column,
208-
"message": msg,
209-
}
210-
)
211-
except Exception as exc:
212-
error = str(exc).replace("\n", " / ")
213-
self.__results.append(
214-
{
215-
"path": self.__path,
216-
"line": row_offset + 1,
217-
"col": col_offset + 1,
218-
"message": (
219-
f"Internal error while linting YAML: exception {type(exc)}:"
220-
f" {error}; traceback: {traceback.format_exc()!r}"
221-
),
222-
}
223-
)
224-
225-
raise nodes.SkipNode
226-
227-
228-
def main():
42+
def create_warn_unknown_block(
43+
results: list[dict[str, t.Any]], path: str
44+
) -> t.Callable[[int | str, int, str], None]:
45+
def warn_unknown_block(line: int | str, col: int, content: str) -> None:
46+
results.append(
47+
{
48+
"path": path,
49+
"line": line,
50+
"col": col,
51+
"message": (
52+
"Warning: found unknown literal block! Check for double colons '::'."
53+
" If that is not the cause, please report this warning."
54+
" It might indicate a bug in the checker or an unsupported Sphinx directive."
55+
f" Content: {content!r}"
56+
),
57+
}
58+
)
59+
60+
return warn_unknown_block
61+
62+
63+
def main() -> None:
22964
paths = sys.argv[1:] or sys.stdin.read().splitlines()
230-
results = []
231-
232-
for directive in (
233-
"code",
234-
"code-block",
235-
"sourcecode",
236-
):
237-
register_directive(directive, CodeBlockDirective)
238-
239-
# The following docutils directives should better be ignored:
240-
for directive in ("parsed-literal",):
241-
register_directive(directive, IgnoreDirective)
65+
results: list[dict[str, t.Any]] = []
24266

24367
# TODO: should we handle the 'literalinclude' directive? maybe check file directly if right extension?
24468
# (https://www.sphinx-doc.org/en/master/usage/restructuredtext/directives.html#directive-literalinclude)
@@ -253,56 +77,84 @@ def main():
25377
with open(path, "rt", encoding="utf-8") as f:
25478
content = f.read()
25579

256-
# We create a Publisher only to have a mechanism which gives us the settings object.
257-
# Doing this more explicit is a bad idea since the classes used are deprecated and will
258-
# eventually get replaced. Publisher.get_settings() looks like a stable enough API that
259-
# we can 'just use'.
260-
publisher = Publisher(source_class=StringInput)
261-
publisher.set_components("standalone", "restructuredtext", "pseudoxml")
262-
override = {
263-
"root_prefix": docs_root,
264-
"input_encoding": "utf-8",
265-
"file_insertion_enabled": False,
266-
"raw_enabled": False,
267-
"_disable_config": True,
268-
"report_level": Reporter.ERROR_LEVEL,
269-
"warning_stream": io.StringIO(),
270-
}
271-
publisher.process_programmatic_settings(None, override, None)
272-
publisher.set_source(content, path)
273-
274-
# Parse the document
27580
try:
276-
doc = publisher.reader.read(
277-
publisher.source, publisher.parser, publisher.settings
278-
)
279-
except SystemMessage as exc:
280-
error = str(exc).replace("\n", " / ")
281-
results.append(
282-
{
283-
"path": path,
284-
"line": 0,
285-
"col": 0,
286-
"message": f"Cannot parse document: {error}",
287-
}
288-
)
289-
continue
290-
except Exception as exc:
291-
error = str(exc).replace("\n", " / ")
292-
results.append(
293-
{
294-
"path": path,
295-
"line": 0,
296-
"col": 0,
297-
"message": f"Cannot parse document, unexpected error {type(exc)}: {error}; traceback: {traceback.format_exc()!r}",
298-
}
299-
)
300-
continue
81+
for code_block in find_code_blocks(
82+
content,
83+
path=path,
84+
root_prefix=docs_root,
85+
warn_unknown_block=create_warn_unknown_block(results, path),
86+
):
87+
# Now that we have the offsets, we can actually do some processing...
88+
if code_block.language not in {
89+
"YAML",
90+
"yaml",
91+
"yaml+jinja",
92+
"YAML+Jinja",
93+
}:
94+
if code_block.language is None:
95+
allowed_languages = ", ".join(sorted(ALLOWED_LANGUAGES))
96+
results.append(
97+
{
98+
"path": path,
99+
"line": code_block.row_offset + 1,
100+
"col": code_block.col_offset + 1,
101+
"message": (
102+
"Literal block without language!"
103+
f" Allowed languages are: {allowed_languages}."
104+
),
105+
}
106+
)
107+
return
108+
if code_block.language not in ALLOWED_LANGUAGES:
109+
allowed_languages = ", ".join(sorted(ALLOWED_LANGUAGES))
110+
results.append(
111+
{
112+
"path": path,
113+
"line": code_block.row_offset + 1,
114+
"col": code_block.col_offset + 1,
115+
"message": (
116+
f"Warning: literal block with disallowed language: {code_block.language}."
117+
" If the language should be allowed, the checker needs to be updated."
118+
f" Currently allowed languages are: {allowed_languages}."
119+
),
120+
}
121+
)
122+
continue
301123

302-
# Process the document
303-
try:
304-
visitor = YamlLintVisitor(doc, path, results, content, yamllint_config)
305-
doc.walk(visitor)
124+
# So we have YAML. Let's lint it!
125+
try:
126+
problems = linter.run(
127+
io.StringIO(code_block.content),
128+
yamllint_config,
129+
path,
130+
)
131+
for problem in problems:
132+
if problem.level not in REPORT_LEVELS:
133+
continue
134+
msg = f"{problem.level}: {problem.desc}"
135+
if problem.rule:
136+
msg += f" ({problem.rule})"
137+
results.append(
138+
{
139+
"path": path,
140+
"line": code_block.row_offset + problem.line,
141+
"col": code_block.col_offset + problem.column,
142+
"message": msg,
143+
}
144+
)
145+
except Exception as exc:
146+
error = str(exc).replace("\n", " / ")
147+
results.append(
148+
{
149+
"path": path,
150+
"line": code_block.row_offset + 1,
151+
"col": code_block.col_offset + 1,
152+
"message": (
153+
f"Internal error while linting YAML: exception {type(exc)}:"
154+
f" {error}; traceback: {traceback.format_exc()!r}"
155+
),
156+
}
157+
)
306158
except Exception as exc:
307159
error = str(exc).replace("\n", " / ")
308160
results.append(

0 commit comments

Comments
 (0)