diff --git a/allure-behave/src/listener.py b/allure-behave/src/listener.py index ad543526..e3f00d9f 100644 --- a/allure-behave/src/listener.py +++ b/allure-behave/src/listener.py @@ -190,6 +190,10 @@ def global_attach_file(self, source, name, attachment_type, extension): def global_error(self, message, trace): self.logger.global_error(message=message, trace=trace) + @allure_commons.hookimpl + def add_environment(self, env): + self.logger.environment(env) + @allure_commons.hookimpl def add_description(self, test_description): test_result = self.logger.get_test(None) diff --git a/allure-pytest-bdd/src/allure_api_listener.py b/allure-pytest-bdd/src/allure_api_listener.py index 0b980725..aa1c57fa 100644 --- a/allure-pytest-bdd/src/allure_api_listener.py +++ b/allure-pytest-bdd/src/allure_api_listener.py @@ -19,6 +19,7 @@ from .utils import global_attach_data from .utils import global_attach_file from .utils import global_error +from .utils import environment from .utils import get_link_patterns from .steps import start_step from .steps import stop_step @@ -132,3 +133,7 @@ def global_attach_file(self, source, name, attachment_type, extension): @allure_commons.hookimpl def global_error(self, message, trace): global_error(self.lifecycle, message, trace) + + @allure_commons.hookimpl + def add_environment(self, env): + environment(self.lifecycle, env) diff --git a/allure-pytest-bdd/src/utils.py b/allure-pytest-bdd/src/utils.py index 43bc17a1..fe192d06 100644 --- a/allure-pytest-bdd/src/utils.py +++ b/allure-pytest-bdd/src/utils.py @@ -347,6 +347,10 @@ def global_error(lifecycle, message, trace=None): lifecycle.global_error(message=message, trace=trace) +def environment(lifecycle, env): + lifecycle.environment(env) + + def format_csv(rows): with io.StringIO() as buffer: writer = csv.writer(buffer) diff --git a/allure-pytest/examples/environment/environment.rst b/allure-pytest/examples/environment/environment.rst new file mode 100644 index 00000000..36d2bb30 --- /dev/null +++ b/allure-pytest/examples/environment/environment.rst @@ -0,0 +1,41 @@ +Environment +----------- + +Reports can display information about the environment the tests were executed in, such as the operating system, the +browser or the address of the test stand. It is shown on the report's overview page and is provided with invocation of +``allure.environment(*args, **kwargs)``. + +Values may be passed as keyword arguments: + + >>> import allure + + >>> def test_environment_from_keywords(): + ... allure.environment(browser="chrome", stand="staging") + +A mapping may be passed instead, which allows keys that are not valid python identifiers: + + >>> def test_environment_from_mapping(): + ... allure.environment({"os.name": "Windows 11", "browser version": "125.0"}) + +Both forms may be combined in a single call: + + >>> def test_environment_from_mapping_and_keywords(): + ... allure.environment({"python.version": "3.13.0"}, stand="production") + +Values from all the calls made during the test run are merged together, so the environment may be filled in from +different places, for example, from a session-scoped fixture: + + >>> import pytest + + >>> @pytest.fixture(scope="session") + ... def host_name(): + ... allure.environment(hostname="my.host.local") + ... return "my.host.local" + + >>> def test_environment_from_fixture(host_name): + ... pass + +A key set more than once keeps the value of the latest call. + +The data is stored in the ``environment.properties`` file in the allure results directory. Note that the calls are only +recorded once the allure results directory is set up, i.e., starting from the ``pytest_sessionstart`` hook. diff --git a/allure-pytest/src/listener.py b/allure-pytest/src/listener.py index 10ec29df..be59e1fc 100644 --- a/allure-pytest/src/listener.py +++ b/allure-pytest/src/listener.py @@ -281,6 +281,10 @@ def global_attach_file(self, source, name, attachment_type, extension): def global_error(self, message, trace): self.allure_logger.global_error(message=message, trace=trace) + @allure_commons.hookimpl + def add_environment(self, env): + self.allure_logger.environment(env) + @allure_commons.hookimpl def add_title(self, test_title): test_result = self.allure_logger.get_test(None) diff --git a/allure-python-commons-test/src/report.py b/allure-python-commons-test/src/report.py index 1db9413d..f361e9a3 100644 --- a/allure-python-commons-test/src/report.py +++ b/allure-python-commons-test/src/report.py @@ -77,6 +77,37 @@ from hamcrest import only_contains from hamcrest.core.base_matcher import BaseMatcher +ENVIRONMENT_FILE = "environment.properties" +UNESCAPES = {"n": "\n", "r": "\r", "t": "\t"} + + +def parse_properties(content): + """Parses a java-style .properties content into (key, value) pairs.""" + + for line in content.splitlines(): + line = line.lstrip() + if not line or line.startswith(("#", "!")): + continue + name, value = "", "" + target, escaped = "name", False + for character in line: + if escaped: + character = UNESCAPES.get(character, character) + elif character == "\\": + escaped = True + continue + elif target == "name" and character in ("=", ":"): + target = "value" + continue + elif target == "value" and not value and character in (" ", "\t"): + continue + escaped = False + if target == "name": + name += character + else: + value += character + yield name, value + class AllureReport: def __init__(self, result): @@ -105,6 +136,12 @@ def __init__(self, result): "*globals.json" ) ] + self.environment = dict( + item for _, file in self._report_items( + result, + ENVIRONMENT_FILE + ) for item in parse_properties(file.read()) + ) @staticmethod def _report_items(report_dir, glob): diff --git a/allure-python-commons/src/allure/__init__.py b/allure-python-commons/src/allure/__init__.py index 6fe2f270..a2b7f7dc 100644 --- a/allure-python-commons/src/allure/__init__.py +++ b/allure-python-commons/src/allure/__init__.py @@ -12,6 +12,7 @@ from allure_commons._allure import attach from allure_commons._allure import global_attach from allure_commons._allure import global_error +from allure_commons._allure import environment from allure_commons._allure import manual from allure_commons.types import Severity as severity_level from allure_commons.types import AttachmentType as attachment_type @@ -42,6 +43,7 @@ "attach", "global_attach", "global_error", + "environment", "attachment_type", "parameter_mode" ] diff --git a/allure-python-commons/src/allure_commons/_allure.py b/allure-python-commons/src/allure_commons/_allure.py index 607e1cb8..1a16ecfc 100644 --- a/allure-python-commons/src/allure_commons/_allure.py +++ b/allure-python-commons/src/allure_commons/_allure.py @@ -259,6 +259,10 @@ def global_error(value, trace=None): plugin_manager.hook.global_error(message=message, trace=trace) +def environment(*args, **kwargs): + plugin_manager.hook.add_environment(env=dict(*args, **kwargs)) + + class fixture: def __init__(self, fixture_function, parent_uuid=None, name=None): self._fixture_function = fixture_function diff --git a/allure-python-commons/src/allure_commons/_hooks.py b/allure-python-commons/src/allure_commons/_hooks.py index 84e916d9..ca542951 100644 --- a/allure-python-commons/src/allure_commons/_hooks.py +++ b/allure-python-commons/src/allure_commons/_hooks.py @@ -78,6 +78,10 @@ def global_attach_file(self, source, name, attachment_type, extension): def global_error(self, message, trace): """ global error """ + @hookspec + def add_environment(self, env): + """ environment """ + class AllureDeveloperHooks: @@ -116,3 +120,7 @@ def report_attached_data(self, body, file_name): @hookspec def report_globals(self, globals_item): """ reporting """ + + @hookspec + def report_environment(self, env): + """ reporting """ diff --git a/allure-python-commons/src/allure_commons/lifecycle.py b/allure-python-commons/src/allure_commons/lifecycle.py index e2c2251e..a60b74c8 100644 --- a/allure-python-commons/src/allure_commons/lifecycle.py +++ b/allure-python-commons/src/allure_commons/lifecycle.py @@ -173,6 +173,9 @@ def global_error(self, message=None, trace=None): GlobalError(message=message, trace=trace, timestamp=now()) ])) + def environment(self, env): + plugin_manager.hook.report_environment(env=env) + def __resolve_attachment_filename_and_type(self, uuid, attachment_type=None, extension=None): mime_type = attachment_type extension = extension if extension else "attach" diff --git a/allure-python-commons/src/allure_commons/logger.py b/allure-python-commons/src/allure_commons/logger.py index 4345bc77..87894e38 100644 --- a/allure-python-commons/src/allure_commons/logger.py +++ b/allure-python-commons/src/allure_commons/logger.py @@ -6,6 +6,8 @@ import shutil from attr import asdict from allure_commons import hookimpl +from allure_commons.model2 import ENVIRONMENT_FILE +from allure_commons.utils import format_properties INDENT = 4 @@ -13,6 +15,7 @@ class AllureFileLogger: def __init__(self, report_dir, clean=False): + self._environment = {} self._report_dir = Path(report_dir).absolute() if self._report_dir.is_dir() and clean: shutil.rmtree(self._report_dir, ignore_errors=True) @@ -51,6 +54,14 @@ def report_attached_data(self, body, file_name): def report_globals(self, globals_item): self._report_item(globals_item) + @hookimpl + def report_environment(self, env): + if not env: + return + self._environment.update(env) + with io.open(self._report_dir / ENVIRONMENT_FILE, "w", encoding="utf8") as environment_file: + environment_file.write(format_properties(self._environment)) + class AllureMemoryLogger: @@ -59,6 +70,7 @@ def __init__(self): self.test_containers = [] self.attachments = {} self.globals = [] + self.environment = {} @hookimpl def report_result(self, result): @@ -82,3 +94,7 @@ def report_attached_data(self, body, file_name): def report_globals(self, globals_item): data = asdict(globals_item, filter=lambda _, v: v or v is False) self.globals.append(data) + + @hookimpl + def report_environment(self, env): + self.environment.update(env) diff --git a/allure-python-commons/src/allure_commons/model2.py b/allure-python-commons/src/allure_commons/model2.py index cd069b17..80af27f9 100644 --- a/allure-python-commons/src/allure_commons/model2.py +++ b/allure-python-commons/src/allure_commons/model2.py @@ -6,6 +6,7 @@ TEST_CASE_PATTERN = "{prefix}-result.json" ATTACHMENT_PATTERN = "{prefix}-attachment.{ext}" GLOBALS_PATTERN = "{prefix}-globals.json" +ENVIRONMENT_FILE = "environment.properties" INDENT = 4 diff --git a/allure-python-commons/src/allure_commons/reporter.py b/allure-python-commons/src/allure_commons/reporter.py index 7e7b7594..88c5b7b7 100644 --- a/allure-python-commons/src/allure_commons/reporter.py +++ b/allure-python-commons/src/allure_commons/reporter.py @@ -177,6 +177,9 @@ def global_error(self, message=None, trace=None): GlobalError(message=message, trace=trace, timestamp=now()) ])) + def environment(self, env): + plugin_manager.hook.report_environment(env=env) + def __resolve_attachment_filename_and_type(self, uuid, attachment_type=None, extension=None): mime_type = attachment_type extension = extension if extension else "attach" diff --git a/allure-python-commons/src/allure_commons/utils.py b/allure-python-commons/src/allure_commons/utils.py index c50cb7ec..2af8f188 100644 --- a/allure-python-commons/src/allure_commons/utils.py +++ b/allure-python-commons/src/allure_commons/utils.py @@ -14,6 +14,8 @@ from traceback import format_exception_only +PROPERTY_KEY_SPECIAL_CHARACTERS = ("=", ":", " ", "\t", "#", "!") + def md5(*args): m = hashlib.md5() @@ -272,6 +274,29 @@ def format_traceback(exc_traceback): return "".join(traceback.format_tb(exc_traceback)) if exc_traceback else None +def format_properties(properties): + """ + >>> format_properties({"browser": "chrome", "os name": "Windows 11"}) + 'browser=chrome\\nos\\\\ name=Windows 11\\n' + + """ + + return "".join( + f"{escape_property(name, is_key=True)}={escape_property(value)}\n" + for name, value in properties.items() + ) + + +def escape_property(value, is_key=False): + value = str(value).replace("\\", "\\\\").replace("\n", "\\n").replace("\r", "\\r") + if is_key: + for character in PROPERTY_KEY_SPECIAL_CHARACTERS: + value = value.replace(character, f"\\{character}") + elif value.startswith((" ", "\t")): + value = f"\\{value}" + return value + + def format_exception(etype, value): """ >>> import sys diff --git a/allure-robotframework/src/library/__init__.py b/allure-robotframework/src/library/__init__.py index d21bce6b..1436ee7d 100644 --- a/allure-robotframework/src/library/__init__.py +++ b/allure-robotframework/src/library/__init__.py @@ -1,3 +1,4 @@ from .allure_library import attach, attach_file, global_attach, global_attach_file, global_error +from .allure_library import environment -__all__ = ["attach", "attach_file", "global_attach", "global_attach_file", "global_error"] +__all__ = ["attach", "attach_file", "global_attach", "global_attach_file", "global_error", "environment"] diff --git a/allure-robotframework/src/library/allure_library.py b/allure-robotframework/src/library/allure_library.py index e9d4f7b1..61952e67 100644 --- a/allure-robotframework/src/library/allure_library.py +++ b/allure-robotframework/src/library/allure_library.py @@ -1,7 +1,7 @@ import allure -__all__ = ["attach", "attach_file", "global_attach", "global_attach_file", "global_error"] +__all__ = ["attach", "attach_file", "global_attach", "global_attach_file", "global_error", "environment"] def _attachment_type(name): @@ -29,3 +29,7 @@ def global_attach_file(source, name=None, attachment_type=None, extension=None): def global_error(message, trace=None): allure.global_error(message, trace=trace) + + +def environment(**kwargs): + allure.environment(**kwargs) diff --git a/allure-robotframework/src/listener/allure_listener.py b/allure-robotframework/src/listener/allure_listener.py index 6be71a19..5d096416 100644 --- a/allure-robotframework/src/listener/allure_listener.py +++ b/allure-robotframework/src/listener/allure_listener.py @@ -263,6 +263,10 @@ def global_attach_file(self, source, name, attachment_type, extension): def global_error(self, message, trace): self.lifecycle.global_error(message=message, trace=trace) + @allure_commons.hookimpl + def add_environment(self, env): + self.lifecycle.environment(env) + @allure_commons.hookimpl def start_step(self, uuid, title, params): with self.lifecycle.start_step() as step: diff --git a/tests/allure_behave/acceptance/allure_api/environment/__init__.py b/tests/allure_behave/acceptance/allure_api/environment/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/allure_behave/acceptance/allure_api/environment/environment_test.py b/tests/allure_behave/acceptance/allure_api/environment/environment_test.py new file mode 100644 index 00000000..24f3168c --- /dev/null +++ b/tests/allure_behave/acceptance/allure_api/environment/environment_test.py @@ -0,0 +1,38 @@ +import textwrap +from tests.allure_behave.behave_runner import AllureBehaveRunner as Runner +from hamcrest import assert_that, has_entries + + +def test_environment_from_hooks(behave_runner: Runner): + behave_runner.run_behave( + feature_literals=[ + """ + Feature: Environment + Scenario: Environment from hooks + Given noop + """ + ], + step_literals=["given('noop')(lambda c: None)"], + environment_literal=textwrap.dedent( + """ + import allure + + + def before_all(context): + allure.environment(browser="chrome", stand="staging") + + + def after_all(context): + allure.environment({"os.name": "Windows 11"}, browser="firefox") + """ + ), + ) + + assert_that( + behave_runner.allure_results.environment, + has_entries({ + "browser": "firefox", + "stand": "staging", + "os.name": "Windows 11", + }), + ) diff --git a/tests/allure_pytest/acceptance/environment/__init__.py b/tests/allure_pytest/acceptance/environment/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/allure_pytest/acceptance/environment/environment_test.py b/tests/allure_pytest/acceptance/environment/environment_test.py new file mode 100644 index 00000000..c36fc7be --- /dev/null +++ b/tests/allure_pytest/acceptance/environment/environment_test.py @@ -0,0 +1,71 @@ +""" ./allure-pytest/examples/environment/environment.rst """ +from hamcrest import assert_that, has_entries, empty +from tests.allure_pytest.pytest_runner import AllurePytestRunner + + +def test_environment_from_keywords(allure_pytest_runner: AllurePytestRunner): + allure_results = allure_pytest_runner.run_docpath_examples(cache=True) + + assert_that( + allure_results.environment, + has_entries(browser="chrome", stand="production"), + ) + + +def test_environment_from_mapping(allure_pytest_runner: AllurePytestRunner): + allure_results = allure_pytest_runner.run_docpath_examples(cache=True) + + assert_that( + allure_results.environment, + has_entries({ + "os.name": "Windows 11", + "browser version": "125.0", + "python.version": "3.13.0", + }), + ) + + +def test_environment_from_fixture(allure_pytest_runner: AllurePytestRunner): + allure_results = allure_pytest_runner.run_docpath_examples(cache=True) + + assert_that( + allure_results.environment, + has_entries(hostname="my.host.local"), + ) + + +def test_environment_from_session_hook(allure_pytest_runner: AllurePytestRunner): + allure_results = allure_pytest_runner.run_pytest( + """ + def test_environment_from_session_hook(): + pass + """, + conftest_literal=( + """ + import allure + + + def pytest_sessionstart(session): + allure.environment(report="Allure report") + """ + ) + ) + + assert_that( + allure_results.environment, + has_entries(report="Allure report"), + ) + + +def test_empty_environment(allure_pytest_runner: AllurePytestRunner): + allure_results = allure_pytest_runner.run_pytest( + """ + import allure + + + def test_empty_environment(): + allure.environment() + """ + ) + + assert_that(allure_results.environment, empty()) diff --git a/tests/allure_pytest/unit/environment_file_test.py b/tests/allure_pytest/unit/environment_file_test.py new file mode 100644 index 00000000..69095741 --- /dev/null +++ b/tests/allure_pytest/unit/environment_file_test.py @@ -0,0 +1,69 @@ +from allure_commons.logger import AllureFileLogger +from allure_commons.model2 import ENVIRONMENT_FILE +from allure_commons_test.report import parse_properties + + +def read_environment(report_dir): + path = report_dir / ENVIRONMENT_FILE + return dict(parse_properties(path.read_text(encoding="utf-8"))) + + +def test_environment_is_written(tmp_path): + logger = AllureFileLogger(tmp_path) + + logger.report_environment({"browser": "chrome", "env": "staging"}) + + assert read_environment(tmp_path) == {"browser": "chrome", "env": "staging"} + + +def test_environment_is_merged(tmp_path): + logger = AllureFileLogger(tmp_path) + + logger.report_environment({"browser": "chrome", "env": "staging"}) + logger.report_environment({"browser": "firefox", "version": "1.2.3"}) + + assert read_environment(tmp_path) == { + "browser": "firefox", + "env": "staging", + "version": "1.2.3" + } + + +def test_special_characters_are_escaped(tmp_path): + environment = { + "os.name": "Windows 11", + "key with spaces": "value with spaces", + "key=with=separators": "a\nb", + "key:with:colons": "c:\\temp", + "back\\slash": "d\\e" + } + logger = AllureFileLogger(tmp_path) + + logger.report_environment(environment) + + assert read_environment(tmp_path) == environment + + +def test_surrounding_whitespace_is_preserved(tmp_path): + environment = {"leading": " value", "trailing": "value ", "tab": "\tvalue"} + logger = AllureFileLogger(tmp_path) + + logger.report_environment(environment) + + assert read_environment(tmp_path) == environment + + +def test_non_ascii_values_are_written(tmp_path): + logger = AllureFileLogger(tmp_path) + + logger.report_environment({"browser": "Я.Браузер"}) + + assert read_environment(tmp_path) == {"browser": "Я.Браузер"} + + +def test_empty_environment_creates_no_file(tmp_path): + logger = AllureFileLogger(tmp_path) + + logger.report_environment({}) + + assert not (tmp_path / ENVIRONMENT_FILE).exists() diff --git a/tests/allure_pytest_bdd/acceptance/environment_test.py b/tests/allure_pytest_bdd/acceptance/environment_test.py new file mode 100644 index 00000000..fed88fb6 --- /dev/null +++ b/tests/allure_pytest_bdd/acceptance/environment_test.py @@ -0,0 +1,52 @@ +from hamcrest import assert_that +from hamcrest import has_entries + +from tests.allure_pytest.pytest_runner import AllurePytestRunner + + +def test_environment_from_hook_and_step(allure_pytest_bdd_runner: AllurePytestRunner): + feature_content = ( + """ + Feature: Foo + Scenario: Bar + Given noop + """ + ) + steps_content = ( + """ + import allure + + from pytest_bdd import scenario, given + + @scenario("sample.feature", "Bar") + def test_scenario(): + pass + + @given("noop") + def given_noop(): + allure.environment({"os.name": "Windows 11"}, browser="firefox") + """ + ) + conftest_content = ( + """ + import allure + + def pytest_sessionstart(session): + allure.environment(browser="chrome", stand="staging") + """ + ) + + allure_results = allure_pytest_bdd_runner.run_pytest( + ("sample.feature", feature_content), + steps_content, + conftest_literal=conftest_content, + ) + + assert_that( + allure_results.environment, + has_entries({ + "browser": "firefox", + "stand": "staging", + "os.name": "Windows 11", + }), + ) diff --git a/tests/allure_robotframework/acceptance/allure_api/environment/__init__.py b/tests/allure_robotframework/acceptance/allure_api/environment/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/allure_robotframework/acceptance/allure_api/environment/environment_test.py b/tests/allure_robotframework/acceptance/allure_api/environment/environment_test.py new file mode 100644 index 00000000..d2cd55e1 --- /dev/null +++ b/tests/allure_robotframework/acceptance/allure_api/environment/environment_test.py @@ -0,0 +1,40 @@ +from hamcrest import assert_that, has_entries +from tests.allure_robotframework.robot_runner import AllureRobotRunner + + +def test_environment_from_keyword_and_code(robot_runner: AllureRobotRunner): + robot_runner.run_robotframework( + suite_literals={ + "environment.robot": ( + """ + *** Settings *** + Library AllureLibrary + Library ./lib.py + Suite Setup Environment browser=chrome stand=staging + + *** Test Cases *** + Environment + Add Environment From Code + """ + ), + }, + library_literals={ + "lib.py": ( + """ + import allure + + def add_environment_from_code(): + allure.environment({"os.name": "Windows 11"}, browser="firefox") + """ + ), + }, + ) + + assert_that( + robot_runner.allure_results.environment, + has_entries({ + "browser": "firefox", + "stand": "staging", + "os.name": "Windows 11", + }), + )