kctl-lib 0.4.0__tar.gz
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_lib-0.4.0/.gitignore +11 -0
- kctl_lib-0.4.0/PKG-INFO +21 -0
- kctl_lib-0.4.0/README.md +46 -0
- kctl_lib-0.4.0/pyproject.toml +46 -0
- kctl_lib-0.4.0/src/kctl_lib/__init__.py +80 -0
- kctl_lib-0.4.0/src/kctl_lib/api_client.py +214 -0
- kctl_lib-0.4.0/src/kctl_lib/async_api_client.py +214 -0
- kctl_lib-0.4.0/src/kctl_lib/callbacks.py +37 -0
- kctl_lib-0.4.0/src/kctl_lib/completions.py +35 -0
- kctl_lib-0.4.0/src/kctl_lib/config.py +168 -0
- kctl_lib-0.4.0/src/kctl_lib/docker.py +82 -0
- kctl_lib-0.4.0/src/kctl_lib/doctor_base.py +108 -0
- kctl_lib-0.4.0/src/kctl_lib/exceptions.py +84 -0
- kctl_lib-0.4.0/src/kctl_lib/git_ops.py +82 -0
- kctl_lib-0.4.0/src/kctl_lib/history.py +93 -0
- kctl_lib-0.4.0/src/kctl_lib/monitor_base.py +70 -0
- kctl_lib-0.4.0/src/kctl_lib/output.py +200 -0
- kctl_lib-0.4.0/src/kctl_lib/plugins.py +45 -0
- kctl_lib-0.4.0/src/kctl_lib/runner.py +63 -0
- kctl_lib-0.4.0/src/kctl_lib/self_update.py +31 -0
- kctl_lib-0.4.0/src/kctl_lib/skill_generator.py +386 -0
- kctl_lib-0.4.0/src/kctl_lib/testing.py +40 -0
- kctl_lib-0.4.0/src/kctl_lib/validate.py +86 -0
- kctl_lib-0.4.0/tests/conftest.py +1 -0
- kctl_lib-0.4.0/tests/test_api_client.py +283 -0
- kctl_lib-0.4.0/tests/test_async_api_client.py +186 -0
- kctl_lib-0.4.0/tests/test_callbacks.py +63 -0
- kctl_lib-0.4.0/tests/test_completions.py +118 -0
- kctl_lib-0.4.0/tests/test_config.py +150 -0
- kctl_lib-0.4.0/tests/test_docker.py +211 -0
- kctl_lib-0.4.0/tests/test_doctor_base.py +66 -0
- kctl_lib-0.4.0/tests/test_exceptions.py +129 -0
- kctl_lib-0.4.0/tests/test_git_ops.py +212 -0
- kctl_lib-0.4.0/tests/test_history.py +72 -0
- kctl_lib-0.4.0/tests/test_monitor_base.py +62 -0
- kctl_lib-0.4.0/tests/test_output.py +101 -0
- kctl_lib-0.4.0/tests/test_plugins.py +54 -0
- kctl_lib-0.4.0/tests/test_runner.py +47 -0
- kctl_lib-0.4.0/tests/test_self_update.py +84 -0
- kctl_lib-0.4.0/tests/test_skill_generator.py +165 -0
- kctl_lib-0.4.0/tests/test_validate.py +213 -0
kctl_lib-0.4.0/PKG-INFO
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kctl-lib
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Shared core library for kctl-* CLI tools
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Requires-Dist: httpx>=0.28.0
|
|
7
|
+
Requires-Dist: pydantic>=2.10.0
|
|
8
|
+
Requires-Dist: pyyaml>=6.0.2
|
|
9
|
+
Requires-Dist: rich>=13.9.0
|
|
10
|
+
Requires-Dist: typer>=0.15.0
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: httpx>=0.28.0; extra == 'dev'
|
|
13
|
+
Requires-Dist: mypy>=1.14.0; extra == 'dev'
|
|
14
|
+
Requires-Dist: pytest-cov>=6.0.0; extra == 'dev'
|
|
15
|
+
Requires-Dist: pytest-httpx>=0.35.0; extra == 'dev'
|
|
16
|
+
Requires-Dist: pytest>=8.3.0; extra == 'dev'
|
|
17
|
+
Requires-Dist: ruff>=0.9.0; extra == 'dev'
|
|
18
|
+
Requires-Dist: types-pyyaml>=6.0.0; extra == 'dev'
|
|
19
|
+
Provides-Extra: monitor
|
|
20
|
+
Provides-Extra: testing
|
|
21
|
+
Requires-Dist: pytest>=8.3.0; extra == 'testing'
|
kctl_lib-0.4.0/README.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# kctl-common
|
|
2
|
+
|
|
3
|
+
Shared core library for all kctl-* CLI tools. Published to PyPI as `kctl-common`.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# As a dependency (in pyproject.toml)
|
|
9
|
+
dependencies = ["kctl-common>=0.3.1"]
|
|
10
|
+
|
|
11
|
+
# For development
|
|
12
|
+
cd packages/kctl-common
|
|
13
|
+
uv sync --all-extras
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Modules
|
|
17
|
+
|
|
18
|
+
| Module | Purpose |
|
|
19
|
+
|--------|---------|
|
|
20
|
+
| `api_client.py` | `APIClient` — sync HTTP base client with retry, auth, error mapping |
|
|
21
|
+
| `async_api_client.py` | `AsyncAPIClient` — async HTTP base client with retry, auth, error mapping |
|
|
22
|
+
| `callbacks.py` | `AppContextBase` dataclass — lazy Output init, subclassed by each CLI |
|
|
23
|
+
| `completions.py` | Shell completion generation + install (zsh/bash/fish) |
|
|
24
|
+
| `config.py` | Profile framework — `~/.config/kodemeio/config.yaml`, service-scoped, env var expansion |
|
|
25
|
+
| `docker.py` | `DockerManager` — Docker Compose wrapper (up/down/ps/logs/restart/exec) |
|
|
26
|
+
| `doctor_base.py` | `DoctorCheck` protocol + `run_doctor()` + built-in checks |
|
|
27
|
+
| `exceptions.py` | 9 exception classes: KctlError hierarchy |
|
|
28
|
+
| `git_ops.py` | Git workflow helpers — branch_status, pr_create, changelog_generate |
|
|
29
|
+
| `history.py` | `HistoryStore` — SQLite at `~/.local/share/kodemeio/{app}/history.db` |
|
|
30
|
+
| `monitor_base.py` | `health_check_url()`, `ssl_check()`, `dns_check()` |
|
|
31
|
+
| `output.py` | `Output` class — pretty (Rich), json, csv, yaml formatting |
|
|
32
|
+
| `plugins.py` | `KctlPlugin` protocol + plugin discovery |
|
|
33
|
+
| `runner.py` | `run()`, `run_quiet()`, `get_git_sha()`, `get_git_branch()` |
|
|
34
|
+
| `self_update.py` | PyPI version check + uv tool upgrade |
|
|
35
|
+
| `skill_generator.py` | `SkillGenerator` — Typer app introspection for SKILL.md generation |
|
|
36
|
+
| `testing.py` | `mock_output()`, `mock_app_context()`, `temp_config()` |
|
|
37
|
+
| `validate.py` | YAML/JSON/env/Dockerfile linting with `Issue` dataclass |
|
|
38
|
+
|
|
39
|
+
## Development
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
uv run pytest tests/ -v # 247 tests
|
|
43
|
+
uv run ruff check src/ # Lint
|
|
44
|
+
uv run mypy src/ # Type check
|
|
45
|
+
uv build # Build package
|
|
46
|
+
```
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "kctl-lib"
|
|
7
|
+
version = "0.4.0"
|
|
8
|
+
description = "Shared core library for kctl-* CLI tools"
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"typer>=0.15.0",
|
|
12
|
+
"rich>=13.9.0",
|
|
13
|
+
"pydantic>=2.10.0",
|
|
14
|
+
"pyyaml>=6.0.2",
|
|
15
|
+
"httpx>=0.28.0",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.optional-dependencies]
|
|
19
|
+
testing = ["pytest>=8.3.0"]
|
|
20
|
+
monitor = []
|
|
21
|
+
dev = [
|
|
22
|
+
"pytest>=8.3.0",
|
|
23
|
+
"pytest-cov>=6.0.0",
|
|
24
|
+
"pytest-httpx>=0.35.0",
|
|
25
|
+
"ruff>=0.9.0",
|
|
26
|
+
"mypy>=1.14.0",
|
|
27
|
+
"types-PyYAML>=6.0.0",
|
|
28
|
+
"httpx>=0.28.0",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[tool.ruff]
|
|
32
|
+
target-version = "py312"
|
|
33
|
+
line-length = 120
|
|
34
|
+
|
|
35
|
+
[tool.ruff.lint]
|
|
36
|
+
select = ["E", "F", "I", "W", "UP", "B", "SIM", "N"]
|
|
37
|
+
|
|
38
|
+
[tool.mypy]
|
|
39
|
+
python_version = "3.12"
|
|
40
|
+
strict = true
|
|
41
|
+
|
|
42
|
+
[dependency-groups]
|
|
43
|
+
dev = [
|
|
44
|
+
"anyio>=4.13.0",
|
|
45
|
+
"pytest-anyio>=0.0.0",
|
|
46
|
+
]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""kctl-lib: shared core library for kctl-* CLI tools.
|
|
2
|
+
|
|
3
|
+
Public API:
|
|
4
|
+
- exceptions: KctlError hierarchy
|
|
5
|
+
- output: Output class (pretty/json/csv/yaml)
|
|
6
|
+
- config: Profile/config framework
|
|
7
|
+
- callbacks: AppContextBase
|
|
8
|
+
- runner: run(), run_quiet(), git helpers
|
|
9
|
+
- plugins: KctlPlugin protocol + discovery
|
|
10
|
+
- history: HistoryStore
|
|
11
|
+
- docker: DockerManager
|
|
12
|
+
- validate: YAML/JSON/env/Dockerfile linting
|
|
13
|
+
- git_ops: Branch status, PR, changelog, diff
|
|
14
|
+
- completions: Shell completion generation
|
|
15
|
+
- self_update: PyPI version check + upgrade
|
|
16
|
+
- doctor_base: DoctorCheck protocol + built-in checks
|
|
17
|
+
- monitor_base: Health check, SSL, DNS monitoring
|
|
18
|
+
- testing: Test fixtures (optional dependency)
|
|
19
|
+
- api_client: Base APIClient for httpx-based CLIs
|
|
20
|
+
- async_api_client: Base AsyncAPIClient for async CLIs
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
__version__ = "0.4.0"
|
|
24
|
+
|
|
25
|
+
from kctl_lib.api_client import APIClient
|
|
26
|
+
from kctl_lib.async_api_client import AsyncAPIClient
|
|
27
|
+
from kctl_lib.callbacks import AppContextBase
|
|
28
|
+
from kctl_lib.docker import DockerManager
|
|
29
|
+
from kctl_lib.doctor_base import CheckResult, DoctorCheck, run_doctor
|
|
30
|
+
from kctl_lib.exceptions import (
|
|
31
|
+
APIError,
|
|
32
|
+
AppNotFoundError,
|
|
33
|
+
AuthenticationError,
|
|
34
|
+
CommandError,
|
|
35
|
+
ConfigError,
|
|
36
|
+
ConnectionError,
|
|
37
|
+
DockerError,
|
|
38
|
+
KctlError,
|
|
39
|
+
NotFoundError,
|
|
40
|
+
ValidationError,
|
|
41
|
+
)
|
|
42
|
+
from kctl_lib.output import Output
|
|
43
|
+
from kctl_lib.validate import Issue
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"APIClient",
|
|
47
|
+
"APIError",
|
|
48
|
+
"AppContextBase",
|
|
49
|
+
"AppNotFoundError",
|
|
50
|
+
"AsyncAPIClient",
|
|
51
|
+
"AuthenticationError",
|
|
52
|
+
"CheckResult",
|
|
53
|
+
"CommandError",
|
|
54
|
+
"ConfigError",
|
|
55
|
+
"ConnectionError",
|
|
56
|
+
"DockerError",
|
|
57
|
+
"DockerManager",
|
|
58
|
+
"DoctorCheck",
|
|
59
|
+
"Issue",
|
|
60
|
+
"KctlError",
|
|
61
|
+
"NotFoundError",
|
|
62
|
+
"Output",
|
|
63
|
+
"ValidationError",
|
|
64
|
+
"run_doctor",
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def handle_cli_error(e: KctlError) -> None:
|
|
69
|
+
"""Standardized error handler for CLI _run() entry points.
|
|
70
|
+
|
|
71
|
+
Usage in each CLI's cli.py:
|
|
72
|
+
try:
|
|
73
|
+
app()
|
|
74
|
+
except KctlError as e:
|
|
75
|
+
handle_cli_error(e)
|
|
76
|
+
"""
|
|
77
|
+
import typer
|
|
78
|
+
|
|
79
|
+
typer.echo(f"Error: {e}", err=True)
|
|
80
|
+
raise typer.Exit(1) from e
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Base API client with retry, error mapping, and debug logging."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from random import uniform
|
|
9
|
+
from typing import Any, Self
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from kctl_lib.exceptions import APIError, AuthenticationError, ConfigError
|
|
14
|
+
from kctl_lib.exceptions import ConnectionError as KctlConnectionError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class APIClient:
|
|
18
|
+
"""Synchronous base API client for kctl-* CLI tools.
|
|
19
|
+
|
|
20
|
+
Subclass and override class attributes to customise per-service::
|
|
21
|
+
|
|
22
|
+
class MyClient(APIClient):
|
|
23
|
+
BASE_URL = "https://api.example.com"
|
|
24
|
+
AUTH_HEADER = "Authorization"
|
|
25
|
+
AUTH_PREFIX = "Bearer"
|
|
26
|
+
API_PREFIX = "/v1"
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
# Subclass overrides
|
|
30
|
+
AUTH_HEADER: str = "Authorization"
|
|
31
|
+
AUTH_PREFIX: str = "Bearer"
|
|
32
|
+
API_PREFIX: str = ""
|
|
33
|
+
BASE_URL: str = ""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
base_url: str = "",
|
|
38
|
+
credential: str = "",
|
|
39
|
+
timeout: float = 30.0,
|
|
40
|
+
retry_enabled: bool = False,
|
|
41
|
+
max_retries: int = 3,
|
|
42
|
+
retry_base_delay: float = 2.0,
|
|
43
|
+
retry_max_delay: float = 60.0,
|
|
44
|
+
**_kwargs: Any,
|
|
45
|
+
) -> None:
|
|
46
|
+
if not credential or not credential.strip():
|
|
47
|
+
raise ConfigError("API credential is required")
|
|
48
|
+
|
|
49
|
+
resolved_url = base_url or self.BASE_URL
|
|
50
|
+
if not resolved_url:
|
|
51
|
+
raise ConfigError("base_url is required (pass it or set BASE_URL on the class)")
|
|
52
|
+
|
|
53
|
+
# Clean trailing slash
|
|
54
|
+
resolved_url = resolved_url.rstrip("/")
|
|
55
|
+
|
|
56
|
+
# Append API_PREFIX if not already present
|
|
57
|
+
if self.API_PREFIX and not resolved_url.endswith(self.API_PREFIX.rstrip("/")):
|
|
58
|
+
resolved_url = resolved_url + self.API_PREFIX
|
|
59
|
+
|
|
60
|
+
self._base_url = resolved_url
|
|
61
|
+
self._credential = credential
|
|
62
|
+
self._retry_enabled = retry_enabled
|
|
63
|
+
self._max_retries = max_retries
|
|
64
|
+
self._retry_base_delay = retry_base_delay
|
|
65
|
+
self._retry_max_delay = retry_max_delay
|
|
66
|
+
|
|
67
|
+
self._client = httpx.Client(
|
|
68
|
+
base_url=self._base_url,
|
|
69
|
+
headers=self._build_auth_header(),
|
|
70
|
+
timeout=timeout,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# ------------------------------------------------------------------
|
|
74
|
+
# Auth
|
|
75
|
+
# ------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
def _build_auth_header(self) -> dict[str, str]:
|
|
78
|
+
"""Build the authentication header dict."""
|
|
79
|
+
if self.AUTH_PREFIX:
|
|
80
|
+
return {self.AUTH_HEADER: f"{self.AUTH_PREFIX} {self._credential}"}
|
|
81
|
+
return {self.AUTH_HEADER: self._credential}
|
|
82
|
+
|
|
83
|
+
# ------------------------------------------------------------------
|
|
84
|
+
# Core request
|
|
85
|
+
# ------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
def _request(self, method: str, endpoint: str, **kwargs: Any) -> httpx.Response:
|
|
88
|
+
"""Send an HTTP request with optional retry logic."""
|
|
89
|
+
url = endpoint.lstrip("/")
|
|
90
|
+
attempts = 1 + (self._max_retries if self._retry_enabled else 0)
|
|
91
|
+
|
|
92
|
+
last_exc: Exception | None = None
|
|
93
|
+
last_response: httpx.Response | None = None
|
|
94
|
+
|
|
95
|
+
for attempt in range(attempts):
|
|
96
|
+
try:
|
|
97
|
+
self._log_debug(f"{method.upper()} {self._base_url}{url}")
|
|
98
|
+
response = self._client.request(method, url, **kwargs)
|
|
99
|
+
|
|
100
|
+
if response.status_code < 400:
|
|
101
|
+
return response
|
|
102
|
+
|
|
103
|
+
# Auth errors — never retry
|
|
104
|
+
if response.status_code in (401, 403):
|
|
105
|
+
detail = self._map_error(response)
|
|
106
|
+
raise AuthenticationError(detail)
|
|
107
|
+
|
|
108
|
+
# Server errors — retry if enabled
|
|
109
|
+
if response.status_code >= 500 and self._retry_enabled and attempt < attempts - 1:
|
|
110
|
+
last_response = response
|
|
111
|
+
self._sleep_with_jitter(attempt)
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
# Client errors or final server error
|
|
115
|
+
last_response = response
|
|
116
|
+
break
|
|
117
|
+
|
|
118
|
+
except (httpx.ConnectError, httpx.TimeoutException) as exc:
|
|
119
|
+
last_exc = exc
|
|
120
|
+
if self._retry_enabled and attempt < attempts - 1:
|
|
121
|
+
self._sleep_with_jitter(attempt)
|
|
122
|
+
continue
|
|
123
|
+
raise KctlConnectionError(url=self._base_url, cause=exc) from exc
|
|
124
|
+
|
|
125
|
+
# If we broke out of the loop with a response, raise the appropriate error
|
|
126
|
+
if last_response is not None:
|
|
127
|
+
detail = self._map_error(last_response)
|
|
128
|
+
raise APIError(status_code=last_response.status_code, detail=detail)
|
|
129
|
+
|
|
130
|
+
# Should not reach here, but handle connection errors after exhausted retries
|
|
131
|
+
if last_exc is not None:
|
|
132
|
+
raise KctlConnectionError(url=self._base_url, cause=last_exc) from last_exc
|
|
133
|
+
|
|
134
|
+
raise APIError(detail="Unexpected request state") # pragma: no cover
|
|
135
|
+
|
|
136
|
+
# ------------------------------------------------------------------
|
|
137
|
+
# Response unwrapping
|
|
138
|
+
# ------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
def _unwrap_response(self, response: httpx.Response) -> Any:
|
|
141
|
+
"""Parse the response body. Override for envelope unwrapping."""
|
|
142
|
+
if not response.text.strip():
|
|
143
|
+
return {}
|
|
144
|
+
return response.json()
|
|
145
|
+
|
|
146
|
+
# ------------------------------------------------------------------
|
|
147
|
+
# Error helpers
|
|
148
|
+
# ------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
@staticmethod
|
|
151
|
+
def _map_error(response: httpx.Response) -> str:
|
|
152
|
+
"""Extract a human-readable error detail from the response."""
|
|
153
|
+
try:
|
|
154
|
+
data = response.json()
|
|
155
|
+
if isinstance(data, dict):
|
|
156
|
+
for key in ("detail", "error", "message"):
|
|
157
|
+
if key in data:
|
|
158
|
+
val = data[key]
|
|
159
|
+
return str(val) if not isinstance(val, str) else val
|
|
160
|
+
except Exception: # noqa: BLE001
|
|
161
|
+
pass
|
|
162
|
+
return response.text[:200] if response.text else f"HTTP {response.status_code}"
|
|
163
|
+
|
|
164
|
+
# ------------------------------------------------------------------
|
|
165
|
+
# Retry helpers
|
|
166
|
+
# ------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
def _sleep_with_jitter(self, attempt: int) -> None:
|
|
169
|
+
"""Exponential backoff with full jitter."""
|
|
170
|
+
ceiling = min(self._retry_base_delay * (2**attempt), self._retry_max_delay)
|
|
171
|
+
time.sleep(uniform(0, ceiling)) # noqa: S311
|
|
172
|
+
|
|
173
|
+
# ------------------------------------------------------------------
|
|
174
|
+
# Debug logging
|
|
175
|
+
# ------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
@staticmethod
|
|
178
|
+
def _log_debug(msg: str) -> None:
|
|
179
|
+
"""Print debug info to stderr when KCTL_DEBUG is set."""
|
|
180
|
+
if os.environ.get("KCTL_DEBUG"):
|
|
181
|
+
print(f"[DEBUG] {msg}", file=sys.stderr) # noqa: T201
|
|
182
|
+
|
|
183
|
+
# ------------------------------------------------------------------
|
|
184
|
+
# CRUD convenience methods
|
|
185
|
+
# ------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
def get(self, endpoint: str, **kwargs: Any) -> Any:
|
|
188
|
+
return self._unwrap_response(self._request("GET", endpoint, **kwargs))
|
|
189
|
+
|
|
190
|
+
def post(self, endpoint: str, **kwargs: Any) -> Any:
|
|
191
|
+
return self._unwrap_response(self._request("POST", endpoint, **kwargs))
|
|
192
|
+
|
|
193
|
+
def put(self, endpoint: str, **kwargs: Any) -> Any:
|
|
194
|
+
return self._unwrap_response(self._request("PUT", endpoint, **kwargs))
|
|
195
|
+
|
|
196
|
+
def patch(self, endpoint: str, **kwargs: Any) -> Any:
|
|
197
|
+
return self._unwrap_response(self._request("PATCH", endpoint, **kwargs))
|
|
198
|
+
|
|
199
|
+
def delete(self, endpoint: str, **kwargs: Any) -> Any:
|
|
200
|
+
return self._unwrap_response(self._request("DELETE", endpoint, **kwargs))
|
|
201
|
+
|
|
202
|
+
# ------------------------------------------------------------------
|
|
203
|
+
# Context manager
|
|
204
|
+
# ------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
def __enter__(self) -> Self:
|
|
207
|
+
return self
|
|
208
|
+
|
|
209
|
+
def __exit__(self, *_args: object) -> None:
|
|
210
|
+
self.close()
|
|
211
|
+
|
|
212
|
+
def close(self) -> None:
|
|
213
|
+
"""Close the underlying HTTP client."""
|
|
214
|
+
self._client.close()
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Base async API client with retry, error mapping, and debug logging."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from random import uniform
|
|
9
|
+
from typing import Any, Self
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from kctl_lib.exceptions import APIError, AuthenticationError, ConfigError
|
|
14
|
+
from kctl_lib.exceptions import ConnectionError as KctlConnectionError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AsyncAPIClient:
|
|
18
|
+
"""Asynchronous base API client for kctl-* CLI tools.
|
|
19
|
+
|
|
20
|
+
Subclass and override class attributes to customise per-service::
|
|
21
|
+
|
|
22
|
+
class MyAsyncClient(AsyncAPIClient):
|
|
23
|
+
BASE_URL = "https://api.example.com"
|
|
24
|
+
AUTH_HEADER = "Authorization"
|
|
25
|
+
AUTH_PREFIX = "Bearer"
|
|
26
|
+
API_PREFIX = "/v1"
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
# Subclass overrides
|
|
30
|
+
AUTH_HEADER: str = "Authorization"
|
|
31
|
+
AUTH_PREFIX: str = "Bearer"
|
|
32
|
+
API_PREFIX: str = ""
|
|
33
|
+
BASE_URL: str = ""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
base_url: str = "",
|
|
38
|
+
credential: str = "",
|
|
39
|
+
timeout: float = 30.0,
|
|
40
|
+
retry_enabled: bool = False,
|
|
41
|
+
max_retries: int = 3,
|
|
42
|
+
retry_base_delay: float = 2.0,
|
|
43
|
+
retry_max_delay: float = 60.0,
|
|
44
|
+
**_kwargs: Any,
|
|
45
|
+
) -> None:
|
|
46
|
+
if not credential or not credential.strip():
|
|
47
|
+
raise ConfigError("API credential is required")
|
|
48
|
+
|
|
49
|
+
resolved_url = base_url or self.BASE_URL
|
|
50
|
+
if not resolved_url:
|
|
51
|
+
raise ConfigError("base_url is required (pass it or set BASE_URL on the class)")
|
|
52
|
+
|
|
53
|
+
# Clean trailing slash
|
|
54
|
+
resolved_url = resolved_url.rstrip("/")
|
|
55
|
+
|
|
56
|
+
# Append API_PREFIX if not already present
|
|
57
|
+
if self.API_PREFIX and not resolved_url.endswith(self.API_PREFIX.rstrip("/")):
|
|
58
|
+
resolved_url = resolved_url + self.API_PREFIX
|
|
59
|
+
|
|
60
|
+
self._base_url = resolved_url
|
|
61
|
+
self._credential = credential
|
|
62
|
+
self._retry_enabled = retry_enabled
|
|
63
|
+
self._max_retries = max_retries
|
|
64
|
+
self._retry_base_delay = retry_base_delay
|
|
65
|
+
self._retry_max_delay = retry_max_delay
|
|
66
|
+
|
|
67
|
+
self._client = httpx.AsyncClient(
|
|
68
|
+
base_url=self._base_url,
|
|
69
|
+
headers=self._build_auth_header(),
|
|
70
|
+
timeout=timeout,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# ------------------------------------------------------------------
|
|
74
|
+
# Auth
|
|
75
|
+
# ------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
def _build_auth_header(self) -> dict[str, str]:
|
|
78
|
+
"""Build the authentication header dict."""
|
|
79
|
+
if self.AUTH_PREFIX:
|
|
80
|
+
return {self.AUTH_HEADER: f"{self.AUTH_PREFIX} {self._credential}"}
|
|
81
|
+
return {self.AUTH_HEADER: self._credential}
|
|
82
|
+
|
|
83
|
+
# ------------------------------------------------------------------
|
|
84
|
+
# Core request
|
|
85
|
+
# ------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
async def _request(self, method: str, endpoint: str, **kwargs: Any) -> httpx.Response:
|
|
88
|
+
"""Send an HTTP request with optional retry logic."""
|
|
89
|
+
url = endpoint.lstrip("/")
|
|
90
|
+
attempts = 1 + (self._max_retries if self._retry_enabled else 0)
|
|
91
|
+
|
|
92
|
+
last_exc: Exception | None = None
|
|
93
|
+
last_response: httpx.Response | None = None
|
|
94
|
+
|
|
95
|
+
for attempt in range(attempts):
|
|
96
|
+
try:
|
|
97
|
+
self._log_debug(f"{method.upper()} {self._base_url}{url}")
|
|
98
|
+
response = await self._client.request(method, url, **kwargs)
|
|
99
|
+
|
|
100
|
+
if response.status_code < 400:
|
|
101
|
+
return response
|
|
102
|
+
|
|
103
|
+
# Auth errors — never retry
|
|
104
|
+
if response.status_code in (401, 403):
|
|
105
|
+
detail = self._map_error(response)
|
|
106
|
+
raise AuthenticationError(detail)
|
|
107
|
+
|
|
108
|
+
# Server errors — retry if enabled
|
|
109
|
+
if response.status_code >= 500 and self._retry_enabled and attempt < attempts - 1:
|
|
110
|
+
last_response = response
|
|
111
|
+
await self._sleep_with_jitter(attempt)
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
# Client errors or final server error
|
|
115
|
+
last_response = response
|
|
116
|
+
break
|
|
117
|
+
|
|
118
|
+
except (httpx.ConnectError, httpx.TimeoutException) as exc:
|
|
119
|
+
last_exc = exc
|
|
120
|
+
if self._retry_enabled and attempt < attempts - 1:
|
|
121
|
+
await self._sleep_with_jitter(attempt)
|
|
122
|
+
continue
|
|
123
|
+
raise KctlConnectionError(url=self._base_url, cause=exc) from exc
|
|
124
|
+
|
|
125
|
+
# If we broke out of the loop with a response, raise the appropriate error
|
|
126
|
+
if last_response is not None:
|
|
127
|
+
detail = self._map_error(last_response)
|
|
128
|
+
raise APIError(status_code=last_response.status_code, detail=detail)
|
|
129
|
+
|
|
130
|
+
# Should not reach here, but handle connection errors after exhausted retries
|
|
131
|
+
if last_exc is not None:
|
|
132
|
+
raise KctlConnectionError(url=self._base_url, cause=last_exc) from last_exc
|
|
133
|
+
|
|
134
|
+
raise APIError(detail="Unexpected request state") # pragma: no cover
|
|
135
|
+
|
|
136
|
+
# ------------------------------------------------------------------
|
|
137
|
+
# Response unwrapping
|
|
138
|
+
# ------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
def _unwrap_response(self, response: httpx.Response) -> Any:
|
|
141
|
+
"""Parse the response body. Override for envelope unwrapping."""
|
|
142
|
+
if not response.text.strip():
|
|
143
|
+
return {}
|
|
144
|
+
return response.json()
|
|
145
|
+
|
|
146
|
+
# ------------------------------------------------------------------
|
|
147
|
+
# Error helpers
|
|
148
|
+
# ------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
@staticmethod
|
|
151
|
+
def _map_error(response: httpx.Response) -> str:
|
|
152
|
+
"""Extract a human-readable error detail from the response."""
|
|
153
|
+
try:
|
|
154
|
+
data = response.json()
|
|
155
|
+
if isinstance(data, dict):
|
|
156
|
+
for key in ("detail", "error", "message"):
|
|
157
|
+
if key in data:
|
|
158
|
+
val = data[key]
|
|
159
|
+
return str(val) if not isinstance(val, str) else val
|
|
160
|
+
except Exception: # noqa: BLE001
|
|
161
|
+
pass
|
|
162
|
+
return response.text[:200] if response.text else f"HTTP {response.status_code}"
|
|
163
|
+
|
|
164
|
+
# ------------------------------------------------------------------
|
|
165
|
+
# Retry helpers
|
|
166
|
+
# ------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
async def _sleep_with_jitter(self, attempt: int) -> None:
|
|
169
|
+
"""Exponential backoff with full jitter."""
|
|
170
|
+
ceiling = min(self._retry_base_delay * (2**attempt), self._retry_max_delay)
|
|
171
|
+
await asyncio.sleep(uniform(0, ceiling)) # noqa: S311
|
|
172
|
+
|
|
173
|
+
# ------------------------------------------------------------------
|
|
174
|
+
# Debug logging
|
|
175
|
+
# ------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
@staticmethod
|
|
178
|
+
def _log_debug(msg: str) -> None:
|
|
179
|
+
"""Print debug info to stderr when KCTL_DEBUG is set."""
|
|
180
|
+
if os.environ.get("KCTL_DEBUG"):
|
|
181
|
+
print(f"[DEBUG] {msg}", file=sys.stderr) # noqa: T201
|
|
182
|
+
|
|
183
|
+
# ------------------------------------------------------------------
|
|
184
|
+
# CRUD convenience methods
|
|
185
|
+
# ------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
async def get(self, endpoint: str, **kwargs: Any) -> Any:
|
|
188
|
+
return self._unwrap_response(await self._request("GET", endpoint, **kwargs))
|
|
189
|
+
|
|
190
|
+
async def post(self, endpoint: str, **kwargs: Any) -> Any:
|
|
191
|
+
return self._unwrap_response(await self._request("POST", endpoint, **kwargs))
|
|
192
|
+
|
|
193
|
+
async def put(self, endpoint: str, **kwargs: Any) -> Any:
|
|
194
|
+
return self._unwrap_response(await self._request("PUT", endpoint, **kwargs))
|
|
195
|
+
|
|
196
|
+
async def patch(self, endpoint: str, **kwargs: Any) -> Any:
|
|
197
|
+
return self._unwrap_response(await self._request("PATCH", endpoint, **kwargs))
|
|
198
|
+
|
|
199
|
+
async def delete(self, endpoint: str, **kwargs: Any) -> Any:
|
|
200
|
+
return self._unwrap_response(await self._request("DELETE", endpoint, **kwargs))
|
|
201
|
+
|
|
202
|
+
# ------------------------------------------------------------------
|
|
203
|
+
# Async context manager
|
|
204
|
+
# ------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
async def __aenter__(self) -> Self:
|
|
207
|
+
return self
|
|
208
|
+
|
|
209
|
+
async def __aexit__(self, *_args: object) -> None:
|
|
210
|
+
await self.close()
|
|
211
|
+
|
|
212
|
+
async def close(self) -> None:
|
|
213
|
+
"""Close the underlying HTTP client."""
|
|
214
|
+
await self._client.aclose()
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Abstract base context for kctl-* CLI tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from kctl_lib.output import Output
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class AppContextBase:
|
|
12
|
+
"""Base application context with lazy Output initialization.
|
|
13
|
+
|
|
14
|
+
Subclass in each CLI to add domain-specific properties:
|
|
15
|
+
- Monorepo CLIs (next, react): project_root, apps, packages, validate_app()
|
|
16
|
+
- Server CLIs (odoo, api): url_override, api_key_override, client
|
|
17
|
+
- Claw: root_override, live, docker, gateway, config_mgr
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
json_mode: bool = False
|
|
21
|
+
quiet: bool = False
|
|
22
|
+
profile: str | None = None
|
|
23
|
+
format: str = "pretty"
|
|
24
|
+
no_header: bool = False
|
|
25
|
+
_output: Output | None = field(default=None, repr=False)
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def output(self) -> Output:
|
|
29
|
+
"""Lazy-initialized output handler."""
|
|
30
|
+
if self._output is None:
|
|
31
|
+
self._output = Output(
|
|
32
|
+
json_mode=self.json_mode,
|
|
33
|
+
quiet=self.quiet,
|
|
34
|
+
format=self.format,
|
|
35
|
+
no_header=self.no_header,
|
|
36
|
+
)
|
|
37
|
+
return self._output
|