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.
@@ -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
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any