diff --git a/doc/changes/unreleased.md b/doc/changes/unreleased.md index acc91638d..dd51210ed 100644 --- a/doc/changes/unreleased.md +++ b/doc/changes/unreleased.md @@ -5,3 +5,7 @@ ## Bugfix * #840: Added `export` plugin installation within `dependency-update.yml` + +## Feature + +* #722: Added check in `workflow:generate` to compare the generated and existing content before writing out diff --git a/exasol/toolbox/util/workflows/workflow.py b/exasol/toolbox/util/workflows/workflow.py index 086414f7d..ba08d14a5 100644 --- a/exasol/toolbox/util/workflows/workflow.py +++ b/exasol/toolbox/util/workflows/workflow.py @@ -1,3 +1,4 @@ +import difflib from collections.abc import Mapping from pathlib import Path from typing import ( @@ -41,38 +42,62 @@ class Workflow(BaseModel): model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True) + template_path: Path + output_path: Path content: str @classmethod def load_from_template( cls, - file_path: Path, + template_path: Path, + output_directory: Path, github_template_dict: dict[str, Any], patch_yaml: WorkflowCommentedMap | None = None, ): - with bound_contextvars(template_file_name=file_path.name): - logger.debug("Load workflow template: %s", file_path.name) + with bound_contextvars(template_file_name=template_path.name): + logger.debug("Load workflow template: %s", template_path.name) - if not file_path.exists(): - raise FileNotFoundError(file_path) + if not template_path.exists(): + raise FileNotFoundError(template_path) try: workflow_renderer = WorkflowRenderer( github_template_dict=github_template_dict, - file_path=file_path, + file_path=template_path, patch_yaml=patch_yaml, ) - workflow = workflow_renderer.render() - return cls(content=workflow) + return cls( + template_path=template_path, + output_path=output_directory / template_path.name, + content=workflow_renderer.render(), + ) except (YamlError, YamlKeyError) as ex: raise ex except Exception as ex: # Wrap all other "non-special" exceptions - raise ValueError(f"Error rendering file: {file_path}") from ex + raise ValueError(f"Error rendering file: {template_path}") from ex + + def compare_to_file(self) -> str: + existing_content = ( + self.output_path.read_text().strip() if self.output_path.exists() else "" + ) + generated_content = self.content.strip() + + diff = difflib.unified_diff( + existing_content.splitlines(), + generated_content.splitlines(), + fromfile=f"existing: {self.output_path.name}", + tofile="generated", + lineterm="", + ) + return "\n".join(diff) - def write_to_file(self, file_path: Path) -> None: - logger.info("Write workflow file %s", file_path.name) - file_path.write_text(self.content + "\n") + def write_to_file(self) -> None: + if self.compare_to_file() == "": + logger.debug("Skip up-to-date workflow file %s", self.output_path.name) + return + logger.info("Write workflow file %s", self.output_path.name) + self.output_path.write_text(self.content + "\n") def _select_workflow_template(workflow_name: WorkflowChoice) -> Mapping[str, Path]: @@ -119,12 +144,12 @@ def update_workflow(workflow_choice: WorkflowChoice, config: BaseConfig) -> None try: workflow = Workflow.load_from_template( - file_path=workflow_dict[workflow_name], + template_path=workflow_dict[workflow_name], + output_directory=config.github_workflow_directory, github_template_dict=config.github_template_dict, patch_yaml=patch_yaml, ) - file_path = config.github_workflow_directory / f"{workflow_name}.yml" - workflow.write_to_file(file_path=file_path) + workflow.write_to_file() except YamlKeyError as ex: raise InvalidWorkflowPatcherEntryError( file_path=config.github_workflow_patcher_yaml, entry=ex.entry # type: ignore diff --git a/test/unit/util/workflows/workflow_test.py b/test/unit/util/workflows/workflow_test.py index ea6a308b2..d17790bca 100644 --- a/test/unit/util/workflows/workflow_test.py +++ b/test/unit/util/workflows/workflow_test.py @@ -1,4 +1,5 @@ from inspect import cleandoc +from pathlib import Path from unittest.mock import patch import pytest @@ -47,23 +48,78 @@ def test_works_as_expected(tmp_path, project_config): input_file_path.write_text(content) workflow = Workflow.load_from_template( - file_path=input_file_path, + template_path=input_file_path, + output_directory=tmp_path, github_template_dict=project_config.github_template_dict, ) output_file_path = tmp_path / f"{input_file_path.name}" - workflow.write_to_file(file_path=output_file_path) + assert workflow.template_path == input_file_path + assert workflow.output_path == output_file_path + workflow.write_to_file() assert output_file_path.read_text() == cleandoc(expected_yaml) + "\n" + @staticmethod + def test_compare_to_file_accepts_matching_content(tmp_path): + content = "line 1\nline 2" + file_path = tmp_path / "workflow.yml" + file_path.write_text(f"\n{content}\n") + + workflow = Workflow( + template_path=file_path, + output_path=file_path, + content=f"\n{content}\n", + ) + + assert workflow.compare_to_file() == "" + + @staticmethod + def test_compare_to_file_reports_diff(tmp_path): + file_path = tmp_path / "workflow.yml" + file_path.write_text("line 1\nline 3\n") + workflow = Workflow( + template_path=file_path, + output_path=file_path, + content="line 1\nline 2", + ) + + diff = workflow.compare_to_file() + + assert diff == ( + f"--- existing: {file_path.name}\n" + "+++ generated\n" + "@@ -1,2 +1,2 @@\n" + " line 1\n" + "-line 3\n" + "+line 2" + ) + + @staticmethod + def test_write_to_file_skips_up_to_date_file(tmp_path): + file_path = tmp_path / "workflow.yml" + file_path.write_text("line 1\nline 2\n") + workflow = Workflow( + template_path=file_path, + output_path=file_path, + content="line 1\nline 2", + ) + + with patch.object(Path, "write_text") as write_text: + workflow.write_to_file() + + write_text.assert_not_called() + assert file_path.read_text() == "line 1\nline 2\n" + @staticmethod @pytest.mark.parametrize("template_path", WORKFLOW_TEMPLATE_OPTIONS.values()) def test_works_for_all_templates(tmp_path, project_config, template_path): workflow = Workflow.load_from_template( - file_path=template_path, + template_path=template_path, + output_directory=tmp_path, github_template_dict=project_config.github_template_dict, ) file_path = tmp_path / f"{template_path.name}" - workflow.write_to_file(file_path=file_path) + workflow.write_to_file() assert file_path.read_text() != "" @@ -72,7 +128,8 @@ def test_fails_when_yaml_does_not_exist(tmp_path, project_config): file_path = tmp_path / "test.yaml" with pytest.raises(FileNotFoundError, match="test.yaml"): Workflow.load_from_template( - file_path=file_path, + template_path=file_path, + output_directory=tmp_path, github_template_dict=project_config.github_template_dict, ) @@ -89,7 +146,8 @@ def test_raises_custom_exceptions(tmp_path, project_config, raised_exc): ): with pytest.raises(raised_exc): Workflow.load_from_template( - file_path=file_path, + template_path=file_path, + output_directory=tmp_path, github_template_dict=project_config.github_template_dict, ) @@ -103,7 +161,8 @@ def test_other_exceptions_raised_as_valuerror(tmp_path, project_config): ): with pytest.raises(ValueError): Workflow.load_from_template( - file_path=file_path, + template_path=file_path, + output_directory=tmp_path, github_template_dict=project_config.github_template_dict, )