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 subprocess 

33import sys 

34from pathlib import Path 

35from typing import Any, Dict, List, Union 

36 

37import toml 

38import yaml 

39 

40from xdgpspconf.common import locate_base, walk_ancestors, xdg_base 

41from xdgpspconf.errors import BadConf 

42 

43 

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) 

48 

49 

50def _parse_yaml(config: Path) -> Dict[str, Any]: 

51 """ 

52 Read configuration. 

53 

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 

64 

65 

66def _write_yaml(data: Dict[str, Any], 

67 config: Path, 

68 force: str = 'fail') -> bool: 

69 """ 

70 Write data to configuration file. 

71 

72 Args: 

73 data: serial data to save 

74 config: configuration file path 

75 force: force overwrite {'overwrite', 'update', 'fail'} 

76 

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 

89 

90 

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

92 """ 

93 Read configuration. 

94 

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 

111 

112 

113def _write_ini(data: Dict[str, Any], 

114 config: Path, 

115 force: str = 'fail') -> bool: 

116 """ 

117 Write data to configuration file. 

118 

119 Args: 

120 data: serial data to save 

121 config: configuration file path 

122 force: force overwrite {'overwrite', 'update', 'fail'} 

123 

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 

138 

139 

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

141 """ 

142 Read configuration. 

143 

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 

156 

157 

158def _write_toml(data: Dict[str, Any], 

159 config: Path, 

160 force: str = 'fail') -> bool: 

161 """ 

162 Write data to configuration file. 

163 

164 Args: 

165 data: serial data to save 

166 config: configuration file path 

167 force: force overwrite {'overwrite', 'update', 'fail'} 

168 

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 

181 

182 

183def _parse_rc(config: Path) -> Dict[str, Any]: 

184 """ 

185 Parse rc file. 

186 

187 Args: 

188 config: path to configuration file 

189 

190 Returns: 

191 configuration sections 

192 

193 Raises: 

194 BadConf: Bad configuration 

195 

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 

216 

217 

218def _write_rc(data: Dict[str, Any], config: Path, force: str = 'fail') -> bool: 

219 """ 

220 Write data to configuration file. 

221 

222 Args: 

223 data: serial data to save 

224 config: configuration file path 

225 force: force overwrite {'overwrite', 'update', 'fail'} 

226 

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) 

235 

236 

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

238 """ 

239 Walk up to nearest mountpoint or project root. 

240 

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 

246 

247 Args: 

248 child_dir: walk ancestry of `this` directory 

249 rcfile: name of rcfile 

250 

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 

257 

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 

262 

263 

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

265 """ 

266 Get XDG_CONFIG_HOME locations. 

267 

268 `specifications 

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

270 

271 Returns: 

272 List of xdg-config Paths 

273 First directory is most dominant 

274 """ 

275 return xdg_base('CONFIG') 

276 

277 

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. 

285 

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 

292 

293 Returns: 

294 List of all possible configuration paths: 

295 Existing and non-existing 

296 First directory is most dominant 

297 

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

317 

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

326 

327 return config_heir 

328 

329 

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. 

337 

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. 

344 

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 

351 

352 Returns: 

353 Paths: First path is most dominant 

354 

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 

369 

370 

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. 

378 

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 

385 

386 Returns: 

387 parsed configuration from each available file: 

388 first file is most dominant 

389 

390 Raises: 

391 BadConf- Bad configuration file format 

392 

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 

401 

402 # initialize with config 

403 return avail_confs 

404 

405 

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. 

413 

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 

423 

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