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,148 @@
1
+ """`workflows/<id>.json`: one reviewable workflow, which becomes one test file (FR-4)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Iterator
7
+ from enum import StrEnum
8
+ from typing import Any, Literal
9
+
10
+ from pydantic import Field, field_validator, model_validator
11
+
12
+ from workflowval.schemas._base import Strict
13
+
14
+ _PLACEHOLDER = re.compile(r"\{([A-Za-z_]\w*)\}")
15
+ _JSON_PATH = re.compile(r"\$(\.[A-Za-z_][\w-]*|\[\d+\])*")
16
+ BUILTIN_VALUES = frozenset({"unique", "unique_int"})
17
+ """Placeholders always available: fresh values per test run, so runs don't collide (FR-5)."""
18
+
19
+
20
+ class Source(StrEnum):
21
+ """Who added the workflow."""
22
+
23
+ PLANNER = "planner"
24
+ REVIEWER = "reviewer"
25
+ USER = "user"
26
+
27
+
28
+ class Basis(StrEnum):
29
+ """Whether the rule is documented or was inferred by the Reviewer."""
30
+
31
+ DOCUMENTED = "documented"
32
+ ASSUMED = "assumed"
33
+
34
+
35
+ class Expect(Strict):
36
+ """Expected response. `body` maps JSONPath expressions to expected values."""
37
+
38
+ status: int = Field(ge=100, le=599)
39
+ body: dict[str, Any] = Field(default_factory=dict)
40
+
41
+ @field_validator("body")
42
+ @classmethod
43
+ def _body_keys_are_jsonpath(cls, value: dict[str, Any]) -> dict[str, Any]:
44
+ for key in value:
45
+ if not _JSON_PATH.fullmatch(key):
46
+ raise ValueError(f"expect.body key must be a JSONPath like $.a.b[0]: {key!r}")
47
+ return value
48
+
49
+
50
+ class CleanupStep(Strict):
51
+ """A request that removes data the workflow created (NFR-3)."""
52
+
53
+ op: str = Field(min_length=1, description="Spec operationId")
54
+ params: dict[str, Any] = Field(default_factory=dict)
55
+ body: Any = None
56
+
57
+
58
+ class Step(CleanupStep):
59
+ """A request plus its expectation.
60
+
61
+ `body` and `auth` are additions to the doc's example, which has no way to send a request body
62
+ or to call a route unauthenticated. See docs/m0-review.md.
63
+ """
64
+
65
+ save: dict[str, str] = Field(default_factory=dict, description="Name to JSONPath")
66
+ expect: Expect
67
+ auth: bool = Field(default=True, description="False sends this request without credentials")
68
+
69
+ @field_validator("save")
70
+ @classmethod
71
+ def _save_values_are_jsonpath(cls, value: dict[str, str]) -> dict[str, str]:
72
+ for name, path in value.items():
73
+ if not _JSON_PATH.fullmatch(path):
74
+ raise ValueError(f"save[{name!r}] must be a JSONPath like $.a.b[0]: {path!r}")
75
+ return value
76
+
77
+
78
+ class KnownIssue(Strict):
79
+ """A failure reviewed as real, which the test now expects. Written by code, never by a model.
80
+
81
+ It excuses only this check on this step: any other failure still fails the test.
82
+ """
83
+
84
+ label: Literal["API_BUG", "SPEC_DRIFT", "CONFLICT"]
85
+ confidence: Literal["low", "med", "high"]
86
+ reason: str = Field(min_length=1)
87
+ step: int = Field(ge=0, description="Index in `steps` of the step that fails")
88
+ check: Literal["status", "body"]
89
+ status: int | None = Field(default=None, description="The status seen, for a status check")
90
+ report: str | None = Field(default=None, description="The bug report or spec patch file")
91
+
92
+ @model_validator(mode="after")
93
+ def _status_check_names_a_status(self) -> KnownIssue:
94
+ if self.check == "status" and self.status is None:
95
+ raise ValueError("a status check must name the status it saw")
96
+ return self
97
+
98
+
99
+ def _placeholders(value: Any) -> Iterator[str]:
100
+ if isinstance(value, str):
101
+ yield from _PLACEHOLDER.findall(value)
102
+ elif isinstance(value, dict):
103
+ for item in value.values():
104
+ yield from _placeholders(item)
105
+ elif isinstance(value, list):
106
+ for item in value:
107
+ yield from _placeholders(item)
108
+
109
+
110
+ class Workflow(Strict):
111
+ """One user journey. A locked workflow belongs to the user; agents may never change it."""
112
+
113
+ id: str = Field(
114
+ pattern=r"^[a-z0-9_]+$",
115
+ description="Lowercase, digits and underscores: names workflows/<id>.json and test_<id>.py",
116
+ )
117
+ source: Source
118
+ basis: Basis
119
+ locked: bool = False
120
+ rule: str = Field(
121
+ pattern=r"\S",
122
+ description="The business rule this workflow checks, and where it comes from. Required.",
123
+ )
124
+ steps: list[Step] = Field(min_length=1)
125
+ cleanup: list[CleanupStep] = Field(default_factory=list)
126
+ known_issue: KnownIssue | None = None
127
+
128
+ @model_validator(mode="after")
129
+ def _check(self) -> Workflow:
130
+ if self.locked and self.source is not Source.USER:
131
+ raise ValueError("only workflows with source 'user' can be locked")
132
+ if self.known_issue is not None and self.known_issue.step >= len(self.steps):
133
+ raise ValueError("known_issue.step isn't one of this workflow's steps")
134
+ saved = set(BUILTIN_VALUES)
135
+ for index, step in enumerate(self.steps):
136
+ self._require_saved(_placeholders([step.params, step.body]), saved, f"steps[{index}]")
137
+ saved |= set(step.save)
138
+ self._require_saved(_placeholders(step.expect.body), saved, f"steps[{index}].expect")
139
+ for index, cleanup in enumerate(self.cleanup):
140
+ where = f"cleanup[{index}]"
141
+ self._require_saved(_placeholders([cleanup.params, cleanup.body]), saved, where)
142
+ return self
143
+
144
+ @staticmethod
145
+ def _require_saved(names: Iterator[str], saved: set[str], where: str) -> None:
146
+ for name in names:
147
+ if name not in saved:
148
+ raise ValueError(f"{where} uses {{{name}}}, which no earlier step saves")
@@ -0,0 +1,38 @@
1
+ """OpenAPI loading, the dependency graph and response validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from workflowval.spec.graph import (
6
+ Link,
7
+ Operation,
8
+ Parameter,
9
+ SpecGraph,
10
+ build_graph,
11
+ resolve_base_url,
12
+ )
13
+ from workflowval.spec.loader import (
14
+ SpecError,
15
+ load_raw_spec,
16
+ load_spec,
17
+ parse_spec,
18
+ resolve_refs,
19
+ spec_hash,
20
+ )
21
+ from workflowval.spec.validation import ResponseChecker, SpecMismatch
22
+
23
+ __all__ = [
24
+ "Link",
25
+ "Operation",
26
+ "Parameter",
27
+ "ResponseChecker",
28
+ "SpecError",
29
+ "SpecGraph",
30
+ "SpecMismatch",
31
+ "build_graph",
32
+ "load_raw_spec",
33
+ "load_spec",
34
+ "parse_spec",
35
+ "resolve_base_url",
36
+ "resolve_refs",
37
+ "spec_hash",
38
+ ]
@@ -0,0 +1,196 @@
1
+ """The internal graph: resources, operations, parameters and producer-to-consumer links (FR-3)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Any
7
+ from urllib.parse import urlparse
8
+
9
+ from pydantic import BaseModel, Field
10
+
11
+ _METHODS = ("get", "put", "post", "delete", "patch", "head", "options")
12
+ _PATH_PARAM = re.compile(r"\{([^{}]+)\}")
13
+
14
+
15
+ class Parameter(BaseModel):
16
+ """One operation parameter."""
17
+
18
+ name: str
19
+ location: str
20
+ required: bool = False
21
+ schema_: dict[str, Any] = Field(default_factory=dict, alias="schema")
22
+
23
+
24
+ class Operation(BaseModel):
25
+ """One API call."""
26
+
27
+ operation_id: str
28
+ method: str
29
+ path: str
30
+ resource: str
31
+ summary: str = ""
32
+ description: str = ""
33
+ parameters: list[Parameter] = Field(default_factory=list)
34
+ request_schema: dict[str, Any] | None = None
35
+ responses: dict[str, dict[str, Any]] = Field(default_factory=dict)
36
+ security: list[str] = Field(default_factory=list)
37
+
38
+
39
+ class Link(BaseModel):
40
+ """A value one operation returns that another operation consumes."""
41
+
42
+ producer: str
43
+ field: str
44
+ consumer: str
45
+ parameter: str
46
+
47
+
48
+ class SpecGraph(BaseModel):
49
+ """Everything the agents and the test generator need to know about an API."""
50
+
51
+ title: str = ""
52
+ base_path: str = ""
53
+ resources: list[str] = Field(default_factory=list)
54
+ operations: list[Operation] = Field(default_factory=list)
55
+ links: list[Link] = Field(default_factory=list)
56
+ security_schemes: dict[str, dict[str, Any]] = Field(default_factory=dict)
57
+
58
+ def operation_at(self, method: str, path: str) -> Operation | None:
59
+ """Find an operation by its method and path template."""
60
+ wanted = method.upper()
61
+ return next(
62
+ (op for op in self.operations if op.path == path and op.method.upper() == wanted),
63
+ None,
64
+ )
65
+
66
+ def operation(self, operation_id: str) -> Operation | None:
67
+ """Find an operation by its id."""
68
+ return next((op for op in self.operations if op.operation_id == operation_id), None)
69
+
70
+
71
+ def _synthetic_id(method: str, path: str) -> str:
72
+ return "_".join([method, *re.findall(r"[A-Za-z0-9]+", path)])
73
+
74
+
75
+ def _json_schema(content: dict[str, Any] | None) -> dict[str, Any] | None:
76
+ for media_type, media in (content or {}).items():
77
+ if "json" in media_type:
78
+ schema = media.get("schema")
79
+ return schema if isinstance(schema, dict) else {}
80
+ return None
81
+
82
+
83
+ def _build_operation(
84
+ path: str,
85
+ method: str,
86
+ raw: dict[str, Any],
87
+ inherited: list[Any],
88
+ default_security: list[dict[str, Any]],
89
+ ) -> Operation:
90
+ merged = {(p["name"], p["in"]): p for p in [*inherited, *raw.get("parameters", [])]}
91
+ segments = [part for part in path.split("/") if part and not part.startswith("{")]
92
+ request = raw.get("requestBody")
93
+ responses = {
94
+ str(status): {
95
+ "description": response.get("description", ""),
96
+ "schema": _json_schema(response.get("content")),
97
+ }
98
+ for status, response in raw.get("responses", {}).items()
99
+ }
100
+ return Operation(
101
+ operation_id=raw.get("operationId") or _synthetic_id(method, path),
102
+ method=method.upper(),
103
+ path=path,
104
+ resource=segments[0] if segments else "",
105
+ summary=raw.get("summary", ""),
106
+ description=raw.get("description", ""),
107
+ parameters=[
108
+ Parameter(
109
+ name=p["name"],
110
+ location=p["in"],
111
+ required=bool(p.get("required")),
112
+ schema=p.get("schema") or {},
113
+ )
114
+ for p in merged.values()
115
+ ],
116
+ request_schema=_json_schema(request.get("content")) if request else None,
117
+ responses=responses,
118
+ # An operation's own `security` replaces the spec's, and an empty list means no auth at all.
119
+ security=[name for option in raw.get("security", default_security) for name in option],
120
+ )
121
+
122
+
123
+ def _is_success(status: str) -> bool:
124
+ return status == "default" or status.startswith("2")
125
+
126
+
127
+ def _returned_fields(operation: Operation) -> set[str]:
128
+ fields: set[str] = set()
129
+ for status, response in operation.responses.items():
130
+ if _is_success(status):
131
+ fields |= set((response.get("schema") or {}).get("properties", {}))
132
+ return fields
133
+
134
+
135
+ def _singular(word: str) -> str:
136
+ return word[:-1] if word.endswith("s") and not word.endswith("ss") else word
137
+
138
+
139
+ def _matching_field(fields: set[str], parameter: str, collection: str) -> str | None:
140
+ """Find the returned field that feeds a path parameter, most specific first."""
141
+ resource = _singular(collection.rstrip("/").rsplit("/", 1)[-1]).lower()
142
+ wanted = (parameter.lower(), f"{resource}id", f"{resource}_id", "id")
143
+ by_lower = {field.lower(): field for field in fields}
144
+ return next((by_lower[name] for name in wanted if name in by_lower), None)
145
+
146
+
147
+ def _build_links(operations: list[Operation]) -> list[Link]:
148
+ links = []
149
+ for consumer in operations:
150
+ for match in _PATH_PARAM.finditer(consumer.path):
151
+ collection = consumer.path[: match.start()].rstrip("/")
152
+ for producer in operations:
153
+ if producer.method != "POST" or producer.path.rstrip("/") != collection:
154
+ continue
155
+ field = _matching_field(_returned_fields(producer), match.group(1), collection)
156
+ if field:
157
+ links.append(
158
+ Link(
159
+ producer=producer.operation_id,
160
+ field=field,
161
+ consumer=consumer.operation_id,
162
+ parameter=match.group(1),
163
+ )
164
+ )
165
+ return links
166
+
167
+
168
+ def build_graph(spec: dict[str, Any]) -> SpecGraph:
169
+ """Build the graph from a spec whose `$ref`s are already resolved."""
170
+ operations = []
171
+ for path, item in spec.get("paths", {}).items():
172
+ for method in _METHODS:
173
+ if method in item:
174
+ inherited = item.get("parameters", [])
175
+ operations.append(
176
+ _build_operation(
177
+ path, method, item[method], inherited, spec.get("security", [])
178
+ )
179
+ )
180
+ servers = spec.get("servers") or [{}]
181
+ return SpecGraph(
182
+ title=spec.get("info", {}).get("title", ""),
183
+ base_path=urlparse(servers[0].get("url", "")).path.rstrip("/"),
184
+ resources=sorted({op.resource for op in operations if op.resource}),
185
+ operations=operations,
186
+ links=_build_links(operations),
187
+ security_schemes=spec.get("components", {}).get("securitySchemes", {}),
188
+ )
189
+
190
+
191
+ def resolve_base_url(base_url: str, graph: SpecGraph) -> str:
192
+ """Add the spec's server path to a base URL, unless the URL already ends with it."""
193
+ url = base_url.rstrip("/")
194
+ if graph.base_path and not urlparse(url).path.rstrip("/").endswith(graph.base_path):
195
+ return url + graph.base_path
196
+ return url
@@ -0,0 +1,102 @@
1
+ """Load and validate an OpenAPI 3.x JSON spec from a file or URL, and resolve its `$ref`s (FR-1)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import hashlib
7
+ import json
8
+ from pathlib import Path
9
+ from typing import Any
10
+ from urllib.parse import urlparse
11
+
12
+ import httpx
13
+ from openapi_spec_validator import validate
14
+ from openapi_spec_validator.validation.exceptions import OpenAPIValidationError
15
+
16
+
17
+ class SpecError(Exception):
18
+ """A spec can't be used. The message says what's wrong and where."""
19
+
20
+
21
+ def read_spec_text(source: str) -> str:
22
+ """Read a spec's text from a path or an http(s) URL."""
23
+ if urlparse(source).scheme in {"http", "https"}:
24
+ try:
25
+ response = httpx.get(source, timeout=30, follow_redirects=True)
26
+ response.raise_for_status()
27
+ except httpx.HTTPError as exc:
28
+ raise SpecError(f"can't fetch spec from {source}: {exc}") from exc
29
+ return response.text
30
+ path = Path(source)
31
+ if not path.is_file():
32
+ raise SpecError(f"spec file not found: {source}")
33
+ return path.read_text(encoding="utf-8")
34
+
35
+
36
+ def parse_spec(text: str, source: str) -> dict[str, Any]:
37
+ """Parse and validate spec text. Only JSON is supported in v1."""
38
+ try:
39
+ spec = json.loads(text)
40
+ except json.JSONDecodeError as exc:
41
+ raise SpecError(
42
+ f"{source} isn't valid JSON ({exc}). Only JSON specs are supported: "
43
+ "convert YAML to JSON first."
44
+ ) from exc
45
+ if not isinstance(spec, dict):
46
+ raise SpecError(f"{source} must be a JSON object, not {type(spec).__name__}")
47
+ version = str(spec.get("openapi", ""))
48
+ if not version.startswith(("3.0", "3.1")):
49
+ found = version or "no 'openapi' field"
50
+ raise SpecError(f"{source}: only OpenAPI 3.0 and 3.1 are supported, found {found}")
51
+ try:
52
+ validate(spec)
53
+ except OpenAPIValidationError as exc:
54
+ where = "/".join(str(part) for part in exc.absolute_path) or "(top level)"
55
+ raise SpecError(f"{source}: invalid spec at {where}: {exc.message}") from exc
56
+ return spec
57
+
58
+
59
+ def load_raw_spec(source: str) -> dict[str, Any]:
60
+ """Load a spec as written, with its `$ref`s. Spec patches apply to this form."""
61
+ return parse_spec(read_spec_text(source), source)
62
+
63
+
64
+ def load_spec(source: str) -> dict[str, Any]:
65
+ """Load a spec from a path or URL, validated, with every `$ref` resolved."""
66
+ return resolve_refs(parse_spec(read_spec_text(source), source))
67
+
68
+
69
+ def spec_hash(spec: dict[str, Any]) -> str:
70
+ """Return a stable hash of a spec, used to decide whether to re-plan."""
71
+ return hashlib.sha256(json.dumps(spec, sort_keys=True).encode()).hexdigest()
72
+
73
+
74
+ def _lookup(root: dict[str, Any], ref: str) -> Any:
75
+ if not ref.startswith("#/"):
76
+ raise SpecError(f"only local $refs are supported, found {ref!r}")
77
+ node: Any = root
78
+ for part in ref[2:].split("/"):
79
+ key = part.replace("~1", "/").replace("~0", "~")
80
+ if not isinstance(node, dict) or key not in node:
81
+ raise SpecError(f"$ref points at nothing: {ref}")
82
+ node = node[key]
83
+ return node
84
+
85
+
86
+ def resolve_refs(spec: dict[str, Any]) -> dict[str, Any]:
87
+ """Return a copy with `$ref`s replaced. A recursive reference stays as a `$ref`, marking it."""
88
+
89
+ def walk(node: Any, active: tuple[str, ...]) -> Any:
90
+ if isinstance(node, list):
91
+ return [walk(item, active) for item in node]
92
+ if not isinstance(node, dict):
93
+ return node
94
+ ref = node.get("$ref")
95
+ if isinstance(ref, str):
96
+ if ref in active:
97
+ return {"$ref": ref}
98
+ return walk(copy.deepcopy(_lookup(spec, ref)), (*active, ref))
99
+ return {key: walk(value, active) for key, value in node.items()}
100
+
101
+ resolved: dict[str, Any] = walk(spec, ())
102
+ return resolved
@@ -0,0 +1,98 @@
1
+ """Checks live responses against a spec. The one boundary around openapi-core (FR-8)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ from collections.abc import Mapping
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+ from openapi_core import Config, OpenAPI
11
+ from openapi_core.exceptions import OpenAPIError
12
+ from openapi_core.testing import MockRequest, MockResponse
13
+
14
+ from workflowval.redact import MASK
15
+
16
+ _SERVER = "http://workflowval.invalid"
17
+ _MAX_MESSAGE = 300
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class SpecMismatch:
22
+ """One way a response breaks the spec. `key` is the same however the value differs."""
23
+
24
+ key: str
25
+ message: str
26
+
27
+
28
+ def _without_servers(spec: dict[str, Any]) -> dict[str, Any]:
29
+ """Point the spec at one fixed server, since callers check by operation path, not by host."""
30
+ copied = copy.deepcopy(spec)
31
+ copied["servers"] = [{"url": _SERVER}]
32
+ for item in copied.get("paths", {}).values():
33
+ item.pop("servers", None)
34
+ for operation in item.values():
35
+ if isinstance(operation, dict):
36
+ operation.pop("servers", None)
37
+ return copied
38
+
39
+
40
+ def _schema_errors(exc: BaseException) -> list[Any]:
41
+ seen: BaseException | None = exc
42
+ while seen is not None:
43
+ errors = getattr(seen, "schema_errors", None)
44
+ if errors:
45
+ return list(errors)
46
+ seen = seen.__cause__
47
+ return []
48
+
49
+
50
+ def _from_schema_error(error: Any) -> SpecMismatch:
51
+ parts = list(error.absolute_path)
52
+ path = "$" + "".join("[]" if isinstance(part, int) else f".{part}" for part in parts)
53
+ message = str(error.message)
54
+ # The key must be the same wherever one schema shows up, in a list or under another status, and
55
+ # whatever the bad value was. So it uses the field's name and the rule broken, not the path.
56
+ if error.validator == "required":
57
+ return SpecMismatch(message, f"{path}: {message}"[:_MAX_MESSAGE])
58
+ names = [part for part in parts if not isinstance(part, int)]
59
+ field = names[-1] if names else "$"
60
+ shape = f"{field}: {error.validator} {error.validator_value!r}"[:_MAX_MESSAGE]
61
+ return SpecMismatch(shape, f"{path}: {message}"[:_MAX_MESSAGE])
62
+
63
+
64
+ class ResponseChecker:
65
+ """Checks responses against one spec, whichever host they came from."""
66
+
67
+ def check_call(self, call: Mapping[str, Any]) -> list[SpecMismatch]:
68
+ """Check one recorded call. A 5xx is an API failure, and its body says nothing of drift."""
69
+ template, status = call.get("template"), call.get("status")
70
+ if not isinstance(template, str) or not isinstance(status, int) or status >= 500:
71
+ return []
72
+ body = str(call.get("raw_body") or "").encode()
73
+ found = self.check(str(call["method"]), template, status, call.get("content_type"), body)
74
+ # A value that was hidden can't be judged: the mask itself would break a format rule.
75
+ return [mismatch for mismatch in found if MASK not in mismatch.message]
76
+
77
+ def __init__(self, spec: dict[str, Any], *, validate_spec: bool = False) -> None:
78
+ config = None if validate_spec else Config(spec_validator_cls=None)
79
+ self._api = OpenAPI.from_dict(_without_servers(spec), config=config)
80
+
81
+ def check(
82
+ self, method: str, path: str, status: int, content_type: str | None, body: bytes
83
+ ) -> list[SpecMismatch]:
84
+ """Return the ways a response breaks the spec. `path` is the spec's path template."""
85
+ request = MockRequest(_SERVER, method.lower(), path)
86
+ response = MockResponse(body, status_code=status, content_type=content_type or "")
87
+ try:
88
+ self._api.validate_response(request, response)
89
+ except OpenAPIError as exc:
90
+ errors = _schema_errors(exc)
91
+ if errors:
92
+ return [_from_schema_error(error) for error in errors]
93
+ kind = type(exc).__name__
94
+ detail = str(exc.__cause__ or exc)[:_MAX_MESSAGE]
95
+ if kind == "ResponseNotFound":
96
+ return [SpecMismatch(f"status {status}", f"status {status} isn't in the spec")]
97
+ return [SpecMismatch(f"{kind} on status {status}", detail)]
98
+ return []