kctl-common 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kctl_common/__init__.py +59 -0
- kctl_common/callbacks.py +37 -0
- kctl_common/config.py +168 -0
- kctl_common/exceptions.py +84 -0
- kctl_common/history.py +93 -0
- kctl_common/output.py +200 -0
- kctl_common/plugins.py +45 -0
- kctl_common/runner.py +58 -0
- kctl_common/testing.py +40 -0
- kctl_common-0.1.0.dist-info/METADATA +17 -0
- kctl_common-0.1.0.dist-info/RECORD +12 -0
- kctl_common-0.1.0.dist-info/WHEEL +4 -0
kctl_common/__init__.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""kctl-common: 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
|
+
- testing: Test fixtures (optional dependency)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
__version__ = "0.1.0"
|
|
15
|
+
|
|
16
|
+
from kctl_common.callbacks import AppContextBase
|
|
17
|
+
from kctl_common.exceptions import (
|
|
18
|
+
APIError,
|
|
19
|
+
AppNotFoundError,
|
|
20
|
+
AuthenticationError,
|
|
21
|
+
CommandError,
|
|
22
|
+
ConfigError,
|
|
23
|
+
ConnectionError,
|
|
24
|
+
DockerError,
|
|
25
|
+
KctlError,
|
|
26
|
+
NotFoundError,
|
|
27
|
+
ValidationError,
|
|
28
|
+
)
|
|
29
|
+
from kctl_common.output import Output
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"APIError",
|
|
33
|
+
"AppContextBase",
|
|
34
|
+
"AppNotFoundError",
|
|
35
|
+
"AuthenticationError",
|
|
36
|
+
"CommandError",
|
|
37
|
+
"ConfigError",
|
|
38
|
+
"ConnectionError",
|
|
39
|
+
"DockerError",
|
|
40
|
+
"KctlError",
|
|
41
|
+
"NotFoundError",
|
|
42
|
+
"Output",
|
|
43
|
+
"ValidationError",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def handle_cli_error(e: KctlError) -> None:
|
|
48
|
+
"""Standardized error handler for CLI _run() entry points.
|
|
49
|
+
|
|
50
|
+
Usage in each CLI's cli.py:
|
|
51
|
+
try:
|
|
52
|
+
app()
|
|
53
|
+
except KctlError as e:
|
|
54
|
+
handle_cli_error(e)
|
|
55
|
+
"""
|
|
56
|
+
import typer
|
|
57
|
+
|
|
58
|
+
typer.echo(f"Error: {e}", err=True)
|
|
59
|
+
raise typer.Exit(1) from e
|
kctl_common/callbacks.py
ADDED
|
@@ -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_common.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
|
kctl_common/config.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Profile management and configuration framework for kctl-* CLIs.
|
|
2
|
+
|
|
3
|
+
Shared config at ~/.config/kodemeio/config.yaml supports multiple services.
|
|
4
|
+
Each CLI provides its own SERVICE_KEY (e.g., "next", "odoo", "react").
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import yaml
|
|
16
|
+
from pydantic import BaseModel
|
|
17
|
+
|
|
18
|
+
CONFIG_DIR = Path.home() / ".config" / "kodemeio"
|
|
19
|
+
CONFIG_FILE = CONFIG_DIR / "config.yaml"
|
|
20
|
+
|
|
21
|
+
_ENV_VAR_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ConfigFile(BaseModel):
|
|
25
|
+
"""Top-level config file model."""
|
|
26
|
+
|
|
27
|
+
default_profile: str = "default"
|
|
28
|
+
profiles: dict[str, dict[str, Any]] = {}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def expand_env(value: str) -> str:
|
|
32
|
+
"""Expand ${ENV_VAR} references in a string value."""
|
|
33
|
+
|
|
34
|
+
def _replace(match: re.Match[str]) -> str:
|
|
35
|
+
var_name = match.group(1)
|
|
36
|
+
return os.environ.get(var_name, match.group(0))
|
|
37
|
+
|
|
38
|
+
return _ENV_VAR_RE.sub(_replace, value)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def is_service_scoped(profile_data: dict[str, Any]) -> bool:
|
|
42
|
+
"""Check if profile data uses service-scoped format (nested dicts)."""
|
|
43
|
+
return any(isinstance(val, dict) for val in profile_data.values())
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def load_raw_config() -> dict[str, Any]:
|
|
47
|
+
"""Load raw YAML config from disk."""
|
|
48
|
+
if not CONFIG_FILE.exists():
|
|
49
|
+
return {}
|
|
50
|
+
try:
|
|
51
|
+
with open(CONFIG_FILE) as f:
|
|
52
|
+
return yaml.safe_load(f) or {}
|
|
53
|
+
except (yaml.YAMLError, OSError) as e:
|
|
54
|
+
print(f"WARN: Cannot read {CONFIG_FILE}: {e}", file=sys.stderr)
|
|
55
|
+
return {}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def save_raw_config(data: dict[str, Any]) -> None:
|
|
59
|
+
"""Write raw config dict to YAML file."""
|
|
60
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
with open(CONFIG_FILE, "w") as f:
|
|
62
|
+
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def load_config() -> ConfigFile:
|
|
66
|
+
"""Load and validate the config file."""
|
|
67
|
+
data = load_raw_config()
|
|
68
|
+
return ConfigFile(
|
|
69
|
+
default_profile=data.get("default_profile", "default"),
|
|
70
|
+
profiles=data.get("profiles", {}),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def get_service_config(
|
|
75
|
+
profile_name: str,
|
|
76
|
+
service_key: str,
|
|
77
|
+
valid_fields: list[str] | None = None,
|
|
78
|
+
) -> dict[str, Any]:
|
|
79
|
+
"""Get a service config dict from a profile."""
|
|
80
|
+
cfg = load_config()
|
|
81
|
+
profile_data = cfg.profiles.get(profile_name, {})
|
|
82
|
+
if not profile_data:
|
|
83
|
+
return {}
|
|
84
|
+
|
|
85
|
+
if is_service_scoped(profile_data):
|
|
86
|
+
svc_data = profile_data.get(service_key, {})
|
|
87
|
+
if not isinstance(svc_data, dict):
|
|
88
|
+
return {}
|
|
89
|
+
raw = dict(svc_data)
|
|
90
|
+
else:
|
|
91
|
+
raw = dict(profile_data)
|
|
92
|
+
|
|
93
|
+
for k, v in raw.items():
|
|
94
|
+
if isinstance(v, str):
|
|
95
|
+
raw[k] = expand_env(v)
|
|
96
|
+
|
|
97
|
+
if valid_fields is not None:
|
|
98
|
+
raw = {k: v for k, v in raw.items() if k in valid_fields}
|
|
99
|
+
|
|
100
|
+
return raw
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def set_service_config(profile_name: str, service_key: str, svc_data: dict[str, Any]) -> None:
|
|
104
|
+
"""Set a service config within a profile."""
|
|
105
|
+
data = load_raw_config()
|
|
106
|
+
if "profiles" not in data:
|
|
107
|
+
data["profiles"] = {}
|
|
108
|
+
if profile_name not in data["profiles"]:
|
|
109
|
+
data["profiles"][profile_name] = {}
|
|
110
|
+
|
|
111
|
+
profile = data["profiles"][profile_name]
|
|
112
|
+
|
|
113
|
+
if not is_service_scoped(profile):
|
|
114
|
+
old_data = dict(profile)
|
|
115
|
+
profile.clear()
|
|
116
|
+
profile[service_key] = old_data
|
|
117
|
+
|
|
118
|
+
cleaned = {k: v for k, v in svc_data.items() if v}
|
|
119
|
+
profile[service_key] = cleaned
|
|
120
|
+
save_raw_config(data)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def get_profile_names() -> list[str]:
|
|
124
|
+
"""Return all profile names."""
|
|
125
|
+
cfg = load_config()
|
|
126
|
+
return list(cfg.profiles.keys())
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def get_all_services_in_profile(profile_name: str) -> dict[str, dict[str, Any]]:
|
|
130
|
+
"""Return all service configs within a profile."""
|
|
131
|
+
cfg = load_config()
|
|
132
|
+
profile_data = cfg.profiles.get(profile_name, {})
|
|
133
|
+
if is_service_scoped(profile_data):
|
|
134
|
+
return {k: v for k, v in profile_data.items() if isinstance(v, dict)}
|
|
135
|
+
return {}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def get_default_profile() -> str:
|
|
139
|
+
"""Return the default profile name."""
|
|
140
|
+
cfg = load_config()
|
|
141
|
+
return cfg.default_profile
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def set_default_profile(name: str) -> None:
|
|
145
|
+
"""Set the default profile name."""
|
|
146
|
+
data = load_raw_config()
|
|
147
|
+
data["default_profile"] = name
|
|
148
|
+
save_raw_config(data)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def remove_profile(name: str) -> None:
|
|
152
|
+
"""Remove a profile by name."""
|
|
153
|
+
data = load_raw_config()
|
|
154
|
+
profiles = data.get("profiles", {})
|
|
155
|
+
profiles.pop(name, None)
|
|
156
|
+
if data.get("default_profile") == name:
|
|
157
|
+
data["default_profile"] = next(iter(profiles), "default")
|
|
158
|
+
save_raw_config(data)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def resolve_active_profile_name(profile_name: str | None, env_prefix: str) -> str:
|
|
162
|
+
"""Resolve active profile: explicit > env > default."""
|
|
163
|
+
if profile_name:
|
|
164
|
+
return profile_name
|
|
165
|
+
env_var = f"{env_prefix}_PROFILE"
|
|
166
|
+
if env := os.environ.get(env_var):
|
|
167
|
+
return env
|
|
168
|
+
return get_default_profile()
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Unified exception hierarchy for all kctl-* CLI tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class KctlError(Exception):
|
|
7
|
+
"""Base exception for all kctl errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ConfigError(KctlError):
|
|
11
|
+
"""Configuration-related errors."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AuthenticationError(KctlError):
|
|
15
|
+
"""Authentication/API key errors."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class NotFoundError(KctlError):
|
|
19
|
+
"""Resource not found."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, resource_type: str, identifier: str):
|
|
22
|
+
self.resource_type = resource_type
|
|
23
|
+
self.identifier = identifier
|
|
24
|
+
super().__init__(f"{resource_type} not found: {identifier}")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AppNotFoundError(NotFoundError):
|
|
28
|
+
"""App not found in monorepo."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, app_name: str, valid_apps: list[str] | None = None):
|
|
31
|
+
self.valid_apps = valid_apps or []
|
|
32
|
+
super().__init__("app", app_name)
|
|
33
|
+
|
|
34
|
+
def __str__(self) -> str:
|
|
35
|
+
hint = f" (valid: {', '.join(self.valid_apps)})" if self.valid_apps else ""
|
|
36
|
+
return f"App not found: {self.identifier}{hint}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class CommandError(KctlError):
|
|
40
|
+
"""Shell command execution errors."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, command: str, returncode: int, stderr: str = ""):
|
|
43
|
+
self.command = command
|
|
44
|
+
self.returncode = returncode
|
|
45
|
+
self.stderr = stderr
|
|
46
|
+
super().__init__(f"Command failed (exit {returncode}): {command}\n{stderr}".strip())
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class APIError(KctlError):
|
|
50
|
+
"""HTTP API errors."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, status_code: int = 0, detail: str = ""):
|
|
53
|
+
self.status_code = status_code
|
|
54
|
+
self.detail = detail
|
|
55
|
+
msg = f"API error ({status_code}): {detail}" if status_code else detail
|
|
56
|
+
super().__init__(msg)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ConnectionError(KctlError): # noqa: A001
|
|
60
|
+
"""Cannot connect to remote service."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, url: str, cause: Exception | None = None):
|
|
63
|
+
self.url = url
|
|
64
|
+
self.cause = cause
|
|
65
|
+
super().__init__(f"Cannot connect to {url}: {cause}")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class DockerError(KctlError):
|
|
69
|
+
"""Docker compose/exec execution errors."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, command: str, returncode: int, stderr: str = ""):
|
|
72
|
+
self.command = command
|
|
73
|
+
self.returncode = returncode
|
|
74
|
+
self.stderr = stderr
|
|
75
|
+
super().__init__(f"Command failed (exit {returncode}): {command}\n{stderr}".strip())
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ValidationError(KctlError):
|
|
79
|
+
"""Multi-error validation failures."""
|
|
80
|
+
|
|
81
|
+
def __init__(self, errors: list[str]):
|
|
82
|
+
self.errors = errors
|
|
83
|
+
msg = f"{len(errors)} validation error(s):\n" + "\n".join(f" - {e}" for e in errors)
|
|
84
|
+
super().__init__(msg)
|
kctl_common/history.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""SQLite-based history tracking for kctl-* CLI tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sqlite3
|
|
6
|
+
from collections.abc import Generator
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
DATA_BASE_DIR = Path.home() / ".local" / "share" / "kodemeio"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class HistoryStore:
|
|
16
|
+
"""SQLite-based history storage for a kctl-* CLI."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, app_name: str):
|
|
19
|
+
self._app_name = app_name
|
|
20
|
+
self._data_dir = DATA_BASE_DIR / app_name
|
|
21
|
+
self._db_path = self._data_dir / "history.db"
|
|
22
|
+
self._schema_initialized = False
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def db_path(self) -> Path:
|
|
26
|
+
return self._db_path
|
|
27
|
+
|
|
28
|
+
@contextmanager
|
|
29
|
+
def _connect(self) -> Generator[sqlite3.Connection, None, None]:
|
|
30
|
+
"""Context manager for safe DB connections."""
|
|
31
|
+
self._data_dir.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
conn = sqlite3.connect(str(self._db_path))
|
|
33
|
+
conn.row_factory = sqlite3.Row
|
|
34
|
+
try:
|
|
35
|
+
yield conn
|
|
36
|
+
finally:
|
|
37
|
+
conn.close()
|
|
38
|
+
|
|
39
|
+
def ensure_schema(self, statements: list[str]) -> None:
|
|
40
|
+
"""Run CREATE TABLE statements once per process."""
|
|
41
|
+
if self._schema_initialized:
|
|
42
|
+
return
|
|
43
|
+
with self._connect() as conn:
|
|
44
|
+
for stmt in statements:
|
|
45
|
+
conn.execute(stmt)
|
|
46
|
+
conn.commit()
|
|
47
|
+
self._schema_initialized = True
|
|
48
|
+
|
|
49
|
+
def record(self, table: str, **kwargs: Any) -> int:
|
|
50
|
+
"""Insert a record into a table. Adds timestamp automatically."""
|
|
51
|
+
kwargs["timestamp"] = datetime.now(UTC).isoformat()
|
|
52
|
+
columns = ", ".join(kwargs.keys())
|
|
53
|
+
placeholders = ", ".join("?" for _ in kwargs)
|
|
54
|
+
values = list(kwargs.values())
|
|
55
|
+
|
|
56
|
+
with self._connect() as conn:
|
|
57
|
+
cur = conn.execute(f"INSERT INTO {table} ({columns}) VALUES ({placeholders})", values) # noqa: S608
|
|
58
|
+
conn.commit()
|
|
59
|
+
return cur.lastrowid or 0
|
|
60
|
+
|
|
61
|
+
def query(
|
|
62
|
+
self,
|
|
63
|
+
table: str,
|
|
64
|
+
limit: int = 20,
|
|
65
|
+
order_by: str = "timestamp DESC",
|
|
66
|
+
where: dict[str, Any] | None = None,
|
|
67
|
+
) -> list[dict[str, Any]]:
|
|
68
|
+
"""Query records from a table."""
|
|
69
|
+
sql = f"SELECT * FROM {table}" # noqa: S608
|
|
70
|
+
params: list[Any] = []
|
|
71
|
+
|
|
72
|
+
if where:
|
|
73
|
+
clauses = [f"{k} = ?" for k in where]
|
|
74
|
+
sql += " WHERE " + " AND ".join(clauses)
|
|
75
|
+
params.extend(where.values())
|
|
76
|
+
|
|
77
|
+
sql += f" ORDER BY {order_by} LIMIT ?"
|
|
78
|
+
params.append(limit)
|
|
79
|
+
|
|
80
|
+
with self._connect() as conn:
|
|
81
|
+
rows = conn.execute(sql, params).fetchall()
|
|
82
|
+
return [dict(r) for r in rows]
|
|
83
|
+
|
|
84
|
+
def clear(self, table: str | None = None) -> None:
|
|
85
|
+
"""Clear records from a table, or all tables if table is None."""
|
|
86
|
+
with self._connect() as conn:
|
|
87
|
+
if table:
|
|
88
|
+
conn.execute(f"DELETE FROM {table}") # noqa: S608
|
|
89
|
+
else:
|
|
90
|
+
tables = conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()
|
|
91
|
+
for row in tables:
|
|
92
|
+
conn.execute(f"DELETE FROM {row['name']}") # noqa: S608
|
|
93
|
+
conn.commit()
|
kctl_common/output.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Centralized output handler with multi-format support.
|
|
2
|
+
|
|
3
|
+
Supports output formats: pretty (Rich tables), json, csv, yaml.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import csv
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
import sys
|
|
12
|
+
from io import StringIO
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import yaml
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
from rich.panel import Panel
|
|
18
|
+
from rich.table import Table
|
|
19
|
+
from rich.tree import Tree
|
|
20
|
+
|
|
21
|
+
_RICH_TAG_RE = re.compile(r"\[/?[a-z_ ]+\]")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Output:
|
|
25
|
+
"""Output handler that switches between Rich (pretty), JSON, CSV, and YAML modes."""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
json_mode: bool = False,
|
|
30
|
+
quiet: bool = False,
|
|
31
|
+
format: str = "pretty",
|
|
32
|
+
no_header: bool = False,
|
|
33
|
+
):
|
|
34
|
+
self.json_mode = json_mode or format == "json"
|
|
35
|
+
self.quiet = quiet
|
|
36
|
+
self.format = format if not json_mode else "json"
|
|
37
|
+
self.no_header = no_header
|
|
38
|
+
use_stderr = self.format != "pretty"
|
|
39
|
+
self.console = Console(stderr=True) if use_stderr else Console()
|
|
40
|
+
self._stdout = Console(file=sys.stdout)
|
|
41
|
+
|
|
42
|
+
@staticmethod
|
|
43
|
+
def _strip_markup(text: str) -> str:
|
|
44
|
+
"""Remove Rich markup tags from a string."""
|
|
45
|
+
return _RICH_TAG_RE.sub("", str(text))
|
|
46
|
+
|
|
47
|
+
def _build_json_data(
|
|
48
|
+
self,
|
|
49
|
+
columns: list[tuple[str, str]],
|
|
50
|
+
rows: list[list[str]],
|
|
51
|
+
data_for_json: list[dict] | None, # type: ignore[type-arg]
|
|
52
|
+
) -> list[dict]: # type: ignore[type-arg]
|
|
53
|
+
"""Build list-of-dicts from table data."""
|
|
54
|
+
return data_for_json or [
|
|
55
|
+
{col[0].lower().replace(" ", "_"): val for col, val in zip(columns, row, strict=False)} for row in rows
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
def table(
|
|
59
|
+
self,
|
|
60
|
+
title: str,
|
|
61
|
+
columns: list[tuple[str, str]],
|
|
62
|
+
rows: list[list[str]],
|
|
63
|
+
data_for_json: list[dict] | None = None, # type: ignore[type-arg]
|
|
64
|
+
) -> None:
|
|
65
|
+
"""Print a table in the configured format."""
|
|
66
|
+
fmt = self.format
|
|
67
|
+
|
|
68
|
+
if fmt == "json" or self.json_mode:
|
|
69
|
+
json_data = self._build_json_data(columns, rows, data_for_json)
|
|
70
|
+
print(json.dumps(json_data, indent=2, default=str))
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
if fmt == "csv":
|
|
74
|
+
buf = StringIO()
|
|
75
|
+
writer = csv.writer(buf)
|
|
76
|
+
if not self.no_header:
|
|
77
|
+
writer.writerow([col[0] for col in columns])
|
|
78
|
+
stripped = [[self._strip_markup(val) for val in row] for row in rows]
|
|
79
|
+
writer.writerows(stripped)
|
|
80
|
+
sys.stdout.write(buf.getvalue())
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
if fmt == "yaml":
|
|
84
|
+
yaml_data = self._build_json_data(columns, rows, data_for_json)
|
|
85
|
+
print(yaml.dump(yaml_data, default_flow_style=False, allow_unicode=True).rstrip())
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
t = Table(title=title, show_header=True, header_style="bold cyan")
|
|
89
|
+
for col_name, col_style in columns:
|
|
90
|
+
t.add_column(col_name, style=col_style)
|
|
91
|
+
for row in rows:
|
|
92
|
+
t.add_row(*row)
|
|
93
|
+
self.console.print(t)
|
|
94
|
+
|
|
95
|
+
def detail(
|
|
96
|
+
self,
|
|
97
|
+
title: str,
|
|
98
|
+
sections: list[tuple[str, list[tuple[str, str]]]],
|
|
99
|
+
data_for_json: dict[str, Any] | None = None,
|
|
100
|
+
) -> None:
|
|
101
|
+
"""Print a detail panel in the configured format."""
|
|
102
|
+
fmt = self.format
|
|
103
|
+
|
|
104
|
+
if fmt == "json" or self.json_mode:
|
|
105
|
+
if data_for_json:
|
|
106
|
+
print(json.dumps(data_for_json, indent=2, default=str))
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
if fmt == "yaml":
|
|
110
|
+
if data_for_json:
|
|
111
|
+
print(yaml.dump(data_for_json, default_flow_style=False, allow_unicode=True).rstrip())
|
|
112
|
+
else:
|
|
113
|
+
result: dict[str, dict[str, str]] = {}
|
|
114
|
+
for section_title, kvs in sections:
|
|
115
|
+
result[section_title] = {k: v for k, v in kvs}
|
|
116
|
+
print(yaml.dump(result, default_flow_style=False, allow_unicode=True).rstrip())
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
if fmt == "csv":
|
|
120
|
+
buf = StringIO()
|
|
121
|
+
writer = csv.writer(buf)
|
|
122
|
+
if not self.no_header:
|
|
123
|
+
writer.writerow(["section", "key", "value"])
|
|
124
|
+
for section_title, kvs in sections:
|
|
125
|
+
for key, value in kvs:
|
|
126
|
+
writer.writerow(
|
|
127
|
+
[
|
|
128
|
+
self._strip_markup(section_title),
|
|
129
|
+
self._strip_markup(key),
|
|
130
|
+
self._strip_markup(value),
|
|
131
|
+
]
|
|
132
|
+
)
|
|
133
|
+
sys.stdout.write(buf.getvalue())
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
lines: list[str] = []
|
|
137
|
+
for section_title, kvs in sections:
|
|
138
|
+
lines.append(f"[bold cyan]{section_title}[/bold cyan]")
|
|
139
|
+
for key, value in kvs:
|
|
140
|
+
lines.append(f" [dim]{key}:[/dim] {value}")
|
|
141
|
+
lines.append("")
|
|
142
|
+
|
|
143
|
+
content = "\n".join(lines).rstrip()
|
|
144
|
+
self.console.print(Panel(content, title=f"[bold]{title}[/bold]", border_style="blue", expand=False))
|
|
145
|
+
|
|
146
|
+
def tree(self, title: str, nodes: list[dict[str, Any]], data_for_json: list[dict[str, Any]] | None = None) -> None:
|
|
147
|
+
"""Print Rich tree. Each node: {name, children: [...], info: str}."""
|
|
148
|
+
if self.json_mode:
|
|
149
|
+
print(json.dumps(data_for_json or nodes, indent=2, default=str))
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
t = Tree(f"[bold]{title}[/bold]")
|
|
153
|
+
self._build_tree(t, nodes)
|
|
154
|
+
self.console.print(t)
|
|
155
|
+
|
|
156
|
+
def _build_tree(self, parent: Tree, nodes: list[dict[str, Any]]) -> None:
|
|
157
|
+
for node in nodes:
|
|
158
|
+
label = node.get("name", "")
|
|
159
|
+
info = node.get("info", "")
|
|
160
|
+
if info:
|
|
161
|
+
label = f"{label} [dim]({info})[/dim]"
|
|
162
|
+
branch = parent.add(label)
|
|
163
|
+
children = node.get("children", [])
|
|
164
|
+
if children:
|
|
165
|
+
self._build_tree(branch, children)
|
|
166
|
+
|
|
167
|
+
def success(self, message: str) -> None:
|
|
168
|
+
if not self.quiet:
|
|
169
|
+
self.console.print(f"[green]OK[/green] {message}")
|
|
170
|
+
|
|
171
|
+
def error(self, message: str) -> None:
|
|
172
|
+
self.console.print(f"[red]ERROR[/red] {message}", highlight=False)
|
|
173
|
+
|
|
174
|
+
def warn(self, message: str) -> None:
|
|
175
|
+
if not self.quiet:
|
|
176
|
+
self.console.print(f"[yellow]WARN[/yellow] {message}")
|
|
177
|
+
|
|
178
|
+
def info(self, message: str) -> None:
|
|
179
|
+
if not self.quiet:
|
|
180
|
+
self.console.print(f"[blue]INFO[/blue] {message}")
|
|
181
|
+
|
|
182
|
+
def raw_json(self, data: Any) -> None:
|
|
183
|
+
"""Output raw JSON to stdout (for piping)."""
|
|
184
|
+
print(json.dumps(data, indent=2, default=str))
|
|
185
|
+
|
|
186
|
+
def kv(self, key: str, value: str) -> None:
|
|
187
|
+
"""Print a single key-value pair."""
|
|
188
|
+
if self.json_mode:
|
|
189
|
+
return
|
|
190
|
+
self.console.print(f" [dim]{key}:[/dim] {value}")
|
|
191
|
+
|
|
192
|
+
def header(self, title: str) -> None:
|
|
193
|
+
if self.quiet or self.json_mode:
|
|
194
|
+
return
|
|
195
|
+
self.console.print()
|
|
196
|
+
self.console.rule(f"[bold]{title}[/bold]", style="blue")
|
|
197
|
+
|
|
198
|
+
def text(self, msg: str) -> None:
|
|
199
|
+
if not self.quiet:
|
|
200
|
+
self.console.print(msg)
|
kctl_common/plugins.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Plugin discovery and loading via Python entry points.
|
|
2
|
+
|
|
3
|
+
Each kctl-* CLI declares its own entry point group (e.g., "kctl_next.plugins").
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import importlib.metadata
|
|
9
|
+
import logging
|
|
10
|
+
from typing import Protocol
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class KctlPlugin(Protocol):
|
|
18
|
+
"""Interface that kctl plugins must implement."""
|
|
19
|
+
|
|
20
|
+
name: str
|
|
21
|
+
|
|
22
|
+
def register(self, app: typer.Typer) -> None:
|
|
23
|
+
"""Register commands/sub-apps with the main Typer application."""
|
|
24
|
+
...
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def discover_and_load_plugins(app: typer.Typer, entry_point_group: str) -> list[str]:
|
|
28
|
+
"""Discover plugins via entry points and register them."""
|
|
29
|
+
loaded: list[str] = []
|
|
30
|
+
try:
|
|
31
|
+
eps = importlib.metadata.entry_points(group=entry_point_group)
|
|
32
|
+
except Exception:
|
|
33
|
+
return loaded
|
|
34
|
+
|
|
35
|
+
for ep in eps:
|
|
36
|
+
try:
|
|
37
|
+
plugin_cls = ep.load()
|
|
38
|
+
plugin = plugin_cls()
|
|
39
|
+
plugin.register(app)
|
|
40
|
+
loaded.append(ep.name)
|
|
41
|
+
logger.debug("Loaded plugin: %s", ep.name)
|
|
42
|
+
except Exception as e: # noqa: BLE001
|
|
43
|
+
logger.warning("Failed to load plugin %s: %s", ep.name, e)
|
|
44
|
+
|
|
45
|
+
return loaded
|
kctl_common/runner.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Shell command runner with structured error handling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from kctl_common.exceptions import CommandError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def run(
|
|
13
|
+
cmd: list[str],
|
|
14
|
+
cwd: Path | None = None,
|
|
15
|
+
capture: bool = True,
|
|
16
|
+
check: bool = True,
|
|
17
|
+
timeout: int = 300,
|
|
18
|
+
env: dict[str, str] | None = None,
|
|
19
|
+
) -> subprocess.CompletedProcess[str]:
|
|
20
|
+
"""Run a shell command, raising CommandError on failure."""
|
|
21
|
+
run_env = None
|
|
22
|
+
if env:
|
|
23
|
+
run_env = dict(os.environ)
|
|
24
|
+
run_env.update(env)
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
result = subprocess.run(
|
|
28
|
+
cmd,
|
|
29
|
+
cwd=cwd,
|
|
30
|
+
capture_output=capture,
|
|
31
|
+
text=True,
|
|
32
|
+
timeout=timeout,
|
|
33
|
+
env=run_env,
|
|
34
|
+
)
|
|
35
|
+
if check and result.returncode != 0:
|
|
36
|
+
raise CommandError(" ".join(cmd), result.returncode, result.stderr)
|
|
37
|
+
return result
|
|
38
|
+
except subprocess.TimeoutExpired as e:
|
|
39
|
+
raise CommandError(" ".join(cmd), -1, f"Command timed out after {timeout}s") from e
|
|
40
|
+
except FileNotFoundError as e:
|
|
41
|
+
raise CommandError(" ".join(cmd), -1, f"Command not found: {cmd[0]}") from e
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def run_quiet(cmd: list[str], cwd: Path | None = None, timeout: int = 300) -> subprocess.CompletedProcess[str]:
|
|
45
|
+
"""Run a command, returning result without raising on failure."""
|
|
46
|
+
return run(cmd, cwd=cwd, check=False, timeout=timeout)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_git_sha(cwd: Path | None = None) -> str:
|
|
50
|
+
"""Get current git commit SHA."""
|
|
51
|
+
result = run_quiet(["git", "rev-parse", "HEAD"], cwd=cwd)
|
|
52
|
+
return result.stdout.strip() if result.returncode == 0 else ""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def get_git_branch(cwd: Path | None = None) -> str:
|
|
56
|
+
"""Get current git branch name."""
|
|
57
|
+
result = run_quiet(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=cwd)
|
|
58
|
+
return result.stdout.strip() if result.returncode == 0 else ""
|
kctl_common/testing.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Shared test fixtures and helpers for kctl-* CLI test suites.
|
|
2
|
+
|
|
3
|
+
Install via: kctl-common[testing]
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
|
|
13
|
+
from kctl_common.callbacks import AppContextBase
|
|
14
|
+
from kctl_common.output import Output
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def mock_output(**overrides: Any) -> Output:
|
|
18
|
+
"""Create an Output instance in JSON mode for test assertions."""
|
|
19
|
+
defaults = {"json_mode": True, "quiet": False, "format": "json", "no_header": False}
|
|
20
|
+
defaults.update(overrides)
|
|
21
|
+
return Output(**defaults)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def mock_app_context(**overrides: Any) -> AppContextBase:
|
|
25
|
+
"""Create a pre-configured AppContextBase for tests."""
|
|
26
|
+
return AppContextBase(**overrides)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def temp_config(profiles: dict[str, Any], base_dir: Path | None = None) -> Path:
|
|
30
|
+
"""Write a temporary config.yaml and return its path."""
|
|
31
|
+
if base_dir is None:
|
|
32
|
+
import tempfile
|
|
33
|
+
|
|
34
|
+
base_dir = Path(tempfile.mkdtemp())
|
|
35
|
+
|
|
36
|
+
config_file = base_dir / "config.yaml"
|
|
37
|
+
data = {"default_profile": "default", "profiles": profiles}
|
|
38
|
+
with open(config_file, "w") as f:
|
|
39
|
+
yaml.dump(data, f, default_flow_style=False)
|
|
40
|
+
return config_file
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kctl-common
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Shared core library for kctl-* CLI tools
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Requires-Dist: pydantic>=2.10.0
|
|
7
|
+
Requires-Dist: pyyaml>=6.0.2
|
|
8
|
+
Requires-Dist: rich>=13.9.0
|
|
9
|
+
Requires-Dist: typer>=0.15.0
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: mypy>=1.14.0; extra == 'dev'
|
|
12
|
+
Requires-Dist: pytest-cov>=6.0.0; extra == 'dev'
|
|
13
|
+
Requires-Dist: pytest>=8.3.0; extra == 'dev'
|
|
14
|
+
Requires-Dist: ruff>=0.9.0; extra == 'dev'
|
|
15
|
+
Requires-Dist: types-pyyaml>=6.0.0; extra == 'dev'
|
|
16
|
+
Provides-Extra: testing
|
|
17
|
+
Requires-Dist: pytest>=8.3.0; extra == 'testing'
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
kctl_common/__init__.py,sha256=scqF0KqNVOFmFXs2lMbXrMy9slFMRYR4BuRxsh2kPtw,1338
|
|
2
|
+
kctl_common/callbacks.py,sha256=WsjqOEm7kUDKQNvn3YmYiia5tQLkTnz8m2rDN0bngH8,1105
|
|
3
|
+
kctl_common/config.py,sha256=lF10b8lBu7qVIK8GInXu9DuDeT7Rd-JVcgpCcu_IhJA,4837
|
|
4
|
+
kctl_common/exceptions.py,sha256=aWnIf29DfozgNFJmzCCz3DdDOzSLE4Vqw_G_UlwaBaM,2543
|
|
5
|
+
kctl_common/history.py,sha256=wXJdybpFcipsnxhIWiZmD1LZlWe-z_KJdDw9wS6hVjk,3184
|
|
6
|
+
kctl_common/output.py,sha256=9EemPCsstAQprTqkzNCvzOWEZuwO3qyYhxfmyl-KS6Q,6955
|
|
7
|
+
kctl_common/plugins.py,sha256=XtCydQ33VjZcdsCsgtFeyElL6Yywv2YeNTaxhSayXIs,1200
|
|
8
|
+
kctl_common/runner.py,sha256=N0g3PsjkAtTx9ATqBI_32I97YttVr8lQWCN7YA8ZEKU,1873
|
|
9
|
+
kctl_common/testing.py,sha256=PcMkeDoEbd3QOnsGuRey0smohs55p0WX4Z-RQ8dxUmg,1175
|
|
10
|
+
kctl_common-0.1.0.dist-info/METADATA,sha256=t9iarXdFe6E3lcJWh37ni8MTAIBSZtgHkQbHd6siKDA,572
|
|
11
|
+
kctl_common-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
12
|
+
kctl_common-0.1.0.dist-info/RECORD,,
|