ghost-api-cli 0.2.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 (125) hide show
  1. ghost_api/__init__.py +3 -0
  2. ghost_api/__main__.py +5 -0
  3. ghost_api/adopt.py +173 -0
  4. ghost_api/agent/__init__.py +0 -0
  5. ghost_api/agent/orchestrator.py +413 -0
  6. ghost_api/agent/prompts.py +149 -0
  7. ghost_api/agent/tools.py +84 -0
  8. ghost_api/analysis/__init__.py +0 -0
  9. ghost_api/analysis/analyzer.py +130 -0
  10. ghost_api/analysis/detectors.py +188 -0
  11. ghost_api/analysis/exposure.py +80 -0
  12. ghost_api/analysis/graphql.py +67 -0
  13. ghost_api/analysis/models.py +64 -0
  14. ghost_api/analysis/schema.py +109 -0
  15. ghost_api/analysis/url_grouping.py +217 -0
  16. ghost_api/api.py +264 -0
  17. ghost_api/audit.py +78 -0
  18. ghost_api/browser/__init__.py +0 -0
  19. ghost_api/browser/auth_state.py +172 -0
  20. ghost_api/browser/driver.py +273 -0
  21. ghost_api/browser/egress.py +183 -0
  22. ghost_api/browser/gate.py +127 -0
  23. ghost_api/browser/install.py +28 -0
  24. ghost_api/browser/snapshot.py +221 -0
  25. ghost_api/capture/__init__.py +0 -0
  26. ghost_api/capture/filters.py +255 -0
  27. ghost_api/capture/har.py +148 -0
  28. ghost_api/capture/models.py +41 -0
  29. ghost_api/capture/personal.py +188 -0
  30. ghost_api/capture/recorder.py +181 -0
  31. ghost_api/cli/__init__.py +44 -0
  32. ghost_api/cli/_common.py +219 -0
  33. ghost_api/cli/client.py +487 -0
  34. ghost_api/cli/data.py +492 -0
  35. ghost_api/cli/explore.py +379 -0
  36. ghost_api/cli/manage.py +437 -0
  37. ghost_api/config.py +132 -0
  38. ghost_api/drift.py +344 -0
  39. ghost_api/drive.py +211 -0
  40. ghost_api/errors.py +106 -0
  41. ghost_api/events.py +213 -0
  42. ghost_api/exitcodes.py +21 -0
  43. ghost_api/export/__init__.py +71 -0
  44. ghost_api/export/confidence.py +45 -0
  45. ghost_api/export/html.py +41 -0
  46. ghost_api/export/openapi.py +201 -0
  47. ghost_api/export/report.py +227 -0
  48. ghost_api/export/site_data.py +196 -0
  49. ghost_api/export/tabular.py +88 -0
  50. ghost_api/export/viewer/report.html +286 -0
  51. ghost_api/generator/__init__.py +221 -0
  52. ghost_api/generator/cassettes.py +80 -0
  53. ghost_api/generator/models_gen.py +144 -0
  54. ghost_api/generator/naming.py +174 -0
  55. ghost_api/generator/package.py +197 -0
  56. ghost_api/generator/python_gen.py +172 -0
  57. ghost_api/generator/spec.py +486 -0
  58. ghost_api/generator/templates/client.py.j2 +518 -0
  59. ghost_api/generator/templates/client.test.ts.j2 +106 -0
  60. ghost_api/generator/templates/client.ts.j2 +339 -0
  61. ghost_api/generator/templates/models.py.j2 +45 -0
  62. ghost_api/generator/templates/package_readme.md.j2 +111 -0
  63. ghost_api/generator/templates/pyproject.toml.j2 +22 -0
  64. ghost_api/generator/templates/test_client.py.j2 +105 -0
  65. ghost_api/generator/ts_gen.py +232 -0
  66. ghost_api/integrations/__init__.py +0 -0
  67. ghost_api/integrations/claude_code/commands/ghost-data.md +10 -0
  68. ghost_api/integrations/claude_code/commands/ghost-diff.md +10 -0
  69. ghost_api/integrations/claude_code/commands/ghost-explore.md +10 -0
  70. ghost_api/integrations/claude_code/skills/ghost-api/SKILL.md +46 -0
  71. ghost_api/integrations/setup.py +84 -0
  72. ghost_api/llm/__init__.py +0 -0
  73. ghost_api/llm/doctor.py +60 -0
  74. ghost_api/llm/gateway.py +476 -0
  75. ghost_api/llm/providers.py +124 -0
  76. ghost_api/memory/__init__.py +0 -0
  77. ghost_api/memory/knowledge.py +157 -0
  78. ghost_api/memory/store.py +883 -0
  79. ghost_api/net.py +235 -0
  80. ghost_api/netguard.py +203 -0
  81. ghost_api/pipeline.py +745 -0
  82. ghost_api/probe/__init__.py +0 -0
  83. ghost_api/probe/prober.py +311 -0
  84. ghost_api/probe/replay.py +313 -0
  85. ghost_api/project.py +186 -0
  86. ghost_api/py.typed +0 -0
  87. ghost_api/runtime.py +397 -0
  88. ghost_api/serve/__init__.py +101 -0
  89. ghost_api/serve/explorer_mcp.py +305 -0
  90. ghost_api/serve/mcp.py +121 -0
  91. ghost_api/serve/mock.py +130 -0
  92. ghost_api/serve/rest.py +132 -0
  93. ghost_api/site.py +84 -0
  94. ghost_api/specdiff.py +122 -0
  95. ghost_api/ui/__init__.py +0 -0
  96. ghost_api/ui/keys.py +134 -0
  97. ghost_api/ui/repl.py +453 -0
  98. ghost_api/ui/terminal.py +573 -0
  99. ghost_api/verify/__init__.py +0 -0
  100. ghost_api/verify/classify.py +84 -0
  101. ghost_api/verify/repair.py +40 -0
  102. ghost_api/verify/runner.py +110 -0
  103. ghost_api/verify/verifier.py +291 -0
  104. ghost_api/watch.py +313 -0
  105. ghost_api/web/__init__.py +1 -0
  106. ghost_api/web/server.py +690 -0
  107. ghost_api/web/static/assets/Endpoints-zPnjlcM8.js +46 -0
  108. ghost_api/web/static/assets/Run-D85zWHey.js +91 -0
  109. ghost_api/web/static/assets/Settings-D0aWHFAt.js +11 -0
  110. ghost_api/web/static/assets/Site-a88shEmP.js +71 -0
  111. ghost_api/web/static/assets/Watches-CqZWIa9G.js +16 -0
  112. ghost_api/web/static/assets/circle-x-BcTE_1tr.js +11 -0
  113. ghost_api/web/static/assets/index-Ds2e5B9f.js +141 -0
  114. ghost_api/web/static/assets/index-xQpyIBbG.css +1 -0
  115. ghost_api/web/static/assets/minus-C2xqP1eQ.js +6 -0
  116. ghost_api/web/static/assets/refresh-cw-CxdR_8Oi.js +6 -0
  117. ghost_api/web/static/favicon.svg +1 -0
  118. ghost_api/web/static/index.html +26 -0
  119. ghost_api/workflows.py +203 -0
  120. ghost_api_cli-0.2.0.dist-info/METADATA +1019 -0
  121. ghost_api_cli-0.2.0.dist-info/RECORD +125 -0
  122. ghost_api_cli-0.2.0.dist-info/WHEEL +5 -0
  123. ghost_api_cli-0.2.0.dist-info/entry_points.txt +2 -0
  124. ghost_api_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
  125. ghost_api_cli-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,40 @@
1
+ """Targeted schema fixes for fields that failed validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ from typing import Any
7
+
8
+
9
+ def relax_schema(
10
+ schema: dict[str, Any] | None, locations: list[tuple[str, ...]]
11
+ ) -> dict[str, Any] | None:
12
+ """Make each failing field accept anything (and be optional).
13
+
14
+ `locations` are pydantic error paths such as ('quotes', '0', 'author', 'name');
15
+ numeric parts are list indexes and are skipped while walking the schema.
16
+ """
17
+ if schema is None:
18
+ return None
19
+ out = copy.deepcopy(schema)
20
+ for loc in locations:
21
+ _relax(out, [p for p in loc if not p.isdigit()])
22
+ return out
23
+
24
+
25
+ def _relax(node: dict[str, Any], path: list[str]) -> None:
26
+ if not path:
27
+ return
28
+ while node.get("type") == "array" and isinstance(node.get("items"), dict):
29
+ node = node["items"]
30
+ props = node.get("properties")
31
+ if not isinstance(props, dict) or path[0] not in props:
32
+ return
33
+ key = path[0]
34
+ if len(path) == 1:
35
+ props[key] = {} # any value
36
+ required = node.get("required")
37
+ if isinstance(required, list) and key in required:
38
+ required.remove(key)
39
+ return
40
+ _relax(props[key], path[1:])
@@ -0,0 +1,110 @@
1
+ """Run a generated test file in a subprocess and read the results (FR-32)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ import tempfile
10
+ import xml.etree.ElementTree as ET
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Literal
14
+
15
+ Outcome = Literal["passed", "failed", "skipped"]
16
+ TIMEOUT_S = 600
17
+
18
+
19
+ @dataclass
20
+ class TestOutcome:
21
+ __test__ = False # not a pytest test class, despite the name
22
+
23
+ name: str # e.g. test_iter_get_quotes
24
+ method: str # the client method it exercises, e.g. get_quotes
25
+ outcome: Outcome
26
+ message: str = "" # failure text (traceback tail) or skip reason
27
+
28
+
29
+ def method_of(test_name: str) -> str:
30
+ name = test_name.removeprefix("test_")
31
+ return name.removeprefix("iter_")
32
+
33
+
34
+ def run_tests(test_path: Path, env: dict[str, str] | None = None) -> list[TestOutcome]:
35
+ """Run one generated test file. `env` adds variables (e.g. credentials) for this
36
+ run only; they are passed to the subprocess and never written to disk."""
37
+ test_path = test_path.resolve() # the subprocess runs in another directory
38
+ with tempfile.TemporaryDirectory() as tmp:
39
+ report = Path(tmp) / "report.xml"
40
+ cmd = [
41
+ sys.executable, "-m", "pytest", str(test_path.name),
42
+ "-q", "-p", "no:cacheprovider", f"--junitxml={report}",
43
+ f"--rootdir={test_path.parent}", "-o", "addopts=",
44
+ ] # fmt: skip
45
+ # Verification checks the real API, never the recorded replies (cassettes.json).
46
+ full_env = {**os.environ, **(env or {}), "GHOST_API_LIVE": "1"}
47
+ try:
48
+ proc = subprocess.run(
49
+ cmd,
50
+ cwd=test_path.parent,
51
+ env=full_env,
52
+ capture_output=True,
53
+ text=True,
54
+ timeout=TIMEOUT_S,
55
+ )
56
+ except subprocess.TimeoutExpired:
57
+ return [TestOutcome("(all)", "", "failed", f"tests timed out after {TIMEOUT_S}s")]
58
+ if not report.exists():
59
+ tail = (proc.stdout + proc.stderr).strip()[-800:]
60
+ return [TestOutcome("(all)", "", "failed", f"pytest did not run: {tail}")]
61
+ return parse_junit(report.read_text(encoding="utf-8"))
62
+
63
+
64
+ def run_node_tests(test_path: Path, env: dict[str, str] | None = None) -> list[TestOutcome]:
65
+ """Run a generated TypeScript test file with Node's built-in runner (Node 23.6+ runs
66
+ .ts files directly). Results come back in the same JUnit format as pytest's."""
67
+ test_path = test_path.resolve()
68
+ node = shutil.which("node")
69
+ if node is None:
70
+ return [TestOutcome("(all)", "", "skipped", "Node.js is not installed")]
71
+ with tempfile.TemporaryDirectory() as tmp:
72
+ report = Path(tmp) / "report.xml"
73
+ cmd = [node, "--test", "--test-reporter=junit", f"--test-reporter-destination={report}",
74
+ test_path.name] # fmt: skip
75
+ try:
76
+ proc = subprocess.run(
77
+ cmd,
78
+ cwd=test_path.parent,
79
+ env={**os.environ, **(env or {}), "GHOST_API_LIVE": "1"},
80
+ capture_output=True,
81
+ text=True,
82
+ timeout=TIMEOUT_S,
83
+ )
84
+ except subprocess.TimeoutExpired:
85
+ return [TestOutcome("(all)", "", "failed", f"tests timed out after {TIMEOUT_S}s")]
86
+ if not report.exists() or not report.read_text(encoding="utf-8").strip():
87
+ tail = (proc.stdout + proc.stderr).strip()[-800:]
88
+ return [TestOutcome("(all)", "", "failed", f"node did not run the tests: {tail}")]
89
+ return parse_junit(report.read_text(encoding="utf-8"))
90
+
91
+
92
+ def parse_junit(xml_text: str) -> list[TestOutcome]:
93
+ outcomes: list[TestOutcome] = []
94
+ root = ET.fromstring(xml_text)
95
+ for case in root.iter("testcase"):
96
+ name = case.get("name", "")
97
+ problem = case.find("failure")
98
+ if problem is None:
99
+ problem = case.find("error")
100
+ skipped = case.find("skipped")
101
+ if problem is not None:
102
+ text = f"{problem.get('message', '')}\n{problem.text or ''}"
103
+ outcomes.append(TestOutcome(name, method_of(name), "failed", text.strip()))
104
+ elif skipped is not None:
105
+ outcomes.append(
106
+ TestOutcome(name, method_of(name), "skipped", skipped.get("message", ""))
107
+ )
108
+ else:
109
+ outcomes.append(TestOutcome(name, method_of(name), "passed"))
110
+ return outcomes
@@ -0,0 +1,291 @@
1
+ """Run the generated tests, diagnose failures, repair, and try again (Stage 7).
2
+
3
+ Each round: generate the client -> run its tests -> for each failing method, classify
4
+ the failure and apply one repair. Stops when everything passes, nothing more can be
5
+ done, or after MAX_ROUNDS (FR-34, FR-35, FR-36). Every endpoint ends up as:
6
+
7
+ verified its tests passed against the live API
8
+ unverified not tested, or failed for a reason that may be temporary
9
+ (rate limit, network, missing credential)
10
+ failed still failing after repairs (login needed, wrong path, bad model)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import time
16
+ from collections.abc import Callable
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+ from typing import Literal
20
+ from urllib.parse import parse_qsl, urlsplit
21
+
22
+ from ghost_api.analysis.analyzer import analyze, endpoint_keys
23
+ from ghost_api.analysis.models import Endpoint
24
+ from ghost_api.capture.models import CapturedRequest, RawRequest
25
+ from ghost_api.generator import GenerationResult, generate_from
26
+ from ghost_api.generator.naming import NamingPlan
27
+ from ghost_api.generator.spec import ClientSpec
28
+ from ghost_api.memory.store import Store
29
+ from ghost_api.probe.prober import Prober
30
+ from ghost_api.probe.replay import Replayer, ReplayRefused, is_replayable, with_overrides
31
+ from ghost_api.verify.classify import FailureKind, classify, short_reason, validation_locations
32
+ from ghost_api.verify.repair import relax_schema
33
+ from ghost_api.verify.runner import TestOutcome, run_node_tests, run_tests
34
+
35
+ MAX_ROUNDS = 3
36
+ BACKOFF_S = 20.0
37
+ Status = Literal["verified", "unverified", "failed"]
38
+ # Failures that are probably temporary or outside our control.
39
+ SOFT_FAILURES: set[FailureKind] = {"rate-limited", "network", "missing-credential", "unknown"}
40
+
41
+
42
+ @dataclass
43
+ class Repair:
44
+ method: str
45
+ kind: FailureKind
46
+ action: str # what was done, in plain words
47
+
48
+
49
+ @dataclass
50
+ class Round:
51
+ number: int
52
+ outcomes: list[TestOutcome]
53
+ repairs: list[Repair] = field(default_factory=list)
54
+
55
+
56
+ @dataclass
57
+ class VerifyReport:
58
+ rounds: list[Round]
59
+ statuses: dict[str, tuple[Status, str]] # Endpoint.key -> (status, note)
60
+ result: GenerationResult | None
61
+
62
+
63
+ class Verifier:
64
+ def __init__(
65
+ self,
66
+ domain: str,
67
+ start_url: str,
68
+ endpoints: list[Endpoint],
69
+ requests: list[CapturedRequest],
70
+ out_root: Path,
71
+ raw: list[RawRequest | None] | None = None,
72
+ plan: NamingPlan | None = None,
73
+ replayer: Replayer | None = None,
74
+ explore: Callable[[str], None] | None = None,
75
+ on_round: Callable[[Round], None] | None = None,
76
+ add_sample: Callable[[CapturedRequest, RawRequest], object] | None = None,
77
+ sleep: Callable[[float], None] = time.sleep,
78
+ test_runner: Callable[[Path, dict[str, str]], list[TestOutcome]] | None = None,
79
+ lang: str = "python",
80
+ store: Store | None = None,
81
+ ) -> None:
82
+ """`store`: only read, so regenerated packages carry the right client version."""
83
+ self.store = store
84
+ self.domain = domain
85
+ self.start_url = start_url
86
+ self.endpoints = endpoints
87
+ self.requests = requests # shared with the recorder: new samples land here
88
+ self.raw: list[RawRequest | None] = raw if raw is not None else [None] * len(requests)
89
+ self.out_root = out_root
90
+ self.plan = plan
91
+ self.replayer = replayer
92
+ self.explore = explore
93
+ self.on_round = on_round
94
+ self.add_sample = add_sample # e.g. TrafficRecorder.add, which also saves it
95
+ self.sleep = sleep
96
+ self.lang = lang
97
+ self.run_tests = test_runner or (run_node_tests if lang == "typescript" else run_tests)
98
+ self._tried: set[tuple[str, str]] = set() # (endpoint key, repair) already applied
99
+
100
+ # --- main loop ---
101
+
102
+ def run(self) -> VerifyReport:
103
+ rounds: list[Round] = []
104
+ result: GenerationResult | None = None
105
+ for number in range(1, MAX_ROUNDS + 1):
106
+ result = generate_from(
107
+ self.domain, self.start_url, self.endpoints, self.requests,
108
+ self.out_root, self.plan, self.lang, store=self.store,
109
+ ) # fmt: skip
110
+ outcomes = self.run_tests(result.client.tests_path, self._credentials_env(result.spec))
111
+ current = Round(number, outcomes)
112
+ rounds.append(current)
113
+ failing = _failures_by_method(outcomes)
114
+ if failing and number < MAX_ROUNDS:
115
+ for method, message in failing.items():
116
+ repair = self._repair(result.spec, method, message)
117
+ if repair is not None:
118
+ current.repairs.append(repair)
119
+ if self.on_round:
120
+ self.on_round(current)
121
+ if not failing or not current.repairs:
122
+ break
123
+ return VerifyReport(rounds, self._statuses(rounds, result), result)
124
+
125
+ # --- repairs ---
126
+
127
+ def _repair(self, spec: ClientSpec, method: str, message: str) -> Repair | None:
128
+ method_spec = next((m for m in spec.methods if m.name == method), None)
129
+ if method_spec is None:
130
+ return None
131
+ ep = self._endpoint(method_spec.endpoint_key)
132
+ if ep is None:
133
+ return None
134
+ kind = classify(message)
135
+
136
+ if kind == "schema":
137
+ if self._once(ep, "more-samples") and self._collect_samples(ep):
138
+ return Repair(method, kind, "collected fresh samples to widen the model")
139
+ locations = validation_locations(message)
140
+ if locations and self._once(ep, f"relax:{locations}"):
141
+ ep.response_schema = relax_schema(ep.response_schema, locations)
142
+ fields = ", ".join(".".join(p for p in loc if not p.isdigit()) for loc in locations)
143
+ return Repair(method, kind, f"relaxed fields that did not match: {fields}")
144
+ return None
145
+
146
+ if kind == "auth":
147
+ if self._once(ep, "reprobe") and self._reprobe(ep):
148
+ return Repair(method, kind, "re-tested which headers are required")
149
+ if self.explore is not None and self._once(ep, "explore"):
150
+ self.explore(
151
+ f"Revisit the pages that load {ep.label} so a fresh session is captured"
152
+ )
153
+ return Repair(method, kind, "revisited the site for a fresh session")
154
+ return None
155
+
156
+ if kind == "not-found":
157
+ if self._once(ep, "other-example") and self._rotate_examples(ep):
158
+ return Repair(method, kind, "retrying with a different captured example")
159
+ return None
160
+
161
+ if kind in ("rate-limited", "network") and self._once(ep, "backoff"):
162
+ self.sleep(BACKOFF_S)
163
+ return Repair(method, kind, f"waited {BACKOFF_S:.0f}s before retrying")
164
+ return None
165
+
166
+ def _once(self, ep: Endpoint, repair: str) -> bool:
167
+ key = (ep.key, repair)
168
+ if key in self._tried:
169
+ return False
170
+ self._tried.add(key)
171
+ return True
172
+
173
+ def _samples(self, ep: Endpoint) -> list[RawRequest]:
174
+ keys = endpoint_keys(self.requests)
175
+ return [r for k, r in zip(keys, self.raw, strict=False) if k == ep.key and r is not None]
176
+
177
+ def _add(self, captured: CapturedRequest, raw: RawRequest) -> None:
178
+ if self.add_sample is not None:
179
+ self.add_sample(captured, raw) # appends to the shared lists itself
180
+ else:
181
+ self.requests.append(captured)
182
+ self.raw.append(raw)
183
+
184
+ def _collect_samples(self, ep: Endpoint) -> bool:
185
+ """Replay the endpoint (and its next page) to get fresh responses, then re-analyze."""
186
+ if self.replayer is None:
187
+ return False
188
+ sample = next((s for s in reversed(self._samples(ep)) if is_replayable(s)), None)
189
+ if sample is None:
190
+ return False
191
+ probes = [sample]
192
+ page = ep.pagination or {}
193
+ if page.get("location") == "query" and page.get("style") in ("page", "offset"):
194
+ current = dict(parse_qsl(urlsplit(sample.url).query)).get(page["param"], "1")
195
+ if current.lstrip("-").isdigit():
196
+ probes.append(with_overrides(sample, query={page["param"]: str(int(current) + 1)}))
197
+ added = 0
198
+ for request in probes:
199
+ try:
200
+ result = self.replayer.send(request)
201
+ except ReplayRefused:
202
+ break
203
+ if result.ok and result.captured is not None:
204
+ self._add(result.captured, request)
205
+ added += 1
206
+ if added:
207
+ self._reanalyze()
208
+ return added > 0
209
+
210
+ def _reprobe(self, ep: Endpoint) -> bool:
211
+ if self.replayer is None:
212
+ return False
213
+ report = Prober(self.replayer, on_sample=self._add).probe(ep, self._samples(ep))
214
+ if not report.replayable:
215
+ return False
216
+ ep.required_headers = report.required_headers
217
+ ep.auth_type = report.auth_type
218
+ return True
219
+
220
+ def _rotate_examples(self, ep: Endpoint) -> bool:
221
+ changed = False
222
+ for p in ep.path_params:
223
+ if len(p.examples) > 1:
224
+ p.examples = p.examples[1:] + p.examples[:1]
225
+ changed = True
226
+ return changed
227
+
228
+ def _reanalyze(self) -> None:
229
+ """Rebuild endpoints from all samples, keeping what probing learned."""
230
+ old = {e.key: e for e in self.endpoints}
231
+ fresh = []
232
+ for ep in analyze(self.requests):
233
+ prev = old.get(ep.key)
234
+ if prev is None:
235
+ continue # verification only repairs known endpoints
236
+ ep.replayable, ep.auth_type = prev.replayable, prev.auth_type
237
+ ep.pagination, ep.required_headers = prev.pagination, prev.required_headers
238
+ fresh.append(ep)
239
+ self.endpoints[:] = fresh
240
+
241
+ def _endpoint(self, key: str) -> Endpoint | None:
242
+ return next((e for e in self.endpoints if e.key == key), None)
243
+
244
+ # --- credentials and results ---
245
+
246
+ def _credentials_env(self, spec: ClientSpec) -> dict[str, str]:
247
+ """Credential values from the captured originals, for the test process only."""
248
+ env: dict[str, str] = {}
249
+ for cred in spec.credentials:
250
+ for raw in reversed([r for r in self.raw if r is not None]):
251
+ value = None
252
+ if cred.location == "query":
253
+ value = dict(parse_qsl(urlsplit(raw.url).query)).get(cred.wire)
254
+ else:
255
+ value = next(
256
+ (v for k, v in raw.headers.items() if k.lower() == cred.wire.lower()),
257
+ None,
258
+ )
259
+ if value:
260
+ env[cred.env] = value
261
+ break
262
+ return env
263
+
264
+ def _statuses(
265
+ self, rounds: list[Round], result: GenerationResult | None
266
+ ) -> dict[str, tuple[Status, str]]:
267
+ statuses: dict[str, tuple[Status, str]] = {}
268
+ if result is None or not rounds:
269
+ return statuses
270
+ last = rounds[-1].outcomes
271
+ for m in result.spec.methods:
272
+ mine = [o for o in last if o.method == m.name]
273
+ failed = [o for o in mine if o.outcome == "failed"]
274
+ if failed:
275
+ kind = classify(failed[0].message)
276
+ status: Status = "unverified" if kind in SOFT_FAILURES else "failed"
277
+ statuses[m.endpoint_key] = (status, short_reason(kind, failed[0].message))
278
+ elif mine and all(o.outcome == "passed" for o in mine):
279
+ statuses[m.endpoint_key] = ("verified", f"{len(mine)} live test(s) passed")
280
+ else:
281
+ reason = next((o.message for o in mine if o.outcome == "skipped"), "not tested")
282
+ statuses[m.endpoint_key] = ("unverified", reason or "not tested")
283
+ return statuses
284
+
285
+
286
+ def _failures_by_method(outcomes: list[TestOutcome]) -> dict[str, str]:
287
+ failing: dict[str, str] = {}
288
+ for o in outcomes:
289
+ if o.outcome == "failed" and o.method and o.method not in failing:
290
+ failing[o.method] = o.message
291
+ return failing