From 8946c96e1f8cea00f7bc119d4ffc1096dacaa48d Mon Sep 17 00:00:00 2001 From: Markus Zolliker Date: Mon, 23 Feb 2026 16:05:45 +0100 Subject: [PATCH] renamed tools to editcurses + fix current row for completion menu Change-Id: I46424c7b8d22f4b6646851576848c86c995a080e --- frappy/{tools => editcurses}/__init__.py | 0 frappy/{tools => editcurses}/cfgedit.py | 22 ++-- frappy/{tools => editcurses}/completion.py | 0 frappy/{tools => editcurses}/configdata.py | 136 ++++++++++++++++++-- frappy/{tools => editcurses}/terminalgui.py | 52 +++++--- frappy/tools/editorutils.py | 106 --------------- 6 files changed, 165 insertions(+), 151 deletions(-) rename frappy/{tools => editcurses}/__init__.py (100%) rename frappy/{tools => editcurses}/cfgedit.py (98%) rename frappy/{tools => editcurses}/completion.py (100%) rename frappy/{tools => editcurses}/configdata.py (78%) rename frappy/{tools => editcurses}/terminalgui.py (97%) delete mode 100644 frappy/tools/editorutils.py diff --git a/frappy/tools/__init__.py b/frappy/editcurses/__init__.py similarity index 100% rename from frappy/tools/__init__.py rename to frappy/editcurses/__init__.py diff --git a/frappy/tools/cfgedit.py b/frappy/editcurses/cfgedit.py similarity index 98% rename from frappy/tools/cfgedit.py rename to frappy/editcurses/cfgedit.py index 59de0e6c..23ef4c13 100644 --- a/frappy/tools/cfgedit.py +++ b/frappy/editcurses/cfgedit.py @@ -6,8 +6,8 @@ from pathlib import Path from psutil import pid_exists from frappy.errors import ConfigError from frappy.lib import generalConfig -from frappy.lib import mkthread, formatExtendedTraceback -from frappy.config import process_file, to_config_path +from frappy.lib import formatExtendedTraceback +from frappy.config import 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 import frappy.tools.terminalgui as tg @@ -290,8 +290,8 @@ class ModuleWidget(BaseWidget): MenuItem('add parameter/property', 'p', self.new_widget), MenuItem('add module', 'm', self.add_module), MenuItem('add io module', 'i', self.add_iomodule), - MenuItem('purge empty prs', 'e', self.purge_prs), - MenuItem('add recommended prs', '+', self.complete_prs), + MenuItem('purge empty items', 'e', self.purge_prs), + MenuItem('add configurable items', '+', self.complete_prs), MenuItem('cut module', KEY.CUT, parent.cut_module), ] @@ -333,7 +333,7 @@ class ModuleWidget(BaseWidget): def height(self, to_focus=None): main = self.parent - return super().height(to_focus) if main.detailed else 1 + return super().height() if main.detailed else 1 def check_data(self): """check clsobj is valid and check all params and props""" @@ -367,7 +367,7 @@ class ModuleWidget(BaseWidget): 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.widgets = [w for w in self.widgets if w.get_name() in self.fixed_names or w.is_valid()] self.update_widget_dict() def draw_summary_right(self, wr): @@ -512,13 +512,11 @@ class NodeWidget(BaseWidget): return True focus = self.focus + step - def height(self, to_focus=None): + def focus_row(self, to_focus): main = self.parent - if not main.detailed: - return super().height(to_focus) + if main.detailed: + return super().focus_row(to_focus) height = 0 - if to_focus is None: - to_focus = len(self.widgets) for nr, widget in enumerate(self.widgets[:to_focus]): name = widget.get_name() if name in self.summ_edit: @@ -942,7 +940,5 @@ class EditorMain(Main): if __name__ == "__main__": - # os.environ['FRAPPY_CONFDIR'] = 'cfg:cfg/main:cfg/stick:cfg/addons' generalConfig.init() - os.environ['FRAPPY_CONFDIR'] EditorMain(sys.argv[1]).run() diff --git a/frappy/tools/completion.py b/frappy/editcurses/completion.py similarity index 100% rename from frappy/tools/completion.py rename to frappy/editcurses/completion.py diff --git a/frappy/tools/configdata.py b/frappy/editcurses/configdata.py similarity index 78% rename from frappy/tools/configdata.py rename to frappy/editcurses/configdata.py index 5604b330..f499ab86 100644 --- a/frappy/tools/configdata.py +++ b/frappy/editcurses/configdata.py @@ -21,12 +21,14 @@ import re import frappy +import inspect from pathlib import Path from ast import literal_eval from importlib import import_module -from frappy.config import process_file, Node, fix_io_modules -from frappy.core import Module -from frappy.datatypes import DataType +from frappy.lib.comparestring import compare +from frappy.config import process_file, Node +from frappy.core import Module, Parameter, Property +from frappy.datatypes import DataType, EnumType HEADER = "# please edit this file with frappy edit" @@ -134,6 +136,8 @@ class Value: if value is None: raise ValueError(datatype) self.datatype = datatype + if isinstance(datatype, EnumType): + self.completion = NameCompletion([v.name for v in datatype._enum.members]) self.error = error if callback: self.callback = callback @@ -477,6 +481,10 @@ def cfgdata_from_py(name, cfgpath, filecontent, logger): continue iomodcls = iomodcfg['cls'] modcls = modcfg['cls'] + except Exception as e: + logger.info('error %r when checking io for %r', e, modname) + continue + try: ioclass = f"{modcls}.ioClass" if ModuleClass.validate(iomodcls) != ModuleClass.validate(ioclass): continue @@ -484,16 +492,122 @@ def cfgdata_from_py(name, cfgpath, filecontent, logger): iomodcfg.pop('description', None) iodict[ioname] = iomodcfg except Exception as e: - if ioclass: - iomod, iocls = iomodcls.rsplit('.', 1) - mod, cls = modcls.rsplit('.', 1) - if mod == iomod: - iomodcls = iocls - errors[ioname] = f'{ioname}: missing ioClass={iomodcls} in source code of {modcls}' - else: - logger.info('error %r when checking io for %r', e, modname) + iomod, iocls = iomodcls.rsplit('.', 1) + mod, cls = modcls.rsplit('.', 1) + if mod == iomod: + iomodcls = iocls + errors[ioname] = f'{ioname}: missing ioClass={iomodcls} in source code of {modcls}' modules = {k: moddata_from_cfgfile(k, **v) for k, v in config.items()} for error in errors.values(): logger.info(error) return nodedata_from_cfgfile(name, **nodecfg), modules + + +SKIP_PROPS = {'implementation', 'features', + 'interface_classes', 'slowinterval', 'omit_unchanged_within', 'original_id'} + + +def recommended_prs(cls): + """get properties and parameters which are useful in configuration + + returns a dict of + """ + if isinstance(cls, str): + checker = ClassChecker(cls) + if checker.error or not checker.clsobj: + return {} + cls = checker.clsobj + result = {} + for pname, pdict in cls.configurables.items(): + pr = getattr(cls, pname) + if isinstance(pr, Property): + if pname not in SKIP_PROPS: + result[pname] = pr.mandatory + elif isinstance(pr, Parameter): + if pr.needscfg: + result[pname] = True + elif not pr.readonly or hasattr(cls, f'write_{pname}'): + result[pname] = False + if result.get('uri') is False and result.get('io') is False: + result['io'] = True + return result + + +class FrappyModule(str): + """checker for finding subclasses of Module defined in a python module""" + def check(self, cls): + return isinstance(cls, type) and issubclass(cls, Module) and self.startswith(cls.__module__) + + +def get_suggested(guess, allowed_keys): + """select and sort values from allowed_keys based on similarity to a given value + + :param guess: given value. when empty, the allowed keys are return in the given order + :param allowed_keys: list of values sorted in a given order + :return: a list of values + """ + if len(guess) == 0: + return allowed_keys + low = guess.lower() + if len(guess) == 1: + # return only items starting with a single letter + return [k for k in allowed_keys if k.lower().startswith(low)] + comp = {} + # if there are items containing guess, return them only + result = [k for k in allowed_keys if low in k.lower()] + if result: + return result + # calculate similarity + for key in allowed_keys: + comp[key] = compare(guess, key) + comp = sorted(comp.items(), key=lambda t: t[1], reverse=True) + scorelimit = 2 + result = [] + for i, (key, score) in enumerate(comp): + if score < scorelimit: + break + if i > 2: + scorelimit = max(2, score - 0.05) + result.append(key) + return result or allowed_keys + + +def class_completion(value): + """analyze class path and return an array of suggestions for + the last incomplete element""" + checker = ClassChecker(value) + if not checker.error: + return checker.position, [] + if checker.root is None: + sdict = {p: f'{p}.' for p in site.packages} + else: + sdict = {} + if not checker.clsobj: + file = checker.pyfile + if file.name == '__init__.py': + sdict = {p.stem: f'{p.stem}.' + for p in sorted(file.parent.glob('*.py')) + if p.stem != '__init__'} + sdict.update((k, k) for k, v in sorted(inspect.getmembers( + checker.root, FrappyModule(checker.modname).check))) + found = sdict.get(checker.name, None) + if found: + selection = [found] + # selection = [found] + list(sdict.values()) + else: + selection = list(get_suggested(checker.name, sdict.values())) + return checker.position, [checker.name] + selection + + +class NameCompletion: + def __init__(self, names): + self.names = names + self.nameset = set(names) + + def __call__(self, value): + if value in self.nameset: + return len(value), [] + return 0, [value] + list(get_suggested(value, self.names)) + + diff --git a/frappy/tools/terminalgui.py b/frappy/editcurses/terminalgui.py similarity index 97% rename from frappy/tools/terminalgui.py rename to frappy/editcurses/terminalgui.py index 06e3bcc1..88d29e55 100644 --- a/frappy/tools/terminalgui.py +++ b/frappy/editcurses/terminalgui.py @@ -104,6 +104,17 @@ def clamp(*args): return sorted(args)[len(args) // 2] +def mkthread(func, *args, **kwds): + t = threading.Thread( + name=f'{func.__module__}:{func.__name__}', + target=func, + args=args, + kwargs=kwds) + t.daemon = True + t.start() + return t + + class Logger: def __init__(self): # self.parent = self @@ -150,7 +161,7 @@ class Widget: """returns current row""" return 0 - def height(self, to_focus=None): + def height(self): """returns current height""" return self.default_height @@ -166,13 +177,16 @@ class HasWidgets: widgets = None # list of subwidgets def current_row(self): - return self.height(self.focus) + self.get_focus_widget().current_row() + return self.focus_row(self.focus) + self.get_focus_widget().current_row() - def height(self, to_focus=None): - if to_focus is None: - to_focus = len(self.widgets) + def focus_row(self, to_focus): + """return relative row position of focus widget""" return sum(w.height() for w in self.widgets[:to_focus]) + def height(self): + """return the summed up height""" + return self.focus_row(len(self.widgets)) + def handle(self, main): try: while True: @@ -427,8 +441,8 @@ class TextEditCompl(TextEdit): if self.highlighted: return super().get_key(main) main.completion_widget = self - # mkthread(self.get_selection_menu, main, self.value) - self.get_selection_menu(main, self.value) + mkthread(self.get_selection_menu, main, self.value) + # self.get_selection_menu(main, self.value) key = super().get_key(main) self.menu = menu = main.popupmenu if not menu or self.highlighted or key != KEY.DOWN or self.pos < self.completion_pos: @@ -660,9 +674,6 @@ class PopUpMenu: pos = 0 height = 0 - def __repr__(self): - return f'PopUp(pos={self.pos}/{len(self.selection)}, selected={self.selection[self.pos]!r})' - def advance(self, step): pos = self.pos + step if 0 <= pos < self.height: @@ -694,16 +705,16 @@ class CompletionMenu(PopUpMenu): return self.selection[self.pos] def draw(self, wr): - row = wr.nextrow + row = wr.nextrow # the edit row wr.left = max(1, wr.left) col = wr.left - 1 - wr.nextrow -= self.pos - 1 height = self.height + 1 + wr.nextrow -= self.pos if self.pos == 0: # rectangle is open on top - wr.rectangle(row + 1, col, height - 1, self.width + 1, top=row + 2) + wr.rectangle(row - 1, col, height, self.width + 1, top=row + 1) else: - wr.rectangle(row - self.pos, col, height, self.width + 1) + wr.rectangle(row - self.pos - 1, col, height, self.width + 1) for i, value in enumerate(self.selection): wr.startrow() if i == self.pos: @@ -924,7 +935,7 @@ class ContextMenu(PopUpMenu): class BaseWriter: """base for writer. does nothing else than keeping track of position""" - highstyle = brightstyle = errorstyle = barstyle = menustyle = querystyle = warnstyle = None + highstyle = brightstyle = errorstyle = barstyle = menustyle = querystyle = warnstyle = buttonstyle = None errorflag = '! ' def __init__(self): @@ -1110,7 +1121,7 @@ class Writer(BaseWriter): """draw a vertical line :param row, col: upper start point - :param length: unclipped length + :param length: length without clipping :param top, bottom, left, right: clipping :return: , (None is returned, when clipped on this corner) """ @@ -1134,7 +1145,7 @@ class Writer(BaseWriter): """draw a horizontal line :param row, col: left start point - :param length: unclipped length + :param length: length without clipping :param top, bottom, left, right: clipping :return: , (None is returned, when clipped on this corner) """ @@ -1153,7 +1164,6 @@ class Writer(BaseWriter): if length > 0: self.scr.hline(row, col, curses.ACS_HLINE, length, *attr) else: - raise ValueError(length) return None, None return beg, end @@ -1239,7 +1249,7 @@ class Main(HasWidgets): curses.noecho() curses.raw() # disable ctrl-C interrupt and ctrl-Q/S flow control curses.nonl() # accept ctrl-J - self.scr.keypad(1) + self.scr.keypad(True) KEY.init() self.context_menu = [ MenuItem('help', KEY.HELP, self.handle_help, None), @@ -1255,7 +1265,7 @@ class Main(HasWidgets): raise finally: if self.scr: - self.scr.keypad(0) + self.scr.keypad(False) curses.echo() curses.nocbreak() curses.endwin() @@ -1346,7 +1356,7 @@ class Main(HasWidgets): botmargin = sum(v.height() for v in self.footers) bot = wr.height - botmargin - end_scroll = bot - topmargin - 1 + end_scroll = bot - topmargin - 1 - wr.height // 5 current_row = self.current_row() diff --git a/frappy/tools/editorutils.py b/frappy/tools/editorutils.py deleted file mode 100644 index 92bd0c0d..00000000 --- a/frappy/tools/editorutils.py +++ /dev/null @@ -1,106 +0,0 @@ -# ***************************************************************************** -# -# This program is free software; you can redistribute it and/or modify it under -# the terms of the GNU General Public License as published by the Free Software -# Foundation; either version 2 of the License, or (at your option) any later -# version. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -# details. -# -# You should have received a copy of the GNU General Public License along with -# this program; if not, write to the Free Software Foundation, Inc., -# 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -# -# Module authors: -# Markus Zolliker -# -# ***************************************************************************** -"""helper functions for configuration editor""" - -import re -from frappy.config import Node -from frappy.lib import get_class - -SITE_TAIL = 'psi.ch' - - -def repr_param(value=object, **kwds): - if value is object: - if not kwds: - return 'Param()' - items = [] - else: - if not kwds: - return value - items = [value] - items.extend(f'{k}={v}' for k, v in kwds.items()) - return f"Param({', '.join(items)})" - - -def repr_module(modcfg, name, cls): - # items = [f'Mod({name}', f'cls={cls}', f'description={repr_param(**description)}'] + [ - # f'{k}={repr_param(**v)}' for k, v in kwds.items()] + [')'] - description = modcfg.pop('description', '') - items = [f'Mod({name}', cls, repr_param(**description)] + [ - f'{k}={repr_param(**v)}' for k, v in modcfg.items()] + [')'] - return ',\n '.join(items) - - -def repr_node(name, description, cls=None, equipment_id='', **kwds): - equipment_id = fix_equipment_id(name, equipment_id) - items = [f'Node({equipment_id!r}', f'description={description}'] - add_node_class(cls, kwds) - items.extend(f'{k}={v}' for k, v in kwds.items()) - items.append(')') - return ',\n '.join(items) - - -def fix_equipment_id(name, equipment_id): - if not re.match(r'[a-zA_Z0-9_]+(\.[a-zA_Z0-9_]+)*$', equipment_id): - equipment_id = f'{name}.{SITE_TAIL}' - return equipment_id - - -def add_node_class(cls, result): - if cls != Node('', '')['cls']: - result['cls'] = cls - - -def normalize_node(name, equipment_id='', description='', cls=None, interface=None, **kwds): - result = {} - eq = fix_equipment_id(name, equipment_id) - if equipment_id and eq != equipment_id: - result['equipment_id'] = eq - result['description'] = description - result['interface'] = interface or 'tcp://5555' - add_node_class(cls, result) - result.update(kwds) - return result - - -def convert_modcfg(cfgdict): - """convert cfgdict - - convert parameter properties to individual items . - """ - result = {} - for key, cfgvalue in cfgdict.items(): - if isinstance(cfgvalue, dict): - result.update((key, v) if k == 'value' else (f'{key}.{k}', v) - for k, v in cfgvalue.items()) - else: - result[key] = cfgvalue - return result - - -def needed_properties(cls): - if isinstance(cls, str): - cls = get_class(cls) - result = [] - for pname, prop in cls.propertyDict.items(): - if prop.mandatory and pname not in {'implementation', 'features'}: - result.append(pname) - return result