Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog/4603.breaking.rst
Original file line number Diff line number Diff line change
@@ -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).
3 changes: 2 additions & 1 deletion doc/en/reference/exit-codes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:

Expand Down
2 changes: 2 additions & 0 deletions src/_pytest/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
15 changes: 13 additions & 2 deletions src/_pytest/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,9 @@ def wrap_session(
except (KeyboardInterrupt, exit.Exception):
excinfo = _pytest._code.ExceptionInfo.from_current()
exitstatus: int | ExitCode = ExitCode.INTERRUPTED
if isinstance(excinfo.value, exit.Exception):
if isinstance(excinfo.value, CollectionInterrupted):
exitstatus = ExitCode.COLLECTION_ERROR
elif isinstance(excinfo.value, exit.Exception):
if excinfo.value.returncode is not None:
exitstatus = excinfo.value.returncode
if initstate < 2:
Expand Down Expand Up @@ -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:
Comment thread
SemTiOne marked this conversation as resolved.
raise session.Interrupted(
raise CollectionInterrupted(
f"{session.testsfailed} error{'s' if session.testsfailed != 1 else ''} during collection"
)

Expand Down Expand Up @@ -520,6 +522,15 @@ class Interrupted(KeyboardInterrupt):
__module__ = "builtins" # For py3.


class CollectionInterrupted(Interrupted):
"""Signals that the test run was interrupted by collection errors.

Subclasses ``Interrupted`` for compatibility.
"""

__module__ = "builtins" # Match Interrupted.


class Failed(Exception):
"""Signals a stop as failed test run."""

Expand Down
7 changes: 6 additions & 1 deletion src/_pytest/terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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):
Comment thread
SemTiOne marked this conversation as resolved.
self._report_keyboardinterrupt()
self._keyboardinterrupt_memo = None
elif session.shouldstop:
Expand Down Expand Up @@ -1031,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()

Expand Down
12 changes: 7 additions & 5 deletions testing/acceptance_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("")
Expand Down Expand Up @@ -1027,9 +1027,11 @@ 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*"])
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*")
Expand Down Expand Up @@ -1726,13 +1728,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 * =*",
]
)
Expand Down
6 changes: 3 additions & 3 deletions testing/python/collect.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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 !*",
Comment thread
SemTiOne marked this conversation as resolved.
]
)

Expand Down
4 changes: 2 additions & 2 deletions testing/python/metafunc.py
Original file line number Diff line number Diff line change
Expand Up @@ -1754,7 +1754,7 @@ def test_foo(x):
"test_parametrize_misspelling.py:3: in <module>",
' @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 *",
]
)
Expand Down Expand Up @@ -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 *",
]
)
Expand Down
31 changes: 22 additions & 9 deletions testing/test_collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
[
Expand All @@ -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:
Expand Down Expand Up @@ -1087,15 +1100,15 @@ 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",
"*ERROR collecting test_02_import_error.py*",
"*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 *",
]
)
Expand Down Expand Up @@ -1553,15 +1566,15 @@ 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 _*",
"Traceback (most recent call last):",
' File "*/test_collector_respects_tbstyle.py", line 1, in <module>',
" assert 0",
"AssertionError: assert 0",
"*! Interrupted: 1 error during collection !*",
"*! CollectionInterrupted: 1 error during collection !*",
"*= 1 error in *",
]
)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)*"]
)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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*",
Expand Down
8 changes: 4 additions & 4 deletions testing/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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*"])

Expand All @@ -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()
Expand Down Expand Up @@ -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*"])

Expand Down
2 changes: 1 addition & 1 deletion testing/test_doctest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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*",
]
)

Expand Down
2 changes: 1 addition & 1 deletion testing/test_mark.py
Original file line number Diff line number Diff line change
Expand Up @@ -1231,7 +1231,7 @@ def test():
"*= 1 error in *",
]
)
assert result.ret == ExitCode.INTERRUPTED
assert result.ret == ExitCode.COLLECTION_ERROR


def test_paramset_empty_no_idfunc(
Expand Down
4 changes: 2 additions & 2 deletions testing/test_python_path.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand All @@ -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])
Expand Down
6 changes: 3 additions & 3 deletions testing/test_terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
"""\
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 *",
]
)
Expand Down