GeezerVison is showing github.com — `sys.exit` unpacks its argument if it is a 0- or 1-element tuple · Issue #133548 · python/cpython
sys.exit unpacks its argument if it is a 0- or 1-element tuple #133548
Labels interpreter-core(Objects, Python, Grammar, and Parser dirs)(Objects, Python, Grammar, and Parser dirs)type-bugAn unexpected behavior, bug, or errorAn unexpected behavior, bug, or error DescriptionBug report
Bug description:
The documentation of sys.exit says “The optional argument arg can be an integer giving the exit status (defaulting to zero), or another type of object. [...] If another type of object is passed, None is equivalent to passing zero, and any other object is printed to stderr and results in an exit code of 1.” This is no longer true for 0- and 1-element tuples in Python 3.12 and later. It acts like the argument is unpacked: sys.exit(()) is treated like sys.exit() and sys.exit((x,)) is treated like sys.exit(x).
$ python3.11 -c 'import sys; sys.exit(())'; echo $?
()
1
$ python3.12 -c 'import sys; sys.exit(())'; echo $?
0
$ python3.11 -c 'import sys; sys.exit((2,))'; echo $?
(2,)
1
$ python3.12 -c 'import sys; sys.exit((2,))'; echo $?
2
CPython versions tested on:
3.12, 3.13, CPython main branch
Operating systems tested on:
macOS
Linked PRs
Activityterryjreedy commented on May 7, 2025 Can you find the issue that made the change (git blame) and see if the behavior change was intended? If so, the doc should be changed. brianschubert commented on May 7, 2025 AndPuQing commented on Jun 21, 2025 Analysis of the Behavior Change
I've investigated this issue and found the root cause of the behavior change. This appears to be an unintended consequence of the exception normalization process.
Root Cause Analysis
The behavior change occurs through this call chain:
sys.exit((2,)) calls sys_exit_impl()
- Which calls
PyErr_SetObject(PyExc_SystemExit, status) where status = (2,)
- This triggers exception normalization in
_PyErr_SetObject()
- The normalization calls
_PyErr_CreateException() which eventually invokes SystemExit.__init__()
- In SystemExit_init(), when
size == 1, it unpacks the single argument:
if (size == 1) {
Py_XSETREF(self->code, Py_NewRef(PyTuple_GET_ITEM(args, 0)));
}
The Issue
When sys.exit((2,)) is called:
- The tuple
(2,) gets passed to SystemExit.__init__() as args = (2,)
- Since
len(args) == 1, SystemExit unpacks it and sets self.code = 2
However, when directly calling raise SystemExit((2,)):
- The tuple
(2,) is passed as args = ((2,),) to SystemExit.__init__()
- The same unpacking occurs, but the exit code handling path is different
Inconsistency
This creates an inconsistency:
sys.exit((2,)) → exit code 2 (Python 3.12+)
raise SystemExit((2,)) → exit code 1 (prints the tuple to stderr)
Recommendation
Honestly, this feels like an accidental side effect rather than an intentional design change. The fact that sys.exit() and raise SystemExit() now behave differently with the same arguments seems pretty weird to me.
I think we should probably:
- Update the docs to match what actually happens now (since the current docs are just wrong, and this behavior has been going on for a long time.)
- Maybe file another issue about the
sys.exit() vs raise SystemExit() inconsistency? It's kind of annoying that these two do different things now.
I'm leaning towards fixing the docs first since that's the immediate problem, but the inconsistency thing bugs me too. What do you think - should I open a separate issue for that? picnixz commented on Jun 21, 2025
Maybe file another issue about the sys.exit() vs raise SystemExit() inconsistency?
No need for another issue. This issue is already about the inconsistency.
Update the docs to match what actually happens now (since the current docs are just wrong, and this behavior has been going on for a long time.)
I don't think it's a doc issue; it's an implementation issue here. I prefer having 3.12 buggy rather than having all versions changed since 3.12. I think it was an unexpected change (and note that sys.exit([1]) behaves correctly, so in this case we should have sys.exit((1,)) and sys.exit([1]) behave the same).
The issue itself is different. Actually, SystemExit.__init__() has args = (2,) whether it's invoked as sys.exit(2) or sys.exit((2,). So it's impossible to distinguish between those two. What needs to be done is check this in SystemExit.__init__ (my previous patch only patched sys.exit() but semantics are actually the same, my bad) picnixz commented on Jun 21, 2025 @markshannon Do you prefer to handle the status like that directly in SystemExit or do you prefer to change how normalization happens (but this may be annoying for other reasons). I think we have other places where we wrapped the exception tuple args to prevent the constructor flattening them (though, I don't know if we have a function for that exactly). picnixz commented on Jun 21, 2025 Huh, looking at the tests:
# call with tuple argument with one entry
# entry will be unpacked
with self.assertRaises(SystemExit) as cm:
sys.exit((42,))
self.assertEqual(cm.exception.code, 42)
And this check hasn't been touched since 2003... but we actually never tested raise SystemExit here, which is a bit annoying. serhiy-storchaka commented on May 11, 2026 SystemError instances are also special.
$ ./python -c 'import sys; sys.exit(SystemExit(42))'; echo $?
42
AFAIK we recently fixed similar issue with KeyError or something similar. cc @vstinner. vstinner commented on May 11, 2026 sys.exit(status) calls PyErr_SetObject(PyExc_SystemExit, status): the problem is that this function unpacks status if it's a tuple :-( See:
static PyObject*
_PyErr_CreateException(PyObject *exception_type, PyObject *value)
{
PyObject *exc;
if (value == NULL || value == Py_None) {
exc = _PyObject_CallNoArgs(exception_type);
}
else if (PyTuple_Check(value)) {
exc = PyObject_Call(exception_type, value, NULL);
}
else {
exc = PyObject_CallOneArg(exception_type, value);
}
...
}
Unpacking a tuple is wrong, it doesn't respect sys.exit documentation:
If another type of object is passed, None is equivalent to passing zero, and any other object is printed to stderr and results in an exit code of 1.
sys.exit((value,)) should behave as sys.exit(1) for any value.
For me, the first problem is that https://docs.python.org/dev/c-api/exceptions.html#c.PyErr_SetObject doesn't document that it unpacks value if it's a tuple.
On old Python versions (3.11 and older?), sys.exit(status) sets tstate->curexc_value to status. Then PyErr_PrintEx() calls handle_system_exit(). The exception is not normalized.
handle_system_exit() doesn't unpack status:
if (PyLong_Check(value))
exitcode = (int)PyLong_AsLong(value);
else {
PyObject *sys_stderr = PySys_GetObject("stderr");
if (sys_stderr != NULL && sys_stderr != Py_None) {
PyFile_WriteObject(value, sys_stderr, Py_PRINT_RAW);
} else {
PyObject_Print(value, stderr, Py_PRINT_RAW);
fflush(stderr);
}
PySys_WriteStderr("\n");
exitcode = 1;
}vstinner commented on May 11, 2026
AFAIK we recently fixed similar issue with KeyError or something similar. cc @python/editorial-board
Are you thinking at commit d4153a9? If yes, it's a different problem, it's not about unpacking a tuple. picnixz commented on May 11, 2026 Could you have a look at #135789 to see if this approach would work? vstinner commented on May 11, 2026
MetadataMetadatainterpreter-core(Objects, Python, Grammar, and Parser dirs)(Objects, Python, Grammar, and Parser dirs)type-bugAn unexpected behavior, bug, or errorAn unexpected behavior, bug, or error DevelopmentNo branches or pull requestsIssue actions
|