config.py 22.5 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
import logging.config
mathieui's avatar
mathieui committed
14
import os
15
import stat
mathieui's avatar
mathieui committed
16
import sys
mathieui's avatar
mathieui committed
17
import pkg_resources
mathieui's avatar
mathieui committed
18

19
from configparser import RawConfigParser, NoOptionError, NoSectionError
20
from pathlib import Path
mathieui's avatar
mathieui committed
21 22 23
from shutil import copy2
from typing import Callable, Dict, List, Optional, Union, Tuple

mathieui's avatar
mathieui committed
24
from poezio.args import parse_args
25
from poezio import xdg
26

mathieui's avatar
mathieui committed
27 28 29 30
ConfigValue = Union[str, int, float, bool]

DEFSECTION = "Poezio"

31 32 33 34 35 36
DEFAULT_CONFIG = {
    'Poezio': {
        'ack_message_receipts': True,
        'add_space_after_completion': True,
        'after_completion': ',',
        'alternative_nickname': '',
37
        'auto_reconnect': True,
38 39
        'autorejoin_delay': '5',
        'autorejoin': False,
40
        'beep_on': 'highlight private invite disconnect',
41
        'bookmark_on_join': False,
42 43
        'ca_cert_path': '',
        'certificate': '',
mathieui's avatar
mathieui committed
44
        'certfile': '',
45
        'ciphers': 'HIGH+kEDH:HIGH+kEECDH:HIGH:!PSK:!SRP:!3DES:!aNULL',
46 47
        'connection_check_interval': 300,
        'connection_timeout_delay': 30,
48 49 50 51
        'create_gaps': False,
        'custom_host': '',
        'custom_port': '',
        'default_nick': '',
52
        'default_muc_service': '',
53
        'deterministic_nick_colors': True,
54
        'device_id': '',
55
        'nick_color_aliases': True,
56 57 58 59 60
        'display_activity_notifications': False,
        'display_gaming_notifications': False,
        'display_mood_notifications': False,
        'display_tune_notifications': False,
        'display_user_color_in_join_part': True,
61
        'enable_avatars': True,
mathieui's avatar
mathieui committed
62
        'enable_carbons': True,
63
        'enable_css_parsing': True,
64 65 66 67 68
        'enable_user_activity': True,
        'enable_user_gaming': True,
        'enable_user_mood': True,
        'enable_user_nick': True,
        'enable_user_tune': True,
69
        'enable_vertical_tab_list': True,
70
        'enable_xhtml_im': True,
mathieui's avatar
mathieui committed
71
        'enable_smacks': False,
mathieui's avatar
mathieui committed
72
        'eval_password': '',
73 74 75 76
        'exec_remote': False,
        'extract_inline_images': True,
        'filter_info_messages': '',
        'force_encryption': True,
mathieui's avatar
mathieui committed
77
        'force_remote_bookmarks': False,
78
        'go_to_previous_tab_on_alt_number': False,
79 80 81 82 83 84 85
        'group_corrections': True,
        'hide_exit_join': -1,
        'hide_status_change': 120,
        'hide_user_list': False,
        'highlight_on': '',
        'ignore_certificate': False,
        'ignore_private': False,
86
        'image_use_half_blocks': False,
87
        'information_buffer_popup_on': 'error roster warning help info',
88
        'information_buffer_type_filter': '',
89
        'jid': '',
mathieui's avatar
mathieui committed
90
        'keyfile': '',
91 92 93 94 95 96 97 98 99
        '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,
100
        'notify_messages': True,
101 102 103 104 105 106 107 108 109 110 111 112 113 114
        '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,
115
        'self_ping_interval': 0,
116
        'self_ping_timeout': 60,
117 118 119 120 121 122 123 124 125
        '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,
126
        'show_jid_in_conversations': True,
127
        'show_muc_jid': False,
128 129 130 131 132 133
        'show_roster_jids': True,
        'show_roster_subscriptions': '',
        'show_s2s_errors': True,
        'show_tab_names': False,
        'show_tab_numbers': True,
        'show_timestamps': True,
134
        'show_useless_separator': True,
135 136 137 138 139 140
        'status': '',
        'status_message': '',
        'theme': 'default',
        'themes_dir': '',
        'tmp_image_dir': '',
        'use_bookmarks_method': '',
141
        'use_log': True,
142 143 144 145 146 147 148 149 150 151 152 153 154 155
        '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
156
    },
mathieui's avatar
mathieui committed
157
    'muc_colors': {}
158 159
}

mathieui's avatar
mathieui committed
160

161 162 163 164
class Config(RawConfigParser):
    """
    load/save the config to a file
    """
mathieui's avatar
mathieui committed
165

mathieui's avatar
mathieui committed
166
    def __init__(self, file_name: Path, default=None) -> None:
167
        RawConfigParser.__init__(self, None)
168
        # make the options case sensitive
169
        self.optionxform = lambda param: str(param)
170
        self.file_name = file_name
171
        self.read_file()
172
        self.default = default
173 174

    def read_file(self):
175
        RawConfigParser.read(self, str(self.file_name), encoding='utf-8')
176
        # Check config integrity and fix it if it’s wrong
177 178 179 180 181
        # 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)
182

mathieui's avatar
mathieui committed
183 184 185 186
    def get(self,
            option: str,
            default: Optional[ConfigValue] = None,
            section=DEFSECTION) -> ConfigValue:
187 188 189 190 191 192
        """
        get a value from the config but return
        a default value if it is not found
        The type of default defines the type
        returned
        """
193 194 195 196 197 198
        if default is None:
            if self.default:
                default = self.default.get(section, {}).get(option)
            else:
                default = ''

199
        try:
mathieui's avatar
mathieui committed
200 201 202
            if isinstance(default, bool):
                res = self.getboolean(option, section)
            elif isinstance(default, int):
203
                res = self.getint(option, section)
mathieui's avatar
mathieui committed
204
            elif isinstance(default, float):
205
                res = self.getfloat(option, section)
206
            else:
207
                res = self.getstr(option, section)
208
        except (NoOptionError, NoSectionError, ValueError, AttributeError):
209
            return default if default is not None else ''
210

211
        if res is None:
212 213 214
            return default
        return res

mathieui's avatar
mathieui committed
215 216 217 218 219 220
    def get_by_tabname(self,
                       option,
                       tabname,
                       fallback=True,
                       fallback_server=True,
                       default=''):
221 222 223 224 225 226
        """
        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.
        """
227 228
        if self.default and (not default) and fallback:
            default = self.default.get(DEFSECTION, {}).get(option, '')
229 230 231 232
        if tabname in self.sections():
            if option in self.options(tabname):
                # We go the tab-specific option
                return self.get(option, default, tabname)
233 234
        if fallback_server:
            return self.get_by_servname(tabname, option, default, fallback)
235 236 237 238 239
        if fallback:
            # We fallback to the global option
            return self.get(option, default)
        return default

240 241 242 243 244 245 246 247 248 249 250 251 252
    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

253
    def __get(self, option, section=DEFSECTION, **kwargs):
254 255 256
        """
        facility for RawConfigParser.get
        """
257 258 259 260 261 262 263
        return RawConfigParser.get(self, section, option, **kwargs)

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

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

271
    def getint(self, option, section=DEFSECTION):
272 273 274
        """
        get a value and returns it as an int
        """
275
        return RawConfigParser.getint(self, section, option)
276

277
    def getfloat(self, option, section=DEFSECTION):
278 279 280
        """
        get a value and returns it as a float
        """
281
        return RawConfigParser.getfloat(self, section, option)
282

283
    def getboolean(self, option, section=DEFSECTION):
284 285 286
        """
        get a value and returns it as a boolean
        """
287
        return RawConfigParser.getboolean(self, section, option)
288

mathieui's avatar
mathieui committed
289 290
    def write_in_file(self, section: str, option: str,
                      value: ConfigValue) -> bool:
291 292 293 294 295
        """
        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.
        """
296 297 298
        result = self._parse_file()
        if not result:
            return False
299
        else:
300
            sections, result_lines = result
301

302
        if section not in sections:
303
            result_lines.append('[%s]' % section)
mathieui's avatar
mathieui committed
304
            result_lines.append('%s = %s' % (option, value))
305 306
        else:
            begin, end = sections[section]
307 308 309
            pos = find_line(result_lines, begin, end, option)

            if pos is -1:
310
                result_lines.insert(end, '%s = %s' % (option, value))
311 312 313 314 315
            else:
                result_lines[pos] = '%s = %s' % (option, value)

        return self._write_file(result_lines)

mathieui's avatar
mathieui committed
316
    def remove_in_file(self, section: str, option: str) -> bool:
317 318 319 320 321 322 323 324 325
        """
        Our own way to remove an option from the file.
        """
        result = self._parse_file()
        if not result:
            return False
        else:
            sections, result_lines = result

326
        if section not in sections:
mathieui's avatar
mathieui committed
327 328 329
            log.error(
                'Tried to remove the option %s from a non-'
                'existing section (%s)', option, section)
330 331 332 333 334 335
            return True
        else:
            begin, end = sections[section]
            pos = find_line(result_lines, begin, end, option)

            if pos is -1:
mathieui's avatar
mathieui committed
336 337 338
                log.error(
                    'Tried to remove a non-existing option %s'
                    ' from section %s', option, section)
339 340 341
                return True
            else:
                del result_lines[pos]
342 343 344

        return self._write_file(result_lines)

mathieui's avatar
mathieui committed
345
    def _write_file(self, lines: List[str]) -> bool:
346 347 348 349
        """
        Write the config file, write to a temporary file
        before copying it to the final destination
        """
mathieui's avatar
mathieui committed
350
        try:
mathieui's avatar
mathieui committed
351 352
            filename = self.file_name.parent / (
                '.%s.tmp' % self.file_name.name)
353 354
            with os.fdopen(
                    os.open(
Link Mauve's avatar
Link Mauve committed
355
                        str(filename),
356 357 358 359 360 361 362
                        os.O_WRONLY | os.O_CREAT,
                        0o600,
                    ),
                    'w',
                    encoding='utf-8') as fd:
                for line in lines:
                    fd.write('%s\n' % line)
363
            filename.replace(self.file_name)
mathieui's avatar
mathieui committed
364 365
        except:
            success = False
366
            log.error('Unable to save the config file.', exc_info=True)
mathieui's avatar
mathieui committed
367 368 369
        else:
            success = True
        return success
370

mathieui's avatar
mathieui committed
371
    def _parse_file(self) -> Optional[Tuple[Dict[str, List[int]], List[str]]]:
372 373 374 375 376 377 378 379 380 381
        """
        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:
382
                with self.file_name.open('r', encoding='utf-8') as df:
mathieui's avatar
mathieui committed
383 384
                    lines_before = [line.strip()
                                    for line in df]  # type: List[str]
385
            except OSError:
mathieui's avatar
mathieui committed
386 387 388 389
                log.error(
                    'Unable to read the config file %s',
                    self.file_name,
                    exc_info=True)
mathieui's avatar
mathieui committed
390
                return None
391 392 393
        else:
            lines_before = []

mathieui's avatar
mathieui committed
394
        sections = {}  # type: Dict[str, List[int]]
395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414
        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
415
        if not duplicate_section and current_section:
416 417
            sections[current_section][1] = current_line

418
        return (sections, lines_before)
419

mathieui's avatar
mathieui committed
420 421
    def set_and_save(self, option: str, value: ConfigValue,
                     section=DEFSECTION) -> Tuple[str, str]:
422 423 424 425
        """
        set the value in the configuration then save it
        to the file
        """
426 427 428 429 430
        # 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)
431 432
            if isinstance(current, bool):
                value = str(not current)
433
            else:
434 435 436 437 438
                if current.lower() == "false":
                    value = "true"
                elif current.lower() == "true":
                    value = "false"
                else:
mathieui's avatar
mathieui committed
439 440 441 442
                    return (
                        'Could not toggle option: %s.'
                        ' Current value is %s.' % (option, current or "empty"),
                        'Warning')
443 444 445 446
        if self.has_section(section):
            RawConfigParser.set(self, section, option, value)
        else:
            self.add_section(section)
447
            RawConfigParser.set(self, section, option, value)
mathieui's avatar
mathieui committed
448
        if not self.write_in_file(section, option, value):
449
            return ('Unable to write in the config file', 'Error')
mathieui's avatar
mathieui committed
450
        return ("%s=%s" % (option, value), 'Info')
451

mathieui's avatar
mathieui committed
452 453
    def remove_and_save(self, option: str,
                        section=DEFSECTION) -> Tuple[str, str]:
454 455 456 457 458 459
        """
        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):
460 461
            return ('Unable to save the config file', 'Error')
        return ('Option %s deleted' % option, 'Info')
462

mathieui's avatar
mathieui committed
463
    def silent_set(self, option: str, value: ConfigValue, section=DEFSECTION):
mathieui's avatar
mathieui committed
464 465 466 467 468 469 470 471 472
        """
        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)
473

mathieui's avatar
mathieui committed
474
    def set(self, option: str, value: ConfigValue, section=DEFSECTION):
475 476 477 478 479 480 481 482
        """
        Set the value of an option temporarily
        """
        try:
            RawConfigParser.set(self, section, option, value)
        except NoSectionError:
            pass

mathieui's avatar
mathieui committed
483
    def to_dict(self) -> Dict[str, Dict[str, ConfigValue]]:
484 485 486
        """
        Returns a dict of the form {section: {option: value, option: value}, …}
        """
mathieui's avatar
mathieui committed
487
        res = {}  # Dict[str, Dict[str, ConfigValue]]
488 489 490 491 492
        for section in self.sections():
            res[section] = {}
            for option in self.options(section):
                res[section][option] = self.get(option, "", section)
        return res
493 494


mathieui's avatar
mathieui committed
495
def find_line(lines: List[str], start: int, end: int, option: str) -> int:
496 497 498 499 500 501 502 503
    """
    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]:
mathieui's avatar
mathieui committed
504 505
        if (line.startswith('%s ' % option)
                or line.startswith('%s=' % option)):
506 507 508 509
            return current
        current += 1
    return -1

mathieui's avatar
mathieui committed
510

mathieui's avatar
mathieui committed
511
def file_ok(filepath: Path) -> bool:
512 513 514 515
    """
    Returns True if the file exists and is readable and writeable,
    False otherwise.
    """
516
    val = filepath.exists()
Link Mauve's avatar
Link Mauve committed
517
    val &= os.access(str(filepath), os.R_OK | os.W_OK)
518 519
    return bool(val)

mathieui's avatar
mathieui committed
520

mathieui's avatar
mathieui committed
521
def get_image_cache() -> Path:
522 523 524 525 526 527 528 529
    if not config.get('extract_inline_images'):
        return None
    tmp_dir = config.get('tmp_image_dir')
    if tmp_dir:
        return Path(tmp_dir)
    return xdg.CACHE_HOME / 'images'


530 531 532 533 534 535 536 537
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]:
mathieui's avatar
mathieui committed
538 539
            result['changed'].append((option, value,
                                      DEFAULT_CONFIG['Poezio'][option]))
540 541 542 543 544 545 546 547 548 549
        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']:
mathieui's avatar
mathieui committed
550 551
        print(
            '\033[1mOptions changed from the default configuration:\033[0m\n')
552
        for option, new_value, default in result['changed']:
mathieui's avatar
mathieui committed
553 554 555
            print(
                '    \033[1m%s\033[0m = \033[33m%s\033[0m (default: \033[32m%s\033[0m)'
                % (option, new_value, default))
556 557 558 559 560 561

    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)

mathieui's avatar
mathieui committed
562

563
def run_cmdline_args():
564 565
    "Parse the command line arguments"
    global options
566
    options = parse_args(xdg.CONFIG_HOME)
567 568

    # Copy a default file if none exists
569 570 571 572
    if not options.filename.is_file():
        try:
            options.filename.parent.mkdir(parents=True, exist_ok=True)
        except OSError as e:
mathieui's avatar
mathieui committed
573 574
            sys.stderr.write(
                'Poezio was unable to create the config directory: %s\n' % e)
575 576
            sys.exit(1)
        default = Path(__file__).parent / '..' / 'data' / 'default_config.cfg'
mathieui's avatar
mathieui committed
577 578
        other = Path(
            pkg_resources.resource_filename('poezio', 'default_config.cfg'))
579
        if default.is_file():
580
            copy2(str(default), str(options.filename))
581
        elif other.is_file():
582
            copy2(str(other), str(options.filename))
583 584 585 586

        # 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.
587
        if options.filename.exists():
mathieui's avatar
mathieui committed
588 589
            options.filename.chmod(options.filename.stat().st_mode
                                   | stat.S_IWUSR)
590

591 592 593
        global firstrun
        firstrun = True

mathieui's avatar
mathieui committed
594

595 596 597 598
def create_global_config():
    "Create the global config object, or crash"
    try:
        global config
599
        config = Config(options.filename, DEFAULT_CONFIG)
600 601 602 603 604 605 606
    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)

mathieui's avatar
mathieui committed
607

608 609
def setup_logging():
    "Change the logging config according to the cmdline options and config"
610
    global LOG_DIR
611 612
    LOG_DIR = config.get('log_dir')
    LOG_DIR = Path(LOG_DIR).expanduser() if LOG_DIR else xdg.DATA_HOME / 'logs'
613
    if config.get('log_errors'):
614 615 616 617 618 619 620 621 622 623 624 625 626 627
        try:
            LOG_DIR.mkdir(parents=True, exist_ok=True)
        except OSError:
            # We can’t really log any error here, because logging isn’t setup yet.
            pass
        else:
            LOGGING_CONFIG['root']['handlers'].append('error')
            LOGGING_CONFIG['handlers']['error'] = {
                'level': 'ERROR',
                'class': 'logging.FileHandler',
                'filename': str(LOG_DIR / 'errors.log'),
                'formatter': 'simple',
            }
            logging.disable(logging.WARNING)
628 629 630 631

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

    if LOGGING_CONFIG['root']['handlers']:
        logging.config.dictConfig(LOGGING_CONFIG)
    else:
642
        logging.disable(logging.ERROR)
643 644 645 646 647
        logging.basicConfig(level=logging.CRITICAL)

    global log
    log = logging.getLogger(__name__)

mathieui's avatar
mathieui committed
648

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

mathieui's avatar
mathieui committed
656

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

673 674 675
# True if this is the first run, in this case we will display
# some help in the info buffer
firstrun = False
676

677
# Global config object. Is setup in poezio.py
mathieui's avatar
mathieui committed
678
config = None  # type: Optional[Config]
679

680
# The logger object for this module
mathieui's avatar
mathieui committed
681
log = None  # type: Optional[logging.Logger]
682

683 684
# The command-line options
options = None
685

686
# delayed import from common.py
mathieui's avatar
mathieui committed
687
safeJID = None  # type: Optional[Callable]
688

689
# the global log dir
690
LOG_DIR = Path()