From 48e85ee2f57cf37d26ac616bd0de8391163680a2 Mon Sep 17 00:00:00 2001 From: AdamerGitHub Date: Sat, 22 Aug 2026 17:55:03 +0700 Subject: [PATCH 1/2] Add allure.environment API --- allure-behave/src/listener.py | 4 ++ allure-pytest-bdd/src/allure_api_listener.py | 5 ++ allure-pytest-bdd/src/utils.py | 4 ++ .../examples/environment/environment.rst | 41 +++++++++++ allure-pytest/src/listener.py | 4 ++ allure-python-commons-test/src/report.py | 34 +++++++++ allure-python-commons/src/allure/__init__.py | 2 + .../src/allure_commons/_allure.py | 4 ++ .../src/allure_commons/_hooks.py | 8 +++ .../src/allure_commons/lifecycle.py | 3 + .../src/allure_commons/logger.py | 16 +++++ .../src/allure_commons/model2.py | 1 + .../src/allure_commons/reporter.py | 3 + .../src/allure_commons/utils.py | 23 ++++++ allure-robotframework/src/library/__init__.py | 3 +- .../src/library/allure_library.py | 6 +- .../src/listener/allure_listener.py | 4 ++ .../allure_api/environment/__init__.py | 0 .../environment/environment_test.py | 38 ++++++++++ .../acceptance/environment/__init__.py | 0 .../environment/environment_test.py | 71 +++++++++++++++++++ .../unit/environment_file_test.py | 60 ++++++++++++++++ .../acceptance/environment_test.py | 52 ++++++++++++++ .../allure_api/environment/__init__.py | 0 .../environment/environment_test.py | 40 +++++++++++ 25 files changed, 424 insertions(+), 2 deletions(-) create mode 100644 allure-pytest/examples/environment/environment.rst create mode 100644 tests/allure_behave/acceptance/allure_api/environment/__init__.py create mode 100644 tests/allure_behave/acceptance/allure_api/environment/environment_test.py create mode 100644 tests/allure_pytest/acceptance/environment/__init__.py create mode 100644 tests/allure_pytest/acceptance/environment/environment_test.py create mode 100644 tests/allure_pytest/unit/environment_file_test.py create mode 100644 tests/allure_pytest_bdd/acceptance/environment_test.py create mode 100644 tests/allure_robotframework/acceptance/allure_api/environment/__init__.py create mode 100644 tests/allure_robotframework/acceptance/allure_api/environment/environment_test.py 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..3026eba6 100644 --- a/allure-python-commons-test/src/report.py +++ b/allure-python-commons-test/src/report.py @@ -77,6 +77,34 @@ 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(): + 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 + escaped = False + if target == "name": + name += character + else: + value += character + yield name.strip(), value.strip() + class AllureReport: def __init__(self, result): @@ -105,6 +133,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..93e5b758 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,27 @@ 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}") + 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..056e95b2 --- /dev/null +++ b/tests/allure_pytest/unit/environment_file_test.py @@ -0,0 +1,60 @@ +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_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", + }), + ) From bf403a01d8dbef5a1f6657af37202dc0fba90b37 Mon Sep 17 00:00:00 2001 From: AdamerGitHub Date: Sat, 22 Aug 2026 23:52:52 +0700 Subject: [PATCH 2/2] Preserve surrounding whitespace in environment values --- allure-python-commons-test/src/report.py | 5 ++++- allure-python-commons/src/allure_commons/utils.py | 2 ++ tests/allure_pytest/unit/environment_file_test.py | 9 +++++++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/allure-python-commons-test/src/report.py b/allure-python-commons-test/src/report.py index 3026eba6..f361e9a3 100644 --- a/allure-python-commons-test/src/report.py +++ b/allure-python-commons-test/src/report.py @@ -85,6 +85,7 @@ 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 = "", "" @@ -98,12 +99,14 @@ def parse_properties(content): 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.strip(), value.strip() + yield name, value class AllureReport: diff --git a/allure-python-commons/src/allure_commons/utils.py b/allure-python-commons/src/allure_commons/utils.py index 93e5b758..2af8f188 100644 --- a/allure-python-commons/src/allure_commons/utils.py +++ b/allure-python-commons/src/allure_commons/utils.py @@ -292,6 +292,8 @@ def escape_property(value, is_key=False): 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 diff --git a/tests/allure_pytest/unit/environment_file_test.py b/tests/allure_pytest/unit/environment_file_test.py index 056e95b2..69095741 100644 --- a/tests/allure_pytest/unit/environment_file_test.py +++ b/tests/allure_pytest/unit/environment_file_test.py @@ -44,6 +44,15 @@ def test_special_characters_are_escaped(tmp_path): 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)