kctl-api 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.
- kctl_api/__init__.py +3 -0
- kctl_api/__main__.py +5 -0
- kctl_api/cli.py +238 -0
- kctl_api/commands/__init__.py +1 -0
- kctl_api/commands/ai.py +250 -0
- kctl_api/commands/aliases.py +84 -0
- kctl_api/commands/apps.py +172 -0
- kctl_api/commands/auth.py +313 -0
- kctl_api/commands/automation.py +242 -0
- kctl_api/commands/build_cmd.py +87 -0
- kctl_api/commands/clean.py +182 -0
- kctl_api/commands/config_cmd.py +443 -0
- kctl_api/commands/dashboard.py +139 -0
- kctl_api/commands/db.py +599 -0
- kctl_api/commands/deploy.py +84 -0
- kctl_api/commands/deps.py +289 -0
- kctl_api/commands/dev.py +136 -0
- kctl_api/commands/docker_cmd.py +252 -0
- kctl_api/commands/doctor_cmd.py +286 -0
- kctl_api/commands/env.py +289 -0
- kctl_api/commands/files.py +250 -0
- kctl_api/commands/fmt_cmd.py +58 -0
- kctl_api/commands/health.py +479 -0
- kctl_api/commands/jobs.py +169 -0
- kctl_api/commands/lint_cmd.py +81 -0
- kctl_api/commands/logs.py +258 -0
- kctl_api/commands/marketplace.py +316 -0
- kctl_api/commands/monitor_cmd.py +243 -0
- kctl_api/commands/notifications.py +132 -0
- kctl_api/commands/odoo_proxy.py +182 -0
- kctl_api/commands/openapi.py +299 -0
- kctl_api/commands/perf.py +307 -0
- kctl_api/commands/rate_limit.py +223 -0
- kctl_api/commands/realtime.py +100 -0
- kctl_api/commands/redis_cmd.py +609 -0
- kctl_api/commands/routes_cmd.py +277 -0
- kctl_api/commands/saas.py +145 -0
- kctl_api/commands/scaffold.py +362 -0
- kctl_api/commands/security_cmd.py +350 -0
- kctl_api/commands/services.py +191 -0
- kctl_api/commands/shell.py +197 -0
- kctl_api/commands/skill_cmd.py +58 -0
- kctl_api/commands/streams.py +309 -0
- kctl_api/commands/stripe_cmd.py +105 -0
- kctl_api/commands/tenant_ai.py +169 -0
- kctl_api/commands/test_cmd.py +95 -0
- kctl_api/commands/users.py +302 -0
- kctl_api/commands/webhooks.py +56 -0
- kctl_api/commands/workflows.py +127 -0
- kctl_api/commands/ws.py +323 -0
- kctl_api/core/__init__.py +1 -0
- kctl_api/core/async_client.py +120 -0
- kctl_api/core/callbacks.py +88 -0
- kctl_api/core/client.py +190 -0
- kctl_api/core/config.py +260 -0
- kctl_api/core/db.py +65 -0
- kctl_api/core/exceptions.py +43 -0
- kctl_api/core/output.py +5 -0
- kctl_api/core/plugins.py +26 -0
- kctl_api/core/redis.py +35 -0
- kctl_api/core/resolve.py +47 -0
- kctl_api/core/utils.py +109 -0
- kctl_api-0.2.0.dist-info/METADATA +34 -0
- kctl_api-0.2.0.dist-info/RECORD +66 -0
- kctl_api-0.2.0.dist-info/WHEEL +4 -0
- kctl_api-0.2.0.dist-info/entry_points.txt +2 -0
kctl_api/core/client.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""REST API client using httpx.
|
|
2
|
+
|
|
3
|
+
Synchronous client for api-main and ai-main endpoints.
|
|
4
|
+
Supports JWT bearer token and API key authentication.
|
|
5
|
+
|
|
6
|
+
Subclasses :class:`kctl_lib.api_client.APIClient` and keeps dual-auth
|
|
7
|
+
(JWT + API key) as a service-specific override of ``_build_auth_header``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
from kctl_lib.api_client import APIClient
|
|
16
|
+
from kctl_lib.exceptions import AuthenticationError
|
|
17
|
+
from kctl_lib.exceptions import ConnectionError as KctlConnectionError
|
|
18
|
+
|
|
19
|
+
from kctl_api.core.exceptions import APIError
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ApiClient(APIClient):
|
|
23
|
+
"""Synchronous httpx client for Kodemeio REST APIs.
|
|
24
|
+
|
|
25
|
+
Extends the kctl-lib base with dual-auth support (JWT bearer token
|
|
26
|
+
**or** API key via ``X-API-Key`` header) and service-specific helpers
|
|
27
|
+
such as ``login``, ``refresh``, ``upload``, and ``stream_sse``.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
AUTH_HEADER: str = "Authorization"
|
|
31
|
+
AUTH_PREFIX: str = "Bearer"
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
base_url: str,
|
|
36
|
+
api_key: str = "",
|
|
37
|
+
jwt_token: str = "",
|
|
38
|
+
timeout: float = 30.0,
|
|
39
|
+
):
|
|
40
|
+
# Resolve the primary credential for the base class.
|
|
41
|
+
# JWT takes precedence; fall back to API key.
|
|
42
|
+
credential = jwt_token or api_key or "none"
|
|
43
|
+
|
|
44
|
+
# Store extras *before* super().__init__ since _build_auth_header
|
|
45
|
+
# is called during construction.
|
|
46
|
+
self._api_key = api_key
|
|
47
|
+
self._jwt_token = jwt_token
|
|
48
|
+
self._refresh_token: str = ""
|
|
49
|
+
|
|
50
|
+
if not base_url:
|
|
51
|
+
raise KctlConnectionError(
|
|
52
|
+
"(not configured)",
|
|
53
|
+
ValueError("No API URL configured. Run: kctl-api config init"),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
super().__init__(
|
|
57
|
+
base_url=base_url,
|
|
58
|
+
credential=credential,
|
|
59
|
+
timeout=timeout,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Override follow_redirects (base class doesn't set it)
|
|
63
|
+
self._client = httpx.Client(
|
|
64
|
+
base_url=self._base_url,
|
|
65
|
+
headers=self._build_auth_header(),
|
|
66
|
+
timeout=timeout,
|
|
67
|
+
follow_redirects=True,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# ------------------------------------------------------------------
|
|
71
|
+
# Auth override — dual-auth (JWT or API key)
|
|
72
|
+
# ------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
def _build_auth_header(self) -> dict[str, str]:
|
|
75
|
+
"""Build authentication headers supporting JWT and API key."""
|
|
76
|
+
if self._jwt_token:
|
|
77
|
+
return {"Authorization": f"Bearer {self._jwt_token}"}
|
|
78
|
+
if self._api_key:
|
|
79
|
+
return {"X-API-Key": self._api_key}
|
|
80
|
+
return {}
|
|
81
|
+
|
|
82
|
+
# ------------------------------------------------------------------
|
|
83
|
+
# Error mapping override — use local APIError that wraps httpx.Response
|
|
84
|
+
# ------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
def _request(self, method: str, endpoint: str, **kwargs: Any) -> httpx.Response:
|
|
87
|
+
"""Override to use kctl-api's APIError (wraps httpx.Response)."""
|
|
88
|
+
try:
|
|
89
|
+
return super()._request(method, endpoint, **kwargs)
|
|
90
|
+
except KctlConnectionError:
|
|
91
|
+
raise
|
|
92
|
+
except AuthenticationError:
|
|
93
|
+
raise
|
|
94
|
+
except Exception as exc:
|
|
95
|
+
# Re-raise kctl-lib APIError as the local response-aware variant
|
|
96
|
+
from kctl_lib.exceptions import APIError as BaseAPIError
|
|
97
|
+
|
|
98
|
+
if isinstance(exc, BaseAPIError):
|
|
99
|
+
raise APIError(detail=exc.detail) from exc
|
|
100
|
+
raise
|
|
101
|
+
|
|
102
|
+
# ------------------------------------------------------------------
|
|
103
|
+
# Service-specific methods
|
|
104
|
+
# ------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
def upload(self, path: str, file_path: str, **kwargs: Any) -> Any:
|
|
107
|
+
"""Upload a file via multipart POST."""
|
|
108
|
+
url = path
|
|
109
|
+
headers = self._build_auth_header()
|
|
110
|
+
# Remove Content-Type for multipart — httpx sets it automatically
|
|
111
|
+
headers.pop("Content-Type", None)
|
|
112
|
+
|
|
113
|
+
with open(file_path, "rb") as f:
|
|
114
|
+
try:
|
|
115
|
+
response = self._client.post(
|
|
116
|
+
url,
|
|
117
|
+
headers=headers,
|
|
118
|
+
files={"file": f},
|
|
119
|
+
**kwargs,
|
|
120
|
+
)
|
|
121
|
+
except httpx.ConnectError as e:
|
|
122
|
+
raise KctlConnectionError(self._base_url, e) from e
|
|
123
|
+
|
|
124
|
+
if response.status_code == 401:
|
|
125
|
+
raise AuthenticationError("Authentication failed.")
|
|
126
|
+
if response.status_code >= 400:
|
|
127
|
+
raise APIError(response)
|
|
128
|
+
|
|
129
|
+
return response.json()
|
|
130
|
+
|
|
131
|
+
def stream_sse(self, path: str, **kwargs: Any) -> Any:
|
|
132
|
+
"""Open an SSE stream. Returns a context manager -- use with ``with``.
|
|
133
|
+
|
|
134
|
+
Usage::
|
|
135
|
+
|
|
136
|
+
with client.stream_sse("/api/v1/ai/chat/stream") as response:
|
|
137
|
+
for line in response.iter_lines():
|
|
138
|
+
...
|
|
139
|
+
"""
|
|
140
|
+
url = path
|
|
141
|
+
headers = {**self._build_auth_header(), "Accept": "text/event-stream"}
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
return self._client.stream("GET", url, headers=headers, **kwargs)
|
|
145
|
+
except httpx.ConnectError as e:
|
|
146
|
+
raise KctlConnectionError(self._base_url, e) from e
|
|
147
|
+
|
|
148
|
+
def login(self, email: str, password: str) -> dict[str, str]:
|
|
149
|
+
"""Authenticate and obtain JWT tokens.
|
|
150
|
+
|
|
151
|
+
Returns: {"access_token": "...", "refresh_token": "..."}
|
|
152
|
+
"""
|
|
153
|
+
data = self.post(
|
|
154
|
+
"/api/v1/auth/login",
|
|
155
|
+
json={"email": email, "password": password},
|
|
156
|
+
)
|
|
157
|
+
self._jwt_token = data.get("access_token", "")
|
|
158
|
+
self._refresh_token = data.get("refresh_token", "")
|
|
159
|
+
# Update client headers with new token
|
|
160
|
+
self._client.headers.update(self._build_auth_header())
|
|
161
|
+
return data
|
|
162
|
+
|
|
163
|
+
def refresh(self) -> dict[str, str]:
|
|
164
|
+
"""Refresh JWT access token using the refresh token."""
|
|
165
|
+
if not self._refresh_token:
|
|
166
|
+
raise AuthenticationError("No refresh token available. Run: kctl-api auth login")
|
|
167
|
+
data = self.post(
|
|
168
|
+
"/api/v1/auth/refresh",
|
|
169
|
+
json={"refresh_token": self._refresh_token},
|
|
170
|
+
)
|
|
171
|
+
self._jwt_token = data.get("access_token", self._jwt_token)
|
|
172
|
+
# Update client headers with new token
|
|
173
|
+
self._client.headers.update(self._build_auth_header())
|
|
174
|
+
return data
|
|
175
|
+
|
|
176
|
+
def health_check(self) -> tuple[bool, dict]:
|
|
177
|
+
"""Check API health. Returns (is_healthy, details)."""
|
|
178
|
+
try:
|
|
179
|
+
data = self.get("/api/v1/health")
|
|
180
|
+
return data.get("status") == "ok", data
|
|
181
|
+
except Exception as e:
|
|
182
|
+
return False, {"status": "error", "error": str(e)}
|
|
183
|
+
|
|
184
|
+
def for_url(self, base_url: str) -> ApiClient:
|
|
185
|
+
"""Create a new client for a different base URL (same auth)."""
|
|
186
|
+
return ApiClient(
|
|
187
|
+
base_url=base_url,
|
|
188
|
+
api_key=self._api_key,
|
|
189
|
+
jwt_token=self._jwt_token,
|
|
190
|
+
)
|
kctl_api/core/config.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Profile management and configuration resolution for kctl-api.
|
|
2
|
+
|
|
3
|
+
ServiceConfig and resolve_connection() are API-specific and kept here.
|
|
4
|
+
Profile framework functions delegate to kctl-lib.config; low-level I/O
|
|
5
|
+
(load_raw_config, save_raw_config) are kept locally so that the module-level
|
|
6
|
+
CONFIG_FILE / CONFIG_DIR names can be patched in tests.
|
|
7
|
+
|
|
8
|
+
Config format:
|
|
9
|
+
profiles:
|
|
10
|
+
production:
|
|
11
|
+
api: # <- kctl-api reads this
|
|
12
|
+
url: https://api.kodeme.io
|
|
13
|
+
ai_url: https://ai.kodeme.io
|
|
14
|
+
api_key: <key>
|
|
15
|
+
database_url: postgresql+asyncpg://...
|
|
16
|
+
redis_url: redis://...
|
|
17
|
+
odoo: # <- kctl-odoo reads this
|
|
18
|
+
url: https://erp.kodeme.io
|
|
19
|
+
...
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import os
|
|
25
|
+
import sys
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
import kctl_lib.config as _common
|
|
30
|
+
import yaml
|
|
31
|
+
from kctl_lib.config import (
|
|
32
|
+
ConfigFile,
|
|
33
|
+
get_all_services_in_profile,
|
|
34
|
+
get_default_profile,
|
|
35
|
+
get_profile_names,
|
|
36
|
+
remove_profile,
|
|
37
|
+
set_default_profile,
|
|
38
|
+
)
|
|
39
|
+
from kctl_lib.config import (
|
|
40
|
+
is_service_scoped as _is_service_scoped,
|
|
41
|
+
)
|
|
42
|
+
from pydantic import BaseModel
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"CONFIG_DIR",
|
|
46
|
+
"CONFIG_FILE",
|
|
47
|
+
"SERVICE_KEY",
|
|
48
|
+
"ConfigFile",
|
|
49
|
+
"ServiceConfig",
|
|
50
|
+
"_is_service_scoped",
|
|
51
|
+
"get_all_services_in_profile",
|
|
52
|
+
"get_default_profile",
|
|
53
|
+
"get_profile_names",
|
|
54
|
+
"get_project_root_from_config",
|
|
55
|
+
"get_service_config",
|
|
56
|
+
"load_config",
|
|
57
|
+
"load_raw_config",
|
|
58
|
+
"remove_profile",
|
|
59
|
+
"resolve_active_profile_name",
|
|
60
|
+
"resolve_connection",
|
|
61
|
+
"save_raw_config",
|
|
62
|
+
"set_default_profile",
|
|
63
|
+
"set_service_config",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
CONFIG_DIR: Path = _common.CONFIG_DIR
|
|
67
|
+
CONFIG_FILE: Path = _common.CONFIG_FILE
|
|
68
|
+
|
|
69
|
+
# This CLI's service key.
|
|
70
|
+
SERVICE_KEY = "api"
|
|
71
|
+
|
|
72
|
+
# Env-prefix used for KCTL_API_PROFILE / KCTL_API_URL etc.
|
|
73
|
+
_ENV_PREFIX = "KCTL_API"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class ServiceConfig(BaseModel):
|
|
77
|
+
"""API-specific service config within a profile."""
|
|
78
|
+
|
|
79
|
+
url: str = ""
|
|
80
|
+
ai_url: str = ""
|
|
81
|
+
api_key: str = ""
|
|
82
|
+
database_url: str = ""
|
|
83
|
+
redis_url: str = ""
|
|
84
|
+
project_root: str = ""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
# Low-level I/O — kept local so CONFIG_FILE/CONFIG_DIR are patchable in tests.
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def load_raw_config() -> dict[str, Any]:
|
|
93
|
+
"""Load raw YAML config from disk."""
|
|
94
|
+
if not CONFIG_FILE.exists():
|
|
95
|
+
return {}
|
|
96
|
+
try:
|
|
97
|
+
with open(CONFIG_FILE) as f:
|
|
98
|
+
return yaml.safe_load(f) or {}
|
|
99
|
+
except (yaml.YAMLError, OSError) as e:
|
|
100
|
+
print(f"WARN: Cannot read {CONFIG_FILE}: {e}", file=sys.stderr)
|
|
101
|
+
return {}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def save_raw_config(data: dict[str, Any]) -> None:
|
|
105
|
+
"""Write raw config dict to YAML file."""
|
|
106
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
with open(CONFIG_FILE, "w") as f:
|
|
108
|
+
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def load_config() -> ConfigFile:
|
|
112
|
+
"""Load and validate the config file."""
|
|
113
|
+
data = load_raw_config()
|
|
114
|
+
return ConfigFile(
|
|
115
|
+
default_profile=data.get("default_profile", "default"),
|
|
116
|
+
profiles=data.get("profiles", {}),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
# Profile framework — delegates to kctl-lib where possible.
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def get_service_config(profile_name: str) -> ServiceConfig:
|
|
126
|
+
"""Get this CLI's service config from a profile."""
|
|
127
|
+
cfg = load_config()
|
|
128
|
+
profile_data = cfg.profiles.get(profile_name, {})
|
|
129
|
+
if not profile_data:
|
|
130
|
+
return ServiceConfig()
|
|
131
|
+
|
|
132
|
+
if _is_service_scoped(profile_data):
|
|
133
|
+
svc_data = profile_data.get(SERVICE_KEY, {})
|
|
134
|
+
if not isinstance(svc_data, dict):
|
|
135
|
+
return ServiceConfig()
|
|
136
|
+
raw: dict[str, Any] = dict(svc_data)
|
|
137
|
+
else:
|
|
138
|
+
raw = dict(profile_data)
|
|
139
|
+
|
|
140
|
+
# Expand env vars
|
|
141
|
+
for k, v in raw.items():
|
|
142
|
+
if isinstance(v, str):
|
|
143
|
+
raw[k] = _common.expand_env(v)
|
|
144
|
+
|
|
145
|
+
valid = list(ServiceConfig.model_fields)
|
|
146
|
+
raw = {k: v for k, v in raw.items() if k in valid}
|
|
147
|
+
return ServiceConfig(**raw) if raw else ServiceConfig()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def set_service_config(profile_name: str, svc_config: ServiceConfig) -> None:
|
|
151
|
+
"""Write this CLI's service config into a profile (always scoped format)."""
|
|
152
|
+
data = load_raw_config()
|
|
153
|
+
if "profiles" not in data:
|
|
154
|
+
data["profiles"] = {}
|
|
155
|
+
if profile_name not in data["profiles"]:
|
|
156
|
+
data["profiles"][profile_name] = {}
|
|
157
|
+
|
|
158
|
+
profile = data["profiles"][profile_name]
|
|
159
|
+
|
|
160
|
+
if not _is_service_scoped(profile):
|
|
161
|
+
old_data = dict(profile)
|
|
162
|
+
profile.clear()
|
|
163
|
+
profile[SERVICE_KEY] = old_data
|
|
164
|
+
|
|
165
|
+
svc_data = svc_config.model_dump(exclude_defaults=False)
|
|
166
|
+
svc_data = {k: v for k, v in svc_data.items() if v}
|
|
167
|
+
profile[SERVICE_KEY] = svc_data
|
|
168
|
+
save_raw_config(data)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def get_project_root_from_config(profile_name: str | None = None) -> Path | None:
|
|
172
|
+
"""Read project_root from the active config profile.
|
|
173
|
+
|
|
174
|
+
Returns None if not configured or the directory does not exist.
|
|
175
|
+
"""
|
|
176
|
+
pname = resolve_active_profile_name(profile_name)
|
|
177
|
+
svc = get_service_config(pname)
|
|
178
|
+
if svc.project_root:
|
|
179
|
+
root = Path(_common.expand_env(svc.project_root)).expanduser()
|
|
180
|
+
if root.is_dir():
|
|
181
|
+
return root
|
|
182
|
+
# Also check env var
|
|
183
|
+
if env_root := os.environ.get("KCTL_API_REPO"):
|
|
184
|
+
root = Path(env_root)
|
|
185
|
+
if root.is_dir():
|
|
186
|
+
return root
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def resolve_active_profile_name(profile_name: str | None = None) -> str:
|
|
191
|
+
"""Resolve the active profile name from all sources."""
|
|
192
|
+
if profile_name:
|
|
193
|
+
return profile_name
|
|
194
|
+
if env := os.environ.get("KCTL_API_PROFILE"):
|
|
195
|
+
return env
|
|
196
|
+
return get_default_profile()
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def resolve_connection(
|
|
200
|
+
profile_name: str | None = None,
|
|
201
|
+
url_override: str | None = None,
|
|
202
|
+
ai_url_override: str | None = None,
|
|
203
|
+
api_key_override: str | None = None,
|
|
204
|
+
database_url_override: str | None = None,
|
|
205
|
+
redis_url_override: str | None = None,
|
|
206
|
+
) -> tuple[str, str, str, str, str]:
|
|
207
|
+
"""Resolve API URL, AI URL, API key, database URL, and Redis URL.
|
|
208
|
+
|
|
209
|
+
Returns: (url, ai_url, api_key, database_url, redis_url)
|
|
210
|
+
|
|
211
|
+
Priority:
|
|
212
|
+
1. CLI flags (overrides)
|
|
213
|
+
2. KCTL_API_URL / KCTL_API_AI_URL / KCTL_API_KEY / KCTL_API_DATABASE_URL / KCTL_API_REDIS_URL
|
|
214
|
+
3. Profile's api service config
|
|
215
|
+
"""
|
|
216
|
+
url = ""
|
|
217
|
+
ai_url = ""
|
|
218
|
+
api_key = ""
|
|
219
|
+
database_url = ""
|
|
220
|
+
redis_url = ""
|
|
221
|
+
|
|
222
|
+
# 3. Config file profile (service-scoped)
|
|
223
|
+
pname = resolve_active_profile_name(profile_name)
|
|
224
|
+
svc = get_service_config(pname)
|
|
225
|
+
if svc.url:
|
|
226
|
+
url = svc.url
|
|
227
|
+
if svc.ai_url:
|
|
228
|
+
ai_url = svc.ai_url
|
|
229
|
+
if svc.api_key:
|
|
230
|
+
api_key = svc.api_key
|
|
231
|
+
if svc.database_url:
|
|
232
|
+
database_url = svc.database_url
|
|
233
|
+
if svc.redis_url:
|
|
234
|
+
redis_url = svc.redis_url
|
|
235
|
+
|
|
236
|
+
# 2. Environment variables
|
|
237
|
+
if env_url := os.environ.get("KCTL_API_URL"):
|
|
238
|
+
url = env_url
|
|
239
|
+
if env_ai_url := os.environ.get("KCTL_API_AI_URL"):
|
|
240
|
+
ai_url = env_ai_url
|
|
241
|
+
if env_key := os.environ.get("KCTL_API_KEY"):
|
|
242
|
+
api_key = env_key
|
|
243
|
+
if env_db := os.environ.get("KCTL_API_DATABASE_URL"):
|
|
244
|
+
database_url = env_db
|
|
245
|
+
if env_redis := os.environ.get("KCTL_API_REDIS_URL"):
|
|
246
|
+
redis_url = env_redis
|
|
247
|
+
|
|
248
|
+
# 1. CLI flags
|
|
249
|
+
if url_override:
|
|
250
|
+
url = url_override
|
|
251
|
+
if ai_url_override:
|
|
252
|
+
ai_url = ai_url_override
|
|
253
|
+
if api_key_override:
|
|
254
|
+
api_key = api_key_override
|
|
255
|
+
if database_url_override:
|
|
256
|
+
database_url = database_url_override
|
|
257
|
+
if redis_url_override:
|
|
258
|
+
redis_url = redis_url_override
|
|
259
|
+
|
|
260
|
+
return url, ai_url, api_key, database_url, redis_url
|
kctl_api/core/db.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Lazy async SQLAlchemy engine for direct database access.
|
|
2
|
+
|
|
3
|
+
Requires the ``db`` extra: ``pip install kctl-api[db]``
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
_engines: dict[str, Any] = {}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_engine(database_url: str) -> Any:
|
|
15
|
+
"""Get or create an async engine keyed by URL.
|
|
16
|
+
|
|
17
|
+
Raises ImportError if sqlalchemy/asyncpg are not installed.
|
|
18
|
+
"""
|
|
19
|
+
if database_url in _engines:
|
|
20
|
+
return _engines[database_url]
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
from sqlalchemy.ext.asyncio import create_async_engine
|
|
24
|
+
except ImportError as e:
|
|
25
|
+
raise ImportError("Database support requires the 'db' extra. Install with: pip install kctl-api[db]") from e
|
|
26
|
+
|
|
27
|
+
engine = create_async_engine(
|
|
28
|
+
database_url,
|
|
29
|
+
pool_size=1,
|
|
30
|
+
max_overflow=0,
|
|
31
|
+
echo=False,
|
|
32
|
+
)
|
|
33
|
+
_engines[database_url] = engine
|
|
34
|
+
return engine
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def dispose_engine() -> None:
|
|
38
|
+
"""Dispose all cached engines (call during cleanup)."""
|
|
39
|
+
for engine in _engines.values():
|
|
40
|
+
await engine.dispose()
|
|
41
|
+
_engines.clear()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
_VALID_IDENTIFIER = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def validate_identifier(name: str) -> str:
|
|
48
|
+
"""Validate a SQL identifier (table/column name) to prevent injection.
|
|
49
|
+
|
|
50
|
+
Returns the name if valid, raises ValueError otherwise.
|
|
51
|
+
"""
|
|
52
|
+
if not _VALID_IDENTIFIER.match(name):
|
|
53
|
+
raise ValueError(f"Invalid SQL identifier: {name!r}")
|
|
54
|
+
return name
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def execute_query(database_url: str, query: str) -> list[dict]:
|
|
58
|
+
"""Execute a raw SQL query and return rows as list of dicts."""
|
|
59
|
+
from sqlalchemy import text
|
|
60
|
+
|
|
61
|
+
engine = get_engine(database_url)
|
|
62
|
+
async with engine.connect() as conn:
|
|
63
|
+
result = await conn.execute(text(query))
|
|
64
|
+
columns = list(result.keys())
|
|
65
|
+
return [dict(zip(columns, row, strict=False)) for row in result.fetchall()]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Exception hierarchy for kctl-api — thin re-exports from kctl-lib.
|
|
2
|
+
|
|
3
|
+
APIError is kept locally because it wraps httpx.Response.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
from kctl_lib.exceptions import (
|
|
10
|
+
AuthenticationError,
|
|
11
|
+
ConfigError,
|
|
12
|
+
ConnectionError,
|
|
13
|
+
KctlError,
|
|
14
|
+
NotFoundError,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"APIError",
|
|
19
|
+
"AuthenticationError",
|
|
20
|
+
"ConfigError",
|
|
21
|
+
"ConnectionError",
|
|
22
|
+
"KctlError",
|
|
23
|
+
"NotFoundError",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class APIError(KctlError):
|
|
28
|
+
"""REST API error with response details."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, response: httpx.Response | None = None, detail: str = ""):
|
|
31
|
+
if response is not None:
|
|
32
|
+
self.status_code = response.status_code
|
|
33
|
+
self.response = response
|
|
34
|
+
try:
|
|
35
|
+
body = response.json()
|
|
36
|
+
self.detail = body.get("error", "") or body.get("detail", "") or str(body)
|
|
37
|
+
except Exception:
|
|
38
|
+
self.detail = response.text or f"HTTP {self.status_code}"
|
|
39
|
+
else:
|
|
40
|
+
self.status_code = 0
|
|
41
|
+
self.response = None
|
|
42
|
+
self.detail = detail
|
|
43
|
+
super().__init__(f"API error: {self.detail}")
|
kctl_api/core/output.py
ADDED
kctl_api/core/plugins.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Plugin discovery for kctl-api — thin wrapper over kctl-lib.
|
|
2
|
+
|
|
3
|
+
Third-party packages register kctl-api plugins via the ``kctl_api.plugins``
|
|
4
|
+
entry point group. Each entry point must resolve to a class implementing
|
|
5
|
+
:class:`KctlPlugin`.
|
|
6
|
+
|
|
7
|
+
Example ``pyproject.toml`` snippet for a plugin package::
|
|
8
|
+
|
|
9
|
+
[project.entry-points."kctl_api.plugins"]
|
|
10
|
+
my_plugin = "my_package.plugin:MyPlugin"
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
from kctl_lib.plugins import KctlPlugin
|
|
17
|
+
from kctl_lib.plugins import discover_and_load_plugins as _discover
|
|
18
|
+
|
|
19
|
+
__all__ = ["ENTRY_POINT_GROUP", "KctlPlugin", "discover_and_load_plugins"]
|
|
20
|
+
|
|
21
|
+
ENTRY_POINT_GROUP = "kctl_api.plugins"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def discover_and_load_plugins(app: typer.Typer) -> list[str]:
|
|
25
|
+
"""Discover kctl-api plugins via entry points and register them."""
|
|
26
|
+
return _discover(app, ENTRY_POINT_GROUP)
|
kctl_api/core/redis.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Lazy Redis client for direct Redis access.
|
|
2
|
+
|
|
3
|
+
Requires the ``redis`` extra: ``pip install kctl-api[redis]``
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
_redis_clients: dict[str, Any] = {}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_redis(redis_url: str) -> Any:
|
|
14
|
+
"""Get or create a Redis client keyed by URL.
|
|
15
|
+
|
|
16
|
+
Raises ImportError if redis is not installed.
|
|
17
|
+
"""
|
|
18
|
+
if redis_url in _redis_clients:
|
|
19
|
+
return _redis_clients[redis_url]
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
from redis.asyncio import Redis
|
|
23
|
+
except ImportError as e:
|
|
24
|
+
raise ImportError("Redis support requires the 'redis' extra. Install with: pip install kctl-api[redis]") from e
|
|
25
|
+
|
|
26
|
+
client = Redis.from_url(redis_url, decode_responses=True)
|
|
27
|
+
_redis_clients[redis_url] = client
|
|
28
|
+
return client
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def close_redis() -> None:
|
|
32
|
+
"""Close all cached Redis clients."""
|
|
33
|
+
for client in _redis_clients.values():
|
|
34
|
+
await client.aclose()
|
|
35
|
+
_redis_clients.clear()
|
kctl_api/core/resolve.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Shared identifier resolution utilities for API resources."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from kctl_api.core.client import ApiClient
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def resolve_user(
|
|
14
|
+
client: ApiClient,
|
|
15
|
+
identifier: str,
|
|
16
|
+
) -> dict:
|
|
17
|
+
"""Resolve a user by numeric ID or email.
|
|
18
|
+
|
|
19
|
+
Returns the full user dict from the API.
|
|
20
|
+
Raises ``typer.Exit(1)`` when the user cannot be found.
|
|
21
|
+
"""
|
|
22
|
+
# Try numeric ID first
|
|
23
|
+
if identifier.isdigit():
|
|
24
|
+
try:
|
|
25
|
+
return client.get(f"/api/v1/users/{identifier}")
|
|
26
|
+
except Exception as exc:
|
|
27
|
+
typer.echo(f"User not found: {identifier}", err=True)
|
|
28
|
+
raise typer.Exit(1) from exc
|
|
29
|
+
|
|
30
|
+
# Search by email via list endpoint
|
|
31
|
+
data = client.get("/api/v1/users", params={"search": identifier, "per_page": 1})
|
|
32
|
+
items = data.get("items", []) if isinstance(data, dict) else []
|
|
33
|
+
if not items:
|
|
34
|
+
typer.echo(f"User not found: {identifier}", err=True)
|
|
35
|
+
raise typer.Exit(1)
|
|
36
|
+
return items[0]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def resolve_user_id(
|
|
40
|
+
client: ApiClient,
|
|
41
|
+
identifier: str,
|
|
42
|
+
) -> int:
|
|
43
|
+
"""Resolve a user identifier to a numeric ID."""
|
|
44
|
+
if identifier.isdigit():
|
|
45
|
+
return int(identifier)
|
|
46
|
+
user = resolve_user(client, identifier)
|
|
47
|
+
return user["id"]
|