177 lines
5.4 KiB
Python
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",
|
|
]
|