From 36319b5eabe13bc2c67500af9c58e5f404c03ccd Mon Sep 17 00:00:00 2001 From: Maksim Stepanov <17935127+delatrie@users.noreply.github.com> Date: Wed, 9 Jul 2025 19:34:17 +0700 Subject: [PATCH 01/23] feat(commons): add TestResult.titlePath --- allure-python-commons/src/allure_commons/model2.py | 1 + 1 file changed, 1 insertion(+) diff --git a/allure-python-commons/src/allure_commons/model2.py b/allure-python-commons/src/allure_commons/model2.py index ccaf4459..d8591598 100644 --- a/allure-python-commons/src/allure_commons/model2.py +++ b/allure-python-commons/src/allure_commons/model2.py @@ -49,6 +49,7 @@ class TestResult(ExecutableItem): fullName = attrib(default=None) labels = attrib(default=Factory(list)) links = attrib(default=Factory(list)) + titlePath = attrib(default=Factory(list)) @attrs From e8fd86f2f41da15bfd6bbe4abb7b81c31da5df1d Mon Sep 17 00:00:00 2001 From: Maksim Stepanov <17935127+delatrie@users.noreply.github.com> Date: Wed, 9 Jul 2025 19:35:16 +0700 Subject: [PATCH 02/23] test(commons): add has_title_path matcher --- allure-python-commons-test/src/result.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/allure-python-commons-test/src/result.py b/allure-python-commons-test/src/result.py index ee97cbf9..93a393cd 100644 --- a/allure-python-commons-test/src/result.py +++ b/allure-python-commons-test/src/result.py @@ -74,6 +74,13 @@ def has_title(title): return has_entry('name', title) +def has_title_path(*matchers): + return has_entry( + "titlePath", + contains_exactly(*matchers), + ) + + def has_description(*matchers): return has_entry('description', all_of(*matchers)) From 8b5bf5c8472d866d393e4b1d9af892ee5fb1cbba Mon Sep 17 00:00:00 2001 From: Maksim Stepanov <17935127+delatrie@users.noreply.github.com> Date: Wed, 9 Jul 2025 19:36:20 +0700 Subject: [PATCH 03/23] test(pytest): add tests for title path --- .../acceptance/titlepath/__init__.py | 0 .../acceptance/titlepath/titlepath_test.py | 75 +++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 tests/allure_pytest/acceptance/titlepath/__init__.py create mode 100644 tests/allure_pytest/acceptance/titlepath/titlepath_test.py diff --git a/tests/allure_pytest/acceptance/titlepath/__init__.py b/tests/allure_pytest/acceptance/titlepath/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/allure_pytest/acceptance/titlepath/titlepath_test.py b/tests/allure_pytest/acceptance/titlepath/titlepath_test.py new file mode 100644 index 00000000..f731cedd --- /dev/null +++ b/tests/allure_pytest/acceptance/titlepath/titlepath_test.py @@ -0,0 +1,75 @@ +import pytest +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_title_path + + +@pytest.mark.parametrize(["path", "path_segments"], [ + pytest.param("foo_test.py", ["foo_test.py"], id="root"), + pytest.param("foo/bar_test.py", ["foo", "bar_test.py"], id="dir"), + pytest.param("foo/bar/baz_test.py", ["foo", "bar", "baz_test.py"], id="subdir"), +]) +def test_function_title_path(allure_pytest_runner: AllurePytestRunner, path, path_segments): + """ + >>> def test_bar(): + ... pass + """ + + allure_results = allure_pytest_runner.run_docstring(filename=path) + + assert_that( + allure_results, + has_test_case( + "test_bar", + has_title_path(*path_segments), + ) + ) + + +@pytest.mark.parametrize(["path", "path_segments"], [ + pytest.param("foo_test.py", ["foo_test.py"], id="root"), + pytest.param("foo/bar_test.py", ["foo", "bar_test.py"], id="dir"), + pytest.param("foo/bar/baz_test.py", ["foo", "bar", "baz_test.py"], id="subdir"), +]) +def test_method_title_path(allure_pytest_runner: AllurePytestRunner, path, path_segments): + """ + >>> class TestBar: + ... def test_baz(self): + ... pass + """ + + allure_results = allure_pytest_runner.run_docstring(filename=path) + + assert_that( + allure_results, + has_test_case( + "test_baz", + has_title_path(*path_segments, "TestBar"), + ) + ) + + +@pytest.mark.parametrize(["path", "path_segments"], [ + pytest.param("foo_test.py", ["foo_test.py"], id="root"), + pytest.param("foo/bar_test.py", ["foo", "bar_test.py"], id="dir"), + pytest.param("foo/bar/baz_test.py", ["foo", "bar", "baz_test.py"], id="subdir"), +]) +def test_nested_class_method_title_path(allure_pytest_runner: AllurePytestRunner, path, path_segments): + """ + >>> class TestBar: + ... class TestBaz: + ... def test_qux(self): + ... pass + """ + + allure_results = allure_pytest_runner.run_docstring(filename=path) + + assert_that( + allure_results, + has_test_case( + "test_qux", + has_title_path(*path_segments, "TestBar", "TestBaz"), + ) + ) From e37d2468b9feaaf0840844b262cbf3b1fddf1b31 Mon Sep 17 00:00:00 2001 From: Maksim Stepanov <17935127+delatrie@users.noreply.github.com> Date: Wed, 9 Jul 2025 19:37:38 +0700 Subject: [PATCH 04/23] feat(pytest): implement titlePath --- allure-pytest/src/listener.py | 2 ++ allure-pytest/src/utils.py | 16 ++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/allure-pytest/src/listener.py b/allure-pytest/src/listener.py index 11153630..42b7ff49 100644 --- a/allure-pytest/src/listener.py +++ b/allure-pytest/src/listener.py @@ -19,6 +19,7 @@ from allure_pytest.utils import allure_description, allure_description_html from allure_pytest.utils import allure_labels, allure_links, pytest_markers from allure_pytest.utils import allure_full_name, allure_package, allure_name +from allure_pytest.utils import allure_title_path from allure_pytest.utils import allure_suite_labels from allure_pytest.utils import get_status, get_status_details from allure_pytest.utils import get_outcome_status, get_outcome_status_details @@ -109,6 +110,7 @@ def pytest_runtest_setup(self, item): test_result.name = allure_name(item, params, param_id) full_name = allure_full_name(item) test_result.fullName = full_name + test_result.titlePath = [*allure_title_path(item)] test_result.testCaseId = md5(full_name) test_result.description = allure_description(item) test_result.descriptionHtml = allure_description_html(item) diff --git a/allure-pytest/src/utils.py b/allure-pytest/src/utils.py index e90df9c1..856896d0 100644 --- a/allure-pytest/src/utils.py +++ b/allure-pytest/src/utils.py @@ -130,6 +130,22 @@ def allure_full_name(item: pytest.Item): return full_name +class ParsedPytestNodeId: + def __init__(self, nodeid): + filepath, *class_names, function_segment = ensure_len(nodeid.split("::"), 2) + self.filepath = filepath + self.path_segments = filepath.split('/') + self.class_names = class_names + self.test_function = function_segment.split("[", 1)[0] + + +def allure_title_path(item): + nodeid = ParsedPytestNodeId(item.nodeid) + return list( + filter(None, [*nodeid.path_segments, *nodeid.class_names]), + ) + + def ensure_len(value, min_length, fill_value=None): yield from value yield from repeat(fill_value, min_length - len(value)) From 94f827223bc8146f777297f0b190b759bdcff4de Mon Sep 17 00:00:00 2001 From: Maksim Stepanov <17935127+delatrie@users.noreply.github.com> Date: Thu, 10 Jul 2025 20:49:51 +0700 Subject: [PATCH 05/23] refactor(pytest): use ParsedPytestNodeId in allure_package --- allure-pytest/src/utils.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/allure-pytest/src/utils.py b/allure-pytest/src/utils.py index 856896d0..4b3c4355 100644 --- a/allure-pytest/src/utils.py +++ b/allure-pytest/src/utils.py @@ -101,9 +101,7 @@ def should_convert_mark_to_tag(mark): def allure_package(item): - parts = item.nodeid.split('::') - path = parts[0].rsplit('.', 1)[0] - return path.replace('/', '.') + return ParsedPytestNodeId(item).package def allure_name(item, parameters, param_id=None): @@ -135,6 +133,10 @@ def __init__(self, nodeid): filepath, *class_names, function_segment = ensure_len(nodeid.split("::"), 2) self.filepath = filepath self.path_segments = filepath.split('/') + *parent_dirs, filename = ensure_len(self.path_segments, 1) + self.parent_package = '.'.join(parent_dirs) + self.module = filename.rsplit(".", 1)[0] + self.package = '.'.join(filter(None, [self.parent_package, self.module])) self.class_names = class_names self.test_function = function_segment.split("[", 1)[0] From 5749e7e28d1d27e1aa2e10791de8711bb5b48bcc Mon Sep 17 00:00:00 2001 From: Maksim Stepanov <17935127+delatrie@users.noreply.github.com> Date: Thu, 10 Jul 2025 20:51:56 +0700 Subject: [PATCH 06/23] test(pytest): add nested package test --- .../acceptance/label/package/package_test.py | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 tests/allure_pytest/acceptance/label/package/package_test.py diff --git a/tests/allure_pytest/acceptance/label/package/package_test.py b/tests/allure_pytest/acceptance/label/package/package_test.py new file mode 100644 index 00000000..fd57b310 --- /dev/null +++ b/tests/allure_pytest/acceptance/label/package/package_test.py @@ -0,0 +1,22 @@ +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.label import has_package + + +def test_with_package(allure_pytest_runner: AllurePytestRunner): + """ + >>> def test_qux(request): + ... pass + """ + + allure_results = allure_pytest_runner.run_docstring(filename="foo/bar/baz_test.py") + + assert_that( + allure_results, + has_test_case( + "test_qux", + has_package("foo.bar.baz_test"), + ) + ) From 5369e61fe78aeee8fb861d97ad951ee730a5cdf1 Mon Sep 17 00:00:00 2001 From: Maksim Stepanov <17935127+delatrie@users.noreply.github.com> Date: Thu, 10 Jul 2025 20:53:06 +0700 Subject: [PATCH 07/23] test(pytest): move root package test to base unit test suite --- .../acceptance/label/package/package_test.py | 17 +++++++++++++++++ .../label/package/regression_test.py | 19 ------------------- 2 files changed, 17 insertions(+), 19 deletions(-) diff --git a/tests/allure_pytest/acceptance/label/package/package_test.py b/tests/allure_pytest/acceptance/label/package/package_test.py index fd57b310..7acbdc72 100644 --- a/tests/allure_pytest/acceptance/label/package/package_test.py +++ b/tests/allure_pytest/acceptance/label/package/package_test.py @@ -5,6 +5,23 @@ from allure_commons_test.label import has_package +def test_with_no_package(allure_pytest_runner: AllurePytestRunner): + """ + >>> def test_bar(request): + ... pass + """ + + allure_results = allure_pytest_runner.run_docstring(filename="foo_test.py") + + assert_that( + allure_results, + has_test_case( + "test_bar", + has_package("foo_test") + ) + ) + + def test_with_package(allure_pytest_runner: AllurePytestRunner): """ >>> def test_qux(request): diff --git a/tests/allure_pytest/acceptance/label/package/regression_test.py b/tests/allure_pytest/acceptance/label/package/regression_test.py index 42c90d31..ba8f5ecb 100644 --- a/tests/allure_pytest/acceptance/label/package/regression_test.py +++ b/tests/allure_pytest/acceptance/label/package/regression_test.py @@ -26,22 +26,3 @@ def test_path_with_dots_test_example(): has_package("path.with.dots.test_path") ) ) - - -def test_with_no_package(allure_pytest_runner: AllurePytestRunner): - """ - >>> def test_package_less(request): - ... pass - """ - - allure_pytest_runner.pytester.makeini("""[pytest]""") - - allure_results = allure_pytest_runner.run_docstring() - - assert_that( - allure_results, - has_test_case( - "test_package_less", - has_package("test_with_no_package") - ) - ) From 459e59c4783b2f4ed9bf89c41d8285feeb59f77b Mon Sep 17 00:00:00 2001 From: Maksim Stepanov <17935127+delatrie@users.noreply.github.com> Date: Thu, 10 Jul 2025 20:53:34 +0700 Subject: [PATCH 08/23] refactor(pytest): use ParsedPytestNodeId in allure_full_name --- allure-pytest/src/utils.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/allure-pytest/src/utils.py b/allure-pytest/src/utils.py index 4b3c4355..4bcea4cb 100644 --- a/allure-pytest/src/utils.py +++ b/allure-pytest/src/utils.py @@ -120,11 +120,10 @@ def allure_name(item, parameters, param_id=None): def allure_full_name(item: pytest.Item): - package = allure_package(item) - class_names = item.nodeid.split("::")[1:-1] - class_part = ("." + ".".join(class_names)) if class_names else "" - test = item.originalname if isinstance(item, pytest.Function) else item.name.split("[")[0] - full_name = f'{package}{class_part}#{test}' + nodeid = ParsedPytestNodeId(item) + class_part = ("." + ".".join(nodeid.class_names)) if nodeid.class_names else "" + test = item.originalname if isinstance(item, pytest.Function) else nodeid.test_function + full_name = f"{nodeid.package}{class_part}#{test}" return full_name From 767f2276d011a71b3c8c6ec5c0cfba5510a9cc6a Mon Sep 17 00:00:00 2001 From: Maksim Stepanov <17935127+delatrie@users.noreply.github.com> Date: Thu, 10 Jul 2025 20:54:18 +0700 Subject: [PATCH 09/23] refactor(pytest): use ParsedPytestNodeId in allure_suite_labels --- allure-pytest/src/utils.py | 32 +++++++++++++++----------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/allure-pytest/src/utils.py b/allure-pytest/src/utils.py index 4bcea4cb..591b7f47 100644 --- a/allure-pytest/src/utils.py +++ b/allure-pytest/src/utils.py @@ -1,5 +1,5 @@ import pytest -from itertools import chain, islice, repeat +from itertools import repeat from allure_commons.utils import SafeFormatter, md5 from allure_commons.utils import format_exception, format_traceback from allure_commons.model2 import Status @@ -153,23 +153,21 @@ def ensure_len(value, min_length, fill_value=None): def allure_suite_labels(item): - head, *class_names, _ = ensure_len(item.nodeid.split("::"), 2) - file_name, path = islice(chain(reversed(head.rsplit('/', 1)), [None]), 2) - module = file_name.split('.')[0] - package = path.replace('/', '.') if path else None - pairs = dict( - zip( - [LabelType.PARENT_SUITE, LabelType.SUITE, LabelType.SUB_SUITE], - [package, module, " > ".join(class_names)], - ), - ) - labels = dict(allure_labels(item)) - default_suite_labels = [] - for label, value in pairs.items(): - if label not in labels.keys() and value: - default_suite_labels.append((label, value)) + nodeid = ParsedPytestNodeId(item) + + default_suite_labels = { + LabelType.PARENT_SUITE: nodeid.parent_package, + LabelType.SUITE: nodeid.module, + LabelType.SUB_SUITE: " > ".join(nodeid.class_names), + } + + existing_labels = dict(allure_labels(item)) + resolved_default_suite_labels = [] + for label, value in default_suite_labels.items(): + if label not in existing_labels and value: + resolved_default_suite_labels.append((label, value)) - return default_suite_labels + return resolved_default_suite_labels def get_outcome_status(outcome): From 05fd8b01c0cdf83c6cee2244988ecd291cc395d1 Mon Sep 17 00:00:00 2001 From: Maksim Stepanov <17935127+delatrie@users.noreply.github.com> Date: Thu, 10 Jul 2025 20:57:34 +0700 Subject: [PATCH 10/23] refactor(pytest): move ParsedPytestNodeId to the top of the file --- allure-pytest/src/utils.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/allure-pytest/src/utils.py b/allure-pytest/src/utils.py index 591b7f47..763e350d 100644 --- a/allure-pytest/src/utils.py +++ b/allure-pytest/src/utils.py @@ -30,6 +30,19 @@ } +class ParsedPytestNodeId: + def __init__(self, nodeid): + filepath, *class_names, function_segment = ensure_len(nodeid.split("::"), 2) + self.filepath = filepath + self.path_segments = filepath.split('/') + *parent_dirs, filename = ensure_len(self.path_segments, 1) + self.parent_package = '.'.join(parent_dirs) + self.module = filename.rsplit(".", 1)[0] + self.package = '.'.join(filter(None, [self.parent_package, self.module])) + self.class_names = class_names + self.test_function = function_segment.split("[", 1)[0] + + def get_marker_value(item, keyword): marker = item.get_closest_marker(keyword) return marker.args[0] if marker and marker.args else None @@ -127,19 +140,6 @@ def allure_full_name(item: pytest.Item): return full_name -class ParsedPytestNodeId: - def __init__(self, nodeid): - filepath, *class_names, function_segment = ensure_len(nodeid.split("::"), 2) - self.filepath = filepath - self.path_segments = filepath.split('/') - *parent_dirs, filename = ensure_len(self.path_segments, 1) - self.parent_package = '.'.join(parent_dirs) - self.module = filename.rsplit(".", 1)[0] - self.package = '.'.join(filter(None, [self.parent_package, self.module])) - self.class_names = class_names - self.test_function = function_segment.split("[", 1)[0] - - def allure_title_path(item): nodeid = ParsedPytestNodeId(item.nodeid) return list( From a6a021232391bed51ddfcd43dcdcd21dac207662 Mon Sep 17 00:00:00 2001 From: Maksim Stepanov <17935127+delatrie@users.noreply.github.com> Date: Thu, 10 Jul 2025 21:02:28 +0700 Subject: [PATCH 11/23] feat(pytest): stash caching decorator and fns --- allure-pytest/src/stash.py | 61 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 allure-pytest/src/stash.py diff --git a/allure-pytest/src/stash.py b/allure-pytest/src/stash.py new file mode 100644 index 00000000..31d9302b --- /dev/null +++ b/allure-pytest/src/stash.py @@ -0,0 +1,61 @@ +import pytest +from functools import wraps + +HAS_STASH = hasattr(pytest, 'StashKey') + + +def create_stashkey_safe(): + """ + If pytest stash is available, returns a new stash key. + Otherwise, returns `None`. + """ + + return pytest.StashKey() if HAS_STASH else None + + +def stash_get_safe(item, key): + """ + If pytest stash is available and contains the key, retrieves the associated value. + Otherwise, returns `None`. + """ + + if HAS_STASH and key in item.stash: + return item.stash[key] + + +def stash_set_safe(item: pytest.Item, key, value): + """ + If pytest stash is available, associates the value with the key in the stash. + Otherwise, does nothing. + """ + + if HAS_STASH: + item.stash[key] = value + + +def stashed(arg=None): + """ + Cashes the result of the decorated function in the pytest item stash. + The first argument of the function must be a pytest item. + + In pytest<7.0 the stash is not available, so the decorator does nothing. + """ + + key = create_stashkey_safe() if arg is None or callable(arg) else arg + + def decorator(func): + if not HAS_STASH: + return func + + @wraps(func) + def wrapper(item, *args, **kwargs): + if key in item.stash: + return item.stash[key] + + value = func(item, *args, **kwargs) + item.stash[key] = value + return value + + return wrapper + + return decorator(arg) if callable(arg) else decorator From f1d320217ceb050ae765f76757daae8c37760b94 Mon Sep 17 00:00:00 2001 From: Maksim Stepanov <17935127+delatrie@users.noreply.github.com> Date: Thu, 10 Jul 2025 21:02:58 +0700 Subject: [PATCH 12/23] feat(pytest): cache parsed nodeid in stash --- allure-pytest/src/utils.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/allure-pytest/src/utils.py b/allure-pytest/src/utils.py index 763e350d..56594a09 100644 --- a/allure-pytest/src/utils.py +++ b/allure-pytest/src/utils.py @@ -5,7 +5,7 @@ from allure_commons.model2 import Status from allure_commons.model2 import StatusDetails from allure_commons.types import LabelType - +from allure_pytest.stash import stashed ALLURE_DESCRIPTION_MARK = 'allure_description' ALLURE_DESCRIPTION_HTML_MARK = 'allure_description_html' @@ -43,6 +43,11 @@ def __init__(self, nodeid): self.test_function = function_segment.split("[", 1)[0] +@stashed +def parse_nodeid(item): + return ParsedPytestNodeId(item.nodeid) + + def get_marker_value(item, keyword): marker = item.get_closest_marker(keyword) return marker.args[0] if marker and marker.args else None @@ -114,7 +119,7 @@ def should_convert_mark_to_tag(mark): def allure_package(item): - return ParsedPytestNodeId(item).package + return parse_nodeid(item).package def allure_name(item, parameters, param_id=None): @@ -133,7 +138,7 @@ def allure_name(item, parameters, param_id=None): def allure_full_name(item: pytest.Item): - nodeid = ParsedPytestNodeId(item) + nodeid = parse_nodeid(item) class_part = ("." + ".".join(nodeid.class_names)) if nodeid.class_names else "" test = item.originalname if isinstance(item, pytest.Function) else nodeid.test_function full_name = f"{nodeid.package}{class_part}#{test}" @@ -141,7 +146,7 @@ def allure_full_name(item: pytest.Item): def allure_title_path(item): - nodeid = ParsedPytestNodeId(item.nodeid) + nodeid = parse_nodeid(item) return list( filter(None, [*nodeid.path_segments, *nodeid.class_names]), ) @@ -153,7 +158,7 @@ def ensure_len(value, min_length, fill_value=None): def allure_suite_labels(item): - nodeid = ParsedPytestNodeId(item) + nodeid = parse_nodeid(item) default_suite_labels = { LabelType.PARENT_SUITE: nodeid.parent_package, From 347acb57e9696cbabebf475f3a0560ec8e107e95 Mon Sep 17 00:00:00 2001 From: Maksim Stepanov <17935127+delatrie@users.noreply.github.com> Date: Wed, 16 Jul 2025 01:53:31 +0700 Subject: [PATCH 13/23] test(behave): add support for feature filename --- tests/allure_behave/behave_runner.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/allure_behave/behave_runner.py b/tests/allure_behave/behave_runner.py index da49d738..1cbaf845 100644 --- a/tests/allure_behave/behave_runner.py +++ b/tests/allure_behave/behave_runner.py @@ -11,7 +11,7 @@ from behave.step_registry import setup_step_decorators from behave.step_registry import StepRegistry from pytest import FixtureRequest, Pytester -from typing import Sequence +from typing import Sequence, Mapping from tests.e2e import AllureFrameworkRunner, PathlikeT from allure_behave.formatter import AllureFormatter @@ -91,7 +91,10 @@ def load_step_definitions(self, extra_step_paths=None): def load_features(self): self.features.extend( - parse_feature(f) for f in self.__features + parse_feature(feature) if isinstance(feature, str) else parse_feature( + feature[1], + filename=feature[0], + ) for feature in self.__features ) def load_formatter(self): @@ -123,6 +126,7 @@ def run_behave( feature_paths: Sequence[PathlikeT] = None, feature_literals: Sequence[str] = None, feature_rst_ids: Sequence[str] = None, + feature_files: Mapping[str, str] = None, step_paths: Sequence[PathlikeT] = None, step_literals: Sequence[str] = None, step_rst_ids: Sequence[str] = None, @@ -172,7 +176,7 @@ def run_behave( paths=feature_paths, literals=feature_literals, rst_ids=feature_rst_ids - ), + ) + list((feature_files or {}).items()), self._get_all_content( paths=step_paths, literals=step_literals, From 1402e249414fe9bbad1224bd8ceccab085ac9e18 Mon Sep 17 00:00:00 2001 From: Maksim Stepanov <17935127+delatrie@users.noreply.github.com> Date: Wed, 16 Jul 2025 01:53:54 +0700 Subject: [PATCH 14/23] test(behave): add titlepath tests --- .../behave_support/titlepath/__init__.py | 0 .../titlepath/titlepath_test.py | 110 ++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 tests/allure_behave/acceptance/behave_support/titlepath/__init__.py create mode 100644 tests/allure_behave/acceptance/behave_support/titlepath/titlepath_test.py diff --git a/tests/allure_behave/acceptance/behave_support/titlepath/__init__.py b/tests/allure_behave/acceptance/behave_support/titlepath/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/allure_behave/acceptance/behave_support/titlepath/titlepath_test.py b/tests/allure_behave/acceptance/behave_support/titlepath/titlepath_test.py new file mode 100644 index 00000000..7e035a3f --- /dev/null +++ b/tests/allure_behave/acceptance/behave_support/titlepath/titlepath_test.py @@ -0,0 +1,110 @@ +from pathlib import Path +from hamcrest import assert_that +from tests.allure_behave.behave_runner import AllureBehaveRunner +from allure_commons_test.report import has_test_case +from allure_commons_test.result import has_title_path + + +def test_titlepath_of_top_level_feature_file(docstring, behave_runner: AllureBehaveRunner): + """ + Feature: Foo + Scenario: Bar + Given baz + """ + + behave_runner.run_behave( + feature_files={"foo.feature": docstring}, + step_literals=["given('baz')(lambda c:None)"], + ) + + assert_that( + behave_runner.allure_results, + has_test_case( + "Bar", + has_title_path("Foo"), + ) + ) + + +def test_titlepath_of_nested_feature_file(docstring, behave_runner: AllureBehaveRunner): + """ + Feature: Foo + Scenario: Bar + Given baz + """ + + behave_runner.run_behave( + feature_files={"foo/bar/baz.feature": docstring}, + step_literals=["given('baz')(lambda c:None)"], + ) + + assert_that( + behave_runner.allure_results, + has_test_case( + "Bar", + has_title_path("foo", "bar", "Foo"), + ) + ) + + +def test_titlepath_if_feature_name_empty(docstring, behave_runner: AllureBehaveRunner): + """ + Feature: + Scenario: Bar + Given baz + """ + + behave_runner.run_behave( + feature_files={str(Path("foo.feature").absolute()): docstring}, + step_literals=["given('baz')(lambda c:None)"], + ) + + assert_that( + behave_runner.allure_results, + has_test_case( + "Bar", + has_title_path("foo.feature"), + ) + ) + + +def test_titlepath_of_feature_without_filename(docstring, behave_runner: AllureBehaveRunner): + """ + Feature: Foo + Scenario: Bar + Given baz + """ + + behave_runner.run_behave( + feature_literals=[docstring], + step_literals=["given('baz')(lambda c:None)"], + ) + + assert_that( + behave_runner.allure_results, + has_test_case( + "Bar", + has_title_path("Foo"), + ) + ) + + +def test_titlepath_of_feature_without_filename_and_name(docstring, behave_runner: AllureBehaveRunner): + """ + Feature: + Scenario: Bar + Given baz + """ + + behave_runner.run_behave( + feature_literals=[docstring], + step_literals=["given('baz')(lambda c:None)"], + ) + + assert_that( + behave_runner.allure_results, + has_test_case( + "Bar", + has_title_path("Feature"), + ) + ) From 4a25378aeeaca77b26f053a7c6ed5a1f2b924d64 Mon Sep 17 00:00:00 2001 From: Maksim Stepanov <17935127+delatrie@users.noreply.github.com> Date: Wed, 16 Jul 2025 01:54:22 +0700 Subject: [PATCH 15/23] feat(behave): implement titlePath --- allure-behave/src/listener.py | 2 ++ allure-behave/src/utils.py | 24 ++++++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/allure-behave/src/listener.py b/allure-behave/src/listener.py index b9a86d55..7d5d8753 100644 --- a/allure-behave/src/listener.py +++ b/allure-behave/src/listener.py @@ -20,6 +20,7 @@ from allure_behave.utils import scenario_links from allure_behave.utils import scenario_labels from allure_behave.utils import get_fullname +from allure_behave.utils import get_title_path from allure_behave.utils import TEST_PLAN_SKIP_REASON from allure_behave.utils import get_hook_name @@ -77,6 +78,7 @@ def start_scenario(self, scenario): test_case = TestResult(uuid=self.current_scenario_uuid, start=now()) test_case.name = scenario_name(scenario) test_case.fullName = get_fullname(scenario) + test_case.titlePath = get_title_path(scenario) test_case.historyId = scenario_history_id(scenario) test_case.description = '\n'.join(scenario.description) test_case.parameters = scenario_parameters(scenario) diff --git a/allure-behave/src/utils.py b/allure-behave/src/utils.py index b77fe6c4..ce0f2d70 100644 --- a/allure-behave/src/utils.py +++ b/allure-behave/src/utils.py @@ -1,6 +1,7 @@ import csv import io from enum import Enum +from pathlib import Path from behave.runner_util import make_undefined_step_snippet from allure_commons.types import Severity, LabelType from allure_commons.model2 import Status, Parameter @@ -97,6 +98,29 @@ def get_fullname(scenario): return f"{scenario.feature.name}: {name}" +def get_title_path(scenario): + path_parts = [] + feature_part = scenario.feature.name + + # filename is set to "" if the feature comes from a string literal + if scenario.filename and scenario.filename != "": + path = Path(scenario.filename) + + # remove the filename because it's redundant: a feature file can only have one feature defined + path_parts = path.parts[:-1] + + if not feature_part: + # if no feature name is defined, fallback to the filename + feature_part = path.name + + if not feature_part: + # Neither feature name nor filename is defined, use the "Feature" keyword + feature_part = scenario.feature.keyword + + # reminder: scenario name should not be included in titlePath because it is already part of the test case title + return [*path_parts, feature_part] + + def get_hook_name(name, parameters): tag = None if name in ["before_tag", "after_tag"]: From 7f28321fed1229e1ccc36dd324ec130911deb241 Mon Sep 17 00:00:00 2001 From: Maksim Stepanov <17935127+delatrie@users.noreply.github.com> Date: Wed, 16 Jul 2025 02:43:23 +0700 Subject: [PATCH 16/23] test(robot): add rootdir to runner --- tests/allure_robotframework/robot_runner.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/allure_robotframework/robot_runner.py b/tests/allure_robotframework/robot_runner.py index bfb13cee..1d019ffe 100644 --- a/tests/allure_robotframework/robot_runner.py +++ b/tests/allure_robotframework/robot_runner.py @@ -1,7 +1,7 @@ import robot from pytest import FixtureRequest, Pytester from tests.e2e import AllureFrameworkRunner, PathlikeT -from typing import Sequence, Mapping +from typing import Sequence, Mapping, Union from allure_robotframework import allure_robotframework @@ -12,6 +12,7 @@ class AllureRobotRunner(AllureFrameworkRunner): def __init__(self, request: FixtureRequest, pytester: Pytester): super().__init__(request, pytester, AllureRobotRunner.LOGGER_PATH) + self.rootdir: Union[str, None] = None def run_robotframework( self, @@ -79,7 +80,7 @@ def run_robotframework( ) def _run_framework(self, suites, options): - robot.run(*suites, listener=allure_robotframework(None), **options) + robot.run(*[self.rootdir] if self.rootdir else suites, listener=allure_robotframework(None), **options) def __resolve_options(self, options): return { From bb68de9adfc16940a2f28ec03384bcbf46954169 Mon Sep 17 00:00:00 2001 From: Maksim Stepanov <17935127+delatrie@users.noreply.github.com> Date: Wed, 16 Jul 2025 02:43:53 +0700 Subject: [PATCH 17/23] test(robot): add titlePath tests --- .../titlepath/__init__.py | 0 .../titlepath/titlepath_test.py | 56 +++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 tests/allure_robotframework/acceptance/robotframework_support/titlepath/__init__.py create mode 100644 tests/allure_robotframework/acceptance/robotframework_support/titlepath/titlepath_test.py diff --git a/tests/allure_robotframework/acceptance/robotframework_support/titlepath/__init__.py b/tests/allure_robotframework/acceptance/robotframework_support/titlepath/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/allure_robotframework/acceptance/robotframework_support/titlepath/titlepath_test.py b/tests/allure_robotframework/acceptance/robotframework_support/titlepath/titlepath_test.py new file mode 100644 index 00000000..24a88064 --- /dev/null +++ b/tests/allure_robotframework/acceptance/robotframework_support/titlepath/titlepath_test.py @@ -0,0 +1,56 @@ +from hamcrest import assert_that, all_of +from tests.allure_robotframework.robot_runner import AllureRobotRunner +from allure_commons_test.report import has_test_case +from allure_commons_test.result import has_title_path + + +def test_titlepath_of_directly_run_suite(docstring, robot_runner: AllureRobotRunner): + """ + *** Test Cases *** + Bar + No Operation + """ + + robot_runner.run_robotframework( + suite_literals={"foo.robot": docstring} + ) + + assert_that( + robot_runner.allure_results, + has_test_case( + "Bar", + has_title_path("Foo"), + ) + ) + + +def test_titlepath_of_nested_suites(docstring, robot_runner: AllureRobotRunner): + """ + *** Test Cases *** + Qux + No Operation + """ + + robot_runner.rootdir = "foo" + + robot_runner.run_robotframework( + suite_literals={ + "foo/bar/baz.robot": docstring, + "foo/bor/buz.robot": docstring, + } + + ) + + assert_that( + robot_runner.allure_results, + all_of( + has_test_case( + "Foo.Bar.Baz.Qux", + has_title_path("Foo", "Bar", "Baz"), + ), + has_test_case( + "Foo.Bor.Buz.Qux", + has_title_path("Foo", "Bor", "Buz"), + ), + ), + ) From 3be2252862559952dda88fcb6904e21ae2eea16d Mon Sep 17 00:00:00 2001 From: Maksim Stepanov <17935127+delatrie@users.noreply.github.com> Date: Wed, 16 Jul 2025 02:45:31 +0700 Subject: [PATCH 18/23] feat(robot): implement titlePath --- allure-robotframework/src/listener/allure_listener.py | 1 + allure-robotframework/src/listener/robot_listener.py | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/allure-robotframework/src/listener/allure_listener.py b/allure-robotframework/src/listener/allure_listener.py index 2a4d3a80..236a524e 100644 --- a/allure-robotframework/src/listener/allure_listener.py +++ b/allure-robotframework/src/listener/allure_listener.py @@ -131,6 +131,7 @@ def start_test(self, name, attributes): long_name = attributes.get('longname') test_result.name = name test_result.fullName = long_name + test_result.titlePath = attributes.get("titlepath", []) test_result.historyId = md5(long_name) test_result.start = now() diff --git a/allure-robotframework/src/listener/robot_listener.py b/allure-robotframework/src/listener/robot_listener.py index 9dab210e..0ff4ff2c 100644 --- a/allure-robotframework/src/listener/robot_listener.py +++ b/allure-robotframework/src/listener/robot_listener.py @@ -16,6 +16,7 @@ class allure_robotframework: def __init__(self, logger_path=DEFAULT_OUTPUT_PATH): self.messages = Messages() + self.title_path = [] self.logger = AllureFileLogger(logger_path) self.lifecycle = AllureLifecycle() @@ -25,17 +26,19 @@ def __init__(self, logger_path=DEFAULT_OUTPUT_PATH): allure_commons.plugin_manager.register(self.listener) def start_suite(self, name, attributes): + self.title_path.append(name) self.messages.start_context() self.listener.start_suite_container(name, attributes) def end_suite(self, name, attributes): self.messages.stop_context() self.listener.stop_suite_container(name, attributes) + self.title_path.pop() def start_test(self, name, attributes): self.messages.start_context() self.listener.start_test_container(name, attributes) - self.listener.start_test(name, attributes) + self.listener.start_test(name, {**attributes, "titlepath": self.title_path}) def end_test(self, name, attributes): messages = self.messages.stop_context() From e2d4e2e1a54f61779601155a00079a4d7bd10f4e Mon Sep 17 00:00:00 2001 From: Maksim Stepanov <17935127+delatrie@users.noreply.github.com> Date: Thu, 17 Jul 2025 16:41:19 +0700 Subject: [PATCH 19/23] test(nose2): add sepport for explicit module name to runner --- tests/allure_nose2/nose2_runner.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/allure_nose2/nose2_runner.py b/tests/allure_nose2/nose2_runner.py index 67b35d7e..e5cf1802 100644 --- a/tests/allure_nose2/nose2_runner.py +++ b/tests/allure_nose2/nose2_runner.py @@ -12,10 +12,10 @@ class AllureNose2Runner(AllureFrameworkRunner): def __init__(self, request: FixtureRequest, pytester: Pytester): super().__init__(request, pytester, AllureNose2Runner.LOGGER_PATH) - def run_docstring(self): + def run_docstring(self, module_name=None): docstring = self._find_docstring() example_code = script_from_examples(docstring) - spec = importlib.machinery.ModuleSpec(self.request.node.name, None) + spec = importlib.machinery.ModuleSpec(module_name or self.request.node.name, None) module = importlib.util.module_from_spec(spec) return self._run(module, example_code) From 7bb0ef253d1c1de71237b56132551cd281a82fcf Mon Sep 17 00:00:00 2001 From: Maksim Stepanov <17935127+delatrie@users.noreply.github.com> Date: Thu, 17 Jul 2025 16:41:40 +0700 Subject: [PATCH 20/23] test(nose2): add tests for titlePath --- .../nose2_support/titlepath/__init__.py | 0 .../nose2_support/titlepath/titlepath_test.py | 71 +++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 tests/allure_nose2/acceptance/nose2_support/titlepath/__init__.py create mode 100644 tests/allure_nose2/acceptance/nose2_support/titlepath/titlepath_test.py diff --git a/tests/allure_nose2/acceptance/nose2_support/titlepath/__init__.py b/tests/allure_nose2/acceptance/nose2_support/titlepath/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/allure_nose2/acceptance/nose2_support/titlepath/titlepath_test.py b/tests/allure_nose2/acceptance/nose2_support/titlepath/titlepath_test.py new file mode 100644 index 00000000..004c866b --- /dev/null +++ b/tests/allure_nose2/acceptance/nose2_support/titlepath/titlepath_test.py @@ -0,0 +1,71 @@ +import pytest +from hamcrest import assert_that +from tests.allure_nose2.nose2_runner import AllureNose2Runner + +from allure_commons_test.report import has_test_case +from allure_commons_test.result import has_title_path + + +@pytest.mark.parametrize(["module", "path_segments"], [ + pytest.param("foo", ["foo"], id="root"), + pytest.param("foo.bar", ["foo", "bar"], id="level1"), + pytest.param("foo.bar.baz", ["foo", "bar", "baz"], id="level2"), +]) +def test_function_title_path(nose2_runner: AllureNose2Runner, module, path_segments): + """ + >>> def test_qux(): + ... pass + """ + + allure_results = nose2_runner.run_docstring(module_name=module) + + assert_that( + allure_results, + has_test_case( + "test_qux", + has_title_path(*path_segments), + ) + ) + + +@pytest.mark.parametrize(["module", "path_segments"], [ + pytest.param("foo", ["foo"], id="root"), + pytest.param("foo.bar", ["foo", "bar"], id="level1"), + pytest.param("foo.bar.baz", ["foo", "bar", "baz"], id="level2"), +]) +def test_method_title_path(nose2_runner: AllureNose2Runner, module, path_segments): + """ + >>> from unittest import TestCase + >>> class TestQux(TestCase): + ... def test_quux(self): + ... pass + """ + + allure_results = nose2_runner.run_docstring(module_name=module) + + assert_that( + allure_results, + has_test_case( + "test_quux", + has_title_path(*path_segments, "TestQux"), + ) + ) + + +def test_params_ignored(nose2_runner: AllureNose2Runner): + """ + >>> from nose2.tools import params + >>> @params("a.b:c") + ... def test_bar(v): + ... pass + """ + + allure_results = nose2_runner.run_docstring(module_name="foo") + + assert_that( + allure_results, + has_test_case( + "test_bar", + has_title_path("foo"), + ) + ) From a171d88763b6c77339963c08dca867f2b987d639 Mon Sep 17 00:00:00 2001 From: Maksim Stepanov <17935127+delatrie@users.noreply.github.com> Date: Thu, 17 Jul 2025 16:42:10 +0700 Subject: [PATCH 21/23] feat(nose2): implement titlePath --- allure-nose2/src/plugin.py | 2 ++ allure-nose2/src/utils.py | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/allure-nose2/src/plugin.py b/allure-nose2/src/plugin.py index c7f64608..678fe8f1 100644 --- a/allure-nose2/src/plugin.py +++ b/allure-nose2/src/plugin.py @@ -14,6 +14,7 @@ from .utils import timestamp_millis, status_details, update_attrs, labels, name, fullname, params +from .utils import get_title_path import allure_commons @@ -90,6 +91,7 @@ def startTest(self, event): test_result.fullName = fullname(event) test_result.testCaseId = md5(test_result.fullName) test_result.historyId = md5(event.test.id()) + test_result.titlePath = get_title_path(event) test_result.labels.extend(labels(event.test)) test_result.labels.append(Label(name=LabelType.HOST, value=self._host)) test_result.labels.append(Label(name=LabelType.THREAD, value=self._thread)) diff --git a/allure-nose2/src/utils.py b/allure-nose2/src/utils.py index 691e75c7..4e2e885d 100644 --- a/allure-nose2/src/utils.py +++ b/allure-nose2/src/utils.py @@ -81,6 +81,11 @@ def fullname(event): return test_id.split(":")[0] +def get_title_path(event): + test_id = event.test.id() + return test_id.split(":", 1)[0].rsplit(".")[:-1] + + def params(event): def _params(names, values): return [Parameter(name=name, value=represent(value)) for name, value in zip(names, values)] From 1955a1200f242202181a7708f2f613fc23ffff3a Mon Sep 17 00:00:00 2001 From: Maksim Stepanov <17935127+delatrie@users.noreply.github.com> Date: Fri, 18 Jul 2025 17:59:34 +0700 Subject: [PATCH 22/23] test(pytest-bdd): add tests for titlePath --- .../acceptance/titlepath/__init__.py | 0 .../acceptance/titlepath/titlepath_test.py | 91 +++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 tests/allure_pytest_bdd/acceptance/titlepath/__init__.py create mode 100644 tests/allure_pytest_bdd/acceptance/titlepath/titlepath_test.py diff --git a/tests/allure_pytest_bdd/acceptance/titlepath/__init__.py b/tests/allure_pytest_bdd/acceptance/titlepath/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/allure_pytest_bdd/acceptance/titlepath/titlepath_test.py b/tests/allure_pytest_bdd/acceptance/titlepath/titlepath_test.py new file mode 100644 index 00000000..60ee2363 --- /dev/null +++ b/tests/allure_pytest_bdd/acceptance/titlepath/titlepath_test.py @@ -0,0 +1,91 @@ +import pytest +from hamcrest import assert_that +from tests.allure_pytest.pytest_runner import AllurePytestRunner +import allure + +from allure_commons_test.report import has_test_case +from allure_commons_test.result import has_title_path + + +@pytest.mark.parametrize(["path", "path_segments"], [ + pytest.param("foo.feature", ["Qux"], id="root"), + pytest.param("foo/bar.feature", ["foo", "Qux"], id="dir"), + pytest.param("foo/bar/baz.feature", ["foo", "bar", "Qux"], id="subdir"), +]) +def test_title_path(allure_pytest_bdd_runner: AllurePytestRunner, path, path_segments): + allure.dynamic.parent_suite("my suite") + allure.dynamic.suite("my suite") + allure.dynamic.sub_suite("my suite") + + allure.dynamic.epic("my suite") + allure.dynamic.feature("my suite") + allure.dynamic.story("my suite") + + feature_content = ( + """ + Feature: Qux + Scenario: Quux + Given pass + """ + ) + pytest_content = ( + f""" + from pytest_bdd import scenarios, given + import allure + + scenarios("{path}") + + @given("pass") + def given_pass(): + pass + """ + ) + + allure_results = allure_pytest_bdd_runner.run_pytest( + (path, feature_content), + pytest_content, + ) + + assert_that( + allure_results, + has_test_case( + "Quux", + has_title_path(*path_segments), + ) + ) + + +def test_feature_name_missing(allure_pytest_bdd_runner: AllurePytestRunner): + feature_content = ( + """ + Feature: + Scenario: Bar + Given pass + """ + ) + pytest_content = ( + """ + from pytest_bdd import scenarios, given + import allure + + scenarios("foo.feature") + + @given("pass") + def given_pass(): + pass + """ + ) + + allure_results = allure_pytest_bdd_runner.run_pytest( + ("foo.feature", feature_content), + pytest_content, + cli_args=["--capture=no"] + ) + + assert_that( + allure_results, + has_test_case( + "Bar", + has_title_path("foo.feature"), + ) + ) From 33acc2ee49a2b07587c864a13441344d543aab2b Mon Sep 17 00:00:00 2001 From: Maksim Stepanov <17935127+delatrie@users.noreply.github.com> Date: Fri, 18 Jul 2025 18:00:30 +0700 Subject: [PATCH 23/23] feat(pytest-bdd): implement titlePath --- allure-pytest-bdd/src/pytest_bdd_listener.py | 2 ++ allure-pytest-bdd/src/utils.py | 11 +++++++++++ 2 files changed, 13 insertions(+) diff --git a/allure-pytest-bdd/src/pytest_bdd_listener.py b/allure-pytest-bdd/src/pytest_bdd_listener.py index d0697380..bcc6cba0 100644 --- a/allure-pytest-bdd/src/pytest_bdd_listener.py +++ b/allure-pytest-bdd/src/pytest_bdd_listener.py @@ -23,6 +23,7 @@ from .utils import get_allure_links from .utils import convert_params from .utils import get_full_name +from .utils import get_title_path from .utils import get_outline_params from .utils import get_pytest_params from .utils import get_pytest_report_status @@ -59,6 +60,7 @@ def pytest_bdd_before_scenario(self, request, feature, scenario): full_name = get_full_name(feature, scenario) with self.lifecycle.schedule_test_case(uuid=uuid) as test_result: test_result.fullName = full_name + test_result.titlePath = get_title_path(request, feature) test_result.name = get_test_name(item, scenario, params) test_result.description = get_allure_description(item, feature, scenario) test_result.descriptionHtml = get_allure_description_html(item) diff --git a/allure-pytest-bdd/src/utils.py b/allure-pytest-bdd/src/utils.py index 1ba59aa2..f4a838b1 100644 --- a/allure-pytest-bdd/src/utils.py +++ b/allure-pytest-bdd/src/utils.py @@ -3,6 +3,7 @@ import os from urllib.parse import urlparse from uuid import UUID +from pathlib import Path import pytest @@ -171,6 +172,16 @@ def get_full_name(feature, scenario): return f"{feature_path}:{scenario.name}" +def get_rootdir(request): + config = request.config + return getattr(config, "rootpath", None) or Path(config.rootdir) + + +def get_title_path(request, feature): + parts = Path(feature.filename).relative_to(get_rootdir(request)).parts + return [*parts[:-1], feature.name or parts[-1]] + + def get_uuid(*args): return str(UUID(md5(*args)))