Repository navigation
sys.exit unpacks its argument if it is a 0- or 1-element tuple #133548
Description
Activity
- addedtype-bugAn unexpected behavior, bug, or errorAn unexpected behavior, bug, or error
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.
- addedextension-modulesC modules in the Modules dirC modules in the Modules dirinterpreter-core(Objects, Python, Grammar, and Parser dirs)(Objects, Python, Grammar, and Parser dirs)and removedextension-modulesC modules in the Modules dirC modules in the Modules dir
on May 7, 2025 This bisects to feec49c (#101607), cc @markshannon
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)wherestatus = (2,) - This triggers exception normalization in
_PyErr_SetObject() - The normalization calls
_PyErr_CreateException()which eventually invokesSystemExit.__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 toSystemExit.__init__()asargs = (2,) - Since
len(args) == 1, SystemExit unpacks it and setsself.code = 2
However, when directly calling
raise SystemExit((2,)):- The tuple
(2,)is passed asargs = ((2,),)toSystemExit.__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()andraise 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()vsraise 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?
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 havesys.exit((1,))andsys.exit([1])behave the same).
The issue itself is different. Actually,
SystemExit.__init__()hasargs = (2,)whether it's invoked assys.exit(2)orsys.exit((2,). So it's impossible to distinguish between those two. What needs to be done is check this inSystemExit.__init__(my previous patch only patched sys.exit() but semantics are actually the same, my bad)@markshannon Do you prefer to handle the status like that directly in
SystemExitor 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).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 SystemExithere, which is a bit annoying.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.
sys.exit(status)callsPyErr_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 assys.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)setststate->curexc_valueto status. ThenPyErr_PrintEx()callshandle_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; }
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.
Could you have a look at #135789 to see if this approach would work?
I reviewed your PR.
Bug report
Bug description:
The documentation of
sys.exitsays “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,Noneis equivalent to passing zero, and any other object is printed tostderrand 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 likesys.exit()andsys.exit((x,))is treated likesys.exit(x).CPython versions tested on:
3.12, 3.13, CPython main branch
Operating systems tested on:
macOS
Linked PRs
sys.exit#135789