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.
Files changed (41) hide show
  1. kctl_lib-0.4.0/.gitignore +11 -0
  2. kctl_lib-0.4.0/PKG-INFO +21 -0
  3. kctl_lib-0.4.0/README.md +46 -0
  4. kctl_lib-0.4.0/pyproject.toml +46 -0
  5. kctl_lib-0.4.0/src/kctl_lib/__init__.py +80 -0
  6. kctl_lib-0.4.0/src/kctl_lib/api_client.py +214 -0
  7. kctl_lib-0.4.0/src/kctl_lib/async_api_client.py +214 -0
  8. kctl_lib-0.4.0/src/kctl_lib/callbacks.py +37 -0
  9. kctl_lib-0.4.0/src/kctl_lib/completions.py +35 -0
  10. kctl_lib-0.4.0/src/kctl_lib/config.py +168 -0
  11. kctl_lib-0.4.0/src/kctl_lib/docker.py +82 -0
  12. kctl_lib-0.4.0/src/kctl_lib/doctor_base.py +108 -0
  13. kctl_lib-0.4.0/src/kctl_lib/exceptions.py +84 -0
  14. kctl_lib-0.4.0/src/kctl_lib/git_ops.py +82 -0
  15. kctl_lib-0.4.0/src/kctl_lib/history.py +93 -0
  16. kctl_lib-0.4.0/src/kctl_lib/monitor_base.py +70 -0
  17. kctl_lib-0.4.0/src/kctl_lib/output.py +200 -0
  18. kctl_lib-0.4.0/src/kctl_lib/plugins.py +45 -0
  19. kctl_lib-0.4.0/src/kctl_lib/runner.py +63 -0
  20. kctl_lib-0.4.0/src/kctl_lib/self_update.py +31 -0
  21. kctl_lib-0.4.0/src/kctl_lib/skill_generator.py +386 -0
  22. kctl_lib-0.4.0/src/kctl_lib/testing.py +40 -0
  23. kctl_lib-0.4.0/src/kctl_lib/validate.py +86 -0
  24. kctl_lib-0.4.0/tests/conftest.py +1 -0
  25. kctl_lib-0.4.0/tests/test_api_client.py +283 -0
  26. kctl_lib-0.4.0/tests/test_async_api_client.py +186 -0
  27. kctl_lib-0.4.0/tests/test_callbacks.py +63 -0
  28. kctl_lib-0.4.0/tests/test_completions.py +118 -0
  29. kctl_lib-0.4.0/tests/test_config.py +150 -0
  30. kctl_lib-0.4.0/tests/test_docker.py +211 -0
  31. kctl_lib-0.4.0/tests/test_doctor_base.py +66 -0
  32. kctl_lib-0.4.0/tests/test_exceptions.py +129 -0
  33. kctl_lib-0.4.0/tests/test_git_ops.py +212 -0
  34. kctl_lib-0.4.0/tests/test_history.py +72 -0
  35. kctl_lib-0.4.0/tests/test_monitor_base.py +62 -0
  36. kctl_lib-0.4.0/tests/test_output.py +101 -0
  37. kctl_lib-0.4.0/tests/test_plugins.py +54 -0
  38. kctl_lib-0.4.0/tests/test_runner.py +47 -0
  39. kctl_lib-0.4.0/tests/test_self_update.py +84 -0
  40. kctl_lib-0.4.0/tests/test_skill_generator.py +165 -0
  41. kctl_lib-0.4.0/tests/test_validate.py +213 -0
@@ -0,0 +1,11 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ .ruff_cache/
5
+ .mypy_cache/
6
+ .pytest_cache/
7
+ dist/
8
+ build/
9
+ *.egg-info/
10
+ .venv/
11
+ .env
@@ -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'
@@ -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