diff --git a/docs/source/configuration.rst b/docs/source/configuration.rst index ff9a820f5d..ca1a459983 100644 --- a/docs/source/configuration.rst +++ b/docs/source/configuration.rst @@ -173,7 +173,9 @@ join is configured by :attr:`~CoreSection.channels`: .. code-block:: ini [core] - channels = #sopel, #sopelunkers + channels = + #sopel + #sopelunkers It is possible to slow down the initial joining of channels using :attr:`~CoreSection.throttle_join`, for example if the IRC network kicks @@ -388,7 +390,9 @@ can put its name in the :attr:`~CoreSection.exclude` directive. Here, the .. code-block:: ini [core] - exclude = reload, meetbot + exclude = + reload + meetbot Alternatively, you can define a list of allowed plugins with :attr:`~CoreSection.enable`: plugins not in this list will be ignored. In this @@ -398,8 +402,13 @@ example, only the ``bugzilla`` and ``remind`` plugins are enabled (because .. code-block:: ini [core] - enable = bugzilla, remind, meetbot - exclude = reload, meetbot + enable = + bugzilla + remind + meetbot + exclude = + reload + meetbot To detect plugins from extra directories, use the :attr:`~CoreSection.extra` option. diff --git a/sopel/config/types.py b/sopel/config/types.py index c2fb876817..f394905cea 100644 --- a/sopel/config/types.py +++ b/sopel/config/types.py @@ -211,12 +211,46 @@ def configure(self, prompt, default, parent, section_name): class ListAttribute(BaseValidated): """A config attribute containing a list of string values. - Values are saved to the file as a comma-separated list. It does not - currently support commas within items in the list. By default, the spaces - before and after each item are stripped; you can override this by passing - ``strip=False``.""" + From this :class:`StaticSection`:: - ESCAPE_CHARACTER = '\\' + class SpamSection(StaticSection): + cheeses = ListAttribute('cheeses') + + the option will be exposed as a Python :class:`list`:: + + >>> config.spam.cheeses + ['camembert', 'cheddar', 'reblochon'] + + which comes from this configuration file:: + + [spam] + cheeses = + camembert + cheddar + reblochon + + .. versionchanged:: 7.0 + + The option's value will be split on newlines by default. In this + case, the ``strip`` parameter has no effect. + + See the :meth:`parse` method for more information. + + .. note:: + + **About:** backward compatibility with comma-separated values. + + A :class:`ListAttribute` option allows to write, on a single line, + the values separated by commas. As of Sopel 7.x this behavior is + discouraged. It will be deprecated in Sopel 8.x, then removed in + Sopel 9.x. + + Bot owners are encouraged to update their configurations to use + newlines instead of commas. + + The comma delimiter fallback does not support commas within items in + the list. + """ DELIMITER = ',' def __init__(self, name, strip=True, default=None): @@ -225,42 +259,48 @@ def __init__(self, name, strip=True, default=None): self.strip = strip def parse(self, value): - items = [] - is_escape_on = False - current_token = [] - for char in value: - if not is_escape_on: - if char == ListAttribute.ESCAPE_CHARACTER: - is_escape_on = True - elif char == ListAttribute.DELIMITER: - items.append(''.join(current_token)) - current_token = [] - else: - current_token.append(char) - else: - current_token.append(char) - is_escape_on = False - items.append(''.join(current_token)) + """Parse ``value`` into a list. + + :param str value: a multi-line string of values to parse into a list + :return: a list of items from ``value`` + :rtype: :class:`list` + + .. versionchanged:: 7.0 + + The value is now split on newlines, with fallback to comma + when there is no newline in ``value``. + + When modified and saved to a file, items will be stored as a + multi-line string. + """ + if "\n" in value: + items = [ + # remove trailing comma + # because `value,\nother` is valid in Sopel 7.x + item.strip(self.DELIMITER).strip() + 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: + if self.strip: # deprecate strip option in Sopel 8.x return [v.strip() for v in value] else: return value 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.') - items = [] - for item in value: - current_token = [] - for char in item: - if char in [ListAttribute.ESCAPE_CHARACTER, ListAttribute.DELIMITER]: - current_token.append(ListAttribute.ESCAPE_CHARACTER) - current_token.append(char) - items.append(''.join(current_token)) - return ','.join(items) + # 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) def configure(self, prompt, default, parent, section_name): each_prompt = '?' diff --git a/test/test_config.py b/test/test_config.py index 653d7ea4a0..c5c10907b0 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -2,12 +2,33 @@ from __future__ import unicode_literals, division, print_function, absolute_import import os -import tempfile -import unittest + +import pytest + from sopel import config from sopel.config import types +FAKE_CONFIG = """ +[core] +owner=dgw +homedir={homedir} +""" + + +MULTILINE_CONFIG = FAKE_CONFIG + """ +[spam] +eggs = one, two, three, four, and a half +bacons = grilled + burn out, + , greasy, fat, and tasty +cheeses = + cheddar + reblochon + camembert +""" # noqa (trailing whitespaces are intended) + + class FakeConfigSection(types.StaticSection): valattr = types.ValidatedAttribute('valattr') listattr = types.ListAttribute('listattr') @@ -18,131 +39,251 @@ class FakeConfigSection(types.StaticSection): rd_fileattr = types.FilenameAttribute('rd_fileattr', relative=True, directory=True) -class ConfigFunctionalTest(unittest.TestCase): - @classmethod - def read_config(cls): - configo = config.Config(cls.filename) - configo.define_section('fake', FakeConfigSection) - return configo +class SpamSection(types.StaticSection): + eggs = types.ListAttribute('eggs') + bacons = types.ListAttribute('bacons', strip=False) + cheeses = types.ListAttribute('cheeses') + + +@pytest.fixture +def tmphomedir(tmpdir): + sopel_homedir = tmpdir.join('.sopel') + sopel_homedir.mkdir() + sopel_homedir.join('test.tmp').write('') + sopel_homedir.join('test.d').mkdir() + return sopel_homedir + + +@pytest.fixture +def fakeconfig(tmphomedir): + conf_file = tmphomedir.join('conf.cfg') + conf_file.write(FAKE_CONFIG.format(homedir=tmphomedir.strpath)) + + test_settings = config.Config(conf_file.strpath) + test_settings.define_section('fake', FakeConfigSection) + return test_settings + + +@pytest.fixture +def multi_fakeconfig(tmphomedir): + conf_file = tmphomedir.join('conf.cfg') + conf_file.write(MULTILINE_CONFIG.format(homedir=tmphomedir.strpath)) + + test_settings = config.Config(conf_file.strpath) + test_settings.define_section('fake', FakeConfigSection) + test_settings.define_section('spam', SpamSection) + return test_settings + + +def test_validated_string_when_none(fakeconfig): + fakeconfig.fake.valattr = None + assert fakeconfig.fake.valattr is None + + +def test_listattribute_when_empty(fakeconfig): + fakeconfig.fake.listattr = [] + assert fakeconfig.fake.listattr == [] + + +def test_listattribute_with_one_value(fakeconfig): + fakeconfig.fake.listattr = ['foo'] + assert fakeconfig.fake.listattr == ['foo'] + + +def test_listattribute_with_multiple_values(fakeconfig): + fakeconfig.fake.listattr = ['egg', 'sausage', 'bacon'] + assert fakeconfig.fake.listattr == ['egg', 'sausage', 'bacon'] + + +def test_listattribute_with_value_containing_comma(fakeconfig): + fakeconfig.fake.listattr = ['spam, egg, sausage', 'bacon'] + assert fakeconfig.fake.listattr == ['spam, egg, sausage', 'bacon'] + + +def test_listattribute_with_value_containing_nonescape_backslash(fakeconfig): + fakeconfig.fake.listattr = ['spam', r'egg\sausage', 'bacon'] + assert fakeconfig.fake.listattr == ['spam', r'egg\sausage', 'bacon'] + + fakeconfig.fake.listattr = ['spam', r'egg\tacos', 'bacon'] + assert fakeconfig.fake.listattr == ['spam', r'egg\tacos', 'bacon'] + + +def test_listattribute_with_value_containing_standard_escape_sequence(fakeconfig): + fakeconfig.fake.listattr = ['spam', 'egg\tsausage', 'bacon'] + assert fakeconfig.fake.listattr == ['spam', 'egg\tsausage', 'bacon'] + + fakeconfig.fake.listattr = ['spam', 'egg\nsausage', 'bacon'] + assert fakeconfig.fake.listattr == [ + 'spam', 'egg', 'sausage', 'bacon' + ], 'Line break are always converted to new item' - @classmethod - def setUpClass(cls): - cls.filename = tempfile.mkstemp()[1] - with open(cls.filename, 'w') as fileo: - fileo.write( - "[core]\n" - "owner=dgw\n" - "homedir={}".format(os.path.expanduser('~/.sopel')) - ) + fakeconfig.fake.listattr = ['spam', 'egg\\sausage', 'bacon'] + assert fakeconfig.fake.listattr == ['spam', 'egg\\sausage', 'bacon'] - cls.config = cls.read_config() - cls.testfile = open(os.path.expanduser('~/.sopel/test.tmp'), 'w+').name - cls.testdir = os.path.expanduser('~/.sopel/test.d/') - os.mkdir(cls.testdir) +def test_listattribute_with_value_ending_in_special_chars(fakeconfig): + fakeconfig.fake.listattr = ['spam', 'egg', 'sausage\\', 'bacon'] + assert fakeconfig.fake.listattr == ['spam', 'egg', 'sausage\\', 'bacon'] - @classmethod - def tearDownClass(cls): - os.remove(cls.filename) - os.remove(cls.testfile) - os.rmdir(cls.testdir) + fakeconfig.fake.listattr = ['spam', 'egg', 'sausage,', 'bacon'] + assert fakeconfig.fake.listattr == ['spam', 'egg', 'sausage', 'bacon'] - def test_validated_string_when_none(self): - self.config.fake.valattr = None - self.assertEqual(self.config.fake.valattr, None) + fakeconfig.fake.listattr = ['spam', 'egg', 'sausage,,', 'bacon'] + assert fakeconfig.fake.listattr == ['spam', 'egg', 'sausage', 'bacon'] - def test_listattribute_when_empty(self): - self.config.fake.listattr = [] - self.assertEqual(self.config.fake.listattr, []) - def test_listattribute_with_one_value(self): - self.config.fake.listattr = ['foo'] - self.assertEqual(self.config.fake.listattr, ['foo']) +def test_listattribute_with_value_containing_adjacent_special_chars(fakeconfig): + fakeconfig.fake.listattr = ['spam', r'egg\,sausage', 'bacon'] + assert fakeconfig.fake.listattr == ['spam', r'egg\,sausage', 'bacon'] - def test_listattribute_with_multiple_values(self): - self.config.fake.listattr = ['egg', 'sausage', 'bacon'] - self.assertEqual(self.config.fake.listattr, ['egg', 'sausage', 'bacon']) + fakeconfig.fake.listattr = ['spam', r'egg\,\sausage', 'bacon'] + assert fakeconfig.fake.listattr == ['spam', r'egg\,\sausage', 'bacon'] - def test_listattribute_with_value_containing_comma(self): - self.config.fake.listattr = ['spam, egg, sausage', 'bacon'] - self.assertEqual(self.config.fake.listattr, ['spam, egg, sausage', 'bacon']) + fakeconfig.fake.listattr = ['spam', r'egg,\,sausage', 'bacon'] + assert fakeconfig.fake.listattr == ['spam', r'egg,\,sausage', 'bacon'] - def test_listattribute_with_value_containing_nonescape_backslash(self): - self.config.fake.listattr = ['spam', r'egg\sausage', 'bacon'] - self.assertEqual(self.config.fake.listattr, ['spam', r'egg\sausage', 'bacon']) + fakeconfig.fake.listattr = ['spam', 'egg,,sausage', 'bacon'] + assert fakeconfig.fake.listattr == ['spam', 'egg,,sausage', 'bacon'] - self.config.fake.listattr = ['spam', r'egg\tacos', 'bacon'] - self.assertEqual(self.config.fake.listattr, ['spam', r'egg\tacos', 'bacon']) + fakeconfig.fake.listattr = ['spam', r'egg\\sausage', 'bacon'] + assert fakeconfig.fake.listattr == ['spam', r'egg\\sausage', 'bacon'] - def test_listattribute_with_value_containing_standard_escape_sequence(self): - self.config.fake.listattr = ['spam', 'egg\tsausage', 'bacon'] - self.assertEqual(self.config.fake.listattr, ['spam', 'egg\tsausage', 'bacon']) - self.config.fake.listattr = ['spam', 'egg\nsausage', 'bacon'] - self.assertEqual(self.config.fake.listattr, ['spam', 'egg\nsausage', 'bacon']) +def test_choiceattribute_when_none(fakeconfig): + fakeconfig.fake.choiceattr = None + assert fakeconfig.fake.choiceattr is None - self.config.fake.listattr = ['spam', 'egg\\sausage', 'bacon'] - self.assertEqual(self.config.fake.listattr, ['spam', 'egg\\sausage', 'bacon']) - def test_listattribute_with_value_ending_in_special_chars(self): - self.config.fake.listattr = ['spam', 'egg', 'sausage\\', 'bacon'] - self.assertEqual(self.config.fake.listattr, ['spam', 'egg', 'sausage\\', 'bacon']) +def test_choiceattribute_when_not_in_set(fakeconfig): + with pytest.raises(ValueError): + fakeconfig.fake.choiceattr = 'sausage' - self.config.fake.listattr = ['spam', 'egg', 'sausage,', 'bacon'] - self.assertEqual(self.config.fake.listattr, ['spam', 'egg', 'sausage,', 'bacon']) - self.config.fake.listattr = ['spam', 'egg', 'sausage,,', 'bacon'] - self.assertEqual(self.config.fake.listattr, ['spam', 'egg', 'sausage,,', 'bacon']) +def test_choiceattribute_when_valid(fakeconfig): + fakeconfig.fake.choiceattr = 'bacon' + assert fakeconfig.fake.choiceattr == 'bacon' - def test_listattribute_with_value_containing_adjacent_special_chars(self): - self.config.fake.listattr = ['spam', r'egg\,sausage', 'bacon'] - self.assertEqual(self.config.fake.listattr, ['spam', r'egg\,sausage', 'bacon']) - self.config.fake.listattr = ['spam', r'egg\,\sausage', 'bacon'] - self.assertEqual(self.config.fake.listattr, ['spam', r'egg\,\sausage', 'bacon']) +def test_fileattribute_valid_absolute_file_path(fakeconfig): + testfile = os.path.join(fakeconfig.core.homedir, 'test.tmp') + fakeconfig.fake.af_fileattr = testfile + assert fakeconfig.fake.af_fileattr == testfile - self.config.fake.listattr = ['spam', r'egg,\,sausage', 'bacon'] - self.assertEqual(self.config.fake.listattr, ['spam', r'egg,\,sausage', 'bacon']) - self.config.fake.listattr = ['spam', 'egg,,sausage', 'bacon'] - self.assertEqual(self.config.fake.listattr, ['spam', 'egg,,sausage', 'bacon']) +def test_fileattribute_valid_absolute_dir_path(fakeconfig): + testdir = os.path.join(fakeconfig.core.homedir, 'test.d') + fakeconfig.fake.ad_fileattr = testdir + assert fakeconfig.fake.ad_fileattr == testdir - self.config.fake.listattr = ['spam', r'egg\\sausage', 'bacon'] - self.assertEqual(self.config.fake.listattr, ['spam', r'egg\\sausage', 'bacon']) - def test_choiceattribute_when_none(self): - self.config.fake.choiceattr = None - self.assertEqual(self.config.fake.choiceattr, None) +def test_fileattribute_given_relative_when_absolute(fakeconfig): + with pytest.raises(ValueError): + fakeconfig.fake.af_fileattr = '../testconfig.tmp' - def test_choiceattribute_when_not_in_set(self): - with self.assertRaises(ValueError): - self.config.fake.choiceattr = 'sausage' - def test_choiceattribute_when_valid(self): - self.config.fake.choiceattr = 'bacon' - self.assertEqual(self.config.fake.choiceattr, 'bacon') +def test_fileattribute_given_absolute_when_relative(fakeconfig): + testfile = os.path.join(fakeconfig.core.homedir, 'test.tmp') + fakeconfig.fake.rf_fileattr = testfile + assert fakeconfig.fake.rf_fileattr == testfile - def test_fileattribute_valid_absolute_file_path(self): - self.config.fake.af_fileattr = self.testfile - self.assertEqual(self.config.fake.af_fileattr, self.testfile) - def test_fileattribute_valid_absolute_dir_path(self): - testdir = self.testdir - self.config.fake.ad_fileattr = testdir - self.assertEqual(self.config.fake.ad_fileattr, testdir) +def test_fileattribute_given_dir_when_file(fakeconfig): + testdir = os.path.join(fakeconfig.core.homedir, 'test.d') + with pytest.raises(ValueError): + fakeconfig.fake.af_fileattr = testdir - def test_fileattribute_given_relative_when_absolute(self): - with self.assertRaises(ValueError): - self.config.fake.af_fileattr = '../testconfig.tmp' - def test_fileattribute_given_absolute_when_relative(self): - self.config.fake.rf_fileattr = self.testfile - self.assertEqual(self.config.fake.rf_fileattr, self.testfile) +def test_fileattribute_given_file_when_dir(fakeconfig): + testfile = os.path.join(fakeconfig.core.homedir, 'test.tmp') + with pytest.raises(ValueError): + fakeconfig.fake.ad_fileattr = testfile - def test_fileattribute_given_dir_when_file(self): - with self.assertRaises(ValueError): - self.config.fake.af_fileattr = self.testdir - def test_fileattribute_given_file_when_dir(self): - with self.assertRaises(ValueError): - self.config.fake.ad_fileattr = self.testfile +def test_configparser_multi_lines(multi_fakeconfig): + # spam + assert multi_fakeconfig.spam.eggs == [ + 'one', + 'two', + 'three', + 'four', + 'and a half', # no-newline + comma + ], 'Comma separated line: "four" and "and a half" must be separated' + assert multi_fakeconfig.spam.bacons == [ + 'grilled', + 'burn out', + 'greasy, fat, and tasty', + ] + assert multi_fakeconfig.spam.cheeses == [ + 'cheddar', + 'reblochon', + 'camembert', + ] + + +def test_save_unmodified_config(multi_fakeconfig): + """Assert type attributes are kept as they should be""" + multi_fakeconfig.save() + saved_config = config.Config(multi_fakeconfig.filename) + saved_config.define_section('fake', FakeConfigSection) + saved_config.define_section('spam', SpamSection) + + # core + assert saved_config.core.owner == 'dgw' + + # fake + assert saved_config.fake.valattr is None + assert saved_config.fake.listattr == [] + assert saved_config.fake.choiceattr is None + assert saved_config.fake.af_fileattr is None + assert saved_config.fake.ad_fileattr is None + assert saved_config.fake.rf_fileattr is None + assert saved_config.fake.rd_fileattr is None + + # spam + assert saved_config.spam.eggs == [ + 'one', + 'two', + 'three', + 'four', + 'and a half', # no-newline + comma + ], 'Comma separated line: "four" and "and a half" must be separated' + assert saved_config.spam.bacons == [ + 'grilled', + 'burn out', + 'greasy, fat, and tasty', + ] + assert saved_config.spam.cheeses == [ + 'cheddar', + 'reblochon', + 'camembert', + ] + + +def test_save_modified_config(multi_fakeconfig): + """Assert modified values are restored properly""" + multi_fakeconfig.fake.choiceattr = 'spam' + multi_fakeconfig.spam.eggs = [ + 'one', + 'two', + ] + multi_fakeconfig.spam.cheeses = [ + 'camembert, reblochon, and cheddar', + ] + + multi_fakeconfig.save() + + with open(multi_fakeconfig.filename) as fd: + print(fd.read()) # used for debug purpose if an assert fails + + saved_config = config.Config(multi_fakeconfig.filename) + saved_config.define_section('fake', FakeConfigSection) + saved_config.define_section('spam', SpamSection) + + assert saved_config.fake.choiceattr == 'spam' + assert saved_config.spam.eggs == ['one', 'two'] + assert saved_config.spam.cheeses == [ + 'camembert, reblochon, and cheddar', + ], ( + 'ListAttribute with one line only, with commas, must *not* be split ' + 'differently from what was expected, i.e. into one (and only one) value' + )