kctl-common 0.1.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.
@@ -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,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,36 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "kctl-common"
7
+ version = "0.1.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
+ ]
16
+
17
+ [project.optional-dependencies]
18
+ testing = ["pytest>=8.3.0"]
19
+ dev = [
20
+ "pytest>=8.3.0",
21
+ "pytest-cov>=6.0.0",
22
+ "ruff>=0.9.0",
23
+ "mypy>=1.14.0",
24
+ "types-PyYAML>=6.0.0",
25
+ ]
26
+
27
+ [tool.ruff]
28
+ target-version = "py312"
29
+ line-length = 120
30
+
31
+ [tool.ruff.lint]
32
+ select = ["E", "F", "I", "W", "UP", "B", "SIM", "N"]
33
+
34
+ [tool.mypy]
35
+ python_version = "3.12"
36
+ strict = true
@@ -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
@@ -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)
@@ -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()