From 6d3e031fdf6db53ff91344e621872ac3fff447ce Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Tue, 17 Feb 2026 13:14:43 +0100 Subject: [PATCH 01/14] Add structlog as a dependency --- poetry.lock | 17 ++++++++++++++++- pyproject.toml | 1 + 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/poetry.lock b/poetry.lock index 7b341aca5..472ad7147 100644 --- a/poetry.lock +++ b/poetry.lock @@ -3637,6 +3637,21 @@ files = [ {file = "stevedore-5.6.0.tar.gz", hash = "sha256:f22d15c6ead40c5bbfa9ca54aa7e7b4a07d59b36ae03ed12ced1a54cf0b51945"}, ] +[[package]] +name = "structlog" +version = "25.5.0" +description = "Structured Logging for Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f"}, + {file = "structlog-25.5.0.tar.gz", hash = "sha256:098522a3bebed9153d4570c6d0288abf80a031dfdb2048d59a49e9dc2190fc98"}, +] + +[package.dependencies] +typing-extensions = {version = "*", markers = "python_version < \"3.11\""} + [[package]] name = "tabulate" version = "0.9.0" @@ -3938,4 +3953,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.10,<4.0" -content-hash = "d1f9d88ca70834f069d89f24b82e2109852867a1a58396250c9c4ad89119f9af" +content-hash = "172f284e885bc6277b300c747a6c04fbbd77522590e0a2b1deded4291f3c3529" diff --git a/pyproject.toml b/pyproject.toml index 617ff6acd..3dc863472 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,6 +58,7 @@ dependencies = [ "typer[all]>=0.7.0", "twine>=6.1.0,<7", "sphinxcontrib-mermaid (>=2.0.0,<3.0.0)", + "structlog (>=25.5.0,<26.0.0)", ] [project.scripts] From a30ac23ebe953e86217e934ffb82885342b715f7 Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Tue, 17 Feb 2026 13:39:50 +0100 Subject: [PATCH 02/14] Add basic logging --- exasol/toolbox/__init__.py | 3 +++ exasol/toolbox/util/workflows/__init__.py | 3 +++ exasol/toolbox/util/workflows/render_yaml.py | 8 ++++++++ exasol/toolbox/util/workflows/workflow.py | 10 ++++++++++ 4 files changed, 24 insertions(+) diff --git a/exasol/toolbox/__init__.py b/exasol/toolbox/__init__.py index e69de29bb..ff82875b6 100644 --- a/exasol/toolbox/__init__.py +++ b/exasol/toolbox/__init__.py @@ -0,0 +1,3 @@ +import structlog + +structlog.configure() diff --git a/exasol/toolbox/util/workflows/__init__.py b/exasol/toolbox/util/workflows/__init__.py index e69de29bb..6eb60c176 100644 --- a/exasol/toolbox/util/workflows/__init__.py +++ b/exasol/toolbox/util/workflows/__init__.py @@ -0,0 +1,3 @@ +import structlog + +logger = structlog.get_logger(__name__).bind(subsystem="workflows") diff --git a/exasol/toolbox/util/workflows/render_yaml.py b/exasol/toolbox/util/workflows/render_yaml.py index 800acd5e6..a3b5b7fb1 100644 --- a/exasol/toolbox/util/workflows/render_yaml.py +++ b/exasol/toolbox/util/workflows/render_yaml.py @@ -15,6 +15,7 @@ ) from ruamel.yaml.error import YAMLError +from exasol.toolbox.util.workflows import logger from exasol.toolbox.util.workflows.exceptions import ( TemplateRenderingError, YamlOutputError, @@ -60,6 +61,11 @@ def _render_with_jinja(self, input_str: str) -> str: """ Render the template with Jinja. """ + logger.debug( + "Render template with Jinja", + jinja_dict_source="PROJECT_CONFIG.github_template_dict", + jinja_dict_values=self.github_template_dict, + ) jinja_template = jinja_env.from_string(input_str) return jinja_template.render(self.github_template_dict) @@ -72,6 +78,7 @@ def get_yaml_dict(self) -> CommentedMap: try: workflow_string = self._render_with_jinja(raw_content) yaml = self._get_standard_yaml() + logger.debug("Parse template with ruamel-yaml") return yaml.load(workflow_string) except TemplateError as ex: raise TemplateRenderingError(file_path=self.file_path) from ex @@ -84,6 +91,7 @@ def get_as_string(self, yaml_dict: CommentedMap) -> str: """ yaml = self._get_standard_yaml() try: + logger.debug("Output workflow as string") with io.StringIO() as stream: yaml.dump(yaml_dict, stream) workflow_string = stream.getvalue() diff --git a/exasol/toolbox/util/workflows/workflow.py b/exasol/toolbox/util/workflows/workflow.py index ff44dae62..cbdf70e95 100644 --- a/exasol/toolbox/util/workflows/workflow.py +++ b/exasol/toolbox/util/workflows/workflow.py @@ -5,7 +5,12 @@ BaseModel, ConfigDict, ) +from structlog.contextvars import ( + bind_contextvars, + clear_contextvars, +) +from exasol.toolbox.util.workflows import logger from exasol.toolbox.util.workflows.exceptions import YamlError from exasol.toolbox.util.workflows.process_template import WorkflowRenderer @@ -17,6 +22,9 @@ class Workflow(BaseModel): @classmethod def load_from_template(cls, file_path: Path, github_template_dict: dict[str, Any]): + bind_contextvars(template_file_name=file_path.name) + logger.info("Load workflow from template") + if not file_path.exists(): raise FileNotFoundError(file_path) @@ -32,3 +40,5 @@ 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 + finally: + clear_contextvars() From fab9b12c79c0bd20352bede91cc91737d82a6621 Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Tue, 17 Feb 2026 13:59:42 +0100 Subject: [PATCH 03/14] Add documentation about logs and custom exceptions --- doc/api.rst | 17 ++++++++++++ doc/changes/unreleased.md | 1 + doc/conf.py | 2 +- doc/index.rst | 7 +++++ .../features/github_workflows/index.rst | 1 + .../github_workflows/troubleshooting.rst | 26 +++++++++++++++++++ doc/user_guide/troubleshooting.rst | 1 + exasol/toolbox/__init__.py | 9 ++++++- exasol/toolbox/util/workflows/exceptions.py | 2 +- 9 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 doc/api.rst create mode 100644 doc/user_guide/features/github_workflows/troubleshooting.rst diff --git a/doc/api.rst b/doc/api.rst new file mode 100644 index 000000000..9950235c8 --- /dev/null +++ b/doc/api.rst @@ -0,0 +1,17 @@ +.. _api: + +:octicon:`cpu` API Reference +============================= + +.. _workflow_exceptions: + +Workflow Exceptions +------------------- +These custom exceptions are associated with generating +the workflows provided by the PTB. They are located in the +``exasol.toolbox.util.workflows.exceptions`` module. + +.. currentmodule:: exasol.toolbox.util.workflows.exceptions + +.. automodule:: exasol.toolbox.util.workflows.exceptions + :members: diff --git a/doc/changes/unreleased.md b/doc/changes/unreleased.md index 77106267c..65339cd8a 100644 --- a/doc/changes/unreleased.md +++ b/doc/changes/unreleased.md @@ -5,6 +5,7 @@ ## Feature * #691: Started customization of PTB workflows by defining the YML schema +* #712: Added basic logging to workflow processing ## Refactoring diff --git a/doc/conf.py b/doc/conf.py index fc1342280..a411dc4e7 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -42,7 +42,7 @@ "sphinx_copybutton", "exasol.toolbox.sphinx.multiversion", ] - +add_module_names = False intersphinx_mapping = {"python": ("https://docs.python.org/3", None)} # Make sure the target is unique diff --git a/doc/index.rst b/doc/index.rst index e84152e40..bc00b76a2 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -31,6 +31,12 @@ Documentation of the Exasol-Toolbox Custom GitHub Actions providing functionality that is commonly needed in our projects. + .. grid-item-card:: :octicon:`cpu` API Reference + :link: api + :link-type: ref + + Comprehensive technical documentation for API endpoints and methods + .. grid-item-card:: :octicon:`repo` Design Document :link: design_document :link-type: ref @@ -46,4 +52,5 @@ Documentation of the Exasol-Toolbox developer_guide/developer_guide tools github_actions/github_actions + api changes/changelog diff --git a/doc/user_guide/features/github_workflows/index.rst b/doc/user_guide/features/github_workflows/index.rst index b0f05b0ad..52dedaf39 100644 --- a/doc/user_guide/features/github_workflows/index.rst +++ b/doc/user_guide/features/github_workflows/index.rst @@ -8,6 +8,7 @@ Enabling GitHub Workflows :hidden: configuration + troubleshooting The PTB ships with configurable GitHub workflow templates covering the most common CI/CD setup variants for Python projects. The templates are defined in: diff --git a/doc/user_guide/features/github_workflows/troubleshooting.rst b/doc/user_guide/features/github_workflows/troubleshooting.rst new file mode 100644 index 000000000..56d04032e --- /dev/null +++ b/doc/user_guide/features/github_workflows/troubleshooting.rst @@ -0,0 +1,26 @@ +.. _github_workflows_troubleshooting: + +Troubleshooting +=============== + +This troubleshooting guide is helpful if you run into issues installing or updating +the GitHub workflows provided by the PTB. + +Enabling Debug Logging +---------------------- + +To get more detailed output, set the ``LOG_LEVEL`` environment variable to ``DEBUG`` before executing a CLI command. +By default, the ``LOG_LEVEL`` is set to ``INFO``. + +.. code-block:: bash + + export LOG_LEVEL=DEBUG + +Checking Custom Exceptions +---------------------------- + +Certain pain points are associated with custom exceptions. These give a brief statement +on what could be wrong and in which file. For further information, check the traceback. + +For the list of the custom exceptions for installing or updating the GitHub workflows, +see the :ref:`workflow_exceptions`. diff --git a/doc/user_guide/troubleshooting.rst b/doc/user_guide/troubleshooting.rst index ba1232fca..df1a2be95 100644 --- a/doc/user_guide/troubleshooting.rst +++ b/doc/user_guide/troubleshooting.rst @@ -7,3 +7,4 @@ Troubleshooting :maxdepth: 1 features/metrics/ignore_findings + Updating GitHub Workflows diff --git a/exasol/toolbox/__init__.py b/exasol/toolbox/__init__.py index ff82875b6..15b87bce1 100644 --- a/exasol/toolbox/__init__.py +++ b/exasol/toolbox/__init__.py @@ -1,3 +1,10 @@ +import logging +import os + import structlog -structlog.configure() +log_level = os.getenv("LOG_LEVEL", "INFO").upper() + +structlog.configure( + wrapper_class=structlog.make_filtering_bound_logger(getattr(logging, log_level)) +) diff --git a/exasol/toolbox/util/workflows/exceptions.py b/exasol/toolbox/util/workflows/exceptions.py index 662126998..82155657a 100644 --- a/exasol/toolbox/util/workflows/exceptions.py +++ b/exasol/toolbox/util/workflows/exceptions.py @@ -26,7 +26,7 @@ class YamlOutputError(YamlError): class YamlParsingError(YamlError): """ Raised when the rendered template is not a valid YAML file, as it cannot be - parsed by ruamel-yaml. + parsed by ruamel-yaml. """ message_template = ( From a4e9eaabc23d5ff4f2d3d1ffaa8eb57d32344c42 Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Wed, 18 Feb 2026 08:42:16 +0100 Subject: [PATCH 04/14] Switch to context manager so closes for all --- exasol/toolbox/util/workflows/workflow.py | 41 +++++++++++------------ 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/exasol/toolbox/util/workflows/workflow.py b/exasol/toolbox/util/workflows/workflow.py index cbdf70e95..db4b6b0ad 100644 --- a/exasol/toolbox/util/workflows/workflow.py +++ b/exasol/toolbox/util/workflows/workflow.py @@ -6,8 +6,7 @@ ConfigDict, ) from structlog.contextvars import ( - bind_contextvars, - clear_contextvars, + bound_contextvars, ) from exasol.toolbox.util.workflows import logger @@ -22,23 +21,21 @@ class Workflow(BaseModel): @classmethod def load_from_template(cls, file_path: Path, github_template_dict: dict[str, Any]): - bind_contextvars(template_file_name=file_path.name) - logger.info("Load workflow from template") - - if not file_path.exists(): - raise FileNotFoundError(file_path) - - try: - workflow_renderer = WorkflowRenderer( - github_template_dict=github_template_dict, - file_path=file_path, - ) - workflow = workflow_renderer.render() - return cls(content=workflow) - except YamlError as ex: - raise ex - except Exception as ex: - # Wrap all other "non-special" exceptions - raise ValueError(f"Error rendering file: {file_path}") from ex - finally: - clear_contextvars() + with bound_contextvars(template_file_name=file_path.name): + logger.info("Load workflow from template") + + if not file_path.exists(): + raise FileNotFoundError(file_path) + + try: + workflow_renderer = WorkflowRenderer( + github_template_dict=github_template_dict, + file_path=file_path, + ) + workflow = workflow_renderer.render() + return cls(content=workflow) + except YamlError as ex: + raise ex + except Exception as ex: + # Wrap all other "non-special" exceptions + raise ValueError(f"Error rendering file: {file_path}") from ex From a356f56685bce51557585c9ce75aed8679606126 Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Wed, 18 Feb 2026 08:42:46 +0100 Subject: [PATCH 05/14] Add callsite information, which means default processors need to be added --- exasol/toolbox/__init__.py | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/exasol/toolbox/__init__.py b/exasol/toolbox/__init__.py index 15b87bce1..efbda7a1f 100644 --- a/exasol/toolbox/__init__.py +++ b/exasol/toolbox/__init__.py @@ -2,9 +2,32 @@ import os import structlog +from structlog.dev import ConsoleRenderer +from structlog.processors import ( + CallsiteParameter, + CallsiteParameterAdder, + TimeStamper, + add_log_level, + format_exc_info, +) log_level = os.getenv("LOG_LEVEL", "INFO").upper() structlog.configure( - wrapper_class=structlog.make_filtering_bound_logger(getattr(logging, log_level)) + wrapper_class=structlog.make_filtering_bound_logger(getattr(logging, log_level)), + processors=[ + # 1. Enrich the data first + add_log_level, + TimeStamper(fmt="iso"), + CallsiteParameterAdder( + { + CallsiteParameter.MODULE, + CallsiteParameter.FUNC_NAME, + } + ), + # 2. Handle exceptions + format_exc_info, + # 3. Rendering option + ConsoleRenderer(), + ], ) From 18dc655896e8ea4be029634def9f9fc11a950bf4 Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Wed, 18 Feb 2026 09:04:59 +0100 Subject: [PATCH 06/14] Capture structlog separately so output as before --- test/integration/tools/workflow_integration_test.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/test/integration/tools/workflow_integration_test.py b/test/integration/tools/workflow_integration_test.py index a49fb7e23..cf85d0120 100644 --- a/test/integration/tools/workflow_integration_test.py +++ b/test/integration/tools/workflow_integration_test.py @@ -1,6 +1,7 @@ from unittest.mock import patch import pytest +from structlog.testing import capture_logs from exasol.toolbox.tools.workflow import CLI @@ -67,10 +68,11 @@ def test_show_workflow(cli_runner): ], ) def test_diff_workflow(cli_runner, tmp_path, workflow): - # set up with file in tmp_path so checks files are the same - cli_runner.invoke(CLI, ["install", workflow, str(tmp_path)]) + with capture_logs(): + # set up with file in tmp_path so checks files are the same + cli_runner.invoke(CLI, ["install", workflow, str(tmp_path)]) - result = cli_runner.invoke(CLI, ["diff", workflow, str(tmp_path)]) + result = cli_runner.invoke(CLI, ["diff", workflow, str(tmp_path)]) assert result.exit_code == 0 # as the files are the same, we expect no difference @@ -118,7 +120,8 @@ def test_install_twice_no_error(cli_runner, tmp_path): class TestUpdateWorkflow: @staticmethod def test_when_file_does_not_previously_exist(cli_runner, tmp_path): - result = cli_runner.invoke(CLI, ["update", "checks", str(tmp_path)]) + with capture_logs(): + result = cli_runner.invoke(CLI, ["update", "checks", str(tmp_path)]) assert result.exit_code == 0 assert result.output.strip() == f"Updated checks in \n{tmp_path}/checks.yml" From 2e46789fc0be5cc39f8daa0f5fb8170a48b38e21 Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Wed, 18 Feb 2026 09:07:56 +0100 Subject: [PATCH 07/14] Alphabetize & relock --- poetry.lock | 2 +- pyproject.toml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index 472ad7147..634301c0c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -3953,4 +3953,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.10,<4.0" -content-hash = "172f284e885bc6277b300c747a6c04fbbd77522590e0a2b1deded4291f3c3529" +content-hash = "231df9e065279a52f02698bee3a9eab2706b8be77ec1b72d8ef61ff4a9e6af75" diff --git a/pyproject.toml b/pyproject.toml index 3dc863472..d01a5f05b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,10 +55,10 @@ dependencies = [ "sphinx-inline-tabs>=2023.4.21,<2024", "sphinx-design>=0.5.0,<1", "sphinx-toolbox>=4.0.0,<5", - "typer[all]>=0.7.0", - "twine>=6.1.0,<7", "sphinxcontrib-mermaid (>=2.0.0,<3.0.0)", "structlog (>=25.5.0,<26.0.0)", + "typer[all]>=0.7.0", + "twine>=6.1.0,<7", ] [project.scripts] From 7b50a3ecdfea34689b5c1506e3ef8a94897360eb Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Wed, 18 Feb 2026 09:41:49 +0100 Subject: [PATCH 08/14] Refactor so clearer what is jinja vs ruamel-yaml transformations --- test/unit/util/workflows/render_yaml_test.py | 134 ++++++++++--------- 1 file changed, 68 insertions(+), 66 deletions(-) diff --git a/test/unit/util/workflows/render_yaml_test.py b/test/unit/util/workflows/render_yaml_test.py index 9630b33eb..5d1dd9fb2 100644 --- a/test/unit/util/workflows/render_yaml_test.py +++ b/test/unit/util/workflows/render_yaml_test.py @@ -153,29 +153,6 @@ def test_keeps_quotes_for_variables_as_is(test_yml, yaml_renderer): yaml_dict = yaml_renderer.get_yaml_dict() assert yaml_renderer.get_as_string(yaml_dict) == cleandoc(expected_yaml) - @staticmethod - def test_updates_jinja_variables(test_yml, yaml_renderer): - input_yaml = """ - - 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 = """ - - name: Setup Python & Poetry Environment - uses: exasol/python-toolbox/.github/actions/python-environment@v5 - with: - python-version: "3.10" - poetry-version: "2.3.0" - """ - - content = cleandoc(input_yaml) - test_yml.write_text(content) - - yaml_dict = yaml_renderer.get_yaml_dict() - assert yaml_renderer.get_as_string(yaml_dict) == cleandoc(expected_yaml) - @staticmethod def test_preserves_list_format(test_yml, yaml_renderer): input_yaml = """ @@ -200,43 +177,6 @@ def test_preserves_list_format(test_yml, yaml_renderer): yaml_dict = yaml_renderer.get_yaml_dict() assert yaml_renderer.get_as_string(yaml_dict) == cleandoc(input_yaml) - @staticmethod - def test_jinja_variable_unknown(test_yml, yaml_renderer): - input_yaml = """ - - name: Setup Python & Poetry Environment - uses: exasol/python-toolbox/.github/actions/python-environment@v5 - with: - poetry-version: "(( bad_jinja ))" - """ - - content = cleandoc(input_yaml) - test_yml.write_text(content) - - with pytest.raises( - TemplateRenderingError, match="Check for Jinja-related errors." - ) as exc: - yaml_renderer.get_yaml_dict() - assert isinstance(exc.value.__cause__, UndefinedError) - assert "'bad_jinja' is undefined" in str(exc.value.__cause__) - - @staticmethod - def test_jinja_variable_unclosed(test_yml, yaml_renderer): - input_yaml = """ - - name: Setup Python & Poetry Environment - uses: exasol/python-toolbox/.github/actions/python-environment@v5 - with: - python-version: "(( minimum_python_version )" - """ - content = cleandoc(input_yaml) - test_yml.write_text(content) - - with pytest.raises( - TemplateRenderingError, match="Check for Jinja-related errors." - ) as exc: - yaml_renderer.get_yaml_dict() - assert isinstance(exc.value.__cause__, TemplateSyntaxError) - assert "unexpected ')'" in str(exc.value.__cause__) - @staticmethod def test_parsing_fails_when_yaml_malformed(test_yml, yaml_renderer): bad_template = """ @@ -260,11 +200,11 @@ def test_parsing_fails_when_yaml_malformed(test_yml, yaml_renderer): with pytest.raises( YamlParsingError, match="Check for invalid YAML syntax." - ) as excinfo: + ) as ex: yaml_renderer.get_yaml_dict() - assert isinstance(excinfo.value.__cause__, ParserError) - assert "while parsing a block collection" in str(excinfo.value.__cause__) + assert isinstance(ex.value.__cause__, ParserError) + assert "while parsing a block collection" in str(ex.value.__cause__) @staticmethod def test_yaml_cannot_output_to_string(test_yml, yaml_renderer): @@ -281,8 +221,70 @@ def test_yaml_cannot_output_to_string(test_yml, yaml_renderer): yaml_dict = yaml_renderer.get_yaml_dict() yaml_dict["steps"][0]["name"] = lambda x: x + 1 - with pytest.raises(YamlOutputError, match="could not be output") as excinfo: + with pytest.raises(YamlOutputError, match="could not be output") as ex: yaml_renderer.get_as_string(yaml_dict) - assert isinstance(excinfo.value.__cause__, RepresenterError) - assert "cannot represent an object" in str(excinfo.value.__cause__) + assert isinstance(ex.value.__cause__, RepresenterError) + assert "cannot represent an object" in str(ex.value.__cause__) + + +class TestYamlRendererJinja: + @staticmethod + def test_updates_jinja_variables(test_yml, yaml_renderer): + input_yaml = """ + - 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 = """ + - name: Setup Python & Poetry Environment + uses: exasol/python-toolbox/.github/actions/python-environment@v5 + with: + python-version: "3.10" + poetry-version: "2.3.0" + """ + + content = cleandoc(input_yaml) + test_yml.write_text(content) + + yaml_dict = yaml_renderer.get_yaml_dict() + assert yaml_renderer.get_as_string(yaml_dict) == cleandoc(expected_yaml) + + @staticmethod + def test_jinja_variable_unknown(test_yml, yaml_renderer): + input_yaml = """ + - name: Setup Python & Poetry Environment + uses: exasol/python-toolbox/.github/actions/python-environment@v5 + with: + poetry-version: "(( bad_jinja ))" + """ + + content = cleandoc(input_yaml) + test_yml.write_text(content) + + with pytest.raises( + TemplateRenderingError, match="Check for Jinja-related errors." + ) as exc: + yaml_renderer.get_yaml_dict() + assert isinstance(exc.value.__cause__, UndefinedError) + assert "'bad_jinja' is undefined" in str(exc.value.__cause__) + + @staticmethod + def test_jinja_variable_unclosed(test_yml, yaml_renderer): + input_yaml = """ + - name: Setup Python & Poetry Environment + uses: exasol/python-toolbox/.github/actions/python-environment@v5 + with: + python-version: "(( minimum_python_version )" + """ + content = cleandoc(input_yaml) + test_yml.write_text(content) + + with pytest.raises( + TemplateRenderingError, match="Check for Jinja-related errors." + ) as ex: + yaml_renderer.get_yaml_dict() + assert isinstance(ex.value.__cause__, TemplateSyntaxError) + assert "unexpected ')'" in str(ex.value.__cause__) From afbd6f0adbb1da7f02c96367044b62ac8cbffae2 Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Wed, 18 Feb 2026 09:44:07 +0100 Subject: [PATCH 09/14] Refactor to ex --- test/unit/util/workflows/patch_workflow_test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/unit/util/workflows/patch_workflow_test.py b/test/unit/util/workflows/patch_workflow_test.py index 5ac0405a4..a3618b792 100644 --- a/test/unit/util/workflows/patch_workflow_test.py +++ b/test/unit/util/workflows/patch_workflow_test.py @@ -96,8 +96,8 @@ def test_raises_error_for_unknown_action( with pytest.raises( InvalidWorkflowPatcherYamlError, match="is malformed; it failed Pydantic validation", - ) as exc: + ) as ex: workflow_patcher.content - underlying_error = exc.value.__cause__ + underlying_error = ex.value.__cause__ assert isinstance(underlying_error, ValidationError) From 775bd76c5eaaea81934fcb5e9496d6eb2a7c67e5 Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Wed, 18 Feb 2026 12:10:42 +0100 Subject: [PATCH 10/14] Add replace & insert after usage --- doc/changes/unreleased.md | 1 + exasol/toolbox/util/workflows/exceptions.py | 43 ++++ .../util/workflows/process_template.py | 92 ++++++++ exasol/toolbox/util/workflows/workflow.py | 1 + .../util/workflows/process_template_test.py | 211 ++++++++++++++++++ 5 files changed, 348 insertions(+) create mode 100644 test/unit/util/workflows/process_template_test.py diff --git a/doc/changes/unreleased.md b/doc/changes/unreleased.md index 65339cd8a..9995cf8ad 100644 --- a/doc/changes/unreleased.md +++ b/doc/changes/unreleased.md @@ -6,6 +6,7 @@ * #691: Started customization of PTB workflows by defining the YML schema * #712: Added basic logging to workflow processing +* #714: Added logic to modify a workflow using the .workflow-patcher.yml ## Refactoring diff --git a/exasol/toolbox/util/workflows/exceptions.py b/exasol/toolbox/util/workflows/exceptions.py index 82155657a..db6e20d6c 100644 --- a/exasol/toolbox/util/workflows/exceptions.py +++ b/exasol/toolbox/util/workflows/exceptions.py @@ -52,3 +52,46 @@ class InvalidWorkflowPatcherYamlError(YamlError): """ message_template = "File '{file_path}' is malformed; it failed Pydantic validation." + + +class YamlKeyError(Exception): + """ + Base exception for when a specified value cannot be found in a YAML. + """ + + 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) + + +class YamlJobValueError(Exception): + """ + 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): + """ + Raised when a step_id associated with a specific job cannot be found in a YAML file. + """ + + 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/process_template.py b/exasol/toolbox/util/workflows/process_template.py index 40cc2ef24..57182c5f4 100644 --- a/exasol/toolbox/util/workflows/process_template.py +++ b/exasol/toolbox/util/workflows/process_template.py @@ -1,6 +1,20 @@ +import copy +from dataclasses import dataclass + +from ruamel.yaml import CommentedMap + +from exasol.toolbox.util.workflows.exceptions import ( + YamlJobValueError, + YamlStepIdValueError, +) +from exasol.toolbox.util.workflows.patch_workflow import ( + ActionType, + WorkflowCommentedMap, +) from exasol.toolbox.util.workflows.render_yaml import YamlRenderer +@dataclass(frozen=True) class WorkflowRenderer(YamlRenderer): """ The :class:`WorkflowRenderer` renders a workflow template provided by the PTB into @@ -9,9 +23,87 @@ class WorkflowRenderer(YamlRenderer): - standardizing formatting via ruamel.yaml for a consistent output. """ + patch_yaml: WorkflowCommentedMap | None + def render(self) -> str: """ Render the template to the contents of a valid GitHub workflow. """ workflow_dict = self.get_yaml_dict() + + if self.patch_yaml: + workflow_modifier = WorkflowModifier( + workflow_dict=workflow_dict, patch_yaml=self.patch_yaml + ) + workflow_dict = workflow_modifier.get_patched_workflow() + return self.get_as_string(workflow_dict) + + +@dataclass +class WorkflowModifier: + workflow_dict: CommentedMap + patch_yaml: WorkflowCommentedMap + + def __post_init__(self): + # Perform deepcopy to ensure this instance owns its data + self.workflow_dict = copy.deepcopy(self.workflow_dict) + + @property + def jobs_dict(self) -> CommentedMap: + return self.workflow_dict["jobs"] + + def _get_step_list(self, job_name: str) -> CommentedMap: + self._verify_job_exists(job_name=job_name) + return self.jobs_dict[job_name]["steps"] + + def _customize_steps(self, step_customizations: CommentedMap) -> None: + """ + Customize the steps of jobs specified in `step_customizations` in a workflow + (`workflow_dict`). If a `step_id` or its parent `job` cannot be found, an + exception is raised. + """ + for patch in step_customizations: + job_name = patch["job"] + idx = self._get_step_index(job_name=job_name, step_id=patch["step_id"]) + + step_list = self._get_step_list(job_name=job_name) + if patch["action"] == ActionType.REPLACE.value: + step_list[idx : idx + 1] = patch["content"] + elif patch["action"] == ActionType.INSERT_AFTER.value: + step_list[idx + 1 : idx + 1] = patch["content"] + + def _get_step_index(self, job_name: str, step_id: str) -> int: + steps = self._get_step_list(job_name=job_name) + for index, step in enumerate(steps): + if step["id"] == step_id: + return index + raise YamlStepIdValueError(step_id=step_id, job_name=job_name) + + def _remove_jobs(self, remove_jobs: CommentedMap) -> None: + """ + Remove the jobs specified in `remove_jobs` in a workflow yaml (`workflow_dict`). + If a `job` cannot be found, an exception is raised. + """ + for job_name in remove_jobs: + self._verify_job_exists(job_name) + self.jobs_dict.pop(job_name) + + def _verify_job_exists(self, job_name: str) -> None: + if job_name not in self.jobs_dict: + raise YamlJobValueError(job_name=job_name) + + def get_patched_workflow(self): + """ + Patch the `workflow_dict`. As dictionaries are mutable structures, we directly + take advantage of this by having it modified in this class's internal methods + without explicit returns. + """ + + if remove_jobs := self.patch_yaml.get("remove_jobs", {}): + self._remove_jobs(remove_jobs=remove_jobs) + + if step_customizations := self.patch_yaml.get("step_customizations", {}): + self._customize_steps(step_customizations=step_customizations) + + return self.workflow_dict diff --git a/exasol/toolbox/util/workflows/workflow.py b/exasol/toolbox/util/workflows/workflow.py index db4b6b0ad..70dd1d457 100644 --- a/exasol/toolbox/util/workflows/workflow.py +++ b/exasol/toolbox/util/workflows/workflow.py @@ -31,6 +31,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, ) workflow = workflow_renderer.render() return cls(content=workflow) diff --git a/test/unit/util/workflows/process_template_test.py b/test/unit/util/workflows/process_template_test.py new file mode 100644 index 000000000..76f851add --- /dev/null +++ b/test/unit/util/workflows/process_template_test.py @@ -0,0 +1,211 @@ +import pytest +from ruamel.yaml import CommentedMap + +from exasol.toolbox.util.workflows.exceptions import ( + YamlJobValueError, + YamlStepIdValueError, +) +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 + +on: + workflow_call: + +jobs: + build-documentation-and-check-links: + name: Docs + runs-on: "ubuntu-24.04" + permissions: + contents: read + steps: + - name: Check out Repository + id: check-out-repository + uses: actions/checkout@v6 + + run-unit-tests: + name: Run Unit Tests (Python-${{ matrix.python-versions }}) + runs-on: "ubuntu-24.04" + permissions: + contents: read + strategy: + fail-fast: false + matrix: + python-versions: ["3.10", "3.11", "3.12", "3.13", "3.14"] + + steps: + - name: Check out Repository + id: check-out-repository + uses: actions/checkout@v6 + +""" + + +@pytest.fixture +def workflow_name(): + return "checks.yml" + + +@pytest.fixture +def checks_yaml(tmp_path, workflow_name): + file_path = tmp_path / workflow_name + file_path.write_text(WORKFLOW_YAML) + return file_path + + +@pytest.fixture +def workflow_dict(checks_yaml) -> CommentedMap: + return YamlRenderer( + github_template_dict=PROJECT_CONFIG.github_template_dict, file_path=checks_yaml + ).get_yaml_dict() + + +class TestWorkflowModifier: + @staticmethod + def test__remove_jobs_works( + workflow_name, workflow_dict, workflow_patcher, remove_job_yaml + ): + workflow_modifier = WorkflowModifier( + workflow_dict=workflow_dict, + patch_yaml=workflow_patcher.extract_by_workflow(workflow_name), + ) + + result = workflow_modifier.get_patched_workflow() + + # The original was not altered as it was deepcopied before modifications. + assert list(workflow_dict["jobs"].keys()) == [ + "build-documentation-and-check-links", + "run-unit-tests", + ] + # The original and resulting workflows should have the same values here. + assert result["name"] == workflow_dict["name"] + assert result["on"] == workflow_dict["on"] + assert ( + result["jobs"]["run-unit-tests"] == workflow_dict["jobs"]["run-unit-tests"] + ) + # The resulting workflow has job "build-documentation-and-check-links" removed. + assert list(result["jobs"].keys()) == ["run-unit-tests"] + + @staticmethod + @pytest.mark.parametrize( + "step_customization_yaml", + [ActionType.REPLACE.value], + indirect=True, + ) + def test__customize_steps_replacement_works( + workflow_name, workflow_dict, workflow_patcher, step_customization_yaml + ): + workflow_modifier = WorkflowModifier( + workflow_dict=workflow_dict, + patch_yaml=workflow_patcher.extract_by_workflow(workflow_name), + ) + + result = workflow_modifier.get_patched_workflow() + + # The original and resulting workflows should have the same values here. + assert result["name"] == workflow_dict["name"] + assert result["on"] == workflow_dict["on"] + assert list(result["jobs"].keys()) == list(workflow_dict["jobs"].keys()) + assert ( + result["jobs"]["build-documentation-and-check-links"] + == workflow_dict["jobs"]["build-documentation-and-check-links"] + ) + # The replaced step operation was done in job `run-unit-tests` for step + # `check-out-repository`. The replacement was mostly identical to the original + # value, but it has a `with`. + assert result["jobs"]["run-unit-tests"]["steps"][0].pop("with") == CommentedMap( + {"fetch-depth": 0} + ) + # Without the `with`, they should be the same, as that's how the test is set up. + assert ( + result["jobs"]["run-unit-tests"] == workflow_dict["jobs"]["run-unit-tests"] + ) + + @staticmethod + @pytest.mark.parametrize( + "step_customization_yaml", + [ActionType.INSERT_AFTER.value], + indirect=True, + ) + def test__customize_steps_insertion_after_works( + workflow_name, workflow_dict, workflow_patcher, step_customization_yaml + ): + workflow_modifier = WorkflowModifier( + workflow_dict=workflow_dict, + patch_yaml=workflow_patcher.extract_by_workflow(workflow_name), + ) + + result = workflow_modifier.get_patched_workflow() + + # The original and internal workflows should have the same values here. + assert result["name"] == workflow_dict["name"] + assert result["on"] == workflow_dict["on"] + assert ( + result["jobs"]["build-documentation-and-check-links"] + == workflow_dict["jobs"]["build-documentation-and-check-links"] + ) + # The insert after job added a step at the end of `run-unit-tests`. + assert ( + len(result["jobs"]["run-unit-tests"]["steps"]) + == len(workflow_dict["jobs"]["run-unit-tests"]["steps"]) + 1 + == 2 + ) + assert ( + result["jobs"]["run-unit-tests"]["steps"][0] + == workflow_dict["jobs"]["run-unit-tests"]["steps"][0] + ) + # The inserted after was done in job `run-unit-tests`, after step + # `check-out-repository`. It has a `with` but is otherwise identical to + # the preceding step. + assert result["jobs"]["run-unit-tests"]["steps"][1].pop("with") == CommentedMap( + {"fetch-depth": 0} + ) + assert ( + result["jobs"]["run-unit-tests"]["steps"][1] + == result["jobs"]["run-unit-tests"]["steps"][0] + ) + + +class TestWorkflowModifierExceptions: + @staticmethod + def test_job_does_not_exist_raises_exception( + workflow_name, workflow_dict, workflow_patcher, remove_job_yaml + ): + # Remove job that would be otherwise removed by the WorkflowModifier + job_name = "build-documentation-and-check-links" + workflow_dict["jobs"].pop(job_name) + + workflow_modifier = WorkflowModifier( + workflow_dict=workflow_dict, + patch_yaml=workflow_patcher.extract_by_workflow(workflow_name), + ) + + with pytest.raises(YamlJobValueError, match=job_name): + workflow_modifier.get_patched_workflow() + + @staticmethod + @pytest.mark.parametrize( + "step_customization_yaml", + [ActionType.REPLACE.value], + indirect=True, + ) + def test_step_id_does_not_exist_raises_exception( + workflow_name, workflow_dict, workflow_patcher, step_customization_yaml + ): + # Remove step_id that would be otherwise replaced by the WorkflowModifier + job_name = "run-unit-tests" + step_id = "check-out-repository" + step = workflow_dict["jobs"][job_name]["steps"].pop(0) + assert step["id"] == step_id + + workflow_modifier = WorkflowModifier( + workflow_dict=workflow_dict, + patch_yaml=workflow_patcher.extract_by_workflow(workflow_name), + ) + + with pytest.raises(YamlStepIdValueError, match=step_id): + workflow_modifier.get_patched_workflow() From 43f7197a00ea623677ed57a27ba079bea792d417 Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Wed, 18 Feb 2026 12:11:20 +0100 Subject: [PATCH 11/14] Add overall exception for WorkflowPatcher --- exasol/toolbox/util/workflows/exceptions.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/exasol/toolbox/util/workflows/exceptions.py b/exasol/toolbox/util/workflows/exceptions.py index db6e20d6c..5a884c9b9 100644 --- a/exasol/toolbox/util/workflows/exceptions.py +++ b/exasol/toolbox/util/workflows/exceptions.py @@ -54,6 +54,18 @@ class InvalidWorkflowPatcherYamlError(YamlError): message_template = "File '{file_path}' is malformed; it failed Pydantic validation." +class InvalidWorkflowPatcherEntryError(YamlError): + """ + Raised when the :class:`WorkflowPatcher` is used but one of the specified keys it + listed does not exist in the relevant workflow template file. + """ + + message_template = ( + "In file '{file_path}', an entry '{entry}' does not exist in " + "the workflow template. Please fix the entry." + ) + + class YamlKeyError(Exception): """ Base exception for when a specified value cannot be found in a YAML. From 8c2af78ce4369fd4136f558dd483c2be2602c935 Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Wed, 18 Feb 2026 13:53:41 +0100 Subject: [PATCH 12/14] Add debug logging --- exasol/toolbox/util/workflows/process_template.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/exasol/toolbox/util/workflows/process_template.py b/exasol/toolbox/util/workflows/process_template.py index 57182c5f4..1051bb93c 100644 --- a/exasol/toolbox/util/workflows/process_template.py +++ b/exasol/toolbox/util/workflows/process_template.py @@ -3,6 +3,7 @@ from ruamel.yaml import CommentedMap +from exasol.toolbox.util.workflows import logger from exasol.toolbox.util.workflows.exceptions import ( YamlJobValueError, YamlStepIdValueError, @@ -32,6 +33,7 @@ def render(self) -> str: workflow_dict = self.get_yaml_dict() if self.patch_yaml: + logger.debug("Modify workflow custom yaml") workflow_modifier = WorkflowModifier( workflow_dict=workflow_dict, patch_yaml=self.patch_yaml ) @@ -65,12 +67,20 @@ def _customize_steps(self, step_customizations: CommentedMap) -> None: """ for patch in step_customizations: job_name = patch["job"] - idx = self._get_step_index(job_name=job_name, step_id=patch["step_id"]) + patch_id = patch["step_id"] + idx = self._get_step_index(job_name=job_name, step_id=patch_id) step_list = self._get_step_list(job_name=job_name) if patch["action"] == ActionType.REPLACE.value: + logger.debug( + f"Replace YAML at step_id '{patch_id}' in job '{job_name}'" + ) step_list[idx : idx + 1] = patch["content"] + elif patch["action"] == ActionType.INSERT_AFTER.value: + logger.debug( + f"Insert YAML after step_id '{patch_id}' in job '{job_name}'" + ) step_list[idx + 1 : idx + 1] = patch["content"] def _get_step_index(self, job_name: str, step_id: str) -> int: @@ -87,6 +97,7 @@ def _remove_jobs(self, remove_jobs: CommentedMap) -> None: """ for job_name in remove_jobs: self._verify_job_exists(job_name) + logger.debug(f"Remove job '{job_name}'") self.jobs_dict.pop(job_name) def _verify_job_exists(self, job_name: str) -> None: From 46cac3a4782508f449599d2486a453dea05370ac Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Thu, 19 Feb 2026 09:43:59 +0100 Subject: [PATCH 13/14] Update to ex --- test/unit/util/workflows/render_yaml_test.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/unit/util/workflows/render_yaml_test.py b/test/unit/util/workflows/render_yaml_test.py index 5d1dd9fb2..91367d576 100644 --- a/test/unit/util/workflows/render_yaml_test.py +++ b/test/unit/util/workflows/render_yaml_test.py @@ -266,10 +266,10 @@ def test_jinja_variable_unknown(test_yml, yaml_renderer): with pytest.raises( TemplateRenderingError, match="Check for Jinja-related errors." - ) as exc: + ) as ex: yaml_renderer.get_yaml_dict() - assert isinstance(exc.value.__cause__, UndefinedError) - assert "'bad_jinja' is undefined" in str(exc.value.__cause__) + assert isinstance(ex.value.__cause__, UndefinedError) + assert "'bad_jinja' is undefined" in str(ex.value.__cause__) @staticmethod def test_jinja_variable_unclosed(test_yml, yaml_renderer): From 738bcad8ab7c520969c50d5306513a9b3409e9e8 Mon Sep 17 00:00:00 2001 From: Ariel Schulz Date: Thu, 19 Feb 2026 09:52:41 +0100 Subject: [PATCH 14/14] Remove file accidentally re-added when merge happened --- doc/user_guide/troubleshooting.rst | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 doc/user_guide/troubleshooting.rst diff --git a/doc/user_guide/troubleshooting.rst b/doc/user_guide/troubleshooting.rst deleted file mode 100644 index e69de29bb..000000000