Source code for exasol.toolbox.util.workflows.patch_workflow

from enum import Enum
from functools import cached_property
from typing import (
    Annotated,
    Any,
    TypeAlias,
)

from pydantic import (
    AfterValidator,
    BaseModel,
    ConfigDict,
    Field,
    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


class ActionType(str, Enum):
    INSERT_AFTER = "INSERT_AFTER"
    REPLACE = "REPLACE"


[docs] class StepContent(BaseModel): """ The :class:`StepContent` is used to lightly validate the content which would be used to REPLACE or INSERT_AFTER the specified step in the GitHub workflow. With the value ``ConfigDict(extra="allow")``, this model allows for further fields (e.g. ``dummy``) to be specified without any validation. This design choice was intentional, as GitHub already allows additional fields and may specify more fields than what has been specified in this model. As the validation here is light, it is left to GitHub to validate the content. For further information on what is allowed & expected for the fields, refer to `GitHub's documentation on jobs.<job_id>.steps <https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_idsteps>`__. """ model_config = ConfigDict(extra="allow") # This allows extra fields name: str id: str uses: str | None = None run: str | None = None with_: dict[str, Any] | None = Field(None, alias="with") env: dict[str, str] | None = None
[docs] class StepCustomization(BaseModel): """ The :class:`StepCustomization` is used to specify the desired modification. An ``action`` of ``ActionType``: * ``REPLACE`` - means that the contents of the specified ``step_id`` should be replaced with whatever ``content`` is provided. * ``INSERT_AFTER`` - means that the specified `content` should be inserted after the specified ``step_id``. """ action: ActionType job: str step_id: str content: list[StepContent]
[docs] def validate_workflow_name(workflow_name: str) -> str: """ Validates that the given ``workflow_name`` is a valid workflow name provided by the PTB. """ if workflow_name not in WORKFLOW_TEMPLATE_OPTIONS.keys(): raise ValueError( f"Invalid workflow: {workflow_name}. Must be one of {WORKFLOW_TEMPLATE_OPTIONS.keys()}" ) return workflow_name
WorkflowName = Annotated[str, AfterValidator(validate_workflow_name)]
[docs] class Workflow(BaseModel): """ The :class:`Workflow` is used to specify which workflow should be modified. This is determined by the workflow ``name``. A workflow can be modified by specifying: * ``remove_jobs`` - job names in this list will be removed from the workflow. * ``step_customization`` - items in this list indicate which job's step should be modified. """ name: WorkflowName remove_jobs: list[str] = Field(default_factory=list) step_customizations: list[StepCustomization] = Field(default_factory=list)
[docs] class WorkflowPatcherConfig(BaseModel): """ The :class:`WorkflowPatcherConfig` is used to validate the expected format for the ``.workflow-patcher.yml``, which is used to modify the workflow templates provided by the PTB. """ workflows: list[Workflow]
WorkflowCommentedMap: TypeAlias = Annotated[ CommentedMap, f"This CommentedMap is structured according to `{Workflow.__name__}`" ] class WorkflowPatcher(YamlRenderer): """ The :class:`WorkflowPatcher` enables users to define a YAML file to customize PTB-provided workflows by removing or modifying jobs in the file. A job can be modified by replacing or inserting steps. The provided YAML file must meet the conditions of :class:`WorkflowPatcherConfig`. """ @cached_property def content(self) -> CommentedMap: """ The loaded YAML content. It loads on first access and stays cached even though the class is frozen. """ 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: """ Extract from the `content` where `name` matches the `workflow_name`. If the workflow is not found, then `None` is returned. It is an expected and common use case that the `WorkflowPatcher` would only modify a few workflows and not all of them. """ inner_content = self.content["workflows"] for workflow in inner_content: if workflow["name"] == workflow_name: return workflow return None