diff --git a/.import_linter_config b/.import_linter_config index 01a924fd8..78f051f90 100644 --- a/.import_linter_config +++ b/.import_linter_config @@ -28,3 +28,4 @@ ignore_imports = # in each CLI usage), we allow the noxconfig to be imported within these modules. exasol.toolbox.nox.* -> noxconfig exasol.toolbox.tools.template -> noxconfig + exasol.toolbox.util.workflow.* -> noxconfig diff --git a/exasol/toolbox/util/workflows/patch_workflow.py b/exasol/toolbox/util/workflows/patch_workflow.py index 9b48fac6e..9947caea8 100644 --- a/exasol/toolbox/util/workflows/patch_workflow.py +++ b/exasol/toolbox/util/workflows/patch_workflow.py @@ -1,17 +1,33 @@ +from __future__ import annotations + +from dataclasses import dataclass from enum import Enum +from functools import cached_property from pathlib import Path -from typing import Any +from typing import ( + Annotated, + Any, + TypeAlias, +) from pydantic import ( BaseModel, ConfigDict, Field, + ValidationError, ) from ruamel.yaml import CommentedMap from exasol.toolbox.util.workflows.render_yaml import YamlRenderer +class InvalidWorkflowPatcherYamlError(Exception): + def __init__(self, file_path: Path): + super().__init__( + f"File '{file_path}' is malformed; it failed Pydantic validation." + ) + + class ActionType(str, Enum): INSERT_AFTER = "INSERT_AFTER" REPLACE = "REPLACE" @@ -82,15 +98,51 @@ class WorkflowPatcherConfig(BaseModel): workflows: list[Workflow] +WorkflowCommentedMap: TypeAlias = Annotated[ + CommentedMap, f"This CommentedMap is structured according to `{Workflow.__name__}`" +] + +StepCustomizationCommentedMap: TypeAlias = Annotated[ + CommentedMap, + f"This CommentedMap is structured according to `{StepCustomization.__name__}`", +] +StepsCommentedMap: TypeAlias = Annotated[ + CommentedMap, + f"This CommentedMap is structured according to `list[{StepContent.__name__}]`", +] + + +@dataclass(frozen=True) class WorkflowPatcher(YamlRenderer): """ The :class:`WorkflowPatcher` enables users to define a YAML file - to customize PTB-provided workflows by removing or modifying jobs in the file. - A job can be modified by replacing or inserting steps. - The provided YAML file must meet the conditions of :class:`WorkflowPatcherConfig`. + to customize PTB-provided workflows by removing or modifying jobs. + A job can be modified by replacing or inserting steps. The provided + YAML file must meet the conditions of :class:`WorkflowPatcherConfig`. """ - def get_yaml_dict(self, file_path: Path) -> CommentedMap: - loaded_yaml = super().get_yaml_dict(file_path) - WorkflowPatcherConfig.model_validate(loaded_yaml) - return loaded_yaml + @cached_property + def content(self) -> CommentedMap: + """ + The loaded YAML content. It loads on first access and + stays cached even though the class is frozen. + """ + loaded_yaml = self.get_yaml_dict() + try: + WorkflowPatcherConfig.model_validate(loaded_yaml) + return loaded_yaml + except ValidationError as exc: + raise InvalidWorkflowPatcherYamlError(file_path=self.file_path) from exc + + def extract_by_workflow(self, workflow_name: str) -> WorkflowCommentedMap | None: + """ + Extract from the `content` where `name` matches the `workflow_name`. + If the workflow is not found, then `None` is returned. It is an expected and + common use case that the `WorkflowPatcher` would only modify a few workflows + and not all of them. + """ + inner_content = self.content["workflows"] + for workflow in inner_content: + if workflow["name"] == workflow_name: + return workflow + return None diff --git a/exasol/toolbox/util/workflows/process_template.py b/exasol/toolbox/util/workflows/process_template.py index febebaa35..35fb1e345 100644 --- a/exasol/toolbox/util/workflows/process_template.py +++ b/exasol/toolbox/util/workflows/process_template.py @@ -1,8 +1,18 @@ -from pathlib import Path +import copy +from dataclasses import dataclass +from ruamel.yaml import CommentedMap + +from exasol.toolbox.util.workflows.patch_workflow import ( + ActionType, + StepCustomizationCommentedMap, + StepsCommentedMap, + WorkflowCommentedMap, +) from exasol.toolbox.util.workflows.render_yaml import YamlRenderer +@dataclass(frozen=True) class WorkflowRenderer(YamlRenderer): """ The :class:`WorkflowRenderer` renders a workflow template provided by the PTB into @@ -11,9 +21,90 @@ class WorkflowRenderer(YamlRenderer): - standardizing formatting via ruamel.yaml for a consistent output. """ - def render(self, file_path: Path) -> str: + patch_yaml: WorkflowCommentedMap | None = None + + def render(self) -> str: """ Render the template to the contents of a valid GitHub workflow. """ - workflow_dict = self.get_yaml_dict(file_path) + workflow_dict = self.get_yaml_dict() + + if self.patch_yaml: + workflow_modifier = WorkflowPatcher( + workflow_dict=workflow_dict, patch_yaml=self.patch_yaml + ) + workflow_dict = workflow_modifier.get_patched_workflow() + return self.get_as_string(workflow_dict) + + +@dataclass(frozen=True) +class WorkflowPatcher: + workflow_dict: WorkflowCommentedMap + patch_yaml: CommentedMap + + def __post_init__(self): + # Perform deepcopy to ensure this instance owns its data + # Use object.__setattr__ because the dataclass is frozen + object.__setattr__(self, "workflow_dict", copy.deepcopy(self.workflow_dict)) + + @property + def jobs_dict(self) -> CommentedMap: + return self.workflow_dict["jobs"] + + def _get_step_list(self, job_name: str) -> StepsCommentedMap: + self._verify_job_exists(job_name=job_name) + return self.jobs_dict[job_name]["steps"] + + def _customize_steps( + self, step_customizations: StepCustomizationCommentedMap + ) -> None: + """ + Customize the steps of jobs specified in `step_customizations` in a workflow + (`workflow_dict`). If a `step_id` or its parent `job` cannot be found, an + exception is raised. + """ + for patch in step_customizations: + job_name = patch["job"] + idx = self._get_step_index(job_name=job_name, step_id=patch["step_id"]) + + step_list = self._get_step_list(job_name=job_name) + if patch["action"] == ActionType.REPLACE.value: + step_list[idx : idx + 1] = patch["content"] + elif patch["action"] == ActionType.INSERT_AFTER.value: + step_list[idx + 1 : idx + 1] = patch["content"] + + def _get_step_index(self, job_name: str, step_id: str) -> int: + steps = self._get_step_list(job_name=job_name) + for index, step in enumerate(steps): + if step["id"] == step_id: + return index + raise ValueError(f"step_id '{step_id}' not found in job '{job_name}'") + + def _remove_jobs(self, remove_jobs: CommentedMap) -> None: + """ + Remove the jobs specified in `remove_jobs` in a workflow yaml (`workflow_dict`). + If a `job` cannot be found, an exception is raised. + """ + for job_name in remove_jobs: + self._verify_job_exists(job_name) + self.jobs_dict.pop(job_name) + + def _verify_job_exists(self, job_name: str) -> None: + if job_name not in self.jobs_dict: + raise ValueError(f"job '{job_name}' not found") + + def get_patched_workflow(self): + """ + Patch the `workflow_dict`. As dictionaries are mutable structures, we directly + take advantage of this by having it modified in this class's internal methods + without explicit returns. + """ + + if remove_jobs := self.patch_yaml.get("remove_jobs", {}): + self._remove_jobs(remove_jobs=remove_jobs) + + if step_customizations := self.patch_yaml.get("step_customizations", {}): + self._customize_steps(step_customizations=step_customizations) + + return self.workflow_dict diff --git a/exasol/toolbox/util/workflows/render_yaml.py b/exasol/toolbox/util/workflows/render_yaml.py index 23b57dd57..265b95c0d 100644 --- a/exasol/toolbox/util/workflows/render_yaml.py +++ b/exasol/toolbox/util/workflows/render_yaml.py @@ -4,17 +4,46 @@ from pathlib import Path from typing import Any -from jinja2 import Environment +from jinja2 import ( + Environment, + StrictUndefined, + TemplateError, +) from ruamel.yaml import ( YAML, CommentedMap, ) +from ruamel.yaml.error import YAMLError jinja_env = Environment( - variable_start_string="((", variable_end_string="))", autoescape=True + variable_start_string="((", + variable_end_string="))", + autoescape=True, + # This requires that all Jinja variables must be defined in the provided + # dictionary. If not, then a `jinja2.exceptions.UndefinedError` exception + # will be raised. + undefined=StrictUndefined, ) +class YamlSyntaxError(Exception): + """Raised when the rendered template is not a valid YAML document.""" + + def __init__(self, file_path: Path): + super().__init__( + f"File '{file_path}' could not be parsed by ruamel-yaml. Check for invalid YAML syntax." + ) + + +class TemplateRenderingError(Exception): + """Raised when Jinja2 fails to process the template.""" + + def __init__(self, file_path: Path): + super().__init__( + f"File '{file_path}' failed to render. Check Jinja2-related errors." + ) + + @dataclass(frozen=True) class YamlRenderer: """ @@ -25,6 +54,7 @@ class YamlRenderer: """ github_template_dict: dict[str, Any] + file_path: Path @staticmethod def _get_standard_yaml() -> YAML: @@ -45,18 +75,22 @@ def _render_with_jinja(self, input_str: str) -> str: jinja_template = jinja_env.from_string(input_str) return jinja_template.render(self.github_template_dict) - def get_yaml_dict(self, file_path: Path) -> CommentedMap: + def get_yaml_dict(self) -> CommentedMap: """ Load a file as a CommentedMap (dictionary form of a YAML), after rendering it with Jinja. """ - with file_path.open("r", encoding="utf-8") as stream: + with self.file_path.open("r", encoding="utf-8") as stream: raw_content = stream.read() - workflow_string = self._render_with_jinja(raw_content) - - yaml = self._get_standard_yaml() - return yaml.load(workflow_string) + try: + workflow_string = self._render_with_jinja(raw_content) + yaml = self._get_standard_yaml() + return yaml.load(workflow_string) + except TemplateError as exc: + raise TemplateRenderingError(file_path=self.file_path) from exc + except YAMLError as exc: + raise YamlSyntaxError(file_path=self.file_path) from exc def get_as_string(self, yaml_dict: CommentedMap) -> str: """ diff --git a/exasol/toolbox/util/workflows/workflow.py b/exasol/toolbox/util/workflows/workflow.py index fc54b653d..16a93d171 100644 --- a/exasol/toolbox/util/workflows/workflow.py +++ b/exasol/toolbox/util/workflows/workflow.py @@ -19,11 +19,7 @@ def load_from_template(cls, file_path: Path, github_template_dict: dict[str, Any if not file_path.exists(): raise FileNotFoundError(file_path) - try: - workflow_renderer = WorkflowRenderer( - github_template_dict=github_template_dict - ) - workflow = workflow_renderer.render(file_path=file_path) - return cls(content=workflow) - except Exception as e: - raise ValueError(f"Error rendering file: {file_path}") from e + workflow_renderer = WorkflowRenderer( + github_template_dict=github_template_dict, file_path=file_path + ) + return cls(content=workflow_renderer.render()) diff --git a/test/unit/util/workflows/conftest.py b/test/unit/util/workflows/conftest.py new file mode 100644 index 000000000..f2ad2a416 --- /dev/null +++ b/test/unit/util/workflows/conftest.py @@ -0,0 +1,68 @@ +from dataclasses import dataclass +from inspect import cleandoc +from pathlib import Path + +import pytest + +from exasol.toolbox.util.workflows.patch_workflow import WorkflowPatcher +from noxconfig import PROJECT_CONFIG + + +@dataclass(frozen=True) +class ExamplePatcherYaml: + remove_jobs = """ + workflows: + - name: "checks.yml" + remove_jobs: + - build-documentation-and-check-links + """ + step_customization = """ + workflows: + - name: "checks.yml" + step_customizations: + - action: {action} + job: run-unit-tests + step_id: check-out-repository + content: + - name: Check out Repository + id: check-out-repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + """ + + +@pytest.fixture(scope="session") +def example_patcher_yaml(): + return ExamplePatcherYaml + + +@pytest.fixture +def workflow_patcher_yaml(tmp_path: Path) -> Path: + return tmp_path / ".workflow-patcher.yml" + + +@pytest.fixture +def workflow_patcher(workflow_patcher_yaml) -> WorkflowPatcher: + return WorkflowPatcher( + github_template_dict=PROJECT_CONFIG.github_template_dict, + file_path=workflow_patcher_yaml, + ) + + +@pytest.fixture +def remove_job_yaml(example_patcher_yaml, workflow_patcher_yaml): + content = cleandoc(example_patcher_yaml.remove_jobs) + workflow_patcher_yaml.write_text(content) + return content + + +@pytest.fixture +def step_customization_yaml(request, example_patcher_yaml, workflow_patcher_yaml): + # request.param will hold the value passed from @pytest.mark.parametrize + action_value = request.param + + text = example_patcher_yaml.step_customization.format(action=action_value) + content = cleandoc(text) + workflow_patcher_yaml.write_text(content) + return content diff --git a/test/unit/util/workflows/patch_workflow_test.py b/test/unit/util/workflows/patch_workflow_test.py index dc6d98151..8bea1fe7c 100644 --- a/test/unit/util/workflows/patch_workflow_test.py +++ b/test/unit/util/workflows/patch_workflow_test.py @@ -1,90 +1,87 @@ -from dataclasses import dataclass from inspect import cleandoc -from pathlib import Path import pytest from pydantic import ValidationError +from ruamel.yaml import CommentedMap from exasol.toolbox.util.workflows.patch_workflow import ( ActionType, - WorkflowPatcher, + InvalidWorkflowPatcherYamlError, ) -from noxconfig import PROJECT_CONFIG - - -@dataclass(frozen=True) -class ExampleYaml: - remove_jobs = """ - workflows: - - name: "checks.yml" - remove_jobs: - - documentation - """ - step_customization = """ - workflows: - - name: "checks.yml" - step_customizations: - - action: {action} - job: Tests - step_id: checkout-repo - content: - - name: SCM Checkout - id: checkout-repo - uses: actions/checkout@v6 - with: - fetch-depth: 0 - """ - - -@pytest.fixture -def workflow_patcher() -> WorkflowPatcher: - return WorkflowPatcher(github_template_dict=PROJECT_CONFIG.github_template_dict) - - -@pytest.fixture -def workflow_patcher_yaml(tmp_path: Path) -> Path: - return tmp_path / ".workflow-patcher.yml" class TestWorkflowPatcher: @staticmethod - def test_remove_jobs(workflow_patcher_yaml, workflow_patcher): - content = cleandoc(ExampleYaml.remove_jobs) - workflow_patcher_yaml.write_text(content) + def test_remove_jobs(remove_job_yaml, workflow_patcher): + result = workflow_patcher.content + assert workflow_patcher.get_as_string(result) == remove_job_yaml - yaml_dict = workflow_patcher.get_yaml_dict(workflow_patcher_yaml) - - assert workflow_patcher.get_as_string(yaml_dict) == content + @staticmethod + @pytest.mark.parametrize( + "step_customization_yaml", + [action.value for action in ActionType], + indirect=True, + ) + def test_step_customizations(step_customization_yaml, workflow_patcher): + result = workflow_patcher.content + assert workflow_patcher.get_as_string(result) == step_customization_yaml @staticmethod - @pytest.mark.parametrize("action", ActionType) - def test_step_customizations(workflow_patcher_yaml, action, workflow_patcher): - content = cleandoc(ExampleYaml.step_customization.format(action=action.value)) + def test_extract_by_workflow_works_as_expected( + example_patcher_yaml, workflow_patcher_yaml, workflow_patcher + ): + content = f""" + {example_patcher_yaml.remove_jobs} + - name: "pr-merge.yml" + remove_jobs: + - publish-docs + """ + content = cleandoc(content) workflow_patcher_yaml.write_text(content) - yaml_dict = workflow_patcher.get_yaml_dict(workflow_patcher_yaml) + result = workflow_patcher.extract_by_workflow("pr-merge.yml") + assert result == CommentedMap( + {"name": "pr-merge.yml", "remove_jobs": ["publish-docs"]} + ) - assert workflow_patcher.get_as_string(yaml_dict) == content + @staticmethod + def test_extract_by_workflow_not_found_returns_none( + remove_job_yaml, workflow_patcher + ): + result = workflow_patcher.extract_by_workflow("pr-merge.yml") + assert result is None class TestStepCustomization: @staticmethod - def test_allows_extra_field(workflow_patcher_yaml, workflow_patcher): + def test_allows_extra_field( + example_patcher_yaml, workflow_patcher_yaml, workflow_patcher + ): content = f""" - {ExampleYaml.step_customization.format(action="REPLACE")} + {example_patcher_yaml.step_customization.format(action="REPLACE")} extra-field: "test" """ content = cleandoc(content) workflow_patcher_yaml.write_text(content) - yaml_dict = workflow_patcher.get_yaml_dict(workflow_patcher_yaml) + yaml_dict = workflow_patcher.get_yaml_dict() assert workflow_patcher.get_as_string(yaml_dict) == content @staticmethod - def test_raises_error_for_unknown_action(workflow_patcher_yaml, workflow_patcher): - content = cleandoc(ExampleYaml.step_customization.format(action="UNKNOWN")) + def test_raises_error_for_unknown_action( + example_patcher_yaml, workflow_patcher_yaml, workflow_patcher + ): + content = cleandoc( + example_patcher_yaml.step_customization.format(action="UNKNOWN") + ) workflow_patcher_yaml.write_text(content) - with pytest.raises(ValidationError, match="Input should be"): - workflow_patcher.get_yaml_dict(workflow_patcher_yaml) + with pytest.raises( + InvalidWorkflowPatcherYamlError, + match="is malformed; it failed Pydantic validation", + ) as exc: + workflow_patcher.content + + underlying_error = exc.value.__cause__ + assert isinstance(underlying_error, ValidationError) diff --git a/test/unit/util/workflows/process_template_test.py b/test/unit/util/workflows/process_template_test.py new file mode 100644 index 000000000..a218522b5 --- /dev/null +++ b/test/unit/util/workflows/process_template_test.py @@ -0,0 +1,165 @@ +import pytest +from ruamel.yaml import CommentedMap + +from exasol.toolbox.util.workflows.patch_workflow import ActionType +from exasol.toolbox.util.workflows.process_template import WorkflowPatcher +from exasol.toolbox.util.workflows.render_yaml import YamlRenderer +from noxconfig import PROJECT_CONFIG + +WORKFLOW_YAML = """ +name: Checks + +on: + workflow_call: + +jobs: + build-documentation-and-check-links: + name: Docs + runs-on: "ubuntu-24.04" + permissions: + contents: read + steps: + - name: Check out Repository + id: check-out-repository + uses: actions/checkout@v6 + + run-unit-tests: + name: Run Unit Tests (Python-${{ matrix.python-versions }}) + runs-on: "ubuntu-24.04" + permissions: + contents: read + strategy: + fail-fast: false + matrix: + python-versions: ["3.10", "3.11", "3.12", "3.13", "3.14"] + + steps: + - name: Check out Repository + id: check-out-repository + uses: actions/checkout@v6 + +""" + + +@pytest.fixture +def workflow_name(): + return "checks.yml" + + +@pytest.fixture +def checks_yaml(tmp_path, workflow_name): + file_path = tmp_path / workflow_name + file_path.write_text(WORKFLOW_YAML) + return file_path + + +@pytest.fixture +def workflow_dict(checks_yaml) -> CommentedMap: + return YamlRenderer( + github_template_dict=PROJECT_CONFIG.github_template_dict, file_path=checks_yaml + ).get_yaml_dict() + + +class TestWorkflowModifier: + @staticmethod + def test__remove_jobs( + workflow_name, workflow_dict, workflow_patcher, remove_job_yaml + ): + workflow_patcher = WorkflowPatcher( + workflow_dict=workflow_dict, + patch_yaml=workflow_patcher.extract_by_workflow(workflow_name), + ) + + result = workflow_patcher.get_patched_workflow() + + # The original was not altered as it was deepcopied before modifications. + assert list(workflow_dict["jobs"].keys()) == [ + "build-documentation-and-check-links", + "run-unit-tests", + ] + # The original and resulting workflows should have the same values here. + assert result["name"] == workflow_dict["name"] + assert result["on"] == workflow_dict["on"] + assert ( + result["jobs"]["run-unit-tests"] == workflow_dict["jobs"]["run-unit-tests"] + ) + # The resulting workflow has job "build-documentation-and-check-links" removed. + assert list(result["jobs"].keys()) == ["run-unit-tests"] + + @staticmethod + @pytest.mark.parametrize( + "step_customization_yaml", + [ActionType.REPLACE.value], + indirect=True, + ) + def test__customize_steps_replacement( + workflow_name, workflow_dict, workflow_patcher, step_customization_yaml + ): + workflow_patcher = WorkflowPatcher( + workflow_dict=workflow_dict, + patch_yaml=workflow_patcher.extract_by_workflow(workflow_name), + ) + + result = workflow_patcher.get_patched_workflow() + + # The original and resulting workflows should have the same values here. + assert result["name"] == workflow_dict["name"] + assert result["on"] == workflow_dict["on"] + assert list(result["jobs"].keys()) == list(workflow_dict["jobs"].keys()) + assert ( + result["jobs"]["build-documentation-and-check-links"] + == workflow_dict["jobs"]["build-documentation-and-check-links"] + ) + # The replaced step (in job `run-unit-tests`, at step `check-out-repository`) + # has a `with`. + assert result["jobs"]["run-unit-tests"]["steps"][0].pop("with") == CommentedMap( + {"fetch-depth": 0} + ) + # Without the `with`, they should be the same, as that's how the test is set up. + assert ( + result["jobs"]["run-unit-tests"] == workflow_dict["jobs"]["run-unit-tests"] + ) + + @staticmethod + @pytest.mark.parametrize( + "step_customization_yaml", + [ActionType.INSERT_AFTER.value], + indirect=True, + ) + def test__customize_steps_insertion_after( + workflow_name, workflow_dict, workflow_patcher, step_customization_yaml + ): + workflow_patcher = WorkflowPatcher( + workflow_dict=workflow_dict, + patch_yaml=workflow_patcher.extract_by_workflow(workflow_name), + ) + + result = workflow_patcher.get_patched_workflow() + + # The original and internal workflows should have the same values here. + assert result["name"] == workflow_dict["name"] + assert result["on"] == workflow_dict["on"] + assert ( + result["jobs"]["build-documentation-and-check-links"] + == workflow_dict["jobs"]["build-documentation-and-check-links"] + ) + # The insert after job added a step at the end of `run-unit-tests`. + assert ( + len(result["jobs"]["run-unit-tests"]["steps"]) + == len(workflow_dict["jobs"]["run-unit-tests"]["steps"]) + 1 + == 2 + ) + assert ( + result["jobs"]["run-unit-tests"]["steps"][0] + == workflow_dict["jobs"]["run-unit-tests"]["steps"][0] + ) + # The inserted after was done after step (in job `run-unit-tests`, at step + # `check-out-repository`). It has a `with` but is otherwise identical to + # the preceding step. + assert result["jobs"]["run-unit-tests"]["steps"][1].pop("with") == CommentedMap( + {"fetch-depth": 0} + ) + assert ( + result["jobs"]["run-unit-tests"]["steps"][1] + == result["jobs"]["run-unit-tests"]["steps"][0] + ) diff --git a/test/unit/util/workflows/render_yaml_test.py b/test/unit/util/workflows/render_yaml_test.py index 035b7fd1c..804b40230 100644 --- a/test/unit/util/workflows/render_yaml_test.py +++ b/test/unit/util/workflows/render_yaml_test.py @@ -1,19 +1,33 @@ from inspect import cleandoc import pytest - -from exasol.toolbox.util.workflows.render_yaml import YamlRenderer +from jinja2 import ( + TemplateSyntaxError, + UndefinedError, +) + +from exasol.toolbox.util.workflows.render_yaml import ( + TemplateRenderingError, + YamlRenderer, +) from noxconfig import PROJECT_CONFIG @pytest.fixture -def yaml_renderer() -> YamlRenderer: - return YamlRenderer(github_template_dict=PROJECT_CONFIG.github_template_dict) +def dummy_yaml(tmp_path): + return tmp_path / "dummy.yml" + + +@pytest.fixture +def yaml_renderer(dummy_yaml) -> YamlRenderer: + return YamlRenderer( + github_template_dict=PROJECT_CONFIG.github_template_dict, file_path=dummy_yaml + ) -class TestTemplateRenderer: +class TestYamlRenderer: @staticmethod - def test_works_for_general_case(tmp_path, yaml_renderer): + def test_works_for_general_case(dummy_yaml, yaml_renderer): input_yaml = """ name: Build & Publish @@ -29,15 +43,14 @@ def test_works_for_general_case(tmp_path, yaml_renderer): permissions: contents: write """ - file_path = tmp_path / "dummy.yml" content = cleandoc(input_yaml) - file_path.write_text(content) + dummy_yaml.write_text(content) - yaml_dict = yaml_renderer.get_yaml_dict(file_path) + yaml_dict = yaml_renderer.get_yaml_dict() assert yaml_renderer.get_as_string(yaml_dict) == cleandoc(input_yaml) @staticmethod - def test_fixes_extra_horizontal_whitespace(tmp_path, yaml_renderer): + def test_fixes_extra_horizontal_whitespace(dummy_yaml, yaml_renderer): # required has 2 extra spaces input_yaml = """ name: Build & Publish @@ -59,15 +72,14 @@ def test_fixes_extra_horizontal_whitespace(tmp_path, yaml_renderer): required: true """ - file_path = tmp_path / "dummy.yml" content = cleandoc(input_yaml) - file_path.write_text(content) + dummy_yaml.write_text(content) - yaml_dict = yaml_renderer.get_yaml_dict(file_path) + yaml_dict = yaml_renderer.get_yaml_dict() assert yaml_renderer.get_as_string(yaml_dict) == cleandoc(expected_yaml) @staticmethod - def test_keeps_comments(tmp_path, yaml_renderer): + def test_keeps_comments(dummy_yaml, yaml_renderer): input_yaml = """ steps: # Comment in nested area @@ -84,15 +96,14 @@ def test_keeps_comments(tmp_path, yaml_renderer): # Comment in step """ - file_path = tmp_path / "dummy.yml" content = cleandoc(input_yaml) - file_path.write_text(content) + dummy_yaml.write_text(content) - yaml_dict = yaml_renderer.get_yaml_dict(file_path) + yaml_dict = yaml_renderer.get_yaml_dict() assert yaml_renderer.get_as_string(yaml_dict) == cleandoc(expected_yaml) @staticmethod - def test_keeps_quotes_for_variables_as_is(tmp_path, yaml_renderer): + def test_keeps_quotes_for_variables_as_is(dummy_yaml, yaml_renderer): input_yaml = """ - name: Build Artifacts run: poetry build @@ -129,15 +140,14 @@ def test_keeps_quotes_for_variables_as_is(tmp_path, yaml_renderer): dist/* """ - file_path = tmp_path / "dummy.yml" content = cleandoc(input_yaml) - file_path.write_text(content) + dummy_yaml.write_text(content) - yaml_dict = yaml_renderer.get_yaml_dict(file_path) + yaml_dict = yaml_renderer.get_yaml_dict() assert yaml_renderer.get_as_string(yaml_dict) == cleandoc(expected_yaml) @staticmethod - def test_updates_jinja_variables(tmp_path, yaml_renderer): + def test_updates_jinja_variables(dummy_yaml, yaml_renderer): input_yaml = """ - name: Setup Python & Poetry Environment uses: exasol/python-toolbox/.github/actions/python-environment@v5 @@ -153,15 +163,14 @@ def test_updates_jinja_variables(tmp_path, yaml_renderer): poetry-version: "2.3.0" """ - file_path = tmp_path / "dummy.yml" content = cleandoc(input_yaml) - file_path.write_text(content) + dummy_yaml.write_text(content) - yaml_dict = yaml_renderer.get_yaml_dict(file_path) + yaml_dict = yaml_renderer.get_yaml_dict() assert yaml_renderer.get_as_string(yaml_dict) == cleandoc(expected_yaml) @staticmethod - def test_preserves_list_format(tmp_path, yaml_renderer): + def test_preserves_list_format(dummy_yaml, yaml_renderer): input_yaml = """ on: pull_request: @@ -178,9 +187,46 @@ def test_preserves_list_format(tmp_path, yaml_renderer): python-versions: ["3.10", "3.11", "3.12", "3.13", "3.14"] """ - file_path = tmp_path / "dummy.yml" content = cleandoc(input_yaml) - file_path.write_text(content) + dummy_yaml.write_text(content) - yaml_dict = yaml_renderer.get_yaml_dict(file_path) + yaml_dict = yaml_renderer.get_yaml_dict() assert yaml_renderer.get_as_string(yaml_dict) == cleandoc(input_yaml) + + @staticmethod + def test_jinja_variable_unknown(dummy_yaml, yaml_renderer): + input_yaml = """ + - name: Setup Python & Poetry Environment + uses: exasol/python-toolbox/.github/actions/python-environment@v5 + with: + poetry-version: "(( bad_jinja ))" + """ + + content = cleandoc(input_yaml) + dummy_yaml.write_text(content) + + with pytest.raises( + TemplateRenderingError, match="Check Jinja2-related errors." + ) as exc: + yaml_renderer.get_yaml_dict() + assert isinstance(exc.value.__cause__, UndefinedError) + assert "'bad_jinja' is undefined" in str(exc.value.__cause__) + + @staticmethod + def test_jinja_variable_unclosed(dummy_yaml, yaml_renderer): + input_yaml = """ + - name: Setup Python & Poetry Environment + uses: exasol/python-toolbox/.github/actions/python-environment@v5 + with: + python-version: "(( minimum_python_version )" + """ + + content = cleandoc(input_yaml) + dummy_yaml.write_text(content) + + with pytest.raises( + TemplateRenderingError, match="Check Jinja2-related errors." + ) as exc: + yaml_renderer.get_yaml_dict() + assert isinstance(exc.value.__cause__, TemplateSyntaxError) + assert "unexpected ')'" in str(exc.value.__cause__) diff --git a/test/unit/util/workflows/workflow_test.py b/test/unit/util/workflows/workflow_test.py index dafb0665e..69781b0a3 100644 --- a/test/unit/util/workflows/workflow_test.py +++ b/test/unit/util/workflows/workflow_test.py @@ -1,5 +1,4 @@ import pytest -from ruamel.yaml.parser import ParserError from exasol.toolbox.util.workflows.workflow import Workflow from noxconfig import PROJECT_CONFIG @@ -42,17 +41,3 @@ def test_fails_when_yaml_does_not_exist(tmp_path): file_path=file_path, github_template_dict=PROJECT_CONFIG.github_template_dict, ) - - @staticmethod - def test_fails_when_yaml_malformed(tmp_path): - file_path = tmp_path / "test.yaml" - file_path.write_text(BAD_TEMPLATE) - - with pytest.raises(ValueError, match="Error rendering file") as excinfo: - Workflow.load_from_template( - file_path=file_path, - github_template_dict=PROJECT_CONFIG.github_template_dict, - ) - - assert isinstance(excinfo.value.__cause__, ParserError) - assert "while parsing a block collection" in str(excinfo.value.__cause__)