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
Fix #15132: preserve sys.exit() exit codes in non-interactive mode
When running `ipython -c "import sys;sys.exit(N)"` or
`ipython script.py` (with a script that calls sys.exit(N)), IPython
returned exit code 1 instead of the value passed to sys.exit(). The
exit code was hardcoded in TerminalIPythonApp.start() and the file
execution branch of InteractiveShellApp._run_cmd_line_code().

Extract the original exit code from the SystemExit exception stored on
the shell's last_execution_result and propagate it; default to 1 only
for genuine execution failures. Handle SystemExit before the bare
except: in the file execution path so it exits quietly (without a
traceback), matching Python's own behavior.

Co-Authored-By: Claude <noreply@anthropic.com>
  • Loading branch information
2 people authored and Pablo Garcia committed Aug 12, 2026
commit 08eb3ab33d02142a73ba4675ff5331465cf3afd3
11 changes: 11 additions & 0 deletions IPython/core/shellapp.py
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,17 @@ def _run_cmd_line_code(self):
self.exit(2)
try:
self._exec_file(fname, shell_futures=True)
except SystemExit as e:
if not self.interact:
# Preserve the exit code requested by the script (issue
# #15132). SystemExit is not an Exception, so we handle it
# before the bare except: below which is for real errors.
exit_code = 1
if isinstance(e.code, int):
exit_code = e.code
elif e.code is None:
exit_code = 0
self.exit(exit_code)
except Exception:
self.shell.showtraceback(tb_offset=4)
if not self.interact:
Expand Down
14 changes: 13 additions & 1 deletion IPython/terminal/ipapp.py
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,19 @@ def start(self):
self.log.debug("IPython not interactive...")
self.shell.restore_term_title()
if not self.shell.last_execution_succeeded:
sys.exit(1)
# Preserve the original exit code when the user called
# sys.exit(N) (issue #15132). InteractiveShell catches the
# SystemExit and stores it on last_execution_result; default
# to 1 only for genuine execution failures.
exit_code = 1
result = self.shell.last_execution_result
if result is not None and isinstance(result.error_in_exec, SystemExit):
code = result.error_in_exec.code
if isinstance(code, int):
exit_code = code
elif code is None:
exit_code = 0
sys.exit(exit_code)

def load_default_config(ipython_dir=None):
"""Load the default config file from the default ipython_dir.
Expand Down
50 changes: 50 additions & 0 deletions tests/test_shellapp.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
import subprocess
import sys

import pytest
Expand Down Expand Up @@ -141,3 +142,52 @@ def test_init_shell_not_implemented():

with pytest.raises(NotImplementedError):
InteractiveShellApp().init_shell()


# -----------------------------------------------------------------------------
# Exit-code preservation (issue #15132)
# -----------------------------------------------------------------------------


def _ipython_exit_code(*args):
"""Run ``python -m IPython [args]`` and return the subprocess exit code."""
cmd = [sys.executable, "-m", "IPython"] + tt.default_argv() + list(args)
return subprocess.call(
cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
)


def test_sys_exit_preserves_code_zero():
assert _ipython_exit_code("-c", "import sys; sys.exit(0)") == 0


def test_sys_exit_preserves_code_one():
assert _ipython_exit_code("-c", "import sys; sys.exit(1)") == 1


def test_sys_exit_preserves_code_two():
# Regression test for issue #15132: previously this returned 1.
assert _ipython_exit_code("-c", "import sys; sys.exit(2)") == 2


def test_sys_exit_preserves_arbitrary_code():
assert _ipython_exit_code("-c", "import sys; sys.exit(42)") == 42


def test_sys_exit_no_arg_returns_zero():
# ``sys.exit()`` with no argument means exit code 0.
assert _ipython_exit_code("-c", "import sys; sys.exit()") == 0


def test_normal_execution_returns_zero():
assert _ipython_exit_code("-c", "print('hello')") == 0


def test_sys_exit_in_script_file(tmp_pyfile):
fname = tmp_pyfile("import sys; sys.exit(2)\n")
assert _ipython_exit_code("--", fname) == 2


def test_sys_exit_zero_in_script_file(tmp_pyfile):
fname = tmp_pyfile("import sys; sys.exit(0)\n")
assert _ipython_exit_code("--", fname) == 0
Loading