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.
- ghost_api/__init__.py +3 -0
- ghost_api/__main__.py +5 -0
- ghost_api/adopt.py +173 -0
- ghost_api/agent/__init__.py +0 -0
- ghost_api/agent/orchestrator.py +413 -0
- ghost_api/agent/prompts.py +149 -0
- ghost_api/agent/tools.py +84 -0
- ghost_api/analysis/__init__.py +0 -0
- ghost_api/analysis/analyzer.py +130 -0
- ghost_api/analysis/detectors.py +188 -0
- ghost_api/analysis/exposure.py +80 -0
- ghost_api/analysis/graphql.py +67 -0
- ghost_api/analysis/models.py +64 -0
- ghost_api/analysis/schema.py +109 -0
- ghost_api/analysis/url_grouping.py +217 -0
- ghost_api/api.py +264 -0
- ghost_api/audit.py +78 -0
- ghost_api/browser/__init__.py +0 -0
- ghost_api/browser/auth_state.py +172 -0
- ghost_api/browser/driver.py +273 -0
- ghost_api/browser/egress.py +183 -0
- ghost_api/browser/gate.py +127 -0
- ghost_api/browser/install.py +28 -0
- ghost_api/browser/snapshot.py +221 -0
- ghost_api/capture/__init__.py +0 -0
- ghost_api/capture/filters.py +255 -0
- ghost_api/capture/har.py +148 -0
- ghost_api/capture/models.py +41 -0
- ghost_api/capture/personal.py +188 -0
- ghost_api/capture/recorder.py +181 -0
- ghost_api/cli/__init__.py +44 -0
- ghost_api/cli/_common.py +219 -0
- ghost_api/cli/client.py +487 -0
- ghost_api/cli/data.py +492 -0
- ghost_api/cli/explore.py +379 -0
- ghost_api/cli/manage.py +437 -0
- ghost_api/config.py +132 -0
- ghost_api/drift.py +344 -0
- ghost_api/drive.py +211 -0
- ghost_api/errors.py +106 -0
- ghost_api/events.py +213 -0
- ghost_api/exitcodes.py +21 -0
- ghost_api/export/__init__.py +71 -0
- ghost_api/export/confidence.py +45 -0
- ghost_api/export/html.py +41 -0
- ghost_api/export/openapi.py +201 -0
- ghost_api/export/report.py +227 -0
- ghost_api/export/site_data.py +196 -0
- ghost_api/export/tabular.py +88 -0
- ghost_api/export/viewer/report.html +286 -0
- ghost_api/generator/__init__.py +221 -0
- ghost_api/generator/cassettes.py +80 -0
- ghost_api/generator/models_gen.py +144 -0
- ghost_api/generator/naming.py +174 -0
- ghost_api/generator/package.py +197 -0
- ghost_api/generator/python_gen.py +172 -0
- ghost_api/generator/spec.py +486 -0
- ghost_api/generator/templates/client.py.j2 +518 -0
- ghost_api/generator/templates/client.test.ts.j2 +106 -0
- ghost_api/generator/templates/client.ts.j2 +339 -0
- ghost_api/generator/templates/models.py.j2 +45 -0
- ghost_api/generator/templates/package_readme.md.j2 +111 -0
- ghost_api/generator/templates/pyproject.toml.j2 +22 -0
- ghost_api/generator/templates/test_client.py.j2 +105 -0
- ghost_api/generator/ts_gen.py +232 -0
- ghost_api/integrations/__init__.py +0 -0
- ghost_api/integrations/claude_code/commands/ghost-data.md +10 -0
- ghost_api/integrations/claude_code/commands/ghost-diff.md +10 -0
- ghost_api/integrations/claude_code/commands/ghost-explore.md +10 -0
- ghost_api/integrations/claude_code/skills/ghost-api/SKILL.md +46 -0
- ghost_api/integrations/setup.py +84 -0
- ghost_api/llm/__init__.py +0 -0
- ghost_api/llm/doctor.py +60 -0
- ghost_api/llm/gateway.py +476 -0
- ghost_api/llm/providers.py +124 -0
- ghost_api/memory/__init__.py +0 -0
- ghost_api/memory/knowledge.py +157 -0
- ghost_api/memory/store.py +883 -0
- ghost_api/net.py +235 -0
- ghost_api/netguard.py +203 -0
- ghost_api/pipeline.py +745 -0
- ghost_api/probe/__init__.py +0 -0
- ghost_api/probe/prober.py +311 -0
- ghost_api/probe/replay.py +313 -0
- ghost_api/project.py +186 -0
- ghost_api/py.typed +0 -0
- ghost_api/runtime.py +397 -0
- ghost_api/serve/__init__.py +101 -0
- ghost_api/serve/explorer_mcp.py +305 -0
- ghost_api/serve/mcp.py +121 -0
- ghost_api/serve/mock.py +130 -0
- ghost_api/serve/rest.py +132 -0
- ghost_api/site.py +84 -0
- ghost_api/specdiff.py +122 -0
- ghost_api/ui/__init__.py +0 -0
- ghost_api/ui/keys.py +134 -0
- ghost_api/ui/repl.py +453 -0
- ghost_api/ui/terminal.py +573 -0
- ghost_api/verify/__init__.py +0 -0
- ghost_api/verify/classify.py +84 -0
- ghost_api/verify/repair.py +40 -0
- ghost_api/verify/runner.py +110 -0
- ghost_api/verify/verifier.py +291 -0
- ghost_api/watch.py +313 -0
- ghost_api/web/__init__.py +1 -0
- ghost_api/web/server.py +690 -0
- ghost_api/web/static/assets/Endpoints-zPnjlcM8.js +46 -0
- ghost_api/web/static/assets/Run-D85zWHey.js +91 -0
- ghost_api/web/static/assets/Settings-D0aWHFAt.js +11 -0
- ghost_api/web/static/assets/Site-a88shEmP.js +71 -0
- ghost_api/web/static/assets/Watches-CqZWIa9G.js +16 -0
- ghost_api/web/static/assets/circle-x-BcTE_1tr.js +11 -0
- ghost_api/web/static/assets/index-Ds2e5B9f.js +141 -0
- ghost_api/web/static/assets/index-xQpyIBbG.css +1 -0
- ghost_api/web/static/assets/minus-C2xqP1eQ.js +6 -0
- ghost_api/web/static/assets/refresh-cw-CxdR_8Oi.js +6 -0
- ghost_api/web/static/favicon.svg +1 -0
- ghost_api/web/static/index.html +26 -0
- ghost_api/workflows.py +203 -0
- ghost_api_cli-0.2.0.dist-info/METADATA +1019 -0
- ghost_api_cli-0.2.0.dist-info/RECORD +125 -0
- ghost_api_cli-0.2.0.dist-info/WHEEL +5 -0
- ghost_api_cli-0.2.0.dist-info/entry_points.txt +2 -0
- ghost_api_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- 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
|