Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/changes/unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* #719: Added nox session `workflow:update` to install/update workflows using the .workflow-patcher.yml (if desired)
* #719: Added nox session `workflow:update` to install/update workflows using the `.workflow-patcher.yml` (if desired)


## Documentation

Expand Down
5 changes: 5 additions & 0 deletions exasol/toolbox/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
44 changes: 44 additions & 0 deletions exasol/toolbox/nox/_workflow.py
Original file line number Diff line number Diff line change
@@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think as a source comment this isn't too valuable as

  • developers of the PTB are only interested in the latest state (= singular) and would look for older implementation states rather via git diff etc.
  • users will not look at the source code

Could we move it to the changelog / release notes?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also propose renaming the command from workflow:update to workflows:generate.

Rationale

  • In general and to my experience most frequent use case it's about plural (default all workflows)
  • To me, "generate" implies updating in case the target already exists. And I also assume we have a default to not overwrite existing files if option --confirm is not specified.

Additionally:
Could we maybe have the workflow names as simple posargs rather than arguing about whether option "--name" should be singular or plural?

If we want to force users to explicitly agree to generate / update / or overwrite all workflows we could have a cli option --all that avoids mentioning the affected workflows explicitly.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Today, I'm in the mood for radical proposals: Why would anyone want to update only specific workflows at all?

If we ask this question, then we could simplify the Nox session even further.

And if at all, then a user could as well specify the files rather than "names of workflows". And if the PTB doesn't know how to update a specified file (because there is no related template), then the PTB could simply display an error message.

Aside of that, I think, when updating "all" workflows, the PTB should also report if there is a specific workflow file unknown, ignored, and not updated. This could be important information for a user to identify potentially obsolete files.

default=ALL,
choices=WORKFLOW_NAMES,
help="Select one template by name or 'all' to update everything.",
required=True,
)
return parser


@nox.session(name="workflow:update", python=False)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
@nox.session(name="workflow:update", python=False)
@nox.session(name="workflows:generate" python=False)

def update_workflow(session: Session) -> None:
"""
Update (or install if it's not yet existing) one or all generated GitHub workflow(s)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# 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)
1 change: 1 addition & 0 deletions exasol/toolbox/nox/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 2 additions & 2 deletions exasol/toolbox/tools/workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand All @@ -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,
)
Expand Down
41 changes: 19 additions & 22 deletions exasol/toolbox/util/workflows/exceptions.py
Original file line number Diff line number Diff line change
@@ -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)


Expand Down Expand Up @@ -73,37 +76,31 @@ 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):
"""
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)
17 changes: 11 additions & 6 deletions exasol/toolbox/util/workflows/patch_workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(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:
"""
Expand Down
2 changes: 1 addition & 1 deletion exasol/toolbox/util/workflows/process_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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
)
Expand Down
85 changes: 79 additions & 6 deletions exasol/toolbox/util/workflows/workflow.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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_NAMES: Final[list[str]] = [ALL, *WORKFLOW_TEMPLATE_OPTIONS.keys()]

WorkflowName = Annotated[str, f"Should be a value from {WORKFLOW_NAMES}"]


class Workflow(BaseModel):
Expand All @@ -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(f"Load workflow template: {file_path.name}")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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)
Expand All @@ -31,12 +56,60 @@ 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:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

YamlError is the parent class, so maybe this one is sufficient then?

Suggested change
except (YamlError, YamlKeyError) as ex:
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

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")
Comment on lines +69 to +71
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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(f"Writing workflow file %s", path.name)
path.write_text(self.content + "\n")



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]}
Comment on lines +74 to +80
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the more general use case is plural, in rare cases n can be 1.
Could we use a simple dict as return data type?

I also doubt whether indexing the workflow paths by name (uniqueness comes from the OS file system and is based on the assumption that all files are in the same directory.) makes sense.

  • Maybe it would be better representing workflows simply as a list of paths?
  • I don't know for what we need the name of each workflow?
  • instead of storing the name separately (as key) maybe a getter would be more helpful / flexible? The getter could be as simple as lambda path: path.name.
Suggested change
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]}
def _select_workflow_templates(workflow_name: WorkflowName) -> 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_selected_workflow(workflow_name: WorkflowName, config: BaseConfig) -> None:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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.
"""
workflow_dict = _select_workflow_template(workflow_name)
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
3 changes: 2 additions & 1 deletion test/unit/config_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand Down
Loading