diff --git a/pytest_robotframework/__init__.py b/pytest_robotframework/__init__.py index 91dc2ac9..9568753c 100644 --- a/pytest_robotframework/__init__.py +++ b/pytest_robotframework/__init__.py @@ -655,6 +655,7 @@ def __init__( log_pass: bool | None = None, description: str | None = None, fail_message: str | None = None, + verbosity: int | None = None, ) -> None: super().__init__() self.log_pass = log_pass @@ -699,6 +700,27 @@ def __init__( `AssertionError` message if the assertion fails. equivalent to a normal `assert` statement's second argument""" + self.verbosiy = verbosity + """override the verbosity for this assert statement. by default, `assert` statements will + not show the full diff unless pytest is run with `-vv` (aka. `--verbosity=2`). the values + correspond to the following pytest arguments: + + - `--verbosity=-1` (aka. `-q` / `--quiet`) + - `--verbosity=0` (the default verbosity) + - `--verbosity=1` (aka. `-v` / `--verbose`) + - `--verbosity=2` (aka. `-vv`) + + values higher than 2 are allowed but have no effect in pytest itself, however some plugins + might make use of higher verbosity. + + `None` means the verbosity will be unchanged from whatever level pytest was run with + + example: + ------- + due to [this issue](https://github.com/pytest-dev/pytest/issues/12009#issuecomment-2201710676), + this argument has no effect when run from CI. + """ + @override def __repr__(self) -> str: """make the custom fail message appear in the call to `AssertionError`""" diff --git a/pytest_robotframework/_internal/pytest/plugin.py b/pytest_robotframework/_internal/pytest/plugin.py index 55b99b08..bfae6fa8 100644 --- a/pytest_robotframework/_internal/pytest/plugin.py +++ b/pytest_robotframework/_internal/pytest/plugin.py @@ -37,7 +37,7 @@ from robot.result.resultbuilder import ExecutionResult # pyright:ignore[reportUnknownVariableType] from robot.run import RobotFramework, RobotSettings from robot.utils.error import ErrorDetails -from typing_extensions import TYPE_CHECKING, Callable, Generator, Mapping, cast +from typing_extensions import TYPE_CHECKING, Callable, Generator, Mapping, TypeVar, cast from pytest_robotframework import ( AssertOptions, @@ -87,6 +87,7 @@ from types import TracebackType from _pytest.terminal import TerminalReporter + from basedtyping import Fn from pluggy import PluginManager from pytest import CallInfo, Item, Parser, Session @@ -94,7 +95,14 @@ _explanation_key = StashKey[str]() -def _call_assertion_hook( +def _assert_rewrite_context(fn: Fn) -> Fn: + """make the function available within the context of a rewritten assert statement""" + setattr(rewrite, fn.__name__, fn) + return fn + + +@_assert_rewrite_context +def _call_assertion_hook( # pyright:ignore[reportUnusedFunction] expression: str, fail_message: object, line_number: int, @@ -120,9 +128,29 @@ def _call_assertion_hook( ) -# we aren't patching an existing function here but instead adding a new one to the rewrite module, -# since the rewritten assert statement needs to call it, and this is the easist way to do that -rewrite._call_assertion_hook = _call_assertion_hook # pyright:ignore[reportAttributeAccessIssue] +_original_verbosity = StashKey[int]() + + +@_assert_rewrite_context +def _change_verbosity(fail_message: object): # pyright:ignore[reportUnusedFunction] + if not isinstance(fail_message, AssertOptions) or fail_message.verbosiy is None: + return + item = current_item() + if not item: + return + item.stash[_original_verbosity] = item.config.get_verbosity(Config.VERBOSITY_ASSERTIONS) + _set_verbosity(item.config, fail_message.verbosiy) + + +def _change_verbosity_back(): + item = current_item() + if not item: + return + original_verbosity = item.stash.get(_original_verbosity, None) + if original_verbosity is None: + return + _set_verbosity(item.config, original_verbosity) + del item.stash[_original_verbosity] @patch_method(AssertionRewriter) @@ -146,6 +174,9 @@ def visit_Assert( # noqa: N802 except StopIteration: raise InternalError("failed to find if statement for assertion rewriting") from None expression = _get_assertion_exprs(self.source)[assert_.lineno] + # call the verbosity override check right before the main test + result.insert(0, Expr(self.helper("_change_verbosity", assert_msg))) + # rice the fail statements: raise_statement = cast(Raise, main_test.body.pop()) if not raise_statement.exc: @@ -184,7 +215,9 @@ def visit_Assert( # noqa: N802 return result -HookWrapperResult = Generator[None, object, None] +_T_default_object = TypeVar("_T_default_object", default=object) + +HookWrapperResult = Generator[None, _T_default_object, _T_default_object] def _xdist_temp_dir(session: Session) -> Path: @@ -374,6 +407,10 @@ def _robot_run_tests(session: Session, xdist_item: Item | None = None): _ = _run_robot(session, robot_options) +def _set_verbosity(config: Config, verbosity: int): + config._inicache["verbosity_assertions"] = verbosity # pyright:ignore[reportPrivateUsage] + + def pytest_addhooks(pluginmanager: PluginManager): pluginmanager.add_hookspecs(hooks) @@ -534,6 +571,8 @@ def pytest_robot_assertion( if not assertion_error and fail_message.log_pass is not None: show_in_log = fail_message.log_pass description = fail_message.description + if fail_message.verbosiy is not None: + _change_verbosity_back() else: description = None if show_in_log is None: diff --git a/scripts/clear_pycache.py b/scripts/clear_pycache.py index 839c74b3..6702ced9 100644 --- a/scripts/clear_pycache.py +++ b/scripts/clear_pycache.py @@ -1,9 +1,7 @@ from __future__ import annotations from pathlib import Path - -for path in Path().rglob("*.py[co]"): - path.unlink() +from shutil import rmtree for path in Path().rglob("__pycache__"): - path.rmdir() + rmtree(path) diff --git a/tests/fixtures/test_python/test_assert_verbosity_overrides.py b/tests/fixtures/test_python/test_assert_verbosity_overrides.py new file mode 100644 index 00000000..7ea6bf19 --- /dev/null +++ b/tests/fixtures/test_python/test_assert_verbosity_overrides.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from pytest import fixture + +from pytest_robotframework import AssertOptions + +# needed for fixtures +# pylint:disable=redefined-outer-name + + +@fixture +def big_value(): + return list(range(6)) + + +def test_default(big_value: list[int]): + assert big_value == [] + + +def test_verbose(big_value: list[int]): + assert big_value == [], AssertOptions(verbosity=2) + + +def test_set_back_to_default(big_value: list[int]): + assert big_value == [1] diff --git a/tests/test_python.py b/tests/test_python.py index 4e307fa1..1b6594d6 100644 --- a/tests/test_python.py +++ b/tests/test_python.py @@ -959,3 +959,15 @@ def test_maxfail(pr: PytestRobotTester): "--maxfail doesn't work with xdist. https://github.com/pytest-dev/pytest-xdist/issues/868" ) pr.run_and_assert_result("--maxfail=2", failed=2, skipped=1) + + +def test_assert_verbosity_overrides(pr: PytestRobotTester, monkeypatch: MonkeyPatch): + # https://github.com/pytest-dev/pytest/issues/12009#issuecomment-2201710676 + monkeypatch.delenv("CI", raising=False) + pr.run_and_assert_result("-o", "enable_assertion_pass_hook=true", failed=3) + xml = output_xml() + assert xml.xpath("//test[@name='test_default']//msg[contains(., 'Use -v to get more diff')]") + assert xml.xpath("//test[@name='test_verbose']//msg[contains(., 'Full diff:')]") + assert xml.xpath( + "//test[@name='test_set_back_to_default']//msg[contains(., 'Use -v to get more diff')]" + )