gateway/modules/workflows/methods/_actionSignatureValidator.py
2026-04-25 01:13:01 +02:00

177 lines
5.4 KiB
Python

# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Action signature validator for the Typed Action Architecture (Phase 2).
Verifies that every WorkflowActionDefinition exposed by a Method:
1. Declares a parameter `type` that is either a primitive or a known
PORT_TYPE_CATALOG schema name.
2. Declares an `outputType` that exists in PORT_TYPE_CATALOG.
3. Declares container types (`List[X]`, `Dict[K,V]`) whose element types
are also primitives or catalog schemas.
Used at startup (and in CI tests) to prevent silent drift between
backend method signatures and the type catalog.
Plan: wiki/c-work/1-plan/2026-04-typed-action-architecture.md
"""
from __future__ import annotations
import logging
from typing import Dict, Iterable, List, Optional
from modules.datamodels.datamodelWorkflowActions import (
WorkflowActionDefinition,
WorkflowActionParameter,
)
from modules.features.graphicalEditor.portTypes import (
PORT_TYPE_CATALOG,
PRIMITIVE_TYPES,
_stripContainer,
)
logger = logging.getLogger(__name__)
# Catalog types that are explicitly allowed as fire-and-forget outputs
# (no typed payload expected by downstream nodes).
_ALLOWED_GENERIC_OUTPUTS = frozenset({"ActionResult", "Transit"})
def _isKnownType(typeName: str) -> bool:
"""Primitive or catalog-resolvable type name."""
return typeName in PRIMITIVE_TYPES or typeName in PORT_TYPE_CATALOG
def _validateTypeRef(typeStr: str) -> List[str]:
"""
Validate a single type reference string (the value of `type` on a
WorkflowActionParameter or `outputType` on a WorkflowActionDefinition).
Returns a list of human-readable error fragments (empty if OK).
"""
if not typeStr or not isinstance(typeStr, str):
return ["empty/non-string type"]
# Backwards-compatible aliases (lowercase Python builtins)
if typeStr in {"list", "dict"}:
return [
f"'{typeStr}' is too generic — use 'List[X]' / 'Dict[K,V]' or a "
f"catalog schema name"
]
parts = _stripContainer(typeStr)
if not parts:
return [f"could not parse type '{typeStr}'"]
errors: List[str] = []
for part in parts:
if not _isKnownType(part):
errors.append(
f"unknown type '{part}' (not a primitive and not in catalog)"
)
return errors
def _validateActionParameter(
actionId: str,
paramName: str,
param: WorkflowActionParameter,
) -> List[str]:
"""Validate a single parameter; returns prefixed error messages."""
out: List[str] = []
for err in _validateTypeRef(param.type):
out.append(f"{actionId}.{paramName}: {err}")
return out
def _validateActionDefinition(
actionDef: WorkflowActionDefinition,
) -> List[str]:
"""Validate parameters + outputType of one action."""
errors: List[str] = []
actionId = actionDef.actionId or "<no-actionId>"
for paramName, param in (actionDef.parameters or {}).items():
errors.extend(_validateActionParameter(actionId, paramName, param))
outputType = actionDef.outputType
if outputType not in _ALLOWED_GENERIC_OUTPUTS:
for err in _validateTypeRef(outputType):
errors.append(f"{actionId}.<outputType>: {err}")
return errors
def _validateActionsDict(
methodName: str,
actions: Dict[str, WorkflowActionDefinition],
) -> List[str]:
"""Validate every action in a Method's _actions dict."""
errors: List[str] = []
if not actions:
return errors
for localName, actionDef in actions.items():
if not isinstance(actionDef, WorkflowActionDefinition):
errors.append(
f"{methodName}.{localName}: not a WorkflowActionDefinition instance"
)
continue
errors.extend(_validateActionDefinition(actionDef))
return errors
# ---------------------------------------------------------------------------
# Public entry points
# ---------------------------------------------------------------------------
def _validateMethods(methodInstances: Iterable) -> List[str]:
"""
Validate a sequence of Method instances.
Each instance is expected to expose `_actions` (Dict[str, WorkflowActionDefinition]).
"""
errors: List[str] = []
for method in methodInstances:
methodName = getattr(method, "name", method.__class__.__name__)
actions = getattr(method, "_actions", None) or {}
errors.extend(_validateActionsDict(methodName, actions))
return errors
def _formatValidationReport(errors: List[str]) -> str:
"""Build a multi-line human-readable error report."""
if not errors:
return "Action signatures are healthy."
lines = [f"Found {len(errors)} action-signature drift(s):"]
lines.extend(f" - {e}" for e in errors)
return "\n".join(lines)
def _logValidationReport(errors: List[str], strict: bool = False) -> None:
"""
Log validation results.
If `strict=True`, raises RuntimeError on any error (use in tests / CI).
Otherwise emits warnings (use at startup so the app keeps running but
operators see the drift in the log).
"""
report = _formatValidationReport(errors)
if errors:
if strict:
raise RuntimeError(report)
logger.warning(report)
else:
logger.info(report)
__all__ = [
"_validateMethods",
"_validateActionsDict",
"_validateActionDefinition",
"_validateActionParameter",
"_validateTypeRef",
"_formatValidationReport",
"_logValidationReport",
]