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 

32from pathlib import Path 

33from typing import Any, Dict, List, Union 

34 

35import toml 

36import yaml 

37 

38from xdgpspconf.common import locate_base, walk_ancestors, xdg_base 

39from xdgpspconf.errors import BadConf 

40 

41 

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

43 """ 

44 Read configuration. 

45 

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 

56 

57 

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

59 config: Path, 

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

61 """ 

62 Write data to configuration file. 

63 

64 Args: 

65 data: serial data to save 

66 config: configuration file path 

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

68 

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 

81 

82 

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

84 """ 

85 Read configuration. 

86 

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 

103 

104 

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

106 config: Path, 

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

108 """ 

109 Write data to configuration file. 

110 

111 Args: 

112 data: serial data to save 

113 config: configuration file path 

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

115 

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 

130 

131 

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

133 """ 

134 Read configuration. 

135 

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 

148 

149 

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

151 config: Path, 

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

153 """ 

154 Write data to configuration file. 

155 

156 Args: 

157 data: serial data to save 

158 config: configuration file path 

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

160 

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 

173 

174 

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

176 """ 

177 Parse rc file. 

178 

179 Args: 

180 config: path to configuration file 

181 

182 Returns: 

183 configuration sections 

184 

185 Raises: 

186 BadConf: Bad configuration 

187 

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 

208 

209 

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

211 """ 

212 Write data to configuration file. 

213 

214 Args: 

215 data: serial data to save 

216 config: configuration file path 

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

218 

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) 

227 

228 

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

230 """ 

231 Walk up to nearest mountpoint or project root. 

232 

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 

238 

239 Args: 

240 child_dir: walk ancestry of `this` directory 

241 rcfile: name of rcfile 

242 

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 

249 

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 

254 

255 

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

257 """ 

258 Get XDG_CONFIG_HOME locations. 

259 

260 `specifications 

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

262 

263 Returns: 

264 List of xdg-config Paths 

265 First directory is most dominant 

266 """ 

267 return xdg_base('CONFIG') 

268 

269 

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. 

277 

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 

284 

285 Returns: 

286 List of all possible configuration paths: 

287 Existing and non-existing 

288 First directory is most dominant 

289 

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

309 

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

318 

319 for config in config_heir: 

320 print(config) 

321 return config_heir 

322 

323 

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. 

331 

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. 

338 

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 

345 

346 Returns: 

347 Paths: First path is most dominant 

348 

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 

362 

363 

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. 

371 

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 

378 

379 Returns: 

380 parsed configuration from each available file: 

381 first file is most dominant 

382 

383 Raises: 

384 BadConf- Bad configuration file format 

385 

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 

394 

395 # initialize with config 

396 return avail_confs 

397 

398 

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. 

406 

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 

416 

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