config.py 22.4 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
        'deterministic_nick_colors': True,
53
        'device_id': '',
54
        'nick_color_aliases': True,
55
56
57
58
59
        'display_activity_notifications': False,
        'display_gaming_notifications': False,
        'display_mood_notifications': False,
        'display_tune_notifications': False,
        'display_user_color_in_join_part': True,
60
        'enable_avatars': True,
mathieui's avatar
mathieui committed
61
        'enable_carbons': True,
62
        'enable_css_parsing': True,
63
64
65
66
67
68
69
        '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
70
        'enable_smacks': False,
mathieui's avatar
mathieui committed
71
        'eval_password': '',
72
73
74
75
        'exec_remote': False,
        'extract_inline_images': True,
        'filter_info_messages': '',
        'force_encryption': True,
mathieui's avatar
mathieui committed
76
        'force_remote_bookmarks': False,
77
        'go_to_previous_tab_on_alt_number': False,
78
79
80
81
82
83
84
        'group_corrections': True,
        'hide_exit_join': -1,
        'hide_status_change': 120,
        'hide_user_list': False,
        'highlight_on': '',
        'ignore_certificate': False,
        'ignore_private': False,
85
        'image_use_half_blocks': False,
86
        'information_buffer_popup_on': 'error roster warning help info',
87
        'information_buffer_type_filter': '',
88
        'jid': '',
mathieui's avatar
mathieui committed
89
        'keyfile': '',
90
91
92
93
94
95
96
97
98
        '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,
99
        'notify_messages': True,
100
101
102
103
104
105
106
107
108
109
110
111
112
113
        '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,
114
        'self_ping_interval': 0,
115
        'self_ping_timeout': 60,
116
117
118
119
120
121
122
123
124
        '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,
125
        'show_jid_in_conversations': True,
126
        'show_muc_jid': False,
127
128
129
130
131
132
        'show_roster_jids': True,
        'show_roster_subscriptions': '',
        'show_s2s_errors': True,
        'show_tab_names': False,
        'show_tab_numbers': True,
        'show_timestamps': True,
133
        'show_useless_separator': True,
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
        '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
155
    },
mathieui's avatar
mathieui committed
156
    'muc_colors': {}
157
158
}

mathieui's avatar
mathieui committed
159

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        return self._write_file(result_lines)

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

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

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

        return self._write_file(result_lines)

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

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

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

417
        return (sections, lines_before)
418

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

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

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

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

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


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

mathieui's avatar
mathieui committed
509

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

mathieui's avatar
mathieui committed
519

mathieui's avatar
mathieui committed
520
def get_image_cache() -> Path:
521
522
523
524
525
526
527
528
    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'


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

    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
561

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

    # Copy a default file if none exists
568
569
570
571
    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
572
573
            sys.stderr.write(
                'Poezio was unable to create the config directory: %s\n' % e)
574
575
            sys.exit(1)
        default = Path(__file__).parent / '..' / 'data' / 'default_config.cfg'
mathieui's avatar
mathieui committed
576
577
        other = Path(
            pkg_resources.resource_filename('poezio', 'default_config.cfg'))
578
        if default.is_file():
579
            copy2(str(default), str(options.filename))
580
        elif other.is_file():
581
            copy2(str(other), str(options.filename))
582
583
584
585

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

590
591
592
        global firstrun
        firstrun = True

mathieui's avatar
mathieui committed
593

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

607
608
def setup_logging():
    "Change the logging config according to the cmdline options and config"
609
    global LOG_DIR
610
611
    LOG_DIR = config.get('log_dir')
    LOG_DIR = Path(LOG_DIR).expanduser() if LOG_DIR else xdg.DATA_HOME / 'logs'
612
    if config.get('log_errors'):
613
614
615
616
617
618
619
620
621
622
623
624
625
626
        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)
627
628
629
630

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

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

    global log
    log = logging.getLogger(__name__)

mathieui's avatar
mathieui committed
647

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

mathieui's avatar
mathieui committed
655

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

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

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

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

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

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

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