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
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
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"""
22import os
23import sys
24from dataclasses import dataclass, field
25from pathlib import Path
26from typing import Any, Dict, List, Optional, Union
28import yaml
30from xdgpspconf.utils import fs_perm, is_mount
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"""
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)
53@dataclass
54class PlfmXdg():
55 """Platform Suited Variables"""
56 win: XdgVar = XdgVar()
57 """Windows variables"""
58 posix: XdgVar = XdgVar()
59 """POSIX variables"""
62def extract_xdg():
63 """
64 Read from 'strict'-standard locations.
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
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
108XDG = extract_xdg()
111class FsDisc():
112 """
113 File-System DISCovery functions
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`
122 """
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"""
132 self.permargs = permargs
133 """permission arguments"""
135 self.shipped = Path(shipped).resolve().parent if shipped else None
136 """location of developer-shipped files"""
137 self._xdg: PlfmXdg = XDG[base]
139 def locations(self) -> Dict[str, List[Path]]:
140 """
141 Shipped, root, user, improper locations
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 }
153 @property
154 def xdg(self) -> PlfmXdg:
155 """cross-platform xdg variables"""
156 return self._xdg
158 @xdg.setter
159 def xdg(self, value: PlfmXdg):
160 self._xdg = value
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)
168 def trace_ancestors(self, child_dir: Path) -> List[Path]:
169 """
170 Walk up to nearest mountpoint or project root.
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
179 Args:
180 child_dir: walk ancestry of `this` directory
182 Returns:
183 List of Paths to ancestors:
184 First directory is most dominant
185 """
186 pedigree: List[Path] = []
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
200 def user_xdg_loc(self) -> List[Path]:
201 """
202 Get XDG_<BASE>_HOME locations.
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]
230 def root_xdg_loc(self) -> List[Path]:
231 """
232 Get ROOT's counterparts of XDG_<BASE>_HOME locations.
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]
246 def improper_loc(self) -> List[Path]:
247 """
248 Get discouraged improper data locations such as *~/.project*.
250 This is strongly discouraged.
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 ('', '.')]
259 def get_loc(self,
260 dom_start: bool = True,
261 improper: bool = False,
262 **kwargs) -> List[Path]:
263 """
264 Get discovered locations.
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] = []
280 custom = kwargs.get('custom')
281 if custom is not None:
282 # don't check
283 dom_order.append(Path(custom))
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)
292 locations = self.locations()
293 if improper:
294 dom_order.extend(locations['improper'])
296 for loc in ('user_loc', 'root_loc', 'shipped'):
297 dom_order.extend(locations[loc])
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))
310 def safe_loc(self, **kwargs) -> List[Path]:
311 """
312 Locate safe writeable paths.
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``
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`
335 Returns:
336 Paths: First path is most dominant
338 """
339 kwargs['mode'] = kwargs.get('mode', 2)
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))
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)