From 748ea1400adb684663c80a7a175f5f9353cc4d9d Mon Sep 17 00:00:00 2001 From: Markus Zolliker Date: Thu, 13 Apr 2023 16:02:43 +0200 Subject: [PATCH] improve interactive client - remove irrelevant traceback on remote errors - add run() function to execute scripts - when started with bin/frappy-cli, use separate namespace Change-Id: Ic808a76fa76ecd8d814d52b15a6d7d2203c6a2f3 Reviewed-on: https://forge.frm2.tum.de/review/c/secop/frappy/+/30957 Tested-by: Jenkins Automated Tests Reviewed-by: Markus Zolliker --- bin/frappy-cli | 60 +++++++++---------- bin/frappy-play | 2 +- frappy/client/interactive.py | 113 +++++++++++++++++++++++++---------- 3 files changed, 110 insertions(+), 65 deletions(-) diff --git a/bin/frappy-cli b/bin/frappy-cli index b5e8829..a838067 100755 --- a/bin/frappy-cli +++ b/bin/frappy-cli @@ -23,8 +23,6 @@ # # ***************************************************************************** -from __future__ import print_function - import sys import argparse from os import path @@ -32,7 +30,7 @@ from os import path # Add import path for inplace usage sys.path.insert(0, path.abspath(path.join(path.dirname(__file__), '..'))) -from frappy.client.interactive import Client, watch, Console +from frappy.client.interactive import Client, Console, clientenv, run def parseArgv(argv): @@ -49,58 +47,56 @@ def parseArgv(argv): return parser.parse_args(argv) -_USAGE = """ +USAGE = """ Usage: -%s +{client_assign} # for all SECoP modules objects are created in the main namespace - # list all parameters -. = # change parameter -() # set target and wait until not busy - # 'status' and 'value' changes are shown every 1 sec -%s.mininterval = 0.2 # change minimal update interval to 0.2 sec (default is 1 second) - -watch(T) # watch changes of T.status and T.value (stop with ctrl-C) -watch(T='status target') # watch status and target parameters -watch(io, T=True) # watch io and all parameters of T -""" - -_CLIENT_USAGE = """ -c = Client('localhost:5000') -""" - -Client.show_usage = False + # list all parameters +. = # change parameter +() # set target and wait until not busy + # 'status' and 'value' changes are shown every 1 sec +{client_name}.mininterval = 0.2 # change minimal update interval to 0.2 s (default is 1 s) +watch(T) # watch changes of T.status and T.value (stop with ctrl-C) +watch(T='status target') # watch status and target parameters +watch(io, T=True) # watch io and all parameters of T +{tail}""" args = parseArgv(sys.argv[1:]) if not args.node: - _usage_args = ("\ncli = Client('localhost:5000')\n", 'cli') + usage_args = { + 'client_assign': "\ncli = Client('localhost:5000')\n", + 'client_name': 'cli'} success = True else: - _usage_args = ('', '_c0') + usage_args = { + 'client_assign': '', + 'client_name': '_c0'} success = False -for _idx, _node in enumerate(args.node): - _client_name = '_c%d' % _idx +clientenv.init() + +for idx, node in enumerate(args.node): + client_name = '_c%d' % idx try: - setattr(sys.modules['__main__'], _client_name, Client(_node, name=_client_name)) + clientenv.namespace[client_name] = Client(node, name=client_name) success = True except Exception as e: print(repr(e)) - +run_error = '' file_success = False try: for file in args.include: - with open(file, 'r') as f: - exec(f.read()) + run(file) file_success = True except Exception as e: - print('Error while executing %s: %s' % (file, e)) + run_error = f'\n{clientenv.short_traceback()}' if success: if args.include and file_success and args.only_execute: print('skipping interactive mode') exit() - print(_USAGE % _usage_args) - Console(sys.modules['__main__'].__dict__) + print(USAGE.format(tail=run_error, **usage_args)) + Console() diff --git a/bin/frappy-play b/bin/frappy-play index 7cf1416..3c8ca15 100755 --- a/bin/frappy-play +++ b/bin/frappy-play @@ -36,4 +36,4 @@ if len(sys.argv) > 1: else: print(USAGE) -Console(sys.modules['__main__'].__dict__, 'play') +Console('play', sys.modules['__main__'].__dict__) diff --git a/frappy/client/interactive.py b/frappy/client/interactive.py index 6c92d75..dcf67a1 100644 --- a/frappy/client/interactive.py +++ b/frappy/client/interactive.py @@ -28,15 +28,18 @@ from frappy.client.interactive import Client client = Client('localhost:5000') # start client. # this connects and creates objects for all SECoP modules in the main namespace - # list all parameters -. = # change parameter -() # set target and wait until not busy - # 'status' and 'value' changes are shown every 1 sec -client.mininterval = 0.2 # change minimal update interval to 0.2 sec (default is 1 second) + # list all parameters +. = # change parameter +() # set target and wait until not busy + # 'status' and 'value' changes are shown every 1 sec +client.mininterval = 0.2 # change minimal update interval to 0.2 s (default is 1 s) -watch(T) # watch changes of T.status and T.value (stop with ctrl-C) -watch(T='status target') # watch status and target parameters -watch(io, T=True) # watch io and all parameters of T +watch(T) # watch changes of T.status and T.value (stop with ctrl-C) +watch(T='status target') # watch status and target parameters +watch(io, T=True) # watch io and all parameters of T + +run('filename') # execute a script +/filename # execute a script """ import sys @@ -45,6 +48,7 @@ import re import code import signal import os +import traceback from os.path import expanduser from queue import Queue from frappy.client import SecopClient @@ -55,8 +59,6 @@ try: except ImportError: readline = None -main = sys.modules['__main__'] - LOG_LEVELS = {'debug', 'comlog', 'info', 'warning', 'error', 'off'} CLR = '\r\x1b[K' # code to move to the left and clear current line @@ -210,7 +212,7 @@ class Module: def read(self, pname='value'): value, _, error = self._secnode.readParameter(self._name, pname) if error: - Console.raise_without_traceback(error) + clientenv.raise_with_short_traceback(error) return value def __call__(self, target=None): @@ -231,7 +233,7 @@ class Module: return self.value def __repr__(self): - wid = max(len(k) for k in self._parameters) + wid = max((len(k) for k in self._parameters), default=0) return '%s\n%s%s' % ( self._title, '\n'.join(self._one_line(k, wid) for k in self._parameters), @@ -260,8 +262,7 @@ class Param: return self value, _, error = obj._secnode.cache[obj._name, self.name] if error: - Console.raise_without_traceback(error) - raise error + clientenv.raise_with_short_traceback(error) return value def formatted(self, obj): @@ -275,9 +276,10 @@ class Param: obj._running = Queue() try: obj._secnode.setParameter(obj._name, self.name, value) + return except SECoPError as e: - Console.raise_without_traceback(e) - # obj._secnode.log.error(repr(e)) + clientenv.raise_with_short_traceback(e) + obj._secnode.log.error(repr(e)) def format(self, value): return self.datatype.format_value(value) @@ -294,6 +296,8 @@ class Command: if args: raise TypeError('mixed arguments forbidden') result, _ = self.exec(self.modname, self.name, kwds) + elif len(args) == 1: + result, _ = self.exec(self.modname, self.name, *args) else: result, _ = self.exec(self.modname, self.name, args or None) return result @@ -306,7 +310,7 @@ class Command: def show_parameter(modname, pname, *args, forced=False, mininterval=0): """show parameter update""" - mobj = getattr(main, modname) + mobj = clientenv.namespace[modname] mobj._watch_parameter(modname, pname, *args) @@ -320,7 +324,7 @@ def watch(*args, **kwds): else: print(f'do not know {mobj!r}') for key, arg in kwds.items(): - mobj = getattr(main, key, None) + mobj = clientenv.namespace.get(key) if mobj is None: print(f'do not know {key!r}') else: @@ -345,6 +349,9 @@ class Client(SecopClient): mininterval = 1 def __init__(self, uri, loglevel='info', name=''): + if clientenv.namespace is None: + # called from a simple python interpeter + clientenv.init(sys.modules['__main__'].__dict__) # remove previous client: prev = self.secnodes.pop(uri, None) log = Logger(loglevel) @@ -352,10 +359,10 @@ class Client(SecopClient): if prev: log.info('remove previous client to %s', uri) for modname in prev.modules: - prevnode = getattr(getattr(main, modname, None), '_secnode', None) + prevnode = getattr(clientenv.namespace.get(modname), '_secnode', None) if prevnode == prev: removed_modules.append(modname) - delattr(main, modname) + clientenv.namespace.pop(modname) prev.disconnect() self.secnodes[uri] = self if name: @@ -365,7 +372,7 @@ class Client(SecopClient): created_modules = [] skipped_modules = [] for modname, moddesc in self.modules.items(): - prev = getattr(main, modname, None) + prev = clientenv.namespace.get(modname) if prev is None: created_modules.append(modname) else: @@ -383,7 +390,7 @@ class Client(SecopClient): if 'status' in mobj._parameters: self.register_callback((modname, 'status'), updateEvent=mobj._status_value_update) self.register_callback((modname, 'value'), updateEvent=mobj._status_value_update) - setattr(main, modname, mobj) + clientenv.namespace[modname] = mobj if removed_modules: self.log.info('removed modules: %s', ' '.join(removed_modules)) if skipped_modules: @@ -397,7 +404,7 @@ class Client(SecopClient): """handle logging messages""" if action == 'log': modname, loglevel = ident.split(':') - modobj = getattr(main, modname, None) + modobj = clientenv.namespace.get(modname) if modobj: modobj.handle_log_message_(loglevel, data) return @@ -408,9 +415,54 @@ class Client(SecopClient): return f'Client({self.uri!r})' +def run(filepath): + clientenv.namespace.update({ + "__file__": filepath, + "__name__": "__main__", + }) + with open(filepath, 'rb') as file: + # pylint: disable=exec-used + exec(compile(file.read(), filepath, 'exec'), clientenv.namespace, None) + + +class ClientEnvironment: + namespace = None + last_frames = 0 + + def init(self, namespace=None): + self.namespace = namespace or {} + self.namespace.update(run=run, watch=watch, Client=Client) + + def raise_with_short_traceback(self, exc): + # count number of lines of internal irrelevant stack (for remote errors) + self.last_frames = len(traceback.format_exception(*sys.exc_info())) + raise exc + + def short_traceback(self): + """cleanup tracback from irrelevant lines""" + lines = traceback.format_exception(*sys.exc_info()) + # line 0: Traceback header + # skip line 1+2 (contains unspecific console line and exec code) + lines[1:3] = [] + if ' exec(' in lines[1]: + # replace additional irrelevant exec line if needed with run command + lines[1:2] = [] + # skip lines of client code not relevant for remote errors + lines[-self.last_frames-1:-1] = [] + self.last_frames = 0 + if len(lines) <= 2: # traceback contains only console line + lines = lines[-1:] + return ''.join(lines) + + +clientenv = ClientEnvironment() + + class Console(code.InteractiveConsole): - def __init__(self, local, name='cli'): - super().__init__(local) + def __init__(self, name='cli', namespace=None): + if namespace: + clientenv.namespace = namespace + super().__init__(clientenv.namespace) history = None if readline: try: @@ -428,12 +480,9 @@ class Console(code.InteractiveConsole): Logger.sigwinch = bool(readline) # activate refresh signal line = input(prompt) Logger.sigwinch = False + if line.startswith('/'): + line = f"run('{line[1:].strip()}')" return line - @classmethod - def raise_without_traceback(cls, exc): - def omit_traceback_once(cls): - del Console.showtraceback - cls.showtraceback = omit_traceback_once - print('ERROR:', repr(exc)) - raise exc + def showtraceback(self): + self.write(clientenv.short_traceback())