diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index be25973..b392c29 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -47,7 +47,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Append GitHub Actions system path run: echo "$HOME/.local/bin" >> $GITHUB_PATH - - run: pip install coveralls + - run: pip install coveralls ddt - run: pip install . - run: coverage run --source=yamllint -m unittest discover - name: Coveralls diff --git a/.gitignore b/.gitignore index 58d609b..48aa0a0 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,7 @@ __pycache__ /yamllint.egg-info /build /.eggs +.venv +venv +.coverage +coverage.xml diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 312c6d8..b32dd64 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -14,8 +14,12 @@ Pull Request Process .. code:: bash pip install --user . - python -m unittest discover # all tests... - python -m unittest tests/rules/test_commas.py # or just some tests (faster) + pip install coveralls ddt + # all tests... + python -m coverage run --source=yamllint -m unittest discover + coverage report + # or just some tests (faster) + python -m unittest tests/rules/test_commas.py 3. If you add code that should be tested, add tests. diff --git a/tests/test_cli.py b/tests/test_cli.py index 7d16412..e428215 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -314,6 +314,9 @@ class CommandLineTestCase(unittest.TestCase): config = os.path.join(dir, 'config') self.addCleanup(os.environ.update, HOME=os.environ['HOME']) + # remove other env vars to make sure we are using the HOME config file. + os.environ.pop('YAMLLINT_CONFIG_FILE', None) + os.environ.pop('XDG_CONFIG_HOME', None) os.environ['HOME'] = home with open(config, 'w') as f: diff --git a/tests/test_format.py b/tests/test_format.py new file mode 100644 index 0000000..1d08f9a --- /dev/null +++ b/tests/test_format.py @@ -0,0 +1,270 @@ +# -*- coding: utf-8 -*- +# flake8: noqa +import unittest +import string +import ddt + +from yamllint.linter import LintProblem +from yamllint.format import ( + escape_xml, + severity_from_level, + max_level, + Formater, + ParsableFormater, + GithubFormater, + ColoredFormater, + StandardFormater, + JSONFormater, + JunitFormater, + CodeclimateFormater +) + + +@ddt.ddt +class TextToXMLTestCase(unittest.TestCase): + + def test_letters_chars(self): + txt = string.ascii_letters + self.assertEqual(escape_xml(txt), txt) + + @ddt.data( + ('&', '&'), + ('<', '<'), + ('>', '>'), + ('"', '"'), + ("'", '''), + ("too many blank lines (3 > 2)", 'too many blank lines (3 > 2)'), + ('line too long (100 > 80 characters)', 'line too long (100 > 80 characters)') + ) + @ddt.unpack + def test_specials_chars(self, inp, out): + self.assertEqual(escape_xml(inp), out) + + +@ddt.ddt +class CodeClimateSeverityTestCase(unittest.TestCase): + + @ddt.data( + (None, "info"), + ('warning', "minor"), + ('error', "major"), + (0, "info"), + (1, "minor"), + (2, "major"), + ) + @ddt.unpack + def test_specials_chars(self, inp, out): + self.assertEqual(severity_from_level(inp), out) + + +@ddt.ddt +class FormaterTestCase(unittest.TestCase): + + def test_get_formaters_names(self): + self.assertEqual( + set(Formater.get_formaters_names()), + { + "parsable", + "github", + "colored", + "standard", + "json", + "junitxml", + "codeclimate" + } + ) + + @ddt.data( + ("parsable", ParsableFormater, True), + ("github", GithubFormater, True), + ("colored", ColoredFormater, True), + ("standard", StandardFormater, True), + ("json", JSONFormater, True), + ("junitxml", JunitFormater, True), + ("codeclimate", CodeclimateFormater, True), + ("parsable", ParsableFormater, False), + ("github", GithubFormater, False), + ("colored", ColoredFormater, False), + ("standard", StandardFormater, False), + ("json", JSONFormater, False), + ("junitxml", JunitFormater, False), + ("codeclimate", CodeclimateFormater, False), + ) + @ddt.unpack + def test_get_formater(self, name, cls, no_warn): + res = Formater.get_formater(name, no_warn) + self.assertTrue(isinstance(res, cls)) + self.assertEqual(res.no_warn, no_warn) + + def test_unknown_formater(self): + with self.assertRaises(ValueError): + Formater.get_formater("unknown", False) + + def test_abstract_class(self): + inst = Formater(False) + with self.assertRaises(NotImplementedError): + inst.show_problems_for_all_files([]) + with self.assertRaises(NotImplementedError): + inst.show_problems_for_file([], "a") + with self.assertRaises(NotImplementedError): + inst.show_problem(None, "a") + + +NONE = {} +NO_ERROR = {"file1.yml": []} +ONE_NOTHING = {"file1.yml": [ + LintProblem(1, 1, desc="desc of None", rule="my-rule") +]} +ONE_ERROR = {"file1.yml": [ + LintProblem( + line=1, + column=2, + desc="desc of error", + rule="my-rule", + level="error" + ) +]} +ONE_WARNING = {"file1.yml": [ + LintProblem( + line=1, + column=2, + desc="desc of warn", + rule="my-rule", + level="warning" + ) +]} +MIXED_ONE_FILE = {"file1.yml": [ + ONE_NOTHING["file1.yml"][0], + ONE_ERROR["file1.yml"][0], + ONE_WARNING["file1.yml"][0] +]} +MIXED_MULT_FILE = { + "file1.yml": ONE_NOTHING["file1.yml"], + "file2.yml": ONE_ERROR["file1.yml"], + "file3.yml": ONE_WARNING["file1.yml"] +} + + +@ddt.ddt +class FormatersTestCase(unittest.TestCase): + + @ddt.data( + # No errors + (ParsableFormater(True), NONE, ""), + (GithubFormater(True), NONE, ""), + (ColoredFormater(True), NONE, ""), + (StandardFormater(True), NONE, ""), + (JSONFormater(True), NONE, "[]\n"), + (CodeclimateFormater(True), NONE, "[]\n"), + (ParsableFormater(True), NO_ERROR, ""), + (GithubFormater(True), NO_ERROR, ""), + (ColoredFormater(True), NO_ERROR, ""), + (StandardFormater(True), NO_ERROR, ""), + (JSONFormater(True), NO_ERROR, "[]\n"), + (CodeclimateFormater(True), NO_ERROR, "[]\n"), + (ParsableFormater(True), ONE_NOTHING, ""), + (GithubFormater(True), ONE_NOTHING, ""), + (ColoredFormater(True), ONE_NOTHING, ""), + (StandardFormater(True), ONE_NOTHING, ""), + (JSONFormater(True), ONE_NOTHING, '[]\n'), + (CodeclimateFormater(True), ONE_NOTHING, '[]\n'), + # Errors with no level are ignored + (ParsableFormater(False), ONE_NOTHING, ""), + (GithubFormater(False), ONE_NOTHING, ""), + (ColoredFormater(False), ONE_NOTHING, ""), + (StandardFormater(False), ONE_NOTHING, ""), + (JSONFormater(False), ONE_NOTHING, '[]\n'), + (CodeclimateFormater(False), ONE_NOTHING, '[]\n'), + # 1 Skipped warning + (ParsableFormater(True), ONE_WARNING, ""), + (GithubFormater(True), ONE_WARNING, ""), + (ColoredFormater(True), ONE_WARNING, ""), + (StandardFormater(True), ONE_WARNING, ""), + (JSONFormater(True), ONE_WARNING, '[]\n'), + (CodeclimateFormater(True), ONE_WARNING, '[]\n'), + # 1 Unskipped warning + (ParsableFormater(False), ONE_WARNING, 'file1.yml:1:2: [warning] desc of warn (my-rule)\n'), + (GithubFormater(False), ONE_WARNING, '::group::file1.yml\n::warning file=file1.yml,line=1,col=2::1:2 [my-rule] desc of warn\n::endgroup::\n\n'), + (ColoredFormater(False), ONE_WARNING, '\x1b[4mfile1.yml\x1b[0m\n \x1b[2m1:2\x1b[0m \x1b[33mwarning\x1b[0m desc of warn \x1b[2m(my-rule)\x1b[0m\n\n'), + (StandardFormater(False), ONE_WARNING, 'file1.yml\n 1:2 warning desc of warn (my-rule)\n\n'), + (JSONFormater(False), ONE_WARNING, '[\n {\n "line": 1,\n "column": 2,\n "rule": "my-rule",\n "level": "warning",\n "message": "desc of warn",\n "path": "file1.yml"\n }\n]\n'), + (CodeclimateFormater(False), ONE_WARNING, '[\n {\n "type": "issue",\n "check_name": "my-rule",\n "description": "desc of warn",\n "content": "desc of warn (my-rule)",\n "categories": [\n "Style"\n ],\n "location": {\n "path": "file1.yml",\n "positions": {\n "begin": {\n "line": 1,\n "column": 2\n }\n }\n },\n "remediation_points": 1000,\n "severity": "minor"\n }\n]\n'), + # 1 Error + (ParsableFormater(True), ONE_ERROR, 'file1.yml:1:2: [error] desc of error (my-rule)\n'), + (GithubFormater(True), ONE_ERROR, '::group::file1.yml\n::error file=file1.yml,line=1,col=2::1:2 [my-rule] desc of error\n::endgroup::\n\n'), + (ColoredFormater(True), ONE_ERROR, '\x1b[4mfile1.yml\x1b[0m\n \x1b[2m1:2\x1b[0m \x1b[31merror\x1b[0m desc of error \x1b[2m(my-rule)\x1b[0m\n\n'), + (StandardFormater(True), ONE_ERROR, 'file1.yml\n 1:2 error desc of error (my-rule)\n\n'), + (JSONFormater(True), ONE_ERROR, '[\n {\n "line": 1,\n "column": 2,\n "rule": "my-rule",\n "level": "error",\n "message": "desc of error",\n "path": "file1.yml"\n }\n]\n'), + (CodeclimateFormater(True), ONE_ERROR, '[\n {\n "type": "issue",\n "check_name": "my-rule",\n "description": "desc of error",\n "content": "desc of error (my-rule)",\n "categories": [\n "Style"\n ],\n "location": {\n "path": "file1.yml",\n "positions": {\n "begin": {\n "line": 1,\n "column": 2\n }\n }\n },\n "remediation_points": 1000,\n "severity": "major"\n }\n]\n'), + # mixed warn / err on the same file + (ParsableFormater(False), MIXED_ONE_FILE, 'file1.yml:1:2: [error] desc of error (my-rule)\nfile1.yml:1:2: [warning] desc of warn (my-rule)\n'), + (GithubFormater(False), MIXED_ONE_FILE, '::group::file1.yml\n::error file=file1.yml,line=1,col=2::1:2 [my-rule] desc of error\n::warning file=file1.yml,line=1,col=2::1:2 [my-rule] desc of warn\n::endgroup::\n\n'), + (ColoredFormater(False), MIXED_ONE_FILE, '\x1b[4mfile1.yml\x1b[0m\n \x1b[2m1:2\x1b[0m \x1b[31merror\x1b[0m desc of error \x1b[2m(my-rule)\x1b[0m\n \x1b[2m1:2\x1b[0m \x1b[33mwarning\x1b[0m desc of warn \x1b[2m(my-rule)\x1b[0m\n\n'), + (StandardFormater(False), MIXED_ONE_FILE, 'file1.yml\n 1:2 error desc of error (my-rule)\n 1:2 warning desc of warn (my-rule)\n\n'), + (JSONFormater(False), MIXED_ONE_FILE, '[\n {\n "line": 1,\n "column": 2,\n "rule": "my-rule",\n "level": "error",\n "message": "desc of error",\n "path": "file1.yml"\n },\n {\n "line": 1,\n "column": 2,\n "rule": "my-rule",\n "level": "warning",\n "message": "desc of warn",\n "path": "file1.yml"\n }\n]\n'), + (CodeclimateFormater(False), MIXED_ONE_FILE, '[\n {\n "type": "issue",\n "check_name": "my-rule",\n "description": "desc of error",\n "content": "desc of error (my-rule)",\n "categories": [\n "Style"\n ],\n "location": {\n "path": "file1.yml",\n "positions": {\n "begin": {\n "line": 1,\n "column": 2\n }\n }\n },\n "remediation_points": 1000,\n "severity": "major"\n },\n {\n "type": "issue",\n "check_name": "my-rule",\n "description": "desc of warn",\n "content": "desc of warn (my-rule)",\n "categories": [\n "Style"\n ],\n "location": {\n "path": "file1.yml",\n "positions": {\n "begin": {\n "line": 1,\n "column": 2\n }\n }\n },\n "remediation_points": 1000,\n "severity": "minor"\n }\n]\n'), + # mixed warn / err on multiples files + (ParsableFormater(False), MIXED_MULT_FILE, 'file2.yml:1:2: [error] desc of error (my-rule)\nfile3.yml:1:2: [warning] desc of warn (my-rule)\n'), + (GithubFormater(False), MIXED_MULT_FILE, '::group::file2.yml\n::error file=file2.yml,line=1,col=2::1:2 [my-rule] desc of error\n::endgroup::\n\n::group::file3.yml\n::warning file=file3.yml,line=1,col=2::1:2 [my-rule] desc of warn\n::endgroup::\n\n'), + (ColoredFormater(False), MIXED_MULT_FILE, '\x1b[4mfile2.yml\x1b[0m\n \x1b[2m1:2\x1b[0m \x1b[31merror\x1b[0m desc of error \x1b[2m(my-rule)\x1b[0m\n\n\x1b[4mfile3.yml\x1b[0m\n \x1b[2m1:2\x1b[0m \x1b[33mwarning\x1b[0m desc of warn \x1b[2m(my-rule)\x1b[0m\n\n'), + (StandardFormater(False), MIXED_MULT_FILE, 'file2.yml\n 1:2 error desc of error (my-rule)\n\nfile3.yml\n 1:2 warning desc of warn (my-rule)\n\n'), + (JSONFormater(False), MIXED_MULT_FILE, '[\n {\n "line": 1,\n "column": 2,\n "rule": "my-rule",\n "level": "error",\n "message": "desc of error",\n "path": "file2.yml"\n },\n {\n "line": 1,\n "column": 2,\n "rule": "my-rule",\n "level": "warning",\n "message": "desc of warn",\n "path": "file3.yml"\n }\n]\n'), + (CodeclimateFormater(False), MIXED_MULT_FILE, '[\n {\n "type": "issue",\n "check_name": "my-rule",\n "description": "desc of error",\n "content": "desc of error (my-rule)",\n "categories": [\n "Style"\n ],\n "location": {\n "path": "file2.yml",\n "positions": {\n "begin": {\n "line": 1,\n "column": 2\n }\n }\n },\n "remediation_points": 1000,\n "severity": "major"\n },\n {\n "type": "issue",\n "check_name": "my-rule",\n "description": "desc of warn",\n "content": "desc of warn (my-rule)",\n "categories": [\n "Style"\n ],\n "location": {\n "path": "file3.yml",\n "positions": {\n "begin": {\n "line": 1,\n "column": 2\n }\n }\n },\n "remediation_points": 1000,\n "severity": "minor"\n }\n]\n'), + ) + @ddt.unpack + def test_all_formaters(self, inst, inp, ret): + self.assertEqual( + inst.show_problems_for_all_files(inp), + ret + ) + + +@ddt.ddt +class MaxLevelTestCase(unittest.TestCase): + + @ddt.data( + (NONE, 0), + (NO_ERROR, 0), + (ONE_NOTHING, 0), + (ONE_ERROR, 2), + (ONE_WARNING, 1), + (MIXED_ONE_FILE, 2), + (MIXED_MULT_FILE, 2), + ) + @ddt.unpack + def test_all_formaters(self, inp, ret): + self.assertEqual(max_level(inp), ret) + +@ddt.ddt +class JunitTestCase(unittest.TestCase): + + @ddt.data( + (NONE, False, [], ['<\/error><\/testcase>', '<\/failure><\/testcase>'], 7), + (NO_ERROR, False, [], ['<\/error><\/testcase>', '<\/failure><\/testcase>'], 7), + (ONE_NOTHING, False, [], ['<\/error><\/testcase>', '<\/failure><\/testcase>'], 7), + (ONE_ERROR, False, ['<\/error><\/testcase>'], ['<\/failure><\/testcase>'], 7), + (ONE_WARNING, False, ['<\/failure><\/testcase>'], ['<\/error><\/testcase>'], 7), + (ONE_WARNING, True, [], ['<\/error><\/testcase>', '<\/failure><\/testcase>'], 7), + (MIXED_ONE_FILE, False, ['<\/error><\/testcase>', '<\/failure><\/testcase>'], [], 8), + (MIXED_MULT_FILE, False, ['<\/error><\/testcase>', '<\/failure><\/testcase>'], [], 8), + ) + @ddt.unpack + def test_all_formaters(self, inp, no_warn, contain, not_contain, length): + res = JunitFormater(no_warn).show_problems_for_all_files(inp) + self.assertTrue(res.startswith( + '\n' \ + '\n' \ + ' \n' \ + '\n')) + + self.assertEqual(len(res.split('\n')), length) diff --git a/yamllint/cli.py b/yamllint/cli.py index ea0d704..2eb5830 100644 --- a/yamllint/cli.py +++ b/yamllint/cli.py @@ -17,13 +17,13 @@ import argparse import io import locale import os -import platform import sys from yamllint import APP_DESCRIPTION, APP_NAME, APP_VERSION from yamllint import linter from yamllint.config import YamlLintConfig, YamlLintConfigError from yamllint.linter import PROBLEM_LEVELS +from yamllint.format import show_all_problems, Formater def find_files_recursively(items, conf): @@ -38,110 +38,6 @@ def find_files_recursively(items, conf): yield item -def supports_color(): - supported_platform = not (platform.system() == 'Windows' and not - ('ANSICON' in os.environ or - ('TERM' in os.environ and - os.environ['TERM'] == 'ANSI'))) - return (supported_platform and - hasattr(sys.stdout, 'isatty') and sys.stdout.isatty()) - - -class Format(object): - @staticmethod - def parsable(problem, filename): - return ('%(file)s:%(line)s:%(column)s: [%(level)s] %(message)s' % - {'file': filename, - 'line': problem.line, - 'column': problem.column, - 'level': problem.level, - 'message': problem.message}) - - @staticmethod - def standard(problem, filename): - line = ' %d:%d' % (problem.line, problem.column) - line += max(12 - len(line), 0) * ' ' - line += problem.level - line += max(21 - len(line), 0) * ' ' - line += problem.desc - if problem.rule: - line += ' (%s)' % problem.rule - return line - - @staticmethod - def standard_color(problem, filename): - line = ' \033[2m%d:%d\033[0m' % (problem.line, problem.column) - line += max(20 - len(line), 0) * ' ' - if problem.level == 'warning': - line += '\033[33m%s\033[0m' % problem.level - else: - line += '\033[31m%s\033[0m' % problem.level - line += max(38 - len(line), 0) * ' ' - line += problem.desc - if problem.rule: - line += ' \033[2m(%s)\033[0m' % problem.rule - return line - - @staticmethod - def github(problem, filename): - line = '::' - line += problem.level - line += ' file=' + filename + ',' - line += 'line=' + format(problem.line) + ',' - line += 'col=' + format(problem.column) - line += '::' - line += format(problem.line) - line += ':' - line += format(problem.column) - line += ' ' - if problem.rule: - line += '[' + problem.rule + '] ' - line += problem.desc - return line - - -def show_problems(problems, file, args_format, no_warn): - max_level = 0 - first = True - - if args_format == 'auto': - if ('GITHUB_ACTIONS' in os.environ and - 'GITHUB_WORKFLOW' in os.environ): - args_format = 'github' - elif supports_color(): - args_format = 'colored' - - for problem in problems: - max_level = max(max_level, PROBLEM_LEVELS[problem.level]) - if no_warn and (problem.level != 'error'): - continue - if args_format == 'parsable': - print(Format.parsable(problem, file)) - elif args_format == 'github': - if first: - print('::group::%s' % file) - first = False - print(Format.github(problem, file)) - elif args_format == 'colored': - if first: - print('\033[4m%s\033[0m' % file) - first = False - print(Format.standard_color(problem, file)) - else: - if first: - print(file) - first = False - print(Format.standard(problem, file)) - - if not first and args_format == 'github': - print('::endgroup::') - - if not first and args_format != 'parsable': - print('') - - return max_level - - def run(argv=None): parser = argparse.ArgumentParser(prog=APP_NAME, description=APP_DESCRIPTION) @@ -159,8 +55,7 @@ def run(argv=None): action='store', help='custom configuration (as YAML source)') parser.add_argument('-f', '--format', - choices=('parsable', 'standard', 'colored', 'github', - 'auto'), + choices=[*Formater.get_formaters_names(), 'auto'], default='auto', help='format for parsing output') parser.add_argument('-s', '--strict', action='store_true', @@ -208,7 +103,8 @@ def run(argv=None): if conf.locale is not None: locale.setlocale(locale.LC_ALL, conf.locale) - max_level = 0 + # problems dict: {file: problems} + all_problems = dict() for file in find_files_recursively(args.files, conf): filepath = file[2:] if file.startswith('./') else file @@ -218,20 +114,22 @@ def run(argv=None): except EnvironmentError as e: print(e, file=sys.stderr) sys.exit(-1) - prob_level = show_problems(problems, file, args_format=args.format, - no_warn=args.no_warnings) - max_level = max(max_level, prob_level) + all_problems[file] = [pb for pb in problems if pb] - # read yaml from stdin if args.stdin: + # read yaml from stdin try: problems = linter.run(sys.stdin, conf, '') except EnvironmentError as e: print(e, file=sys.stderr) sys.exit(-1) - prob_level = show_problems(problems, 'stdin', args_format=args.format, - no_warn=args.no_warnings) - max_level = max(max_level, prob_level) + all_problems['stdin'] = [pb for pb in problems if pb] + + max_level = show_all_problems( + all_problems, + args_format=args.format, + no_warn=args.no_warnings + ) if max_level == PROBLEM_LEVELS['error']: return_code = 1 diff --git a/yamllint/format.py b/yamllint/format.py new file mode 100644 index 0000000..287501d --- /dev/null +++ b/yamllint/format.py @@ -0,0 +1,429 @@ + +from __future__ import print_function + +import os +import platform +import sys +import json +import datetime + +from yamllint.linter import PROBLEM_LEVELS + + +CODECLIMATE_SEVERITY = { + None: "info", + 'warning': "minor", + 'error': "major", +} + + +def supports_color(): + supported_platform = not (platform.system() == 'Windows' and not + ('ANSICON' in os.environ or + ('TERM' in os.environ and + os.environ['TERM'] == 'ANSI'))) + return (supported_platform and + hasattr(sys.stdout, 'isatty') and sys.stdout.isatty()) + + +def run_on_gh(): + """Return if the currnet job is on github.""" + return 'GITHUB_ACTIONS' in os.environ and 'GITHUB_WORKFLOW' in os.environ + + +def escape_xml(text): + """Escape text for XML.""" + text = text.replace('&', '&') + text = text.replace('<', '<') + text = text.replace('>', '>') + text = text.replace('"', '"') + text = text.replace("'", ''') + return text + + +def severity_from_level(level): + if isinstance(level, int): + level = PROBLEM_LEVELS[level] + return CODECLIMATE_SEVERITY[level] + + +class Formater(object): + """Any formater.""" + # the formater name + name = '' + + @classmethod + def get_formaters_names(cls): + """Return all formaters names.""" + return [f.name for f in cls.__subclasses__()] + + @classmethod + def get_formater(cls, name, no_warn): + """Return a formater instance.""" + + if name == 'auto': + if run_on_gh(): + name = 'github' + elif supports_color(): + name = 'colored' + else: + name = 'standard' + + for formater in cls.__subclasses__(): + if name == formater.name: + return formater(no_warn) + raise ValueError('unknown formater: %s' % name) + + def __init__(self, no_warn): + """Setup the formater.""" + self.no_warn = no_warn + + def show_problems_for_all_files(self, all_problems): + """Show all problems of all files.""" + raise NotImplementedError() + + def show_problems_for_file(self, problems, file): + """Show all problems of a specific file.""" + raise NotImplementedError() + + def show_problem(self, problem, file): + """Show all problems of a specific file.""" + raise NotImplementedError() + + +class ParsableFormater(Formater): + """The parsable formater.""" + name = 'parsable' + + def show_problems_for_all_files(self, all_problems): + """Show all problems of all files.""" + string = '' + for file, problems in all_problems.items(): + string += self.show_problems_for_file(problems, file) + return string + + def show_problems_for_file(self, problems, file): + """Show all problems of a specific file.""" + string = '' + for problem in problems: + if problem.level is not None and ( + problem.level == "error" or not self.no_warn + ): + string += self.show_problem(problem, file) + return string + + def show_problem(self, problem, file): + """Show all problems of a specific file.""" + if self.no_warn and (problem.level != 'error'): + return '' + return ( + '%(file)s:%(line)s:%(column)s: [%(level)s] %(message)s\n' % + { + 'file': file, + 'line': problem.line, + 'column': problem.column, + 'level': problem.level, + 'message': problem.message + } + ) + + +class GithubFormater(Formater): + """The github formater.""" + name = 'github' + + def show_problems_for_all_files(self, all_problems): + """Show all problems of all files.""" + string = '' + for file, problems in all_problems.items(): + string += self.show_problems_for_file(problems, file) + return string + + def show_problems_for_file(self, problems, file): + """Show all problems of a specific file.""" + string = '::group::%s\n' % file + for problem in problems: + if problem.level is not None and ( + problem.level == "error" or not self.no_warn + ): + string += self.show_problem(problem, file) + if string == '::group::%s\n' % file: + return '' + return string + '::endgroup::\n\n' + + def show_problem(self, problem, file): + """Show all problems of a specific file.""" + if self.no_warn and (problem.level != 'error'): + return '' + line = '::' + line += problem.level + line += ' file=' + file + ',' + line += 'line=' + format(problem.line) + ',' + line += 'col=' + format(problem.column) + line += '::' + line += format(problem.line) + line += ':' + line += format(problem.column) + line += ' ' + if problem.rule: + line += '[' + problem.rule + '] ' + line += problem.desc + line += '\n' + return line + + +class ColoredFormater(Formater): + """The colored formater.""" + name = 'colored' + + def show_problems_for_all_files(self, all_problems): + """Show all problems of all files.""" + string = '' + for file, problems in all_problems.items(): + string += self.show_problems_for_file(problems, file) + return string + + def show_problems_for_file(self, problems, file): + """Show all problems of a specific file.""" + string = '\033[4m%s\033[0m\n' % file + for problem in problems: + if problem.level is not None and ( + problem.level == "error" or not self.no_warn + ): + string += self.show_problem(problem, file) + if string == '\033[4m%s\033[0m\n' % file: + return '' + return string + '\n' + + def show_problem(self, problem, file): + """Show all problems of a specific file.""" + if self.no_warn and (problem.level != 'error'): + return '' + line = ' \033[2m%d:%d\033[0m' % (problem.line, problem.column) + line += max(20 - len(line), 0) * ' ' + if problem.level == 'warning': + line += '\033[33m%s\033[0m' % problem.level + else: + line += '\033[31m%s\033[0m' % problem.level + line += max(38 - len(line), 0) * ' ' + line += problem.desc + if problem.rule: + line += ' \033[2m(%s)\033[0m' % problem.rule + line += '\n' + return line + + +class StandardFormater(Formater): + """The standard formater.""" + name = 'standard' + + def show_problems_for_all_files(self, all_problems): + """Show all problems of all files.""" + string = '' + for file, problems in all_problems.items(): + string += self.show_problems_for_file(problems, file) + return string + + def show_problems_for_file(self, problems, file): + """Show all problems of a specific file.""" + string = file + '\n' + for problem in problems: + if problem.level is not None and ( + problem.level == "error" or not self.no_warn + ): + string += self.show_problem(problem, file) + if string == file + '\n': + return '' + return string + '\n' + + def show_problem(self, problem, file): + """Show all problems of a specific file.""" + if self.no_warn and (problem.level != 'error'): + return '' + line = ' %d:%d' % (problem.line, problem.column) + line += max(12 - len(line), 0) * ' ' + line += problem.level + line += max(21 - len(line), 0) * ' ' + line += problem.desc + if problem.rule: + line += ' (%s)' % problem.rule + line += '\n' + return line + + +class JSONFormater(Formater): + """The json formater.""" + name = 'json' + + def show_problems_for_all_files(self, all_problems): + """Show all problems of all files.""" + lst = [] + for k, v in all_problems.items(): + lst += self.show_problems_for_file(v, k) + return json.dumps(lst, indent=4) + '\n' + + def show_problems_for_file(self, problems, file): + """Show all problems of a specific file.""" + lst = [] + for problem in problems: + if problem.level is not None and ( + problem.level == "error" or not self.no_warn + ): + lst.append(self.show_problem(problem, file)) + return lst + + def show_problem(self, problem, file): + """Show all problems of a specific file. + + The desired format is: + + >>> { + >>> "path": "dir/file.yaml", + >>> "line": 1337, + >>> "column": 42, + >>> "message": "duplication of key \"k\" in mapping", + >>> "rule": "key-duplicates", + >>> "level": "error" + >>> } + """ + dico = problem.dict + dico["message"] = dico.pop("desc") + dico["path"] = file + return dico + + +class JunitFormater(Formater): + """The parsable formater.""" + name = 'junitxml' + + def show_problems_for_all_files(self, all_problems): + """Show all problems of all files.""" + string = '\n\n' + + errors = warnings = 0 + lst = [] + for k, v in all_problems.items(): + lst += self.show_problems_for_file(v, k) + + lines = [] + for item in lst: + if item['level'] == 'warning': + warnings += 1 + to_append = '<\/failure><\/testcase>' # noqa + elif item['level'] == 'error': + errors += 1 + to_append = '<\/error><\/testcase>' # noqa + lines.append(' ' * 8 + to_append % ( + item['file'], + item['line'], + item['column'], + item['rule'], + escape_xml(item['desc'])) + ) + + string += ' '*4 + '\n' % (errors, warnings, errors + warnings, datetime.datetime.now().isoformat(), platform.node()) # noqa + string += '\n'.join(lines) + '\n' + string += ' \n\n' + return string + + def show_problems_for_file(self, problems, file): + """Show all problems of a specific file.""" + lst = [] + for problem in problems: + if problem.level is not None and ( + problem.level == "error" or not self.no_warn + ): + lst.append(self.show_problem(problem, file)) + return lst + + def show_problem(self, problem, file): + """Show all problems of a specific file.""" + return {**problem.dict, "file": file} + + +class CodeclimateFormater(Formater): + """The codeclimate formater.""" + name = 'codeclimate' + + def show_problems_for_all_files(self, all_problems): + """Show all problems of all files.""" + lst = [] + for k, v in all_problems.items(): + lst += self.show_problems_for_file(v, k) + return json.dumps(lst, indent=4) + '\n' + + def show_problems_for_file(self, problems, file): + """Show all problems of a specific file.""" + lst = [] + for problem in problems: + if problem.level is not None and ( + problem.level == "error" or not self.no_warn + ): + lst.append(self.show_problem(problem, file)) + return lst + + def show_problem(self, problem, file): + """Show all problems of a specific file. + + Using the codeclimate format. + https://github.com/codeclimate/platform/blob/master/spec/analyzers/SPEC.md#data-types + + + * `type` -- **Required**. Must always be "issue". + * `check_name` -- **Required**. A unique name representing the static analysis check that emitted this issue. + * `description` -- **Required**. A string explaining the issue that was detected. + * `content` -- **Optional**. A markdown snippet describing the issue, including deeper explanations and links to other resources. + * `categories` -- **Required**. At least one category indicating the nature of the issue being reported. + * `location` -- **Required**. A `Location` object representing the place in the source code where the issue was discovered. + * `trace` -- **Optional.** A `Trace` object representing other interesting source code locations related to this issue. + * `remediation_points` -- **Optional**. An integer indicating a rough estimate of how long it would take to resolve the reported issue. + * `severity` -- **Optional**. A `Severity` string (`info`, `minor`, `major`, `critical`, or `blocker`) describing the potential impact of the issue found. + * `fingerprint` -- **Optional**. A unique, deterministic identifier for the specific issue being reported to allow a user to exclude it from future analyses. + + For now the categories doc is empty, just put Style. + https://github.com/codeclimate/platform/blob/master/spec/analyzers/SPEC.md#categories + + I don't find a value of remdiation_points, just set it at 1k. + + I don't know how to calculate the fingerprint, maybe with a sha but it will be slow. + """ # noqa + return { + "type": "issue", + "check_name": problem.rule, + "description": problem.desc, + "content": problem.message, + "categories": ["Style"], + "location": { + "path": file, + "positions": { + "begin": { + "line": problem.line, + "column": problem.column + }, + } + }, + "remediation_points": 1_000, + "severity": severity_from_level(problem.level) + } + + +def max_level(all_problems): + """Return the max level of all problems.""" + all_levels = [ + problem.level + for problems in all_problems.values() + for problem in problems + ] + if all_levels: + return max(map(lambda x: int(PROBLEM_LEVELS[x]), all_levels)) + return 0 + + +def show_all_problems(all_problems, args_format, no_warn): + """Print all problems, return the max level.""" + + fmt = Formater.get_formater(args_format, no_warn) + + print(fmt.show_problems_for_all_files(all_problems), end='') + + return max_level(all_problems) diff --git a/yamllint/linter.py b/yamllint/linter.py index 58dbba4..b44a107 100644 --- a/yamllint/linter.py +++ b/yamllint/linter.py @@ -35,7 +35,14 @@ ENABLE_RULE_PATTERN = re.compile(r'^# yamllint enable( rule:\S+)*\s*$') class LintProblem(object): """Represents a linting problem found by yamllint.""" - def __init__(self, line, column, desc='', rule=None): + def __init__( + self, + line, + column, + desc='', + rule=None, + level=None + ): #: Line on which the problem was found (starting at 1) self.line = line #: Column on which the problem was found (starting at 1) @@ -44,7 +51,7 @@ class LintProblem(object): self.desc = desc #: Identifier of the rule that detected the problem self.rule = rule - self.level = None + self.level = level @property def message(self): @@ -64,6 +71,18 @@ class LintProblem(object): def __repr__(self): return '%d:%d: %s' % (self.line, self.column, self.message) + @property + def dict(self): + """Return self as a dictionary.""" + return { + "line": self.line, + "column": self.column, + "desc": self.desc, + "rule": self.rule, + "level": self.level, + "message": f"[{self.level}] {self.message}" + } + def get_cosmetic_problems(buffer, conf, filepath): rules = conf.enabled_rules(filepath)