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)