From 842478e5ce1ceaa5ee44d863755fc76fd7dd9706 Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Thu, 19 Feb 2026 14:00:33 +0100 Subject: [PATCH 01/22] Add _select_workflows --- exasol/toolbox/util/workflows/workflow.py | 31 ++++++++++++++++++++--- test/unit/util/workflows/workflow_test.py | 19 +++++++++++++- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/exasol/toolbox/util/workflows/workflow.py b/exasol/toolbox/util/workflows/workflow.py index 70dd1d457..5ed4c8eb8 100644 --- a/exasol/toolbox/util/workflows/workflow.py +++ b/exasol/toolbox/util/workflows/workflow.py @@ -1,5 +1,10 @@ +from collections.abc import Mapping from pathlib import Path -from typing import Any +from typing import ( + Annotated, + Any, + Final, +) from pydantic import ( BaseModel, @@ -11,7 +16,14 @@ from exasol.toolbox.util.workflows import logger from exasol.toolbox.util.workflows.exceptions import YamlError +from exasol.toolbox.util.workflows.patch_workflow import WorkflowCommentedMap from exasol.toolbox.util.workflows.process_template import WorkflowRenderer +from exasol.toolbox.util.workflows.templates import WORKFLOW_TEMPLATE_OPTIONS + +ALL: Final[str] = "all" +WORKFLOW_NAMES: Final[list[str]] = [ALL, *WORKFLOW_TEMPLATE_OPTIONS.keys()] + +WorkflowName = Annotated[str, f"Should be a value from {WORKFLOW_NAMES}"] class Workflow(BaseModel): @@ -20,9 +32,14 @@ class Workflow(BaseModel): content: str @classmethod - def load_from_template(cls, file_path: Path, github_template_dict: dict[str, Any]): + def load_from_template( + cls, + file_path: Path, + github_template_dict: dict[str, Any], + patch_yaml: WorkflowCommentedMap | None = None, + ): with bound_contextvars(template_file_name=file_path.name): - logger.info("Load workflow from template") + logger.info(f"Load workflow template: {file_path.name}") if not file_path.exists(): raise FileNotFoundError(file_path) @@ -31,7 +48,7 @@ def load_from_template(cls, file_path: Path, github_template_dict: dict[str, Any workflow_renderer = WorkflowRenderer( github_template_dict=github_template_dict, file_path=file_path, - patch_yaml=None, + patch_yaml=patch_yaml, ) workflow = workflow_renderer.render() return cls(content=workflow) @@ -40,3 +57,9 @@ def load_from_template(cls, file_path: Path, github_template_dict: dict[str, Any except Exception as ex: # Wrap all other "non-special" exceptions raise ValueError(f"Error rendering file: {file_path}") from ex + + +def _select_workflows(workflow_name: WorkflowName) -> Mapping[str, Path]: + if workflow_name == ALL: + return WORKFLOW_TEMPLATE_OPTIONS + return {workflow_name: WORKFLOW_TEMPLATE_OPTIONS[workflow_name]} diff --git a/test/unit/util/workflows/workflow_test.py b/test/unit/util/workflows/workflow_test.py index c12f7ab7e..e98048b2d 100644 --- a/test/unit/util/workflows/workflow_test.py +++ b/test/unit/util/workflows/workflow_test.py @@ -9,7 +9,11 @@ ) from exasol.toolbox.util.workflows.process_template import WorkflowRenderer from exasol.toolbox.util.workflows.templates import WORKFLOW_TEMPLATE_OPTIONS -from exasol.toolbox.util.workflows.workflow import Workflow +from exasol.toolbox.util.workflows.workflow import ( + ALL, + Workflow, + _select_workflows, +) from noxconfig import PROJECT_CONFIG @@ -61,3 +65,16 @@ def test_other_exceptions_raised_as_valuerror(tmp_path): file_path=file_path, github_template_dict=PROJECT_CONFIG.github_template_dict, ) + + +class TestSelectWorkflow: + @staticmethod + def test_for_all_works_as_expected(): + result = _select_workflows(ALL) + assert result == WORKFLOW_TEMPLATE_OPTIONS + + @staticmethod + @pytest.mark.parametrize("workflow_name", WORKFLOW_TEMPLATE_OPTIONS) + def test_for_individual_workflows_works_as_expected(workflow_name): + result = _select_workflows(workflow_name) + assert result == {workflow_name: WORKFLOW_TEMPLATE_OPTIONS[workflow_name]} From 41382f821e46ad0b1d77c9747034b261b65568e4 Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Thu, 19 Feb 2026 14:15:21 +0100 Subject: [PATCH 02/22] Add write_to_file --- exasol/toolbox/util/workflows/workflow.py | 4 +++ test/unit/util/workflows/workflow_test.py | 40 +++++++++++++++++++++-- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/exasol/toolbox/util/workflows/workflow.py b/exasol/toolbox/util/workflows/workflow.py index 5ed4c8eb8..284e2627a 100644 --- a/exasol/toolbox/util/workflows/workflow.py +++ b/exasol/toolbox/util/workflows/workflow.py @@ -58,6 +58,10 @@ def load_from_template( # Wrap all other "non-special" exceptions raise ValueError(f"Error rendering file: {file_path}") from ex + def write_to_file(self, file_path: Path) -> None: + logger.info(f"Write out workflow: {file_path.name}", file_path=file_path) + file_path.write_text(self.content + "\n") + def _select_workflows(workflow_name: WorkflowName) -> Mapping[str, Path]: if workflow_name == ALL: diff --git a/test/unit/util/workflows/workflow_test.py b/test/unit/util/workflows/workflow_test.py index e98048b2d..2002f19ea 100644 --- a/test/unit/util/workflows/workflow_test.py +++ b/test/unit/util/workflows/workflow_test.py @@ -1,3 +1,4 @@ +from inspect import cleandoc from unittest.mock import patch import pytest @@ -18,13 +19,48 @@ class TestWorkflow: + @staticmethod + def test_works_as_expected(tmp_path): + input_yaml = """ + # This is a useful comment. + - name: Setup Python & Poetry Environment + uses: exasol/python-toolbox/.github/actions/python-environment@v5 + with: + python-version: "(( minimum_python_version ))" + poetry-version: "(( dependency_manager_version ))" + """ + expected_yaml = """ + # This is a useful comment. + - name: Setup Python & Poetry Environment + uses: exasol/python-toolbox/.github/actions/python-environment@v5 + with: + python-version: "3.10" + poetry-version: "2.3.0" + """ + input_file_path = tmp_path / "test.yml" + content = cleandoc(input_yaml) + input_file_path.write_text(content) + + workflow = Workflow.load_from_template( + file_path=input_file_path, + github_template_dict=PROJECT_CONFIG.github_template_dict, + ) + output_file_path = tmp_path / f"{input_file_path.name}" + workflow.write_to_file(file_path=output_file_path) + + assert output_file_path.read_text() == cleandoc(expected_yaml) + "\n" + @staticmethod @pytest.mark.parametrize("template_path", WORKFLOW_TEMPLATE_OPTIONS.values()) - def test_works_for_all_templates(template_path): - Workflow.load_from_template( + def test_works_for_all_templates(tmp_path, template_path): + workflow = Workflow.load_from_template( file_path=template_path, github_template_dict=PROJECT_CONFIG.github_template_dict, ) + file_path = tmp_path / f"{template_path.name}" + workflow.write_to_file(file_path=file_path) + + assert file_path.read_text() != "" @staticmethod def test_fails_when_yaml_does_not_exist(tmp_path): From 176b432a9fcf1fb97fd5ea5a8297d2e05500bad4 Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Thu, 19 Feb 2026 14:17:40 +0100 Subject: [PATCH 03/22] Add github_workflow_directory --- exasol/toolbox/config.py | 5 +++++ test/unit/config_test.py | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/exasol/toolbox/config.py b/exasol/toolbox/config.py index c66fa5d92..9ef6de688 100644 --- a/exasol/toolbox/config.py +++ b/exasol/toolbox/config.py @@ -266,6 +266,11 @@ def version_filepath(self) -> Path: """ return self.source_code_path / "version.py" + @computed_field + @property + def github_workflow_directory(self) -> Path: + return self.root_path / ".github" / "workflows" + @computed_field # type: ignore[misc] @property def github_template_dict(self) -> dict[str, Any]: diff --git a/test/unit/config_test.py b/test/unit/config_test.py index ee647c282..eed855ce3 100644 --- a/test/unit/config_test.py +++ b/test/unit/config_test.py @@ -15,7 +15,7 @@ class TestBaseConfig: @staticmethod - def test_works_as_defined(test_project_config_factory): + def test_works_as_defined(tmp_path, test_project_config_factory): config = test_project_config_factory() root_path = config.root_path @@ -34,6 +34,7 @@ def test_works_as_defined(test_project_config_factory): "dist", "venv", ), + "github_workflow_directory": tmp_path / ".github" / "workflows", "github_workflow_patcher_yaml": None, "github_template_dict": { "dependency_manager_version": "2.3.0", From 22894fe7dbb6a886904ed4fd2e00294383bce014 Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Thu, 19 Feb 2026 14:40:49 +0100 Subject: [PATCH 04/22] Switch to pytest fixture for config --- test/unit/util/workflows/conftest.py | 42 +++++++++++++------ .../util/workflows/patch_workflow_test.py | 5 +-- .../util/workflows/process_template_test.py | 5 +-- test/unit/util/workflows/render_yaml_test.py | 5 +-- test/unit/util/workflows/templates_test.py | 2 +- test/unit/util/workflows/workflow_test.py | 21 +++++----- 6 files changed, 46 insertions(+), 34 deletions(-) diff --git a/test/unit/util/workflows/conftest.py b/test/unit/util/workflows/conftest.py index 217ba009b..9e8545ba7 100644 --- a/test/unit/util/workflows/conftest.py +++ b/test/unit/util/workflows/conftest.py @@ -3,9 +3,12 @@ from pathlib import Path import pytest +from pydantic import ( + computed_field, +) +from toolbox.config import BaseConfig from exasol.toolbox.util.workflows.patch_workflow import WorkflowPatcher -from noxconfig import PROJECT_CONFIG @dataclass(frozen=True) @@ -38,31 +41,44 @@ def example_patcher_yaml(): @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: +def workflow_patcher(project_config) -> WorkflowPatcher: return WorkflowPatcher( - github_template_dict=PROJECT_CONFIG.github_template_dict, - file_path=workflow_patcher_yaml, + github_template_dict=project_config.github_template_dict, + file_path=project_config.github_workflow_patcher_yaml, ) @pytest.fixture -def remove_job_yaml(example_patcher_yaml, workflow_patcher_yaml): +def remove_job_yaml(example_patcher_yaml, project_config): content = cleandoc(example_patcher_yaml.remove_jobs) - workflow_patcher_yaml.write_text(content) + project_config.github_workflow_patcher_yaml.write_text(content) return content @pytest.fixture -def step_customization_yaml(request, example_patcher_yaml, workflow_patcher_yaml): +def step_customization_yaml(request, example_patcher_yaml, project_config): # 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) + project_config.github_workflow_patcher_yaml.write_text(content) return content + + +class Config(BaseConfig): + @computed_field # type: ignore[misc] + @property + def github_workflow_patcher_yaml(self) -> Path | None: + """ + Override for testing purposes + """ + return self.root_path / ".workflow-patcher.yml" + + +@pytest.fixture +def project_config(tmp_path) -> Config: + return Config( + root_path=tmp_path, + project_name="test", + ) diff --git a/test/unit/util/workflows/patch_workflow_test.py b/test/unit/util/workflows/patch_workflow_test.py index 35ff3c82d..e64179151 100644 --- a/test/unit/util/workflows/patch_workflow_test.py +++ b/test/unit/util/workflows/patch_workflow_test.py @@ -10,7 +10,6 @@ ActionType, WorkflowPatcher, ) -from noxconfig import PROJECT_CONFIG @pytest.fixture @@ -19,9 +18,9 @@ def workflow_patcher_yaml(tmp_path: Path) -> Path: @pytest.fixture -def workflow_patcher(workflow_patcher_yaml) -> WorkflowPatcher: +def workflow_patcher(workflow_patcher_yaml, project_config) -> WorkflowPatcher: return WorkflowPatcher( - github_template_dict=PROJECT_CONFIG.github_template_dict, + github_template_dict=project_config.github_template_dict, file_path=workflow_patcher_yaml, ) diff --git a/test/unit/util/workflows/process_template_test.py b/test/unit/util/workflows/process_template_test.py index 96780169a..22f609e01 100644 --- a/test/unit/util/workflows/process_template_test.py +++ b/test/unit/util/workflows/process_template_test.py @@ -8,7 +8,6 @@ from exasol.toolbox.util.workflows.patch_workflow import ActionType from exasol.toolbox.util.workflows.process_template import WorkflowModifier from exasol.toolbox.util.workflows.render_yaml import YamlRenderer -from noxconfig import PROJECT_CONFIG WORKFLOW_YAML = """ name: Checks @@ -58,9 +57,9 @@ def checks_yaml(tmp_path, workflow_name): @pytest.fixture -def workflow_dict(checks_yaml) -> CommentedMap: +def workflow_dict(checks_yaml, project_config) -> CommentedMap: return YamlRenderer( - github_template_dict=PROJECT_CONFIG.github_template_dict, file_path=checks_yaml + github_template_dict=project_config.github_template_dict, file_path=checks_yaml ).get_yaml_dict() diff --git a/test/unit/util/workflows/render_yaml_test.py b/test/unit/util/workflows/render_yaml_test.py index 91367d576..a78bc1813 100644 --- a/test/unit/util/workflows/render_yaml_test.py +++ b/test/unit/util/workflows/render_yaml_test.py @@ -17,7 +17,6 @@ from exasol.toolbox.util.workflows.render_yaml import ( YamlRenderer, ) -from noxconfig import PROJECT_CONFIG @pytest.fixture @@ -26,9 +25,9 @@ def test_yml(tmp_path: Path) -> Path: @pytest.fixture -def yaml_renderer(test_yml) -> YamlRenderer: +def yaml_renderer(test_yml, project_config) -> YamlRenderer: return YamlRenderer( - github_template_dict=PROJECT_CONFIG.github_template_dict, file_path=test_yml + github_template_dict=project_config.github_template_dict, file_path=test_yml ) diff --git a/test/unit/util/workflows/templates_test.py b/test/unit/util/workflows/templates_test.py index 9f19da78b..994777e26 100644 --- a/test/unit/util/workflows/templates_test.py +++ b/test/unit/util/workflows/templates_test.py @@ -2,7 +2,7 @@ from noxconfig import PROJECT_CONFIG -def test_get_workflow_templates(): +def test_get_workflow_templates(project_config): result = get_workflow_templates() assert result.keys() == { diff --git a/test/unit/util/workflows/workflow_test.py b/test/unit/util/workflows/workflow_test.py index 2002f19ea..b2a5cc766 100644 --- a/test/unit/util/workflows/workflow_test.py +++ b/test/unit/util/workflows/workflow_test.py @@ -15,12 +15,11 @@ Workflow, _select_workflows, ) -from noxconfig import PROJECT_CONFIG class TestWorkflow: @staticmethod - def test_works_as_expected(tmp_path): + def test_works_as_expected(tmp_path, project_config): input_yaml = """ # This is a useful comment. - name: Setup Python & Poetry Environment @@ -43,7 +42,7 @@ def test_works_as_expected(tmp_path): workflow = Workflow.load_from_template( file_path=input_file_path, - github_template_dict=PROJECT_CONFIG.github_template_dict, + github_template_dict=project_config.github_template_dict, ) output_file_path = tmp_path / f"{input_file_path.name}" workflow.write_to_file(file_path=output_file_path) @@ -52,10 +51,10 @@ def test_works_as_expected(tmp_path): @staticmethod @pytest.mark.parametrize("template_path", WORKFLOW_TEMPLATE_OPTIONS.values()) - def test_works_for_all_templates(tmp_path, template_path): + def test_works_for_all_templates(tmp_path, project_config, template_path): workflow = Workflow.load_from_template( file_path=template_path, - github_template_dict=PROJECT_CONFIG.github_template_dict, + github_template_dict=project_config.github_template_dict, ) file_path = tmp_path / f"{template_path.name}" workflow.write_to_file(file_path=file_path) @@ -63,19 +62,19 @@ def test_works_for_all_templates(tmp_path, template_path): assert file_path.read_text() != "" @staticmethod - def test_fails_when_yaml_does_not_exist(tmp_path): + def test_fails_when_yaml_does_not_exist(tmp_path, project_config): file_path = tmp_path / "test.yaml" with pytest.raises(FileNotFoundError, match="test.yaml"): Workflow.load_from_template( file_path=file_path, - github_template_dict=PROJECT_CONFIG.github_template_dict, + github_template_dict=project_config.github_template_dict, ) @staticmethod @pytest.mark.parametrize( "raised_exc", [TemplateRenderingError, YamlParsingError, YamlOutputError] ) - def test_raises_custom_exceptions(tmp_path, raised_exc): + def test_raises_custom_exceptions(tmp_path, project_config, raised_exc): file_path = tmp_path / "test.yaml" file_path.write_text("dummy content") @@ -85,11 +84,11 @@ def test_raises_custom_exceptions(tmp_path, raised_exc): with pytest.raises(raised_exc): Workflow.load_from_template( file_path=file_path, - github_template_dict=PROJECT_CONFIG.github_template_dict, + github_template_dict=project_config.github_template_dict, ) @staticmethod - def test_other_exceptions_raised_as_valuerror(tmp_path): + def test_other_exceptions_raised_as_valuerror(tmp_path, project_config): file_path = tmp_path / "test.yaml" file_path.write_text("dummy content") @@ -99,7 +98,7 @@ def test_other_exceptions_raised_as_valuerror(tmp_path): with pytest.raises(ValueError): Workflow.load_from_template( file_path=file_path, - github_template_dict=PROJECT_CONFIG.github_template_dict, + github_template_dict=project_config.github_template_dict, ) From 3dd7570278b45b57249698603c3dd4047f9000ba Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Thu, 19 Feb 2026 15:02:43 +0100 Subject: [PATCH 05/22] Add and test update_selected_workflow --- exasol/toolbox/util/workflows/exceptions.py | 41 +++++----- exasol/toolbox/util/workflows/workflow.py | 50 +++++++++++- test/unit/util/workflows/conftest.py | 34 +++++--- test/unit/util/workflows/workflow_test.py | 88 +++++++++++++++++++++ 4 files changed, 179 insertions(+), 34 deletions(-) diff --git a/exasol/toolbox/util/workflows/exceptions.py b/exasol/toolbox/util/workflows/exceptions.py index 5a884c9b9..bdaaa5ac7 100644 --- a/exasol/toolbox/util/workflows/exceptions.py +++ b/exasol/toolbox/util/workflows/exceptions.py @@ -1,15 +1,18 @@ +from collections.abc import Mapping from pathlib import Path class YamlError(Exception): - """Base exception for YAML errors.""" + """ + Base exception for YAML errors. + """ message_template = "An error occurred with file: {file_path}" - def __init__(self, file_path: Path): + def __init__(self, file_path: Path, **kwargs): self.file_path = file_path # Format the template defined in the subclass - message = self.message_template.format(file_path=file_path) + message = self.message_template.format(file_path=file_path, **kwargs) super().__init__(message) @@ -73,26 +76,27 @@ class YamlKeyError(Exception): message_template = "An error occurred with job: '{job_name}'" - def __init__(self, job_name: str): - self.job_name = job_name - # Format the template defined in the subclass - message = self.message_template.format(job_name=job_name) - super().__init__(message) + def __init__(self, **kwargs): + # Store all attributes dynamically (job_name, step_id, etc.) + for key, value in kwargs.items(): + setattr(self, key, value) + + self._data = kwargs + # Format the template using the passed-in arguments + super().__init__(self.message_template.format(**kwargs)) + + @property + def entry(self) -> Mapping[str, str]: + return self._data -class YamlJobValueError(Exception): +class YamlJobValueError(YamlKeyError): """ Raised when a job cannot be found in a YAML file. """ message_template = "Job '{job_name}' could not be found" - def __init__(self, job_name: str): - self.job_name = job_name - # Format the template defined in the subclass - message = self.message_template.format(job_name=job_name) - super().__init__(message) - class YamlStepIdValueError(YamlKeyError): """ @@ -100,10 +104,3 @@ class YamlStepIdValueError(YamlKeyError): """ message_template = "Step_id '{step_id}' not found in job '{job_name}'" - - def __init__(self, step_id: str, job_name: str): - self.step_id = step_id - self.job_name = job_name - - message = self.message_template.format(step_id=step_id, job_name=job_name) - super(YamlKeyError, self).__init__(message) diff --git a/exasol/toolbox/util/workflows/workflow.py b/exasol/toolbox/util/workflows/workflow.py index 284e2627a..7db8af316 100644 --- a/exasol/toolbox/util/workflows/workflow.py +++ b/exasol/toolbox/util/workflows/workflow.py @@ -14,9 +14,17 @@ bound_contextvars, ) +from exasol.toolbox.config import BaseConfig from exasol.toolbox.util.workflows import logger -from exasol.toolbox.util.workflows.exceptions import YamlError -from exasol.toolbox.util.workflows.patch_workflow import WorkflowCommentedMap +from exasol.toolbox.util.workflows.exceptions import ( + InvalidWorkflowPatcherEntryError, + YamlError, + YamlKeyError, +) +from exasol.toolbox.util.workflows.patch_workflow import ( + WorkflowCommentedMap, + WorkflowPatcher, +) from exasol.toolbox.util.workflows.process_template import WorkflowRenderer from exasol.toolbox.util.workflows.templates import WORKFLOW_TEMPLATE_OPTIONS @@ -52,7 +60,7 @@ def load_from_template( ) workflow = workflow_renderer.render() return cls(content=workflow) - except YamlError as ex: + except (YamlError, YamlKeyError) as ex: raise ex except Exception as ex: # Wrap all other "non-special" exceptions @@ -67,3 +75,39 @@ def _select_workflows(workflow_name: WorkflowName) -> Mapping[str, Path]: if workflow_name == ALL: return WORKFLOW_TEMPLATE_OPTIONS return {workflow_name: WORKFLOW_TEMPLATE_OPTIONS[workflow_name]} + + +def update_selected_workflow(workflow_name: WorkflowName, config: BaseConfig) -> None: + """ + Updates a selected workflow or all workflows + """ + workflow_dict = _select_workflows(workflow_name) + logger.info(f"Selected workflow(s) to update: {list(workflow_dict.keys())}") + + workflow_patcher = None + if config.github_workflow_patcher_yaml: + # TODO add logging to WorkflowPatcher process and check other paths ;) + workflow_patcher = WorkflowPatcher( + github_template_dict=config.github_template_dict, + file_path=config.github_workflow_patcher_yaml, + ) + + for workflow_name in workflow_dict: + patch_yaml = None + if workflow_patcher: + patch_yaml = workflow_patcher.extract_by_workflow( + workflow_name=workflow_name + ) + + try: + workflow = Workflow.load_from_template( + file_path=workflow_dict[workflow_name], + github_template_dict=config.github_template_dict, + patch_yaml=patch_yaml, + ) + file_path = config.github_workflow_directory / f"{workflow_name}.yml" + workflow.write_to_file(file_path=file_path) + except YamlKeyError as ex: + raise InvalidWorkflowPatcherEntryError( + file_path=config.github_workflow_patcher_yaml, entry=ex.entry + ) from ex diff --git a/test/unit/util/workflows/conftest.py b/test/unit/util/workflows/conftest.py index 9e8545ba7..b7cf2bfe5 100644 --- a/test/unit/util/workflows/conftest.py +++ b/test/unit/util/workflows/conftest.py @@ -66,18 +66,34 @@ def step_customization_yaml(request, example_patcher_yaml, project_config): return content -class Config(BaseConfig): - @computed_field # type: ignore[misc] - @property - def github_workflow_patcher_yaml(self) -> Path | None: - """ - Override for testing purposes - """ - return self.root_path / ".workflow-patcher.yml" +@pytest.fixture +def project_config(tmp_path) -> BaseConfig: + class Config(BaseConfig): + @computed_field # type: ignore[misc] + @property + def github_workflow_patcher_yaml(self) -> Path: + """ + Override for testing purposes + """ + return self.root_path / ".workflow-patcher.yml" + + return Config( + root_path=tmp_path, + project_name="test", + ) @pytest.fixture -def project_config(tmp_path) -> Config: +def project_config_without_patcher(tmp_path) -> BaseConfig: + class Config(BaseConfig): + @computed_field # type: ignore[misc] + @property + def github_workflow_patcher_yaml(self) -> None: + """ + Override for testing purposes + """ + return None + return Config( root_path=tmp_path, project_name="test", diff --git a/test/unit/util/workflows/workflow_test.py b/test/unit/util/workflows/workflow_test.py index b2a5cc766..72c582401 100644 --- a/test/unit/util/workflows/workflow_test.py +++ b/test/unit/util/workflows/workflow_test.py @@ -4,7 +4,9 @@ import pytest from exasol.toolbox.util.workflows.exceptions import ( + InvalidWorkflowPatcherEntryError, TemplateRenderingError, + YamlJobValueError, YamlOutputError, YamlParsingError, ) @@ -14,6 +16,7 @@ ALL, Workflow, _select_workflows, + update_selected_workflow, ) @@ -113,3 +116,88 @@ def test_for_all_works_as_expected(): def test_for_individual_workflows_works_as_expected(workflow_name): result = _select_workflows(workflow_name) assert result == {workflow_name: WORKFLOW_TEMPLATE_OPTIONS[workflow_name]} + + +class TestUpdateSelectedWorkflow: + @staticmethod + def test_works_as_expected_without_patcher(project_config_without_patcher): + workflow_name = "merge-gate" + # setup + project_config_without_patcher.github_workflow_directory.mkdir(parents=True) + input_text = WORKFLOW_TEMPLATE_OPTIONS[workflow_name].read_text() + expected_file_path = ( + project_config_without_patcher.github_workflow_directory + / f"{workflow_name}.yml" + ) + + update_selected_workflow( + workflow_name=workflow_name, config=project_config_without_patcher + ) + result = expected_file_path.read_text() + + # Currently, we check only a subselection as we must preserve formatting for tbx + # endpoints, and there are 2 minor whitespace differences. + assert result[:10] == input_text[:10] + + @staticmethod + def test_works_as_expected_with_relevant_patcher(project_config, remove_job_yaml): + # remove_job_yaml modifies "checks" and that's also the workflow being updated + workflow_name = "checks" + # setup + project_config.github_workflow_directory.mkdir(parents=True) + input_text = WORKFLOW_TEMPLATE_OPTIONS[workflow_name].read_text() + expected_file_path = ( + project_config.github_workflow_directory / f"{workflow_name}.yml" + ) + # setup checks + removed_job_name = "build-documentation-and-check-links" + assert removed_job_name in remove_job_yaml + assert removed_job_name in input_text + + update_selected_workflow(workflow_name="checks", config=project_config) + result = expected_file_path.read_text() + + # We compare only a subselection to verify that the files are roughly the + # same, and we expect them to differ as the 'result' does not contain + # the 'removed_job_name' + assert result[:10] == input_text[:10] + assert removed_job_name not in result + + @staticmethod + def test_works_as_expected_with_not_relevant_patcher( + project_config, remove_job_yaml + ): + # remove_job_yaml modifies "checks" and that's NOT the workflow being updated + workflow_name = "merge-gate" + # setup + project_config.github_workflow_directory.mkdir(parents=True) + input_text = WORKFLOW_TEMPLATE_OPTIONS[workflow_name].read_text() + expected_file_path = ( + project_config.github_workflow_directory / f"{workflow_name}.yml" + ) + + update_selected_workflow(workflow_name=workflow_name, config=project_config) + result = expected_file_path.read_text() + + # Currently, we check only a subselection as we must preserve formatting for tbx + # endpoints, and there are 2 minor whitespace differences. + assert result[:10] == input_text[:10] + + @staticmethod + def test_raises_invalidworkflowpatcherentryerror(project_config): + patcher_yml = """ + workflows: + - name: "checks" + remove_jobs: + - unknown-job + """ + project_config.github_workflow_patcher_yaml.write_text(patcher_yml) + + with pytest.raises(InvalidWorkflowPatcherEntryError) as ex: + update_selected_workflow(workflow_name="checks", config=project_config) + + assert ( + f"In file '{project_config.github_workflow_patcher_yaml}', " + "an entry '{'job_name': 'unknown-job'}' does not exist in" + ) in str(ex.value) + assert isinstance(ex.value.__cause__, YamlJobValueError) From 5d8ee8ae93e588fd65f54e2d1c2feaa39f8d0271 Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Thu, 19 Feb 2026 15:36:37 +0100 Subject: [PATCH 06/22] Harmonize to singular and template usage in backend --- exasol/toolbox/util/workflows/workflow.py | 9 ++++++--- test/unit/util/workflows/workflow_test.py | 8 ++++---- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/exasol/toolbox/util/workflows/workflow.py b/exasol/toolbox/util/workflows/workflow.py index 7db8af316..027ab2d35 100644 --- a/exasol/toolbox/util/workflows/workflow.py +++ b/exasol/toolbox/util/workflows/workflow.py @@ -71,7 +71,10 @@ def write_to_file(self, file_path: Path) -> None: file_path.write_text(self.content + "\n") -def _select_workflows(workflow_name: WorkflowName) -> Mapping[str, Path]: +def _select_workflow_template(workflow_name: WorkflowName) -> Mapping[str, Path]: + """ + Returns a mapping of a workflow template or of all workflow templates. + """ if workflow_name == ALL: return WORKFLOW_TEMPLATE_OPTIONS return {workflow_name: WORKFLOW_TEMPLATE_OPTIONS[workflow_name]} @@ -79,9 +82,9 @@ def _select_workflows(workflow_name: WorkflowName) -> Mapping[str, Path]: def update_selected_workflow(workflow_name: WorkflowName, config: BaseConfig) -> None: """ - Updates a selected workflow or all workflows + Updates a selected workflow or all workflows. """ - workflow_dict = _select_workflows(workflow_name) + workflow_dict = _select_workflow_template(workflow_name) logger.info(f"Selected workflow(s) to update: {list(workflow_dict.keys())}") workflow_patcher = None diff --git a/test/unit/util/workflows/workflow_test.py b/test/unit/util/workflows/workflow_test.py index 72c582401..c7b3af9eb 100644 --- a/test/unit/util/workflows/workflow_test.py +++ b/test/unit/util/workflows/workflow_test.py @@ -15,7 +15,7 @@ from exasol.toolbox.util.workflows.workflow import ( ALL, Workflow, - _select_workflows, + _select_workflow_template, update_selected_workflow, ) @@ -105,16 +105,16 @@ def test_other_exceptions_raised_as_valuerror(tmp_path, project_config): ) -class TestSelectWorkflow: +class TestSelectWorkflowTemplate: @staticmethod def test_for_all_works_as_expected(): - result = _select_workflows(ALL) + result = _select_workflow_template(ALL) assert result == WORKFLOW_TEMPLATE_OPTIONS @staticmethod @pytest.mark.parametrize("workflow_name", WORKFLOW_TEMPLATE_OPTIONS) def test_for_individual_workflows_works_as_expected(workflow_name): - result = _select_workflows(workflow_name) + result = _select_workflow_template(workflow_name) assert result == {workflow_name: WORKFLOW_TEMPLATE_OPTIONS[workflow_name]} From c64b251859866db8ba7d73eb465a7c116c990fb9 Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Thu, 19 Feb 2026 15:40:40 +0100 Subject: [PATCH 07/22] Fix missing import part --- exasol/toolbox/util/workflows/patch_workflow.py | 2 ++ test/unit/util/workflows/conftest.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/exasol/toolbox/util/workflows/patch_workflow.py b/exasol/toolbox/util/workflows/patch_workflow.py index 330eb2059..08523e38a 100644 --- a/exasol/toolbox/util/workflows/patch_workflow.py +++ b/exasol/toolbox/util/workflows/patch_workflow.py @@ -15,6 +15,7 @@ ) from ruamel.yaml import CommentedMap +from exasol.toolbox.util.workflows import logger from exasol.toolbox.util.workflows.exceptions import InvalidWorkflowPatcherYamlError from exasol.toolbox.util.workflows.render_yaml import YamlRenderer from exasol.toolbox.util.workflows.templates import WORKFLOW_TEMPLATE_OPTIONS @@ -120,6 +121,7 @@ def content(self) -> CommentedMap: The loaded YAML content. It loads on first access and stays cached even though the class is frozen. """ + logger.info(f"Load workflow template: {file_path.name}") loaded_yaml = self.get_yaml_dict() try: WorkflowPatcherConfig.model_validate(loaded_yaml) diff --git a/test/unit/util/workflows/conftest.py b/test/unit/util/workflows/conftest.py index b7cf2bfe5..e5bd7465b 100644 --- a/test/unit/util/workflows/conftest.py +++ b/test/unit/util/workflows/conftest.py @@ -6,8 +6,8 @@ from pydantic import ( computed_field, ) -from toolbox.config import BaseConfig +from exasol.toolbox.config import BaseConfig from exasol.toolbox.util.workflows.patch_workflow import WorkflowPatcher From 4aed04d12ebe740955b4fde2fbaf92562b4a7080 Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Thu, 19 Feb 2026 15:44:20 +0100 Subject: [PATCH 08/22] Add logging to WorkflowPatcher --- exasol/toolbox/util/workflows/patch_workflow.py | 17 ++++++++++------- .../toolbox/util/workflows/process_template.py | 2 +- exasol/toolbox/util/workflows/workflow.py | 1 - 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/exasol/toolbox/util/workflows/patch_workflow.py b/exasol/toolbox/util/workflows/patch_workflow.py index 08523e38a..bcf13e5bb 100644 --- a/exasol/toolbox/util/workflows/patch_workflow.py +++ b/exasol/toolbox/util/workflows/patch_workflow.py @@ -14,6 +14,7 @@ ValidationError, ) from ruamel.yaml import CommentedMap +from structlog.contextvars import bound_contextvars from exasol.toolbox.util.workflows import logger from exasol.toolbox.util.workflows.exceptions import InvalidWorkflowPatcherYamlError @@ -121,13 +122,15 @@ def content(self) -> CommentedMap: The loaded YAML content. It loads on first access and stays cached even though the class is frozen. """ - logger.info(f"Load workflow template: {file_path.name}") - loaded_yaml = self.get_yaml_dict() - try: - WorkflowPatcherConfig.model_validate(loaded_yaml) - return loaded_yaml - except ValidationError as ex: - raise InvalidWorkflowPatcherYamlError(file_path=self.file_path) from ex + with bound_contextvars(template_file_name=self.file_path.name): + logger.info(f"Load workflow patcher: {self.file_path.name}") + loaded_yaml = self.get_yaml_dict() + try: + logger.debug("Validate workflow patcher with Pydantic") + WorkflowPatcherConfig.model_validate(loaded_yaml) + return loaded_yaml + except ValidationError as ex: + raise InvalidWorkflowPatcherYamlError(file_path=self.file_path) from ex def extract_by_workflow(self, workflow_name: str) -> WorkflowCommentedMap | None: """ diff --git a/exasol/toolbox/util/workflows/process_template.py b/exasol/toolbox/util/workflows/process_template.py index 1051bb93c..d98b5e1d6 100644 --- a/exasol/toolbox/util/workflows/process_template.py +++ b/exasol/toolbox/util/workflows/process_template.py @@ -33,7 +33,7 @@ def render(self) -> str: workflow_dict = self.get_yaml_dict() if self.patch_yaml: - logger.debug("Modify workflow custom yaml") + logger.debug("Modify workflow with custom yaml") workflow_modifier = WorkflowModifier( workflow_dict=workflow_dict, patch_yaml=self.patch_yaml ) diff --git a/exasol/toolbox/util/workflows/workflow.py b/exasol/toolbox/util/workflows/workflow.py index 027ab2d35..9083ee610 100644 --- a/exasol/toolbox/util/workflows/workflow.py +++ b/exasol/toolbox/util/workflows/workflow.py @@ -89,7 +89,6 @@ def update_selected_workflow(workflow_name: WorkflowName, config: BaseConfig) -> workflow_patcher = None if config.github_workflow_patcher_yaml: - # TODO add logging to WorkflowPatcher process and check other paths ;) workflow_patcher = WorkflowPatcher( github_template_dict=config.github_template_dict, file_path=config.github_workflow_patcher_yaml, From c0a51010376fd41a35f71a5988ea368cf6f6bde5 Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Thu, 19 Feb 2026 15:47:03 +0100 Subject: [PATCH 09/22] Add workflow:update Nox session --- exasol/toolbox/nox/_workflow.py | 44 +++++++++++++++++++++++++++++++++ exasol/toolbox/nox/tasks.py | 1 + 2 files changed, 45 insertions(+) create mode 100644 exasol/toolbox/nox/_workflow.py diff --git a/exasol/toolbox/nox/_workflow.py b/exasol/toolbox/nox/_workflow.py new file mode 100644 index 000000000..0b0fc226b --- /dev/null +++ b/exasol/toolbox/nox/_workflow.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import argparse + +import nox +from nox import Session + +from exasol.toolbox.util.workflows.workflow import ( + ALL, + WORKFLOW_NAMES, + update_selected_workflow, +) +from noxconfig import PROJECT_CONFIG + + +def _create_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="nox -s workflow:update", + usage="nox -s workflow:update -- [-h] --name", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + + parser.add_argument( + "--name", # Changed to singular + default=ALL, + choices=["all"] + WORKFLOW_NAMES, + help="Select one template by name or 'all' to update everything.", + required=True, + ) + return parser + + +@nox.session(name="workflow:update", python=False) +def update_workflow(session: Session) -> None: + """ + Update (or install if it's not yet existing) one or all generated GitHub workflow(s) + """ + parser = _create_parser() + args = parser.parse_args(session.posargs) + + # Ensure that the GitHub workflow directory exists + PROJECT_CONFIG.github_workflow_directory.mkdir(parents=True, exist_ok=True) + + update_selected_workflow(workflow_name=args.name, config=PROJECT_CONFIG) diff --git a/exasol/toolbox/nox/tasks.py b/exasol/toolbox/nox/tasks.py index f3932da8a..825363e7c 100644 --- a/exasol/toolbox/nox/tasks.py +++ b/exasol/toolbox/nox/tasks.py @@ -90,6 +90,7 @@ def check(session: Session) -> None: from exasol.toolbox.nox._package_version import version_check from exasol.toolbox.nox._package import package_check +from exasol.toolbox.nox._workflow import update_workflow # isort: on # fmt: on From 5578a953dcc1f9d545cfe72980bea953e3678549 Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Thu, 19 Feb 2026 15:49:41 +0100 Subject: [PATCH 10/22] Ignore type error --- exasol/toolbox/config.py | 2 +- exasol/toolbox/util/workflows/workflow.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/exasol/toolbox/config.py b/exasol/toolbox/config.py index 9ef6de688..e0fdd3bfb 100644 --- a/exasol/toolbox/config.py +++ b/exasol/toolbox/config.py @@ -266,7 +266,7 @@ def version_filepath(self) -> Path: """ return self.source_code_path / "version.py" - @computed_field + @computed_field # type: ignore[misc] @property def github_workflow_directory(self) -> Path: return self.root_path / ".github" / "workflows" diff --git a/exasol/toolbox/util/workflows/workflow.py b/exasol/toolbox/util/workflows/workflow.py index 9083ee610..56145cce6 100644 --- a/exasol/toolbox/util/workflows/workflow.py +++ b/exasol/toolbox/util/workflows/workflow.py @@ -111,5 +111,5 @@ def update_selected_workflow(workflow_name: WorkflowName, config: BaseConfig) -> workflow.write_to_file(file_path=file_path) except YamlKeyError as ex: raise InvalidWorkflowPatcherEntryError( - file_path=config.github_workflow_patcher_yaml, entry=ex.entry + file_path=config.github_workflow_patcher_yaml, entry=ex.entry # type: ignore ) from ex From e90bd8176717b15df5a1335d3bc28e2d15bc1826 Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Thu, 19 Feb 2026 15:54:34 +0100 Subject: [PATCH 11/22] Update deprecation warning with replacement --- exasol/toolbox/tools/workflow.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/exasol/toolbox/tools/workflow.py b/exasol/toolbox/tools/workflow.py index b255462c4..f07f4164b 100644 --- a/exasol/toolbox/tools/workflow.py +++ b/exasol/toolbox/tools/workflow.py @@ -76,7 +76,7 @@ def install_workflow( template=workflow, dest=dest, pkg=PKG, template_type=TEMPLATE_TYPE ) warnings.warn( - "\033[31m`tbx workflow install` is deprecated; this will be replaced by a nox session after 2026-04-22\033[0m", + "\033[31m`tbx workflow install` is deprecated; this will be replaced by the Nox session `workflow:update` after 2026-04-22\033[0m", category=FutureWarning, stacklevel=2, ) @@ -101,7 +101,7 @@ def update_workflow( template_type=TEMPLATE_TYPE, ) warnings.warn( - "\033[31m`tbx workflow update` is deprecated; this will be replaced by a nox session after 2026-04-22\033[0m", + "\033[31m`tbx workflow update` is deprecated; this will be replaced by the Nox session `workflow:update` after 2026-04-22\033[0m", category=FutureWarning, stacklevel=2, ) From 29ff1237e70ff213dd49f2e49a37fd3ee5603682 Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Thu, 19 Feb 2026 15:55:29 +0100 Subject: [PATCH 12/22] Add changelog entry --- doc/changes/unreleased.md | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/changes/unreleased.md b/doc/changes/unreleased.md index 1a6e744a5..46f9a894d 100644 --- a/doc/changes/unreleased.md +++ b/doc/changes/unreleased.md @@ -8,6 +8,7 @@ * #712: Added basic logging to workflow processing * #714: Added logic to modify a workflow using the .workflow-patcher.yml * #717: Restricted workflow names in .workflow-patcher.yml to template workflow names +* #719: Added nox session `workflow:update` to install/update workflows using the .workflow-patcher.yml (if desired) ## Documentation From c46899da0f9abff9e06d0cc77a87df61321b1feb Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Thu, 19 Feb 2026 16:20:41 +0100 Subject: [PATCH 13/22] Add final tests --- exasol/toolbox/nox/_workflow.py | 2 +- test/unit/nox/_workflow_test.py | 78 ++++++++++++++++++++++++++++ test/unit/util/workflows/conftest.py | 4 +- 3 files changed, 80 insertions(+), 4 deletions(-) create mode 100644 test/unit/nox/_workflow_test.py diff --git a/exasol/toolbox/nox/_workflow.py b/exasol/toolbox/nox/_workflow.py index 0b0fc226b..3b3f8e832 100644 --- a/exasol/toolbox/nox/_workflow.py +++ b/exasol/toolbox/nox/_workflow.py @@ -23,7 +23,7 @@ def _create_parser() -> argparse.ArgumentParser: parser.add_argument( "--name", # Changed to singular default=ALL, - choices=["all"] + WORKFLOW_NAMES, + choices=WORKFLOW_NAMES, help="Select one template by name or 'all' to update everything.", required=True, ) diff --git a/test/unit/nox/_workflow_test.py b/test/unit/nox/_workflow_test.py new file mode 100644 index 000000000..1446c5c08 --- /dev/null +++ b/test/unit/nox/_workflow_test.py @@ -0,0 +1,78 @@ +from unittest.mock import patch + +import pytest +from pydantic import computed_field +from toolbox.util.workflows.templates import WORKFLOW_TEMPLATE_OPTIONS + +from exasol.toolbox.config import BaseConfig +from exasol.toolbox.nox._workflow import update_workflow +from exasol.toolbox.util.workflows.workflow import ALL + + +@pytest.fixture +def project_config_without_patcher(tmp_path) -> BaseConfig: + class Config(BaseConfig): + @computed_field # type: ignore[misc] + @property + def github_workflow_patcher_yaml(self) -> None: + """ + Override for testing purposes + """ + return None + + return Config( + root_path=tmp_path, + project_name="test", + ) + + +@pytest.fixture +def nox_session_runner_posargs(request): + return ["--name", request.param] + + +class TestUpdateWorkflow: + @staticmethod + @pytest.mark.parametrize( + "nox_session_runner_posargs, expected_count", + [(ALL, 13), *[(key, 1) for key in WORKFLOW_TEMPLATE_OPTIONS.keys()]], + indirect=["nox_session_runner_posargs"], + ) + def test_works_as_expected( + nox_session, + project_config_without_patcher, + nox_session_runner_posargs, + expected_count, + ): + with patch( + "exasol.toolbox.nox._workflow.PROJECT_CONFIG", + new=project_config_without_patcher, + ): + update_workflow(nox_session) + + count = sum( + 1 + for _ in project_config_without_patcher.github_workflow_directory.glob( + "*.yml" + ) + ) + assert count == expected_count + + @staticmethod + @pytest.mark.parametrize( + "nox_session_runner_posargs", + ["not-a-valid-name"], + indirect=["nox_session_runner_posargs"], + ) + def test_raises_exception_when_name_incorrect( + nox_session, project_config_without_patcher, capsys, nox_session_runner_posargs + ): + with patch( + "exasol.toolbox.nox._workflow.PROJECT_CONFIG", + new=project_config_without_patcher, + ): + + with pytest.raises(SystemExit): + update_workflow(nox_session) + + assert "invalid choice: 'not-a-valid-name'" in capsys.readouterr().err diff --git a/test/unit/util/workflows/conftest.py b/test/unit/util/workflows/conftest.py index e5bd7465b..c1e763f23 100644 --- a/test/unit/util/workflows/conftest.py +++ b/test/unit/util/workflows/conftest.py @@ -3,9 +3,7 @@ from pathlib import Path import pytest -from pydantic import ( - computed_field, -) +from pydantic import computed_field from exasol.toolbox.config import BaseConfig from exasol.toolbox.util.workflows.patch_workflow import WorkflowPatcher From c80027bb55dec698c00614b530f9e335fbdeb4af Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Thu, 19 Feb 2026 16:23:50 +0100 Subject: [PATCH 14/22] Fix missing import part --- test/unit/nox/_workflow_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/nox/_workflow_test.py b/test/unit/nox/_workflow_test.py index 1446c5c08..b3281b0e3 100644 --- a/test/unit/nox/_workflow_test.py +++ b/test/unit/nox/_workflow_test.py @@ -2,10 +2,10 @@ import pytest from pydantic import computed_field -from toolbox.util.workflows.templates import WORKFLOW_TEMPLATE_OPTIONS from exasol.toolbox.config import BaseConfig from exasol.toolbox.nox._workflow import update_workflow +from exasol.toolbox.util.workflows.templates import WORKFLOW_TEMPLATE_OPTIONS from exasol.toolbox.util.workflows.workflow import ALL From 420bac1a243783b91327b79c20212605d6d298f0 Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Fri, 20 Feb 2026 08:39:06 +0100 Subject: [PATCH 15/22] Apply reviewer changes with modifications to make consistent --- exasol/toolbox/util/workflows/workflow.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/exasol/toolbox/util/workflows/workflow.py b/exasol/toolbox/util/workflows/workflow.py index 56145cce6..0d0137455 100644 --- a/exasol/toolbox/util/workflows/workflow.py +++ b/exasol/toolbox/util/workflows/workflow.py @@ -47,7 +47,7 @@ def load_from_template( patch_yaml: WorkflowCommentedMap | None = None, ): with bound_contextvars(template_file_name=file_path.name): - logger.info(f"Load workflow template: {file_path.name}") + logger.info(f"Load workflow template: %s", file_path.name) if not file_path.exists(): raise FileNotFoundError(file_path) @@ -66,9 +66,9 @@ def load_from_template( # Wrap all other "non-special" exceptions raise ValueError(f"Error rendering file: {file_path}") from ex - def write_to_file(self, file_path: Path) -> None: - logger.info(f"Write out workflow: {file_path.name}", file_path=file_path) - file_path.write_text(self.content + "\n") + def write_to_file(self, path: Path) -> None: + logger.info("Write workflow file %s", path.name) + path.write_text(self.content + "\n") def _select_workflow_template(workflow_name: WorkflowName) -> Mapping[str, Path]: From 8e856fece4b53635ab3b1367170e844601a7fb4b Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Fri, 20 Feb 2026 08:52:43 +0100 Subject: [PATCH 16/22] Switch to generate as it is more specific --- doc/changes/unreleased.md | 2 +- exasol/toolbox/nox/_workflow.py | 9 ++++----- exasol/toolbox/nox/tasks.py | 2 +- test/unit/nox/_workflow_test.py | 8 ++++---- 4 files changed, 10 insertions(+), 11 deletions(-) diff --git a/doc/changes/unreleased.md b/doc/changes/unreleased.md index 46f9a894d..22d3fc7c7 100644 --- a/doc/changes/unreleased.md +++ b/doc/changes/unreleased.md @@ -8,7 +8,7 @@ * #712: Added basic logging to workflow processing * #714: Added logic to modify a workflow using the .workflow-patcher.yml * #717: Restricted workflow names in .workflow-patcher.yml to template workflow names -* #719: Added nox session `workflow:update` to install/update workflows using the .workflow-patcher.yml (if desired) +* #719: Added nox session `workflow:generate` to generate/update workflows using the `.workflow-patcher.yml` (if desired) ## Documentation diff --git a/exasol/toolbox/nox/_workflow.py b/exasol/toolbox/nox/_workflow.py index 3b3f8e832..dbb5669e4 100644 --- a/exasol/toolbox/nox/_workflow.py +++ b/exasol/toolbox/nox/_workflow.py @@ -21,7 +21,7 @@ def _create_parser() -> argparse.ArgumentParser: ) parser.add_argument( - "--name", # Changed to singular + "--name", default=ALL, choices=WORKFLOW_NAMES, help="Select one template by name or 'all' to update everything.", @@ -30,15 +30,14 @@ def _create_parser() -> argparse.ArgumentParser: return parser -@nox.session(name="workflow:update", python=False) -def update_workflow(session: Session) -> None: +@nox.session(name="workflow:generate", python=False) +def generate_workflow(session: Session) -> None: """ - Update (or install if it's not yet existing) one or all generated GitHub workflow(s) + Generate or update the specified GitHub workflow or all of them. """ parser = _create_parser() args = parser.parse_args(session.posargs) - # Ensure that the GitHub workflow directory exists PROJECT_CONFIG.github_workflow_directory.mkdir(parents=True, exist_ok=True) update_selected_workflow(workflow_name=args.name, config=PROJECT_CONFIG) diff --git a/exasol/toolbox/nox/tasks.py b/exasol/toolbox/nox/tasks.py index 825363e7c..d31823e74 100644 --- a/exasol/toolbox/nox/tasks.py +++ b/exasol/toolbox/nox/tasks.py @@ -90,7 +90,7 @@ def check(session: Session) -> None: from exasol.toolbox.nox._package_version import version_check from exasol.toolbox.nox._package import package_check -from exasol.toolbox.nox._workflow import update_workflow +from exasol.toolbox.nox._workflow import generate_workflow # isort: on # fmt: on diff --git a/test/unit/nox/_workflow_test.py b/test/unit/nox/_workflow_test.py index b3281b0e3..fda34cdf8 100644 --- a/test/unit/nox/_workflow_test.py +++ b/test/unit/nox/_workflow_test.py @@ -4,7 +4,7 @@ from pydantic import computed_field from exasol.toolbox.config import BaseConfig -from exasol.toolbox.nox._workflow import update_workflow +from exasol.toolbox.nox._workflow import generate_workflow from exasol.toolbox.util.workflows.templates import WORKFLOW_TEMPLATE_OPTIONS from exasol.toolbox.util.workflows.workflow import ALL @@ -31,7 +31,7 @@ def nox_session_runner_posargs(request): return ["--name", request.param] -class TestUpdateWorkflow: +class TestGenerateWorkflow: @staticmethod @pytest.mark.parametrize( "nox_session_runner_posargs, expected_count", @@ -48,7 +48,7 @@ def test_works_as_expected( "exasol.toolbox.nox._workflow.PROJECT_CONFIG", new=project_config_without_patcher, ): - update_workflow(nox_session) + generate_workflow(nox_session) count = sum( 1 @@ -73,6 +73,6 @@ def test_raises_exception_when_name_incorrect( ): with pytest.raises(SystemExit): - update_workflow(nox_session) + generate_workflow(nox_session) assert "invalid choice: 'not-a-valid-name'" in capsys.readouterr().err From 2705d661c95baf794848538fa9524ec406cb3465 Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Fri, 20 Feb 2026 08:54:40 +0100 Subject: [PATCH 17/22] Apply review comment for log --- exasol/toolbox/util/workflows/process_template.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/exasol/toolbox/util/workflows/process_template.py b/exasol/toolbox/util/workflows/process_template.py index d98b5e1d6..ca6898784 100644 --- a/exasol/toolbox/util/workflows/process_template.py +++ b/exasol/toolbox/util/workflows/process_template.py @@ -33,7 +33,7 @@ def render(self) -> str: workflow_dict = self.get_yaml_dict() if self.patch_yaml: - logger.debug("Modify workflow with custom yaml") + logger.debug("Customize workflow with `patch_yaml`") workflow_modifier = WorkflowModifier( workflow_dict=workflow_dict, patch_yaml=self.patch_yaml ) From 7f8db5d11f31774e64b4431b487d53c7e644ce74 Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Fri, 20 Feb 2026 08:57:01 +0100 Subject: [PATCH 18/22] Can change name as it no longer collides with nox session function --- exasol/toolbox/nox/_workflow.py | 4 ++-- exasol/toolbox/util/workflows/workflow.py | 8 ++++---- test/unit/util/workflows/workflow_test.py | 12 ++++++------ 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/exasol/toolbox/nox/_workflow.py b/exasol/toolbox/nox/_workflow.py index dbb5669e4..3d4a02bd5 100644 --- a/exasol/toolbox/nox/_workflow.py +++ b/exasol/toolbox/nox/_workflow.py @@ -8,7 +8,7 @@ from exasol.toolbox.util.workflows.workflow import ( ALL, WORKFLOW_NAMES, - update_selected_workflow, + update_workflow, ) from noxconfig import PROJECT_CONFIG @@ -40,4 +40,4 @@ def generate_workflow(session: Session) -> None: PROJECT_CONFIG.github_workflow_directory.mkdir(parents=True, exist_ok=True) - update_selected_workflow(workflow_name=args.name, config=PROJECT_CONFIG) + update_workflow(workflow_name=args.name, config=PROJECT_CONFIG) diff --git a/exasol/toolbox/util/workflows/workflow.py b/exasol/toolbox/util/workflows/workflow.py index 0d0137455..afe74fabf 100644 --- a/exasol/toolbox/util/workflows/workflow.py +++ b/exasol/toolbox/util/workflows/workflow.py @@ -66,9 +66,9 @@ def load_from_template( # Wrap all other "non-special" exceptions raise ValueError(f"Error rendering file: {file_path}") from ex - def write_to_file(self, path: Path) -> None: - logger.info("Write workflow file %s", path.name) - path.write_text(self.content + "\n") + def write_to_file(self, file_path: Path) -> None: + logger.info("Write workflow file %s", file_path.name) + file_path.write_text(self.content + "\n") def _select_workflow_template(workflow_name: WorkflowName) -> Mapping[str, Path]: @@ -80,7 +80,7 @@ def _select_workflow_template(workflow_name: WorkflowName) -> Mapping[str, Path] return {workflow_name: WORKFLOW_TEMPLATE_OPTIONS[workflow_name]} -def update_selected_workflow(workflow_name: WorkflowName, config: BaseConfig) -> None: +def update_workflow(workflow_name: WorkflowName, config: BaseConfig) -> None: """ Updates a selected workflow or all workflows. """ diff --git a/test/unit/util/workflows/workflow_test.py b/test/unit/util/workflows/workflow_test.py index c7b3af9eb..bae421691 100644 --- a/test/unit/util/workflows/workflow_test.py +++ b/test/unit/util/workflows/workflow_test.py @@ -16,7 +16,7 @@ ALL, Workflow, _select_workflow_template, - update_selected_workflow, + update_workflow, ) @@ -118,7 +118,7 @@ def test_for_individual_workflows_works_as_expected(workflow_name): assert result == {workflow_name: WORKFLOW_TEMPLATE_OPTIONS[workflow_name]} -class TestUpdateSelectedWorkflow: +class TestUpdateWorkflow: @staticmethod def test_works_as_expected_without_patcher(project_config_without_patcher): workflow_name = "merge-gate" @@ -130,7 +130,7 @@ def test_works_as_expected_without_patcher(project_config_without_patcher): / f"{workflow_name}.yml" ) - update_selected_workflow( + update_workflow( workflow_name=workflow_name, config=project_config_without_patcher ) result = expected_file_path.read_text() @@ -154,7 +154,7 @@ def test_works_as_expected_with_relevant_patcher(project_config, remove_job_yaml assert removed_job_name in remove_job_yaml assert removed_job_name in input_text - update_selected_workflow(workflow_name="checks", config=project_config) + update_workflow(workflow_name="checks", config=project_config) result = expected_file_path.read_text() # We compare only a subselection to verify that the files are roughly the @@ -176,7 +176,7 @@ def test_works_as_expected_with_not_relevant_patcher( project_config.github_workflow_directory / f"{workflow_name}.yml" ) - update_selected_workflow(workflow_name=workflow_name, config=project_config) + update_workflow(workflow_name=workflow_name, config=project_config) result = expected_file_path.read_text() # Currently, we check only a subselection as we must preserve formatting for tbx @@ -194,7 +194,7 @@ def test_raises_invalidworkflowpatcherentryerror(project_config): project_config.github_workflow_patcher_yaml.write_text(patcher_yml) with pytest.raises(InvalidWorkflowPatcherEntryError) as ex: - update_selected_workflow(workflow_name="checks", config=project_config) + update_workflow(workflow_name="checks", config=project_config) assert ( f"In file '{project_config.github_workflow_patcher_yaml}', " From 51d6ea03b8f538f1963c9a49f36659ec35aa188a Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Fri, 20 Feb 2026 09:02:05 +0100 Subject: [PATCH 19/22] Replace the last workflow:update to workflow:generate --- exasol/toolbox/nox/_workflow.py | 4 ++-- exasol/toolbox/tools/workflow.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/exasol/toolbox/nox/_workflow.py b/exasol/toolbox/nox/_workflow.py index 3d4a02bd5..badd14296 100644 --- a/exasol/toolbox/nox/_workflow.py +++ b/exasol/toolbox/nox/_workflow.py @@ -15,8 +15,8 @@ def _create_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( - prog="nox -s workflow:update", - usage="nox -s workflow:update -- [-h] --name", + prog="nox -s workflow:generate", + usage="nox -s workflow:generate -- [-h] --name", formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) diff --git a/exasol/toolbox/tools/workflow.py b/exasol/toolbox/tools/workflow.py index f07f4164b..ddab46891 100644 --- a/exasol/toolbox/tools/workflow.py +++ b/exasol/toolbox/tools/workflow.py @@ -76,7 +76,7 @@ def install_workflow( template=workflow, dest=dest, pkg=PKG, template_type=TEMPLATE_TYPE ) warnings.warn( - "\033[31m`tbx workflow install` is deprecated; this will be replaced by the Nox session `workflow:update` after 2026-04-22\033[0m", + "\033[31m`tbx workflow install` is deprecated; this will be replaced by the Nox session `workflow:generate` after 2026-04-22\033[0m", category=FutureWarning, stacklevel=2, ) @@ -101,7 +101,7 @@ def update_workflow( template_type=TEMPLATE_TYPE, ) warnings.warn( - "\033[31m`tbx workflow update` is deprecated; this will be replaced by the Nox session `workflow:update` after 2026-04-22\033[0m", + "\033[31m`tbx workflow update` is deprecated; this will be replaced by the Nox session `workflow:generate` after 2026-04-22\033[0m", category=FutureWarning, stacklevel=2, ) From 976610bd4b1f962fd77ecaf1cd3089c7c90889f9 Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Fri, 20 Feb 2026 09:16:38 +0100 Subject: [PATCH 20/22] Switch to a positional argument without a default --- exasol/toolbox/nox/_workflow.py | 15 ++++++--------- exasol/toolbox/util/workflows/workflow.py | 10 +++++----- test/unit/nox/_workflow_test.py | 2 +- test/unit/util/workflows/workflow_test.py | 8 ++++---- 4 files changed, 16 insertions(+), 19 deletions(-) diff --git a/exasol/toolbox/nox/_workflow.py b/exasol/toolbox/nox/_workflow.py index badd14296..4f2085b1b 100644 --- a/exasol/toolbox/nox/_workflow.py +++ b/exasol/toolbox/nox/_workflow.py @@ -6,8 +6,7 @@ from nox import Session from exasol.toolbox.util.workflows.workflow import ( - ALL, - WORKFLOW_NAMES, + WORKFLOW_CHOICES, update_workflow, ) from noxconfig import PROJECT_CONFIG @@ -16,16 +15,14 @@ def _create_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="nox -s workflow:generate", - usage="nox -s workflow:generate -- [-h] --name", + usage="nox -s workflow:generate -- [-h] ", formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) parser.add_argument( - "--name", - default=ALL, - choices=WORKFLOW_NAMES, - help="Select one template by name or 'all' to update everything.", - required=True, + "workflow_choice", + choices=WORKFLOW_CHOICES, + help="Select one workflow or 'all' to all workflows.", ) return parser @@ -40,4 +37,4 @@ def generate_workflow(session: Session) -> None: PROJECT_CONFIG.github_workflow_directory.mkdir(parents=True, exist_ok=True) - update_workflow(workflow_name=args.name, config=PROJECT_CONFIG) + update_workflow(workflow_choice=args.workflow_choice, config=PROJECT_CONFIG) diff --git a/exasol/toolbox/util/workflows/workflow.py b/exasol/toolbox/util/workflows/workflow.py index afe74fabf..e7c693c28 100644 --- a/exasol/toolbox/util/workflows/workflow.py +++ b/exasol/toolbox/util/workflows/workflow.py @@ -29,9 +29,9 @@ from exasol.toolbox.util.workflows.templates import WORKFLOW_TEMPLATE_OPTIONS ALL: Final[str] = "all" -WORKFLOW_NAMES: Final[list[str]] = [ALL, *WORKFLOW_TEMPLATE_OPTIONS.keys()] +WORKFLOW_CHOICES: Final[list[str]] = [ALL, *WORKFLOW_TEMPLATE_OPTIONS.keys()] -WorkflowName = Annotated[str, f"Should be a value from {WORKFLOW_NAMES}"] +WorkflowChoice = Annotated[str, f"Should be a value from {WORKFLOW_CHOICES}"] class Workflow(BaseModel): @@ -71,7 +71,7 @@ def write_to_file(self, file_path: Path) -> None: file_path.write_text(self.content + "\n") -def _select_workflow_template(workflow_name: WorkflowName) -> Mapping[str, Path]: +def _select_workflow_template(workflow_name: WorkflowChoice) -> Mapping[str, Path]: """ Returns a mapping of a workflow template or of all workflow templates. """ @@ -80,11 +80,11 @@ def _select_workflow_template(workflow_name: WorkflowName) -> Mapping[str, Path] return {workflow_name: WORKFLOW_TEMPLATE_OPTIONS[workflow_name]} -def update_workflow(workflow_name: WorkflowName, config: BaseConfig) -> None: +def update_workflow(workflow_choice: WorkflowChoice, config: BaseConfig) -> None: """ Updates a selected workflow or all workflows. """ - workflow_dict = _select_workflow_template(workflow_name) + workflow_dict = _select_workflow_template(workflow_choice) logger.info(f"Selected workflow(s) to update: {list(workflow_dict.keys())}") workflow_patcher = None diff --git a/test/unit/nox/_workflow_test.py b/test/unit/nox/_workflow_test.py index fda34cdf8..c4a048719 100644 --- a/test/unit/nox/_workflow_test.py +++ b/test/unit/nox/_workflow_test.py @@ -28,7 +28,7 @@ def github_workflow_patcher_yaml(self) -> None: @pytest.fixture def nox_session_runner_posargs(request): - return ["--name", request.param] + return [request.param] class TestGenerateWorkflow: diff --git a/test/unit/util/workflows/workflow_test.py b/test/unit/util/workflows/workflow_test.py index bae421691..3c51265df 100644 --- a/test/unit/util/workflows/workflow_test.py +++ b/test/unit/util/workflows/workflow_test.py @@ -131,7 +131,7 @@ def test_works_as_expected_without_patcher(project_config_without_patcher): ) update_workflow( - workflow_name=workflow_name, config=project_config_without_patcher + workflow_choice=workflow_name, config=project_config_without_patcher ) result = expected_file_path.read_text() @@ -154,7 +154,7 @@ def test_works_as_expected_with_relevant_patcher(project_config, remove_job_yaml assert removed_job_name in remove_job_yaml assert removed_job_name in input_text - update_workflow(workflow_name="checks", config=project_config) + update_workflow(workflow_choice="checks", config=project_config) result = expected_file_path.read_text() # We compare only a subselection to verify that the files are roughly the @@ -176,7 +176,7 @@ def test_works_as_expected_with_not_relevant_patcher( project_config.github_workflow_directory / f"{workflow_name}.yml" ) - update_workflow(workflow_name=workflow_name, config=project_config) + update_workflow(workflow_choice=workflow_name, config=project_config) result = expected_file_path.read_text() # Currently, we check only a subselection as we must preserve formatting for tbx @@ -194,7 +194,7 @@ def test_raises_invalidworkflowpatcherentryerror(project_config): project_config.github_workflow_patcher_yaml.write_text(patcher_yml) with pytest.raises(InvalidWorkflowPatcherEntryError) as ex: - update_workflow(workflow_name="checks", config=project_config) + update_workflow(workflow_choice="checks", config=project_config) assert ( f"In file '{project_config.github_workflow_patcher_yaml}', " From d527c87dd3265a1b04dc1e0e97b9d983a5ce7a54 Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Fri, 20 Feb 2026 09:41:45 +0100 Subject: [PATCH 21/22] Adapt docstring per review --- exasol/toolbox/util/workflows/workflow.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/exasol/toolbox/util/workflows/workflow.py b/exasol/toolbox/util/workflows/workflow.py index e7c693c28..d1319e4bd 100644 --- a/exasol/toolbox/util/workflows/workflow.py +++ b/exasol/toolbox/util/workflows/workflow.py @@ -73,7 +73,8 @@ def write_to_file(self, file_path: Path) -> None: def _select_workflow_template(workflow_name: WorkflowChoice) -> Mapping[str, Path]: """ - Returns a mapping of a workflow template or of all workflow templates. + Returns a mapping of workflow names to paths. Can be a single item or all workflow + templates. """ if workflow_name == ALL: return WORKFLOW_TEMPLATE_OPTIONS From b7675bb2cf985edb87d93a239c1ec3c925a998d6 Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Fri, 20 Feb 2026 10:00:01 +0100 Subject: [PATCH 22/22] Fix code smell --- exasol/toolbox/util/workflows/patch_workflow.py | 2 +- exasol/toolbox/util/workflows/workflow.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/exasol/toolbox/util/workflows/patch_workflow.py b/exasol/toolbox/util/workflows/patch_workflow.py index bcf13e5bb..ad80f9865 100644 --- a/exasol/toolbox/util/workflows/patch_workflow.py +++ b/exasol/toolbox/util/workflows/patch_workflow.py @@ -123,7 +123,7 @@ def content(self) -> CommentedMap: the class is frozen. """ with bound_contextvars(template_file_name=self.file_path.name): - logger.info(f"Load workflow patcher: {self.file_path.name}") + logger.info("Load workflow patcher: %s", self.file_path.name) loaded_yaml = self.get_yaml_dict() try: logger.debug("Validate workflow patcher with Pydantic") diff --git a/exasol/toolbox/util/workflows/workflow.py b/exasol/toolbox/util/workflows/workflow.py index d1319e4bd..69b9e9654 100644 --- a/exasol/toolbox/util/workflows/workflow.py +++ b/exasol/toolbox/util/workflows/workflow.py @@ -47,7 +47,7 @@ def load_from_template( patch_yaml: WorkflowCommentedMap | None = None, ): with bound_contextvars(template_file_name=file_path.name): - logger.info(f"Load workflow template: %s", file_path.name) + logger.info("Load workflow template: %s", file_path.name) if not file_path.exists(): raise FileNotFoundError(file_path)