workflowval 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- workflowval/__init__.py +3 -0
- workflowval/cli.py +584 -0
- workflowval/gen/__init__.py +194 -0
- workflowval/gen/source.py +109 -0
- workflowval/gen/style.md +17 -0
- workflowval/gen/templates/.gitkeep +0 -0
- workflowval/gen/templates/conftest.py.tmpl +332 -0
- workflowval/loop/__init__.py +55 -0
- workflowval/loop/runner.py +488 -0
- workflowval/loop/stats.py +178 -0
- workflowval/loop/trace.py +29 -0
- workflowval/models/__init__.py +19 -0
- workflowval/models/base.py +68 -0
- workflowval/models/fake.py +55 -0
- workflowval/models/litellm_adapter.py +153 -0
- workflowval/pipeline.py +906 -0
- workflowval/planner/__init__.py +62 -0
- workflowval/prompts/__init__.py +34 -0
- workflowval/prompts/planner.md +37 -0
- workflowval/prompts/reviewer.md +30 -0
- workflowval/prompts/task_answer_review.md +14 -0
- workflowval/prompts/task_finding.md +17 -0
- workflowval/prompts/task_fix_test.md +6 -0
- workflowval/prompts/task_fix_workflow.md +7 -0
- workflowval/prompts/task_patch.md +9 -0
- workflowval/prompts/task_plan.md +13 -0
- workflowval/prompts/task_plan_gaps.md +6 -0
- workflowval/prompts/task_report.md +9 -0
- workflowval/prompts/task_review_plan.md +17 -0
- workflowval/prompts/task_review_results.md +19 -0
- workflowval/prompts/task_triage.md +9 -0
- workflowval/prompts/writer.md +11 -0
- workflowval/py.typed +0 -0
- workflowval/redact.py +97 -0
- workflowval/report/__init__.py +162 -0
- workflowval/report/curl.py +75 -0
- workflowval/schemas/__init__.py +78 -0
- workflowval/schemas/_base.py +43 -0
- workflowval/schemas/auth.py +66 -0
- workflowval/schemas/labels.py +31 -0
- workflowval/schemas/results.py +222 -0
- workflowval/schemas/review.py +94 -0
- workflowval/schemas/state.py +32 -0
- workflowval/schemas/stats.py +36 -0
- workflowval/schemas/workflow.py +148 -0
- workflowval/spec/__init__.py +38 -0
- workflowval/spec/graph.py +196 -0
- workflowval/spec/loader.py +102 -0
- workflowval/spec/validation.py +98 -0
- workflowval/testrun.py +159 -0
- workflowval/tools/__init__.py +6 -0
- workflowval/tools/base.py +49 -0
- workflowval/tools/registry.py +637 -0
- workflowval/triage/__init__.py +199 -0
- workflowval-0.1.0.dist-info/METADATA +215 -0
- workflowval-0.1.0.dist-info/RECORD +59 -0
- workflowval-0.1.0.dist-info/WHEEL +4 -0
- workflowval-0.1.0.dist-info/entry_points.txt +2 -0
- workflowval-0.1.0.dist-info/licenses/LICENSE +21 -0
workflowval/testrun.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""The Runner's test execution: pytest in a subprocess, with a timeout (FR-6). No model involved."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
from collections.abc import Collection
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from workflowval.redact import redact
|
|
18
|
+
|
|
19
|
+
PER_TEST_TIMEOUT_SECONDS = 60
|
|
20
|
+
# Generated tests may have been edited by a model, so they only get the env vars they need.
|
|
21
|
+
_PASSED_THROUGH = ("PATH", "HOME", "LANG", "LC_ALL", "TMPDIR", "SYSTEMROOT")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Outcome:
|
|
26
|
+
"""One workflow's test result."""
|
|
27
|
+
|
|
28
|
+
workflow_id: str
|
|
29
|
+
status: str
|
|
30
|
+
duration_s: float = 0.0
|
|
31
|
+
failing_assertion: str | None = None
|
|
32
|
+
stdout: str = ""
|
|
33
|
+
exchanges: list[dict[str, Any]] = field(default_factory=list)
|
|
34
|
+
calls: list[dict[str, Any]] = field(default_factory=list)
|
|
35
|
+
steps_run: int = 0
|
|
36
|
+
known_issue: str | None = None
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def passed(self) -> bool:
|
|
40
|
+
"""True when the test passed."""
|
|
41
|
+
return self.status == "passed"
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def expected(self) -> bool:
|
|
45
|
+
"""True when the test hit the failure it was already reviewed for."""
|
|
46
|
+
return self.status == "xfailed"
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def skipped(self) -> bool:
|
|
50
|
+
"""True when a read-only run didn't send the test's write."""
|
|
51
|
+
return self.status == "skipped"
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def ok(self) -> bool:
|
|
55
|
+
"""True unless the test failed in a way nobody has reviewed."""
|
|
56
|
+
return self.passed or self.expected or self.skipped
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def looks_fixed(self) -> bool:
|
|
60
|
+
"""True when a test marked as a known issue passed."""
|
|
61
|
+
return self.passed and self.known_issue is not None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_id(workflow_id: str) -> str:
|
|
65
|
+
"""The id of a workflow's one test."""
|
|
66
|
+
return f"tests/wf/test_{workflow_id}.py::test_{workflow_id}"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def is_reachable(base_url: str) -> bool:
|
|
70
|
+
"""Check the API answers at all. Any HTTP response counts, even an error."""
|
|
71
|
+
try:
|
|
72
|
+
httpx.get(base_url, timeout=5)
|
|
73
|
+
except httpx.HTTPError:
|
|
74
|
+
return False
|
|
75
|
+
return True
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _workflow_of_node(node_id: str) -> str | None:
|
|
79
|
+
stem = Path(node_id.split("::")[0]).stem
|
|
80
|
+
return stem.removeprefix("test_") if stem.startswith("test_") else None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _redacted(result: dict[str, Any], *, no_redact: bool) -> dict[str, Any]:
|
|
84
|
+
"""Redact one test's result. --no-redact keeps only the recorded response bodies exact."""
|
|
85
|
+
cleaned: dict[str, Any] = redact(result)
|
|
86
|
+
if no_redact:
|
|
87
|
+
for original, call in zip(
|
|
88
|
+
result.get("calls") or (), cleaned.get("calls") or (), strict=True
|
|
89
|
+
):
|
|
90
|
+
call["raw_body"] = original.get("raw_body")
|
|
91
|
+
return cleaned
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def run_tests(
|
|
95
|
+
tests_dir: Path,
|
|
96
|
+
*,
|
|
97
|
+
base_url: str | None,
|
|
98
|
+
workflow_ids: Collection[str] | None = None,
|
|
99
|
+
env_names: Collection[str] = (),
|
|
100
|
+
read_only: bool = False,
|
|
101
|
+
allowed_hosts: Collection[str] = (),
|
|
102
|
+
no_redact: bool = False,
|
|
103
|
+
) -> list[Outcome]:
|
|
104
|
+
"""Run tests and return one outcome per workflow. A test with no result counts as an error."""
|
|
105
|
+
wanted = (
|
|
106
|
+
sorted(workflow_ids)
|
|
107
|
+
if workflow_ids is not None
|
|
108
|
+
else sorted(path.stem.removeprefix("test_") for path in tests_dir.glob("test_*.py"))
|
|
109
|
+
)
|
|
110
|
+
if not wanted:
|
|
111
|
+
return []
|
|
112
|
+
with tempfile.TemporaryDirectory() as folder:
|
|
113
|
+
capture = Path(folder) / "capture.json"
|
|
114
|
+
env = {
|
|
115
|
+
name: os.environ[name] for name in (*_PASSED_THROUGH, *env_names) if name in os.environ
|
|
116
|
+
}
|
|
117
|
+
env["WORKFLOWVAL_CAPTURE"] = str(capture)
|
|
118
|
+
if base_url:
|
|
119
|
+
env["WORKFLOWVAL_BASE_URL"] = base_url
|
|
120
|
+
if read_only:
|
|
121
|
+
env["WORKFLOWVAL_READ_ONLY"] = "1"
|
|
122
|
+
if no_redact:
|
|
123
|
+
env["WORKFLOWVAL_NO_REDACT"] = "1"
|
|
124
|
+
if allowed_hosts:
|
|
125
|
+
env["WORKFLOWVAL_ALLOWED_HOSTS"] = ",".join(allowed_hosts)
|
|
126
|
+
files = [f"test_{workflow_id}.py" for workflow_id in wanted]
|
|
127
|
+
command = [
|
|
128
|
+
sys.executable,
|
|
129
|
+
"-m",
|
|
130
|
+
"pytest",
|
|
131
|
+
"-q",
|
|
132
|
+
"--rootdir=.",
|
|
133
|
+
"--confcutdir=.",
|
|
134
|
+
"-p",
|
|
135
|
+
"no:cacheprovider",
|
|
136
|
+
*files,
|
|
137
|
+
]
|
|
138
|
+
try:
|
|
139
|
+
subprocess.run( # noqa: S603 - fixed command; test files are checked before they're written
|
|
140
|
+
command,
|
|
141
|
+
cwd=tests_dir,
|
|
142
|
+
env=env,
|
|
143
|
+
capture_output=True,
|
|
144
|
+
text=True,
|
|
145
|
+
check=False,
|
|
146
|
+
timeout=PER_TEST_TIMEOUT_SECONDS * len(files),
|
|
147
|
+
)
|
|
148
|
+
except subprocess.TimeoutExpired:
|
|
149
|
+
return [Outcome(w, "error", failing_assertion="the test run timed out") for w in wanted]
|
|
150
|
+
captured = json.loads(capture.read_text(encoding="utf-8")) if capture.exists() else {}
|
|
151
|
+
outcomes: dict[str, Outcome] = {}
|
|
152
|
+
for node_id, result in captured.items():
|
|
153
|
+
workflow_id = _workflow_of_node(node_id)
|
|
154
|
+
if workflow_id is not None:
|
|
155
|
+
outcomes[workflow_id] = Outcome(workflow_id, **_redacted(result, no_redact=no_redact))
|
|
156
|
+
return [
|
|
157
|
+
outcomes.get(w) or Outcome(w, "error", failing_assertion="pytest reported no result")
|
|
158
|
+
for w in wanted
|
|
159
|
+
]
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Functions the model can call. Each role gets only the tools listed for it."""
|
|
2
|
+
|
|
3
|
+
from workflowval.tools.base import Tool, ToolContext, ToolError
|
|
4
|
+
from workflowval.tools.registry import ROLE_TOOLS, TOOLS
|
|
5
|
+
|
|
6
|
+
__all__ = ["ROLE_TOOLS", "TOOLS", "Tool", "ToolContext", "ToolError"]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""The tool type: a function the model can call, with a Pydantic input schema."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel
|
|
11
|
+
|
|
12
|
+
from workflowval.models.base import ToolSpec
|
|
13
|
+
from workflowval.schemas import AuthConfig
|
|
14
|
+
from workflowval.spec import SpecGraph
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ToolError(Exception):
|
|
18
|
+
"""A refusal or failure to report back to the model, not a crash."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class ToolContext:
|
|
23
|
+
"""Where a tool may read and write, and what it knows about the API."""
|
|
24
|
+
|
|
25
|
+
workdir: Path
|
|
26
|
+
graph: SpecGraph | None = None
|
|
27
|
+
docs_dir: Path | None = None
|
|
28
|
+
reports_dir: Path | None = None
|
|
29
|
+
raw_spec: dict[str, Any] | None = None
|
|
30
|
+
auth: AuthConfig | None = None
|
|
31
|
+
new_workflows_only: bool = False
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class Tool:
|
|
36
|
+
"""A callable tool. `fn` receives arguments already validated against `schema`."""
|
|
37
|
+
|
|
38
|
+
name: str
|
|
39
|
+
description: str
|
|
40
|
+
schema: type[BaseModel]
|
|
41
|
+
fn: Callable[[Any, ToolContext], str]
|
|
42
|
+
|
|
43
|
+
def spec(self) -> ToolSpec:
|
|
44
|
+
"""Describe this tool to the model."""
|
|
45
|
+
return ToolSpec(self.name, self.description, self.schema.model_json_schema())
|
|
46
|
+
|
|
47
|
+
def invoke(self, arguments: dict[str, Any], context: ToolContext) -> str:
|
|
48
|
+
"""Validate arguments and run. Raises ValidationError or ToolError."""
|
|
49
|
+
return self.fn(self.schema.model_validate(arguments), context)
|