1# Copyright (C) 2008-2010 Adam Olsen
2#
3# This program is free software; you can redistribute it and/or modify
4# it under the terms of the GNU General Public License as published by
5# the Free Software Foundation; either version 2, or (at your option)
6# any later version.
7#
8# This program is distributed in the hope that it will be useful,
9# but WITHOUT ANY WARRANTY; without even the implied warranty of
10# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11# GNU General Public License for more details.
12#
13# You should have received a copy of the GNU General Public License
14# along with this program; if not, write to the Free Software
15# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
16#
17#
18# The developers of the Exaile media player hereby grant permission
19# for non-GPL compatible GStreamer and Exaile plugins to be used and
20# distributed together with GStreamer and Exaile. This permission is
21# above and beyond the permissions granted by the GPL license by which
22# Exaile is covered. If you modify this code, you may extend this
23# exception to your version of the code, but you are not obligated to
24# do so. If you do not wish to do so, delete this exception statement
25# from your version.
26
27"""
28    Central storage of application and user settings
29"""
30
31import ast
32from configparser import RawConfigParser, NoSectionError, NoOptionError
33import logging
34import os
35import sys
36from typing import Any, ClassVar
37
38logger = logging.getLogger(__name__)
39
40from xl import event, xdg
41from xl.common import VersionError, glib_wait, glib_wait_seconds
42from xl.nls import gettext as _
43
44MANAGER = None
45
46
47class SettingsManager(RawConfigParser):
48    """
49    Manages Exaile's settings
50    """
51
52    VERSION: ClassVar[int] = 2
53
54    _last_serial: ClassVar[int] = 0
55
56    # xl.common.glib_wait needs instances of this class to be hashable to use
57    # as key in its WeakKeyDictionary. We simply use an increasing serial
58    # number as this hash.
59    _serial: int
60
61    def __init__(self, location=None, default_location=None):
62        """
63        Sets up the settings manager. Expects a location
64        to a file where settings will be stored. Also sets up
65        periodic saves to disk.
66
67        :param location: the location to save the settings to,
68            settings will never be stored if this is None
69        :type location: str or None
70        :param default_location: the default location to
71            initialize settings from
72        """
73        RawConfigParser.__init__(self)
74
75        self.location = location
76        self._saving = False
77        self._dirty = False
78
79        self._serial = self.__class__._last_serial = self.__class__._last_serial + 1
80
81        if default_location is not None:
82            try:
83                self.read(default_location)
84            except Exception:
85                pass
86
87        if location is not None:
88            try:
89                self.read(self.location) or self.read(
90                    self.location + ".new"
91                ) or self.read(self.location + ".old")
92            except Exception:
93                pass
94
95        version = self.get_option('settings/version')
96        if version and version > self.VERSION:
97            raise VersionError(_('Settings version is newer than current.'))
98        if version != self.VERSION:
99            self.set_option('settings/version', self.VERSION)
100
101        # save settings every 30 seconds
102        if location is not None:
103            self._timeout_save()
104
105    def __hash__(self):
106        return self._serial
107
108    @glib_wait_seconds(30)
109    def _timeout_save(self):
110        """Save every 30 seconds"""
111        self.save()
112        return True
113
114    def copy_settings(self, settings):
115        """
116        Copies one all of the settings contained
117        in this instance to another
118
119        :param settings: the settings object to copy to
120        :type settings: :class:`xl.settings.SettingsManager`
121        """
122        for section in self.sections():
123            for (key, value) in self.items(section):
124                settings._set_direct('%s/%s' % (section, key), value)
125
126    def clone(self):
127        """
128        Creates a copy of this settings object
129        """
130        settings = SettingsManager(None)
131        self.copy_settings(settings)
132        return settings
133
134    def set_option(self, option, value, save=True):
135        """
136        Set an option (in ``section/key`` syntax) to the specified value
137
138        :param option: the full path to an option
139        :type option: string
140        :param value: the value the option should be assigned
141        :type value: any
142        :param save: If True, cause the settings to be written to file
143        """
144        value = self._val_to_str(value)
145        splitvals = option.split('/')
146        section, key = "/".join(splitvals[:-1]), splitvals[-1]
147
148        try:
149            self.set(section, key, value)
150        except NoSectionError:
151            self.add_section(section)
152            self.set(section, key, value)
153
154        self._dirty = True
155
156        if save:
157            self.delayed_save()
158
159        section = section.replace('/', '_')
160
161        event.log_event('option_set', self, option)
162        event.log_event('%s_option_set' % section, self, option)
163
164    def get_option(self, option: str, default: Any = None) -> Any:
165        """
166        Get the value of an option (in ``section/key`` syntax),
167        returning *default* if the key does not exist yet
168
169        :param option: the full path to an option
170        :param default: a default value to use as fallback
171        :returns: the option value or *default*
172        """
173        splitvals = option.split('/')
174        section, key = "/".join(splitvals[:-1]), splitvals[-1]
175
176        try:
177            value = self.get(section, key)
178            value = self._str_to_val(value)
179        except NoSectionError:
180            value = default
181        except NoOptionError:
182            value = default
183
184        return value
185
186    def has_option(self, option):
187        """
188        Returns information about the existence
189        of a particular option
190
191        :param option: the option path
192        :type option: string
193        :returns: whether the option exists or not
194        :rtype: bool
195        """
196        splitvals = option.split('/')
197        section, key = "/".join(splitvals[:-1]), splitvals[-1]
198
199        return RawConfigParser.has_option(self, section, key)
200
201    def remove_option(self, option):
202        """
203        Removes an option (in ``section/key`` syntax),
204        thus will not be saved anymore
205
206        :param option: the option path
207        :type option: string
208        """
209        splitvals = option.split('/')
210        section, key = "/".join(splitvals[:-1]), splitvals[-1]
211
212        RawConfigParser.remove_option(self, section, key)
213
214    def _set_direct(self, option, value):
215        """
216        Sets the option directly to the value,
217        only for use in copying settings.
218
219        :param option: the option path
220        :type option: string
221        :param value: the value to set
222        :type value: any
223        """
224        splitvals = option.split('/')
225        section, key = "/".join(splitvals[:-1]), splitvals[-1]
226
227        try:
228            self.set(section, key, value)
229        except NoSectionError:
230            self.add_section(section)
231            self.set(section, key, value)
232
233        event.log_event('option_set', self, option)
234
235    def _val_to_str(self, value):
236        """
237        Turns a value of some type into a string so it
238        can be a configuration value.
239        """
240        for kind, type_ in (
241            # bool is subclass of int so it must appear earlier
242            ('B', bool),
243            ('I', int),
244            ('F', float),
245            ('L', list),
246            ('D', dict),
247        ):
248            if isinstance(value, type_):
249                return '%s: %r' % (kind, value)
250        if isinstance(value, str):
251            return 'S: %s' % value  # Not quoted, hence %s
252
253        raise ValueError(
254            "Don't know how to store setting %r of type %s" % (value, type(value))
255        )
256
257    def _str_to_val(self, configstr):
258        """
259        Convert setting strings back to normal values.
260        """
261        try:
262            kind, value = configstr.split(': ', 1)
263        except ValueError:
264            return ''
265
266        if kind == 'B':  # Must appear before I
267            return value == 'True'
268        if kind in 'SU':  # U is for backwards compatibility (Python 2 unicode)
269            return value
270        try:
271            if kind == 'I':
272                return int(value)
273            if kind == 'F':
274                return float(value)
275            for kind in 'LD':
276                return ast.literal_eval(value)
277        except Exception:
278            logger.exception("Failed decoding value %r of kind %s", value, kind)
279            return value
280
281        raise ValueError("Unknown type of setting on: %s", configstr)
282
283    @glib_wait(500)
284    def delayed_save(self):
285        '''Save options after a delay, waiting for multiple saves to accumulate'''
286        self.save()
287
288    def save(self):
289        """
290        Save the settings to disk
291        """
292        if self.location is None:
293            logger.debug("Save requested but not saving settings, " "location is None")
294            return
295
296        if self._saving or not self._dirty:
297            return
298
299        self._saving = True
300
301        logger.debug("Saving settings...")
302
303        with open(self.location + ".new", 'w') as f:
304            self.write(f)
305
306            try:
307                # make it readable by current user only, to protect private data
308                os.fchmod(f.fileno(), 384)
309            except Exception:
310                pass  # fail gracefully, eg if on windows
311
312            f.flush()
313
314        try:
315            os.rename(self.location, self.location + ".old")
316        except Exception:
317            pass  # if it doesn'texist we don't care
318
319        os.rename(self.location + ".new", self.location)
320
321        try:
322            os.remove(self.location + ".old")
323        except Exception:
324            pass
325
326        self._saving = False
327        self._dirty = False
328
329
330location = xdg.get_config_dir()
331
332
333# Provide a mechanism for setting up default settings for different platforms
334if sys.platform == 'win32':
335    __settings_file = 'settings-win32.ini'
336elif sys.platform == 'darwin':
337    __settings_file = 'settings-osx.ini'
338else:
339    __settings_file = 'settings.ini'
340
341
342MANAGER = SettingsManager(
343    os.path.join(location, "settings.ini"), xdg.get_config_path("settings.ini")
344)
345
346get_option = MANAGER.get_option
347set_option = MANAGER.set_option
348
349# vim: et sts=4 sw=4
350