settings.py 11.4 KB
Newer Older
1
2
3
4
5
6
#!/usr/bin/env python
#
# settings.py - Persistent application settings.
#
# Author: Paul McCarthy <pauldmccarthy@gmail.com>
#
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
"""This module provides functions for storing and retrieving persistent
configuration settings and data files.

The :func:`initialise` function must be called to initialise the module. Then,
the following functions can be called at the module-level:

.. autosummary::
   :nosignatures:

   Settings.read
   Settings.write
   Settings.delete
   Settings.readFile
   Settings.writeFile
   Settings.deleteFile
22
   Settings.filePath
Paul McCarthy's avatar
Paul McCarthy committed
23
   Settings.readAll
24
   Settings.listFiles
25
26
   Settings.clear

Paul McCarthy's avatar
Paul McCarthy committed
27

28
29
30
31
32
33
34
35
36
These functions will have no effect before :func:`initialise` is called.

Two types of configuration data are available:

  - Key-value pairs - access these via the ``read``, ``write`` and ``delete``
    functions. These are stored in a single file, via ``pickle``. Anything
    that can be pickled can be stored.

  - Separate files, either text or binary. Access these via the ``readFile``,
Paul McCarthy's avatar
Paul McCarthy committed
37
    ``writeFile``, and ``deleteFile`` functions.
38
39
40
41

Both of the above data types will be stored in a configuration directory.
The location of this directory differs from platform to platform, but is
likely to be either  `~/.fslpy/` or `~/.config/fslpy/`.
42
43
44
"""


45
from __future__ import absolute_import
46

47
48
49
import            os
import os.path as op
import            sys
Paul McCarthy's avatar
Paul McCarthy committed
50
import            copy
51
52
53
54
import            atexit
import            shutil
import            pickle
import            logging
Paul McCarthy's avatar
Paul McCarthy committed
55
import            fnmatch
56
57
import            tempfile
import            platform
58
import            contextlib
59

60

61
62
63
log = logging.getLogger(__name__)


64
_CONFIG_ID = 'fslpy'
65
"""The default configuration identifier, used as the directory name for
66
storing configuration files.
67
68
69
"""


70
71
72
73
74
def initialise(*args, **kwargs):
    """Initialise the ``settings`` module. This function creates a
    :class:`Settings` instance, and enables the module-level
    functions. All settings are passed through to :meth:`Settings.__init__`.
    """
75

76
77
78
79
80
81
82
83
84
85
    mod = sys.modules[__name__]

    settings       = Settings(*args, **kwargs)
    mod.settings   = settings
    mod.read       = settings.read
    mod.write      = settings.write
    mod.delete     = settings.delete
    mod.readFile   = settings.readFile
    mod.writeFile  = settings.writeFile
    mod.deleteFile = settings.deleteFile
86
    mod.filePath   = settings.filePath
Paul McCarthy's avatar
Paul McCarthy committed
87
    mod.readAll    = settings.readAll
88
    mod.listFiles  = settings.listFiles
89
90
91
92
93
    mod.clear      = settings.clear


# These are all overwritten by
# the initialise function.
94
95
def read(name, default=None):
    return default
96
97
98
99
100
101
102
103
104
105
def write(*args, **kwargs):
    pass
def delete(*args, **kwargs):
    pass
def readFile(*args, **kwargs):
    pass
def writeFile(*args, **kwargs):
    pass
def deleteFile(*args, **kwargs):
    pass
106
def filePath(*args, **kwargs):
107
    pass
Paul McCarthy's avatar
Paul McCarthy committed
108
109
def readAll(*args, **kwarg):
    return {}
110
111
def listFiles(*args, **kwarg):
    return []
112
113
114
115
116
117
118
119
120
def clear(*args, **kwarg):
    pass


class Settings(object):
    """The ``Settings`` class contains all of the logic provided by the
    ``settings`` module.  It is not meant to be instantiated directly
    (although you may do so if you wish).
    """
121
122


123
124
    def __init__(self, cfgid=_CONFIG_ID, cfgdir=None, writeOnExit=True):
        """Create a ``Settings`` instance.
125

126
127
        :arg cfgid:       Configuration ID, used as the name of the
                          configuration directory.
128

129
        :arg cfgdir:      Store configuration settings in this directory,
130
                          instead of the default.
131

132
133
134
        :arg writeOnExit: If ``True`` (the default), an ``atexit`` function
                          is registered, which calls :meth:`writeConfigFile`.
        """
135

136
137
        if cfgdir is None:
            cfgdir = self.__getConfigDir(cfgid)
138

139
140
141
        self.__configID  = cfgid
        self.__configDir = cfgdir
        self.__config    = self.__readConfigFile()
142

143
144
        if writeOnExit:
            atexit.register(self.writeConfigFile)
145

146

147
148
149
150
    @property
    def configID(self):
        """Returns the configuration identifier. """
        return self.__configID
151
152


153
154
155
156
    @property
    def configDir(self):
        """Returns the location of the configuration directory. """
        return self.__configDir
157

158

159
160
161
    def read(self, name, default=None):
        """Reads a setting with the given ``name``, return ``default`` if
        there is no setting called ``name``.
162
        """
163

164
165
        log.debug('Reading {}/{}'.format(self.__configID, name))
        return self.__config.get(name, default)
166

167

168
169
    def write(self, name, value):
        """Writes the given ``value`` to the given file ``path``. """
170

171
172
        log.debug('Writing {}/{}: {}'.format(self.__configID, name, value))
        self.__config[name] = value
173

174

175
176
    def delete(self, name):
        """Delete the setting with the given ``name``. """
177

178
179
        log.debug('Deleting {}/{}'.format(self.__configID, name))
        self.__config.pop(name, None)
180
181


182
    def readFile(self, path, mode='t'):
183
        """Reads and returns the contents of the given file ``path``.
184
        Returns ``None`` if the path does not exist.
185
186

        :arg mode: ``'t'`` for text mode, or ``'b'`` for binary.
187
        """
188

189
        mode = 'r' + mode
190
        path = self.filePath(path)
191
192
193
194
195
196
197
198

        if op.exists(path):
            with open(path, mode) as f:
                return f.read()
        else:
            return None


199
200
201
202
203
204
205
    @contextlib.contextmanager
    def writeFile(self, path, mode='t'):
        """Write to the given file ``path``. This function is intended
        to be used as a context manager. For example::


            with settings.writeFile('mydata.txt') as f:
Paul McCarthy's avatar
Paul McCarthy committed
206
                f.write('data\\n')
207
208
209
210
211
212
213
214


        An alternate method of writing to a file is via :meth:`filePath`,
        e.g.::


            fname = settings.filePath('mydata.txt')
            with open(fname, 'wt') as f:
Paul McCarthy's avatar
Paul McCarthy committed
215
                f.write('data\\n')
216
217
218
219
220


        However using ``writeFile`` has the advantage that any intermediate
        directories will be created if they don't already exist.
        """
221
222

        mode    = 'w' + mode
223
        path    = self.filePath(path)
224
225
226
227
228
229
        pathdir = op.dirname(path)

        if not op.exists(pathdir):
            os.makedirs(pathdir)

        with open(path, mode) as f:
230
            yield f
231
232
233
234
235


    def deleteFile(self, path):
        """Deletes the given file ``path``. """

236
        path = self.filePath(path)
237
238
239
240
        if op.exists(path):
            os.remove(path)


241
    def filePath(self, path):
242
243
244
245
        """Converts the given ``path`` to an absolute path.  Note that there
        is no guarantee that the returned file path (or its containing
        directory) exists.
        """
246
247
248
249
250
251

        path = self.__fixPath(path)
        path = op.join(self.__configDir, path)
        return path


Paul McCarthy's avatar
Paul McCarthy committed
252
253
254
255
256
257
258
259
260
261
262
263
264
    def readAll(self, pattern=None):
        """Returns all settings with names that match the given glob-style
        pattern.
        """
        if pattern is None:
            return copy.deepcopy(self.__config)

        keys = fnmatch.filter(self.__config.keys(), pattern)
        vals = [copy.deepcopy(self.__config[k]) for k in keys]

        return dict(zip(keys, vals))


265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
    def listFiles(self, pattern=None):
        """Returns a list of all stored settings files which match the given
        glob-style pattern. If a pattern is not given, all files are returned.
        """
        allFiles = []

        if pattern is not None:
            pattern = self.__fixPath(pattern)

        for dirpath, dirnames, filenames in os.walk(self.__configDir):

            dirpath   = op.relpath(dirpath, self.__configDir)
            filenames = [op.join(dirpath, fn) for fn in filenames]

            if pattern is None:
                allFiles.extend(filenames)
            else:
                allFiles.extend(fnmatch.filter(filenames, pattern))

        return allFiles


287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
    def clear(self):
        """Delete all configuration settings and files. """

        log.debug('Clearing all settings in {}'.format(self.__configID))

        self.__config = {}

        for path in os.listdir(self.__configDir):
            path = op.join(self.__configDir, path)
            if op.isdir(path):
                shutil.rmtree(path)
            else:
                os.remove(path)


    def __fixPath(self, path):
        """Ensures that the given path (passed into :meth:`readFile`,
304
305
306
        :meth:`writeFile`, or :meth:`deleteFile`) is cross-platform
        compatible. Only works for paths which use ``'/'`` as the path
        separator.
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
        """
        return op.join(*path.split('/'))


    def __getConfigDir(self, cid):
        """Returns a directory in which configuration files can be stored.

        .. note:: If, for whatever reason, a configuration directory could not
                  be located or created, a temporary directory will be used.
                  This means that all settings read during this session will
                  be lost on exit.
        """

        cfgdir  = None
        homedir = op.expanduser('~')

323
        # On linux, if $XDG_CONFIG_HOME is set, use $XDG_CONFIG_HOME/fslpy/
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
        # Otherwise, use $HOME/.config/fslpy/
        #
        # https://standards.freedesktop.org/basedir-spec/basedir-spec-latest.html
        if platform.system().lower().startswith('linux'):

            basedir = os.environ.get('XDG_CONFIG_HOME')
            if basedir is None:
                basedir = op.join(homedir, '.config')

            cfgdir = op.join(basedir, cid)

        # On all other platforms, use $HOME/.fslpy/
        else:
            cfgdir = op.join(homedir, '.{}'.format(cid))

        # Try and create the config directory
        # tree if it does not exist
        if not op.exists(cfgdir):
            try:
                os.makedirs(cfgdir)
Paul McCarthy's avatar
Paul McCarthy committed
344
            except OSError:
345
346
347
348
349
350
                log.warning(
                    'Unable to create {} configuration '
                    'directory: {}'.format(cid, cfgdir),
                    exc_info=True)
                cfgdir = None

351
        # If dir creation failed, use a temporary
352
353
354
355
356
357
358
359
        # directory, and delete it on exit
        if cfgdir is None:
            cfgdir = tempfile.mkdtemp()
            atexit.register(shutil.rmtree, cfgdir, ignore_errors=True)

        log.debug('{} configuration directory: {}'.format(cid, cfgdir))

        return cfgdir
360
361


362
363
364
365
    def __readConfigFile(self):
        """Called by :meth:`__init__`. Reads any settings that were stored
        in a file, and returns them in a dictionary.
        """
366

367
        configFile = op.join(self.__configDir, 'config.pkl')
368

369
370
        log.debug('Reading {} configuration from: {}'.format(
            self.__configID, configFile))
371

372
373
374
        try:
            with open(configFile, 'rb') as f:
                return pickle.load(f)
375
        except (IOError, pickle.UnpicklingError, EOFError):
376
377
378
            log.debug('Unable to load stored {} configuration file '
                      '{}'.format(self.__configID, configFile),
                      exc_info=True)
379
            return {}
380

381
382

    def writeConfigFile(self):
383
        """Writes all settings to a file."""
384
385
386
387
388

        config     = self.__config
        configFile = op.join(self.__configDir, 'config.pkl')

        log.debug('Writing {} configuration to: {}'.format(
389
390
            self.__configID, configFile))

391
392
393
        try:
            with open(configFile, 'wb') as f:
                pickle.dump(config, f)
394
        except (IOError, pickle.PicklingError, EOFError):
395
396
397
            log.warning('Unable to save {} configuration file '
                        '{}'.format(self.__configID, configFile),
                        exc_info=True)