mirror of
https://github.com/ansible-collections/community.general.git
synced 2025-05-11 03:31:29 -07:00
Add module support to yamllint sanity test. (#34964)
* Add module support to yamllint sanity test. * Fix duplicate keys in module RETURN docs. * Fix syntax in return_common docs fragment. * Fix duplicate keys in module EXAMPLES docs.
This commit is contained in:
parent
240024ea4a
commit
227ff61f9d
53 changed files with 533 additions and 344 deletions
|
@ -1,8 +1,8 @@
|
|||
"""Sanity test using yamllint."""
|
||||
from __future__ import absolute_import, print_function
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
||||
from lib.sanity import (
|
||||
SanitySingleVersion,
|
||||
|
@ -15,7 +15,6 @@ from lib.sanity import (
|
|||
from lib.util import (
|
||||
SubprocessError,
|
||||
run_command,
|
||||
find_executable,
|
||||
)
|
||||
|
||||
from lib.config import (
|
||||
|
@ -31,15 +30,42 @@ class YamllintTest(SanitySingleVersion):
|
|||
:type targets: SanityTargets
|
||||
:rtype: SanityResult
|
||||
"""
|
||||
paths = sorted(i.path for i in targets.include if os.path.splitext(i.path)[1] in ('.yml', '.yaml'))
|
||||
paths = [
|
||||
[i.path for i in targets.include if os.path.splitext(i.path)[1] in ('.yml', '.yaml')],
|
||||
|
||||
[i.path for i in targets.include if os.path.splitext(i.path)[1] == '.py' and
|
||||
os.path.basename(i.path) != '__init__.py' and
|
||||
i.path.startswith('lib/ansible/modules/')],
|
||||
|
||||
[i.path for i in targets.include if os.path.splitext(i.path)[1] == '.py' and
|
||||
os.path.basename(i.path) != '__init__.py' and
|
||||
i.path.startswith('lib/ansible/utils/module_docs_fragments/')],
|
||||
]
|
||||
|
||||
paths = [sorted(p) for p in paths if p]
|
||||
|
||||
if not paths:
|
||||
return SanitySkipped(self.name)
|
||||
|
||||
results = []
|
||||
|
||||
for test_paths in paths:
|
||||
results += self.test_paths(args, test_paths)
|
||||
|
||||
if results:
|
||||
return SanityFailure(self.name, messages=results)
|
||||
|
||||
return SanitySuccess(self.name)
|
||||
|
||||
def test_paths(self, args, paths):
|
||||
"""
|
||||
:type args: SanityConfig
|
||||
:type paths: list[str]
|
||||
:rtype: list[SanityMessage]
|
||||
"""
|
||||
cmd = [
|
||||
'python%s' % args.python_version,
|
||||
find_executable('yamllint'),
|
||||
'--format', 'parsable',
|
||||
'test/sanity/yamllint/yamllinter.py',
|
||||
] + paths
|
||||
|
||||
try:
|
||||
|
@ -54,11 +80,9 @@ class YamllintTest(SanitySingleVersion):
|
|||
raise SubprocessError(cmd=cmd, status=status, stderr=stderr, stdout=stdout)
|
||||
|
||||
if args.explain:
|
||||
return SanitySuccess(self.name)
|
||||
return []
|
||||
|
||||
pattern = r'^(?P<path>[^:]*):(?P<line>[0-9]+):(?P<column>[0-9]+): \[(?P<level>warning|error)\] (?P<message>.*)$'
|
||||
|
||||
results = [re.search(pattern, line).groupdict() for line in stdout.splitlines()]
|
||||
results = json.loads(stdout)['messages']
|
||||
|
||||
results = [SanityMessage(
|
||||
message=r['message'],
|
||||
|
@ -68,7 +92,4 @@ class YamllintTest(SanitySingleVersion):
|
|||
level=r['level'],
|
||||
) for r in results]
|
||||
|
||||
if results:
|
||||
return SanityFailure(self.name, messages=results)
|
||||
|
||||
return SanitySuccess(self.name)
|
||||
return results
|
||||
|
|
19
test/sanity/yamllint/config/default.yml
Normal file
19
test/sanity/yamllint/config/default.yml
Normal file
|
@ -0,0 +1,19 @@
|
|||
extends: default
|
||||
|
||||
rules:
|
||||
braces: {max-spaces-inside: 1, level: error}
|
||||
brackets: {max-spaces-inside: 1, level: error}
|
||||
colons: {max-spaces-after: -1, level: error}
|
||||
commas: {max-spaces-after: -1, level: error}
|
||||
comments: disable
|
||||
comments-indentation: disable
|
||||
document-start: disable
|
||||
empty-lines: {max: 3, level: error}
|
||||
hyphens: {level: error}
|
||||
indentation: disable
|
||||
key-duplicates: enable
|
||||
line-length: disable
|
||||
new-line-at-end-of-file: disable
|
||||
new-lines: {type: unix}
|
||||
trailing-spaces: disable
|
||||
truthy: disable
|
19
test/sanity/yamllint/config/modules.yml
Normal file
19
test/sanity/yamllint/config/modules.yml
Normal file
|
@ -0,0 +1,19 @@
|
|||
extends: default
|
||||
|
||||
rules:
|
||||
braces: disable
|
||||
brackets: disable
|
||||
colons: disable
|
||||
commas: disable
|
||||
comments: disable
|
||||
comments-indentation: disable
|
||||
document-start: disable
|
||||
empty-lines: disable
|
||||
hyphens: disable
|
||||
indentation: disable
|
||||
key-duplicates: enable
|
||||
line-length: disable
|
||||
new-line-at-end-of-file: disable
|
||||
new-lines: {type: unix}
|
||||
trailing-spaces: disable
|
||||
truthy: disable
|
183
test/sanity/yamllint/yamllinter.py
Executable file
183
test/sanity/yamllint/yamllinter.py
Executable file
|
@ -0,0 +1,183 @@
|
|||
#!/usr/bin/env python
|
||||
"""Wrapper around yamllint that supports YAML embedded in Ansible modules."""
|
||||
|
||||
from __future__ import absolute_import, print_function
|
||||
|
||||
import ast
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
from yamllint import linter
|
||||
from yamllint.config import YamlLintConfig
|
||||
|
||||
|
||||
def main():
|
||||
"""Main program body."""
|
||||
paths = sys.argv[1:]
|
||||
|
||||
checker = YamlChecker()
|
||||
checker.check(paths)
|
||||
checker.report()
|
||||
|
||||
|
||||
class YamlChecker(object):
|
||||
"""Wrapper around yamllint that supports YAML embedded in Ansible modules."""
|
||||
def __init__(self):
|
||||
self.messages = []
|
||||
|
||||
def report(self):
|
||||
"""Print yamllint report to stdout."""
|
||||
report = dict(
|
||||
messages=self.messages,
|
||||
)
|
||||
|
||||
print(json.dumps(report, indent=4, sort_keys=True))
|
||||
|
||||
def check(self, paths):
|
||||
"""
|
||||
:type paths: str
|
||||
"""
|
||||
yaml_conf = YamlLintConfig(file='test/sanity/yamllint/config/default.yml')
|
||||
module_conf = YamlLintConfig(file='test/sanity/yamllint/config/modules.yml')
|
||||
|
||||
for path in paths:
|
||||
extension = os.path.splitext(path)[1]
|
||||
|
||||
with open(path) as f:
|
||||
contents = f.read()
|
||||
|
||||
if extension in ('.yml', '.yaml'):
|
||||
self.check_yaml(yaml_conf, path, contents)
|
||||
elif extension == '.py':
|
||||
self.check_module(module_conf, path, contents)
|
||||
else:
|
||||
raise Exception('unsupported extension: %s' % extension)
|
||||
|
||||
def check_yaml(self, conf, path, contents):
|
||||
"""
|
||||
:type conf: YamlLintConfig
|
||||
:type path: str
|
||||
:type contents: str
|
||||
"""
|
||||
self.messages += [self.result_to_message(r, path) for r in linter.run(contents, conf, path)]
|
||||
|
||||
def check_module(self, conf, path, contents):
|
||||
"""
|
||||
:type conf: YamlLintConfig
|
||||
:type path: str
|
||||
:type contents: str
|
||||
"""
|
||||
docs = self.get_module_docs(path, contents)
|
||||
|
||||
for key, value in docs.items():
|
||||
yaml = value['yaml']
|
||||
lineno = value['lineno']
|
||||
|
||||
if yaml.startswith('\n'):
|
||||
yaml = yaml[1:]
|
||||
lineno += 1
|
||||
|
||||
messages = list(linter.run(yaml, conf, path))
|
||||
|
||||
self.messages += [self.result_to_message(r, path, lineno - 1, key) for r in messages]
|
||||
|
||||
@staticmethod
|
||||
def result_to_message(result, path, line_offset=0, prefix=''):
|
||||
"""
|
||||
:type result: any
|
||||
:type path: str
|
||||
:type line_offset: int
|
||||
:type prefix: str
|
||||
:rtype: dict[str, any]
|
||||
"""
|
||||
if prefix:
|
||||
prefix = '%s: ' % prefix
|
||||
|
||||
return dict(
|
||||
code=result.rule or result.level,
|
||||
message=prefix + result.desc,
|
||||
path=path,
|
||||
line=result.line + line_offset,
|
||||
column=result.column,
|
||||
level=result.level,
|
||||
)
|
||||
|
||||
def get_module_docs(self, path, contents):
|
||||
"""
|
||||
:type path: str
|
||||
:type contents: str
|
||||
:rtype: dict[str, any]
|
||||
"""
|
||||
module_doc_types = [
|
||||
'DOCUMENTATION',
|
||||
'EXAMPLES',
|
||||
'RETURN',
|
||||
]
|
||||
|
||||
docs = {}
|
||||
|
||||
def check_assignment(statement, doc_types=None):
|
||||
"""Check the given statement for a documentation assignment."""
|
||||
for target in statement.targets:
|
||||
if doc_types and target.id not in doc_types:
|
||||
continue
|
||||
|
||||
docs[target.id] = dict(
|
||||
yaml=statement.value.s,
|
||||
lineno=statement.lineno,
|
||||
end_lineno=statement.lineno + len(statement.value.s.splitlines())
|
||||
)
|
||||
|
||||
module_ast = self.parse_module(path, contents)
|
||||
|
||||
if not module_ast:
|
||||
return {}
|
||||
|
||||
if path.startswith('lib/ansible/modules/'):
|
||||
for body_statement in module_ast.body:
|
||||
if isinstance(body_statement, ast.Assign):
|
||||
check_assignment(body_statement, module_doc_types)
|
||||
elif path.startswith('lib/ansible/utils/module_docs_fragments/'):
|
||||
for body_statement in module_ast.body:
|
||||
if isinstance(body_statement, ast.ClassDef):
|
||||
for class_statement in body_statement.body:
|
||||
if isinstance(class_statement, ast.Assign):
|
||||
check_assignment(class_statement)
|
||||
else:
|
||||
raise Exception('unsupported path: %s' % path)
|
||||
|
||||
return docs
|
||||
|
||||
def parse_module(self, path, contents):
|
||||
"""
|
||||
:type path: str
|
||||
:type contents: str
|
||||
:rtype: ast.Module | None
|
||||
"""
|
||||
try:
|
||||
return ast.parse(contents)
|
||||
except SyntaxError as ex:
|
||||
self.messages.append(dict(
|
||||
code='python-syntax-error',
|
||||
message=str(ex),
|
||||
path=path,
|
||||
line=ex.lineno,
|
||||
column=ex.offset,
|
||||
level='error',
|
||||
))
|
||||
except Exception as ex:
|
||||
self.messages.append(dict(
|
||||
code='python-parse-error',
|
||||
message=str(ex),
|
||||
path=path,
|
||||
line=0,
|
||||
column=0,
|
||||
level='error',
|
||||
))
|
||||
|
||||
return None
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
Loading…
Add table
Add a link
Reference in a new issue