summaryrefslogtreecommitdiffstats
path: root/cli_helpers/config.py
diff options
context:
space:
mode:
Diffstat (limited to 'cli_helpers/config.py')
-rw-r--r--cli_helpers/config.py270
1 files changed, 270 insertions, 0 deletions
diff --git a/cli_helpers/config.py b/cli_helpers/config.py
new file mode 100644
index 0000000..3d6cb16
--- /dev/null
+++ b/cli_helpers/config.py
@@ -0,0 +1,270 @@
+# -*- coding: utf-8 -*-
+"""Read and write an application's config files."""
+
+from __future__ import unicode_literals
+import io
+import logging
+import os
+
+from configobj import ConfigObj, ConfigObjError
+from validate import ValidateError, Validator
+
+from .compat import MAC, text_type, UserDict, WIN
+
+logger = logging.getLogger(__name__)
+
+
+class ConfigError(Exception):
+ """Base class for exceptions in this module."""
+ pass
+
+
+class DefaultConfigValidationError(ConfigError):
+ """Indicates the default config file did not validate correctly."""
+ pass
+
+
+class Config(UserDict, object):
+ """Config reader/writer class.
+
+ :param str app_name: The application's name.
+ :param str app_author: The application author/organization.
+ :param str filename: The config filename to look for (e.g. ``config``).
+ :param dict/str default: The default config values or absolute path to
+ config file.
+ :param bool validate: Whether or not to validate the config file.
+ :param bool write_default: Whether or not to write the default config
+ file to the user config directory if it doesn't
+ already exist.
+ :param tuple additional_dirs: Additional directories to check for a config
+ file.
+ """
+
+ def __init__(self, app_name, app_author, filename, default=None,
+ validate=False, write_default=False, additional_dirs=()):
+ super(Config, self).__init__()
+ #: The :class:`ConfigObj` instance.
+ self.data = ConfigObj()
+
+ self.default = {}
+ self.default_file = self.default_config = None
+ self.config_filenames = []
+
+ self.app_name, self.app_author = app_name, app_author
+ self.filename = filename
+ self.write_default = write_default
+ self.validate = validate
+ self.additional_dirs = additional_dirs
+
+ if isinstance(default, dict):
+ self.default = default
+ self.update(default)
+ elif isinstance(default, text_type):
+ self.default_file = default
+ elif default is not None:
+ raise TypeError(
+ '"default" must be a dict or {}, not {}'.format(
+ text_type.__name__, type(default)))
+
+ if self.write_default and not self.default_file:
+ raise ValueError('Cannot use "write_default" without specifying '
+ 'a default file.')
+
+ if self.validate and not self.default_file:
+ raise ValueError('Cannot use "validate" without specifying a '
+ 'default file.')
+
+ def read_default_config(self):
+ """Read the default config file.
+
+ :raises DefaultConfigValidationError: There was a validation error with
+ the *default* file.
+ """
+ if self.validate:
+ self.default_config = ConfigObj(configspec=self.default_file,
+ list_values=False, _inspec=True,
+ encoding='utf8')
+ valid = self.default_config.validate(Validator(), copy=True,
+ preserve_errors=True)
+ if valid is not True:
+ for name, section in valid.items():
+ if section is True:
+ continue
+ for key, value in section.items():
+ if isinstance(value, ValidateError):
+ raise DefaultConfigValidationError(
+ 'section [{}], key "{}": {}'.format(
+ name, key, value))
+ elif self.default_file:
+ self.default_config, _ = self.read_config_file(self.default_file)
+
+ self.update(self.default_config)
+
+ def read(self):
+ """Read the default, additional, system, and user config files.
+
+ :raises DefaultConfigValidationError: There was a validation error with
+ the *default* file.
+ """
+ if self.default_file:
+ self.read_default_config()
+ return self.read_config_files(self.all_config_files())
+
+ def user_config_file(self):
+ """Get the absolute path to the user config file."""
+ return os.path.join(
+ get_user_config_dir(self.app_name, self.app_author),
+ self.filename)
+
+ def system_config_files(self):
+ """Get a list of absolute paths to the system config files."""
+ return [os.path.join(f, self.filename) for f in get_system_config_dirs(
+ self.app_name, self.app_author)]
+
+ def additional_files(self):
+ """Get a list of absolute paths to the additional config files."""
+ return [os.path.join(f, self.filename) for f in self.additional_dirs]
+
+ def all_config_files(self):
+ """Get a list of absolute paths to all the config files."""
+ return (self.additional_files() + self.system_config_files() +
+ [self.user_config_file()])
+
+ def write_default_config(self, overwrite=False):
+ """Write the default config to the user's config file.
+
+ :param bool overwrite: Write over an existing config if it exists.
+ """
+ destination = self.user_config_file()
+ if not overwrite and os.path.exists(destination):
+ return
+
+ with io.open(destination, mode='wb') as f:
+ self.default_config.write(f)
+
+ def write(self, outfile=None, section=None):
+ """Write the current config to a file (defaults to user config).
+
+ :param str outfile: The path to the file to write to.
+ :param None/str section: The config section to write, or :data:`None`
+ to write the entire config.
+ """
+ with io.open(outfile or self.user_config_file(), 'wb') as f:
+ self.data.write(outfile=f, section=section)
+
+ def read_config_file(self, f):
+ """Read a config file *f*.
+
+ :param str f: The path to a file to read.
+ """
+ configspec = self.default_file if self.validate else None
+ try:
+ config = ConfigObj(infile=f, configspec=configspec,
+ interpolation=False, encoding='utf8')
+ except ConfigObjError as e:
+ logger.warning(
+ 'Unable to parse line {} of config file {}'.format(
+ e.line_number, f))
+ config = e.config
+
+ valid = True
+ if self.validate:
+ valid = config.validate(Validator(), preserve_errors=True,
+ copy=True)
+ if bool(config):
+ self.config_filenames.append(config.filename)
+
+ return config, valid
+
+ def read_config_files(self, files):
+ """Read a list of config files.
+
+ :param iterable files: An iterable (e.g. list) of files to read.
+ """
+ errors = {}
+ for _file in files:
+ config, valid = self.read_config_file(_file)
+ self.update(config)
+ if valid is not True:
+ errors[_file] = valid
+ return errors or True
+
+
+def get_user_config_dir(app_name, app_author, roaming=True, force_xdg=True):
+ """Returns the config folder for the application. The default behavior
+ is to return whatever is most appropriate for the operating system.
+
+ For an example application called ``"My App"`` by ``"Acme"``,
+ something like the following folders could be returned:
+
+ macOS (non-XDG):
+ ``~/Library/Application Support/My App``
+ Mac OS X (XDG):
+ ``~/.config/my-app``
+ Unix:
+ ``~/.config/my-app``
+ Windows 7 (roaming):
+ ``C:\\Users\\<user>\\AppData\\Roaming\\Acme\\My App``
+ Windows 7 (not roaming):
+ ``C:\\Users\\<user>\\AppData\\Local\\Acme\\My App``
+
+ :param app_name: the application name. This should be properly capitalized
+ and can contain whitespace.
+ :param app_author: The app author's name (or company). This should be
+ properly capitalized and can contain whitespace.
+ :param roaming: controls if the folder should be roaming or not on Windows.
+ Has no effect on non-Windows systems.
+ :param force_xdg: if this is set to `True`, then on macOS the XDG Base
+ Directory Specification will be followed. Has no effect
+ on non-macOS systems.
+
+ """
+ if WIN:
+ key = 'APPDATA' if roaming else 'LOCALAPPDATA'
+ folder = os.path.expanduser(os.environ.get(key, '~'))
+ return os.path.join(folder, app_author, app_name)
+ if MAC and not force_xdg:
+ return os.path.join(os.path.expanduser(
+ '~/Library/Application Support'), app_name)
+ return os.path.join(
+ os.path.expanduser(os.environ.get('XDG_CONFIG_HOME', '~/.config')),
+ _pathify(app_name))
+
+
+def get_system_config_dirs(app_name, app_author, force_xdg=True):
+ r"""Returns a list of system-wide config folders for the application.
+
+ For an example application called ``"My App"`` by ``"Acme"``,
+ something like the following folders could be returned:
+
+ macOS (non-XDG):
+ ``['/Library/Application Support/My App']``
+ Mac OS X (XDG):
+ ``['/etc/xdg/my-app']``
+ Unix:
+ ``['/etc/xdg/my-app']``
+ Windows 7:
+ ``['C:\ProgramData\Acme\My App']``
+
+ :param app_name: the application name. This should be properly capitalized
+ and can contain whitespace.
+ :param app_author: The app author's name (or company). This should be
+ properly capitalized and can contain whitespace.
+ :param force_xdg: if this is set to `True`, then on macOS the XDG Base
+ Directory Specification will be followed. Has no effect
+ on non-macOS systems.
+
+ """
+ if WIN:
+ folder = os.environ.get('PROGRAMDATA')
+ return [os.path.join(folder, app_author, app_name)]
+ if MAC and not force_xdg:
+ return [os.path.join('/Library/Application Support', app_name)]
+ dirs = os.environ.get('XDG_CONFIG_DIRS', '/etc/xdg')
+ paths = [os.path.expanduser(x) for x in dirs.split(os.pathsep)]
+ return [os.path.join(d, _pathify(app_name)) for d in paths]
+
+
+def _pathify(s):
+ """Convert spaces to hyphens and lowercase a string."""
+ return '-'.join(s.split()).lower()