Coverage for src/hods/config/sourcefile.py: 100.00%

Shortcuts 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

131 statements  

1"""hods - home directory synchronization. 

2 

3Copyright (C) 2016-2020 Mathias Stelzer <knoppo@rolln.de> 

4 

5hods is free software: you can redistribute it and/or modify 

6it under the terms of the GNU General Public License as published by 

7the Free Software Foundation, either version 3 of the License, or 

8(at your option) any later version. 

9 

10hods is distributed in the hope that it will be useful, 

11but WITHOUT ANY WARRANTY; without even the implied warranty of 

12MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

13GNU General Public License for more details. 

14 

15You should have received a copy of the GNU General Public License 

16along with this program. If not, see <http://www.gnu.org/licenses/>. 

17""" 

18import logging 

19import os 

20 

21from hods.config.template import ( 

22 TemplateError, 

23 get_template_engine, 

24 load_context, 

25) 

26from hods.node import DirectoryNode, FileNode, validate_child_path 

27 

28logger = logging.getLogger(__name__) 

29 

30 

31class SourceMixin: 

32 """Mixin class for all source nodes.""" 

33 

34 def __init__(self, parent, name, destination=None, **kwargs): 

35 """Initialize node. 

36 

37 :param parent: `hods.sources.Source` or 

38 `hods.sourcefile.SourceDir` - Parent instance 

39 :param name: `str` - basename of the node 

40 :param attach: `bool` - attach the file to the parent 

41 (Default: ``True``) 

42 :param ignore: `bool` - Don't create symlink(s) for this source 

43 in the home directory. (Default: ``False``) 

44 """ 

45 super().__init__(parent, name, **kwargs) 

46 

47 self._destination_path = None # property 

48 self.destination_path = destination 

49 

50 @property 

51 def relative_source_path(self): 

52 """The relative path to the parent source.""" 

53 return self.relpath(self.source.path) 

54 

55 @property 

56 def relative_feature_path(self): 

57 """The relative path to the parent feature.""" 

58 return os.path.join(self.source.basename, self.relative_source_path) 

59 

60 @property 

61 def relative_config_path(self): 

62 """The relative path to the parent config tree.""" 

63 return os.path.join(self.source.feature.basename, self.relative_feature_path) 

64 

65 @property 

66 def default_destination(self): 

67 """The parent destination path joined with this file's basename.""" 

68 return os.path.join(self.parent.destination_path, self.basename) 

69 

70 @property 

71 def destination_path(self): 

72 """Return the path of the symlink relative to the home directory.""" 

73 if self._destination_path is not None: 

74 return self._destination_path 

75 return self.default_destination 

76 

77 @destination_path.setter 

78 def destination_path(self, value: str): 

79 """Set the path for the symlink or rendered template. 

80 

81 Pass ``None`` to use the default. 

82 If an absolute path is given, convert it to a relative path. 

83 

84 Args: 

85 value (`str`): The path for the symlink of this source file. 

86 

87 Raises 

88 `NotInTree` - If the given absolute path is not in the home directory. 

89 `ValueError` - If the path is in the .hods/ directory. 

90 """ 

91 if not value: 

92 self._destination_path = None 

93 return 

94 if os.path.isabs(value): 

95 value = validate_child_path(value, self.tree.settings.home_path) 

96 if value.split(os.sep)[0] == '.hods': 

97 msg = '{}.destination_path must NOT be (in) your ~/.hods/ directory! ({})' 

98 raise ValueError(msg.format(self.__class__.__name__, value)) 

99 self._destination_path = value 

100 

101 @property 

102 def destination_abspath(self): 

103 """Return the absolute destination path.""" 

104 return os.path.join(self.tree.settings.home_path, self.destination_path) 

105 

106 @property 

107 def destination_basename(self): 

108 """Return the basename of the `destination_path`.""" 

109 return os.path.basename(self.destination_path) 

110 

111 @property 

112 def destination_dirname(self): 

113 """Return the dirname of the `destination_path`.""" 

114 return os.path.dirname(self.destination_path) 

115 

116 def destination_is_default(self): 

117 """Return a `bool` whether the `destination_path` is not configured.""" 

118 return self._destination_path is None 

119 

120 def is_ignored(self): 

121 """Return a `bool` whether this file should be ignored.""" 

122 return False 

123 

124 def as_dict(self, **kwargs): 

125 """Return a `dict` to store and recreate this instance.""" 

126 data = super().as_dict(**kwargs) 

127 if self._destination_path is not None: 

128 data['destination'] = self._destination_path 

129 return data 

130 

131 

132class SourceChildMixin: 

133 """Mixin for child source nodes.""" 

134 

135 def __init__(self, *args, **kwargs): 

136 """Initialize source child node.""" 

137 super().__init__(*args, **kwargs) 

138 

139 self.source_parents = list(self._get_source_parents()) 

140 self.source = self.source_parents[-1] 

141 self.tree = self.source.feature.tree 

142 

143 def _get_source_parents(self, exclude_source=False): 

144 parent = self.parent 

145 while not parent.is_source_root: 

146 yield parent 

147 parent = parent.parent 

148 if not exclude_source: 

149 yield parent 

150 

151 def is_ignored(self): 

152 """Return a `bool` whether this file should be ignored.""" 

153 if self.parent.is_ignored(): 

154 return True 

155 return super().is_ignored() 

156 

157 

158class SourceFile(SourceChildMixin, SourceMixin, FileNode): 

159 """A file leaf node in the *.hods* directory. 

160 

161 The root must be a :class`hods.sources.Source` (sub-)class instance. 

162 """ 

163 

164 #: We don't want to share `mode` between instances! So we define it 

165 #: in `__init__` using this default value instead of defining it here. 

166 default_mode = 'link' 

167 

168 def __init__(self, *args, mode=None, template_engine_id='jinja', **kwargs): 

169 """Initialize file. 

170 

171 :param destination: `str` - Optional custom relative path for 

172 the link. 

173 :param mode: `str` - One of ('link', 'copy', 'template', 'ignore') 

174 """ 

175 super().__init__(*args, **kwargs) 

176 

177 if mode is None: 

178 mode = self.default_mode 

179 self.mode = mode 

180 

181 self.template_engine_id = template_engine_id 

182 

183 def as_dict(self) -> dict: 

184 """Return a `dict` to store and recreate this instance.""" 

185 data = super().as_dict() 

186 if self.mode != self.default_mode: 

187 data['mode'] = self.mode 

188 if self.mode == 'template' and self.template_engine_id != 'jinja': 

189 data['template_engine_id'] = self.template_engine_id 

190 return data 

191 

192 def is_ignored(self): 

193 """Return a `bool` whether this file should be ignored.""" 

194 if self.mode in (None, 'ignore'): 

195 return True 

196 return super().is_ignored() 

197 

198 def render(self, engine_id=None): 

199 """Render this template using the given template engine.""" 

200 if engine_id is None: 

201 engine_id = self.template_engine_id 

202 

203 template = self.read() 

204 context = dict(load_context(self.tree)) 

205 context['file'] = self 

206 try: 

207 engine_class = get_template_engine(engine_id) 

208 engine = engine_class() 

209 return engine.render(template, context) 

210 except TemplateError as e: 

211 e.sourcefile = self 

212 raise e 

213 

214 

215class BaseSourceDirectory(SourceMixin, DirectoryNode): 

216 """Base class for root and nested directory nodes in a source tree. 

217 

218 This is the container for all source files and supports unlimited nesting. 

219 """ 

220 

221 ignore_basenames = ['.git'] 

222 

223 is_source_root = False 

224 

225 def get_child_directory_class(self): 

226 """Return `SourceDirectory` to instantiate child directories.""" 

227 return SourceDirectory 

228 

229 child_file_class = SourceFile 

230 

231 clear_on_scan = False 

232 

233 #: Ignore all source files in this directory. 

234 ignore_default = False 

235 

236 def __init__(self, parent, name, ignore=None, **kwargs): 

237 """Initialize directory. 

238 

239 :param ignore: Ignore all source files in this directory. (Default: ``False``) 

240 """ 

241 super().__init__(parent, name, **kwargs) 

242 

243 if ignore is None: 

244 ignore = self.ignore_default 

245 self.ignore = ignore 

246 

247 def as_dict(self): 

248 """Return a `dict` to store and recreate this instance.""" 

249 data = super().as_dict(include_children=not self.ignore) 

250 if self.ignore: 

251 data['ignore'] = True 

252 return data 

253 

254 def is_ignored(self): 

255 """Return a `bool` whether this file should be ignored.""" 

256 return self.ignore 

257 

258 def collect_children(self, exclude_dirs=False, exclude_ignored=False): 

259 """Recursively collect all children. 

260 

261 Args: 

262 exclude_dirs: Exclude directories 

263 exclude_ignored: Exclude ignored directories and files 

264 

265 Yields: The next child matching given requirements 

266 """ 

267 for child in super().collect_children(exclude_dirs=exclude_dirs): 

268 if not exclude_ignored or not child.is_ignored(): 

269 yield child 

270 

271 

272class SourceDirectory(SourceChildMixin, BaseSourceDirectory): 

273 """The nested directory node in a source tree.""" 

274 

275 pass