Hide keyboard shortcuts

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. 

22 

23Read: 

24 - standard xdg-base locations 

25 - current directory and ancestors 

26 - custom location 

27 

28""" 

29 

30import configparser 

31import os 

32import sys 

33from pathlib import Path 

34from typing import Any, Dict, List 

35 

36import toml 

37import yaml 

38 

39from xdgpspconf.errors import BadConf 

40 

41 

42def _is_mount(path: Path): 

43 """ 

44 Check across platform if path is mountpoint or drive. 

45 

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 

57 

58 

59def _parse_yaml(config: Path) -> Dict[str, dict]: 

60 """ 

61 Read configuration. 

62 

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 

73 

74 

75def _parse_ini(config: Path, sub_section: bool = False) -> Dict[str, dict]: 

76 """ 

77 Read configuration. 

78 

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 

95 

96 

97def _parse_toml(config: Path, sub_section: bool = False) -> Dict[str, dict]: 

98 """ 

99 Read configuration. 

100 

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 

113 

114 

115def _parse_rc(config: Path) -> Dict[str, dict]: 

116 """ 

117 Parse rc file. 

118 

119 Args: 

120 config: path to configuration file 

121 

122 Returns: 

123 configuration sections 

124 

125 Raises: 

126 BadConf: Bad configuration 

127 

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 

148 

149 

150def ancestral_config(child_dir: Path, rcfile: str) -> List[Path]: 

151 """ 

152 Walk up to nearest mountpoint or project root. 

153 

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 

159 

160 Args: 

161 child_dir: walk ancestry of `this` directory 

162 rcfile: name of rcfile 

163 

164 Returns: 

165 List of Paths to ancestral configurations: 

166 First directory is most dominant 

167 """ 

168 config_heir: List[Path] = [] 

169 

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 

181 

182 

183def xdg_config() -> List[Path]: 

184 """ 

185 Get XDG_CONFIG_HOME locations. 

186 

187 `specifications 

188 <https://specifications.freedesktop.org/basedir-spec/latest/ar01s03.html>`__ 

189 

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 

214 

215 

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. 

222 

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 

228 

229 Returns: 

230 List of all possible configuration paths: 

231 Existing and non-existing 

232 First directory is most dominant 

233 

234 """ 

235 # Preference of configurations *Most dominant first* 

236 config_heir: List[Path] = [] 

237 

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)) 

244 

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)) 

252 

253 # Current directory 

254 current_dir = Path('.').resolve() 

255 config_heir.append((current_dir / f'.{project}rc')) 

256 

257 if ancestors: 

258 # ancestral directories 

259 config_heir.extend(ancestral_config(current_dir, f'.{project}rc')) 

260 

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)) 

267 

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)) 

272 

273 return config_heir 

274 

275 

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. 

282 

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 

288 

289 Returns: 

290 parsed configuration from each available file 

291 

292 Raises: 

293 BadConf- Bad configuration file format 

294 

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 

303 

304 # initialize with config 

305 return avail_confs