From 23d5ec9ce0061f2368c1574bf02a8af4722d870f Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Thu, 12 Feb 2026 10:33:52 +0100 Subject: [PATCH 01/10] Adjust so that noxconfig.py can be loaded here until the migration is done --- .import_linter_config | 1 + 1 file changed, 1 insertion(+) 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 From 60b4a832605ad447b7abdb24a6791a0e7c5ae826 Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Thu, 12 Feb 2026 10:55:32 +0100 Subject: [PATCH 02/10] Add custom exception for InvalidWorkflowPatcherYamlError --- exasol/toolbox/util/workflows/patch_workflow.py | 15 +++++++++++++-- test/unit/util/workflows/patch_workflow_test.py | 9 ++++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/exasol/toolbox/util/workflows/patch_workflow.py b/exasol/toolbox/util/workflows/patch_workflow.py index 9b48fac6e..1527a2caf 100644 --- a/exasol/toolbox/util/workflows/patch_workflow.py +++ b/exasol/toolbox/util/workflows/patch_workflow.py @@ -6,12 +6,20 @@ 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" @@ -92,5 +100,8 @@ class WorkflowPatcher(YamlRenderer): 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 + try: + WorkflowPatcherConfig.model_validate(loaded_yaml) + return loaded_yaml + except ValidationError as exc: + raise InvalidWorkflowPatcherYamlError(file_path=file_path) from exc diff --git a/test/unit/util/workflows/patch_workflow_test.py b/test/unit/util/workflows/patch_workflow_test.py index dc6d98151..e95bf0974 100644 --- a/test/unit/util/workflows/patch_workflow_test.py +++ b/test/unit/util/workflows/patch_workflow_test.py @@ -7,6 +7,7 @@ from exasol.toolbox.util.workflows.patch_workflow import ( ActionType, + InvalidWorkflowPatcherYamlError, WorkflowPatcher, ) from noxconfig import PROJECT_CONFIG @@ -86,5 +87,11 @@ def test_raises_error_for_unknown_action(workflow_patcher_yaml, workflow_patcher content = cleandoc(ExampleYaml.step_customization.format(action="UNKNOWN")) workflow_patcher_yaml.write_text(content) - with pytest.raises(ValidationError, match="Input should be"): + with pytest.raises( + InvalidWorkflowPatcherYamlError, + match="is malformed; it failed Pydantic validation", + ) as exc: workflow_patcher.get_yaml_dict(workflow_patcher_yaml) + + underlying_error = exc.value.__cause__ + assert isinstance(underlying_error, ValidationError) From 33c8f57c55a5132649310e3f9ca98ea8d34adebc Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Thu, 12 Feb 2026 11:24:19 +0100 Subject: [PATCH 03/10] Add custom exceptions YamlSyntaxError and TemplateRenderingError --- exasol/toolbox/util/workflows/render_yaml.py | 45 +++++++-- exasol/toolbox/util/workflows/workflow.py | 11 +-- test/unit/util/workflows/render_yaml_test.py | 98 ++++++++++++++------ test/unit/util/workflows/workflow_test.py | 15 --- 4 files changed, 113 insertions(+), 56 deletions(-) diff --git a/exasol/toolbox/util/workflows/render_yaml.py b/exasol/toolbox/util/workflows/render_yaml.py index 23b57dd57..7650f58ff 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: """ @@ -53,10 +82,14 @@ def get_yaml_dict(self, file_path: Path) -> CommentedMap: with 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=file_path) from exc + except YAMLError as exc: + raise YamlSyntaxError(file_path=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..4bd02b19b 100644 --- a/exasol/toolbox/util/workflows/workflow.py +++ b/exasol/toolbox/util/workflows/workflow.py @@ -19,11 +19,6 @@ 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) + workflow = workflow_renderer.render(file_path=file_path) + return cls(content=workflow) diff --git a/test/unit/util/workflows/render_yaml_test.py b/test/unit/util/workflows/render_yaml_test.py index 035b7fd1c..cd8f33a34 100644 --- a/test/unit/util/workflows/render_yaml_test.py +++ b/test/unit/util/workflows/render_yaml_test.py @@ -1,8 +1,15 @@ 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 @@ -11,9 +18,14 @@ def yaml_renderer() -> YamlRenderer: return YamlRenderer(github_template_dict=PROJECT_CONFIG.github_template_dict) -class TestTemplateRenderer: +@pytest.fixture +def dummy_yaml(tmp_path): + return tmp_path / "dummy.yml" + + +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 +41,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(dummy_yaml) 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 +70,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(dummy_yaml) 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 +94,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(dummy_yaml) 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 +138,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(dummy_yaml) 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 +161,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(dummy_yaml) 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 +185,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(dummy_yaml) 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(dummy_yaml) + 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(dummy_yaml) + 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__) From 14ed14b6700ef8f14d735628e83335e0c2509ab5 Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Thu, 12 Feb 2026 11:48:38 +0100 Subject: [PATCH 04/10] Move example to conftest for re-use --- test/unit/util/workflows/conftest.py | 32 ++++++++++++ .../util/workflows/patch_workflow_test.py | 51 +++++++------------ 2 files changed, 50 insertions(+), 33 deletions(-) create mode 100644 test/unit/util/workflows/conftest.py diff --git a/test/unit/util/workflows/conftest.py b/test/unit/util/workflows/conftest.py new file mode 100644 index 000000000..730e6caa6 --- /dev/null +++ b/test/unit/util/workflows/conftest.py @@ -0,0 +1,32 @@ +from dataclasses import dataclass + +import pytest + + +@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: SCM Checkout + id: checkout-repo + uses: actions/checkout@v6 + with: + fetch-depth: 0 + """ + + +@pytest.fixture(scope="session") +def example_patcher_yaml(): + return ExamplePatcherYaml diff --git a/test/unit/util/workflows/patch_workflow_test.py b/test/unit/util/workflows/patch_workflow_test.py index e95bf0974..fdcc5ec12 100644 --- a/test/unit/util/workflows/patch_workflow_test.py +++ b/test/unit/util/workflows/patch_workflow_test.py @@ -1,4 +1,3 @@ -from dataclasses import dataclass from inspect import cleandoc from pathlib import Path @@ -13,30 +12,6 @@ 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) @@ -49,8 +24,8 @@ def workflow_patcher_yaml(tmp_path: Path) -> Path: class TestWorkflowPatcher: @staticmethod - def test_remove_jobs(workflow_patcher_yaml, workflow_patcher): - content = cleandoc(ExampleYaml.remove_jobs) + def test_remove_jobs(example_patcher_yaml, workflow_patcher_yaml, workflow_patcher): + content = cleandoc(example_patcher_yaml.remove_jobs) workflow_patcher_yaml.write_text(content) yaml_dict = workflow_patcher.get_yaml_dict(workflow_patcher_yaml) @@ -59,8 +34,12 @@ def test_remove_jobs(workflow_patcher_yaml, workflow_patcher): @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_step_customizations( + example_patcher_yaml, workflow_patcher_yaml, action, workflow_patcher + ): + content = cleandoc( + example_patcher_yaml.step_customization.format(action=action.value) + ) workflow_patcher_yaml.write_text(content) yaml_dict = workflow_patcher.get_yaml_dict(workflow_patcher_yaml) @@ -70,9 +49,11 @@ def test_step_customizations(workflow_patcher_yaml, action, workflow_patcher): 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) @@ -83,8 +64,12 @@ def test_allows_extra_field(workflow_patcher_yaml, workflow_patcher): 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( From 92a08b93c91373c698eb9392e7be7b2b1a4b3214 Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Thu, 12 Feb 2026 13:23:35 +0100 Subject: [PATCH 05/10] Switch to cached value --- .../toolbox/util/workflows/patch_workflow.py | 18 +++++++++++++++--- .../util/workflows/patch_workflow_test.py | 19 ++++++++++--------- 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/exasol/toolbox/util/workflows/patch_workflow.py b/exasol/toolbox/util/workflows/patch_workflow.py index 1527a2caf..118b0c886 100644 --- a/exasol/toolbox/util/workflows/patch_workflow.py +++ b/exasol/toolbox/util/workflows/patch_workflow.py @@ -1,4 +1,8 @@ +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 @@ -90,6 +94,7 @@ class WorkflowPatcherConfig(BaseModel): workflows: list[Workflow] +@dataclass(frozen=True) class WorkflowPatcher(YamlRenderer): """ The :class:`WorkflowPatcher` enables users to define a YAML file @@ -98,10 +103,17 @@ class WorkflowPatcher(YamlRenderer): 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) + file_path: Path + + @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(self.file_path) try: WorkflowPatcherConfig.model_validate(loaded_yaml) return loaded_yaml except ValidationError as exc: - raise InvalidWorkflowPatcherYamlError(file_path=file_path) from exc + raise InvalidWorkflowPatcherYamlError(file_path=self.file_path) from exc diff --git a/test/unit/util/workflows/patch_workflow_test.py b/test/unit/util/workflows/patch_workflow_test.py index fdcc5ec12..401bdf505 100644 --- a/test/unit/util/workflows/patch_workflow_test.py +++ b/test/unit/util/workflows/patch_workflow_test.py @@ -13,8 +13,11 @@ @pytest.fixture -def workflow_patcher() -> WorkflowPatcher: - return WorkflowPatcher(github_template_dict=PROJECT_CONFIG.github_template_dict) +def workflow_patcher(workflow_patcher_yaml) -> WorkflowPatcher: + return WorkflowPatcher( + github_template_dict=PROJECT_CONFIG.github_template_dict, + file_path=workflow_patcher_yaml, + ) @pytest.fixture @@ -28,9 +31,8 @@ def test_remove_jobs(example_patcher_yaml, workflow_patcher_yaml, workflow_patch content = cleandoc(example_patcher_yaml.remove_jobs) workflow_patcher_yaml.write_text(content) - yaml_dict = workflow_patcher.get_yaml_dict(workflow_patcher_yaml) - - assert workflow_patcher.get_as_string(yaml_dict) == content + result = workflow_patcher.content + assert workflow_patcher.get_as_string(result) == content @staticmethod @pytest.mark.parametrize("action", ActionType) @@ -42,9 +44,8 @@ def test_step_customizations( ) workflow_patcher_yaml.write_text(content) - yaml_dict = workflow_patcher.get_yaml_dict(workflow_patcher_yaml) - - assert workflow_patcher.get_as_string(yaml_dict) == content + result = workflow_patcher.content + assert workflow_patcher.get_as_string(result) == content class TestStepCustomization: @@ -76,7 +77,7 @@ def test_raises_error_for_unknown_action( InvalidWorkflowPatcherYamlError, match="is malformed; it failed Pydantic validation", ) as exc: - workflow_patcher.get_yaml_dict(workflow_patcher_yaml) + workflow_patcher.content underlying_error = exc.value.__cause__ assert isinstance(underlying_error, ValidationError) From 40397cab356fbe79f22cdf08a02ea121c27a5412 Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Fri, 13 Feb 2026 09:10:24 +0100 Subject: [PATCH 06/10] Make file_path part of class for improved exceptions --- .../toolbox/util/workflows/patch_workflow.py | 4 +-- .../util/workflows/process_template.py | 6 ++--- exasol/toolbox/util/workflows/render_yaml.py | 9 ++++--- exasol/toolbox/util/workflows/workflow.py | 7 ++--- .../util/workflows/patch_workflow_test.py | 2 +- test/unit/util/workflows/render_yaml_test.py | 26 ++++++++++--------- 6 files changed, 27 insertions(+), 27 deletions(-) diff --git a/exasol/toolbox/util/workflows/patch_workflow.py b/exasol/toolbox/util/workflows/patch_workflow.py index 118b0c886..2e2657d7d 100644 --- a/exasol/toolbox/util/workflows/patch_workflow.py +++ b/exasol/toolbox/util/workflows/patch_workflow.py @@ -103,15 +103,13 @@ class WorkflowPatcher(YamlRenderer): The provided YAML file must meet the conditions of :class:`WorkflowPatcherConfig`. """ - file_path: Path - @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(self.file_path) + loaded_yaml = self.get_yaml_dict() try: WorkflowPatcherConfig.model_validate(loaded_yaml) return loaded_yaml diff --git a/exasol/toolbox/util/workflows/process_template.py b/exasol/toolbox/util/workflows/process_template.py index febebaa35..40cc2ef24 100644 --- a/exasol/toolbox/util/workflows/process_template.py +++ b/exasol/toolbox/util/workflows/process_template.py @@ -1,5 +1,3 @@ -from pathlib import Path - from exasol.toolbox.util.workflows.render_yaml import YamlRenderer @@ -11,9 +9,9 @@ class WorkflowRenderer(YamlRenderer): - standardizing formatting via ruamel.yaml for a consistent output. """ - def render(self, file_path: Path) -> str: + 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() return self.get_as_string(workflow_dict) diff --git a/exasol/toolbox/util/workflows/render_yaml.py b/exasol/toolbox/util/workflows/render_yaml.py index 7650f58ff..265b95c0d 100644 --- a/exasol/toolbox/util/workflows/render_yaml.py +++ b/exasol/toolbox/util/workflows/render_yaml.py @@ -54,6 +54,7 @@ class YamlRenderer: """ github_template_dict: dict[str, Any] + file_path: Path @staticmethod def _get_standard_yaml() -> YAML: @@ -74,12 +75,12 @@ 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() try: @@ -87,9 +88,9 @@ def get_yaml_dict(self, file_path: Path) -> CommentedMap: yaml = self._get_standard_yaml() return yaml.load(workflow_string) except TemplateError as exc: - raise TemplateRenderingError(file_path=file_path) from exc + raise TemplateRenderingError(file_path=self.file_path) from exc except YAMLError as exc: - raise YamlSyntaxError(file_path=file_path) from 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 4bd02b19b..16a93d171 100644 --- a/exasol/toolbox/util/workflows/workflow.py +++ b/exasol/toolbox/util/workflows/workflow.py @@ -19,6 +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) - workflow_renderer = WorkflowRenderer(github_template_dict=github_template_dict) - workflow = workflow_renderer.render(file_path=file_path) - return cls(content=workflow) + 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/patch_workflow_test.py b/test/unit/util/workflows/patch_workflow_test.py index 401bdf505..29d344c39 100644 --- a/test/unit/util/workflows/patch_workflow_test.py +++ b/test/unit/util/workflows/patch_workflow_test.py @@ -60,7 +60,7 @@ def test_allows_extra_field( 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 diff --git a/test/unit/util/workflows/render_yaml_test.py b/test/unit/util/workflows/render_yaml_test.py index cd8f33a34..804b40230 100644 --- a/test/unit/util/workflows/render_yaml_test.py +++ b/test/unit/util/workflows/render_yaml_test.py @@ -14,13 +14,15 @@ @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 dummy_yaml(tmp_path): - return tmp_path / "dummy.yml" +def yaml_renderer(dummy_yaml) -> YamlRenderer: + return YamlRenderer( + github_template_dict=PROJECT_CONFIG.github_template_dict, file_path=dummy_yaml + ) class TestYamlRenderer: @@ -44,7 +46,7 @@ def test_works_for_general_case(dummy_yaml, yaml_renderer): content = cleandoc(input_yaml) dummy_yaml.write_text(content) - yaml_dict = yaml_renderer.get_yaml_dict(dummy_yaml) + yaml_dict = yaml_renderer.get_yaml_dict() assert yaml_renderer.get_as_string(yaml_dict) == cleandoc(input_yaml) @staticmethod @@ -73,7 +75,7 @@ def test_fixes_extra_horizontal_whitespace(dummy_yaml, yaml_renderer): content = cleandoc(input_yaml) dummy_yaml.write_text(content) - yaml_dict = yaml_renderer.get_yaml_dict(dummy_yaml) + yaml_dict = yaml_renderer.get_yaml_dict() assert yaml_renderer.get_as_string(yaml_dict) == cleandoc(expected_yaml) @staticmethod @@ -97,7 +99,7 @@ def test_keeps_comments(dummy_yaml, yaml_renderer): content = cleandoc(input_yaml) dummy_yaml.write_text(content) - yaml_dict = yaml_renderer.get_yaml_dict(dummy_yaml) + yaml_dict = yaml_renderer.get_yaml_dict() assert yaml_renderer.get_as_string(yaml_dict) == cleandoc(expected_yaml) @staticmethod @@ -141,7 +143,7 @@ def test_keeps_quotes_for_variables_as_is(dummy_yaml, yaml_renderer): content = cleandoc(input_yaml) dummy_yaml.write_text(content) - yaml_dict = yaml_renderer.get_yaml_dict(dummy_yaml) + yaml_dict = yaml_renderer.get_yaml_dict() assert yaml_renderer.get_as_string(yaml_dict) == cleandoc(expected_yaml) @staticmethod @@ -164,7 +166,7 @@ def test_updates_jinja_variables(dummy_yaml, yaml_renderer): content = cleandoc(input_yaml) dummy_yaml.write_text(content) - yaml_dict = yaml_renderer.get_yaml_dict(dummy_yaml) + yaml_dict = yaml_renderer.get_yaml_dict() assert yaml_renderer.get_as_string(yaml_dict) == cleandoc(expected_yaml) @staticmethod @@ -188,7 +190,7 @@ def test_preserves_list_format(dummy_yaml, yaml_renderer): content = cleandoc(input_yaml) dummy_yaml.write_text(content) - yaml_dict = yaml_renderer.get_yaml_dict(dummy_yaml) + yaml_dict = yaml_renderer.get_yaml_dict() assert yaml_renderer.get_as_string(yaml_dict) == cleandoc(input_yaml) @staticmethod @@ -206,7 +208,7 @@ def test_jinja_variable_unknown(dummy_yaml, yaml_renderer): with pytest.raises( TemplateRenderingError, match="Check Jinja2-related errors." ) as exc: - yaml_renderer.get_yaml_dict(dummy_yaml) + yaml_renderer.get_yaml_dict() assert isinstance(exc.value.__cause__, UndefinedError) assert "'bad_jinja' is undefined" in str(exc.value.__cause__) @@ -225,6 +227,6 @@ def test_jinja_variable_unclosed(dummy_yaml, yaml_renderer): with pytest.raises( TemplateRenderingError, match="Check Jinja2-related errors." ) as exc: - yaml_renderer.get_yaml_dict(dummy_yaml) + yaml_renderer.get_yaml_dict() assert isinstance(exc.value.__cause__, TemplateSyntaxError) assert "unexpected ')'" in str(exc.value.__cause__) From 811ae802affb120b8e90baeafd3ee4ad7a630acd Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Fri, 13 Feb 2026 10:09:38 +0100 Subject: [PATCH 07/10] Switch these to fixtures as will be resused for unit tests --- test/unit/util/workflows/conftest.py | 36 +++++++++++++++++ .../util/workflows/patch_workflow_test.py | 40 +++++-------------- 2 files changed, 45 insertions(+), 31 deletions(-) diff --git a/test/unit/util/workflows/conftest.py b/test/unit/util/workflows/conftest.py index 730e6caa6..32b0d8032 100644 --- a/test/unit/util/workflows/conftest.py +++ b/test/unit/util/workflows/conftest.py @@ -1,7 +1,12 @@ 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: @@ -30,3 +35,34 @@ class ExamplePatcherYaml: @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 29d344c39..dbe3c7f0f 100644 --- a/test/unit/util/workflows/patch_workflow_test.py +++ b/test/unit/util/workflows/patch_workflow_test.py @@ -1,5 +1,4 @@ from inspect import cleandoc -from pathlib import Path import pytest from pydantic import ValidationError @@ -7,45 +6,24 @@ from exasol.toolbox.util.workflows.patch_workflow import ( ActionType, InvalidWorkflowPatcherYamlError, - WorkflowPatcher, ) -from noxconfig import PROJECT_CONFIG - - -@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 workflow_patcher_yaml(tmp_path: Path) -> Path: - return tmp_path / ".workflow-patcher.yml" class TestWorkflowPatcher: @staticmethod - def test_remove_jobs(example_patcher_yaml, workflow_patcher_yaml, workflow_patcher): - content = cleandoc(example_patcher_yaml.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) == content + assert workflow_patcher.get_as_string(result) == remove_job_yaml @staticmethod - @pytest.mark.parametrize("action", ActionType) - def test_step_customizations( - example_patcher_yaml, workflow_patcher_yaml, action, workflow_patcher - ): - content = cleandoc( - example_patcher_yaml.step_customization.format(action=action.value) - ) - workflow_patcher_yaml.write_text(content) - + @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) == content + assert workflow_patcher.get_as_string(result) == step_customization_yaml class TestStepCustomization: From 946f3e809ee2afdbff1124d1535177eb915abf96 Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Fri, 13 Feb 2026 10:59:56 +0100 Subject: [PATCH 08/10] Add extract_by_workflow and test --- .../toolbox/util/workflows/patch_workflow.py | 13 ++++++++++ .../util/workflows/patch_workflow_test.py | 26 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/exasol/toolbox/util/workflows/patch_workflow.py b/exasol/toolbox/util/workflows/patch_workflow.py index 2e2657d7d..a4f2ca636 100644 --- a/exasol/toolbox/util/workflows/patch_workflow.py +++ b/exasol/toolbox/util/workflows/patch_workflow.py @@ -115,3 +115,16 @@ def content(self) -> CommentedMap: return loaded_yaml except ValidationError as exc: raise InvalidWorkflowPatcherYamlError(file_path=self.file_path) from exc + + def extract_by_workflow(self, workflow_name: str) -> CommentedMap | 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/test/unit/util/workflows/patch_workflow_test.py b/test/unit/util/workflows/patch_workflow_test.py index dbe3c7f0f..8bea1fe7c 100644 --- a/test/unit/util/workflows/patch_workflow_test.py +++ b/test/unit/util/workflows/patch_workflow_test.py @@ -2,6 +2,7 @@ import pytest from pydantic import ValidationError +from ruamel.yaml import CommentedMap from exasol.toolbox.util.workflows.patch_workflow import ( ActionType, @@ -25,6 +26,31 @@ def test_step_customizations(step_customization_yaml, workflow_patcher): result = workflow_patcher.content assert workflow_patcher.get_as_string(result) == step_customization_yaml + @staticmethod + 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) + + result = workflow_patcher.extract_by_workflow("pr-merge.yml") + assert result == CommentedMap( + {"name": "pr-merge.yml", "remove_jobs": ["publish-docs"]} + ) + + @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 From a654651986e3ea2daf0b73c6489d9b7c8abeceaf Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Fri, 13 Feb 2026 11:02:24 +0100 Subject: [PATCH 09/10] Update docstring --- exasol/toolbox/util/workflows/patch_workflow.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/exasol/toolbox/util/workflows/patch_workflow.py b/exasol/toolbox/util/workflows/patch_workflow.py index a4f2ca636..6f96a27d8 100644 --- a/exasol/toolbox/util/workflows/patch_workflow.py +++ b/exasol/toolbox/util/workflows/patch_workflow.py @@ -98,9 +98,9 @@ class WorkflowPatcherConfig(BaseModel): 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`. """ @cached_property From a03971f838a98db134af01404f4de27fd6d744a6 Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Fri, 13 Feb 2026 11:52:23 +0100 Subject: [PATCH 10/10] Add class to patch the workflow & tests to verify but do not activate yet --- .../toolbox/util/workflows/patch_workflow.py | 22 ++- .../util/workflows/process_template.py | 93 ++++++++++ test/unit/util/workflows/conftest.py | 4 +- .../util/workflows/process_template_test.py | 165 ++++++++++++++++++ 4 files changed, 280 insertions(+), 4 deletions(-) create mode 100644 test/unit/util/workflows/process_template_test.py diff --git a/exasol/toolbox/util/workflows/patch_workflow.py b/exasol/toolbox/util/workflows/patch_workflow.py index 6f96a27d8..9947caea8 100644 --- a/exasol/toolbox/util/workflows/patch_workflow.py +++ b/exasol/toolbox/util/workflows/patch_workflow.py @@ -4,7 +4,11 @@ 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, @@ -94,6 +98,20 @@ 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): """ @@ -116,7 +134,7 @@ def content(self) -> CommentedMap: except ValidationError as exc: raise InvalidWorkflowPatcherYamlError(file_path=self.file_path) from exc - def extract_by_workflow(self, workflow_name: str) -> CommentedMap | None: + 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 diff --git a/exasol/toolbox/util/workflows/process_template.py b/exasol/toolbox/util/workflows/process_template.py index 40cc2ef24..35fb1e345 100644 --- a/exasol/toolbox/util/workflows/process_template.py +++ b/exasol/toolbox/util/workflows/process_template.py @@ -1,6 +1,18 @@ +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 @@ -9,9 +21,90 @@ class WorkflowRenderer(YamlRenderer): - standardizing formatting via ruamel.yaml for a consistent output. """ + 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() + + 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/test/unit/util/workflows/conftest.py b/test/unit/util/workflows/conftest.py index 32b0d8032..f2ad2a416 100644 --- a/test/unit/util/workflows/conftest.py +++ b/test/unit/util/workflows/conftest.py @@ -24,8 +24,8 @@ class ExamplePatcherYaml: job: run-unit-tests step_id: check-out-repository content: - - name: SCM Checkout - id: checkout-repo + - name: Check out Repository + id: check-out-repository uses: actions/checkout@v6 with: fetch-depth: 0 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] + )