Skip to content

feat: add titlePath to results generated by all allure-pytest integrations #870

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 23 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
36319b5
feat(commons): add TestResult.titlePath
delatrie Jul 9, 2025
e8fd86f
test(commons): add has_title_path matcher
delatrie Jul 9, 2025
8b5bf5c
test(pytest): add tests for title path
delatrie Jul 9, 2025
e37d246
feat(pytest): implement titlePath
delatrie Jul 9, 2025
94f8272
refactor(pytest): use ParsedPytestNodeId in allure_package
delatrie Jul 10, 2025
5749e7e
test(pytest): add nested package test
delatrie Jul 10, 2025
5369e61
test(pytest): move root package test to base unit test suite
delatrie Jul 10, 2025
459e59c
refactor(pytest): use ParsedPytestNodeId in allure_full_name
delatrie Jul 10, 2025
767f227
refactor(pytest): use ParsedPytestNodeId in allure_suite_labels
delatrie Jul 10, 2025
05fd8b0
refactor(pytest): move ParsedPytestNodeId to the top of the file
delatrie Jul 10, 2025
a6a0212
feat(pytest): stash caching decorator and fns
delatrie Jul 10, 2025
f1d3202
feat(pytest): cache parsed nodeid in stash
delatrie Jul 10, 2025
347acb5
test(behave): add support for feature filename
delatrie Jul 15, 2025
1402e24
test(behave): add titlepath tests
delatrie Jul 15, 2025
4a25378
feat(behave): implement titlePath
delatrie Jul 15, 2025
7f28321
test(robot): add rootdir to runner
delatrie Jul 15, 2025
bb68de9
test(robot): add titlePath tests
delatrie Jul 15, 2025
3be2252
feat(robot): implement titlePath
delatrie Jul 15, 2025
e2d4e2e
test(nose2): add sepport for explicit module name to runner
delatrie Jul 17, 2025
7bb0ef2
test(nose2): add tests for titlePath
delatrie Jul 17, 2025
a171d88
feat(nose2): implement titlePath
delatrie Jul 17, 2025
1955a12
test(pytest-bdd): add tests for titlePath
delatrie Jul 18, 2025
33acc2e
feat(pytest-bdd): implement titlePath
delatrie Jul 18, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions allure-behave/src/listener.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
24 changes: 24 additions & 0 deletions allure-behave/src/utils.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 "<string>" if the feature comes from a string literal
if scenario.filename and scenario.filename != "<string>":
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"]:
Expand Down
2 changes: 2 additions & 0 deletions allure-nose2/src/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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))
Expand Down
5 changes: 5 additions & 0 deletions allure-nose2/src/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
2 changes: 2 additions & 0 deletions allure-pytest-bdd/src/pytest_bdd_listener.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
11 changes: 11 additions & 0 deletions allure-pytest-bdd/src/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import os
from urllib.parse import urlparse
from uuid import UUID
from pathlib import Path

import pytest

Expand Down Expand Up @@ -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)))

Expand Down
2 changes: 2 additions & 0 deletions allure-pytest/src/listener.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
61 changes: 61 additions & 0 deletions allure-pytest/src/stash.py
Original file line number Diff line number Diff line change
@@ -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
72 changes: 46 additions & 26 deletions allure-pytest/src/utils.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
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
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'
Expand All @@ -30,6 +30,24 @@
}


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]


@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
Expand Down Expand Up @@ -101,9 +119,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 parse_nodeid(item).package


def allure_name(item, parameters, param_id=None):
Expand All @@ -122,37 +138,41 @@ 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 = 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}"
return full_name


def allure_title_path(item):
nodeid = parse_nodeid(item)
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))


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 = parse_nodeid(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):
Expand Down
7 changes: 7 additions & 0 deletions allure-python-commons-test/src/result.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
1 change: 1 addition & 0 deletions allure-python-commons/src/allure_commons/model2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions allure-robotframework/src/listener/allure_listener.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
5 changes: 4 additions & 1 deletion allure-robotframework/src/listener/robot_listener.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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()
Expand Down
Empty file.
Loading