lekha-poth-cli 2.1.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.
- lekha_poth_cli/__init__.py +6 -0
- lekha_poth_cli/__main__.py +4 -0
- lekha_poth_cli/app.py +287 -0
- lekha_poth_cli/client.py +149 -0
- lekha_poth_cli/config.py +145 -0
- lekha_poth_cli/endpoints.json +5821 -0
- lekha_poth_cli/errors.py +75 -0
- lekha_poth_cli/media.py +94 -0
- lekha_poth_cli/output.py +35 -0
- lekha_poth_cli/register.py +326 -0
- lekha_poth_cli/spec.py +58 -0
- lekha_poth_cli-2.1.0.dist-info/METADATA +109 -0
- lekha_poth_cli-2.1.0.dist-info/RECORD +15 -0
- lekha_poth_cli-2.1.0.dist-info/WHEEL +4 -0
- lekha_poth_cli-2.1.0.dist-info/entry_points.txt +3 -0
lekha_poth_cli/app.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""Typer application: `lp`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from lekha_poth_cli import __version__
|
|
13
|
+
from lekha_poth_cli.client import ApiClient
|
|
14
|
+
from lekha_poth_cli.config import (
|
|
15
|
+
LpConfig,
|
|
16
|
+
OutputFormat,
|
|
17
|
+
apply_overrides,
|
|
18
|
+
default_config_path,
|
|
19
|
+
load_config,
|
|
20
|
+
save_config,
|
|
21
|
+
stamp_created,
|
|
22
|
+
)
|
|
23
|
+
from lekha_poth_cli.errors import ApiError, ConfigError, LpError, NetworkError
|
|
24
|
+
from lekha_poth_cli.media import upload_file
|
|
25
|
+
from lekha_poth_cli.output import emit_data, emit_response, err_console
|
|
26
|
+
from lekha_poth_cli.register import CTX_CONFIG, CTX_OUTPUT, load_endpoints, register_all
|
|
27
|
+
|
|
28
|
+
app = typer.Typer(
|
|
29
|
+
name="lp",
|
|
30
|
+
help="Lekha Poth production CLI — every public and admin HTTP endpoint.",
|
|
31
|
+
no_args_is_help=True,
|
|
32
|
+
pretty_exceptions_show_locals=False,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
config_app = typer.Typer(help="Persistent configuration (~/.config/lekha-poth/config.json).")
|
|
36
|
+
app.add_typer(config_app, name="config")
|
|
37
|
+
|
|
38
|
+
api_app = typer.Typer(help="Raw HTTP escape hatch.")
|
|
39
|
+
app.add_typer(api_app, name="api")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _version_callback(value: bool) -> None:
|
|
43
|
+
if value:
|
|
44
|
+
typer.echo(__version__)
|
|
45
|
+
raise typer.Exit()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@app.callback()
|
|
49
|
+
def _root(
|
|
50
|
+
ctx: typer.Context,
|
|
51
|
+
api_base: Optional[str] = typer.Option(None, "--api-base", envvar="LEKHA_POTH_API_BASE"),
|
|
52
|
+
api_key: Optional[str] = typer.Option(None, "--api-key", envvar="LEKHA_POTH_API_KEY"),
|
|
53
|
+
timeout: Optional[float] = typer.Option(None, "--timeout"),
|
|
54
|
+
output: Optional[OutputFormat] = typer.Option(None, "--output", "-o"),
|
|
55
|
+
version: bool = typer.Option(False, "--version", callback=_version_callback, is_eager=True),
|
|
56
|
+
) -> None:
|
|
57
|
+
cfg = apply_overrides(load_config(), api_base=api_base, api_key=api_key, timeout=timeout, output=output)
|
|
58
|
+
ctx.ensure_object(dict)
|
|
59
|
+
ctx.obj[CTX_CONFIG] = cfg
|
|
60
|
+
ctx.obj[CTX_OUTPUT] = output or cfg.output
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@config_app.command("show")
|
|
64
|
+
def config_show(ctx: typer.Context, reveal: bool = typer.Option(False, "--reveal")) -> None:
|
|
65
|
+
cfg: LpConfig = ctx.obj[CTX_CONFIG]
|
|
66
|
+
data = cfg.model_dump() if reveal else cfg.masked()
|
|
67
|
+
data["config_path"] = str(default_config_path())
|
|
68
|
+
emit_data(data, ctx.obj[CTX_OUTPUT])
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@config_app.command("path")
|
|
72
|
+
def config_path() -> None:
|
|
73
|
+
typer.echo(str(default_config_path()))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@config_app.command("set")
|
|
77
|
+
def config_set(
|
|
78
|
+
ctx: typer.Context,
|
|
79
|
+
api_base: Optional[str] = typer.Option(None, "--api-base"),
|
|
80
|
+
site_url: Optional[str] = typer.Option(None, "--site-url"),
|
|
81
|
+
api_key: Optional[str] = typer.Option(None, "--api-key"),
|
|
82
|
+
key_name: Optional[str] = typer.Option(None, "--key-name"),
|
|
83
|
+
access_token: Optional[str] = typer.Option(None, "--access-token"),
|
|
84
|
+
refresh_token: Optional[str] = typer.Option(None, "--refresh-token"),
|
|
85
|
+
timeout: Optional[float] = typer.Option(None, "--timeout"),
|
|
86
|
+
user_agent: Optional[str] = typer.Option(None, "--user-agent"),
|
|
87
|
+
) -> None:
|
|
88
|
+
"""Save connection credentials. Does not wipe output defaults."""
|
|
89
|
+
cfg = load_config()
|
|
90
|
+
data = cfg.model_dump()
|
|
91
|
+
mapping = {
|
|
92
|
+
"api_base": api_base,
|
|
93
|
+
"site_url": site_url,
|
|
94
|
+
"api_key": api_key,
|
|
95
|
+
"key_name": key_name,
|
|
96
|
+
"access_token": access_token,
|
|
97
|
+
"refresh_token": refresh_token,
|
|
98
|
+
"timeout": timeout,
|
|
99
|
+
"user_agent": user_agent,
|
|
100
|
+
}
|
|
101
|
+
changed = False
|
|
102
|
+
for key, value in mapping.items():
|
|
103
|
+
if value is not None:
|
|
104
|
+
data[key] = value
|
|
105
|
+
changed = True
|
|
106
|
+
if not changed:
|
|
107
|
+
raise ConfigError("Pass at least one flag to `lp config set`.")
|
|
108
|
+
saved = stamp_created(LpConfig.model_validate(data))
|
|
109
|
+
path = save_config(saved)
|
|
110
|
+
emit_data({"ok": True, "path": str(path), **saved.masked()}, ctx.obj[CTX_OUTPUT])
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@config_app.command("set-defaults")
|
|
114
|
+
def config_set_defaults(
|
|
115
|
+
ctx: typer.Context,
|
|
116
|
+
output: Optional[OutputFormat] = typer.Option(None, "--output"),
|
|
117
|
+
default_size: Optional[int] = typer.Option(None, "--default-size", min=1, max=100),
|
|
118
|
+
timeout: Optional[float] = typer.Option(None, "--timeout"),
|
|
119
|
+
) -> None:
|
|
120
|
+
"""Save preferences without overwriting API keys."""
|
|
121
|
+
cfg = load_config()
|
|
122
|
+
data = cfg.model_dump()
|
|
123
|
+
if output is not None:
|
|
124
|
+
data["output"] = output
|
|
125
|
+
if default_size is not None:
|
|
126
|
+
data["default_size"] = default_size
|
|
127
|
+
if timeout is not None:
|
|
128
|
+
data["timeout"] = timeout
|
|
129
|
+
saved = LpConfig.model_validate(data)
|
|
130
|
+
path = save_config(saved)
|
|
131
|
+
emit_data({"ok": True, "path": str(path), **saved.masked()}, ctx.obj[CTX_OUTPUT])
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@config_app.command("clear-tokens")
|
|
135
|
+
def config_clear_tokens(ctx: typer.Context) -> None:
|
|
136
|
+
cfg = load_config()
|
|
137
|
+
saved = cfg.model_copy(update={"access_token": None, "refresh_token": None})
|
|
138
|
+
save_config(saved)
|
|
139
|
+
emit_data({"ok": True}, ctx.obj[CTX_OUTPUT])
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@app.command("status")
|
|
143
|
+
def status_cmd(ctx: typer.Context) -> None:
|
|
144
|
+
"""GET /health + /ready + optional /me."""
|
|
145
|
+
cfg: LpConfig = ctx.obj[CTX_CONFIG]
|
|
146
|
+
out: dict = {}
|
|
147
|
+
with ApiClient(cfg) as client:
|
|
148
|
+
try:
|
|
149
|
+
out["health"] = client.request("GET", "/health", mount="root").json()
|
|
150
|
+
except (ApiError, NetworkError) as exc:
|
|
151
|
+
out["health"] = {"error": exc.format()}
|
|
152
|
+
try:
|
|
153
|
+
out["ready"] = client.request("GET", "/ready", mount="root").json()
|
|
154
|
+
except (ApiError, NetworkError) as exc:
|
|
155
|
+
out["ready"] = {"error": exc.format()}
|
|
156
|
+
if cfg.api_key or cfg.access_token:
|
|
157
|
+
try:
|
|
158
|
+
out["me"] = client.request("GET", "/me", mount="v1", auth_required=True).json()
|
|
159
|
+
except (ApiError, NetworkError) as exc:
|
|
160
|
+
out["me"] = {"error": exc.format()}
|
|
161
|
+
else:
|
|
162
|
+
out["me"] = None
|
|
163
|
+
emit_data(out, ctx.obj[CTX_OUTPUT])
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@app.command("whoami")
|
|
167
|
+
def whoami_cmd(ctx: typer.Context) -> None:
|
|
168
|
+
cfg: LpConfig = ctx.obj[CTX_CONFIG]
|
|
169
|
+
with ApiClient(cfg) as client:
|
|
170
|
+
emit_response(client.request("GET", "/me", mount="v1", auth_required=True), ctx.obj[CTX_OUTPUT])
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@app.command("endpoints")
|
|
174
|
+
def endpoints_cmd(
|
|
175
|
+
ctx: typer.Context,
|
|
176
|
+
deprecated: bool = typer.Option(False, "--deprecated", help="Only deprecated aliases"),
|
|
177
|
+
admin: bool = typer.Option(False, "--admin"),
|
|
178
|
+
) -> None:
|
|
179
|
+
"""List every bound HTTP route."""
|
|
180
|
+
rows = []
|
|
181
|
+
for ep in load_endpoints():
|
|
182
|
+
if deprecated and not ep.deprecated:
|
|
183
|
+
continue
|
|
184
|
+
if admin and not (ep.path.startswith("/admin") or ep.group[:1] == ["admin"]):
|
|
185
|
+
continue
|
|
186
|
+
rows.append(
|
|
187
|
+
{
|
|
188
|
+
"id": ep.id,
|
|
189
|
+
"cli": " ".join(["lp", *ep.group, ep.name]),
|
|
190
|
+
"method": ep.method,
|
|
191
|
+
"path": ep.path,
|
|
192
|
+
"deprecated": ep.deprecated,
|
|
193
|
+
"auth": ep.auth,
|
|
194
|
+
}
|
|
195
|
+
)
|
|
196
|
+
emit_data({"count": len(rows), "endpoints": rows}, ctx.obj[CTX_OUTPUT])
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@api_app.command("request")
|
|
200
|
+
def api_request(
|
|
201
|
+
ctx: typer.Context,
|
|
202
|
+
method: str = typer.Argument(...),
|
|
203
|
+
path: str = typer.Argument(..., help="Path under /api/v1, or absolute URL. Use --root for /health."),
|
|
204
|
+
root: bool = typer.Option(False, "--root", help="Call API origin (health/ready), not /api/v1."),
|
|
205
|
+
param: list[str] = typer.Option([], "--param", help="query key=value (repeatable)"),
|
|
206
|
+
header: list[str] = typer.Option([], "--header", help="Header Name: value"),
|
|
207
|
+
json_body: Optional[str] = typer.Option(None, "--json-body"),
|
|
208
|
+
body_file: Optional[Path] = typer.Option(None, "--body-file"),
|
|
209
|
+
) -> None:
|
|
210
|
+
"""Call any path. Example: lp api request GET /items --param size=5"""
|
|
211
|
+
cfg: LpConfig = ctx.obj[CTX_CONFIG]
|
|
212
|
+
params = {}
|
|
213
|
+
for item in param:
|
|
214
|
+
if "=" not in item:
|
|
215
|
+
raise LpError(f"Bad --param {item!r}; expected key=value")
|
|
216
|
+
k, v = item.split("=", 1)
|
|
217
|
+
params[k] = v
|
|
218
|
+
headers = {}
|
|
219
|
+
for item in header:
|
|
220
|
+
name, _, value = item.partition(":")
|
|
221
|
+
headers[name.strip()] = value.strip()
|
|
222
|
+
body = None
|
|
223
|
+
try:
|
|
224
|
+
if json_body:
|
|
225
|
+
body = json.loads(json_body)
|
|
226
|
+
elif body_file:
|
|
227
|
+
body = json.loads(body_file.read_text(encoding="utf-8"))
|
|
228
|
+
except json.JSONDecodeError as exc:
|
|
229
|
+
raise LpError(f"Body is not valid JSON: {exc}") from exc
|
|
230
|
+
with ApiClient(cfg) as client:
|
|
231
|
+
emit_response(
|
|
232
|
+
client.request(
|
|
233
|
+
method,
|
|
234
|
+
path,
|
|
235
|
+
mount="root" if root else "v1",
|
|
236
|
+
params=params or None,
|
|
237
|
+
json_body=body,
|
|
238
|
+
headers=headers or None,
|
|
239
|
+
),
|
|
240
|
+
ctx.obj[CTX_OUTPUT],
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
media_app = typer.Typer(help="Staff media helpers (initiate + PUT + complete).")
|
|
245
|
+
# Nested under admin after register? We'll attach at admin.media.upload-file via extra typer.
|
|
246
|
+
# Register generated commands first, then add helper onto the same tree.
|
|
247
|
+
|
|
248
|
+
_ENDPOINTS = register_all(app)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@app.command("upload")
|
|
252
|
+
def upload_cmd(
|
|
253
|
+
ctx: typer.Context,
|
|
254
|
+
file: Path = typer.Argument(..., exists=True, readable=True, dir_okay=False),
|
|
255
|
+
mime_type: Optional[str] = typer.Option(None, "--mime-type"),
|
|
256
|
+
visibility: str = typer.Option("private", "--visibility"),
|
|
257
|
+
role: str = typer.Option("original", "--role"),
|
|
258
|
+
item_id: Optional[str] = typer.Option(None, "--item-id"),
|
|
259
|
+
wait: bool = typer.Option(True, "--wait/--no-wait", help="Poll until ready/failed."),
|
|
260
|
+
) -> None:
|
|
261
|
+
"""Staff convenience: POST /admin/media/uploads → PUT bytes → complete → optional poll."""
|
|
262
|
+
cfg: LpConfig = ctx.obj[CTX_CONFIG]
|
|
263
|
+
result = upload_file(
|
|
264
|
+
cfg,
|
|
265
|
+
file,
|
|
266
|
+
mime_type=mime_type,
|
|
267
|
+
visibility=visibility,
|
|
268
|
+
role=role,
|
|
269
|
+
item_id=item_id,
|
|
270
|
+
wait=wait,
|
|
271
|
+
)
|
|
272
|
+
emit_data(result, ctx.obj[CTX_OUTPUT])
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def main() -> None:
|
|
276
|
+
"""Console entry point.
|
|
277
|
+
|
|
278
|
+
Errors are turned into an exit code here, with ``sys.exit`` rather than
|
|
279
|
+
``typer.Exit``: this runs *outside* Typer's dispatch loop, so a raised
|
|
280
|
+
``typer.Exit`` is an uncaught exception that prints a traceback and exits 1
|
|
281
|
+
whatever code it carried. stderr gets exactly one line.
|
|
282
|
+
"""
|
|
283
|
+
try:
|
|
284
|
+
app()
|
|
285
|
+
except LpError as exc:
|
|
286
|
+
err_console.print(exc.format(), markup=False, highlight=False, soft_wrap=True)
|
|
287
|
+
sys.exit(exc.exit_code)
|
lekha_poth_cli/client.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""HTTP client. Always sends a browser-like User-Agent (Cloudflare 403s default urllib)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
from urllib.parse import quote
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from lekha_poth_cli.config import LpConfig
|
|
12
|
+
from lekha_poth_cli.errors import ApiError, ConfigError, NetworkError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ApiClient:
|
|
16
|
+
def __init__(self, cfg: LpConfig) -> None:
|
|
17
|
+
self.cfg = cfg
|
|
18
|
+
headers = {
|
|
19
|
+
"Accept": "application/json",
|
|
20
|
+
"User-Agent": cfg.user_agent,
|
|
21
|
+
}
|
|
22
|
+
token = cfg.api_key or cfg.access_token
|
|
23
|
+
if token:
|
|
24
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
25
|
+
self._client = httpx.Client(
|
|
26
|
+
timeout=cfg.timeout,
|
|
27
|
+
headers=headers,
|
|
28
|
+
follow_redirects=True,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def close(self) -> None:
|
|
32
|
+
self._client.close()
|
|
33
|
+
|
|
34
|
+
def __enter__(self) -> ApiClient:
|
|
35
|
+
return self
|
|
36
|
+
|
|
37
|
+
def __exit__(self, *args: object) -> None:
|
|
38
|
+
self.close()
|
|
39
|
+
|
|
40
|
+
def url(self, path: str, *, mount: str) -> str:
|
|
41
|
+
if path.startswith("http://") or path.startswith("https://"):
|
|
42
|
+
return path
|
|
43
|
+
if not path.startswith("/"):
|
|
44
|
+
path = "/" + path
|
|
45
|
+
if mount == "root":
|
|
46
|
+
return self.cfg.origin() + path
|
|
47
|
+
# v1
|
|
48
|
+
return self.cfg.api_base + path
|
|
49
|
+
|
|
50
|
+
def request(
|
|
51
|
+
self,
|
|
52
|
+
method: str,
|
|
53
|
+
path: str,
|
|
54
|
+
*,
|
|
55
|
+
mount: str = "v1",
|
|
56
|
+
params: dict[str, Any] | None = None,
|
|
57
|
+
json_body: Any = None,
|
|
58
|
+
headers: dict[str, str] | None = None,
|
|
59
|
+
stream: bool = False,
|
|
60
|
+
auth_required: bool = False,
|
|
61
|
+
) -> httpx.Response:
|
|
62
|
+
if auth_required and not (self.cfg.api_key or self.cfg.access_token):
|
|
63
|
+
raise ConfigError(
|
|
64
|
+
"This command needs a Bearer token. Run `lp config set --api-key lpak_…` "
|
|
65
|
+
"or `lp auth login`."
|
|
66
|
+
)
|
|
67
|
+
if mount == "v1" and not self.cfg.api_base:
|
|
68
|
+
raise ConfigError(
|
|
69
|
+
"API base is not configured. Set LEKHA_POTH_API_BASE or run "
|
|
70
|
+
"`lp config set --api-base https://api.example.com/api/v1` "
|
|
71
|
+
"(must end in /api/v1)."
|
|
72
|
+
)
|
|
73
|
+
if mount == "root" and not self.cfg.origin():
|
|
74
|
+
raise ConfigError(
|
|
75
|
+
"API base is not configured. Set LEKHA_POTH_API_BASE or run "
|
|
76
|
+
"`lp config set --api-base https://api.example.com/api/v1`."
|
|
77
|
+
)
|
|
78
|
+
url = self.url(path, mount=mount)
|
|
79
|
+
clean_params = _clean_params(params or {})
|
|
80
|
+
extra = dict(headers or {})
|
|
81
|
+
kwargs: dict[str, Any] = {
|
|
82
|
+
"method": method.upper(),
|
|
83
|
+
"url": url,
|
|
84
|
+
"params": clean_params or None,
|
|
85
|
+
"headers": extra or None,
|
|
86
|
+
}
|
|
87
|
+
if json_body is not None:
|
|
88
|
+
kwargs["json"] = json_body
|
|
89
|
+
extra.setdefault("Content-Type", "application/json")
|
|
90
|
+
kwargs["headers"] = extra
|
|
91
|
+
try:
|
|
92
|
+
if stream:
|
|
93
|
+
return self._client.send(self._client.build_request(**kwargs), stream=True)
|
|
94
|
+
response = self._client.request(**kwargs)
|
|
95
|
+
except httpx.TransportError as exc:
|
|
96
|
+
raise NetworkError(f"{type(exc).__name__}: {exc} ({method.upper()} {url})") from exc
|
|
97
|
+
return self._raise_for_api(response)
|
|
98
|
+
|
|
99
|
+
def _raise_for_api(self, response: httpx.Response) -> httpx.Response:
|
|
100
|
+
if response.status_code < 400:
|
|
101
|
+
return response
|
|
102
|
+
body: Any
|
|
103
|
+
try:
|
|
104
|
+
body = response.json()
|
|
105
|
+
except json.JSONDecodeError:
|
|
106
|
+
body = response.text
|
|
107
|
+
code = None
|
|
108
|
+
message = None
|
|
109
|
+
request_id = None
|
|
110
|
+
details = None
|
|
111
|
+
if isinstance(body, dict):
|
|
112
|
+
err = body.get("error")
|
|
113
|
+
if isinstance(err, dict):
|
|
114
|
+
code = err.get("code")
|
|
115
|
+
message = err.get("message")
|
|
116
|
+
request_id = err.get("request_id") or err.get("requestId")
|
|
117
|
+
details = err.get("details")
|
|
118
|
+
else:
|
|
119
|
+
message = body.get("message") or body.get("detail")
|
|
120
|
+
request_id = request_id or response.headers.get("x-request-id")
|
|
121
|
+
if not message:
|
|
122
|
+
message = f"HTTP {response.status_code} {response.reason_phrase}"
|
|
123
|
+
raise ApiError(
|
|
124
|
+
str(message),
|
|
125
|
+
status_code=response.status_code,
|
|
126
|
+
code=str(code) if code else None,
|
|
127
|
+
request_id=str(request_id) if request_id else None,
|
|
128
|
+
details=details,
|
|
129
|
+
body=body,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def encode_path_param(value: str) -> str:
|
|
134
|
+
return quote(str(value), safe="")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _clean_params(params: dict[str, Any]) -> dict[str, Any]:
|
|
138
|
+
out: dict[str, Any] = {}
|
|
139
|
+
for key, value in params.items():
|
|
140
|
+
if value is None:
|
|
141
|
+
continue
|
|
142
|
+
if isinstance(value, bool):
|
|
143
|
+
out[key] = "true" if value else "false"
|
|
144
|
+
elif isinstance(value, (list, tuple)):
|
|
145
|
+
# FastAPI list query: repeat key
|
|
146
|
+
out[key] = [str(v) for v in value if v is not None]
|
|
147
|
+
else:
|
|
148
|
+
out[key] = value
|
|
149
|
+
return out
|
lekha_poth_cli/config.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Persistent configuration (Pydantic). Compatible with existing ~/.config/lekha-poth/config.json."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Literal, Self
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, Field, field_validator
|
|
12
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
13
|
+
|
|
14
|
+
from lekha_poth_cli.errors import ConfigError
|
|
15
|
+
|
|
16
|
+
# No compiled-in production host. Point the CLI at a deploy with
|
|
17
|
+
# `lp config set --api-base … --site-url …` or LEKHA_POTH_API_BASE / _SITE_URL.
|
|
18
|
+
DEFAULT_SITE_URL = ""
|
|
19
|
+
DEFAULT_API_BASE = ""
|
|
20
|
+
DEFAULT_USER_AGENT = "lekha-poth-cli/2.0"
|
|
21
|
+
CONFIG_DIR = Path.home() / ".config" / "lekha-poth"
|
|
22
|
+
CONFIG_PATH = CONFIG_DIR / "config.json"
|
|
23
|
+
OutputFormat = Literal["pretty", "json", "raw"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def default_config_path() -> Path:
|
|
27
|
+
override = os.environ.get("LEKHA_POTH_CONFIG")
|
|
28
|
+
if override:
|
|
29
|
+
return Path(override).expanduser()
|
|
30
|
+
return CONFIG_PATH
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class LpConfig(BaseModel):
|
|
34
|
+
"""On-disk config. Secrets live here (mode 0600); never in skills or memory."""
|
|
35
|
+
|
|
36
|
+
api_base: str = DEFAULT_API_BASE
|
|
37
|
+
site_url: str = DEFAULT_SITE_URL
|
|
38
|
+
api_key: str | None = None
|
|
39
|
+
key_name: str | None = None
|
|
40
|
+
created_at: str | None = None
|
|
41
|
+
access_token: str | None = None
|
|
42
|
+
refresh_token: str | None = None
|
|
43
|
+
timeout: float = 30.0
|
|
44
|
+
output: OutputFormat = "pretty"
|
|
45
|
+
user_agent: str = DEFAULT_USER_AGENT
|
|
46
|
+
default_size: int = Field(default=20, ge=1, le=100)
|
|
47
|
+
|
|
48
|
+
@field_validator("api_base", "site_url")
|
|
49
|
+
@classmethod
|
|
50
|
+
def _strip_slash(cls, value: str) -> str:
|
|
51
|
+
return value.rstrip("/")
|
|
52
|
+
|
|
53
|
+
def origin(self) -> str:
|
|
54
|
+
"""API host without ``/api/v1`` (health/ready live here)."""
|
|
55
|
+
base = self.api_base
|
|
56
|
+
for suffix in ("/api/v1", "/api"):
|
|
57
|
+
if base.endswith(suffix):
|
|
58
|
+
return base[: -len(suffix)]
|
|
59
|
+
return base
|
|
60
|
+
|
|
61
|
+
def masked(self) -> dict[str, Any]:
|
|
62
|
+
data = self.model_dump()
|
|
63
|
+
for key in ("api_key", "access_token", "refresh_token"):
|
|
64
|
+
val = data.get(key)
|
|
65
|
+
if isinstance(val, str) and val:
|
|
66
|
+
data[key] = val[:4] + "…" + f"({len(val)} chars)"
|
|
67
|
+
else:
|
|
68
|
+
data[key] = None
|
|
69
|
+
return data
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class RuntimeSettings(BaseSettings):
|
|
73
|
+
"""Env overlay. File values are the base; env wins; CLI flags win last."""
|
|
74
|
+
|
|
75
|
+
model_config = SettingsConfigDict(env_prefix="LEKHA_POTH_", extra="ignore")
|
|
76
|
+
|
|
77
|
+
api_base: str | None = None
|
|
78
|
+
site_url: str | None = None
|
|
79
|
+
api_key: str | None = None
|
|
80
|
+
timeout: float | None = None
|
|
81
|
+
output: OutputFormat | None = None
|
|
82
|
+
user_agent: str | None = None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def load_config(path: Path | None = None) -> LpConfig:
|
|
86
|
+
path = path or default_config_path()
|
|
87
|
+
if not path.is_file():
|
|
88
|
+
env = RuntimeSettings()
|
|
89
|
+
return _merge(LpConfig(), env)
|
|
90
|
+
try:
|
|
91
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
92
|
+
except json.JSONDecodeError as exc:
|
|
93
|
+
raise ConfigError(f"Invalid JSON in {path}: {exc}") from exc
|
|
94
|
+
if not isinstance(raw, dict):
|
|
95
|
+
raise ConfigError(f"{path} must contain a JSON object")
|
|
96
|
+
cfg = LpConfig.model_validate(raw)
|
|
97
|
+
return _merge(cfg, RuntimeSettings())
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _merge(cfg: LpConfig, env: RuntimeSettings) -> LpConfig:
|
|
101
|
+
updates: dict[str, Any] = {}
|
|
102
|
+
for field in ("api_base", "site_url", "api_key", "timeout", "output", "user_agent"):
|
|
103
|
+
value = getattr(env, field)
|
|
104
|
+
if value is not None:
|
|
105
|
+
updates[field] = value
|
|
106
|
+
return cfg.model_copy(update=updates) if updates else cfg
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def save_config(cfg: LpConfig, path: Path | None = None) -> Path:
|
|
110
|
+
path = path or default_config_path()
|
|
111
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
payload = cfg.model_dump()
|
|
113
|
+
tmp = path.with_suffix(".tmp")
|
|
114
|
+
tmp.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
115
|
+
os.chmod(tmp, 0o600)
|
|
116
|
+
tmp.replace(path)
|
|
117
|
+
os.chmod(path, 0o600)
|
|
118
|
+
return path
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def apply_overrides(
|
|
122
|
+
cfg: LpConfig,
|
|
123
|
+
*,
|
|
124
|
+
api_base: str | None = None,
|
|
125
|
+
api_key: str | None = None,
|
|
126
|
+
timeout: float | None = None,
|
|
127
|
+
output: OutputFormat | None = None,
|
|
128
|
+
) -> LpConfig:
|
|
129
|
+
data = cfg.model_dump()
|
|
130
|
+
if api_base is not None:
|
|
131
|
+
data["api_base"] = api_base.rstrip("/")
|
|
132
|
+
if api_key is not None:
|
|
133
|
+
data["api_key"] = api_key
|
|
134
|
+
if timeout is not None:
|
|
135
|
+
data["timeout"] = timeout
|
|
136
|
+
if output is not None:
|
|
137
|
+
data["output"] = output
|
|
138
|
+
return LpConfig.model_validate(data)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def stamp_created(cfg: LpConfig) -> LpConfig:
|
|
142
|
+
if cfg.created_at:
|
|
143
|
+
return cfg
|
|
144
|
+
now = datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
|
145
|
+
return cfg.model_copy(update={"created_at": now})
|