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