From 67a60e9b95a72e4cee4d9f016a388d323e5a424f Mon Sep 17 00:00:00 2001 From: Mikhail Golikov Date: Sun, 3 May 2026 23:44:15 +0100 Subject: [PATCH] fix(step): support attribute access in step title placeholders Closes #896. @allure.step("Process: {data.name}") used to raise AttributeError when data was an object such as a dataclass. The reason was that func_parameters() called represent() on every parameter value before str.format() ran, so by the time the title was being formatted all arguments were already strings and {data.name} tried attribute access on a string. Fix: introduce _StepTitleFormatter, a subclass of string.Formatter, that applies represent() in format_field instead of at parameter binding time. The formatter is given the raw arguments (with defaults filled in from the function signature), so attribute traversal works on the original objects. The leaf value is then wrapped via represent(), preserving the existing repr-style display for plain positional and keyword placeholders. Added regression tests covering dataclass attribute access, plain object attribute access, and nested attribute access (user.address.city). --- .../src/allure_commons/_allure.py | 44 ++++++++- .../step/step_attribute_placeholder_test.py | 98 +++++++++++++++++++ 2 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 tests/allure_pytest/acceptance/step/step_attribute_placeholder_test.py 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'"), + ), + )