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
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Renders pytest files from workflow files, with no model involved (FR-5)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from importlib import resources
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from workflowval.gen.source import SourceError, check_test_source, format_source, lint_source
|
|
11
|
+
from workflowval.schemas import AuthConfig, LoginAuth, Workflow
|
|
12
|
+
from workflowval.schemas.workflow import _PLACEHOLDER, CleanupStep, KnownIssue, Step
|
|
13
|
+
from workflowval.spec import SpecGraph
|
|
14
|
+
|
|
15
|
+
HASH_PREFIX = "# workflow-sha256: "
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class GenerationError(Exception):
|
|
19
|
+
"""A workflow can't be rendered against this spec."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class TestFile:
|
|
24
|
+
"""A generated test file."""
|
|
25
|
+
|
|
26
|
+
__test__ = False
|
|
27
|
+
|
|
28
|
+
path: str
|
|
29
|
+
source: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def workflow_hash(workflow: Workflow) -> str:
|
|
33
|
+
"""Hash a workflow, so a test is regenerated only when its workflow changed."""
|
|
34
|
+
return hashlib.sha256(workflow.model_dump_json().encode()).hexdigest()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def hash_line(workflow: Workflow) -> str:
|
|
38
|
+
"""The first line of a generated test, tying it to its workflow."""
|
|
39
|
+
return f"{HASH_PREFIX}{workflow_hash(workflow)}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_path(workflow_id: str) -> str:
|
|
43
|
+
"""Where a workflow's test file lives."""
|
|
44
|
+
return f"tests/wf/test_{workflow_id}.py"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _expr(value: Any) -> str:
|
|
48
|
+
"""Render a JSON value as Python. Text goes through repr, so it can never become code."""
|
|
49
|
+
if isinstance(value, str):
|
|
50
|
+
match = _PLACEHOLDER.fullmatch(value)
|
|
51
|
+
if match:
|
|
52
|
+
name = match.group(1)
|
|
53
|
+
return f"api.{name}" if name in {"unique", "unique_int"} else f"saved[{name!r}]"
|
|
54
|
+
return f"api.fill({value!r}, saved)" if _PLACEHOLDER.search(value) else repr(value)
|
|
55
|
+
if isinstance(value, dict):
|
|
56
|
+
items = ", ".join(f"{key!r}: {_expr(item)}" for key, item in value.items())
|
|
57
|
+
return "{" + items + "}"
|
|
58
|
+
if isinstance(value, list):
|
|
59
|
+
return "[" + ", ".join(_expr(item) for item in value) + "]"
|
|
60
|
+
return repr(value)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _call_arguments(step: CleanupStep, graph: SpecGraph) -> str:
|
|
64
|
+
operation = graph.operation(step.op)
|
|
65
|
+
if operation is None:
|
|
66
|
+
raise GenerationError(f"operation {step.op!r} isn't in the spec")
|
|
67
|
+
groups: dict[str, dict[str, Any]] = {"path": {}, "params": {}, "headers": {}}
|
|
68
|
+
target = {"path": "path", "query": "params", "header": "headers"}
|
|
69
|
+
locations = {parameter.name: parameter.location for parameter in operation.parameters}
|
|
70
|
+
for name, value in step.params.items():
|
|
71
|
+
location = locations.get(name)
|
|
72
|
+
if location not in target:
|
|
73
|
+
raise GenerationError(f"{step.op} has no path, query or header parameter {name!r}")
|
|
74
|
+
groups[target[location]][name] = value
|
|
75
|
+
parts = [repr(operation.method), repr(operation.path)]
|
|
76
|
+
parts += [f"{key}={_expr(group)}" for key, group in groups.items() if group]
|
|
77
|
+
if step.body is not None:
|
|
78
|
+
parts.append(f"body={_expr(step.body)}")
|
|
79
|
+
if isinstance(step, Step) and not step.auth:
|
|
80
|
+
parts.append("auth=False")
|
|
81
|
+
return ", ".join(parts)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _render_step(step: Step, graph: SpecGraph, issue: KnownIssue | None = None) -> list[str]:
|
|
85
|
+
"""Render one step. `issue` is set only on the step with a reviewed, expected failure."""
|
|
86
|
+
lines = [f"response = api.call({_call_arguments(step, graph)})"]
|
|
87
|
+
if issue is not None and issue.check == "status":
|
|
88
|
+
lines += [f"if response.status_code == {issue.status}:", " api.expected_failure()"]
|
|
89
|
+
lines.append(f"assert response.status_code == {step.expect.status}, response.text")
|
|
90
|
+
checks = [f"saved[{k!r}] = api.value(response, {v!r})" for k, v in step.save.items()]
|
|
91
|
+
for path, value in step.expect.body.items():
|
|
92
|
+
# True, False and None compare by identity, so a truthy 1 can't pass as True.
|
|
93
|
+
operator = "is" if value is None or isinstance(value, bool) else "=="
|
|
94
|
+
checks.append(f"assert api.value(response, {path!r}) {operator} {_expr(value)}")
|
|
95
|
+
if issue is not None and issue.check == "body" and checks:
|
|
96
|
+
lines.append("with api.expected_failure_on_assert():")
|
|
97
|
+
return lines + [f" {line}" for line in checks]
|
|
98
|
+
return lines + checks
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _render_cleanup(step: CleanupStep, graph: SpecGraph) -> list[str]:
|
|
102
|
+
used = sorted(
|
|
103
|
+
set(_PLACEHOLDER.findall(repr([step.params, step.body]))) - {"unique", "unique_int"}
|
|
104
|
+
)
|
|
105
|
+
call = f"api.cleanup({_call_arguments(step, graph)})"
|
|
106
|
+
if not used:
|
|
107
|
+
return [call]
|
|
108
|
+
guard = " and ".join(f"{name!r} in saved" for name in used)
|
|
109
|
+
return [f"if {guard}:", f" {call}"]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def generate(workflow: Workflow, graph: SpecGraph) -> TestFile:
|
|
113
|
+
"""Render a workflow as one readable pytest file."""
|
|
114
|
+
issue = workflow.known_issue
|
|
115
|
+
body = [
|
|
116
|
+
line
|
|
117
|
+
for index, step in enumerate(workflow.steps)
|
|
118
|
+
for line in _render_step(step, graph, issue if issue and issue.step == index else None)
|
|
119
|
+
]
|
|
120
|
+
cleanup = [line for step in workflow.cleanup for line in _render_cleanup(step, graph)]
|
|
121
|
+
lines = [hash_line(workflow), repr(workflow.rule), "", "", f"def test_{workflow.id}(api):"]
|
|
122
|
+
if issue is not None:
|
|
123
|
+
lines.append(f" api.known_issue({f'{issue.label}: {issue.reason}'!r})")
|
|
124
|
+
if "saved" in "\n".join([*body, *cleanup]):
|
|
125
|
+
lines.append(" saved = {}")
|
|
126
|
+
if cleanup:
|
|
127
|
+
lines.append(" try:")
|
|
128
|
+
lines += [f" {line}" for line in body]
|
|
129
|
+
lines.append(" finally:")
|
|
130
|
+
lines += [f" {line}" for line in cleanup]
|
|
131
|
+
else:
|
|
132
|
+
lines += [f" {line}" for line in body]
|
|
133
|
+
try:
|
|
134
|
+
source = format_source("\n".join(lines) + "\n")
|
|
135
|
+
lint_source(source)
|
|
136
|
+
except SourceError as exc:
|
|
137
|
+
raise GenerationError(str(exc)) from exc
|
|
138
|
+
return TestFile(test_path(workflow.id), source)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def token_style(scheme: LoginAuth, graph: SpecGraph) -> tuple[str, str]:
|
|
142
|
+
"""Decide how to send a login token: config first, then the spec's security section."""
|
|
143
|
+
header, prefix = scheme.token_header, scheme.token_prefix
|
|
144
|
+
for security in graph.security_schemes.values():
|
|
145
|
+
if header is not None and prefix is not None:
|
|
146
|
+
break
|
|
147
|
+
kind = security.get("type")
|
|
148
|
+
if kind == "http" and str(security.get("scheme", "")).lower() == "bearer":
|
|
149
|
+
header, prefix = header or "Authorization", "Bearer " if prefix is None else prefix
|
|
150
|
+
elif kind == "apiKey" and security.get("in") == "header":
|
|
151
|
+
header, prefix = header or security["name"], "" if prefix is None else prefix
|
|
152
|
+
elif kind == "apiKey" and security.get("in") == "cookie":
|
|
153
|
+
default_prefix = f"{security['name']}="
|
|
154
|
+
header, prefix = header or "Cookie", default_prefix if prefix is None else prefix
|
|
155
|
+
return header or "Authorization", "Bearer " if prefix is None else prefix
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _auth_settings(auth: AuthConfig | None, graph: SpecGraph) -> dict[str, Any] | None:
|
|
159
|
+
if auth is None:
|
|
160
|
+
return None
|
|
161
|
+
scheme = auth.scheme
|
|
162
|
+
settings: dict[str, Any] = scheme.model_dump()
|
|
163
|
+
if isinstance(scheme, LoginAuth):
|
|
164
|
+
operation = graph.operation(scheme.op)
|
|
165
|
+
if operation is None:
|
|
166
|
+
raise GenerationError(f"auth config: operation {scheme.op!r} isn't in the spec")
|
|
167
|
+
settings["path"], settings["method"] = operation.path, operation.method
|
|
168
|
+
settings["token_header"], settings["token_prefix"] = token_style(scheme, graph)
|
|
169
|
+
return settings
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def render_conftest(base_url: str, auth: AuthConfig | None, graph: SpecGraph) -> str:
|
|
173
|
+
"""Render the shared conftest.py. It holds env var names only, never secret values."""
|
|
174
|
+
template = resources.files(__package__).joinpath("templates/conftest.py.tmpl")
|
|
175
|
+
source = template.read_text(encoding="utf-8")
|
|
176
|
+
source = source.replace("__BASE_URL__", repr(base_url))
|
|
177
|
+
source = source.replace("__AUTH__", repr(_auth_settings(auth, graph)))
|
|
178
|
+
return format_source(source)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
__all__ = [
|
|
182
|
+
"GenerationError",
|
|
183
|
+
"SourceError",
|
|
184
|
+
"TestFile",
|
|
185
|
+
"check_test_source",
|
|
186
|
+
"format_source",
|
|
187
|
+
"generate",
|
|
188
|
+
"hash_line",
|
|
189
|
+
"lint_source",
|
|
190
|
+
"render_conftest",
|
|
191
|
+
"test_path",
|
|
192
|
+
"token_style",
|
|
193
|
+
"workflow_hash",
|
|
194
|
+
]
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Formats, lints and checks generated test code. Everything here runs before code is executed."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from workflowval.schemas import Workflow
|
|
10
|
+
|
|
11
|
+
ALLOWED_IMPORTS = frozenset({"pytest", "httpx", "json", "re", "uuid", "time", "datetime"})
|
|
12
|
+
FORBIDDEN_NAMES = frozenset(
|
|
13
|
+
{"eval", "exec", "compile", "open", "__import__", "globals", "locals", "getattr", "setattr"}
|
|
14
|
+
)
|
|
15
|
+
FORBIDDEN_ATTRIBUTES = frozenset({"skip", "skipif", "xfail", "importorskip"})
|
|
16
|
+
EXPECTED_FAILURE_CALLS = frozenset(
|
|
17
|
+
{"known_issue", "expected_failure", "expected_failure_on_assert"}
|
|
18
|
+
)
|
|
19
|
+
_RUFF_ARGS = ("--isolated", "--line-length", "100", "--stdin-filename", "test_generated.py")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SourceError(Exception):
|
|
23
|
+
"""Test code was refused. The message says why."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _ruff(*args: str, source: str) -> subprocess.CompletedProcess[str]:
|
|
27
|
+
return subprocess.run( # noqa: S603 - fixed arguments, and source goes in through stdin
|
|
28
|
+
[sys.executable, "-m", "ruff", *args],
|
|
29
|
+
input=source,
|
|
30
|
+
capture_output=True,
|
|
31
|
+
text=True,
|
|
32
|
+
check=False,
|
|
33
|
+
timeout=60,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def format_source(source: str) -> str:
|
|
38
|
+
"""Format Python source with ruff."""
|
|
39
|
+
result = _ruff("format", *_RUFF_ARGS, "-", source=source)
|
|
40
|
+
if result.returncode != 0:
|
|
41
|
+
raise SourceError(f"code can't be formatted: {result.stderr.strip()}")
|
|
42
|
+
return result.stdout
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def lint_source(source: str) -> None:
|
|
46
|
+
"""Refuse source that doesn't pass ruff."""
|
|
47
|
+
result = _ruff(
|
|
48
|
+
"check",
|
|
49
|
+
*_RUFF_ARGS,
|
|
50
|
+
"--select",
|
|
51
|
+
"E,F,I,B",
|
|
52
|
+
"--ignore",
|
|
53
|
+
"E501",
|
|
54
|
+
"--output-format",
|
|
55
|
+
"concise",
|
|
56
|
+
"-",
|
|
57
|
+
source=source,
|
|
58
|
+
)
|
|
59
|
+
if result.returncode != 0:
|
|
60
|
+
raise SourceError(f"ruff found problems: {result.stdout.strip()}")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def count_assertions(tree: ast.AST) -> int:
|
|
64
|
+
"""Count the assert statements in parsed source."""
|
|
65
|
+
return sum(isinstance(node, ast.Assert) for node in ast.walk(tree))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def check_test_source(source: str, workflow: Workflow, previous: str | None = None) -> None:
|
|
69
|
+
"""Refuse an edited test that could run something unexpected or hide a real failure.
|
|
70
|
+
|
|
71
|
+
This is a safety check on model-written code, not a sandbox.
|
|
72
|
+
"""
|
|
73
|
+
try:
|
|
74
|
+
tree = ast.parse(source)
|
|
75
|
+
except SyntaxError as exc:
|
|
76
|
+
raise SourceError(f"not valid Python: {exc}") from exc
|
|
77
|
+
for node in ast.walk(tree):
|
|
78
|
+
if isinstance(node, ast.Import):
|
|
79
|
+
modules = [alias.name for alias in node.names]
|
|
80
|
+
elif isinstance(node, ast.ImportFrom):
|
|
81
|
+
modules = [node.module or "."]
|
|
82
|
+
else:
|
|
83
|
+
modules = []
|
|
84
|
+
for module in modules:
|
|
85
|
+
if module.split(".")[0] not in ALLOWED_IMPORTS:
|
|
86
|
+
raise SourceError(f"import of {module!r} isn't allowed in a generated test")
|
|
87
|
+
if isinstance(node, ast.Name) and node.id in FORBIDDEN_NAMES:
|
|
88
|
+
raise SourceError(f"{node.id!r} isn't allowed in a generated test")
|
|
89
|
+
if isinstance(node, ast.Attribute) and node.attr in FORBIDDEN_ATTRIBUTES:
|
|
90
|
+
raise SourceError(f"pytest {node.attr} isn't allowed: a real failure must stay visible")
|
|
91
|
+
if (
|
|
92
|
+
isinstance(node, ast.Attribute)
|
|
93
|
+
and node.attr in EXPECTED_FAILURE_CALLS
|
|
94
|
+
and workflow.known_issue is None
|
|
95
|
+
):
|
|
96
|
+
raise SourceError(f"{node.attr} isn't allowed: only a reviewed failure can be expected")
|
|
97
|
+
functions = [node for node in ast.walk(tree) if isinstance(node, ast.FunctionDef)]
|
|
98
|
+
names = [function.name for function in functions if function.name.startswith("test")]
|
|
99
|
+
expected = f"test_{workflow.id}"
|
|
100
|
+
if names != [expected]:
|
|
101
|
+
raise SourceError(f"the file must define exactly one test, {expected}; found {names}")
|
|
102
|
+
constants = {node.value for node in ast.walk(tree) if isinstance(node, ast.Constant)}
|
|
103
|
+
for step in workflow.steps:
|
|
104
|
+
if step.expect.status not in constants:
|
|
105
|
+
raise SourceError(f"the test no longer checks status {step.expect.status} ({step.op})")
|
|
106
|
+
if previous is not None:
|
|
107
|
+
before, after = count_assertions(ast.parse(previous)), count_assertions(tree)
|
|
108
|
+
if after < before:
|
|
109
|
+
raise SourceError(f"assertion weakening refused: {before} assertions became {after}")
|
workflowval/gen/style.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Generated test style
|
|
2
|
+
|
|
3
|
+
Tests are rendered from workflow files by `workflowval.gen`, so these rules hold by construction.
|
|
4
|
+
The Writer model only edits a test to fix a failure labeled `TEST_BUG`, and `write_test` enforces
|
|
5
|
+
the rules below on its edits.
|
|
6
|
+
|
|
7
|
+
- One file per workflow, `tests/wf/test_<id>.py`, with one function, `test_<id>(api)`.
|
|
8
|
+
- Plain pytest + httpx through the `api` fixture in `conftest.py`. No workflowval import.
|
|
9
|
+
- Each step's status code and values are checked with a plain `assert`.
|
|
10
|
+
- Cleanup runs in `finally`, and only touches data the test created.
|
|
11
|
+
- Fresh data per run comes from `api.unique` and `api.unique_int`.
|
|
12
|
+
- No secrets in test files: credentials come from env vars inside `conftest.py`.
|
|
13
|
+
- Only these imports: pytest, httpx, json, re, uuid, time, datetime.
|
|
14
|
+
- No `skip` or `xfail`, and no fewer assertions than before. A real failure must stay visible.
|
|
15
|
+
- Only code may mark a reviewed failure as expected, through `known_issue` in the workflow file. It
|
|
16
|
+
excuses that one check on that one step, and any other failure still fails the test.
|
|
17
|
+
- `ruff` passes (rules E, F, I, B; line length isn't enforced on long literals).
|
|
File without changes
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""Generated by workflowval. Shared test helpers; needs only pytest and httpx to run."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import secrets
|
|
9
|
+
import uuid
|
|
10
|
+
from collections.abc import Generator, Iterator
|
|
11
|
+
from contextlib import contextmanager
|
|
12
|
+
from typing import Any
|
|
13
|
+
from urllib.parse import quote
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
import pytest
|
|
17
|
+
|
|
18
|
+
BASE_URL = os.environ.get("WORKFLOWVAL_BASE_URL", __BASE_URL__).rstrip("/")
|
|
19
|
+
AUTH: dict[str, Any] | None = __AUTH__
|
|
20
|
+
TIMEOUT_SECONDS = 30
|
|
21
|
+
READ_ONLY = os.environ.get("WORKFLOWVAL_READ_ONLY") == "1"
|
|
22
|
+
KEEP_RAW_BODIES = os.environ.get("WORKFLOWVAL_NO_REDACT") == "1"
|
|
23
|
+
ALLOWED_HOSTS = {
|
|
24
|
+
httpx.URL(BASE_URL).host,
|
|
25
|
+
*filter(None, os.environ.get("WORKFLOWVAL_ALLOWED_HOSTS", "").split(",")),
|
|
26
|
+
}
|
|
27
|
+
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
|
|
28
|
+
MASK = "[REDACTED]"
|
|
29
|
+
SECRET_HEADERS = {"authorization", "cookie", "set-cookie", "proxy-authorization", "x-api-key"}
|
|
30
|
+
if AUTH and AUTH.get("type") == "api_key" and AUTH["location"] == "header":
|
|
31
|
+
SECRET_HEADERS.add(AUTH["name"].lower())
|
|
32
|
+
|
|
33
|
+
_secrets: set[str] = set()
|
|
34
|
+
_exchanges: list[httpx.Response] = []
|
|
35
|
+
_MAX_RAW_BODY = 200_000
|
|
36
|
+
_recording = True
|
|
37
|
+
_results: dict[str, dict[str, Any]] = {}
|
|
38
|
+
_steps_run = 0
|
|
39
|
+
_known_issue: str | None = None
|
|
40
|
+
_PATH_PART = re.compile(r"\.([A-Za-z_][\w-]*)|\[(\d+)\]")
|
|
41
|
+
_PLACEHOLDER = re.compile(r"\{([A-Za-z_]\w*)\}")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _env(name: str) -> str:
|
|
45
|
+
value = os.environ.get(name)
|
|
46
|
+
if not value:
|
|
47
|
+
raise RuntimeError(f"environment variable {name} is not set")
|
|
48
|
+
_secrets.add(value)
|
|
49
|
+
return value
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _mask(text: str) -> str:
|
|
53
|
+
for value in sorted(_secrets, key=len, reverse=True):
|
|
54
|
+
text = text.replace(value, MASK)
|
|
55
|
+
if AUTH and AUTH.get("type") == "api_key" and AUTH["location"] == "query":
|
|
56
|
+
text = re.sub(rf"({re.escape(AUTH['name'])}=)[^&\s]+", rf"\g<1>{MASK}", text)
|
|
57
|
+
return text
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _value_at(document: Any, path: str) -> Any:
|
|
61
|
+
node = document
|
|
62
|
+
for name, index in _PATH_PART.findall(path):
|
|
63
|
+
if name:
|
|
64
|
+
if not isinstance(node, dict) or name not in node:
|
|
65
|
+
raise AssertionError(f"no value at {path} in the response")
|
|
66
|
+
node = node[name]
|
|
67
|
+
else:
|
|
68
|
+
if not isinstance(node, list) or int(index) >= len(node):
|
|
69
|
+
raise AssertionError(f"no value at {path} in the response")
|
|
70
|
+
node = node[int(index)]
|
|
71
|
+
return node
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _nested(fields: dict[str, str]) -> dict[str, Any]:
|
|
75
|
+
"""Turn `user.email` into {"user": {"email": ...}}, for APIs that nest the login body."""
|
|
76
|
+
body: dict[str, Any] = {}
|
|
77
|
+
for name, value in fields.items():
|
|
78
|
+
*parents, last = name.split(".")
|
|
79
|
+
node = body
|
|
80
|
+
for parent in parents:
|
|
81
|
+
node = node.setdefault(parent, {})
|
|
82
|
+
node[last] = value
|
|
83
|
+
return body
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class _LoginAuth(httpx.Auth):
|
|
87
|
+
"""Logs in once, reuses the token and logs in again on a 401."""
|
|
88
|
+
|
|
89
|
+
requires_response_body = True
|
|
90
|
+
|
|
91
|
+
def __init__(self, scheme: dict[str, Any]) -> None:
|
|
92
|
+
self.scheme = scheme
|
|
93
|
+
self.token: str | None = None
|
|
94
|
+
|
|
95
|
+
def _login(self) -> Generator[httpx.Request, httpx.Response, None]:
|
|
96
|
+
scheme = self.scheme
|
|
97
|
+
fields = {name: _env(env) for name, env in scheme["body_env"].items()}
|
|
98
|
+
url = BASE_URL + scheme["path"]
|
|
99
|
+
if scheme["method"] == "GET":
|
|
100
|
+
request = httpx.Request("GET", url, params=fields)
|
|
101
|
+
else:
|
|
102
|
+
request = httpx.Request(scheme["method"], url, json=_nested(fields))
|
|
103
|
+
request.headers["Accept"] = "application/json"
|
|
104
|
+
request.extensions["workflowval_login"] = True
|
|
105
|
+
response = yield request
|
|
106
|
+
self.token = str(_value_at(response.json(), scheme["token_path"]))
|
|
107
|
+
_secrets.add(self.token)
|
|
108
|
+
|
|
109
|
+
def _apply(self, request: httpx.Request) -> None:
|
|
110
|
+
scheme = self.scheme
|
|
111
|
+
request.headers[scheme["token_header"]] = f"{scheme['token_prefix']}{self.token}"
|
|
112
|
+
|
|
113
|
+
def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]:
|
|
114
|
+
if self.token is None:
|
|
115
|
+
yield from self._login()
|
|
116
|
+
self._apply(request)
|
|
117
|
+
response = yield request
|
|
118
|
+
if response.status_code == 401 and self.scheme["refresh_on_401"]:
|
|
119
|
+
yield from self._login()
|
|
120
|
+
self._apply(request)
|
|
121
|
+
yield request
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _guard(request: httpx.Request) -> None:
|
|
125
|
+
"""Refuse any request to a host that isn't allowed, whatever made it."""
|
|
126
|
+
if request.url.host not in ALLOWED_HOSTS:
|
|
127
|
+
raise RuntimeError(f"{request.url.host} isn't an allowed host: {sorted(ALLOWED_HOSTS)}")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _record(response: httpx.Response) -> None:
|
|
131
|
+
if _recording and not response.request.extensions.get("workflowval_login"):
|
|
132
|
+
response.read()
|
|
133
|
+
_exchanges.append(response)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _make_client(*, authenticated: bool) -> httpx.Client:
|
|
137
|
+
options: dict[str, Any] = {}
|
|
138
|
+
if authenticated and AUTH:
|
|
139
|
+
kind = AUTH["type"]
|
|
140
|
+
if kind == "bearer":
|
|
141
|
+
options["headers"] = {"Authorization": f"Bearer {_env(AUTH['token_env'])}"}
|
|
142
|
+
elif kind == "basic":
|
|
143
|
+
options["auth"] = httpx.BasicAuth(_env(AUTH["username_env"]), _env(AUTH["password_env"]))
|
|
144
|
+
elif kind == "api_key" and AUTH["location"] == "header":
|
|
145
|
+
options["headers"] = {AUTH["name"]: _env(AUTH["value_env"])}
|
|
146
|
+
elif kind == "api_key":
|
|
147
|
+
options["params"] = {AUTH["name"]: _env(AUTH["value_env"])}
|
|
148
|
+
elif kind == "login":
|
|
149
|
+
options["auth"] = _LoginAuth(AUTH)
|
|
150
|
+
client = httpx.Client(
|
|
151
|
+
timeout=TIMEOUT_SECONDS,
|
|
152
|
+
event_hooks={"request": [_guard], "response": [_record]},
|
|
153
|
+
**options,
|
|
154
|
+
)
|
|
155
|
+
client.headers["Accept"] = "application/json"
|
|
156
|
+
return client
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class Api:
|
|
160
|
+
"""What a generated test uses to call the API."""
|
|
161
|
+
|
|
162
|
+
def __init__(self, client: httpx.Client, anonymous: httpx.Client) -> None:
|
|
163
|
+
self._client = client
|
|
164
|
+
self._anonymous = anonymous
|
|
165
|
+
self.unique = uuid.uuid4().hex[:12]
|
|
166
|
+
self.unique_int = secrets.randbelow(2**31 - 2) + 1
|
|
167
|
+
|
|
168
|
+
def call(
|
|
169
|
+
self,
|
|
170
|
+
method: str,
|
|
171
|
+
template: str,
|
|
172
|
+
*,
|
|
173
|
+
path: dict[str, Any] | None = None,
|
|
174
|
+
params: dict[str, Any] | None = None,
|
|
175
|
+
headers: dict[str, Any] | None = None,
|
|
176
|
+
body: Any = None,
|
|
177
|
+
auth: bool = True,
|
|
178
|
+
) -> httpx.Response:
|
|
179
|
+
"""Send one request. `auth=False` sends it without credentials."""
|
|
180
|
+
global _steps_run
|
|
181
|
+
if READ_ONLY and method.upper() not in SAFE_METHODS:
|
|
182
|
+
pytest.skip(f"read-only run: {method} {template} wasn't sent")
|
|
183
|
+
if _recording:
|
|
184
|
+
_steps_run += 1
|
|
185
|
+
url = template
|
|
186
|
+
for name, value in (path or {}).items():
|
|
187
|
+
url = url.replace("{" + name + "}", quote(str(value), safe=""))
|
|
188
|
+
request_headers = {name: str(value) for name, value in (headers or {}).items()}
|
|
189
|
+
client = self._client if auth else self._anonymous
|
|
190
|
+
options: dict[str, Any] = {"params": params, "headers": request_headers}
|
|
191
|
+
if body is not None:
|
|
192
|
+
options["json"] = body
|
|
193
|
+
return client.request(
|
|
194
|
+
method, BASE_URL + url, extensions={"workflowval_template": template}, **options
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
def known_issue(self, reason: str) -> None:
|
|
198
|
+
"""Note that this test has a failure already reviewed as real."""
|
|
199
|
+
global _known_issue
|
|
200
|
+
_known_issue = reason
|
|
201
|
+
|
|
202
|
+
def expected_failure(self) -> None:
|
|
203
|
+
"""End the test as an expected failure, with the reviewed reason."""
|
|
204
|
+
pytest.xfail(_known_issue or "known issue")
|
|
205
|
+
|
|
206
|
+
@contextmanager
|
|
207
|
+
def expected_failure_on_assert(self) -> Iterator[None]:
|
|
208
|
+
"""Treat a failed check inside this block as the reviewed failure."""
|
|
209
|
+
try:
|
|
210
|
+
yield
|
|
211
|
+
except AssertionError:
|
|
212
|
+
self.expected_failure()
|
|
213
|
+
|
|
214
|
+
def cleanup(self, method: str, template: str, **options: Any) -> None:
|
|
215
|
+
"""Remove test data. A failure here must not hide the test's own result."""
|
|
216
|
+
global _recording
|
|
217
|
+
if READ_ONLY and method.upper() not in SAFE_METHODS:
|
|
218
|
+
return
|
|
219
|
+
_recording = False
|
|
220
|
+
try:
|
|
221
|
+
self.call(method, template, **options)
|
|
222
|
+
except httpx.HTTPError:
|
|
223
|
+
pass
|
|
224
|
+
finally:
|
|
225
|
+
_recording = True
|
|
226
|
+
|
|
227
|
+
def value(self, response: httpx.Response, path: str) -> Any:
|
|
228
|
+
"""Read a value from a JSON response, like $.a.b[0]."""
|
|
229
|
+
try:
|
|
230
|
+
document = response.json()
|
|
231
|
+
except ValueError as exc:
|
|
232
|
+
raise AssertionError(f"response isn't JSON: {response.text[:200]}") from exc
|
|
233
|
+
return _value_at(document, path)
|
|
234
|
+
|
|
235
|
+
def fill(self, text: str, saved: dict[str, Any]) -> str:
|
|
236
|
+
"""Replace {placeholders} inside a longer string."""
|
|
237
|
+
known = {**saved, "unique": self.unique, "unique_int": self.unique_int}
|
|
238
|
+
return _PLACEHOLDER.sub(lambda match: str(known[match.group(1)]), text)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@pytest.fixture
|
|
242
|
+
def api() -> Generator[Api, None, None]:
|
|
243
|
+
"""One API helper per test, with fresh unique values."""
|
|
244
|
+
global _steps_run, _known_issue
|
|
245
|
+
_exchanges.clear()
|
|
246
|
+
_steps_run = 0
|
|
247
|
+
_known_issue = None
|
|
248
|
+
with _make_client(authenticated=True) as client, _make_client(authenticated=False) as anon:
|
|
249
|
+
yield Api(client, anon)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _body(content: bytes) -> Any:
|
|
253
|
+
text = _mask(content.decode("utf-8", errors="replace"))[:4000]
|
|
254
|
+
try:
|
|
255
|
+
return json.loads(text)
|
|
256
|
+
except ValueError:
|
|
257
|
+
return text
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _headers(headers: httpx.Headers) -> dict[str, str]:
|
|
261
|
+
return {
|
|
262
|
+
name: MASK if name.lower() in SECRET_HEADERS else _mask(value)
|
|
263
|
+
for name, value in headers.items()
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _exchange(response: httpx.Response) -> dict[str, Any]:
|
|
268
|
+
request = response.request
|
|
269
|
+
return {
|
|
270
|
+
"method": request.method,
|
|
271
|
+
"url": _mask(str(request.url)),
|
|
272
|
+
"request_headers": _headers(request.headers),
|
|
273
|
+
"request_body": _body(request.content) if request.content else None,
|
|
274
|
+
"status": response.status_code,
|
|
275
|
+
"response_headers": _headers(response.headers),
|
|
276
|
+
"response_body": _body(response.content) if response.content else None,
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _call(response: httpx.Response) -> dict[str, Any]:
|
|
281
|
+
"""One request and its whole response, for checking against the spec."""
|
|
282
|
+
raw_body = response.content.decode("utf-8", errors="replace")
|
|
283
|
+
return {
|
|
284
|
+
**_exchange(response),
|
|
285
|
+
"template": response.request.extensions.get("workflowval_template"),
|
|
286
|
+
"content_type": response.headers.get("content-type"),
|
|
287
|
+
"raw_body": (raw_body if KEEP_RAW_BODIES else _mask(raw_body))[:_MAX_RAW_BODY],
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def pytest_runtest_logreport(report: pytest.TestReport) -> None:
|
|
292
|
+
"""Record each test's outcome and its last 3 HTTP exchanges."""
|
|
293
|
+
if report.when == "call" or report.outcome == "failed":
|
|
294
|
+
crash = getattr(report.longrepr, "reprcrash", None)
|
|
295
|
+
message = crash.message if crash else str(report.longrepr or "")
|
|
296
|
+
_results[report.nodeid] = {
|
|
297
|
+
"status": "xfailed" if hasattr(report, "wasxfail") else report.outcome,
|
|
298
|
+
"duration_s": report.duration,
|
|
299
|
+
"failing_assertion": _mask(message) if report.failed else None,
|
|
300
|
+
"stdout": _mask(report.capstdout),
|
|
301
|
+
"exchanges": [_exchange(response) for response in _exchanges[-3:]],
|
|
302
|
+
"calls": [_call(response) for response in _exchanges],
|
|
303
|
+
"steps_run": _steps_run,
|
|
304
|
+
"known_issue": _mask(_known_issue) if _known_issue else None,
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def pytest_collectreport(report: pytest.CollectReport) -> None:
|
|
309
|
+
"""Record test files that can't even be imported."""
|
|
310
|
+
if report.failed:
|
|
311
|
+
_results[report.nodeid] = {
|
|
312
|
+
"status": "error",
|
|
313
|
+
"duration_s": 0.0,
|
|
314
|
+
"failing_assertion": _mask(str(report.longrepr))[-2000:],
|
|
315
|
+
"stdout": "",
|
|
316
|
+
"exchanges": [],
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def pytest_terminal_summary(terminalreporter: Any) -> None:
|
|
321
|
+
"""Say when a test marked as a known issue passed, so the bug may be fixed."""
|
|
322
|
+
for node_id, result in _results.items():
|
|
323
|
+
if result["status"] == "passed" and result.get("known_issue"):
|
|
324
|
+
terminalreporter.write_line(f"{node_id} passed; this may be fixed: {result['known_issue']}")
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def pytest_sessionfinish() -> None:
|
|
328
|
+
"""Write results for workflowval, when it's the one running these tests."""
|
|
329
|
+
path = os.environ.get("WORKFLOWVAL_CAPTURE")
|
|
330
|
+
if path:
|
|
331
|
+
with open(path, "w", encoding="utf-8") as handle:
|
|
332
|
+
json.dump(_results, handle, default=str)
|