From a1a06ae9f4de3bd7ee84e39659c3226894fe1a66 Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Fri, 6 Feb 2026 14:07:51 +0100 Subject: [PATCH 01/18] Add functions to identify and parse .exasol-toolbox.yml file --- doc/changes/unreleased.md | 4 + exasol/toolbox/config.py | 15 +++ .../util/workflows/customize_workflow.py | 54 +++++++++++ exasol/toolbox/util/workflows/format_yaml.py | 10 ++ .../util/workflows/template_processing.py | 12 +-- test/unit/config_test.py | 1 + .../util/workflows/customize_workflow_test.py | 93 +++++++++++++++++++ 7 files changed, 181 insertions(+), 8 deletions(-) create mode 100644 exasol/toolbox/util/workflows/customize_workflow.py create mode 100644 exasol/toolbox/util/workflows/format_yaml.py create mode 100644 test/unit/util/workflows/customize_workflow_test.py diff --git a/doc/changes/unreleased.md b/doc/changes/unreleased.md index fb4737052..f5db04f32 100644 --- a/doc/changes/unreleased.md +++ b/doc/changes/unreleased.md @@ -1,3 +1,7 @@ # Unreleased ## Summary + +## Feature + +* #691: Started customization of PTB workflows by defining the YML schema diff --git a/exasol/toolbox/config.py b/exasol/toolbox/config.py index 47ac24ff0..809d4f066 100644 --- a/exasol/toolbox/config.py +++ b/exasol/toolbox/config.py @@ -279,3 +279,18 @@ def github_template_dict(self) -> dict[str, Any]: "os_version": self.os_version, "python_versions": self.python_versions, } + + @computed_field # type: ignore[misc] + @property + def github_modification_yml(self) -> Path | None: + """ + For modifying the GitHub workflow templates provided by the PTB, + a project can define a `.exasol-toolbox.yml`. + + This feature is a work-in-progress that will be completed with: + https://github.com/exasol/python-toolbox/issues/690 + """ + modification_yml = self.root_path / ".exasol-toolbox.yml" + if modification_yml.exists(): + return modification_yml + return None diff --git a/exasol/toolbox/util/workflows/customize_workflow.py b/exasol/toolbox/util/workflows/customize_workflow.py new file mode 100644 index 000000000..e291fe2f5 --- /dev/null +++ b/exasol/toolbox/util/workflows/customize_workflow.py @@ -0,0 +1,54 @@ +from enum import Enum +from pathlib import Path +from typing import Any + +from pydantic import ( + BaseModel, + ConfigDict, + Field, +) +from ruamel.yaml import CommentedMap + +from exasol.toolbox.util.workflows.format_yaml import get_standard_yaml + + +class ActionType(str, Enum): + INSERT_AFTER = "INSERT_AFTER" + REPLACE = "REPLACE" + + +class StepContent(BaseModel): + model_config = ConfigDict(extra="allow") # This allows extra fields + + name: str + id: str + uses: str | None = None + run: str | None = None + with_: dict[str, Any] | None = Field(None, alias="with") + env: dict[str, str] | None = None + + +class StepCustomization(BaseModel): + action: ActionType + job: str + step_id: str + content: StepContent + + +class Workflow(BaseModel): + name: str + remove_jobs: list[str] = Field(default_factory=list) + step_customizations: list[StepCustomization] = Field(default_factory=list) + + +class WorkflowConfig(BaseModel): + workflows: list[Workflow] + + +def load_and_validate_custom_workflow(file_path: Path) -> CommentedMap: + standard_yaml = get_standard_yaml() + with file_path.open("r", encoding="utf-8") as stream: + yaml_dict = standard_yaml.load(stream) + + WorkflowConfig.model_validate(yaml_dict) + return yaml_dict diff --git a/exasol/toolbox/util/workflows/format_yaml.py b/exasol/toolbox/util/workflows/format_yaml.py new file mode 100644 index 000000000..51a79fa6e --- /dev/null +++ b/exasol/toolbox/util/workflows/format_yaml.py @@ -0,0 +1,10 @@ +from ruamel.yaml import YAML + + +def get_standard_yaml() -> YAML: + yaml = YAML() + yaml.width = 200 + yaml.preserve_quotes = True + yaml.sort_base_mapping_type_on_output = False # type: ignore + yaml.indent(mapping=2, sequence=4, offset=2) + return yaml diff --git a/exasol/toolbox/util/workflows/template_processing.py b/exasol/toolbox/util/workflows/template_processing.py index e78deba45..45367b212 100644 --- a/exasol/toolbox/util/workflows/template_processing.py +++ b/exasol/toolbox/util/workflows/template_processing.py @@ -3,6 +3,8 @@ from jinja2 import Environment +from exasol.toolbox.util.workflows.format_yaml import get_standard_yaml + jinja_env = Environment( variable_start_string="((", variable_end_string="))", autoescape=True ) @@ -10,8 +12,6 @@ import io from inspect import cleandoc -from ruamel.yaml import YAML - @dataclass(frozen=True) class TemplateRenderer: @@ -29,13 +29,9 @@ def render_to_workflow(self) -> str: """ Render the template to the contents of a valid GitHub workflow. """ - yaml = YAML() - yaml.width = 200 - yaml.preserve_quotes = True - yaml.sort_base_mapping_type_on_output = False # type: ignore - yaml.indent(mapping=2, sequence=4, offset=2) - workflow_string = self._render_with_jinja(self.template_str) + + yaml = get_standard_yaml() workflow_dict = yaml.load(workflow_string) stream = io.StringIO() diff --git a/test/unit/config_test.py b/test/unit/config_test.py index cba1e54b4..f2c101c40 100644 --- a/test/unit/config_test.py +++ b/test/unit/config_test.py @@ -34,6 +34,7 @@ def test_works_as_defined(test_project_config_factory): "dist", "venv", ), + "github_modification_yml": None, "github_template_dict": { "dependency_manager_version": "2.3.0", "minimum_python_version": "3.10", diff --git a/test/unit/util/workflows/customize_workflow_test.py b/test/unit/util/workflows/customize_workflow_test.py new file mode 100644 index 000000000..3d5b45831 --- /dev/null +++ b/test/unit/util/workflows/customize_workflow_test.py @@ -0,0 +1,93 @@ +import io +from dataclasses import dataclass +from inspect import cleandoc + +import pytest +from pydantic import ValidationError + +from exasol.toolbox.util.workflows.customize_workflow import ( + ActionType, + load_and_validate_custom_workflow, +) +from exasol.toolbox.util.workflows.format_yaml import get_standard_yaml + + +@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 + # The PTB has unit tests which require the fetch-depth to be 0. + with: + fetch-depth: 0 + """ + + +def convert_back_to_str(yaml_dict): + stream = io.StringIO() + yaml = get_standard_yaml() + yaml.dump(yaml_dict, stream) + return stream.getvalue() + + +class TestLoadAndValidateCustomWorkflowWorks: + @staticmethod + def test_remove_jobs(tmp_path): + file_path = tmp_path / ".exasol-toolbox.yml" + content = cleandoc(ExampleYaml.remove_jobs) + file_path.write_text(content) + + result = load_and_validate_custom_workflow(file_path) + + assert convert_back_to_str(result) == content + "\n" + + @staticmethod + @pytest.mark.parametrize("action", ActionType) + def test_step_customizations(tmp_path, action): + file_path = tmp_path / ".exasol-toolbox.yml" + content = cleandoc(ExampleYaml.step_customization.format(action=action.value)) + file_path.write_text(content) + + result = load_and_validate_custom_workflow(file_path) + + assert convert_back_to_str(result) == content + "\n" + + +class TestStepCustomization: + @staticmethod + def test_allows_extra_field(tmp_path): + file_path = tmp_path / ".exasol-toolbox.yml" + + content = f""" + {ExampleYaml.step_customization.format(action="REPLACE")} + extra-field: "test" + """ + content = cleandoc(content) + file_path.write_text(content) + + result = load_and_validate_custom_workflow(file_path) + + assert convert_back_to_str(result) == content + "\n" + + @staticmethod + def test_raises_error_for_unknown_action(tmp_path): + file_path = tmp_path / ".exasol-toolbox.yml" + content = cleandoc(ExampleYaml.step_customization.format(action="UNKNOWN")) + file_path.write_text(content) + + with pytest.raises(ValidationError, match="Input should be"): + load_and_validate_custom_workflow(file_path) From 351f7a2e73fe3a4fdda6fbc36993ed0077c19a95 Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Mon, 9 Feb 2026 11:59:53 +0100 Subject: [PATCH 02/18] Rename to process_template --- .../workflows/{template_processing.py => process_template.py} | 0 exasol/toolbox/util/workflows/workflow.py | 2 +- .../{template_processing_test.py => process_template_test.py} | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename exasol/toolbox/util/workflows/{template_processing.py => process_template.py} (100%) rename test/unit/util/workflows/{template_processing_test.py => process_template_test.py} (98%) diff --git a/exasol/toolbox/util/workflows/template_processing.py b/exasol/toolbox/util/workflows/process_template.py similarity index 100% rename from exasol/toolbox/util/workflows/template_processing.py rename to exasol/toolbox/util/workflows/process_template.py diff --git a/exasol/toolbox/util/workflows/workflow.py b/exasol/toolbox/util/workflows/workflow.py index 83964a2ea..e6070f637 100644 --- a/exasol/toolbox/util/workflows/workflow.py +++ b/exasol/toolbox/util/workflows/workflow.py @@ -6,7 +6,7 @@ ConfigDict, ) -from exasol.toolbox.util.workflows.template_processing import TemplateRenderer +from exasol.toolbox.util.workflows.process_template import TemplateRenderer class Workflow(BaseModel): diff --git a/test/unit/util/workflows/template_processing_test.py b/test/unit/util/workflows/process_template_test.py similarity index 98% rename from test/unit/util/workflows/template_processing_test.py rename to test/unit/util/workflows/process_template_test.py index 93b6b2dfe..c8d491f7b 100644 --- a/test/unit/util/workflows/template_processing_test.py +++ b/test/unit/util/workflows/process_template_test.py @@ -1,6 +1,6 @@ from inspect import cleandoc -from exasol.toolbox.util.workflows.template_processing import TemplateRenderer +from exasol.toolbox.util.workflows.process_template import TemplateRenderer from noxconfig import PROJECT_CONFIG From 3ebc05d9d03cf08020fc8078bb04ecfd09bb7dcf Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Mon, 9 Feb 2026 12:02:24 +0100 Subject: [PATCH 03/18] Rename to load_and_validate_workflow_customizer --- exasol/toolbox/util/workflows/customize_workflow.py | 2 +- test/unit/util/workflows/customize_workflow_test.py | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/exasol/toolbox/util/workflows/customize_workflow.py b/exasol/toolbox/util/workflows/customize_workflow.py index e291fe2f5..caab83ce5 100644 --- a/exasol/toolbox/util/workflows/customize_workflow.py +++ b/exasol/toolbox/util/workflows/customize_workflow.py @@ -45,7 +45,7 @@ class WorkflowConfig(BaseModel): workflows: list[Workflow] -def load_and_validate_custom_workflow(file_path: Path) -> CommentedMap: +def load_and_validate_workflow_customizer(file_path: Path) -> CommentedMap: standard_yaml = get_standard_yaml() with file_path.open("r", encoding="utf-8") as stream: yaml_dict = standard_yaml.load(stream) diff --git a/test/unit/util/workflows/customize_workflow_test.py b/test/unit/util/workflows/customize_workflow_test.py index 3d5b45831..005109fb4 100644 --- a/test/unit/util/workflows/customize_workflow_test.py +++ b/test/unit/util/workflows/customize_workflow_test.py @@ -7,7 +7,7 @@ from exasol.toolbox.util.workflows.customize_workflow import ( ActionType, - load_and_validate_custom_workflow, + load_and_validate_workflow_customizer, ) from exasol.toolbox.util.workflows.format_yaml import get_standard_yaml @@ -44,14 +44,14 @@ def convert_back_to_str(yaml_dict): return stream.getvalue() -class TestLoadAndValidateCustomWorkflowWorks: +class TestLoadAndValidateWorkflowCustomizer: @staticmethod def test_remove_jobs(tmp_path): file_path = tmp_path / ".exasol-toolbox.yml" content = cleandoc(ExampleYaml.remove_jobs) file_path.write_text(content) - result = load_and_validate_custom_workflow(file_path) + result = load_and_validate_workflow_customizer(file_path) assert convert_back_to_str(result) == content + "\n" @@ -62,7 +62,7 @@ def test_step_customizations(tmp_path, action): content = cleandoc(ExampleYaml.step_customization.format(action=action.value)) file_path.write_text(content) - result = load_and_validate_custom_workflow(file_path) + result = load_and_validate_workflow_customizer(file_path) assert convert_back_to_str(result) == content + "\n" @@ -79,7 +79,7 @@ def test_allows_extra_field(tmp_path): content = cleandoc(content) file_path.write_text(content) - result = load_and_validate_custom_workflow(file_path) + result = load_and_validate_workflow_customizer(file_path) assert convert_back_to_str(result) == content + "\n" @@ -90,4 +90,4 @@ def test_raises_error_for_unknown_action(tmp_path): file_path.write_text(content) with pytest.raises(ValidationError, match="Input should be"): - load_and_validate_custom_workflow(file_path) + load_and_validate_workflow_customizer(file_path) From cb2fe31a00871d6aca6098eefc7ce11db9460859 Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Mon, 9 Feb 2026 13:45:54 +0100 Subject: [PATCH 04/18] Extract and use YamlRenderer class --- .../util/workflows/customize_workflow.py | 17 ++-- exasol/toolbox/util/workflows/format_yaml.py | 68 ++++++++++++++-- .../util/workflows/process_template.py | 39 ++------- exasol/toolbox/util/workflows/workflow.py | 5 +- .../util/workflows/customize_workflow_test.py | 66 +++++++-------- .../util/workflows/process_template_test.py | 80 ++++++++++--------- 6 files changed, 153 insertions(+), 122 deletions(-) diff --git a/exasol/toolbox/util/workflows/customize_workflow.py b/exasol/toolbox/util/workflows/customize_workflow.py index caab83ce5..9c9e9119e 100644 --- a/exasol/toolbox/util/workflows/customize_workflow.py +++ b/exasol/toolbox/util/workflows/customize_workflow.py @@ -9,7 +9,7 @@ ) from ruamel.yaml import CommentedMap -from exasol.toolbox.util.workflows.format_yaml import get_standard_yaml +from exasol.toolbox.util.workflows.format_yaml import YamlRenderer class ActionType(str, Enum): @@ -45,10 +45,13 @@ class WorkflowConfig(BaseModel): workflows: list[Workflow] -def load_and_validate_workflow_customizer(file_path: Path) -> CommentedMap: - standard_yaml = get_standard_yaml() - with file_path.open("r", encoding="utf-8") as stream: - yaml_dict = standard_yaml.load(stream) +class CustomWorkflow(YamlRenderer): + """ + The PTB allows users to define a YAML (of type :class:`WorkflowConfig`) to + customize the workflows provided by the PTB. + """ - WorkflowConfig.model_validate(yaml_dict) - return yaml_dict + def get_yaml_dict(self, file_path: Path) -> CommentedMap: + loaded_yaml = super().get_yaml_dict(file_path) + WorkflowConfig.model_validate(loaded_yaml) + return loaded_yaml diff --git a/exasol/toolbox/util/workflows/format_yaml.py b/exasol/toolbox/util/workflows/format_yaml.py index 51a79fa6e..c73f19138 100644 --- a/exasol/toolbox/util/workflows/format_yaml.py +++ b/exasol/toolbox/util/workflows/format_yaml.py @@ -1,10 +1,62 @@ -from ruamel.yaml import YAML +import io +from dataclasses import dataclass +from inspect import cleandoc +from pathlib import Path +from typing import Any +from jinja2 import Environment +from ruamel.yaml import ( + YAML, + CommentedMap, +) -def get_standard_yaml() -> YAML: - yaml = YAML() - yaml.width = 200 - yaml.preserve_quotes = True - yaml.sort_base_mapping_type_on_output = False # type: ignore - yaml.indent(mapping=2, sequence=4, offset=2) - return yaml +jinja_env = Environment( + variable_start_string="((", variable_end_string="))", autoescape=True +) + + +@dataclass(frozen=True) +class YamlRenderer: + github_template_dict: dict[str, Any] + + @staticmethod + def _get_standard_yaml() -> YAML: + """ + Prepare standard YAML class + """ + yaml = YAML() + yaml.width = 200 + yaml.preserve_quotes = True + yaml.sort_base_mapping_type_on_output = False # type: ignore + yaml.indent(mapping=2, sequence=4, offset=2) + return yaml + + def _render_with_jinja(self, input_str: str) -> str: + """ + Render the template with Jinja. + """ + 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: + """ + 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: + raw_content = stream.read() + + workflow_string = self._render_with_jinja(raw_content) + + yaml = self._get_standard_yaml() + return yaml.load(workflow_string) + + def get_as_string(self, yaml_dict: CommentedMap) -> str: + """ + Output a YAML string. + """ + yaml = self._get_standard_yaml() + with io.StringIO() as stream: + yaml.dump(yaml_dict, stream) + workflow_string = stream.getvalue() + return cleandoc(workflow_string) diff --git a/exasol/toolbox/util/workflows/process_template.py b/exasol/toolbox/util/workflows/process_template.py index 45367b212..7074d259c 100644 --- a/exasol/toolbox/util/workflows/process_template.py +++ b/exasol/toolbox/util/workflows/process_template.py @@ -1,40 +1,13 @@ -from dataclasses import dataclass -from typing import Any +from pathlib import Path -from jinja2 import Environment +from exasol.toolbox.util.workflows.format_yaml import YamlRenderer -from exasol.toolbox.util.workflows.format_yaml import get_standard_yaml -jinja_env = Environment( - variable_start_string="((", variable_end_string="))", autoescape=True -) +class TemplateRenderer(YamlRenderer): -import io -from inspect import cleandoc - - -@dataclass(frozen=True) -class TemplateRenderer: - template_str: str - github_template_dict: dict[str, Any] - - def _render_with_jinja(self, input_str: str) -> str: - """ - Render the template with Jinja. - """ - jinja_template = jinja_env.from_string(input_str) - return jinja_template.render(self.github_template_dict) - - def render_to_workflow(self) -> str: + def render_to_workflow(self, file_path: Path) -> str: """ Render the template to the contents of a valid GitHub workflow. """ - workflow_string = self._render_with_jinja(self.template_str) - - yaml = get_standard_yaml() - workflow_dict = yaml.load(workflow_string) - - stream = io.StringIO() - yaml.dump(workflow_dict, stream) - workflow_string = stream.getvalue() - return cleandoc(workflow_string) + workflow_dict = self.get_yaml_dict(file_path) + return self.get_as_string(workflow_dict) diff --git a/exasol/toolbox/util/workflows/workflow.py b/exasol/toolbox/util/workflows/workflow.py index e6070f637..99a75cfbb 100644 --- a/exasol/toolbox/util/workflows/workflow.py +++ b/exasol/toolbox/util/workflows/workflow.py @@ -20,11 +20,10 @@ def load_from_template(cls, file_path: Path, github_template_dict: dict[str, Any raise FileNotFoundError(file_path) try: - raw_content = file_path.read_text() template_renderer = TemplateRenderer( - template_str=raw_content, github_template_dict=github_template_dict + github_template_dict=github_template_dict ) - workflow = template_renderer.render_to_workflow() + workflow = template_renderer.render_to_workflow(file_path=file_path) return cls(content=workflow) except Exception as e: raise ValueError(f"Error rendering file: {file_path}") from e diff --git a/test/unit/util/workflows/customize_workflow_test.py b/test/unit/util/workflows/customize_workflow_test.py index 005109fb4..4a04a1cdf 100644 --- a/test/unit/util/workflows/customize_workflow_test.py +++ b/test/unit/util/workflows/customize_workflow_test.py @@ -1,4 +1,3 @@ -import io from dataclasses import dataclass from inspect import cleandoc @@ -7,69 +6,66 @@ from exasol.toolbox.util.workflows.customize_workflow import ( ActionType, - load_and_validate_workflow_customizer, + CustomWorkflow, ) -from exasol.toolbox.util.workflows.format_yaml import get_standard_yaml +from noxconfig import PROJECT_CONFIG @dataclass(frozen=True) class ExampleYaml: remove_jobs = """ workflows: - - name: "checks.yml" - remove_jobs: - - documentation + - 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 - # The PTB has unit tests which require the fetch-depth to be 0. - with: - fetch-depth: 0 + - 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 """ -def convert_back_to_str(yaml_dict): - stream = io.StringIO() - yaml = get_standard_yaml() - yaml.dump(yaml_dict, stream) - return stream.getvalue() +@pytest.fixture +def custom_workflow() -> CustomWorkflow: + return CustomWorkflow(github_template_dict=PROJECT_CONFIG.github_template_dict) -class TestLoadAndValidateWorkflowCustomizer: +class TestCustomWorkflow: @staticmethod - def test_remove_jobs(tmp_path): + def test_remove_jobs(tmp_path, custom_workflow): file_path = tmp_path / ".exasol-toolbox.yml" content = cleandoc(ExampleYaml.remove_jobs) file_path.write_text(content) - result = load_and_validate_workflow_customizer(file_path) + yaml_dict = custom_workflow.get_yaml_dict(file_path) - assert convert_back_to_str(result) == content + "\n" + assert custom_workflow.get_as_string(yaml_dict) == content @staticmethod @pytest.mark.parametrize("action", ActionType) - def test_step_customizations(tmp_path, action): + def test_step_customizations(tmp_path, action, custom_workflow): file_path = tmp_path / ".exasol-toolbox.yml" content = cleandoc(ExampleYaml.step_customization.format(action=action.value)) file_path.write_text(content) - result = load_and_validate_workflow_customizer(file_path) + yaml_dict = custom_workflow.get_yaml_dict(file_path) - assert convert_back_to_str(result) == content + "\n" + assert custom_workflow.get_as_string(yaml_dict) == content class TestStepCustomization: @staticmethod - def test_allows_extra_field(tmp_path): + def test_allows_extra_field(tmp_path, custom_workflow): file_path = tmp_path / ".exasol-toolbox.yml" content = f""" @@ -79,15 +75,15 @@ def test_allows_extra_field(tmp_path): content = cleandoc(content) file_path.write_text(content) - result = load_and_validate_workflow_customizer(file_path) + yaml_dict = custom_workflow.get_yaml_dict(file_path) - assert convert_back_to_str(result) == content + "\n" + assert custom_workflow.get_as_string(yaml_dict) == content @staticmethod - def test_raises_error_for_unknown_action(tmp_path): + def test_raises_error_for_unknown_action(tmp_path, custom_workflow): file_path = tmp_path / ".exasol-toolbox.yml" content = cleandoc(ExampleYaml.step_customization.format(action="UNKNOWN")) file_path.write_text(content) with pytest.raises(ValidationError, match="Input should be"): - load_and_validate_workflow_customizer(file_path) + custom_workflow.get_yaml_dict(file_path) diff --git a/test/unit/util/workflows/process_template_test.py b/test/unit/util/workflows/process_template_test.py index c8d491f7b..dd87f796c 100644 --- a/test/unit/util/workflows/process_template_test.py +++ b/test/unit/util/workflows/process_template_test.py @@ -1,12 +1,19 @@ from inspect import cleandoc +import pytest + from exasol.toolbox.util.workflows.process_template import TemplateRenderer from noxconfig import PROJECT_CONFIG +@pytest.fixture +def template_renderer() -> TemplateRenderer: + return TemplateRenderer(github_template_dict=PROJECT_CONFIG.github_template_dict) + + class TestTemplateRenderer: @staticmethod - def test_works_for_general_case(): + def test_works_for_general_case(tmp_path, template_renderer): input_yaml = """ name: Build & Publish @@ -22,15 +29,15 @@ def test_works_for_general_case(): permissions: contents: write """ + file_path = tmp_path / "dummy.yml" + content = cleandoc(input_yaml) + file_path.write_text(content) - template_renderer = TemplateRenderer( - template_str=cleandoc(input_yaml), - github_template_dict=PROJECT_CONFIG.github_template_dict, - ) - assert template_renderer.render_to_workflow() == cleandoc(input_yaml) + result = template_renderer.render_to_workflow(file_path=file_path) + assert result == cleandoc(input_yaml) @staticmethod - def test_fixes_extra_horizontal_whitespace(): + def test_fixes_extra_horizontal_whitespace(tmp_path, template_renderer): # required has 2 extra spaces input_yaml = """ name: Build & Publish @@ -52,14 +59,15 @@ def test_fixes_extra_horizontal_whitespace(): required: true """ - template_renderer = TemplateRenderer( - template_str=cleandoc(input_yaml), - github_template_dict=PROJECT_CONFIG.github_template_dict, - ) - assert template_renderer.render_to_workflow() == cleandoc(expected_yaml) + file_path = tmp_path / "dummy.yml" + content = cleandoc(input_yaml) + file_path.write_text(content) + + result = template_renderer.render_to_workflow(file_path=file_path) + assert result == cleandoc(expected_yaml) @staticmethod - def test_keeps_comments(): + def test_keeps_comments(tmp_path, template_renderer): input_yaml = """ steps: # Comment in nested area @@ -76,15 +84,15 @@ def test_keeps_comments(): # Comment in step """ - template_renderer = TemplateRenderer( - template_str=cleandoc(input_yaml), - github_template_dict=PROJECT_CONFIG.github_template_dict, - ) + file_path = tmp_path / "dummy.yml" + content = cleandoc(input_yaml) + file_path.write_text(content) - assert template_renderer.render_to_workflow() == cleandoc(expected_yaml) + result = template_renderer.render_to_workflow(file_path=file_path) + assert result == cleandoc(expected_yaml) @staticmethod - def test_keeps_quotes_for_variables_as_is(): + def test_keeps_quotes_for_variables_as_is(tmp_path, template_renderer): input_yaml = """ - name: Build Artifacts run: poetry build @@ -121,15 +129,15 @@ def test_keeps_quotes_for_variables_as_is(): dist/* """ - template_renderer = TemplateRenderer( - template_str=cleandoc(input_yaml), - github_template_dict=PROJECT_CONFIG.github_template_dict, - ) + file_path = tmp_path / "dummy.yml" + content = cleandoc(input_yaml) + file_path.write_text(content) - assert template_renderer.render_to_workflow() == cleandoc(expected_yaml) + result = template_renderer.render_to_workflow(file_path=file_path) + assert result == cleandoc(expected_yaml) @staticmethod - def test_updates_jinja_variables(): + def test_updates_jinja_variables(tmp_path, template_renderer): input_yaml = """ - name: Setup Python & Poetry Environment uses: exasol/python-toolbox/.github/actions/python-environment@v5 @@ -145,15 +153,15 @@ def test_updates_jinja_variables(): poetry-version: "2.3.0" """ - template_renderer = TemplateRenderer( - template_str=cleandoc(input_yaml), - github_template_dict=PROJECT_CONFIG.github_template_dict, - ) + file_path = tmp_path / "dummy.yml" + content = cleandoc(input_yaml) + file_path.write_text(content) - assert template_renderer.render_to_workflow() == cleandoc(expected_yaml) + result = template_renderer.render_to_workflow(file_path=file_path) + assert result == cleandoc(expected_yaml) @staticmethod - def test_preserves_list_format(): + def test_preserves_list_format(tmp_path, template_renderer): input_yaml = """ on: pull_request: @@ -170,9 +178,9 @@ def test_preserves_list_format(): python-versions: ["3.10", "3.11", "3.12", "3.13", "3.14"] """ - template_renderer = TemplateRenderer( - template_str=cleandoc(input_yaml), - github_template_dict=PROJECT_CONFIG.github_template_dict, - ) + file_path = tmp_path / "dummy.yml" + content = cleandoc(input_yaml) + file_path.write_text(content) - assert template_renderer.render_to_workflow() == cleandoc(input_yaml) + result = template_renderer.render_to_workflow(file_path=file_path) + assert result == cleandoc(input_yaml) From 380dd64f4478c5d6c9a05f903e48d24a2de25743 Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Mon, 9 Feb 2026 13:53:15 +0100 Subject: [PATCH 05/18] Change to render_yaml --- exasol/toolbox/util/workflows/customize_workflow.py | 2 +- exasol/toolbox/util/workflows/process_template.py | 2 +- .../toolbox/util/workflows/{format_yaml.py => render_yaml.py} | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename exasol/toolbox/util/workflows/{format_yaml.py => render_yaml.py} (100%) diff --git a/exasol/toolbox/util/workflows/customize_workflow.py b/exasol/toolbox/util/workflows/customize_workflow.py index 9c9e9119e..e2314a922 100644 --- a/exasol/toolbox/util/workflows/customize_workflow.py +++ b/exasol/toolbox/util/workflows/customize_workflow.py @@ -9,7 +9,7 @@ ) from ruamel.yaml import CommentedMap -from exasol.toolbox.util.workflows.format_yaml import YamlRenderer +from exasol.toolbox.util.workflows.render_yaml import YamlRenderer class ActionType(str, Enum): diff --git a/exasol/toolbox/util/workflows/process_template.py b/exasol/toolbox/util/workflows/process_template.py index 7074d259c..bd85696aa 100644 --- a/exasol/toolbox/util/workflows/process_template.py +++ b/exasol/toolbox/util/workflows/process_template.py @@ -1,6 +1,6 @@ from pathlib import Path -from exasol.toolbox.util.workflows.format_yaml import YamlRenderer +from exasol.toolbox.util.workflows.render_yaml import YamlRenderer class TemplateRenderer(YamlRenderer): diff --git a/exasol/toolbox/util/workflows/format_yaml.py b/exasol/toolbox/util/workflows/render_yaml.py similarity index 100% rename from exasol/toolbox/util/workflows/format_yaml.py rename to exasol/toolbox/util/workflows/render_yaml.py From 759deb29de1a1f98e360ca0a25356d32068c5c55 Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Mon, 9 Feb 2026 13:54:23 +0100 Subject: [PATCH 06/18] Change to WorkflowCustomizer --- .../util/workflows/customize_workflow.py | 2 +- .../util/workflows/customize_workflow_test.py | 29 +++++++++---------- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/exasol/toolbox/util/workflows/customize_workflow.py b/exasol/toolbox/util/workflows/customize_workflow.py index e2314a922..4b2ecfa08 100644 --- a/exasol/toolbox/util/workflows/customize_workflow.py +++ b/exasol/toolbox/util/workflows/customize_workflow.py @@ -45,7 +45,7 @@ class WorkflowConfig(BaseModel): workflows: list[Workflow] -class CustomWorkflow(YamlRenderer): +class WorkflowCustomizer(YamlRenderer): """ The PTB allows users to define a YAML (of type :class:`WorkflowConfig`) to customize the workflows provided by the PTB. diff --git a/test/unit/util/workflows/customize_workflow_test.py b/test/unit/util/workflows/customize_workflow_test.py index 4a04a1cdf..05fd936cf 100644 --- a/test/unit/util/workflows/customize_workflow_test.py +++ b/test/unit/util/workflows/customize_workflow_test.py @@ -6,7 +6,7 @@ from exasol.toolbox.util.workflows.customize_workflow import ( ActionType, - CustomWorkflow, + WorkflowCustomizer, ) from noxconfig import PROJECT_CONFIG @@ -36,38 +36,37 @@ class ExampleYaml: @pytest.fixture -def custom_workflow() -> CustomWorkflow: - return CustomWorkflow(github_template_dict=PROJECT_CONFIG.github_template_dict) +def workflow_customizer() -> WorkflowCustomizer: + return WorkflowCustomizer(github_template_dict=PROJECT_CONFIG.github_template_dict) class TestCustomWorkflow: @staticmethod - def test_remove_jobs(tmp_path, custom_workflow): + def test_remove_jobs(tmp_path, workflow_customizer): file_path = tmp_path / ".exasol-toolbox.yml" content = cleandoc(ExampleYaml.remove_jobs) file_path.write_text(content) - yaml_dict = custom_workflow.get_yaml_dict(file_path) + yaml_dict = workflow_customizer.get_yaml_dict(file_path) - assert custom_workflow.get_as_string(yaml_dict) == content + assert workflow_customizer.get_as_string(yaml_dict) == content @staticmethod @pytest.mark.parametrize("action", ActionType) - def test_step_customizations(tmp_path, action, custom_workflow): + def test_step_customizations(tmp_path, action, workflow_customizer): file_path = tmp_path / ".exasol-toolbox.yml" content = cleandoc(ExampleYaml.step_customization.format(action=action.value)) file_path.write_text(content) - yaml_dict = custom_workflow.get_yaml_dict(file_path) + yaml_dict = workflow_customizer.get_yaml_dict(file_path) - assert custom_workflow.get_as_string(yaml_dict) == content + assert workflow_customizer.get_as_string(yaml_dict) == content class TestStepCustomization: @staticmethod - def test_allows_extra_field(tmp_path, custom_workflow): + def test_allows_extra_field(tmp_path, workflow_customizer): file_path = tmp_path / ".exasol-toolbox.yml" - content = f""" {ExampleYaml.step_customization.format(action="REPLACE")} extra-field: "test" @@ -75,15 +74,15 @@ def test_allows_extra_field(tmp_path, custom_workflow): content = cleandoc(content) file_path.write_text(content) - yaml_dict = custom_workflow.get_yaml_dict(file_path) + yaml_dict = workflow_customizer.get_yaml_dict(file_path) - assert custom_workflow.get_as_string(yaml_dict) == content + assert workflow_customizer.get_as_string(yaml_dict) == content @staticmethod - def test_raises_error_for_unknown_action(tmp_path, custom_workflow): + def test_raises_error_for_unknown_action(tmp_path, workflow_customizer): file_path = tmp_path / ".exasol-toolbox.yml" content = cleandoc(ExampleYaml.step_customization.format(action="UNKNOWN")) file_path.write_text(content) with pytest.raises(ValidationError, match="Input should be"): - custom_workflow.get_yaml_dict(file_path) + workflow_customizer.get_yaml_dict(file_path) From 86e2be5afbcdbb5a0d62dcd2b3e54f9fb0edf6c2 Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Mon, 9 Feb 2026 14:07:33 +0100 Subject: [PATCH 07/18] Add docstrings --- exasol/toolbox/util/workflows/customize_workflow.py | 5 +++-- exasol/toolbox/util/workflows/process_template.py | 7 +++++++ exasol/toolbox/util/workflows/render_yaml.py | 9 ++++++++- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/exasol/toolbox/util/workflows/customize_workflow.py b/exasol/toolbox/util/workflows/customize_workflow.py index 4b2ecfa08..bf625d21a 100644 --- a/exasol/toolbox/util/workflows/customize_workflow.py +++ b/exasol/toolbox/util/workflows/customize_workflow.py @@ -47,8 +47,9 @@ class WorkflowConfig(BaseModel): class WorkflowCustomizer(YamlRenderer): """ - The PTB allows users to define a YAML (of type :class:`WorkflowConfig`) to - customize the workflows provided by the PTB. + The :class:`WorkflowCustomizer` enables users to define a YAML file + to customize PTB-provided workflows. The provided YAML file must meet + the conditions of :class:`WorkflowConfig`. """ def get_yaml_dict(self, file_path: Path) -> CommentedMap: diff --git a/exasol/toolbox/util/workflows/process_template.py b/exasol/toolbox/util/workflows/process_template.py index bd85696aa..b4866cb46 100644 --- a/exasol/toolbox/util/workflows/process_template.py +++ b/exasol/toolbox/util/workflows/process_template.py @@ -4,6 +4,13 @@ class TemplateRenderer(YamlRenderer): + """ + The :class:`TemplateRenderer` is used to process the + `PTB-provided GitHub workflow templates __` + by: + - resolving Jinja variables. + - standardising formatting via ruamel.yaml for consistent output. + """ def render_to_workflow(self, file_path: Path) -> str: """ diff --git a/exasol/toolbox/util/workflows/render_yaml.py b/exasol/toolbox/util/workflows/render_yaml.py index c73f19138..23b57dd57 100644 --- a/exasol/toolbox/util/workflows/render_yaml.py +++ b/exasol/toolbox/util/workflows/render_yaml.py @@ -17,12 +17,19 @@ @dataclass(frozen=True) class YamlRenderer: + """ + The :class:`YamlRenderer` provides a standardised interface for rendering YAML + files within the PTB. To simplify configuration and reduce manual coordination, + use Jinja variables as defined in :meth:`BaseConfig.github_template_dict` in your + YAML files. + """ + github_template_dict: dict[str, Any] @staticmethod def _get_standard_yaml() -> YAML: """ - Prepare standard YAML class + Prepare standard YAML class. """ yaml = YAML() yaml.width = 200 From 81eda7d89bc68c7fa23821700e5f20cb7f6e3fe1 Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Mon, 9 Feb 2026 14:12:57 +0100 Subject: [PATCH 08/18] Convert test to rightly be YamlRenderer --- ...s_template_test.py => render_yaml_test.py} | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) rename test/unit/util/workflows/{process_template_test.py => render_yaml_test.py} (75%) diff --git a/test/unit/util/workflows/process_template_test.py b/test/unit/util/workflows/render_yaml_test.py similarity index 75% rename from test/unit/util/workflows/process_template_test.py rename to test/unit/util/workflows/render_yaml_test.py index dd87f796c..035b7fd1c 100644 --- a/test/unit/util/workflows/process_template_test.py +++ b/test/unit/util/workflows/render_yaml_test.py @@ -2,18 +2,18 @@ import pytest -from exasol.toolbox.util.workflows.process_template import TemplateRenderer +from exasol.toolbox.util.workflows.render_yaml import YamlRenderer from noxconfig import PROJECT_CONFIG @pytest.fixture -def template_renderer() -> TemplateRenderer: - return TemplateRenderer(github_template_dict=PROJECT_CONFIG.github_template_dict) +def yaml_renderer() -> YamlRenderer: + return YamlRenderer(github_template_dict=PROJECT_CONFIG.github_template_dict) class TestTemplateRenderer: @staticmethod - def test_works_for_general_case(tmp_path, template_renderer): + def test_works_for_general_case(tmp_path, yaml_renderer): input_yaml = """ name: Build & Publish @@ -33,11 +33,11 @@ def test_works_for_general_case(tmp_path, template_renderer): content = cleandoc(input_yaml) file_path.write_text(content) - result = template_renderer.render_to_workflow(file_path=file_path) - assert result == cleandoc(input_yaml) + yaml_dict = yaml_renderer.get_yaml_dict(file_path) + assert yaml_renderer.get_as_string(yaml_dict) == cleandoc(input_yaml) @staticmethod - def test_fixes_extra_horizontal_whitespace(tmp_path, template_renderer): + def test_fixes_extra_horizontal_whitespace(tmp_path, yaml_renderer): # required has 2 extra spaces input_yaml = """ name: Build & Publish @@ -63,11 +63,11 @@ def test_fixes_extra_horizontal_whitespace(tmp_path, template_renderer): content = cleandoc(input_yaml) file_path.write_text(content) - result = template_renderer.render_to_workflow(file_path=file_path) - assert result == cleandoc(expected_yaml) + yaml_dict = yaml_renderer.get_yaml_dict(file_path) + assert yaml_renderer.get_as_string(yaml_dict) == cleandoc(expected_yaml) @staticmethod - def test_keeps_comments(tmp_path, template_renderer): + def test_keeps_comments(tmp_path, yaml_renderer): input_yaml = """ steps: # Comment in nested area @@ -88,11 +88,11 @@ def test_keeps_comments(tmp_path, template_renderer): content = cleandoc(input_yaml) file_path.write_text(content) - result = template_renderer.render_to_workflow(file_path=file_path) - assert result == cleandoc(expected_yaml) + yaml_dict = yaml_renderer.get_yaml_dict(file_path) + assert yaml_renderer.get_as_string(yaml_dict) == cleandoc(expected_yaml) @staticmethod - def test_keeps_quotes_for_variables_as_is(tmp_path, template_renderer): + def test_keeps_quotes_for_variables_as_is(tmp_path, yaml_renderer): input_yaml = """ - name: Build Artifacts run: poetry build @@ -133,11 +133,11 @@ def test_keeps_quotes_for_variables_as_is(tmp_path, template_renderer): content = cleandoc(input_yaml) file_path.write_text(content) - result = template_renderer.render_to_workflow(file_path=file_path) - assert result == cleandoc(expected_yaml) + yaml_dict = yaml_renderer.get_yaml_dict(file_path) + assert yaml_renderer.get_as_string(yaml_dict) == cleandoc(expected_yaml) @staticmethod - def test_updates_jinja_variables(tmp_path, template_renderer): + def test_updates_jinja_variables(tmp_path, yaml_renderer): input_yaml = """ - name: Setup Python & Poetry Environment uses: exasol/python-toolbox/.github/actions/python-environment@v5 @@ -157,11 +157,11 @@ def test_updates_jinja_variables(tmp_path, template_renderer): content = cleandoc(input_yaml) file_path.write_text(content) - result = template_renderer.render_to_workflow(file_path=file_path) - assert result == cleandoc(expected_yaml) + yaml_dict = yaml_renderer.get_yaml_dict(file_path) + assert yaml_renderer.get_as_string(yaml_dict) == cleandoc(expected_yaml) @staticmethod - def test_preserves_list_format(tmp_path, template_renderer): + def test_preserves_list_format(tmp_path, yaml_renderer): input_yaml = """ on: pull_request: @@ -182,5 +182,5 @@ def test_preserves_list_format(tmp_path, template_renderer): content = cleandoc(input_yaml) file_path.write_text(content) - result = template_renderer.render_to_workflow(file_path=file_path) - assert result == cleandoc(input_yaml) + yaml_dict = yaml_renderer.get_yaml_dict(file_path) + assert yaml_renderer.get_as_string(yaml_dict) == cleandoc(input_yaml) From 0484ec6fcc8a6e0c6d196da6c7a41227f6ac6e75 Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Mon, 9 Feb 2026 15:38:26 +0100 Subject: [PATCH 09/18] Stick with custom as the adjective/verb --- exasol/toolbox/config.py | 10 +++---- .../util/workflows/customize_workflow.py | 4 +-- test/unit/config_test.py | 2 +- .../util/workflows/customize_workflow_test.py | 30 +++++++++---------- 4 files changed, 23 insertions(+), 23 deletions(-) diff --git a/exasol/toolbox/config.py b/exasol/toolbox/config.py index 809d4f066..7269e8936 100644 --- a/exasol/toolbox/config.py +++ b/exasol/toolbox/config.py @@ -282,15 +282,15 @@ def github_template_dict(self) -> dict[str, Any]: @computed_field # type: ignore[misc] @property - def github_modification_yml(self) -> Path | None: + def github_custom_yml(self) -> Path | None: """ - For modifying the GitHub workflow templates provided by the PTB, + For customizing the GitHub workflow templates provided by the PTB, a project can define a `.exasol-toolbox.yml`. This feature is a work-in-progress that will be completed with: https://github.com/exasol/python-toolbox/issues/690 """ - modification_yml = self.root_path / ".exasol-toolbox.yml" - if modification_yml.exists(): - return modification_yml + customization_yml = self.root_path / ".exasol-toolbox.yml" + if customization_yml.exists(): + return customization_yml return None diff --git a/exasol/toolbox/util/workflows/customize_workflow.py b/exasol/toolbox/util/workflows/customize_workflow.py index bf625d21a..75cef78dc 100644 --- a/exasol/toolbox/util/workflows/customize_workflow.py +++ b/exasol/toolbox/util/workflows/customize_workflow.py @@ -45,9 +45,9 @@ class WorkflowConfig(BaseModel): workflows: list[Workflow] -class WorkflowCustomizer(YamlRenderer): +class CustomYamlRenderer(YamlRenderer): """ - The :class:`WorkflowCustomizer` enables users to define a YAML file + The :class:`CustomYamlRenderer` enables users to define a YAML file to customize PTB-provided workflows. The provided YAML file must meet the conditions of :class:`WorkflowConfig`. """ diff --git a/test/unit/config_test.py b/test/unit/config_test.py index f2c101c40..3f04289b2 100644 --- a/test/unit/config_test.py +++ b/test/unit/config_test.py @@ -34,7 +34,7 @@ def test_works_as_defined(test_project_config_factory): "dist", "venv", ), - "github_modification_yml": None, + "github_custom_yml": None, "github_template_dict": { "dependency_manager_version": "2.3.0", "minimum_python_version": "3.10", diff --git a/test/unit/util/workflows/customize_workflow_test.py b/test/unit/util/workflows/customize_workflow_test.py index 05fd936cf..82f2cf002 100644 --- a/test/unit/util/workflows/customize_workflow_test.py +++ b/test/unit/util/workflows/customize_workflow_test.py @@ -6,7 +6,7 @@ from exasol.toolbox.util.workflows.customize_workflow import ( ActionType, - WorkflowCustomizer, + CustomYamlRenderer, ) from noxconfig import PROJECT_CONFIG @@ -36,36 +36,36 @@ class ExampleYaml: @pytest.fixture -def workflow_customizer() -> WorkflowCustomizer: - return WorkflowCustomizer(github_template_dict=PROJECT_CONFIG.github_template_dict) +def custom_yaml_renderer() -> CustomYamlRenderer: + return CustomYamlRenderer(github_template_dict=PROJECT_CONFIG.github_template_dict) -class TestCustomWorkflow: +class TestCustomYamlRenderer: @staticmethod - def test_remove_jobs(tmp_path, workflow_customizer): + def test_remove_jobs(tmp_path, custom_yaml_renderer): file_path = tmp_path / ".exasol-toolbox.yml" content = cleandoc(ExampleYaml.remove_jobs) file_path.write_text(content) - yaml_dict = workflow_customizer.get_yaml_dict(file_path) + yaml_dict = custom_yaml_renderer.get_yaml_dict(file_path) - assert workflow_customizer.get_as_string(yaml_dict) == content + assert custom_yaml_renderer.get_as_string(yaml_dict) == content @staticmethod @pytest.mark.parametrize("action", ActionType) - def test_step_customizations(tmp_path, action, workflow_customizer): + def test_step_customizations(tmp_path, action, custom_yaml_renderer): file_path = tmp_path / ".exasol-toolbox.yml" content = cleandoc(ExampleYaml.step_customization.format(action=action.value)) file_path.write_text(content) - yaml_dict = workflow_customizer.get_yaml_dict(file_path) + yaml_dict = custom_yaml_renderer.get_yaml_dict(file_path) - assert workflow_customizer.get_as_string(yaml_dict) == content + assert custom_yaml_renderer.get_as_string(yaml_dict) == content class TestStepCustomization: @staticmethod - def test_allows_extra_field(tmp_path, workflow_customizer): + def test_allows_extra_field(tmp_path, custom_yaml_renderer): file_path = tmp_path / ".exasol-toolbox.yml" content = f""" {ExampleYaml.step_customization.format(action="REPLACE")} @@ -74,15 +74,15 @@ def test_allows_extra_field(tmp_path, workflow_customizer): content = cleandoc(content) file_path.write_text(content) - yaml_dict = workflow_customizer.get_yaml_dict(file_path) + yaml_dict = custom_yaml_renderer.get_yaml_dict(file_path) - assert workflow_customizer.get_as_string(yaml_dict) == content + assert custom_yaml_renderer.get_as_string(yaml_dict) == content @staticmethod - def test_raises_error_for_unknown_action(tmp_path, workflow_customizer): + def test_raises_error_for_unknown_action(tmp_path, custom_yaml_renderer): file_path = tmp_path / ".exasol-toolbox.yml" content = cleandoc(ExampleYaml.step_customization.format(action="UNKNOWN")) file_path.write_text(content) with pytest.raises(ValidationError, match="Input should be"): - workflow_customizer.get_yaml_dict(file_path) + custom_yaml_renderer.get_yaml_dict(file_path) From 654c2e1851d6b1f24501079e8bddd90796473bea Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Tue, 10 Feb 2026 12:28:00 +0100 Subject: [PATCH 10/18] Rename to WorkflowPatcher --- .../util/workflows/customize_workflow.py | 14 +++++---- .../util/workflows/customize_workflow_test.py | 30 +++++++++---------- 2 files changed, 23 insertions(+), 21 deletions(-) diff --git a/exasol/toolbox/util/workflows/customize_workflow.py b/exasol/toolbox/util/workflows/customize_workflow.py index 75cef78dc..37e34de93 100644 --- a/exasol/toolbox/util/workflows/customize_workflow.py +++ b/exasol/toolbox/util/workflows/customize_workflow.py @@ -41,18 +41,20 @@ class Workflow(BaseModel): step_customizations: list[StepCustomization] = Field(default_factory=list) -class WorkflowConfig(BaseModel): +class WorkflowPatcherConfig(BaseModel): workflows: list[Workflow] -class CustomYamlRenderer(YamlRenderer): +class WorkflowPatcher(YamlRenderer): """ - The :class:`CustomYamlRenderer` enables users to define a YAML file - to customize PTB-provided workflows. The provided YAML file must meet - the conditions of :class:`WorkflowConfig`. + The :class:`WorkflowPatcher` enables users to define a YAML file + to customize PTB-provided workflows by: + - removing jobs + - modifying steps by either replacing or inserting after + 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) - WorkflowConfig.model_validate(loaded_yaml) + WorkflowPatcherConfig.model_validate(loaded_yaml) return loaded_yaml diff --git a/test/unit/util/workflows/customize_workflow_test.py b/test/unit/util/workflows/customize_workflow_test.py index 82f2cf002..ef947c09c 100644 --- a/test/unit/util/workflows/customize_workflow_test.py +++ b/test/unit/util/workflows/customize_workflow_test.py @@ -6,7 +6,7 @@ from exasol.toolbox.util.workflows.customize_workflow import ( ActionType, - CustomYamlRenderer, + WorkflowPatcher, ) from noxconfig import PROJECT_CONFIG @@ -36,36 +36,36 @@ class ExampleYaml: @pytest.fixture -def custom_yaml_renderer() -> CustomYamlRenderer: - return CustomYamlRenderer(github_template_dict=PROJECT_CONFIG.github_template_dict) +def workflow_patcher() -> WorkflowPatcher: + return WorkflowPatcher(github_template_dict=PROJECT_CONFIG.github_template_dict) -class TestCustomYamlRenderer: +class TestWorkflowPatcher: @staticmethod - def test_remove_jobs(tmp_path, custom_yaml_renderer): + def test_remove_jobs(tmp_path, workflow_patcher): file_path = tmp_path / ".exasol-toolbox.yml" content = cleandoc(ExampleYaml.remove_jobs) file_path.write_text(content) - yaml_dict = custom_yaml_renderer.get_yaml_dict(file_path) + yaml_dict = workflow_patcher.get_yaml_dict(file_path) - assert custom_yaml_renderer.get_as_string(yaml_dict) == content + assert workflow_patcher.get_as_string(yaml_dict) == content @staticmethod @pytest.mark.parametrize("action", ActionType) - def test_step_customizations(tmp_path, action, custom_yaml_renderer): + def test_step_customizations(tmp_path, action, workflow_patcher): file_path = tmp_path / ".exasol-toolbox.yml" content = cleandoc(ExampleYaml.step_customization.format(action=action.value)) file_path.write_text(content) - yaml_dict = custom_yaml_renderer.get_yaml_dict(file_path) + yaml_dict = workflow_patcher.get_yaml_dict(file_path) - assert custom_yaml_renderer.get_as_string(yaml_dict) == content + assert workflow_patcher.get_as_string(yaml_dict) == content class TestStepCustomization: @staticmethod - def test_allows_extra_field(tmp_path, custom_yaml_renderer): + def test_allows_extra_field(tmp_path, workflow_patcher): file_path = tmp_path / ".exasol-toolbox.yml" content = f""" {ExampleYaml.step_customization.format(action="REPLACE")} @@ -74,15 +74,15 @@ def test_allows_extra_field(tmp_path, custom_yaml_renderer): content = cleandoc(content) file_path.write_text(content) - yaml_dict = custom_yaml_renderer.get_yaml_dict(file_path) + yaml_dict = workflow_patcher.get_yaml_dict(file_path) - assert custom_yaml_renderer.get_as_string(yaml_dict) == content + assert workflow_patcher.get_as_string(yaml_dict) == content @staticmethod - def test_raises_error_for_unknown_action(tmp_path, custom_yaml_renderer): + def test_raises_error_for_unknown_action(tmp_path, workflow_patcher): file_path = tmp_path / ".exasol-toolbox.yml" content = cleandoc(ExampleYaml.step_customization.format(action="UNKNOWN")) file_path.write_text(content) with pytest.raises(ValidationError, match="Input should be"): - custom_yaml_renderer.get_yaml_dict(file_path) + workflow_patcher.get_yaml_dict(file_path) From 9c339269d19eb24765b06496bf44903175022c7c Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Tue, 10 Feb 2026 12:29:00 +0100 Subject: [PATCH 11/18] Switch to patch_workflow --- .../util/workflows/{customize_workflow.py => patch_workflow.py} | 0 .../{customize_workflow_test.py => patch_workflow_test.py} | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename exasol/toolbox/util/workflows/{customize_workflow.py => patch_workflow.py} (100%) rename test/unit/util/workflows/{customize_workflow_test.py => patch_workflow_test.py} (97%) diff --git a/exasol/toolbox/util/workflows/customize_workflow.py b/exasol/toolbox/util/workflows/patch_workflow.py similarity index 100% rename from exasol/toolbox/util/workflows/customize_workflow.py rename to exasol/toolbox/util/workflows/patch_workflow.py diff --git a/test/unit/util/workflows/customize_workflow_test.py b/test/unit/util/workflows/patch_workflow_test.py similarity index 97% rename from test/unit/util/workflows/customize_workflow_test.py rename to test/unit/util/workflows/patch_workflow_test.py index ef947c09c..883a9e0fd 100644 --- a/test/unit/util/workflows/customize_workflow_test.py +++ b/test/unit/util/workflows/patch_workflow_test.py @@ -4,7 +4,7 @@ import pytest from pydantic import ValidationError -from exasol.toolbox.util.workflows.customize_workflow import ( +from exasol.toolbox.util.workflows.patch_workflow import ( ActionType, WorkflowPatcher, ) From e79ac3c913b7814c5f9f3c573be0968a1803756b Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Tue, 10 Feb 2026 12:30:40 +0100 Subject: [PATCH 12/18] Switch to workflow_patcher_yaml --- exasol/toolbox/config.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/exasol/toolbox/config.py b/exasol/toolbox/config.py index 7269e8936..2d4da0b4c 100644 --- a/exasol/toolbox/config.py +++ b/exasol/toolbox/config.py @@ -282,7 +282,7 @@ def github_template_dict(self) -> dict[str, Any]: @computed_field # type: ignore[misc] @property - def github_custom_yml(self) -> Path | None: + def github_workflow_patcher_yaml(self) -> Path | None: """ For customizing the GitHub workflow templates provided by the PTB, a project can define a `.exasol-toolbox.yml`. @@ -290,7 +290,7 @@ def github_custom_yml(self) -> Path | None: This feature is a work-in-progress that will be completed with: https://github.com/exasol/python-toolbox/issues/690 """ - customization_yml = self.root_path / ".exasol-toolbox.yml" - if customization_yml.exists(): - return customization_yml + workflow_patcher_yaml = self.root_path / ".exasol-toolbox.yml" + if workflow_patcher_yaml.exists(): + return workflow_patcher_yaml return None From 0d958524b62d594207bc5024ea2276d7e40ec688 Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Tue, 10 Feb 2026 12:35:16 +0100 Subject: [PATCH 13/18] Rename to WorkflowRenderer --- exasol/toolbox/util/workflows/process_template.py | 9 ++++----- exasol/toolbox/util/workflows/workflow.py | 4 ++-- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/exasol/toolbox/util/workflows/process_template.py b/exasol/toolbox/util/workflows/process_template.py index b4866cb46..002f86ece 100644 --- a/exasol/toolbox/util/workflows/process_template.py +++ b/exasol/toolbox/util/workflows/process_template.py @@ -3,13 +3,12 @@ from exasol.toolbox.util.workflows.render_yaml import YamlRenderer -class TemplateRenderer(YamlRenderer): +class WorkflowRenderer(YamlRenderer): """ - The :class:`TemplateRenderer` is used to process the - `PTB-provided GitHub workflow templates __` - by: + The :class:`WorkflowRenderer` renders a workflow template provided by the PTB into + a final workflow. It renders the final workflow template by: - resolving Jinja variables. - - standardising formatting via ruamel.yaml for consistent output. + - standardizing formatting via ruamel.yaml for a consistent output. """ def render_to_workflow(self, file_path: Path) -> str: diff --git a/exasol/toolbox/util/workflows/workflow.py b/exasol/toolbox/util/workflows/workflow.py index 99a75cfbb..6ce1a5294 100644 --- a/exasol/toolbox/util/workflows/workflow.py +++ b/exasol/toolbox/util/workflows/workflow.py @@ -6,7 +6,7 @@ ConfigDict, ) -from exasol.toolbox.util.workflows.process_template import TemplateRenderer +from exasol.toolbox.util.workflows.process_template import WorkflowRenderer class Workflow(BaseModel): @@ -20,7 +20,7 @@ def load_from_template(cls, file_path: Path, github_template_dict: dict[str, Any raise FileNotFoundError(file_path) try: - template_renderer = TemplateRenderer( + template_renderer = WorkflowRenderer( github_template_dict=github_template_dict ) workflow = template_renderer.render_to_workflow(file_path=file_path) From 58ddffafe6ac6ec8e369dac9f46d62379628a5b8 Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Tue, 10 Feb 2026 12:36:22 +0100 Subject: [PATCH 14/18] Rename function to reduce redundancy --- exasol/toolbox/util/workflows/process_template.py | 2 +- exasol/toolbox/util/workflows/workflow.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/exasol/toolbox/util/workflows/process_template.py b/exasol/toolbox/util/workflows/process_template.py index 002f86ece..fd1a2ba76 100644 --- a/exasol/toolbox/util/workflows/process_template.py +++ b/exasol/toolbox/util/workflows/process_template.py @@ -11,7 +11,7 @@ class WorkflowRenderer(YamlRenderer): - standardizing formatting via ruamel.yaml for a consistent output. """ - def render_to_workflow(self, file_path: Path) -> str: + def render(self, file_path: Path) -> str: """ Render the template to the contents of a valid GitHub workflow. """ diff --git a/exasol/toolbox/util/workflows/workflow.py b/exasol/toolbox/util/workflows/workflow.py index 6ce1a5294..fc54b653d 100644 --- a/exasol/toolbox/util/workflows/workflow.py +++ b/exasol/toolbox/util/workflows/workflow.py @@ -20,10 +20,10 @@ def load_from_template(cls, file_path: Path, github_template_dict: dict[str, Any raise FileNotFoundError(file_path) try: - template_renderer = WorkflowRenderer( + workflow_renderer = WorkflowRenderer( github_template_dict=github_template_dict ) - workflow = template_renderer.render_to_workflow(file_path=file_path) + 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 From 16b1b85c03d1066ae2c8b8f2e4dc2d326e1cad0a Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Tue, 10 Feb 2026 12:39:22 +0100 Subject: [PATCH 15/18] Fix missed renaming --- test/unit/config_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/config_test.py b/test/unit/config_test.py index 3f04289b2..ee647c282 100644 --- a/test/unit/config_test.py +++ b/test/unit/config_test.py @@ -34,7 +34,7 @@ def test_works_as_defined(test_project_config_factory): "dist", "venv", ), - "github_custom_yml": None, + "github_workflow_patcher_yaml": None, "github_template_dict": { "dependency_manager_version": "2.3.0", "minimum_python_version": "3.10", From aa53c771b9ed059435f6c4818da4604bfa6e8c6a Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Tue, 10 Feb 2026 16:05:06 +0100 Subject: [PATCH 16/18] Apply reviewer changes --- exasol/toolbox/config.py | 4 +++- exasol/toolbox/util/workflows/patch_workflow.py | 5 ++--- exasol/toolbox/util/workflows/process_template.py | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/exasol/toolbox/config.py b/exasol/toolbox/config.py index 2d4da0b4c..1356c0f8c 100644 --- a/exasol/toolbox/config.py +++ b/exasol/toolbox/config.py @@ -285,7 +285,9 @@ def github_template_dict(self) -> dict[str, Any]: def github_workflow_patcher_yaml(self) -> Path | None: """ For customizing the GitHub workflow templates provided by the PTB, - a project can define a `.exasol-toolbox.yml`. + a project can define a `.exasol-toolbox.yml` file containing instructions to + delete or modyfy jobs in the PTB template. + Modification includes replacing and inserting steps. This feature is a work-in-progress that will be completed with: https://github.com/exasol/python-toolbox/issues/690 diff --git a/exasol/toolbox/util/workflows/patch_workflow.py b/exasol/toolbox/util/workflows/patch_workflow.py index 37e34de93..c4bc10d5a 100644 --- a/exasol/toolbox/util/workflows/patch_workflow.py +++ b/exasol/toolbox/util/workflows/patch_workflow.py @@ -48,9 +48,8 @@ class WorkflowPatcherConfig(BaseModel): class WorkflowPatcher(YamlRenderer): """ The :class:`WorkflowPatcher` enables users to define a YAML file - to customize PTB-provided workflows by: - - removing jobs - - modifying steps by either replacing or inserting after + 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`. """ diff --git a/exasol/toolbox/util/workflows/process_template.py b/exasol/toolbox/util/workflows/process_template.py index fd1a2ba76..febebaa35 100644 --- a/exasol/toolbox/util/workflows/process_template.py +++ b/exasol/toolbox/util/workflows/process_template.py @@ -6,7 +6,7 @@ class WorkflowRenderer(YamlRenderer): """ The :class:`WorkflowRenderer` renders a workflow template provided by the PTB into - a final workflow. It renders the final workflow template by: + a final workflow. It renders the final workflow by: - resolving Jinja variables. - standardizing formatting via ruamel.yaml for a consistent output. """ From ffabe7d05249ec9c026643861d71c7dc9c83e182 Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Tue, 10 Feb 2026 16:06:47 +0100 Subject: [PATCH 17/18] Make file name unique so purpose is clearer --- exasol/toolbox/config.py | 8 ++++---- test/unit/util/workflows/patch_workflow_test.py | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/exasol/toolbox/config.py b/exasol/toolbox/config.py index 1356c0f8c..c66fa5d92 100644 --- a/exasol/toolbox/config.py +++ b/exasol/toolbox/config.py @@ -285,14 +285,14 @@ def github_template_dict(self) -> dict[str, Any]: def github_workflow_patcher_yaml(self) -> Path | None: """ For customizing the GitHub workflow templates provided by the PTB, - a project can define a `.exasol-toolbox.yml` file containing instructions to - delete or modyfy jobs in the PTB template. - Modification includes replacing and inserting steps. + a project can define a `.workflow-patcher.yml` file containing instructions to + delete or modify jobs in the PTB template. Modification includes replacing and + inserting steps. This feature is a work-in-progress that will be completed with: https://github.com/exasol/python-toolbox/issues/690 """ - workflow_patcher_yaml = self.root_path / ".exasol-toolbox.yml" + workflow_patcher_yaml = self.root_path / ".workflow-patcher.yml" if workflow_patcher_yaml.exists(): return workflow_patcher_yaml return None diff --git a/test/unit/util/workflows/patch_workflow_test.py b/test/unit/util/workflows/patch_workflow_test.py index 883a9e0fd..37781e0d6 100644 --- a/test/unit/util/workflows/patch_workflow_test.py +++ b/test/unit/util/workflows/patch_workflow_test.py @@ -43,7 +43,7 @@ def workflow_patcher() -> WorkflowPatcher: class TestWorkflowPatcher: @staticmethod def test_remove_jobs(tmp_path, workflow_patcher): - file_path = tmp_path / ".exasol-toolbox.yml" + file_path = tmp_path / ".workflow-patcher.yml" content = cleandoc(ExampleYaml.remove_jobs) file_path.write_text(content) @@ -54,7 +54,7 @@ def test_remove_jobs(tmp_path, workflow_patcher): @staticmethod @pytest.mark.parametrize("action", ActionType) def test_step_customizations(tmp_path, action, workflow_patcher): - file_path = tmp_path / ".exasol-toolbox.yml" + file_path = tmp_path / ".workflow-patcher.yml" content = cleandoc(ExampleYaml.step_customization.format(action=action.value)) file_path.write_text(content) @@ -66,7 +66,7 @@ def test_step_customizations(tmp_path, action, workflow_patcher): class TestStepCustomization: @staticmethod def test_allows_extra_field(tmp_path, workflow_patcher): - file_path = tmp_path / ".exasol-toolbox.yml" + file_path = tmp_path / ".workflow-patcher.yml" content = f""" {ExampleYaml.step_customization.format(action="REPLACE")} extra-field: "test" @@ -80,7 +80,7 @@ def test_allows_extra_field(tmp_path, workflow_patcher): @staticmethod def test_raises_error_for_unknown_action(tmp_path, workflow_patcher): - file_path = tmp_path / ".exasol-toolbox.yml" + file_path = tmp_path / ".workflow-patcher.yml" content = cleandoc(ExampleYaml.step_customization.format(action="UNKNOWN")) file_path.write_text(content) From 71af884d8b3d9ae6f6808c35fcc46ba8b936e36c Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Tue, 10 Feb 2026 16:16:01 +0100 Subject: [PATCH 18/18] Use fixture to simplify tests --- .../util/workflows/patch_workflow_test.py | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/test/unit/util/workflows/patch_workflow_test.py b/test/unit/util/workflows/patch_workflow_test.py index 37781e0d6..aab1acd76 100644 --- a/test/unit/util/workflows/patch_workflow_test.py +++ b/test/unit/util/workflows/patch_workflow_test.py @@ -1,5 +1,6 @@ from dataclasses import dataclass from inspect import cleandoc +from pathlib import Path import pytest from pydantic import ValidationError @@ -40,49 +41,50 @@ 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(tmp_path, workflow_patcher): - file_path = tmp_path / ".workflow-patcher.yml" + def test_remove_jobs(workflow_patcher_yaml, workflow_patcher): content = cleandoc(ExampleYaml.remove_jobs) - file_path.write_text(content) + workflow_patcher_yaml.write_text(content) - yaml_dict = workflow_patcher.get_yaml_dict(file_path) + yaml_dict = workflow_patcher.get_yaml_dict(workflow_patcher_yaml) assert workflow_patcher.get_as_string(yaml_dict) == content @staticmethod @pytest.mark.parametrize("action", ActionType) - def test_step_customizations(tmp_path, action, workflow_patcher): - file_path = tmp_path / ".workflow-patcher.yml" + def test_step_customizations(workflow_patcher_yaml, action, workflow_patcher): content = cleandoc(ExampleYaml.step_customization.format(action=action.value)) - file_path.write_text(content) + workflow_patcher_yaml.write_text(content) - yaml_dict = workflow_patcher.get_yaml_dict(file_path) + yaml_dict = workflow_patcher.get_yaml_dict(workflow_patcher_yaml) assert workflow_patcher.get_as_string(yaml_dict) == content class TestStepCustomization: @staticmethod - def test_allows_extra_field(tmp_path, workflow_patcher): - file_path = tmp_path / ".workflow-patcher.yml" + def test_allows_extra_field(workflow_patcher_yaml, workflow_patcher): content = f""" {ExampleYaml.step_customization.format(action="REPLACE")} extra-field: "test" """ content = cleandoc(content) - file_path.write_text(content) + workflow_patcher_yaml.write_text(content) - yaml_dict = workflow_patcher.get_yaml_dict(file_path) + yaml_dict = workflow_patcher.get_yaml_dict(workflow_patcher_yaml) assert workflow_patcher.get_as_string(yaml_dict) == content @staticmethod - def test_raises_error_for_unknown_action(tmp_path, workflow_patcher): - file_path = tmp_path / ".workflow-patcher.yml" + def test_raises_error_for_unknown_action(workflow_patcher_yaml, workflow_patcher): content = cleandoc(ExampleYaml.step_customization.format(action="UNKNOWN")) - file_path.write_text(content) + workflow_patcher_yaml.write_text(content) with pytest.raises(ValidationError, match="Input should be"): - workflow_patcher.get_yaml_dict(file_path) + workflow_patcher.get_yaml_dict(workflow_patcher_yaml)