From 8a9709830503db3268f58588229f69b267c36857 Mon Sep 17 00:00:00 2001 From: detachhead Date: Mon, 7 Oct 2024 19:04:25 +1000 Subject: [PATCH] fix crash with keyword decorator on class libraries --- .../robot/listeners_and_suite_visitors.py | 52 ++++++++++++++++--- .../ClassLibrary.py | 16 ++++++ .../__init__.py | 0 .../asdf.robot | 7 +++ tests/test_robot.py | 6 +++ 5 files changed, 73 insertions(+), 8 deletions(-) create mode 100644 tests/fixtures/test_robot/test_keyword_decorator_class_library/ClassLibrary.py create mode 100644 tests/fixtures/test_robot/test_keyword_decorator_class_library/__init__.py create mode 100644 tests/fixtures/test_robot/test_keyword_decorator_class_library/asdf.robot diff --git a/pytest_robotframework/_internal/robot/listeners_and_suite_visitors.py b/pytest_robotframework/_internal/robot/listeners_and_suite_visitors.py index c2b4b300..0e3331f9 100644 --- a/pytest_robotframework/_internal/robot/listeners_and_suite_visitors.py +++ b/pytest_robotframework/_internal/robot/listeners_and_suite_visitors.py @@ -7,7 +7,18 @@ from functools import wraps from inspect import getdoc from re import sub -from typing import TYPE_CHECKING, Callable, Final, Generator, Literal, Optional, Tuple, cast +from types import MethodType +from typing import ( + TYPE_CHECKING, + Callable, + Final, + Generator, + Literal, + Optional, + Tuple, + TypeVar, + cast, +) from _pytest import runner from _pytest.python import PyobjMixin @@ -23,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] @@ -582,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: @@ -599,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): @@ -622,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 0e2cdf7e..3495b51c 100644 --- a/tests/test_robot.py +++ b/tests/test_robot.py @@ -326,3 +326,9 @@ 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(), "//kw[@name='Foo' and @owner='ClassLibrary']/msg[.='hi']")