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.
Files changed (66) hide show
  1. kctl_api/__init__.py +3 -0
  2. kctl_api/__main__.py +5 -0
  3. kctl_api/cli.py +238 -0
  4. kctl_api/commands/__init__.py +1 -0
  5. kctl_api/commands/ai.py +250 -0
  6. kctl_api/commands/aliases.py +84 -0
  7. kctl_api/commands/apps.py +172 -0
  8. kctl_api/commands/auth.py +313 -0
  9. kctl_api/commands/automation.py +242 -0
  10. kctl_api/commands/build_cmd.py +87 -0
  11. kctl_api/commands/clean.py +182 -0
  12. kctl_api/commands/config_cmd.py +443 -0
  13. kctl_api/commands/dashboard.py +139 -0
  14. kctl_api/commands/db.py +599 -0
  15. kctl_api/commands/deploy.py +84 -0
  16. kctl_api/commands/deps.py +289 -0
  17. kctl_api/commands/dev.py +136 -0
  18. kctl_api/commands/docker_cmd.py +252 -0
  19. kctl_api/commands/doctor_cmd.py +286 -0
  20. kctl_api/commands/env.py +289 -0
  21. kctl_api/commands/files.py +250 -0
  22. kctl_api/commands/fmt_cmd.py +58 -0
  23. kctl_api/commands/health.py +479 -0
  24. kctl_api/commands/jobs.py +169 -0
  25. kctl_api/commands/lint_cmd.py +81 -0
  26. kctl_api/commands/logs.py +258 -0
  27. kctl_api/commands/marketplace.py +316 -0
  28. kctl_api/commands/monitor_cmd.py +243 -0
  29. kctl_api/commands/notifications.py +132 -0
  30. kctl_api/commands/odoo_proxy.py +182 -0
  31. kctl_api/commands/openapi.py +299 -0
  32. kctl_api/commands/perf.py +307 -0
  33. kctl_api/commands/rate_limit.py +223 -0
  34. kctl_api/commands/realtime.py +100 -0
  35. kctl_api/commands/redis_cmd.py +609 -0
  36. kctl_api/commands/routes_cmd.py +277 -0
  37. kctl_api/commands/saas.py +145 -0
  38. kctl_api/commands/scaffold.py +362 -0
  39. kctl_api/commands/security_cmd.py +350 -0
  40. kctl_api/commands/services.py +191 -0
  41. kctl_api/commands/shell.py +197 -0
  42. kctl_api/commands/skill_cmd.py +58 -0
  43. kctl_api/commands/streams.py +309 -0
  44. kctl_api/commands/stripe_cmd.py +105 -0
  45. kctl_api/commands/tenant_ai.py +169 -0
  46. kctl_api/commands/test_cmd.py +95 -0
  47. kctl_api/commands/users.py +302 -0
  48. kctl_api/commands/webhooks.py +56 -0
  49. kctl_api/commands/workflows.py +127 -0
  50. kctl_api/commands/ws.py +323 -0
  51. kctl_api/core/__init__.py +1 -0
  52. kctl_api/core/async_client.py +120 -0
  53. kctl_api/core/callbacks.py +88 -0
  54. kctl_api/core/client.py +190 -0
  55. kctl_api/core/config.py +260 -0
  56. kctl_api/core/db.py +65 -0
  57. kctl_api/core/exceptions.py +43 -0
  58. kctl_api/core/output.py +5 -0
  59. kctl_api/core/plugins.py +26 -0
  60. kctl_api/core/redis.py +35 -0
  61. kctl_api/core/resolve.py +47 -0
  62. kctl_api/core/utils.py +109 -0
  63. kctl_api-0.2.0.dist-info/METADATA +34 -0
  64. kctl_api-0.2.0.dist-info/RECORD +66 -0
  65. kctl_api-0.2.0.dist-info/WHEEL +4 -0
  66. kctl_api-0.2.0.dist-info/entry_points.txt +2 -0
@@ -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
+ )
@@ -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}")
@@ -0,0 +1,5 @@
1
+ """Output handler — re-exported from kctl-lib."""
2
+
3
+ from kctl_lib.output import Output
4
+
5
+ __all__ = ["Output"]
@@ -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()
@@ -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"]