diff --git a/odoo/addons/base/tests/config/cli b/odoo/addons/base/tests/config/cli index 4a7879835d97d..9c8c8f9306cd7 100644 --- a/odoo/addons/base/tests/config/cli +++ b/odoo/addons/base/tests/config/cli @@ -62,7 +62,6 @@ --no-database-list --dev xml,reload ---shell-interface ipython --stop-after-init --osv-memory-count-limit 71 --transient-age-limit 4 diff --git a/odoo/addons/base/tests/config/non_default.conf b/odoo/addons/base/tests/config/non_default.conf index a652c6fb86957..9e033e8220953 100644 --- a/odoo/addons/base/tests/config/non_default.conf +++ b/odoo/addons/base/tests/config/non_default.conf @@ -85,7 +85,6 @@ list_db = False # advanced dev_mode = xml -shell_interface = ipython stop_after_init = True osv_memory_count_limit = 71 transient_age_limit = 4.0 diff --git a/odoo/addons/base/tests/shell_file.txt b/odoo/addons/base/tests/shell_file.txt new file mode 100644 index 0000000000000..9f13538c0259c --- /dev/null +++ b/odoo/addons/base/tests/shell_file.txt @@ -0,0 +1 @@ +message = 'Hello from Python!' \ No newline at end of file diff --git a/odoo/addons/base/tests/test_cli.py b/odoo/addons/base/tests/test_cli.py index 89f64ffd6086b..58be67e5069ff 100644 --- a/odoo/addons/base/tests/test_cli.py +++ b/odoo/addons/base/tests/test_cli.py @@ -1,35 +1,41 @@ +import os import re import sys import subprocess as sp +import unittest +from pathlib import Path from odoo.cli.command import commands, load_addons_commands, load_internal_commands -from odoo.tools import config -from odoo.tests import BaseCase - +from odoo.tools import config, file_path +from odoo.tests import BaseCase, Like class TestCommand(BaseCase): @classmethod def setUpClass(cls): super().setUpClass() - cls.odoo_bin = sys.argv[0] - assert 'odoo-bin' in cls.odoo_bin + cls.odoo_bin = Path(__file__).parents[4].resolve() / 'odoo-bin' + addons_path = config.format('addons_path', config['addons_path']) + cls.run_args = (sys.executable, cls.odoo_bin, f'--addons-path={addons_path}') def run_command(self, *args, check=True, capture_output=True, text=True, **kwargs): - addons_path = config.format('addons_path', config['addons_path']) return sp.run( - [ - sys.executable, - self.odoo_bin, - f'--addons-path={addons_path}', - *args, - ], + [*self.run_args, *args], capture_output=capture_output, check=check, text=text, **kwargs ) + def popen_command(self, *args, capture_output=True, text=True, **kwargs): + if capture_output: + kwargs['stdout'] = kwargs['stderr'] = sp.PIPE + return sp.Popen( + [*self.run_args, *args], + text=text, + **kwargs + ) + def test_docstring(self): load_internal_commands() load_addons_commands() @@ -82,3 +88,30 @@ def test_upgrade_code_standalone(self): self.assertIn("usage: ", proc.stdout) self.assertIn("Rewrite the entire source code", proc.stdout) self.assertFalse(proc.stderr) + + @unittest.skipIf(os.name != 'posix', '`os.openpty` only available on POSIX systems') + def test_shell(self): + + main, child = os.openpty() + + shell = self.popen_command( + 'shell', + '--shell-interface=python', + '--shell-file', file_path('base/tests/shell_file.txt'), + stdin=main, + close_fds=True, + ) + with os.fdopen(child, 'w', encoding="utf-8") as stdin_file: + stdin_file.write( + 'print(message)\n' + 'exit()\n' + ) + shell.wait() + + self.assertEqual(shell.stdout.read().splitlines(), [ + Like("No environment set..."), + Like("odoo: "), + Like("openerp: "), + ">>> Hello from Python!", + '>>> ' + ]) diff --git a/odoo/addons/base/tests/test_configmanager.py b/odoo/addons/base/tests/test_configmanager.py index 9910b44eca082..c7a95cbb6d3d9 100644 --- a/odoo/addons/base/tests/test_configmanager.py +++ b/odoo/addons/base/tests/test_configmanager.py @@ -148,7 +148,6 @@ def test_01_default_config(self): # advanced 'dev_mode': [], - 'shell_interface': '', 'stop_after_init': False, 'osv_memory_count_limit': 0, 'transient_age_limit': 1.0, @@ -266,7 +265,6 @@ def test_02_config_file(self): # advanced 'dev_mode': ['xml'], # blacklist for save, read from the config file - 'shell_interface': 'ipython', # blacklist for save, read from the config file 'stop_after_init': True, # blacklist for save, read from the config file 'osv_memory_count_limit': 71, 'transient_age_limit': 4.0, @@ -379,7 +377,6 @@ def test_04_odoo16_config_file(self): 'language': None, 'publisher_warranty_url': 'http://services.odoo.com/publisher-warranty/', 'save': False, - 'shell_interface': '', 'stop_after_init': False, 'translate_in': '', 'translate_out': '', @@ -548,7 +545,6 @@ def test_06_cli(self): # advanced 'dev_mode': ['xml', 'reload'], - 'shell_interface': 'ipython', 'stop_after_init': True, 'osv_memory_count_limit': 71, 'transient_age_limit': 4.0, diff --git a/odoo/cli/shell.py b/odoo/cli/shell.py index 7bf960e7c3c08..d5c8fcb7e616a 100644 --- a/odoo/cli/shell.py +++ b/odoo/cli/shell.py @@ -1,6 +1,6 @@ -# Part of Odoo. See LICENSE file for full copyright and licensing details. import code import logging +import optparse import os import signal import sys @@ -39,15 +39,15 @@ def raise_keyboard_interrupt(*a): class Console(code.InteractiveConsole): - def __init__(self, locals=None, filename=""): - code.InteractiveConsole.__init__(self, locals, filename) + def __init__(self, local_vars=None, filename=""): + code.InteractiveConsole.__init__(self, locals=local_vars, filename=filename) try: import readline import rlcompleter except ImportError: print('readline or rlcompleter not available, autocomplete disabled.') else: - readline.set_completer(rlcompleter.Completer(locals).complete) + readline.set_completer(rlcompleter.Completer(local_vars).complete) readline.parse_and_bind("tab: complete") @@ -57,6 +57,19 @@ class Shell(Command): def init(self, args): config.parser.prog = f'{Path(sys.argv[0]).name} {self.name}' + + group = optparse.OptionGroup(config.parser, "Shell options") + group.add_option( + '--shell-file', dest='shell_file', type='string', default='', my_default='', + help="Specify a python script to be run after the start of the shell. " + "Overrides the env variable PYTHONSTARTUP." + ) + group.add_option( + '--shell-interface', dest='shell_interface', type='string', + help="Specify a preferred REPL to use in shell mode. " + "Supported REPLs are: [ipython|ptpython|bpython|python]" + ) + config.parser.add_option_group(group) config.parse_config(args, setup_logging=True) cli_server.report_configuration() server.start(preload=[], stop=True) @@ -72,6 +85,8 @@ def console(self, local_vars): for i in sorted(local_vars): print('%s: %s' % (i, local_vars[i])) + pythonstartup = config.options.get('shell_file') or os.environ.get('PYTHONSTARTUP') + preferred_interface = config.options.get('shell_interface') if preferred_interface: shells_to_try = [preferred_interface, 'python'] @@ -80,27 +95,36 @@ def console(self, local_vars): for shell in shells_to_try: try: - return getattr(self, shell)(local_vars) + shell_func = getattr(self, shell) + return shell_func(local_vars, pythonstartup) except ImportError: pass except Exception: - _logger.warning("Could not start '%s' shell." % shell) + _logger.warning("Could not start '%s' shell.", shell) _logger.debug("Shell error:", exc_info=True) - def ipython(self, local_vars): - from IPython import start_ipython - start_ipython(argv=[], user_ns=local_vars) - - def ptpython(self, local_vars): - from ptpython.repl import embed - embed({}, local_vars) - - def bpython(self, local_vars): - from bpython import embed - embed(local_vars) - - def python(self, local_vars): - Console(locals=local_vars).interact() + def ipython(self, local_vars, pythonstartup=None): + from IPython import start_ipython # noqa: PLC0415 + argv = ( + ['--TerminalIPythonApp.display_banner=False'] + + ([f'--TerminalIPythonApp.exec_files={pythonstartup}'] if pythonstartup else []) + ) + start_ipython(argv=argv, user_ns=local_vars) + + def ptpython(self, local_vars, pythonstartup=None): + from ptpython.repl import embed # noqa: PLC0415 + embed({}, local_vars, startup_paths=[pythonstartup] if pythonstartup else False) + + def bpython(self, local_vars, pythonstartup=None): + from bpython import embed # noqa: PLC0415 + embed(local_vars, args=['-q', '-i', pythonstartup] if pythonstartup else None) + + def python(self, local_vars, pythonstartup=None): + console = Console(local_vars) + if pythonstartup: + with open(pythonstartup, encoding='utf-8') as f: + console.runsource(f.read(), filename=pythonstartup, symbol='exec') + console.interact(banner='') def shell(self, dbname): local_vars = { diff --git a/odoo/tools/config.py b/odoo/tools/config.py index 3ca9756103a6e..345c105123eab 100644 --- a/odoo/tools/config.py +++ b/odoo/tools/config.py @@ -383,9 +383,6 @@ def _build_cli(self): "- reload: restart server on change in the source code " "- werkzeug: open a html debugger on http request error " "- xml: read views from the source code, and not the db ") - group.add_option('--shell-interface', dest='shell_interface', my_default='', file_exportable=False, - help="Specify a preferred REPL to use in shell mode. Supported REPLs are: " - "[ipython|ptpython|bpython|python]") group.add_option("--stop-after-init", action="store_true", dest="stop_after_init", my_default=False, file_exportable=False, help="stop the server after its initialization") group.add_option("--osv-memory-count-limit", dest="osv_memory_count_limit", my_default=0,