1__license__   = 'GPL v3'
2__copyright__ = '2008, Kovid Goyal kovid@kovidgoyal.net'
3__docformat__ = 'restructuredtext en'
4
5'''
6Manage application-wide preferences.
7'''
8
9import optparse
10import os
11from copy import deepcopy
12
13from calibre.constants import (
14    CONFIG_DIR_MODE, __appname__, __author__, config_dir, get_version, iswindows
15)
16from calibre.utils.config_base import (
17    Config, ConfigInterface, ConfigProxy, Option, OptionSet, OptionValues,
18    StringConfig, json_dumps, json_loads, make_config_dir, plugin_dir, prefs,
19    tweaks, from_json, to_json
20)
21from calibre.utils.lock import ExclusiveFile
22from polyglot.builtins import string_or_bytes, native_string_type
23
24
25# optparse uses gettext.gettext instead of _ from builtins, so we
26# monkey patch it.
27optparse._ = _
28
29if False:
30    # Make pyflakes happy
31    Config, ConfigProxy, Option, OptionValues, StringConfig, OptionSet,
32    ConfigInterface, tweaks, plugin_dir, prefs, from_json, to_json
33
34
35def check_config_write_access():
36    return os.access(config_dir, os.W_OK) and os.access(config_dir, os.X_OK)
37
38
39class CustomHelpFormatter(optparse.IndentedHelpFormatter):
40
41    def format_usage(self, usage):
42        from calibre.utils.terminal import colored
43        parts = usage.split(' ')
44        if parts:
45            parts[0] = colored(parts[0], fg='yellow', bold=True)
46        usage = ' '.join(parts)
47        return colored(_('Usage'), fg='blue', bold=True) + ': ' + usage
48
49    def format_heading(self, heading):
50        from calibre.utils.terminal import colored
51        return "%*s%s:\n" % (self.current_indent, '',
52                                 colored(heading, fg='blue', bold=True))
53
54    def format_option(self, option):
55        import textwrap
56        from calibre.utils.terminal import colored
57
58        result = []
59        opts = self.option_strings[option]
60        opt_width = self.help_position - self.current_indent - 2
61        if len(opts) > opt_width:
62            opts = "%*s%s\n" % (self.current_indent, "",
63                                    colored(opts, fg='green'))
64            indent_first = self.help_position
65        else:                       # start help on same line as opts
66            opts = "%*s%-*s  " % (self.current_indent, "", opt_width +
67                    len(colored('', fg='green')), colored(opts, fg='green'))
68            indent_first = 0
69        result.append(opts)
70        if option.help:
71            help_text = self.expand_default(option).split('\n')
72            help_lines = []
73
74            for line in help_text:
75                help_lines.extend(textwrap.wrap(line, self.help_width))
76            result.append("%*s%s\n" % (indent_first, "", help_lines[0]))
77            result.extend(["%*s%s\n" % (self.help_position, "", line)
78                           for line in help_lines[1:]])
79        elif opts[-1] != "\n":
80            result.append("\n")
81        return "".join(result)+'\n'
82
83
84class OptionParser(optparse.OptionParser):
85
86    def __init__(self,
87                 usage='%prog [options] filename',
88                 version=None,
89                 epilog=None,
90                 gui_mode=False,
91                 conflict_handler='resolve',
92                 **kwds):
93        import textwrap
94        from calibre.utils.terminal import colored
95
96        usage = textwrap.dedent(usage)
97        if epilog is None:
98            epilog = _('Created by ')+colored(__author__, fg='cyan')
99        usage += '\n\n'+_('''Whenever you pass arguments to %prog that have spaces in them, '''
100                          '''enclose the arguments in quotation marks. For example: "{}"''').format(
101                               "C:\\some path with spaces" if iswindows else '/some path/with spaces') +'\n'
102        if version is None:
103            version = '%%prog (%s %s)'%(__appname__, get_version())
104        optparse.OptionParser.__init__(self, usage=usage, version=version, epilog=epilog,
105                               formatter=CustomHelpFormatter(),
106                               conflict_handler=conflict_handler, **kwds)
107        self.gui_mode = gui_mode
108        if False:
109            # Translatable string from optparse
110            _("Options")
111            _("show this help message and exit")
112            _("show program's version number and exit")
113
114    def print_usage(self, file=None):
115        from calibre.utils.terminal import ANSIStream
116        s = ANSIStream(file)
117        optparse.OptionParser.print_usage(self, file=s)
118
119    def print_help(self, file=None):
120        from calibre.utils.terminal import ANSIStream
121        s = ANSIStream(file)
122        optparse.OptionParser.print_help(self, file=s)
123
124    def print_version(self, file=None):
125        from calibre.utils.terminal import ANSIStream
126        s = ANSIStream(file)
127        optparse.OptionParser.print_version(self, file=s)
128
129    def error(self, msg):
130        if self.gui_mode:
131            raise Exception(msg)
132        optparse.OptionParser.error(self, msg)
133
134    def merge(self, parser):
135        '''
136        Add options from parser to self. In case of conflicts, conflicting options from
137        parser are skipped.
138        '''
139        opts   = list(parser.option_list)
140        groups = list(parser.option_groups)
141
142        def merge_options(options, container):
143            for opt in deepcopy(options):
144                if not self.has_option(opt.get_opt_string()):
145                    container.add_option(opt)
146
147        merge_options(opts, self)
148
149        for group in groups:
150            g = self.add_option_group(group.title)
151            merge_options(group.option_list, g)
152
153    def subsume(self, group_name, msg=''):
154        '''
155        Move all existing options into a subgroup named
156        C{group_name} with description C{msg}.
157        '''
158        opts = [opt for opt in self.options_iter() if opt.get_opt_string() not in ('--version', '--help')]
159        self.option_groups = []
160        subgroup = self.add_option_group(group_name, msg)
161        for opt in opts:
162            self.remove_option(opt.get_opt_string())
163            subgroup.add_option(opt)
164
165    def options_iter(self):
166        for opt in self.option_list:
167            if native_string_type(opt).strip():
168                yield opt
169        for gr in self.option_groups:
170            for opt in gr.option_list:
171                if native_string_type(opt).strip():
172                    yield opt
173
174    def option_by_dest(self, dest):
175        for opt in self.options_iter():
176            if opt.dest == dest:
177                return opt
178
179    def merge_options(self, lower, upper):
180        '''
181        Merge options in lower and upper option lists into upper.
182        Default values in upper are overridden by
183        non default values in lower.
184        '''
185        for dest in lower.__dict__.keys():
186            if dest not in upper.__dict__:
187                continue
188            opt = self.option_by_dest(dest)
189            if lower.__dict__[dest] != opt.default and \
190               upper.__dict__[dest] == opt.default:
191                upper.__dict__[dest] = lower.__dict__[dest]
192
193    def add_option_group(self, *args, **kwargs):
194        if isinstance(args[0], string_or_bytes):
195            args = list(args)
196            args[0] = native_string_type(args[0])
197        return optparse.OptionParser.add_option_group(self, *args, **kwargs)
198
199
200class DynamicConfig(dict):
201    '''
202    A replacement for QSettings that supports dynamic config keys.
203    Returns `None` if a config key is not found. Note that the config
204    data is stored in a JSON file.
205    '''
206
207    def __init__(self, name='dynamic'):
208        dict.__init__(self, {})
209        self.name = name
210        self.defaults = {}
211        self.refresh()
212
213    @property
214    def file_path(self):
215        return os.path.join(config_dir, self.name+'.pickle.json')
216
217    def decouple(self, prefix):
218        self.name = prefix + self.name
219        self.refresh()
220
221    def read_old_serialized_representation(self):
222        from calibre.utils.shared_file import share_open
223        from calibre.utils.serialize import pickle_loads
224        path = self.file_path.rpartition('.')[0]
225        try:
226            with share_open(path, 'rb') as f:
227                raw = f.read()
228        except OSError:
229            raw = b''
230        try:
231            d = pickle_loads(raw).copy()
232        except Exception:
233            d = {}
234        return d
235
236    def refresh(self, clear_current=True):
237        d = {}
238        migrate = False
239        if clear_current:
240            self.clear()
241        if os.path.exists(self.file_path):
242            with ExclusiveFile(self.file_path) as f:
243                raw = f.read()
244            if raw:
245                try:
246                    d = json_loads(raw)
247                except Exception as err:
248                    print('Failed to de-serialize JSON representation of stored dynamic data for {} with error: {}'.format(
249                        self.name, err))
250            else:
251                d = self.read_old_serialized_representation()
252                migrate = bool(d)
253        else:
254            d = self.read_old_serialized_representation()
255            migrate = bool(d)
256        if migrate and d:
257            raw = json_dumps(d, ignore_unserializable=True)
258            with ExclusiveFile(self.file_path) as f:
259                f.seek(0), f.truncate()
260                f.write(raw)
261
262        self.update(d)
263
264    def __getitem__(self, key):
265        try:
266            return dict.__getitem__(self, key)
267        except KeyError:
268            return self.defaults.get(key, None)
269
270    def get(self, key, default=None):
271        try:
272            return dict.__getitem__(self, key)
273        except KeyError:
274            return self.defaults.get(key, default)
275
276    def __setitem__(self, key, val):
277        dict.__setitem__(self, key, val)
278        self.commit()
279
280    def set(self, key, val):
281        self.__setitem__(key, val)
282
283    def commit(self):
284        if not getattr(self, 'name', None):
285            return
286        if not os.path.exists(self.file_path):
287            make_config_dir()
288        raw = json_dumps(self)
289        with ExclusiveFile(self.file_path) as f:
290            f.seek(0)
291            f.truncate()
292            f.write(raw)
293
294
295dynamic = DynamicConfig()
296
297
298class XMLConfig(dict):
299
300    '''
301    Similar to :class:`DynamicConfig`, except that it uses an XML storage
302    backend instead of a pickle file.
303
304    See `https://docs.python.org/library/plistlib.html`_ for the supported
305    data types.
306    '''
307
308    EXTENSION = '.plist'
309
310    def __init__(self, rel_path_to_cf_file, base_path=config_dir):
311        dict.__init__(self)
312        self.no_commit = False
313        self.defaults = {}
314        self.file_path = os.path.join(base_path,
315                *(rel_path_to_cf_file.split('/')))
316        self.file_path = os.path.abspath(self.file_path)
317        if not self.file_path.endswith(self.EXTENSION):
318            self.file_path += self.EXTENSION
319
320        self.refresh()
321
322    def mtime(self):
323        try:
324            return os.path.getmtime(self.file_path)
325        except OSError:
326            return 0
327
328    def touch(self):
329        try:
330            os.utime(self.file_path, None)
331        except OSError:
332            pass
333
334    def raw_to_object(self, raw):
335        from polyglot.plistlib import loads
336        return loads(raw)
337
338    def to_raw(self):
339        from polyglot.plistlib import dumps
340        return dumps(self)
341
342    def decouple(self, prefix):
343        self.file_path = os.path.join(os.path.dirname(self.file_path), prefix + os.path.basename(self.file_path))
344        self.refresh()
345
346    def refresh(self, clear_current=True):
347        d = {}
348        if os.path.exists(self.file_path):
349            with ExclusiveFile(self.file_path) as f:
350                raw = f.read()
351                try:
352                    d = self.raw_to_object(raw) if raw.strip() else {}
353                except SystemError:
354                    pass
355                except:
356                    import traceback
357                    traceback.print_exc()
358                    d = {}
359        if clear_current:
360            self.clear()
361        self.update(d)
362
363    def has_key(self, key):
364        return dict.__contains__(self, key)
365
366    def __getitem__(self, key):
367        try:
368            return dict.__getitem__(self, key)
369        except KeyError:
370            return self.defaults.get(key, None)
371
372    def get(self, key, default=None):
373        try:
374            return dict.__getitem__(self, key)
375        except KeyError:
376            return self.defaults.get(key, default)
377
378    def __setitem__(self, key, val):
379        dict.__setitem__(self, key, val)
380        self.commit()
381
382    def set(self, key, val):
383        self.__setitem__(key, val)
384
385    def __delitem__(self, key):
386        try:
387            dict.__delitem__(self, key)
388        except KeyError:
389            pass  # ignore missing keys
390        else:
391            self.commit()
392
393    def commit(self):
394        if self.no_commit:
395            return
396        if hasattr(self, 'file_path') and self.file_path:
397            dpath = os.path.dirname(self.file_path)
398            if not os.path.exists(dpath):
399                os.makedirs(dpath, mode=CONFIG_DIR_MODE)
400            with ExclusiveFile(self.file_path) as f:
401                raw = self.to_raw()
402                f.seek(0)
403                f.truncate()
404                f.write(raw)
405
406    def __enter__(self):
407        self.no_commit = True
408
409    def __exit__(self, *args):
410        self.no_commit = False
411        self.commit()
412
413
414class JSONConfig(XMLConfig):
415
416    EXTENSION = '.json'
417
418    def raw_to_object(self, raw):
419        return json_loads(raw)
420
421    def to_raw(self):
422        return json_dumps(self)
423
424    def __getitem__(self, key):
425        try:
426            return dict.__getitem__(self, key)
427        except KeyError:
428            return self.defaults[key]
429
430    def get(self, key, default=None):
431        try:
432            return dict.__getitem__(self, key)
433        except KeyError:
434            return self.defaults.get(key, default)
435
436    def __setitem__(self, key, val):
437        dict.__setitem__(self, key, val)
438        self.commit()
439
440
441class DevicePrefs:
442
443    def __init__(self, global_prefs):
444        self.global_prefs = global_prefs
445        self.overrides = {}
446
447    def set_overrides(self, **kwargs):
448        self.overrides = kwargs.copy()
449
450    def __getitem__(self, key):
451        return self.overrides.get(key, self.global_prefs[key])
452
453
454device_prefs = DevicePrefs(prefs)
455