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