frappy-edit: fix class completion

when the suggestion ends with a dot, all possibilities have
to be on the popup menu

Change-Id: Ic6f759d1e9d4028695d8949be5d4e3e81bbbe044
This commit is contained in:
2026-02-12 09:00:22 +01:00
parent 00318cc7a1
commit 18f6fa239b
3 changed files with 31 additions and 142 deletions

View File

@@ -32,6 +32,7 @@ from frappy.tools.terminalgui import Completion
SKIP_PROPS = {'implementation', 'features', SKIP_PROPS = {'implementation', 'features',
'interface_classes', 'slowinterval', 'omit_unchanged_within', 'original_id'} 'interface_classes', 'slowinterval', 'omit_unchanged_within', 'original_id'}
def recommended_prs(cls): def recommended_prs(cls):
"""get properties and parameters which are useful in configuration """get properties and parameters which are useful in configuration
@@ -58,7 +59,6 @@ def recommended_prs(cls):
return result return result
class FrappyModule(str): class FrappyModule(str):
"""checker for finding subclasses of Module defined in a python module""" """checker for finding subclasses of Module defined in a python module"""
def check(self, cls): def check(self, cls):
@@ -98,43 +98,18 @@ def get_suggested(guess, allowed_keys):
return result or allowed_keys return result or allowed_keys
class CheckerObsolete:
root = None
modobj = None
clsobj = None
modname = None
pyfile = None
def module(self, base, name):
modname = f'{base}.{name}' if base else name
try:
self.modobj = self.root = import_module(modname)
self.modname = modname
self.pyfile = Path(self.modobj.__file__)
return None
except ImportError as e:
return str(e)
except Exception as e:
return f'{modname}: {e!r}'
def cls(self, base, name):
try:
self.clsobj = getattr(self.root, name)
return None
except Exception:
return f'{base}.{name} does not exist'
def class_completion(value): def class_completion(value):
"""analyze class path and return an array of suggestions for
the last element not matching"""
checker = ClassChecker(value) checker = ClassChecker(value)
if checker.position == len(value): if not checker.error:
return checker.position, [] return checker.position, []
if checker.root is None: if checker.root is None:
sdict = {p: f'{p}.' for p in site.packages} sdict = {p: f'{p}.' for p in site.packages}
else: else:
sdict = {} sdict = {}
file = checker.pyfile
if not checker.clsobj: if not checker.clsobj:
file = checker.pyfile
if file.name == '__init__.py': if file.name == '__init__.py':
sdict = {p.stem: f'{p.stem}.' sdict = {p.stem: f'{p.stem}.'
for p in sorted(file.parent.glob('*.py')) for p in sorted(file.parent.glob('*.py'))
@@ -150,101 +125,6 @@ def class_completion(value):
return checker.position, [checker.name] + selection return checker.position, [checker.name] + selection
class Base(Completion, DataType):
def __init__(self, callback):
self.callback = callback
super().__init__()
def from_string(self, strvalue):
value = self.validate(strvalue)
if self.callback:
self.callback(value)
return value
def to_string(self, value):
value = self.validate(value)
return f'{value.__module__}.{value.__qualname__}'
def format_value(self, value, unit=None):
result = repr(self.to_string(value))
if '<' in result:
raise ValueError(result, value)
return result
class ClassCompletionObsolete(Base):
@staticmethod
def propose(value, get_clsobj=False):
"""analyze value to propositions of class path
returns the length of the valid part and a list of guesses
"""
clspath = value.split('.')
check = CheckerObsolete()
for pathpos, name in enumerate(clspath):
base = '.'.join(clspath[:pathpos])
clsroot = check.root
if name:
if check.clsobj:
error = check.cls(base, name)
elif name.isupper():
error = check.cls(base, name)
if error and check.module(base, name) is None:
error = None
else:
error = check.module(base, name)
if error and check.cls(base, name) is None:
error = None
else:
error = 'empty element'
if get_clsobj:
if error:
return None, error
elif pathpos == len(clspath) - 1 or error:
# get suggestions
# sdict is a dict '<proposed name>' of '<name>' or '<name>.'
# the latter when it is a pymodule
# sdict = {name: None}
if pathpos == 0:
sdict = {p: f'{p}.' for p in site.packages}
else:
sdict = {}
file = check.pyfile
if not check.clsobj:
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(
clsroot, FrappyModule(check.modname).check)))
found = sdict.get(name, None)
if found:
selection = [found]
# selection = [found] + list(sdict.values())
else:
selection = list(get_suggested(name, sdict.values()))
position = sum(len(v) for v in clspath[:pathpos]) + pathpos
return position, [name] + selection
check.root = check.clsobj or check.modobj
if get_clsobj:
return check.clsobj, error
return 0, None
@classmethod
def validate(cls, value, previous=None):
if isinstance(value, type):
if issubclass(value, Module):
return value
raise ValueError('value is a class, but not a frappy module')
clsobj, error = cls.propose(value, True)
if error:
raise ValueError(error)
return clsobj
class NameCompletion(Completion, DataType): class NameCompletion(Completion, DataType):
# TODO: make obsolete # TODO: make obsolete
def __init__(self, callback, get_name_info): def __init__(self, callback, get_name_info):

View File

@@ -231,31 +231,32 @@ def make_value(pname, cls, value):
class ClassChecker: class ClassChecker:
root = None root = None # = clsobj if the imported object exists or modobj
modobj = None modobj = None # the python module imported or None if no import succeeded
clsobj = None clsobj = None # the object imported or None on failure
modname = None modname = None # the name of the module imported
pyfile = None pyfile = None # the python file of the module imported
error = None error = None # None on success or a reason of failure
def __init__(self, clsstr): def __init__(self, clsstr):
"""analyze clsstr """analyze clsstr
returns the length of the valid part and a list of guesses try to resolve clsstr from left to right, quit on error
overwrite the class attributes above
""" """
clspath = clsstr.split('.') clspath = clsstr.split('.')
for pathpos, name in enumerate(clspath): for pathpos, name in enumerate(clspath):
base = '.'.join(clspath[:pathpos]) base = '.'.join(clspath[:pathpos])
if name: if name:
if self.clsobj: if self.clsobj:
error = self.cls(base, name) error = self.try_cls(base, name)
elif name.isupper(): elif name.isupper():
error = self.cls(base, name) error = self.try_cls(base, name)
if error and self.module(base, name) is None: if error and self.try_module(base, name) is None:
error = None error = None
else: else:
error = self.module(base, name) error = self.try_module(base, name)
if error and self.cls(base, name) is None: if error and self.try_cls(base, name) is None:
error = None error = None
else: else:
error = 'empty element' error = 'empty element'
@@ -269,7 +270,11 @@ class ClassChecker:
self.error = None self.error = None
self.position = len(clsstr) self.position = len(clsstr)
def module(self, base, name): def try_module(self, base, name):
"""try if base + name is a python module
return None on success or an error message otherwise
"""
modname = f'{base}.{name}' if base else name modname = f'{base}.{name}' if base else name
try: try:
self.modobj = self.root = import_module(modname) self.modobj = self.root = import_module(modname)
@@ -281,7 +286,11 @@ class ClassChecker:
except Exception as e: except Exception as e:
return f'{modname}: {e!r}' return f'{modname}: {e!r}'
def cls(self, base, name): def try_cls(self, base, name):
"""try if base + name is a python object (typically a class)
return None on success or an error message otherwise
"""
try: try:
self.clsobj = getattr(self.root, name) self.clsobj = getattr(self.root, name)
return None return None

View File

@@ -697,13 +697,13 @@ class CompletionMenu(PopUpMenu):
row = wr.nextrow row = wr.nextrow
wr.left = max(1, wr.left) wr.left = max(1, wr.left)
col = wr.left - 1 col = wr.left - 1
wr.nextrow -= self.pos wr.nextrow -= self.pos - 1
height = self.height + 1 height = self.height + 1
if self.pos == 0: if self.pos == 0:
# rectangle is open on top # rectangle is open on top
wr.rectangle(row - self.pos - 1, col, height, self.width + 1, top=row + 1) wr.rectangle(row + 1, col, height - 1, self.width + 1, top=row + 2)
else: else:
wr.rectangle(row - self.pos - 1, col, height, self.width + 1) wr.rectangle(row - self.pos, col, height, self.width + 1)
for i, value in enumerate(self.selection): for i, value in enumerate(self.selection):
wr.startrow() wr.startrow()
if i == self.pos: if i == self.pos: