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.
- kctl_common-0.1.0/.gitignore +11 -0
- kctl_common-0.1.0/PKG-INFO +17 -0
- kctl_common-0.1.0/pyproject.toml +36 -0
- kctl_common-0.1.0/src/kctl_common/__init__.py +59 -0
- kctl_common-0.1.0/src/kctl_common/callbacks.py +37 -0
- kctl_common-0.1.0/src/kctl_common/config.py +168 -0
- kctl_common-0.1.0/src/kctl_common/exceptions.py +84 -0
- kctl_common-0.1.0/src/kctl_common/history.py +93 -0
- kctl_common-0.1.0/src/kctl_common/output.py +200 -0
- kctl_common-0.1.0/src/kctl_common/plugins.py +45 -0
- kctl_common-0.1.0/src/kctl_common/runner.py +58 -0
- kctl_common-0.1.0/src/kctl_common/testing.py +40 -0
- kctl_common-0.1.0/tests/conftest.py +1 -0
- kctl_common-0.1.0/tests/test_callbacks.py +63 -0
- kctl_common-0.1.0/tests/test_config.py +150 -0
- kctl_common-0.1.0/tests/test_exceptions.py +129 -0
- kctl_common-0.1.0/tests/test_history.py +72 -0
- kctl_common-0.1.0/tests/test_output.py +101 -0
- kctl_common-0.1.0/tests/test_plugins.py +54 -0
- kctl_common-0.1.0/tests/test_runner.py +47 -0
|
@@ -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()
|