From ad506c20e8980bbebbc85d6f0fce1512e932ca52 Mon Sep 17 00:00:00 2001 From: Jeremy Bethmont Date: Tue, 12 May 2026 13:27:18 +0700 Subject: [PATCH] Add non-interactive mode Run a Python snippet against a connected client and exit, instead of dropping into the REPL. Two ways to trigger: * ``--command ""`` passes the code as a string; * a non-tty stdin is read as a script (e.g. ``odooly --env demo < script.py``). In both cases the ``client`` and ``env`` globals are pre-populated and ``_set_interactive`` is not called, so the snippet runs as a plain Python script (``client.connect()`` raises, login errors propagate). --- CHANGES.rst | 9 +++++++++ README.rst | 24 ++++++++++++++++++++++++ odooly.py | 28 ++++++++++++++++++++++++++++ odooly_run.py | 4 ++++ tests/test_interact.py | 36 ++++++++++++++++++++++++++++++++++++ 5 files changed, 101 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index 0a94708..ada5dee 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,15 @@ Changelog --------- +Unreleased +~~~~~~~~~~ + +* Add non-interactive mode. Use ``--command ""`` to run a Python + snippet against a connected client and exit, or pipe a script through + stdin (e.g. ``odooly --env demo < script.py``). In both cases the + ``client`` and ``env`` globals are pre-populated and no REPL is started. + + 2.6.5 (2026-04-12) ~~~~~~~~~~~~~~~~~~ diff --git a/README.rst b/README.rst index 10f526a..135e91f 100644 --- a/README.rst +++ b/README.rst @@ -125,3 +125,27 @@ This is a sample session:: create an empty file in your home directory:: ~$ touch ~/.odooly_history + + +Non-interactive use +------------------- + +Odooly can also run a snippet of Python against a connected client and +exit, instead of dropping into the REPL. This is convenient for scripts +and one-off automations. + +Pass the code inline with ``--command``:: + + ~$ odooly --env demo --command 'print(env["res.users"].search_count([]))' + +Or pipe a script through stdin:: + + ~$ odooly --env demo < provision.py + ~$ cat <<'EOF' | odooly --env demo + ... users = env['res.users'].search([]) + ... print(len(users), 'users') + ... EOF + +In both cases the ``client`` and ``env`` globals are pre-populated; no +prompt is shown. When stdin is not a terminal (e.g. inside CI), Odooly +automatically reads the script from stdin. diff --git a/odooly.py b/odooly.py index 205551b..c73cb8a 100644 --- a/odooly.py +++ b/odooly.py @@ -2459,10 +2459,35 @@ def get_parser(): parser.add_argument( '-v', '--verbose', default=0, action='count', help='verbose') + parser.add_argument( + '--command', default=None, metavar='CMD', + help='program passed in as string; runs non-interactively') parser.add_argument('--version', action='version', version=__version__) return parser +def _is_non_interactive(args): + return args.command is not None or not sys.stdin.isatty() + + +def _exec_script(args): + """Run a script string or stdin against a connected client, then exit.""" + client = connect_client(args) + if args.command is not None: + src, filename = args.command, '' + else: + src, filename = sys.stdin.read(), '' + ns = { + '__name__': '__main__', + '__doc__': None, + 'Client': Client, + 'client': client, + 'env': client.env, + } + exec(compile(src, filename, 'exec'), ns) + return ns + + def connect_client(args): if args.env: client = Client.from_config(args.env, user=args.user, verbose=args.verbose) @@ -2486,6 +2511,9 @@ def main(interact=_interact): print('Available settings: ' + ' '.join(read_config())) return + if _is_non_interactive(args): + return _exec_script(args) + global_vars = Client._set_interactive() print(color_repr(USAGE)) diff --git a/odooly_run.py b/odooly_run.py index 81c0d61..b7e25d0 100755 --- a/odooly_run.py +++ b/odooly_run.py @@ -168,6 +168,10 @@ def main(): print('Available settings: ' + ' '.join(odooly.read_config())) return + if odooly._is_non_interactive(args): + odooly._exec_script(args) + return + global_vars = odooly.Client._set_interactive() if odooly.color_py is not str or (os.getenv('FORCE_COLOR') and not os.getenv('NO_COLOR')): global_vars.update(patch_colors(odooly)) diff --git a/tests/test_interact.py b/tests/test_interact.py index d343459..bd21f80 100644 --- a/tests/test_interact.py +++ b/tests/test_interact.py @@ -17,6 +17,8 @@ def setUp(self): mock.patch.dict('sys.modules', {'readline': None}).start() # Hide _pyrepl module mock.patch.dict('sys.modules', {'_pyrepl': None}).start() + # Force interactive flow (test stdin is not a real tty) + mock.patch('sys.stdin.isatty', return_value=True).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() @@ -123,6 +125,40 @@ def test_no_database(self): ]) self.assertOutput(stderr=ANY) + def test_command(self): + """--command runs a script then exits without invoking REPL.""" + env_tuple = (self.server, 'database', 'usr', 'password', None) + mock.patch('sys.argv', new=['odooly', '--env', 'demo', + '--command', 'print(env.uid)\nprint(2 + 2)']).start() + mock.patch('odooly.Client.get_config', return_value=env_tuple).start() + self.service.database.list.return_value = ['database'] + self.service.common.login.return_value = 17 + self.service.object.execute_kw.return_value = {} + + ns = odooly.main() + + self.assertEqual(self.interact.call_count, 0) + self.assertEqual(ns['client'].env.uid, 17) + outlines = self.stdout.popvalue().splitlines() + self.assertSequenceEqual(outlines[-2:], ['17', '4']) + + def test_stdin_pipe(self): + """Piped (non-tty) stdin runs as a script then exits without REPL.""" + env_tuple = (self.server, 'database', 'usr', 'password', None) + mock.patch('sys.argv', new=['odooly', '--env', 'demo']).start() + mock.patch('sys.stdin.isatty', return_value=False).start() + mock.patch('sys.stdin.read', return_value='print(env.uid + 1)\n').start() + mock.patch('odooly.Client.get_config', return_value=env_tuple).start() + self.service.database.list.return_value = ['database'] + self.service.common.login.return_value = 17 + self.service.object.execute_kw.return_value = {} + + odooly.main() + + self.assertEqual(self.interact.call_count, 0) + outlines = self.stdout.popvalue().splitlines() + self.assertEqual(outlines[-1], '18') + def test_invalid_user_password(self): env_tuple = (self.server, 'database', 'usr', 'passwd', None) mock.patch('sys.argv', new=['odooly', '--env', 'demo']).start()