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 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