diff --git a/doc/changes/unreleased.md b/doc/changes/unreleased.md index 1a6e744a5..22d3fc7c7 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:generate` to generate/update workflows using the `.workflow-patcher.yml` (if desired) ## Documentation diff --git a/exasol/toolbox/config.py b/exasol/toolbox/config.py index c66fa5d92..e0fdd3bfb 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 # type: ignore[misc] + @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/exasol/toolbox/nox/_workflow.py b/exasol/toolbox/nox/_workflow.py new file mode 100644 index 000000000..4f2085b1b --- /dev/null +++ b/exasol/toolbox/nox/_workflow.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import argparse + +import nox +from nox import Session + +from exasol.toolbox.util.workflows.workflow import ( + WORKFLOW_CHOICES, + update_workflow, +) +from noxconfig import PROJECT_CONFIG + + +def _create_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="nox -s workflow:generate", + usage="nox -s workflow:generate -- [-h] ", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + + parser.add_argument( + "workflow_choice", + choices=WORKFLOW_CHOICES, + help="Select one workflow or 'all' to all workflows.", + ) + return parser + + +@nox.session(name="workflow:generate", python=False) +def generate_workflow(session: Session) -> None: + """ + Generate or update the specified GitHub workflow or all of them. + """ + parser = _create_parser() + args = parser.parse_args(session.posargs) + + PROJECT_CONFIG.github_workflow_directory.mkdir(parents=True, exist_ok=True) + + update_workflow(workflow_choice=args.workflow_choice, config=PROJECT_CONFIG) diff --git a/exasol/toolbox/nox/tasks.py b/exasol/toolbox/nox/tasks.py index f3932da8a..d31823e74 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 generate_workflow # isort: on # fmt: on diff --git a/exasol/toolbox/tools/workflow.py b/exasol/toolbox/tools/workflow.py index b255462c4..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 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: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 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:generate` after 2026-04-22\033[0m", category=FutureWarning, stacklevel=2, ) 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/patch_workflow.py b/exasol/toolbox/util/workflows/patch_workflow.py index 330eb2059..ad80f9865 100644 --- a/exasol/toolbox/util/workflows/patch_workflow.py +++ b/exasol/toolbox/util/workflows/patch_workflow.py @@ -14,7 +14,9 @@ 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 from exasol.toolbox.util.workflows.render_yaml import YamlRenderer from exasol.toolbox.util.workflows.templates import WORKFLOW_TEMPLATE_OPTIONS @@ -120,12 +122,15 @@ def content(self) -> CommentedMap: The loaded YAML content. It loads on first access and stays cached even though the class is frozen. """ - loaded_yaml = self.get_yaml_dict() - 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("Load workflow patcher: %s", 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..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 custom yaml") + logger.debug("Customize workflow with `patch_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 70dd1d457..69b9e9654 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, @@ -9,9 +14,24 @@ 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.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 + +ALL: Final[str] = "all" +WORKFLOW_CHOICES: Final[list[str]] = [ALL, *WORKFLOW_TEMPLATE_OPTIONS.keys()] + +WorkflowChoice = Annotated[str, f"Should be a value from {WORKFLOW_CHOICES}"] class Workflow(BaseModel): @@ -20,9 +40,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("Load workflow template: %s", file_path.name) if not file_path.exists(): raise FileNotFoundError(file_path) @@ -31,12 +56,61 @@ 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) - except YamlError as ex: + except (YamlError, YamlKeyError) as ex: raise ex except Exception as ex: # 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("Write workflow file %s", file_path.name) + file_path.write_text(self.content + "\n") + + +def _select_workflow_template(workflow_name: WorkflowChoice) -> Mapping[str, Path]: + """ + 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 + return {workflow_name: WORKFLOW_TEMPLATE_OPTIONS[workflow_name]} + + +def update_workflow(workflow_choice: WorkflowChoice, config: BaseConfig) -> None: + """ + Updates a selected workflow or all workflows. + """ + workflow_dict = _select_workflow_template(workflow_choice) + logger.info(f"Selected workflow(s) to update: {list(workflow_dict.keys())}") + + workflow_patcher = None + if config.github_workflow_patcher_yaml: + 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 # type: ignore + ) from ex 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", diff --git a/test/unit/nox/_workflow_test.py b/test/unit/nox/_workflow_test.py new file mode 100644 index 000000000..c4a048719 --- /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 exasol.toolbox.config import BaseConfig +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 + + +@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 [request.param] + + +class TestGenerateWorkflow: + @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, + ): + generate_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): + generate_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 217ba009b..c1e763f23 100644 --- a/test/unit/util/workflows/conftest.py +++ b/test/unit/util/workflows/conftest.py @@ -3,9 +3,10 @@ from pathlib import Path import pytest +from pydantic import computed_field +from exasol.toolbox.config import BaseConfig from exasol.toolbox.util.workflows.patch_workflow import WorkflowPatcher -from noxconfig import PROJECT_CONFIG @dataclass(frozen=True) @@ -38,31 +39,60 @@ 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 + + +@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_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/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 c12f7ab7e..3c51265df 100644 --- a/test/unit/util/workflows/workflow_test.py +++ b/test/unit/util/workflows/workflow_test.py @@ -1,41 +1,83 @@ +from inspect import cleandoc from unittest.mock import patch import pytest from exasol.toolbox.util.workflows.exceptions import ( + InvalidWorkflowPatcherEntryError, TemplateRenderingError, + YamlJobValueError, YamlOutputError, YamlParsingError, ) 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 noxconfig import PROJECT_CONFIG +from exasol.toolbox.util.workflows.workflow import ( + ALL, + Workflow, + _select_workflow_template, + update_workflow, +) class TestWorkflow: + @staticmethod + def test_works_as_expected(tmp_path, project_config): + 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, 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) + + 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") @@ -45,11 +87,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") @@ -59,5 +101,103 @@ 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, ) + + +class TestSelectWorkflowTemplate: + @staticmethod + def test_for_all_works_as_expected(): + 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_workflow_template(workflow_name) + assert result == {workflow_name: WORKFLOW_TEMPLATE_OPTIONS[workflow_name]} + + +class TestUpdateWorkflow: + @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_workflow( + workflow_choice=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_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 + # 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_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 + # 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_workflow(workflow_choice="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)