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,227 @@
1
+ """Write report.md for a run (FR-47, FR-48), from the database only.
2
+
3
+ Sections: summary and usage, endpoints (status, confidence, samples), hypotheses with
4
+ evidence, the agent's reasoning log, known issues, and lessons for next time.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from datetime import datetime
10
+ from typing import Any
11
+
12
+ from ghost_api import __version__
13
+ from ghost_api.analysis.exposure import Exposure, exposures, summary_line
14
+ from ghost_api.analysis.models import Endpoint
15
+ from ghost_api.capture.personal import LABEL
16
+ from ghost_api.export.confidence import confidence
17
+ from ghost_api.generator.spec import ClientSpec
18
+ from ghost_api.memory.store import RunRow, Store
19
+ from ghost_api.site import site_host
20
+
21
+ RESULT_MARK = {"confirmed": "confirmed", "rejected": "rejected", "inconclusive": "inconclusive"}
22
+
23
+
24
+ def _network_section(net: dict[str, Any]) -> list[str]:
25
+ """What the run sent where, and which rules applied."""
26
+ rows = [
27
+ ("Site", f"`{net['target']}`" + (" (local target: private addresses allowed)"
28
+ if net.get("local_target") else "")),
29
+ ("robots.txt", _md(str(net.get("robots", "")))),
30
+ ("Rate limit", f"at most {net.get('rate_limit', 0)} request(s) per second per site"),
31
+ ("API calls the pages made", str(net.get("api_calls_seen", 0))),
32
+ ("Direct calls Ghost API sent", f"{net.get('direct_calls', 0)} (read-only endpoint tests)"),
33
+ ("Other API hosts", ", ".join(f"`{h}`" for h in net.get("third_party_hosts", []))
34
+ or "none"),
35
+ ("Saved login used", "yes" if net.get("saved_login") else "no"),
36
+ ("LLM calls", str(net.get("llm_calls", 0))),
37
+ ] # fmt: skip
38
+ out = ["| | |", "|---|---|", *(f"| {k} | {v} |" for k, v in rows)]
39
+ blocked = net.get("blocked") or []
40
+ out += ["", f"**Blocked by the rules ({len(blocked)}):**" if blocked else
41
+ "Nothing was blocked by the network rules."] # fmt: skip
42
+ out += [f"- {_md(b)}" for b in blocked[:30]]
43
+ return out
44
+
45
+
46
+ def build_report(store: Store, domain: str, run_id: int, spec: ClientSpec | None = None) -> str:
47
+ run = store.get_run(run_id)
48
+ if run is None:
49
+ raise ValueError(f"run {run_id} not found")
50
+ endpoints = store.current_endpoints(domain)
51
+ hypotheses = store.hypotheses_for_run(run_id)
52
+ steps = store.steps_for_run(run_id)
53
+ lessons = store.lessons_for(domain, limit=10)
54
+
55
+ out: list[str] = [
56
+ f"# Ghost API report: {domain}",
57
+ "",
58
+ f"Generated by Ghost API {__version__} for run #{run.id}. Goal: _{_md(run.goal)}_",
59
+ "",
60
+ "## Summary",
61
+ "",
62
+ *_summary(run, endpoints, spec),
63
+ "",
64
+ "## Endpoints",
65
+ "",
66
+ ]
67
+ out += _endpoint_table(endpoints, domain) if endpoints else ["No endpoints were found."]
68
+ exposed = exposures(endpoints, store.requests_for_domain(domain))
69
+ if exposed:
70
+ out += ["", "## Personal data exposed", "", *_exposure_section(exposed)]
71
+ network = [e for e in store.events_for_run(run_id) if e.get("type") == "network"]
72
+ if network:
73
+ out += ["", "## Network and permissions", "", *_network_section(network[-1])]
74
+ out += ["", "## Hypotheses tested", ""]
75
+ out += _hypotheses(hypotheses) or ["No direct-call tests in this run."]
76
+ out += ["", "## How the agent explored", ""]
77
+ out += _steps(steps) or ["The agent was not used in this run."]
78
+ out += ["", "## Known issues", ""]
79
+ out += _issues(endpoints, hypotheses, spec) or ["None found."]
80
+ if lessons:
81
+ out += ["", "## Lessons remembered for next time", ""]
82
+ out += [f"- {_md(lesson)}" for lesson in lessons]
83
+ out += ["", "---", "", _CONFIDENCE_NOTE, ""]
84
+ return "\n".join(out)
85
+
86
+
87
+ def _exposure_section(exposed: list[Exposure]) -> list[str]:
88
+ lines = [
89
+ f"**{summary_line(exposed)}.** Ghost API hid these values: they are not in its "
90
+ "database, this report or the generated code. Examples below are masked.",
91
+ "",
92
+ "| Endpoint | Without login | Field | What | Seen | Example |",
93
+ "|---|---|---|---|---|---|",
94
+ ]
95
+ for e in exposed:
96
+ for f in e.fields:
97
+ lines.append(
98
+ f"| `{_md(e.label)}` | {'yes' if e.open else 'no'} | `{_md(f['path'])}` "
99
+ f"| {LABEL.get(f['kind'], f['kind'])} ({f['severity']}) | {f['count']} "
100
+ f"| `{_md(f['example'])}` |"
101
+ )
102
+ lines += [
103
+ "",
104
+ "_What to do: if this is your site, stop returning these fields (or require a "
105
+ "login). If it isn't, don't collect or share them, and consider telling the site "
106
+ "owner privately (responsible disclosure)._",
107
+ ]
108
+ return lines
109
+
110
+
111
+ _CONFIDENCE_NOTE = (
112
+ "_Confidence: verified by live tests +2, callable directly +1, pagination confirmed +1, "
113
+ "2-4 samples +1 or 5+ samples +2, failed -3. High is 4 or more, Medium 2-3, Low below 2._"
114
+ )
115
+
116
+
117
+ def _summary(run: RunRow, endpoints: list[Endpoint], spec: ClientSpec | None) -> list[str]:
118
+ counts = {
119
+ s: sum(e.status == s for e in endpoints) for s in ("verified", "unverified", "failed")
120
+ }
121
+ outcome = run.status
122
+ if run.status == "running" and run.finished_at is None:
123
+ outcome = "did not finish (stopped unexpectedly)"
124
+ lines = [
125
+ f"- **Outcome:** {outcome}",
126
+ f"- **Endpoints:** {len(endpoints)} ({counts['verified']} verified, "
127
+ f"{counts['unverified']} unverified, {counts['failed']} failed)",
128
+ ]
129
+ if spec is not None:
130
+ methods = ", ".join(f"`{m.name}()`" for m in spec.methods)
131
+ lines.append(f"- **Client:** `{spec.class_name}` with {methods}")
132
+ duration = _duration(run.started_at, run.finished_at)
133
+ lines.append(
134
+ f"- **Usage:** {run.llm_calls} LLM call{_s(run.llm_calls)}, {run.tokens:,} tokens, "
135
+ f"{run.request_count} captured request{_s(run.request_count)}"
136
+ f"{', ' + duration if duration else ''}, $0.00 (free tiers)"
137
+ )
138
+ return lines
139
+
140
+
141
+ def _endpoint_table(endpoints: list[Endpoint], domain: str) -> list[str]:
142
+ rows = [
143
+ "| Endpoint | Status | Confidence | Samples | Auth | Paging | Notes |",
144
+ "|---|---|---|---|---|---|---|",
145
+ ]
146
+ host = site_host(domain)
147
+ for ep in endpoints:
148
+ label = ep.label if ep.host == host else f"{ep.method} {ep.host}{ep.path_template}"
149
+ paging = "-"
150
+ if ep.pagination:
151
+ mark = "" if ep.pagination.get("confirmed") else " (unconfirmed)"
152
+ paging = f"{ep.pagination['style']}: `{ep.pagination['param']}`{mark}"
153
+ rows.append(
154
+ f"| `{_md(label)}` | {ep.status} | {confidence(ep)} | {ep.sample_count} | "
155
+ f"{ep.auth_type or '-'} | {paging} | {_md(ep.status_note) or '-'} |"
156
+ )
157
+ return rows
158
+
159
+
160
+ def _hypotheses(hypotheses: list[dict[str, Any]]) -> list[str]:
161
+ lines: list[str] = []
162
+ current = None
163
+ for h in hypotheses:
164
+ if h["endpoint_key"] != current:
165
+ current = h["endpoint_key"]
166
+ if lines:
167
+ lines.append("")
168
+ lines += [f"**`{_md(str(current))}`**", ""]
169
+ result = RESULT_MARK[str(h["result"])]
170
+ lines.append(f"- {result}: {_md(str(h['text']))} ({_md(str(h['evidence']))})")
171
+ return lines
172
+
173
+
174
+ def _steps(steps: list[dict[str, Any]]) -> list[str]:
175
+ lines = []
176
+ for s in steps:
177
+ args = ", ".join(f"{k}={v!r}" for k, v in dict(s["args"]).items())
178
+ lines.append(f"{s['step_no']}. `{s['action']}({_md(args)})`: {_md(str(s['thought']))}")
179
+ lines.append(f" - Result: {_md(str(s['result']))}")
180
+ return lines
181
+
182
+
183
+ def _issues(
184
+ endpoints: list[Endpoint], hypotheses: list[dict[str, Any]], spec: ClientSpec | None
185
+ ) -> list[str]:
186
+ issues = []
187
+ for ep in endpoints:
188
+ if ep.status in ("failed", "unverified"):
189
+ issues.append(
190
+ f"- `{_md(ep.label)}` is {ep.status}: {_md(ep.status_note) or 'not tested'}"
191
+ )
192
+ elif ep.status == "discovered":
193
+ issues.append(
194
+ f"- `{_md(ep.label)}` has not been verified yet "
195
+ "(run `ghost-api verify` or `ghost-api refresh`)"
196
+ )
197
+ if ep.replayable is False:
198
+ issues.append(f"- `{_md(ep.label)}` cannot be called outside the browser")
199
+ for h in hypotheses:
200
+ if h["result"] == "inconclusive":
201
+ issues.append(f"- Inconclusive: {_md(str(h['text']))} ({_md(str(h['evidence']))})")
202
+ if spec is not None:
203
+ issues += [f"- Not in the client: {_md(s)}" for s in spec.skipped]
204
+ for cred in spec.credentials:
205
+ issues.append(f"- The client needs `{cred.arg}`: set `{cred.env}` or pass it in")
206
+ return issues
207
+
208
+
209
+ def _duration(started: str, finished: str | None) -> str:
210
+ if not finished:
211
+ return ""
212
+ try:
213
+ seconds = (
214
+ datetime.fromisoformat(finished) - datetime.fromisoformat(started)
215
+ ).total_seconds()
216
+ except ValueError:
217
+ return ""
218
+ return f"{seconds:.0f}s"
219
+
220
+
221
+ def _s(count: int) -> str:
222
+ return "" if count == 1 else "s"
223
+
224
+
225
+ def _md(text: str) -> str:
226
+ """Safe inside a Markdown table cell or list item."""
227
+ return text.replace("|", "\\|").replace("\n", " ").strip()
@@ -0,0 +1,196 @@
1
+ """What is stored about a site, as plain JSON for the dashboard and report.html.
2
+
3
+ The dashboard's API and the shareable report use these same shapes, so the React
4
+ components that show them work in both places (see web/src/types.ts).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from dataclasses import asdict
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from ghost_api import __version__
15
+ from ghost_api.analysis.analyzer import endpoint_keys
16
+ from ghost_api.analysis.exposure import exposures
17
+ from ghost_api.analysis.models import Endpoint
18
+ from ghost_api.analysis.schema import field_paths, top_level_fields
19
+ from ghost_api.export.confidence import confidence
20
+ from ghost_api.generator.naming import apply_plan
21
+ from ghost_api.memory.store import RunRow, Store
22
+ from ghost_api.runtime import inputs_for, is_callable, load_plan
23
+ from ghost_api.site import site_host
24
+
25
+ SAFE_NAME = re.compile(r"^[A-Za-z0-9_.-]+$")
26
+
27
+ # Code and spec files embedded in report.html (bigger files are listed, not embedded).
28
+ MAX_EMBEDDED_FILE = 300_000
29
+ EMBEDDED_KINDS = ("client", "models", "tests", "openapi")
30
+ # Left out of the shared report: request events (many, with full URLs), endpoint lists
31
+ # (the report has its own, with sensitive examples removed) and screenshots (not embedded).
32
+ SKIPPED_EVENTS = {"request", "endpoints", "screenshot"}
33
+
34
+
35
+ def display_label(ep: Endpoint, site: str) -> str:
36
+ """The label as the site sees it; `site` may be a host or host/section."""
37
+ host = site_host(site)
38
+ return ep.label if ep.host == host else f"{ep.method} {ep.host}{ep.path_template}"
39
+
40
+
41
+ def endpoint_summary(ep: Endpoint, site: str) -> dict[str, Any]:
42
+ """Everything an interface needs to show an endpoint, as plain JSON."""
43
+ host = site_host(site)
44
+ params = [
45
+ {
46
+ "name": p.name,
47
+ "type": p.type,
48
+ "location": "path",
49
+ "kind": "required",
50
+ "examples": p.examples[:3],
51
+ "sensitive": False,
52
+ }
53
+ for p in ep.path_params
54
+ ] + [
55
+ {
56
+ "name": p.name,
57
+ "type": p.type,
58
+ "location": "query",
59
+ "kind": p.kind,
60
+ "examples": p.examples[:3],
61
+ "sensitive": p.sensitive,
62
+ }
63
+ for p in ep.query_params
64
+ ]
65
+ return {
66
+ "key": ep.key,
67
+ "label": display_label(ep, site),
68
+ "method": ep.method,
69
+ "host": ep.host,
70
+ "path": ep.path_template,
71
+ "third_party": ep.host != host,
72
+ "params": params,
73
+ "samples": ep.sample_count,
74
+ "statuses": ep.statuses,
75
+ "status": ep.status,
76
+ "status_note": ep.status_note,
77
+ "replayable": ep.replayable,
78
+ "auth_type": ep.auth_type,
79
+ "required_headers": ep.required_headers,
80
+ "pagination": ep.pagination,
81
+ "confidence": confidence(ep),
82
+ "fields": top_level_fields(ep.response_schema),
83
+ "field_paths": field_paths(ep.response_schema, limit=40),
84
+ "has_request_body": ep.request_schema is not None,
85
+ }
86
+
87
+
88
+ def endpoint_details(store: Store, domain: str) -> list[dict[str, Any]]:
89
+ """Summaries plus schemas and example URLs, from the latest run that found endpoints."""
90
+ run_id = store.latest_endpoint_run(domain)
91
+ if run_id is None:
92
+ return []
93
+ details = []
94
+ endpoints = store.current_endpoints(domain)
95
+ requests = store.requests_for_domain(domain)
96
+ found = exposures(endpoints, requests)
97
+ exposed = {e.endpoint_key: e.to_dict() for e in found}
98
+ samples: dict[str, list[Any]] = {}
99
+ for key, req in zip(endpoint_keys(requests), requests, strict=False):
100
+ samples.setdefault(key, []).append(req)
101
+ allowed = store.allowed_endpoints(domain)
102
+ _, names = apply_plan(load_plan(store.load_naming_plan(domain)), endpoints, domain)
103
+ for ep in endpoints:
104
+ summary = endpoint_summary(ep, domain)
105
+ summary.update(
106
+ request_schema=ep.request_schema,
107
+ response_schema=ep.response_schema,
108
+ example_urls=ep.example_urls,
109
+ exposure=exposed.get(ep.key),
110
+ # For Try it / Get data: can Ghost API call it, and with which inputs.
111
+ callable=is_callable(ep, ep.key in allowed),
112
+ allowed=ep.key in allowed,
113
+ operation=names[ep.key].method_name, # for fetch / watch / serve / mcp
114
+ inputs=[asdict(i) for i in inputs_for(ep, samples.get(ep.key, []))],
115
+ )
116
+ details.append(summary)
117
+ return details
118
+
119
+
120
+ def run_dict(r: RunRow) -> dict[str, Any]:
121
+ return {
122
+ "id": r.id,
123
+ "domain": r.domain,
124
+ "start_url": r.start_url,
125
+ "goal": r.goal,
126
+ "status": r.status,
127
+ "started_at": r.started_at,
128
+ "finished_at": r.finished_at,
129
+ "requests": r.request_count,
130
+ "llm_calls": r.llm_calls,
131
+ "tokens": r.tokens,
132
+ }
133
+
134
+
135
+ def file_kind(name: str) -> str:
136
+ if name.startswith("test_") or name.endswith(".test.ts"):
137
+ return "tests"
138
+ if name.endswith(("_client.py", "_client.ts")):
139
+ return "client"
140
+ if name == "models.py":
141
+ return "models"
142
+ if name.endswith((".yaml", ".yml")):
143
+ return "openapi"
144
+ if name.startswith("report."):
145
+ return "report"
146
+ return "other"
147
+
148
+
149
+ def site_files(folder: Path) -> list[dict[str, Any]]:
150
+ if not folder.is_dir():
151
+ return []
152
+ return [
153
+ {"name": f.name, "size": f.stat().st_size, "kind": file_kind(f.name)}
154
+ for f in sorted(folder.iterdir())
155
+ if f.is_file()
156
+ ]
157
+
158
+
159
+ def _public_endpoint(ep: dict[str, Any]) -> dict[str, Any]:
160
+ """Drop example values of sensitive parameters before sharing."""
161
+ params = [{**p, "examples": []} if p["sensitive"] else p for p in ep["params"]]
162
+ return {**ep, "params": params}
163
+
164
+
165
+ def report_data(
166
+ store: Store, domain: str, run_id: int, folder: Path, report_md: str, generated_at: str
167
+ ) -> dict[str, Any]:
168
+ """Everything report.html shows. It is meant to be shared, so it holds no secrets:
169
+ stored requests are already redacted, sensitive examples are dropped, and request
170
+ events (full URLs) are left out."""
171
+ run = store.get_run(run_id)
172
+ if run is None:
173
+ raise ValueError(f"run {run_id} not found")
174
+ files = []
175
+ for f in site_files(folder):
176
+ if f["kind"] not in EMBEDDED_KINDS:
177
+ continue
178
+ path = folder / f["name"]
179
+ content = (
180
+ path.read_text(encoding="utf-8", errors="replace")
181
+ if f["size"] <= MAX_EMBEDDED_FILE
182
+ else None
183
+ )
184
+ files.append({**f, "content": content})
185
+ events = [e for e in store.events_for_run(run_id) if e.get("type") not in SKIPPED_EVENTS]
186
+ return {
187
+ "version": __version__,
188
+ "generated_at": generated_at,
189
+ "domain": domain,
190
+ "run": run_dict(run),
191
+ "endpoints": [_public_endpoint(e) for e in endpoint_details(store, domain)],
192
+ "events": events,
193
+ "lessons": store.lessons_for(domain, limit=20),
194
+ "files": files,
195
+ "report_md": report_md,
196
+ }
@@ -0,0 +1,88 @@
1
+ """Write rows of records as CSV, Excel or JSON (for `ghost-api fetch` and the dashboard).
2
+
3
+ Rows come from `runtime.flatten`, so nested fields are already dotted columns
4
+ ("venue.name"). Columns keep the order they first appear in.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import csv
10
+ import io
11
+ import json
12
+ from pathlib import Path
13
+ from typing import Any, Literal
14
+
15
+ from ghost_api.runtime import flatten
16
+
17
+ Format = Literal["csv", "xlsx", "json"]
18
+ FORMATS: tuple[Format, ...] = ("csv", "xlsx", "json")
19
+ MEDIA_TYPES = {
20
+ "csv": "text/csv; charset=utf-8",
21
+ "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
22
+ "json": "application/json",
23
+ }
24
+ EXCEL_MAX_CELL = 32_767 # Excel refuses longer cell text
25
+
26
+
27
+ def columns(rows: list[dict[str, Any]]) -> list[str]:
28
+ seen: dict[str, None] = {}
29
+ for row in rows:
30
+ for key in row:
31
+ seen.setdefault(key, None)
32
+ return list(seen)
33
+
34
+
35
+ def to_bytes(records: list[Any], fmt: Format) -> bytes:
36
+ """The records as a file's contents. JSON keeps the original nesting."""
37
+ if fmt == "json":
38
+ return json.dumps(records, ensure_ascii=False, indent=2).encode("utf-8")
39
+ rows = [flatten(r) for r in records]
40
+ cols = columns(rows)
41
+ if fmt == "csv":
42
+ buffer = io.StringIO()
43
+ writer = csv.DictWriter(buffer, fieldnames=cols, extrasaction="ignore")
44
+ writer.writeheader()
45
+ writer.writerows({k: _safe_text(v) for k, v in row.items()} for row in rows)
46
+ return buffer.getvalue().encode("utf-8-sig") # the BOM makes Excel read UTF-8
47
+ return _xlsx(rows, cols)
48
+
49
+
50
+ def write(records: list[Any], path: Path, fmt: Format) -> Path:
51
+ path.parent.mkdir(parents=True, exist_ok=True)
52
+ path.write_bytes(to_bytes(records, fmt))
53
+ return path
54
+
55
+
56
+ def _xlsx(rows: list[dict[str, Any]], cols: list[str]) -> bytes:
57
+ from openpyxl import Workbook
58
+ from openpyxl.styles import Font
59
+
60
+ book = Workbook()
61
+ sheet = book.active
62
+ assert sheet is not None
63
+ sheet.title = "data"
64
+ sheet.append(cols)
65
+ for cell in sheet[1]:
66
+ cell.font = Font(bold=True)
67
+ for row in rows:
68
+ sheet.append([_cell(row.get(c)) for c in cols])
69
+ sheet.freeze_panes = "A2"
70
+ for i, col in enumerate(cols, start=1):
71
+ width = max([len(col)] + [len(str(r.get(col) or "")) for r in rows[:200]])
72
+ sheet.column_dimensions[sheet.cell(1, i).column_letter].width = min(max(width, 8), 60)
73
+ buffer = io.BytesIO()
74
+ book.save(buffer)
75
+ return buffer.getvalue()
76
+
77
+
78
+ def _cell(value: Any) -> Any:
79
+ safe = _safe_text(value)
80
+ return safe[:EXCEL_MAX_CELL] if isinstance(safe, str) else safe
81
+
82
+
83
+ def _safe_text(value: Any) -> Any:
84
+ """Site data must never become a spreadsheet formula ("=HYPERLINK(...)")."""
85
+ if value is None or isinstance(value, bool | int | float):
86
+ return value
87
+ text = str(value)
88
+ return "'" + text if text[:1] in ("=", "+", "-", "@", "\t", "\r") else text