diff --git a/docs/changelog/3271.feature.rst b/docs/changelog/3271.feature.rst new file mode 100644 index 000000000..432a36fb7 --- /dev/null +++ b/docs/changelog/3271.feature.rst @@ -0,0 +1 @@ +Add support for inverting exit code success criteria using bang (!) diff --git a/docs/faq.rst b/docs/faq.rst index f7727444d..1a00f724c 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -187,12 +187,22 @@ a given command add a ``-`` prefix to that line (similar syntax to how the GNU ` .. code-block:: ini - [testenv] commands = - python -c 'import sys; sys.exit(1)' python --version +You can also choose to provide a ``!`` prefix instead to purposely invert the exit code, making the line fail if the +command returned exit code 0. Any other exit code is considered a success. + +.. code-block:: ini + + [testenv] + commands = + ! python -c 'import sys; sys.exit(1)' + python --version + + Customizing virtual environment creation ---------------------------------------- diff --git a/src/tox/config/types.py b/src/tox/config/types.py index 37ac9fa94..8086ea66f 100644 --- a/src/tox/config/types.py +++ b/src/tox/config/types.py @@ -16,15 +16,20 @@ def __init__(self, args: list[str]) -> None: :param args: the command line arguments (first value can be ``-`` to indicate ignore the exit code) """ self.ignore_exit_code: bool = args[0] == "-" #: a flag indicating if the exit code should be ignored - self.args: list[str] = args[1:] if self.ignore_exit_code else args #: the command line arguments + self.invert_exit_code: bool = args[0] == "!" #: a flag for flipped exit code (non-zero = success, 0 = error) + self.args: list[str] = ( + args[1:] if self.ignore_exit_code or self.invert_exit_code else args + ) #: the command line arguments def __repr__(self) -> str: - return f"{type(self).__name__}(args={(['-'] if self.ignore_exit_code else []) + self.args!r})" + args = (["-"] if self.ignore_exit_code else ["!"] if self.invert_exit_code else []) + self.args + return f"{type(self).__name__}(args={args!r})" def __eq__(self, other: object) -> bool: - return type(self) == type(other) and (self.args, self.ignore_exit_code) == ( + return type(self) == type(other) and (self.args, self.ignore_exit_code, self.invert_exit_code) == ( other.args, # type: ignore[attr-defined] other.ignore_exit_code, # type: ignore[attr-defined] + other.invert_exit_code, # type: ignore[attr-defined] ) def __ne__(self, other: object) -> bool: diff --git a/src/tox/execute/api.py b/src/tox/execute/api.py index 09dc8b2e9..c6fe3c20f 100644 --- a/src/tox/execute/api.py +++ b/src/tox/execute/api.py @@ -252,6 +252,12 @@ def assert_success(self) -> None: self._assert_fail() self.log_run_done(logging.INFO) + def assert_failure(self) -> None: + """Assert that the execution failed.""" + if self.exit_code is not None and self.exit_code == self.OK: + self._assert_fail() + self.log_run_done(logging.INFO) + def _assert_fail(self) -> NoReturn: if self.show_on_standard is False: if self.out: diff --git a/src/tox/session/cmd/run/single.py b/src/tox/session/cmd/run/single.py index 77842a3a3..ccb29e6ae 100644 --- a/src/tox/session/cmd/run/single.py +++ b/src/tox/session/cmd/run/single.py @@ -112,7 +112,10 @@ def run_command_set( ) outcomes.append(current_outcome) try: - current_outcome.assert_success() + if cmd.invert_exit_code: + current_outcome.assert_failure() + else: + current_outcome.assert_success() except SystemExit as exception: if cmd.ignore_exit_code: logging.warning("command failed but is marked ignore outcome so handling it as success") diff --git a/tests/config/test_types.py b/tests/config/test_types.py index e859ea18b..662943f19 100644 --- a/tests/config/test_types.py +++ b/tests/config/test_types.py @@ -6,15 +6,24 @@ def tests_command_repr() -> None: cmd = Command(["python", "-m", "pip", "list"]) assert repr(cmd) == "Command(args=['python', '-m', 'pip', 'list'])" + assert cmd.invert_exit_code is False assert cmd.ignore_exit_code is False def tests_command_repr_ignore() -> None: cmd = Command(["-", "python", "-m", "pip", "list"]) assert repr(cmd) == "Command(args=['-', 'python', '-m', 'pip', 'list'])" + assert cmd.invert_exit_code is False assert cmd.ignore_exit_code is True +def tests_command_repr_invert() -> None: + cmd = Command(["!", "python", "-m", "pip", "list"]) + assert repr(cmd) == "Command(args=['!', 'python', '-m', 'pip', 'list'])" + assert cmd.invert_exit_code is True + assert cmd.ignore_exit_code is False + + def tests_command_eq() -> None: cmd_1 = Command(["python", "-m", "pip", "list"]) cmd_2 = Command(["python", "-m", "pip", "list"]) @@ -24,7 +33,8 @@ def tests_command_eq() -> None: def tests_command_ne() -> None: cmd_1 = Command(["python", "-m", "pip", "list"]) cmd_2 = Command(["-", "python", "-m", "pip", "list"]) - assert cmd_1 != cmd_2 + cmd_3 = Command(["!", "python", "-m", "pip", "list"]) + assert cmd_1 != cmd_2 != cmd_3 def tests_env_list_repr() -> None: