1# -*- coding: utf-8 -*- 2"""Read and write an application's config files.""" 3 4from __future__ import unicode_literals 5import io 6import logging 7import os 8 9from configobj import ConfigObj, ConfigObjError 10from validate import ValidateError, Validator 11 12from .compat import MAC, text_type, UserDict, WIN 13 14logger = logging.getLogger(__name__) 15 16 17class ConfigError(Exception): 18 """Base class for exceptions in this module.""" 19 20 pass 21 22 23class DefaultConfigValidationError(ConfigError): 24 """Indicates the default config file did not validate correctly.""" 25 26 pass 27 28 29class Config(UserDict, object): 30 """Config reader/writer class. 31 32 :param str app_name: The application's name. 33 :param str app_author: The application author/organization. 34 :param str filename: The config filename to look for (e.g. ``config``). 35 :param dict/str default: The default config values or absolute path to 36 config file. 37 :param bool validate: Whether or not to validate the config file. 38 :param bool write_default: Whether or not to write the default config 39 file to the user config directory if it doesn't 40 already exist. 41 :param tuple additional_dirs: Additional directories to check for a config 42 file. 43 """ 44 45 def __init__( 46 self, 47 app_name, 48 app_author, 49 filename, 50 default=None, 51 validate=False, 52 write_default=False, 53 additional_dirs=(), 54 ): 55 super(Config, self).__init__() 56 #: The :class:`ConfigObj` instance. 57 self.data = ConfigObj(encoding="utf8") 58 59 self.default = {} 60 self.default_file = self.default_config = None 61 self.config_filenames = [] 62 63 self.app_name, self.app_author = app_name, app_author 64 self.filename = filename 65 self.write_default = write_default 66 self.validate = validate 67 self.additional_dirs = additional_dirs 68 69 if isinstance(default, dict): 70 self.default = default 71 self.update(default) 72 elif isinstance(default, text_type): 73 self.default_file = default 74 elif default is not None: 75 raise TypeError( 76 '"default" must be a dict or {}, not {}'.format( 77 text_type.__name__, type(default) 78 ) 79 ) 80 81 if self.write_default and not self.default_file: 82 raise ValueError( 83 'Cannot use "write_default" without specifying ' "a default file." 84 ) 85 86 if self.validate and not self.default_file: 87 raise ValueError( 88 'Cannot use "validate" without specifying a ' "default file." 89 ) 90 91 def read_default_config(self): 92 """Read the default config file. 93 94 :raises DefaultConfigValidationError: There was a validation error with 95 the *default* file. 96 """ 97 if self.validate: 98 self.default_config = ConfigObj( 99 configspec=self.default_file, 100 list_values=False, 101 _inspec=True, 102 encoding="utf8", 103 ) 104 # ConfigObj does not set the encoding on the configspec. 105 self.default_config.configspec.encoding = "utf8" 106 107 valid = self.default_config.validate( 108 Validator(), copy=True, preserve_errors=True 109 ) 110 if valid is not True: 111 for name, section in valid.items(): 112 if section is True: 113 continue 114 for key, value in section.items(): 115 if isinstance(value, ValidateError): 116 raise DefaultConfigValidationError( 117 'section [{}], key "{}": {}'.format(name, key, value) 118 ) 119 elif self.default_file: 120 self.default_config, _ = self.read_config_file(self.default_file) 121 122 self.update(self.default_config) 123 124 def read(self): 125 """Read the default, additional, system, and user config files. 126 127 :raises DefaultConfigValidationError: There was a validation error with 128 the *default* file. 129 """ 130 if self.default_file: 131 self.read_default_config() 132 return self.read_config_files(self.all_config_files()) 133 134 def user_config_file(self): 135 """Get the absolute path to the user config file.""" 136 return os.path.join( 137 get_user_config_dir(self.app_name, self.app_author), self.filename 138 ) 139 140 def system_config_files(self): 141 """Get a list of absolute paths to the system config files.""" 142 return [ 143 os.path.join(f, self.filename) 144 for f in get_system_config_dirs(self.app_name, self.app_author) 145 ] 146 147 def additional_files(self): 148 """Get a list of absolute paths to the additional config files.""" 149 return [os.path.join(f, self.filename) for f in self.additional_dirs] 150 151 def all_config_files(self): 152 """Get a list of absolute paths to all the config files.""" 153 return ( 154 self.additional_files() 155 + self.system_config_files() 156 + [self.user_config_file()] 157 ) 158 159 def write_default_config(self, overwrite=False): 160 """Write the default config to the user's config file. 161 162 :param bool overwrite: Write over an existing config if it exists. 163 """ 164 destination = self.user_config_file() 165 if not overwrite and os.path.exists(destination): 166 return 167 168 with io.open(destination, mode="wb") as f: 169 self.default_config.write(f) 170 171 def write(self, outfile=None, section=None): 172 """Write the current config to a file (defaults to user config). 173 174 :param str outfile: The path to the file to write to. 175 :param None/str section: The config section to write, or :data:`None` 176 to write the entire config. 177 """ 178 with io.open(outfile or self.user_config_file(), "wb") as f: 179 self.data.write(outfile=f, section=section) 180 181 def read_config_file(self, f): 182 """Read a config file *f*. 183 184 :param str f: The path to a file to read. 185 """ 186 configspec = self.default_file if self.validate else None 187 try: 188 config = ConfigObj( 189 infile=f, configspec=configspec, interpolation=False, encoding="utf8" 190 ) 191 # ConfigObj does not set the encoding on the configspec. 192 if config.configspec is not None: 193 config.configspec.encoding = "utf8" 194 except ConfigObjError as e: 195 logger.warning( 196 "Unable to parse line {} of config file {}".format(e.line_number, f) 197 ) 198 config = e.config 199 200 valid = True 201 if self.validate: 202 valid = config.validate(Validator(), preserve_errors=True, copy=True) 203 if bool(config): 204 self.config_filenames.append(config.filename) 205 206 return config, valid 207 208 def read_config_files(self, files): 209 """Read a list of config files. 210 211 :param iterable files: An iterable (e.g. list) of files to read. 212 """ 213 errors = {} 214 for _file in files: 215 config, valid = self.read_config_file(_file) 216 self.update(config) 217 if valid is not True: 218 errors[_file] = valid 219 return errors or True 220 221 222def get_user_config_dir(app_name, app_author, roaming=True, force_xdg=True): 223 """Returns the config folder for the application. The default behavior 224 is to return whatever is most appropriate for the operating system. 225 226 For an example application called ``"My App"`` by ``"Acme"``, 227 something like the following folders could be returned: 228 229 macOS (non-XDG): 230 ``~/Library/Application Support/My App`` 231 Mac OS X (XDG): 232 ``~/.config/my-app`` 233 Unix: 234 ``~/.config/my-app`` 235 Windows 7 (roaming): 236 ``C:\\Users\\<user>\\AppData\\Roaming\\Acme\\My App`` 237 Windows 7 (not roaming): 238 ``C:\\Users\\<user>\\AppData\\Local\\Acme\\My App`` 239 240 :param app_name: the application name. This should be properly capitalized 241 and can contain whitespace. 242 :param app_author: The app author's name (or company). This should be 243 properly capitalized and can contain whitespace. 244 :param roaming: controls if the folder should be roaming or not on Windows. 245 Has no effect on non-Windows systems. 246 :param force_xdg: if this is set to `True`, then on macOS the XDG Base 247 Directory Specification will be followed. Has no effect 248 on non-macOS systems. 249 250 """ 251 if WIN: 252 key = "APPDATA" if roaming else "LOCALAPPDATA" 253 folder = os.path.expanduser(os.environ.get(key, "~")) 254 return os.path.join(folder, app_author, app_name) 255 if MAC and not force_xdg: 256 return os.path.join( 257 os.path.expanduser("~/Library/Application Support"), app_name 258 ) 259 return os.path.join( 260 os.path.expanduser(os.environ.get("XDG_CONFIG_HOME", "~/.config")), 261 _pathify(app_name), 262 ) 263 264 265def get_system_config_dirs(app_name, app_author, force_xdg=True): 266 r"""Returns a list of system-wide config folders for the application. 267 268 For an example application called ``"My App"`` by ``"Acme"``, 269 something like the following folders could be returned: 270 271 macOS (non-XDG): 272 ``['/Library/Application Support/My App']`` 273 Mac OS X (XDG): 274 ``['/etc/xdg/my-app']`` 275 Unix: 276 ``['/etc/xdg/my-app']`` 277 Windows 7: 278 ``['C:\ProgramData\Acme\My App']`` 279 280 :param app_name: the application name. This should be properly capitalized 281 and can contain whitespace. 282 :param app_author: The app author's name (or company). This should be 283 properly capitalized and can contain whitespace. 284 :param force_xdg: if this is set to `True`, then on macOS the XDG Base 285 Directory Specification will be followed. Has no effect 286 on non-macOS systems. 287 288 """ 289 if WIN: 290 folder = os.environ.get("PROGRAMDATA") 291 return [os.path.join(folder, app_author, app_name)] 292 if MAC and not force_xdg: 293 return [os.path.join("/Library/Application Support", app_name)] 294 dirs = os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg") 295 paths = [os.path.expanduser(x) for x in dirs.split(os.pathsep)] 296 return [os.path.join(d, _pathify(app_name)) for d in paths] 297 298 299def _pathify(s): 300 """Convert spaces to hyphens and lowercase a string.""" 301 return "-".join(s.split()).lower() 302