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/__init__.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""
|
|
2
|
+
clidev
|
|
3
|
+
======
|
|
4
|
+
|
|
5
|
+
A framework for building production-ready CLI applications and Terminal
|
|
6
|
+
User Interfaces (TUIs) with minimal code.
|
|
7
|
+
|
|
8
|
+
from clidev import App
|
|
9
|
+
|
|
10
|
+
app = App("My App")
|
|
11
|
+
home = app.menu("Home")
|
|
12
|
+
home.option("Exit", app.exit)
|
|
13
|
+
app.run()
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from .app import App
|
|
17
|
+
from .cards import Card
|
|
18
|
+
from .dashboard import Dashboard
|
|
19
|
+
from .dialogs import Dialog
|
|
20
|
+
from .events import EventDispatcher
|
|
21
|
+
from .exceptions import (
|
|
22
|
+
ClidevError,
|
|
23
|
+
CommandError,
|
|
24
|
+
NavigationError,
|
|
25
|
+
PluginError,
|
|
26
|
+
StorageError,
|
|
27
|
+
ValidationError,
|
|
28
|
+
WorkflowError,
|
|
29
|
+
)
|
|
30
|
+
from .forms import Form
|
|
31
|
+
from .menus import Menu
|
|
32
|
+
from .plugins import Plugin
|
|
33
|
+
from .router import Router
|
|
34
|
+
from .state import State
|
|
35
|
+
from .storage import Storage
|
|
36
|
+
from .table import Table
|
|
37
|
+
from .themes import Theme
|
|
38
|
+
from .tree import Tree
|
|
39
|
+
from .workflow import Workflow
|
|
40
|
+
|
|
41
|
+
__version__ = "0.1.0"
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"App",
|
|
45
|
+
"Card",
|
|
46
|
+
"Dashboard",
|
|
47
|
+
"Dialog",
|
|
48
|
+
"EventDispatcher",
|
|
49
|
+
"ClidevError",
|
|
50
|
+
"CommandError",
|
|
51
|
+
"NavigationError",
|
|
52
|
+
"PluginError",
|
|
53
|
+
"StorageError",
|
|
54
|
+
"ValidationError",
|
|
55
|
+
"WorkflowError",
|
|
56
|
+
"Form",
|
|
57
|
+
"Menu",
|
|
58
|
+
"Plugin",
|
|
59
|
+
"Router",
|
|
60
|
+
"State",
|
|
61
|
+
"Storage",
|
|
62
|
+
"Table",
|
|
63
|
+
"Theme",
|
|
64
|
+
"Tree",
|
|
65
|
+
"Workflow",
|
|
66
|
+
"__version__",
|
|
67
|
+
]
|
clidev/actions.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Conditional-navigation helpers: app.if_value(...).goto(...) and @app.when(...)."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ConditionBuilder:
|
|
5
|
+
"""Returned by app.if_value(key, **comparisons).
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
app.if_value("Role", equals="Admin").goto("admin_menu")
|
|
9
|
+
app.if_value("Age", greater_than=18).goto("adult_menu")
|
|
10
|
+
|
|
11
|
+
Supported comparisons: equals, not_equals, greater_than, less_than,
|
|
12
|
+
greater_equal, less_equal, contains, in_list.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, app, data: dict, key: str, **comparisons):
|
|
16
|
+
self.app = app
|
|
17
|
+
self.data = data
|
|
18
|
+
self.key = key
|
|
19
|
+
self.comparisons = comparisons
|
|
20
|
+
|
|
21
|
+
def _value(self):
|
|
22
|
+
return self.data.get(self.key)
|
|
23
|
+
|
|
24
|
+
def matches(self) -> bool:
|
|
25
|
+
value = self._value()
|
|
26
|
+
for op, target in self.comparisons.items():
|
|
27
|
+
if op == "equals" and not (value == target):
|
|
28
|
+
return False
|
|
29
|
+
if op == "not_equals" and not (value != target):
|
|
30
|
+
return False
|
|
31
|
+
if op == "greater_than" and not (value is not None and value > target):
|
|
32
|
+
return False
|
|
33
|
+
if op == "less_than" and not (value is not None and value < target):
|
|
34
|
+
return False
|
|
35
|
+
if op == "greater_equal" and not (value is not None and value >= target):
|
|
36
|
+
return False
|
|
37
|
+
if op == "less_equal" and not (value is not None and value <= target):
|
|
38
|
+
return False
|
|
39
|
+
if op == "contains" and not (value is not None and target in value):
|
|
40
|
+
return False
|
|
41
|
+
if op == "in_list" and not (value in target):
|
|
42
|
+
return False
|
|
43
|
+
return True
|
|
44
|
+
|
|
45
|
+
def goto(self, page_name: str):
|
|
46
|
+
if self.matches():
|
|
47
|
+
return self.app.goto(page_name)
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
def then(self, fn):
|
|
51
|
+
if self.matches():
|
|
52
|
+
return fn()
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def when(condition_fn):
|
|
57
|
+
"""Decorator factory: @app.when(lambda data: data["Role"] == "Admin")
|
|
58
|
+
|
|
59
|
+
Wraps `fn` so that calling wrapped(data) only invokes the original
|
|
60
|
+
function if condition_fn(data) is truthy.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def decorator(fn):
|
|
64
|
+
def wrapper(data=None, *args, **kwargs):
|
|
65
|
+
if condition_fn(data):
|
|
66
|
+
return fn(data, *args, **kwargs) if data is not None else fn(*args, **kwargs)
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
wrapper.__wrapped__ = fn
|
|
70
|
+
wrapper.__condition__ = condition_fn
|
|
71
|
+
return wrapper
|
|
72
|
+
|
|
73
|
+
return decorator
|
clidev/app.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""The core App class -- the main entry point for every clidev application."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
|
|
7
|
+
from .actions import ConditionBuilder, when as _when
|
|
8
|
+
from .cards import Card
|
|
9
|
+
from .config import Config
|
|
10
|
+
from .dashboard import Dashboard
|
|
11
|
+
from .dialogs import Dialog
|
|
12
|
+
from .events import EventDispatcher
|
|
13
|
+
from .exceptions import ClidevError
|
|
14
|
+
from .forms import Form
|
|
15
|
+
from .logger import Logger
|
|
16
|
+
from .menus import Menu
|
|
17
|
+
from .notifications import Notifications
|
|
18
|
+
from .plugins import PluginManager
|
|
19
|
+
from .progress import ProgressContext
|
|
20
|
+
from .router import Router
|
|
21
|
+
from .scheduler import Scheduler
|
|
22
|
+
from .shell import Shell
|
|
23
|
+
from .spinner import Spinner
|
|
24
|
+
from .state import State
|
|
25
|
+
from .statusbar import StatusBar
|
|
26
|
+
from .storage import Storage
|
|
27
|
+
from .table import Table
|
|
28
|
+
from .tasks import TaskRegistry
|
|
29
|
+
from .themes import Theme
|
|
30
|
+
from .tree import Tree
|
|
31
|
+
from .workflow import Workflow
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class App:
|
|
35
|
+
"""The main application object.
|
|
36
|
+
|
|
37
|
+
app = App("My App")
|
|
38
|
+
home = app.menu("Home")
|
|
39
|
+
home.option("Exit", app.exit)
|
|
40
|
+
app.run()
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, title: str = "clidev App", theme: str | Theme = "dark",
|
|
44
|
+
storage_backend: str = "memory", **config_kwargs):
|
|
45
|
+
self.title = title
|
|
46
|
+
self.console = Console()
|
|
47
|
+
self.config = Config(theme=theme if isinstance(theme, str) else "custom", **config_kwargs)
|
|
48
|
+
|
|
49
|
+
self.theme = theme if isinstance(theme, Theme) else Theme.preset(theme)
|
|
50
|
+
self.logger = Logger(theme=self.theme, console=self.console,
|
|
51
|
+
timestamps=self.config.log_timestamps)
|
|
52
|
+
self.state = State()
|
|
53
|
+
self.storage = Storage(backend=storage_backend)
|
|
54
|
+
self.events = EventDispatcher()
|
|
55
|
+
self.router = Router(app=self)
|
|
56
|
+
self.tasks = TaskRegistry()
|
|
57
|
+
self.scheduler = Scheduler()
|
|
58
|
+
self.plugins = PluginManager(app=self)
|
|
59
|
+
self.shell = Shell()
|
|
60
|
+
self.notify = Notifications(theme=self.theme, console=self.console)
|
|
61
|
+
self.dialog = Dialog(theme=self.theme)
|
|
62
|
+
self.statusbar = StatusBar(theme=self.theme, console=self.console)
|
|
63
|
+
|
|
64
|
+
self._should_exit = False
|
|
65
|
+
self._last_form_data: dict = {}
|
|
66
|
+
self._main_menu: Menu | None = None
|
|
67
|
+
|
|
68
|
+
# ------------------------------------------------------------------
|
|
69
|
+
# UI factories
|
|
70
|
+
# ------------------------------------------------------------------
|
|
71
|
+
def menu(self, title: str = "Menu") -> Menu:
|
|
72
|
+
m = Menu(title=title, app=self)
|
|
73
|
+
if self._main_menu is None:
|
|
74
|
+
self._main_menu = m
|
|
75
|
+
return m
|
|
76
|
+
|
|
77
|
+
def form(self, title: str = "Form") -> Form:
|
|
78
|
+
return Form(title=title, app=self)
|
|
79
|
+
|
|
80
|
+
def table(self, title: str = "", columns: list[str] | None = None) -> Table:
|
|
81
|
+
return Table(title=title, columns=columns, theme=self.theme)
|
|
82
|
+
|
|
83
|
+
def tree(self, label: str) -> Tree:
|
|
84
|
+
return Tree(label)
|
|
85
|
+
|
|
86
|
+
def card(self, title: str, content: str = "") -> Card:
|
|
87
|
+
return Card(title, content, theme=self.theme)
|
|
88
|
+
|
|
89
|
+
def dashboard(self, title: str = "Dashboard") -> Dashboard:
|
|
90
|
+
return Dashboard(title, theme=self.theme)
|
|
91
|
+
|
|
92
|
+
def workflow(self, name: str = "workflow") -> Workflow:
|
|
93
|
+
return Workflow(app=self, name=name)
|
|
94
|
+
|
|
95
|
+
def progress(self, label: str = "Working", total: int | None = None) -> ProgressContext:
|
|
96
|
+
return ProgressContext(label=label, total=total)
|
|
97
|
+
|
|
98
|
+
def spinner(self, label: str = "Loading...") -> Spinner:
|
|
99
|
+
return Spinner(label=label, console=self.console)
|
|
100
|
+
|
|
101
|
+
# ------------------------------------------------------------------
|
|
102
|
+
# Navigation / pages
|
|
103
|
+
# ------------------------------------------------------------------
|
|
104
|
+
def page(self, name: str, handler=None):
|
|
105
|
+
return self.router.page(name, handler)
|
|
106
|
+
|
|
107
|
+
def goto(self, name: str, *args, **kwargs):
|
|
108
|
+
return self.router.goto(name, *args, **kwargs)
|
|
109
|
+
|
|
110
|
+
def back(self):
|
|
111
|
+
return self.router.back()
|
|
112
|
+
|
|
113
|
+
# ------------------------------------------------------------------
|
|
114
|
+
# Conditional navigation
|
|
115
|
+
# ------------------------------------------------------------------
|
|
116
|
+
def if_value(self, key: str, **comparisons) -> ConditionBuilder:
|
|
117
|
+
return ConditionBuilder(self, self._last_form_data, key, **comparisons)
|
|
118
|
+
|
|
119
|
+
def when(self, condition_fn):
|
|
120
|
+
return _when(condition_fn)
|
|
121
|
+
|
|
122
|
+
# ------------------------------------------------------------------
|
|
123
|
+
# Events
|
|
124
|
+
# ------------------------------------------------------------------
|
|
125
|
+
def on_start(self, fn):
|
|
126
|
+
return self.events.on("start", fn)
|
|
127
|
+
|
|
128
|
+
def on_exit(self, fn):
|
|
129
|
+
return self.events.on("exit", fn)
|
|
130
|
+
|
|
131
|
+
def on_error(self, fn):
|
|
132
|
+
return self.events.on("error", fn)
|
|
133
|
+
|
|
134
|
+
def on_submit(self, form: Form):
|
|
135
|
+
"""Decorator: @app.on_submit(some_form) def handler(data): ..."""
|
|
136
|
+
|
|
137
|
+
def decorator(fn):
|
|
138
|
+
def wrapped(data):
|
|
139
|
+
self._last_form_data = data
|
|
140
|
+
return fn(data)
|
|
141
|
+
|
|
142
|
+
self.events.on(f"submit:{form.id}", wrapped)
|
|
143
|
+
return fn
|
|
144
|
+
|
|
145
|
+
return decorator
|
|
146
|
+
|
|
147
|
+
# ------------------------------------------------------------------
|
|
148
|
+
# Shell / tasks / plugins
|
|
149
|
+
# ------------------------------------------------------------------
|
|
150
|
+
def cmd(self, command: str, capture: bool = False, background: bool = False,
|
|
151
|
+
check: bool = False, cwd: str | None = None):
|
|
152
|
+
return self.shell.run(command, capture=capture, background=background,
|
|
153
|
+
check=check, cwd=cwd)
|
|
154
|
+
|
|
155
|
+
def task(self, fn=None, *, name: str | None = None):
|
|
156
|
+
return self.tasks.register(fn, name=name)
|
|
157
|
+
|
|
158
|
+
def run_task(self, name: str, *args, **kwargs):
|
|
159
|
+
return self.tasks.run(name, *args, **kwargs)
|
|
160
|
+
|
|
161
|
+
def use(self, plugin):
|
|
162
|
+
return self.plugins.use(plugin)
|
|
163
|
+
|
|
164
|
+
# ------------------------------------------------------------------
|
|
165
|
+
# Notifications shortcuts
|
|
166
|
+
# ------------------------------------------------------------------
|
|
167
|
+
def success(self, message: str):
|
|
168
|
+
self.notify.success(message)
|
|
169
|
+
|
|
170
|
+
def error(self, message: str):
|
|
171
|
+
self.notify.error(message)
|
|
172
|
+
|
|
173
|
+
def warning(self, message: str):
|
|
174
|
+
self.notify.warning(message)
|
|
175
|
+
|
|
176
|
+
def info(self, message: str):
|
|
177
|
+
self.notify.info(message)
|
|
178
|
+
|
|
179
|
+
# ------------------------------------------------------------------
|
|
180
|
+
# Lifecycle
|
|
181
|
+
# ------------------------------------------------------------------
|
|
182
|
+
def exit(self, *args, **kwargs):
|
|
183
|
+
"""Register as a menu handler or call directly to stop the app loop."""
|
|
184
|
+
if self.config.confirm_exit:
|
|
185
|
+
if not self.dialog.confirm_exit():
|
|
186
|
+
return
|
|
187
|
+
self._should_exit = True
|
|
188
|
+
self.events.emit("exit")
|
|
189
|
+
self.plugins.exit_all()
|
|
190
|
+
|
|
191
|
+
def run(self):
|
|
192
|
+
try:
|
|
193
|
+
self.events.emit("start")
|
|
194
|
+
self.plugins.start_all()
|
|
195
|
+
if self._main_menu is not None:
|
|
196
|
+
self._main_menu.run()
|
|
197
|
+
except (KeyboardInterrupt, EOFError):
|
|
198
|
+
self.console.print()
|
|
199
|
+
self.info("Interrupted. Exiting.")
|
|
200
|
+
except ClidevError as e:
|
|
201
|
+
self.events.emit("error", e)
|
|
202
|
+
self.error(str(e))
|
|
203
|
+
sys.exit(1)
|
|
204
|
+
finally:
|
|
205
|
+
if not self._should_exit:
|
|
206
|
+
self.events.emit("exit")
|
|
207
|
+
self.plugins.exit_all()
|
|
208
|
+
|
|
209
|
+
def __repr__(self):
|
|
210
|
+
return f"App(title={self.title!r})"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Built-in reusable pieces (sample plugins, sample themes) shipped with clidev."""
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""A couple of small example plugins shipped with clidev for reference."""
|
|
2
|
+
|
|
3
|
+
from ..plugins import Plugin
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class GitPlugin(Plugin):
|
|
7
|
+
"""Adds basic git-awareness to an app: checks if the cwd is a repo."""
|
|
8
|
+
|
|
9
|
+
name = "git"
|
|
10
|
+
|
|
11
|
+
def on_install(self, app):
|
|
12
|
+
result = app.cmd("git rev-parse --is-inside-work-tree", capture=True)
|
|
13
|
+
app.state["git_repo"] = result.ok
|
|
14
|
+
|
|
15
|
+
def on_start(self, app):
|
|
16
|
+
if app.state.get("git_repo"):
|
|
17
|
+
app.logger.debug("Git repository detected.")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class DatabasePlugin(Plugin):
|
|
21
|
+
"""Example plugin that ensures a storage backend is ready on start."""
|
|
22
|
+
|
|
23
|
+
name = "database"
|
|
24
|
+
|
|
25
|
+
def on_start(self, app):
|
|
26
|
+
app.logger.debug(f"Storage backend ready: {app.storage.all().keys()}")
|
clidev/cards.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Card widget: a titled panel of content, wraps rich.panel.Panel."""
|
|
2
|
+
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from rich.panel import Panel
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Card:
|
|
8
|
+
def __init__(self, title: str, content: str = "", theme=None):
|
|
9
|
+
self.title = title
|
|
10
|
+
self.content = content
|
|
11
|
+
self.theme = theme
|
|
12
|
+
self._console = Console()
|
|
13
|
+
|
|
14
|
+
def show(self):
|
|
15
|
+
color = self.theme.get("primary") if self.theme else None
|
|
16
|
+
border_style = color or "white"
|
|
17
|
+
self._console.print(
|
|
18
|
+
Panel(self.content, title=self.title, border_style=border_style, expand=False)
|
|
19
|
+
)
|
clidev/cli.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""The `clidev` terminal command: project scaffolding utilities."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
|
|
6
|
+
from .generators.project import create_project
|
|
7
|
+
from . import __version__
|
|
8
|
+
|
|
9
|
+
console = Console()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@click.group()
|
|
13
|
+
@click.version_option(__version__, prog_name="clidev")
|
|
14
|
+
def main():
|
|
15
|
+
"""clidev: build production-ready CLI apps and TUIs with minimal code."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@main.command("new")
|
|
19
|
+
@click.argument("name")
|
|
20
|
+
@click.option("--dir", "target_dir", default=None, help="Parent directory to create the project in.")
|
|
21
|
+
def new(name, target_dir):
|
|
22
|
+
"""Scaffold a new clidev project called NAME."""
|
|
23
|
+
try:
|
|
24
|
+
project_dir = create_project(name, target_dir)
|
|
25
|
+
except FileExistsError as e:
|
|
26
|
+
console.print(f"[bold red]\u2717[/bold red] {e}")
|
|
27
|
+
raise SystemExit(1)
|
|
28
|
+
|
|
29
|
+
console.print(f"[bold green]\u2713[/bold green] Created new clidev project at [bold]{project_dir}[/bold]")
|
|
30
|
+
console.print("\nNext steps:")
|
|
31
|
+
console.print(f" cd {project_dir.name}")
|
|
32
|
+
console.print(" python app.py")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@main.command("version")
|
|
36
|
+
def version():
|
|
37
|
+
"""Print the installed clidev version."""
|
|
38
|
+
console.print(f"clidev {__version__}")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
if __name__ == "__main__":
|
|
42
|
+
main()
|
clidev/colors.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Color name constants and helpers, mapped to rich-compatible color strings."""
|
|
2
|
+
|
|
3
|
+
PALETTE = {
|
|
4
|
+
"blue": "#3B82F6",
|
|
5
|
+
"green": "#22C55E",
|
|
6
|
+
"red": "#EF4444",
|
|
7
|
+
"yellow": "#EAB308",
|
|
8
|
+
"orange": "#F97316",
|
|
9
|
+
"purple": "#A855F7",
|
|
10
|
+
"cyan": "#06B6D4",
|
|
11
|
+
"magenta": "#D946EF",
|
|
12
|
+
"white": "#FFFFFF",
|
|
13
|
+
"black": "#000000",
|
|
14
|
+
"gray": "#6B7280",
|
|
15
|
+
"grey": "#6B7280",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def resolve(color: str) -> str:
|
|
20
|
+
"""Resolve a friendly color name to a rich-compatible color string.
|
|
21
|
+
|
|
22
|
+
If the color isn't in the palette, it's passed through unchanged
|
|
23
|
+
(so hex codes and rich color names still work).
|
|
24
|
+
"""
|
|
25
|
+
if not color:
|
|
26
|
+
return color
|
|
27
|
+
return PALETTE.get(color.lower(), color)
|
clidev/config.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Simple application configuration container."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Config:
|
|
5
|
+
"""Holds app-level configuration with attribute and dict-style access."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, **kwargs):
|
|
8
|
+
self._data = {
|
|
9
|
+
"theme": "dark",
|
|
10
|
+
"storage_backend": "memory",
|
|
11
|
+
"storage_path": None,
|
|
12
|
+
"log_timestamps": True,
|
|
13
|
+
"confirm_exit": False,
|
|
14
|
+
}
|
|
15
|
+
self._data.update(kwargs)
|
|
16
|
+
|
|
17
|
+
def __getattr__(self, key):
|
|
18
|
+
try:
|
|
19
|
+
return self._data[key]
|
|
20
|
+
except KeyError as e:
|
|
21
|
+
raise AttributeError(key) from e
|
|
22
|
+
|
|
23
|
+
def __setattr__(self, key, value):
|
|
24
|
+
if key == "_data":
|
|
25
|
+
super().__setattr__(key, value)
|
|
26
|
+
else:
|
|
27
|
+
self._data[key] = value
|
|
28
|
+
|
|
29
|
+
def __getitem__(self, key):
|
|
30
|
+
return self._data[key]
|
|
31
|
+
|
|
32
|
+
def __setitem__(self, key, value):
|
|
33
|
+
self._data[key] = value
|
|
34
|
+
|
|
35
|
+
def as_dict(self):
|
|
36
|
+
return dict(self._data)
|
clidev/dashboard.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Dashboard: a simple grid layout of panels for at-a-glance views."""
|
|
2
|
+
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from rich.columns import Columns
|
|
5
|
+
from rich.panel import Panel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Dashboard:
|
|
9
|
+
"""app.dashboard("Overview").panel("Users", "1,204").panel("Errors", "3").show()"""
|
|
10
|
+
|
|
11
|
+
def __init__(self, title: str = "Dashboard", theme=None):
|
|
12
|
+
self.title = title
|
|
13
|
+
self.theme = theme
|
|
14
|
+
self._panels: list[tuple[str, str]] = []
|
|
15
|
+
self._console = Console()
|
|
16
|
+
|
|
17
|
+
def panel(self, title: str, content: str) -> "Dashboard":
|
|
18
|
+
self._panels.append((title, content))
|
|
19
|
+
return self
|
|
20
|
+
|
|
21
|
+
def show(self):
|
|
22
|
+
color = self.theme.get("primary") if self.theme else "white"
|
|
23
|
+
self._console.rule(f"[bold {color}]{self.title}[/bold {color}]")
|
|
24
|
+
rendered = [
|
|
25
|
+
Panel(str(content), title=title, border_style=color, expand=True)
|
|
26
|
+
for title, content in self._panels
|
|
27
|
+
]
|
|
28
|
+
self._console.print(Columns(rendered, equal=True, expand=True))
|
clidev/dialogs.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Dialog helpers: confirm / prompt / alert boxes."""
|
|
2
|
+
|
|
3
|
+
import questionary
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.panel import Panel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Dialog:
|
|
9
|
+
def __init__(self, theme=None):
|
|
10
|
+
self.theme = theme
|
|
11
|
+
self._console = Console()
|
|
12
|
+
|
|
13
|
+
def confirm(self, message: str, default: bool = True) -> bool:
|
|
14
|
+
return questionary.confirm(message, default=default).ask()
|
|
15
|
+
|
|
16
|
+
def prompt(self, message: str, default: str = "") -> str:
|
|
17
|
+
return questionary.text(message, default=default).ask()
|
|
18
|
+
|
|
19
|
+
def alert(self, message: str, title: str = "Alert"):
|
|
20
|
+
color = self.theme.get("warning") if self.theme else "yellow"
|
|
21
|
+
self._console.print(Panel(message, title=title, border_style=color, expand=False))
|
|
22
|
+
|
|
23
|
+
def confirm_exit(self, message: str = "Are you sure you want to exit?") -> bool:
|
|
24
|
+
return self.confirm(message, default=False)
|
clidev/events.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Event dispatcher powering @app.on_start / on_exit / on_submit / on_error."""
|
|
2
|
+
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class EventDispatcher:
|
|
7
|
+
"""A minimal pub/sub dispatcher.
|
|
8
|
+
|
|
9
|
+
Named events: "start", "exit", "error" plus per-form "submit:<form_id>".
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def __init__(self):
|
|
13
|
+
self._handlers = defaultdict(list)
|
|
14
|
+
|
|
15
|
+
def on(self, event: str, handler=None):
|
|
16
|
+
"""Register a handler for `event`. Can be used as a decorator too."""
|
|
17
|
+
if handler is not None:
|
|
18
|
+
self._handlers[event].append(handler)
|
|
19
|
+
return handler
|
|
20
|
+
|
|
21
|
+
def decorator(fn):
|
|
22
|
+
self._handlers[event].append(fn)
|
|
23
|
+
return fn
|
|
24
|
+
|
|
25
|
+
return decorator
|
|
26
|
+
|
|
27
|
+
def off(self, event: str, handler):
|
|
28
|
+
if handler in self._handlers[event]:
|
|
29
|
+
self._handlers[event].remove(handler)
|
|
30
|
+
|
|
31
|
+
def emit(self, event: str, *args, **kwargs):
|
|
32
|
+
"""Call every handler registered for `event`, returning their results."""
|
|
33
|
+
results = []
|
|
34
|
+
for handler in self._handlers.get(event, []):
|
|
35
|
+
results.append(handler(*args, **kwargs))
|
|
36
|
+
return results
|
|
37
|
+
|
|
38
|
+
def has(self, event: str) -> bool:
|
|
39
|
+
return bool(self._handlers.get(event))
|
clidev/exceptions.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Custom exceptions used across clidev."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ClidevError(Exception):
|
|
5
|
+
"""Base exception for all clidev errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class NavigationError(ClidevError):
|
|
9
|
+
"""Raised when navigation (goto/back) fails, e.g. unknown page."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ValidationError(ClidevError):
|
|
13
|
+
"""Raised when a form field fails validation."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class StorageError(ClidevError):
|
|
17
|
+
"""Raised when a storage backend operation fails."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class WorkflowError(ClidevError):
|
|
21
|
+
"""Raised when a workflow step fails or is misconfigured."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PluginError(ClidevError):
|
|
25
|
+
"""Raised when a plugin fails to load or run."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CommandError(ClidevError):
|
|
29
|
+
"""Raised when a shell command fails (non-zero exit) and check=True."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, message, returncode=None, stdout=None, stderr=None):
|
|
32
|
+
super().__init__(message)
|
|
33
|
+
self.returncode = returncode
|
|
34
|
+
self.stdout = stdout
|
|
35
|
+
self.stderr = stderr
|