diff --git a/allure-python-commons/src/allure_commons/_allure.py b/allure-python-commons/src/allure_commons/_allure.py index 607e1cb8..85ef583d 100644 --- a/allure-python-commons/src/allure_commons/_allure.py +++ b/allure-python-commons/src/allure_commons/_allure.py @@ -1,3 +1,5 @@ +import inspect +import string from functools import wraps from typing import Any, Callable, TypeVar, Union, overload @@ -10,6 +12,25 @@ _TFunc = TypeVar("_TFunc", bound=Callable[..., Any]) +class _StepTitleFormatter(string.Formatter): + """ + Custom string formatter for step titles. + + Defers represent() conversion until format_field, so attribute access + placeholders such as {data.name} can traverse to the attribute on the + original object before its repr-style representation is produced. + + Existing behavior is preserved for plain positional and keyword + placeholders: leaf values are still wrapped via represent(). + """ + + def format_field(self, value, format_spec): + return format(represent(value), format_spec) + + +_step_title_formatter = _StepTitleFormatter() + + def safely(result): if result: return result[0] @@ -198,8 +219,27 @@ def __call__(self, func: _TFunc) -> _TFunc: def impl(*a, **kw): __tracebackhide__ = True params = func_parameters(func, *a, **kw) - args = list(map(lambda x: represent(x), a)) - with StepContext(self.title.format(*args, **params), params): + # Build a kwargs mapping with the original (non-stringified) + # values so the formatter can traverse attributes on objects + # passed as arguments, e.g. {data.name} on a dataclass. + # Includes defaults to match func_parameters() behaviour. + try: + arg_spec = inspect.getfullargspec(func) + raw_kwargs = {} + if arg_spec.defaults: + defaults_map = dict(zip( + arg_spec.args[-len(arg_spec.defaults):], + arg_spec.defaults, + )) + raw_kwargs.update(defaults_map) + raw_kwargs.update(dict(zip(arg_spec.args, a))) + raw_kwargs.update(kw) + if arg_spec.args and arg_spec.args[0] in ("self", "cls"): + raw_kwargs.pop(arg_spec.args[0], None) + except TypeError: + raw_kwargs = dict(kw) + title = _step_title_formatter.format(self.title, *a, **raw_kwargs) + with StepContext(title, params): return func(*a, **kw) return impl # type: ignore diff --git a/tests/allure_pytest/acceptance/step/step_attribute_placeholder_test.py b/tests/allure_pytest/acceptance/step/step_attribute_placeholder_test.py new file mode 100644 index 00000000..518f3976 --- /dev/null +++ b/tests/allure_pytest/acceptance/step/step_attribute_placeholder_test.py @@ -0,0 +1,98 @@ +""" +Regression tests for object attribute access in step title placeholders. + +See https://github.com/allure-framework/allure-python/issues/896. +""" + +from hamcrest import assert_that +from tests.allure_pytest.pytest_runner import AllurePytestRunner + +from allure_commons_test.report import has_test_case +from allure_commons_test.result import has_step + + +def test_step_with_dataclass_attribute_placeholder(allure_pytest_runner: AllurePytestRunner): + """ + >>> import allure + >>> from dataclasses import dataclass + + >>> @dataclass + ... class Item: + ... name: str + + >>> @allure.step("Process item: {item.name}") + ... def process(item): + ... pass + + >>> def test_dataclass_attribute_access(): + ... process(Item(name="Widget")) + """ + + allure_results = allure_pytest_runner.run_docstring() + + assert_that( + allure_results, + has_test_case( + "test_dataclass_attribute_access", + has_step("Process item: 'Widget'"), + ), + ) + + +def test_step_with_object_attribute_placeholder(allure_pytest_runner: AllurePytestRunner): + """ + >>> import allure + + >>> class Order: + ... def __init__(self, status): + ... self.status = status + + >>> @allure.step("Order is {order.status}") + ... def check(order): + ... pass + + >>> def test_object_attribute_access(): + ... check(Order("delivered")) + """ + + allure_results = allure_pytest_runner.run_docstring() + + assert_that( + allure_results, + has_test_case( + "test_object_attribute_access", + has_step("Order is 'delivered'"), + ), + ) + + +def test_step_with_nested_attribute_placeholder(allure_pytest_runner: AllurePytestRunner): + """ + >>> import allure + >>> from dataclasses import dataclass + + >>> @dataclass + ... class Address: + ... city: str + + >>> @dataclass + ... class User: + ... address: Address + + >>> @allure.step("User from {user.address.city}") + ... def greet(user): + ... pass + + >>> def test_nested_attribute_access(): + ... greet(User(address=Address(city="Brighton"))) + """ + + allure_results = allure_pytest_runner.run_docstring() + + assert_that( + allure_results, + has_test_case( + "test_nested_attribute_access", + has_step("User from 'Brighton'"), + ), + )