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
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
19import os
21from hods.config.template import (
22 TemplateError,
23 get_template_engine,
24 load_context,
25)
26from hods.node import DirectoryNode, FileNode, validate_child_path
28logger = logging.getLogger(__name__)
31class SourceMixin:
32 """Mixin class for all source nodes."""
34 def __init__(self, parent, name, destination=None, **kwargs):
35 """Initialize node.
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)
47 self._destination_path = None # property
48 self.destination_path = destination
50 @property
51 def relative_source_path(self):
52 """The relative path to the parent source."""
53 return self.relpath(self.source.path)
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)
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)
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)
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
77 @destination_path.setter
78 def destination_path(self, value: str):
79 """Set the path for the symlink or rendered template.
81 Pass ``None`` to use the default.
82 If an absolute path is given, convert it to a relative path.
84 Args:
85 value (`str`): The path for the symlink of this source file.
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
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)
106 @property
107 def destination_basename(self):
108 """Return the basename of the `destination_path`."""
109 return os.path.basename(self.destination_path)
111 @property
112 def destination_dirname(self):
113 """Return the dirname of the `destination_path`."""
114 return os.path.dirname(self.destination_path)
116 def destination_is_default(self):
117 """Return a `bool` whether the `destination_path` is not configured."""
118 return self._destination_path is None
120 def is_ignored(self):
121 """Return a `bool` whether this file should be ignored."""
122 return False
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
132class SourceChildMixin:
133 """Mixin for child source nodes."""
135 def __init__(self, *args, **kwargs):
136 """Initialize source child node."""
137 super().__init__(*args, **kwargs)
139 self.source_parents = list(self._get_source_parents())
140 self.source = self.source_parents[-1]
141 self.tree = self.source.feature.tree
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
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()
158class SourceFile(SourceChildMixin, SourceMixin, FileNode):
159 """A file leaf node in the *.hods* directory.
161 The root must be a :class`hods.sources.Source` (sub-)class instance.
162 """
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'
168 def __init__(self, *args, mode=None, template_engine_id='jinja', **kwargs):
169 """Initialize file.
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)
177 if mode is None:
178 mode = self.default_mode
179 self.mode = mode
181 self.template_engine_id = template_engine_id
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
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()
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
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
215class BaseSourceDirectory(SourceMixin, DirectoryNode):
216 """Base class for root and nested directory nodes in a source tree.
218 This is the container for all source files and supports unlimited nesting.
219 """
221 ignore_basenames = ['.git']
223 is_source_root = False
225 def get_child_directory_class(self):
226 """Return `SourceDirectory` to instantiate child directories."""
227 return SourceDirectory
229 child_file_class = SourceFile
231 clear_on_scan = False
233 #: Ignore all source files in this directory.
234 ignore_default = False
236 def __init__(self, parent, name, ignore=None, **kwargs):
237 """Initialize directory.
239 :param ignore: Ignore all source files in this directory. (Default: ``False``)
240 """
241 super().__init__(parent, name, **kwargs)
243 if ignore is None:
244 ignore = self.ignore_default
245 self.ignore = ignore
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
254 def is_ignored(self):
255 """Return a `bool` whether this file should be ignored."""
256 return self.ignore
258 def collect_children(self, exclude_dirs=False, exclude_ignored=False):
259 """Recursively collect all children.
261 Args:
262 exclude_dirs: Exclude directories
263 exclude_ignored: Exclude ignored directories and files
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
272class SourceDirectory(SourceChildMixin, BaseSourceDirectory):
273 """The nested directory node in a source tree."""
275 pass