ghost-api-cli 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. ghost_api/__init__.py +3 -0
  2. ghost_api/__main__.py +5 -0
  3. ghost_api/adopt.py +173 -0
  4. ghost_api/agent/__init__.py +0 -0
  5. ghost_api/agent/orchestrator.py +413 -0
  6. ghost_api/agent/prompts.py +149 -0
  7. ghost_api/agent/tools.py +84 -0
  8. ghost_api/analysis/__init__.py +0 -0
  9. ghost_api/analysis/analyzer.py +130 -0
  10. ghost_api/analysis/detectors.py +188 -0
  11. ghost_api/analysis/exposure.py +80 -0
  12. ghost_api/analysis/graphql.py +67 -0
  13. ghost_api/analysis/models.py +64 -0
  14. ghost_api/analysis/schema.py +109 -0
  15. ghost_api/analysis/url_grouping.py +217 -0
  16. ghost_api/api.py +264 -0
  17. ghost_api/audit.py +78 -0
  18. ghost_api/browser/__init__.py +0 -0
  19. ghost_api/browser/auth_state.py +172 -0
  20. ghost_api/browser/driver.py +273 -0
  21. ghost_api/browser/egress.py +183 -0
  22. ghost_api/browser/gate.py +127 -0
  23. ghost_api/browser/install.py +28 -0
  24. ghost_api/browser/snapshot.py +221 -0
  25. ghost_api/capture/__init__.py +0 -0
  26. ghost_api/capture/filters.py +255 -0
  27. ghost_api/capture/har.py +148 -0
  28. ghost_api/capture/models.py +41 -0
  29. ghost_api/capture/personal.py +188 -0
  30. ghost_api/capture/recorder.py +181 -0
  31. ghost_api/cli/__init__.py +44 -0
  32. ghost_api/cli/_common.py +219 -0
  33. ghost_api/cli/client.py +487 -0
  34. ghost_api/cli/data.py +492 -0
  35. ghost_api/cli/explore.py +379 -0
  36. ghost_api/cli/manage.py +437 -0
  37. ghost_api/config.py +132 -0
  38. ghost_api/drift.py +344 -0
  39. ghost_api/drive.py +211 -0
  40. ghost_api/errors.py +106 -0
  41. ghost_api/events.py +213 -0
  42. ghost_api/exitcodes.py +21 -0
  43. ghost_api/export/__init__.py +71 -0
  44. ghost_api/export/confidence.py +45 -0
  45. ghost_api/export/html.py +41 -0
  46. ghost_api/export/openapi.py +201 -0
  47. ghost_api/export/report.py +227 -0
  48. ghost_api/export/site_data.py +196 -0
  49. ghost_api/export/tabular.py +88 -0
  50. ghost_api/export/viewer/report.html +286 -0
  51. ghost_api/generator/__init__.py +221 -0
  52. ghost_api/generator/cassettes.py +80 -0
  53. ghost_api/generator/models_gen.py +144 -0
  54. ghost_api/generator/naming.py +174 -0
  55. ghost_api/generator/package.py +197 -0
  56. ghost_api/generator/python_gen.py +172 -0
  57. ghost_api/generator/spec.py +486 -0
  58. ghost_api/generator/templates/client.py.j2 +518 -0
  59. ghost_api/generator/templates/client.test.ts.j2 +106 -0
  60. ghost_api/generator/templates/client.ts.j2 +339 -0
  61. ghost_api/generator/templates/models.py.j2 +45 -0
  62. ghost_api/generator/templates/package_readme.md.j2 +111 -0
  63. ghost_api/generator/templates/pyproject.toml.j2 +22 -0
  64. ghost_api/generator/templates/test_client.py.j2 +105 -0
  65. ghost_api/generator/ts_gen.py +232 -0
  66. ghost_api/integrations/__init__.py +0 -0
  67. ghost_api/integrations/claude_code/commands/ghost-data.md +10 -0
  68. ghost_api/integrations/claude_code/commands/ghost-diff.md +10 -0
  69. ghost_api/integrations/claude_code/commands/ghost-explore.md +10 -0
  70. ghost_api/integrations/claude_code/skills/ghost-api/SKILL.md +46 -0
  71. ghost_api/integrations/setup.py +84 -0
  72. ghost_api/llm/__init__.py +0 -0
  73. ghost_api/llm/doctor.py +60 -0
  74. ghost_api/llm/gateway.py +476 -0
  75. ghost_api/llm/providers.py +124 -0
  76. ghost_api/memory/__init__.py +0 -0
  77. ghost_api/memory/knowledge.py +157 -0
  78. ghost_api/memory/store.py +883 -0
  79. ghost_api/net.py +235 -0
  80. ghost_api/netguard.py +203 -0
  81. ghost_api/pipeline.py +745 -0
  82. ghost_api/probe/__init__.py +0 -0
  83. ghost_api/probe/prober.py +311 -0
  84. ghost_api/probe/replay.py +313 -0
  85. ghost_api/project.py +186 -0
  86. ghost_api/py.typed +0 -0
  87. ghost_api/runtime.py +397 -0
  88. ghost_api/serve/__init__.py +101 -0
  89. ghost_api/serve/explorer_mcp.py +305 -0
  90. ghost_api/serve/mcp.py +121 -0
  91. ghost_api/serve/mock.py +130 -0
  92. ghost_api/serve/rest.py +132 -0
  93. ghost_api/site.py +84 -0
  94. ghost_api/specdiff.py +122 -0
  95. ghost_api/ui/__init__.py +0 -0
  96. ghost_api/ui/keys.py +134 -0
  97. ghost_api/ui/repl.py +453 -0
  98. ghost_api/ui/terminal.py +573 -0
  99. ghost_api/verify/__init__.py +0 -0
  100. ghost_api/verify/classify.py +84 -0
  101. ghost_api/verify/repair.py +40 -0
  102. ghost_api/verify/runner.py +110 -0
  103. ghost_api/verify/verifier.py +291 -0
  104. ghost_api/watch.py +313 -0
  105. ghost_api/web/__init__.py +1 -0
  106. ghost_api/web/server.py +690 -0
  107. ghost_api/web/static/assets/Endpoints-zPnjlcM8.js +46 -0
  108. ghost_api/web/static/assets/Run-D85zWHey.js +91 -0
  109. ghost_api/web/static/assets/Settings-D0aWHFAt.js +11 -0
  110. ghost_api/web/static/assets/Site-a88shEmP.js +71 -0
  111. ghost_api/web/static/assets/Watches-CqZWIa9G.js +16 -0
  112. ghost_api/web/static/assets/circle-x-BcTE_1tr.js +11 -0
  113. ghost_api/web/static/assets/index-Ds2e5B9f.js +141 -0
  114. ghost_api/web/static/assets/index-xQpyIBbG.css +1 -0
  115. ghost_api/web/static/assets/minus-C2xqP1eQ.js +6 -0
  116. ghost_api/web/static/assets/refresh-cw-CxdR_8Oi.js +6 -0
  117. ghost_api/web/static/favicon.svg +1 -0
  118. ghost_api/web/static/index.html +26 -0
  119. ghost_api/workflows.py +203 -0
  120. ghost_api_cli-0.2.0.dist-info/METADATA +1019 -0
  121. ghost_api_cli-0.2.0.dist-info/RECORD +125 -0
  122. ghost_api_cli-0.2.0.dist-info/WHEEL +5 -0
  123. ghost_api_cli-0.2.0.dist-info/entry_points.txt +2 -0
  124. ghost_api_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
  125. ghost_api_cli-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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