Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
74 changes: 50 additions & 24 deletions odooly.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = """\
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"]
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand All @@ -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="<stdin>")
exfmt = {"colorize": console.can_colorize} if use_pyrepl else {}
run(console) if use_pyrepl else console.interact('', '')


def main(interact=_interact):
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions tests/test_interact.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down