Compare commits

...

6 Commits

Author SHA1 Message Date
Adrien Vergé
a54cbce1b6 yamllint version 1.23.0 2020-04-17 10:31:52 +02:00
Adrien Vergé
b711fd993e quoted-strings: Add options extra-required and extra-allowed
Add ability to:
- require strings to be quoted if they match a pattern (PCRE regex)
- allow quoted strings if they match a pattern, while `require:
  only-when-needed` is enforced.

Co-Authored-By: Leo Feyer (https://github.com/adrienverge/yamllint/pull/246)
2020-04-17 10:29:55 +02:00
Adrien Vergé
d68022b846 config: Allow generic types inside lists
For example it's possible to define a conf like:

    rule:
      foo: [str],
      bar: [int, bool, 'magic'],
2020-04-17 10:29:55 +02:00
Adrien Vergé
851d34b9fd config: Allow rules to validate their configuration 2020-04-17 10:29:55 +02:00
Adrien Vergé
483a8d89a5 yamllint version 1.22.1 2020-04-15 07:55:57 +02:00
Adrien Vergé
fa87913566 quoted-strings: Fix only-when-needed on corner cases
Change implementation of `required: only-when-needed`, because
maintaining a list of `START_TOKENS` and just looking at the first
character of string values has proven to be partially broken.

Cf. discussion at
https://github.com/adrienverge/yamllint/pull/246#issuecomment-612354097.

Fixes https://github.com/adrienverge/yamllint/issues/242 and
https://github.com/adrienverge/yamllint/pull/244.
2020-04-15 07:48:59 +02:00
5 changed files with 246 additions and 28 deletions

View File

@@ -1,6 +1,17 @@
Changelog Changelog
========= =========
1.23.0 (2020-04-17)
-------------------
- Allow rules to validate their configuration
- Add options ``extra-required`` and ``extra-allowed`` to ``quoted-strings``
1.22.1 (2020-04-15)
-------------------
- Fix ``quoted-strings`` rule with ``only-when-needed`` on corner cases
1.22.0 (2020-04-13) 1.22.0 (2020-04-13)
------------------- -------------------

View File

@@ -16,6 +16,8 @@
from tests.common import RuleTestCase from tests.common import RuleTestCase
from yamllint import config
class QuotedTestCase(RuleTestCase): class QuotedTestCase(RuleTestCase):
rule_id = 'quoted-strings' rule_id = 'quoted-strings'
@@ -316,3 +318,120 @@ class QuotedTestCase(RuleTestCase):
' "word 1\\\n' # fails ' "word 1\\\n' # fails
' word 2"\n', ' word 2"\n',
conf, problem1=(12, 3)) conf, problem1=(12, 3))
def test_only_when_needed_corner_cases(self):
conf = 'quoted-strings: {required: only-when-needed}\n'
self.check('---\n'
'- ""\n'
'- "- item"\n'
'- "key: value"\n'
'- "%H:%M:%S"\n'
'- "%wheel ALL=(ALL) NOPASSWD: ALL"\n'
'- \'"quoted"\'\n'
'- "\'foo\' == \'bar\'"\n'
'- "\'Mac\' in ansible_facts.product_name"\n',
conf)
self.check('---\n'
'k1: ""\n'
'k2: "- item"\n'
'k3: "key: value"\n'
'k4: "%H:%M:%S"\n'
'k5: "%wheel ALL=(ALL) NOPASSWD: ALL"\n'
'k6: \'"quoted"\'\n'
'k7: "\'foo\' == \'bar\'"\n'
'k8: "\'Mac\' in ansible_facts.product_name"\n',
conf)
self.check('---\n'
'- ---\n'
'- "---"\n' # fails
'- ----------\n'
'- "----------"\n' # fails
'- :wq\n'
'- ":wq"\n', # fails
conf, problem1=(3, 3), problem2=(5, 3), problem3=(7, 3))
self.check('---\n'
'k1: ---\n'
'k2: "---"\n' # fails
'k3: ----------\n'
'k4: "----------"\n' # fails
'k5: :wq\n'
'k6: ":wq"\n', # fails
conf, problem1=(3, 5), problem2=(5, 5), problem3=(7, 5))
def test_only_when_needed_extras(self):
conf = ('quoted-strings:\n'
' required: true\n'
' extra-allowed: [^http://]\n')
self.assertRaises(config.YamlLintConfigError, self.check, '', conf)
conf = ('quoted-strings:\n'
' required: true\n'
' extra-required: [^http://]\n')
self.assertRaises(config.YamlLintConfigError, self.check, '', conf)
conf = ('quoted-strings:\n'
' required: false\n'
' extra-allowed: [^http://]\n')
self.assertRaises(config.YamlLintConfigError, self.check, '', conf)
conf = ('quoted-strings:\n'
' required: true\n')
self.check('---\n'
'- 123\n'
'- "123"\n'
'- localhost\n' # fails
'- "localhost"\n'
'- http://localhost\n' # fails
'- "http://localhost"\n'
'- ftp://localhost\n' # fails
'- "ftp://localhost"\n',
conf, problem1=(4, 3), problem2=(6, 3), problem3=(8, 3))
conf = ('quoted-strings:\n'
' required: only-when-needed\n'
' extra-allowed: [^ftp://]\n'
' extra-required: [^http://]\n')
self.check('---\n'
'- 123\n'
'- "123"\n'
'- localhost\n'
'- "localhost"\n' # fails
'- http://localhost\n' # fails
'- "http://localhost"\n'
'- ftp://localhost\n'
'- "ftp://localhost"\n',
conf, problem1=(5, 3), problem2=(6, 3))
conf = ('quoted-strings:\n'
' required: false\n'
' extra-required: [^http://, ^ftp://]\n')
self.check('---\n'
'- 123\n'
'- "123"\n'
'- localhost\n'
'- "localhost"\n'
'- http://localhost\n' # fails
'- "http://localhost"\n'
'- ftp://localhost\n' # fails
'- "ftp://localhost"\n',
conf, problem1=(6, 3), problem2=(8, 3))
conf = ('quoted-strings:\n'
' required: only-when-needed\n'
' extra-allowed: [^ftp://, ";$", " "]\n')
self.check('---\n'
'- localhost\n'
'- "localhost"\n' # fails
'- ftp://localhost\n'
'- "ftp://localhost"\n'
'- i=i+1\n'
'- "i=i+1"\n' # fails
'- i=i+2;\n'
'- "i=i+2;"\n'
'- foo\n'
'- "foo"\n' # fails
'- foo bar\n'
'- "foo bar"\n',
conf, problem1=(3, 3), problem2=(7, 3), problem3=(11, 3))

View File

@@ -22,7 +22,7 @@ indentation, etc."""
APP_NAME = 'yamllint' APP_NAME = 'yamllint'
APP_VERSION = '1.22.0' APP_VERSION = '1.23.0'
APP_DESCRIPTION = __doc__ APP_DESCRIPTION = __doc__
__author__ = u'Adrien Vergé' __author__ = u'Adrien Vergé'

View File

@@ -157,11 +157,12 @@ def validate_rule_conf(rule, conf):
raise YamlLintConfigError( raise YamlLintConfigError(
'invalid config: option "%s" of "%s" should be in %s' 'invalid config: option "%s" of "%s" should be in %s'
% (optkey, rule.ID, options[optkey])) % (optkey, rule.ID, options[optkey]))
# Example: CONF = {option: ['flag1', 'flag2']} # Example: CONF = {option: ['flag1', 'flag2', int]}
# → {option: [flag1]} → {option: [flag1, flag2]} # → {option: [flag1]} → {option: [42, flag1, flag2]}
elif isinstance(options[optkey], list): elif isinstance(options[optkey], list):
if (type(conf[optkey]) is not list or if (type(conf[optkey]) is not list or
any(flag not in options[optkey] any(flag not in options[optkey] and
type(flag) not in options[optkey]
for flag in conf[optkey])): for flag in conf[optkey])):
raise YamlLintConfigError( raise YamlLintConfigError(
('invalid config: option "%s" of "%s" should only ' ('invalid config: option "%s" of "%s" should only '
@@ -177,6 +178,12 @@ def validate_rule_conf(rule, conf):
for optkey in options: for optkey in options:
if optkey not in conf: if optkey not in conf:
conf[optkey] = options_default[optkey] conf[optkey] = options_default[optkey]
if hasattr(rule, 'VALIDATE'):
res = rule.VALIDATE(conf)
if res:
raise YamlLintConfigError('invalid config: %s: %s' %
(rule.ID, res))
else: else:
raise YamlLintConfigError(('invalid config: rule "%s": should be ' raise YamlLintConfigError(('invalid config: rule "%s": should be '
'either "enable", "disable" or a dict') 'either "enable", "disable" or a dict')

View File

@@ -26,6 +26,11 @@ used.
* ``required`` defines whether using quotes in string values is required * ``required`` defines whether using quotes in string values is required
(``true``, default) or not (``false``), or only allowed when really needed (``true``, default) or not (``false``), or only allowed when really needed
(``only-when-needed``). (``only-when-needed``).
* ``extra-required`` is a list of PCRE regexes to force string values to be
quoted, if they match any regex. This option can only be used with
``required: false`` and ``required: only-when-needed``.
* ``extra-allowed`` is a list of PCRE regexes to allow quoted string values,
even if ``required: only-when-needed`` is set.
**Note**: Multi-line strings (with ``|`` or ``>``) will not be checked. **Note**: Multi-line strings (with ``|`` or ``>``) will not be checked.
@@ -63,8 +68,44 @@ used.
:: ::
foo: 'bar' foo: 'bar'
#. With ``quoted-strings: {required: false, extra-required: [^http://,
^ftp://]}``
the following code snippet would **PASS**:
::
- localhost
- "localhost"
- "http://localhost"
- "ftp://localhost"
the following code snippet would **FAIL**:
::
- http://localhost
- ftp://localhost
#. With ``quoted-strings: {required: only-when-needed, extra-allowed:
[^http://, ^ftp://], extra-required: [QUOTED]}``
the following code snippet would **PASS**:
::
- localhost
- "http://localhost"
- "ftp://localhost"
- "this is a string that needs to be QUOTED"
the following code snippet would **FAIL**:
::
- "localhost"
- this is a string that needs to be QUOTED
""" """
import re
import yaml import yaml
from yamllint.linter import LintProblem from yamllint.linter import LintProblem
@@ -72,21 +113,49 @@ from yamllint.linter import LintProblem
ID = 'quoted-strings' ID = 'quoted-strings'
TYPE = 'token' TYPE = 'token'
CONF = {'quote-type': ('any', 'single', 'double'), CONF = {'quote-type': ('any', 'single', 'double'),
'required': (True, False, 'only-when-needed')} 'required': (True, False, 'only-when-needed'),
'extra-required': [str],
'extra-allowed': [str]}
DEFAULT = {'quote-type': 'any', DEFAULT = {'quote-type': 'any',
'required': True} 'required': True,
'extra-required': [],
'extra-allowed': []}
def VALIDATE(conf):
if conf['required'] is True and len(conf['extra-allowed']) > 0:
return 'cannot use both "required: true" and "extra-allowed"'
if conf['required'] is True and len(conf['extra-required']) > 0:
return 'cannot use both "required: true" and "extra-required"'
if conf['required'] is False and len(conf['extra-allowed']) > 0:
return 'cannot use both "required: false" and "extra-allowed"'
DEFAULT_SCALAR_TAG = u'tag:yaml.org,2002:str' DEFAULT_SCALAR_TAG = u'tag:yaml.org,2002:str'
START_TOKENS = {'#', '*', '!', '?', '@', '`', '&',
',', '-', '{', '}', '[', ']', ':'}
def quote_match(quote_type, token_style): def _quote_match(quote_type, token_style):
return ((quote_type == 'any') or return ((quote_type == 'any') or
(quote_type == 'single' and token_style == "'") or (quote_type == 'single' and token_style == "'") or
(quote_type == 'double' and token_style == '"')) (quote_type == 'double' and token_style == '"'))
def _quotes_are_needed(string):
loader = yaml.BaseLoader('key: ' + string)
# Remove the 5 first tokens corresponding to 'key: ' (StreamStartToken,
# BlockMappingStartToken, KeyToken, ScalarToken(value=key), ValueToken)
for _ in range(5):
loader.get_token()
try:
a, b = loader.get_token(), loader.get_token()
if (isinstance(a, yaml.ScalarToken) and a.style is None and
isinstance(b, yaml.BlockEndToken)):
return False
return True
except yaml.scanner.ScannerError:
return True
def check(conf, token, prev, next, nextnext, context): def check(conf, token, prev, next, nextnext, context):
if not (isinstance(token, yaml.tokens.ScalarToken) and if not (isinstance(token, yaml.tokens.ScalarToken) and
isinstance(prev, (yaml.BlockEntryToken, yaml.FlowEntryToken, isinstance(prev, (yaml.BlockEntryToken, yaml.FlowEntryToken,
@@ -111,36 +180,48 @@ def check(conf, token, prev, next, nextnext, context):
return return
quote_type = conf['quote-type'] quote_type = conf['quote-type']
required = conf['required']
# Completely relaxed about quotes (same as the rule being disabled)
if required is False and quote_type == 'any':
return
msg = None msg = None
if required is True: if conf['required'] is True:
# Quotes are mandatory and need to match config # Quotes are mandatory and need to match config
if token.style is None or not quote_match(quote_type, token.style): if token.style is None or not _quote_match(quote_type, token.style):
msg = "string value is not quoted with %s quotes" % (quote_type) msg = "string value is not quoted with %s quotes" % quote_type
elif required is False: elif conf['required'] is False:
# Quotes are not mandatory but when used need to match config # Quotes are not mandatory but when used need to match config
if token.style and not quote_match(quote_type, token.style): if token.style and not _quote_match(quote_type, token.style):
msg = "string value is not quoted with %s quotes" % (quote_type) msg = "string value is not quoted with %s quotes" % quote_type
elif not token.plain: elif not token.style:
is_extra_required = any(re.search(r, token.value)
for r in conf['extra-required'])
if is_extra_required:
msg = "string value is not quoted"
# Quotes are disallowed when not needed elif conf['required'] == 'only-when-needed':
if (tag == DEFAULT_SCALAR_TAG and token.value
and token.value[0] not in START_TOKENS): # Quotes are not strictly needed here
msg = "string value is redundantly quoted with %s quotes" % ( if (token.style and tag == DEFAULT_SCALAR_TAG and token.value and
quote_type) not _quotes_are_needed(token.value)):
is_extra_required = any(re.search(r, token.value)
for r in conf['extra-required'])
is_extra_allowed = any(re.search(r, token.value)
for r in conf['extra-allowed'])
if not (is_extra_required or is_extra_allowed):
msg = "string value is redundantly quoted with %s quotes" % (
quote_type)
# But when used need to match config # But when used need to match config
elif token.style and not quote_match(quote_type, token.style): elif token.style and not _quote_match(quote_type, token.style):
msg = "string value is not quoted with %s quotes" % (quote_type) msg = "string value is not quoted with %s quotes" % quote_type
elif not token.style:
is_extra_required = len(conf['extra-required']) and any(
re.search(r, token.value) for r in conf['extra-required'])
if is_extra_required:
msg = "string value is not quoted"
if msg is not None: if msg is not None:
yield LintProblem( yield LintProblem(