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/forms.py ADDED
@@ -0,0 +1,97 @@
1
+ """Chainable form builder: app.form("User").text("Name")...run()"""
2
+
3
+ import uuid
4
+
5
+ from rich.console import Console
6
+ from rich.panel import Panel
7
+
8
+ from .inputs import Field
9
+
10
+
11
+ class Form:
12
+ """A chainable form. Each `.text()`, `.email()`, etc. call adds a field.
13
+
14
+ form = app.form("User")
15
+ form.text("Name")
16
+ form.email("Email")
17
+ data = form.run()
18
+ """
19
+
20
+ def __init__(self, title: str = "Form", app=None):
21
+ self.title = title
22
+ self.app = app
23
+ self.id = f"form_{uuid.uuid4().hex[:8]}"
24
+ self.fields: list[Field] = []
25
+ self._console = Console()
26
+
27
+ # --- field builders ---------------------------------------------------
28
+ def _add(self, name, kind, **kwargs) -> "Form":
29
+ self.fields.append(Field(name=name, kind=kind, **kwargs))
30
+ return self
31
+
32
+ def text(self, name, **kwargs) -> "Form":
33
+ return self._add(name, "text", **kwargs)
34
+
35
+ def email(self, name, **kwargs) -> "Form":
36
+ return self._add(name, "email", **kwargs)
37
+
38
+ def password(self, name, **kwargs) -> "Form":
39
+ return self._add(name, "password", **kwargs)
40
+
41
+ def number(self, name, **kwargs) -> "Form":
42
+ return self._add(name, "number", **kwargs)
43
+
44
+ def url(self, name, **kwargs) -> "Form":
45
+ return self._add(name, "url", **kwargs)
46
+
47
+ def file(self, name, **kwargs) -> "Form":
48
+ return self._add(name, "file", **kwargs)
49
+
50
+ def folder(self, name, **kwargs) -> "Form":
51
+ return self._add(name, "folder", **kwargs)
52
+
53
+ def date(self, name, **kwargs) -> "Form":
54
+ return self._add(name, "date", **kwargs)
55
+
56
+ def time(self, name, **kwargs) -> "Form":
57
+ return self._add(name, "time", **kwargs)
58
+
59
+ def checkbox(self, name, **kwargs) -> "Form":
60
+ return self._add(name, "checkbox", **kwargs)
61
+
62
+ def toggle(self, name, **kwargs) -> "Form":
63
+ return self._add(name, "toggle", **kwargs)
64
+
65
+ def radio(self, name, choices, **kwargs) -> "Form":
66
+ return self._add(name, "radio", choices=choices, **kwargs)
67
+
68
+ def select(self, name, choices, **kwargs) -> "Form":
69
+ return self._add(name, "select", choices=choices, **kwargs)
70
+
71
+ def dropdown(self, name, choices, **kwargs) -> "Form":
72
+ return self._add(name, "dropdown", choices=choices, **kwargs)
73
+
74
+ def multiselect(self, name, choices, **kwargs) -> "Form":
75
+ return self._add(name, "multiselect", choices=choices, **kwargs)
76
+
77
+ def searchable(self, name, choices, **kwargs) -> "Form":
78
+ return self._add(name, "searchable", choices=choices, **kwargs)
79
+
80
+ # --- execution ----------------------------------------------------------
81
+ def run(self) -> dict:
82
+ """Render the form interactively, one field at a time, and return a dict."""
83
+ self._console.print(Panel(f"[bold]{self.title}[/bold]", expand=False))
84
+ data = {}
85
+ try:
86
+ for f in self.fields:
87
+ data[f.name] = f.prompt()
88
+ except (KeyboardInterrupt, EOFError):
89
+ return {}
90
+
91
+ if self.app is not None:
92
+ self.app.events.emit(f"submit:{self.id}", data)
93
+ self.app.state.update({f"{self.title}.{k}": v for k, v in data.items()})
94
+ return data
95
+
96
+ def __repr__(self):
97
+ return f"Form(title={self.title!r}, fields={[f.name for f in self.fields]})"
@@ -0,0 +1,13 @@
1
+ """Code generators used by the `clidev` CLI command (project/menu/form/workflow)."""
2
+
3
+ from .project import create_project
4
+ from .menu import generate_menu_snippet
5
+ from .form import generate_form_snippet
6
+ from .workflow import generate_workflow_snippet
7
+
8
+ __all__ = [
9
+ "create_project",
10
+ "generate_menu_snippet",
11
+ "generate_form_snippet",
12
+ "generate_workflow_snippet",
13
+ ]
@@ -0,0 +1,11 @@
1
+ """Helper to scaffold a new form snippet."""
2
+
3
+ FORM_SNIPPET = '''
4
+ {var_name} = app.form("{title}")
5
+ {var_name}.text("Name")
6
+ '''
7
+
8
+
9
+ def generate_form_snippet(title: str) -> str:
10
+ var_name = title.lower().replace(" ", "_")
11
+ return FORM_SNIPPET.format(var_name=var_name, title=title)
@@ -0,0 +1,11 @@
1
+ """Helper to scaffold a new menu snippet into menus.py-like files."""
2
+
3
+ MENU_SNIPPET = '''
4
+ {var_name} = app.menu("{title}")
5
+ {var_name}.option("Exit", app.exit)
6
+ '''
7
+
8
+
9
+ def generate_menu_snippet(title: str) -> str:
10
+ var_name = title.lower().replace(" ", "_")
11
+ return MENU_SNIPPET.format(var_name=var_name, title=title)
@@ -0,0 +1,150 @@
1
+ """Scaffolds a new clidev project: `clidev new myproject`."""
2
+
3
+ from pathlib import Path
4
+
5
+ APP_PY = '''"""Main application entry point for {project_name}."""
6
+
7
+ from clidev import App
8
+
9
+ from routes import register_routes
10
+ from menus import build_menus
11
+
12
+ app = App("{title}")
13
+
14
+ register_routes(app)
15
+ build_menus(app)
16
+
17
+ if __name__ == "__main__":
18
+ app.run()
19
+ '''
20
+
21
+ ROUTES_PY = '''"""Page routes for {project_name}."""
22
+
23
+
24
+ def register_routes(app):
25
+ @app.page("home")
26
+ def home():
27
+ app.info("Welcome to {title}!")
28
+ '''
29
+
30
+ MENUS_PY = '''"""Menu definitions for {project_name}."""
31
+
32
+
33
+ def build_menus(app):
34
+ main = app.menu("Main Menu")
35
+ main.option("Say Hello", lambda: app.success("Hello from {title}!"))
36
+ main.option("Exit", app.exit)
37
+ return main
38
+ '''
39
+
40
+ FORMS_PY = '''"""Form definitions for {project_name}."""
41
+
42
+
43
+ def build_forms(app):
44
+ form = app.form("Example")
45
+ form.text("Name")
46
+ form.email("Email")
47
+ return form
48
+ '''
49
+
50
+ WORKFLOWS_PY = '''"""Workflow definitions for {project_name}."""
51
+
52
+
53
+ def build_workflows(app):
54
+ workflow = app.workflow("example")
55
+
56
+ def step_one(context):
57
+ app.info("Step one running...")
58
+ return {{"step_one_done": True}}
59
+
60
+ workflow.step(step_one)
61
+ return workflow
62
+ '''
63
+
64
+ COMMANDS_PY = '''"""Shell command helpers for {project_name}."""
65
+
66
+
67
+ def setup_environment(app):
68
+ app.cmd("python -m venv .venv")
69
+ app.success("Virtual environment created.")
70
+ '''
71
+
72
+ STORAGE_PY = '''"""Storage configuration for {project_name}."""
73
+
74
+
75
+ def configure_storage(app):
76
+ # Swap "memory" for "json", "sqlite", "yaml", or "toml" as needed.
77
+ return app.storage
78
+ '''
79
+
80
+ SETTINGS_PY = '''"""Settings for {project_name}."""
81
+
82
+ APP_TITLE = "{title}"
83
+ THEME = "dark"
84
+ STORAGE_BACKEND = "memory"
85
+ '''
86
+
87
+ GITIGNORE = """__pycache__/
88
+ *.pyc
89
+ .venv/
90
+ .clidev_storage.json
91
+ .clidev_storage.db
92
+ .clidev_storage.yaml
93
+ .clidev_storage.toml
94
+ """
95
+
96
+ README = '''# {title}
97
+
98
+ A CLI application built with [clidev](https://github.com/clidev/clidev).
99
+
100
+ ## Run
101
+
102
+ ```bash
103
+ python app.py
104
+ ```
105
+
106
+ ## Structure
107
+
108
+ - `app.py` - main entry point
109
+ - `routes.py` - page routes
110
+ - `menus.py` - menu definitions
111
+ - `forms.py` - form definitions
112
+ - `workflows.py` - workflow definitions
113
+ - `commands.py` - shell command helpers
114
+ - `storage.py` - storage configuration
115
+ - `settings.py` - app settings
116
+ - `assets/` - static assets
117
+ '''
118
+
119
+
120
+ def create_project(name: str, target_dir: str | None = None) -> Path:
121
+ """Create a new clidev project scaffold named `name`."""
122
+ base = Path(target_dir) if target_dir else Path.cwd()
123
+ project_dir = base / name
124
+ if project_dir.exists():
125
+ raise FileExistsError(f"Directory '{project_dir}' already exists.")
126
+
127
+ project_dir.mkdir(parents=True)
128
+ (project_dir / "assets").mkdir()
129
+
130
+ title = name.replace("_", " ").replace("-", " ").title()
131
+ ctx = {"project_name": name, "title": title}
132
+
133
+ files = {
134
+ "app.py": APP_PY,
135
+ "routes.py": ROUTES_PY,
136
+ "menus.py": MENUS_PY,
137
+ "forms.py": FORMS_PY,
138
+ "workflows.py": WORKFLOWS_PY,
139
+ "commands.py": COMMANDS_PY,
140
+ "storage.py": STORAGE_PY,
141
+ "settings.py": SETTINGS_PY,
142
+ ".gitignore": GITIGNORE,
143
+ "README.md": README,
144
+ }
145
+
146
+ for filename, template in files.items():
147
+ content = template.format(**ctx) if "{" in template else template
148
+ (project_dir / filename).write_text(content)
149
+
150
+ return project_dir
@@ -0,0 +1,17 @@
1
+ """Helper to scaffold a new workflow snippet."""
2
+
3
+ WORKFLOW_SNIPPET = '''
4
+ {var_name} = app.workflow("{name}")
5
+
6
+
7
+ def {var_name}_step_one(context):
8
+ app.info("Running {name} step one...")
9
+
10
+
11
+ {var_name}.step({var_name}_step_one)
12
+ '''
13
+
14
+
15
+ def generate_workflow_snippet(name: str) -> str:
16
+ var_name = name.lower().replace(" ", "_")
17
+ return WORKFLOW_SNIPPET.format(var_name=var_name, name=name)
clidev/icons.py ADDED
@@ -0,0 +1,16 @@
1
+ """Small set of unicode icons used throughout clidev's UI output."""
2
+
3
+ ICONS = {
4
+ "success": "\u2713", # check mark
5
+ "error": "\u2717", # cross mark
6
+ "warning": "\u26A0", # warning sign
7
+ "info": "\u2139", # info
8
+ "arrow": "\u2192", # right arrow
9
+ "bullet": "\u2022",
10
+ "star": "\u2605",
11
+ "spinner": "\u25CF",
12
+ }
13
+
14
+
15
+ def icon(name: str, default: str = "") -> str:
16
+ return ICONS.get(name, default)
clidev/inputs.py ADDED
@@ -0,0 +1,90 @@
1
+ """Individual input widgets used by Form. Rendered via questionary."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, Callable
5
+
6
+ import questionary
7
+
8
+ from . import validators as v
9
+ from .exceptions import ValidationError
10
+
11
+
12
+ @dataclass
13
+ class Field:
14
+ """Represents a single form field/input widget."""
15
+
16
+ name: str
17
+ kind: str
18
+ label: str | None = None
19
+ choices: list | None = None
20
+ default: Any = None
21
+ required: bool = True
22
+ extra_validators: list[Callable] = field(default_factory=list)
23
+
24
+ def _label(self):
25
+ return self.label or self.name
26
+
27
+ def _validate(self, value):
28
+ validators = []
29
+ if self.required:
30
+ validators.append(v.required)
31
+ if self.kind == "email":
32
+ validators.append(v.is_email)
33
+ elif self.kind == "url":
34
+ validators.append(v.is_url)
35
+ elif self.kind == "number":
36
+ validators.append(v.is_number)
37
+ elif self.kind == "date":
38
+ validators.append(v.is_date())
39
+ validators.extend(self.extra_validators)
40
+ return v.run_validators(value, validators)
41
+
42
+ def prompt(self):
43
+ """Render this field interactively and return the validated value."""
44
+ label = self._label()
45
+
46
+ def _q_validate(raw):
47
+ try:
48
+ self._validate(raw)
49
+ return True
50
+ except ValidationError as e:
51
+ return str(e)
52
+
53
+ if self.kind == "text":
54
+ answer = questionary.text(label, default=self.default or "", validate=_q_validate).ask()
55
+ elif self.kind == "email":
56
+ answer = questionary.text(label, default=self.default or "", validate=_q_validate).ask()
57
+ elif self.kind == "url":
58
+ answer = questionary.text(label, default=self.default or "", validate=_q_validate).ask()
59
+ elif self.kind == "password":
60
+ answer = questionary.password(label).ask()
61
+ elif self.kind == "number":
62
+ answer = questionary.text(label, default=str(self.default or ""), validate=_q_validate).ask()
63
+ answer = v.is_number(answer) if answer not in (None, "") else None
64
+ elif self.kind == "date":
65
+ answer = questionary.text(label + " (YYYY-MM-DD)", validate=_q_validate).ask()
66
+ elif self.kind == "time":
67
+ answer = questionary.text(label + " (HH:MM)").ask()
68
+ elif self.kind == "file":
69
+ answer = questionary.path(label).ask()
70
+ elif self.kind == "folder":
71
+ answer = questionary.path(label, only_directories=True).ask()
72
+ elif self.kind == "checkbox":
73
+ answer = questionary.confirm(label, default=bool(self.default)).ask()
74
+ elif self.kind == "toggle":
75
+ answer = questionary.confirm(label, default=bool(self.default)).ask()
76
+ elif self.kind == "select" or self.kind == "dropdown":
77
+ answer = questionary.select(label, choices=self.choices or []).ask()
78
+ elif self.kind == "radio":
79
+ answer = questionary.select(label, choices=self.choices or []).ask()
80
+ elif self.kind == "multiselect":
81
+ answer = questionary.checkbox(label, choices=self.choices or []).ask()
82
+ elif self.kind == "searchable":
83
+ answer = questionary.autocomplete(label, choices=self.choices or []).ask()
84
+ else:
85
+ answer = questionary.text(label).ask()
86
+
87
+ if self.kind not in ("number", "checkbox", "toggle", "multiselect"):
88
+ if answer is not None:
89
+ self._validate(answer)
90
+ return answer
clidev/logger.py ADDED
@@ -0,0 +1,50 @@
1
+ """Themed logger used as app.logger."""
2
+
3
+ import datetime as _dt
4
+
5
+ from rich.console import Console
6
+
7
+
8
+ class Logger:
9
+ """Simple themed logger.
10
+
11
+ app.logger.info("Started")
12
+ app.logger.warning("Careful")
13
+ app.logger.error("Failed")
14
+ """
15
+
16
+ def __init__(self, theme=None, console: Console | None = None, timestamps: bool = True):
17
+ self.theme = theme
18
+ self.console = console or Console()
19
+ self.timestamps = timestamps
20
+ self._history = []
21
+
22
+ def _ts(self):
23
+ return _dt.datetime.now().strftime("%H:%M:%S")
24
+
25
+ def _emit(self, level, style_key, symbol, message):
26
+ color = self.theme.get(style_key) if self.theme else None
27
+ prefix = f"[dim]{self._ts()}[/dim] " if self.timestamps else ""
28
+ style = f"bold {color}" if color else "bold"
29
+ line = f"{prefix}[{style}]{symbol} {level.upper()}[/{style}] {message}"
30
+ self._history.append((level, message))
31
+ self.console.print(line)
32
+
33
+ def info(self, message):
34
+ self._emit("info", "info", "\u2139", message)
35
+
36
+ def success(self, message):
37
+ self._emit("success", "success", "\u2713", message)
38
+
39
+ def warning(self, message):
40
+ self._emit("warning", "warning", "\u26A0", message)
41
+
42
+ def error(self, message):
43
+ self._emit("error", "error", "\u2717", message)
44
+
45
+ def debug(self, message):
46
+ self._emit("debug", "muted", "\u2022", message)
47
+
48
+ @property
49
+ def history(self):
50
+ return list(self._history)
clidev/menus.py ADDED
@@ -0,0 +1,69 @@
1
+ """Menu + nested menu engine."""
2
+
3
+ import questionary
4
+ from rich.console import Console
5
+ from rich.panel import Panel
6
+
7
+
8
+ class MenuOption:
9
+ def __init__(self, label: str, target):
10
+ self.label = label
11
+ self.target = target # callable, page name string, or Menu instance
12
+
13
+
14
+ class Menu:
15
+ """A single-level (optionally nested) menu.
16
+
17
+ menu = app.menu("Main Menu")
18
+ menu.option("Create Project", create_project)
19
+ menu.option("Deploy", deploy)
20
+ menu.option("Exit", app.exit)
21
+
22
+ Nested:
23
+ main.link("Settings", settings_menu)
24
+ """
25
+
26
+ def __init__(self, title: str = "Menu", app=None):
27
+ self.title = title
28
+ self.app = app
29
+ self.options: list[MenuOption] = []
30
+ self._console = Console()
31
+
32
+ def option(self, label: str, target) -> "Menu":
33
+ """Add a menu entry. `target` can be a callable, a page-name string,
34
+ or another Menu (equivalent to .link)."""
35
+ self.options.append(MenuOption(label, target))
36
+ return self
37
+
38
+ def link(self, label: str, submenu: "Menu") -> "Menu":
39
+ """Add a nested submenu entry."""
40
+ self.options.append(MenuOption(label, submenu))
41
+ return self
42
+
43
+ def _resolve(self, target):
44
+ if isinstance(target, Menu):
45
+ target.run()
46
+ elif isinstance(target, str):
47
+ if self.app is not None:
48
+ self.app.goto(target)
49
+ elif callable(target):
50
+ target()
51
+
52
+ def run(self):
53
+ """Render the menu in a loop until the user exits or a handler navigates away."""
54
+ while True:
55
+ self._console.print(Panel(f"[bold]{self.title}[/bold]", expand=False))
56
+ choice = questionary.select(
57
+ "Select an option:",
58
+ choices=[opt.label for opt in self.options],
59
+ ).ask()
60
+ if choice is None:
61
+ return
62
+ selected = next(o for o in self.options if o.label == choice)
63
+ self._resolve(selected.target)
64
+ # If app signalled exit, stop looping.
65
+ if self.app is not None and getattr(self.app, "_should_exit", False):
66
+ return
67
+
68
+ def __repr__(self):
69
+ return f"Menu(title={self.title!r}, options={[o.label for o in self.options]})"
@@ -0,0 +1,26 @@
1
+ """Inline notification / toast-style messages (success, error, info, warning)."""
2
+
3
+ from rich.console import Console
4
+
5
+
6
+ class Notifications:
7
+ def __init__(self, theme=None, console: Console | None = None):
8
+ self.theme = theme
9
+ self.console = console or Console()
10
+
11
+ def _print(self, symbol: str, style_key: str, message: str):
12
+ color = self.theme.get(style_key) if self.theme else None
13
+ style = f"bold {color}" if color else "bold"
14
+ self.console.print(f"[{style}]{symbol}[/{style}] {message}")
15
+
16
+ def success(self, message: str):
17
+ self._print("\u2713", "success", message)
18
+
19
+ def error(self, message: str):
20
+ self._print("\u2717", "error", message)
21
+
22
+ def warning(self, message: str):
23
+ self._print("\u26A0", "warning", message)
24
+
25
+ def info(self, message: str):
26
+ self._print("\u2139", "info", message)
clidev/pages.py ADDED
@@ -0,0 +1,42 @@
1
+ """Page registration. Pages are named callables the router can navigate to."""
2
+
3
+
4
+ class Page:
5
+ def __init__(self, name: str, handler=None):
6
+ self.name = name
7
+ self.handler = handler
8
+
9
+ def render(self, *args, **kwargs):
10
+ if self.handler is not None:
11
+ return self.handler(*args, **kwargs)
12
+
13
+
14
+ class PageRegistry:
15
+ """Holds all registered pages by name."""
16
+
17
+ def __init__(self):
18
+ self._pages: dict[str, Page] = {}
19
+
20
+ def register(self, name: str, handler=None):
21
+ """Register a page. Can be used directly or as a decorator:
22
+
23
+ app.page("home") -> returns a decorator when handler is None
24
+ """
25
+ if handler is not None:
26
+ self._pages[name] = Page(name, handler)
27
+ return self._pages[name]
28
+
29
+ def decorator(fn):
30
+ self._pages[name] = Page(name, fn)
31
+ return fn
32
+
33
+ return decorator
34
+
35
+ def get(self, name: str) -> Page | None:
36
+ return self._pages.get(name)
37
+
38
+ def exists(self, name: str) -> bool:
39
+ return name in self._pages
40
+
41
+ def names(self):
42
+ return list(self._pages.keys())
clidev/plugins.py ADDED
@@ -0,0 +1,51 @@
1
+ """Plugin system: app.use(SomePlugin())."""
2
+
3
+ from .exceptions import PluginError
4
+
5
+
6
+ class Plugin:
7
+ """Base class for clidev plugins.
8
+
9
+ Subclass and override the lifecycle hooks you need:
10
+
11
+ class GitPlugin(Plugin):
12
+ name = "git"
13
+
14
+ def on_install(self, app):
15
+ app.state["git_enabled"] = True
16
+
17
+ def on_start(self, app):
18
+ ...
19
+ """
20
+
21
+ name: str = "plugin"
22
+
23
+ def on_install(self, app):
24
+ """Called once, immediately when app.use(plugin) runs."""
25
+
26
+ def on_start(self, app):
27
+ """Called when the app starts (app.run())."""
28
+
29
+ def on_exit(self, app):
30
+ """Called when the app exits."""
31
+
32
+
33
+ class PluginManager:
34
+ def __init__(self, app=None):
35
+ self.app = app
36
+ self.plugins: list[Plugin] = []
37
+
38
+ def use(self, plugin: Plugin):
39
+ if not isinstance(plugin, Plugin):
40
+ raise PluginError(f"{plugin!r} must be a Plugin instance.")
41
+ self.plugins.append(plugin)
42
+ plugin.on_install(self.app)
43
+ return plugin
44
+
45
+ def start_all(self):
46
+ for p in self.plugins:
47
+ p.on_start(self.app)
48
+
49
+ def exit_all(self):
50
+ for p in self.plugins:
51
+ p.on_exit(self.app)