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,221 @@
|
|
|
1
|
+
"""Generate a typed Python client from an endpoint map (Stage 6)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from ghost_api.analysis.models import Endpoint
|
|
10
|
+
from ghost_api.capture.models import CapturedRequest
|
|
11
|
+
from ghost_api.generator.naming import (
|
|
12
|
+
NAMING_SYSTEM,
|
|
13
|
+
NamingPlan,
|
|
14
|
+
apply_plan,
|
|
15
|
+
default_client_name,
|
|
16
|
+
naming_prompt,
|
|
17
|
+
)
|
|
18
|
+
from ghost_api.generator.python_gen import GeneratedClient, write
|
|
19
|
+
from ghost_api.generator.spec import ClientSpec, build_spec, is_allowed
|
|
20
|
+
from ghost_api.generator.ts_gen import write_typescript
|
|
21
|
+
from ghost_api.llm.gateway import LLMError, LLMGateway
|
|
22
|
+
from ghost_api.memory.store import Store
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from ghost_api.drift import VersionInfo
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"GenerationResult",
|
|
29
|
+
"NothingToGenerate",
|
|
30
|
+
"generate_client",
|
|
31
|
+
"generate_from",
|
|
32
|
+
"name_endpoints",
|
|
33
|
+
"plan_for",
|
|
34
|
+
"site_spec",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class NothingToGenerate(Exception):
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class GenerationResult:
|
|
44
|
+
client: GeneratedClient
|
|
45
|
+
spec: ClientSpec
|
|
46
|
+
plan: NamingPlan | None # reuse it when regenerating, to save LLM calls
|
|
47
|
+
version: VersionInfo | None = None # with a store: the client's version and changes
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def named_by_llm(self) -> bool:
|
|
51
|
+
return self.plan is not None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def name_endpoints(
|
|
55
|
+
llm: LLMGateway | None, domain: str, endpoints: list[Endpoint]
|
|
56
|
+
) -> NamingPlan | None:
|
|
57
|
+
"""One LLM call for nicer names. None if there's no LLM or it fails."""
|
|
58
|
+
if llm is None or not endpoints:
|
|
59
|
+
return None
|
|
60
|
+
try:
|
|
61
|
+
reply = llm.ask(
|
|
62
|
+
[
|
|
63
|
+
{"role": "system", "content": NAMING_SYSTEM},
|
|
64
|
+
{"role": "user", "content": naming_prompt(domain, endpoints)},
|
|
65
|
+
],
|
|
66
|
+
response_model=NamingPlan,
|
|
67
|
+
)
|
|
68
|
+
except LLMError:
|
|
69
|
+
return None # deterministic names still work
|
|
70
|
+
return reply.parsed
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def plan_for(
|
|
74
|
+
store: Store, llm: LLMGateway | None, domain: str, endpoints: list[Endpoint]
|
|
75
|
+
) -> NamingPlan | None:
|
|
76
|
+
"""Reuse the stored naming plan if it covers every endpoint; otherwise ask the LLM once
|
|
77
|
+
and store the new plan (Stage 8: no LLM call when nothing changed).
|
|
78
|
+
|
|
79
|
+
Once a client version exists, names are part of its public API: existing names never
|
|
80
|
+
change (a rename would break code using the client); only new endpoints get names."""
|
|
81
|
+
stored = store.load_naming_plan(domain)
|
|
82
|
+
plan: NamingPlan | None = None
|
|
83
|
+
if stored:
|
|
84
|
+
try:
|
|
85
|
+
plan = NamingPlan.model_validate_json(stored)
|
|
86
|
+
except ValueError:
|
|
87
|
+
plan = None
|
|
88
|
+
if plan is not None and {e.key for e in endpoints} <= {n.key for n in plan.endpoints}:
|
|
89
|
+
return plan
|
|
90
|
+
if store.api_versions(domain):
|
|
91
|
+
return _name_new_endpoints(store, llm, domain, endpoints, plan)
|
|
92
|
+
plan = name_endpoints(llm, domain, endpoints)
|
|
93
|
+
if plan is not None:
|
|
94
|
+
store.save_naming_plan(domain, plan.model_dump_json())
|
|
95
|
+
return plan
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _name_new_endpoints(
|
|
99
|
+
store: Store,
|
|
100
|
+
llm: LLMGateway | None,
|
|
101
|
+
domain: str,
|
|
102
|
+
endpoints: list[Endpoint],
|
|
103
|
+
plan: NamingPlan | None,
|
|
104
|
+
) -> NamingPlan | None:
|
|
105
|
+
"""A released client keeps every name it has; the LLM names only what is new."""
|
|
106
|
+
named = {n.key for n in plan.endpoints} if plan else set()
|
|
107
|
+
if plan is None:
|
|
108
|
+
# The released client used names derived from URLs: keep those for what exists.
|
|
109
|
+
previous = store.api_versions(domain)[0]["snapshot"]["methods"]
|
|
110
|
+
calls = {m["call"] for m in previous.values()}
|
|
111
|
+
named = {e.key for e in endpoints if f"{e.method} {e.path_template}" in calls}
|
|
112
|
+
named |= {e.key for e in endpoints if any(c.endswith(e.path_template) for c in calls)}
|
|
113
|
+
new = [e for e in endpoints if e.key not in named]
|
|
114
|
+
fresh = name_endpoints(llm, domain, new) if new else None
|
|
115
|
+
if fresh is None:
|
|
116
|
+
return plan
|
|
117
|
+
base = plan or NamingPlan(client_class=default_client_name(domain), endpoints=[])
|
|
118
|
+
merged = NamingPlan(
|
|
119
|
+
client_class=base.client_class,
|
|
120
|
+
endpoints=[*base.endpoints, *(n for n in fresh.endpoints if n.key not in named)],
|
|
121
|
+
)
|
|
122
|
+
store.save_naming_plan(domain, merged.model_dump_json())
|
|
123
|
+
return merged
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def generate_from(
|
|
127
|
+
domain: str,
|
|
128
|
+
start_url: str,
|
|
129
|
+
endpoints: list[Endpoint],
|
|
130
|
+
requests: list[CapturedRequest],
|
|
131
|
+
out_root: Path,
|
|
132
|
+
plan: NamingPlan | None = None,
|
|
133
|
+
lang: str = "python",
|
|
134
|
+
allow: set[str] | None = None,
|
|
135
|
+
store: Store | None = None,
|
|
136
|
+
) -> GenerationResult:
|
|
137
|
+
"""`store`: compare with the stored client versions, so the packages carry the right
|
|
138
|
+
version and changelog (nothing is recorded here; see drift.record)."""
|
|
139
|
+
class_name, names = apply_plan(plan, endpoints, domain)
|
|
140
|
+
spec = build_spec(domain, start_url, endpoints, requests, class_name, names, allow)
|
|
141
|
+
if not spec.methods:
|
|
142
|
+
reasons = "; ".join(spec.skipped) or "no endpoints"
|
|
143
|
+
raise NothingToGenerate(f"no endpoint can be turned into a method ({reasons})")
|
|
144
|
+
info = None
|
|
145
|
+
if store is not None:
|
|
146
|
+
from ghost_api import drift # drift imports this package
|
|
147
|
+
|
|
148
|
+
info = drift.version_for(store, domain, spec)
|
|
149
|
+
spec.version, spec.changelog = info.version, drift.changelog(spec, info)
|
|
150
|
+
folder = out_root / domain
|
|
151
|
+
client = write_typescript(spec, folder) if lang == "typescript" else write(spec, folder)
|
|
152
|
+
_write_cassettes(spec, requests, folder, lang)
|
|
153
|
+
return GenerationResult(client, spec, plan, info)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _write_cassettes(
|
|
157
|
+
spec: ClientSpec, requests: list[CapturedRequest], folder: Path, lang: str
|
|
158
|
+
) -> None:
|
|
159
|
+
"""Recorded replies next to the tests, so they run offline (generator/cassettes.py)."""
|
|
160
|
+
from ghost_api.generator import cassettes
|
|
161
|
+
|
|
162
|
+
text = cassettes.dumps(cassettes.build(spec, requests))
|
|
163
|
+
targets = [folder / cassettes.FILE]
|
|
164
|
+
if lang == "python":
|
|
165
|
+
targets.append(folder / "package" / "python" / "tests" / cassettes.FILE)
|
|
166
|
+
for target in targets:
|
|
167
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
168
|
+
target.write_text(text, encoding="utf-8", newline="\n")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def generate_client(
|
|
172
|
+
store: Store,
|
|
173
|
+
domain: str,
|
|
174
|
+
out_root: Path,
|
|
175
|
+
llm: LLMGateway | None = None,
|
|
176
|
+
lang: str = "python",
|
|
177
|
+
allow: set[str] | None = None,
|
|
178
|
+
) -> GenerationResult:
|
|
179
|
+
"""Build the client for `domain` from everything known about it (all runs)."""
|
|
180
|
+
run_id = store.latest_endpoint_run(domain)
|
|
181
|
+
run = store.latest_run(domain)
|
|
182
|
+
if run_id is None or run is None:
|
|
183
|
+
raise NothingToGenerate(f"no endpoints stored for {domain}; run `ghost-api run` first")
|
|
184
|
+
endpoints = store.current_endpoints(domain)
|
|
185
|
+
requests = store.requests_for_domain(domain)
|
|
186
|
+
plan = plan_for(store, llm, domain, endpoints)
|
|
187
|
+
# Remember what the user allowed, so a later plain `generate` keeps those methods.
|
|
188
|
+
if allow:
|
|
189
|
+
unknown = {a for a in allow if not any(is_allowed(e, {a}) for e in endpoints)}
|
|
190
|
+
if unknown:
|
|
191
|
+
names = ", ".join(sorted(unknown))
|
|
192
|
+
raise NothingToGenerate(f"--allow names no stored endpoint: {names}")
|
|
193
|
+
store.allow_endpoints(domain, [e.key for e in endpoints if is_allowed(e, allow)])
|
|
194
|
+
allowed = store.allowed_endpoints(domain)
|
|
195
|
+
result = generate_from(
|
|
196
|
+
domain, run.start_url, endpoints, requests, out_root, plan, lang, allowed, store
|
|
197
|
+
)
|
|
198
|
+
from ghost_api import drift
|
|
199
|
+
|
|
200
|
+
result.version = drift.record(store, domain, result.spec, run.id)
|
|
201
|
+
return result
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def site_spec(store: Store, domain: str) -> ClientSpec:
|
|
205
|
+
"""The client `generate` would build from what is stored now, without writing files
|
|
206
|
+
or calling an LLM (the stored naming plan is reused)."""
|
|
207
|
+
run = store.latest_run(domain)
|
|
208
|
+
endpoints = store.current_endpoints(domain)
|
|
209
|
+
if run is None or not endpoints:
|
|
210
|
+
raise NothingToGenerate(f"no endpoints stored for {domain}; run `ghost-api run` first")
|
|
211
|
+
plan = None
|
|
212
|
+
stored = store.load_naming_plan(domain)
|
|
213
|
+
if stored:
|
|
214
|
+
try:
|
|
215
|
+
plan = NamingPlan.model_validate_json(stored)
|
|
216
|
+
except ValueError:
|
|
217
|
+
plan = None
|
|
218
|
+
class_name, names = apply_plan(plan, endpoints, domain)
|
|
219
|
+
requests = store.requests_for_domain(domain)
|
|
220
|
+
allowed = store.allowed_endpoints(domain)
|
|
221
|
+
return build_spec(domain, run.start_url, endpoints, requests, class_name, names, allowed)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Recorded replies ("cassettes") so a generated client's tests run offline.
|
|
2
|
+
|
|
3
|
+
The generated tests replay these by default: deterministic, fast, no traffic to the site,
|
|
4
|
+
fine for CI. `GHOST_API_LIVE=1` runs the same tests against the real API (Ghost API's own
|
|
5
|
+
verification always does).
|
|
6
|
+
|
|
7
|
+
Replies come from what was stored while exploring, so cookies, tokens and keys are already
|
|
8
|
+
redacted and personal data hidden; every entry is checked again before it is written.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
from typing import Any
|
|
16
|
+
from urllib.parse import parse_qsl, urlsplit
|
|
17
|
+
|
|
18
|
+
from ghost_api.analysis.analyzer import endpoint_keys
|
|
19
|
+
from ghost_api.analysis.graphql import graphql_payload, operation_name
|
|
20
|
+
from ghost_api.capture.filters import REDACTED, redact_text
|
|
21
|
+
from ghost_api.capture.models import CapturedRequest
|
|
22
|
+
from ghost_api.generator.spec import ClientSpec
|
|
23
|
+
|
|
24
|
+
PER_ENDPOINT = 6 # the latest replies for each endpoint (pages, search terms...)
|
|
25
|
+
FILE = "cassettes.json"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def path_regex(path: str) -> str:
|
|
29
|
+
"""'/api/items/{item_id}' -> '/api/items/[^/]+' (matched against the request path)."""
|
|
30
|
+
parts = re.split(r"\{[^}]*\}", path)
|
|
31
|
+
return "[^/]+".join(re.escape(p) for p in parts)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def build(spec: ClientSpec, requests: list[CapturedRequest]) -> dict[str, Any]:
|
|
35
|
+
by_key: dict[str, list[CapturedRequest]] = {}
|
|
36
|
+
for key, req in zip(endpoint_keys(requests), requests, strict=False):
|
|
37
|
+
by_key.setdefault(key, []).append(req)
|
|
38
|
+
replies: list[dict[str, Any]] = []
|
|
39
|
+
for method in spec.methods:
|
|
40
|
+
usable = [r for r in by_key.get(method.endpoint_key, []) if _usable(r)]
|
|
41
|
+
for req in usable[-PER_ENDPOINT:]:
|
|
42
|
+
entry = _entry(method.http_method, method.path, req)
|
|
43
|
+
if entry is not None:
|
|
44
|
+
replies.append(entry)
|
|
45
|
+
return {
|
|
46
|
+
"about": "Replies recorded by Ghost API, replayed by the generated tests. "
|
|
47
|
+
"Set GHOST_API_LIVE=1 to test against the real API instead.",
|
|
48
|
+
"site": spec.domain,
|
|
49
|
+
"replies": replies,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _usable(req: CapturedRequest) -> bool:
|
|
54
|
+
return 200 <= req.status < 300 and bool(req.resp_body) and not req.truncated
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _entry(http_method: str, path: str, req: CapturedRequest) -> dict[str, Any] | None:
|
|
58
|
+
url = urlsplit(req.url)
|
|
59
|
+
query = {
|
|
60
|
+
k: v for k, v in parse_qsl(url.query, keep_blank_values=True) if REDACTED not in v
|
|
61
|
+
} # a redacted key is not something a request can match on
|
|
62
|
+
payload = graphql_payload(req.method, req.url, req.req_body)
|
|
63
|
+
entry: dict[str, Any] = {
|
|
64
|
+
"method": http_method,
|
|
65
|
+
"path": url.path,
|
|
66
|
+
"path_regex": path_regex(path),
|
|
67
|
+
"query": query,
|
|
68
|
+
"operation": operation_name(payload) if payload else None,
|
|
69
|
+
"status": req.status,
|
|
70
|
+
"content_type": req.resp_content_type or "application/json",
|
|
71
|
+
"body": req.resp_body,
|
|
72
|
+
}
|
|
73
|
+
text = json.dumps(entry, ensure_ascii=False)
|
|
74
|
+
if redact_text(text) != text:
|
|
75
|
+
return None # never write anything secret-looking, even into test data
|
|
76
|
+
return entry
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def dumps(cassettes: dict[str, Any]) -> str:
|
|
80
|
+
return json.dumps(cassettes, indent=1, ensure_ascii=False) + "\n"
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Turn inferred JSON schemas into pydantic model definitions (FR-28).
|
|
2
|
+
|
|
3
|
+
- Nested objects become their own classes, named after the field (quotes[] -> Quote).
|
|
4
|
+
- Objects with the same structure share one class.
|
|
5
|
+
- Fields missing from some samples, or seen as null, become `T | None = None`.
|
|
6
|
+
- Field names that aren't valid Python get a snake_case name plus an alias.
|
|
7
|
+
- Generated models allow extra fields, so a new field in the API doesn't break them.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel
|
|
16
|
+
|
|
17
|
+
from ghost_api.analysis.url_grouping import singular
|
|
18
|
+
from ghost_api.generator.naming import pascal, snake, unique
|
|
19
|
+
|
|
20
|
+
PRIMITIVES = {
|
|
21
|
+
"string": "str",
|
|
22
|
+
"integer": "int",
|
|
23
|
+
"number": "float",
|
|
24
|
+
"boolean": "bool",
|
|
25
|
+
"null": "None",
|
|
26
|
+
}
|
|
27
|
+
# Names that would shadow pydantic BaseModel attributes (json, copy, schema, model_*...).
|
|
28
|
+
_RESERVED = {n for n in dir(BaseModel) if not n.startswith("_")}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class FieldDef:
|
|
33
|
+
name: str # Python attribute name
|
|
34
|
+
alias: str | None # original JSON key, when different
|
|
35
|
+
type: str # type expression, e.g. "list[Quote] | None"
|
|
36
|
+
optional: bool
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class ModelDef:
|
|
41
|
+
name: str
|
|
42
|
+
fields: list[FieldDef] = field(default_factory=list)
|
|
43
|
+
doc: str = ""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ModelBuilder:
|
|
47
|
+
def __init__(self, reserved_names: set[str] | None = None) -> None:
|
|
48
|
+
self.models: list[ModelDef] = []
|
|
49
|
+
self._by_signature: dict[tuple[Any, ...], str] = {}
|
|
50
|
+
self._used: set[str] = set(reserved_names or ())
|
|
51
|
+
# Treat "seen in every sample" as "always present" only with enough evidence:
|
|
52
|
+
# many items from one reply (one area, one search) say little about the next one.
|
|
53
|
+
self.trust_required = True
|
|
54
|
+
|
|
55
|
+
def root_type(self, schema: dict[str, Any] | None, name: str, doc: str = "") -> str:
|
|
56
|
+
"""Type expression for a whole response; creates models as needed."""
|
|
57
|
+
if not schema:
|
|
58
|
+
return "Any"
|
|
59
|
+
if _is_object(schema):
|
|
60
|
+
return self._model(schema, name, doc, exact_name=True)
|
|
61
|
+
return self.type_for(schema, name)
|
|
62
|
+
|
|
63
|
+
def type_for(self, schema: dict[str, Any] | None, hint: str) -> str:
|
|
64
|
+
if not schema:
|
|
65
|
+
return "Any"
|
|
66
|
+
if "anyOf" in schema:
|
|
67
|
+
return _union(self.type_for(s, hint) for s in schema["anyOf"])
|
|
68
|
+
kinds = schema.get("type")
|
|
69
|
+
if isinstance(kinds, list):
|
|
70
|
+
parts = []
|
|
71
|
+
for k in kinds:
|
|
72
|
+
sub = {**schema, "type": k}
|
|
73
|
+
parts.append(self.type_for(sub, hint))
|
|
74
|
+
return _union(parts)
|
|
75
|
+
if kinds == "object":
|
|
76
|
+
if schema.get("properties"):
|
|
77
|
+
return self._model(schema, hint)
|
|
78
|
+
return "dict[str, Any]"
|
|
79
|
+
if kinds == "array":
|
|
80
|
+
items = schema.get("items")
|
|
81
|
+
item_hint = pascal(singular(snake(hint))) if hint else "Item"
|
|
82
|
+
return f"list[{self.type_for(items, item_hint) if items else 'Any'}]"
|
|
83
|
+
return PRIMITIVES.get(str(kinds), "Any")
|
|
84
|
+
|
|
85
|
+
def _model(
|
|
86
|
+
self, schema: dict[str, Any], hint: str, doc: str = "", exact_name: bool = False
|
|
87
|
+
) -> str:
|
|
88
|
+
props: dict[str, Any] = schema.get("properties") or {}
|
|
89
|
+
required = set(schema.get("required") or [])
|
|
90
|
+
fields: list[FieldDef] = []
|
|
91
|
+
used_fields: set[str] = set()
|
|
92
|
+
for key, sub in props.items():
|
|
93
|
+
type_expr = self.type_for(sub, pascal(key))
|
|
94
|
+
if type_expr == "None": # only ever seen as null: real type unknown
|
|
95
|
+
type_expr = "Any"
|
|
96
|
+
nullable = "None" in _top_level_members(type_expr) or type_expr == "Any"
|
|
97
|
+
optional = key not in required or nullable or not self.trust_required
|
|
98
|
+
if optional and not nullable:
|
|
99
|
+
type_expr = _union([type_expr, "None"])
|
|
100
|
+
py = snake(key)
|
|
101
|
+
if py in _RESERVED or py.startswith("model_"):
|
|
102
|
+
py += "_"
|
|
103
|
+
py = unique(py, used_fields)
|
|
104
|
+
fields.append(FieldDef(py, None if py == key else key, type_expr, optional))
|
|
105
|
+
|
|
106
|
+
signature = tuple((f.alias or f.name, f.type, f.optional) for f in fields)
|
|
107
|
+
if signature in self._by_signature and not exact_name:
|
|
108
|
+
return self._by_signature[signature]
|
|
109
|
+
name = unique(pascal(hint) or "Model", self._used)
|
|
110
|
+
self.models.append(ModelDef(name, fields, doc))
|
|
111
|
+
self._by_signature.setdefault(signature, name)
|
|
112
|
+
return name
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _is_object(schema: dict[str, Any]) -> bool:
|
|
116
|
+
return schema.get("type") == "object" and bool(schema.get("properties"))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _top_level_members(type_expr: str) -> list[str]:
|
|
120
|
+
"""'int | list[int | None] | None' -> ['int', 'list[int | None]', 'None']: split
|
|
121
|
+
only outside brackets, or nested unions fall apart (seen on Google Maps data)."""
|
|
122
|
+
members, depth, start = [], 0, 0
|
|
123
|
+
for i, ch in enumerate(type_expr):
|
|
124
|
+
if ch == "[":
|
|
125
|
+
depth += 1
|
|
126
|
+
elif ch == "]":
|
|
127
|
+
depth -= 1
|
|
128
|
+
elif depth == 0 and type_expr.startswith(" | ", i):
|
|
129
|
+
members.append(type_expr[start:i])
|
|
130
|
+
start = i + 3
|
|
131
|
+
members.append(type_expr[start:])
|
|
132
|
+
return members
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _union(parts: Any) -> str:
|
|
136
|
+
seen: list[str] = []
|
|
137
|
+
for part in parts:
|
|
138
|
+
for p in _top_level_members(str(part)):
|
|
139
|
+
if p not in seen:
|
|
140
|
+
seen.append(p)
|
|
141
|
+
if "Any" in seen:
|
|
142
|
+
return "Any"
|
|
143
|
+
seen.sort(key=lambda p: p == "None") # None last
|
|
144
|
+
return " | ".join(seen) or "Any"
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Names for generated code: safe Python identifiers, with or without the LLM.
|
|
2
|
+
|
|
3
|
+
Deterministic names always work (get_quotes, QuotesResponse). One optional LLM call
|
|
4
|
+
(FR-22) can replace them with better ones (search_items, SearchResults); every name it
|
|
5
|
+
returns is validated, and anything unusable falls back to the deterministic name.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ipaddress
|
|
11
|
+
import keyword
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, Field
|
|
15
|
+
|
|
16
|
+
from ghost_api.analysis.graphql import split_template
|
|
17
|
+
from ghost_api.analysis.models import Endpoint
|
|
18
|
+
from ghost_api.analysis.schema import field_paths
|
|
19
|
+
from ghost_api.analysis.url_grouping import singular
|
|
20
|
+
|
|
21
|
+
_VERSION_RE = re.compile(r"^(v\d+(\.\d+)*|\d{1,2})$", re.I)
|
|
22
|
+
_NOISE_SEGMENTS = {"api", "rest", "json", "public", "ajax", "www"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def snake(name: str) -> str:
|
|
26
|
+
"""'nbHits' -> 'nb_hits', 'x-algolia-agent' -> 'x_algolia_agent', '2fa' -> 'n_2fa'."""
|
|
27
|
+
name = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "_", name)
|
|
28
|
+
name = re.sub(r"[^0-9a-zA-Z]+", "_", name).strip("_").lower() or "value"
|
|
29
|
+
if name[0].isdigit():
|
|
30
|
+
name = f"n_{name}"
|
|
31
|
+
if keyword.iskeyword(name) or name in ("self", "cls"):
|
|
32
|
+
name += "_"
|
|
33
|
+
return name
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def pascal(name: str) -> str:
|
|
37
|
+
"""'quotes response' / 'quotes_response' -> 'QuotesResponse'."""
|
|
38
|
+
parts = re.split(r"[^0-9a-zA-Z]+", re.sub(r"(?<=[a-z0-9])(?=[A-Z])", " ", name))
|
|
39
|
+
out = "".join(p[:1].upper() + p[1:] for p in parts if p) or "Model"
|
|
40
|
+
return out if not out[0].isdigit() else f"M{out}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def is_identifier(name: str) -> bool:
|
|
44
|
+
return name.isidentifier() and not keyword.iskeyword(name)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
MAX_NAME_WORDS = 3
|
|
48
|
+
MAX_WORD_CHARS = 30
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def path_words(endpoint: Endpoint) -> list[str]:
|
|
52
|
+
"""Meaningful literal segments: /api/v1/books/{id}/reviews -> ['books', 'reviews']."""
|
|
53
|
+
words = []
|
|
54
|
+
for seg in endpoint.path_template.strip("/").split("/"):
|
|
55
|
+
if not seg or seg.startswith("{") or seg.lower() in _NOISE_SEGMENTS:
|
|
56
|
+
continue
|
|
57
|
+
if _VERSION_RE.match(seg):
|
|
58
|
+
continue
|
|
59
|
+
words.append(snake(seg)[:MAX_WORD_CHARS].rstrip("_"))
|
|
60
|
+
# Keep names readable: the last few words say what the endpoint is.
|
|
61
|
+
return words[-MAX_NAME_WORDS:]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def default_method_name(endpoint: Endpoint) -> str:
|
|
65
|
+
_, operation = split_template(endpoint.path_template)
|
|
66
|
+
if operation: # GraphQL: the operation's own name (GetUser -> get_user)
|
|
67
|
+
return snake(operation)
|
|
68
|
+
words = path_words(endpoint) or [snake(endpoint.host.split(".")[0])]
|
|
69
|
+
verb = {"GET": "get", "HEAD": "head", "POST": "post"}.get(
|
|
70
|
+
endpoint.method, endpoint.method.lower()
|
|
71
|
+
)
|
|
72
|
+
if endpoint.path_template.rstrip("/").endswith("}") and words:
|
|
73
|
+
words[-1] = singular(words[-1]) # /books/{id} -> get_book
|
|
74
|
+
return snake(f"{verb}_{'_'.join(words)}")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def default_model_name(endpoint: Endpoint) -> str:
|
|
78
|
+
_, operation = split_template(endpoint.path_template)
|
|
79
|
+
if operation:
|
|
80
|
+
return f"{pascal(operation)}Response"
|
|
81
|
+
words = path_words(endpoint) or [endpoint.host.split(".")[0]]
|
|
82
|
+
base = pascal("_".join(words))
|
|
83
|
+
if endpoint.path_template.rstrip("/").endswith("}"):
|
|
84
|
+
base = pascal(singular(words[-1]))
|
|
85
|
+
return f"{base}Response"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _is_ip(host: str) -> bool:
|
|
89
|
+
try:
|
|
90
|
+
ipaddress.ip_address(host)
|
|
91
|
+
except ValueError:
|
|
92
|
+
return False
|
|
93
|
+
return True
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def default_client_name(domain: str) -> str:
|
|
97
|
+
"""'quotes.toscrape.com' -> 'QuotesToscrapeClient'; 'hn.algolia.com' -> 'HnAlgoliaClient';
|
|
98
|
+
a site with a section: 'www.google.com/maps' -> 'GoogleMapsClient'."""
|
|
99
|
+
host, _, section = domain.partition("/")
|
|
100
|
+
host = host.lower().split(":")[0].strip("[]")
|
|
101
|
+
if host == "localhost" or _is_ip(host):
|
|
102
|
+
base = "local" # '127.0.0.1' would otherwise give 'M1270Client'
|
|
103
|
+
else:
|
|
104
|
+
labels = [p for p in host.split(".") if p not in ("www", "com", "org", "net", "io")]
|
|
105
|
+
base = "_".join(labels[:2]) or "api"
|
|
106
|
+
return pascal(f"{base}_{section}" if section else base) + "Client"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def unique(name: str, used: set[str]) -> str:
|
|
110
|
+
out, n = name, 2
|
|
111
|
+
while out in used:
|
|
112
|
+
out, n = f"{name}{n}", n + 1
|
|
113
|
+
used.add(out)
|
|
114
|
+
return out
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# --- optional LLM naming pass ----------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class EndpointNames(BaseModel):
|
|
121
|
+
key: str = Field(description="The endpoint key exactly as given.")
|
|
122
|
+
method_name: str = Field(description="snake_case Python method name, e.g. search_stories.")
|
|
123
|
+
response_model: str = Field(description="PascalCase model name, e.g. SearchResults.")
|
|
124
|
+
summary: str = Field(description="One short sentence: what this endpoint returns.")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class NamingPlan(BaseModel):
|
|
128
|
+
client_class: str = Field(description="PascalCase client class name ending in Client.")
|
|
129
|
+
endpoints: list[EndpointNames]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
NAMING_SYSTEM = """\
|
|
133
|
+
You name the parts of a generated Python API client. Names must be short, clear and
|
|
134
|
+
describe what the endpoint does for a user (search_stories, get_quote, list_tags), not
|
|
135
|
+
how the URL looks. Use snake_case for methods and PascalCase for models."""
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def naming_prompt(domain: str, endpoints: list[Endpoint]) -> str:
|
|
139
|
+
lines = [f"Website: {domain}", "", "Endpoints:"]
|
|
140
|
+
for ep in endpoints:
|
|
141
|
+
params = [p.name for p in ep.path_params] + [p.name for p in ep.query_params]
|
|
142
|
+
fields = ", ".join(field_paths(ep.response_schema, limit=12)) or "-"
|
|
143
|
+
lines.append(f"- key: {ep.key}\n params: {', '.join(params) or '-'}\n returns: {fields}")
|
|
144
|
+
return "\n".join(lines)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def apply_plan(
|
|
148
|
+
plan: NamingPlan | None, endpoints: list[Endpoint], domain: str
|
|
149
|
+
) -> tuple[str, dict[str, EndpointNames]]:
|
|
150
|
+
"""Validated names for the client and every endpoint, falling back where needed."""
|
|
151
|
+
client = default_client_name(domain)
|
|
152
|
+
if plan and is_identifier(plan.client_class) and plan.client_class[:1].isupper():
|
|
153
|
+
client = (
|
|
154
|
+
plan.client_class
|
|
155
|
+
if plan.client_class.endswith("Client")
|
|
156
|
+
else f"{plan.client_class}Client"
|
|
157
|
+
)
|
|
158
|
+
suggested = {e.key: e for e in plan.endpoints} if plan else {}
|
|
159
|
+
used_methods: set[str] = set()
|
|
160
|
+
used_models: set[str] = {client}
|
|
161
|
+
names: dict[str, EndpointNames] = {}
|
|
162
|
+
for ep in endpoints:
|
|
163
|
+
s = suggested.get(ep.key)
|
|
164
|
+
method = snake(s.method_name) if s and s.method_name else default_method_name(ep)
|
|
165
|
+
model = s.response_model if s and is_identifier(s.response_model) else ""
|
|
166
|
+
model = pascal(model) if model else default_model_name(ep)
|
|
167
|
+
summary = (s.summary.strip() if s else "") or f"{ep.method} {ep.path_template}"
|
|
168
|
+
names[ep.key] = EndpointNames(
|
|
169
|
+
key=ep.key,
|
|
170
|
+
method_name=unique(method, used_methods),
|
|
171
|
+
response_model=unique(model, used_models),
|
|
172
|
+
summary=summary.replace('"""', "'''")[:200],
|
|
173
|
+
)
|
|
174
|
+
return client, names
|