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
ghost_api/drift.py
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""API drift: what changed in a site's API, how much it matters, and the client's version.
|
|
2
|
+
|
|
3
|
+
A *snapshot* is what a client user sees: each method (its call, parameters and return
|
|
4
|
+
type) and each model (its fields and their types). Snapshots are stored per site with a
|
|
5
|
+
version number. Comparing two snapshots gives a list of changes:
|
|
6
|
+
|
|
7
|
+
breaking code using the client can stop working: a method or field is gone, a type
|
|
8
|
+
changed, a parameter became required or disappeared
|
|
9
|
+
additive something new: a method, an optional parameter, a field, a model
|
|
10
|
+
|
|
11
|
+
The client's version follows semantic versioning: breaking -> major, additive -> minor,
|
|
12
|
+
anything else (a description, an example) -> patch. The first version is 1.0.0.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
from dataclasses import asdict, dataclass
|
|
20
|
+
from typing import Any, Literal
|
|
21
|
+
|
|
22
|
+
from ghost_api.generator.spec import ClientSpec
|
|
23
|
+
from ghost_api.memory.store import Store
|
|
24
|
+
|
|
25
|
+
Severity = Literal["breaking", "additive", "info"]
|
|
26
|
+
FIRST_VERSION = "1.0.0"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class Change:
|
|
31
|
+
severity: Severity
|
|
32
|
+
where: str # "get_quotes", "Quote.author"
|
|
33
|
+
text: str # plain words: "returns QuotesResponse instead of list[Quote]"
|
|
34
|
+
# "client": the client Ghost API generates now differs from the last version (the site's
|
|
35
|
+
# data changed, or a newer Ghost API types things differently); "live": a fresh reply
|
|
36
|
+
# from the site differs from what was captured.
|
|
37
|
+
source: str = "client"
|
|
38
|
+
|
|
39
|
+
def line(self) -> str:
|
|
40
|
+
mark = {"breaking": "!", "additive": "+", "info": "~"}[self.severity]
|
|
41
|
+
live = "[live] " if self.source == "live" else ""
|
|
42
|
+
return f"{mark} {live}{self.where}: {self.text}"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def snapshot(spec: ClientSpec) -> dict[str, Any]:
|
|
46
|
+
"""The client's public shape, as plain JSON (stable order, so equal APIs compare equal)."""
|
|
47
|
+
return {
|
|
48
|
+
"methods": {
|
|
49
|
+
m.name: {
|
|
50
|
+
"call": f"{m.http_method} {m.host or ''}{m.path}",
|
|
51
|
+
"params": {p.name: {"type": p.type, "required": p.required} for p in m.params},
|
|
52
|
+
"returns": m.response_type,
|
|
53
|
+
"summary": m.summary,
|
|
54
|
+
}
|
|
55
|
+
for m in sorted(spec.methods, key=lambda m: m.name)
|
|
56
|
+
},
|
|
57
|
+
"models": {
|
|
58
|
+
md.name: {f.name: {"type": f.type, "optional": f.optional} for f in md.fields}
|
|
59
|
+
for md in sorted(spec.models, key=lambda md: md.name)
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def compare(old: dict[str, Any], new: dict[str, Any]) -> list[Change]:
|
|
65
|
+
changes: list[Change] = []
|
|
66
|
+
old_m, new_m = old.get("methods", {}), new.get("methods", {})
|
|
67
|
+
for name in sorted(old_m.keys() - new_m.keys()):
|
|
68
|
+
changes.append(Change("breaking", name, f"method removed ({old_m[name]['call']})"))
|
|
69
|
+
for name in sorted(new_m.keys() - old_m.keys()):
|
|
70
|
+
changes.append(Change("additive", name, f"new method ({new_m[name]['call']})"))
|
|
71
|
+
for name in sorted(old_m.keys() & new_m.keys()):
|
|
72
|
+
changes += _method_changes(name, old_m[name], new_m[name])
|
|
73
|
+
old_d, new_d = old.get("models", {}), new.get("models", {})
|
|
74
|
+
for name in sorted(old_d.keys() - new_d.keys()):
|
|
75
|
+
changes.append(Change("breaking", name, "model removed"))
|
|
76
|
+
for name in sorted(new_d.keys() - old_d.keys()):
|
|
77
|
+
changes.append(Change("additive", name, "new model"))
|
|
78
|
+
for name in sorted(old_d.keys() & new_d.keys()):
|
|
79
|
+
changes += _field_changes(name, old_d[name], new_d[name])
|
|
80
|
+
return changes
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _method_changes(name: str, old: dict[str, Any], new: dict[str, Any]) -> list[Change]:
|
|
84
|
+
out: list[Change] = []
|
|
85
|
+
if old["call"] != new["call"]:
|
|
86
|
+
out.append(Change("info", name, f"now calls {new['call']} (was {old['call']})"))
|
|
87
|
+
if old["returns"] != new["returns"]:
|
|
88
|
+
out.append(
|
|
89
|
+
Change("breaking", name, f"returns {new['returns']} instead of {old['returns']}")
|
|
90
|
+
)
|
|
91
|
+
if old.get("summary") != new.get("summary"):
|
|
92
|
+
out.append(Change("info", name, "description changed"))
|
|
93
|
+
old_p, new_p = old["params"], new["params"]
|
|
94
|
+
for p in sorted(old_p.keys() - new_p.keys()):
|
|
95
|
+
out.append(Change("breaking", f"{name}({p})", "parameter removed"))
|
|
96
|
+
for p in sorted(new_p.keys() - old_p.keys()):
|
|
97
|
+
if new_p[p]["required"]:
|
|
98
|
+
out.append(Change("breaking", f"{name}({p})", "new required parameter"))
|
|
99
|
+
else:
|
|
100
|
+
out.append(Change("additive", f"{name}({p})", "new optional parameter"))
|
|
101
|
+
for p in sorted(old_p.keys() & new_p.keys()):
|
|
102
|
+
a, b = old_p[p], new_p[p]
|
|
103
|
+
if a["type"] != b["type"]:
|
|
104
|
+
text = f"type {b['type']} instead of {a['type']}"
|
|
105
|
+
out.append(Change("breaking", f"{name}({p})", text))
|
|
106
|
+
if not a["required"] and b["required"]:
|
|
107
|
+
out.append(Change("breaking", f"{name}({p})", "is now required"))
|
|
108
|
+
elif a["required"] and not b["required"]:
|
|
109
|
+
out.append(Change("additive", f"{name}({p})", "is now optional"))
|
|
110
|
+
return out
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _field_changes(model: str, old: dict[str, Any], new: dict[str, Any]) -> list[Change]:
|
|
114
|
+
out: list[Change] = []
|
|
115
|
+
for f in sorted(old.keys() - new.keys()):
|
|
116
|
+
out.append(Change("breaking", f"{model}.{f}", "field removed"))
|
|
117
|
+
for f in sorted(new.keys() - old.keys()):
|
|
118
|
+
out.append(Change("additive", f"{model}.{f}", f"new field ({new[f]['type']})"))
|
|
119
|
+
for f in sorted(old.keys() & new.keys()):
|
|
120
|
+
a, b = old[f]["type"], new[f]["type"]
|
|
121
|
+
if a == b:
|
|
122
|
+
continue
|
|
123
|
+
where = f"{model}.{f}"
|
|
124
|
+
# Reply data: a field that can no longer be None is safe for code reading it;
|
|
125
|
+
# one that may now be None is not.
|
|
126
|
+
if _without_none(a) == _without_none(b):
|
|
127
|
+
if _nullable(a) and not _nullable(b):
|
|
128
|
+
out.append(Change("info", where, "is now always present"))
|
|
129
|
+
else:
|
|
130
|
+
out.append(Change("breaking", where, f"may now be missing ({b})"))
|
|
131
|
+
else:
|
|
132
|
+
out.append(Change("breaking", where, f"type {b} instead of {a}"))
|
|
133
|
+
return out
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _nullable(type_text: str) -> bool:
|
|
137
|
+
return "None" in [part.strip() for part in type_text.split("|")]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _without_none(type_text: str) -> str:
|
|
141
|
+
return " | ".join(p.strip() for p in type_text.split("|") if p.strip() != "None")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def bump(version: str, changes: list[Change]) -> str:
|
|
145
|
+
major, minor, patch = (int(x) for x in version.split("."))
|
|
146
|
+
if any(c.severity == "breaking" for c in changes):
|
|
147
|
+
return f"{major + 1}.0.0"
|
|
148
|
+
if any(c.severity == "additive" for c in changes):
|
|
149
|
+
return f"{major}.{minor + 1}.0"
|
|
150
|
+
return f"{major}.{minor}.{patch + 1}"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@dataclass
|
|
154
|
+
class VersionInfo:
|
|
155
|
+
version: str
|
|
156
|
+
changes: list[Change]
|
|
157
|
+
previous: str | None # the version this one follows, None for the first
|
|
158
|
+
is_new: bool # differs from the latest stored version
|
|
159
|
+
history: list[dict[str, Any]] # stored versions, newest first (for changelogs)
|
|
160
|
+
|
|
161
|
+
def summary(self) -> str:
|
|
162
|
+
if self.previous is None:
|
|
163
|
+
return f"first version {self.version}"
|
|
164
|
+
if not self.is_new:
|
|
165
|
+
return f"no change since {self.version}"
|
|
166
|
+
counts = {s: sum(c.severity == s for c in self.changes) for s in ("breaking", "additive")}
|
|
167
|
+
parts = [f"{n} {s}" for s, n in counts.items() if n]
|
|
168
|
+
return f"{self.previous} -> {self.version} ({', '.join(parts) or 'small changes'})"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def version_for(store: Store, domain: str, spec: ClientSpec) -> VersionInfo:
|
|
172
|
+
"""The version `spec` has (or would get), compared with the latest stored one."""
|
|
173
|
+
history = store.api_versions(domain)
|
|
174
|
+
snap = snapshot(spec)
|
|
175
|
+
if not history:
|
|
176
|
+
return VersionInfo(FIRST_VERSION, [], None, True, history)
|
|
177
|
+
latest = history[0]
|
|
178
|
+
if latest["snapshot"] == snap:
|
|
179
|
+
return VersionInfo(latest["version"], [], latest["version"], False, history)
|
|
180
|
+
changes = compare(latest["snapshot"], snap)
|
|
181
|
+
return VersionInfo(bump(latest["version"], changes), changes, latest["version"], True, history)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def record(store: Store, domain: str, spec: ClientSpec, run_id: int | None) -> VersionInfo:
|
|
185
|
+
"""Store `spec` as a new version when it differs from the latest one."""
|
|
186
|
+
info = version_for(store, domain, spec)
|
|
187
|
+
if info.is_new:
|
|
188
|
+
store.add_api_version(
|
|
189
|
+
domain, info.version, snapshot(spec), [asdict(c) for c in info.changes], run_id
|
|
190
|
+
)
|
|
191
|
+
return info
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def changelog(spec: ClientSpec, info: VersionInfo) -> str:
|
|
195
|
+
"""CHANGELOG.md for a client package: every stored version, plus this one if new."""
|
|
196
|
+
lines = [
|
|
197
|
+
f"# Changelog: {spec.class_name}",
|
|
198
|
+
"",
|
|
199
|
+
f"Versions of the unofficial {spec.domain} client, written by Ghost API when the "
|
|
200
|
+
"site's API changed. `!` breaking, `+` added, `~` other.",
|
|
201
|
+
"",
|
|
202
|
+
]
|
|
203
|
+
entries = list(info.history)
|
|
204
|
+
if info.is_new:
|
|
205
|
+
entries.insert(
|
|
206
|
+
0,
|
|
207
|
+
{"version": info.version, "created_at": "unreleased",
|
|
208
|
+
"changes": [asdict(c) for c in info.changes]},
|
|
209
|
+
) # fmt: skip
|
|
210
|
+
for entry in entries:
|
|
211
|
+
lines += [f"## {entry['version']} ({str(entry['created_at'])[:10]})", ""]
|
|
212
|
+
changes = [Change(**c) for c in entry["changes"]]
|
|
213
|
+
lines += [f"- {c.line()}" for c in changes] or ["- First version."]
|
|
214
|
+
lines.append("")
|
|
215
|
+
return "\n".join(lines)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def to_markdown(site: str, info: VersionInfo, changes: list[Change]) -> str:
|
|
219
|
+
"""A pull-request body for `ghost-api diff --markdown`."""
|
|
220
|
+
if not changes:
|
|
221
|
+
return f"No API changes for **{site}** (client {info.version})."
|
|
222
|
+
breaking = [c for c in changes if c.severity == "breaking"]
|
|
223
|
+
lines = [
|
|
224
|
+
f"## API changes on {site}",
|
|
225
|
+
"",
|
|
226
|
+
f"Client version **{info.previous or '-'} -> {info.version}**"
|
|
227
|
+
+ (f": **{len(breaking)} breaking**" if breaking else ""),
|
|
228
|
+
"",
|
|
229
|
+
]
|
|
230
|
+
for title, severity in (("Breaking", "breaking"), ("Added", "additive"), ("Other", "info")):
|
|
231
|
+
group = [c for c in changes if c.severity == severity]
|
|
232
|
+
if group:
|
|
233
|
+
lines += [f"### {title}", ""] + [f"- `{c.where}`: {c.text}" for c in group] + [""]
|
|
234
|
+
lines.append("Generated by [Ghost API](https://github.com/Danielraj5605/Ghost-api).")
|
|
235
|
+
return "\n".join(lines)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def changes_json(changes: list[Change]) -> list[dict[str, Any]]:
|
|
239
|
+
return [asdict(c) for c in changes]
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def dumps(snap: dict[str, Any]) -> str:
|
|
243
|
+
return json.dumps(snap, sort_keys=True)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# --- live check: compare a fresh reply with the stored response schema ---------------------
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@dataclass(frozen=True)
|
|
250
|
+
class FieldShape:
|
|
251
|
+
types: frozenset[str]
|
|
252
|
+
required: bool # present in every captured reply
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def schema_fields(schema: dict[str, Any] | None, max_depth: int = 4) -> dict[str, FieldShape]:
|
|
256
|
+
"""{"quotes[].author.name": FieldShape({"string"}, True), ...} for a JSON schema."""
|
|
257
|
+
out: dict[str, FieldShape] = {}
|
|
258
|
+
|
|
259
|
+
def types(node: dict[str, Any]) -> frozenset[str]:
|
|
260
|
+
kind = node.get("type")
|
|
261
|
+
found = set(kind) if isinstance(kind, list) else {kind} if kind else set()
|
|
262
|
+
for option in node.get("anyOf", []):
|
|
263
|
+
found |= types(option)
|
|
264
|
+
return frozenset(t for t in found if t)
|
|
265
|
+
|
|
266
|
+
def walk(node: dict[str, Any], prefix: str, required: bool, depth: int) -> None:
|
|
267
|
+
kinds = types(node)
|
|
268
|
+
if prefix:
|
|
269
|
+
out[prefix] = FieldShape(kinds, required)
|
|
270
|
+
if depth >= max_depth:
|
|
271
|
+
return
|
|
272
|
+
if "array" in kinds and isinstance(node.get("items"), dict):
|
|
273
|
+
walk(node["items"], prefix + "[]", required, depth + 1)
|
|
274
|
+
needed = set(node.get("required", []))
|
|
275
|
+
for key, child in (node.get("properties") or {}).items():
|
|
276
|
+
if isinstance(child, dict):
|
|
277
|
+
path = f"{prefix}.{key}" if prefix else key
|
|
278
|
+
walk(child, path, required and key in needed, depth + 1)
|
|
279
|
+
for option in node.get("anyOf", []):
|
|
280
|
+
if isinstance(option, dict) and option.get("properties"):
|
|
281
|
+
walk({**option, "type": None}, prefix, required, depth)
|
|
282
|
+
|
|
283
|
+
if schema:
|
|
284
|
+
walk(schema, "", True, 0)
|
|
285
|
+
return out
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def live_changes(
|
|
289
|
+
where: str, stored: dict[str, Any] | None, fresh: dict[str, Any] | None
|
|
290
|
+
) -> list[Change]:
|
|
291
|
+
"""How a fresh reply differs from what was captured before. A field missing from one
|
|
292
|
+
reply is only breaking when every earlier reply had it."""
|
|
293
|
+
before, now = schema_fields(stored), schema_fields(fresh)
|
|
294
|
+
out: list[Change] = []
|
|
295
|
+
for path in sorted(before.keys() - now.keys()):
|
|
296
|
+
if before[path].required and "[]" not in path.rstrip("[]"):
|
|
297
|
+
out.append(
|
|
298
|
+
Change("breaking", f"{where} {path}", "field missing from the reply", "live")
|
|
299
|
+
)
|
|
300
|
+
for path in sorted(now.keys() - before.keys()):
|
|
301
|
+
out.append(Change("additive", f"{where} {path}", "new field in the reply", "live"))
|
|
302
|
+
for path in sorted(before.keys() & now.keys()):
|
|
303
|
+
old, new = before[path].types - {"null"}, now[path].types - {"null"}
|
|
304
|
+
if old and new and not (new <= old or {"integer", "number"} >= old | new):
|
|
305
|
+
text = f"now {'/'.join(sorted(new))}, was {'/'.join(sorted(old))}"
|
|
306
|
+
out.append(Change("breaking", f"{where} {path}", text, "live"))
|
|
307
|
+
return out
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def live_check(
|
|
311
|
+
store: Store, site: str, on_progress: Callable[[str], None] | None = None
|
|
312
|
+
) -> list[Change]:
|
|
313
|
+
"""Call each operation once (read-only, rate-limited, the site's rules apply) and
|
|
314
|
+
compare each reply's shape with what was captured."""
|
|
315
|
+
from ghost_api.analysis.schema import build_schema
|
|
316
|
+
from ghost_api.probe.replay import ReplayRefused
|
|
317
|
+
from ghost_api.runtime import OperationError, SiteRuntime
|
|
318
|
+
|
|
319
|
+
try:
|
|
320
|
+
runtime = SiteRuntime(store, site)
|
|
321
|
+
except OperationError as exc:
|
|
322
|
+
if on_progress is not None:
|
|
323
|
+
on_progress(f"live check skipped: {exc}")
|
|
324
|
+
return []
|
|
325
|
+
stored = {e.key: e.response_schema for e in store.current_endpoints(site)}
|
|
326
|
+
changes: list[Change] = []
|
|
327
|
+
with runtime:
|
|
328
|
+
for op in runtime.operations():
|
|
329
|
+
inputs = {i.name: i.example for i in op.inputs if i.example}
|
|
330
|
+
try:
|
|
331
|
+
result = runtime.call(op, inputs)
|
|
332
|
+
except (OperationError, ReplayRefused) as exc:
|
|
333
|
+
changes.append(Change("info", op.name, f"not checked live: {exc}", "live"))
|
|
334
|
+
continue
|
|
335
|
+
if not result.ok:
|
|
336
|
+
status = result.status or "network error"
|
|
337
|
+
changes.append(Change("breaking", op.name, f"live call failed ({status})", "live"))
|
|
338
|
+
continue
|
|
339
|
+
changes += live_changes(
|
|
340
|
+
op.name, stored.get(op.endpoint.key), build_schema([result.body])
|
|
341
|
+
)
|
|
342
|
+
if on_progress is not None:
|
|
343
|
+
on_progress(f"checked {op.name} live")
|
|
344
|
+
return changes
|
ghost_api/drive.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Explore a site one step at a time, with the decisions made elsewhere (an AI assistant).
|
|
2
|
+
|
|
3
|
+
`ghost-api mcp --explore` hands these steps to Claude (or any MCP client): the assistant
|
|
4
|
+
reads the page and decides; Ghost API acts with all its rules (robots.txt, rate limit,
|
|
5
|
+
same site only, no destructive or login elements, no private network), records every
|
|
6
|
+
request, and at the end tests the endpoints, writes the client, report and version, just
|
|
7
|
+
like `ghost-api run`. No LLM key is needed: the assistant is the agent.
|
|
8
|
+
|
|
9
|
+
Playwright's browser belongs to the thread that opened it, so each session runs all its
|
|
10
|
+
work on one worker thread of its own.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import time
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, TypeVar
|
|
20
|
+
from urllib.parse import urlsplit
|
|
21
|
+
|
|
22
|
+
from ghost_api.agent.orchestrator import BotProtection, Explorer
|
|
23
|
+
from ghost_api.agent.prompts import format_page
|
|
24
|
+
from ghost_api.agent.tools import Decision
|
|
25
|
+
from ghost_api.analysis.analyzer import analyze
|
|
26
|
+
from ghost_api.browser.driver import BrowserSession
|
|
27
|
+
from ghost_api.config import check_language, default_db_path, load_env
|
|
28
|
+
from ghost_api.events import EventBus
|
|
29
|
+
from ghost_api.export.site_data import endpoint_summary
|
|
30
|
+
from ghost_api.memory.knowledge import load_memory, memory_prompt
|
|
31
|
+
from ghost_api.memory.store import Store
|
|
32
|
+
from ghost_api.net import RateLimiter
|
|
33
|
+
from ghost_api.pipeline import RunOptions, Session, finish, open_session, robots_policy
|
|
34
|
+
from ghost_api.site import site_key
|
|
35
|
+
from ghost_api.workflows import NotAllowed, RunCollector, enforce
|
|
36
|
+
|
|
37
|
+
T = TypeVar("T")
|
|
38
|
+
MAX_STEPS = 60 # a ceiling, so an assistant can't keep a browser busy forever
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class DriveError(Exception):
|
|
42
|
+
"""Something the assistant should be told plainly (a closed session, a bad action)."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class DriveSession:
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
url: str,
|
|
49
|
+
goal: str,
|
|
50
|
+
*,
|
|
51
|
+
db: Path | None = None,
|
|
52
|
+
out: Path = Path("output"),
|
|
53
|
+
lang: str = "python",
|
|
54
|
+
rate_limit: float = 1.0,
|
|
55
|
+
max_steps: int = MAX_STEPS,
|
|
56
|
+
) -> None:
|
|
57
|
+
self.url, self.goal = url, goal
|
|
58
|
+
self.db = db or default_db_path()
|
|
59
|
+
self.out = out
|
|
60
|
+
self.lang = check_language(lang)
|
|
61
|
+
self.rate_limit = rate_limit
|
|
62
|
+
self.max_steps = max_steps
|
|
63
|
+
self.collector = RunCollector()
|
|
64
|
+
self.closed = False
|
|
65
|
+
self._worker = ThreadPoolExecutor(max_workers=1, thread_name_prefix="ghost-drive")
|
|
66
|
+
self._store: Store | None = None
|
|
67
|
+
self._session: Session | None = None
|
|
68
|
+
self._browser: BrowserSession | None = None
|
|
69
|
+
self._explorer: Explorer | None = None
|
|
70
|
+
self._started = 0.0
|
|
71
|
+
|
|
72
|
+
# --- public, thread-safe: every call runs on the session's own worker thread ---
|
|
73
|
+
|
|
74
|
+
def start(self) -> dict[str, Any]:
|
|
75
|
+
return self._run(self._start)
|
|
76
|
+
|
|
77
|
+
def page(self) -> str:
|
|
78
|
+
return self._run(self._page)
|
|
79
|
+
|
|
80
|
+
def act(self, action: str, **arguments: Any) -> dict[str, Any]:
|
|
81
|
+
return self._run(lambda: self._act(action, arguments))
|
|
82
|
+
|
|
83
|
+
def endpoints(self) -> list[dict[str, Any]]:
|
|
84
|
+
return self._run(self._endpoints)
|
|
85
|
+
|
|
86
|
+
def finish(self, goal_covered: bool = False, note: str = "") -> dict[str, Any]:
|
|
87
|
+
try:
|
|
88
|
+
return self._run(lambda: self._finish(goal_covered, note))
|
|
89
|
+
finally:
|
|
90
|
+
self._worker.shutdown(wait=False)
|
|
91
|
+
|
|
92
|
+
# --- the work ---
|
|
93
|
+
|
|
94
|
+
def _run(self, work: Callable[[], T]) -> T:
|
|
95
|
+
if self.closed:
|
|
96
|
+
raise DriveError("this exploration has finished; start a new one")
|
|
97
|
+
return self._worker.submit(work).result()
|
|
98
|
+
|
|
99
|
+
def _start(self) -> dict[str, Any]:
|
|
100
|
+
load_env()
|
|
101
|
+
host = urlsplit(self.url).hostname or ""
|
|
102
|
+
policy = enforce(site_key(self.url) or host, use_agent=True) # the assistant decides
|
|
103
|
+
bus = EventBus()
|
|
104
|
+
bus.subscribe(self.collector)
|
|
105
|
+
limiter = RateLimiter(self.rate_limit)
|
|
106
|
+
robots, allowed = robots_policy(self.url, False, limiter, bus)
|
|
107
|
+
if not allowed:
|
|
108
|
+
raise NotAllowed(f"robots.txt does not allow {self.url}")
|
|
109
|
+
self._store = Store(self.db)
|
|
110
|
+
self._session = s = open_session(
|
|
111
|
+
self._store, bus, host, self.url, self.goal, False, limiter, robots, None,
|
|
112
|
+
min(60, policy.max_direct_calls or 60),
|
|
113
|
+
screens_root=self.db, lang=self.lang,
|
|
114
|
+
) # fmt: skip
|
|
115
|
+
known = load_memory(self._store, s.site)
|
|
116
|
+
if known is not None:
|
|
117
|
+
s.memory = memory_prompt(known.endpoints, known.lessons)
|
|
118
|
+
self._browser = s.browser().__enter__()
|
|
119
|
+
self._explorer = Explorer(
|
|
120
|
+
self._browser, None, lambda: s.recorder.captured, self.goal, self.url, host,
|
|
121
|
+
robots=s.robots, on_step=s.on_step, replayer=s.replayer,
|
|
122
|
+
raw_requests=lambda: s.recorder.raw, add_sample=s.recorder.add, memory=s.memory,
|
|
123
|
+
) # fmt: skip
|
|
124
|
+
self._started = time.monotonic()
|
|
125
|
+
self._explorer.begin()
|
|
126
|
+
return {
|
|
127
|
+
"site": s.site,
|
|
128
|
+
"memory": s.memory or "nothing known about this site yet",
|
|
129
|
+
"page": self._page(),
|
|
130
|
+
"endpoints": self._endpoints(),
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
def _require(self) -> tuple[Session, BrowserSession, Explorer]:
|
|
134
|
+
if self._session is None or self._browser is None or self._explorer is None:
|
|
135
|
+
raise DriveError("start the exploration first (open_site)")
|
|
136
|
+
return self._session, self._browser, self._explorer
|
|
137
|
+
|
|
138
|
+
def _page(self) -> str:
|
|
139
|
+
session, browser, _ = self._require()
|
|
140
|
+
return format_page(browser.snapshot(session.host, session.robots))
|
|
141
|
+
|
|
142
|
+
def _act(self, action: str, arguments: dict[str, Any]) -> dict[str, Any]:
|
|
143
|
+
_, _, explorer = self._require()
|
|
144
|
+
if action in ("finish", "ask_user"):
|
|
145
|
+
raise DriveError(f"'{action}' is not a step here: use finish_exploration instead")
|
|
146
|
+
if len(explorer.steps) >= self.max_steps:
|
|
147
|
+
raise DriveError(f"step limit reached ({self.max_steps}); finish the exploration")
|
|
148
|
+
try:
|
|
149
|
+
decision = Decision(thought=str(arguments.pop("why", "") or "(assistant)"),
|
|
150
|
+
action=action, **arguments) # type: ignore[arg-type] # fmt: skip
|
|
151
|
+
except ValueError as exc:
|
|
152
|
+
raise DriveError(f"not a valid {action}: {exc}") from exc
|
|
153
|
+
try:
|
|
154
|
+
record = explorer.act(decision)
|
|
155
|
+
except BotProtection as exc:
|
|
156
|
+
raise DriveError(
|
|
157
|
+
f"bot-protection page ({exc}): Ghost API never tries to get past it. Finish, "
|
|
158
|
+
"or record the site yourself and use `ghost-api import`."
|
|
159
|
+
) from exc
|
|
160
|
+
return {
|
|
161
|
+
"result": record.result,
|
|
162
|
+
"new_endpoints": record.new_endpoints,
|
|
163
|
+
"page": self._page(),
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
def _endpoints(self) -> list[dict[str, Any]]:
|
|
167
|
+
session, _, _ = self._require()
|
|
168
|
+
found = analyze(session.recorder.captured)
|
|
169
|
+
return [_compact(endpoint_summary(e, session.site)) for e in found]
|
|
170
|
+
|
|
171
|
+
def _finish(self, goal_covered: bool, note: str) -> dict[str, Any]:
|
|
172
|
+
session, browser, explorer = self._require()
|
|
173
|
+
self.closed = True
|
|
174
|
+
try:
|
|
175
|
+
browser.__exit__(None, None, None)
|
|
176
|
+
covered = goal_covered and bool(analyze(session.recorder.captured))
|
|
177
|
+
status = "goal-covered" if covered else "finished"
|
|
178
|
+
reason = note or f"explored by an assistant in {len(explorer.steps)} step(s)"
|
|
179
|
+
opts = RunOptions(lang=self.lang, max_steps=self.max_steps, out=self.out)
|
|
180
|
+
finish(session, status, reason, self._started, opts)
|
|
181
|
+
finally:
|
|
182
|
+
if self._store is not None:
|
|
183
|
+
self._store.close()
|
|
184
|
+
data = self.collector.data
|
|
185
|
+
client = data.get("client") or {}
|
|
186
|
+
return {
|
|
187
|
+
"status": data.get("status"),
|
|
188
|
+
"site": data.get("site"),
|
|
189
|
+
"endpoints": [_compact(e) for e in data.get("endpoints", [])],
|
|
190
|
+
"client": {
|
|
191
|
+
"class": client.get("class_name"),
|
|
192
|
+
"directory": client.get("directory"),
|
|
193
|
+
"files": client.get("files", []),
|
|
194
|
+
}
|
|
195
|
+
if client
|
|
196
|
+
else None,
|
|
197
|
+
"report": data.get("report_html") or data.get("report"),
|
|
198
|
+
"openapi": data.get("openapi"),
|
|
199
|
+
"network": data.get("network"),
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _compact(summary: dict[str, Any]) -> dict[str, Any]:
|
|
204
|
+
"""What an assistant needs to decide, in few tokens: no schemas or examples."""
|
|
205
|
+
keep = ("method", "host", "path", "status", "statuses", "samples", "pagination",
|
|
206
|
+
"auth_type", "fields", "confidence") # fmt: skip
|
|
207
|
+
out = {k: summary[k] for k in keep if summary.get(k) not in (None, [], {}, "")}
|
|
208
|
+
params = summary.get("params") or []
|
|
209
|
+
if params:
|
|
210
|
+
out["params"] = [f"{p['name']} ({p['location']})" for p in params][:12]
|
|
211
|
+
return out
|
ghost_api/errors.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Turn common low-level errors into plain explanations with a fix (NFR-23)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
# (pattern in the error text, explanation and fix). The order matches ERROR_CODES.
|
|
8
|
+
_KNOWN: list[tuple[re.Pattern[str], str]] = [
|
|
9
|
+
(
|
|
10
|
+
re.compile(r"Executable doesn't exist|playwright install", re.I),
|
|
11
|
+
"Chromium isn't installed for Playwright. Run: playwright install chromium",
|
|
12
|
+
),
|
|
13
|
+
(
|
|
14
|
+
re.compile(r"ERR_NAME_NOT_RESOLVED|getaddrinfo failed|Name or service not known", re.I),
|
|
15
|
+
"The website's address could not be found. Check the URL and your internet connection.",
|
|
16
|
+
),
|
|
17
|
+
(
|
|
18
|
+
re.compile(r"ERR_CONNECTION_REFUSED|Connection refused|actively refused", re.I),
|
|
19
|
+
"The website refused the connection. Is the URL (and port) right, and is the site up?",
|
|
20
|
+
),
|
|
21
|
+
(
|
|
22
|
+
re.compile(r"ERR_UNSAFE_PORT", re.I),
|
|
23
|
+
"Browsers refuse to open this port for safety reasons. Use a different port.",
|
|
24
|
+
),
|
|
25
|
+
(
|
|
26
|
+
re.compile(r"ERR_INTERNET_DISCONNECTED|ERR_NETWORK_CHANGED", re.I),
|
|
27
|
+
"No internet connection.",
|
|
28
|
+
),
|
|
29
|
+
(
|
|
30
|
+
# Only the browser's own timeouts: an LLM or robots.txt timeout is not the website.
|
|
31
|
+
re.compile(r"Timeout \d+ms exceeded|net::ERR_(CONNECTION_)?TIMED_OUT", re.I),
|
|
32
|
+
"The website took too long to respond. Try again, or check that it loads in a browser.",
|
|
33
|
+
),
|
|
34
|
+
(
|
|
35
|
+
re.compile(r"ERR_CERT_|CERTIFICATE_VERIFY_FAILED", re.I),
|
|
36
|
+
"The website's security certificate is not valid.",
|
|
37
|
+
),
|
|
38
|
+
(
|
|
39
|
+
re.compile(r"file is not a database|database disk image is malformed", re.I),
|
|
40
|
+
"The database file is damaged or is not a Ghost API database. Move it aside "
|
|
41
|
+
"(or use --db to pick another file) and Ghost API will start a fresh one.",
|
|
42
|
+
),
|
|
43
|
+
(
|
|
44
|
+
re.compile(r"database is locked", re.I),
|
|
45
|
+
"The database is busy, probably because another ghost-api command is running. "
|
|
46
|
+
"Wait for it to finish, or use --db to pick another file.",
|
|
47
|
+
),
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# Stable codes for the explanations above, in the same order: for logs, scripts and bug
|
|
52
|
+
# reports, so nothing depends on the wording of a message.
|
|
53
|
+
ERROR_CODES = (
|
|
54
|
+
"browser-missing",
|
|
55
|
+
"dns-failed",
|
|
56
|
+
"connection-refused",
|
|
57
|
+
"unsafe-port",
|
|
58
|
+
"offline",
|
|
59
|
+
"site-timeout",
|
|
60
|
+
"bad-certificate",
|
|
61
|
+
"database-damaged",
|
|
62
|
+
"database-locked",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def error_code(exc: BaseException) -> str:
|
|
67
|
+
"""A stable short code for `exc` (see ERROR_CODES), 'network-denied',
|
|
68
|
+
'response-too-large', or 'unexpected'."""
|
|
69
|
+
from ghost_api.netguard import PolicyDenied, ResponseTooLarge
|
|
70
|
+
|
|
71
|
+
chain = _chain(exc)
|
|
72
|
+
if any(isinstance(e, PolicyDenied) for e in chain):
|
|
73
|
+
return "network-denied"
|
|
74
|
+
if any(isinstance(e, ResponseTooLarge) for e in chain):
|
|
75
|
+
return "response-too-large"
|
|
76
|
+
for error in chain:
|
|
77
|
+
for (pattern, _), code in zip(_KNOWN, ERROR_CODES, strict=True):
|
|
78
|
+
if pattern.search(str(error)):
|
|
79
|
+
return code
|
|
80
|
+
return "unexpected"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _chain(exc: BaseException) -> list[BaseException]:
|
|
84
|
+
chain = [exc]
|
|
85
|
+
while chain[-1].__cause__ is not None and len(chain) < 5:
|
|
86
|
+
chain.append(chain[-1].__cause__)
|
|
87
|
+
return chain
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def friendly_error(exc: BaseException) -> str:
|
|
91
|
+
"""A short, human explanation of `exc`; falls back to its first line, plus the
|
|
92
|
+
underlying cause when there is one, so the real reason is not lost."""
|
|
93
|
+
for error in _chain(exc):
|
|
94
|
+
for pattern, message in _KNOWN:
|
|
95
|
+
if pattern.search(str(error)):
|
|
96
|
+
return message
|
|
97
|
+
text = _first_line(exc)
|
|
98
|
+
cause = exc.__cause__
|
|
99
|
+
if cause is not None and _first_line(cause) not in text:
|
|
100
|
+
text += f" (cause: {_first_line(cause)})"
|
|
101
|
+
return text[:300]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _first_line(exc: BaseException) -> str:
|
|
105
|
+
text = str(exc).strip()
|
|
106
|
+
return text.splitlines()[0] if text else type(exc).__name__
|