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
@@ -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}")
@@ -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)