import sys import os import time from subprocess import Popen, PIPE from pathlib import Path from psutil import pid_exists from frappy.core import Module from frappy.errors import ConfigError from frappy.lib import generalConfig from frappy.lib.comparestring import compare from frappy.lib import mkthread, formatExtendedTraceback from frappy.config import process_file, to_config_path from frappy.tools.configdata import Value, cfgdata_to_py, cfgdata_from_py, get_datatype, site, ModuleClass, stringtype from frappy.tools.completion import class_completion, recommended_prs from frappy.tools.terminalgui import Main, Container, LineEdit, MultiLineEdit, MenuItem, Writer, ContextMenu, \ TextWidget, TextEdit, NameEdit, TextEditCompl, Completion, TitleBar, StatusBar, Widget, ConfirmDialog, \ K K.add(TOGGLE_DETAILED='^t', NEXT_VERSION='^n', PREV_VERSION='^b') VERSION_SEPARATOR = "\n''''" TIMESTAMP_FMT = '%Y-%m-%d-%H%M%S' # TODO: # - ctrl-K / ctrlV/U for cutting/pasting module(s) or parameter(2) # * separate buffer for modules and parameters? # * when inserting a parameter which already exists, overwrite the value only # - implement IO modules # - version control: create one file, with a separator (save disk space, no numbering housekeeping needed) # - use Exceptions for ctrl-X and ctrl-C # - use also shift-Tab for level up? # - directory to save to def unix_cmd(cmd, *args): out = Popen(cmd.split() + list(args), stdout=PIPE).communicate()[0] return list(out.decode().split('\n')) class StringValue: # TODO: unused? error = None def __init__(self, value, from_string=False, datatype=None): self.strvalue = value def set_value(self, value): self.strvalue = value def set_from_string(self, strvalue): self.strvalue = strvalue def get_repr(self): return repr(self.strvalue) class TopWidget: parent_cls = Main class Child(Widget): """child widget of NodeWidget ot ModuleWidget""" parent = TopWidget def get_name(self): return None def collect(self, cfgdict): pass def check_data(self): pass def is_valid(self): return True class HasValue(Child): clsobj = None def init_value_widget(self, parent, valobj): self.init_parent(parent) self.valobj = valobj def validate(self, strvalue, main=None): pname = self.get_name() try: if pname != 'cls': if self.clsobj != self.parent.clsobj: self.clsobj = self.parent.clsobj self.log.info('validate %r %r %r %r', self.parent.get_name(), self.get_name(), self.clsobj, self.valobj) self.valobj.datatype, self.valobj.error = get_datatype( self.get_name(), self.clsobj, self.valobj.value) self.log.info('validate %r %r %r %r', self.parent.get_name(), self.get_name(), self.clsobj, self.valobj) self.valobj.validate_from_string(strvalue) self.error = None except Exception as e: self.error = str(e) if self.get_name() == 'tolerance': self.log.info('checked %r %r', self.valobj, self.valobj.error) if main: main.touch() return strvalue def check_data(self): self.validate(self.valobj.strvalue) def is_valid(self): return self.get_name() and self.valobj.strvalue class ValueWidget(HasValue, LineEdit): fixedname = None def __init__(self, parent, name, valobj, label=None): """init a value widget :param parent: the parent widget :param name: the initial name :param valobj: the object containing value and datatype :param label: None: the name is changeable, else: a label (which might be different from name) """ self.init_value_widget(parent, valobj) if label is not None: labelwidget = TextWidget(label) self.fixedname = name else: labelwidget = NameEdit(name, self.validate_name) # self.log.info('value widget %r %r', name, self.fixedname) if valobj.completion: valueedit = TextEditCompl(valobj.strvalue, self.validate, valobj.completion) else: valueedit = TextEdit(valobj.strvalue, self.validate) super().__init__(labelwidget, valueedit) def validate_name(self, name, main): widget_dict = self.parent.widget_dict if name.isidentifier(): other = widget_dict.get(name) if other and other != self: self.error = f'duplicate name {name!r}' return self.get_name() self.clsobj = None self.error = None widget = widget_dict.pop(self.get_name(), None) if widget: widget_dict[name] = widget else: self.error = f'illegal name {name!r}' return self.get_name() return name def get_name(self): if self.fixedname: return self.fixedname return self.labelwidget.value def collect(self, as_dict): """collect data""" name = self.get_name() if name: as_dict[name] = self.valobj class DocWidget(HasValue, MultiLineEdit): parent_cls = TopWidget def __init__(self, parent, name, valobj): self.init_value_widget(parent, valobj) self.valobj = valobj self.name = name super().__init__(name, valobj.strvalue, 'doc: ') def get_name(self): return self.name def collect(self, config): self.valobj.set_value(self.value) config[self.name] = self.valobj class BaseWidget(TopWidget, Container): """base for Module or Node""" clsobj = None header = 'Module' special_names = 'name', 'cls', 'description' def init(self, parent): self.widgets = [] self.init_parent(parent, EditorMain) self.focus = 0 self.widget_dict = {} self.fixed_names = self.get_fixed_names() def get_menu(self): main = self.parent if main.version_view: return main.get_menu() return self.context_menu + main.get_menu() def get_fixed_names(self): result = {k: k for k in self.special_names} result['name'] = self.header return result def add_widget(self, name, valobj, pos=None): label = self.fixed_names.get(name) widget = ValueWidget(self, name, valobj, label) self.widget_dict[name] = widget # self.log.info('add widget %r: label=%r name=%r', name, label, widget.get_name()) if pos is None: self.widgets.append(widget) else: if pos < 0: pos += len(self.widgets) self.widgets.insert(pos, widget) return widget def new_widget(self): raise NotImplementedError def add_module(self, after_current=False): modcfg = {'name': Value(''), 'cls': Value(f'{site.frappy_subdir}.'), 'description': Value('')} main = self.parent module = ModuleWidget(main, '', modcfg) main.insert(main.focus + after_current, module) main.set_focus(main.focus + 1) if not after_current: self.set_focus(1) main.advance(-1) module.set_focus(1) # go to cls widget def get_widget_value(self, key): try: return self.widget_dict[key].valobj.strvalue except KeyError: return '' def get_name(self): return self.get_widget_value('name') def draw_summary(self, wr, in_focus): raise NotImplementedError def draw(self, wr, in_focus=False): main = self.parent wr.set_leftwidth(main.leftwidth) if main.detailed: self.draw_widgets(wr, in_focus) else: self.draw_summary(wr, in_focus) def current_row(self): main = self.parent return super().current_row() if main.detailed else 0 def height(self): main = self.parent return super().height() if main.detailed else 1 def collect(self, result): name = self.get_name() if name: result[name] = modcfg = {} for w in self.widgets: w.collect(modcfg) class ModuleName(Value): def __init__(self, main, name): self.main = main super().__init__(name) def validate_from_string(self, value): if not value: self.strvalue = self.value = '' raise ValueError('empty name') if value != self.value and value in self.main.widget_dict: self.strvalue = self.value = '' raise ValueError(f'duplicate name {value!r}') self.value = self.strvalue = value class ModuleWidget(BaseWidget): def __init__(self, parent, name, modulecfg): assert name == modulecfg['name'].value modulecfg['name'] = ModuleName(parent, name) self.init(parent) self.context_menu = [ MenuItem('add parameter/property', 'p', self.new_widget), MenuItem('add module', 'm', self.add_module), MenuItem('purge empty prs', 'e', self.purge_prs), MenuItem('add recommended prs', '+', self.complete_prs), MenuItem('cut module', K.CUT, parent.cut_module), ] clsvalue = modulecfg['cls'] clsvalue.callback = self.update_cls clsvalue.completion = class_completion clsobj = clsvalue.value if clsobj: self.fixed_names.update({k: k for k, v in recommended_prs(clsobj).items() if v}) for name, valobj in modulecfg.items(): self.add_widget(name, valobj) self.widgets.append(EndModule(self)) def new_widget(self, name='', pos=None): self.add_widget(name, Value('', *get_datatype('', self.clsobj, ''), from_string=True), self.focus) def update_widget_dict(self): self.widget_dict = {w.get_name(): w for w in self.widgets} def get_name_info(self): return self.clsobj, self.widget_dict def handle(self, main): while True: key = super().handle(main) if main.detailed else main.get_key() if key in (K.RIGHT, K.TAB): main.detailed = True main.status('') main.offset = None # recalculate offset from screen pos else: return key def check_data(self): """check clsobj is valid and check all params and props""" # clswidget, = self.find_widgets('cls') # clsobj = clswidget.valobj.value for widget in self.widgets: widget.check_data() def update_cls(self, cls): if cls != self.clsobj: self.complete_prs(True) self.clsobj = cls self.check_data() return cls def complete_prs(self, only_mandatory=False): if self.clsobj: fixed_names = self.get_fixed_names() names = set(w.get_name() for w in self.widgets) for name, mandatory in recommended_prs(self.clsobj).items(): if mandatory: fixed_names[name] = name if name not in names and mandatory >= only_mandatory: valobj = Value('', *get_datatype(name, self.clsobj, '')) if name == 'cls': self.log.info('add needed %r', valobj) widget = self.add_widget(name, valobj, -1) if mandatory: widget.error = 'please set this mandatory property' self.fixed_names = fixed_names self.update_widget_dict() def purge_prs(self): self.widgets = [w for w in self.widgets if w.get_name() not in self.fixed_names and w.is_valid()] self.update_widget_dict() def draw_summary(self, wr, in_focus): wr.startrow() wr.norm('Module ') name = self.get_widget_value('name') if in_focus: wr.set_cursor_pos() wr.bright(name, round(wr.width * 0.2)) else: wr.norm(name.ljust(round(wr.width * 0.2))) half = (wr.width - wr.col) // 2 wr.norm(f"{self.get_widget_value('description').ljust(half)} {self.get_widget_value('cls')} ") def collect(self, result): super().collect(result) name = self.get_name() if name: assert result[name].pop('name').value == name class EndNode(Child): parent_cls = TopWidget helptext = 'RET: add module p: add parameter or property' def __init__(self, parent): self.init_parent(parent) super().__init__() def draw(self, wr, in_focus=False): wr.startrow() if in_focus: wr.set_cursor_pos(wr.leftwidth) wr.col = wr.leftwidth wr.bright(' ') wr.norm(' ' + self.helptext) def collect(self, result): pass def check_data(self): pass def get_name(self): return None def handle(self, main): self.showhelp = False while True: key = main.get_key() if key in (K.RETURN, K.ENTER): self.parent.add_module(True) elif key in (K.UP, K.DOWN, K.QUIT): return key elif key == 'p': self.parent.new_widget() return K.GOTO_MAIN return None class EndModule(EndNode): parent_cls = ModuleWidget class IOWidget(ModuleWidget): def __init__(self, parent, name, modulecfg): assert name == modulecfg['name'].value modulecfg['name'] = ModuleName(parent, name) self.init(parent) self.context_menu = [ MenuItem('add module', 'm', self.add_module), MenuItem('cut module', K.CUT, parent.cut_module), ] urivalue = modulecfg.get('uri') if urivalue is None: urivalue = Value('uri', stringtype) self.add_widget('uri', urivalue) self.widgets.append(EndModule(self)) class EndIO(EndNode): parent_cls = IOWidget helptext = 'RET: add module' class NodeName(Value): def __init__(self, main, name): self.main = main super().__init__(name) def set_from_string(self, value): self.error = self.main.set_node_name(value) if self.error: self.value = self.strvalue = self.main.cfgname else: self.value = self.strvalue = value class NodeWidget(BaseWidget): header = 'Node' special_names = 'name', 'equipment_id', 'interface', 'title', 'doc' summ_edit = {'name', 'title', 'doc'} # editable widgets in summary def __init__(self, parent, name, nodecfg): nodecfg['name'] = NodeName(parent, name) self.init(parent) self.context_menu = [ MenuItem('add parameter/property', 'p', self.new_widget), # MenuItem('select line', '^K', self.select, None), ] for name, valobj in nodecfg.items(): if name == 'doc': docwidget = DocWidget(self, name, valobj) self.widgets.append(docwidget) self.widget_dict['doc'] = docwidget else: self.add_widget(name, valobj) self.widgets.append(EndNode(self)) def new_widget(self, name=''): """insert new widget at focus pos""" self.add_widget(name, Value('', None, from_string=True), self.focus) def get_name(self): return 'node' def set_focus(self, focus, step=1): self.log.info('node focus %r %r %r', self.focus, focus, step) while super().set_focus(focus, step): self.log.info('node find %r name %r', self.focus, self.get_focus_widget().get_name()) if self.parent.detailed or self.get_focus_widget().get_name() in self.summ_edit: self.log.info('found') return True focus = self.focus + step self.log.info('node focus end %r', self.focus) def height(self): main = self.parent return sum(w.height() for w in self.widgets if main.detailed or w.get_name() in self.summ_edit) # def handle(self, main): # if main.detailed: # return super().handle(main) # advance = self.next # key = None # itry = 10 # while itry > 0: # widget = self.get_focus_widget() # if widget.get_name() in ('title', 'doc'): # key = widget.handle(main) # itry = 10 # # if key in (CTRL_X, K.LEFT): # # return key # if key == K.UP: # advance = self.prev # elif key in (K.DOWN, K.RETURN, K.ENTER): # advance = self.next # else: # return key # if not advance(): # return key # itry -= 1 # raise ValueError('x') def draw_summary(self, wr, in_focus): # wr.startrow() # wr.norm('Node ') wr.set_leftwidth(7) focus = self.focus if in_focus else None for nr, widget in enumerate(self.widgets): name = widget.get_name() if name in self.summ_edit: widget.draw(wr, nr == focus) HELP_TEXT = """ ctrl-X Exit ctrl-T Toggle view mode (summary <-> detailed) """ class EditorMain(Main): name = 'Main' detailed = False tmppath = None help_text = HELP_TEXT version_view = 0 # current version or when > 0 previous versions (not editable) completion_widget = None # widget currently running a thread for guesses leftwidth = 0.15 cut_modules = () cut_extend = False def __init__(self, cfg): self.titlebar = TitleBar('Frappy Cfg Editor') super().__init__([], [self.titlebar], [StatusBar(self)]) # self.select_menu = MenuItem('select module', CUT_KEY) self.version_menu = [ MenuItem('previous version', K.PREV_VERSION, self.prev_version), MenuItem('next version', K.NEXT_VERSION, self.next_version), MenuItem('restore this version', 'r', self.restore_version), ] self.main_menu = [ MenuItem('show previous version', K.PREV_VERSION, self.prev_version), ] self.detailed_menuitem = MenuItem('toggle detailed', K.TOGGLE_DETAILED, self.toggle_detailed) self.cut_menuitem = MenuItem('insert cut modules', K.PASTE, self.insert_cut) self.version_dir = Path('~/.local/share/frappy_config_editor').expanduser() self.version_dir.mkdir(mode=0o700, parents=True, exist_ok=True) self.cfgname = None self.pidfile = None self.dirty = False # cleanup pidfiles for file in self.version_dir.glob('*.pid'): pidstr = file.read_text() if not pid_exists(int(pidstr)): file.unlink() cfgpath = Path(cfg) if cfg != cfgpath.stem: # this is a filename if cfg.endswith('_cfg.py'): cfg = cfgpath.name[:-7] else: cfg = self.cfgpath.stem else: cfgpath = None self.filecontent = None error = self.set_node_name(cfg, cfgpath) if error: raise RuntimeError(error) self.init_from_content(self.filecontent) self.module_clipboard = {} self.pr_clipboard = {} def get_menu(self): self.detailed_menuitem.name = 'summary view' if self.detailed else 'detailed view' cmenu = [self.cut_menuitem] if self.cut_modules else [] vmenu = self.version_menu if self.version_view else self.main_menu return cmenu + vmenu + [self.detailed_menuitem] + self.context_menu def init_from_content(self, filecontent): self.log.info('init from content %r', len(filecontent)) widgets = [] nodedata, moddata = cfgdata_from_py(self.cfgname, self.cfgpath, filecontent, self.log) widgets.append(NodeWidget(self, self.cfgname, nodedata)) for key, modcfg in moddata.items(): clsvalue = modcfg.get('cls') if clsvalue: if clsvalue.value == '': widgets.append(IOWidget(self, key, modcfg)) continue else: modcfg['cls'] = Value('', ModuleClass, from_string=True) widgets.append(ModuleWidget(self, key, modcfg)) self.log.info('widgets %r', len(self.widgets)) self.widgets = widgets def toggle_detailed(self): self.detailed = not self.detailed self.offset = None # recalculate offset from screen pos self.status(None) def get_key(self): if self.dirty: if not self.version_view: self.save() self.dirty = False while True: if self.completion_widget: key = super().get_key(0.1) if key is None: continue else: key = super().get_key() if self.version_view: if isinstance(key, str) or key in [K.DEL]: self.status('', 'can not edit previous version') else: break else: break return key def cut_module(self): if not self.cut_modules: self.cut_extend = False module = self.get_focus_widget() if not isinstance(module, ModuleWidget): self.status('', warn='can not cut node') return self.widgets[self.focus:self.focus+1] = [] if not self.cut_extend: self.cut_modules = [] self.log.info('start cut modules') self.cut_modules.append(module) self.cut_extend = True self.status(f'{len(self.cut_modules)} modules buffered') def insert_cut(self): if self.cut_modules: self.widgets[self.focus:self.focus] = self.cut_modules self.cut_modules = [] def set_node_name(self, name, cfgpath=None): if name == self.cfgname: return None if not name: return f'{name!r} is not a valid node name' if self.pidfile: self.pidfile.unlink() self.pidfile = self.version_dir / f'{name}.pid' try: pidstr = self.pidfile.read_text() if pid_exists(int(pidstr)): return f'{name} is already edited by process {pidstr}' # overwrite pidfile from dead process except FileNotFoundError: pass self.cfgname = name versions_path = self.version_dir / f'{name}.versions' try: sections = versions_path.read_text().split(VERSION_SEPARATOR) assert sections.pop(0) == '' except FileNotFoundError: sections = [] self.versions = dict((v.split('\n', 1) for v in sections)) self.tmppath = self.version_dir / f'{name}.current' if cfgpath: cfgpaths = [cfgpath] else: try: cfgpaths = [to_config_path(name, self.log)] except ConfigError: cfgpaths = [] cfgpaths.append(self.tmppath) for cfgpath in cfgpaths: try: filecontent = cfgpath.read_text() self.cfgpath = cfgpath timestamp = time.strftime(TIMESTAMP_FMT, time.localtime(cfgpath.stat().st_mtime)) break except FileNotFoundError: pass else: filecontent = None timestamp = time.strftime(TIMESTAMP_FMT) self.cfgpath = cfgpaths[0] self.filecontent = filecontent self.add_version(filecontent, timestamp) return None def add_version(self, filecontent, timestamp): if self.versions: # remove last version if unchanged key, content = next(reversed(self.versions.items())) if content == filecontent: self.versions.pop(key) if filecontent: self.versions[timestamp] = filecontent sep = VERSION_SEPARATOR versions_path = self.version_dir / f'{self.cfgname}.versions' tmpname = versions_path.with_suffix('.tmp') with open(tmpname, 'w') as f: for ts, section in self.versions.items(): f.write(sep) f.write(f'{ts}\n') f.write(section) os.rename(tmpname, versions_path) def restore_version(self): if not self.version_view: self.status('this is already the current version') return self.popupmenu = menu = ConfirmDialog('restore this version? [N]') if not self.popupmenu.handle(self): self.status('cancelled restore') return version = list(self.versions)[-self.version_view] self.status(f'restored from {version}') content = self.versions.pop(version) self.add_version(self.filecontent, time.strftime(TIMESTAMP_FMT)) self.filecontent = content self.version_view = 0 self.titlebar.right = '' self.init_from_content(self.filecontent) self.save() def set_version(self): if self.version_view: version = list(self.versions)[-self.version_view] self.titlebar.right = f'version {version}' try: self.init_from_content(self.versions[version]) self.status(None) except Exception as e: self.status('', f'bad version: {e}') else: self.init_from_content(self.filecontent) self.titlebar.right = '' def prev_version(self): maxv = len(self.versions) self.version_view += 1 self.log.info('back to version %r', self.version_view) if self.version_view > maxv: self.status('this is the oldest version') self.version_view = maxv else: self.set_version() def next_version(self): if self.version_view: self.version_view -= 1 self.set_version() else: self.status('this is the current version') def current_row(self): return super().current_row() + self.get_topmargin() def touch(self): self.dirty = True def save(self): cfgdata = {} for widget in self.widgets: widget.collect(cfgdata) if 'node' not in cfgdata: raise ValueError(list(cfgdata), len(self.widgets)) config_code = cfgdata_to_py(**cfgdata) # if self.cfgpath: # self.cfgpath.write_text(config_code) self.tmppath.write_text(config_code) return config_code def finish(self, exc): # TODO: ask where to save tmp file self.save() def advance(self, step): done = super().advance(step) if done: self.get_focus_widget().set_focus(None, step) return done def run(self): try: self.pidfile.write_text(str(os.getpid())) super().run(Writer) except Exception: print(formatExtendedTraceback()) finally: self.pidfile.unlink() if __name__ == "__main__": os.environ['FRAPPY_CONFDIR'] = 'cfg:cfg/main:cfg/stick:cfg/addons' generalConfig.init() EditorMain(sys.argv[1]).run()