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,339 @@
1
+ /**
2
+ * {{ spec.class_name }}: a typed TypeScript client for {{ spec.domain }}.
3
+ *
4
+ * Generated by Ghost API {{ version }} on {{ generated_on }} from the site's own traffic.
5
+ * Made for Node 18+ (servers, scripts, serverless functions). The types describe responses
6
+ * seen while exploring; they are not checked at runtime.
7
+ *
8
+ * In a web page: browsers don't let code set the User-Agent header, so this client doesn't
9
+ * try there (the site can't tell the calls come from it), and the browser only lets the page
10
+ * read the replies if the site allows cross-origin requests (CORS), which internal APIs
11
+ * rarely do. Call the client from your own server and have the page call that instead.
12
+ *
13
+ * This is an unofficial client for a website's internal API. It can change without
14
+ * notice, and the site's terms of service still apply. Re-run `ghost-api run` to refresh.
15
+ */
16
+ {% for m in spec.models %}
17
+
18
+ {% if m.doc %}
19
+ /** {{ m.doc | doc }} */
20
+ {% endif %}
21
+ export interface {{ m.name }} {
22
+ {% for f in m.fields %}
23
+ {{ f | ts_prop }}{% if f.optional %}?{% endif %}: {{ f.type | ts_type }};
24
+ {% endfor %}
25
+ /** Fields not seen while exploring. */
26
+ [extra: string]: unknown;
27
+ }
28
+ {% endfor %}
29
+
30
+ /** An API call failed. `status` is the HTTP status (0 for network errors). */
31
+ export class {{ spec.error_class }} extends Error {
32
+ readonly status: number;
33
+ readonly body: unknown;
34
+
35
+ constructor(status: number, message: string, body?: unknown) {
36
+ super(status ? `HTTP ${status}: ${message}` : message);
37
+ this.name = new.target.name;
38
+ this.status = status;
39
+ this.body = body;
40
+ }
41
+ }
42
+
43
+ /** 401 or 403: a credential is missing, wrong or expired. */
44
+ export class {{ spec.error_classes.auth }} extends {{ spec.error_class }} {}
45
+ /** 404: no such resource (or the site moved this endpoint). */
46
+ export class {{ spec.error_classes.not_found }} extends {{ spec.error_class }} {}
47
+ /** 429 after all retries: slow down (see `maxRequestsPerSecond`). */
48
+ export class {{ spec.error_classes.rate_limit }} extends {{ spec.error_class }} {}
49
+ /** 5xx after all retries: the site has a problem. */
50
+ export class {{ spec.error_classes.server }} extends {{ spec.error_class }} {}
51
+
52
+ function errorFor(status: number, message: string, body?: unknown): {{ spec.error_class }} {
53
+ if (status === 401 || status === 403) return new {{ spec.error_classes.auth }}(status, message, body);
54
+ if (status === 404) return new {{ spec.error_classes.not_found }}(status, message, body);
55
+ if (status === 429) return new {{ spec.error_classes.rate_limit }}(status, message, body);
56
+ if (status >= 500) return new {{ spec.error_classes.server }}(status, message, body);
57
+ return new {{ spec.error_class }}(status, message, body);
58
+ }
59
+
60
+ export interface {{ spec.class_name }}Options {
61
+ baseUrl?: string;
62
+ {% for c in spec.credentials %}
63
+ /** Defaults to the {{ c.env }} environment variable (Node). */
64
+ {{ c.arg | camel }}?: string;
65
+ {% endfor %}
66
+ maxRequestsPerSecond?: number;
67
+ maxRetries?: number;
68
+ fetch?: typeof fetch;
69
+ }
70
+
71
+ const USER_AGENT = {{ user_agent | json }};
72
+ const RETRY_STATUSES = new Set([429, 500, 502, 503, 504]);
73
+ // Guards some sites put before JSON so other sites can't read it (Google: ")]}'").
74
+ const JSON_GUARDS = [")]}'", "while(1);", "for(;;);"];
75
+ const CREDENTIAL_ENV: Record<string, string> = {
76
+ {% for c in spec.credentials %}
77
+ {{ c.arg | camel | json }}: {{ c.env | json }},
78
+ {% endfor %}
79
+ };
80
+
81
+ /** True in a web page (and web workers), where the User-Agent header can't be set. */
82
+ function inBrowser(): boolean {
83
+ const g = globalThis as { document?: unknown; importScripts?: unknown };
84
+ return g.document !== undefined || typeof g.importScripts === "function";
85
+ }
86
+
87
+ function readEnv(name: string): string | undefined {
88
+ const proc = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process;
89
+ return proc?.env?.[name];
90
+ }
91
+
92
+ function parseJson(text: string): unknown {
93
+ let body = text.trimStart();
94
+ const guard = JSON_GUARDS.find((g) => body.startsWith(g));
95
+ if (guard) body = body.slice(guard.length).replace(/^,/, "").trimStart();
96
+ return JSON.parse(body);
97
+ }
98
+
99
+ /** GraphQL variables without the ones left out. */
100
+ function compact(values: Record<string, unknown>): Record<string, unknown> {
101
+ return Object.fromEntries(Object.entries(values).filter(([, v]) => v !== undefined && v !== null));
102
+ }
103
+
104
+ function sleep(ms: number): Promise<void> {
105
+ return new Promise((resolve) => setTimeout(resolve, ms));
106
+ }
107
+
108
+ export class {{ spec.class_name }} {
109
+ static readonly BASE_URL = {{ spec.base_url | json }};
110
+
111
+ readonly baseUrl: string;
112
+ private readonly credentials: Record<string, string | undefined>;
113
+ private readonly interval: number;
114
+ private readonly maxRetries: number;
115
+ private readonly fetchImpl: typeof fetch;
116
+ private lastRequest = 0;
117
+
118
+ constructor(options: {{ spec.class_name }}Options = {}) {
119
+ this.baseUrl = (options.baseUrl ?? {{ spec.class_name }}.BASE_URL).replace(/\/+$/, "");
120
+ this.credentials = {
121
+ {% for c in spec.credentials %}
122
+ {{ c.arg | camel | json }}: options.{{ c.arg | camel }} ?? readEnv({{ c.env | json }}),
123
+ {% endfor %}
124
+ };
125
+ const rate = options.maxRequestsPerSecond ?? 1;
126
+ this.interval = rate > 0 ? 1000 / rate : 0;
127
+ this.maxRetries = options.maxRetries ?? 3;
128
+ this.fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
129
+ }
130
+ {% for m in spec.methods %}
131
+
132
+ /**
133
+ * {{ m.summary | doc }}
134
+ *
135
+ * `{{ m.http_method }} {{ (m.host and 'https://' ~ m.host) or spec.base_url }}{{ m.path }}`
136
+ */
137
+ async {{ m.name | camel }}(
138
+ {% for p in m.required %}
139
+ {{ p.name | camel }}: {{ p.type | ts_type }},
140
+ {% endfor %}
141
+ {% if m.optional %}
142
+ opts: {
143
+ {% for p in m.optional %}
144
+ {{ p.name | camel }}?: {{ p.type | ts_type }}{% if not p.required and (p.default is none or p.default == "None") %} | null{% endif %};
145
+ {% endfor %}
146
+ } = {},
147
+ {% endif %}
148
+ ): Promise<{{ m.response_type | ts_type }}> {
149
+ const data = await this.request(
150
+ "{{ m.http_method }}",
151
+ {% if m.host %}"https://{{ m.host }}"{% else %}this.baseUrl{% endif %} + {{ m | path_expr }},
152
+ {
153
+ {% for p in m.by_location('query') %}
154
+ {{ p.wire | json }}: {{ p | value }},
155
+ {% endfor %}
156
+ {% for c in m | creds(spec, 'query') %}
157
+ {{ c.wire | json }}: this.credential({{ c.arg | camel | json }}),
158
+ {% endfor %}
159
+ },
160
+ {
161
+ {% for name, value in m.headers.items() %}
162
+ {{ name | json }}: {{ value | json }},
163
+ {% endfor %}
164
+ {% for c in m | creds(spec, 'header') %}
165
+ {{ c.wire | json }}: this.credential({{ c.arg | camel | json }}),
166
+ {% endfor %}
167
+ },
168
+ {% if m.graphql %}
169
+ {
170
+ {% for key, value in m.graphql.items() %}
171
+ {{ key | json }}: {{ value | json }},
172
+ {% endfor %}
173
+ variables: compact({
174
+ {% for p in m.by_location('variable') %}
175
+ {{ p.wire | json }}: {{ p | value }},
176
+ {% endfor %}
177
+ }),
178
+ },
179
+ {% elif m.by_location('body') %}
180
+ {
181
+ {% for p in m.by_location('body') %}
182
+ {{ p.wire | json }}: {{ p | value }},
183
+ {% endfor %}
184
+ },
185
+ {% endif %}
186
+ );
187
+ return data as {{ m.response_type | ts_type }};
188
+ }
189
+ {% if m.page_param %}
190
+ {% set pp = m.page_param %}
191
+
192
+ /**
193
+ * Yield each page of `{{ m.name | camel }}()` until the results run out.
194
+ * Pages by {{ m.pagination.style }} using `{{ pp.wire }}`. Set `maxPages` to stop early.
195
+ */
196
+ async *iter{{ (m.name | camel)[:1] | upper }}{{ (m.name | camel)[1:] }}(
197
+ {% for p in m.required %}
198
+ {{ p.name | camel }}: {{ p.type | ts_type }},
199
+ {% endfor %}
200
+ opts: {
201
+ {% for p in m.optional if p.name != pp.name %}
202
+ {{ p.name | camel }}?: {{ p.type | ts_type }}{% if p.default is none or p.default == "None" %} | null{% endif %};
203
+ {% endfor %}
204
+ start?: {{ pp.type | ts_type }};
205
+ maxPages?: number;
206
+ } = {},
207
+ ): AsyncGenerator<{{ m.response_type | ts_type }}> {
208
+ const { start, maxPages, ...rest } = opts;
209
+ let position: unknown = start ?? {{ pp.default | js }};
210
+ let previous = "";
211
+ for (let pages = 0; maxPages === undefined || pages < maxPages; pages++) {
212
+ const page = await this.{{ m.name | camel }}(
213
+ {% for p in m.required %}
214
+ {{ p.name | camel }},
215
+ {% endfor %}
216
+ { ...rest, {{ pp.name | camel }}: position as {{ pp.type | ts_type }} },
217
+ );
218
+ const items = findItems(page);
219
+ const fingerprint = JSON.stringify(items);
220
+ if (!items || items.length === 0 || fingerprint === previous) return;
221
+ yield page;
222
+ if (hasMore(page) === false) return;
223
+ previous = fingerprint;
224
+ {% if m.pagination.style == "cursor" %}
225
+ position = nextCursor(page);
226
+ if (position === undefined) return;
227
+ {% elif m.pagination.style == "offset" %}
228
+ position = Number(position ?? 0) + items.length;
229
+ {% else %}
230
+ position = Number(position ?? 0) + 1;
231
+ {% endif %}
232
+ }
233
+ }
234
+ {% endif %}
235
+ {% endfor %}
236
+
237
+ // --- plumbing ---
238
+
239
+ private credential(name: string): string {
240
+ const value = this.credentials[name];
241
+ if (!value) {
242
+ const env = CREDENTIAL_ENV[name] ?? "";
243
+ throw new {{ spec.error_classes.auth }}(0, `Missing credential: pass ${name} or set ${env}`);
244
+ }
245
+ return value;
246
+ }
247
+
248
+ private async request(
249
+ method: string,
250
+ url: string,
251
+ query: Record<string, unknown>,
252
+ headers: Record<string, string>,
253
+ body?: Record<string, unknown>,
254
+ ): Promise<unknown> {
255
+ const target = new URL(url);
256
+ for (const [key, value] of Object.entries(query)) {
257
+ if (value !== undefined && value !== null) target.searchParams.set(key, String(value));
258
+ }
259
+ const init: RequestInit = {
260
+ method,
261
+ headers: {
262
+ ...(inBrowser() ? {} : { "User-Agent": USER_AGENT }),
263
+ Accept: "application/json",
264
+ ...headers,
265
+ },
266
+ };
267
+ if (body !== undefined) {
268
+ const clean = Object.fromEntries(Object.entries(body).filter(([, v]) => v !== undefined && v !== null));
269
+ init.body = JSON.stringify(clean);
270
+ init.headers = { ...init.headers, "Content-Type": "application/json" };
271
+ }
272
+ for (let attempt = 0; ; attempt++) {
273
+ const wait = this.lastRequest + this.interval - Date.now();
274
+ if (wait > 0) await sleep(wait);
275
+ this.lastRequest = Date.now();
276
+ let response: Response;
277
+ try {
278
+ response = await this.fetchImpl(target, init);
279
+ } catch (error) {
280
+ if (attempt >= this.maxRetries) {
281
+ throw new {{ spec.error_class }}(0, `network error: ${String(error)}`);
282
+ }
283
+ await sleep(1000 * 2 ** attempt);
284
+ continue;
285
+ }
286
+ if (RETRY_STATUSES.has(response.status) && attempt < this.maxRetries) {
287
+ const retryAfter = Number(response.headers.get("retry-after"));
288
+ await sleep(Number.isFinite(retryAfter) && retryAfter >= 0 ? retryAfter * 1000 : 1000 * 2 ** attempt);
289
+ continue;
290
+ }
291
+ const text = await response.text();
292
+ let data: unknown;
293
+ try {
294
+ data = parseJson(text);
295
+ } catch {
296
+ if (!response.ok) throw errorFor(response.status, response.statusText, text.slice(0, 500));
297
+ throw new {{ spec.error_class }}(response.status, "response is not JSON", text.slice(0, 500));
298
+ }
299
+ if (!response.ok) throw errorFor(response.status, response.statusText, data);
300
+ return data;
301
+ }
302
+ }
303
+ }
304
+
305
+ function findItems(data: unknown, depth = 0): unknown[] | undefined {
306
+ if (Array.isArray(data)) return data;
307
+ if (data === null || typeof data !== "object" || depth > 2) return undefined;
308
+ let best: unknown[] | undefined;
309
+ for (const value of Object.values(data)) {
310
+ const found = Array.isArray(value) ? value : findItems(value, depth + 1);
311
+ if (found && (!best || found.length > best.length)) best = found;
312
+ }
313
+ return best;
314
+ }
315
+
316
+ function hasMore(data: unknown): boolean | undefined {
317
+ if (data === null || typeof data !== "object") return undefined;
318
+ const record = data as Record<string, unknown>;
319
+ for (const key of ["has_next", "hasNext", "has_more", "hasMore"]) {
320
+ if (typeof record[key] === "boolean") return record[key] as boolean;
321
+ }
322
+ return undefined;
323
+ }
324
+
325
+ function nextCursor(data: unknown): string | undefined {
326
+ if (data === null || typeof data !== "object") return undefined;
327
+ const record = data as Record<string, unknown>;
328
+ const places = [record, ...["meta", "pagination", "paging", "pageInfo", "links"]
329
+ .map((k) => record[k])
330
+ .filter((v): v is Record<string, unknown> => v !== null && typeof v === "object")];
331
+ for (const place of places) {
332
+ for (const key of ["next_cursor", "nextCursor", "cursor", "next", "after", "endCursor",
333
+ "next_page_token", "nextPageToken"]) {
334
+ const value = place[key];
335
+ if ((typeof value === "string" && value) || typeof value === "number") return String(value);
336
+ }
337
+ }
338
+ return undefined;
339
+ }
@@ -0,0 +1,45 @@
1
+ """Data models for {{ spec.domain }}, inferred from real responses.
2
+
3
+ Generated by Ghost API {{ version }} on {{ generated_on }}. Fields that were missing or
4
+ null in some responses are optional. Unknown extra fields are kept, so a new field in
5
+ the API does not break parsing.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ {% if uses_any %}
11
+ from typing import Any
12
+
13
+ {% endif %}
14
+ from pydantic import BaseModel, ConfigDict{% if uses_field %}, Field{% endif %}
15
+
16
+
17
+ __all__ = [
18
+ {% for m in spec.models %}
19
+ {{ m.name | pyrepr }},
20
+ {% endfor %}
21
+ ]
22
+
23
+
24
+ class _Model(BaseModel):
25
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
26
+ {% for m in spec.models %}
27
+
28
+
29
+ class {{ m.name }}(_Model):
30
+ {% if m.doc %}
31
+ """{{ m.doc | doc }}"""
32
+ {% endif %}
33
+ {% for f in m.fields %}
34
+ {% if f.alias %}
35
+ {{ f.name }}: {{ f.type }} = Field({% if f.optional %}None, {% endif %}alias={{ f.alias | pyrepr }})
36
+ {% elif f.optional %}
37
+ {{ f.name }}: {{ f.type }} = None
38
+ {% else %}
39
+ {{ f.name }}: {{ f.type }}
40
+ {% endif %}
41
+ {% endfor %}
42
+ {% if not m.fields and not m.doc %}
43
+ pass
44
+ {% endif %}
45
+ {% endfor %}
@@ -0,0 +1,111 @@
1
+ # {{ dist }}
2
+
3
+ An **unofficial**, typed {{ "Python" if lang == "python" else "TypeScript" }} client for
4
+ [{{ spec.domain }}]({{ spec.base_url }}), generated by
5
+ [Ghost API](https://github.com/Danielraj5605/Ghost-api) {{ version }} from the site's own
6
+ traffic. It is not made or supported by {{ spec.domain }}: the API can change without notice,
7
+ and the site's terms of service apply. Regenerate it with `ghost-api refresh {{ spec.domain }}`.
8
+
9
+ ## Install
10
+
11
+ {% if lang == "python" %}
12
+ ```bash
13
+ pip install path/to/package/python # or vendor src/{{ module }}/ into your project
14
+ ```
15
+
16
+ Needs Python 3.10+, `httpx` and `pydantic` (installed with it).
17
+ {% else %}
18
+ ```bash
19
+ npm install path/to/package/typescript # builds dist/ on install
20
+ ```
21
+
22
+ Made for Node 18+ (servers, scripts, serverless functions). In a web page, browsers block
23
+ reading another site's replies unless it allows CORS, and don't let code set the
24
+ User-Agent: call this client from your own server instead.
25
+ {% endif %}
26
+
27
+ ## Use it
28
+
29
+ {% if lang == "python" %}
30
+ ```python
31
+ from {{ module }} import {{ spec.class_name }}
32
+
33
+ with {{ spec.class_name }}() as api:
34
+ {% if methods %}
35
+ result = api.{{ methods[0].method.name }}({{ methods[0].py_args }})
36
+ {% endif %}
37
+ ```
38
+
39
+ The same methods with asyncio:
40
+
41
+ ```python
42
+ from {{ module }} import {{ spec.async_class }}
43
+
44
+ async with {{ spec.async_class }}() as api:
45
+ {% if methods %}
46
+ result = await api.{{ methods[0].method.name }}({{ methods[0].py_args }})
47
+ {% endif %}
48
+ ```
49
+ {% else %}
50
+ ```ts
51
+ import { {{ spec.class_name }} } from "{{ dist }}";
52
+
53
+ const api = new {{ spec.class_name }}();
54
+ {% if methods %}
55
+ const result = await api.{{ methods[0].ts_name }}({{ methods[0].ts_args }});
56
+ {% endif %}
57
+ ```
58
+ {% endif %}
59
+ {% if spec.credentials %}
60
+
61
+ ### Credentials
62
+
63
+ | Argument | Environment variable |
64
+ |---|---|
65
+ {% for c in spec.credentials %}
66
+ | `{{ c.arg if lang == "python" else (c.arg | camel) }}` | `{{ c.env }}` |
67
+ {% endfor %}
68
+
69
+ Pass them to the constructor, or set the environment variables.
70
+ {% endif %}
71
+
72
+ ## Methods
73
+
74
+ | Method | Calls | Returns | What it does |
75
+ |---|---|---|---|
76
+ {% for item in methods %}
77
+ {% set m = item.method %}
78
+ | `{{ m.name if lang == "python" else item.ts_name }}` | `{{ m.http_method }} {{ m.path }}` | `{{ m.response_type }}` | {{ m.summary | replace("|", "/") | replace("\n", " ") }}{% if m.page_param %} Pages with `{{ ("iter_" ~ m.name) if lang == "python" else item.ts_iter }}()`.{% endif %} |
79
+ {% endfor %}
80
+
81
+ ## The same calls with curl
82
+
83
+ Values are examples seen while exploring the site.
84
+
85
+ {% for item in methods %}
86
+ **{{ item.method.name }}**
87
+
88
+ ```bash
89
+ {{ item.curl }}
90
+ ```
91
+
92
+ {% endfor %}
93
+ ## Errors
94
+
95
+ Every failure is a `{{ spec.error_class }}` with `.status` (0 for network errors) and
96
+ `.body`. Catch one kind with its subclass:
97
+
98
+ | Error | When |
99
+ |---|---|
100
+ | `{{ spec.error_classes.auth }}` | 401 / 403, or a missing credential |
101
+ | `{{ spec.error_classes.not_found }}` | 404 |
102
+ | `{{ spec.error_classes.rate_limit }}` | 429 after the retries |
103
+ | `{{ spec.error_classes.server }}` | 5xx after the retries |
104
+
105
+ ## Manners
106
+
107
+ The client sends at most one request per second by default
108
+ (`{{ "max_requests_per_second" if lang == "python" else "maxRequestsPerSecond" }}`), retries
109
+ 429 and 5xx with backoff (`{{ "max_retries" if lang == "python" else "maxRetries" }}`), and
110
+ identifies itself with a User-Agent naming Ghost API. Keep it that way: it's someone else's
111
+ server.
@@ -0,0 +1,22 @@
1
+ # Generated by Ghost API. Install with: pip install . (or publish it to your own index)
2
+ [build-system]
3
+ requires = ["setuptools>=77"]
4
+ build-backend = "setuptools.build_meta"
5
+
6
+ [project]
7
+ name = "{{ dist }}"
8
+ version = "{{ version }}"
9
+ description = "Unofficial typed client for {{ spec.domain }} (generated by Ghost API)"
10
+ readme = "README.md"
11
+ requires-python = ">=3.10"
12
+ dependencies = ["httpx>=0.27", "pydantic>=2.7"]
13
+ classifiers = ["Typing :: Typed", "Private :: Do Not Upload"]
14
+
15
+ [project.optional-dependencies]
16
+ test = ["pytest>=8"]
17
+
18
+ [tool.setuptools.packages.find]
19
+ where = ["src"]
20
+
21
+ [tool.setuptools.package-data]
22
+ {{ module }} = ["py.typed"]
@@ -0,0 +1,105 @@
1
+ """Tests for {{ spec.class_name }} (generated by Ghost API {{ version }}).
2
+
3
+ By default they replay replies recorded from {{ spec.domain }} (cassettes.json): offline,
4
+ fast and the same every time, so they fit CI. With GHOST_API_LIVE=1 they call the real API,
5
+ slowly (1 request per second), with values seen while exploring; a failure then usually
6
+ means the site changed its API: `ghost-api diff --live` says what changed.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import re
14
+ import sys
15
+ from collections.abc import Iterator
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ import httpx
20
+ import pytest
21
+
22
+ sys.path.insert(0, str(Path(__file__).parent))
23
+
24
+ from {{ module }} import {{ spec.class_name }} # noqa: E402
25
+
26
+ CASSETTES = Path(__file__).parent / "cassettes.json"
27
+ LIVE = os.environ.get("GHOST_API_LIVE") == "1" or not CASSETTES.exists()
28
+ REPLIES: list[dict[str, Any]] = (
29
+ [] if LIVE else json.loads(CASSETTES.read_text(encoding="utf-8"))["replies"]
30
+ )
31
+
32
+
33
+ def _replay(request: httpx.Request) -> httpx.Response:
34
+ """The recorded reply closest to this request: same path first, then query values."""
35
+ query = dict(request.url.params)
36
+ operation = None
37
+ if request.content:
38
+ try:
39
+ body = json.loads(request.content)
40
+ operation = body.get("operationName") if isinstance(body, dict) else None
41
+ except ValueError:
42
+ pass
43
+ found = [
44
+ r for r in REPLIES
45
+ if r["method"] == request.method
46
+ and re.fullmatch(r["path_regex"], request.url.path)
47
+ and (operation is None or r.get("operation") in (None, operation))
48
+ ] # fmt: skip
49
+ if not found:
50
+ message = f"no recorded reply for {request.method} {request.url.path}"
51
+ return httpx.Response(404, json={"error": message})
52
+
53
+ def score(reply: dict[str, Any]) -> tuple[bool, int, int]:
54
+ same = sum(1 for k, v in reply["query"].items() if query.get(k) == v)
55
+ return reply["path"] == request.url.path, same, -abs(len(reply["query"]) - len(query))
56
+
57
+ best = max(found, key=score)
58
+ return httpx.Response(
59
+ best["status"], text=best["body"], headers={"content-type": best["content_type"]}
60
+ )
61
+
62
+
63
+ @pytest.fixture(scope="module")
64
+ def api() -> Iterator[{{ spec.class_name }}]:
65
+ if LIVE:
66
+ with {{ spec.class_name }}() as client:
67
+ yield client
68
+ return
69
+ offline = httpx.Client(transport=httpx.MockTransport(_replay))
70
+ with {{ spec.class_name }}(
71
+ {% for c in spec.credentials %}
72
+ {{ c.arg }}="offline-placeholder",
73
+ {% endfor %}
74
+ http_client=offline,
75
+ max_requests_per_second=0,
76
+ ) as client:
77
+ yield client
78
+ {% for m in spec.methods if m.testable %}
79
+
80
+
81
+ def test_{{ m.name }}(api: {{ spec.class_name }}) -> None:
82
+ {% for c in m | creds(spec, 'any') %}
83
+ if LIVE and not os.environ.get({{ c.env | pyrepr }}):
84
+ pytest.skip("set {{ c.env }} to run this test against the live API")
85
+ {% endfor %}
86
+ result = api.{{ m.name }}({% for p in m.required %}{{ p.name }}={{ p.example }}{% if not loop.last %}, {% endif %}{% endfor %})
87
+ assert result is not None
88
+ {% if m.page_param %}
89
+
90
+
91
+ def test_iter_{{ m.name }}(api: {{ spec.class_name }}) -> None:
92
+ {% for c in m | creds(spec, 'any') %}
93
+ if LIVE and not os.environ.get({{ c.env | pyrepr }}):
94
+ pytest.skip("set {{ c.env }} to run this test against the live API")
95
+ {% endfor %}
96
+ pages = list(api.iter_{{ m.name }}({% for p in m.required %}{{ p.name }}={{ p.example }}, {% endfor %}max_pages=2))
97
+ assert len(pages) >= 1
98
+ {% endif %}
99
+ {% endfor %}
100
+ {% for m in spec.methods if not m.testable %}
101
+
102
+
103
+ @pytest.mark.skip(reason="no example values were captured for its required arguments")
104
+ def test_{{ m.name }}() -> None: ...
105
+ {% endfor %}