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