Coverage for src/hods/delta.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
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
1"""hods - home directory synchronization.
3Copyright (C) 2016-2020 Mathias Stelzer <knoppo@rolln.de>
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.
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.
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
20from hods.node import DirectoryNode, FileNode, RootDirectoryNode
21from hods.path import Directory, File
23logger = logging.getLogger(__name__)
26class SameDestinationError(Exception):
27 """Exception for 2 SourceFile instances with the same destination."""
29 def __init__(self, first, second, *args, **kwargs):
30 """Initialize error.
32 :param first: first sourcefile object
33 :param second: second sourcefile object
34 :param args: more Exception args
35 :param kwargs: more Exception kwargs
36 """
37 self.first = first
38 self.second = second
39 msg = '"{first}" and "{second}" are both trying to create a symlink at "{destination}".'.format(
40 first=first.relative_config_path,
41 second=second.relative_config_path,
42 destination=first.destination_path)
43 super().__init__(msg, *args, **kwargs)
46class DeltaBuilder:
47 """Compare a tree with the home directory."""
49 def __init__(self, config):
50 """Initialize home directory update.Initialize home directory update.
52 :param config: `hods.config.base.Config` instance
53 """
54 self.config = config
56 def build(self):
57 """Build a new `Delta` instance and return it.
59 This compares the source destination tree with the current home directory and stores
60 all changes and necessary actions in a `Delta` instance.
61 """
62 delta = Delta(self.config)
64 if not delta.exists():
65 msg = 'Your home directory does not exist! ({})'.format(delta.path)
66 logger.error(msg)
67 raise FileNotFoundError(msg)
69 for sourcefile in self.config.tree.collect_sourcefiles():
70 self.add_sourcefile(delta, sourcefile)
72 for sourcefile in self.config.settings.tree.collect_sourcefiles():
73 if delta.find(sourcefile.destination_path) is None:
74 # file is not in the config anymore -> it was removed.
75 self.delete_sourcefile(delta, sourcefile)
76 return delta
78 def add_sourcefile(self, delta, sourcefile):
79 """Add a source file to this delta."""
80 # check if there already is a file or directory for the destination
81 existing = delta.find(sourcefile.destination_path)
82 if existing:
83 # If it has a sourcefile assigned we have a collision! This is
84 # a configuration error and should be forwarded to the user.
85 existing_sourcefile = getattr(existing, 'sourcefile', None)
86 if existing_sourcefile is not None:
87 raise SameDestinationError(sourcefile, existing_sourcefile)
88 # otherwise replace it (detach and use its parent)
89 existing.detach()
90 parent = existing.parent
91 else:
92 parent = delta.find(sourcefile.destination_dirname, create=True, cls='dir')
94 if sourcefile.mode == 'link':
95 Symlink(parent, sourcefile)
96 elif sourcefile.mode == 'template':
97 Template(parent, sourcefile)
98 else:
99 m = 'Unable to add {} with invalid mode {}'.format(repr(sourcefile), repr(sourcefile.mode))
100 raise ValueError(m)
102 def delete_sourcefile(self, delta, sourcefile):
103 """Add a deleted file to this delta."""
104 parent = delta.find(sourcefile.destination_dirname, create=True,
105 node_cls=DeletedDirectory, cls=DeletedDirectory)
107 if sourcefile.mode == 'link':
108 DeletedSymlink(parent, sourcefile)
109 elif sourcefile.mode == 'template':
110 DeletedTemplate(parent, sourcefile)
111 else:
112 m = 'Unable to delete {} with invalid mode "{}"'.format(repr(sourcefile), sourcefile.mode)
113 raise ValueError(m)
116class ApplyError(Exception):
117 """Raised when applying a file or directory fails."""
119 def __init__(self, node, action, message):
120 """Initialize exception."""
121 self.node = node
122 self.action = action
123 self.message = message
124 super().__init__('Failed to {} "{}": {}'.format(action, node.path, message))
127class Delta(RootDirectoryNode):
128 """A home directory update.
130 Instances are created by `DeltaBuilder` and contain directories and files representing
131 the destinations and necessary actions in a source tree.
132 """
134 ignore_basenames = ['.hods']
136 def __init__(self, config):
137 """Initialize the home directory tree."""
138 super().__init__(config.settings.home_path)
139 self.config = config
141 def get_child_directory_class(self):
142 """Return `DeltaDirectory` to instantiate child directories."""
143 return DeltaDirectory
145 def get_child_file_class(self):
146 """Return `DeltaFile` to instantiate child files."""
147 return DeltaFile
149 def get_file_action(self, file):
150 """Get the action for the given file and return it.
152 Overwrite this to intercept the update process of individual files.
154 :param file: `hods.update.BaseDeltaPath` - The file that will be updated
155 :return: Callable or None - The action to update the given file
156 """
157 return file.get_action()
159 def apply(self):
160 """Apply this delta to the home directory."""
161 files = list(self.collect_children())
162 while files: # and directories
163 f = files.pop(0)
165 action = self.get_file_action(f)
166 if action is None:
167 logger.debug('update %s: skip', f)
168 continue
170 logger.info('update %s: %s', f, action)
171 try:
172 action()
173 except OSError as e:
174 raise ApplyError(f, action.__name__, str(e))
175 self.config.settings.tree.load_data(self.config.tree.as_dict())
176 logger.debug(str(self.config.settings.tree.features))
177 self.config.settings.save()
180class DeltaNodeMixin:
181 """Base file in the home directory."""
183 label = None
185 def __init__(self, parent, path, attach=True):
186 """Initialize HomeFile.
188 :param parent: `DeltaPath` instance - Parent directory
189 :param path: `str` - basename
190 :param attach: `bool` - attach the file to the parent (Default: ``True``)
191 """
192 super().__init__(parent, path, attach=attach)
193 self.home = self.root
194 if self.label is None:
195 self.label = self.__class__.__name__.lower()
197 @property
198 def relative_home_path(self):
199 """Return the relative path to the home directory."""
200 return self.relpath(self.home.path)
202 def get_action(self):
203 """Get the required method to apply the delta."""
204 if self.is_applied():
205 return
206 if self.islink() or self.exists():
207 return self.replace
208 return self.create
210 def is_applied(self):
211 """Overwrite in subclass and return the required action."""
212 raise NotImplementedError
214 def create(self):
215 """Overwrite in subclass and return the required action."""
216 raise NotImplementedError
218 def replace(self):
219 """Overwrite in subclass and return the required action."""
220 self.delete()
221 self.create()
223 def delete(self):
224 """Delete the file or directory at this path."""
225 if not self.islink() and self.isdir():
226 Directory(self.path).delete()
227 else:
228 File(self.path).delete()
230 def get_label_for_action(self, action):
231 """Get descriptive label for given action."""
232 if action == 'create':
233 return 'create {}'.format(self.label)
234 if action == 'replace':
235 return 'replace with {}'.format(self.label)
236 return action
238 def get_action_label(self):
239 """Get descriptive label for current action."""
240 action = self.get_action()
241 if action is None:
242 return ''
243 return self.get_label_for_action(action.__name__)
246class DeltaFile(DeltaNodeMixin, FileNode):
247 """A simple file in the home directory."""
249 pass
252class DeltaDirectory(DeltaNodeMixin, DirectoryNode):
253 """A directory in the home directory."""
255 child_file_class = DeltaFile
257 label = 'directory'
259 def get_child_directory_class(self):
260 """Return the child class for the given basename."""
261 return DeltaDirectory
263 def is_applied(self):
264 """Check whether the directory exists."""
265 # if the parent gets re-created we need to re-create this child, too
266 if not self.parent.is_root and not self.parent.is_applied():
267 return False
268 return not self.islink() and self.isdir()
270 def create(self):
271 """Create the directory node."""
272 self.mkdir()
275class DestinationMixin(DeltaNodeMixin):
276 """A file in the home directory with an assigned source file."""
278 def __init__(self, parent, sourcefile, attach=True):
279 """Initialize source file delta node. node.
281 :param parent: `BaseDeltaPath` object - Parent node.
282 :param sourcefile: `hods.config.sourcefile.SourceFile` - The
283 source file to apply.
284 :param attach: `bool` - Attach the node to the tree.
285 (Default: True)
286 """
287 super().__init__(parent, sourcefile.destination_basename, attach=attach)
288 self.sourcefile = sourcefile
291class Symlink(DestinationMixin, DeltaFile):
292 """Symlink in the home directory."""
294 def is_linked(self):
295 """Whether this file is a symlink pointing to the assigned `SourceFile`."""
296 if not self.islink():
297 return False
298 return self.realpath() == self.sourcefile.path
300 def is_applied(self):
301 """Check whether the symlink is already created."""
302 return self.is_linked()
304 def create(self):
305 """Symlink the source files destination to it."""
306 self.sourcefile.symlink(self.path)
309class Template(DestinationMixin, DeltaFile):
310 """Template in the home directory."""
312 def __init__(self, parent, sourcefile, attach=True):
313 """Initialize template."""
314 super().__init__(parent, sourcefile, attach=attach)
315 self.rendered = sourcefile.render()
317 def is_applied(self):
318 """Check whether the template is rendered."""
319 if self.isdir() or self.islink():
320 return False
321 try:
322 text = self.read()
323 except FileNotFoundError:
324 return False
325 return self.rendered == text
327 def create(self):
328 """Render and write the template."""
329 self.write(self.rendered)
332class DeletedDeltaNodeMixin:
333 """Mixin for destination file classes of a deleted source file."""
335 def get_action(self):
336 """Return 'delete' action if file has not changed since last update."""
337 if self.is_applied():
338 return self.delete
341class DeletedDirectory(DeletedDeltaNodeMixin, DeltaDirectory):
342 """Deleted directory in the home directory."""
344 def will_be_empty(self):
345 """Check whether the directory can be safely deleted."""
346 filenames = self.listdir()
347 for child in self.children[:]:
348 if child.get_action() == child.delete:
349 filenames.remove(child.basename)
350 return not bool(filenames)
352 def get_action(self):
353 """Get the required method to apply the delta."""
354 action = super().get_action()
355 if action is None:
356 return
357 if self.will_be_empty():
358 return action
361class DeletedSymlink(DeletedDeltaNodeMixin, Symlink):
362 """Deleted symlink in the home directory."""
364 pass
367class DeletedTemplate(DeletedDeltaNodeMixin, Template):
368 """Deleted template in the home directory."""
370 pass