ghost-api-cli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ghost_api/__init__.py +3 -0
- ghost_api/__main__.py +5 -0
- ghost_api/adopt.py +173 -0
- ghost_api/agent/__init__.py +0 -0
- ghost_api/agent/orchestrator.py +413 -0
- ghost_api/agent/prompts.py +149 -0
- ghost_api/agent/tools.py +84 -0
- ghost_api/analysis/__init__.py +0 -0
- ghost_api/analysis/analyzer.py +130 -0
- ghost_api/analysis/detectors.py +188 -0
- ghost_api/analysis/exposure.py +80 -0
- ghost_api/analysis/graphql.py +67 -0
- ghost_api/analysis/models.py +64 -0
- ghost_api/analysis/schema.py +109 -0
- ghost_api/analysis/url_grouping.py +217 -0
- ghost_api/api.py +264 -0
- ghost_api/audit.py +78 -0
- ghost_api/browser/__init__.py +0 -0
- ghost_api/browser/auth_state.py +172 -0
- ghost_api/browser/driver.py +273 -0
- ghost_api/browser/egress.py +183 -0
- ghost_api/browser/gate.py +127 -0
- ghost_api/browser/install.py +28 -0
- ghost_api/browser/snapshot.py +221 -0
- ghost_api/capture/__init__.py +0 -0
- ghost_api/capture/filters.py +255 -0
- ghost_api/capture/har.py +148 -0
- ghost_api/capture/models.py +41 -0
- ghost_api/capture/personal.py +188 -0
- ghost_api/capture/recorder.py +181 -0
- ghost_api/cli/__init__.py +44 -0
- ghost_api/cli/_common.py +219 -0
- ghost_api/cli/client.py +487 -0
- ghost_api/cli/data.py +492 -0
- ghost_api/cli/explore.py +379 -0
- ghost_api/cli/manage.py +437 -0
- ghost_api/config.py +132 -0
- ghost_api/drift.py +344 -0
- ghost_api/drive.py +211 -0
- ghost_api/errors.py +106 -0
- ghost_api/events.py +213 -0
- ghost_api/exitcodes.py +21 -0
- ghost_api/export/__init__.py +71 -0
- ghost_api/export/confidence.py +45 -0
- ghost_api/export/html.py +41 -0
- ghost_api/export/openapi.py +201 -0
- ghost_api/export/report.py +227 -0
- ghost_api/export/site_data.py +196 -0
- ghost_api/export/tabular.py +88 -0
- ghost_api/export/viewer/report.html +286 -0
- ghost_api/generator/__init__.py +221 -0
- ghost_api/generator/cassettes.py +80 -0
- ghost_api/generator/models_gen.py +144 -0
- ghost_api/generator/naming.py +174 -0
- ghost_api/generator/package.py +197 -0
- ghost_api/generator/python_gen.py +172 -0
- ghost_api/generator/spec.py +486 -0
- ghost_api/generator/templates/client.py.j2 +518 -0
- ghost_api/generator/templates/client.test.ts.j2 +106 -0
- ghost_api/generator/templates/client.ts.j2 +339 -0
- ghost_api/generator/templates/models.py.j2 +45 -0
- ghost_api/generator/templates/package_readme.md.j2 +111 -0
- ghost_api/generator/templates/pyproject.toml.j2 +22 -0
- ghost_api/generator/templates/test_client.py.j2 +105 -0
- ghost_api/generator/ts_gen.py +232 -0
- ghost_api/integrations/__init__.py +0 -0
- ghost_api/integrations/claude_code/commands/ghost-data.md +10 -0
- ghost_api/integrations/claude_code/commands/ghost-diff.md +10 -0
- ghost_api/integrations/claude_code/commands/ghost-explore.md +10 -0
- ghost_api/integrations/claude_code/skills/ghost-api/SKILL.md +46 -0
- ghost_api/integrations/setup.py +84 -0
- ghost_api/llm/__init__.py +0 -0
- ghost_api/llm/doctor.py +60 -0
- ghost_api/llm/gateway.py +476 -0
- ghost_api/llm/providers.py +124 -0
- ghost_api/memory/__init__.py +0 -0
- ghost_api/memory/knowledge.py +157 -0
- ghost_api/memory/store.py +883 -0
- ghost_api/net.py +235 -0
- ghost_api/netguard.py +203 -0
- ghost_api/pipeline.py +745 -0
- ghost_api/probe/__init__.py +0 -0
- ghost_api/probe/prober.py +311 -0
- ghost_api/probe/replay.py +313 -0
- ghost_api/project.py +186 -0
- ghost_api/py.typed +0 -0
- ghost_api/runtime.py +397 -0
- ghost_api/serve/__init__.py +101 -0
- ghost_api/serve/explorer_mcp.py +305 -0
- ghost_api/serve/mcp.py +121 -0
- ghost_api/serve/mock.py +130 -0
- ghost_api/serve/rest.py +132 -0
- ghost_api/site.py +84 -0
- ghost_api/specdiff.py +122 -0
- ghost_api/ui/__init__.py +0 -0
- ghost_api/ui/keys.py +134 -0
- ghost_api/ui/repl.py +453 -0
- ghost_api/ui/terminal.py +573 -0
- ghost_api/verify/__init__.py +0 -0
- ghost_api/verify/classify.py +84 -0
- ghost_api/verify/repair.py +40 -0
- ghost_api/verify/runner.py +110 -0
- ghost_api/verify/verifier.py +291 -0
- ghost_api/watch.py +313 -0
- ghost_api/web/__init__.py +1 -0
- ghost_api/web/server.py +690 -0
- ghost_api/web/static/assets/Endpoints-zPnjlcM8.js +46 -0
- ghost_api/web/static/assets/Run-D85zWHey.js +91 -0
- ghost_api/web/static/assets/Settings-D0aWHFAt.js +11 -0
- ghost_api/web/static/assets/Site-a88shEmP.js +71 -0
- ghost_api/web/static/assets/Watches-CqZWIa9G.js +16 -0
- ghost_api/web/static/assets/circle-x-BcTE_1tr.js +11 -0
- ghost_api/web/static/assets/index-Ds2e5B9f.js +141 -0
- ghost_api/web/static/assets/index-xQpyIBbG.css +1 -0
- ghost_api/web/static/assets/minus-C2xqP1eQ.js +6 -0
- ghost_api/web/static/assets/refresh-cw-CxdR_8Oi.js +6 -0
- ghost_api/web/static/favicon.svg +1 -0
- ghost_api/web/static/index.html +26 -0
- ghost_api/workflows.py +203 -0
- ghost_api_cli-0.2.0.dist-info/METADATA +1019 -0
- ghost_api_cli-0.2.0.dist-info/RECORD +125 -0
- ghost_api_cli-0.2.0.dist-info/WHEEL +5 -0
- ghost_api_cli-0.2.0.dist-info/entry_points.txt +2 -0
- ghost_api_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- ghost_api_cli-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Turn a generated client into a package you can install, publish or vendor.
|
|
2
|
+
|
|
3
|
+
Next to the loose files (which you can copy into a project as they are), each generate
|
|
4
|
+
writes:
|
|
5
|
+
|
|
6
|
+
package/python/ pyproject.toml, README.md, src/<module>/ (client, models, py.typed)
|
|
7
|
+
package/typescript/ package.json, tsconfig.json, README.md, src/index.ts
|
|
8
|
+
|
|
9
|
+
`pip install output/<site>/package/python` and `npm install output/<site>/package/typescript`
|
|
10
|
+
then work, and `from <module> import <Client>` is the same import as with the loose files.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import ast
|
|
16
|
+
import json
|
|
17
|
+
import shlex
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
from urllib.parse import urlencode
|
|
21
|
+
|
|
22
|
+
from jinja2 import Environment, FileSystemLoader, StrictUndefined
|
|
23
|
+
|
|
24
|
+
from ghost_api import __version__
|
|
25
|
+
from ghost_api.generator.spec import ClientSpec, MethodSpec
|
|
26
|
+
from ghost_api.generator.ts_gen import camel
|
|
27
|
+
|
|
28
|
+
TEMPLATES = Path(__file__).parent / "templates"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def dist_name(module: str) -> str:
|
|
32
|
+
return module.replace("_", "-")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def example_value(literal: str | None) -> Any:
|
|
36
|
+
"""'"dune"' -> 'dune', '12' -> 12: a generated test's example as a plain value."""
|
|
37
|
+
if literal is None:
|
|
38
|
+
return None
|
|
39
|
+
try:
|
|
40
|
+
return ast.literal_eval(literal)
|
|
41
|
+
except (ValueError, SyntaxError):
|
|
42
|
+
return literal
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def curl_example(spec: ClientSpec, method: MethodSpec) -> str:
|
|
46
|
+
"""A copy-paste curl command for a method, using values seen while exploring."""
|
|
47
|
+
base = f"https://{method.host}" if method.host else spec.base_url
|
|
48
|
+
path = method.path
|
|
49
|
+
for p in method.by_location("path"):
|
|
50
|
+
path = path.replace("{" + p.name + "}", str(example_value(p.example) or f"<{p.name}>"))
|
|
51
|
+
query = {
|
|
52
|
+
p.wire: example_value(p.example)
|
|
53
|
+
for p in method.by_location("query")
|
|
54
|
+
if p.required and p.example is not None
|
|
55
|
+
}
|
|
56
|
+
creds = {c.arg: c for c in spec.credentials if c.arg in method.credentials}
|
|
57
|
+
for cred in creds.values():
|
|
58
|
+
if cred.location == "query":
|
|
59
|
+
query[cred.wire] = f"${cred.env}"
|
|
60
|
+
url = base + path + (f"?{urlencode(query, safe='$')}" if query else "")
|
|
61
|
+
parts = ["curl"]
|
|
62
|
+
if method.http_method != "GET":
|
|
63
|
+
parts += ["-X", method.http_method]
|
|
64
|
+
parts.append(shlex.quote(url) if "$" not in url else f'"{url}"')
|
|
65
|
+
for name, value in method.headers.items():
|
|
66
|
+
parts += ["-H", shlex.quote(f"{name}: {value}")]
|
|
67
|
+
for cred in creds.values():
|
|
68
|
+
if cred.location == "header":
|
|
69
|
+
parts += ["-H", f'"{cred.wire}: ${cred.env}"']
|
|
70
|
+
body = {
|
|
71
|
+
p.wire: example_value(p.example)
|
|
72
|
+
for p in method.by_location("body")
|
|
73
|
+
if p.example is not None
|
|
74
|
+
}
|
|
75
|
+
if method.by_location("body"):
|
|
76
|
+
parts += ["-H", shlex.quote("Content-Type: application/json")]
|
|
77
|
+
parts += ["-d", shlex.quote(json.dumps(body))]
|
|
78
|
+
return " ".join(parts)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _env() -> Environment:
|
|
82
|
+
env = Environment(
|
|
83
|
+
loader=FileSystemLoader(TEMPLATES),
|
|
84
|
+
trim_blocks=True,
|
|
85
|
+
lstrip_blocks=True,
|
|
86
|
+
keep_trailing_newline=True,
|
|
87
|
+
undefined=StrictUndefined,
|
|
88
|
+
autoescape=False, # Markdown and config files
|
|
89
|
+
)
|
|
90
|
+
env.filters.update(camel=camel, json=json.dumps)
|
|
91
|
+
return env
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def readme(spec: ClientSpec, module: str, lang: str) -> str:
|
|
95
|
+
methods = [
|
|
96
|
+
{
|
|
97
|
+
"method": m,
|
|
98
|
+
"curl": curl_example(spec, m),
|
|
99
|
+
"py_args": ", ".join(f"{p.name}={p.example}" for p in m.required if p.example),
|
|
100
|
+
"ts_args": ", ".join(
|
|
101
|
+
json.dumps(example_value(p.example)) for p in m.required if p.example
|
|
102
|
+
),
|
|
103
|
+
"ts_name": camel(m.name),
|
|
104
|
+
"ts_iter": "iter" + camel(m.name)[:1].upper() + camel(m.name)[1:],
|
|
105
|
+
}
|
|
106
|
+
for m in spec.methods
|
|
107
|
+
]
|
|
108
|
+
return (
|
|
109
|
+
_env()
|
|
110
|
+
.get_template("package_readme.md.j2")
|
|
111
|
+
.render(
|
|
112
|
+
spec=spec,
|
|
113
|
+
module=module,
|
|
114
|
+
dist=dist_name(module),
|
|
115
|
+
lang=lang,
|
|
116
|
+
methods=methods,
|
|
117
|
+
version=__version__,
|
|
118
|
+
client_version=spec.version,
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def python_package(spec: ClientSpec, module: str, files: dict[str, str], out_dir: Path) -> Path:
|
|
124
|
+
"""`files`: the rendered loose files ({module}.py, models.py, test_{module}.py)."""
|
|
125
|
+
root = out_dir / "package" / "python"
|
|
126
|
+
src = root / "src" / module
|
|
127
|
+
src.mkdir(parents=True, exist_ok=True)
|
|
128
|
+
(root / "tests").mkdir(exist_ok=True)
|
|
129
|
+
clients = [spec.class_name, spec.async_class, spec.error_class, *spec.error_classes.values()]
|
|
130
|
+
doc = f"{spec.class_name}: a typed client for {spec.domain} (generated by Ghost API)."
|
|
131
|
+
lines = [f'"""{doc}"""', ""]
|
|
132
|
+
lines += ["from .client import (", *(f" {name}," for name in clients), ")"]
|
|
133
|
+
if spec.model_names:
|
|
134
|
+
lines += ["from .models import (", *(f" {name}," for name in spec.model_names), ")"]
|
|
135
|
+
lines += ["", f"__all__ = {[*clients, *spec.model_names]!r}", ""]
|
|
136
|
+
init = "\n".join(lines)
|
|
137
|
+
contents = {
|
|
138
|
+
src / "__init__.py": init,
|
|
139
|
+
src / "client.py": files[f"{module}.py"],
|
|
140
|
+
src / "models.py": files["models.py"],
|
|
141
|
+
src / "py.typed": "",
|
|
142
|
+
root / "tests" / f"test_{module}.py": files[f"test_{module}.py"],
|
|
143
|
+
root / "pyproject.toml": _env()
|
|
144
|
+
.get_template("pyproject.toml.j2")
|
|
145
|
+
.render(spec=spec, module=module, dist=dist_name(module), version=spec.version),
|
|
146
|
+
root / "README.md": readme(spec, module, "python"),
|
|
147
|
+
}
|
|
148
|
+
if spec.changelog:
|
|
149
|
+
contents[root / "CHANGELOG.md"] = spec.changelog
|
|
150
|
+
for path, text in contents.items():
|
|
151
|
+
path.write_text(text, encoding="utf-8", newline="\n")
|
|
152
|
+
return root
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def typescript_package(spec: ClientSpec, module: str, client_source: str, out_dir: Path) -> Path:
|
|
156
|
+
root = out_dir / "package" / "typescript"
|
|
157
|
+
(root / "src").mkdir(parents=True, exist_ok=True)
|
|
158
|
+
package_json = {
|
|
159
|
+
"name": dist_name(module),
|
|
160
|
+
"version": spec.version,
|
|
161
|
+
"description": f"Unofficial typed client for {spec.domain} (generated by Ghost API)",
|
|
162
|
+
"type": "module",
|
|
163
|
+
"main": "./dist/index.js",
|
|
164
|
+
"types": "./dist/index.d.ts",
|
|
165
|
+
"exports": {".": {"types": "./dist/index.d.ts", "default": "./dist/index.js"}},
|
|
166
|
+
"files": ["dist", "README.md"],
|
|
167
|
+
"engines": {"node": ">=18"},
|
|
168
|
+
"scripts": {"build": "tsc -p .", "prepare": "tsc -p ."},
|
|
169
|
+
"devDependencies": {"typescript": "^5.4.0"},
|
|
170
|
+
"keywords": [spec.domain, "api-client", "ghost-api"],
|
|
171
|
+
"license": "UNLICENSED",
|
|
172
|
+
}
|
|
173
|
+
tsconfig = {
|
|
174
|
+
"compilerOptions": {
|
|
175
|
+
"target": "es2022",
|
|
176
|
+
"module": "es2022",
|
|
177
|
+
"moduleResolution": "bundler",
|
|
178
|
+
"lib": ["es2022", "dom"],
|
|
179
|
+
"strict": True,
|
|
180
|
+
"declaration": True,
|
|
181
|
+
"outDir": "dist",
|
|
182
|
+
"rootDir": "src",
|
|
183
|
+
"skipLibCheck": True,
|
|
184
|
+
},
|
|
185
|
+
"include": ["src"],
|
|
186
|
+
}
|
|
187
|
+
contents = {
|
|
188
|
+
root / "src" / "index.ts": client_source,
|
|
189
|
+
root / "package.json": json.dumps(package_json, indent=2) + "\n",
|
|
190
|
+
root / "tsconfig.json": json.dumps(tsconfig, indent=2) + "\n",
|
|
191
|
+
root / "README.md": readme(spec, module, "typescript"),
|
|
192
|
+
}
|
|
193
|
+
if spec.changelog:
|
|
194
|
+
contents[root / "CHANGELOG.md"] = spec.changelog
|
|
195
|
+
for path, text in contents.items():
|
|
196
|
+
path.write_text(text, encoding="utf-8", newline="\n")
|
|
197
|
+
return root
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Render a ClientSpec into Python files, then check them (Stage 6).
|
|
2
|
+
|
|
3
|
+
Output, in output/<domain>/:
|
|
4
|
+
<name>_client.py the client class
|
|
5
|
+
models.py pydantic models
|
|
6
|
+
test_<name>.py live tests (run in Stage 7)
|
|
7
|
+
|
|
8
|
+
Checks before anything is written:
|
|
9
|
+
- every file compiles
|
|
10
|
+
- no secret-looking value and no [REDACTED] placeholder is present (NFR-16)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from datetime import UTC, datetime
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from jinja2 import Environment, FileSystemLoader, StrictUndefined
|
|
25
|
+
|
|
26
|
+
from ghost_api import __version__
|
|
27
|
+
from ghost_api.capture.filters import REDACTED, redact_text
|
|
28
|
+
from ghost_api.config import USER_AGENT_SUFFIX
|
|
29
|
+
from ghost_api.generator.naming import snake
|
|
30
|
+
from ghost_api.generator.spec import ClientSpec, CredentialSpec, MethodSpec
|
|
31
|
+
|
|
32
|
+
TEMPLATES = Path(__file__).parent / "templates"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class GenerationError(Exception):
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class GeneratedClient:
|
|
41
|
+
directory: Path
|
|
42
|
+
module: str # import name of the client module
|
|
43
|
+
client_path: Path
|
|
44
|
+
models_path: Path
|
|
45
|
+
tests_path: Path
|
|
46
|
+
formatted: bool
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def module_name(spec: ClientSpec) -> str:
|
|
50
|
+
return snake(spec.class_name.removesuffix("Client")) + "_client"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def render(spec: ClientSpec, packaged: bool = False) -> dict[str, str]:
|
|
54
|
+
"""Render all files to strings: {filename: source}. `packaged`: for package/python
|
|
55
|
+
(the client imports its models only as `.models`)."""
|
|
56
|
+
env = Environment(
|
|
57
|
+
loader=FileSystemLoader(TEMPLATES),
|
|
58
|
+
trim_blocks=True,
|
|
59
|
+
lstrip_blocks=True,
|
|
60
|
+
keep_trailing_newline=True,
|
|
61
|
+
undefined=StrictUndefined,
|
|
62
|
+
autoescape=False, # generating Python, not HTML
|
|
63
|
+
)
|
|
64
|
+
env.filters["pyrepr"] = repr
|
|
65
|
+
env.filters["doc"] = _doc
|
|
66
|
+
env.filters["path_expr"] = _path_expr
|
|
67
|
+
env.filters["creds"] = _creds
|
|
68
|
+
env.filters["env_map"] = lambda creds: {c.arg: c.env for c in creds}
|
|
69
|
+
|
|
70
|
+
module = module_name(spec)
|
|
71
|
+
model_types = " ".join(f.type for m in spec.models for f in m.fields)
|
|
72
|
+
context: dict[str, Any] = {
|
|
73
|
+
"spec": spec,
|
|
74
|
+
"module": module,
|
|
75
|
+
"version": __version__,
|
|
76
|
+
"generated_on": datetime.now(UTC).strftime("%Y-%m-%d"),
|
|
77
|
+
"user_agent": f"{module}/{__version__} (generated by {USER_AGENT_SUFFIX})",
|
|
78
|
+
"uses_any": bool(re.search(r"\bAny\b", model_types)),
|
|
79
|
+
"uses_field": any(f.alias for m in spec.models for f in m.fields),
|
|
80
|
+
"packaged": packaged,
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
f"{module}.py": env.get_template("client.py.j2").render(context),
|
|
84
|
+
"models.py": env.get_template("models.py.j2").render(context),
|
|
85
|
+
f"test_{module}.py": env.get_template("test_client.py.j2").render(context),
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def check(files: dict[str, str]) -> None:
|
|
90
|
+
for name, source in files.items():
|
|
91
|
+
try:
|
|
92
|
+
compile(source, name, "exec")
|
|
93
|
+
except SyntaxError as exc:
|
|
94
|
+
raise GenerationError(f"{name} does not compile: {exc}") from exc
|
|
95
|
+
if REDACTED in source:
|
|
96
|
+
raise GenerationError(f"{name} contains a redacted placeholder")
|
|
97
|
+
if redact_text(source) != source:
|
|
98
|
+
raise GenerationError(f"{name} contains a value that looks like a secret")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def write(spec: ClientSpec, out_dir: Path) -> GeneratedClient:
|
|
102
|
+
files = render(spec)
|
|
103
|
+
check(files)
|
|
104
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
105
|
+
for name, source in files.items():
|
|
106
|
+
(out_dir / name).write_text(source, encoding="utf-8", newline="\n")
|
|
107
|
+
formatted = _format(out_dir, list(files))
|
|
108
|
+
if formatted: # formatting must not break anything
|
|
109
|
+
check({n: (out_dir / n).read_text(encoding="utf-8") for n in files})
|
|
110
|
+
module = module_name(spec)
|
|
111
|
+
from ghost_api.generator.package import python_package # ts_gen imports this module
|
|
112
|
+
|
|
113
|
+
packaged = render(spec, packaged=True)
|
|
114
|
+
check(packaged)
|
|
115
|
+
root = python_package(spec, module, packaged, out_dir)
|
|
116
|
+
_format(root / "src" / module, ["client.py", "models.py"])
|
|
117
|
+
return GeneratedClient(
|
|
118
|
+
directory=out_dir,
|
|
119
|
+
module=module,
|
|
120
|
+
client_path=out_dir / f"{module}.py",
|
|
121
|
+
models_path=out_dir / "models.py",
|
|
122
|
+
tests_path=out_dir / f"test_{module}.py",
|
|
123
|
+
formatted=formatted,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _format(directory: Path, names: list[str]) -> bool:
|
|
128
|
+
"""Format with ruff if it's installed; generated code is readable without it too."""
|
|
129
|
+
cmd = [sys.executable, "-m", "ruff"] if _has_module("ruff") else None
|
|
130
|
+
if cmd is None and shutil.which("ruff"):
|
|
131
|
+
cmd = ["ruff"]
|
|
132
|
+
if cmd is None:
|
|
133
|
+
return False
|
|
134
|
+
try:
|
|
135
|
+
subprocess.run(
|
|
136
|
+
[*cmd, "format", "--quiet", "--line-length", "100", *names],
|
|
137
|
+
cwd=directory,
|
|
138
|
+
check=True,
|
|
139
|
+
capture_output=True,
|
|
140
|
+
timeout=60,
|
|
141
|
+
)
|
|
142
|
+
except (subprocess.SubprocessError, OSError):
|
|
143
|
+
return False
|
|
144
|
+
return True
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _has_module(name: str) -> bool:
|
|
148
|
+
import importlib.util
|
|
149
|
+
|
|
150
|
+
return importlib.util.find_spec(name) is not None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# --- template filters ------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _doc(text: str) -> str:
|
|
157
|
+
"""Safe inside a triple-quoted docstring."""
|
|
158
|
+
return text.replace("\\", "/").replace('"""', "'''").strip()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _path_expr(method: MethodSpec) -> str:
|
|
162
|
+
"""'/api/books/{id}' -> f"/api/books/{quote(str(id), safe='')}"."""
|
|
163
|
+
if "{" not in method.path:
|
|
164
|
+
return repr(method.path)
|
|
165
|
+
escaped = method.path.replace("'", "\\'")
|
|
166
|
+
expr = re.sub(r"\{(\w+)\}", lambda m: "{quote(str(" + m.group(1) + "), safe='')}", escaped)
|
|
167
|
+
return 'f"' + expr.replace('"', '\\"') + '"'
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _creds(method: MethodSpec, spec: ClientSpec, location: str) -> list[CredentialSpec]:
|
|
171
|
+
wanted = set(method.credentials)
|
|
172
|
+
return [c for c in spec.credentials if c.arg in wanted and location in ("any", c.location)]
|