diff --git a/.travis.yml b/.travis.yml index 58dd54a..44ea648 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,10 +3,10 @@ dist: xenial # required for Python >= 3.7 (travis-ci/travis-ci#9069) language: python python: - 2.7 - - 3.4 - 3.5 - 3.6 - 3.7 + - 3.8 - nightly install: - pip install pyyaml coveralls flake8 flake8-import-order doc8 diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 119917f..4e56ab6 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,13 @@ Changelog ========= +1.21.0 (2020-03-24) +------------------- + +- Fix ``new-lines`` rule on Python 3 with DOS line endings +- Fix ``quoted-strings`` rule not working for string values matching scalars +- Add ``required: only-when-needed`` option to the ``quoted-strings`` rule + 1.20.0 (2019-12-26) ------------------- diff --git a/tests/rules/test_quoted_strings.py b/tests/rules/test_quoted_strings.py index 04ee492..0fdba71 100644 --- a/tests/rules/test_quoted_strings.py +++ b/tests/rules/test_quoted_strings.py @@ -22,6 +22,7 @@ class QuotedTestCase(RuleTestCase): def test_disabled(self): conf = 'quoted-strings: disable' + self.check('---\n' 'foo: bar\n', conf) self.check('---\n' @@ -30,18 +31,23 @@ class QuotedTestCase(RuleTestCase): 'foo: \'bar\'\n', conf) self.check('---\n' 'bar: 123\n', conf) + self.check('---\n' + 'bar: "123"\n', conf) def test_quote_type_any(self): conf = 'quoted-strings: {quote-type: any}\n' + self.check('---\n' 'boolean1: true\n' 'number1: 123\n' 'string1: foo\n' # fails 'string2: "foo"\n' - 'string3: \'bar\'\n' - 'string4: !!str genericstring\n' - 'string5: !!str 456\n' - 'string6: !!str "quotedgenericstring"\n' + 'string3: "true"\n' + 'string4: "123"\n' + 'string5: \'bar\'\n' + 'string6: !!str genericstring\n' + 'string7: !!str 456\n' + 'string8: !!str "quotedgenericstring"\n' 'binary: !!binary binstring\n' 'integer: !!int intstring\n' 'boolean2: !!bool boolstring\n' @@ -55,7 +61,7 @@ class QuotedTestCase(RuleTestCase): ' word 1\n' ' word 2\n' 'multiline string 3:\n' - ' word 1\n' + ' word 1\n' # fails ' word 2\n' 'multiline string 4:\n' ' "word 1\\\n' @@ -64,20 +70,24 @@ class QuotedTestCase(RuleTestCase): def test_quote_type_single(self): conf = 'quoted-strings: {quote-type: single}\n' + self.check('---\n' 'boolean1: true\n' 'number1: 123\n' 'string1: foo\n' # fails 'string2: "foo"\n' # fails - 'string3: \'bar\'\n' - 'string4: !!str genericstring\n' - 'string5: !!str 456\n' - 'string6: !!str "quotedgenericstring"\n' + 'string3: "true"\n' # fails + 'string4: "123"\n' # fails + 'string5: \'bar\'\n' + 'string6: !!str genericstring\n' + 'string7: !!str 456\n' + 'string8: !!str "quotedgenericstring"\n' 'binary: !!binary binstring\n' 'integer: !!int intstring\n' 'boolean2: !!bool boolstring\n' 'boolean3: !!bool "quotedboolstring"\n', - conf, problem1=(4, 10), problem2=(5, 10)) + conf, problem1=(4, 10), problem2=(5, 10), + problem3=(6, 10), problem4=(7, 10)) self.check('---\n' 'multiline string 1: |\n' ' line 1\n' @@ -86,7 +96,7 @@ class QuotedTestCase(RuleTestCase): ' word 1\n' ' word 2\n' 'multiline string 3:\n' - ' word 1\n' + ' word 1\n' # fails ' word 2\n' 'multiline string 4:\n' ' "word 1\\\n' @@ -95,20 +105,162 @@ class QuotedTestCase(RuleTestCase): def test_quote_type_double(self): conf = 'quoted-strings: {quote-type: double}\n' + + self.check('---\n' + 'boolean1: true\n' + 'number1: 123\n' + 'string1: foo\n' # fails + 'string2: "foo"\n' + 'string3: "true"\n' + 'string4: "123"\n' + 'string5: \'bar\'\n' # fails + 'string6: !!str genericstring\n' + 'string7: !!str 456\n' + 'string8: !!str "quotedgenericstring"\n' + 'binary: !!binary binstring\n' + 'integer: !!int intstring\n' + 'boolean2: !!bool boolstring\n' + 'boolean3: !!bool "quotedboolstring"\n', + conf, problem1=(4, 10), problem2=(8, 10)) + self.check('---\n' + 'multiline string 1: |\n' + ' line 1\n' + ' line 2\n' + 'multiline string 2: >\n' + ' word 1\n' + ' word 2\n' + 'multiline string 3:\n' + ' word 1\n' # fails + ' word 2\n' + 'multiline string 4:\n' + ' "word 1\\\n' + ' word 2"\n', + conf, problem1=(9, 3)) + + def test_disallow_redundant_quotes(self): + conf = 'quoted-strings: {required: only-when-needed}\n' + + self.check('---\n' + 'boolean1: true\n' + 'number1: 123\n' + 'string1: foo\n' + 'string2: "foo"\n' # fails + 'string3: "true"\n' + 'string4: "123"\n' + 'string5: \'bar\'\n' # fails + 'string6: !!str genericstring\n' + 'string7: !!str 456\n' + 'string8: !!str "quotedgenericstring"\n' + 'binary: !!binary binstring\n' + 'integer: !!int intstring\n' + 'boolean2: !!bool boolstring\n' + 'boolean3: !!bool "quotedboolstring"\n', + conf, problem1=(5, 10), problem2=(8, 10)) + self.check('---\n' + 'multiline string 1: |\n' + ' line 1\n' + ' line 2\n' + 'multiline string 2: >\n' + ' word 1\n' + ' word 2\n' + 'multiline string 3:\n' + ' word 1\n' + ' word 2\n' + 'multiline string 4:\n' + ' "word 1\\\n' # fails + ' word 2"\n', + conf, problem1=(12, 3)) + + def test_disallow_redundant_single_quotes(self): + conf = 'quoted-strings: {quote-type: single, ' + \ + 'required: only-when-needed}\n' + + self.check('---\n' + 'boolean1: true\n' + 'number1: 123\n' + 'string1: foo\n' + 'string2: "foo"\n' # fails + 'string3: "true"\n' # fails + 'string4: "123"\n' # fails + 'string5: \'bar\'\n' # fails + 'string6: !!str genericstring\n' + 'string7: !!str 456\n' + 'string8: !!str "quotedgenericstring"\n' + 'binary: !!binary binstring\n' + 'integer: !!int intstring\n' + 'boolean2: !!bool boolstring\n' + 'boolean3: !!bool "quotedboolstring"\n', + conf, problem1=(5, 10), problem2=(6, 10), + problem3=(7, 10), problem4=(8, 10)) + self.check('---\n' + 'multiline string 1: |\n' + ' line 1\n' + ' line 2\n' + 'multiline string 2: >\n' + ' word 1\n' + ' word 2\n' + 'multiline string 3:\n' + ' word 1\n' + ' word 2\n' + 'multiline string 4:\n' + ' "word 1\\\n' # fails + ' word 2"\n', + conf, problem1=(12, 3)) + + def test_single_quotes_required(self): + conf = 'quoted-strings: {quote-type: single, required: true}\n' + self.check('---\n' 'boolean1: true\n' 'number1: 123\n' 'string1: foo\n' # fails + 'string2: "foo"\n' # fails + 'string3: "true"\n' # fails + 'string4: "123"\n' # fails + 'string5: \'bar\'\n' + 'string6: !!str genericstring\n' + 'string7: !!str 456\n' + 'string8: !!str "quotedgenericstring"\n' + 'binary: !!binary binstring\n' + 'integer: !!int intstring\n' + 'boolean2: !!bool boolstring\n' + 'boolean3: !!bool "quotedboolstring"\n', + conf, problem1=(4, 10), problem2=(5, 10), + problem3=(6, 10), problem4=(7, 10)) + self.check('---\n' + 'multiline string 1: |\n' + ' line 1\n' + ' line 2\n' + 'multiline string 2: >\n' + ' word 1\n' + ' word 2\n' + 'multiline string 3:\n' + ' word 1\n' # fails + ' word 2\n' + 'multiline string 4:\n' + ' "word 1\\\n' # fails + ' word 2"\n', + conf, problem1=(9, 3), problem2=(12, 3)) + + def test_any_quotes_relaxed(self): + conf = 'quoted-strings: {quote-type: any, required: false}\n' + + self.check('---\n' + 'boolean1: true\n' + 'number1: 123\n' + 'string1: foo\n' 'string2: "foo"\n' - 'string3: \'bar\'\n' # fails - 'string4: !!str genericstring\n' - 'string5: !!str 456\n' - 'string6: !!str "quotedgenericstring"\n' + 'string3: "true"\n' + 'string4: "123"\n' + 'string5: \'bar\'\n' + 'string6: !!str genericstring\n' + 'string7: !!str 456\n' + 'string8: !!str "quotedgenericstring"\n' 'binary: !!binary binstring\n' 'integer: !!int intstring\n' 'boolean2: !!bool boolstring\n' 'boolean3: !!bool "quotedboolstring"\n', - conf, problem1=(4, 10), problem2=(6, 10)) + conf) self.check('---\n' 'multiline string 1: |\n' ' line 1\n' @@ -122,4 +274,73 @@ class QuotedTestCase(RuleTestCase): 'multiline string 4:\n' ' "word 1\\\n' ' word 2"\n', + conf) + + def test_single_quotes_relaxed(self): + conf = 'quoted-strings: {quote-type: single, required: false}\n' + + self.check('---\n' + 'boolean1: true\n' + 'number1: 123\n' + 'string1: foo\n' + 'string2: "foo"\n' # fails + 'string3: "true"\n' # fails + 'string4: "123"\n' # fails + 'string5: \'bar\'\n' + 'string6: !!str genericstring\n' + 'string7: !!str 456\n' + 'string8: !!str "quotedgenericstring"\n' + 'binary: !!binary binstring\n' + 'integer: !!int intstring\n' + 'boolean2: !!bool boolstring\n' + 'boolean3: !!bool "quotedboolstring"\n', + conf, problem2=(5, 10), + problem3=(6, 10), problem4=(7, 10)) + self.check('---\n' + 'multiline string 1: |\n' + ' line 1\n' + ' line 2\n' + 'multiline string 2: >\n' + ' word 1\n' + ' word 2\n' + 'multiline string 3:\n' + ' word 1\n' + ' word 2\n' + 'multiline string 4:\n' + ' "word 1\\\n' # fails + ' word 2"\n', + conf, problem1=(12, 3)) + + def test_quotes_required(self): + conf = 'quoted-strings: {quote-type: any, required: true}\n' + + self.check('---\n' + 'boolean1: true\n' + 'number1: 123\n' + 'string1: foo\n' # fails + 'string2: "foo"\n' + 'string3: "true"\n' + 'string4: "123"\n' + 'string5: \'bar\'\n' + 'string6: !!str genericstring\n' + 'string7: !!str 456\n' + 'string8: !!str "quotedgenericstring"\n' + 'binary: !!binary binstring\n' + 'integer: !!int intstring\n' + 'boolean2: !!bool boolstring\n' + 'boolean3: !!bool "quotedboolstring"\n', + conf, problem2=(4, 10)) + self.check('---\n' + 'multiline string 1: |\n' + ' line 1\n' + ' line 2\n' + 'multiline string 2: >\n' + ' word 1\n' + ' word 2\n' + 'multiline string 3:\n' + ' word 1\n' # fails + ' word 2\n' + 'multiline string 4:\n' + ' "word 1\\\n' + ' word 2"\n', conf, problem1=(9, 3)) diff --git a/tests/test_cli.py b/tests/test_cli.py index 4244dc8..517bc62 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -32,6 +32,29 @@ from yamllint import cli from yamllint import config +class RunContext(object): + """Context manager for ``cli.run()`` to capture exit code and streams.""" + + def __init__(self, case): + self.stdout = self.stderr = None + self._raises_ctx = case.assertRaises(SystemExit) + + def __enter__(self): + self._raises_ctx.__enter__() + sys.stdout = self.outstream = StringIO() + sys.stderr = self.errstream = StringIO() + return self + + def __exit__(self, *exc_info): + self.stdout, sys.stdout = self.outstream.getvalue(), sys.__stdout__ + self.stderr, sys.stderr = self.errstream.getvalue(), sys.__stderr__ + return self._raises_ctx.__exit__(*exc_info) + + @property + def returncode(self): + return self._raises_ctx.exception.code + + class CommandLineTestCase(unittest.TestCase): @classmethod def setUpClass(cls): @@ -59,12 +82,15 @@ class CommandLineTestCase(unittest.TestCase): 'no-yaml.json': '---\n' 'key: value\n', # non-ASCII chars - 'non-ascii/utf-8': ( + 'non-ascii/éçäγλνπ¥/utf-8': ( u'---\n' u'- hétérogénéité\n' u'# 19.99 €\n' u'- お早う御座います。\n' u'# الأَبْجَدِيَّة العَرَبِيَّة\n').encode('utf-8'), + # dos line endings yaml + 'dos.yml': '---\r\n' + 'dos: true', }) @classmethod @@ -78,6 +104,7 @@ class CommandLineTestCase(unittest.TestCase): self.assertEqual( sorted(cli.find_files_recursively([self.wd], conf)), [os.path.join(self.wd, 'a.yaml'), + os.path.join(self.wd, 'dos.yml'), os.path.join(self.wd, 'empty.yml'), os.path.join(self.wd, 's/s/s/s/s/s/s/s/s/s/s/s/s/s/s/file.yaml'), os.path.join(self.wd, 'sub/ok.yaml'), @@ -123,7 +150,8 @@ class CommandLineTestCase(unittest.TestCase): ' - \'*.yml\'\n') self.assertEqual( sorted(cli.find_files_recursively([self.wd], conf)), - [os.path.join(self.wd, 'empty.yml')] + [os.path.join(self.wd, 'dos.yml'), + os.path.join(self.wd, 'empty.yml')] ) conf = config.YamlLintConfig('extends: default\n' @@ -140,9 +168,10 @@ class CommandLineTestCase(unittest.TestCase): self.assertEqual( sorted(cli.find_files_recursively([self.wd], conf)), [os.path.join(self.wd, 'a.yaml'), + os.path.join(self.wd, 'dos.yml'), os.path.join(self.wd, 'empty.yml'), os.path.join(self.wd, 'no-yaml.json'), - os.path.join(self.wd, 'non-ascii/utf-8'), + os.path.join(self.wd, 'non-ascii/éçäγλνπ¥/utf-8'), os.path.join(self.wd, 's/s/s/s/s/s/s/s/s/s/s/s/s/s/s/file.yaml'), os.path.join(self.wd, 'sub/ok.yaml'), os.path.join(self.wd, 'warn.yaml')] @@ -156,9 +185,10 @@ class CommandLineTestCase(unittest.TestCase): self.assertEqual( sorted(cli.find_files_recursively([self.wd], conf)), [os.path.join(self.wd, 'a.yaml'), + os.path.join(self.wd, 'dos.yml'), os.path.join(self.wd, 'empty.yml'), os.path.join(self.wd, 'no-yaml.json'), - os.path.join(self.wd, 'non-ascii/utf-8'), + os.path.join(self.wd, 'non-ascii/éçäγλνπ¥/utf-8'), os.path.join(self.wd, 's/s/s/s/s/s/s/s/s/s/s/s/s/s/s/file.yaml'), os.path.join(self.wd, 'sub/ok.yaml'), os.path.join(self.wd, 'warn.yaml')] @@ -170,205 +200,148 @@ class CommandLineTestCase(unittest.TestCase): ' - \'**/utf-8\'\n') self.assertEqual( sorted(cli.find_files_recursively([self.wd], conf)), - [os.path.join(self.wd, 'non-ascii/utf-8')] + [os.path.join(self.wd, 'non-ascii/éçäγλνπ¥/utf-8')] ) def test_run_with_bad_arguments(self): - sys.stdout, sys.stderr = StringIO(), StringIO() - with self.assertRaises(SystemExit) as ctx: + with RunContext(self) as ctx: cli.run(()) + self.assertNotEqual(ctx.returncode, 0) + self.assertEqual(ctx.stdout, '') + self.assertRegexpMatches(ctx.stderr, r'^usage') - self.assertNotEqual(ctx.exception.code, 0) - - out, err = sys.stdout.getvalue(), sys.stderr.getvalue() - self.assertEqual(out, '') - self.assertRegexpMatches(err, r'^usage') - - sys.stdout, sys.stderr = StringIO(), StringIO() - with self.assertRaises(SystemExit) as ctx: + with RunContext(self) as ctx: cli.run(('--unknown-arg', )) + self.assertNotEqual(ctx.returncode, 0) + self.assertEqual(ctx.stdout, '') + self.assertRegexpMatches(ctx.stderr, r'^usage') - self.assertNotEqual(ctx.exception.code, 0) - - out, err = sys.stdout.getvalue(), sys.stderr.getvalue() - self.assertEqual(out, '') - self.assertRegexpMatches(err, r'^usage') - - sys.stdout, sys.stderr = StringIO(), StringIO() - with self.assertRaises(SystemExit) as ctx: + with RunContext(self) as ctx: cli.run(('-c', './conf.yaml', '-d', 'relaxed', 'file')) - - self.assertNotEqual(ctx.exception.code, 0) - - out, err = sys.stdout.getvalue(), sys.stderr.getvalue() - self.assertEqual(out, '') + self.assertNotEqual(ctx.returncode, 0) + self.assertEqual(ctx.stdout, '') self.assertRegexpMatches( - err.splitlines()[-1], + ctx.stderr.splitlines()[-1], r'^yamllint: error: argument -d\/--config-data: ' r'not allowed with argument -c\/--config-file$' ) # checks if reading from stdin and files are mutually exclusive - sys.stdout, sys.stderr = StringIO(), StringIO() - with self.assertRaises(SystemExit) as ctx: + with RunContext(self) as ctx: cli.run(('-', 'file')) - - self.assertNotEqual(ctx.exception.code, 0) - - out, err = sys.stdout.getvalue(), sys.stderr.getvalue() - self.assertEqual(out, '') - self.assertRegexpMatches(err, r'^usage') + self.assertNotEqual(ctx.returncode, 0) + self.assertEqual(ctx.stdout, '') + self.assertRegexpMatches(ctx.stderr, r'^usage') def test_run_with_bad_config(self): - sys.stdout, sys.stderr = StringIO(), StringIO() - with self.assertRaises(SystemExit) as ctx: + with RunContext(self) as ctx: cli.run(('-d', 'rules: {a: b}', 'file')) - - self.assertEqual(ctx.exception.code, -1) - - out, err = sys.stdout.getvalue(), sys.stderr.getvalue() - self.assertEqual(out, '') - self.assertRegexpMatches(err, r'^invalid config: no such rule') + self.assertEqual(ctx.returncode, -1) + self.assertEqual(ctx.stdout, '') + self.assertRegexpMatches(ctx.stderr, r'^invalid config: no such rule') def test_run_with_empty_config(self): - sys.stdout, sys.stderr = StringIO(), StringIO() - with self.assertRaises(SystemExit) as ctx: + with RunContext(self) as ctx: cli.run(('-d', '', 'file')) - - self.assertEqual(ctx.exception.code, -1) - - out, err = sys.stdout.getvalue(), sys.stderr.getvalue() - self.assertEqual(out, '') - self.assertRegexpMatches(err, r'^invalid config: not a dict') + self.assertEqual(ctx.returncode, -1) + self.assertEqual(ctx.stdout, '') + self.assertRegexpMatches(ctx.stderr, r'^invalid config: not a dict') def test_run_with_config_file(self): with open(os.path.join(self.wd, 'config'), 'w') as f: f.write('rules: {trailing-spaces: disable}') - with self.assertRaises(SystemExit) as ctx: + with RunContext(self) as ctx: cli.run(('-c', f.name, os.path.join(self.wd, 'a.yaml'))) - self.assertEqual(ctx.exception.code, 0) + self.assertEqual(ctx.returncode, 0) with open(os.path.join(self.wd, 'config'), 'w') as f: f.write('rules: {trailing-spaces: enable}') - with self.assertRaises(SystemExit) as ctx: + with RunContext(self) as ctx: cli.run(('-c', f.name, os.path.join(self.wd, 'a.yaml'))) - self.assertEqual(ctx.exception.code, 1) + self.assertEqual(ctx.returncode, 1) def test_run_with_user_global_config_file(self): home = os.path.join(self.wd, 'fake-home') - os.mkdir(home) - dir = os.path.join(home, '.config') - os.mkdir(dir) - dir = os.path.join(dir, 'yamllint') - os.mkdir(dir) + dir = os.path.join(home, '.config', 'yamllint') + os.makedirs(dir) config = os.path.join(dir, 'config') - temp = os.environ['HOME'] + self.addCleanup(os.environ.update, HOME=os.environ['HOME']) os.environ['HOME'] = home with open(config, 'w') as f: f.write('rules: {trailing-spaces: disable}') - with self.assertRaises(SystemExit) as ctx: + with RunContext(self) as ctx: cli.run((os.path.join(self.wd, 'a.yaml'), )) - self.assertEqual(ctx.exception.code, 0) + self.assertEqual(ctx.returncode, 0) with open(config, 'w') as f: f.write('rules: {trailing-spaces: enable}') - with self.assertRaises(SystemExit) as ctx: + with RunContext(self) as ctx: cli.run((os.path.join(self.wd, 'a.yaml'), )) - self.assertEqual(ctx.exception.code, 1) - - os.environ['HOME'] = temp + self.assertEqual(ctx.returncode, 1) def test_run_version(self): - sys.stdout, sys.stderr = StringIO(), StringIO() - with self.assertRaises(SystemExit) as ctx: + with RunContext(self) as ctx: cli.run(('--version', )) - - self.assertEqual(ctx.exception.code, 0) - - out, err = sys.stdout.getvalue(), sys.stderr.getvalue() - self.assertRegexpMatches(out + err, r'yamllint \d+\.\d+') + self.assertEqual(ctx.returncode, 0) + self.assertRegexpMatches(ctx.stdout + ctx.stderr, r'yamllint \d+\.\d+') def test_run_non_existing_file(self): - file = os.path.join(self.wd, 'i-do-not-exist.yaml') - - sys.stdout, sys.stderr = StringIO(), StringIO() - with self.assertRaises(SystemExit) as ctx: - cli.run(('-f', 'parsable', file)) + path = os.path.join(self.wd, 'i-do-not-exist.yaml') - self.assertEqual(ctx.exception.code, -1) - - out, err = sys.stdout.getvalue(), sys.stderr.getvalue() - self.assertEqual(out, '') - self.assertRegexpMatches(err, r'No such file or directory') + with RunContext(self) as ctx: + cli.run(('-f', 'parsable', path)) + self.assertEqual(ctx.returncode, -1) + self.assertEqual(ctx.stdout, '') + self.assertRegexpMatches(ctx.stderr, r'No such file or directory') def test_run_one_problem_file(self): - file = os.path.join(self.wd, 'a.yaml') - - sys.stdout, sys.stderr = StringIO(), StringIO() - with self.assertRaises(SystemExit) as ctx: - cli.run(('-f', 'parsable', file)) + path = os.path.join(self.wd, 'a.yaml') - self.assertEqual(ctx.exception.code, 1) - - out, err = sys.stdout.getvalue(), sys.stderr.getvalue() - self.assertEqual(out, ( + with RunContext(self) as ctx: + cli.run(('-f', 'parsable', path)) + self.assertEqual(ctx.returncode, 1) + self.assertEqual(ctx.stdout, ( '%s:2:4: [error] trailing spaces (trailing-spaces)\n' '%s:3:4: [error] no new line character at the end of file ' - '(new-line-at-end-of-file)\n') % (file, file)) - self.assertEqual(err, '') + '(new-line-at-end-of-file)\n' % (path, path))) + self.assertEqual(ctx.stderr, '') def test_run_one_warning(self): - file = os.path.join(self.wd, 'warn.yaml') + path = os.path.join(self.wd, 'warn.yaml') - sys.stdout, sys.stderr = StringIO(), StringIO() - with self.assertRaises(SystemExit) as ctx: - cli.run(('-f', 'parsable', file)) - - self.assertEqual(ctx.exception.code, 0) + with RunContext(self) as ctx: + cli.run(('-f', 'parsable', path)) + self.assertEqual(ctx.returncode, 0) def test_run_warning_in_strict_mode(self): - file = os.path.join(self.wd, 'warn.yaml') - - sys.stdout, sys.stderr = StringIO(), StringIO() - with self.assertRaises(SystemExit) as ctx: - cli.run(('-f', 'parsable', '--strict', file)) + path = os.path.join(self.wd, 'warn.yaml') - self.assertEqual(ctx.exception.code, 2) + with RunContext(self) as ctx: + cli.run(('-f', 'parsable', '--strict', path)) + self.assertEqual(ctx.returncode, 2) def test_run_one_ok_file(self): - file = os.path.join(self.wd, 'sub', 'ok.yaml') - - sys.stdout, sys.stderr = StringIO(), StringIO() - with self.assertRaises(SystemExit) as ctx: - cli.run(('-f', 'parsable', file)) + path = os.path.join(self.wd, 'sub', 'ok.yaml') - self.assertEqual(ctx.exception.code, 0) - - out, err = sys.stdout.getvalue(), sys.stderr.getvalue() - self.assertEqual(out, '') - self.assertEqual(err, '') + with RunContext(self) as ctx: + cli.run(('-f', 'parsable', path)) + self.assertEqual((ctx.returncode, ctx.stdout, ctx.stderr), (0, '', '')) def test_run_empty_file(self): - file = os.path.join(self.wd, 'empty.yml') - - sys.stdout, sys.stderr = StringIO(), StringIO() - with self.assertRaises(SystemExit) as ctx: - cli.run(('-f', 'parsable', file)) + path = os.path.join(self.wd, 'empty.yml') - self.assertEqual(ctx.exception.code, 0) - - out, err = sys.stdout.getvalue(), sys.stderr.getvalue() - self.assertEqual(out, '') - self.assertEqual(err, '') + with RunContext(self) as ctx: + cli.run(('-f', 'parsable', path)) + self.assertEqual((ctx.returncode, ctx.stdout, ctx.stderr), (0, '', '')) def test_run_non_ascii_file(self): - file = os.path.join(self.wd, 'non-ascii', 'utf-8') + path = os.path.join(self.wd, 'non-ascii', 'éçäγλνπ¥', 'utf-8') # Make sure the default localization conditions on this "system" # support UTF-8 encoding. @@ -377,63 +350,46 @@ class CommandLineTestCase(unittest.TestCase): locale.setlocale(locale.LC_ALL, 'C.UTF-8') except locale.Error: locale.setlocale(locale.LC_ALL, 'en_US.UTF-8') + self.addCleanup(locale.setlocale, locale.LC_ALL, loc) - sys.stdout, sys.stderr = StringIO(), StringIO() - with self.assertRaises(SystemExit) as ctx: - cli.run(('-f', 'parsable', file)) - - locale.setlocale(locale.LC_ALL, loc) - - self.assertEqual(ctx.exception.code, 0) - - out, err = sys.stdout.getvalue(), sys.stderr.getvalue() - self.assertEqual(out, '') - self.assertEqual(err, '') + with RunContext(self) as ctx: + cli.run(('-f', 'parsable', path)) + self.assertEqual((ctx.returncode, ctx.stdout, ctx.stderr), (0, '', '')) def test_run_multiple_files(self): items = [os.path.join(self.wd, 'empty.yml'), os.path.join(self.wd, 's')] - file = items[1] + '/s/s/s/s/s/s/s/s/s/s/s/s/s/s/file.yaml' + path = items[1] + '/s/s/s/s/s/s/s/s/s/s/s/s/s/s/file.yaml' - sys.stdout, sys.stderr = StringIO(), StringIO() - with self.assertRaises(SystemExit) as ctx: + with RunContext(self) as ctx: cli.run(['-f', 'parsable'] + items) - - self.assertEqual(ctx.exception.code, 1) - - out, err = sys.stdout.getvalue(), sys.stderr.getvalue() - self.assertEqual(out, ( + self.assertEqual((ctx.returncode, ctx.stderr), (1, '')) + self.assertEqual(ctx.stdout, ( '%s:3:1: [error] duplication of key "key" in mapping ' - '(key-duplicates)\n') % file) - self.assertEqual(err, '') + '(key-duplicates)\n') % path) def test_run_piped_output_nocolor(self): - file = os.path.join(self.wd, 'a.yaml') - - sys.stdout, sys.stderr = StringIO(), StringIO() - with self.assertRaises(SystemExit) as ctx: - cli.run((file, )) - - self.assertEqual(ctx.exception.code, 1) + path = os.path.join(self.wd, 'a.yaml') - out, err = sys.stdout.getvalue(), sys.stderr.getvalue() - self.assertEqual(out, ( + with RunContext(self) as ctx: + cli.run((path, )) + self.assertEqual((ctx.returncode, ctx.stderr), (1, '')) + self.assertEqual(ctx.stdout, ( '%s\n' ' 2:4 error trailing spaces (trailing-spaces)\n' ' 3:4 error no new line character at the end of file ' '(new-line-at-end-of-file)\n' - '\n' % file)) - self.assertEqual(err, '') + '\n' % path)) def test_run_default_format_output_in_tty(self): - file = os.path.join(self.wd, 'a.yaml') + path = os.path.join(self.wd, 'a.yaml') # Create a pseudo-TTY and redirect stdout to it master, slave = pty.openpty() sys.stdout = sys.stderr = os.fdopen(slave, 'w') with self.assertRaises(SystemExit) as ctx: - cli.run((file, )) + cli.run((path, )) sys.stdout.flush() self.assertEqual(ctx.exception.code, 1) @@ -456,114 +412,108 @@ class CommandLineTestCase(unittest.TestCase): ' \033[2m3:4\033[0m \033[31merror\033[0m ' 'no new line character at the end of file ' '\033[2m(new-line-at-end-of-file)\033[0m\n' - '\n' % file)) + '\n' % path)) def test_run_default_format_output_without_tty(self): - file = os.path.join(self.wd, 'a.yaml') - - sys.stdout, sys.stderr = StringIO(), StringIO() - with self.assertRaises(SystemExit) as ctx: - cli.run((file, )) + path = os.path.join(self.wd, 'a.yaml') - self.assertEqual(ctx.exception.code, 1) - - out, err = sys.stdout.getvalue(), sys.stderr.getvalue() - self.assertEqual(out, ( + with RunContext(self) as ctx: + cli.run((path, )) + expected_out = ( '%s\n' ' 2:4 error trailing spaces (trailing-spaces)\n' ' 3:4 error no new line character at the end of file ' '(new-line-at-end-of-file)\n' - '\n' % file)) - self.assertEqual(err, '') + '\n' % path) + self.assertEqual( + (ctx.returncode, ctx.stdout, ctx.stderr), (1, expected_out, '')) def test_run_auto_output_without_tty_output(self): - file = os.path.join(self.wd, 'a.yaml') - - sys.stdout, sys.stderr = StringIO(), StringIO() - with self.assertRaises(SystemExit) as ctx: - cli.run((file, '--format', 'auto')) + path = os.path.join(self.wd, 'a.yaml') - self.assertEqual(ctx.exception.code, 1) - - out, err = sys.stdout.getvalue(), sys.stderr.getvalue() - self.assertEqual(out, ( + with RunContext(self) as ctx: + cli.run((path, '--format', 'auto')) + expected_out = ( '%s\n' ' 2:4 error trailing spaces (trailing-spaces)\n' ' 3:4 error no new line character at the end of file ' '(new-line-at-end-of-file)\n' - '\n' % file)) - self.assertEqual(err, '') + '\n' % path) + self.assertEqual( + (ctx.returncode, ctx.stdout, ctx.stderr), (1, expected_out, '')) def test_run_format_colored(self): - file = os.path.join(self.wd, 'a.yaml') - - sys.stdout, sys.stderr = StringIO(), StringIO() - with self.assertRaises(SystemExit) as ctx: - cli.run((file, '--format', 'colored')) + path = os.path.join(self.wd, 'a.yaml') - self.assertEqual(ctx.exception.code, 1) - - out, err = sys.stdout.getvalue(), sys.stderr.getvalue() - self.assertEqual(out, ( + with RunContext(self) as ctx: + cli.run((path, '--format', 'colored')) + expected_out = ( '\033[4m%s\033[0m\n' ' \033[2m2:4\033[0m \033[31merror\033[0m ' 'trailing spaces \033[2m(trailing-spaces)\033[0m\n' ' \033[2m3:4\033[0m \033[31merror\033[0m ' 'no new line character at the end of file ' '\033[2m(new-line-at-end-of-file)\033[0m\n' - '\n' % file)) - self.assertEqual(err, '') + '\n' % path) + self.assertEqual( + (ctx.returncode, ctx.stdout, ctx.stderr), (1, expected_out, '')) def test_run_read_from_stdin(self): # prepares stdin with an invalid yaml string so that we can check # for its specific error, and be assured that stdin was read - sys.stdout, sys.stderr = StringIO(), StringIO() + self.addCleanup(setattr, sys, 'stdin', sys.__stdin__) sys.stdin = StringIO( 'I am a string\n' 'therefore: I am an error\n') - with self.assertRaises(SystemExit) as ctx: + with RunContext(self) as ctx: cli.run(('-', '-f', 'parsable')) - - self.assertNotEqual(ctx.exception.code, 0) - - out, err = sys.stdout.getvalue(), sys.stderr.getvalue() - self.assertEqual(out, ( + expected_out = ( 'stdin:2:10: [error] syntax error: ' - 'mapping values are not allowed here (syntax)\n')) - self.assertEqual(err, '') + 'mapping values are not allowed here (syntax)\n') + self.assertEqual( + (ctx.returncode, ctx.stdout, ctx.stderr), (1, expected_out, '')) def test_run_no_warnings(self): - file = os.path.join(self.wd, 'a.yaml') + path = os.path.join(self.wd, 'a.yaml') - sys.stdout, sys.stderr = StringIO(), StringIO() - with self.assertRaises(SystemExit) as ctx: - cli.run((file, '--no-warnings', '-f', 'auto')) - - self.assertEqual(ctx.exception.code, 1) - - out, err = sys.stdout.getvalue(), sys.stderr.getvalue() - self.assertEqual(out, ( + with RunContext(self) as ctx: + cli.run((path, '--no-warnings', '-f', 'auto')) + expected_out = ( '%s\n' ' 2:4 error trailing spaces (trailing-spaces)\n' ' 3:4 error no new line character at the end of file ' '(new-line-at-end-of-file)\n' - '\n' % file)) - self.assertEqual(err, '') - - file = os.path.join(self.wd, 'warn.yaml') + '\n' % path) + self.assertEqual( + (ctx.returncode, ctx.stdout, ctx.stderr), (1, expected_out, '')) - sys.stdout, sys.stderr = StringIO(), StringIO() - with self.assertRaises(SystemExit) as ctx: - cli.run((file, '--no-warnings', '-f', 'auto')) + path = os.path.join(self.wd, 'warn.yaml') - self.assertEqual(ctx.exception.code, 0) + with RunContext(self) as ctx: + cli.run((path, '--no-warnings', '-f', 'auto')) + self.assertEqual(ctx.returncode, 0) def test_run_no_warnings_and_strict(self): - file = os.path.join(self.wd, 'warn.yaml') + path = os.path.join(self.wd, 'warn.yaml') - sys.stdout, sys.stderr = StringIO(), StringIO() - with self.assertRaises(SystemExit) as ctx: - cli.run((file, '--no-warnings', '-s')) + with RunContext(self) as ctx: + cli.run((path, '--no-warnings', '-s')) + self.assertEqual(ctx.returncode, 2) + + def test_run_non_universal_newline(self): + path = os.path.join(self.wd, 'dos.yml') - self.assertEqual(ctx.exception.code, 2) + with RunContext(self) as ctx: + cli.run(('-d', 'rules:\n new-lines:\n type: dos', path)) + self.assertEqual((ctx.returncode, ctx.stdout, ctx.stderr), (0, '', '')) + + with RunContext(self) as ctx: + cli.run(('-d', 'rules:\n new-lines:\n type: unix', path)) + expected_out = ( + '%s\n' + ' 1:4 error wrong new line character: expected \\n' + ' (new-lines)\n' + '\n' % path) + self.assertEqual( + (ctx.returncode, ctx.stdout, ctx.stderr), (1, expected_out, '')) diff --git a/yamllint/__init__.py b/yamllint/__init__.py index 676c80f..5b04ef6 100644 --- a/yamllint/__init__.py +++ b/yamllint/__init__.py @@ -22,7 +22,7 @@ indentation, etc.""" APP_NAME = 'yamllint' -APP_VERSION = '1.20.0' +APP_VERSION = '1.21.0' APP_DESCRIPTION = __doc__ __author__ = u'Adrien Vergé' diff --git a/yamllint/cli.py b/yamllint/cli.py index 26bdb1f..e99fd2c 100644 --- a/yamllint/cli.py +++ b/yamllint/cli.py @@ -17,6 +17,7 @@ from __future__ import print_function import argparse +import io import os import platform import sys @@ -176,7 +177,7 @@ def run(argv=None): for file in find_files_recursively(args.files, conf): filepath = file[2:] if file.startswith('./') else file try: - with open(file) as f: + with io.open(file, newline='') as f: problems = linter.run(f, conf, filepath) except EnvironmentError as e: print(e, file=sys.stderr) diff --git a/yamllint/rules/quoted_strings.py b/yamllint/rules/quoted_strings.py index b019261..aaa635d 100644 --- a/yamllint/rules/quoted_strings.py +++ b/yamllint/rules/quoted_strings.py @@ -15,15 +15,23 @@ # along with this program. If not, see . """ -Use this rule to forbid any string values that are not quoted. -You can also enforce the type of the quote used using the ``quote-type`` option -(``single``, ``double`` or ``any``). +Use this rule to forbid any string values that are not quoted, or to prevent +quoted strings without needing it. You can also enforce the type of the quote +used. + +.. rubric:: Options + +* ``quote-type`` defines allowed quotes: ``single``, ``double`` or ``any`` + (default). +* ``required`` defines whether using quotes in string values is required + (``true``, default) or not (``false``), or only allowed when really needed + (``only-when-needed``). **Note**: Multi-line strings (with ``|`` or ``>``) will not be checked. .. rubric:: Examples -#. With ``quoted-strings: {quote-type: any}`` +#. With ``quoted-strings: {quote-type: any, required: true}`` the following code snippet would **PASS**: :: @@ -37,6 +45,24 @@ You can also enforce the type of the quote used using the ``quote-type`` option :: foo: bar + +#. With ``quoted-strings: {quote-type: single, required: only-when-needed}`` + + the following code snippet would **PASS**: + :: + + foo: bar + bar: foo + not_number: '123' + not_boolean: 'true' + not_comment: '# comment' + not_list: '[1, 2, 3]' + not_map: '{a: 1, b: 2}' + + the following code snippet would **FAIL**: + :: + + foo: 'bar' """ import yaml @@ -45,34 +71,76 @@ from yamllint.linter import LintProblem ID = 'quoted-strings' TYPE = 'token' -CONF = {'quote-type': ('any', 'single', 'double')} -DEFAULT = {'quote-type': 'any'} +CONF = {'quote-type': ('any', 'single', 'double'), + 'required': (True, False, 'only-when-needed')} +DEFAULT = {'quote-type': 'any', + 'required': True} + +DEFAULT_SCALAR_TAG = u'tag:yaml.org,2002:str' +START_TOKENS = {'#', '*', '!', '?', '@', '`', '&', + ',', '-', '{', '}', '[', ']', ':'} + + +def quote_match(quote_type, token_style): + return ((quote_type == 'any') or + (quote_type == 'single' and token_style == "'") or + (quote_type == 'double' and token_style == '"')) def check(conf, token, prev, next, nextnext, context): + if not (isinstance(token, yaml.tokens.ScalarToken) and + isinstance(prev, (yaml.ValueToken, yaml.TagToken))): + return + + # Ignore explicit types, e.g. !!str testtest or !!int 42 + if (prev and isinstance(prev, yaml.tokens.TagToken) and + prev.value[0] == '!!'): + return + + # Ignore numbers, booleans, etc. + resolver = yaml.resolver.Resolver() + tag = resolver.resolve(yaml.nodes.ScalarNode, token.value, (True, False)) + if token.plain and tag != DEFAULT_SCALAR_TAG: + return + + # Ignore multi-line strings + if (not token.plain) and (token.style == "|" or token.style == ">"): + return + quote_type = conf['quote-type'] + required = conf['required'] - if (isinstance(token, yaml.tokens.ScalarToken) and - isinstance(prev, (yaml.ValueToken, yaml.TagToken))): - # Ignore explicit types, e.g. !!str testtest or !!int 42 - if (prev and isinstance(prev, yaml.tokens.TagToken) and - prev.value[0] == '!!'): - return - - # Ignore numbers, booleans, etc. - resolver = yaml.resolver.Resolver() - if resolver.resolve(yaml.nodes.ScalarNode, token.value, - (True, False)) != 'tag:yaml.org,2002:str': - return - - # Ignore multi-line strings - if (not token.plain) and (token.style == "|" or token.style == ">"): - return - - if ((quote_type == 'single' and token.style != "'") or - (quote_type == 'double' and token.style != '"') or - (quote_type == 'any' and token.style is None)): - yield LintProblem( - token.start_mark.line + 1, - token.start_mark.column + 1, - "string value is not quoted with %s quotes" % (quote_type)) + # Completely relaxed about quotes (same as the rule being disabled) + if required is False and quote_type == 'any': + return + + msg = None + if required is True: + + # Quotes are mandatory and need to match config + if token.style is None or not quote_match(quote_type, token.style): + msg = "string value is not quoted with %s quotes" % (quote_type) + + elif required is False: + + # Quotes are not mandatory but when used need to match config + if token.style and not quote_match(quote_type, token.style): + msg = "string value is not quoted with %s quotes" % (quote_type) + + elif not token.plain: + + # Quotes are disallowed when not needed + if (tag == DEFAULT_SCALAR_TAG and token.value + and token.value[0] not in START_TOKENS): + msg = "string value is redundantly quoted with %s quotes" % ( + quote_type) + + # But when used need to match config + elif token.style and not quote_match(quote_type, token.style): + msg = "string value is not quoted with %s quotes" % (quote_type) + + if msg is not None: + yield LintProblem( + token.start_mark.line + 1, + token.start_mark.column + 1, + msg)