Source code for cli_helpers.config

# -*- 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__)


[docs]class ConfigError(Exception): """Base class for exceptions in this module.""" pass
[docs]class DefaultConfigValidationError(ConfigError): """Indicates the default config file did not validate correctly.""" pass
[docs]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.')
[docs] 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)
[docs] 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())
[docs] 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)
[docs] 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)]
[docs] 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]
[docs] 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()])
[docs] 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)
[docs] 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)
[docs] 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
[docs] 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
[docs]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))
[docs]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()