config.py 20.1 KB
Newer Older
1
2
"""
Defines the global config instance, used to get or set (and save) values
3
4
5
6
7
from/to the config file.

This module has the particularity that some imports and global variables
are delayed because it would mean doing an incomplete setup of the python
loggers.
8
9
10

TODO: get http://bugs.python.org/issue1410680 fixed, one day, in order
to remove our ugly custom I/O methods.
11
12
"""

13
14
DEFSECTION = "Poezio"

15
import logging.config
mathieui's avatar
mathieui committed
16
17
import os
import sys
mathieui's avatar
mathieui committed
18
from gettext import gettext as _
mathieui's avatar
mathieui committed
19

20
from configparser import RawConfigParser, NoOptionError, NoSectionError
21
from os import environ, makedirs, path, remove
22
from shutil import copy2
23
from args import parse_args
24

25
26
27
28
29
30
DEFAULT_CONFIG = {
    'Poezio': {
        'ack_message_receipts': True,
        'add_space_after_completion': True,
        'after_completion': ',',
        'alternative_nickname': '',
31
        'auto_reconnect': True,
32
33
34
35
36
        'autorejoin_delay': '5',
        'autorejoin': False,
        'beep_on': 'highlight private invite',
        'ca_cert_path': '',
        'certificate': '',
mathieui's avatar
mathieui committed
37
        'certfile': '',
38
39
40
41
42
43
44
        'ciphers': 'HIGH+kEDH:HIGH+kEECDH:HIGH:!PSK:!SRP:!3DES:!aNULL',
        'connection_check_interval': 60,
        'connection_timeout_delay': 10,
        'create_gaps': False,
        'custom_host': '',
        'custom_port': '',
        'default_nick': '',
45
        'deterministic_nick_colors': True,
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
        'display_activity_notifications': False,
        'display_gaming_notifications': False,
        'display_mood_notifications': False,
        'display_tune_notifications': False,
        'display_user_color_in_join_part': True,
        'enable_carbons': False,
        'enable_user_activity': True,
        'enable_user_gaming': True,
        'enable_user_mood': True,
        'enable_user_nick': True,
        'enable_user_tune': True,
        'enable_vertical_tab_list': False,
        'enable_xhtml_im': True,
        'exec_remote': False,
        'extract_inline_images': True,
        'filter_info_messages': '',
        'force_encryption': True,
63
        'go_to_previous_tab_on_alt_number': False,
64
65
66
67
68
69
70
71
72
        'group_corrections': True,
        'hide_exit_join': -1,
        'hide_status_change': 120,
        'hide_user_list': False,
        'highlight_on': '',
        'ignore_certificate': False,
        'ignore_private': False,
        'information_buffer_popup_on': 'error roster warning help info',
        'jid': '',
mathieui's avatar
mathieui committed
73
        'keyfile': '',
74
75
76
77
78
79
80
81
82
83
        'lang': 'en',
        'lazy_resize': True,
        'load_log': 10,
        'log_dir': '',
        'logfile': 'logs',
        'log_errors': True,
        'max_lines_in_memory': 2048,
        'max_messages_in_memory': 2048,
        'max_nick_length': 25,
        'muc_history_length': 50,
84
        'notify_messages': True,
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
        'open_all_bookmarks': False,
        'password': '',
        'plugins_autoload': '',
        'plugins_conf_dir': '',
        'plugins_dir': '',
        'popup_time': 4,
        'private_auto_response': '',
        'remote_fifo_path': './',
        'request_message_receipts': True,
        'resource': '',
        'rooms': '',
        'roster_group_sort': 'name',
        'roster_show_offline': False,
        'roster_sort': 'jid:show',
        'save_status': True,
        'send_chat_states': True,
        'send_initial_presence': True,
        'send_os_info': True,
        'send_poezio_info': True,
        'send_time': True,
        'separate_history': False,
        'server': 'anon.jeproteste.info',
        'show_composing_tabs': 'direct',
        'show_inactive_tabs': True,
        'show_muc_jid': True,
        'show_roster_jids': True,
        'show_roster_subscriptions': '',
        'show_s2s_errors': True,
        'show_tab_names': False,
        'show_tab_numbers': True,
        'show_timestamps': True,
        'show_useless_separator': False,
        'status': '',
        'status_message': '',
        'theme': 'default',
        'themes_dir': '',
        'tmp_image_dir': '',
        'use_bookmarks_method': '',
        'use_log': False,
        'use_remote_bookmarks': True,
        'user_list_sort': 'desc',
        'use_tab_nicks': True,
        'vertical_tab_list_size': 20,
        'vertical_tab_list_sort': 'desc',
        'whitespace_interval': 300,
        'words': ''
    },
    'bindings': {
        'M-i': '^I'
    },
    'var': {
        'folded_roster_groups': '',
        'info_win_height': 2
138
139
    },
    'muc_colors': {
140
141
142
    }
}

143
144
145
146
class Config(RawConfigParser):
    """
    load/save the config to a file
    """
147
    def __init__(self, file_name, default=None):
148
        RawConfigParser.__init__(self, None)
149
150
        # make the options case sensitive
        self.optionxform = str
151
        self.file_name = file_name
152
        self.read_file()
153
        self.default = default
154
155

    def read_file(self):
mathieui's avatar
mathieui committed
156
        try:
157
            RawConfigParser.read(self, self.file_name, encoding='utf-8')
mathieui's avatar
mathieui committed
158
        except TypeError: # python < 3.2 sucks
159
            RawConfigParser.read(self, self.file_name)
160
        # Check config integrity and fix it if it’s wrong
161
162
163
164
165
        # only when the object is the main config
        if self.__class__ is Config:
            for section in ('bindings', 'var'):
                if not self.has_section(section):
                    self.add_section(section)
166

167
    def get(self, option, default=None, section=DEFSECTION):
168
169
170
171
172
173
        """
        get a value from the config but return
        a default value if it is not found
        The type of default defines the type
        returned
        """
174
175
176
177
178
179
        if default is None:
            if self.default:
                default = self.default.get(section, {}).get(option)
            else:
                default = ''

180
        try:
181
            if type(default) == int:
182
                res = self.getint(option, section)
183
            elif type(default) == float:
184
                res = self.getfloat(option, section)
185
            elif type(default) == bool:
186
                res = self.getboolean(option, section)
187
            else:
188
                res = self.getstr(option, section)
189
        except (NoOptionError, NoSectionError, ValueError, AttributeError):
190
            return default
191

192
        if res is None:
193
194
195
            return default
        return res

196
197
    def get_by_tabname(self, option, tabname,
                       fallback=True, fallback_server=True, default=''):
198
199
200
201
202
203
        """
        Try to get the value for the option. First we look in
        a section named `tabname`, if the option is not present
        in the section, we search for the global option if fallback is
        True. And we return `default` as a fallback as a last resort.
        """
204
205
        if self.default and (not default) and fallback:
            default = self.default.get(DEFSECTION, {}).get(option, '')
206
207
208
209
        if tabname in self.sections():
            if option in self.options(tabname):
                # We go the tab-specific option
                return self.get(option, default, tabname)
210
211
        if fallback_server:
            return self.get_by_servname(tabname, option, default, fallback)
212
213
214
215
216
        if fallback:
            # We fallback to the global option
            return self.get(option, default)
        return default

217
218
219
220
221
222
223
224
225
226
227
228
229
230
    def get_by_servname(self, jid, option, default, fallback=True):
        """
        Try to get the value of an option for a server
        """
        server = safeJID(jid).server
        if server:
            server = '@' + server
            if server in self.sections() and option in self.options(server):
                return self.get(option, default, server)
        if fallback:
            return self.get(option, default)
        return default


231
    def __get(self, option, section=DEFSECTION, **kwargs):
232
233
234
        """
        facility for RawConfigParser.get
        """
235
236
237
238
239
240
241
        return RawConfigParser.get(self, section, option, **kwargs)

    def _get(self, section, conv, option, **kwargs):
        """
        Redirects RawConfigParser._get
        """
        return conv(self.__get(option, section, **kwargs))
242

243
    def getstr(self, option, section=DEFSECTION):
244
245
246
        """
        get a value and returns it as a string
        """
247
        return self.__get(option, section)
248

249
    def getint(self, option, section=DEFSECTION):
250
251
252
        """
        get a value and returns it as an int
        """
253
        return RawConfigParser.getint(self, section, option)
254

255
    def getfloat(self, option, section=DEFSECTION):
256
257
258
        """
        get a value and returns it as a float
        """
259
        return RawConfigParser.getfloat(self, section, option)
260

261
    def getboolean(self, option, section=DEFSECTION):
262
263
264
        """
        get a value and returns it as a boolean
        """
265
        return RawConfigParser.getboolean(self, section, option)
266

267
268
269
270
271
272
    def write_in_file(self, section, option, value):
        """
        Our own way to save write the value in the file
        Just find the right section, and then find the
        right option, and edit it.
        """
273
274
275
        result = self._parse_file()
        if not result:
            return False
276
        else:
277
            sections, result_lines = result
278

279
        if not section in sections:
280
            result_lines.append('[%s]' % section)
mathieui's avatar
mathieui committed
281
            result_lines.append('%s = %s' % (option, value))
282
283
        else:
            begin, end = sections[section]
284
285
286
            pos = find_line(result_lines, begin, end, option)

            if pos is -1:
287
                result_lines.insert(end, '%s = %s' % (option, value))
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
            else:
                result_lines[pos] = '%s = %s' % (option, value)

        return self._write_file(result_lines)

    def remove_in_file(self, section, option):
        """
        Our own way to remove an option from the file.
        """
        result = self._parse_file()
        if not result:
            return False
        else:
            sections, result_lines = result

        if not section in sections:
            log.error('Tried to remove the option %s from a non-'
                      'existing section (%s)', option, section)
            return True
        else:
            begin, end = sections[section]
            pos = find_line(result_lines, begin, end, option)

            if pos is -1:
                log.error('Tried to remove a non-existing option %s'
                          ' from section %s', option, section)
                return True
            else:
                del result_lines[pos]
317
318
319
320
321
322
323
324

        return self._write_file(result_lines)

    def _write_file(self, lines):
        """
        Write the config file, write to a temporary file
        before copying it to the final destination
        """
mathieui's avatar
mathieui committed
325
        try:
326
327
328
329
330
331
332
333
            prefix, file = path.split(self.file_name)
            filename = path.join(prefix, '.%s.tmp' % file)
            fd = os.fdopen(
                    os.open(
                        filename,
                        os.O_WRONLY | os.O_CREAT,
                        0o600),
                    'w')
334
            for line in lines:
335
336
337
338
                fd.write('%s\n' % line)
            fd.close()
            copy2(filename, self.file_name)
            remove(filename)
mathieui's avatar
mathieui committed
339
340
        except:
            success = False
341
            log.error('Unable to save the config file.', exc_info=True)
mathieui's avatar
mathieui committed
342
343
344
        else:
            success = True
        return success
345

346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
    def _parse_file(self):
        """
        Parse the config file and return the list of sections with
        their start and end positions, and the lines in the file.

        Duplicate sections are preserved but ignored for the parsing.

        Returns an empty tuple if reading fails
        """
        if file_ok(self.file_name):
            try:
                with open(self.file_name, 'r', encoding='utf-8') as df:
                    lines_before = [line.strip() for line in df]
            except:
                log.error('Unable to read the config file %s',
                        self.file_name,
                        exc_info=True)
                return tuple()
        else:
            lines_before = []

        sections = {}
        duplicate_section = False
        current_section = ''
        current_line = 0

        for line in lines_before:
            if line.startswith('['):
                if not duplicate_section and current_section:
                    sections[current_section][1] = current_line

                duplicate_section = False
                current_section = line[1:-1]

                if current_section in sections:
                    log.error('Error while reading the configuration file,'
                              ' skipping until next section')
                    duplicate_section = True
                else:
                    sections[current_section] = [current_line, current_line]

            current_line += 1
388
        if not duplicate_section and current_section:
389
390
            sections[current_section][1] = current_line

391
        return (sections, lines_before)
392

393
    def set_and_save(self, option, value, section=DEFSECTION):
394
395
396
397
        """
        set the value in the configuration then save it
        to the file
        """
398
399
400
401
402
        # Special case for a 'toggle' value. We take the current value
        # and set the opposite. Warning if the no current value exists
        # or it is not a bool.
        if value == "toggle":
            current = self.get(option, "", section)
403
404
            if isinstance(current, bool):
                value = str(not current)
405
            else:
406
407
408
409
410
                if current.lower() == "false":
                    value = "true"
                elif current.lower() == "true":
                    value = "false"
                else:
411
412
413
414
                    return (_('Could not toggle option: %s.'
                              ' Current value is %s.') %
                                  (option, current or _("empty")),
                            'Warning')
415
416
417
418
        if self.has_section(section):
            RawConfigParser.set(self, section, option, value)
        else:
            self.add_section(section)
419
            RawConfigParser.set(self, section, option, value)
mathieui's avatar
mathieui committed
420
421
422
        if not self.write_in_file(section, option, value):
            return (_('Unable to write in the config file'), 'Error')
        return ("%s=%s" % (option, value), 'Info')
423

424
425
426
427
428
429
430
431
432
433
    def remove_and_save(self, option, section=DEFSECTION):
        """
        Remove an option and then save it the config file
        """
        if self.has_section(section):
            RawConfigParser.remove_option(self, section, option)
        if not self.remove_in_file(section, option):
            return (_('Unable to save the config file'), 'Error')
        return (_('Option %s deleted') % option, 'Info')

mathieui's avatar
mathieui committed
434
435
436
437
438
439
440
441
442
443
    def silent_set(self, option, value, section=DEFSECTION):
        """
        Set a value, save, and return True on success and False on failure
        """
        if self.has_section(section):
            RawConfigParser.set(self, section, option, value)
        else:
            self.add_section(section)
            RawConfigParser.set(self, section, option, value)
        return self.write_in_file(section, option, value)
444

445
446
447
448
449
450
451
452
453
    def set(self, option, value, section=DEFSECTION):
        """
        Set the value of an option temporarily
        """
        try:
            RawConfigParser.set(self, section, option, value)
        except NoSectionError:
            pass

454
455
456
457
458
459
460
461
462
463
    def to_dict(self):
        """
        Returns a dict of the form {section: {option: value, option: value}, …}
        """
        res = {}
        for section in self.sections():
            res[section] = {}
            for option in self.options(section):
                res[section][option] = self.get(option, "", section)
        return res
464
465


466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
def find_line(lines, start, end, option):
    """
    Get the number of the line containing the option in the
    relevant part of the config file.

    Returns -1 if the option isn’t found
    """
    current = start
    for line in lines[start:end]:
        if (line.startswith('%s ' % option) or
                line.startswith('%s=' % option)):
            return current
        current += 1
    return -1

481
482
483
484
485
486
487
488
489
def file_ok(filepath):
    """
    Returns True if the file exists and is readable and writeable,
    False otherwise.
    """
    val = path.exists(filepath)
    val &= os.access(filepath, os.R_OK | os.W_OK)
    return bool(val)

490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
def check_create_config_dir():
    """
    create the configuration directory if it doesn't exist
    """
    CONFIG_HOME = environ.get("XDG_CONFIG_HOME")
    if not CONFIG_HOME:
        CONFIG_HOME = path.join(environ.get('HOME'), '.config')
    CONFIG_PATH = path.join(CONFIG_HOME, 'poezio')

    try:
        makedirs(CONFIG_PATH)
    except OSError:
        pass
    return CONFIG_PATH

505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
def check_create_cache_dir():
    """
    create the cache directory if it doesn't exist
    also create the subdirectories
    """
    global CACHE_DIR
    CACHE_HOME = environ.get("XDG_CACHE_HOME")
    if not CACHE_HOME:
        CACHE_HOME = path.join(environ.get('HOME'), '.cache')
    CACHE_DIR = path.join(CACHE_HOME, 'poezio')

    try:
        makedirs(CACHE_DIR)
        makedirs(path.join(CACHE_DIR, 'images'))
    except OSError:
        pass

522
523
524
525
526
527
528
def run_cmdline_args(CONFIG_PATH):
    "Parse the command line arguments"
    global options
    options = parse_args(CONFIG_PATH)

    # Copy a default file if none exists
    if not path.isfile(options.filename):
529
530
        default = path.join(path.dirname(__file__),
                            '../data/default_config.cfg')
531
532
533
534
535
536
537
538
539
540
541
542
        other = path.join(path.dirname(__file__), 'default_config.cfg')
        if path.isfile(default):
            copy2(default, options.filename)
        elif path.isfile(other):
            copy2(other, options.filename)
        global firstrun
        firstrun = True

def create_global_config():
    "Create the global config object, or crash"
    try:
        global config
543
        config = Config(options.filename, DEFAULT_CONFIG)
544
545
546
547
548
549
550
551
552
553
    except:
        import traceback
        sys.stderr.write('Poezio was unable to read or'
                         ' parse the config file.\n')
        traceback.print_exc(limit=0)
        sys.exit(1)

def check_create_log_dir():
    "Create the poezio logging directory if it doesn’t exist"
    global LOG_DIR
554
    LOG_DIR = config.get('log_dir')
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573

    if not LOG_DIR:

        data_dir = environ.get('XDG_DATA_HOME')
        if not data_dir:
            home = environ.get('HOME')
            data_dir = path.join(home, '.local', 'share')

        LOG_DIR = path.join(data_dir, 'poezio')

    LOG_DIR = path.expanduser(LOG_DIR)

    try:
        makedirs(LOG_DIR)
    except:
        pass

def setup_logging():
    "Change the logging config according to the cmdline options and config"
574
    if config.get('log_errors'):
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
        LOGGING_CONFIG['root']['handlers'].append('error')
        LOGGING_CONFIG['handlers']['error'] = {
                'level': 'ERROR',
                'class': 'logging.FileHandler',
                'filename': path.join(LOG_DIR, 'errors.log'),
                'formatter': 'simple',
            }

    if options.debug:
        LOGGING_CONFIG['root']['handlers'].append('debug')
        LOGGING_CONFIG['handlers']['debug'] = {
                'level':'DEBUG',
                'class':'logging.FileHandler',
                'filename': options.debug,
                'formatter': 'simple',
            }


    if LOGGING_CONFIG['root']['handlers']:
        logging.config.dictConfig(LOGGING_CONFIG)
    else:
        logging.basicConfig(level=logging.CRITICAL)

    global log
    log = logging.getLogger(__name__)

def post_logging_setup():
louiz’'s avatar
louiz’ committed
602
    # common imports slixmpp, which creates then its loggers, so
603
604
605
606
    # it needs to be after logger configuration
    from common import safeJID as JID
    global safeJID
    safeJID = JID
607
608
609
610
611
612

LOGGING_CONFIG = {
    'version': 1,
    'disable_existing_loggers': True,
    'formatters': {
        'simple': {
613
            'format': '%(asctime)s %(levelname)s:%(module)s:%(message)s'
614
615
616
617
618
619
620
621
622
623
624
        }
    },
    'handlers': {
    },
    'root': {
            'handlers': [],
            'propagate': True,
            'level': 'DEBUG',
    }
}

625
626
627
# True if this is the first run, in this case we will display
# some help in the info buffer
firstrun = False
628

629
630
# Global config object. Is setup in poezio.py
config = None
631

632
633
# The logger object for this module
log = None
634

635
636
# The command-line options
options = None
637

638
639
# delayed import from common.py
safeJID = None
640

641
642
# the global log dir
LOG_DIR = ''
643
644
645

# the global cache dir
CACHE_DIR = ''