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