[WIP] work on curses cfg editor

state as of 2026-01-28

Change-Id: I73d2fa4e6fda8820a95fe4e7256c7a23bf565f67
This commit is contained in:
2026-01-28 08:32:59 +01:00
parent e741404d0b
commit 53256d1583
11 changed files with 2950 additions and 34 deletions

814
paramedit.py Normal file
View File

@@ -0,0 +1,814 @@
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 == '<auto>':
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()