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/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]})"
|
clidev/notifications.py
ADDED
|
@@ -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)
|