sopel.config.types   F
last analyzed

Complexity

Total Complexity 87

Size/Duplication

Total Lines 431
Duplicated Lines 9.51 %

Importance

Changes 0
Metric Value
wmc 87
eloc 222
dl 41
loc 431
rs 2
c 0
b 0
f 0

26 Methods

Rating   Name   Duplication   Size   Complexity  
A BaseValidated.configure() 10 10 5
A BaseValidated.parse() 0 5 1
B StaticSection.__init__() 0 19 6
B StaticSection.configure_setting() 0 29 8
A BaseValidated.__init__() 0 8 1
A BaseValidated.serialize() 0 6 1
A BaseValidated.__get__() 10 22 5
A ValidatedAttribute.serialize() 0 2 1
A ValidatedAttribute.parse() 0 2 1
A ValidatedAttribute.configure() 0 5 3
A BaseValidated.__delete__() 0 2 1
A BaseValidated.__set__() 0 8 3
A ValidatedAttribute.__init__() 0 16 4
A FilenameAttribute.__init__() 0 10 1
A ChoiceAttribute.__init__() 0 3 1
A ChoiceAttribute.serialize() 0 5 2
A FilenameAttribute.__get__() 11 20 5
A FilenameAttribute.configure() 10 10 5
A ChoiceAttribute.parse() 0 5 2
A ListAttribute.serialize() 0 9 2
A FilenameAttribute.serialize() 0 3 1
C FilenameAttribute.parse() 0 23 10
A FilenameAttribute.__set__() 0 5 1
A ListAttribute.__init__() 0 4 1
A ListAttribute.parse() 0 33 3
B ListAttribute.configure() 0 23 7

2 Functions

Rating   Name   Duplication   Size   Complexity  
A _serialize_boolean() 0 2 2
A _parse_boolean() 0 6 4

How to fix   Duplicated Code    Complexity   

Duplicated Code

Duplicate code is one of the most pungent code smells. A rule that is often used is to re-structure code once it is duplicated in three or more places.

Common duplication problems, and corresponding solutions are:

Complexity

 Tip:   Before tackling complexity, make sure that you eliminate any duplication first. This often can reduce the size of classes significantly.

Complex classes like sopel.config.types often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

1
# coding=utf-8
2
"""Types for creating section definitions.
3
4
A section definition consists of a subclass of :class:`StaticSection`, on which
5
any number of subclasses of :class:`BaseValidated` (a few common ones of which
6
are available in this module) are assigned as attributes. These descriptors
7
define how to read values from, and write values to, the config file.
8
9
As an example, if one wanted to define the ``[spam]`` section as having an
10
``eggs`` option, which contains a list of values, they could do this:
11
12
    >>> class SpamSection(StaticSection):
13
    ...     eggs = ListAttribute('eggs')
14
    ...
15
    >>> SpamSection(config, 'spam')
16
    >>> print(config.spam.eggs)
17
    []
18
    >>> config.spam.eggs = ['goose', 'turkey', 'duck', 'chicken', 'quail']
19
    >>> print(config.spam.eggs)
20
    ['goose', 'turkey', 'duck', 'chicken', 'quail']
21
    >>> config.spam.eggs = 'herring'
22
    Traceback (most recent call last):
23
        ...
24
    ValueError: ListAttribute value must be a list.
25
"""
26
27
from __future__ import unicode_literals, absolute_import, print_function, division
28
29
import os.path
30
import sys
31
from sopel.tools import get_input
32
33
if sys.version_info.major >= 3:
34
    unicode = str
35
    basestring = (str, bytes)
36
37
38
class NO_DEFAULT(object):
39
    """A special value to indicate that there should be no default."""
40
41
42
class StaticSection(object):
43
    """A configuration section with parsed and validated settings.
44
45
    This class is intended to be subclassed with added ``ValidatedAttribute``\\s.
46
    """
47
    def __init__(self, config, section_name, validate=True):
48
        if not config.parser.has_section(section_name):
49
            config.parser.add_section(section_name)
50
        self._parent = config
51
        self._parser = config.parser
52
        self._section_name = section_name
53
        for value in dir(self):
54
            try:
55
                getattr(self, value)
56
            except ValueError as e:
57
                raise ValueError(
58
                    'Invalid value for {}.{}: {}'.format(section_name, value,
59
                                                         str(e))
60
                )
61
            except AttributeError:
62
                if validate:
63
                    raise ValueError(
64
                        'Missing required value for {}.{}'.format(section_name,
65
                                                                  value)
66
                    )
67
68
    def configure_setting(self, name, prompt, default=NO_DEFAULT):
69
        """Return a validated value for this attribute from the terminal.
70
71
        ``prompt`` will be the docstring of the attribute if not given.
72
73
        If ``default`` is passed, it will be used if no value is given by the
74
        user. If it is not passed, the current value of the setting, or the
75
        default value if it's unset, will be used. Note that if ``default`` is
76
        passed, the current value of the setting will be ignored, even if it is
77
        not the attribute's default.
78
        """
79
        clazz = getattr(self.__class__, name)
80
        if default is NO_DEFAULT:
81
            try:
82
                default = getattr(self, name)
83
            except AttributeError:
84
                pass
85
            except ValueError:
86
                print('The configured value for this option was invalid.')
87
                if clazz.default is not NO_DEFAULT:
88
                    default = clazz.default
89
        while True:
90
            try:
91
                value = clazz.configure(prompt, default, self._parent, self._section_name)
92
            except ValueError as exc:
93
                print(exc)
94
            else:
95
                break
96
        setattr(self, name, value)
97
98
99
class BaseValidated(object):
100
    """The base type for a descriptor in a ``StaticSection``."""
101
    def __init__(self, name, default=None):
102
        """
103
        ``name`` is the name of the setting in the section.
104
        ``default`` is the value to be returned if the setting is not set. If
105
        not given, AttributeError will be raised instead.
106
        """
107
        self.name = name
108
        self.default = default
109
110 View Code Duplication
    def configure(self, prompt, default, parent, section_name):
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated in your project.
Loading history...
111
        """With the prompt and default, parse and return a value from terminal.
112
        """
113
        if default is not NO_DEFAULT and default is not None:
114
            prompt = '{} [{}]'.format(prompt, default)
115
        value = get_input(prompt + ' ')
116
        if not value and default is NO_DEFAULT:
117
            raise ValueError("You must provide a value for this option.")
118
        value = value or default
119
        return self.parse(value)
120
121
    def serialize(self, value):
122
        """Take some object, and return the string to be saved to the file.
123
124
        Must be implemented in subclasses.
125
        """
126
        raise NotImplementedError("Serialize method must be implemented in subclass")
127
128
    def parse(self, value):
129
        """Take a string from the file, and return the appropriate object.
130
131
        Must be implemented in subclasses."""
132
        raise NotImplementedError("Parse method must be implemented in subclass")
133
134
    def __get__(self, instance, owner=None):
135
        if instance is None:
136
            # If instance is None, we're getting from a section class, not an
137
            # instance of a session class. It makes the wizard code simpler
138
            # (and is really just more intuitive) to return the descriptor
139
            # instance here.
140
            return self
141
142
        env_name = 'SOPEL_%s_%s' % (instance._section_name.upper(), self.name.upper())
143 View Code Duplication
        if env_name in os.environ:
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated in your project.
Loading history...
144
            value = os.environ.get(env_name)
145
        elif instance._parser.has_option(instance._section_name, self.name):
146
            value = instance._parser.get(instance._section_name, self.name)
147
        else:
148
            if self.default is not NO_DEFAULT:
149
                return self.default
150
            raise AttributeError(
151
                "Missing required value for {}.{}".format(
152
                    instance._section_name, self.name
153
                )
154
            )
155
        return self.parse(value)
156
157
    def __set__(self, instance, value):
158
        if value is None:
159
            if self.default == NO_DEFAULT:
160
                raise ValueError('Cannot unset an option with a required value.')
161
            instance._parser.remove_option(instance._section_name, self.name)
162
            return
163
        value = self.serialize(value)
164
        instance._parser.set(instance._section_name, self.name, value)
165
166
    def __delete__(self, instance):
167
        instance._parser.remove_option(instance._section_name, self.name)
168
169
170
def _parse_boolean(value):
171
    if value is True or value == 1:
172
        return value
173
    if isinstance(value, basestring):
0 ignored issues
show
introduced by
The variable basestring does not seem to be defined in case sys.version_info.major >= 3 on line 33 is False. Are you sure this can never be the case?
Loading history...
174
        return value.lower() in ['1', 'yes', 'y', 'true', 'on']
175
    return bool(value)
176
177
178
def _serialize_boolean(value):
179
    return 'true' if _parse_boolean(value) else 'false'
180
181
182
class ValidatedAttribute(BaseValidated):
183
    def __init__(self, name, parse=None, serialize=None, default=None):
184
        """A descriptor for settings in a ``StaticSection``
185
186
        ``parse`` is the function to be used to read the string and create the
187
        appropriate object. If not given, return the string as-is.
188
        ``serialize`` takes an object, and returns the value to be written to
189
        the file. If not given, defaults to ``unicode``.
190
        """
191
        self.name = name
192
        if parse == bool:
193
            parse = _parse_boolean
194
            if not serialize or serialize == bool:
195
                serialize = _serialize_boolean
196
        self.parse = parse or self.parse
197
        self.serialize = serialize or self.serialize
198
        self.default = default
199
200
    def serialize(self, value):
201
        return unicode(value)
0 ignored issues
show
introduced by
The variable unicode does not seem to be defined in case sys.version_info.major >= 3 on line 33 is False. Are you sure this can never be the case?
Loading history...
202
203
    def parse(self, value):
204
        return value
205
206
    def configure(self, prompt, default, parent, section_name):
207
        if self.parse == _parse_boolean:
208
            prompt += ' (y/n)'
209
            default = 'y' if default else 'n'
210
        return super(ValidatedAttribute, self).configure(prompt, default, parent, section_name)
211
212
213
class ListAttribute(BaseValidated):
214
    """A config attribute containing a list of string values.
215
216
    From this :class:`StaticSection`::
217
218
        class SpamSection(StaticSection):
219
            cheeses = ListAttribute('cheeses')
220
221
    the option will be exposed as a Python :class:`list`::
222
223
        >>> config.spam.cheeses
224
        ['camembert', 'cheddar', 'reblochon']
225
226
    which comes from this configuration file::
227
228
        [spam]
229
        cheeses =
230
            camembert
231
            cheddar
232
            reblochon
233
234
    .. versionchanged:: 7.0
235
236
        The option's value will be split on newlines by default. In this
237
        case, the ``strip`` parameter has no effect.
238
239
        See the :meth:`parse` method for more information.
240
241
    .. note::
242
243
        **About:** backward compatibility with comma-separated values.
244
245
        A :class:`ListAttribute` option allows to write, on a single line,
246
        the values separated by commas. As of Sopel 7.x this behavior is
247
        discouraged. It will be deprecated in Sopel 8.x, then removed in
248
        Sopel 9.x.
249
250
        Bot owners are encouraged to update their configurations to use
251
        newlines instead of commas.
252
253
        The comma delimiter fallback does not support commas within items in
254
        the list.
255
    """
256
    DELIMITER = ','
257
258
    def __init__(self, name, strip=True, default=None):
259
        default = default or []
260
        super(ListAttribute, self).__init__(name, default=default)
261
        self.strip = strip
262
263
    def parse(self, value):
264
        """Parse ``value`` into a list.
265
266
        :param str value: a multi-line string of values to parse into a list
267
        :return: a list of items from ``value``
268
        :rtype: :class:`list`
269
270
        .. versionchanged:: 7.0
271
272
            The value is now split on newlines, with fallback to comma
273
            when there is no newline in ``value``.
274
275
            When modified and saved to a file, items will be stored as a
276
            multi-line string.
277
        """
278
        if "\n" in value:
279
            items = [
280
                # remove trailing comma
281
                # because `value,\nother` is valid in Sopel 7.x
282
                item.strip(self.DELIMITER).strip()
283
                for item in value.splitlines()]
284
        else:
285
            # this behavior will be:
286
            # - Discouraged in Sopel 7.x (in the documentation)
287
            # - Deprecated in Sopel 8.x
288
            # - Removed from Sopel 9.x
289
            items = value.split(self.DELIMITER)
290
291
        value = list(filter(None, items))
292
        if self.strip:  # deprecate strip option in Sopel 8.x
293
            return [v.strip() for v in value]
294
        else:
295
            return value
296
297
    def serialize(self, value):
298
        """Serialize ``value`` into a multi-line string."""
299
        if not isinstance(value, (list, set)):
300
            raise ValueError('ListAttribute value must be a list.')
301
302
        # we ensure to read a newline, even with only one value in the list
303
        # this way, comma will be ignored when the configuration file
304
        # is read again later
305
        return '\n' + '\n'.join(value)
306
307
    def configure(self, prompt, default, parent, section_name):
308
        each_prompt = '?'
309
        if isinstance(prompt, tuple):
310
            each_prompt = prompt[1]
311
            prompt = prompt[0]
312
313
        if default is not NO_DEFAULT:
314
            default_prompt = ','.join(['"{}"'.format(item) for item in default])
315
            prompt = '{} [{}]'.format(prompt, default_prompt)
316
        else:
317
            default = []
318
        print(prompt)
319
        values = []
320
        value = get_input(each_prompt + ' ') or default
321
        if (value == default) and not default:
322
            value = ''
323
        while value:
324
            if value == default:
325
                values.extend(value)
326
            else:
327
                values.append(value)
328
            value = get_input(each_prompt + ' ')
329
        return self.parse(self.serialize(values))
330
331
332
class ChoiceAttribute(BaseValidated):
333
    """A config attribute which must be one of a set group of options.
334
335
    Currently, the choices can only be strings."""
336
    def __init__(self, name, choices, default=None):
337
        super(ChoiceAttribute, self).__init__(name, default=default)
338
        self.choices = choices
339
340
    def parse(self, value):
341
        if value in self.choices:
342
            return value
343
        else:
344
            raise ValueError('Value must be in {}'.format(self.choices))
345
346
    def serialize(self, value):
347
        if value in self.choices:
348
            return value
349
        else:
350
            raise ValueError('Value must be in {}'.format(self.choices))
351
352
353
class FilenameAttribute(BaseValidated):
354
    """A config attribute which must be a file or directory."""
355
    def __init__(self, name, relative=True, directory=False, default=None):
356
        """
357
        ``relative`` is whether the path should be relative to the location
358
        of the config file (absolute paths will still be absolute). If
359
        ``directory`` is True, the path must indicate a directory, rather than
360
        a file.
361
        """
362
        super(FilenameAttribute, self).__init__(name, default=default)
363
        self.relative = relative
364
        self.directory = directory
365
366
    def __get__(self, instance, owner=None):
367
        if instance is None:
368
            return self
369
        env_name = 'SOPEL_%s_%s' % (instance._section_name.upper(), self.name.upper())
370 View Code Duplication
        if env_name in os.environ:
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated in your project.
Loading history...
371
            value = os.environ.get(env_name)
372
        elif instance._parser.has_option(instance._section_name, self.name):
373
            value = instance._parser.get(instance._section_name, self.name)
374
        else:
375
            if self.default is not NO_DEFAULT:
376
                value = self.default
377
            else:
378
                raise AttributeError(
379
                    "Missing required value for {}.{}".format(
380
                        instance._section_name, self.name
381
                    )
382
                )
383
        main_config = instance._parent
384
        this_section = getattr(main_config, instance._section_name)
385
        return self.parse(main_config, this_section, value)
386
387
    def __set__(self, instance, value):
388
        main_config = instance._parent
389
        this_section = getattr(main_config, instance._section_name)
390
        value = self.serialize(main_config, this_section, value)
391
        instance._parser.set(instance._section_name, self.name, value)
392
393 View Code Duplication
    def configure(self, prompt, default, parent, section_name):
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated in your project.
Loading history...
394
        """With the prompt and default, parse and return a value from terminal.
395
        """
396
        if default is not NO_DEFAULT and default is not None:
397
            prompt = '{} [{}]'.format(prompt, default)
398
        value = get_input(prompt + ' ')
399
        if not value and default is NO_DEFAULT:
400
            raise ValueError("You must provide a value for this option.")
401
        value = value or default
402
        return self.parse(parent, section_name, value)
403
404
    def parse(self, main_config, this_section, value):
405
        if value is None:
406
            return
407
408
        value = os.path.expanduser(value)
409
410
        if not os.path.isabs(value):
411
            if not self.relative:
412
                raise ValueError("Value must be an absolute path.")
413
            value = os.path.join(main_config.homedir, value)
414
415
        if self.directory and not os.path.isdir(value):
416
            try:
417
                os.makedirs(value)
418
            except (IOError, OSError):
419
                raise ValueError(
420
                    "Value must be an existing or creatable directory.")
421
        if not self.directory and not os.path.isfile(value):
422
            try:
423
                open(value, 'w').close()
424
            except (IOError, OSError):
425
                raise ValueError("Value must be an existing or creatable file.")
426
        return value
427
428
    def serialize(self, main_config, this_section, value):
429
        self.parse(main_config, this_section, value)
430
        return value  # So that it's still relative
431