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.
Files changed (59) hide show
  1. workflowval/__init__.py +3 -0
  2. workflowval/cli.py +584 -0
  3. workflowval/gen/__init__.py +194 -0
  4. workflowval/gen/source.py +109 -0
  5. workflowval/gen/style.md +17 -0
  6. workflowval/gen/templates/.gitkeep +0 -0
  7. workflowval/gen/templates/conftest.py.tmpl +332 -0
  8. workflowval/loop/__init__.py +55 -0
  9. workflowval/loop/runner.py +488 -0
  10. workflowval/loop/stats.py +178 -0
  11. workflowval/loop/trace.py +29 -0
  12. workflowval/models/__init__.py +19 -0
  13. workflowval/models/base.py +68 -0
  14. workflowval/models/fake.py +55 -0
  15. workflowval/models/litellm_adapter.py +153 -0
  16. workflowval/pipeline.py +906 -0
  17. workflowval/planner/__init__.py +62 -0
  18. workflowval/prompts/__init__.py +34 -0
  19. workflowval/prompts/planner.md +37 -0
  20. workflowval/prompts/reviewer.md +30 -0
  21. workflowval/prompts/task_answer_review.md +14 -0
  22. workflowval/prompts/task_finding.md +17 -0
  23. workflowval/prompts/task_fix_test.md +6 -0
  24. workflowval/prompts/task_fix_workflow.md +7 -0
  25. workflowval/prompts/task_patch.md +9 -0
  26. workflowval/prompts/task_plan.md +13 -0
  27. workflowval/prompts/task_plan_gaps.md +6 -0
  28. workflowval/prompts/task_report.md +9 -0
  29. workflowval/prompts/task_review_plan.md +17 -0
  30. workflowval/prompts/task_review_results.md +19 -0
  31. workflowval/prompts/task_triage.md +9 -0
  32. workflowval/prompts/writer.md +11 -0
  33. workflowval/py.typed +0 -0
  34. workflowval/redact.py +97 -0
  35. workflowval/report/__init__.py +162 -0
  36. workflowval/report/curl.py +75 -0
  37. workflowval/schemas/__init__.py +78 -0
  38. workflowval/schemas/_base.py +43 -0
  39. workflowval/schemas/auth.py +66 -0
  40. workflowval/schemas/labels.py +31 -0
  41. workflowval/schemas/results.py +222 -0
  42. workflowval/schemas/review.py +94 -0
  43. workflowval/schemas/state.py +32 -0
  44. workflowval/schemas/stats.py +36 -0
  45. workflowval/schemas/workflow.py +148 -0
  46. workflowval/spec/__init__.py +38 -0
  47. workflowval/spec/graph.py +196 -0
  48. workflowval/spec/loader.py +102 -0
  49. workflowval/spec/validation.py +98 -0
  50. workflowval/testrun.py +159 -0
  51. workflowval/tools/__init__.py +6 -0
  52. workflowval/tools/base.py +49 -0
  53. workflowval/tools/registry.py +637 -0
  54. workflowval/triage/__init__.py +199 -0
  55. workflowval-0.1.0.dist-info/METADATA +215 -0
  56. workflowval-0.1.0.dist-info/RECORD +59 -0
  57. workflowval-0.1.0.dist-info/WHEEL +4 -0
  58. workflowval-0.1.0.dist-info/entry_points.txt +2 -0
  59. 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)