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,486 @@
1
+ """Everything the templates need to render a client, derived from the endpoint map.
2
+
3
+ Rules:
4
+ - Only read-only endpoints become methods (GET, and search/query POSTs), the same
5
+ rule the replayer uses. Endpoints that could not be replayed are left out.
6
+ - Path parameters and changing required parameters are required arguments.
7
+ - Parameters that never changed get their observed value as a default.
8
+ - The pagination parameter is optional and starts at the observed value.
9
+ - Secret-looking parameters/headers become credentials: constructor arguments that
10
+ default to an environment variable. Their values are never written (NFR-16).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import re
17
+ from collections import defaultdict
18
+ from dataclasses import dataclass, field
19
+ from typing import Any, Literal
20
+ from urllib.parse import parse_qsl, urlsplit
21
+
22
+ from ghost_api.analysis.analyzer import endpoint_keys
23
+ from ghost_api.analysis.graphql import graphql_payload, split_template
24
+ from ghost_api.analysis.models import Endpoint
25
+ from ghost_api.analysis.url_grouping import looks_like_id
26
+ from ghost_api.capture.filters import REDACTED, is_sensitive_name
27
+ from ghost_api.capture.models import CapturedRequest, RawRequest
28
+ from ghost_api.generator.models_gen import ModelBuilder, ModelDef
29
+ from ghost_api.generator.naming import EndpointNames, snake, unique
30
+ from ghost_api.probe.replay import is_replayable
31
+
32
+ PY_TYPES = {"integer": "int", "number": "float", "boolean": "bool", "string": "str"}
33
+ # Headers the client sets itself, or that only browsers send.
34
+ SKIP_HEADERS = {
35
+ "content-type",
36
+ "content-length",
37
+ "user-agent",
38
+ "accept-encoding",
39
+ "host",
40
+ "connection",
41
+ "cookie",
42
+ }
43
+
44
+
45
+ # Fields are generated as always present only with enough evidence; otherwise every field
46
+ # may be missing. Found on Turf Town: a field present in all 24 venues of one area (over 4
47
+ # pages) was missing for a venue in another area. Evidence is replies to at least 2
48
+ # different inputs (paging aside) across MIN_REPLIES_FOR_REQUIRED replies, or simply
49
+ # MANY_REPLIES replies.
50
+ MIN_REPLIES_FOR_REQUIRED = 3
51
+ MANY_REPLIES = 6
52
+ _PAGING_KEYS = {
53
+ "page", "offset", "skip", "limit", "cursor", "start", "per_page", "perpage", "page_size",
54
+ "pagesize", "size", "after", "before", "from", "hitsperpage", "hits_per_page",
55
+ } # fmt: skip
56
+
57
+
58
+ def trust_required(reqs: list[CapturedRequest]) -> bool:
59
+ """Is "seen in every sample" good evidence for "always present" for this endpoint?"""
60
+ replies = {r.resp_body for r in reqs}
61
+ if len(replies) >= MANY_REPLIES:
62
+ return True
63
+ inputs = {_input_signature(r) for r in reqs}
64
+ return len(replies) >= MIN_REPLIES_FOR_REQUIRED and len(inputs) >= 2
65
+
66
+
67
+ def _input_signature(req: CapturedRequest) -> str:
68
+ query = {
69
+ k: v
70
+ for k, v in parse_qsl(urlsplit(req.url).query, keep_blank_values=True)
71
+ if k.lower() not in _PAGING_KEYS
72
+ }
73
+ body = _json_dict(req.req_body) or {}
74
+ body = {k: v for k, v in body.items() if k.lower() not in _PAGING_KEYS}
75
+ return json.dumps([sorted(query.items()), body], sort_keys=True, default=str)
76
+
77
+
78
+ @dataclass
79
+ class ParamSpec:
80
+ name: str # Python argument name
81
+ wire: str # name on the wire
82
+ location: Literal["path", "query", "body", "variable"] # variable: a GraphQL variable
83
+ type: str # Python type, e.g. "int"
84
+ required: bool
85
+ default: str | None = None # Python literal, when there is one
86
+ example: str | None = None # Python literal used by generated tests
87
+
88
+ @property
89
+ def mutable_default(self) -> bool:
90
+ return bool(self.default) and self.default[:1] in ("[", "{") # type: ignore[index]
91
+
92
+ @property
93
+ def annotation(self) -> str:
94
+ if self.required or (self.default not in (None, "None") and not self.mutable_default):
95
+ return self.type
96
+ return f"{self.type} | None"
97
+
98
+ @property
99
+ def default_literal(self) -> str:
100
+ if self.default is None or self.mutable_default:
101
+ return "None"
102
+ return self.default
103
+
104
+ @property
105
+ def value_expr(self) -> str:
106
+ """The value to send: list/dict defaults are filled in at call time."""
107
+ if self.mutable_default:
108
+ return f"{self.name} if {self.name} is not None else {self.default}"
109
+ return self.name
110
+
111
+
112
+ @dataclass
113
+ class CredentialSpec:
114
+ arg: str # constructor argument
115
+ wire: str # header or query parameter name
116
+ location: Literal["header", "query"]
117
+ env: str # environment variable that supplies it
118
+
119
+
120
+ @dataclass
121
+ class MethodSpec:
122
+ name: str
123
+ endpoint_key: str # Endpoint.key this method calls
124
+ summary: str
125
+ http_method: str
126
+ host: str
127
+ path: str # template with {python_name} placeholders
128
+ params: list[ParamSpec]
129
+ response_type: str
130
+ headers: dict[str, str]
131
+ credentials: list[str] # CredentialSpec.arg values this method needs
132
+ pagination: dict[str, Any] | None = None
133
+ page_param: ParamSpec | None = None
134
+ replayable: bool | None = None
135
+ # GraphQL: the operation sent with every call (operationName, query, extensions); the
136
+ # method's "variable" parameters fill in its variables.
137
+ graphql: dict[str, Any] | None = None
138
+
139
+ @property
140
+ def required(self) -> list[ParamSpec]:
141
+ return [p for p in self.params if p.required]
142
+
143
+ @property
144
+ def optional(self) -> list[ParamSpec]:
145
+ return [p for p in self.params if not p.required]
146
+
147
+ def by_location(self, location: str) -> list[ParamSpec]:
148
+ return [p for p in self.params if p.location == location]
149
+
150
+ @property
151
+ def testable(self) -> bool:
152
+ return self.replayable is not False and all(p.example is not None for p in self.required)
153
+
154
+
155
+ @dataclass
156
+ class ClientSpec:
157
+ class_name: str
158
+ domain: str
159
+ base_url: str
160
+ methods: list[MethodSpec] = field(default_factory=list)
161
+ models: list[ModelDef] = field(default_factory=list)
162
+ credentials: list[CredentialSpec] = field(default_factory=list)
163
+ skipped: list[str] = field(default_factory=list) # endpoint labels left out, with why
164
+ version: str = "1.0.0" # the client's version (drift.py), used by the packages
165
+ changelog: str = "" # CHANGELOG.md for the packages
166
+
167
+ @property
168
+ def error_class(self) -> str:
169
+ return self.class_name.removesuffix("Client") + "Error"
170
+
171
+ @property
172
+ def error_classes(self) -> dict[str, str]:
173
+ """Typed errors, each a subclass of `error_class`: kind -> class name."""
174
+ stem = self.class_name.removesuffix("Client")
175
+ return {
176
+ "auth": f"{stem}AuthError", # 401, 403
177
+ "not_found": f"{stem}NotFoundError", # 404
178
+ "rate_limit": f"{stem}RateLimitError", # 429
179
+ "server": f"{stem}ServerError", # 5xx
180
+ }
181
+
182
+ @property
183
+ def async_class(self) -> str:
184
+ return f"Async{self.class_name}"
185
+
186
+ @property
187
+ def reserved_names(self) -> set[str]:
188
+ return {self.class_name, self.async_class, self.error_class, *self.error_classes.values()}
189
+
190
+ @property
191
+ def model_names(self) -> list[str]:
192
+ return [m.name for m in self.models]
193
+
194
+ @property
195
+ def client_imports(self) -> list[str]:
196
+ """Models the client module refers to directly (response types)."""
197
+ used = " ".join(m.response_type for m in self.methods)
198
+ return [n for n in self.model_names if re.search(rf"\b{n}\b", used)]
199
+
200
+
201
+ def build_spec(
202
+ domain: str,
203
+ start_url: str,
204
+ endpoints: list[Endpoint],
205
+ requests: list[CapturedRequest],
206
+ class_name: str,
207
+ names: dict[str, EndpointNames],
208
+ allow: set[str] | None = None,
209
+ ) -> ClientSpec:
210
+ """`allow`: endpoints the user has checked only read data, by label
211
+ ("POST /api/search" or "POST api.example.com/search"). Ghost API never decides
212
+ that by itself for a POST without a read word in its URL."""
213
+ parts = urlsplit(start_url)
214
+ spec = ClientSpec(class_name, domain, f"{parts.scheme}://{parts.netloc}")
215
+ main_host = (parts.hostname or "").lower()
216
+ keys = endpoint_keys(requests)
217
+ samples: dict[str, list[CapturedRequest]] = defaultdict(list)
218
+ for key, req in zip(keys, requests, strict=False):
219
+ samples[key].append(req)
220
+
221
+ builder = ModelBuilder(reserved_names=spec.reserved_names)
222
+ env_prefix = snake(class_name.removesuffix("Client")).upper()
223
+ creds: dict[tuple[str, str], CredentialSpec] = {}
224
+
225
+ for ep in endpoints:
226
+ reqs = samples.get(ep.key, [])
227
+ reason = _skip_reason(ep, reqs, allowed=is_allowed(ep, allow or set()))
228
+ if reason:
229
+ spec.skipped.append(f"{ep.method} {ep.host}{ep.path_template}: {reason}")
230
+ continue
231
+ n = names[ep.key]
232
+ used: set[str] = {"self"}
233
+ params: list[ParamSpec] = []
234
+ path, operation = split_template(ep.path_template)
235
+ for pp in ep.path_params:
236
+ arg = unique(snake(pp.name), used)
237
+ path = path.replace("{" + pp.name + "}", "{" + arg + "}")
238
+ params.append(
239
+ ParamSpec(
240
+ arg,
241
+ pp.name,
242
+ "path",
243
+ PY_TYPES.get(pp.type, "str"),
244
+ True,
245
+ example=_literal(pp.examples[0], pp.type) if pp.examples else None,
246
+ )
247
+ )
248
+
249
+ method_creds: list[str] = []
250
+ page = ep.pagination or {}
251
+ for qp in ep.query_params:
252
+ if qp.sensitive or is_sensitive_name(qp.name):
253
+ method_creds.append(_credential(creds, qp.name, "query", env_prefix))
254
+ continue
255
+ arg = unique(snake(qp.name), used)
256
+ first = qp.examples[0] if qp.examples else None
257
+ literal = _literal(first, qp.type) if first is not None else None
258
+ is_page = page.get("location") == "query" and page.get("param") == qp.name
259
+ if qp.constant or is_page:
260
+ # A cursor starts empty (first page); page/offset start where we saw them.
261
+ cursor = is_page and page.get("style") == "cursor"
262
+ start = None if cursor else literal
263
+ params.append(
264
+ ParamSpec(
265
+ arg,
266
+ qp.name,
267
+ "query",
268
+ # Cursors are opaque tokens, even when they look like numbers: the
269
+ # reply's next_cursor is passed back as it came.
270
+ "str" if cursor else PY_TYPES.get(qp.type, "str"),
271
+ False,
272
+ default=start,
273
+ example=start,
274
+ )
275
+ )
276
+ else:
277
+ required = qp.required and is_user_input(qp.name, qp.examples)
278
+ params.append(
279
+ ParamSpec(
280
+ arg,
281
+ qp.name,
282
+ "query",
283
+ PY_TYPES.get(qp.type, "str"),
284
+ required,
285
+ # Optional settings default to a value seen working.
286
+ default=None if required or not qp.required else literal,
287
+ example=literal,
288
+ )
289
+ )
290
+
291
+ graphql: dict[str, Any] | None = None
292
+ if operation:
293
+ payloads = [
294
+ p for p in (graphql_payload(r.method, r.url, r.req_body) for r in reqs) if p
295
+ ]
296
+ graphql = {k: payloads[-1][k] for k in ("operationName", "query", "extensions")
297
+ if payloads and payloads[-1].get(k)} # fmt: skip
298
+ graphql.setdefault("operationName", operation)
299
+ variables = [p["variables"] for p in payloads if isinstance(p.get("variables"), dict)]
300
+ params += _value_params(variables, used, {}, "variable")
301
+ else:
302
+ params += _body_params(reqs, used, page)
303
+
304
+ headers: dict[str, str] = {}
305
+ sample_headers = reqs[-1].req_headers if reqs else {}
306
+ for h in ep.required_headers:
307
+ if h.lower() == "cookie" or is_sensitive_name(h):
308
+ method_creds.append(_credential(creds, h, "header", env_prefix))
309
+ elif h.lower() not in SKIP_HEADERS:
310
+ value = next((v for k, v in sample_headers.items() if k.lower() == h.lower()), "")
311
+ if value and value != REDACTED:
312
+ headers[h] = value
313
+
314
+ builder.trust_required = trust_required(reqs)
315
+ response_type = builder.root_type(ep.response_schema, n.response_model, n.summary)
316
+ builder.trust_required = True
317
+ page_param = next(
318
+ (
319
+ p
320
+ for p in params
321
+ if page and p.wire == page.get("param") and p.location == page.get("location")
322
+ ),
323
+ None,
324
+ )
325
+ spec.methods.append(
326
+ MethodSpec(
327
+ name=n.method_name,
328
+ endpoint_key=ep.key,
329
+ summary=n.summary,
330
+ http_method=ep.method,
331
+ host="" if ep.host == main_host else ep.host,
332
+ path=path,
333
+ params=params,
334
+ response_type=response_type,
335
+ headers=headers,
336
+ credentials=sorted(set(method_creds)),
337
+ pagination=page or None,
338
+ page_param=page_param if page.get("confirmed") else None,
339
+ replayable=ep.replayable,
340
+ graphql=graphql,
341
+ )
342
+ )
343
+ spec.models = builder.models
344
+ spec.credentials = sorted(creds.values(), key=lambda c: c.arg)
345
+ return spec
346
+
347
+
348
+ def is_allowed(ep: Endpoint, allow: set[str]) -> bool:
349
+ labels = {
350
+ ep.key,
351
+ f"{ep.method} {ep.path_template}",
352
+ f"{ep.method} {ep.host}{ep.path_template}",
353
+ ep.label,
354
+ }
355
+ return bool(labels & {a.strip() for a in allow})
356
+
357
+
358
+ def _skip_reason(ep: Endpoint, reqs: list[CapturedRequest], allowed: bool = False) -> str | None:
359
+ probe = RawRequest(ep.method, f"https://{ep.host}{ep.path_template}", {}, None)
360
+ if not allowed and not is_replayable(probe):
361
+ return "may change data, so no method is generated (use --allow if it only reads)"
362
+ if not allowed and ep.replayable is False:
363
+ return "could not be called outside the browser"
364
+ if ep.method == "POST" and reqs and not _json_dict(reqs[-1].req_body) and reqs[-1].req_body:
365
+ return "request body is not JSON"
366
+ return None
367
+
368
+
369
+ def _body_params(
370
+ reqs: list[CapturedRequest], used: set[str], page: dict[str, Any]
371
+ ) -> list[ParamSpec]:
372
+ bodies = [b for b in (_json_dict(r.req_body) for r in reqs) if b is not None]
373
+ return _value_params(bodies, used, page, "body")
374
+
375
+
376
+ def _value_params(
377
+ bodies: list[dict[str, Any]],
378
+ used: set[str],
379
+ page: dict[str, Any],
380
+ location: Literal["body", "variable"],
381
+ ) -> list[ParamSpec]:
382
+ """Parameters from the keys of JSON bodies (or GraphQL variables) seen in requests."""
383
+ if not bodies:
384
+ return []
385
+ values: dict[str, list[Any]] = defaultdict(list)
386
+ for body in bodies:
387
+ for k, v in body.items():
388
+ values[k].append(v)
389
+ params = []
390
+ for key, seen in values.items():
391
+ if is_sensitive_name(key) or any(v == REDACTED for v in seen):
392
+ continue # never generate secrets; rare in bodies
393
+ arg = unique(snake(key), used)
394
+ py_type = _py_type(seen)
395
+ literal = repr(seen[0]) if _is_literal(seen[0]) else None
396
+ constant = len(bodies) > 1 and all(v == seen[0] for v in seen)
397
+ is_page = page.get("location") == "body" and page.get("param") == key
398
+ if constant or is_page or len(seen) < len(bodies):
399
+ params.append(
400
+ ParamSpec(
401
+ arg,
402
+ key,
403
+ location,
404
+ py_type,
405
+ False,
406
+ default=literal if (constant or is_page) else None,
407
+ example=literal,
408
+ )
409
+ )
410
+ elif is_user_input(key, [str(v) for v in seen]):
411
+ params.append(ParamSpec(arg, key, location, py_type, True, example=literal))
412
+ else:
413
+ # Varies between captured requests but isn't user input (a setting or
414
+ # filter): optional, defaulting to a value seen working.
415
+ params.append(
416
+ ParamSpec(arg, key, location, py_type, False, default=literal, example=literal)
417
+ )
418
+ return params
419
+
420
+
421
+ # Names of parameters a caller is expected to supply.
422
+ _INPUT_NAMES = {
423
+ "q", "query", "search", "searchterm", "search_query", "term", "keyword", "keywords",
424
+ "text", "id", "slug", "key", "sku", "username", "user", "name", "url",
425
+ } # fmt: skip
426
+
427
+
428
+ def is_user_input(name: str, examples: list[str]) -> bool:
429
+ """True for search terms and identifiers; False for settings like sort or filters."""
430
+ n = snake(name)
431
+ if n in _INPUT_NAMES or n.endswith(("_id", "_slug", "_query")):
432
+ return True
433
+ # ID-shaped strings (UUIDs, hashes) are inputs too; plain numbers like 1 or 30 are
434
+ # usually settings, so they don't count on their own.
435
+ return bool(examples) and all(
436
+ looks_like_id(str(v)) and not str(v).lstrip("-").isdigit() for v in examples
437
+ )
438
+
439
+
440
+ def _credential(
441
+ creds: dict[tuple[str, str], CredentialSpec], wire: str, location: str, prefix: str
442
+ ) -> str:
443
+ key = (wire.lower(), location)
444
+ if key not in creds:
445
+ arg = snake(wire)
446
+ creds[key] = CredentialSpec(arg, wire, location, f"{prefix}_{arg.upper()}") # type: ignore[arg-type]
447
+ return creds[key].arg
448
+
449
+
450
+ def _json_dict(body: str | None) -> dict[str, Any] | None:
451
+ try:
452
+ data = json.loads(body or "")
453
+ except ValueError:
454
+ return None
455
+ return data if isinstance(data, dict) else None
456
+
457
+
458
+ def _literal(value: str, json_type: str) -> str:
459
+ if json_type == "integer":
460
+ return str(int(value))
461
+ if json_type == "number":
462
+ return str(float(value))
463
+ if json_type == "boolean":
464
+ return str(value.lower() == "true")
465
+ return repr(value)
466
+
467
+
468
+ def _is_literal(value: Any) -> bool:
469
+ return isinstance(value, str | int | float | bool | type(None) | list | dict)
470
+
471
+
472
+ def _py_type(values: list[Any]) -> str:
473
+ kinds = {type(v) for v in values if v is not None}
474
+ if kinds == {bool}:
475
+ return "bool"
476
+ if kinds == {int}:
477
+ return "int"
478
+ if kinds <= {int, float} and kinds:
479
+ return "float"
480
+ if kinds == {str}:
481
+ return "str"
482
+ if kinds == {list}:
483
+ return "list[Any]"
484
+ if kinds == {dict}:
485
+ return "dict[str, Any]"
486
+ return "Any"