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

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
32import subprocess
33import sys
34from pathlib import Path
35from typing import Any, Dict, List, Union
37import toml
38import yaml
40from xdgpspconf.common import locate_base, walk_ancestors, xdg_base
41from xdgpspconf.errors import BadConf
44def _fs_perm(loc: Path):
45 while not loc.exists():
46 loc = loc.parent
47 return os.access(loc, os.W_OK | os.R_OK, effective_ids=True)
50def _parse_yaml(config: Path) -> Dict[str, Any]:
51 """
52 Read configuration.
54 Specified as a yaml file:
55 - .rc
56 - style.yml
57 - *.yml
58 """
59 with open(config, 'r') as rcfile:
60 conf: Dict[str, Any] = yaml.safe_load(rcfile)
61 if conf is None: # pragma: no cover
62 raise yaml.YAMLError
63 return conf
66def _write_yaml(data: Dict[str, Any],
67 config: Path,
68 force: str = 'fail') -> bool:
69 """
70 Write data to configuration file.
72 Args:
73 data: serial data to save
74 config: configuration file path
75 force: force overwrite {'overwrite', 'update', 'fail'}
77 """
78 old_data: Dict[str, Any] = {}
79 if config.is_file():
80 # file already exists
81 if force == 'fail':
82 return False
83 if force == 'update':
84 old_data = _parse_yaml(config)
85 data = {**old_data, **data}
86 with open(config, 'w') as rcfile:
87 yaml.dump(data, rcfile)
88 return True
91def _parse_ini(config: Path, sub_section: bool = False) -> Dict[str, Any]:
92 """
93 Read configuration.
95 Supplied in ``setup.cfg`` OR
96 - *.cfg
97 - *.conf
98 - *.ini
99 """
100 parser = configparser.ConfigParser()
101 parser.read(config)
102 if sub_section:
103 return {
104 pspcfg.replace('.', ''): dict(parser.items(pspcfg))
105 for pspcfg in parser.sections() if '' in pspcfg
106 }
107 return {
108 pspcfg: dict(parser.items(pspcfg))
109 for pspcfg in parser.sections()
110 } # pragma: no cover
113def _write_ini(data: Dict[str, Any],
114 config: Path,
115 force: str = 'fail') -> bool:
116 """
117 Write data to configuration file.
119 Args:
120 data: serial data to save
121 config: configuration file path
122 force: force overwrite {'overwrite', 'update', 'fail'}
124 """
125 old_data: Dict[str, Any] = {}
126 if config.is_file():
127 # file already exists
128 if force == 'fail':
129 return False
130 if force == 'update':
131 old_data = _parse_ini(config)
132 data = {**old_data, **data}
133 parser = configparser.ConfigParser()
134 parser.update(data)
135 with open(config, 'w') as rcfile:
136 parser.write(rcfile)
137 return True
140def _parse_toml(config: Path, sub_section: bool = False) -> Dict[str, Any]:
141 """
142 Read configuration.
144 Supplied in ``pyproject.toml`` OR
145 - *.toml
146 """
147 if sub_section:
148 with open(config, 'r') as rcfile:
149 conf: Dict[str, Any] = toml.load(rcfile).get('', {})
150 return conf
151 with open(config, 'r') as rcfile:
152 conf = dict(toml.load(rcfile))
153 if conf is None: # pragma: no cover
154 raise toml.TomlDecodeError
155 return conf
158def _write_toml(data: Dict[str, Any],
159 config: Path,
160 force: str = 'fail') -> bool:
161 """
162 Write data to configuration file.
164 Args:
165 data: serial data to save
166 config: configuration file path
167 force: force overwrite {'overwrite', 'update', 'fail'}
169 """
170 old_data: Dict[str, Any] = {}
171 if config.is_file():
172 # file already exists
173 if force == 'fail':
174 return False
175 if force == 'update':
176 old_data = _parse_toml(config)
177 data = {**old_data, **data}
178 with open(config, 'w') as rcfile:
179 toml.dump(data, rcfile)
180 return True
183def _parse_rc(config: Path) -> Dict[str, Any]:
184 """
185 Parse rc file.
187 Args:
188 config: path to configuration file
190 Returns:
191 configuration sections
193 Raises:
194 BadConf: Bad configuration
196 """
197 if config.name == 'setup.cfg':
198 # declared inside setup.cfg
199 return _parse_ini(config, sub_section=True)
200 if config.name == 'pyproject.toml':
201 # declared inside pyproject.toml
202 return _parse_toml(config, sub_section=True)
203 try:
204 # yaml configuration format
205 return _parse_yaml(config)
206 except yaml.YAMLError:
207 try:
208 # toml configuration format
209 return _parse_toml(config)
210 except toml.TomlDecodeError:
211 try:
212 # try generic config-parser
213 return _parse_ini(config)
214 except configparser.Error:
215 raise BadConf(config_file=config) from None
218def _write_rc(data: Dict[str, Any], config: Path, force: str = 'fail') -> bool:
219 """
220 Write data to configuration file.
222 Args:
223 data: serial data to save
224 config: configuration file path
225 force: force overwrite {'overwrite', 'update', 'fail'}
227 Returns: success
228 """
229 if config.suffix in ('.conf', '.cfg', '.ini'):
230 return _write_ini(data, config, force)
231 if config.suffix == '.toml':
232 return _write_toml(data, config, force)
233 # assume yaml
234 return _write_yaml(data, config, force)
237def ancestral_config(child_dir: Path, rcfile: str) -> List[Path]:
238 """
239 Walk up to nearest mountpoint or project root.
241 - collect all directories containing __init__.py
242 (assumed to be source directories)
243 - project root is directory that contains ``setup.cfg`` or ``setup.py``
244 - mountpoint is a unix mountpoint or windows drive root
245 - I am **NOT** my ancestor
247 Args:
248 child_dir: walk ancestry of `this` directory
249 rcfile: name of rcfile
251 Returns:
252 List of Paths to ancestral configurations:
253 First directory is most dominant
254 """
255 config_dirs = walk_ancestors(child_dir)
256 # setup.cfg, pyproject.toml are missing
258 config_heir: List[Path] = [conf_dir / rcfile for conf_dir in config_dirs]
259 for sub_section_file in ('pyproject.toml', 'setup.cfg'):
260 config_heir.append(config_dirs[-1] / sub_section_file)
261 return config_heir
264def xdg_config() -> List[Path]:
265 """
266 Get XDG_CONFIG_HOME locations.
268 `specifications
269 <https://specifications.freedesktop.org/basedir-spec/latest/ar01s03.html>`__
271 Returns:
272 List of xdg-config Paths
273 First directory is most dominant
274 """
275 return xdg_base('CONFIG')
278def locate_config(project: str,
279 custom: os.PathLike = None,
280 ancestors: bool = False,
281 cname: str = 'config',
282 py_bin: os.PathLike = None) -> List[Path]:
283 """
284 Locate configurations at standard locations.
286 Args:
287 project: name of project whose configuration is being fetched
288 custom: custom location for configuration
289 ancestors: inherit ancestor directories that contain __init__.py
290 cname: name of config file
291 py_bin: namespace.__file__ that imports this function
293 Returns:
294 List of all possible configuration paths:
295 Existing and non-existing
296 First directory is most dominant
298 """
299 _custom_p = Path(custom).parent if custom else None
300 config_dirs = locate_base(project, _custom_p, ancestors, 'CONFIG', py_bin)
301 # missing: filename, .{project}RC /cname config/cname
302 # Preference of configurations *Most dominant first*
303 config_heir: List[Path] = []
304 for conf_dir in config_dirs:
305 # config in ancestor files should be an rc file
306 if (conf_dir in Path('.').resolve().parents
307 or conf_dir == Path('.').resolve()):
308 if conf_dir == _custom_p:
309 config_heir.append(conf_dir /
310 Path(custom).name) # type: ignore
311 else:
312 config_heir.append(conf_dir / f'.{project}rc')
313 else:
314 # non-ancestor
315 for ext in '.yml', '.yaml', '.toml', '.conf':
316 config_heir.append((conf_dir / cname).with_suffix(ext))
318 # environment variable
319 rc_val = os.environ.get(project.upper() + 'RC')
320 if rc_val is not None:
321 if not Path(rc_val).is_file():
322 raise FileNotFoundError(
323 f'RC configuration file: {rc_val} not found')
324 insert_pos = 1 if custom else 0
325 config_heir.insert(insert_pos, Path(rc_val))
327 return config_heir
330def safe_config(project: str,
331 custom: os.PathLike = None,
332 ext: Union[str, List[str]] = None,
333 ancestors: bool = False,
334 cname: str = 'config') -> List[Path]:
335 """
336 Locate safe writable paths of configuration files.
338 - Doesn't care about accessibility or existance of locations.
339 - User must catch:
340 - ``PermissionError``
341 - ``IsADirectoryError``
342 - ``FileNotFoundError``
343 - Recommendation: Try saving your configuration in in reversed order.
345 Args:
346 project: name of project whose configuration is being fetched
347 custom: custom location for configuration
348 ext: extension filter(s)
349 ancestors: inherit ancestor directories that contain ``__init__.py``
350 cname: name of config file
352 Returns:
353 Paths: First path is most dominant
355 """
356 if isinstance(ext, str):
357 ext = [ext]
358 safe_paths: List[Path] = []
359 for loc in locate_config(project, custom, ancestors, cname):
360 if any(private in str(loc)
361 for private in ('site-packages', 'venv', '/etc', 'setup',
362 'pyproject')):
363 continue
364 if ext and loc.suffix and loc.suffix not in list(ext):
365 continue
366 if _fs_perm(loc):
367 safe_paths.append(loc)
368 return safe_paths
371def read_config(project: str,
372 custom: os.PathLike = None,
373 ancestors: bool = False,
374 cname: str = 'config',
375 py_bin: os.PathLike = None) -> Dict[Path, Dict[str, Any]]:
376 """
377 Locate Paths to standard directories and parse config.
379 Args:
380 project: name of project whose configuration is being fetched
381 custom: custom location for configuration
382 ancestors: inherit ancestor directories that contain __init__.py
383 cname: name of config file
384 py_bin: namespace.__file__ that imports this function
386 Returns:
387 parsed configuration from each available file:
388 first file is most dominant
390 Raises:
391 BadConf- Bad configuration file format
393 """
394 avail_confs: Dict[Path, Dict[str, Any]] = {}
395 # load configs from oldest ancestor to current directory
396 for config in locate_config(project, custom, ancestors, cname, py_bin):
397 try:
398 avail_confs[config] = _parse_rc(config)
399 except (PermissionError, FileNotFoundError, IsADirectoryError):
400 pass
402 # initialize with config
403 return avail_confs
406def write_config(data: Dict[str, Any],
407 project: str,
408 ancestors: bool = False,
409 force: str = 'fail',
410 **kwargs) -> bool:
411 """
412 Write data to a safe configuration file.
414 Args:
415 data: serial data to save
416 project: project name
417 ancestors: inherit ancestor directories that contain __init__.py
418 force: force overwrite {'overwrite', 'update', 'fail'}
419 **kwargs:
420 custom: custom configuration file
421 ext: extension restriction filter(s)
422 cname: custom configuration filename
424 Returns: success
425 """
426 config_l = list(
427 reversed(
428 safe_config(project,
429 custom=kwargs.get('custom'),
430 ext=kwargs.get('ext'),
431 ancestors=ancestors,
432 cname=kwargs.get('cname', 'config'))))
433 for config in config_l:
434 try:
435 return _write_rc(data, config, force=force)
436 except (PermissionError, IsADirectoryError, FileNotFoundError):
437 continue
438 return False