From 43054332115b7e4f6a319f5fbf793abfb2b1726d Mon Sep 17 00:00:00 2001 From: Dane Parin Date: Sun, 13 Sep 2026 17:01:53 +0700 Subject: [PATCH 1/5] Introduce ExitCode.COLLECTION_ERROR for collection errors Co-authored-by: Claude Sonnet 5 --- changelog/4603.breaking.rst | 1 + doc/en/reference/exit-codes.rst | 3 ++- src/_pytest/config/__init__.py | 2 ++ src/_pytest/main.py | 15 ++++++++++++++- src/_pytest/terminal.py | 3 ++- testing/acceptance_test.py | 8 ++++---- testing/test_collection.py | 31 ++++++++++++++++++++++--------- testing/test_config.py | 8 ++++---- testing/test_mark.py | 2 +- testing/test_python_path.py | 4 ++-- testing/test_terminal.py | 6 +++--- 11 files changed, 57 insertions(+), 26 deletions(-) create mode 100644 changelog/4603.breaking.rst diff --git a/changelog/4603.breaking.rst b/changelog/4603.breaking.rst new file mode 100644 index 00000000000..34ee6175146 --- /dev/null +++ b/changelog/4603.breaking.rst @@ -0,0 +1 @@ +Collection errors such as syntax or import errors now exit with the new ``ExitCode.COLLECTION_ERROR`` (7) instead of the misleading ``ExitCode.INTERRUPTED`` (2). diff --git a/doc/en/reference/exit-codes.rst b/doc/en/reference/exit-codes.rst index fff25c96ea0..d6b48e9aac2 100644 --- a/doc/en/reference/exit-codes.rst +++ b/doc/en/reference/exit-codes.rst @@ -3,7 +3,7 @@ Exit codes ======================================================== -Running ``pytest`` can result in seven different exit codes: +Running ``pytest`` can result in eight different exit codes: :Exit code 0: All tests were collected and passed successfully :Exit code 1: Tests were collected and run but some of the tests failed @@ -12,6 +12,7 @@ Running ``pytest`` can result in seven different exit codes: :Exit code 4: pytest command line usage error, including a plugin that cannot be found or a ``conftest.py`` that fails to import :Exit code 5: No tests were collected :Exit code 6: Maximum number of warnings exceeded (see :option:`--max-warnings`) +:Exit code 7: Errors occurred during collection They are represented by the :class:`pytest.ExitCode` enum. The exit codes being a part of the public API can be imported and accessed directly using: diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index c7bd3e1afab..e2258bfaed9 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -125,6 +125,8 @@ class ExitCode(enum.IntEnum): NO_TESTS_COLLECTED = 5 #: All tests pass, but maximum number of warnings exceeded. MAX_WARNINGS_ERROR = 6 + #: Errors occurred during collection. + COLLECTION_ERROR = 7 __module__ = "pytest" diff --git a/src/_pytest/main.py b/src/_pytest/main.py index d43a68b4679..acc46f6e02f 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -339,6 +339,8 @@ def wrap_session( except (KeyboardInterrupt, exit.Exception): excinfo = _pytest._code.ExceptionInfo.from_current() exitstatus: int | ExitCode = ExitCode.INTERRUPTED + if isinstance(excinfo.value, CollectionInterrupted): + exitstatus = ExitCode.COLLECTION_ERROR if isinstance(excinfo.value, exit.Exception): if excinfo.value.returncode is not None: exitstatus = excinfo.value.returncode @@ -399,7 +401,7 @@ def pytest_collection(session: Session) -> None: def pytest_runtestloop(session: Session) -> bool: if session.testsfailed and not session.config.option.continue_on_collection_errors: - raise session.Interrupted( + raise session.CollectionInterrupted( f"{session.testsfailed} error{'s' if session.testsfailed != 1 else ''} during collection" ) @@ -520,6 +522,16 @@ class Interrupted(KeyboardInterrupt): __module__ = "builtins" # For py3. +class CollectionInterrupted(Interrupted): + """Signals that the test run was interrupted by collection errors. + + Subclasses ``Interrupted`` for compatibility; ``wrap_session`` maps it + to ``ExitCode.COLLECTION_ERROR``. + """ + + __module__ = "builtins" # Match Interrupted. + + class Failed(Exception): """Signals a stop as failed test run.""" @@ -596,6 +608,7 @@ class Session(nodes.Collector): """ Interrupted = Interrupted + CollectionInterrupted = CollectionInterrupted Failed = Failed # Set on the session by runner.pytest_sessionstart. _setupstate: SetupState diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index 023fdcaabb8..e6a697e0686 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -975,6 +975,7 @@ def pytest_sessionfinish( ExitCode.OK, ExitCode.TESTS_FAILED, ExitCode.INTERRUPTED, + ExitCode.COLLECTION_ERROR, ExitCode.USAGE_ERROR, ExitCode.NO_TESTS_COLLECTED, ExitCode.MAX_WARNINGS_ERROR, @@ -999,7 +1000,7 @@ def pytest_sessionfinish( ) if session.shouldfail: self.write_sep("!", str(session.shouldfail), red=True) - if exitstatus == ExitCode.INTERRUPTED: + if exitstatus in (ExitCode.INTERRUPTED, ExitCode.COLLECTION_ERROR): self._report_keyboardinterrupt() self._keyboardinterrupt_memo = None elif session.shouldstop: diff --git a/testing/acceptance_test.py b/testing/acceptance_test.py index 3983bd0007d..bf600f88d2b 100644 --- a/testing/acceptance_test.py +++ b/testing/acceptance_test.py @@ -185,7 +185,7 @@ def test_this(): "*No module named *does_not_work*", ] ) - assert result.ret == 2 + assert result.ret == ExitCode.COLLECTION_ERROR def test_not_collectable_arguments(self, pytester: Pytester) -> None: p1 = pytester.makepyfile("") @@ -1027,7 +1027,7 @@ def test_with_failing_collection(self, pytester: Pytester, mock_timing) -> None: pytester.makepyfile(self.source) pytester.makepyfile(test_collecterror="""xyz""") result = pytester.runpytest_inprocess("--durations=2", "-k test_1") - assert result.ret == 2 + assert result.ret == ExitCode.COLLECTION_ERROR result.stdout.fnmatch_lines(["*Interrupted: 1 error during collection*"]) # Collection errors abort test execution, therefore no duration is @@ -1726,13 +1726,13 @@ def test_no_terminal_plugin(pytester: Pytester) -> None: def test_stop_iteration_from_collect(pytester: Pytester) -> None: pytester.makepyfile(test_it="raise StopIteration('hello')") result = pytester.runpytest() - assert result.ret == ExitCode.INTERRUPTED + assert result.ret == ExitCode.COLLECTION_ERROR result.assert_outcomes(failed=0, passed=0, errors=1) result.stdout.fnmatch_lines( [ "=* short test summary info =*", "ERROR test_it.py - StopIteration: hello", - "!* Interrupted: 1 error during collection !*", + "!* CollectionInterrupted: 1 error during collection !*", "=* 1 error in * =*", ] ) diff --git a/testing/test_collection.py b/testing/test_collection.py index 093162ddec4..8d546a1e6b2 100644 --- a/testing/test_collection.py +++ b/testing/test_collection.py @@ -1041,7 +1041,7 @@ def test_exit_on_collection_error(pytester: Pytester) -> None: pytester.makepyfile(**COLLECTION_ERROR_PY_FILES) res = pytester.runpytest() - assert res.ret == 2 + assert res.ret == ExitCode.COLLECTION_ERROR res.stdout.fnmatch_lines( [ @@ -1054,6 +1054,19 @@ def test_exit_on_collection_error(pytester: Pytester) -> None: ) +def test_collection_error_exit_code(pytester: Pytester) -> None: + """Collection errors exit with COLLECTION_ERROR, not INTERRUPTED.""" + pytester.makepyfile( + """ + def x: + pass + """ + ) + res = pytester.runpytest() + assert res.ret == ExitCode.COLLECTION_ERROR + res.stdout.fnmatch_lines(["*! CollectionInterrupted: 1 error during collection !*"]) + + def test_exit_on_collection_with_maxfail_smaller_than_n_errors( pytester: Pytester, ) -> None: @@ -1087,7 +1100,7 @@ def test_exit_on_collection_with_maxfail_bigger_than_n_errors( pytester.makepyfile(**COLLECTION_ERROR_PY_FILES) res = pytester.runpytest("--maxfail=4") - assert res.ret == 2 + assert res.ret == ExitCode.COLLECTION_ERROR res.stdout.fnmatch_lines( [ "collected 2 items / 2 errors", @@ -1095,7 +1108,7 @@ def test_exit_on_collection_with_maxfail_bigger_than_n_errors( "*No module named *asdfa*", "*ERROR collecting test_03_import_error.py*", "*No module named *asdfa*", - "*! Interrupted: 2 errors during collection !*", + "*! CollectionInterrupted: 2 errors during collection !*", "*= 2 errors in *", ] ) @@ -1553,7 +1566,7 @@ def test_collect_sub_with_symlinks(use_pkg: bool, pytester: Pytester) -> None: def test_collector_respects_tbstyle(pytester: Pytester) -> None: p1 = pytester.makepyfile("assert 0") result = pytester.runpytest(p1, "--tb=native") - assert result.ret == ExitCode.INTERRUPTED + assert result.ret == ExitCode.COLLECTION_ERROR result.stdout.fnmatch_lines( [ "*_ ERROR collecting test_collector_respects_tbstyle.py _*", @@ -1561,7 +1574,7 @@ def test_collector_respects_tbstyle(pytester: Pytester) -> None: ' File "*/test_collector_respects_tbstyle.py", line 1, in ', " assert 0", "AssertionError: assert 0", - "*! Interrupted: 1 error during collection !*", + "*! CollectionInterrupted: 1 error during collection !*", "*= 1 error in *", ] ) @@ -1739,7 +1752,7 @@ def a(): return 4 ) result = pytester.runpytest() # Not INTERNAL_ERROR - assert result.ret == ExitCode.INTERRUPTED + assert result.ret == ExitCode.COLLECTION_ERROR def test_does_not_crash_on_recursive_symlink(pytester: Pytester) -> None: @@ -1927,7 +1940,7 @@ def test_with_yield(): """ ) result = pytester.runpytest() - assert result.ret == 2 + assert result.ret == ExitCode.COLLECTION_ERROR result.stdout.fnmatch_lines( ["*'yield' keyword is allowed in fixtures, but not in tests (test_with_yield)*"] ) @@ -2752,7 +2765,7 @@ def test1(x, y): result = pytester.runpytest() - assert result.ret == ExitCode.INTERRUPTED + assert result.ret == ExitCode.COLLECTION_ERROR expected_parametersets = ", ".join(str(list(p)) for p in x_y) expected_ids = ", ".join(f"{x}-{y}" for x, y in x_y) result.stdout.fnmatch_lines( @@ -2789,7 +2802,7 @@ def test1(x): result = pytester.runpytest() - assert result.ret == ExitCode.INTERRUPTED + assert result.ret == ExitCode.COLLECTION_ERROR result.stdout.fnmatch_lines( [ "Duplicate parametrization IDs detected*", diff --git a/testing/test_config.py b/testing/test_config.py index dad1653e299..56b4ec21b53 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -2168,7 +2168,7 @@ def pytest_internalerror(self): def test_no_terminal_discovery_error(pytester: Pytester) -> None: pytester.makepyfile("raise TypeError('oops!')") result = pytester.runpytest("-p", "no:terminal", "--collect-only") - assert result.ret == ExitCode.INTERRUPTED + assert result.ret == ExitCode.COLLECTION_ERROR def test_load_initial_conftest_last_ordering(_config_for_test): @@ -2962,7 +2962,7 @@ def test_func(): """ ) res = pytester.runpytest() - assert res.ret == 2 + assert res.ret == ExitCode.COLLECTION_ERROR msg = "Defining 'pytest_plugins' in a non-top-level conftest is no longer supported" res.stdout.fnmatch_lines([f"*{msg}*", f"*subdirectory{os.sep}conftest.py*"]) @@ -2984,7 +2984,7 @@ def test_pytest_plugins_in_non_top_level_conftest_unsupported_pyargs( args = ("--pyargs", "pkg") if use_pyargs else () res = pytester.runpytest(*args) - assert res.ret == (0 if use_pyargs else 2) + assert res.ret == (ExitCode.OK if use_pyargs else ExitCode.COLLECTION_ERROR) msg = "Defining 'pytest_plugins' in a non-top-level conftest is no longer supported" if use_pyargs: assert msg not in res.stdout.str() @@ -3013,7 +3013,7 @@ def test_func(): ) res = pytester.runpytest_subprocess() - assert res.ret == 2 + assert res.ret == ExitCode.COLLECTION_ERROR msg = "Defining 'pytest_plugins' in a non-top-level conftest is no longer supported" res.stdout.fnmatch_lines([f"*{msg}*", f"*subdirectory{os.sep}conftest.py*"]) diff --git a/testing/test_mark.py b/testing/test_mark.py index 706ae8120fd..b5b05525c7c 100644 --- a/testing/test_mark.py +++ b/testing/test_mark.py @@ -1207,7 +1207,7 @@ def test(): "*= 1 error in *", ] ) - assert result.ret == ExitCode.INTERRUPTED + assert result.ret == ExitCode.COLLECTION_ERROR def test_paramset_empty_no_idfunc( diff --git a/testing/test_python_path.py b/testing/test_python_path.py index f75bcb6bb57..464bfa97c7e 100644 --- a/testing/test_python_path.py +++ b/testing/test_python_path.py @@ -86,7 +86,7 @@ def test_module_not_found(pytester: Pytester, file_structure) -> None: """Without the pythonpath setting, the module should not be found.""" pytester.makefile(".ini", pytest="[pytest]\n") result = pytester.runpytest("test_foo.py") - assert result.ret == pytest.ExitCode.INTERRUPTED + assert result.ret == pytest.ExitCode.COLLECTION_ERROR result.assert_outcomes(errors=1) expected_error = "E ModuleNotFoundError: No module named 'foo'" result.stdout.fnmatch_lines([expected_error]) @@ -95,7 +95,7 @@ def test_module_not_found(pytester: Pytester, file_structure) -> None: def test_no_config_file(pytester: Pytester, file_structure) -> None: """If no configuration file, test should error.""" result = pytester.runpytest("test_foo.py") - assert result.ret == pytest.ExitCode.INTERRUPTED + assert result.ret == pytest.ExitCode.COLLECTION_ERROR result.assert_outcomes(errors=1) expected_error = "E ModuleNotFoundError: No module named 'foo'" result.stdout.fnmatch_lines([expected_error]) diff --git a/testing/test_terminal.py b/testing/test_terminal.py index 30208084ab2..b06238518bc 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -578,7 +578,7 @@ def test_method(self): def test_collectonly_error(self, pytester: Pytester) -> None: p = pytester.makepyfile("import Errlkjqweqwe") result = pytester.runpytest("--collect-only", p) - assert result.ret == 2 + assert result.ret == ExitCode.COLLECTION_ERROR result.stdout.fnmatch_lines( textwrap.dedent( """\ @@ -856,7 +856,7 @@ def test_bar(): "* ERROR collecting test_selected_count_error.py *", ] ) - assert result.ret == ExitCode.INTERRUPTED + assert result.ret == ExitCode.COLLECTION_ERROR def test_no_skip_summary_if_failure(self, pytester: Pytester) -> None: pytester.makepyfile( @@ -2792,7 +2792,7 @@ def test_collecterror(pytester: Pytester) -> None: "E SyntaxError: *", "*= short test summary info =*", "ERROR test_collecterror.py", - "*! Interrupted: 1 error during collection !*", + "*! CollectionInterrupted: 1 error during collection !*", "*= 1 error in *", ] ) From 733558d97f711d4e558124d91f77ea7f7329a2b3 Mon Sep 17 00:00:00 2001 From: Dane Parin Date: Sun, 13 Sep 2026 20:17:02 +0700 Subject: [PATCH 2/5] Update remaining tests for collection error exit code Co-authored-by: Claude Sonnet 5 --- testing/acceptance_test.py | 4 +++- testing/python/collect.py | 6 +++--- testing/python/metafunc.py | 4 ++-- testing/test_doctest.py | 2 +- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/testing/acceptance_test.py b/testing/acceptance_test.py index bf600f88d2b..3a40b7c83dc 100644 --- a/testing/acceptance_test.py +++ b/testing/acceptance_test.py @@ -1029,7 +1029,9 @@ def test_with_failing_collection(self, pytester: Pytester, mock_timing) -> None: result = pytester.runpytest_inprocess("--durations=2", "-k test_1") assert result.ret == ExitCode.COLLECTION_ERROR - result.stdout.fnmatch_lines(["*Interrupted: 1 error during collection*"]) + result.stdout.fnmatch_lines( + ["*CollectionInterrupted: 1 error during collection*"] + ) # Collection errors abort test execution, therefore no duration is # output result.stdout.no_fnmatch_line("*duration*") diff --git a/testing/python/collect.py b/testing/python/collect.py index dddd15b8f67..042f5a6ecf3 100644 --- a/testing/python/collect.py +++ b/testing/python/collect.py @@ -124,7 +124,7 @@ def test_show_traceback_import_error( "*cannot import name *NOT_AVAILABLE*", ] ) - assert result.ret == 2 + assert result.ret == ExitCode.COLLECTION_ERROR stdout = result.stdout.str() if verbose == 2: @@ -145,7 +145,7 @@ def test_show_traceback_import_error_unicode(self, pytester: Pytester) -> None: "*raise ImportError*Something bad happened*", ] ) - assert result.ret == 2 + assert result.ret == ExitCode.COLLECTION_ERROR class TestClass: @@ -1495,7 +1495,7 @@ def test_collect_error_with_fulltrace(pytester: Pytester) -> None: "E assert 0", "", "test_collect_error_with_fulltrace.py:1: AssertionError", - "*! Interrupted: 1 error during collection !*", + "*! CollectionInterrupted: 1 error during collection !*", ] ) diff --git a/testing/python/metafunc.py b/testing/python/metafunc.py index 685ce506663..11e416df15a 100644 --- a/testing/python/metafunc.py +++ b/testing/python/metafunc.py @@ -1754,7 +1754,7 @@ def test_foo(x): "test_parametrize_misspelling.py:3: in ", ' @pytest.mark.parametrise("x", range(2))', "E Failed: Unknown 'parametrise' mark, did you mean 'parametrize'?", - "*! Interrupted: 1 error during collection !*", + "*! CollectionInterrupted: 1 error during collection !*", "*= no tests collected, 1 error in *", ] ) @@ -2476,7 +2476,7 @@ def test_func(foo, bar): "*_ ERROR collecting test_multiple_hidden_param_is_forbidden.py _*", "E Failed: In test_multiple_hidden_param_is_forbidden.py::test_func: multiple instances of " "HIDDEN_PARAM cannot be used in the same parametrize call, because the tests names need to be unique.", - "*! Interrupted: 1 error during collection !*", + "*! CollectionInterrupted: 1 error during collection !*", "*= no tests collected, 1 error in *", ] ) diff --git a/testing/test_doctest.py b/testing/test_doctest.py index a2b91bc4096..26dd8316207 100644 --- a/testing/test_doctest.py +++ b/testing/test_doctest.py @@ -465,7 +465,7 @@ def test_doctest_unex_importerror_with_module(self, pytester: Pytester): [ "*ERROR collecting hello.py*", "*ModuleNotFoundError: No module named *asdals*", - "*Interrupted: 1 error during collection*", + "*CollectionInterrupted: 1 error during collection*", ] ) From acd0dc2035cbcba5352e88bc5305192d8a6ec127 Mon Sep 17 00:00:00 2001 From: Dane Parin Date: Sun, 13 Sep 2026 20:40:20 +0700 Subject: [PATCH 3/5] Fix RTD CI --- src/_pytest/main.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/_pytest/main.py b/src/_pytest/main.py index acc46f6e02f..6ff9d922f72 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -401,7 +401,7 @@ def pytest_collection(session: Session) -> None: def pytest_runtestloop(session: Session) -> bool: if session.testsfailed and not session.config.option.continue_on_collection_errors: - raise session.CollectionInterrupted( + raise CollectionInterrupted( f"{session.testsfailed} error{'s' if session.testsfailed != 1 else ''} during collection" ) @@ -608,7 +608,6 @@ class Session(nodes.Collector): """ Interrupted = Interrupted - CollectionInterrupted = CollectionInterrupted Failed = Failed # Set on the session by runner.pytest_sessionstart. _setupstate: SetupState From f7556ed2488c3299cb084a311a4abb4fb7764642 Mon Sep 17 00:00:00 2001 From: Dane Parin Date: Mon, 14 Sep 2026 20:33:59 +0700 Subject: [PATCH 4/5] Apply batched suggestions from code review Co-authored-by: Ran Benita --- src/_pytest/main.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/_pytest/main.py b/src/_pytest/main.py index 6ff9d922f72..c6871e13598 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -341,7 +341,7 @@ def wrap_session( exitstatus: int | ExitCode = ExitCode.INTERRUPTED if isinstance(excinfo.value, CollectionInterrupted): exitstatus = ExitCode.COLLECTION_ERROR - if isinstance(excinfo.value, exit.Exception): + elif isinstance(excinfo.value, exit.Exception): if excinfo.value.returncode is not None: exitstatus = excinfo.value.returncode if initstate < 2: @@ -525,8 +525,7 @@ class Interrupted(KeyboardInterrupt): class CollectionInterrupted(Interrupted): """Signals that the test run was interrupted by collection errors. - Subclasses ``Interrupted`` for compatibility; ``wrap_session`` maps it - to ``ExitCode.COLLECTION_ERROR``. + Subclasses ``Interrupted`` for compatibility. """ __module__ = "builtins" # Match Interrupted. From f3fb7bae82a4a6fa1f3360b9c59c7aa0dbc450bb Mon Sep 17 00:00:00 2001 From: Dane Parin Date: Mon, 14 Sep 2026 21:30:37 +0700 Subject: [PATCH 5/5] Document unconfigure fallback --- src/_pytest/terminal.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index e6a697e0686..32249ce6c63 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -1032,6 +1032,10 @@ def pytest_keyboard_interrupt(self, excinfo: ExceptionInfo[BaseException]) -> No self._keyboardinterrupt_memo = excinfo.getrepr(funcargs=True) def pytest_unconfigure(self) -> None: + # Reports as a fallback because wrap_session skips sessionfinish + # for interrupts raised before the session starts (initstate < 2). + # sessionfinish clears the memo after reporting, so this cannot + # double-print. if self._keyboardinterrupt_memo is not None: self._report_keyboardinterrupt()