-
Notifications
You must be signed in to change notification settings - Fork 1
Define YML to modify GitHub workflows & validate loading it with pydantic #696
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
ArBridgeman
merged 18 commits into
main
from
feature/691_define_workflow_modification_yml
Feb 10, 2026
Merged
Changes from all commits
Commits
Show all changes
18 commits
Select commit
Hold shift + click to select a range
a1a06ae
Add functions to identify and parse .exasol-toolbox.yml file
ArBridgeman 351f7a2
Rename to process_template
ArBridgeman 3ebc05d
Rename to load_and_validate_workflow_customizer
ArBridgeman cb2fe31
Extract and use YamlRenderer class
ArBridgeman 380dd64
Change to render_yaml
ArBridgeman 759deb2
Change to WorkflowCustomizer
ArBridgeman 86e2be5
Add docstrings
ArBridgeman 81eda7d
Convert test to rightly be YamlRenderer
ArBridgeman 0484ec6
Stick with custom as the adjective/verb
ArBridgeman 654c2e1
Rename to WorkflowPatcher
ArBridgeman 9c33926
Switch to patch_workflow
ArBridgeman e79ac3c
Switch to workflow_patcher_yaml
ArBridgeman 0d95852
Rename to WorkflowRenderer
ArBridgeman 58ddffa
Rename function to reduce redundancy
ArBridgeman 16b1b85
Fix missed renaming
ArBridgeman aa53c77
Apply reviewer changes
ArBridgeman ffabe7d
Make file name unique so purpose is clearer
ArBridgeman 71af884
Use fixture to simplify tests
ArBridgeman File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,7 @@ | ||
| # Unreleased | ||
|
|
||
| ## Summary | ||
|
|
||
| ## Feature | ||
|
|
||
| * #691: Started customization of PTB workflows by defining the YML schema |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| 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.render_yaml import YamlRenderer | ||
|
|
||
|
|
||
| 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 WorkflowPatcherConfig(BaseModel): | ||
| workflows: list[Workflow] | ||
|
|
||
|
|
||
| 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`. | ||
| """ | ||
|
|
||
| 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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| from pathlib import Path | ||
|
|
||
| from exasol.toolbox.util.workflows.render_yaml import YamlRenderer | ||
|
|
||
|
|
||
| class WorkflowRenderer(YamlRenderer): | ||
| """ | ||
| The :class:`WorkflowRenderer` renders a workflow template provided by the PTB into | ||
| a final workflow. It renders the final workflow by: | ||
| - resolving Jinja variables. | ||
| - standardizing formatting via ruamel.yaml for a consistent output. | ||
| """ | ||
|
|
||
| def render(self, file_path: Path) -> str: | ||
| """ | ||
| Render the template to the contents of a valid GitHub workflow. | ||
| """ | ||
| workflow_dict = self.get_yaml_dict(file_path) | ||
| return self.get_as_string(workflow_dict) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| 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, | ||
| ) | ||
|
|
||
| jinja_env = Environment( | ||
| variable_start_string="((", variable_end_string="))", autoescape=True | ||
| ) | ||
|
|
||
|
|
||
| @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. | ||
| """ | ||
| 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) |
This file was deleted.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,90 @@ | ||
| from dataclasses import dataclass | ||
| from inspect import cleandoc | ||
| from pathlib import Path | ||
|
|
||
| import pytest | ||
| from pydantic import ValidationError | ||
|
|
||
| from exasol.toolbox.util.workflows.patch_workflow import ( | ||
| ActionType, | ||
| WorkflowPatcher, | ||
| ) | ||
| 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 | ||
ckunki marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| """ | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def workflow_patcher() -> WorkflowPatcher: | ||
| return WorkflowPatcher(github_template_dict=PROJECT_CONFIG.github_template_dict) | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def workflow_patcher_yaml(tmp_path: Path) -> Path: | ||
| return tmp_path / ".workflow-patcher.yml" | ||
|
|
||
|
|
||
| class TestWorkflowPatcher: | ||
| @staticmethod | ||
| def test_remove_jobs(workflow_patcher_yaml, workflow_patcher): | ||
| content = cleandoc(ExampleYaml.remove_jobs) | ||
ArBridgeman marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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 | ||
|
|
||
| @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)) | ||
| 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 | ||
|
|
||
|
|
||
| class TestStepCustomization: | ||
| @staticmethod | ||
| def test_allows_extra_field(workflow_patcher_yaml, workflow_patcher): | ||
| content = f""" | ||
| {ExampleYaml.step_customization.format(action="REPLACE")} | ||
| extra-field: "test" | ||
| """ | ||
| content = cleandoc(content) | ||
| workflow_patcher_yaml.write_text(content) | ||
|
|
||
| yaml_dict = workflow_patcher.get_yaml_dict(workflow_patcher_yaml) | ||
|
|
||
| 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")) | ||
| workflow_patcher_yaml.write_text(content) | ||
|
|
||
| with pytest.raises(ValidationError, match="Input should be"): | ||
| workflow_patcher.get_yaml_dict(workflow_patcher_yaml) | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.