feat: consolidate legacy workflows into gyxx-flow
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
"""Validated workflow and schedule catalog."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import time
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any, Literal
|
||||
|
||||
_WORKFLOW_ID = re.compile(r"^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$")
|
||||
_MODULES = {
|
||||
"content_marketing",
|
||||
"product_commerce",
|
||||
"shop_intelligence",
|
||||
"supply_chain",
|
||||
}
|
||||
_TRIGGERS = {"scheduled", "manual", "unavailable"}
|
||||
_KINDS = {"daily", "weekly", "monthly", "interval_days"}
|
||||
_WEEKDAYS = {"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"}
|
||||
|
||||
|
||||
class CatalogError(ValueError):
|
||||
"""Raised for an invalid workflow catalog."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WorkflowEntry:
|
||||
workflow_id: str
|
||||
module: str
|
||||
trigger: Literal["scheduled", "manual", "unavailable"]
|
||||
entry: str
|
||||
args: tuple[str, ...] = ()
|
||||
source_project: str | None = None
|
||||
source_task_name: str | None = None
|
||||
note: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ScheduleEntry:
|
||||
workflow_id: str
|
||||
kind: Literal["daily", "weekly", "monthly", "interval_days"]
|
||||
at: str
|
||||
days: tuple[str, ...] = ()
|
||||
day_of_month: int | None = None
|
||||
every_days: int | None = None
|
||||
anchor_date: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WorkflowCatalog:
|
||||
timezone: str
|
||||
workflows: tuple[WorkflowEntry, ...]
|
||||
schedules: tuple[ScheduleEntry, ...]
|
||||
|
||||
@classmethod
|
||||
def load(cls, config_dir: Path | str) -> "WorkflowCatalog":
|
||||
root = Path(config_dir)
|
||||
workflow_payload = _load_json(root / "workflows.json")
|
||||
schedule_payload = _load_json(root / "schedules.json")
|
||||
_require_schema(workflow_payload, "workflows.json", expected=2)
|
||||
_require_schema(schedule_payload, "schedules.json", expected=1)
|
||||
workflows = tuple(_parse_workflow(item) for item in workflow_payload.get("workflows", []))
|
||||
schedules = tuple(_parse_schedule(item) for item in schedule_payload.get("schedules", []))
|
||||
timezone = schedule_payload.get("timezone")
|
||||
if not isinstance(timezone, str) or not timezone:
|
||||
raise CatalogError("schedules.json requires timezone")
|
||||
catalog = cls(timezone=timezone, workflows=workflows, schedules=schedules)
|
||||
catalog._validate_relations()
|
||||
return catalog
|
||||
|
||||
def _validate_relations(self) -> None:
|
||||
workflow_ids = [item.workflow_id for item in self.workflows]
|
||||
duplicate_ids = _duplicates(workflow_ids)
|
||||
if duplicate_ids:
|
||||
raise CatalogError(f"duplicate workflow id: {sorted(duplicate_ids)[0]}")
|
||||
|
||||
task_names = [item.source_task_name for item in self.scheduled_workflows()]
|
||||
duplicates = _duplicates([name for name in task_names if name])
|
||||
if duplicates:
|
||||
raise CatalogError(f"duplicate legacy task name: {sorted(duplicates)[0]}")
|
||||
|
||||
known = set(workflow_ids)
|
||||
schedule_ids = [item.workflow_id for item in self.schedules]
|
||||
duplicate_schedules = _duplicates(schedule_ids)
|
||||
if duplicate_schedules:
|
||||
raise CatalogError(f"duplicate schedule: {sorted(duplicate_schedules)[0]}")
|
||||
unknown = set(schedule_ids) - known
|
||||
if unknown:
|
||||
raise CatalogError(f"schedule references unknown workflow: {sorted(unknown)[0]}")
|
||||
scheduled_ids = {item.workflow_id for item in self.scheduled_workflows()}
|
||||
if set(schedule_ids) != scheduled_ids:
|
||||
missing = scheduled_ids - set(schedule_ids)
|
||||
extra = set(schedule_ids) - scheduled_ids
|
||||
raise CatalogError(f"schedule relation mismatch; missing={sorted(missing)}, extra={sorted(extra)}")
|
||||
|
||||
def scheduled_workflows(self) -> tuple[WorkflowEntry, ...]:
|
||||
return tuple(item for item in self.workflows if item.trigger == "scheduled")
|
||||
|
||||
def manual_workflows(self) -> tuple[WorkflowEntry, ...]:
|
||||
return tuple(item for item in self.workflows if item.trigger == "manual")
|
||||
|
||||
def unavailable_workflows(self) -> tuple[WorkflowEntry, ...]:
|
||||
return tuple(item for item in self.workflows if item.trigger == "unavailable")
|
||||
|
||||
def schedule_for(self, workflow_id: str) -> ScheduleEntry:
|
||||
for schedule in self.schedules:
|
||||
if schedule.workflow_id == workflow_id:
|
||||
return schedule
|
||||
raise KeyError(workflow_id)
|
||||
|
||||
|
||||
def _load_json(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise CatalogError(f"cannot load {path.name}: {exc}") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise CatalogError(f"{path.name} must contain an object")
|
||||
return payload
|
||||
|
||||
|
||||
def _require_schema(payload: dict[str, Any], filename: str, *, expected: int) -> None:
|
||||
if payload.get("schema_version") != expected:
|
||||
raise CatalogError(f"unsupported schema_version in {filename}")
|
||||
|
||||
|
||||
def _parse_workflow(item: Any) -> WorkflowEntry:
|
||||
if not isinstance(item, dict):
|
||||
raise CatalogError("workflow entry must be an object")
|
||||
workflow_id = item.get("id")
|
||||
module = item.get("module")
|
||||
trigger = item.get("trigger")
|
||||
execution = item.get("execution")
|
||||
provenance = item.get("provenance", {})
|
||||
if not isinstance(workflow_id, str) or not _WORKFLOW_ID.fullmatch(workflow_id):
|
||||
raise CatalogError(f"invalid workflow id: {workflow_id!r}")
|
||||
if module not in _MODULES:
|
||||
raise CatalogError(f"invalid module for {workflow_id}: {module!r}")
|
||||
if trigger not in _TRIGGERS:
|
||||
raise CatalogError(f"invalid trigger for {workflow_id}: {trigger!r}")
|
||||
if not isinstance(execution, dict):
|
||||
raise CatalogError(f"missing execution definition for {workflow_id}")
|
||||
if not isinstance(provenance, dict):
|
||||
raise CatalogError(f"invalid provenance definition for {workflow_id}")
|
||||
entry = execution.get("entry")
|
||||
if not _is_relative_entry(entry):
|
||||
raise CatalogError(f"execution entry must be relative for {workflow_id}")
|
||||
project = provenance.get("source_project")
|
||||
if project is not None and (not isinstance(project, str) or not project):
|
||||
raise CatalogError(f"invalid source project for {workflow_id}")
|
||||
task_name = provenance.get("task_name")
|
||||
if trigger == "scheduled" and (not isinstance(task_name, str) or not task_name):
|
||||
raise CatalogError(f"scheduled workflow requires source task name: {workflow_id}")
|
||||
args = execution.get("args", [])
|
||||
if not isinstance(args, list) or not all(isinstance(value, str) for value in args):
|
||||
raise CatalogError(f"execution args must be strings for {workflow_id}")
|
||||
return WorkflowEntry(
|
||||
workflow_id=workflow_id,
|
||||
module=module,
|
||||
trigger=trigger,
|
||||
entry=entry,
|
||||
args=tuple(args),
|
||||
source_project=project,
|
||||
source_task_name=task_name,
|
||||
note=item.get("note"),
|
||||
)
|
||||
|
||||
|
||||
def _is_relative_entry(entry: Any) -> bool:
|
||||
if not isinstance(entry, str) or not entry or "\\" in entry or ":" in entry:
|
||||
return False
|
||||
path = PurePosixPath(entry)
|
||||
return not path.is_absolute() and ".." not in path.parts
|
||||
|
||||
|
||||
def _parse_schedule(item: Any) -> ScheduleEntry:
|
||||
if not isinstance(item, dict):
|
||||
raise CatalogError("schedule entry must be an object")
|
||||
workflow_id = item.get("workflow_id")
|
||||
kind = item.get("kind")
|
||||
at = item.get("at")
|
||||
if not isinstance(workflow_id, str) or not _WORKFLOW_ID.fullmatch(workflow_id):
|
||||
raise CatalogError(f"invalid schedule workflow id: {workflow_id!r}")
|
||||
if kind not in _KINDS:
|
||||
raise CatalogError(f"invalid schedule kind for {workflow_id}: {kind!r}")
|
||||
try:
|
||||
time.fromisoformat(at)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise CatalogError(f"invalid schedule time for {workflow_id}: {at!r}") from exc
|
||||
days = tuple(item.get("days", []))
|
||||
if kind == "weekly" and (not days or not set(days) <= _WEEKDAYS):
|
||||
raise CatalogError(f"invalid weekly days for {workflow_id}")
|
||||
day_of_month = item.get("day_of_month")
|
||||
if kind == "monthly" and (not isinstance(day_of_month, int) or not 1 <= day_of_month <= 31):
|
||||
raise CatalogError(f"invalid day_of_month for {workflow_id}")
|
||||
every_days = item.get("every_days")
|
||||
if kind == "interval_days" and (not isinstance(every_days, int) or every_days < 1):
|
||||
raise CatalogError(f"invalid every_days for {workflow_id}")
|
||||
return ScheduleEntry(
|
||||
workflow_id=workflow_id,
|
||||
kind=kind,
|
||||
at=at,
|
||||
days=days,
|
||||
day_of_month=day_of_month,
|
||||
every_days=every_days,
|
||||
anchor_date=item.get("anchor_date"),
|
||||
)
|
||||
|
||||
|
||||
def _duplicates(values: list[str]) -> set[str]:
|
||||
seen: set[str] = set()
|
||||
duplicates: set[str] = set()
|
||||
for value in values:
|
||||
if value in seen:
|
||||
duplicates.add(value)
|
||||
seen.add(value)
|
||||
return duplicates
|
||||
Reference in New Issue
Block a user