Coverage for src/hods/tui/config/edit.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 os
19import subprocess
21import urwid
23from hods.config.feature import Feature
24from hods.config.sources import GitRepository, Source
25from hods.config.template import TEMPLATE_ENGINE_REGISTRY
26from hods.tui.base.edit import EditWindow, ObjectDeleteMixin, ObjectEditWindow, radio_buttons
27from hods.tui.base.view import ErrorWindow
28from hods.tui.base.widgets import FocusMap
31class FeatureAddWindow(EditWindow):
32 """A window widget to create sources (and features)."""
34 def __init__(self, app, **kwargs):
35 """Initialize window.
37 :param app: `hods.app.App` instance
38 """
39 super().__init__(
40 app,
41 title='New Feature',
42 attr='feature window',
43 height=5,
44 **kwargs)
46 default_exists = any(f.name == 'default' for f in app.config.tree.features)
47 self.edit_name = urwid.Edit(edit_text='' if default_exists else 'default')
48 self.checkbox_default = urwid.CheckBox('Install by default', state=not default_exists)
50 def get_items(self):
51 """Collect and return form widgets."""
52 yield urwid.Columns([
53 ('pack', urwid.Text('Name:')),
54 FocusMap(self.edit_name, 'edit'),
55 ], 1)
56 yield self.checkbox_default
58 def clean(self):
59 """Clean and return form data."""
60 name = self.edit_name.edit_text.strip()
61 if not name:
62 raise self.ValidationError('A name is required!')
63 for feature in self.app.config.tree.features:
64 if feature.name == name:
65 raise self.ValidationError('A feature called "{}" already exists!'.format(name))
66 default = self.checkbox_default.state
67 return {
68 'name': name,
69 'installed': True,
70 'default': default,
71 }
73 def save(self, data):
74 """Validate and save data."""
75 obj = Feature(self.app.config.tree, **data)
76 try:
77 os.mkdir(self.app.settings.hods_path, 0o700)
78 except FileExistsError:
79 pass
80 obj.mkdir()
82 self.app.save()
83 return obj
86class FeatureInstallWindow(EditWindow):
87 """A window widget to select features to install."""
89 save_button_text = 'Install'
91 def __init__(self, app, **kwargs):
92 """Initialize window.
94 :param app: `hods.app.App` instance
95 """
96 super().__init__(
97 app,
98 attr='feature window',
99 title='Install Features',
100 height=6,
101 **kwargs)
103 self.checkboxes = [
104 urwid.CheckBox(f.name, state=f.installed or f.default)
105 for f in app.config.new_features
106 ]
108 def get_items(self):
109 """Collect and return form widgets."""
110 yield urwid.Text('New features found. Choose which ones to install:')
111 for checkbox in self.checkboxes:
112 yield checkbox
114 def clean(self):
115 """Clean and return form data."""
116 return {'installed_features': [c.label for c in self.checkboxes if c.state]}
118 def save(self, data):
119 """Validate and save data."""
120 for feature in self.app.config.tree.features:
121 feature.installed = feature.name in data['installed_features']
122 self.app.save()
125class FeatureEditWindow(ObjectEditWindow):
126 """A window to edit `hods.feature.Feature` instances."""
128 def __init__(self, app, obj, **kwargs):
129 """Initialize widget.
131 :param app: `hods.__main__.TUI` instance
132 :param feature: `hods.feature.Feature` object to edit
133 :param kwargs: Additional options, passed to `EditWindow`
134 """
135 super().__init__(
136 app,
137 obj,
138 height=11,
139 title='Feature: {}'.format(obj.name),
140 attr='feature window',
141 **kwargs)
142 self.checkbox_installed = urwid.CheckBox('Installed', state=obj.installed)
143 self.checkbox_default = urwid.CheckBox('Install By Default', state=obj.default)
145 self.hook_columns = {}
147 def get_items(self):
148 """Collect and return form widgets."""
149 yield self.checkbox_installed
150 yield self.checkbox_default
152 yield urwid.Divider()
154 yield urwid.Text('Hooks:')
155 yield self.get_hook_columns('Update', self.object.hooks.update, 'When updating home directory')
156 yield self.get_hook_columns('Push', self.object.hooks.push, 'When uploading configuration and sources')
158 def get_hook_columns(self, label, hooks, help_text):
159 """Collect hook related form widgets."""
160 col, pre_attr, post_attr = self.hook_columns.get(hooks.name, (None, None, None))
162 if col is None:
163 pre_btn = urwid.Button('Pre', on_press=self.on_press_hook, user_data=hooks.pre)
164 post_btn = urwid.Button('Post', on_press=self.on_press_hook, user_data=hooks.post)
166 pre_attr = FocusMap(pre_btn, 'button')
167 post_attr = FocusMap(post_btn, 'button')
168 col = urwid.Columns([
169 (6, urwid.Text(label)),
170 (7, pre_attr),
171 (8, post_attr),
172 urwid.Text(help_text),
173 ], dividechars=1)
174 self.hook_columns[hooks.name] = col, pre_attr, post_attr
176 pre_attr.attr_map = {None: 'button' if hooks.pre.content else 'disabled'}
177 post_attr.attr_map = {None: 'button' if hooks.post.content else 'disabled'}
178 return col
180 def on_press_hook(self, btn, script):
181 """Open script in editor and store it again afterwards."""
182 new = self.app.open_in_editor(script.content, parent=self, suffix='.sh')
183 if new is not None:
184 script.content = new
186 def clean(self):
187 """Clean and return form data."""
188 return {
189 'installed': self.checkbox_installed.state,
190 'default': self.checkbox_default.state,
191 }
194class SourceFileAddWindow(EditWindow):
195 """A window to select possible source files in the home directory."""
197 save_button_text = 'Add'
199 close_on_save = False
201 def __init__(self, app, source, *args, **kwargs):
202 """Initialize window."""
203 super().__init__(
204 app,
205 *args,
206 title='Select files to add to "{}"'.format(source.relative_feature_path),
207 **kwargs,
208 )
209 self.source = source
211 def get_listbox(self):
212 """Build and return the home directory tree widget."""
213 from hods.tui.config.tree import HomeTree, HomeTreeDecoration, PathTreeBox
214 tree = HomeTree(self.app, self.app.settings.home_path)
215 self.decorated_tree = HomeTreeDecoration(self.app, tree)
216 return PathTreeBox(self.decorated_tree)
218 def wrap_body(self, body):
219 """Wrap the listbox with a 'body' `urwid.AttrMap`."""
220 return urwid.AttrMap(body, 'body')
222 def refresh(self):
223 """Never refresh the walker."""
224 pass
226 def clean(self):
227 """Validate all field values and return them."""
228 data = {}
229 for obj in self.decorated_tree.clean():
230 relpath = obj.relpath(self.app.settings.home_path)
231 destination = os.path.join(self.source.path, relpath)
232 if self.source.find(destination) is not None:
233 raise self.ValidationError('{} already exists in {}'.format(relpath, self.source.basename))
234 data[obj] = destination
235 return data
237 def save(self, data):
238 """Store cleaned data."""
239 if not data:
240 return
241 self.close()
242 for obj, destination in data.items():
243 obj.copy(destination)
244 self.source.scan(recursive=True)
245 self.app.save()
248SOURCEFILE_PATH_TYPE_CHOICES = [
249 ('Default:', 'default'),
250 ('Custom:', 'custom'),
251]
254class BaseSourceEditWindow(ObjectDeleteMixin, ObjectEditWindow):
255 """Base class for source edit windows."""
257 def __init__(self, app, obj, **kwargs):
258 """Initialize widget.
260 :param app: `hods.__main__.TUI` instance
261 :param obj: `hods.file.SourceFile` object to edit
262 :param kwargs: Additional options, passed to `EditWindow`
263 """
264 super().__init__(app, obj, **kwargs)
266 is_default = obj.destination_is_default()
267 self.path_type = 'default' if is_default else 'custom'
268 self.edit_path = urwid.Edit(edit_text='' if is_default else obj.destination_path)
270 def get_items(self):
271 """Collect widgets to display."""
272 yield urwid.Divider()
273 yield urwid.Text('Destination:')
275 buttons = list(radio_buttons(
276 SOURCEFILE_PATH_TYPE_CHOICES,
277 getset=(self, 'path_type'),
278 columns=True,
279 ))
280 yield urwid.Columns([
281 buttons[0],
282 urwid.Text(os.path.join('~/', self.object.default_destination)),
283 ], dividechars=1)
284 yield urwid.Columns([
285 buttons[1],
286 urwid.Columns([
287 (2, urwid.Text('~/')),
288 FocusMap(self.edit_path, 'edit'),
289 ]),
290 ], dividechars=1)
292 def clean(self):
293 """Clean all values and return them as `dict`."""
294 destination_path = None
295 if self.path_type == 'custom':
296 destination_path = self.edit_path.edit_text
297 if not destination_path:
298 raise self.ValidationError('Custom path cannot be empty!')
299 return {'destination_path': destination_path}
302class SourceDirectoryEditWindow(BaseSourceEditWindow):
303 """Edit form for :class`hods.config.sourcefile.SourceDirectory`."""
305 def __init__(self, app, obj, **kwargs):
306 """Initialize widget.
308 :param app: `hods.__main__.TUI` instance
309 :param sourcefile: `hods.file.SourceFile` object to edit
310 :param kwargs: Additional options, passed to `EditWindow`
311 """
312 kwargs.setdefault('height', 7)
313 kwargs.setdefault('title', 'Source Directory: {}'.format(obj.basename))
314 super().__init__(app, obj, **kwargs)
316 self.checkbox_ignore = urwid.CheckBox('Ignore', state=obj.ignore)
318 def get_items(self):
319 """Collect widgets to display."""
320 yield self.checkbox_ignore
321 yield from super().get_items()
323 def clean(self):
324 """Clean and return form data."""
325 data = super().clean()
326 data['ignore'] = self.checkbox_ignore.state
327 return data
330class RenderErrorWindow(ErrorWindow):
331 """Window to display a template rendering error."""
333 def __init__(self, app, exception, *args, **kwargs):
334 """Initialize window."""
335 text = str(exception)
336 kwargs.setdefault('title', exception.engine.label + ' Error')
337 super().__init__(app, text, **kwargs)
340SOURCEFILE_MODE_CHOICES = [
341 ('Ignore', 'ignore'),
342 ('Link', 'link'),
343 ('Template', 'template'),
344]
347class SourceFileEditWindow(BaseSourceEditWindow):
348 """Edit form for :class`hods.config.sourcefile.SourceFile`."""
350 def __init__(self, app, obj, **kwargs):
351 """Initialize widget.
353 :param app: `hods.__main__.TUI` instance
354 :param sourcefile: `hods.file.SourceFile` object to edit
355 :param kwargs: Additional options, passed to `EditWindow`
356 """
357 kwargs.setdefault('height', 13)
358 kwargs.setdefault('title', 'Source File: {}'.format(obj.basename))
359 super().__init__(app, obj, **kwargs)
360 self.mode = obj.mode
361 self.engine_id = obj.template_engine_id
363 def get_items(self):
364 """Collect and return form widgets."""
365 mode_columns = [(5, urwid.Text('Mode:'))] + list(radio_buttons(
366 SOURCEFILE_MODE_CHOICES,
367 initial=self.mode,
368 on_change=self.on_change_mode,
369 columns=True,
370 ))
371 self.mode_columns = urwid.Columns(mode_columns, dividechars=1)
372 yield self.mode_columns
374 if self.mode in ('link', 'template'):
375 yield from super().get_items()
377 if self.mode == 'template':
378 yield from self.get_template_items()
380 def get_template_items(self):
381 """Collect template related form widgets."""
382 yield urwid.Divider()
383 yield urwid.Columns([
384 ('pack', urwid.Text('Template Engine:')),
385 (11, FocusMap(
386 urwid.Button('Preview', on_press=self.preview_template),
387 'button',
388 )),
389 (10, FocusMap(
390 urwid.Button('Editor', on_press=self.open_editor),
391 'button',
392 )),
393 ], dividechars=1)
395 engines = list(TEMPLATE_ENGINE_REGISTRY.values())
396 choices = [(e.label, e.engine_id) for e in engines]
397 buttons = radio_buttons(choices, getset=(self, 'engine_id'))
398 label_width = max(len(e.label) for e in engines)
400 for button, engine in zip(buttons, engines):
401 attr = None
402 description_items = [urwid.Text(engine.description)]
403 if not engine.is_installed():
404 attr = 'disabled'
405 description_items.append((15, FocusMap(urwid.Text('(Not Installed)'), 'error window')))
406 yield FocusMap(urwid.Columns([
407 (label_width + 5, button),
408 *description_items,
409 ], dividechars=2), attr)
411 def preview_template(self, btn, user_data=None):
412 """Render template and open it in editor."""
413 from hods.config.template import TemplateEngineNotInstalled, TemplateRenderError
415 try:
416 result = self.object.render(self.engine_id)
417 except TemplateEngineNotInstalled as e:
418 ErrorWindow(self.app, str(e), parent=self).show()
419 return
420 except TemplateRenderError as e:
421 RenderErrorWindow(self.app, e, parent=self).show()
422 return
424 self.app.open_in_editor(result, ignore_error=True, parent=self, suffix=self.object.basename)
426 def open_editor(self, btn, user_data=None):
427 """Open file in editor."""
428 try:
429 self.app.open_editor(self.object.path, parent=self)
430 except subprocess.CalledProcessError:
431 pass
433 def on_change_mode(self, btn, state, user_data=None):
434 """Callback to handle changing the source file mode."""
435 if state:
436 self.mode = user_data
437 self.refresh()
438 index = btn.group.index(btn)
439 self.mode_columns.set_focus(index + 1)
441 def clean(self):
442 """Validate and return all fields."""
443 data = super().clean()
444 data['mode'] = self.mode
445 data['template_engine_id'] = self.engine_id
446 return data
449class BaseSourceMixin:
450 """A window mixin to edit sources."""
452 def __init__(self, *args, **kwargs):
453 """Initialize window."""
454 kwargs.setdefault('height', 9)
455 super().__init__(*args, **kwargs)
457 obj = getattr(self, 'object', None)
458 state = False if obj is None else obj.pull_only
459 self.checkbox_pull_only = urwid.CheckBox('Pull only', state=state)
461 def clean(self):
462 """Clean form data and return it."""
463 data = super().clean()
464 data['pull_only'] = self.checkbox_pull_only.state
465 return data
467 def get_items(self):
468 """Return widgets to display."""
469 yield self.checkbox_pull_only
470 yield from super().get_items()
472 def get_existing_basenames(self):
473 """Generate sources of the selected feature."""
474 for source in self.object.feature.sources:
475 if source == self.object:
476 continue # skip self
477 yield source.basename
479 def clean_basename(self, basename):
480 """Validate the given basename."""
481 if not basename:
482 raise self.ValidationError('A name is required!')
483 if basename in self.get_existing_basenames():
484 raise self.ValidationError('A source called "{}" already exists!'.format(basename))
487class SourceAddMixin:
488 """Mixin for source add windows."""
490 def get_existing_basenames(self):
491 """Return sources of the selected feature."""
492 for source in self.feature.sources:
493 yield source.basename
496class SourceEditMixin:
497 """Mixin for source edit windows."""
499 def save(self, data):
500 """Move the source directory if renamed."""
501 if data['name'] != self.object.name:
502 self.object.move(self.object.parent.join(data['name']))
503 return super().save(data)
506class GitRepositoryMixin(BaseSourceMixin):
507 """A window mixin to edit git repositories."""
509 def __init__(self, *args, **kwargs):
510 """Initialize window."""
511 super().__init__(*args, attr='source window', **kwargs)
512 self.edit_url = urwid.Edit()
514 def get_items(self):
515 """Return widgets to display."""
516 if not GitRepository.is_dependency_installed():
517 text = 'git is not installed!'
518 yield urwid.AttrMap(urwid.Text(text, align='center'), 'error window')
519 yield urwid.Columns([
520 ('pack', urwid.Text('URL:')),
521 FocusMap(self.edit_url, 'edit'),
522 ], dividechars=1)
523 yield from super().get_items()
525 def clean(self):
526 """Clean form data and return it."""
527 data = super().clean()
529 url = self.edit_url.edit_text.strip()
530 if not url:
531 raise self.ValidationError('An url is required!')
532 data['url'] = url
534 basename = GitRepository.basename_by_url(url)
535 self.clean_basename(basename)
536 data['name'] = basename
538 return data
541class GitRepositoryEditWindow(SourceEditMixin, GitRepositoryMixin, SourceDirectoryEditWindow):
542 """A window to edit git repositories."""
544 def __init__(self, app, source, **kwargs):
545 """Initialize window.
547 :param app: `hods.tui.__main__.App`
548 :param source: `hods.config.sources.GitRepository` instance -
549 The git repository to edit.
550 :param kwargs: pass to parent
551 """
552 kwargs.setdefault('title', 'git source: {}'.format(source.basename))
553 super().__init__(app, source, **kwargs)
554 self.edit_url.edit_text = self.object.url
557class GitRepositoryAddWindow(SourceAddMixin, GitRepositoryMixin, EditWindow):
558 """A window to add git repositories."""
560 def __init__(self, app, feature, **kwargs):
561 """Initialize window.
563 Args:
564 app:
565 The main `~hods.tui.__main__.App` instance
566 feature:
567 The parent `~hods.config.feature.Feature` instance
568 the new source should be added to.
569 **kwargs:
570 Additional keyword arguments passed to parent classes.
571 """
572 kwargs['title'] = 'New git source'
573 kwargs.setdefault('height', 7)
574 super().__init__(app, **kwargs)
575 self.feature = feature
576 self.creation_method = 'init'
578 def get_items(self):
579 """Return widgets to display."""
580 yield from super().get_items()
581 yield from radio_buttons(
582 [
583 ('Init new repository', 'init'),
584 ('Clone existing repository', 'clone'),
585 ],
586 getset=(self, 'creation_method'))
588 def save(self, data):
589 """Store cleaned data."""
590 obj = GitRepository(self.feature, **data)
591 with self.app.pause_loop():
592 getattr(obj, self.creation_method)()
594 self.app.save()
595 return obj
598class ServerDirectoryMixin(BaseSourceMixin):
599 """A window mixin to edit and validate a rsync directory."""
601 def __init__(self, *args, **kwargs):
602 """Initialize window."""
603 super().__init__(*args, attr='source window', **kwargs)
604 self.edit_name = urwid.Edit()
606 def get_items(self):
607 """Return widgets to display."""
608 yield urwid.Columns([
609 ('pack', urwid.Text('Name:')),
610 FocusMap(self.edit_name, 'edit'),
611 ], dividechars=1)
612 yield from super().get_items()
614 def clean(self):
615 """Clean form data and return it."""
616 data = super().clean()
618 basename = self.edit_name.edit_text.strip()
619 self.clean_basename(basename)
620 data['name'] = basename
622 return data
625class ServerDirectoryEditWindow(SourceEditMixin, ServerDirectoryMixin, SourceDirectoryEditWindow):
626 """A window to edit rsync directories."""
628 def __init__(self, app, source, **kwargs):
629 """Initialize window."""
630 kwargs.setdefault('title', 'rsync source: {}'.format(source.basename))
631 super().__init__(app, source, **kwargs)
632 self.edit_name.edit_text = source.name
635class ServerDirectoryAddWindow(SourceAddMixin, ServerDirectoryMixin, EditWindow):
636 """A window to add rsync/server directories."""
638 def __init__(self, app, feature, **kwargs):
639 """Initialize window."""
640 kwargs.setdefault('height', 5)
641 kwargs.setdefault('title', 'New rsync source')
642 super().__init__(app, **kwargs)
643 self.feature = feature
645 def save(self, data):
646 """Store cleaned data."""
647 obj = Source(self.feature, **data)
648 obj.init()
650 self.app.save()
651 return obj