Coverage for /home/pradyumna/Languages/python/packages/xdgpspconf/xdgpspconf/config.py : 43%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1#!/usr/bin/env python3
2# -*- coding: utf-8; mode: python; -*-
3# Copyright © 2020-2021 Pradyumna Paranjape
4#
5# This file is part of xdgpspconf.
6#
7# xdgpspconf is free software: you can redistribute it and/or modify
8# it under the terms of the GNU Lesser General Public License as published by
9# the Free Software Foundation, either version 3 of the License, or
10# (at your option) any later version.
11#
12# xdgpspconf is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15# GNU Lesser General Public License for more details.
16#
17# You should have received a copy of the GNU Lesser General Public License
18# along with xdgpspconf. If not, see <https://www.gnu.org/licenses/>.
19#
20"""
21Locate and read configurations.
23Read:
24 - standard xdg-base locations
25 - current directory and ancestors
26 - custom location
28"""
30import configparser
31import os
32from pathlib import Path
33from typing import Any, Dict, List, Union
35import toml
36import yaml
38from xdgpspconf.common import locate_base, walk_ancestors, xdg_base
39from xdgpspconf.errors import BadConf
42def _parse_yaml(config: Path) -> Dict[str, Any]:
43 """
44 Read configuration.
46 Specified as a yaml file:
47 - .rc
48 - style.yml
49 - *.yml
50 """
51 with open(config, 'r') as rcfile:
52 conf: Dict[str, Any] = yaml.safe_load(rcfile)
53 if conf is None: # pragma: no cover
54 raise yaml.YAMLError
55 return conf
58def _write_yaml(data: Dict[str, Any],
59 config: Path,
60 force: str = 'fail') -> bool:
61 """
62 Write data to configuration file.
64 Args:
65 data: serial data to save
66 config: configuration file path
67 force: force overwrite {'overwrite', 'update', 'fail'}
69 """
70 old_data: Dict[str, Any] = {}
71 if config.is_file():
72 # file already exists
73 if force == 'fail':
74 return False
75 if force == 'update':
76 old_data = _parse_yaml(config)
77 data = {**old_data, **data}
78 with open(config, 'w') as rcfile:
79 yaml.dump(data, rcfile)
80 return True
83def _parse_ini(config: Path, sub_section: bool = False) -> Dict[str, Any]:
84 """
85 Read configuration.
87 Supplied in ``setup.cfg`` OR
88 - *.cfg
89 - *.conf
90 - *.ini
91 """
92 parser = configparser.ConfigParser()
93 parser.read(config)
94 if sub_section:
95 return {
96 pspcfg.replace('.', ''): dict(parser.items(pspcfg))
97 for pspcfg in parser.sections() if '' in pspcfg
98 }
99 return {
100 pspcfg: dict(parser.items(pspcfg))
101 for pspcfg in parser.sections()
102 } # pragma: no cover
105def _write_ini(data: Dict[str, Any],
106 config: Path,
107 force: str = 'fail') -> bool:
108 """
109 Write data to configuration file.
111 Args:
112 data: serial data to save
113 config: configuration file path
114 force: force overwrite {'overwrite', 'update', 'fail'}
116 """
117 old_data: Dict[str, Any] = {}
118 if config.is_file():
119 # file already exists
120 if force == 'fail':
121 return False
122 if force == 'update':
123 old_data = _parse_ini(config)
124 data = {**old_data, **data}
125 parser = configparser.ConfigParser()
126 parser.update(data)
127 with open(config, 'w') as rcfile:
128 parser.write(rcfile)
129 return True
132def _parse_toml(config: Path, sub_section: bool = False) -> Dict[str, Any]:
133 """
134 Read configuration.
136 Supplied in ``pyproject.toml`` OR
137 - *.toml
138 """
139 if sub_section:
140 with open(config, 'r') as rcfile:
141 conf: Dict[str, Any] = toml.load(rcfile).get('', {})
142 return conf
143 with open(config, 'r') as rcfile:
144 conf = dict(toml.load(rcfile))
145 if conf is None: # pragma: no cover
146 raise toml.TomlDecodeError
147 return conf
150def _write_toml(data: Dict[str, Any],
151 config: Path,
152 force: str = 'fail') -> bool:
153 """
154 Write data to configuration file.
156 Args:
157 data: serial data to save
158 config: configuration file path
159 force: force overwrite {'overwrite', 'update', 'fail'}
161 """
162 old_data: Dict[str, Any] = {}
163 if config.is_file():
164 # file already exists
165 if force == 'fail':
166 return False
167 if force == 'update':
168 old_data = _parse_toml(config)
169 data = {**old_data, **data}
170 with open(config, 'w') as rcfile:
171 toml.dump(data, rcfile)
172 return True
175def _parse_rc(config: Path) -> Dict[str, Any]:
176 """
177 Parse rc file.
179 Args:
180 config: path to configuration file
182 Returns:
183 configuration sections
185 Raises:
186 BadConf: Bad configuration
188 """
189 if config.name == 'setup.cfg':
190 # declared inside setup.cfg
191 return _parse_ini(config, sub_section=True)
192 if config.name == 'pyproject.toml':
193 # declared inside pyproject.toml
194 return _parse_toml(config, sub_section=True)
195 try:
196 # yaml configuration format
197 return _parse_yaml(config)
198 except yaml.YAMLError:
199 try:
200 # toml configuration format
201 return _parse_toml(config)
202 except toml.TomlDecodeError:
203 try:
204 # try generic config-parser
205 return _parse_ini(config)
206 except configparser.Error:
207 raise BadConf(config_file=config) from None
210def _write_rc(data: Dict[str, Any], config: Path, force: str = 'fail') -> bool:
211 """
212 Write data to configuration file.
214 Args:
215 data: serial data to save
216 config: configuration file path
217 force: force overwrite {'overwrite', 'update', 'fail'}
219 Returns: success
220 """
221 if config.suffix in ('.conf', '.cfg', '.ini'):
222 return _write_ini(data, config, force)
223 if config.suffix == '.toml':
224 return _write_toml(data, config, force)
225 # assume yaml
226 return _write_yaml(data, config, force)
229def ancestral_config(child_dir: Path, rcfile: str) -> List[Path]:
230 """
231 Walk up to nearest mountpoint or project root.
233 - collect all directories containing __init__.py
234 (assumed to be source directories)
235 - project root is directory that contains ``setup.cfg`` or ``setup.py``
236 - mountpoint is a unix mountpoint or windows drive root
237 - I am **NOT** my ancestor
239 Args:
240 child_dir: walk ancestry of `this` directory
241 rcfile: name of rcfile
243 Returns:
244 List of Paths to ancestral configurations:
245 First directory is most dominant
246 """
247 config_dirs = walk_ancestors(child_dir)
248 # setup.cfg, pyproject.toml are missing
250 config_heir: List[Path] = [conf_dir / rcfile for conf_dir in config_dirs]
251 for sub_section_file in ('pyproject.toml', 'setup.cfg'):
252 config_heir.append(config_dirs[-1] / sub_section_file)
253 return config_heir
256def xdg_config() -> List[Path]:
257 """
258 Get XDG_CONFIG_HOME locations.
260 `specifications
261 <https://specifications.freedesktop.org/basedir-spec/latest/ar01s03.html>`__
263 Returns:
264 List of xdg-config Paths
265 First directory is most dominant
266 """
267 return xdg_base('CONFIG')
270def locate_config(project: str,
271 custom: os.PathLike = None,
272 ancestors: bool = False,
273 cname: str = 'config',
274 py_bin: os.PathLike = None) -> List[Path]:
275 """
276 Locate configurations at standard locations.
278 Args:
279 project: name of project whose configuration is being fetched
280 custom: custom location for configuration
281 ancestors: inherit ancestor directories that contain __init__.py
282 cname: name of config file
283 py_bin: namespace.__file__ that imports this function
285 Returns:
286 List of all possible configuration paths:
287 Existing and non-existing
288 First directory is most dominant
290 """
291 _custom_p = Path(custom).parent if custom else None
292 config_dirs = locate_base(project, _custom_p, ancestors, 'CONFIG', py_bin)
293 # missing: filename, .{project}RC /cname config/cname
294 # Preference of configurations *Most dominant first*
295 config_heir: List[Path] = []
296 for conf_dir in config_dirs:
297 # config in ancestor files should be an rc file
298 if (conf_dir in Path('.').resolve().parents
299 or conf_dir == Path('.').resolve()):
300 if conf_dir == _custom_p:
301 config_heir.append(conf_dir /
302 Path(custom).name) # type: ignore
303 else:
304 config_heir.append(conf_dir / f'.{project}rc')
305 else:
306 # non-ancestor
307 for ext in '.yml', '.yaml', '.toml', '.conf':
308 config_heir.append((conf_dir / cname).with_suffix(ext))
310 # environment variable
311 rc_val = os.environ.get(project.upper() + 'RC')
312 if rc_val is not None:
313 if not Path(rc_val).is_file():
314 raise FileNotFoundError(
315 f'RC configuration file: {rc_val} not found')
316 insert_pos = 1 if custom else 0
317 config_heir.insert(insert_pos, Path(rc_val))
319 for config in config_heir:
320 print(config)
321 return config_heir
324def safe_config(project: str,
325 custom: os.PathLike = None,
326 ext: Union[str, List[str]] = None,
327 ancestors: bool = False,
328 cname: str = 'config') -> List[Path]:
329 """
330 Locate safe writable paths of configuration files.
332 - Doesn't care about accessibility or existance of locations.
333 - User must catch:
334 - ``PermissionError``
335 - ``IsADirectoryError``
336 - ``FileNotFoundError``
337 - Recommendation: Try saving your configuration in in reversed order.
339 Args:
340 project: name of project whose configuration is being fetched
341 custom: custom location for configuration
342 ext: extension filter(s)
343 ancestors: inherit ancestor directories that contain ``__init__.py``
344 cname: name of config file
346 Returns:
347 Paths: First path is most dominant
349 """
350 if isinstance(ext, str):
351 ext = [ext]
352 safe_paths: List[Path] = []
353 for loc in locate_config(project, custom, ancestors, cname):
354 if any(private in str(loc)
355 for private in ('site-packages', 'venv', '/etc', 'setup',
356 'pyproject')):
357 continue
358 if ext and loc.suffix not in list(ext):
359 continue
360 safe_paths.append(loc)
361 return safe_paths
364def read_config(project: str,
365 custom: os.PathLike = None,
366 ancestors: bool = False,
367 cname: str = 'config',
368 py_bin: os.PathLike = None) -> Dict[Path, Dict[str, Any]]:
369 """
370 Locate Paths to standard directories and parse config.
372 Args:
373 project: name of project whose configuration is being fetched
374 custom: custom location for configuration
375 ancestors: inherit ancestor directories that contain __init__.py
376 cname: name of config file
377 py_bin: namespace.__file__ that imports this function
379 Returns:
380 parsed configuration from each available file:
381 first file is most dominant
383 Raises:
384 BadConf- Bad configuration file format
386 """
387 avail_confs: Dict[Path, Dict[str, Any]] = {}
388 # load configs from oldest ancestor to current directory
389 for config in locate_config(project, custom, ancestors, cname, py_bin):
390 try:
391 avail_confs[config] = _parse_rc(config)
392 except (PermissionError, FileNotFoundError, IsADirectoryError):
393 pass
395 # initialize with config
396 return avail_confs
399def write_config(data: Dict[str, Any],
400 project: str,
401 ancestors: bool = False,
402 force: str = 'fail',
403 **kwargs) -> bool:
404 """
405 Write data to a safe configuration file.
407 Args:
408 data: serial data to save
409 project: project name
410 ancestors: inherit ancestor directories that contain __init__.py
411 force: force overwrite {'overwrite', 'update', 'fail'}
412 **kwargs:
413 custom: custom configuration file
414 ext: extension restriction filter(s)
415 cname: custom configuration filename
417 Returns: success
418 """
419 config_l = list(
420 reversed(
421 safe_config(project,
422 custom=kwargs.get('custom'),
423 ext=kwargs.get('ext'),
424 ancestors=ancestors,
425 cname=kwargs.get('cname', 'config'))))
426 for conf in config_l:
427 print(conf)
428 for config in config_l:
429 try:
430 return _write_rc(data, config, force=force)
431 except (PermissionError, IsADirectoryError, FileNotFoundError):
432 continue
433 return False