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/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())