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.
@@ -0,0 +1,6 @@
1
+ """Lekha Poth production CLI (Typer + Pydantic)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = ["__version__"]
6
+ __version__ = "2.1.0"
@@ -0,0 +1,4 @@
1
+ from lekha_poth_cli.app import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
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)
@@ -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
@@ -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})