config.py 21.8 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
import os
17
import stat
mathieui's avatar
mathieui committed
18
import sys
mathieui's avatar
mathieui committed
19
import pkg_resources
mathieui's avatar
mathieui committed
20

21
from configparser import RawConfigParser, NoOptionError, NoSectionError
22
from os import environ, makedirs, path, remove
23
from shutil import copy2
mathieui's avatar
mathieui committed
24
from poezio.args import parse_args
25

26
27
28
29
30
31
DEFAULT_CONFIG = {
    'Poezio': {
        'ack_message_receipts': True,
        'add_space_after_completion': True,
        'after_completion': ',',
        'alternative_nickname': '',
32
        'auto_reconnect': True,
33
34
        'autorejoin_delay': '5',
        'autorejoin': False,
35
        'beep_on': 'highlight private invite disconnect',
36
37
        'ca_cert_path': '',
        'certificate': '',
mathieui's avatar
mathieui committed
38
        'certfile': '',
39
        'ciphers': 'HIGH+kEDH:HIGH+kEECDH:HIGH:!PSK:!SRP:!3DES:!aNULL',
40
41
        'connection_check_interval': 300,
        'connection_timeout_delay': 30,
42
43
44
45
        'create_gaps': False,
        'custom_host': '',
        'custom_port': '',
        'default_nick': '',
46
        'deterministic_nick_colors': True,
47
        'nick_color_aliases': True,
48
49
50
51
52
        'display_activity_notifications': False,
        'display_gaming_notifications': False,
        'display_mood_notifications': False,
        'display_tune_notifications': False,
        'display_user_color_in_join_part': True,
53
        'enable_avatars': True,
mathieui's avatar
mathieui committed
54
        'enable_carbons': True,
55
56
57
58
59
60
61
        '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,
mathieui's avatar
mathieui committed
62
        'enable_smacks': False,
mathieui's avatar
mathieui committed
63
        'eval_password': '',
64
65
66
67
        'exec_remote': False,
        'extract_inline_images': True,
        'filter_info_messages': '',
        'force_encryption': True,
mathieui's avatar
mathieui committed
68
        'force_remote_bookmarks': False,
69
        'go_to_previous_tab_on_alt_number': False,
70
71
72
73
74
75
76
77
        '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',
78
        'information_buffer_type_filter': '',
79
        'jid': '',
mathieui's avatar
mathieui committed
80
        'keyfile': '',
81
82
83
84
85
86
87
88
89
        'lang': 'en',
        'lazy_resize': True,
        'load_log': 10,
        'log_dir': '',
        'log_errors': True,
        'max_lines_in_memory': 2048,
        'max_messages_in_memory': 2048,
        'max_nick_length': 25,
        'muc_history_length': 50,
90
        'notify_messages': True,
91
92
93
94
95
96
97
98
99
100
101
102
103
104
        '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,
        'rooms': '',
        'roster_group_sort': 'name',
        'roster_show_offline': False,
        'roster_sort': 'jid:show',
        'save_status': True,
105
        'self_ping_delay': 0,
106
107
108
109
110
111
112
113
114
        '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,
115
        'show_jid_in_conversations': True,
116
117
118
119
120
121
122
        '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,
123
        'show_useless_separator': True,
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
        '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
145
146
    },
    'muc_colors': {
147
148
149
    }
}

150
151
152
153
class Config(RawConfigParser):
    """
    load/save the config to a file
    """
154
    def __init__(self, file_name, default=None):
155
        RawConfigParser.__init__(self, None)
156
157
        # make the options case sensitive
        self.optionxform = str
158
        self.file_name = file_name
159
        self.read_file()
160
        self.default = default
161
162

    def read_file(self):
163
        RawConfigParser.read(self, self.file_name, encoding='utf-8')
164
        # Check config integrity and fix it if it’s wrong
165
166
167
168
169
        # 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)
170

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

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

196
        if res is None:
197
198
199
            return default
        return res

200
201
    def get_by_tabname(self, option, tabname,
                       fallback=True, fallback_server=True, default=''):
202
203
204
205
206
207
        """
        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.
        """
208
209
        if self.default and (not default) and fallback:
            default = self.default.get(DEFSECTION, {}).get(option, '')
210
211
212
213
        if tabname in self.sections():
            if option in self.options(tabname):
                # We go the tab-specific option
                return self.get(option, default, tabname)
214
215
        if fallback_server:
            return self.get_by_servname(tabname, option, default, fallback)
216
217
218
219
220
        if fallback:
            # We fallback to the global option
            return self.get(option, default)
        return default

221
222
223
224
225
226
227
228
229
230
231
232
233
234
    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


235
    def __get(self, option, section=DEFSECTION, **kwargs):
236
237
238
        """
        facility for RawConfigParser.get
        """
239
240
241
242
243
244
245
        return RawConfigParser.get(self, section, option, **kwargs)

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

247
    def getstr(self, option, section=DEFSECTION):
248
249
250
        """
        get a value and returns it as a string
        """
251
        return self.__get(option, section)
252

253
    def getint(self, option, section=DEFSECTION):
254
255
256
        """
        get a value and returns it as an int
        """
257
        return RawConfigParser.getint(self, section, option)
258

259
    def getfloat(self, option, section=DEFSECTION):
260
261
262
        """
        get a value and returns it as a float
        """
263
        return RawConfigParser.getfloat(self, section, option)
264

265
    def getboolean(self, option, section=DEFSECTION):
266
267
268
        """
        get a value and returns it as a boolean
        """
269
        return RawConfigParser.getboolean(self, section, option)
270

271
272
273
274
275
276
    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.
        """
277
278
279
        result = self._parse_file()
        if not result:
            return False
280
        else:
281
            sections, result_lines = result
282

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

            if pos is -1:
291
                result_lines.insert(end, '%s = %s' % (option, value))
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
            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

307
        if section not in sections:
308
309
310
311
312
313
314
315
316
317
318
319
320
            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]
321
322
323
324
325
326
327
328

        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
329
        try:
330
331
332
333
334
335
336
337
            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')
338
            for line in lines:
339
340
341
342
                fd.write('%s\n' % line)
            fd.close()
            copy2(filename, self.file_name)
            remove(filename)
mathieui's avatar
mathieui committed
343
344
        except:
            success = False
345
            log.error('Unable to save the config file.', exc_info=True)
mathieui's avatar
mathieui committed
346
347
348
        else:
            success = True
        return success
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
388
389
390
391
    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
392
        if not duplicate_section and current_section:
393
394
            sections[current_section][1] = current_line

395
        return (sections, lines_before)
396

397
    def set_and_save(self, option, value, section=DEFSECTION):
398
399
400
401
        """
        set the value in the configuration then save it
        to the file
        """
402
403
404
405
406
        # 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)
407
408
            if isinstance(current, bool):
                value = str(not current)
409
            else:
410
411
412
413
414
                if current.lower() == "false":
                    value = "true"
                elif current.lower() == "true":
                    value = "false"
                else:
415
416
417
                    return ('Could not toggle option: %s.'
                            ' Current value is %s.' %
                                (option, current or "empty"),
418
                            'Warning')
419
420
421
422
        if self.has_section(section):
            RawConfigParser.set(self, section, option, value)
        else:
            self.add_section(section)
423
            RawConfigParser.set(self, section, option, value)
mathieui's avatar
mathieui committed
424
        if not self.write_in_file(section, option, value):
425
            return ('Unable to write in the config file', 'Error')
mathieui's avatar
mathieui committed
426
        return ("%s=%s" % (option, value), 'Info')
427

428
429
430
431
432
433
434
    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):
435
436
            return ('Unable to save the config file', 'Error')
        return ('Option %s deleted' % option, 'Info')
437

mathieui's avatar
mathieui committed
438
439
440
441
442
443
444
445
446
447
    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)
448

449
450
451
452
453
454
455
456
457
    def set(self, option, value, section=DEFSECTION):
        """
        Set the value of an option temporarily
        """
        try:
            RawConfigParser.set(self, section, option, value)
        except NoSectionError:
            pass

458
459
460
461
462
463
464
465
466
467
    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
468
469


470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
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

485
486
487
488
489
490
491
492
493
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)

494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
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

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)
Link Mauve's avatar
Link Mauve committed
522
        makedirs(path.join(CACHE_DIR, 'avatars'))
523
524
525
526
        makedirs(path.join(CACHE_DIR, 'images'))
    except OSError:
        pass

527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
def check_config():
    """
    Check the config file and print results
    """
    result = {'missing': [], 'changed': []}
    for option in DEFAULT_CONFIG['Poezio']:
        value = config.get(option)
        if value != DEFAULT_CONFIG['Poezio'][option]:
            result['changed'].append((option, value, DEFAULT_CONFIG['Poezio'][option]))
        else:
            value = config.get(option, default='')
            upper = value.upper()
            default = str(DEFAULT_CONFIG['Poezio'][option]).upper()
            if upper != default:
                result['missing'].append(option)

    result['changed'].sort(key=lambda x: x[0])
    result['missing'].sort()
    if result['changed']:
        print('\033[1mOptions changed from the default configuration:\033[0m\n')
        for option, new_value, default in result['changed']:
            print('    \033[1m%s\033[0m = \033[33m%s\033[0m (default: \033[32m%s\033[0m)' % (option, new_value, default))

    if result['missing']:
        print('\n\033[1mMissing options:\033[0m (the defaults are used)\n')
        for option in result['missing']:
            print('    \033[31m%s\033[0m' % option)

555
556
557
558
559
560
561
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):
mathieui's avatar
mathieui committed
562
563
        default = path.join(path.dirname(__file__), '../data/default_config.cfg')
        other = pkg_resources.resource_filename('poezio', 'default_config.cfg')
564
565
566
567
        if path.isfile(default):
            copy2(default, options.filename)
        elif path.isfile(other):
            copy2(other, options.filename)
568
569
570
571
572
573
574
575

        # Inside the nixstore and possibly other distributions, the reference
        # file is readonly, so is the copy.
        # Make it writable by the user who just created it.
        if os.path.exists(options.filename):
            os.chmod(options.filename,
                     os.stat(options.filename).st_mode | stat.S_IWUSR)

576
577
578
579
580
581
582
        global firstrun
        firstrun = True

def create_global_config():
    "Create the global config object, or crash"
    try:
        global config
583
        config = Config(options.filename, DEFAULT_CONFIG)
584
585
586
587
588
589
590
591
592
593
    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
594
    LOG_DIR = config.get('log_dir')
595
596
597
598
599
600
601
602

    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')

603
        LOG_DIR = path.join(data_dir, 'poezio', 'logs')
604
605
606
607
608
609
610
611
612
613

    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"
614
    if config.get('log_errors'):
615
616
617
618
619
620
621
        LOGGING_CONFIG['root']['handlers'].append('error')
        LOGGING_CONFIG['handlers']['error'] = {
                'level': 'ERROR',
                'class': 'logging.FileHandler',
                'filename': path.join(LOG_DIR, 'errors.log'),
                'formatter': 'simple',
            }
622
        logging.disable(logging.WARNING)
623
624
625
626
627
628
629
630
631

    if options.debug:
        LOGGING_CONFIG['root']['handlers'].append('debug')
        LOGGING_CONFIG['handlers']['debug'] = {
                'level':'DEBUG',
                'class':'logging.FileHandler',
                'filename': options.debug,
                'formatter': 'simple',
            }
632
        logging.disable(logging.NOTSET)
633
634
635
636
637


    if LOGGING_CONFIG['root']['handlers']:
        logging.config.dictConfig(LOGGING_CONFIG)
    else:
638
        logging.disable(logging.ERROR)
639
640
641
642
643
644
        logging.basicConfig(level=logging.CRITICAL)

    global log
    log = logging.getLogger(__name__)

def post_logging_setup():
louiz’'s avatar
louiz’ committed
645
    # common imports slixmpp, which creates then its loggers, so
646
    # it needs to be after logger configuration
mathieui's avatar
mathieui committed
647
    from poezio.common import safeJID as JID
648
649
    global safeJID
    safeJID = JID
650
651
652
653
654
655

LOGGING_CONFIG = {
    'version': 1,
    'disable_existing_loggers': True,
    'formatters': {
        'simple': {
656
            'format': '%(asctime)s %(levelname)s:%(module)s:%(message)s'
657
658
659
660
661
662
663
664
665
666
667
        }
    },
    'handlers': {
    },
    'root': {
            'handlers': [],
            'propagate': True,
            'level': 'DEBUG',
    }
}

668
669
670
# True if this is the first run, in this case we will display
# some help in the info buffer
firstrun = False
671

672
673
# Global config object. Is setup in poezio.py
config = None
674

675
676
# The logger object for this module
log = None
677

678
679
# The command-line options
options = None
680

681
682
# delayed import from common.py
safeJID = None
683

684
685
# the global log dir
LOG_DIR = ''
686
687
688

# the global cache dir
CACHE_DIR = ''