clidevkit 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.
- clidev/__init__.py +67 -0
- clidev/actions.py +73 -0
- clidev/app.py +210 -0
- clidev/builtins/__init__.py +1 -0
- clidev/builtins/plugins.py +26 -0
- clidev/cards.py +19 -0
- clidev/cli.py +42 -0
- clidev/colors.py +27 -0
- clidev/config.py +36 -0
- clidev/dashboard.py +28 -0
- clidev/dialogs.py +24 -0
- clidev/events.py +39 -0
- clidev/exceptions.py +35 -0
- clidev/forms.py +97 -0
- clidev/generators/__init__.py +13 -0
- clidev/generators/form.py +11 -0
- clidev/generators/menu.py +11 -0
- clidev/generators/project.py +150 -0
- clidev/generators/workflow.py +17 -0
- clidev/icons.py +16 -0
- clidev/inputs.py +90 -0
- clidev/logger.py +50 -0
- clidev/menus.py +69 -0
- clidev/notifications.py +26 -0
- clidev/pages.py +42 -0
- clidev/plugins.py +51 -0
- clidev/progress.py +42 -0
- clidev/router.py +37 -0
- clidev/scheduler.py +59 -0
- clidev/shell.py +84 -0
- clidev/spinner.py +25 -0
- clidev/state.py +47 -0
- clidev/statusbar.py +20 -0
- clidev/storage.py +221 -0
- clidev/table.py +39 -0
- clidev/tasks.py +28 -0
- clidev/themes.py +96 -0
- clidev/tree.py +36 -0
- clidev/utils.py +20 -0
- clidev/validators.py +90 -0
- clidev/workflow.py +52 -0
- clidevkit-0.1.0.dist-info/METADATA +449 -0
- clidevkit-0.1.0.dist-info/RECORD +46 -0
- clidevkit-0.1.0.dist-info/WHEEL +5 -0
- clidevkit-0.1.0.dist-info/entry_points.txt +2 -0
- clidevkit-0.1.0.dist-info/top_level.txt +1 -0
clidev/progress.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Progress bar context manager: with app.progress("Installing"): ..."""
|
|
2
|
+
|
|
3
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TimeElapsedColumn
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ProgressContext:
|
|
7
|
+
"""Context manager wrapping a rich Progress bar with one indeterminate task.
|
|
8
|
+
|
|
9
|
+
with app.progress("Installing"):
|
|
10
|
+
app.cmd("pip install numpy")
|
|
11
|
+
app.cmd("pip install pandas")
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def __init__(self, label: str = "Working", total: int | None = None):
|
|
15
|
+
self.label = label
|
|
16
|
+
self.total = total
|
|
17
|
+
self._progress = Progress(
|
|
18
|
+
SpinnerColumn(),
|
|
19
|
+
TextColumn("[progress.description]{task.description}"),
|
|
20
|
+
BarColumn(),
|
|
21
|
+
TimeElapsedColumn(),
|
|
22
|
+
)
|
|
23
|
+
self._task_id = None
|
|
24
|
+
|
|
25
|
+
def __enter__(self):
|
|
26
|
+
self._progress.start()
|
|
27
|
+
self._task_id = self._progress.add_task(self.label, total=self.total)
|
|
28
|
+
return self
|
|
29
|
+
|
|
30
|
+
def advance(self, amount: float = 1):
|
|
31
|
+
if self._task_id is not None:
|
|
32
|
+
self._progress.update(self._task_id, advance=amount)
|
|
33
|
+
|
|
34
|
+
def set_description(self, text: str):
|
|
35
|
+
if self._task_id is not None:
|
|
36
|
+
self._progress.update(self._task_id, description=text)
|
|
37
|
+
|
|
38
|
+
def __exit__(self, exc_type, exc, tb):
|
|
39
|
+
if self._task_id is not None and self.total:
|
|
40
|
+
self._progress.update(self._task_id, completed=self.total)
|
|
41
|
+
self._progress.stop()
|
|
42
|
+
return False
|
clidev/router.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Navigation: app.goto(name), app.back(), history stack."""
|
|
2
|
+
|
|
3
|
+
from .exceptions import NavigationError
|
|
4
|
+
from .pages import PageRegistry
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Router:
|
|
8
|
+
def __init__(self, app=None):
|
|
9
|
+
self.app = app
|
|
10
|
+
self.pages = PageRegistry()
|
|
11
|
+
self.history: list[str] = []
|
|
12
|
+
self.current: str | None = None
|
|
13
|
+
|
|
14
|
+
def page(self, name: str, handler=None):
|
|
15
|
+
return self.pages.register(name, handler)
|
|
16
|
+
|
|
17
|
+
def goto(self, name: str, *args, **kwargs):
|
|
18
|
+
page = self.pages.get(name)
|
|
19
|
+
if page is None:
|
|
20
|
+
raise NavigationError(f"No page named '{name}' has been registered.")
|
|
21
|
+
if self.current is not None:
|
|
22
|
+
self.history.append(self.current)
|
|
23
|
+
self.current = name
|
|
24
|
+
return page.render(*args, **kwargs)
|
|
25
|
+
|
|
26
|
+
def back(self):
|
|
27
|
+
if not self.history:
|
|
28
|
+
raise NavigationError("No previous page to go back to.")
|
|
29
|
+
previous = self.history.pop()
|
|
30
|
+
page = self.pages.get(previous)
|
|
31
|
+
self.current = previous
|
|
32
|
+
if page is not None:
|
|
33
|
+
return page.render()
|
|
34
|
+
|
|
35
|
+
def reset(self):
|
|
36
|
+
self.history.clear()
|
|
37
|
+
self.current = None
|
clidev/scheduler.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Lightweight in-process scheduler for delayed / repeating callbacks."""
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ScheduledJob:
|
|
8
|
+
def __init__(self, interval: float, fn, repeat: bool = False):
|
|
9
|
+
self.interval = interval
|
|
10
|
+
self.fn = fn
|
|
11
|
+
self.repeat = repeat
|
|
12
|
+
self._timer: threading.Timer | None = None
|
|
13
|
+
self._cancelled = False
|
|
14
|
+
|
|
15
|
+
def _run(self):
|
|
16
|
+
if self._cancelled:
|
|
17
|
+
return
|
|
18
|
+
self.fn()
|
|
19
|
+
if self.repeat and not self._cancelled:
|
|
20
|
+
self._schedule()
|
|
21
|
+
|
|
22
|
+
def _schedule(self):
|
|
23
|
+
self._timer = threading.Timer(self.interval, self._run)
|
|
24
|
+
self._timer.daemon = True
|
|
25
|
+
self._timer.start()
|
|
26
|
+
|
|
27
|
+
def start(self):
|
|
28
|
+
self._schedule()
|
|
29
|
+
return self
|
|
30
|
+
|
|
31
|
+
def cancel(self):
|
|
32
|
+
self._cancelled = True
|
|
33
|
+
if self._timer is not None:
|
|
34
|
+
self._timer.cancel()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Scheduler:
|
|
38
|
+
"""app.scheduler.after(5, fn) / app.scheduler.every(10, fn)"""
|
|
39
|
+
|
|
40
|
+
def __init__(self):
|
|
41
|
+
self.jobs: list[ScheduledJob] = []
|
|
42
|
+
|
|
43
|
+
def after(self, seconds: float, fn) -> ScheduledJob:
|
|
44
|
+
job = ScheduledJob(seconds, fn, repeat=False).start()
|
|
45
|
+
self.jobs.append(job)
|
|
46
|
+
return job
|
|
47
|
+
|
|
48
|
+
def every(self, seconds: float, fn) -> ScheduledJob:
|
|
49
|
+
job = ScheduledJob(seconds, fn, repeat=True).start()
|
|
50
|
+
self.jobs.append(job)
|
|
51
|
+
return job
|
|
52
|
+
|
|
53
|
+
def cancel_all(self):
|
|
54
|
+
for job in self.jobs:
|
|
55
|
+
job.cancel()
|
|
56
|
+
self.jobs.clear()
|
|
57
|
+
|
|
58
|
+
def sleep(self, seconds: float):
|
|
59
|
+
time.sleep(seconds)
|
clidev/shell.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Cross-platform shell / subprocess execution: app.cmd(...)."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import threading
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from .exceptions import CommandError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class CommandResult:
|
|
12
|
+
command: str
|
|
13
|
+
returncode: int
|
|
14
|
+
stdout: str = ""
|
|
15
|
+
stderr: str = ""
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def ok(self) -> bool:
|
|
19
|
+
return self.returncode == 0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BackgroundHandle:
|
|
23
|
+
"""Handle returned for background=True commands."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, thread: threading.Thread, popen: subprocess.Popen):
|
|
26
|
+
self._thread = thread
|
|
27
|
+
self.popen = popen
|
|
28
|
+
|
|
29
|
+
def is_running(self) -> bool:
|
|
30
|
+
return self.popen.poll() is None
|
|
31
|
+
|
|
32
|
+
def wait(self) -> CommandResult:
|
|
33
|
+
stdout, stderr = self.popen.communicate()
|
|
34
|
+
self._thread.join()
|
|
35
|
+
return CommandResult(
|
|
36
|
+
command=" ".join(self.popen.args) if isinstance(self.popen.args, list) else str(self.popen.args),
|
|
37
|
+
returncode=self.popen.returncode,
|
|
38
|
+
stdout=stdout or "",
|
|
39
|
+
stderr=stderr or "",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def terminate(self):
|
|
43
|
+
self.popen.terminate()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Shell:
|
|
47
|
+
"""Facade exposed as app.cmd via a thin wrapper in App, or used directly."""
|
|
48
|
+
|
|
49
|
+
def run(self, command: str, capture: bool = False, background: bool = False,
|
|
50
|
+
check: bool = False, shell: bool = True, cwd: str | None = None):
|
|
51
|
+
if background:
|
|
52
|
+
popen = subprocess.Popen(
|
|
53
|
+
command,
|
|
54
|
+
shell=shell,
|
|
55
|
+
cwd=cwd,
|
|
56
|
+
stdout=subprocess.PIPE if capture else None,
|
|
57
|
+
stderr=subprocess.PIPE if capture else None,
|
|
58
|
+
text=True,
|
|
59
|
+
)
|
|
60
|
+
thread = threading.Thread(target=popen.wait, daemon=True)
|
|
61
|
+
thread.start()
|
|
62
|
+
return BackgroundHandle(thread, popen)
|
|
63
|
+
|
|
64
|
+
result = subprocess.run(
|
|
65
|
+
command,
|
|
66
|
+
shell=shell,
|
|
67
|
+
cwd=cwd,
|
|
68
|
+
capture_output=capture,
|
|
69
|
+
text=True,
|
|
70
|
+
)
|
|
71
|
+
cmd_result = CommandResult(
|
|
72
|
+
command=command,
|
|
73
|
+
returncode=result.returncode,
|
|
74
|
+
stdout=result.stdout or "" if capture else "",
|
|
75
|
+
stderr=result.stderr or "" if capture else "",
|
|
76
|
+
)
|
|
77
|
+
if check and not cmd_result.ok:
|
|
78
|
+
raise CommandError(
|
|
79
|
+
f"Command '{command}' exited with code {cmd_result.returncode}",
|
|
80
|
+
returncode=cmd_result.returncode,
|
|
81
|
+
stdout=cmd_result.stdout,
|
|
82
|
+
stderr=cmd_result.stderr,
|
|
83
|
+
)
|
|
84
|
+
return cmd_result
|
clidev/spinner.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Simple indeterminate spinner context manager: with app.spinner("Loading"): ..."""
|
|
2
|
+
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from rich.status import Status
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Spinner:
|
|
8
|
+
def __init__(self, label: str = "Loading...", console: Console | None = None):
|
|
9
|
+
self.label = label
|
|
10
|
+
self.console = console or Console()
|
|
11
|
+
self._status: Status | None = None
|
|
12
|
+
|
|
13
|
+
def __enter__(self):
|
|
14
|
+
self._status = self.console.status(self.label)
|
|
15
|
+
self._status.start()
|
|
16
|
+
return self
|
|
17
|
+
|
|
18
|
+
def update(self, text: str):
|
|
19
|
+
if self._status is not None:
|
|
20
|
+
self._status.update(text)
|
|
21
|
+
|
|
22
|
+
def __exit__(self, exc_type, exc, tb):
|
|
23
|
+
if self._status is not None:
|
|
24
|
+
self._status.stop()
|
|
25
|
+
return False
|
clidev/state.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Global, dict-like application state: app.state["key"] = value"""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Callable
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class State:
|
|
7
|
+
"""A dict-like global state container with optional change listeners."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, initial: dict | None = None):
|
|
10
|
+
self._data: dict[str, Any] = dict(initial or {})
|
|
11
|
+
self._listeners: list[Callable[[str, Any, Any], None]] = []
|
|
12
|
+
|
|
13
|
+
def on_change(self, callback: Callable[[str, Any, Any], None]):
|
|
14
|
+
"""Register a callback(key, old_value, new_value) fired on every set."""
|
|
15
|
+
self._listeners.append(callback)
|
|
16
|
+
return callback
|
|
17
|
+
|
|
18
|
+
def __getitem__(self, key):
|
|
19
|
+
return self._data[key]
|
|
20
|
+
|
|
21
|
+
def __setitem__(self, key, value):
|
|
22
|
+
old = self._data.get(key)
|
|
23
|
+
self._data[key] = value
|
|
24
|
+
for cb in self._listeners:
|
|
25
|
+
cb(key, old, value)
|
|
26
|
+
|
|
27
|
+
def __delitem__(self, key):
|
|
28
|
+
del self._data[key]
|
|
29
|
+
|
|
30
|
+
def __contains__(self, key):
|
|
31
|
+
return key in self._data
|
|
32
|
+
|
|
33
|
+
def get(self, key, default=None):
|
|
34
|
+
return self._data.get(key, default)
|
|
35
|
+
|
|
36
|
+
def update(self, other: dict):
|
|
37
|
+
for k, v in other.items():
|
|
38
|
+
self[k] = v
|
|
39
|
+
|
|
40
|
+
def as_dict(self):
|
|
41
|
+
return dict(self._data)
|
|
42
|
+
|
|
43
|
+
def clear(self):
|
|
44
|
+
self._data.clear()
|
|
45
|
+
|
|
46
|
+
def __repr__(self):
|
|
47
|
+
return f"State({self._data})"
|
clidev/statusbar.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""A simple single-line status bar rendered at the bottom of output."""
|
|
2
|
+
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class StatusBar:
|
|
7
|
+
def __init__(self, theme=None, console: Console | None = None):
|
|
8
|
+
self.theme = theme
|
|
9
|
+
self.console = console or Console()
|
|
10
|
+
self._segments: list[str] = []
|
|
11
|
+
|
|
12
|
+
def set(self, *segments: str) -> "StatusBar":
|
|
13
|
+
self._segments = list(segments)
|
|
14
|
+
return self
|
|
15
|
+
|
|
16
|
+
def render(self):
|
|
17
|
+
color = self.theme.get("muted") if self.theme else None
|
|
18
|
+
style = color or "dim"
|
|
19
|
+
line = " | ".join(self._segments)
|
|
20
|
+
self.console.print(f"[{style}]{line}[/{style}]")
|
clidev/storage.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Pluggable data storage backends: memory, JSON, SQLite, YAML, TOML."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sqlite3
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .exceptions import StorageError
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
import yaml
|
|
12
|
+
except ImportError: # pragma: no cover
|
|
13
|
+
yaml = None
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
import toml
|
|
17
|
+
except ImportError: # pragma: no cover
|
|
18
|
+
toml = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class StorageBackend(ABC):
|
|
22
|
+
@abstractmethod
|
|
23
|
+
def save(self, name: str, data): ...
|
|
24
|
+
|
|
25
|
+
@abstractmethod
|
|
26
|
+
def load(self, name: str): ...
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
def delete(self, name: str): ...
|
|
30
|
+
|
|
31
|
+
@abstractmethod
|
|
32
|
+
def all(self) -> dict: ...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class MemoryStorage(StorageBackend):
|
|
36
|
+
"""Volatile, in-process storage. Data is lost when the app exits."""
|
|
37
|
+
|
|
38
|
+
def __init__(self):
|
|
39
|
+
self._data = {}
|
|
40
|
+
|
|
41
|
+
def save(self, name, data):
|
|
42
|
+
self._data[name] = data
|
|
43
|
+
|
|
44
|
+
def load(self, name):
|
|
45
|
+
return self._data.get(name)
|
|
46
|
+
|
|
47
|
+
def delete(self, name):
|
|
48
|
+
self._data.pop(name, None)
|
|
49
|
+
|
|
50
|
+
def all(self):
|
|
51
|
+
return dict(self._data)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class JSONStorage(StorageBackend):
|
|
55
|
+
"""Persists all records to a single JSON file."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, path: str = ".clidev_storage.json"):
|
|
58
|
+
self.path = Path(path)
|
|
59
|
+
if not self.path.exists():
|
|
60
|
+
self.path.write_text("{}")
|
|
61
|
+
|
|
62
|
+
def _read(self):
|
|
63
|
+
try:
|
|
64
|
+
return json.loads(self.path.read_text() or "{}")
|
|
65
|
+
except json.JSONDecodeError as e:
|
|
66
|
+
raise StorageError(f"Corrupt JSON storage file: {e}") from e
|
|
67
|
+
|
|
68
|
+
def _write(self, data):
|
|
69
|
+
self.path.write_text(json.dumps(data, indent=2, default=str))
|
|
70
|
+
|
|
71
|
+
def save(self, name, data):
|
|
72
|
+
all_data = self._read()
|
|
73
|
+
all_data[name] = data
|
|
74
|
+
self._write(all_data)
|
|
75
|
+
|
|
76
|
+
def load(self, name):
|
|
77
|
+
return self._read().get(name)
|
|
78
|
+
|
|
79
|
+
def delete(self, name):
|
|
80
|
+
data = self._read()
|
|
81
|
+
data.pop(name, None)
|
|
82
|
+
self._write(data)
|
|
83
|
+
|
|
84
|
+
def all(self):
|
|
85
|
+
return self._read()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class YAMLStorage(StorageBackend):
|
|
89
|
+
def __init__(self, path: str = ".clidev_storage.yaml"):
|
|
90
|
+
if yaml is None:
|
|
91
|
+
raise StorageError("PyYAML is required for YAMLStorage. pip install pyyaml")
|
|
92
|
+
self.path = Path(path)
|
|
93
|
+
if not self.path.exists():
|
|
94
|
+
self.path.write_text("{}")
|
|
95
|
+
|
|
96
|
+
def _read(self):
|
|
97
|
+
return yaml.safe_load(self.path.read_text()) or {}
|
|
98
|
+
|
|
99
|
+
def _write(self, data):
|
|
100
|
+
self.path.write_text(yaml.safe_dump(data))
|
|
101
|
+
|
|
102
|
+
def save(self, name, data):
|
|
103
|
+
all_data = self._read()
|
|
104
|
+
all_data[name] = data
|
|
105
|
+
self._write(all_data)
|
|
106
|
+
|
|
107
|
+
def load(self, name):
|
|
108
|
+
return self._read().get(name)
|
|
109
|
+
|
|
110
|
+
def delete(self, name):
|
|
111
|
+
data = self._read()
|
|
112
|
+
data.pop(name, None)
|
|
113
|
+
self._write(data)
|
|
114
|
+
|
|
115
|
+
def all(self):
|
|
116
|
+
return self._read()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class TOMLStorage(StorageBackend):
|
|
120
|
+
def __init__(self, path: str = ".clidev_storage.toml"):
|
|
121
|
+
if toml is None:
|
|
122
|
+
raise StorageError("toml is required for TOMLStorage. pip install toml")
|
|
123
|
+
self.path = Path(path)
|
|
124
|
+
if not self.path.exists():
|
|
125
|
+
self.path.write_text("")
|
|
126
|
+
|
|
127
|
+
def _read(self):
|
|
128
|
+
content = self.path.read_text()
|
|
129
|
+
return toml.loads(content) if content.strip() else {}
|
|
130
|
+
|
|
131
|
+
def _write(self, data):
|
|
132
|
+
self.path.write_text(toml.dumps(data))
|
|
133
|
+
|
|
134
|
+
def save(self, name, data):
|
|
135
|
+
all_data = self._read()
|
|
136
|
+
all_data[name] = data
|
|
137
|
+
self._write(all_data)
|
|
138
|
+
|
|
139
|
+
def load(self, name):
|
|
140
|
+
return self._read().get(name)
|
|
141
|
+
|
|
142
|
+
def delete(self, name):
|
|
143
|
+
data = self._read()
|
|
144
|
+
data.pop(name, None)
|
|
145
|
+
self._write(data)
|
|
146
|
+
|
|
147
|
+
def all(self):
|
|
148
|
+
return self._read()
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class SQLiteStorage(StorageBackend):
|
|
152
|
+
"""Key-value storage backed by SQLite (values stored as JSON text)."""
|
|
153
|
+
|
|
154
|
+
def __init__(self, path: str = ".clidev_storage.db"):
|
|
155
|
+
self.path = path
|
|
156
|
+
self._conn = sqlite3.connect(self.path)
|
|
157
|
+
self._conn.execute(
|
|
158
|
+
"CREATE TABLE IF NOT EXISTS clidev_kv (name TEXT PRIMARY KEY, value TEXT)"
|
|
159
|
+
)
|
|
160
|
+
self._conn.commit()
|
|
161
|
+
|
|
162
|
+
def save(self, name, data):
|
|
163
|
+
self._conn.execute(
|
|
164
|
+
"INSERT INTO clidev_kv (name, value) VALUES (?, ?) "
|
|
165
|
+
"ON CONFLICT(name) DO UPDATE SET value=excluded.value",
|
|
166
|
+
(name, json.dumps(data, default=str)),
|
|
167
|
+
)
|
|
168
|
+
self._conn.commit()
|
|
169
|
+
|
|
170
|
+
def load(self, name):
|
|
171
|
+
row = self._conn.execute(
|
|
172
|
+
"SELECT value FROM clidev_kv WHERE name = ?", (name,)
|
|
173
|
+
).fetchone()
|
|
174
|
+
return json.loads(row[0]) if row else None
|
|
175
|
+
|
|
176
|
+
def delete(self, name):
|
|
177
|
+
self._conn.execute("DELETE FROM clidev_kv WHERE name = ?", (name,))
|
|
178
|
+
self._conn.commit()
|
|
179
|
+
|
|
180
|
+
def all(self):
|
|
181
|
+
rows = self._conn.execute("SELECT name, value FROM clidev_kv").fetchall()
|
|
182
|
+
return {name: json.loads(value) for name, value in rows}
|
|
183
|
+
|
|
184
|
+
def close(self):
|
|
185
|
+
self._conn.close()
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
BACKENDS = {
|
|
189
|
+
"memory": MemoryStorage,
|
|
190
|
+
"json": JSONStorage,
|
|
191
|
+
"sqlite": SQLiteStorage,
|
|
192
|
+
"yaml": YAMLStorage,
|
|
193
|
+
"toml": TOMLStorage,
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class Storage:
|
|
198
|
+
"""Facade exposed as app.storage, e.g. app.storage.save("user", data)."""
|
|
199
|
+
|
|
200
|
+
def __init__(self, backend: str | StorageBackend = "memory", **kwargs):
|
|
201
|
+
if isinstance(backend, StorageBackend):
|
|
202
|
+
self._backend = backend
|
|
203
|
+
else:
|
|
204
|
+
if backend not in BACKENDS:
|
|
205
|
+
raise StorageError(
|
|
206
|
+
f"Unknown storage backend '{backend}'. Options: {list(BACKENDS)}"
|
|
207
|
+
)
|
|
208
|
+
self._backend = BACKENDS[backend](**kwargs)
|
|
209
|
+
|
|
210
|
+
def save(self, name: str, data):
|
|
211
|
+
self._backend.save(name, data)
|
|
212
|
+
|
|
213
|
+
def load(self, name: str, default=None):
|
|
214
|
+
value = self._backend.load(name)
|
|
215
|
+
return value if value is not None else default
|
|
216
|
+
|
|
217
|
+
def delete(self, name: str):
|
|
218
|
+
self._backend.delete(name)
|
|
219
|
+
|
|
220
|
+
def all(self) -> dict:
|
|
221
|
+
return self._backend.all()
|
clidev/table.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Table rendering widget, wraps rich.table.Table."""
|
|
2
|
+
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from rich.table import Table as RichTable
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Table:
|
|
8
|
+
"""app.table("Users", columns=["Name", "Age"]).add_row("Bob", "30").show()"""
|
|
9
|
+
|
|
10
|
+
def __init__(self, title: str = "", columns: list[str] | None = None, theme=None):
|
|
11
|
+
self.title = title
|
|
12
|
+
self.columns = columns or []
|
|
13
|
+
self.rows: list[list] = []
|
|
14
|
+
self.theme = theme
|
|
15
|
+
self._console = Console()
|
|
16
|
+
|
|
17
|
+
def add_column(self, name: str) -> "Table":
|
|
18
|
+
self.columns.append(name)
|
|
19
|
+
return self
|
|
20
|
+
|
|
21
|
+
def add_row(self, *values) -> "Table":
|
|
22
|
+
self.rows.append(list(values))
|
|
23
|
+
return self
|
|
24
|
+
|
|
25
|
+
def add_rows(self, rows: list[list]) -> "Table":
|
|
26
|
+
self.rows.extend(rows)
|
|
27
|
+
return self
|
|
28
|
+
|
|
29
|
+
def build(self) -> RichTable:
|
|
30
|
+
color = self.theme.get("primary") if self.theme else None
|
|
31
|
+
rich_table = RichTable(title=self.title or None, header_style=f"bold {color}" if color else "bold")
|
|
32
|
+
for col in self.columns:
|
|
33
|
+
rich_table.add_column(col)
|
|
34
|
+
for row in self.rows:
|
|
35
|
+
rich_table.add_row(*[str(v) for v in row])
|
|
36
|
+
return rich_table
|
|
37
|
+
|
|
38
|
+
def show(self):
|
|
39
|
+
self._console.print(self.build())
|
clidev/tasks.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Task registry: @app.task decorator + app.run_task(name)."""
|
|
2
|
+
|
|
3
|
+
from .exceptions import ClidevError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TaskRegistry:
|
|
7
|
+
def __init__(self):
|
|
8
|
+
self._tasks: dict[str, callable] = {}
|
|
9
|
+
|
|
10
|
+
def register(self, fn=None, *, name: str | None = None):
|
|
11
|
+
"""Use as @app.task or @app.task(name="custom_name")."""
|
|
12
|
+
if fn is not None and callable(fn):
|
|
13
|
+
self._tasks[name or fn.__name__] = fn
|
|
14
|
+
return fn
|
|
15
|
+
|
|
16
|
+
def decorator(f):
|
|
17
|
+
self._tasks[name or f.__name__] = f
|
|
18
|
+
return f
|
|
19
|
+
|
|
20
|
+
return decorator
|
|
21
|
+
|
|
22
|
+
def run(self, name: str, *args, **kwargs):
|
|
23
|
+
if name not in self._tasks:
|
|
24
|
+
raise ClidevError(f"No task named '{name}' has been registered.")
|
|
25
|
+
return self._tasks[name](*args, **kwargs)
|
|
26
|
+
|
|
27
|
+
def names(self):
|
|
28
|
+
return list(self._tasks.keys())
|