diff --git a/CHANGES.rst b/CHANGES.rst index db35002..5637c44 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,8 @@ Changelog 2.6.x (unreleased) ~~~~~~~~~~~~~~~~~~ +* Colorize interactive console with Python 3.13+. + * Drop support for Python 3.6 and 3.7. * Drop support for OpenERP and for Odoo 8. diff --git a/odooly.py b/odooly.py index 7b7e1fb..97e9d39 100644 --- a/odooly.py +++ b/odooly.py @@ -39,6 +39,7 @@ ADMIN_USER = 'admin' SYSTEM_USER = '__system__' MAXCOL = [79, 179, 9999] # Line length in verbose mode +PP_FORMAT = {'sort_dicts': False, 'width': 120} USER_AGENT = f'Mozilla/5.0 (X11) odooly.py/{__version__}' USAGE = """\ @@ -110,7 +111,7 @@ ('unlink', ['ids']), ('write', ['ids', 'vals']), ] -http_context = None +colorize, http_context = str, None if os.getenv('ODOOLY_SSL_UNVERIFIED'): import ssl @@ -233,14 +234,14 @@ def format_params(params, hide=('passw', 'pwd')): def format_exception(exc_type, exc, tb, limit=None, chain=True, - _format_exception=traceback.format_exception): + _format_exception=traceback.format_exception, **kw): """Format a stack trace and the exception information. This wrapper is a replacement of ``traceback.format_exception`` which formats the error and traceback received by API. If `chain` is True, then the original exception is printed too. """ - values = _format_exception(exc_type, exc, tb, limit=limit) + values = _format_exception(exc_type, exc, tb, limit=limit, **kw) server_error = None if issubclass(exc_type, Error): # Client-side values = [f"{exc}\n"] @@ -1130,6 +1131,7 @@ def verbose(self): def verbose(self, cols): cols = MAXCOL[min(3, cols) - 1] if (cols or 9) < 9 else cols self._printer.cols = cols and max(36, cols) or None + PP_FORMAT['width'] = cols and max(79, cols) or PP_FORMAT['width'] def _set_services(self, server, db): if isinstance(server, list): @@ -1428,12 +1430,19 @@ def _set_prompt(self): @classmethod def _set_interactive(cls, global_vars={}): + global colorize # Don't call multiple times del Client._set_interactive assert not cls._is_interactive() - for name in ['__name__', '__version__', '__doc__', 'Client']: global_vars[name] = globals()[name] + try: # Python >= 3.14 + from _pyrepl.utils import disp_str, gen_colors, _colorize + colorize = lambda v: "".join(disp_str(v, colors=[*gen_colors(v)])[0]) + colorize.__name__ = colorize.__qualname__ = 'colorize' + global_vars |= {'colorize': colorize, 'decolor': _colorize.decolor} + except ImportError: + pass cls._globals = global_vars return global_vars @@ -2317,11 +2326,40 @@ def __setattr__(self, attr, value): def _interact(global_vars, use_pprint=True, usage=USAGE): import builtins - import code import pprint + if use_pyrepl := not os.getenv("PYTHON_BASIC_REPL"): + try: + from _pyrepl.main import CAN_USE_PYREPL as use_pyrepl + except ImportError: + use_pyrepl = False + + if use_pyrepl: # Python >= 3.13 + from _pyrepl.console import InteractiveColoredConsole as Console + from _pyrepl.simple_interact import run_multiline_interactive_console as run + from _pyrepl import readline + else: + from code import InteractiveConsole as Console + try: + import readline + import rlcompleter + readline.parse_and_bind('tab: complete') + except ImportError: + pass + + try: + readline.read_history_file(HIST_FILE) + if readline.get_history_length() < 0: + readline.set_history_length(int(os.getenv('HISTSIZE', 500))) + # better append instead of replace? + atexit.register(readline.write_history_file, HIST_FILE) + except Exception: + pass # IOError if file missing, or other error + if use_pprint: - def displayhook(value, _printer=pprint.pp, _builtins=builtins): + pp = lambda obj: print(colorize(pprint.pformat(obj, **PP_FORMAT))) + + def displayhook(value, _printer=pp, _builtins=builtins): # Pretty-format the output if value is not None: _printer(value) @@ -2330,28 +2368,16 @@ def displayhook(value, _printer=pprint.pp, _builtins=builtins): def excepthook(exc_type, exc, tb): # Print readable errors - msg = ''.join(format_exception(exc_type, exc, tb, chain=False)) - print(msg.strip()) + msg = ''.join(format_exception(exc_type, exc, tb, chain=False, **exfmt)) + print(colorize(msg.rstrip())) sys.excepthook = excepthook builtins.usage = type('Usage', (), {'__call__': lambda s: print(usage), '__repr__': lambda s: usage})() - try: - import readline as rl - import rlcompleter - rl.parse_and_bind('tab: complete') - # IOError if file missing, or broken Apple readline - rl.read_history_file(HIST_FILE) - except Exception: - pass - else: - if rl.get_history_length() < 0: - rl.set_history_length(int(os.getenv('HISTSIZE', 500))) - # better append instead of replace? - atexit.register(rl.write_history_file, HIST_FILE) - - code.InteractiveConsole(global_vars).interact('', '') + console = Console(global_vars, filename="") + exfmt = {"colorize": console.can_colorize} if use_pyrepl else {} + run(console) if use_pyrepl else console.interact('', '') def main(interact=_interact): @@ -2390,7 +2416,7 @@ def main(interact=_interact): return global_vars = Client._set_interactive() - print(USAGE) + print(colorize(USAGE)) if args.env: client = Client.from_config(args.env, user=args.user, verbose=args.verbose) diff --git a/tests/test_interact.py b/tests/test_interact.py index c159e40..d343459 100644 --- a/tests/test_interact.py +++ b/tests/test_interact.py @@ -15,6 +15,8 @@ def setUp(self): odooly.Client._set_interactive.__func__.__defaults__ = ({},) # Hide readline module mock.patch.dict('sys.modules', {'readline': None}).start() + # Hide _pyrepl module + mock.patch.dict('sys.modules', {'_pyrepl': None}).start() mock.patch('odooly.Client._globals', None).start() mock.patch('odooly.Client._set_interactive', wraps=odooly.Client._set_interactive).start() self.interact = mock.patch('odooly._interact', wraps=odooly._interact).start()