Skip to content

Commit

Permalink
Merge pull request #1690 from Exirel/fix-listattribute-comments
Browse files Browse the repository at this point in the history
config: quote ListAttribute items when required
  • Loading branch information
dgw authored Oct 23, 2019
2 parents 8548331 + 5dd7b8f commit 5755a11
Show file tree
Hide file tree
Showing 2 changed files with 110 additions and 9 deletions.
63 changes: 54 additions & 9 deletions sopel/config/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from __future__ import unicode_literals, absolute_import, print_function, division

import os.path
import re
import sys
from sopel.tools import get_input

Expand Down Expand Up @@ -221,7 +222,7 @@ class SpamSection(StaticSection):
the option will be exposed as a Python :class:`list`::
>>> config.spam.cheeses
['camembert', 'cheddar', 'reblochon']
['camembert', 'cheddar', 'reblochon', '#brie']
which comes from this configuration file::
Expand All @@ -230,6 +231,12 @@ class SpamSection(StaticSection):
camembert
cheddar
reblochon
"#brie"
Note that the ``#brie`` item starts with a ``#``, hence the double quote:
without these quotation marks, the config parser would think it's a
comment. The quote/unquote is managed automatically by this field, and
if and only if it's necessary (see :meth:`parse` and :meth:`serialize`).
.. versionchanged:: 7.0
Expand All @@ -254,6 +261,13 @@ class SpamSection(StaticSection):
the list.
"""
DELIMITER = ','
QUOTE_REGEX = re.compile(r'^"(?P<value>#.*)"$')
"""Regex pattern to match value that requires quotation marks.
This pattern matches values that start with ``#`` inside quotation marks
only: ``"#sopel"`` will match, but ``"sopel"`` won't, and neither will any
variant that doesn't conform to this pattern.
"""

def __init__(self, name, strip=True, default=None):
default = default or []
Expand All @@ -276,33 +290,64 @@ def parse(self, value):
multi-line string.
"""
if "\n" in value:
items = [
items = (
# remove trailing comma
# because `value,\nother` is valid in Sopel 7.x
item.strip(self.DELIMITER).strip()
for item in value.splitlines()]
for item in value.splitlines())
else:
# this behavior will be:
# - Discouraged in Sopel 7.x (in the documentation)
# - Deprecated in Sopel 8.x
# - Removed from Sopel 9.x
items = value.split(self.DELIMITER)

value = list(filter(None, items))
if self.strip: # deprecate strip option in Sopel 8.x
return [v.strip() for v in value]
else:
return value
items = (self.parse_item(item) for item in items if item)
if self.strip:
return [item.strip() for item in items]

return list(items)

def parse_item(self, item):
"""Parse one ``item`` from the list.
:param str item: one item from the list to parse
:rtype: str
If ``item`` matches the :attr:`QUOTE_REGEX` pattern, then it will be
unquoted. Otherwise it's returned as-is.
"""
result = self.QUOTE_REGEX.match(item)
if result:
return result.group('value')
return item

def serialize(self, value):
"""Serialize ``value`` into a multi-line string."""
if not isinstance(value, (list, set)):
raise ValueError('ListAttribute value must be a list.')
elif not value:
# return an empty string when there is no value
return ''

# we ensure to read a newline, even with only one value in the list
# this way, comma will be ignored when the configuration file
# is read again later
return '\n' + '\n'.join(value)
return '\n' + '\n'.join(self.serialize_item(item) for item in value)

def serialize_item(self, item):
"""Serialize an ``item`` from the list value.
:param str item: one item of the list to serialize
:rtype: str
If ``item`` starts with a ``#`` it will be quoted in order to prevent
the config parser from thinking it's a comment.
"""
if item.startswith('#'):
# we need to protect item that would otherwise appear as comment
return '"%s"' % item
return item

def configure(self, prompt, default, parent, section_name):
each_prompt = '?'
Expand Down
56 changes: 56 additions & 0 deletions test/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from __future__ import unicode_literals, division, print_function, absolute_import

import os
import sys

import pytest

Expand All @@ -26,8 +27,43 @@
cheddar
reblochon
camembert
channels =
"#sopel"
&peculiar
# regular comment
# python 3 only comment
"#private"
"#startquote
&endquote"
"&quoted"
""" # noqa (trailing whitespaces are intended)

TEST_CHANNELS = [
'#sopel',
'&peculiar',
'#private',
'"#startquote', # start quote without end quote: kept
'&endquote"',
'"&quoted"', # quoted, but no #: quotes kept
]

if sys.version_info.major < 3:
# Python 2.7's ConfigParser interprets as comment
# a line that starts with # or ;.
# Python 3, on the other hand, allows comments to be indented.
# As a result, the same config file will result in a different
# config object depending on the Python version used.
# TODO: Deprecated with Python 2.7.
TEST_CHANNELS = [
'#sopel',
'&peculiar',
'# python 3 only comment', # indented lines cannot be comments in Py2
'#private',
'"#startquote',
'&endquote"',
'"&quoted"',
]


class FakeConfigSection(types.StaticSection):
valattr = types.ValidatedAttribute('valattr')
Expand All @@ -43,6 +79,7 @@ class SpamSection(types.StaticSection):
eggs = types.ListAttribute('eggs')
bacons = types.ListAttribute('bacons', strip=False)
cheeses = types.ListAttribute('cheeses')
channels = types.ListAttribute('channels')


@pytest.fixture
Expand Down Expand Up @@ -219,6 +256,8 @@ def test_configparser_multi_lines(multi_fakeconfig):
'camembert',
]

assert multi_fakeconfig.spam.channels == TEST_CHANNELS


def test_save_unmodified_config(multi_fakeconfig):
"""Assert type attributes are kept as they should be"""
Expand Down Expand Up @@ -257,6 +296,7 @@ def test_save_unmodified_config(multi_fakeconfig):
'reblochon',
'camembert',
]
assert saved_config.spam.channels == TEST_CHANNELS


def test_save_modified_config(multi_fakeconfig):
Expand All @@ -269,6 +309,14 @@ def test_save_modified_config(multi_fakeconfig):
multi_fakeconfig.spam.cheeses = [
'camembert, reblochon, and cheddar',
]
multi_fakeconfig.spam.channels = [
'#sopel',
'#private',
'&peculiar',
'"#startquote',
'&endquote"',
'"&quoted"',
]

multi_fakeconfig.save()

Expand All @@ -287,3 +335,11 @@ def test_save_modified_config(multi_fakeconfig):
'ListAttribute with one line only, with commas, must *not* be split '
'differently from what was expected, i.e. into one (and only one) value'
)
assert saved_config.spam.channels == [
'#sopel',
'#private',
'&peculiar',
'"#startquote', # start quote without end quote: kept
'&endquote"',
'"&quoted"', # doesn't start with a # so it isn't escaped
]

0 comments on commit 5755a11

Please sign in to comment.