From 833cce301b93bd7c3ba5d6c561a313878201575d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Hirsz?= Date: Thu, 3 Jul 2025 18:06:32 +0200 Subject: [PATCH 1/3] Release upper bound of click dependency --- robotidy/version.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/robotidy/version.py b/robotidy/version.py index b80decf2..dfdbb7a0 100644 --- a/robotidy/version.py +++ b/robotidy/version.py @@ -1 +1 @@ -__version__ = "4.17.0" +__version__ = "4.18.0" diff --git a/setup.py b/setup.py index a01cd62b..411076f1 100644 --- a/setup.py +++ b/setup.py @@ -42,7 +42,7 @@ python_requires=">=3.8", install_requires=[ "robotframework>=4.0", - "click==8.1.*", + "click>=8.1", "colorama>=0.4.3", "pathspec>=0.9.0", "tomli>=2.0", From 0d803538b0c40829ce1953b3751fbb642a3c8517 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Hirsz?= Date: Thu, 3 Jul 2025 18:13:59 +0200 Subject: [PATCH 2/3] Fix CliRunner usage after click==8.2 upgrade --- setup.py | 1 + tests/atest/__init__.py | 322 ++++++++++----------- tests/e2e/test_transform_stability.py | 396 +++++++++++++------------- tests/utest/utils.py | 58 ++-- 4 files changed, 389 insertions(+), 388 deletions(-) diff --git a/setup.py b/setup.py index 411076f1..824d0667 100644 --- a/setup.py +++ b/setup.py @@ -51,6 +51,7 @@ ], extras_require={ "dev": [ + "click>=8.2", # 8.2 change signature of CliRunner used in test "coverage", "invoke", "jinja2", diff --git a/tests/atest/__init__.py b/tests/atest/__init__.py index a37ce9ce..4b33e7bf 100644 --- a/tests/atest/__init__.py +++ b/tests/atest/__init__.py @@ -1,161 +1,161 @@ -from __future__ import annotations - -import filecmp -import shutil -from difflib import unified_diff -from pathlib import Path - -import pytest -from click.testing import CliRunner -from packaging import version -from packaging.specifiers import SpecifierSet -from rich.console import Console -from robot.version import VERSION as RF_VERSION - -from robotidy.cli import cli -from robotidy.utils.misc import decorate_diff_with_color - -VERSION_MATRIX = {"ReplaceReturns": 5, "InlineIf": 5, "ReplaceBreakContinue": 5, "Translate": 6, "ReplaceWithVAR": 7} -ROBOT_VERSION = version.parse(RF_VERSION) - - -def display_file_diff(expected, actual): - print("\nExpected file after transforming does not match actual") - with open(expected, encoding="utf-8") as f, open(actual, encoding="utf-8") as f2: - expected_lines = f.readlines() - actual_lines = f2.readlines() - lines = [ - line - for line in unified_diff( - expected_lines, actual_lines, fromfile=f"expected: {expected}\t", tofile=f"actual: {actual}\t" - ) - ] - colorized_output = decorate_diff_with_color(lines) - console = Console(color_system="windows", width=400) - for line in colorized_output: - console.print(line, end="", highlight=False) - - -class TransformerAcceptanceTest: - TRANSFORMER_NAME: str = "PLACEHOLDER" - TRANSFORMERS_DIR = Path(__file__).parent / "transformers" - - def compare( - self, - source: str, - not_modified: bool = False, - expected: str | None = None, - config: str = "", - target_version: str | None = None, - run_all: bool = False, - ): - """ - Compare actual (source) and expected files. If expected filename is not provided it's assumed to be the same - as source. - - Use not_modified flag if the content of the file shouldn't be modified by transformer. - """ - if expected is None: - expected = source - if run_all: - run_cmd = config - else: - run_cmd = f"--transform {self.TRANSFORMER_NAME}{config}" - self.run_tidy(args=run_cmd.split(), source=source, not_modified=not_modified, target_version=target_version) - if not not_modified: - self.compare_file(source, expected) - - def run_tidy( - self, - args: list[str] = None, - source: str = None, - exit_code: int = 0, - not_modified: bool = False, - target_version: str | None = None, - ): - if not self.enabled_in_version(target_version): - pytest.skip(f"Test enabled only for RF {target_version}") - runner = CliRunner(mix_stderr=False) - output_path = str(self.TRANSFORMERS_DIR / "actual" / source) - arguments = ["--output", output_path] - if not_modified: - arguments.extend(["--check", "--overwrite"]) - if args is not None: - arguments += args - if source is None: - source_path = str(self.TRANSFORMERS_DIR / self.TRANSFORMER_NAME / "source") - else: - source_path = str(self.TRANSFORMERS_DIR / self.TRANSFORMER_NAME / "source" / source) - cmd = arguments + [source_path] - result = runner.invoke(cli, cmd) - if result.exit_code != exit_code: - raise AssertionError( - f"robotidy exit code: {result.exit_code} does not match expected: {exit_code}. " - f"Exception description: {result.exception}" - ) - return result - - def compare_file(self, actual_name: str, expected_name: str = None): - if expected_name is None: - expected_name = actual_name - expected = self.TRANSFORMERS_DIR / self.TRANSFORMER_NAME / "expected" / expected_name - actual = self.TRANSFORMERS_DIR / "actual" / actual_name - if not filecmp.cmp(expected, actual): - display_file_diff(expected, actual) - pytest.fail(f"File {actual_name} is not same as expected") - - def enabled_in_version(self, target_version: str | None) -> bool: - if target_version and ROBOT_VERSION not in SpecifierSet(target_version, prereleases=True): - return False - if self.TRANSFORMER_NAME in VERSION_MATRIX: - return ROBOT_VERSION.major >= VERSION_MATRIX[self.TRANSFORMER_NAME] - return True - - -class MultipleConfigsTest: - TEST_DIR: str = "PLACEHOLDER" - ROOT_DIR = Path(__file__).parent / "configuration_files" - - def run_tidy(self, tmpdir, args: list[str] | None = None, exit_code: int = 0, not_modified: bool = False): - runner = CliRunner(mix_stderr=False) - temporary_dir = tmpdir / self.TEST_DIR - shutil.copytree(self.ROOT_DIR / self.TEST_DIR / "source", temporary_dir) - arguments = [] - if not_modified: - arguments.extend(["--check", "--overwrite"]) - if args is not None: - arguments += args - cmd = arguments + [str(temporary_dir)] - result = runner.invoke(cli, cmd) - if result.exit_code != exit_code: - raise AssertionError( - f"robotidy exit code: {result.exit_code} does not match expected: {exit_code}. " - f"Exception description: {result.exception}" - ) - - def compare_files(self, tmpdir, expected_dir: str): - expected = self.ROOT_DIR / self.TEST_DIR / expected_dir - actual = Path(tmpdir / self.TEST_DIR) - if compare_file_tree(expected, actual): - pytest.fail(f"Files in expected file tree: {expected} and the actual are not the same.") - - -def compare_file_tree(actual_dir: Path, expected_dir: Path) -> bool: - error = False - actual_files = {x.name: x for x in actual_dir.iterdir()} - expected_files = {x.name: x for x in expected_dir.iterdir()} - for exp_file_name, exp_file in expected_files.items(): - if exp_file_name in actual_files: - if exp_file.is_dir(): - error = compare_file_tree(actual_files[exp_file_name], exp_file) or error - elif not filecmp.cmp(exp_file, actual_files[exp_file_name]): - error = True - display_file_diff(exp_file, actual_files[exp_file_name]) - else: - error = True - print(f"File {exp_file} not found in the actual files.") - for actual_file_name, actual_file in actual_files.items(): - if actual_file_name not in expected_files: - error = True - print(f"Extra {actual_file} found in the actual files.") - return error +from __future__ import annotations + +import filecmp +import shutil +from difflib import unified_diff +from pathlib import Path + +import pytest +from click.testing import CliRunner +from packaging import version +from packaging.specifiers import SpecifierSet +from rich.console import Console +from robot.version import VERSION as RF_VERSION + +from robotidy.cli import cli +from robotidy.utils.misc import decorate_diff_with_color + +VERSION_MATRIX = {"ReplaceReturns": 5, "InlineIf": 5, "ReplaceBreakContinue": 5, "Translate": 6, "ReplaceWithVAR": 7} +ROBOT_VERSION = version.parse(RF_VERSION) + + +def display_file_diff(expected, actual): + print("\nExpected file after transforming does not match actual") + with open(expected, encoding="utf-8") as f, open(actual, encoding="utf-8") as f2: + expected_lines = f.readlines() + actual_lines = f2.readlines() + lines = [ + line + for line in unified_diff( + expected_lines, actual_lines, fromfile=f"expected: {expected}\t", tofile=f"actual: {actual}\t" + ) + ] + colorized_output = decorate_diff_with_color(lines) + console = Console(color_system="windows", width=400) + for line in colorized_output: + console.print(line, end="", highlight=False) + + +class TransformerAcceptanceTest: + TRANSFORMER_NAME: str = "PLACEHOLDER" + TRANSFORMERS_DIR = Path(__file__).parent / "transformers" + + def compare( + self, + source: str, + not_modified: bool = False, + expected: str | None = None, + config: str = "", + target_version: str | None = None, + run_all: bool = False, + ): + """ + Compare actual (source) and expected files. If expected filename is not provided it's assumed to be the same + as source. + + Use not_modified flag if the content of the file shouldn't be modified by transformer. + """ + if expected is None: + expected = source + if run_all: + run_cmd = config + else: + run_cmd = f"--transform {self.TRANSFORMER_NAME}{config}" + self.run_tidy(args=run_cmd.split(), source=source, not_modified=not_modified, target_version=target_version) + if not not_modified: + self.compare_file(source, expected) + + def run_tidy( + self, + args: list[str] = None, + source: str = None, + exit_code: int = 0, + not_modified: bool = False, + target_version: str | None = None, + ): + if not self.enabled_in_version(target_version): + pytest.skip(f"Test enabled only for RF {target_version}") + runner = CliRunner() + output_path = str(self.TRANSFORMERS_DIR / "actual" / source) + arguments = ["--output", output_path] + if not_modified: + arguments.extend(["--check", "--overwrite"]) + if args is not None: + arguments += args + if source is None: + source_path = str(self.TRANSFORMERS_DIR / self.TRANSFORMER_NAME / "source") + else: + source_path = str(self.TRANSFORMERS_DIR / self.TRANSFORMER_NAME / "source" / source) + cmd = arguments + [source_path] + result = runner.invoke(cli, cmd) + if result.exit_code != exit_code: + raise AssertionError( + f"robotidy exit code: {result.exit_code} does not match expected: {exit_code}. " + f"Exception description: {result.exception}" + ) + return result + + def compare_file(self, actual_name: str, expected_name: str = None): + if expected_name is None: + expected_name = actual_name + expected = self.TRANSFORMERS_DIR / self.TRANSFORMER_NAME / "expected" / expected_name + actual = self.TRANSFORMERS_DIR / "actual" / actual_name + if not filecmp.cmp(expected, actual): + display_file_diff(expected, actual) + pytest.fail(f"File {actual_name} is not same as expected") + + def enabled_in_version(self, target_version: str | None) -> bool: + if target_version and ROBOT_VERSION not in SpecifierSet(target_version, prereleases=True): + return False + if self.TRANSFORMER_NAME in VERSION_MATRIX: + return ROBOT_VERSION.major >= VERSION_MATRIX[self.TRANSFORMER_NAME] + return True + + +class MultipleConfigsTest: + TEST_DIR: str = "PLACEHOLDER" + ROOT_DIR = Path(__file__).parent / "configuration_files" + + def run_tidy(self, tmpdir, args: list[str] | None = None, exit_code: int = 0, not_modified: bool = False): + runner = CliRunner() + temporary_dir = tmpdir / self.TEST_DIR + shutil.copytree(self.ROOT_DIR / self.TEST_DIR / "source", temporary_dir) + arguments = [] + if not_modified: + arguments.extend(["--check", "--overwrite"]) + if args is not None: + arguments += args + cmd = arguments + [str(temporary_dir)] + result = runner.invoke(cli, cmd) + if result.exit_code != exit_code: + raise AssertionError( + f"robotidy exit code: {result.exit_code} does not match expected: {exit_code}. " + f"Exception description: {result.exception}" + ) + + def compare_files(self, tmpdir, expected_dir: str): + expected = self.ROOT_DIR / self.TEST_DIR / expected_dir + actual = Path(tmpdir / self.TEST_DIR) + if compare_file_tree(expected, actual): + pytest.fail(f"Files in expected file tree: {expected} and the actual are not the same.") + + +def compare_file_tree(actual_dir: Path, expected_dir: Path) -> bool: + error = False + actual_files = {x.name: x for x in actual_dir.iterdir()} + expected_files = {x.name: x for x in expected_dir.iterdir()} + for exp_file_name, exp_file in expected_files.items(): + if exp_file_name in actual_files: + if exp_file.is_dir(): + error = compare_file_tree(actual_files[exp_file_name], exp_file) or error + elif not filecmp.cmp(exp_file, actual_files[exp_file_name]): + error = True + display_file_diff(exp_file, actual_files[exp_file_name]) + else: + error = True + print(f"File {exp_file} not found in the actual files.") + for actual_file_name, actual_file in actual_files.items(): + if actual_file_name not in expected_files: + error = True + print(f"Extra {actual_file} found in the actual files.") + return error diff --git a/tests/e2e/test_transform_stability.py b/tests/e2e/test_transform_stability.py index 08397bc5..acbad20d 100644 --- a/tests/e2e/test_transform_stability.py +++ b/tests/e2e/test_transform_stability.py @@ -1,198 +1,198 @@ -from __future__ import annotations - -import functools -import shutil -from pathlib import Path - -import pytest -from click.testing import CliRunner - -from robotidy.cli import cli -from robotidy.transformers import TransformConfigMap, load_transformers -from robotidy.utils.misc import ROBOT_VERSION - -RERUN_NEEDED_4 = { - "RenameKeywords": {"run_keywords": 2, "disablers": 2}, - "ReplaceReturns": {"replace_returns_disablers": 2, "return_from_keyword_if": 2}, -} -# Transformer_name: {"name of test file from atest": "reruns until stable"} -RERUN_NEEDED = { - "AddMissingEnd": {"test": 2}, - "AlignKeywordsSection": {"blocks_rf5": 2, "non_ascii_spaces": 2}, - "AlignSettingsSection": {"blank_line_doc": 2, "multiline_keywords": 2}, - "AlignTemplatedTestCases": {"with_settings": 2}, - "AlignTestCasesSection": { - "blocks_rf5": 2, - "compact_overflow_bug": 2, - "dynamic_compact_overflow": 2, - "dynamic_compact_overflow_limit_1": 2, - "non_ascii_spaces": 2, - "skip_keywords": 2, - "overflow_first_line": 2, - "skip_return_values_overflow": 2, - }, - "IndentNestedKeywords": {"run_keyword": 2}, - "InlineIf": { - "invalid_if": 2, - "invalid_inline_if": 3, - "test": 2, - "test_disablers": 2, - }, - "MergeAndOrderSections": {"disablers": 3, "parsing_error": 2, "translated": 2, "tests": 3}, - "NormalizeNewLines": {"tests": 2, "multiline": 2}, - "NormalizeSeparators": {"continuation_indent": 2, "test": 2, "disablers": 2, "pipes": 2}, - "NormalizeSettingName": {"disablers": 2, "translated": 2, "tests": 2}, - "NormalizeTags": {"disablers": 2, "duplicates": 2, "tests": 2, "preserve_format": 2}, - "OrderSettings": {"test": 2, "translated": 2}, - "OrderSettingsSection": {"test": 2}, - "OrderTags": {"tests": 2}, - "RemoveEmptySettings": {"empty": 2, "disablers": 2, "overwritten": 2}, - "RenameKeywords": {"disablers": 2}, - "ReplaceBreakContinue": {"test": 2}, - "ReplaceReturns": { - "replace_returns_disablers": 2, - "return_from_keyword": 2, - "test": 2, - "return_from_keyword_if": 2, - }, - "ReplaceRunKeywordIf": { - "configure_whitespace": 2, - "disablers": 3, - "set_variable_workaround": 2, - "tests": 2, - "keyword_name_in_var": 2, - }, - "ReplaceWithVAR": {"invalid_inline_if": 2}, - "SmartSortKeywords": {"multiple_sections": 2, "sort_input": 2}, - "SplitTooLongLine": {"continuation_indent": 2, "disablers": 2, "tests": 2, "comments": 2, "settings": 2}, - "Translate": {"pl_language_header": 2}, -} -SKIP_TESTS_4 = {"ReplaceReturns": {"test"}, "GenerateDocumentation": {"test": 2}, "SplitTooLongLine": {"settings"}} -SKIP_TESTS = { - "ReplaceRunKeywordIf": {"invalid_data"}, - "SplitTooLongLine": {"variables"}, - "AlignKeywordsSection": "error_node", -} - - -def run_tidy(cmd, enable_disabled: bool): - if enable_disabled: - cmd = get_enable_disabled_config() + cmd - runner = CliRunner(mix_stderr=False) - return runner.invoke(cli, cmd) - - -def run_with_source(source: Path, enable_disabled: bool, reruns: int): - cmd = ["--reruns", str(reruns), str(source)] - result = run_tidy(cmd, enable_disabled) - if result.exit_code != 0: - raise AssertionError(f"Run failed for {source}") - - -def run_with_source_and_check(source: Path, orig: Path, enable_disabled: bool): - cmd = ["--diff", "--check", "--overwrite", str(source)] - result = run_tidy(cmd, enable_disabled) - if result.exit_code != 0: - print(result.output) - raise AssertionError(f"The code was modified for {orig}") - - -@functools.lru_cache(1) -def get_enable_disabled_config() -> list[str]: - """Returns config required to enable all disabled transformers.""" - - def is_transformer_disabled(transformer): - return not getattr(transformer, "ENABLED", True) - - transformers = load_transformers( - TransformConfigMap([], [], []), - allow_disabled=True, - target_version=ROBOT_VERSION.major, - allow_version_mismatch=False, - ) - config = [] - for transformer in transformers: - if not is_transformer_disabled(transformer.instance): - continue - config.extend(["--configure", f"{transformer.name}:enabled=True"]) - return config - - -def is_e2e_only(path: Path) -> bool: - return path.parent.parent.name == "e2e" - - -def get_test_attributes_from_path(path: Path) -> tuple[str, str]: - transformer = path.parent.parent.name - test_name = path.stem - return transformer, test_name - - -def should_be_skip(path: Path) -> bool: - """ - Checks if test file is in list of invalid data that cannot pass stability checks. - """ - if is_e2e_only(path): - return False - transformer, test_name = get_test_attributes_from_path(path) - if ROBOT_VERSION.major == 4 and transformer in SKIP_TESTS_4: - return test_name in SKIP_TESTS_4[transformer] - if transformer not in SKIP_TESTS: - return False - return test_name in SKIP_TESTS[transformer] - - -def reruns_needed(path: Path) -> int: - """ - Check if test data requires extra rerun. - An example would be for example missing END, which is added in the first run, - newly created blocks fixed in the second and third run should not modify code. - Returns number of reruns needed. - """ - if is_e2e_only(path): - return 1 - transformer, test_name = get_test_attributes_from_path(path) - if ROBOT_VERSION.major == 4 and transformer in RERUN_NEEDED_4: - return RERUN_NEEDED_4[transformer].get(test_name, 1) - if transformer in RERUN_NEEDED: - return RERUN_NEEDED[transformer].get(test_name, 1) - return 1 - - -def gen_test_data(): - """ - Yields list of test data paths. Paths are: - - *.robot files inside e2e/test_data directory - - *.robot files inside atest/*/source directories - """ - e2e_dir = Path(__file__).parent - atest_dir = e2e_dir.parent / "atest" / "transformers" - - yield from e2e_dir.rglob("test_data/*.robot") - for path in atest_dir.rglob("*/source/*.robot"): - yield path - - -def get_test_id_from_path(path) -> str: - """Generate test id from path to the source file.""" - test_name = path.stem - if path.parent.parent.name == "e2e": - return f"e2e_{test_name}" - transformer = path.parent.parent.name - return f"{transformer}_{test_name}" - - -@pytest.mark.e2e -@pytest.mark.parametrize("test_file", gen_test_data(), ids=get_test_id_from_path) -@pytest.mark.parametrize("enable_disabled", [False, True], ids=["defaults", "all"]) -def test_stability_of_transformation(tmpdir, test_file, enable_disabled): - if should_be_skip(test_file): - pytest.skip("Skip invalid test data") - reruns = reruns_needed(test_file) - # copy test data to temp directory - test_data_dst = tmpdir / test_file.name - shutil.copy(test_file, test_data_dst) - run_with_source(test_data_dst, enable_disabled, reruns) - for _ in range(2): - # rerun with --check twice to confirm stability - run_with_source_and_check(test_data_dst, test_file, enable_disabled) +from __future__ import annotations + +import functools +import shutil +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from robotidy.cli import cli +from robotidy.transformers import TransformConfigMap, load_transformers +from robotidy.utils.misc import ROBOT_VERSION + +RERUN_NEEDED_4 = { + "RenameKeywords": {"run_keywords": 2, "disablers": 2}, + "ReplaceReturns": {"replace_returns_disablers": 2, "return_from_keyword_if": 2}, +} +# Transformer_name: {"name of test file from atest": "reruns until stable"} +RERUN_NEEDED = { + "AddMissingEnd": {"test": 2}, + "AlignKeywordsSection": {"blocks_rf5": 2, "non_ascii_spaces": 2}, + "AlignSettingsSection": {"blank_line_doc": 2, "multiline_keywords": 2}, + "AlignTemplatedTestCases": {"with_settings": 2}, + "AlignTestCasesSection": { + "blocks_rf5": 2, + "compact_overflow_bug": 2, + "dynamic_compact_overflow": 2, + "dynamic_compact_overflow_limit_1": 2, + "non_ascii_spaces": 2, + "skip_keywords": 2, + "overflow_first_line": 2, + "skip_return_values_overflow": 2, + }, + "IndentNestedKeywords": {"run_keyword": 2}, + "InlineIf": { + "invalid_if": 2, + "invalid_inline_if": 3, + "test": 2, + "test_disablers": 2, + }, + "MergeAndOrderSections": {"disablers": 3, "parsing_error": 2, "translated": 2, "tests": 3}, + "NormalizeNewLines": {"tests": 2, "multiline": 2}, + "NormalizeSeparators": {"continuation_indent": 2, "test": 2, "disablers": 2, "pipes": 2}, + "NormalizeSettingName": {"disablers": 2, "translated": 2, "tests": 2}, + "NormalizeTags": {"disablers": 2, "duplicates": 2, "tests": 2, "preserve_format": 2}, + "OrderSettings": {"test": 2, "translated": 2}, + "OrderSettingsSection": {"test": 2}, + "OrderTags": {"tests": 2}, + "RemoveEmptySettings": {"empty": 2, "disablers": 2, "overwritten": 2}, + "RenameKeywords": {"disablers": 2}, + "ReplaceBreakContinue": {"test": 2}, + "ReplaceReturns": { + "replace_returns_disablers": 2, + "return_from_keyword": 2, + "test": 2, + "return_from_keyword_if": 2, + }, + "ReplaceRunKeywordIf": { + "configure_whitespace": 2, + "disablers": 3, + "set_variable_workaround": 2, + "tests": 2, + "keyword_name_in_var": 2, + }, + "ReplaceWithVAR": {"invalid_inline_if": 2}, + "SmartSortKeywords": {"multiple_sections": 2, "sort_input": 2}, + "SplitTooLongLine": {"continuation_indent": 2, "disablers": 2, "tests": 2, "comments": 2, "settings": 2}, + "Translate": {"pl_language_header": 2}, +} +SKIP_TESTS_4 = {"ReplaceReturns": {"test"}, "GenerateDocumentation": {"test": 2}, "SplitTooLongLine": {"settings"}} +SKIP_TESTS = { + "ReplaceRunKeywordIf": {"invalid_data"}, + "SplitTooLongLine": {"variables"}, + "AlignKeywordsSection": "error_node", +} + + +def run_tidy(cmd, enable_disabled: bool): + if enable_disabled: + cmd = get_enable_disabled_config() + cmd + runner = CliRunner() + return runner.invoke(cli, cmd) + + +def run_with_source(source: Path, enable_disabled: bool, reruns: int): + cmd = ["--reruns", str(reruns), str(source)] + result = run_tidy(cmd, enable_disabled) + if result.exit_code != 0: + raise AssertionError(f"Run failed for {source}") + + +def run_with_source_and_check(source: Path, orig: Path, enable_disabled: bool): + cmd = ["--diff", "--check", "--overwrite", str(source)] + result = run_tidy(cmd, enable_disabled) + if result.exit_code != 0: + print(result.output) + raise AssertionError(f"The code was modified for {orig}") + + +@functools.lru_cache(1) +def get_enable_disabled_config() -> list[str]: + """Returns config required to enable all disabled transformers.""" + + def is_transformer_disabled(transformer): + return not getattr(transformer, "ENABLED", True) + + transformers = load_transformers( + TransformConfigMap([], [], []), + allow_disabled=True, + target_version=ROBOT_VERSION.major, + allow_version_mismatch=False, + ) + config = [] + for transformer in transformers: + if not is_transformer_disabled(transformer.instance): + continue + config.extend(["--configure", f"{transformer.name}:enabled=True"]) + return config + + +def is_e2e_only(path: Path) -> bool: + return path.parent.parent.name == "e2e" + + +def get_test_attributes_from_path(path: Path) -> tuple[str, str]: + transformer = path.parent.parent.name + test_name = path.stem + return transformer, test_name + + +def should_be_skip(path: Path) -> bool: + """ + Checks if test file is in list of invalid data that cannot pass stability checks. + """ + if is_e2e_only(path): + return False + transformer, test_name = get_test_attributes_from_path(path) + if ROBOT_VERSION.major == 4 and transformer in SKIP_TESTS_4: + return test_name in SKIP_TESTS_4[transformer] + if transformer not in SKIP_TESTS: + return False + return test_name in SKIP_TESTS[transformer] + + +def reruns_needed(path: Path) -> int: + """ + Check if test data requires extra rerun. + An example would be for example missing END, which is added in the first run, + newly created blocks fixed in the second and third run should not modify code. + Returns number of reruns needed. + """ + if is_e2e_only(path): + return 1 + transformer, test_name = get_test_attributes_from_path(path) + if ROBOT_VERSION.major == 4 and transformer in RERUN_NEEDED_4: + return RERUN_NEEDED_4[transformer].get(test_name, 1) + if transformer in RERUN_NEEDED: + return RERUN_NEEDED[transformer].get(test_name, 1) + return 1 + + +def gen_test_data(): + """ + Yields list of test data paths. Paths are: + - *.robot files inside e2e/test_data directory + - *.robot files inside atest/*/source directories + """ + e2e_dir = Path(__file__).parent + atest_dir = e2e_dir.parent / "atest" / "transformers" + + yield from e2e_dir.rglob("test_data/*.robot") + for path in atest_dir.rglob("*/source/*.robot"): + yield path + + +def get_test_id_from_path(path) -> str: + """Generate test id from path to the source file.""" + test_name = path.stem + if path.parent.parent.name == "e2e": + return f"e2e_{test_name}" + transformer = path.parent.parent.name + return f"{transformer}_{test_name}" + + +@pytest.mark.e2e +@pytest.mark.parametrize("test_file", gen_test_data(), ids=get_test_id_from_path) +@pytest.mark.parametrize("enable_disabled", [False, True], ids=["defaults", "all"]) +def test_stability_of_transformation(tmpdir, test_file, enable_disabled): + if should_be_skip(test_file): + pytest.skip("Skip invalid test data") + reruns = reruns_needed(test_file) + # copy test data to temp directory + test_data_dst = tmpdir / test_file.name + shutil.copy(test_file, test_data_dst) + run_with_source(test_data_dst, enable_disabled, reruns) + for _ in range(2): + # rerun with --check twice to confirm stability + run_with_source_and_check(test_data_dst, test_file, enable_disabled) diff --git a/tests/utest/utils.py b/tests/utest/utils.py index dd7ee5c8..c462f6ab 100644 --- a/tests/utest/utils.py +++ b/tests/utest/utils.py @@ -1,29 +1,29 @@ -from __future__ import annotations - -from pathlib import Path - -from click.testing import CliRunner - -from robotidy.cli import cli - - -def run_tidy( - args: list[str] = None, - exit_code: int = 0, - output: str | None = None, - std_in: str | None = None, - overwrite_input: bool = False, -): - runner = CliRunner(mix_stderr=False) - arguments = args if args is not None else [] - if not overwrite_input: - if output: - output_path = str(Path(Path(__file__).parent, "actual", output)) - else: - output_path = str(Path(Path(__file__).parent, "actual", "tmp")) - arguments = ["--output", output_path] + arguments - result = runner.invoke(cli, arguments, input=std_in) - if result.exit_code != exit_code: - print(result.output) - raise AssertionError(f"robotidy exit code: {result.exit_code} does not match expected: {exit_code}") - return result +from __future__ import annotations + +from pathlib import Path + +from click.testing import CliRunner + +from robotidy.cli import cli + + +def run_tidy( + args: list[str] = None, + exit_code: int = 0, + output: str | None = None, + std_in: str | None = None, + overwrite_input: bool = False, +): + runner = CliRunner() + arguments = args if args is not None else [] + if not overwrite_input: + if output: + output_path = str(Path(Path(__file__).parent, "actual", output)) + else: + output_path = str(Path(Path(__file__).parent, "actual", "tmp")) + arguments = ["--output", output_path] + arguments + result = runner.invoke(cli, arguments, input=std_in) + if result.exit_code != exit_code: + print(result.output) + raise AssertionError(f"robotidy exit code: {result.exit_code} does not match expected: {exit_code}") + return result From 53d6df8f44cb30a9d525e1ccbe29f52047ff23b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Hirsz?= Date: Thu, 3 Jul 2025 18:21:33 +0200 Subject: [PATCH 3/3] Adjust CliRunner for click< and > 8.2 --- setup.py | 1 - tests/atest/__init__.py | 322 ++++++++++----------- tests/e2e/test_transform_stability.py | 396 +++++++++++++------------- tests/utest/utils.py | 57 ++-- tests/utils.py | 8 + 5 files changed, 395 insertions(+), 389 deletions(-) create mode 100644 tests/utils.py diff --git a/setup.py b/setup.py index 824d0667..411076f1 100644 --- a/setup.py +++ b/setup.py @@ -51,7 +51,6 @@ ], extras_require={ "dev": [ - "click>=8.2", # 8.2 change signature of CliRunner used in test "coverage", "invoke", "jinja2", diff --git a/tests/atest/__init__.py b/tests/atest/__init__.py index 4b33e7bf..4c87019d 100644 --- a/tests/atest/__init__.py +++ b/tests/atest/__init__.py @@ -1,161 +1,161 @@ -from __future__ import annotations - -import filecmp -import shutil -from difflib import unified_diff -from pathlib import Path - -import pytest -from click.testing import CliRunner -from packaging import version -from packaging.specifiers import SpecifierSet -from rich.console import Console -from robot.version import VERSION as RF_VERSION - -from robotidy.cli import cli -from robotidy.utils.misc import decorate_diff_with_color - -VERSION_MATRIX = {"ReplaceReturns": 5, "InlineIf": 5, "ReplaceBreakContinue": 5, "Translate": 6, "ReplaceWithVAR": 7} -ROBOT_VERSION = version.parse(RF_VERSION) - - -def display_file_diff(expected, actual): - print("\nExpected file after transforming does not match actual") - with open(expected, encoding="utf-8") as f, open(actual, encoding="utf-8") as f2: - expected_lines = f.readlines() - actual_lines = f2.readlines() - lines = [ - line - for line in unified_diff( - expected_lines, actual_lines, fromfile=f"expected: {expected}\t", tofile=f"actual: {actual}\t" - ) - ] - colorized_output = decorate_diff_with_color(lines) - console = Console(color_system="windows", width=400) - for line in colorized_output: - console.print(line, end="", highlight=False) - - -class TransformerAcceptanceTest: - TRANSFORMER_NAME: str = "PLACEHOLDER" - TRANSFORMERS_DIR = Path(__file__).parent / "transformers" - - def compare( - self, - source: str, - not_modified: bool = False, - expected: str | None = None, - config: str = "", - target_version: str | None = None, - run_all: bool = False, - ): - """ - Compare actual (source) and expected files. If expected filename is not provided it's assumed to be the same - as source. - - Use not_modified flag if the content of the file shouldn't be modified by transformer. - """ - if expected is None: - expected = source - if run_all: - run_cmd = config - else: - run_cmd = f"--transform {self.TRANSFORMER_NAME}{config}" - self.run_tidy(args=run_cmd.split(), source=source, not_modified=not_modified, target_version=target_version) - if not not_modified: - self.compare_file(source, expected) - - def run_tidy( - self, - args: list[str] = None, - source: str = None, - exit_code: int = 0, - not_modified: bool = False, - target_version: str | None = None, - ): - if not self.enabled_in_version(target_version): - pytest.skip(f"Test enabled only for RF {target_version}") - runner = CliRunner() - output_path = str(self.TRANSFORMERS_DIR / "actual" / source) - arguments = ["--output", output_path] - if not_modified: - arguments.extend(["--check", "--overwrite"]) - if args is not None: - arguments += args - if source is None: - source_path = str(self.TRANSFORMERS_DIR / self.TRANSFORMER_NAME / "source") - else: - source_path = str(self.TRANSFORMERS_DIR / self.TRANSFORMER_NAME / "source" / source) - cmd = arguments + [source_path] - result = runner.invoke(cli, cmd) - if result.exit_code != exit_code: - raise AssertionError( - f"robotidy exit code: {result.exit_code} does not match expected: {exit_code}. " - f"Exception description: {result.exception}" - ) - return result - - def compare_file(self, actual_name: str, expected_name: str = None): - if expected_name is None: - expected_name = actual_name - expected = self.TRANSFORMERS_DIR / self.TRANSFORMER_NAME / "expected" / expected_name - actual = self.TRANSFORMERS_DIR / "actual" / actual_name - if not filecmp.cmp(expected, actual): - display_file_diff(expected, actual) - pytest.fail(f"File {actual_name} is not same as expected") - - def enabled_in_version(self, target_version: str | None) -> bool: - if target_version and ROBOT_VERSION not in SpecifierSet(target_version, prereleases=True): - return False - if self.TRANSFORMER_NAME in VERSION_MATRIX: - return ROBOT_VERSION.major >= VERSION_MATRIX[self.TRANSFORMER_NAME] - return True - - -class MultipleConfigsTest: - TEST_DIR: str = "PLACEHOLDER" - ROOT_DIR = Path(__file__).parent / "configuration_files" - - def run_tidy(self, tmpdir, args: list[str] | None = None, exit_code: int = 0, not_modified: bool = False): - runner = CliRunner() - temporary_dir = tmpdir / self.TEST_DIR - shutil.copytree(self.ROOT_DIR / self.TEST_DIR / "source", temporary_dir) - arguments = [] - if not_modified: - arguments.extend(["--check", "--overwrite"]) - if args is not None: - arguments += args - cmd = arguments + [str(temporary_dir)] - result = runner.invoke(cli, cmd) - if result.exit_code != exit_code: - raise AssertionError( - f"robotidy exit code: {result.exit_code} does not match expected: {exit_code}. " - f"Exception description: {result.exception}" - ) - - def compare_files(self, tmpdir, expected_dir: str): - expected = self.ROOT_DIR / self.TEST_DIR / expected_dir - actual = Path(tmpdir / self.TEST_DIR) - if compare_file_tree(expected, actual): - pytest.fail(f"Files in expected file tree: {expected} and the actual are not the same.") - - -def compare_file_tree(actual_dir: Path, expected_dir: Path) -> bool: - error = False - actual_files = {x.name: x for x in actual_dir.iterdir()} - expected_files = {x.name: x for x in expected_dir.iterdir()} - for exp_file_name, exp_file in expected_files.items(): - if exp_file_name in actual_files: - if exp_file.is_dir(): - error = compare_file_tree(actual_files[exp_file_name], exp_file) or error - elif not filecmp.cmp(exp_file, actual_files[exp_file_name]): - error = True - display_file_diff(exp_file, actual_files[exp_file_name]) - else: - error = True - print(f"File {exp_file} not found in the actual files.") - for actual_file_name, actual_file in actual_files.items(): - if actual_file_name not in expected_files: - error = True - print(f"Extra {actual_file} found in the actual files.") - return error +from __future__ import annotations + +import filecmp +import shutil +from difflib import unified_diff +from pathlib import Path + +import pytest +from packaging import version +from packaging.specifiers import SpecifierSet +from rich.console import Console +from robot.version import VERSION as RF_VERSION + +from robotidy.cli import cli +from robotidy.utils.misc import decorate_diff_with_color +from tests.utils import cli_runner + +VERSION_MATRIX = {"ReplaceReturns": 5, "InlineIf": 5, "ReplaceBreakContinue": 5, "Translate": 6, "ReplaceWithVAR": 7} +ROBOT_VERSION = version.parse(RF_VERSION) + + +def display_file_diff(expected, actual): + print("\nExpected file after transforming does not match actual") + with open(expected, encoding="utf-8") as f, open(actual, encoding="utf-8") as f2: + expected_lines = f.readlines() + actual_lines = f2.readlines() + lines = [ + line + for line in unified_diff( + expected_lines, actual_lines, fromfile=f"expected: {expected}\t", tofile=f"actual: {actual}\t" + ) + ] + colorized_output = decorate_diff_with_color(lines) + console = Console(color_system="windows", width=400) + for line in colorized_output: + console.print(line, end="", highlight=False) + + +class TransformerAcceptanceTest: + TRANSFORMER_NAME: str = "PLACEHOLDER" + TRANSFORMERS_DIR = Path(__file__).parent / "transformers" + + def compare( + self, + source: str, + not_modified: bool = False, + expected: str | None = None, + config: str = "", + target_version: str | None = None, + run_all: bool = False, + ): + """ + Compare actual (source) and expected files. If expected filename is not provided it's assumed to be the same + as source. + + Use not_modified flag if the content of the file shouldn't be modified by transformer. + """ + if expected is None: + expected = source + if run_all: + run_cmd = config + else: + run_cmd = f"--transform {self.TRANSFORMER_NAME}{config}" + self.run_tidy(args=run_cmd.split(), source=source, not_modified=not_modified, target_version=target_version) + if not not_modified: + self.compare_file(source, expected) + + def run_tidy( + self, + args: list[str] = None, + source: str = None, + exit_code: int = 0, + not_modified: bool = False, + target_version: str | None = None, + ): + if not self.enabled_in_version(target_version): + pytest.skip(f"Test enabled only for RF {target_version}") + runner = cli_runner() + output_path = str(self.TRANSFORMERS_DIR / "actual" / source) + arguments = ["--output", output_path] + if not_modified: + arguments.extend(["--check", "--overwrite"]) + if args is not None: + arguments += args + if source is None: + source_path = str(self.TRANSFORMERS_DIR / self.TRANSFORMER_NAME / "source") + else: + source_path = str(self.TRANSFORMERS_DIR / self.TRANSFORMER_NAME / "source" / source) + cmd = arguments + [source_path] + result = runner.invoke(cli, cmd) + if result.exit_code != exit_code: + raise AssertionError( + f"robotidy exit code: {result.exit_code} does not match expected: {exit_code}. " + f"Exception description: {result.exception}" + ) + return result + + def compare_file(self, actual_name: str, expected_name: str = None): + if expected_name is None: + expected_name = actual_name + expected = self.TRANSFORMERS_DIR / self.TRANSFORMER_NAME / "expected" / expected_name + actual = self.TRANSFORMERS_DIR / "actual" / actual_name + if not filecmp.cmp(expected, actual): + display_file_diff(expected, actual) + pytest.fail(f"File {actual_name} is not same as expected") + + def enabled_in_version(self, target_version: str | None) -> bool: + if target_version and ROBOT_VERSION not in SpecifierSet(target_version, prereleases=True): + return False + if self.TRANSFORMER_NAME in VERSION_MATRIX: + return ROBOT_VERSION.major >= VERSION_MATRIX[self.TRANSFORMER_NAME] + return True + + +class MultipleConfigsTest: + TEST_DIR: str = "PLACEHOLDER" + ROOT_DIR = Path(__file__).parent / "configuration_files" + + def run_tidy(self, tmpdir, args: list[str] | None = None, exit_code: int = 0, not_modified: bool = False): + runner = cli_runner() + temporary_dir = tmpdir / self.TEST_DIR + shutil.copytree(self.ROOT_DIR / self.TEST_DIR / "source", temporary_dir) + arguments = [] + if not_modified: + arguments.extend(["--check", "--overwrite"]) + if args is not None: + arguments += args + cmd = arguments + [str(temporary_dir)] + result = runner.invoke(cli, cmd) + if result.exit_code != exit_code: + raise AssertionError( + f"robotidy exit code: {result.exit_code} does not match expected: {exit_code}. " + f"Exception description: {result.exception}" + ) + + def compare_files(self, tmpdir, expected_dir: str): + expected = self.ROOT_DIR / self.TEST_DIR / expected_dir + actual = Path(tmpdir / self.TEST_DIR) + if compare_file_tree(expected, actual): + pytest.fail(f"Files in expected file tree: {expected} and the actual are not the same.") + + +def compare_file_tree(actual_dir: Path, expected_dir: Path) -> bool: + error = False + actual_files = {x.name: x for x in actual_dir.iterdir()} + expected_files = {x.name: x for x in expected_dir.iterdir()} + for exp_file_name, exp_file in expected_files.items(): + if exp_file_name in actual_files: + if exp_file.is_dir(): + error = compare_file_tree(actual_files[exp_file_name], exp_file) or error + elif not filecmp.cmp(exp_file, actual_files[exp_file_name]): + error = True + display_file_diff(exp_file, actual_files[exp_file_name]) + else: + error = True + print(f"File {exp_file} not found in the actual files.") + for actual_file_name, actual_file in actual_files.items(): + if actual_file_name not in expected_files: + error = True + print(f"Extra {actual_file} found in the actual files.") + return error diff --git a/tests/e2e/test_transform_stability.py b/tests/e2e/test_transform_stability.py index acbad20d..10efc1b8 100644 --- a/tests/e2e/test_transform_stability.py +++ b/tests/e2e/test_transform_stability.py @@ -1,198 +1,198 @@ -from __future__ import annotations - -import functools -import shutil -from pathlib import Path - -import pytest -from click.testing import CliRunner - -from robotidy.cli import cli -from robotidy.transformers import TransformConfigMap, load_transformers -from robotidy.utils.misc import ROBOT_VERSION - -RERUN_NEEDED_4 = { - "RenameKeywords": {"run_keywords": 2, "disablers": 2}, - "ReplaceReturns": {"replace_returns_disablers": 2, "return_from_keyword_if": 2}, -} -# Transformer_name: {"name of test file from atest": "reruns until stable"} -RERUN_NEEDED = { - "AddMissingEnd": {"test": 2}, - "AlignKeywordsSection": {"blocks_rf5": 2, "non_ascii_spaces": 2}, - "AlignSettingsSection": {"blank_line_doc": 2, "multiline_keywords": 2}, - "AlignTemplatedTestCases": {"with_settings": 2}, - "AlignTestCasesSection": { - "blocks_rf5": 2, - "compact_overflow_bug": 2, - "dynamic_compact_overflow": 2, - "dynamic_compact_overflow_limit_1": 2, - "non_ascii_spaces": 2, - "skip_keywords": 2, - "overflow_first_line": 2, - "skip_return_values_overflow": 2, - }, - "IndentNestedKeywords": {"run_keyword": 2}, - "InlineIf": { - "invalid_if": 2, - "invalid_inline_if": 3, - "test": 2, - "test_disablers": 2, - }, - "MergeAndOrderSections": {"disablers": 3, "parsing_error": 2, "translated": 2, "tests": 3}, - "NormalizeNewLines": {"tests": 2, "multiline": 2}, - "NormalizeSeparators": {"continuation_indent": 2, "test": 2, "disablers": 2, "pipes": 2}, - "NormalizeSettingName": {"disablers": 2, "translated": 2, "tests": 2}, - "NormalizeTags": {"disablers": 2, "duplicates": 2, "tests": 2, "preserve_format": 2}, - "OrderSettings": {"test": 2, "translated": 2}, - "OrderSettingsSection": {"test": 2}, - "OrderTags": {"tests": 2}, - "RemoveEmptySettings": {"empty": 2, "disablers": 2, "overwritten": 2}, - "RenameKeywords": {"disablers": 2}, - "ReplaceBreakContinue": {"test": 2}, - "ReplaceReturns": { - "replace_returns_disablers": 2, - "return_from_keyword": 2, - "test": 2, - "return_from_keyword_if": 2, - }, - "ReplaceRunKeywordIf": { - "configure_whitespace": 2, - "disablers": 3, - "set_variable_workaround": 2, - "tests": 2, - "keyword_name_in_var": 2, - }, - "ReplaceWithVAR": {"invalid_inline_if": 2}, - "SmartSortKeywords": {"multiple_sections": 2, "sort_input": 2}, - "SplitTooLongLine": {"continuation_indent": 2, "disablers": 2, "tests": 2, "comments": 2, "settings": 2}, - "Translate": {"pl_language_header": 2}, -} -SKIP_TESTS_4 = {"ReplaceReturns": {"test"}, "GenerateDocumentation": {"test": 2}, "SplitTooLongLine": {"settings"}} -SKIP_TESTS = { - "ReplaceRunKeywordIf": {"invalid_data"}, - "SplitTooLongLine": {"variables"}, - "AlignKeywordsSection": "error_node", -} - - -def run_tidy(cmd, enable_disabled: bool): - if enable_disabled: - cmd = get_enable_disabled_config() + cmd - runner = CliRunner() - return runner.invoke(cli, cmd) - - -def run_with_source(source: Path, enable_disabled: bool, reruns: int): - cmd = ["--reruns", str(reruns), str(source)] - result = run_tidy(cmd, enable_disabled) - if result.exit_code != 0: - raise AssertionError(f"Run failed for {source}") - - -def run_with_source_and_check(source: Path, orig: Path, enable_disabled: bool): - cmd = ["--diff", "--check", "--overwrite", str(source)] - result = run_tidy(cmd, enable_disabled) - if result.exit_code != 0: - print(result.output) - raise AssertionError(f"The code was modified for {orig}") - - -@functools.lru_cache(1) -def get_enable_disabled_config() -> list[str]: - """Returns config required to enable all disabled transformers.""" - - def is_transformer_disabled(transformer): - return not getattr(transformer, "ENABLED", True) - - transformers = load_transformers( - TransformConfigMap([], [], []), - allow_disabled=True, - target_version=ROBOT_VERSION.major, - allow_version_mismatch=False, - ) - config = [] - for transformer in transformers: - if not is_transformer_disabled(transformer.instance): - continue - config.extend(["--configure", f"{transformer.name}:enabled=True"]) - return config - - -def is_e2e_only(path: Path) -> bool: - return path.parent.parent.name == "e2e" - - -def get_test_attributes_from_path(path: Path) -> tuple[str, str]: - transformer = path.parent.parent.name - test_name = path.stem - return transformer, test_name - - -def should_be_skip(path: Path) -> bool: - """ - Checks if test file is in list of invalid data that cannot pass stability checks. - """ - if is_e2e_only(path): - return False - transformer, test_name = get_test_attributes_from_path(path) - if ROBOT_VERSION.major == 4 and transformer in SKIP_TESTS_4: - return test_name in SKIP_TESTS_4[transformer] - if transformer not in SKIP_TESTS: - return False - return test_name in SKIP_TESTS[transformer] - - -def reruns_needed(path: Path) -> int: - """ - Check if test data requires extra rerun. - An example would be for example missing END, which is added in the first run, - newly created blocks fixed in the second and third run should not modify code. - Returns number of reruns needed. - """ - if is_e2e_only(path): - return 1 - transformer, test_name = get_test_attributes_from_path(path) - if ROBOT_VERSION.major == 4 and transformer in RERUN_NEEDED_4: - return RERUN_NEEDED_4[transformer].get(test_name, 1) - if transformer in RERUN_NEEDED: - return RERUN_NEEDED[transformer].get(test_name, 1) - return 1 - - -def gen_test_data(): - """ - Yields list of test data paths. Paths are: - - *.robot files inside e2e/test_data directory - - *.robot files inside atest/*/source directories - """ - e2e_dir = Path(__file__).parent - atest_dir = e2e_dir.parent / "atest" / "transformers" - - yield from e2e_dir.rglob("test_data/*.robot") - for path in atest_dir.rglob("*/source/*.robot"): - yield path - - -def get_test_id_from_path(path) -> str: - """Generate test id from path to the source file.""" - test_name = path.stem - if path.parent.parent.name == "e2e": - return f"e2e_{test_name}" - transformer = path.parent.parent.name - return f"{transformer}_{test_name}" - - -@pytest.mark.e2e -@pytest.mark.parametrize("test_file", gen_test_data(), ids=get_test_id_from_path) -@pytest.mark.parametrize("enable_disabled", [False, True], ids=["defaults", "all"]) -def test_stability_of_transformation(tmpdir, test_file, enable_disabled): - if should_be_skip(test_file): - pytest.skip("Skip invalid test data") - reruns = reruns_needed(test_file) - # copy test data to temp directory - test_data_dst = tmpdir / test_file.name - shutil.copy(test_file, test_data_dst) - run_with_source(test_data_dst, enable_disabled, reruns) - for _ in range(2): - # rerun with --check twice to confirm stability - run_with_source_and_check(test_data_dst, test_file, enable_disabled) +from __future__ import annotations + +import functools +import shutil +from pathlib import Path + +import pytest + +from robotidy.cli import cli +from robotidy.transformers import TransformConfigMap, load_transformers +from robotidy.utils.misc import ROBOT_VERSION +from tests.utils import cli_runner + +RERUN_NEEDED_4 = { + "RenameKeywords": {"run_keywords": 2, "disablers": 2}, + "ReplaceReturns": {"replace_returns_disablers": 2, "return_from_keyword_if": 2}, +} +# Transformer_name: {"name of test file from atest": "reruns until stable"} +RERUN_NEEDED = { + "AddMissingEnd": {"test": 2}, + "AlignKeywordsSection": {"blocks_rf5": 2, "non_ascii_spaces": 2}, + "AlignSettingsSection": {"blank_line_doc": 2, "multiline_keywords": 2}, + "AlignTemplatedTestCases": {"with_settings": 2}, + "AlignTestCasesSection": { + "blocks_rf5": 2, + "compact_overflow_bug": 2, + "dynamic_compact_overflow": 2, + "dynamic_compact_overflow_limit_1": 2, + "non_ascii_spaces": 2, + "skip_keywords": 2, + "overflow_first_line": 2, + "skip_return_values_overflow": 2, + }, + "IndentNestedKeywords": {"run_keyword": 2}, + "InlineIf": { + "invalid_if": 2, + "invalid_inline_if": 3, + "test": 2, + "test_disablers": 2, + }, + "MergeAndOrderSections": {"disablers": 3, "parsing_error": 2, "translated": 2, "tests": 3}, + "NormalizeNewLines": {"tests": 2, "multiline": 2}, + "NormalizeSeparators": {"continuation_indent": 2, "test": 2, "disablers": 2, "pipes": 2}, + "NormalizeSettingName": {"disablers": 2, "translated": 2, "tests": 2}, + "NormalizeTags": {"disablers": 2, "duplicates": 2, "tests": 2, "preserve_format": 2}, + "OrderSettings": {"test": 2, "translated": 2}, + "OrderSettingsSection": {"test": 2}, + "OrderTags": {"tests": 2}, + "RemoveEmptySettings": {"empty": 2, "disablers": 2, "overwritten": 2}, + "RenameKeywords": {"disablers": 2}, + "ReplaceBreakContinue": {"test": 2}, + "ReplaceReturns": { + "replace_returns_disablers": 2, + "return_from_keyword": 2, + "test": 2, + "return_from_keyword_if": 2, + }, + "ReplaceRunKeywordIf": { + "configure_whitespace": 2, + "disablers": 3, + "set_variable_workaround": 2, + "tests": 2, + "keyword_name_in_var": 2, + }, + "ReplaceWithVAR": {"invalid_inline_if": 2}, + "SmartSortKeywords": {"multiple_sections": 2, "sort_input": 2}, + "SplitTooLongLine": {"continuation_indent": 2, "disablers": 2, "tests": 2, "comments": 2, "settings": 2}, + "Translate": {"pl_language_header": 2}, +} +SKIP_TESTS_4 = {"ReplaceReturns": {"test"}, "GenerateDocumentation": {"test": 2}, "SplitTooLongLine": {"settings"}} +SKIP_TESTS = { + "ReplaceRunKeywordIf": {"invalid_data"}, + "SplitTooLongLine": {"variables"}, + "AlignKeywordsSection": "error_node", +} + + +def run_tidy(cmd, enable_disabled: bool): + if enable_disabled: + cmd = get_enable_disabled_config() + cmd + runner = cli_runner() + return runner.invoke(cli, cmd) + + +def run_with_source(source: Path, enable_disabled: bool, reruns: int): + cmd = ["--reruns", str(reruns), str(source)] + result = run_tidy(cmd, enable_disabled) + if result.exit_code != 0: + raise AssertionError(f"Run failed for {source}") + + +def run_with_source_and_check(source: Path, orig: Path, enable_disabled: bool): + cmd = ["--diff", "--check", "--overwrite", str(source)] + result = run_tidy(cmd, enable_disabled) + if result.exit_code != 0: + print(result.output) + raise AssertionError(f"The code was modified for {orig}") + + +@functools.lru_cache(1) +def get_enable_disabled_config() -> list[str]: + """Returns config required to enable all disabled transformers.""" + + def is_transformer_disabled(transformer): + return not getattr(transformer, "ENABLED", True) + + transformers = load_transformers( + TransformConfigMap([], [], []), + allow_disabled=True, + target_version=ROBOT_VERSION.major, + allow_version_mismatch=False, + ) + config = [] + for transformer in transformers: + if not is_transformer_disabled(transformer.instance): + continue + config.extend(["--configure", f"{transformer.name}:enabled=True"]) + return config + + +def is_e2e_only(path: Path) -> bool: + return path.parent.parent.name == "e2e" + + +def get_test_attributes_from_path(path: Path) -> tuple[str, str]: + transformer = path.parent.parent.name + test_name = path.stem + return transformer, test_name + + +def should_be_skip(path: Path) -> bool: + """ + Checks if test file is in list of invalid data that cannot pass stability checks. + """ + if is_e2e_only(path): + return False + transformer, test_name = get_test_attributes_from_path(path) + if ROBOT_VERSION.major == 4 and transformer in SKIP_TESTS_4: + return test_name in SKIP_TESTS_4[transformer] + if transformer not in SKIP_TESTS: + return False + return test_name in SKIP_TESTS[transformer] + + +def reruns_needed(path: Path) -> int: + """ + Check if test data requires extra rerun. + An example would be for example missing END, which is added in the first run, + newly created blocks fixed in the second and third run should not modify code. + Returns number of reruns needed. + """ + if is_e2e_only(path): + return 1 + transformer, test_name = get_test_attributes_from_path(path) + if ROBOT_VERSION.major == 4 and transformer in RERUN_NEEDED_4: + return RERUN_NEEDED_4[transformer].get(test_name, 1) + if transformer in RERUN_NEEDED: + return RERUN_NEEDED[transformer].get(test_name, 1) + return 1 + + +def gen_test_data(): + """ + Yields list of test data paths. Paths are: + - *.robot files inside e2e/test_data directory + - *.robot files inside atest/*/source directories + """ + e2e_dir = Path(__file__).parent + atest_dir = e2e_dir.parent / "atest" / "transformers" + + yield from e2e_dir.rglob("test_data/*.robot") + for path in atest_dir.rglob("*/source/*.robot"): + yield path + + +def get_test_id_from_path(path) -> str: + """Generate test id from path to the source file.""" + test_name = path.stem + if path.parent.parent.name == "e2e": + return f"e2e_{test_name}" + transformer = path.parent.parent.name + return f"{transformer}_{test_name}" + + +@pytest.mark.e2e +@pytest.mark.parametrize("test_file", gen_test_data(), ids=get_test_id_from_path) +@pytest.mark.parametrize("enable_disabled", [False, True], ids=["defaults", "all"]) +def test_stability_of_transformation(tmpdir, test_file, enable_disabled): + if should_be_skip(test_file): + pytest.skip("Skip invalid test data") + reruns = reruns_needed(test_file) + # copy test data to temp directory + test_data_dst = tmpdir / test_file.name + shutil.copy(test_file, test_data_dst) + run_with_source(test_data_dst, enable_disabled, reruns) + for _ in range(2): + # rerun with --check twice to confirm stability + run_with_source_and_check(test_data_dst, test_file, enable_disabled) diff --git a/tests/utest/utils.py b/tests/utest/utils.py index c462f6ab..b9f75b3c 100644 --- a/tests/utest/utils.py +++ b/tests/utest/utils.py @@ -1,29 +1,28 @@ -from __future__ import annotations - -from pathlib import Path - -from click.testing import CliRunner - -from robotidy.cli import cli - - -def run_tidy( - args: list[str] = None, - exit_code: int = 0, - output: str | None = None, - std_in: str | None = None, - overwrite_input: bool = False, -): - runner = CliRunner() - arguments = args if args is not None else [] - if not overwrite_input: - if output: - output_path = str(Path(Path(__file__).parent, "actual", output)) - else: - output_path = str(Path(Path(__file__).parent, "actual", "tmp")) - arguments = ["--output", output_path] + arguments - result = runner.invoke(cli, arguments, input=std_in) - if result.exit_code != exit_code: - print(result.output) - raise AssertionError(f"robotidy exit code: {result.exit_code} does not match expected: {exit_code}") - return result +from __future__ import annotations + +from pathlib import Path + +from robotidy.cli import cli +from tests.utils import cli_runner + + +def run_tidy( + args: list[str] = None, + exit_code: int = 0, + output: str | None = None, + std_in: str | None = None, + overwrite_input: bool = False, +): + runner = cli_runner() + arguments = args if args is not None else [] + if not overwrite_input: + if output: + output_path = str(Path(Path(__file__).parent, "actual", output)) + else: + output_path = str(Path(Path(__file__).parent, "actual", "tmp")) + arguments = ["--output", output_path] + arguments + result = runner.invoke(cli, arguments, input=std_in) + if result.exit_code != exit_code: + print(result.output) + raise AssertionError(f"robotidy exit code: {result.exit_code} does not match expected: {exit_code}") + return result diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 00000000..181975bd --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,8 @@ +from click.testing import CliRunner + + +def cli_runner(): + try: + return CliRunner(mix_stderr=False) + except TypeError: + return CliRunner()