Skip to content

Unhandled exceptions in pytest_unconfigure hooks exit 1 and not 3 - ExitCode.INTERNAL_ERROR #9808

Open

Description

@symonk

Hi, as (briefly) discussed in this discussion, pytest_unconfigure differs from pytest_configure in terms of exiting.

When a pytest_configure(...) implementation raises an unhandled exception; pytest exits 3 - ExitCode.INTERNAL_ERROR; this is outlined below:

# conftest.py
import pytest

@pytest.hookimpl
def pytest_configure(config: pytest.Config):
    raise Exception("Unhandled exception; exit will be 3.")

# test_foo.py

def test_foo():
    assert True
pytest .
echo $?
3

However; the reverse is not true, an unhandled exception in the pytest_unconfigure(...) cases forces pytest to exit 1 instead.

# conftest.py
import pytest

@pytest.hookimpl
def pytest_configure(config: pytest.Config) -> None:
    raise Exception("Unhandled exc; exit will be 3.")


@pytest.hookimpl
def pytest_unconfigure(config: pytest.Config) -> None:
    raise Exception("No internal error here; pytest will exit 1")

# test_foo.py

def test_foo():
    assert True

and a 1 exit code:

    raise Exception("No internal error here; pytest will exit 1")
Exception: No internal error here; pytest will exit 1
(venv)  ✘ sy  ~/PycharmProjects/pythonProject45  echo $?
1

Activity

  1. RonnyPfannschmidt commented on Mar 22, 2022

    @RonnyPfannschmidt
    Member

    pytest/src/_pytest/main.py

    Lines 255 to 311 in c326c04

    def wrap_session(
    config: Config, doit: Callable[[Config, "Session"], Optional[Union[int, ExitCode]]]
    ) -> Union[int, ExitCode]:
    """Skeleton command line program."""
    session = Session.from_config(config)
    session.exitstatus = ExitCode.OK
    initstate = 0
    try:
    try:
    config._do_configure()
    initstate = 1
    config.hook.pytest_sessionstart(session=session)
    initstate = 2
    session.exitstatus = doit(config, session) or 0
    except UsageError:
    session.exitstatus = ExitCode.USAGE_ERROR
    raise
    except Failed:
    session.exitstatus = ExitCode.TESTS_FAILED
    except (KeyboardInterrupt, exit.Exception):
    excinfo = _pytest._code.ExceptionInfo.from_current()
    exitstatus: Union[int, ExitCode] = ExitCode.INTERRUPTED
    if isinstance(excinfo.value, exit.Exception):
    if excinfo.value.returncode is not None:
    exitstatus = excinfo.value.returncode
    if initstate < 2:
    sys.stderr.write(f"{excinfo.typename}: {excinfo.value.msg}\n")
    config.hook.pytest_keyboard_interrupt(excinfo=excinfo)
    session.exitstatus = exitstatus
    except BaseException:
    session.exitstatus = ExitCode.INTERNAL_ERROR
    excinfo = _pytest._code.ExceptionInfo.from_current()
    try:
    config.notify_exception(excinfo, config.option)
    except exit.Exception as exc:
    if exc.returncode is not None:
    session.exitstatus = exc.returncode
    sys.stderr.write(f"{type(exc).__name__}: {exc}\n")
    else:
    if isinstance(excinfo.value, SystemExit):
    sys.stderr.write("mainloop: caught unexpected SystemExit!\n")
    finally:
    # Explicitly break reference cycle.
    excinfo = None # type: ignore
    os.chdir(session.startpath)
    if initstate >= 2:
    try:
    config.hook.pytest_sessionfinish(
    session=session, exitstatus=session.exitstatus
    )
    except exit.Exception as exc:
    if exc.returncode is not None:
    session.exitstatus = exc.returncode
    sys.stderr.write(f"{type(exc).__name__}: {exc}\n")
    config._ensure_unconfigure()
    return session.exitstatus

    we pretty much dont handle exceptons from that particualr hook

    on a sidenote, i also beleive its not expected that it causes issues in the form of exceptions - its too late for errors

  2. added
    topic: configrelated to config handling, argument parsing and config file
    on Mar 23, 2022
  3. changed the title [-]Unhandled exceptions in `pytest_unconfigure` hooks exit `1` and not `3 - ExitCode. INTERNAL_ERROR`[/-] [+]Unhandled exceptions in `pytest_unconfigure` hooks exit `1` and not `3` - `ExitCode.INTERNAL_ERROR`[/+] on Apr 29, 2022
  4. symonk commented on Apr 29, 2022

    @symonk
    MemberAuthor

    I've just started having a look at this one; heres an example test to prove out the case:

    def test_unconfigure_exc_handling(pytester: Pytester) -> None:
        pytester.makeconftest("""
        import pytest
        
        @pytest.hookimpl
        def pytest_configure(config: pytest.Config) -> None:
            raise Exception("before")
        
        
        @pytest.hookimpl
        def pytest_unconfigure(config: pytest.Config) -> None:
            raise Exception("after")
            """)
        pytester.makepyfile("""
            def test_foo():
                assert True
                
            def test_bar():
                assert False
        """)
        output = pytester.runpytest_subprocess()
        assert output.ret == ExitCode.TESTS_FAILED

    @RonnyPfannschmidt @nicoddemus my question is: what is the best approach here in 'solving' it? do we need to guard the cleanup (pytest_unconfigure and registered callables on the config? There is a lot of guarding on on the setup/runtestloop aspects of things and none here as Ronny mentions, on the 'it's too late for errors' could you elaborate that and how you would expect it to be handled?

    Thanks!

  5. rafael-g-v commented on Oct 15, 2026

    @rafael-g-v

    Hi! I'm Rafael, a college student from Brasil, and I'd like to work on this issue.

    Looking at the code, I think the cause is in wrap_session (src/_pytest/main.py):
    exceptions during the session are caught and turned into INTERNAL_ERROR, but the
    call to config._ensure_unconfigure() at the end of the finally block is not
    protected, so the exception escapes and Python exits with code 1.

    My plan is to wrap that call in a try/except, similar to how pytest_sessionfinish
    is handled right above it, and add a test in testing/test_main.py.

    Would a PR for this be welcome?

    PS.: English is not my native language, so I had to ask an agent to translate this message

  6. added a commit that references this issue on Oct 15, 2026
    24a89e5
  7. rafael-g-v commented on Oct 19, 2026

    @rafael-g-v

    I opened #15186 to fix this. Any feedback is welcome!

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    topic: configrelated to config handling, argument parsing and config filetype: refactoringinternal improvements to the code

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions