Visitar URL original
`sys.exit` unpacks its argument if it is a 0- or 1-element tuple · Issue #133548 · python/cpython · GitHub
Skip to content

sys.exit unpacks its argument if it is a 0- or 1-element tuple #133548

Description

@dscorbett

Bug 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

Activity

  1. terryjreedy commented on May 7, 2025

    @terryjreedy
    Member

    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.

  2. added
    interpreter-core(Objects, Python, Grammar, and Parser dirs)
    and removed on May 7, 2025
  3. brianschubert commented on May 7, 2025

    @brianschubert
    Contributor

    This bisects to feec49c (#101607), cc @markshannon

  4. AndPuQing commented on Jun 21, 2025

    @AndPuQing
    Contributor

    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:

    1. sys.exit((2,)) calls sys_exit_impl()
    2. Which calls PyErr_SetObject(PyExc_SystemExit, status) where status = (2,)
    3. This triggers exception normalization in _PyErr_SetObject()
    4. The normalization calls _PyErr_CreateException() which eventually invokes SystemExit.__init__()
    5. 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:

    1. 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.)
    2. 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?

  5. picnixz commented on Jun 21, 2025

    @picnixz
    Member

    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)

  6. picnixz commented on Jun 21, 2025

    @picnixz
    Member

    @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).

  7. self-assigned this
    on Jun 21, 2025
  8. picnixz commented on Jun 21, 2025

    @picnixz
    Member

    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.

  9. serhiy-storchaka commented on May 11, 2026

    @serhiy-storchaka
    Member

    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.

  10. vstinner commented on May 11, 2026

    @vstinner
    Member

    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;
        }
  11. vstinner commented on May 11, 2026

    @vstinner
    Member

    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.

  12. picnixz commented on May 11, 2026

    @picnixz
    Member

    Could you have a look at #135789 to see if this approach would work?

  13. vstinner commented on May 11, 2026

    @vstinner
    Member

    I reviewed your PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

interpreter-core(Objects, Python, Grammar, and Parser dirs)type-bugAn unexpected behavior, bug, or error

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions