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

312 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 os 

19import subprocess 

20 

21import urwid 

22 

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 

29 

30 

31class FeatureAddWindow(EditWindow): 

32 """A window widget to create sources (and features).""" 

33 

34 def __init__(self, app, **kwargs): 

35 """Initialize window. 

36 

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) 

45 

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) 

49 

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 

57 

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 } 

72 

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() 

81 

82 self.app.save() 

83 return obj 

84 

85 

86class FeatureInstallWindow(EditWindow): 

87 """A window widget to select features to install.""" 

88 

89 save_button_text = 'Install' 

90 

91 def __init__(self, app, **kwargs): 

92 """Initialize window. 

93 

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) 

102 

103 self.checkboxes = [ 

104 urwid.CheckBox(f.name, state=f.installed or f.default) 

105 for f in app.config.new_features 

106 ] 

107 

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 

113 

114 def clean(self): 

115 """Clean and return form data.""" 

116 return {'installed_features': [c.label for c in self.checkboxes if c.state]} 

117 

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() 

123 

124 

125class FeatureEditWindow(ObjectEditWindow): 

126 """A window to edit `hods.feature.Feature` instances.""" 

127 

128 def __init__(self, app, obj, **kwargs): 

129 """Initialize widget. 

130 

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) 

144 

145 self.hook_columns = {} 

146 

147 def get_items(self): 

148 """Collect and return form widgets.""" 

149 yield self.checkbox_installed 

150 yield self.checkbox_default 

151 

152 yield urwid.Divider() 

153 

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') 

157 

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)) 

161 

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) 

165 

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 

175 

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 

179 

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 

185 

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 } 

192 

193 

194class SourceFileAddWindow(EditWindow): 

195 """A window to select possible source files in the home directory.""" 

196 

197 save_button_text = 'Add' 

198 

199 close_on_save = False 

200 

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 

210 

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) 

217 

218 def wrap_body(self, body): 

219 """Wrap the listbox with a 'body' `urwid.AttrMap`.""" 

220 return urwid.AttrMap(body, 'body') 

221 

222 def refresh(self): 

223 """Never refresh the walker.""" 

224 pass 

225 

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 

236 

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() 

246 

247 

248SOURCEFILE_PATH_TYPE_CHOICES = [ 

249 ('Default:', 'default'), 

250 ('Custom:', 'custom'), 

251] 

252 

253 

254class BaseSourceEditWindow(ObjectDeleteMixin, ObjectEditWindow): 

255 """Base class for source edit windows.""" 

256 

257 def __init__(self, app, obj, **kwargs): 

258 """Initialize widget. 

259 

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) 

265 

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) 

269 

270 def get_items(self): 

271 """Collect widgets to display.""" 

272 yield urwid.Divider() 

273 yield urwid.Text('Destination:') 

274 

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) 

291 

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} 

300 

301 

302class SourceDirectoryEditWindow(BaseSourceEditWindow): 

303 """Edit form for :class`hods.config.sourcefile.SourceDirectory`.""" 

304 

305 def __init__(self, app, obj, **kwargs): 

306 """Initialize widget. 

307 

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) 

315 

316 self.checkbox_ignore = urwid.CheckBox('Ignore', state=obj.ignore) 

317 

318 def get_items(self): 

319 """Collect widgets to display.""" 

320 yield self.checkbox_ignore 

321 yield from super().get_items() 

322 

323 def clean(self): 

324 """Clean and return form data.""" 

325 data = super().clean() 

326 data['ignore'] = self.checkbox_ignore.state 

327 return data 

328 

329 

330class RenderErrorWindow(ErrorWindow): 

331 """Window to display a template rendering error.""" 

332 

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) 

338 

339 

340SOURCEFILE_MODE_CHOICES = [ 

341 ('Ignore', 'ignore'), 

342 ('Link', 'link'), 

343 ('Template', 'template'), 

344] 

345 

346 

347class SourceFileEditWindow(BaseSourceEditWindow): 

348 """Edit form for :class`hods.config.sourcefile.SourceFile`.""" 

349 

350 def __init__(self, app, obj, **kwargs): 

351 """Initialize widget. 

352 

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 

362 

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 

373 

374 if self.mode in ('link', 'template'): 

375 yield from super().get_items() 

376 

377 if self.mode == 'template': 

378 yield from self.get_template_items() 

379 

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) 

394 

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) 

399 

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) 

410 

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 

414 

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 

423 

424 self.app.open_in_editor(result, ignore_error=True, parent=self, suffix=self.object.basename) 

425 

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 

432 

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) 

440 

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 

447 

448 

449class BaseSourceMixin: 

450 """A window mixin to edit sources.""" 

451 

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

453 """Initialize window.""" 

454 kwargs.setdefault('height', 9) 

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

456 

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) 

460 

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 

466 

467 def get_items(self): 

468 """Return widgets to display.""" 

469 yield self.checkbox_pull_only 

470 yield from super().get_items() 

471 

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 

478 

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)) 

485 

486 

487class SourceAddMixin: 

488 """Mixin for source add windows.""" 

489 

490 def get_existing_basenames(self): 

491 """Return sources of the selected feature.""" 

492 for source in self.feature.sources: 

493 yield source.basename 

494 

495 

496class SourceEditMixin: 

497 """Mixin for source edit windows.""" 

498 

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) 

504 

505 

506class GitRepositoryMixin(BaseSourceMixin): 

507 """A window mixin to edit git repositories.""" 

508 

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

510 """Initialize window.""" 

511 super().__init__(*args, attr='source window', **kwargs) 

512 self.edit_url = urwid.Edit() 

513 

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() 

524 

525 def clean(self): 

526 """Clean form data and return it.""" 

527 data = super().clean() 

528 

529 url = self.edit_url.edit_text.strip() 

530 if not url: 

531 raise self.ValidationError('An url is required!') 

532 data['url'] = url 

533 

534 basename = GitRepository.basename_by_url(url) 

535 self.clean_basename(basename) 

536 data['name'] = basename 

537 

538 return data 

539 

540 

541class GitRepositoryEditWindow(SourceEditMixin, GitRepositoryMixin, SourceDirectoryEditWindow): 

542 """A window to edit git repositories.""" 

543 

544 def __init__(self, app, source, **kwargs): 

545 """Initialize window. 

546 

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 

555 

556 

557class GitRepositoryAddWindow(SourceAddMixin, GitRepositoryMixin, EditWindow): 

558 """A window to add git repositories.""" 

559 

560 def __init__(self, app, feature, **kwargs): 

561 """Initialize window. 

562 

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' 

577 

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')) 

587 

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)() 

593 

594 self.app.save() 

595 return obj 

596 

597 

598class ServerDirectoryMixin(BaseSourceMixin): 

599 """A window mixin to edit and validate a rsync directory.""" 

600 

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

602 """Initialize window.""" 

603 super().__init__(*args, attr='source window', **kwargs) 

604 self.edit_name = urwid.Edit() 

605 

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() 

613 

614 def clean(self): 

615 """Clean form data and return it.""" 

616 data = super().clean() 

617 

618 basename = self.edit_name.edit_text.strip() 

619 self.clean_basename(basename) 

620 data['name'] = basename 

621 

622 return data 

623 

624 

625class ServerDirectoryEditWindow(SourceEditMixin, ServerDirectoryMixin, SourceDirectoryEditWindow): 

626 """A window to edit rsync directories.""" 

627 

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 

633 

634 

635class ServerDirectoryAddWindow(SourceAddMixin, ServerDirectoryMixin, EditWindow): 

636 """A window to add rsync/server directories.""" 

637 

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 

644 

645 def save(self, data): 

646 """Store cleaned data.""" 

647 obj = Source(self.feature, **data) 

648 obj.init() 

649 

650 self.app.save() 

651 return obj