feat(validation): add validation pipeline and results model
ISSUES CLOSED: #175
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
"""ASV benchmarks for the validation pipeline.
|
||||
|
||||
Measures the performance of:
|
||||
- Pipeline creation and command sorting
|
||||
- Pipeline execution with passing validations
|
||||
- Summary building and aggregation
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
_SRC = str(Path(__file__).resolve().parents[1] / "src")
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
from cleveragents.application.services.validation_pipeline import (
|
||||
ValidationCommand,
|
||||
ValidationPipeline,
|
||||
ValidationResult,
|
||||
ValidationSummary,
|
||||
)
|
||||
from cleveragents.domain.models.core.tool import ValidationMode
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _mock_executor(validation_name: str, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Trivial executor that always passes."""
|
||||
return {"passed": True, "message": f"{validation_name} ok"}
|
||||
|
||||
|
||||
def _build_commands(count: int) -> list[ValidationCommand]:
|
||||
"""Build a list of N validation commands across multiple resources."""
|
||||
commands: list[ValidationCommand] = []
|
||||
for i in range(count):
|
||||
resource_name = f"resource-{i % 5}"
|
||||
mode = ValidationMode.REQUIRED if i % 3 else ValidationMode.INFORMATIONAL
|
||||
commands.append(
|
||||
ValidationCommand(
|
||||
validation_name=f"check-{i:04d}",
|
||||
resource_id=f"R{i:05d}",
|
||||
resource_name=resource_name,
|
||||
mode=mode,
|
||||
arguments={"index": i},
|
||||
timeout_seconds=30.0,
|
||||
)
|
||||
)
|
||||
return commands
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Benchmark suites
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ValidationPipelineCreationSuite:
|
||||
"""Benchmarks for pipeline instantiation and command sorting."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def setup(self) -> None:
|
||||
self.commands_10 = _build_commands(10)
|
||||
self.commands_100 = _build_commands(100)
|
||||
|
||||
def time_create_pipeline_10_commands(self) -> None:
|
||||
"""Benchmark creating a pipeline with 10 commands."""
|
||||
ValidationPipeline(
|
||||
commands=self.commands_10,
|
||||
executor=_mock_executor,
|
||||
max_workers=4,
|
||||
)
|
||||
|
||||
def time_create_pipeline_100_commands(self) -> None:
|
||||
"""Benchmark creating a pipeline with 100 commands."""
|
||||
ValidationPipeline(
|
||||
commands=self.commands_100,
|
||||
executor=_mock_executor,
|
||||
max_workers=4,
|
||||
)
|
||||
|
||||
def time_sort_100_commands(self) -> None:
|
||||
"""Benchmark sorting 100 commands."""
|
||||
ValidationPipeline._sort_commands(self.commands_100)
|
||||
|
||||
|
||||
class ValidationPipelineExecutionSuite:
|
||||
"""Benchmarks for pipeline execution with passing validations."""
|
||||
|
||||
timeout = 120
|
||||
|
||||
def setup(self) -> None:
|
||||
self.commands_10 = _build_commands(10)
|
||||
self.commands_50 = _build_commands(50)
|
||||
|
||||
def time_run_10_validations(self) -> None:
|
||||
"""Benchmark running 10 validations."""
|
||||
pipeline = ValidationPipeline(
|
||||
commands=self.commands_10,
|
||||
executor=_mock_executor,
|
||||
max_workers=4,
|
||||
)
|
||||
pipeline.run()
|
||||
|
||||
def time_run_50_validations_sequential(self) -> None:
|
||||
"""Benchmark running 50 validations with max_workers=1."""
|
||||
pipeline = ValidationPipeline(
|
||||
commands=self.commands_50,
|
||||
executor=_mock_executor,
|
||||
max_workers=1,
|
||||
)
|
||||
pipeline.run()
|
||||
|
||||
def time_run_10_validations_for_plan(self) -> None:
|
||||
"""Benchmark run_for_plan with 10 validations."""
|
||||
pipeline = ValidationPipeline(
|
||||
commands=self.commands_10,
|
||||
executor=_mock_executor,
|
||||
max_workers=4,
|
||||
)
|
||||
metadata: dict[str, Any] = {}
|
||||
pipeline.run_for_plan(plan_metadata=metadata)
|
||||
|
||||
|
||||
class ValidationPipelineSummarySuite:
|
||||
"""Benchmarks for summary construction and grouping."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def setup(self) -> None:
|
||||
self.results: list[ValidationResult] = []
|
||||
for i in range(50):
|
||||
self.results.append(
|
||||
ValidationResult(
|
||||
validation_name=f"check-{i:04d}",
|
||||
resource_id=f"R{i:05d}",
|
||||
resource_name=f"resource-{i % 5}",
|
||||
mode=(
|
||||
ValidationMode.REQUIRED
|
||||
if i % 3
|
||||
else ValidationMode.INFORMATIONAL
|
||||
),
|
||||
passed=i % 2 == 0,
|
||||
message=f"Result for check-{i:04d}",
|
||||
data=None,
|
||||
duration_ms=float(i),
|
||||
error=None,
|
||||
timed_out=False,
|
||||
)
|
||||
)
|
||||
|
||||
def time_group_by_resource_50_results(self) -> None:
|
||||
"""Benchmark grouping 50 results by resource."""
|
||||
ValidationPipeline.group_by_resource(self.results)
|
||||
|
||||
def time_build_summary_model(self) -> None:
|
||||
"""Benchmark building a ValidationSummary from 50 results."""
|
||||
req_p = sum(
|
||||
1 for r in self.results if r.mode == ValidationMode.REQUIRED and r.passed
|
||||
)
|
||||
req_f = sum(
|
||||
1
|
||||
for r in self.results
|
||||
if r.mode == ValidationMode.REQUIRED and not r.passed
|
||||
)
|
||||
info_p = sum(
|
||||
1
|
||||
for r in self.results
|
||||
if r.mode == ValidationMode.INFORMATIONAL and r.passed
|
||||
)
|
||||
info_f = sum(
|
||||
1
|
||||
for r in self.results
|
||||
if r.mode == ValidationMode.INFORMATIONAL and not r.passed
|
||||
)
|
||||
ValidationSummary(
|
||||
total=len(self.results),
|
||||
required_passed=req_p,
|
||||
required_failed=req_f,
|
||||
informational_passed=info_p,
|
||||
informational_failed=info_f,
|
||||
results=self.results,
|
||||
)
|
||||
@@ -1,3 +1,154 @@
|
||||
# Validation Pipeline
|
||||
|
||||
The **Validation Pipeline** orchestrates deterministic execution of validations
|
||||
against plan resources, enforcing required vs informational semantics and
|
||||
producing aggregated summaries for the plan lifecycle.
|
||||
|
||||
## Overview
|
||||
|
||||
When a plan reaches the end of its Execute phase, the validation pipeline
|
||||
runs all attached validations. Each validation is modelled as a
|
||||
`ValidationCommand` that specifies the target resource, execution mode,
|
||||
arguments, and timeout. Results are collected into `ValidationResult`
|
||||
objects and aggregated into a `ValidationSummary`.
|
||||
|
||||
```
|
||||
ValidationCommand(s) ──► ValidationPipeline.run() ──► ValidationSummary
|
||||
```
|
||||
|
||||
## Ordering Rules
|
||||
|
||||
Validations are sorted deterministically before execution:
|
||||
|
||||
1. **Resource name** (alphabetical)
|
||||
2. **Mode** (`informational` before `required` by enum value)
|
||||
3. **Validation name** (alphabetical)
|
||||
|
||||
This ensures repeatable execution order regardless of the order in which
|
||||
validations are registered or attached.
|
||||
|
||||
## Required vs Informational Behaviour
|
||||
|
||||
| Mode | On Pass | On Fail |
|
||||
|-----------------|---------|----------------------------------------------|
|
||||
| `required` | OK | Blocks: `all_required_passed` becomes `False` |
|
||||
| `informational` | OK | Logged but does **not** block |
|
||||
|
||||
- A `ValidationSummary` with `all_required_passed == False` signals that
|
||||
the plan should not proceed to the Apply phase.
|
||||
- Informational failures are logged at INFO level for review but do not
|
||||
affect the `all_required_passed` property.
|
||||
|
||||
## Timeout Handling
|
||||
|
||||
Each `ValidationCommand` carries a `timeout_seconds` field (default 30 s).
|
||||
Timeouts are enforced using daemon threads with `thread.join(timeout=N)`.
|
||||
If a validation exceeds its timeout:
|
||||
|
||||
- The daemon thread is abandoned (not blocked on).
|
||||
- The result is marked `passed=False` and `timed_out=True`.
|
||||
- The error field contains `TimeoutError: exceeded <N>s`.
|
||||
- The duration reflects the wall-clock time up to the timeout.
|
||||
|
||||
## Concurrency and Capture
|
||||
|
||||
The pipeline uses a `concurrent.futures.ThreadPoolExecutor` with a
|
||||
configurable `max_workers` (default 4). Validations are submitted
|
||||
concurrently but results are re-sorted to maintain deterministic ordering.
|
||||
|
||||
Before execution, `sys.stdout` and `sys.stderr` are replaced with
|
||||
thread-local stream wrappers (`_ThreadLocalStream`). Each worker thread
|
||||
captures its own output independently, avoiding cross-thread pollution.
|
||||
The original streams are restored in a `finally` block after all
|
||||
validations complete.
|
||||
|
||||
Setting `max_workers=1` forces purely sequential execution, which is
|
||||
useful for debugging or when validations have interdependencies.
|
||||
|
||||
## Output Normalisation
|
||||
|
||||
Validation executors may return arbitrary output. The pipeline normalises
|
||||
all results to a consistent schema:
|
||||
|
||||
| Executor Returns | Normalised To |
|
||||
|------------------------|---------------------------------------------|
|
||||
| `{"passed": True, ...}`| Used directly |
|
||||
| Non-dict value | `passed=False`, message describes the error |
|
||||
| Missing `passed` key | Defaults to `False` |
|
||||
| Missing `message` key | Defaults to `"No message provided"` |
|
||||
| Non-dict `data` value | Wrapped in `{"raw": <value>}` |
|
||||
|
||||
When an executor raises an exception, the result is recorded as
|
||||
`passed=False` with a message describing the exception type and details
|
||||
(e.g., `Validation raised RuntimeError: ...`). The exception
|
||||
information is preserved in the `error` field.
|
||||
|
||||
Captured stdout/stderr during validation execution is stored in the
|
||||
result's `data` dict under `_captured_stdout` and `_captured_stderr` keys.
|
||||
|
||||
## Read-Only Resource Guard
|
||||
|
||||
If a validation targets a resource marked as read-only (via the
|
||||
`read_only_resources` parameter), the pipeline **skips** the validation
|
||||
and returns a passing result with a descriptive message. A warning is
|
||||
logged when this occurs.
|
||||
|
||||
## Integration with Plan Lifecycle
|
||||
|
||||
The `run_for_plan()` method extends `run()` by persisting the validation
|
||||
summary into the plan's metadata dict:
|
||||
|
||||
```python
|
||||
pipeline = ValidationPipeline(commands, executor)
|
||||
summary = pipeline.run_for_plan(plan_metadata=plan.metadata)
|
||||
# plan.metadata["validation_summary"] now contains the full summary
|
||||
```
|
||||
|
||||
This allows downstream phases (Apply, CLI display) to inspect validation
|
||||
results without re-running the pipeline.
|
||||
|
||||
## Models Reference
|
||||
|
||||
### ValidationCommand
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|--------------------|------------------|---------|----------------------------|
|
||||
| `validation_name` | `str` | — | Validation identifier |
|
||||
| `resource_id` | `str` | — | Target resource ULID |
|
||||
| `resource_name` | `str` | — | Human-readable name |
|
||||
| `mode` | `ValidationMode` | — | `required`/`informational` |
|
||||
| `arguments` | `dict` | `{}` | Executor arguments |
|
||||
| `timeout_seconds` | `float` | `30.0` | Timeout in seconds |
|
||||
|
||||
### ValidationResult
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|--------------------|------------------|---------|----------------------------|
|
||||
| `validation_name` | `str` | — | Validation identifier |
|
||||
| `resource_id` | `str` | — | Resource ULID |
|
||||
| `resource_name` | `str` | — | Human-readable name |
|
||||
| `mode` | `ValidationMode` | — | `required`/`informational` |
|
||||
| `passed` | `bool` | — | Pass/fail status |
|
||||
| `message` | `str` | — | Result description |
|
||||
| `data` | `dict \| None` | `None` | Structured data |
|
||||
| `duration_ms` | `float` | — | Execution time (ms) |
|
||||
| `error` | `str \| None` | `None` | Error message |
|
||||
| `timed_out` | `bool` | `False` | Timeout flag |
|
||||
|
||||
### ValidationSummary
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------------------------|-------------------------|--------------------------|
|
||||
| `total` | `int` | Total validations |
|
||||
| `required_passed` | `int` | Required passes |
|
||||
| `required_failed` | `int` | Required failures |
|
||||
| `informational_passed` | `int` | Informational passes |
|
||||
| `informational_failed` | `int` | Informational failures |
|
||||
| `results` | `list[ValidationResult]`| All results |
|
||||
| `all_required_passed` | `bool` (property) | True if no req. failures |
|
||||
|
||||
---
|
||||
|
||||
# Validation Pipeline — Apply Gating
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -6,7 +6,8 @@ integration layer.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
"""Step definitions for tool_registry_service_fallback_coverage.feature.
|
||||
|
||||
Exercises the fallback method-resolution branches in ToolRegistryService:
|
||||
- register_tool: falls back from create -> add -> create (AttributeError)
|
||||
- update_tool: tool_config parameter paths (dict, Tool model)
|
||||
- remove_tool: falls back from delete -> remove -> delete (AttributeError)
|
||||
- list_tools: delegates to list_all
|
||||
- attach_validation: invalid mode raises ValidationError
|
||||
|
||||
All repository dependencies are lightweight mocks — no database needed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
|
||||
from cleveragents.application.services.tool_registry_service import (
|
||||
ToolRegistryService,
|
||||
)
|
||||
from cleveragents.core.exceptions import ValidationError
|
||||
from cleveragents.domain.models.core.tool import Tool, ToolSource
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mock repos with specific method presence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _AddOnlyToolRepo:
|
||||
"""A repo that has add() but no create() method."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._added: Any = None
|
||||
|
||||
# Deliberately no create() method
|
||||
|
||||
def add(self, tool: Any) -> Any:
|
||||
self._added = tool
|
||||
return tool
|
||||
|
||||
def get_by_name(self, name: str) -> Any:
|
||||
return None
|
||||
|
||||
def list_all(self, **kwargs: Any) -> list[Any]:
|
||||
return []
|
||||
|
||||
def update(self, tool: Any) -> Any:
|
||||
return tool
|
||||
|
||||
def delete(self, name: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
class _NoCreateNoAddToolRepo:
|
||||
"""A repo that has neither create() nor add() as callable methods.
|
||||
|
||||
Has create as a non-callable (string) to trigger the final fallback.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# create is set to a non-callable to force the fallback path
|
||||
pass
|
||||
|
||||
def get_by_name(self, name: str) -> Any:
|
||||
return None
|
||||
|
||||
def list_all(self, **kwargs: Any) -> list[Any]:
|
||||
return []
|
||||
|
||||
def update(self, tool: Any) -> Any:
|
||||
return tool
|
||||
|
||||
|
||||
class _RemoveOnlyToolRepo:
|
||||
"""A repo that has remove() but no delete() method."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._removed: str | None = None
|
||||
|
||||
# Deliberately no delete() method
|
||||
|
||||
def remove(self, name: str) -> bool:
|
||||
self._removed = name
|
||||
return True
|
||||
|
||||
def get_by_name(self, name: str) -> Any:
|
||||
return None
|
||||
|
||||
def create(self, tool: Any) -> Any:
|
||||
return tool
|
||||
|
||||
def list_all(self, **kwargs: Any) -> list[Any]:
|
||||
return []
|
||||
|
||||
def update(self, tool: Any) -> Any:
|
||||
return tool
|
||||
|
||||
|
||||
class _NoDeleteNoRemoveToolRepo:
|
||||
"""A repo that has neither delete() nor remove()."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
pass
|
||||
|
||||
def get_by_name(self, name: str) -> Any:
|
||||
return None
|
||||
|
||||
def create(self, tool: Any) -> Any:
|
||||
return tool
|
||||
|
||||
def list_all(self, **kwargs: Any) -> list[Any]:
|
||||
return []
|
||||
|
||||
def update(self, tool: Any) -> Any:
|
||||
return tool
|
||||
|
||||
|
||||
class _StandardMockToolRepo:
|
||||
"""Standard mock with all methods present."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._last_updated: Any = None
|
||||
self._list_all_return: list[Any] = [
|
||||
{"name": "local/tool-1"},
|
||||
{"name": "local/tool-2"},
|
||||
]
|
||||
self._get_by_name_return: Any = {"name": "local/some-check"}
|
||||
|
||||
def create(self, tool: Any) -> Any:
|
||||
return tool
|
||||
|
||||
def get_by_name(self, name: str) -> Any:
|
||||
return self._get_by_name_return
|
||||
|
||||
def list_all(
|
||||
self,
|
||||
namespace: str | None = None,
|
||||
tool_type: str | None = None,
|
||||
source: str | None = None,
|
||||
) -> list[Any]:
|
||||
return self._list_all_return
|
||||
|
||||
def update(self, tool: Any) -> Any:
|
||||
self._last_updated = tool
|
||||
return tool
|
||||
|
||||
def delete(self, name: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
class _MinimalAttachmentRepo:
|
||||
"""Attachment repo that satisfies the service interface."""
|
||||
|
||||
def attach(self, **kwargs: Any) -> dict[str, str]:
|
||||
return {"attachment_id": "fallback-att-001"}
|
||||
|
||||
def detach(self, attachment_id: str) -> bool:
|
||||
return True
|
||||
|
||||
def list_for_resource(self, **kwargs: Any) -> list[Any]:
|
||||
return []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Given steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a tool registry service with a repo that only has add method")
|
||||
def step_given_add_only_repo(context: Context) -> None:
|
||||
context.fb_tool_repo = _AddOnlyToolRepo()
|
||||
context.fb_attachment_repo = _MinimalAttachmentRepo()
|
||||
context.fb_service = ToolRegistryService(
|
||||
tool_repo=context.fb_tool_repo,
|
||||
attachment_repo=context.fb_attachment_repo,
|
||||
)
|
||||
context.fb_error: Exception | None = None
|
||||
context.fb_result: Any = None
|
||||
|
||||
|
||||
@given("a tool registry service with a repo that has no create or add methods")
|
||||
def step_given_no_create_no_add(context: Context) -> None:
|
||||
context.fb_tool_repo = _NoCreateNoAddToolRepo()
|
||||
context.fb_attachment_repo = _MinimalAttachmentRepo()
|
||||
context.fb_service = ToolRegistryService(
|
||||
tool_repo=context.fb_tool_repo,
|
||||
attachment_repo=context.fb_attachment_repo,
|
||||
)
|
||||
context.fb_error = None
|
||||
context.fb_result = None
|
||||
|
||||
|
||||
@given("a tool registry service with a standard mock repo")
|
||||
def step_given_standard_repo(context: Context) -> None:
|
||||
context.fb_tool_repo = _StandardMockToolRepo()
|
||||
context.fb_attachment_repo = _MinimalAttachmentRepo()
|
||||
context.fb_service = ToolRegistryService(
|
||||
tool_repo=context.fb_tool_repo,
|
||||
attachment_repo=context.fb_attachment_repo,
|
||||
)
|
||||
context.fb_error = None
|
||||
context.fb_result = None
|
||||
|
||||
|
||||
@given("a tool registry service with a repo that only has remove method")
|
||||
def step_given_remove_only_repo(context: Context) -> None:
|
||||
context.fb_tool_repo = _RemoveOnlyToolRepo()
|
||||
context.fb_attachment_repo = _MinimalAttachmentRepo()
|
||||
context.fb_service = ToolRegistryService(
|
||||
tool_repo=context.fb_tool_repo,
|
||||
attachment_repo=context.fb_attachment_repo,
|
||||
)
|
||||
context.fb_error = None
|
||||
context.fb_result = None
|
||||
|
||||
|
||||
@given("a tool registry service with a repo that has no delete or remove methods")
|
||||
def step_given_no_delete_no_remove(context: Context) -> None:
|
||||
context.fb_tool_repo = _NoDeleteNoRemoveToolRepo()
|
||||
context.fb_attachment_repo = _MinimalAttachmentRepo()
|
||||
context.fb_service = ToolRegistryService(
|
||||
tool_repo=context.fb_tool_repo,
|
||||
attachment_repo=context.fb_attachment_repo,
|
||||
)
|
||||
context.fb_error = None
|
||||
context.fb_result = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# When steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("I register a tool via the fallback service")
|
||||
def step_when_register_fallback(context: Context) -> None:
|
||||
tool = {"name": "local/fallback-tool", "source": "builtin"}
|
||||
try:
|
||||
context.fb_result = context.fb_service.register_tool(tool)
|
||||
context.fb_error = None
|
||||
except Exception as exc:
|
||||
context.fb_error = exc
|
||||
|
||||
|
||||
@when("I register a tool via the fallback service and expect error")
|
||||
def step_when_register_fallback_error(context: Context) -> None:
|
||||
tool = {"name": "local/fallback-tool", "source": "builtin"}
|
||||
try:
|
||||
context.fb_result = context.fb_service.register_tool(tool)
|
||||
context.fb_error = None
|
||||
except Exception as exc:
|
||||
context.fb_error = exc
|
||||
|
||||
|
||||
@when('I update tool "{name}" with dict config via the fallback service')
|
||||
def step_when_update_dict_config(context: Context, name: str) -> None:
|
||||
try:
|
||||
context.fb_result = context.fb_service.update_tool(
|
||||
name,
|
||||
tool_config={"description": "Updated description"},
|
||||
)
|
||||
context.fb_error = None
|
||||
except Exception as exc:
|
||||
context.fb_error = exc
|
||||
|
||||
|
||||
@when('I update tool "{name}" with Tool model config via the fallback service')
|
||||
def step_when_update_tool_model(context: Context, name: str) -> None:
|
||||
try:
|
||||
tool_model = Tool(
|
||||
name="local/my-tool",
|
||||
description="A tool model",
|
||||
source=ToolSource.BUILTIN,
|
||||
tool_type="tool",
|
||||
timeout=300,
|
||||
)
|
||||
context.fb_result = context.fb_service.update_tool(name, tool_config=tool_model)
|
||||
context.fb_error = None
|
||||
except Exception as exc:
|
||||
context.fb_error = exc
|
||||
|
||||
|
||||
@when("I update with dict tool and dict config via the fallback service")
|
||||
def step_when_update_dict_tool_dict_config(context: Context) -> None:
|
||||
try:
|
||||
tool_dict = {"name": "local/dict-tool", "source": "builtin"}
|
||||
context.fb_result = context.fb_service.update_tool(
|
||||
tool_dict,
|
||||
tool_config={"description": "Updated"},
|
||||
)
|
||||
context.fb_error = None
|
||||
except Exception as exc:
|
||||
context.fb_error = exc
|
||||
|
||||
|
||||
@when('I remove tool "{name}" via the fallback service')
|
||||
def step_when_remove_fallback(context: Context, name: str) -> None:
|
||||
try:
|
||||
context.fb_result = context.fb_service.remove_tool(name)
|
||||
context.fb_error = None
|
||||
except Exception as exc:
|
||||
context.fb_error = exc
|
||||
|
||||
|
||||
@when('I remove tool "{name}" via the fallback service and expect error')
|
||||
def step_when_remove_fallback_error(context: Context, name: str) -> None:
|
||||
try:
|
||||
context.fb_result = context.fb_service.remove_tool(name)
|
||||
context.fb_error = None
|
||||
except Exception as exc:
|
||||
context.fb_error = exc
|
||||
|
||||
|
||||
@when('I list tools with namespace "{ns}" and type "{tt}" via the fallback service')
|
||||
def step_when_list_tools(context: Context, ns: str, tt: str) -> None:
|
||||
try:
|
||||
context.fb_result = context.fb_service.list_tools(namespace=ns, tool_type=tt)
|
||||
context.fb_error = None
|
||||
except Exception as exc:
|
||||
context.fb_error = exc
|
||||
|
||||
|
||||
@when('I attach validation with invalid mode "{mode}" via the fallback service')
|
||||
def step_when_attach_invalid_mode(context: Context, mode: str) -> None:
|
||||
try:
|
||||
context.fb_result = context.fb_service.attach_validation(
|
||||
validation_name="local/check",
|
||||
resource_id="res-001",
|
||||
mode=mode,
|
||||
)
|
||||
context.fb_error = None
|
||||
except Exception as exc:
|
||||
context.fb_error = exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then("the tool should be registered via the add method")
|
||||
def step_then_registered_via_add(context: Context) -> None:
|
||||
assert context.fb_result is not None, "Expected a result from register_tool"
|
||||
assert hasattr(context.fb_tool_repo, "_added"), "Expected _added attribute on repo"
|
||||
assert context.fb_tool_repo._added is not None, (
|
||||
"Expected tool to be passed to add()"
|
||||
)
|
||||
|
||||
|
||||
@then("no error should be raised by the fallback service")
|
||||
def step_then_no_fallback_error(context: Context) -> None:
|
||||
assert context.fb_error is None, (
|
||||
f"Expected no error, got {type(context.fb_error).__name__}: {context.fb_error}"
|
||||
)
|
||||
|
||||
|
||||
@then("a fallback AttributeError should be raised")
|
||||
def step_then_attribute_error(context: Context) -> None:
|
||||
assert context.fb_error is not None, "Expected an error but no error was raised"
|
||||
assert isinstance(context.fb_error, AttributeError), (
|
||||
f"Expected AttributeError, got {type(context.fb_error).__name__}: "
|
||||
f"{context.fb_error}"
|
||||
)
|
||||
|
||||
|
||||
@then('the updated tool should have name "{name}"')
|
||||
def step_then_updated_has_name(context: Context, name: str) -> None:
|
||||
assert context.fb_result is not None, "Expected a result from update_tool"
|
||||
if isinstance(context.fb_result, dict):
|
||||
assert context.fb_result.get("name") == name, (
|
||||
f"Expected name '{name}', got {context.fb_result.get('name')}"
|
||||
)
|
||||
else:
|
||||
assert getattr(context.fb_result, "name", None) == name
|
||||
|
||||
|
||||
@then("the update should use the Tool model directly")
|
||||
def step_then_update_uses_model(context: Context) -> None:
|
||||
assert context.fb_result is not None, "Expected a result"
|
||||
assert isinstance(context.fb_result, Tool), (
|
||||
f"Expected Tool instance, got {type(context.fb_result).__name__}"
|
||||
)
|
||||
|
||||
|
||||
@then("the updated tool should be the original dict")
|
||||
def step_then_updated_is_dict(context: Context) -> None:
|
||||
assert context.fb_result is not None, "Expected a result"
|
||||
assert isinstance(context.fb_result, dict), (
|
||||
f"Expected dict, got {type(context.fb_result).__name__}"
|
||||
)
|
||||
|
||||
|
||||
@then("the tool should be removed via the remove method")
|
||||
def step_then_removed_via_remove(context: Context) -> None:
|
||||
assert context.fb_result is True, (
|
||||
f"Expected True from remove_tool, got {context.fb_result}"
|
||||
)
|
||||
assert hasattr(context.fb_tool_repo, "_removed"), (
|
||||
"Expected _removed attribute on repo"
|
||||
)
|
||||
assert context.fb_tool_repo._removed is not None, (
|
||||
"Expected tool name to be passed to remove()"
|
||||
)
|
||||
|
||||
|
||||
@then("the list result should be returned from list_all")
|
||||
def step_then_list_result(context: Context) -> None:
|
||||
assert context.fb_result is not None, "Expected a list result"
|
||||
assert isinstance(context.fb_result, list), (
|
||||
f"Expected list, got {type(context.fb_result).__name__}"
|
||||
)
|
||||
assert len(context.fb_result) == 2, (
|
||||
f"Expected 2 items, got {len(context.fb_result)}"
|
||||
)
|
||||
|
||||
|
||||
@then('a fallback ValidationError should be raised with message containing "{text}"')
|
||||
def step_then_validation_error(context: Context, text: str) -> None:
|
||||
assert context.fb_error is not None, (
|
||||
"Expected ValidationError but no error was raised"
|
||||
)
|
||||
assert isinstance(context.fb_error, ValidationError), (
|
||||
f"Expected ValidationError, got {type(context.fb_error).__name__}: "
|
||||
f"{context.fb_error}"
|
||||
)
|
||||
assert text in str(context.fb_error), (
|
||||
f"Expected '{text}' in error message, got '{context.fb_error}'"
|
||||
)
|
||||
@@ -0,0 +1,545 @@
|
||||
"""Step definitions for validation pipeline scenarios.
|
||||
|
||||
All step names are prefixed with 'validation pipeline' context to avoid
|
||||
AmbiguousStep collisions with existing step definitions.
|
||||
Uses the ``re`` step matcher for patterns that have optional trailing parts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from behave import given, then, use_step_matcher, when
|
||||
|
||||
from cleveragents.application.services.validation_pipeline import (
|
||||
ValidationCommand,
|
||||
ValidationPipeline,
|
||||
ValidationResult,
|
||||
ValidationSummary,
|
||||
)
|
||||
from cleveragents.domain.models.core.tool import ValidationMode
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mock executor
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class MockValidationExecutor:
|
||||
"""Configurable mock executor for testing the validation pipeline."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._results: dict[str, dict[str, Any]] = {}
|
||||
self._timeout_names: set[str] = set()
|
||||
self._exception_names: dict[str, Exception] = {}
|
||||
self._non_dict_names: set[str] = set()
|
||||
self._missing_passed_names: set[str] = set()
|
||||
self._default_passed: bool = False
|
||||
self._stdout_names: set[str] = set()
|
||||
self._stderr_names: set[str] = set()
|
||||
|
||||
def set_passed(self, name: str) -> None:
|
||||
self._results[name] = {"passed": True, "message": f"{name} passed"}
|
||||
|
||||
def set_passed_with_data(self, name: str) -> None:
|
||||
self._results[name] = {
|
||||
"passed": True,
|
||||
"message": f"{name} passed with data",
|
||||
"data": {"detail": "some-value", "count": 42},
|
||||
}
|
||||
|
||||
def set_failed(self, name: str, message: str) -> None:
|
||||
self._results[name] = {"passed": False, "message": message}
|
||||
|
||||
def set_timeout(self, name: str) -> None:
|
||||
self._timeout_names.add(name)
|
||||
|
||||
def set_exception(self, name: str, exc: Exception) -> None:
|
||||
self._exception_names[name] = exc
|
||||
|
||||
def set_non_dict(self, name: str) -> None:
|
||||
self._non_dict_names.add(name)
|
||||
|
||||
def set_missing_passed(self, name: str) -> None:
|
||||
self._missing_passed_names.add(name)
|
||||
|
||||
def set_default_passed(self) -> None:
|
||||
self._default_passed = True
|
||||
|
||||
def set_non_bool_passed(self, name: str) -> None:
|
||||
self._results[name] = {"passed": "yes", "message": f"{name} passed"}
|
||||
|
||||
def set_non_string_message(self, name: str) -> None:
|
||||
self._results[name] = {"passed": True, "message": 12345}
|
||||
|
||||
def set_non_dict_data(self, name: str) -> None:
|
||||
self._results[name] = {"passed": True, "message": f"{name} ok", "data": [1, 2]}
|
||||
|
||||
def set_prints_stdout(self, name: str) -> None:
|
||||
self._results[name] = {"passed": True, "message": f"{name} ok"}
|
||||
self._stdout_names.add(name)
|
||||
|
||||
def set_prints_stderr(self, name: str) -> None:
|
||||
self._results[name] = {"passed": True, "message": f"{name} ok"}
|
||||
self._stderr_names.add(name)
|
||||
|
||||
def __call__(
|
||||
self, validation_name: str, arguments: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
if validation_name in self._timeout_names:
|
||||
# Sleep longer than the test timeout (0.2 s) but not excessively
|
||||
time.sleep(1)
|
||||
return {"passed": True, "message": "should not reach here"}
|
||||
|
||||
if validation_name in self._exception_names:
|
||||
raise self._exception_names[validation_name]
|
||||
|
||||
if validation_name in self._non_dict_names:
|
||||
return "not-a-dict" # type: ignore[return-value]
|
||||
|
||||
if validation_name in self._missing_passed_names:
|
||||
return {"message": "no passed key here"}
|
||||
|
||||
if validation_name in self._stdout_names:
|
||||
print(f"stdout from {validation_name}")
|
||||
|
||||
if validation_name in self._stderr_names:
|
||||
print(f"stderr from {validation_name}", file=sys.stderr)
|
||||
|
||||
if validation_name in self._results:
|
||||
return self._results[validation_name]
|
||||
|
||||
if self._default_passed:
|
||||
return {"passed": True, "message": f"{validation_name} passed (default)"}
|
||||
|
||||
return {"passed": False, "message": f"{validation_name}: no result configured"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_RESOURCE_ID_COUNTER: int = 0
|
||||
|
||||
|
||||
def _next_resource_id() -> str:
|
||||
global _RESOURCE_ID_COUNTER
|
||||
_RESOURCE_ID_COUNTER += 1
|
||||
return f"R{_RESOURCE_ID_COUNTER:05d}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Given steps — use "re" matcher for the required/informational patterns
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
use_step_matcher("re")
|
||||
|
||||
|
||||
@given(r"a validation pipeline test environment")
|
||||
def step_given_pipeline_env(context: Any) -> None:
|
||||
context.vp_commands: list[ValidationCommand] = []
|
||||
context.vp_executor = MockValidationExecutor()
|
||||
context.vp_max_workers: int = 4
|
||||
context.vp_read_only_resources: set[str] = set()
|
||||
context.vp_summary: ValidationSummary | None = None
|
||||
context.vp_plan_metadata: dict[str, Any] | None = None
|
||||
context.vp_resource_id_map: dict[str, str] = {}
|
||||
global _RESOURCE_ID_COUNTER
|
||||
_RESOURCE_ID_COUNTER = 0
|
||||
|
||||
|
||||
@given(r"an empty list of validation commands")
|
||||
def step_given_empty_commands(context: Any) -> None:
|
||||
context.vp_commands = []
|
||||
|
||||
|
||||
@given(
|
||||
r'a required vp command "(?P<name>[^"]+)" on resource "(?P<resource>[^"]+)"'
|
||||
r" with timeout (?P<timeout>[\d.]+)"
|
||||
)
|
||||
def step_given_required_command_timeout(
|
||||
context: Any, name: str, resource: str, timeout: str
|
||||
) -> None:
|
||||
rid = context.vp_resource_id_map.get(resource, _next_resource_id())
|
||||
context.vp_resource_id_map[resource] = rid
|
||||
context.vp_commands.append(
|
||||
ValidationCommand(
|
||||
validation_name=name,
|
||||
resource_id=rid,
|
||||
resource_name=resource,
|
||||
mode=ValidationMode.REQUIRED,
|
||||
arguments={},
|
||||
timeout_seconds=float(timeout),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@given(
|
||||
r'a required vp command "(?P<name>[^"]+)" on resource "(?P<resource>[^"]+)"'
|
||||
r' having resource_id "(?P<rid>[^"]+)"'
|
||||
)
|
||||
def step_given_required_command_with_rid(
|
||||
context: Any, name: str, resource: str, rid: str
|
||||
) -> None:
|
||||
context.vp_resource_id_map[resource] = rid
|
||||
context.vp_commands.append(
|
||||
ValidationCommand(
|
||||
validation_name=name,
|
||||
resource_id=rid,
|
||||
resource_name=resource,
|
||||
mode=ValidationMode.REQUIRED,
|
||||
arguments={},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@given(r'a required vp command "(?P<name>[^"]+)" on resource "(?P<resource>[^"]+)"')
|
||||
def step_given_required_command(context: Any, name: str, resource: str) -> None:
|
||||
rid = context.vp_resource_id_map.get(resource, _next_resource_id())
|
||||
context.vp_resource_id_map[resource] = rid
|
||||
context.vp_commands.append(
|
||||
ValidationCommand(
|
||||
validation_name=name,
|
||||
resource_id=rid,
|
||||
resource_name=resource,
|
||||
mode=ValidationMode.REQUIRED,
|
||||
arguments={},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@given(
|
||||
r'an informational vp command "(?P<name>[^"]+)"'
|
||||
r' on resource "(?P<resource>[^"]+)"'
|
||||
)
|
||||
def step_given_informational_command(context: Any, name: str, resource: str) -> None:
|
||||
rid = context.vp_resource_id_map.get(resource, _next_resource_id())
|
||||
context.vp_resource_id_map[resource] = rid
|
||||
context.vp_commands.append(
|
||||
ValidationCommand(
|
||||
validation_name=name,
|
||||
resource_id=rid,
|
||||
resource_name=resource,
|
||||
mode=ValidationMode.INFORMATIONAL,
|
||||
arguments={},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@given(r'the vp executor returns passed for "(?P<name>[^"]+)"')
|
||||
def step_given_executor_passed(context: Any, name: str) -> None:
|
||||
context.vp_executor.set_passed(name)
|
||||
|
||||
|
||||
@given(
|
||||
r'the vp executor returns failed for "(?P<name>[^"]+)"'
|
||||
r' with message "(?P<message>[^"]+)"'
|
||||
)
|
||||
def step_given_executor_failed(context: Any, name: str, message: str) -> None:
|
||||
context.vp_executor.set_failed(name, message)
|
||||
|
||||
|
||||
@given(r"the vp executor returns passed for all validations")
|
||||
def step_given_executor_all_passed(context: Any) -> None:
|
||||
context.vp_executor.set_default_passed()
|
||||
|
||||
|
||||
@given(r'the vp executor will timeout for "(?P<name>[^"]+)"')
|
||||
def step_given_executor_timeout(context: Any, name: str) -> None:
|
||||
context.vp_executor.set_timeout(name)
|
||||
|
||||
|
||||
@given(r'the vp executor returns non-dict output for "(?P<name>[^"]+)"')
|
||||
def step_given_executor_non_dict(context: Any, name: str) -> None:
|
||||
context.vp_executor.set_non_dict(name)
|
||||
|
||||
|
||||
@given(r'the vp executor returns dict without passed key for "(?P<name>[^"]+)"')
|
||||
def step_given_executor_missing_passed(context: Any, name: str) -> None:
|
||||
context.vp_executor.set_missing_passed(name)
|
||||
|
||||
|
||||
@given(r'the vp executor raises an exception for "(?P<name>[^"]+)"')
|
||||
def step_given_executor_exception(context: Any, name: str) -> None:
|
||||
context.vp_executor.set_exception(name, RuntimeError("Test exception"))
|
||||
|
||||
|
||||
@given(r'the vp executor returns passed with data for "(?P<name>[^"]+)"')
|
||||
def step_given_executor_passed_with_data(context: Any, name: str) -> None:
|
||||
context.vp_executor.set_passed_with_data(name)
|
||||
|
||||
|
||||
@given(r'the vp resource "(?P<rid>[^"]+)" is marked as read-only')
|
||||
def step_given_read_only_resource(context: Any, rid: str) -> None:
|
||||
context.vp_read_only_resources.add(rid)
|
||||
|
||||
|
||||
@given(r"a validation summary with (?P<count>\d+) required failures")
|
||||
def step_given_summary_with_failures(context: Any, count: str) -> None:
|
||||
cnt = int(count)
|
||||
context.vp_summary = ValidationSummary(
|
||||
total=cnt + 1,
|
||||
required_passed=1,
|
||||
required_failed=cnt,
|
||||
informational_passed=0,
|
||||
informational_failed=0,
|
||||
results=[],
|
||||
)
|
||||
|
||||
|
||||
@given(r"the pipeline max_workers is set to (?P<workers>\d+)")
|
||||
def step_given_max_workers(context: Any, workers: str) -> None:
|
||||
context.vp_max_workers = int(workers)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# When steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when(r"the validation pipeline runs for plan with metadata")
|
||||
def step_when_pipeline_runs_for_plan(context: Any) -> None:
|
||||
context.vp_plan_metadata = {}
|
||||
pipeline = ValidationPipeline(
|
||||
commands=context.vp_commands,
|
||||
executor=context.vp_executor,
|
||||
max_workers=context.vp_max_workers,
|
||||
read_only_resources=context.vp_read_only_resources,
|
||||
)
|
||||
context.vp_summary = pipeline.run_for_plan(plan_metadata=context.vp_plan_metadata)
|
||||
|
||||
|
||||
@when(r"the validation pipeline runs")
|
||||
def step_when_pipeline_runs(context: Any) -> None:
|
||||
pipeline = ValidationPipeline(
|
||||
commands=context.vp_commands,
|
||||
executor=context.vp_executor,
|
||||
max_workers=context.vp_max_workers,
|
||||
read_only_resources=context.vp_read_only_resources,
|
||||
)
|
||||
context.vp_summary = pipeline.run()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then(r"the validation summary total is (?P<count>\d+)")
|
||||
def step_then_summary_total(context: Any, count: str) -> None:
|
||||
cnt = int(count)
|
||||
assert context.vp_summary is not None, "No summary available"
|
||||
assert context.vp_summary.total == cnt, (
|
||||
f"Expected total={cnt}, got {context.vp_summary.total}"
|
||||
)
|
||||
|
||||
|
||||
@then(r"the validation summary required_passed is (?P<count>\d+)")
|
||||
def step_then_summary_required_passed(context: Any, count: str) -> None:
|
||||
cnt = int(count)
|
||||
assert context.vp_summary is not None
|
||||
assert context.vp_summary.required_passed == cnt, (
|
||||
f"Expected required_passed={cnt}, got {context.vp_summary.required_passed}"
|
||||
)
|
||||
|
||||
|
||||
@then(r"the validation summary required_failed is (?P<count>\d+)")
|
||||
def step_then_summary_required_failed(context: Any, count: str) -> None:
|
||||
cnt = int(count)
|
||||
assert context.vp_summary is not None
|
||||
assert context.vp_summary.required_failed == cnt, (
|
||||
f"Expected required_failed={cnt}, got {context.vp_summary.required_failed}"
|
||||
)
|
||||
|
||||
|
||||
@then(r"the validation summary informational_passed is (?P<count>\d+)")
|
||||
def step_then_summary_informational_passed(context: Any, count: str) -> None:
|
||||
cnt = int(count)
|
||||
assert context.vp_summary is not None
|
||||
assert context.vp_summary.informational_passed == cnt, (
|
||||
f"Expected informational_passed={cnt}, "
|
||||
f"got {context.vp_summary.informational_passed}"
|
||||
)
|
||||
|
||||
|
||||
@then(r"the validation summary informational_failed is (?P<count>\d+)")
|
||||
def step_then_summary_informational_failed(context: Any, count: str) -> None:
|
||||
cnt = int(count)
|
||||
assert context.vp_summary is not None
|
||||
assert context.vp_summary.informational_failed == cnt, (
|
||||
f"Expected informational_failed={cnt}, "
|
||||
f"got {context.vp_summary.informational_failed}"
|
||||
)
|
||||
|
||||
|
||||
@then(r"all required validations passed")
|
||||
def step_then_all_required_passed(context: Any) -> None:
|
||||
assert context.vp_summary is not None
|
||||
assert context.vp_summary.all_required_passed, (
|
||||
f"Expected all_required_passed=True, got False "
|
||||
f"(required_failed={context.vp_summary.required_failed})"
|
||||
)
|
||||
|
||||
|
||||
@then(r"not all required validations passed")
|
||||
def step_then_not_all_required_passed(context: Any) -> None:
|
||||
assert context.vp_summary is not None
|
||||
assert not context.vp_summary.all_required_passed, (
|
||||
"Expected all_required_passed=False, got True"
|
||||
)
|
||||
|
||||
|
||||
@then(r"the validation results are sorted by resource then mode then name")
|
||||
def step_then_results_sorted(context: Any) -> None:
|
||||
assert context.vp_summary is not None
|
||||
results = context.vp_summary.results
|
||||
keys = [(r.resource_name, r.mode.value, r.validation_name) for r in results]
|
||||
assert keys == sorted(keys), f"Results not sorted: {keys}"
|
||||
|
||||
|
||||
@then(r'the vp result for "(?P<name>[^"]+)" has timed_out true')
|
||||
def step_then_result_timed_out(context: Any, name: str) -> None:
|
||||
assert context.vp_summary is not None
|
||||
result = _find_result(context.vp_summary, name)
|
||||
assert result.timed_out, f"Expected timed_out=True for '{name}'"
|
||||
|
||||
|
||||
@then(r'the vp result for "(?P<name>[^"]+)" has passed false')
|
||||
def step_then_result_passed_false(context: Any, name: str) -> None:
|
||||
assert context.vp_summary is not None
|
||||
result = _find_result(context.vp_summary, name)
|
||||
assert not result.passed, f"Expected passed=False for '{name}'"
|
||||
|
||||
|
||||
@then(r'the vp result for "(?P<name>[^"]+)" has passed true')
|
||||
def step_then_result_passed_true(context: Any, name: str) -> None:
|
||||
assert context.vp_summary is not None
|
||||
result = _find_result(context.vp_summary, name)
|
||||
assert result.passed, f"Expected passed=True for '{name}'"
|
||||
|
||||
|
||||
@then(r'the vp result for "(?P<name>[^"]+)" has error containing "(?P<text>[^"]+)"')
|
||||
def step_then_result_error_contains(context: Any, name: str, text: str) -> None:
|
||||
assert context.vp_summary is not None
|
||||
result = _find_result(context.vp_summary, name)
|
||||
assert result.error is not None, f"Expected error for '{name}', got None"
|
||||
assert text in result.error, (
|
||||
f"Expected error containing '{text}', got: {result.error}"
|
||||
)
|
||||
|
||||
|
||||
@then(r'the vp result for "(?P<name>[^"]+)" has message containing "(?P<text>[^"]+)"')
|
||||
def step_then_result_message_contains(context: Any, name: str, text: str) -> None:
|
||||
assert context.vp_summary is not None
|
||||
result = _find_result(context.vp_summary, name)
|
||||
assert text in result.message, (
|
||||
f"Expected message containing '{text}', got: {result.message}"
|
||||
)
|
||||
|
||||
|
||||
@then(r'the vp result for "(?P<name>[^"]+)" has data key "(?P<key>[^"]+)"')
|
||||
def step_then_result_has_data_key(context: Any, name: str, key: str) -> None:
|
||||
assert context.vp_summary is not None
|
||||
result = _find_result(context.vp_summary, name)
|
||||
assert result.data is not None, f"Expected data for '{name}', got None"
|
||||
assert key in result.data, (
|
||||
f"Expected data key '{key}' in {list(result.data.keys())}"
|
||||
)
|
||||
|
||||
|
||||
@then(r"the results grouped by resource have (?P<count>\d+) groups")
|
||||
def step_then_groups_count(context: Any, count: str) -> None:
|
||||
cnt = int(count)
|
||||
assert context.vp_summary is not None
|
||||
groups = ValidationPipeline.group_by_resource(context.vp_summary.results)
|
||||
assert len(groups) == cnt, f"Expected {cnt} groups, got {len(groups)}"
|
||||
|
||||
|
||||
@then(r'the vp resource group "(?P<resource>[^"]+)" has (?P<count>\d+) result')
|
||||
def step_then_group_result_count(context: Any, resource: str, count: str) -> None:
|
||||
cnt = int(count)
|
||||
assert context.vp_summary is not None
|
||||
groups = ValidationPipeline.group_by_resource(context.vp_summary.results)
|
||||
assert resource in groups, f"Resource '{resource}' not in groups"
|
||||
actual = len(groups[resource])
|
||||
assert actual == cnt, f"Expected {cnt} results for '{resource}', got {actual}"
|
||||
|
||||
|
||||
@then(r"the summary all_required_passed property is true")
|
||||
def step_then_summary_property_true(context: Any) -> None:
|
||||
assert context.vp_summary is not None
|
||||
assert context.vp_summary.all_required_passed
|
||||
|
||||
|
||||
@then(r"the summary all_required_passed property is false")
|
||||
def step_then_summary_property_false(context: Any) -> None:
|
||||
assert context.vp_summary is not None
|
||||
assert not context.vp_summary.all_required_passed
|
||||
|
||||
|
||||
@then(r"the plan metadata contains validation_summary")
|
||||
def step_then_plan_metadata_has_summary(context: Any) -> None:
|
||||
assert context.vp_plan_metadata is not None
|
||||
assert "validation_summary" in context.vp_plan_metadata
|
||||
|
||||
|
||||
@then(r"the plan metadata validation_summary total is (?P<count>\d+)")
|
||||
def step_then_plan_metadata_total(context: Any, count: str) -> None:
|
||||
cnt = int(count)
|
||||
assert context.vp_plan_metadata is not None
|
||||
vs = context.vp_plan_metadata["validation_summary"]
|
||||
assert vs["total"] == cnt, f"Expected total={cnt}, got {vs['total']}"
|
||||
|
||||
|
||||
@given(r'the vp executor returns non-bool passed for "(?P<name>[^"]+)"')
|
||||
def step_given_executor_non_bool_passed(context: Any, name: str) -> None:
|
||||
context.vp_executor.set_non_bool_passed(name)
|
||||
|
||||
|
||||
@given(r'the vp executor returns non-string message for "(?P<name>[^"]+)"')
|
||||
def step_given_executor_non_string_message(context: Any, name: str) -> None:
|
||||
context.vp_executor.set_non_string_message(name)
|
||||
|
||||
|
||||
@given(r'the vp executor returns non-dict data for "(?P<name>[^"]+)"')
|
||||
def step_given_executor_non_dict_data(context: Any, name: str) -> None:
|
||||
context.vp_executor.set_non_dict_data(name)
|
||||
|
||||
|
||||
@given(r'the vp executor prints to stdout for "(?P<name>[^"]+)"')
|
||||
def step_given_executor_prints_stdout(context: Any, name: str) -> None:
|
||||
context.vp_executor.set_prints_stdout(name)
|
||||
|
||||
|
||||
@given(r'the vp executor prints to stderr for "(?P<name>[^"]+)"')
|
||||
def step_given_executor_prints_stderr(context: Any, name: str) -> None:
|
||||
context.vp_executor.set_prints_stderr(name)
|
||||
|
||||
|
||||
@then(r'the vp result for "(?P<name>[^"]+)" has positive duration_ms')
|
||||
def step_then_result_positive_duration(context: Any, name: str) -> None:
|
||||
assert context.vp_summary is not None
|
||||
result = _find_result(context.vp_summary, name)
|
||||
assert result.duration_ms > 0, (
|
||||
f"Expected duration_ms > 0 for '{name}', got {result.duration_ms}"
|
||||
)
|
||||
|
||||
|
||||
# Reset to default parse matcher
|
||||
use_step_matcher("parse")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _find_result(summary: ValidationSummary, name: str) -> ValidationResult:
|
||||
"""Find a validation result by name."""
|
||||
for r in summary.results:
|
||||
if r.validation_name == name:
|
||||
return r
|
||||
available = [r.validation_name for r in summary.results]
|
||||
msg = f"No result for '{name}', available: {available}"
|
||||
raise AssertionError(msg)
|
||||
@@ -0,0 +1,221 @@
|
||||
"""Step definitions for validation_pipeline_stream_coverage.feature.
|
||||
|
||||
Exercises the _ThreadLocalStream helper methods that lack coverage:
|
||||
- encoding property (line 58)
|
||||
- writable() method (line 61)
|
||||
- readable() method (line 64)
|
||||
- flush() with active buffer (line 75)
|
||||
- isatty() method (line 79)
|
||||
|
||||
All tests use a simple mock original stream — no real I/O needed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
|
||||
from cleveragents.application.services.validation_pipeline import (
|
||||
_ThreadLocalStream,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mock original stream
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _MockOriginalStream:
|
||||
"""Configurable mock that mimics a real stdout/stderr."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.encoding: str = "utf-8"
|
||||
self._has_encoding: bool = True
|
||||
self._isatty_return: bool = False
|
||||
self._written: list[str] = []
|
||||
self._flushed: bool = False
|
||||
|
||||
def write(self, s: str) -> int:
|
||||
self._written.append(s)
|
||||
return len(s)
|
||||
|
||||
def flush(self) -> None:
|
||||
self._flushed = True
|
||||
|
||||
def isatty(self) -> bool:
|
||||
return self._isatty_return
|
||||
|
||||
|
||||
class _NoEncodingStream:
|
||||
"""Mock stream without an encoding attribute."""
|
||||
|
||||
def write(self, s: str) -> int:
|
||||
return len(s)
|
||||
|
||||
def flush(self) -> None:
|
||||
pass
|
||||
|
||||
def isatty(self) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Background
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a thread-local stream wrapping a mock original stream")
|
||||
def step_given_tl_stream(context: Context) -> None:
|
||||
context.tls_original = _MockOriginalStream()
|
||||
context.tls_stream = _ThreadLocalStream(context.tls_original)
|
||||
context.tls_result: Any = None
|
||||
context.tls_captured: str = ""
|
||||
context.tls_error: Exception | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Given steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given('the original stream has encoding "{enc}"')
|
||||
def step_given_encoding(context: Context, enc: str) -> None:
|
||||
context.tls_original.encoding = enc
|
||||
|
||||
|
||||
@given("the original stream has no encoding attribute")
|
||||
def step_given_no_encoding(context: Context) -> None:
|
||||
context.tls_stream = _ThreadLocalStream(_NoEncodingStream())
|
||||
|
||||
|
||||
@given("the original stream isatty returns True")
|
||||
def step_given_isatty_true(context: Context) -> None:
|
||||
context.tls_original._isatty_return = True
|
||||
|
||||
|
||||
@given("the original stream isatty returns False")
|
||||
def step_given_isatty_false(context: Context) -> None:
|
||||
context.tls_original._isatty_return = False
|
||||
|
||||
|
||||
@given("capture is started on the thread-local stream")
|
||||
def step_given_capture_started(context: Context) -> None:
|
||||
context.tls_stream.start_capture()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# When steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("I read the thread-local stream encoding")
|
||||
def step_when_read_encoding(context: Context) -> None:
|
||||
context.tls_result = context.tls_stream.encoding
|
||||
|
||||
|
||||
@when("I check if the thread-local stream is writable")
|
||||
def step_when_check_writable(context: Context) -> None:
|
||||
context.tls_result = context.tls_stream.writable()
|
||||
|
||||
|
||||
@when("I check if the thread-local stream is readable")
|
||||
def step_when_check_readable(context: Context) -> None:
|
||||
context.tls_result = context.tls_stream.readable()
|
||||
|
||||
|
||||
@when("I flush the thread-local stream")
|
||||
def step_when_flush(context: Context) -> None:
|
||||
try:
|
||||
context.tls_stream.flush()
|
||||
context.tls_error = None
|
||||
except Exception as exc:
|
||||
context.tls_error = exc
|
||||
|
||||
|
||||
@when("I check if the thread-local stream isatty")
|
||||
def step_when_check_isatty(context: Context) -> None:
|
||||
context.tls_result = context.tls_stream.isatty()
|
||||
|
||||
|
||||
@when('I write "{text}" to the thread-local stream')
|
||||
def step_when_write(context: Context, text: str) -> None:
|
||||
context.tls_stream.write(text)
|
||||
|
||||
|
||||
@when("capture is stopped on the thread-local stream")
|
||||
def step_when_stop_capture(context: Context) -> None:
|
||||
context.tls_captured = context.tls_stream.stop_capture()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then('the encoding should be "{enc}"')
|
||||
def step_then_encoding(context: Context, enc: str) -> None:
|
||||
assert context.tls_result == enc, (
|
||||
f"Expected encoding '{enc}', got '{context.tls_result}'"
|
||||
)
|
||||
|
||||
|
||||
@then("writable should return True")
|
||||
def step_then_writable_true(context: Context) -> None:
|
||||
assert context.tls_result is True, (
|
||||
f"Expected writable()=True, got {context.tls_result}"
|
||||
)
|
||||
|
||||
|
||||
@then("readable should return False")
|
||||
def step_then_readable_false(context: Context) -> None:
|
||||
assert context.tls_result is False, (
|
||||
f"Expected readable()=False, got {context.tls_result}"
|
||||
)
|
||||
|
||||
|
||||
@then("the flush should succeed without error")
|
||||
def step_then_flush_ok(context: Context) -> None:
|
||||
assert context.tls_error is None, f"Expected no error, got {context.tls_error}"
|
||||
|
||||
|
||||
@then("the original stream flush should be called")
|
||||
def step_then_original_flushed(context: Context) -> None:
|
||||
assert context.tls_original._flushed, (
|
||||
"Expected original stream flush() to be called"
|
||||
)
|
||||
|
||||
|
||||
@then("isatty should return True")
|
||||
def step_then_isatty_true(context: Context) -> None:
|
||||
assert context.tls_result is True, (
|
||||
f"Expected isatty()=True, got {context.tls_result}"
|
||||
)
|
||||
|
||||
|
||||
@then("isatty should return False")
|
||||
def step_then_isatty_false(context: Context) -> None:
|
||||
assert context.tls_result is False, (
|
||||
f"Expected isatty()=False, got {context.tls_result}"
|
||||
)
|
||||
|
||||
|
||||
@then('the original stream should have received "{text}"')
|
||||
def step_then_original_received(context: Context, text: str) -> None:
|
||||
assert text in context.tls_original._written, (
|
||||
f"Expected '{text}' in original writes, got {context.tls_original._written}"
|
||||
)
|
||||
|
||||
|
||||
@then('the captured text should be "{text}"')
|
||||
def step_then_captured_text(context: Context, text: str) -> None:
|
||||
assert context.tls_captured == text, (
|
||||
f"Expected captured text '{text}', got '{context.tls_captured}'"
|
||||
)
|
||||
|
||||
|
||||
@then("the captured text should be empty")
|
||||
def step_then_captured_text_empty(context: Context) -> None:
|
||||
assert context.tls_captured == "", (
|
||||
f"Expected empty captured text, got '{context.tls_captured}'"
|
||||
)
|
||||
@@ -0,0 +1,66 @@
|
||||
@unit @coverage @tool_registry
|
||||
Feature: Tool Registry Service - fallback method paths
|
||||
As a developer maintaining the tool registry service
|
||||
I want to exercise the fallback method resolution branches
|
||||
So that all delegation paths in register_tool, update_tool, and remove_tool are covered
|
||||
|
||||
# --- register_tool fallback: no create, uses add -------------------------
|
||||
|
||||
Scenario: register_tool falls back to add when create does not exist
|
||||
Given a tool registry service with a repo that only has add method
|
||||
When I register a tool via the fallback service
|
||||
Then the tool should be registered via the add method
|
||||
And no error should be raised by the fallback service
|
||||
|
||||
Scenario: register_tool falls back to create attribute when neither create nor add are callable
|
||||
Given a tool registry service with a repo that has no create or add methods
|
||||
When I register a tool via the fallback service and expect error
|
||||
Then a fallback AttributeError should be raised
|
||||
|
||||
# --- update_tool with tool_config parameter (dict) ----------------------
|
||||
|
||||
Scenario: update_tool with string tool name and dict config merges them
|
||||
Given a tool registry service with a standard mock repo
|
||||
When I update tool "local/my-tool" with dict config via the fallback service
|
||||
Then the updated tool should have name "local/my-tool"
|
||||
And no error should be raised by the fallback service
|
||||
|
||||
Scenario: update_tool with string tool name and Tool model config uses model
|
||||
Given a tool registry service with a standard mock repo
|
||||
When I update tool "local/my-tool" with Tool model config via the fallback service
|
||||
Then the update should use the Tool model directly
|
||||
And no error should be raised by the fallback service
|
||||
|
||||
Scenario: update_tool with dict tool and dict config uses the dict tool
|
||||
Given a tool registry service with a standard mock repo
|
||||
When I update with dict tool and dict config via the fallback service
|
||||
Then the updated tool should be the original dict
|
||||
And no error should be raised by the fallback service
|
||||
|
||||
# --- remove_tool fallback: no delete, uses remove -----------------------
|
||||
|
||||
Scenario: remove_tool falls back to remove when delete does not exist
|
||||
Given a tool registry service with a repo that only has remove method
|
||||
When I remove tool "local/gone" via the fallback service
|
||||
Then the tool should be removed via the remove method
|
||||
And no error should be raised by the fallback service
|
||||
|
||||
Scenario: remove_tool falls back to delete attribute when neither delete nor remove are callable
|
||||
Given a tool registry service with a repo that has no delete or remove methods
|
||||
When I remove tool "local/gone" via the fallback service and expect error
|
||||
Then a fallback AttributeError should be raised
|
||||
|
||||
# --- list_tools delegates to list_all -----------------------------------
|
||||
|
||||
Scenario: list_tools delegates to repo list_all with filters
|
||||
Given a tool registry service with a standard mock repo
|
||||
When I list tools with namespace "local" and type "validation" via the fallback service
|
||||
Then the list result should be returned from list_all
|
||||
And no error should be raised by the fallback service
|
||||
|
||||
# --- attach_validation with invalid mode --------------------------------
|
||||
|
||||
Scenario: attach_validation raises ValidationError for invalid mode
|
||||
Given a tool registry service with a standard mock repo
|
||||
When I attach validation with invalid mode "bogus" via the fallback service
|
||||
Then a fallback ValidationError should be raised with message containing "Invalid mode"
|
||||
@@ -0,0 +1,235 @@
|
||||
Feature: Validation pipeline
|
||||
As a plan executor
|
||||
I want a validation pipeline that runs validations deterministically
|
||||
So that required failures block and informational failures are reported
|
||||
|
||||
Background:
|
||||
Given a validation pipeline test environment
|
||||
|
||||
# ── Empty pipeline ─────────────────────────────────────────────
|
||||
|
||||
Scenario: Empty validation list returns passing summary
|
||||
Given an empty list of validation commands
|
||||
When the validation pipeline runs
|
||||
Then the validation summary total is 0
|
||||
And all required validations passed
|
||||
|
||||
# ── Single required validation ─────────────────────────────────
|
||||
|
||||
Scenario: Single required validation passes
|
||||
Given a required vp command "check-syntax" on resource "repo-main"
|
||||
And the vp executor returns passed for "check-syntax"
|
||||
When the validation pipeline runs
|
||||
Then the validation summary total is 1
|
||||
And the validation summary required_passed is 1
|
||||
And all required validations passed
|
||||
|
||||
Scenario: Single required validation fails and blocks
|
||||
Given a required vp command "check-syntax" on resource "repo-main"
|
||||
And the vp executor returns failed for "check-syntax" with message "Syntax error on line 42"
|
||||
When the validation pipeline runs
|
||||
Then the validation summary total is 1
|
||||
And the validation summary required_failed is 1
|
||||
And not all required validations passed
|
||||
|
||||
# ── Single informational validation ────────────────────────────
|
||||
|
||||
Scenario: Single informational validation passes
|
||||
Given an informational vp command "lint-check" on resource "repo-main"
|
||||
And the vp executor returns passed for "lint-check"
|
||||
When the validation pipeline runs
|
||||
Then the validation summary total is 1
|
||||
And the validation summary informational_passed is 1
|
||||
And all required validations passed
|
||||
|
||||
Scenario: Single informational validation fails but does not block
|
||||
Given an informational vp command "lint-check" on resource "repo-main"
|
||||
And the vp executor returns failed for "lint-check" with message "Linting warnings found"
|
||||
When the validation pipeline runs
|
||||
Then the validation summary total is 1
|
||||
And the validation summary informational_failed is 1
|
||||
And all required validations passed
|
||||
|
||||
# ── Multiple validations sorted by resource/mode/name ──────────
|
||||
|
||||
Scenario: Multiple validations are sorted by resource name then mode then name
|
||||
Given a required vp command "z-check" on resource "beta-repo"
|
||||
And a required vp command "a-check" on resource "alpha-repo"
|
||||
And an informational vp command "b-lint" on resource "alpha-repo"
|
||||
And the vp executor returns passed for all validations
|
||||
When the validation pipeline runs
|
||||
Then the validation results are sorted by resource then mode then name
|
||||
And the validation summary total is 3
|
||||
|
||||
# ── Timeout handling ───────────────────────────────────────────
|
||||
|
||||
Scenario: Validation that exceeds timeout is marked as timed out
|
||||
Given a required vp command "slow-check" on resource "repo-main" with timeout 0.2
|
||||
And the vp executor will timeout for "slow-check"
|
||||
When the validation pipeline runs
|
||||
Then the vp result for "slow-check" has timed_out true
|
||||
And the vp result for "slow-check" has passed false
|
||||
And the vp result for "slow-check" has error containing "TimeoutError"
|
||||
|
||||
# ── Error normalisation ────────────────────────────────────────
|
||||
|
||||
Scenario: Executor returning non-dict output is normalised to failure
|
||||
Given a required vp command "broken-check" on resource "repo-main"
|
||||
And the vp executor returns non-dict output for "broken-check"
|
||||
When the validation pipeline runs
|
||||
Then the vp result for "broken-check" has passed false
|
||||
And the vp result for "broken-check" has message containing "non-dict"
|
||||
|
||||
Scenario: Executor missing passed key defaults to false
|
||||
Given a required vp command "partial-check" on resource "repo-main"
|
||||
And the vp executor returns dict without passed key for "partial-check"
|
||||
When the validation pipeline runs
|
||||
Then the vp result for "partial-check" has passed false
|
||||
|
||||
Scenario: Executor raising exception is captured as error
|
||||
Given a required vp command "error-check" on resource "repo-main"
|
||||
And the vp executor raises an exception for "error-check"
|
||||
When the validation pipeline runs
|
||||
Then the vp result for "error-check" has passed false
|
||||
And the vp result for "error-check" has error containing "RuntimeError"
|
||||
|
||||
# ── Required + informational mixed results ─────────────────────
|
||||
|
||||
Scenario: Mixed required and informational with all required passing
|
||||
Given a required vp command "req-check" on resource "repo-main"
|
||||
And an informational vp command "info-lint" on resource "repo-main"
|
||||
And the vp executor returns passed for "req-check"
|
||||
And the vp executor returns failed for "info-lint" with message "Info warning"
|
||||
When the validation pipeline runs
|
||||
Then the validation summary required_passed is 1
|
||||
And the validation summary informational_failed is 1
|
||||
And all required validations passed
|
||||
|
||||
Scenario: Mixed validations where required fails blocks
|
||||
Given a required vp command "req-check" on resource "repo-main"
|
||||
And an informational vp command "info-lint" on resource "repo-main"
|
||||
And the vp executor returns failed for "req-check" with message "Required failed"
|
||||
And the vp executor returns passed for "info-lint"
|
||||
When the validation pipeline runs
|
||||
Then the validation summary required_failed is 1
|
||||
And the validation summary informational_passed is 1
|
||||
And not all required validations passed
|
||||
|
||||
# ── Summary counts verification ────────────────────────────────
|
||||
|
||||
Scenario: Summary counts are accurate for multiple validations
|
||||
Given a required vp command "req-a" on resource "repo-1"
|
||||
And a required vp command "req-b" on resource "repo-1"
|
||||
And an informational vp command "info-a" on resource "repo-1"
|
||||
And an informational vp command "info-b" on resource "repo-1"
|
||||
And the vp executor returns passed for "req-a"
|
||||
And the vp executor returns failed for "req-b" with message "Failed"
|
||||
And the vp executor returns passed for "info-a"
|
||||
And the vp executor returns failed for "info-b" with message "Info fail"
|
||||
When the validation pipeline runs
|
||||
Then the validation summary total is 4
|
||||
And the validation summary required_passed is 1
|
||||
And the validation summary required_failed is 1
|
||||
And the validation summary informational_passed is 1
|
||||
And the validation summary informational_failed is 1
|
||||
|
||||
# ── Grouping by resource ───────────────────────────────────────
|
||||
|
||||
Scenario: Validation results can be grouped by resource
|
||||
Given a required vp command "check-a" on resource "repo-alpha"
|
||||
And a required vp command "check-b" on resource "repo-beta"
|
||||
And the vp executor returns passed for all validations
|
||||
When the validation pipeline runs
|
||||
Then the results grouped by resource have 2 groups
|
||||
And the vp resource group "repo-alpha" has 1 result
|
||||
And the vp resource group "repo-beta" has 1 result
|
||||
|
||||
# ── Read-only resource guard ───────────────────────────────────
|
||||
|
||||
Scenario: Skip validation on read-only resource
|
||||
Given a required vp command "write-check" on resource "readonly-repo" having resource_id "RO001"
|
||||
And the vp resource "RO001" is marked as read-only
|
||||
And the vp executor returns passed for all validations
|
||||
When the validation pipeline runs
|
||||
Then the vp result for "write-check" has passed true
|
||||
And the vp result for "write-check" has message containing "read-only"
|
||||
|
||||
# ── ValidationSummary all_required_passed property ─────────────
|
||||
|
||||
Scenario: ValidationSummary all_required_passed is true when no required failures
|
||||
Given a validation summary with 0 required failures
|
||||
Then the summary all_required_passed property is true
|
||||
|
||||
Scenario: ValidationSummary all_required_passed is false when required failures exist
|
||||
Given a validation summary with 2 required failures
|
||||
Then the summary all_required_passed property is false
|
||||
|
||||
# ── Pipeline with max_workers=1 ────────────────────────────────
|
||||
|
||||
Scenario: Pipeline with max_workers=1 executes sequentially
|
||||
Given a required vp command "seq-a" on resource "repo-1"
|
||||
And a required vp command "seq-b" on resource "repo-1"
|
||||
And the vp executor returns passed for all validations
|
||||
And the pipeline max_workers is set to 1
|
||||
When the validation pipeline runs
|
||||
Then the validation summary total is 2
|
||||
And all required validations passed
|
||||
|
||||
# ── Validation with data payload ───────────────────────────────
|
||||
|
||||
Scenario: Validation result includes data payload
|
||||
Given a required vp command "data-check" on resource "repo-main"
|
||||
And the vp executor returns passed with data for "data-check"
|
||||
When the validation pipeline runs
|
||||
Then the vp result for "data-check" has data key "detail"
|
||||
|
||||
# ── run_for_plan integration ───────────────────────────────────
|
||||
|
||||
Scenario: run_for_plan stores summary in plan metadata
|
||||
Given a required vp command "plan-check" on resource "repo-main"
|
||||
And the vp executor returns passed for "plan-check"
|
||||
When the validation pipeline runs for plan with metadata
|
||||
Then the plan metadata contains validation_summary
|
||||
And the plan metadata validation_summary total is 1
|
||||
|
||||
# ── Normalisation edge cases ───────────────────────────────────
|
||||
|
||||
Scenario: Executor returns non-bool passed value is normalised to false
|
||||
Given a required vp command "nonbool-check" on resource "repo-main"
|
||||
And the vp executor returns non-bool passed for "nonbool-check"
|
||||
When the validation pipeline runs
|
||||
Then the vp result for "nonbool-check" has passed false
|
||||
|
||||
Scenario: Executor returns non-string message is coerced to string
|
||||
Given a required vp command "nonstr-msg" on resource "repo-main"
|
||||
And the vp executor returns non-string message for "nonstr-msg"
|
||||
When the validation pipeline runs
|
||||
Then the vp result for "nonstr-msg" has passed true
|
||||
|
||||
Scenario: Executor returns non-dict data is wrapped in raw key
|
||||
Given a required vp command "nondict-data" on resource "repo-main"
|
||||
And the vp executor returns non-dict data for "nondict-data"
|
||||
When the validation pipeline runs
|
||||
Then the vp result for "nondict-data" has data key "raw"
|
||||
|
||||
# ── Stdout/stderr capture ──────────────────────────────────────
|
||||
|
||||
Scenario: Validation that prints to stdout has output captured
|
||||
Given a required vp command "stdout-check" on resource "repo-main"
|
||||
And the vp executor prints to stdout for "stdout-check"
|
||||
When the validation pipeline runs
|
||||
Then the vp result for "stdout-check" has data key "_captured_stdout"
|
||||
|
||||
Scenario: Validation that prints to stderr has output captured
|
||||
Given a required vp command "stderr-check" on resource "repo-main"
|
||||
And the vp executor prints to stderr for "stderr-check"
|
||||
When the validation pipeline runs
|
||||
Then the vp result for "stderr-check" has data key "_captured_stderr"
|
||||
|
||||
# ── Duration tracking ────────────────────────────────────────────
|
||||
|
||||
Scenario: Validation result has positive duration
|
||||
Given a required vp command "dur-check" on resource "repo-main"
|
||||
And the vp executor returns passed for "dur-check"
|
||||
When the validation pipeline runs
|
||||
Then the vp result for "dur-check" has positive duration_ms
|
||||
@@ -0,0 +1,76 @@
|
||||
@unit @coverage @validation_pipeline
|
||||
Feature: Validation pipeline _ThreadLocalStream coverage
|
||||
As a developer maintaining the validation pipeline
|
||||
I want to exercise the _ThreadLocalStream helper methods
|
||||
So that all stream-interface methods have coverage
|
||||
|
||||
Background:
|
||||
Given a thread-local stream wrapping a mock original stream
|
||||
|
||||
# --- encoding property ---------------------------------------------------
|
||||
|
||||
Scenario: encoding property returns the original stream encoding
|
||||
Given the original stream has encoding "utf-8"
|
||||
When I read the thread-local stream encoding
|
||||
Then the encoding should be "utf-8"
|
||||
|
||||
Scenario: encoding property falls back to utf-8 when original has no encoding
|
||||
Given the original stream has no encoding attribute
|
||||
When I read the thread-local stream encoding
|
||||
Then the encoding should be "utf-8"
|
||||
|
||||
# --- writable method -----------------------------------------------------
|
||||
|
||||
Scenario: writable returns True
|
||||
When I check if the thread-local stream is writable
|
||||
Then writable should return True
|
||||
|
||||
# --- readable method -----------------------------------------------------
|
||||
|
||||
Scenario: readable returns False
|
||||
When I check if the thread-local stream is readable
|
||||
Then readable should return False
|
||||
|
||||
# --- flush method with active buffer -------------------------------------
|
||||
|
||||
Scenario: flush flushes the capture buffer when capturing is active
|
||||
Given capture is started on the thread-local stream
|
||||
When I write "hello" to the thread-local stream
|
||||
And I flush the thread-local stream
|
||||
Then the flush should succeed without error
|
||||
|
||||
# --- flush method without active buffer ----------------------------------
|
||||
|
||||
Scenario: flush flushes the original stream when no capture is active
|
||||
When I flush the thread-local stream
|
||||
Then the original stream flush should be called
|
||||
|
||||
# --- isatty method -------------------------------------------------------
|
||||
|
||||
Scenario: isatty delegates to the original stream
|
||||
Given the original stream isatty returns True
|
||||
When I check if the thread-local stream isatty
|
||||
Then isatty should return True
|
||||
|
||||
Scenario: isatty returns False when original returns False
|
||||
Given the original stream isatty returns False
|
||||
When I check if the thread-local stream isatty
|
||||
Then isatty should return False
|
||||
|
||||
# --- write with and without capture ------------------------------------
|
||||
|
||||
Scenario: write without capture goes to original stream
|
||||
When I write "direct output" to the thread-local stream
|
||||
Then the original stream should have received "direct output"
|
||||
|
||||
Scenario: write with capture goes to capture buffer
|
||||
Given capture is started on the thread-local stream
|
||||
When I write "captured output" to the thread-local stream
|
||||
And capture is stopped on the thread-local stream
|
||||
Then the captured text should be "captured output"
|
||||
|
||||
# --- stop_capture when no capture was started ----------------------------
|
||||
|
||||
Scenario: stop_capture returns empty string when no capture was started
|
||||
When capture is stopped on the thread-local stream
|
||||
Then the captured text should be empty
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Helper script for Robot Framework validation pipeline tests.
|
||||
|
||||
Self-contained Python helper that exercises the ValidationPipeline
|
||||
and its models, printing sentinel strings on success and exiting
|
||||
with code 1 on failure.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# Ensure src is importable when run from workspace root
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
|
||||
from cleveragents.application.services.validation_pipeline import (
|
||||
ValidationCommand,
|
||||
ValidationPipeline,
|
||||
ValidationResult,
|
||||
ValidationSummary,
|
||||
)
|
||||
from cleveragents.domain.models.core.tool import ValidationMode
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mock executor
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _mock_executor(validation_name: str, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Simple mock executor that always passes."""
|
||||
return {"passed": True, "message": f"{validation_name} passed"}
|
||||
|
||||
|
||||
def _failing_executor(
|
||||
validation_name: str, arguments: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Mock executor that always fails."""
|
||||
return {"passed": False, "message": f"{validation_name} failed"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sub-commands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_models() -> None:
|
||||
"""Verify ValidationCommand, ValidationResult, ValidationSummary models."""
|
||||
cmd = ValidationCommand(
|
||||
validation_name="check-syntax",
|
||||
resource_id="R00001",
|
||||
resource_name="repo-main",
|
||||
mode=ValidationMode.REQUIRED,
|
||||
arguments={"key": "value"},
|
||||
timeout_seconds=15.0,
|
||||
)
|
||||
assert cmd.validation_name == "check-syntax"
|
||||
assert cmd.mode == ValidationMode.REQUIRED
|
||||
assert cmd.timeout_seconds == 15.0
|
||||
|
||||
result = ValidationResult(
|
||||
validation_name="check-syntax",
|
||||
resource_id="R00001",
|
||||
resource_name="repo-main",
|
||||
mode=ValidationMode.REQUIRED,
|
||||
passed=True,
|
||||
message="All good",
|
||||
data={"detail": "ok"},
|
||||
duration_ms=42.5,
|
||||
error=None,
|
||||
timed_out=False,
|
||||
)
|
||||
assert result.passed is True
|
||||
assert result.duration_ms == 42.5
|
||||
|
||||
summary = ValidationSummary(
|
||||
total=2,
|
||||
required_passed=1,
|
||||
required_failed=1,
|
||||
informational_passed=0,
|
||||
informational_failed=0,
|
||||
results=[],
|
||||
)
|
||||
assert summary.all_required_passed is False
|
||||
print("models-ok")
|
||||
|
||||
|
||||
def test_empty_pipeline() -> None:
|
||||
"""Verify empty pipeline returns passing summary."""
|
||||
pipeline = ValidationPipeline(
|
||||
commands=[],
|
||||
executor=_mock_executor,
|
||||
)
|
||||
summary = pipeline.run()
|
||||
assert summary.total == 0
|
||||
assert summary.all_required_passed is True
|
||||
print("empty-pipeline-ok")
|
||||
|
||||
|
||||
def test_passing_pipeline() -> None:
|
||||
"""Verify pipeline with passing validations."""
|
||||
commands = [
|
||||
ValidationCommand(
|
||||
validation_name="check-a",
|
||||
resource_id="R1",
|
||||
resource_name="repo",
|
||||
mode=ValidationMode.REQUIRED,
|
||||
),
|
||||
ValidationCommand(
|
||||
validation_name="check-b",
|
||||
resource_id="R1",
|
||||
resource_name="repo",
|
||||
mode=ValidationMode.INFORMATIONAL,
|
||||
),
|
||||
]
|
||||
pipeline = ValidationPipeline(
|
||||
commands=commands,
|
||||
executor=_mock_executor,
|
||||
max_workers=1,
|
||||
)
|
||||
summary = pipeline.run()
|
||||
assert summary.total == 2
|
||||
assert summary.required_passed == 1
|
||||
assert summary.informational_passed == 1
|
||||
assert summary.all_required_passed is True
|
||||
print("passing-pipeline-ok")
|
||||
|
||||
|
||||
def test_failing_required() -> None:
|
||||
"""Verify failing required validation blocks."""
|
||||
commands = [
|
||||
ValidationCommand(
|
||||
validation_name="check-fail",
|
||||
resource_id="R1",
|
||||
resource_name="repo",
|
||||
mode=ValidationMode.REQUIRED,
|
||||
),
|
||||
]
|
||||
pipeline = ValidationPipeline(
|
||||
commands=commands,
|
||||
executor=_failing_executor,
|
||||
max_workers=1,
|
||||
)
|
||||
summary = pipeline.run()
|
||||
assert summary.total == 1
|
||||
assert summary.required_failed == 1
|
||||
assert summary.all_required_passed is False
|
||||
print("failing-required-ok")
|
||||
|
||||
|
||||
def test_run_for_plan() -> None:
|
||||
"""Verify run_for_plan stores summary in metadata."""
|
||||
commands = [
|
||||
ValidationCommand(
|
||||
validation_name="plan-check",
|
||||
resource_id="R1",
|
||||
resource_name="repo",
|
||||
mode=ValidationMode.REQUIRED,
|
||||
),
|
||||
]
|
||||
pipeline = ValidationPipeline(
|
||||
commands=commands,
|
||||
executor=_mock_executor,
|
||||
max_workers=1,
|
||||
)
|
||||
metadata: dict[str, Any] = {}
|
||||
summary = pipeline.run_for_plan(plan_metadata=metadata)
|
||||
assert summary.total == 1
|
||||
assert "validation_summary" in metadata
|
||||
assert metadata["validation_summary"]["total"] == 1
|
||||
assert metadata["validation_summary"]["all_required_passed"] is True
|
||||
print("run-for-plan-ok")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if __name__ == "__main__":
|
||||
cmd = sys.argv[1] if len(sys.argv) > 1 else "models"
|
||||
commands_map = {
|
||||
"models": test_models,
|
||||
"empty_pipeline": test_empty_pipeline,
|
||||
"passing_pipeline": test_passing_pipeline,
|
||||
"failing_required": test_failing_required,
|
||||
"run_for_plan": test_run_for_plan,
|
||||
}
|
||||
handler = commands_map.get(cmd)
|
||||
if handler is None:
|
||||
print(f"Unknown command: {cmd}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
try:
|
||||
handler()
|
||||
except Exception as exc:
|
||||
print(f"FAIL: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,47 @@
|
||||
*** Settings ***
|
||||
Documentation Smoke tests for the validation pipeline service
|
||||
Library Process
|
||||
Library OperatingSystem
|
||||
Resource ${CURDIR}/common.resource
|
||||
Suite Setup Setup Test Environment
|
||||
Suite Teardown Cleanup Test Environment
|
||||
|
||||
*** Variables ***
|
||||
${HELPER_SCRIPT} robot/helper_validation_pipeline.py
|
||||
|
||||
*** Test Cases ***
|
||||
Validation Pipeline Models Are Valid
|
||||
[Documentation] Verify ValidationCommand, ValidationResult, ValidationSummary Pydantic models
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} models cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} models-ok
|
||||
|
||||
Validation Pipeline Empty Pipeline Returns Passing Summary
|
||||
[Documentation] Empty command list produces a zero-total passing summary
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} empty_pipeline cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} empty-pipeline-ok
|
||||
|
||||
Validation Pipeline Passing Validations
|
||||
[Documentation] Pipeline with passing required and informational validations
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} passing_pipeline cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} passing-pipeline-ok
|
||||
|
||||
Validation Pipeline Failing Required Blocks
|
||||
[Documentation] A failing required validation sets all_required_passed to False
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} failing_required cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} failing-required-ok
|
||||
|
||||
Validation Pipeline Run For Plan Integration
|
||||
[Documentation] run_for_plan stores validation_summary in plan metadata dict
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} run_for_plan cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} run-for-plan-ok
|
||||
|
||||
Validation Pipeline Unknown Command Returns Error
|
||||
[Documentation] Running with unknown command exits with code 1
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} nonexistent_cmd cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 1
|
||||
Should Contain ${result.stderr} Unknown command
|
||||
@@ -38,6 +38,12 @@ from cleveragents.application.services.validation_apply import (
|
||||
ValidationAttachment,
|
||||
ValidationRunner,
|
||||
)
|
||||
from cleveragents.application.services.validation_pipeline import (
|
||||
ValidationCommand,
|
||||
ValidationPipeline,
|
||||
ValidationResult,
|
||||
ValidationSummary,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ApplyValidationGate",
|
||||
@@ -58,5 +64,9 @@ __all__ = [
|
||||
"SkillRegistryService",
|
||||
"ToolRegistryService",
|
||||
"ValidationAttachment",
|
||||
"ValidationCommand",
|
||||
"ValidationPipeline",
|
||||
"ValidationResult",
|
||||
"ValidationRunner",
|
||||
"ValidationSummary",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,569 @@
|
||||
"""Validation pipeline service for CleverAgents.
|
||||
|
||||
Orchestrates execution of validation commands against resources,
|
||||
enforces required vs informational semantics, and produces an
|
||||
aggregated validation summary.
|
||||
|
||||
The pipeline:
|
||||
- Sorts validations deterministically by (resource_name, mode, validation_name)
|
||||
- Groups validations by resource for organised reporting
|
||||
- Executes each validation with a configurable timeout
|
||||
- Captures stdout/stderr for troubleshooting
|
||||
- Normalises output to a consistent passed/message/data schema
|
||||
- Builds a ValidationSummary with aggregated counts
|
||||
- Integrates with the plan lifecycle for metadata persistence
|
||||
|
||||
Based on ``docs/specification.md`` and ADR-013 (Validation Abstraction).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Any, Protocol
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from cleveragents.domain.models.core.tool import ValidationMode
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Thread-safe stdout/stderr capture
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _ThreadLocalStream(io.TextIOBase):
|
||||
"""Stream wrapper providing thread-local output capture.
|
||||
|
||||
Non-capturing threads write through to the original stream.
|
||||
Threads that call ``start_capture()`` collect writes in a
|
||||
per-thread ``StringIO`` buffer until ``stop_capture()`` returns
|
||||
the captured text.
|
||||
"""
|
||||
|
||||
def __init__(self, original: Any) -> None:
|
||||
self._original = original
|
||||
self._local = threading.local()
|
||||
|
||||
@property
|
||||
def encoding(self) -> str: # type: ignore[override]
|
||||
return getattr(self._original, "encoding", "utf-8")
|
||||
|
||||
def writable(self) -> bool:
|
||||
return True
|
||||
|
||||
def readable(self) -> bool:
|
||||
return False
|
||||
|
||||
def write(self, s: str) -> int:
|
||||
buf: io.StringIO | None = getattr(self._local, "buffer", None)
|
||||
if buf is not None:
|
||||
return buf.write(s)
|
||||
return self._original.write(s)
|
||||
|
||||
def flush(self) -> None:
|
||||
buf: io.StringIO | None = getattr(self._local, "buffer", None)
|
||||
if buf is not None:
|
||||
buf.flush()
|
||||
self._original.flush()
|
||||
|
||||
def isatty(self) -> bool:
|
||||
return self._original.isatty()
|
||||
|
||||
def start_capture(self) -> None:
|
||||
"""Begin capturing writes for the current thread."""
|
||||
self._local.buffer = io.StringIO()
|
||||
|
||||
def stop_capture(self) -> str:
|
||||
"""Stop capturing and return captured text for the current thread."""
|
||||
buf: io.StringIO | None = getattr(self._local, "buffer", None)
|
||||
self._local.buffer = None
|
||||
return buf.getvalue() if buf else ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Type alias for validation executor callables
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ValidationExecutor(Protocol):
|
||||
"""Protocol for a callable that executes a single validation."""
|
||||
|
||||
def __call__(
|
||||
self, validation_name: str, arguments: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Execute a validation and return result dict.
|
||||
|
||||
Must return a dict with at least:
|
||||
- ``passed`` (bool): whether the validation passed
|
||||
- ``message`` (str): human-readable result description
|
||||
|
||||
May optionally include:
|
||||
- ``data`` (dict): additional structured data
|
||||
"""
|
||||
... # pragma: no cover
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ValidationCommand(BaseModel):
|
||||
"""Command describing a single validation to execute.
|
||||
|
||||
Attributes:
|
||||
validation_name: Name of the validation to run.
|
||||
resource_id: ULID of the resource being validated.
|
||||
resource_name: Human-readable resource name.
|
||||
mode: Whether failure blocks (required) or is informational.
|
||||
arguments: Arguments to pass to the validation executor.
|
||||
timeout_seconds: Maximum execution time in seconds.
|
||||
"""
|
||||
|
||||
validation_name: str = Field(
|
||||
..., min_length=1, description="Name of the validation"
|
||||
)
|
||||
resource_id: str = Field(..., min_length=1, description="Resource ULID")
|
||||
resource_name: str = Field(
|
||||
..., min_length=1, description="Human-readable resource name"
|
||||
)
|
||||
mode: ValidationMode = Field(..., description="Required or informational")
|
||||
arguments: dict[str, Any] = Field(
|
||||
default_factory=dict, description="Arguments for the validation"
|
||||
)
|
||||
timeout_seconds: float = Field(
|
||||
default=30.0, ge=0.1, description="Timeout in seconds"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(
|
||||
str_strip_whitespace=True,
|
||||
validate_assignment=True,
|
||||
use_enum_values=False,
|
||||
)
|
||||
|
||||
|
||||
class ValidationResult(BaseModel):
|
||||
"""Result of executing a single validation.
|
||||
|
||||
Attributes:
|
||||
validation_name: Name of the executed validation.
|
||||
resource_id: ULID of the validated resource.
|
||||
resource_name: Human-readable resource name.
|
||||
mode: Whether the validation was required or informational.
|
||||
passed: Whether the validation passed.
|
||||
message: Human-readable result description.
|
||||
data: Optional structured result data.
|
||||
duration_ms: Execution duration in milliseconds.
|
||||
error: Error message if the validation raised an exception.
|
||||
timed_out: Whether the validation exceeded its timeout.
|
||||
"""
|
||||
|
||||
validation_name: str = Field(..., description="Name of the validation")
|
||||
resource_id: str = Field(..., description="Resource ULID")
|
||||
resource_name: str = Field(..., description="Human-readable resource name")
|
||||
mode: ValidationMode = Field(..., description="Required or informational")
|
||||
passed: bool = Field(..., description="Whether the validation passed")
|
||||
message: str = Field(..., description="Human-readable result description")
|
||||
data: dict[str, Any] | None = Field(
|
||||
default=None, description="Optional structured data"
|
||||
)
|
||||
duration_ms: float = Field(..., ge=0.0, description="Duration in milliseconds")
|
||||
error: str | None = Field(
|
||||
default=None, description="Error message if validation raised"
|
||||
)
|
||||
timed_out: bool = Field(
|
||||
default=False, description="Whether the validation timed out"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(
|
||||
str_strip_whitespace=True,
|
||||
validate_assignment=True,
|
||||
use_enum_values=False,
|
||||
)
|
||||
|
||||
|
||||
class ValidationSummary(BaseModel):
|
||||
"""Aggregated summary of all validation results.
|
||||
|
||||
Attributes:
|
||||
total: Total number of validations executed.
|
||||
required_passed: Count of required validations that passed.
|
||||
required_failed: Count of required validations that failed.
|
||||
informational_passed: Count of informational validations that passed.
|
||||
informational_failed: Count of informational validations that failed.
|
||||
results: Individual validation results.
|
||||
"""
|
||||
|
||||
total: int = Field(..., ge=0, description="Total validations executed")
|
||||
required_passed: int = Field(..., ge=0, description="Required validations passed")
|
||||
required_failed: int = Field(..., ge=0, description="Required validations failed")
|
||||
informational_passed: int = Field(
|
||||
..., ge=0, description="Informational validations passed"
|
||||
)
|
||||
informational_failed: int = Field(
|
||||
..., ge=0, description="Informational validations failed"
|
||||
)
|
||||
results: list[ValidationResult] = Field(
|
||||
default_factory=list, description="Individual results"
|
||||
)
|
||||
|
||||
@property
|
||||
def all_required_passed(self) -> bool:
|
||||
"""Return True if all required validations passed."""
|
||||
return self.required_failed == 0
|
||||
|
||||
model_config = ConfigDict(
|
||||
str_strip_whitespace=True,
|
||||
validate_assignment=True,
|
||||
use_enum_values=False,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pipeline
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _normalise_executor_output(raw: Any) -> dict[str, Any]:
|
||||
"""Normalise raw executor output to passed/message/data schema.
|
||||
|
||||
Handles various error conditions:
|
||||
- Non-dict output is coerced to a failed result
|
||||
- Missing ``passed`` defaults to False
|
||||
- Missing ``message`` gets a default string
|
||||
"""
|
||||
if not isinstance(raw, dict):
|
||||
return {
|
||||
"passed": False,
|
||||
"message": f"Validation returned non-dict output: {raw!r}",
|
||||
"data": None,
|
||||
}
|
||||
passed = raw.get("passed", False)
|
||||
if not isinstance(passed, bool):
|
||||
passed = False
|
||||
message = raw.get("message", "No message provided")
|
||||
if not isinstance(message, str):
|
||||
message = str(message)
|
||||
data = raw.get("data")
|
||||
if data is not None and not isinstance(data, dict):
|
||||
data = {"raw": data}
|
||||
return {"passed": passed, "message": message, "data": data}
|
||||
|
||||
|
||||
class ValidationPipeline:
|
||||
"""Orchestrates validation execution with deterministic ordering.
|
||||
|
||||
The pipeline sorts validations by ``(resource_name, mode, validation_name)``
|
||||
for deterministic execution, groups them by resource for reporting, and
|
||||
enforces required vs informational semantics.
|
||||
|
||||
Args:
|
||||
commands: List of validation commands to execute.
|
||||
executor: Callable that runs a single validation.
|
||||
max_workers: Maximum concurrent validation threads.
|
||||
read_only_resources: Set of resource IDs that are read-only.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
commands: list[ValidationCommand],
|
||||
executor: Callable[[str, dict[str, Any]], dict[str, Any]],
|
||||
max_workers: int = 4,
|
||||
read_only_resources: set[str] | None = None,
|
||||
) -> None:
|
||||
self._commands = self._sort_commands(commands)
|
||||
self._executor = executor
|
||||
self._max_workers = max_workers
|
||||
self._read_only_resources: set[str] = read_only_resources or set()
|
||||
|
||||
@staticmethod
|
||||
def _sort_commands(
|
||||
commands: list[ValidationCommand],
|
||||
) -> list[ValidationCommand]:
|
||||
"""Sort commands by (resource_name, mode, validation_name)."""
|
||||
return sorted(
|
||||
commands,
|
||||
key=lambda c: (c.resource_name, c.mode.value, c.validation_name),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def group_by_resource(
|
||||
results: list[ValidationResult],
|
||||
) -> dict[str, list[ValidationResult]]:
|
||||
"""Group validation results by resource_name."""
|
||||
groups: dict[str, list[ValidationResult]] = defaultdict(list)
|
||||
for result in results:
|
||||
groups[result.resource_name].append(result)
|
||||
return dict(groups)
|
||||
|
||||
def _execute_single(self, cmd: ValidationCommand) -> ValidationResult:
|
||||
"""Execute a single validation command with timeout and output capture.
|
||||
|
||||
Uses a daemon thread for timeout enforcement so that timed-out
|
||||
tasks are abandoned immediately without blocking the caller.
|
||||
Stdout/stderr capture is thread-safe via ``_ThreadLocalStream``.
|
||||
"""
|
||||
# Check read-only resource guard
|
||||
if cmd.resource_id in self._read_only_resources:
|
||||
logger.warning(
|
||||
"Skipping validation '%s' on read-only resource '%s' (%s)",
|
||||
cmd.validation_name,
|
||||
cmd.resource_name,
|
||||
cmd.resource_id,
|
||||
)
|
||||
return ValidationResult(
|
||||
validation_name=cmd.validation_name,
|
||||
resource_id=cmd.resource_id,
|
||||
resource_name=cmd.resource_name,
|
||||
mode=cmd.mode,
|
||||
passed=True,
|
||||
message=(f"Skipped: resource '{cmd.resource_name}' is read-only"),
|
||||
data=None,
|
||||
duration_ms=0.0,
|
||||
error=None,
|
||||
timed_out=False,
|
||||
)
|
||||
|
||||
start = time.monotonic()
|
||||
|
||||
# Cross-thread communication holders
|
||||
result_holder: list[Any] = []
|
||||
error_holder: list[Exception] = []
|
||||
stdout_holder: list[str] = [""]
|
||||
stderr_holder: list[str] = [""]
|
||||
|
||||
def _run_in_thread() -> None:
|
||||
# Activate per-thread capture if stream wrappers are installed
|
||||
tl_out = sys.stdout
|
||||
tl_err = sys.stderr
|
||||
capture_out = isinstance(tl_out, _ThreadLocalStream)
|
||||
capture_err = isinstance(tl_err, _ThreadLocalStream)
|
||||
if capture_out:
|
||||
tl_out.start_capture()
|
||||
if capture_err:
|
||||
tl_err.start_capture()
|
||||
try:
|
||||
raw = self._executor(cmd.validation_name, cmd.arguments)
|
||||
result_holder.append(raw)
|
||||
except Exception as exc:
|
||||
error_holder.append(exc)
|
||||
finally:
|
||||
if capture_out:
|
||||
stdout_holder[0] = tl_out.stop_capture()
|
||||
if capture_err:
|
||||
stderr_holder[0] = tl_err.stop_capture()
|
||||
|
||||
thread = threading.Thread(target=_run_in_thread, daemon=True)
|
||||
thread.start()
|
||||
thread.join(timeout=cmd.timeout_seconds)
|
||||
elapsed_ms = (time.monotonic() - start) * 1000
|
||||
|
||||
if thread.is_alive():
|
||||
# Timeout: daemon thread is abandoned (cleaned up at exit)
|
||||
return ValidationResult(
|
||||
validation_name=cmd.validation_name,
|
||||
resource_id=cmd.resource_id,
|
||||
resource_name=cmd.resource_name,
|
||||
mode=cmd.mode,
|
||||
passed=False,
|
||||
message=(f"Validation timed out after {cmd.timeout_seconds}s"),
|
||||
data=None,
|
||||
duration_ms=elapsed_ms,
|
||||
error=f"TimeoutError: exceeded {cmd.timeout_seconds}s",
|
||||
timed_out=True,
|
||||
)
|
||||
|
||||
if error_holder:
|
||||
exc = error_holder[0]
|
||||
return ValidationResult(
|
||||
validation_name=cmd.validation_name,
|
||||
resource_id=cmd.resource_id,
|
||||
resource_name=cmd.resource_name,
|
||||
mode=cmd.mode,
|
||||
passed=False,
|
||||
message=f"Validation raised {type(exc).__name__}: {exc}",
|
||||
data=None,
|
||||
duration_ms=elapsed_ms,
|
||||
error=f"{type(exc).__name__}: {exc}",
|
||||
timed_out=False,
|
||||
)
|
||||
|
||||
raw_result = result_holder[0] if result_holder else None
|
||||
normalised = _normalise_executor_output(raw_result)
|
||||
|
||||
# Merge captured output into data
|
||||
stdout_text = stdout_holder[0]
|
||||
stderr_text = stderr_holder[0]
|
||||
result_data = normalised["data"]
|
||||
if stdout_text or stderr_text:
|
||||
if result_data is None:
|
||||
result_data = {}
|
||||
if stdout_text:
|
||||
result_data["_captured_stdout"] = stdout_text
|
||||
if stderr_text:
|
||||
result_data["_captured_stderr"] = stderr_text
|
||||
|
||||
return ValidationResult(
|
||||
validation_name=cmd.validation_name,
|
||||
resource_id=cmd.resource_id,
|
||||
resource_name=cmd.resource_name,
|
||||
mode=cmd.mode,
|
||||
passed=normalised["passed"],
|
||||
message=normalised["message"],
|
||||
data=result_data if result_data else None,
|
||||
duration_ms=elapsed_ms,
|
||||
error=None,
|
||||
timed_out=False,
|
||||
)
|
||||
|
||||
def _build_summary(self, results: list[ValidationResult]) -> ValidationSummary:
|
||||
"""Build an aggregated summary from individual results."""
|
||||
required_passed = 0
|
||||
required_failed = 0
|
||||
informational_passed = 0
|
||||
informational_failed = 0
|
||||
|
||||
for r in results:
|
||||
if r.mode == ValidationMode.REQUIRED:
|
||||
if r.passed:
|
||||
required_passed += 1
|
||||
else:
|
||||
required_failed += 1
|
||||
else:
|
||||
if r.passed:
|
||||
informational_passed += 1
|
||||
else:
|
||||
informational_failed += 1
|
||||
|
||||
return ValidationSummary(
|
||||
total=len(results),
|
||||
required_passed=required_passed,
|
||||
required_failed=required_failed,
|
||||
informational_passed=informational_passed,
|
||||
informational_failed=informational_failed,
|
||||
results=results,
|
||||
)
|
||||
|
||||
def run(self) -> ValidationSummary:
|
||||
"""Execute all validations and return a summary.
|
||||
|
||||
Validations are executed concurrently up to ``max_workers``.
|
||||
Required failures cause ``all_required_passed`` to be False.
|
||||
Informational failures are logged but do not block.
|
||||
|
||||
Returns:
|
||||
ValidationSummary with aggregated counts and results.
|
||||
"""
|
||||
if not self._commands:
|
||||
return ValidationSummary(
|
||||
total=0,
|
||||
required_passed=0,
|
||||
required_failed=0,
|
||||
informational_passed=0,
|
||||
informational_failed=0,
|
||||
results=[],
|
||||
)
|
||||
|
||||
results: list[ValidationResult] = []
|
||||
|
||||
# Install thread-local stream wrappers so _execute_single can
|
||||
# capture per-thread stdout/stderr without global races.
|
||||
orig_stdout = sys.stdout
|
||||
orig_stderr = sys.stderr
|
||||
sys.stdout = _ThreadLocalStream(orig_stdout)
|
||||
sys.stderr = _ThreadLocalStream(orig_stderr)
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=self._max_workers) as pool:
|
||||
futures_map = {
|
||||
pool.submit(self._execute_single, cmd): cmd
|
||||
for cmd in self._commands
|
||||
}
|
||||
for future in futures_map:
|
||||
result = future.result()
|
||||
results.append(result)
|
||||
finally:
|
||||
sys.stdout = orig_stdout
|
||||
sys.stderr = orig_stderr
|
||||
|
||||
# Re-sort results to match command ordering (deterministic)
|
||||
results.sort(
|
||||
key=lambda r: (
|
||||
r.resource_name,
|
||||
r.mode.value,
|
||||
r.validation_name,
|
||||
)
|
||||
)
|
||||
|
||||
# Log informational failures
|
||||
for r in results:
|
||||
if r.mode == ValidationMode.INFORMATIONAL and not r.passed:
|
||||
logger.info(
|
||||
"Informational validation '%s' on resource '%s' failed: %s",
|
||||
r.validation_name,
|
||||
r.resource_name,
|
||||
r.message,
|
||||
)
|
||||
|
||||
summary = self._build_summary(results)
|
||||
|
||||
if not summary.all_required_passed:
|
||||
logger.warning(
|
||||
"Validation pipeline: %d required validation(s) failed",
|
||||
summary.required_failed,
|
||||
)
|
||||
|
||||
return summary
|
||||
|
||||
def run_for_plan(
|
||||
self,
|
||||
plan_metadata: dict[str, Any] | None = None,
|
||||
) -> ValidationSummary:
|
||||
"""Execute validations and persist summary to plan metadata.
|
||||
|
||||
This integrates the validation pipeline with the plan lifecycle
|
||||
by storing the validation summary in the plan's metadata dict.
|
||||
|
||||
Args:
|
||||
plan_metadata: Mutable dict for plan metadata. If provided,
|
||||
``validation_summary`` key will be updated with summary data.
|
||||
|
||||
Returns:
|
||||
ValidationSummary with aggregated counts and results.
|
||||
"""
|
||||
summary = self.run()
|
||||
|
||||
if plan_metadata is not None:
|
||||
plan_metadata["validation_summary"] = {
|
||||
"total": summary.total,
|
||||
"required_passed": summary.required_passed,
|
||||
"required_failed": summary.required_failed,
|
||||
"informational_passed": summary.informational_passed,
|
||||
"informational_failed": summary.informational_failed,
|
||||
"all_required_passed": summary.all_required_passed,
|
||||
"results": [
|
||||
{
|
||||
"validation_name": r.validation_name,
|
||||
"resource_id": r.resource_id,
|
||||
"resource_name": r.resource_name,
|
||||
"mode": r.mode.value,
|
||||
"passed": r.passed,
|
||||
"message": r.message,
|
||||
"data": r.data,
|
||||
"duration_ms": r.duration_ms,
|
||||
"error": r.error,
|
||||
"timed_out": r.timed_out,
|
||||
}
|
||||
for r in summary.results
|
||||
],
|
||||
}
|
||||
|
||||
return summary
|
||||
@@ -277,3 +277,14 @@ SubgraphCycleError # noqa: B018, F821
|
||||
MissingNodeError # noqa: B018, F821
|
||||
InvalidEntryExitError # noqa: B018, F821
|
||||
ActorResolver # noqa: B018, F821
|
||||
|
||||
# Validation pipeline — public API for validation orchestration (C6.pipeline)
|
||||
ValidationCommand # noqa: B018, F821
|
||||
ValidationResult # noqa: B018, F821
|
||||
ValidationSummary # noqa: B018, F821
|
||||
ValidationPipeline # noqa: B018, F821
|
||||
ValidationExecutor # noqa: B018, F821
|
||||
_normalise_executor_output # noqa: B018, F821
|
||||
group_by_resource # noqa: B018, F821
|
||||
run_for_plan # noqa: B018, F821
|
||||
all_required_passed # noqa: B018, F821
|
||||
|
||||
Reference in New Issue
Block a user