Skip to content
Open
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
9 changes: 9 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@ Changelog
---------


Unreleased
~~~~~~~~~~

* Add non-interactive mode. Use ``--command "<code>"`` 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)
~~~~~~~~~~~~~~~~~~

Expand Down
24 changes: 24 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
28 changes: 28 additions & 0 deletions odooly.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, '<string>'
else:
src, filename = sys.stdin.read(), '<stdin>'
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)
Expand All @@ -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))

Expand Down
4 changes: 4 additions & 0 deletions odooly_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
36 changes: 36 additions & 0 deletions tests/test_interact.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down