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

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 sys
33from pathlib import Path
34from typing import Any, Dict, List
36import toml
37import yaml
39from xdgpspconf.errors import BadConf
42def _is_mount(path: Path):
43 """
44 Check across platform if path is mountpoint or drive.
46 Args:
47 path: path to be checked
48 """
49 try:
50 if path.is_mount():
51 return True
52 return False
53 except NotImplementedError:
54 if path.resolve().drive + '\\' == str(path):
55 return True
56 return False
59def _parse_yaml(config: Path) -> Dict[str, dict]:
60 """
61 Read configuration.
63 Specified as a yaml file:
64 - .rc
65 - style.yml
66 - *.yml
67 """
68 with open(config, 'r') as rcfile:
69 conf: Dict[str, dict] = yaml.safe_load(rcfile)
70 if conf is None: # pragma: no cover
71 raise yaml.YAMLError
72 return conf
75def _parse_ini(config: Path, sub_section: bool = False) -> Dict[str, dict]:
76 """
77 Read configuration.
79 Supplied in ``setup.cfg`` OR
80 - *.cfg
81 - *.conf
82 - *.ini
83 """
84 parser = configparser.ConfigParser()
85 parser.read(config)
86 if sub_section:
87 return {
88 pspcfg.replace('.', ''): dict(parser.items(pspcfg))
89 for pspcfg in parser.sections() if '' in pspcfg
90 }
91 return {
92 pspcfg: dict(parser.items(pspcfg))
93 for pspcfg in parser.sections()
94 } # pragma: no cover
97def _parse_toml(config: Path, sub_section: bool = False) -> Dict[str, dict]:
98 """
99 Read configuration.
101 Supplied in ``pyproject.toml`` OR
102 - *.toml
103 """
104 if sub_section:
105 with open(config, 'r') as rcfile:
106 conf: Dict[str, dict] = toml.load(rcfile).get('', {})
107 return conf
108 with open(config, 'r') as rcfile:
109 conf = dict(toml.load(rcfile))
110 if conf is None: # pragma: no cover
111 raise toml.TomlDecodeError
112 return conf
115def _parse_rc(config: Path) -> Dict[str, dict]:
116 """
117 Parse rc file.
119 Args:
120 config: path to configuration file
122 Returns:
123 configuration sections
125 Raises:
126 BadConf: Bad configuration
128 """
129 if config.name == 'setup.cfg':
130 # declared inside setup.cfg
131 return _parse_ini(config, sub_section=True)
132 if config.name == 'pyproject.toml':
133 # declared inside pyproject.toml
134 return _parse_toml(config, sub_section=True)
135 try:
136 # yaml configuration format
137 return _parse_yaml(config)
138 except yaml.YAMLError:
139 try:
140 # toml configuration format
141 return _parse_toml(config)
142 except toml.TomlDecodeError:
143 try:
144 # try generic config-parser
145 return _parse_ini(config)
146 except configparser.Error:
147 raise BadConf(config_file=config) from None
150def ancestral_config(child_dir: Path, rcfile: str) -> List[Path]:
151 """
152 Walk up to nearest mountpoint or project root.
154 - collect all directories containing __init__.py
155 (assumed to be source directories)
156 - project root is directory that contains ``setup.cfg`` or ``setup.py``
157 - mountpoint is a unix mountpoint or windows drive root
158 - I am **NOT** my ancestor
160 Args:
161 child_dir: walk ancestry of `this` directory
162 rcfile: name of rcfile
164 Returns:
165 List of Paths to ancestral configurations:
166 First directory is most dominant
167 """
168 config_heir: List[Path] = []
170 while not _is_mount(child_dir):
171 if (child_dir / '__init__.py').is_file():
172 config_heir.append((child_dir / rcfile))
173 if any((child_dir / setup).is_file()
174 for setup in ('setup.cfg', 'setup.py')):
175 # project directory
176 config_heir.append((child_dir / 'pyproject.toml'))
177 config_heir.append((child_dir / 'setup.cfg'))
178 break
179 child_dir = child_dir.parent
180 return config_heir
183def xdg_config() -> List[Path]:
184 """
185 Get XDG_CONFIG_HOME locations.
187 `specifications
188 <https://specifications.freedesktop.org/basedir-spec/latest/ar01s03.html>`__
190 Returns:
191 List of xdg-config Paths
192 First directory is most dominant
193 """
194 xdg_heir: List[Path] = []
195 # environment
196 if sys.platform.startswith('win'): # pragma: no cover
197 # windows
198 user_home = Path(os.environ['USERPROFILE'])
199 root_config = Path(os.environ['APPDATA'])
200 xdg_config_home = Path(
201 os.environ.get('LOCALAPPDATA', user_home / 'AppData/Local'))
202 xdg_heir.append(xdg_config_home)
203 xdg_heir.append(root_config)
204 else:
205 # assume POSIX
206 user_home = Path(os.environ['HOME'])
207 xdg_config_home = Path(
208 os.environ.get('XDG_CONFIG_HOME', user_home / '.config'))
209 xdg_heir.append(xdg_config_home)
210 xdg_config_dirs = os.environ.get('XDG_CONFIG_DIRS', '/etc/xdg')
211 for xdg_dirs in xdg_config_dirs.split(':'):
212 xdg_heir.append(Path(xdg_dirs))
213 return xdg_heir
216def locate_config(project: str,
217 custom: os.PathLike = None,
218 ancestors: bool = False,
219 cname: str = 'config') -> List[Path]:
220 """
221 Locate configurations at standard locations.
223 Args:
224 project: name of project whose configuration is being fetched
225 custom: custom location for configuration
226 ancestors: inherit ancestor directories that contain __init__.py
227 cname: name of config file
229 Returns:
230 List of all possible configuration paths:
231 Existing and non-existing
232 First directory is most dominant
234 """
235 # Preference of configurations *Most dominant first*
236 config_heir: List[Path] = []
238 # custom
239 if custom is not None:
240 if not Path(custom).is_file():
241 raise FileNotFoundError(
242 f'Custom configuration file: {custom} not found')
243 config_heir.append(Path(custom))
245 # environment variable
246 rc_val = os.environ.get(project.upper() + 'RC')
247 if rc_val is not None:
248 if not Path(rc_val).is_file():
249 raise FileNotFoundError(
250 f'RC configuration file: {rc_val} not found')
251 config_heir.append(Path(rc_val))
253 # Current directory
254 current_dir = Path('.').resolve()
255 config_heir.append((current_dir / f'.{project}rc'))
257 if ancestors:
258 # ancestral directories
259 config_heir.extend(ancestral_config(current_dir, f'.{project}rc'))
261 # xdg locations
262 xdg_heir = xdg_config()
263 for heir in xdg_heir:
264 for ext in '.yml', '.yaml', '.toml', '.conf':
265 config_heir.append((heir / project).with_suffix(ext))
266 config_heir.append((heir / f'{project}/{cname}').with_suffix(ext))
268 # Shipped location
269 for ext in '.yml', '.yaml', '.toml', '.conf':
270 config_heir.append((Path(__file__).parent.parent /
271 f'{project}/{cname}').with_suffix(ext))
273 return config_heir
276def read_config(project: str,
277 custom: os.PathLike = None,
278 ancestors: bool = False,
279 cname: str = 'config') -> Dict[Path, Dict[str, Any]]:
280 """
281 Locate Paths to standard directories.
283 Args:
284 project: name of project whose configuration is being fetched
285 custom: custom location for configuration
286 ancestors: inherit ancestor directories that contain __init__.py
287 cname: name of config file
289 Returns:
290 parsed configuration from each available file
292 Raises:
293 BadConf- Bad configuration file format
295 """
296 avail_confs: Dict[Path, Dict[str, Any]] = {}
297 # load configs from oldest ancestor to current directory
298 for config in reversed(locate_config(project, custom, ancestors, cname)):
299 try:
300 avail_confs[config] = _parse_rc(config)
301 except (PermissionError, FileNotFoundError, IsADirectoryError):
302 pass
304 # initialize with config
305 return avail_confs