Coverage for /home/pradyumna/Languages/python/packages/xdgpspconf/xdgpspconf/base.py: 100%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

120 statements  

1#!/usr/bin/env python3 

2# -*- coding: utf-8; mode: python; -*- 

3# Copyright © 2021, 2022 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"""Discovery base""" 

21 

22import os 

23import sys 

24from dataclasses import dataclass, field 

25from pathlib import Path 

26from typing import Any, Dict, List, Optional, Union 

27 

28import yaml 

29 

30from xdgpspconf.utils import fs_perm, is_mount 

31 

32 

33@dataclass 

34class XdgVar(): 

35 """xdg-defined variable""" 

36 var: str = '' 

37 """XDG variable name""" 

38 dirs: Optional[str] = None 

39 """XDG variable list""" 

40 root: List[str] = field(default_factory=list) 

41 """root locations""" 

42 default: List[str] = field(default_factory=list) 

43 """default location""" 

44 

45 def update(self, master: Dict[str, Any]): 

46 """Update values""" 

47 for key, val in master.items(): 

48 if key not in self.__dict__: 

49 raise KeyError(f'{key} is not a recognised key') 

50 setattr(self, key, val) 

51 

52 

53@dataclass 

54class PlfmXdg(): 

55 """Platform Suited Variables""" 

56 win: XdgVar = XdgVar() 

57 """Windows variables""" 

58 posix: XdgVar = XdgVar() 

59 """POSIX variables""" 

60 

61 

62def extract_xdg(): 

63 """ 

64 Read from 'strict'-standard locations. 

65 

66 'Strict' locations: 

67 POSIX: 

68 - ``<shipped_root>/xdg.yml`` 

69 - ``/etc/xdgpspconf/xdg.yml`` 

70 - ``/etc/xdg/xdgpspconf/xdg.yml`` 

71 - ``${XDG_CONFIG_HOME:-${HOME}/.config}/xdgpspconf/xdg.yml`` 

72 Windows: 

73 - ``%APPDATA%\\xdgpspconf\\xdg.yml`` 

74 - ``%LOCALAPPDATA%\\xdgpspconf\\xdg.yml`` 

75 """ 

76 xdg_info = {} 

77 pspxdg_locs = [Path(__file__).parent / 'xdg.yml'] 

78 config_tail = 'xdgpspconf/xdg.yml' 

79 if sys.platform.startswith('win'): # pragma: no cover 

80 pspxdg_locs.extend( 

81 (Path(os.environ['APPDATA']) / config_tail, 

82 Path(os.environ.get('LOCALAPPDATA', 

83 Path.home() / 'AppData/Local')) / 

84 config_tail)) 

85 else: 

86 pspxdg_locs.extend( 

87 (Path(__file__).parent / 'xdg.yml', Path('/etc') / config_tail, 

88 Path('/etc/xdg') / config_tail, 

89 Path(os.environ.get('XDG_CONFIG_HOME', 

90 Path.home() / '.config')) / config_tail)) 

91 for conf_xdg in pspxdg_locs: 

92 try: 

93 with open(conf_xdg) as conf: 

94 xdg_info.update(yaml.safe_load(conf)) 

95 except (FileNotFoundError, IsADirectoryError, PermissionError): 

96 pass 

97 

98 xdg: Dict[str, PlfmXdg] = {} 

99 for var_type, var_info in xdg_info.items(): 

100 win_xdg = XdgVar() 

101 posix_xdg = XdgVar() 

102 win_xdg.update(var_info.get('win')) 

103 posix_xdg.update(var_info.get('posix')) 

104 xdg[var_type] = PlfmXdg(win=win_xdg, posix=posix_xdg) 

105 return xdg 

106 

107 

108XDG = extract_xdg() 

109 

110 

111class FsDisc(): 

112 """ 

113 File-System DISCovery functions 

114 

115 Args: 

116 project: str: project under consideration 

117 base: str: xdg base to fetch {CACHE,CONFIG,DATA,STATE} 

118 shipped: Path: ``namespace.__file__`` 

119 **permargs: all (arguments to :py:meth:`os.access`) are passed to 

120 :py:meth:`xdgpspconf.utils.fs_perm` 

121 

122 """ 

123 

124 def __init__(self, 

125 project: str, 

126 base: str = 'data', 

127 shipped: Union[Path, str] = None, 

128 **permargs): 

129 self.project = project 

130 """project under consideration""" 

131 

132 self.permargs = permargs 

133 """permission arguments""" 

134 

135 self.shipped = Path(shipped).resolve().parent if shipped else None 

136 """location of developer-shipped files""" 

137 self._xdg: PlfmXdg = XDG[base] 

138 

139 def locations(self) -> Dict[str, List[Path]]: 

140 """ 

141 Shipped, root, user, improper locations 

142 

143 Returns: 

144 named dictionary containing respective list of Paths 

145 """ 

146 return { 

147 'improper': self.improper_loc(), 

148 'user_loc': self.user_xdg_loc(), 

149 'root_loc': self.root_xdg_loc(), 

150 'shipped': [self.shipped] if self.shipped is not None else [] 

151 } 

152 

153 @property 

154 def xdg(self) -> PlfmXdg: 

155 """cross-platform xdg variables""" 

156 return self._xdg 

157 

158 @xdg.setter 

159 def xdg(self, value: PlfmXdg): 

160 self._xdg = value 

161 

162 def __repr__(self) -> str: 

163 r_out = [] 

164 for attr in ('project', 'permargs', 'shipped', 'xdg'): 

165 r_out.append(f'{attr}: {getattr(self, attr)}') 

166 return '\n'.join(r_out) 

167 

168 def trace_ancestors(self, child_dir: Path) -> List[Path]: 

169 """ 

170 Walk up to nearest mountpoint or project root. 

171 

172 - collect all directories containing ``__init__.py`` 

173 (assumed to be source directories) 

174 - project root is directory that contains ``setup.cfg`` 

175 or ``setup.py`` 

176 - mountpoint is a unix mountpoint or windows drive root 

177 - I **AM** my 0th ancestor 

178 

179 Args: 

180 child_dir: walk ancestry of `this` directory 

181 

182 Returns: 

183 List of Paths to ancestors: 

184 First directory is most dominant 

185 """ 

186 pedigree: List[Path] = [] 

187 

188 # I **AM** my 0th ancestor 

189 while not is_mount(child_dir): 

190 if (child_dir / '__init__.py').is_file(): 

191 pedigree.append(child_dir) 

192 if any((child_dir / setup).is_file() 

193 for setup in ('setup.cfg', 'setup.py')): 

194 # project directory 

195 pedigree.append(child_dir) 

196 break 

197 child_dir = child_dir.parent 

198 return pedigree 

199 

200 def user_xdg_loc(self) -> List[Path]: 

201 """ 

202 Get XDG_<BASE>_HOME locations. 

203 

204 Returns: 

205 List of xdg-<base> Paths 

206 First directory is most dominant 

207 """ 

208 user_home = Path.home() 

209 # environment 

210 if sys.platform.startswith('win'): # pragma: no cover 

211 # windows 

212 os_xdg_loc = os.environ.get(self.xdg.win.var) 

213 os_default = self.xdg.win.default 

214 else: 

215 # assume POSIX 

216 os_xdg_loc = os.environ.get(self.xdg.posix.var) 

217 os_default = self.xdg.posix.default 

218 if os_xdg_loc is None: # pragma: no cover 

219 xdg_base_loc = [(user_home / loc) for loc in os_default] 

220 else: 

221 xdg_base_loc = [Path(loc) for loc in os_xdg_loc.split(os.pathsep)] 

222 if not sys.platform.startswith('win'): 

223 # DONT: combine with previous condition, order is important 

224 # assume POSIX 

225 if self.xdg.posix.dirs and self.xdg.posix.dirs in os.environ: 

226 xdg_base_loc.extend((Path(unix_loc) for unix_loc in os.environ[ 

227 self.xdg.posix.dirs].split(os.pathsep))) 

228 return [loc / self.project for loc in xdg_base_loc] 

229 

230 def root_xdg_loc(self) -> List[Path]: 

231 """ 

232 Get ROOT's counterparts of XDG_<BASE>_HOME locations. 

233 

234 Returns: 

235 List of root-<base> Paths (parents to project's base) 

236 First directory is most dominant 

237 """ 

238 if sys.platform.startswith('win'): # pragma: no cover 

239 # windows 

240 os_root = self.xdg.win.root 

241 else: 

242 # assume POSIX 

243 os_root = self.xdg.posix.root 

244 return [Path(root_base) / self.project for root_base in os_root] 

245 

246 def improper_loc(self) -> List[Path]: 

247 """ 

248 Get discouraged improper data locations such as *~/.project*. 

249 

250 This is strongly discouraged. 

251 

252 Returns: 

253 List of xdg-<base> Paths (parents to project's base) 

254 First directory is most dominant 

255 """ 

256 user_home = Path.home() 

257 return [user_home / (hide + self.project) for hide in ('', '.')] 

258 

259 def get_loc(self, 

260 dom_start: bool = True, 

261 improper: bool = False, 

262 **kwargs) -> List[Path]: 

263 """ 

264 Get discovered locations. 

265 

266 Args: 

267 dom_start: when ``False``, end with most dominant 

268 improper: include improper locations such as *~/.project* 

269 **kwargs: 

270 - custom: custom location 

271 - trace_pwd: when supplied, walk up to mountpoint or 

272 project-root and inherit all locations that contain 

273 ``__init__.py``. Project-root is identified by discovery of 

274 ``setup.py`` or ``setup.cfg``. Mountpoint is ``is_mount`` 

275 in unix or Drive in Windows. If ``True``, walk from ``$PWD`` 

276 - permargs passed on to :py:meth:`xdgpspconf.utils.fs_perm` 

277 """ 

278 dom_order: List[Path] = [] 

279 

280 custom = kwargs.get('custom') 

281 if custom is not None: 

282 # don't check 

283 dom_order.append(Path(custom)) 

284 

285 trace_pwd = kwargs.get('trace_pwd') 

286 if trace_pwd is True: 

287 trace_pwd = Path('.').resolve() 

288 if trace_pwd: 

289 inheritance = self.trace_ancestors(Path(trace_pwd)) 

290 dom_order.extend(inheritance) 

291 

292 locations = self.locations() 

293 if improper: 

294 dom_order.extend(locations['improper']) 

295 

296 for loc in ('user_loc', 'root_loc', 'shipped'): 

297 dom_order.extend(locations[loc]) 

298 

299 permargs = { 

300 key: val 

301 for key, val in kwargs.items() 

302 if key in ('mode', 'dir_fs', 'effective_ids', 'follow_symlinks') 

303 } 

304 permargs = {**self.permargs, **permargs} 

305 dom_order = list(filter(lambda x: fs_perm(x, **permargs), dom_order)) 

306 if dom_start: 

307 return dom_order 

308 return list(reversed(dom_order)) 

309 

310 def safe_loc(self, **kwargs) -> List[Path]: 

311 """ 

312 Locate safe writeable paths. 

313 

314 - Doesn't care about accessibility or existence of locations. 

315 - User must catch: 

316 - ``PermissionError`` 

317 - ``IsADirectoryError`` 

318 - ``FileNotFoundError`` 

319 - Improper locations (*~/.project*) are deliberately dropped 

320 - Recommendation: set dom_start = ``False`` 

321 

322 Args: 

323 ext: extension filter(s) 

324 **kwargs: 

325 - custom: custom location 

326 - trace_pwd: when supplied, walk up to mountpoint or 

327 project-root and inherit all locations that contain 

328 ``__init__.py``. Project-root is identified by discovery of 

329 ``setup.py`` or ``setup.cfg``. Mountpoint is ``is_mount`` 

330 in unix or Drive in Windows. If ``True``, walk from ``$PWD`` 

331 - dom_start: when ``False``, end with most dominant 

332 - permargs passed on to :py:meth:`xdgpspconf.utils.fs_perm` 

333 

334 

335 Returns: 

336 Paths: First path is most dominant 

337 

338 """ 

339 kwargs['mode'] = kwargs.get('mode', 2) 

340 

341 # filter private locations 

342 private_locs = ['site-packages', 'venv', '/etc', 'setup', 'pyproject'] 

343 if self.shipped is not None: 

344 private_locs.append(str(self.shipped)) 

345 

346 safe_paths = filter( 

347 lambda x: not any(private in str(x) for private in private_locs), 

348 self.get_loc(**kwargs)) 

349 return list(safe_paths)