1# -*- coding: utf-8 -*- 2# 3# Picard, the next-generation MusicBrainz tagger 4# 5# Copyright (C) 2006-2007, 2014, 2017 Lukáš Lalinský 6# Copyright (C) 2008, 2014, 2019-2021 Philipp Wolfer 7# Copyright (C) 2012, 2017 Wieland Hoffmann 8# Copyright (C) 2012-2014 Michael Wiencek 9# Copyright (C) 2013-2016, 2018-2019 Laurent Monin 10# Copyright (C) 2016 Suhas 11# Copyright (C) 2016-2018 Sambhav Kothari 12# Copyright (C) 2017 Sophist-UK 13# Copyright (C) 2018 Vishal Choudhary 14# 15# This program is free software; you can redistribute it and/or 16# modify it under the terms of the GNU General Public License 17# as published by the Free Software Foundation; either version 2 18# of the License, or (at your option) any later version. 19# 20# This program is distributed in the hope that it will be useful, 21# but WITHOUT ANY WARRANTY; without even the implied warranty of 22# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 23# GNU General Public License for more details. 24# 25# You should have received a copy of the GNU General Public License 26# along with this program; if not, write to the Free Software 27# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 28 29 30from collections import defaultdict 31from operator import itemgetter 32import os 33import shutil 34import threading 35 36import fasteners 37 38from PyQt5 import QtCore 39 40from picard import ( 41 PICARD_APP_NAME, 42 PICARD_ORG_NAME, 43 PICARD_VERSION, 44 log, 45) 46from picard.version import Version 47 48 49class Memovar: 50 def __init__(self): 51 self.dirty = True 52 self.value = None 53 54 55class ConfigUpgradeError(Exception): 56 pass 57 58 59class ConfigSection(QtCore.QObject): 60 61 """Configuration section.""" 62 63 def __init__(self, config, name): 64 super().__init__() 65 self.__qt_config = config 66 self.__name = name 67 self.__prefix = self.__name + '/' 68 self.__prefix_len = len(self.__prefix) 69 self._memoization = defaultdict(Memovar) 70 71 def key(self, name): 72 return self.__prefix + name 73 74 def __getitem__(self, name): 75 opt = Option.get(self.__name, name) 76 if opt is None: 77 return None 78 return self.value(name, opt, opt.default) 79 80 def __setitem__(self, name, value): 81 key = self.key(name) 82 self.__qt_config.setValue(key, value) 83 self._memoization[key].dirty = True 84 85 def __contains__(self, name): 86 return self.__qt_config.contains(self.key(name)) 87 88 def remove(self, name): 89 key = self.key(name) 90 config = self.__qt_config 91 if config.contains(key): 92 config.remove(key) 93 try: 94 del self._memoization[key] 95 except KeyError: 96 pass 97 98 def raw_value(self, name, qtype=None): 99 """Return an option value without any type conversion.""" 100 key = self.key(name) 101 if qtype is not None: 102 value = self.__qt_config.value(key, type=qtype) 103 else: 104 value = self.__qt_config.value(key) 105 return value 106 107 def value(self, name, option_type, default=None): 108 """Return an option value converted to the given Option type.""" 109 if name in self: 110 key = self.key(name) 111 memovar = self._memoization[key] 112 113 if memovar.dirty: 114 try: 115 value = self.raw_value(name, qtype=option_type.qtype) 116 value = option_type.convert(value) 117 memovar.dirty = False 118 memovar.value = value 119 except Exception as why: 120 log.error('Cannot read %s value: %s', self.key(name), why, exc_info=True) 121 value = default 122 return value 123 else: 124 return memovar.value 125 return default 126 127 128class Config(QtCore.QSettings): 129 130 """Configuration. 131 QSettings is not thread safe, each thread must use its own instance of this class. 132 Use `get_config()` to obtain a Config instance for the current thread. 133 Changes to one Config instances are automatically available to all other instances. 134 135 Use `Config.from_app` or `Config.from_file` to obtain a new `Config` instance. 136 137 See: https://doc.qt.io/qt-5/qsettings.html#accessing-settings-from-multiple-threads-or-processes-simultaneously 138 """ 139 140 def __init__(self): 141 # Do not call `QSettings.__init__` here. The proper overloaded `QSettings.__init__` 142 # gets called in `from_app` or `from_config`. Only those class methods must be used 143 # to create a new instance of `Config`. 144 pass 145 146 def __initialize(self): 147 """Common initializer method for :meth:`from_app` and 148 :meth:`from_file`.""" 149 150 self.setAtomicSyncRequired(False) # See comment in event() 151 self.application = ConfigSection(self, "application") 152 self.setting = ConfigSection(self, "setting") 153 self.persist = ConfigSection(self, "persist") 154 self.profile = ConfigSection(self, "profile/default") 155 self.current_preset = "default" 156 157 TextOption("application", "version", '0.0.0dev0') 158 self._version = Version.from_string(self.application["version"]) 159 self._upgrade_hooks = dict() 160 161 def event(self, event): 162 if event.type() == QtCore.QEvent.UpdateRequest: 163 # Syncing the config file can trigger a deadlock between QSettings internal mutex and 164 # the Python GIL in PyQt up to 5.15.2. Workaround this by handling this ourselves 165 # with custom file locking. 166 # See also https: // tickets.metabrainz.org/browse/PICARD-2088 167 log.debug('Config file update requested on thread %r', threading.get_ident()) 168 self.sync() 169 return True 170 else: 171 return super().event(event) 172 173 def sync(self): 174 # Custom file locking for save multi process syncing of the config file. This is needed 175 # as we have atomicSyncRequired disabled. 176 with fasteners.InterProcessLock(self.get_lockfile_name()): 177 super().sync() 178 179 def get_lockfile_name(self): 180 filename = self.fileName() 181 directory = os.path.dirname(filename) 182 filename = '.' + os.path.basename(filename) + '.synclock' 183 return os.path.join(directory, filename) 184 185 @classmethod 186 def from_app(cls, parent): 187 """Build a Config object using the default configuration file 188 location.""" 189 this = cls() 190 QtCore.QSettings.__init__(this, QtCore.QSettings.IniFormat, 191 QtCore.QSettings.UserScope, PICARD_ORG_NAME, 192 PICARD_APP_NAME, parent) 193 194 # Check if there is a config file specifically for this version 195 versioned_config_file = this._versioned_config_filename(PICARD_VERSION) 196 if os.path.isfile(versioned_config_file): 197 return cls.from_file(parent, versioned_config_file) 198 199 # If there are no settings, copy existing settings from old format 200 # (registry on windows systems) 201 if not this.allKeys(): 202 oldFormat = QtCore.QSettings(PICARD_ORG_NAME, PICARD_APP_NAME) 203 for k in oldFormat.allKeys(): 204 this.setValue(k, oldFormat.value(k)) 205 this.sync() 206 207 this.__initialize() 208 this._backup_settings() 209 return this 210 211 @classmethod 212 def from_file(cls, parent, filename): 213 """Build a Config object using a user-provided configuration file 214 path.""" 215 this = cls() 216 QtCore.QSettings.__init__(this, filename, QtCore.QSettings.IniFormat, 217 parent) 218 this.__initialize() 219 return this 220 221 def switchProfile(self, profilename): 222 """Sets the current profile.""" 223 key = "profile/%s" % (profilename,) 224 if self.contains(key): 225 self.profile.name = key 226 else: 227 raise KeyError("Unknown profile '%s'" % (profilename,)) 228 229 def register_upgrade_hook(self, func, *args): 230 """Register a function to upgrade from one config version to another""" 231 to_version = Version.from_string(func.__name__) 232 assert to_version <= PICARD_VERSION, "%r > %r !!!" % (to_version, PICARD_VERSION) 233 self._upgrade_hooks[to_version] = { 234 'func': func, 235 'args': args, 236 'done': False 237 } 238 239 def run_upgrade_hooks(self, outputfunc=None): 240 """Executes registered functions to upgrade config version to the latest""" 241 if self._version == Version(0, 0, 0, 'dev', 0): 242 # This is a freshly created config 243 self._version = PICARD_VERSION 244 self._write_version() 245 return 246 if not self._upgrade_hooks: 247 return 248 if self._version >= PICARD_VERSION: 249 if self._version > PICARD_VERSION: 250 print("Warning: config file %s was created by a more recent " 251 "version of Picard (current is %s)" % ( 252 self._version.to_string(), 253 PICARD_VERSION.to_string() 254 )) 255 return 256 for version in sorted(self._upgrade_hooks): 257 hook = self._upgrade_hooks[version] 258 if self._version < version: 259 try: 260 if outputfunc and hook['func'].__doc__: 261 outputfunc("Config upgrade %s -> %s: %s" % ( 262 self._version.to_string(), 263 version.to_string(), 264 hook['func'].__doc__.strip())) 265 hook['func'](self, *hook['args']) 266 except BaseException: 267 import traceback 268 raise ConfigUpgradeError( 269 "Error during config upgrade from version %s to %s " 270 "using %s():\n%s" % ( 271 self._version.to_string(), 272 version.to_string(), 273 hook['func'].__name__, 274 traceback.format_exc() 275 )) 276 else: 277 hook['done'] = True 278 self._version = version 279 self._write_version() 280 else: 281 # hook is not applicable, mark as done 282 hook['done'] = True 283 284 if all(map(itemgetter("done"), self._upgrade_hooks.values())): 285 # all hooks were executed, ensure config is marked with latest version 286 self._version = PICARD_VERSION 287 self._write_version() 288 289 def _backup_settings(self): 290 if Version(0, 0, 0) < self._version < PICARD_VERSION: 291 backup_path = self._versioned_config_filename() 292 log.info('Backing up config file to %s', backup_path) 293 try: 294 shutil.copyfile(self.fileName(), backup_path) 295 except OSError: 296 log.error('Failed backing up config file to %s', backup_path) 297 298 def _write_version(self): 299 self.application["version"] = self._version.to_string() 300 self.sync() 301 302 def _versioned_config_filename(self, version=None): 303 if not version: 304 version = self._version 305 return os.path.join(os.path.dirname(self.fileName()), '%s-%s.ini' % ( 306 self.applicationName(), version.to_string(short=True))) 307 308 309class Option(QtCore.QObject): 310 311 """Generic option.""" 312 313 registry = {} 314 qtype = None 315 316 def __init__(self, section, name, default): 317 super().__init__() 318 self.section = section 319 self.name = name 320 self.default = default 321 if not hasattr(self, "convert"): 322 self.convert = type(default) 323 self.registry[(self.section, self.name)] = self 324 325 @classmethod 326 def get(cls, section, name): 327 return cls.registry.get((section, name)) 328 329 330class TextOption(Option): 331 332 convert = str 333 qtype = 'QString' 334 335 336class BoolOption(Option): 337 338 convert = bool 339 qtype = bool 340 341 342class IntOption(Option): 343 344 convert = int 345 346 347class FloatOption(Option): 348 349 convert = float 350 351 352class ListOption(Option): 353 354 convert = list 355 qtype = 'QVariantList' 356 357 358config = None 359setting = None 360persist = None 361 362_thread_configs = {} 363_thread_config_lock = threading.RLock() 364 365 366def setup_config(app, filename=None): 367 global config, setting, persist 368 if filename is None: 369 config = Config.from_app(app) 370 else: 371 config = Config.from_file(app, filename) 372 _thread_configs[threading.get_ident()] = config 373 setting = config.setting 374 persist = config.persist 375 _init_purge_config_timer() 376 377 378def get_config(): 379 """Returns a config object for the current thread. 380 381 Config objects for threads are created on demand and cached for later use. 382 """ 383 thread_id = threading.get_ident() 384 thread_config = _thread_configs.get(thread_id) 385 if not thread_config: 386 if not config: 387 return None # Not yet initialized 388 _thread_config_lock.acquire() 389 try: 390 config_file = config.fileName() 391 log.debug('Instantiating Config for thread %s using %s.', thread_id, config_file) 392 thread_config = Config.from_file(None, config_file) 393 _thread_configs[thread_id] = thread_config 394 finally: 395 _thread_config_lock.release() 396 return thread_config 397 398 399def _init_purge_config_timer(purge_interval_milliseconds=60000): 400 def run_purge_config_timer(): 401 purge_config_instances() 402 start_purge_config_timer() 403 404 def start_purge_config_timer(): 405 QtCore.QTimer.singleShot(purge_interval_milliseconds, run_purge_config_timer) 406 407 start_purge_config_timer() 408 409 410def purge_config_instances(): 411 """Removes cached config instances for no longer active threads.""" 412 _thread_config_lock.acquire() 413 try: 414 all_threads = set([thread.ident for thread in threading.enumerate()]) 415 threads_config = set(_thread_configs) 416 for thread_id in threads_config.difference(all_threads): 417 log.debug('Purging config instance for thread %s.', thread_id) 418 del _thread_configs[thread_id] 419 finally: 420 _thread_config_lock.release() 421