diff --git a/pytest_robotframework/_internal/robot/listeners_and_suite_visitors.py b/pytest_robotframework/_internal/robot/listeners_and_suite_visitors.py index 8cea4ef7..c507fe4b 100644 --- a/pytest_robotframework/_internal/robot/listeners_and_suite_visitors.py +++ b/pytest_robotframework/_internal/robot/listeners_and_suite_visitors.py @@ -8,7 +8,17 @@ from functools import wraps from inspect import getdoc from re import sub -from typing import TYPE_CHECKING, Callable, Final, Literal, Optional, cast +from types import MethodType +from typing import ( + TYPE_CHECKING, + Callable, + Final, + Literal, + Optional, + Tuple, + TypeVar, + cast, +) from _pytest import runner from _pytest.python import PyobjMixin @@ -24,7 +34,7 @@ from robot.running.librarykeywordrunner import LibraryKeywordRunner from robot.running.model import Body from robot.utils.error import ErrorDetails -from typing_extensions import override +from typing_extensions import Concatenate, override from pytest_robotframework import ( _get_status_reporter_failures, # pyright:ignore[reportPrivateUsage] @@ -583,6 +593,19 @@ def wrapped(*args: P.args, **kwargs: P.kwargs) -> T: return wrapped +_R = TypeVar("_R") + + +def _bound_method(instance: T, fn: Callable[Concatenate[T, P], _R]) -> Callable[P, _R]: + """if the keyword we're patching is on a class library, we need to re-bound the method to the + instance""" + + def inner(*args: P.args, **kwargs: P.kwargs) -> _R: + return fn(instance, *args, **kwargs) + + return inner + + # the methods used in this listener were added in robot 7. in robot 6 we do this by patching # `LibraryKeywordRunner._runner_for` instead if robot_6: @@ -600,13 +623,20 @@ def _runner_for( # pyright:ignore[reportUnusedFunction] # noqa: PLR0917 named: dict[str, object], ) -> Function: """use the original function instead of the `@keyword` wrapped one""" - handler = _hide_already_raised_exception_from_robot_log( - cast(Function, getattr(handler, _keyword_original_function_attr, handler)) + original_function: Function | None = getattr(handler, _keyword_original_function_attr, None) + wrapped_function = _hide_already_raised_exception_from_robot_log( + cast( + Function, + _bound_method(handler.__self__, original_function) + if original_function is not None and isinstance(handler, MethodType) + else (original_function or handler), + ) ) - return old_method(self, context, handler, positional, named) + return old_method(self, context, wrapped_function, positional, named) else: from robot.running.librarykeyword import StaticKeyword + from robot.running.testlibraries import ClassLibrary @catch_errors class KeywordUnwrapper(ListenerV3): @@ -623,14 +653,19 @@ def start_library_keyword( ): if not isinstance(implementation, StaticKeyword): return - unwrapped_method: Function | None = getattr( + original_function: Function | None = getattr( implementation.method, _keyword_original_function_attr, None ) - if unwrapped_method is None: + + if original_function is None: return setattr( implementation.owner.instance, # pyright:ignore[reportAny] implementation.method_name, - _hide_already_raised_exception_from_robot_log(unwrapped_method), + _hide_already_raised_exception_from_robot_log( + _bound_method(implementation.owner.instance, original_function) # pyright:ignore[reportAny] + if isinstance(implementation.owner, ClassLibrary) + else original_function + ), ) diff --git a/tests/fixtures/test_robot/test_keyword_decorator_class_library/ClassLibrary.py b/tests/fixtures/test_robot/test_keyword_decorator_class_library/ClassLibrary.py new file mode 100644 index 00000000..2f5511c4 --- /dev/null +++ b/tests/fixtures/test_robot/test_keyword_decorator_class_library/ClassLibrary.py @@ -0,0 +1,16 @@ +# noqa: N999 +# robot class libraries need to have the same name as the module +from __future__ import annotations + +from robot.api import logger + +from pytest_robotframework import keyword + + +class ClassLibrary: + def __init__(self): + pass + + @keyword + def foo(self): # noqa: PLR6301 + logger.info("hi") diff --git a/tests/fixtures/test_robot/test_keyword_decorator_class_library/__init__.py b/tests/fixtures/test_robot/test_keyword_decorator_class_library/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/fixtures/test_robot/test_keyword_decorator_class_library/asdf.robot b/tests/fixtures/test_robot/test_keyword_decorator_class_library/asdf.robot new file mode 100644 index 00000000..86deff6c --- /dev/null +++ b/tests/fixtures/test_robot/test_keyword_decorator_class_library/asdf.robot @@ -0,0 +1,7 @@ +*** Settings *** +Library ./ClassLibrary.py + + +*** Test Cases *** +Asdf + Foo diff --git a/tests/test_robot.py b/tests/test_robot.py index d40f5e37..f1981b44 100644 --- a/tests/test_robot.py +++ b/tests/test_robot.py @@ -5,6 +5,7 @@ from pytest import ExitCode, Item, Mark +from pytest_robotframework._internal.robot.utils import robot_6 from tests.conftest import PytestRobotTester, assert_robot_total_stats, output_xml, xpath if TYPE_CHECKING: @@ -326,3 +327,12 @@ def test_empty_setup_or_teardown(pr: PytestRobotTester): ) assert not xml.xpath("//test[@name='Disable setup']/kw[@name='Setup']/kw[@name='Log']") assert not xml.xpath("//test[@name='Disable teardown']/kw[@name='Teardown']/kw[@name='Log']") + + +def test_keyword_decorator_class_library(pr: PytestRobotTester): + pr.run_and_assert_result("--robot-loglevel", "DEBUG:INFO", passed=1) + pr.assert_log_file_exists() + assert xpath( + output_xml(), + f"//kw[@name='Foo' and @{'library' if robot_6 else 'owner'}='ClassLibrary']/msg[.='hi']", + )