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