luvz 0.0.1.dev0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 luvbyte
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,52 @@
1
+ Metadata-Version: 2.4
2
+ Name: luvz
3
+ Version: 0.0.1.dev0
4
+ Summary: Lazy script builder
5
+ Author-email: luvbyte <lovemelong@protonmail.com>
6
+ License: MIT
7
+ Requires-Python: >=3.8
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENCE
10
+ Requires-Dist: cmd2
11
+ Requires-Dist: rich
12
+ Requires-Dist: pydantic
13
+ Dynamic: license-file
14
+
15
+ # luvz 💤
16
+ A lightweight framework for building interactive Python scripts
17
+
18
+ `luvz` is a Python package built on top of [`cmd2`](https://cmd2.readthedocs.io) that makes it easy to build interactive, command-driven Python programs.
19
+ It gives you a simple API for defining commands, handling user input, and extending functionality — perfect for creating REPL-like tools, admin shells, or prototypes.
20
+
21
+ ---
22
+
23
+ ## ✨ Features
24
+
25
+ - Simple API to create interactive command-line applications
26
+ - Built on `cmd2` for history, tab completion, transcripts, and more
27
+ - Minimal setup — just subclass and add commands
28
+ - Great for prototypes, admin utilities, or sharing interactive tools
29
+
30
+ ---
31
+
32
+ ## 📦 Installation
33
+
34
+ ```bash
35
+ pip install luvz
36
+ ```
37
+
38
+ (or clone and install locally:)
39
+ ```bash
40
+ git clone https://github.com/luvbyte/luvz.git
41
+ cd luvz
42
+ pip install .
43
+ ```
44
+ ---
45
+ ## ⚡ Why luvz?
46
+
47
+ Save time — skip boilerplate when building interactive shells
48
+
49
+ Enjoy rich features from cmd2 without the setup
50
+
51
+ Create professional-feeling CLI tools quickly
52
+
@@ -0,0 +1,38 @@
1
+ # luvz 💤
2
+ A lightweight framework for building interactive Python scripts
3
+
4
+ `luvz` is a Python package built on top of [`cmd2`](https://cmd2.readthedocs.io) that makes it easy to build interactive, command-driven Python programs.
5
+ It gives you a simple API for defining commands, handling user input, and extending functionality — perfect for creating REPL-like tools, admin shells, or prototypes.
6
+
7
+ ---
8
+
9
+ ## ✨ Features
10
+
11
+ - Simple API to create interactive command-line applications
12
+ - Built on `cmd2` for history, tab completion, transcripts, and more
13
+ - Minimal setup — just subclass and add commands
14
+ - Great for prototypes, admin utilities, or sharing interactive tools
15
+
16
+ ---
17
+
18
+ ## 📦 Installation
19
+
20
+ ```bash
21
+ pip install luvz
22
+ ```
23
+
24
+ (or clone and install locally:)
25
+ ```bash
26
+ git clone https://github.com/luvbyte/luvz.git
27
+ cd luvz
28
+ pip install .
29
+ ```
30
+ ---
31
+ ## ⚡ Why luvz?
32
+
33
+ Save time — skip boilerplate when building interactive shells
34
+
35
+ Enjoy rich features from cmd2 without the setup
36
+
37
+ Create professional-feeling CLI tools quickly
38
+
@@ -0,0 +1,10 @@
1
+ from .core.context import ZScript, Arg
2
+ from .core.runner import run_script, run_script_cli, run_script_it
3
+
4
+ __all__ = [
5
+ "Arg",
6
+ "ZScript",
7
+ "run_script",
8
+ "run_script_it",
9
+ "run_script_cli"
10
+ ]
@@ -0,0 +1 @@
1
+ __version__ = "0.0.1"
@@ -0,0 +1,2 @@
1
+ from .script import ZScript
2
+ from .command import Arg, ScriptCommand
@@ -0,0 +1,133 @@
1
+ import cmd2
2
+ import inspect
3
+
4
+ class Arg:
5
+ def __init__(self, *args, **kwargs):
6
+ self.args = args
7
+ self.kwargs = kwargs
8
+
9
+ @staticmethod
10
+ def Path(*args, **kwargs):
11
+ return Arg(completer=cmd2.Cmd.path_complete, *args, **kwargs)
12
+
13
+ @staticmethod
14
+ def Choice(*args, **kwargs):
15
+ return Arg(choices=[str(name) for name in args], **kwargs)
16
+
17
+ class ScriptCommand:
18
+ def __init__(self, name, func, short=None, desc=None):
19
+ self.name = name
20
+ self.func = func # callable
21
+ # Short description
22
+ self.short = short
23
+
24
+ self.desc = desc or (self.func.__doc__ or "").strip()
25
+
26
+ # if desc or get from func desc
27
+ self.argparser = cmd2.Cmd2ArgumentParser(
28
+ prog=self.name,
29
+ description=self.desc
30
+ )
31
+ # parse args from function
32
+ self._parse_func_args()
33
+
34
+ def has(self, name):
35
+ return name in self._registers
36
+
37
+ def get(self, name, default=None):
38
+ return self._registers.get(name, default)
39
+
40
+ def help_text(self, line):
41
+ return self.argparser.format_help()
42
+
43
+ # Building argparse
44
+ def _parse_func_args(self):
45
+ sig = inspect.signature(self.func)
46
+ for param_name, param in sig.parameters.items():
47
+ if param.kind == inspect.Parameter.VAR_POSITIONAL:
48
+ self._add_argument(param_name, nargs='*', help=f"Extra positional args for {param_name}")
49
+ continue
50
+ elif param.kind == inspect.Parameter.VAR_KEYWORD:
51
+ continue
52
+
53
+ if isinstance(param.annotation, Arg):
54
+ arg = param.annotation
55
+ # if no default / required positional argument
56
+ if param.default == inspect._empty:
57
+ self._add_argument(param_name, *arg.args, **arg.kwargs)
58
+ else:
59
+ self._add_argument(f"--{param_name}", *arg.args, default=param.default, **arg.kwargs)
60
+ continue
61
+
62
+ arg_type = param.annotation if param.annotation != inspect._empty else str
63
+
64
+ if param.default == inspect._empty:
65
+ self._add_argument(param_name, type=arg_type)
66
+ else:
67
+ if arg_type is bool:
68
+ if param.default is False:
69
+ self._add_argument(f"--{param_name}", action="store_true", default=False)
70
+ else:
71
+ self._add_argument(f"--no-{param_name}", action="store_false", dest=param_name, default=True)
72
+ else:
73
+ self._add_argument(f"--{param_name}", type=arg_type, default=param.default)
74
+
75
+ def _add_argument(self, *args, **kwargs):
76
+ self.argparser.add_argument(*args, **kwargs)
77
+
78
+ def emit_func(self, *args, **kwargs):
79
+ return self.func(*args, **kwargs) if callable(self.func) else self.func
80
+
81
+ # args will be cmd2 Namespace()
82
+ def run(self, args):
83
+ import inspect
84
+
85
+ # Convert argparse Namespace to dict
86
+ if not isinstance(args, dict):
87
+ arg_dict = vars(args)
88
+ else:
89
+ arg_dict = args
90
+
91
+ sig = inspect.signature(self.func)
92
+ positional = []
93
+ keywords = {}
94
+ varargs = []
95
+ extra_kwargs = {}
96
+
97
+ for name, param in sig.parameters.items():
98
+ if param.kind == inspect.Parameter.VAR_POSITIONAL:
99
+ # collect *args
100
+ varargs = arg_dict.get(name, [])
101
+ elif param.kind == inspect.Parameter.VAR_KEYWORD:
102
+ # collect remaining kwargs not already matched
103
+ for k, v in arg_dict.items():
104
+ if k not in sig.parameters:
105
+ extra_kwargs[k] = v
106
+ else:
107
+ # normal positional or keyword parameters
108
+ if name in arg_dict:
109
+ if param.default is inspect._empty:
110
+ # no default → positional
111
+ positional.append(arg_dict[name])
112
+ else:
113
+ # has default → keyword
114
+ keywords[name] = arg_dict[name]
115
+
116
+ # Call the function
117
+ return self.func(*positional, *varargs, **keywords, **extra_kwargs)
118
+
119
+ def run_cli(self, args):
120
+ return self.run(self.argparser.parse_args(args))
121
+
122
+ class ScriptCommands:
123
+ def __init__(self):
124
+ self._registers = {}
125
+
126
+ def items(self):
127
+ return self._registers.items()
128
+
129
+ def get(self, name, default=None):
130
+ return self._registers.get(name, default)
131
+
132
+ def add(self, name, func, *args, **kwargs):
133
+ self._registers[name] = ScriptCommand(name, func, *args, **kwargs)
@@ -0,0 +1,199 @@
1
+ import os
2
+ import sys
3
+ import cmd2
4
+ import inspect
5
+
6
+ from uuid import uuid4
7
+ from pathlib import Path
8
+
9
+ from luvz.modules.process import sh
10
+ from luvz.utils.path import ensure_dir
11
+ from luvz.modules.console import AdvConsole
12
+ from luvz.core.models.script import ScriptConfigModel
13
+
14
+ from .task import ScriptTasks
15
+ from .command import ScriptCommands, Arg
16
+
17
+
18
+ # ---- script events
19
+
20
+ class ScriptEvent:
21
+ def __init__(self, name, func, *args, **kwargs):
22
+ self.name = name
23
+ self.func = func
24
+
25
+ def emit(self, *args, **kwargs):
26
+ return self.func(*args, **kwargs)
27
+
28
+ class ScriptEvents:
29
+ def __init__(self):
30
+ self._registers = {}
31
+
32
+ def add(self, name: str, func, *args, **kwargs) -> ScriptEvent:
33
+ event = ScriptEvent(name, func, *args, **kwargs)
34
+ self._registers.setdefault(name, []).append(event)
35
+
36
+ return event
37
+
38
+ def has(self, name: str):
39
+ return name in self._registers
40
+
41
+ def get(self, name, default=None):
42
+ return self._registers.get(name, default)
43
+
44
+ def emit(self, name, *args, **kwargs):
45
+ for ev in self._registers.get(name, []):
46
+ ev.emit(*args, **kwargs)
47
+
48
+ # ---- Config and ZOptions
49
+
50
+ class ScriptConfig:
51
+ def __init__(self, config: ScriptConfigModel):
52
+ self._config = config
53
+ self.luvz_path = ensure_dir(Path.home() / ".luvz")
54
+
55
+ class ScriptOption:
56
+ def __init__(self, name, value=None, require=False, type=str, choices=(), help=None):
57
+ self.name = name
58
+ self._value = value # store actual value internally
59
+ self._type = type
60
+ self.choices = choices
61
+ self.require = require
62
+
63
+ self.help = help
64
+
65
+ @property
66
+ def value(self):
67
+ """Return the value, enforcing 'require' rule."""
68
+ if self.require and self._value is None:
69
+ raise ValueError(f"Required option '{self.name}' is missing a value")
70
+ return self._value
71
+
72
+ @value.setter
73
+ def value(self, new_value):
74
+ # 1. cast the input
75
+ try:
76
+ casted = self._type(new_value)
77
+ except (ValueError, TypeError):
78
+ raise TypeError(
79
+ f"Option '{self.name}' expects type {self._type.__name__}, "
80
+ f"but got {new_value!r} of type {type(new_value).__name__}"
81
+ )
82
+
83
+ # 2. cast the choices to _type too, once, to be safe
84
+ if self.choices:
85
+ try:
86
+ normalized_choices = tuple(self._type(c) for c in self.choices)
87
+ except (ValueError, TypeError):
88
+ raise ValueError(f"Choices for option '{self.name}' cannot be cast to {self._type.__name__}")
89
+ if casted not in normalized_choices:
90
+ raise ValueError(
91
+ f"Invalid value for option '{self.name}': {casted!r}. "
92
+ f"Allowed choices: {normalized_choices}"
93
+ )
94
+
95
+ # 3. enforce require
96
+ if self.require and casted is None:
97
+ raise ValueError(f"Cannot set None for required option '{self.name}'")
98
+
99
+ self._value = casted
100
+
101
+ def __str__(self):
102
+ return self.value or ""
103
+
104
+ class ScriptOptions:
105
+ def __init__(self):
106
+ self._options = {}
107
+
108
+ def add(self, name, *args, **kwargs):
109
+ option = ScriptOption(name, *args, **kwargs)
110
+ self._options[name] = option
111
+ return option
112
+
113
+ # sets option else raises error
114
+ def set(self, name, value):
115
+ if name not in self._options:
116
+ raise KeyError(f"Option '{name}' does not exist")
117
+ self._options[name].value = value
118
+
119
+ # returns direct value
120
+ def get(self, name):
121
+ if name not in self._options:
122
+ raise KeyError(f"Option '{name}' does not exist")
123
+ return self._options[name].value
124
+
125
+ def __call__(self, name):
126
+ return self.get(name)
127
+
128
+ # ---- Script Args (argv)
129
+
130
+ class ScriptArgs:
131
+ def __init__(self):
132
+ self._raw_args = sys.argv[1:]
133
+
134
+ def get(self, index, default=None):
135
+ try:
136
+ return self._raw_args[index]
137
+ except ValueError:
138
+ return default
139
+
140
+ def __str__(self):
141
+ return " ".join(self._raw_args)
142
+
143
+ # ---- ZScript
144
+
145
+ class ZScript:
146
+ prompt = "| "
147
+ banner = None
148
+ def __init__(self, name=None, version=None, author=None, desc=None, config={}):
149
+ # script paths
150
+ self.script_full_path = Path(sys.argv[0])
151
+ self.script_path = self.script_full_path.parent
152
+
153
+ # with ext
154
+ self.script_name = os.path.basename(sys.argv[0])
155
+ # script meta
156
+ self.name = (name or Path(sys.argv[0]).with_suffix("").name).capitalize()
157
+ self.desc = desc
158
+ self.author = author
159
+ self.version = version
160
+
161
+ self.scr = AdvConsole()
162
+ self.options = ScriptOptions()
163
+ self.config = ScriptConfig(ScriptConfigModel(**config))
164
+
165
+ self.events = ScriptEvents()
166
+ self.commands = ScriptCommands()
167
+ self.tasks = ScriptTasks()
168
+
169
+ self.sh = sh
170
+ # finally parsing args
171
+ self.args = ScriptArgs()
172
+
173
+ @property
174
+ def cwd(self):
175
+ return os.getcwd()
176
+
177
+ def arg(self, *args, **kwargs):
178
+ return Arg(*args, **kwargs)
179
+
180
+ def add_option(self, *args, **kwargs):
181
+ return self.options.add(*args, **kwargs)
182
+
183
+ def on(self, name, *args, **kwargs):
184
+ def wrapper(func):
185
+ if name.startswith("luvz:"):
186
+ self.events.add(name[4:], func, *args, **kwargs)
187
+ else:
188
+ self.commands.add(name, func, *args, **kwargs)
189
+ return wrapper
190
+
191
+ def on_event(self, name, *args, **kwargs):
192
+ def wrapper(func):
193
+ self.events.add(name, func, *args, **kwargs)
194
+ return wrapper
195
+
196
+ def __call__(self, cmd):
197
+ if cmd.startswith("$"):
198
+ return self.sh(cmd[1:])
199
+
@@ -0,0 +1,13 @@
1
+ from uuid import uuid4
2
+
3
+
4
+ class ScriptTask:
5
+ def __init__(self, name):
6
+ self.uid = uuid4().hex
7
+ self.name = name
8
+
9
+ class ScriptTasks:
10
+ def __init__(self):
11
+ # taskID - Task
12
+ self._active = {}
13
+
@@ -0,0 +1,5 @@
1
+ from pydantic import BaseModel
2
+
3
+ class ScriptConfigModel(BaseModel):
4
+ pass
5
+
@@ -0,0 +1,13 @@
1
+ import sys
2
+
3
+ # ------ Run types
4
+ def run_script_it(script, intro: bool = True):
5
+ from .interactive import ZScriptRunner
6
+ return ZScriptRunner(script).run(intro=intro)
7
+
8
+ def run_script_cli(script, intro: bool = False):
9
+ from .cli import ZScriptRunnerCli
10
+ return ZScriptRunnerCli(script).run(intro=intro)
11
+
12
+ def run_script(script, *args, **kwargs):
13
+ return run_script_cli(script, *args, **kwargs) if len(sys.argv) > 1 else run_script_it(script, *args, **kwargs)
@@ -0,0 +1,167 @@
1
+ from rich.table import Table
2
+ from luvz.core import __version__
3
+
4
+ import atexit
5
+
6
+ BANNER = r"""
7
+ [blue]███████╗███████╗███████╗[/]
8
+ ╚══███╔╝╚══███╔╝╚══███╔╝
9
+ [red] ███╔╝ ███╔╝ ███╔╝ [/]
10
+ ███╔╝ ███╔╝ ███╔╝
11
+ [yellow]███████╗███████╗███████╗[/]
12
+ ╚══════╝╚══════╝╚══════╝
13
+ """
14
+ BANNER = r"""
15
+ | _ _ ___ _______
16
+ | | | | | | \ \ / /__ /
17
+ | | | | | | |\ \ / / / /
18
+ | | |__| |_| | \ V / / /_
19
+ | |_____\___/ \_/ /____|
20
+
21
+ """
22
+ BANNER = r"""
23
+ [blue]██╗ ██╗ ██╗ ██╗ ██╗ ███████╗[/]
24
+ ██║ ██║ ██║ ██║ ██║ ╚══███╔╝
25
+ [red]██║ ██║ ██║ ██║ ██║ ███╔╝ [/]
26
+ ██║ ██║ ██║ ╚██╗ ██╔╝ ███╔╝
27
+ [yellow]███████╗ ╚██████╔╝ ╚████╔╝ ███████╗[/]
28
+ ╚══════╝ ╚═════╝ ╚═══╝ ╚══════╝
29
+ """
30
+
31
+ class RunnerUtils:
32
+ def __init__(self, script):
33
+ self.script = script
34
+ atexit.register(self.__on_exit)
35
+ # emiting init
36
+ self.script.events.emit("init")
37
+
38
+ @property
39
+ def scr(self): # global scr for runners
40
+ return self.script.scr
41
+
42
+ def print_header(self):
43
+ self.scr.print_center(self.script.banner or BANNER)
44
+ self.scr.print_center(f"luvz: [blue]luvbyte[/blue] | version: [red]{__version__}[/red]")
45
+
46
+ def print_script_header(self):
47
+ # Script header
48
+ version = f" [red]v{self.script.version}[/red]" if self.script.version else ""
49
+ author = self.script.author if self.script.author else "Someone [red]ᥫ᭡[/red]"
50
+ self.scr.print_panel(f"✨ [blue]{self.script.name}[/blue]{version} by [green]{author}[/green]", padding=False)
51
+
52
+ def print_intro(self):
53
+ # if banner found print it
54
+ if self.script.banner:
55
+ self.scr.print_center(self.script.banner)
56
+ # if its False then dont print any banner
57
+ elif self.script.banner is not False:
58
+ self.print_header()
59
+
60
+ # script details
61
+ self.print_script_header()
62
+
63
+ def _print_commands(self, cmd2_commands: bool):
64
+ commands = list(self.script.commands.items())
65
+
66
+ self.scr.print_center(f"[italic magenta]{'No' if not cmd2_commands and len(commands) <= 0 else ''} Available Commands[/italic magenta]")
67
+
68
+ # Split based on with and without short descriptions
69
+ no_short, with_short = [], []
70
+ for name, cmd in commands:
71
+ (with_short if cmd.short else no_short).append((name, getattr(cmd, 'short', None)))
72
+
73
+ if with_short or cmd2_commands:
74
+ table = Table(header_style="bold cyan", border_style="blue", expand=True)
75
+ table.add_column("Command", style="green")
76
+ table.add_column("Description", style="yellow")
77
+
78
+ # Predefined cmd2_commands rows
79
+ if cmd2_commands:
80
+ builtin_commands = {
81
+ "alias": "Create command shortcuts",
82
+ "edit": "Edit a script or configuration",
83
+ "help": "Show help (use 'help -v' for verbose)",
84
+ "history": "Show command history",
85
+ "macro": "Record or play macros",
86
+ "quit": "Exit the program",
87
+ "run_pyscript": "Run a Python script",
88
+ "run_script": "Run a script",
89
+ "set": "Set configuration options",
90
+ "shell": "Run shell commands",
91
+ "shortcuts": "List available keyboard shortcuts",
92
+ "options": "Script options",
93
+ "commands": "Script commands",
94
+ "zset": "Set ZOption",
95
+ "---": "---"
96
+ }
97
+ for name, desc in builtin_commands.items():
98
+ table.add_row(name, desc)
99
+
100
+ # Add user commands with short descriptions
101
+ for name, desc in with_short:
102
+ table.add_row(name, desc)
103
+
104
+ self.scr.print(table)
105
+
106
+ if no_short:
107
+ names = " ".join(name for name, _ in no_short)
108
+ self.scr.print_panel(f"[blue]{names}[/blue]", padding=False)
109
+
110
+ def print_commands_cmd2(self):
111
+ return self._print_commands(True)
112
+
113
+ def print_commands_cli(self):
114
+ return self._print_commands(False)
115
+
116
+ def print_options(self, required_only: bool = False):
117
+ options = self.script.options._options
118
+
119
+ # Filter only required options if present
120
+ if required_only:
121
+ options = {name: opt for name, opt in options.items() if opt.require}
122
+
123
+ if not options:
124
+ return self.scr.print_center(
125
+ f"[italic red]Script has no{' required' if required_only else ''} options[/italic red]"
126
+ )
127
+
128
+ table = Table(
129
+ title=f"[blue]Script{' Required' if required_only else ''} Options[/blue]",
130
+ expand=True
131
+ )
132
+
133
+ for col, style in [
134
+ ("Name", "cyan"),
135
+ ("Value", "green"),
136
+ ("Required", "magenta"),
137
+ ("Type", "yellow"),
138
+ ("Choices", "blue")
139
+ ]:
140
+ table.add_column(col, style=style, no_wrap=(col == "Name"))
141
+
142
+ for name, opt in options.items():
143
+ # Safely retrieve value
144
+ try:
145
+ val = opt.value
146
+ except Exception:
147
+ val = "[red]-[/red]"
148
+
149
+ # Choices fallback to "-"
150
+ choices = ", ".join(map(str, getattr(opt, "choices", []))) or "-"
151
+
152
+ table.add_row(
153
+ name,
154
+ str(val),
155
+ "Yes" if getattr(opt, "require", False) else "No",
156
+ getattr(opt._type, "__name__", str(opt._type)),
157
+ choices
158
+ )
159
+
160
+ self.scr.print(table)
161
+
162
+ def __on_exit(self):
163
+ self.script.events.emit("exit")
164
+
165
+ def exception(self, text):
166
+ self.scr.print(f"[red]Error:[/red] luvz: [blue]{text}[/blue]")
167
+
@@ -0,0 +1,50 @@
1
+ from .base import RunnerUtils
2
+
3
+ from luvz.core.context import ZScript
4
+
5
+
6
+ # cli
7
+ class ZScriptRunnerCli:
8
+ def __init__(self, script: ZScript):
9
+ self.script = script
10
+ self.utils = RunnerUtils(self.script)
11
+
12
+ self.script.events.emit("cli:init")
13
+
14
+ @property
15
+ def scr(self):
16
+ return self.utils.scr
17
+
18
+ def _display_cli_help(self):
19
+ self.utils.print_script_header()
20
+ self.scr.print(f"[red]Usage[/red]: {self.script.script_name} command [ARGS] [-h]")
21
+ self.scr.br()
22
+
23
+ self.utils.print_commands_cli()
24
+
25
+ def run(self, intro: bool = False):
26
+ self.script.events.emit("run")
27
+ args = self.script.args._raw_args
28
+
29
+ if len(args) <= 0:
30
+ args = ["-h"]
31
+
32
+ command = args[0]
33
+ command_args = args[1:]
34
+
35
+ # Show help
36
+ if command in ("-h", "--help"):
37
+ return self._display_cli_help()
38
+
39
+ if intro:
40
+ self.utils.print_intro()
41
+
42
+ func = self.script.commands.get(command)
43
+ if func is None:
44
+ self.exception(f"command '{command}' not found")
45
+ return self.scr.br()
46
+
47
+ func.run_cli(command_args)
48
+
49
+ def exception(self, text):
50
+ self.utils.exception(text)
@@ -0,0 +1,217 @@
1
+ import os
2
+ import sys
3
+ import types
4
+ import argparse
5
+
6
+ from pathlib import Path
7
+ from datetime import datetime
8
+ from cmd2 import Cmd, Cmd2ArgumentParser, with_argparser
9
+
10
+ from rich.text import Text
11
+ from rich.table import Table
12
+
13
+ from luvz.modules.process import sh
14
+ from .base import BANNER, RunnerUtils
15
+ from luvz.core.context import ZScript, ScriptCommand
16
+
17
+
18
+ def make_options_parser():
19
+ # script argparser
20
+ script_argparse = Cmd2ArgumentParser()
21
+ script_argparse.add_argument(
22
+ 'subcommand',
23
+ nargs="?",
24
+ choices=['required'],
25
+ help='Display only required options'
26
+ )
27
+ return script_argparse
28
+
29
+ def make_commands_parser():
30
+ # script argparser
31
+ script_argparse = Cmd2ArgumentParser()
32
+ script_argparse.add_argument(
33
+ 'subcommand',
34
+ nargs="?",
35
+ choices=['all'],
36
+ help='Display all commands list'
37
+ )
38
+ return script_argparse
39
+
40
+ def make_cd_parser():
41
+ # script argparser
42
+ cd_argparse = Cmd2ArgumentParser()
43
+ cd_argparse.add_argument('path', nargs="?", default=Path.home(), help='Change current directory')
44
+ return cd_argparse
45
+
46
+
47
+ # cmd2 - interactive
48
+ class ZScriptRunner(Cmd):
49
+ def __init__(self, script: ZScript):
50
+ # bypassing cmd2 default argparse :)
51
+ original_argv = sys.argv.copy()
52
+ sys.argv = [sys.argv[0]]
53
+ # init of Cmd
54
+ super().__init__()
55
+ # restoring argv
56
+ self.script = script
57
+ sys.argv = original_argv
58
+
59
+ self._register_commands()
60
+
61
+ self.utils = RunnerUtils(self.script)
62
+ self.script.events.emit("it:init")
63
+
64
+ # arg parser
65
+ def _register_commands(self):
66
+ for name, command in self.script.commands.items():
67
+ desired_prog = command.argparser.prog or name
68
+
69
+ @with_argparser(command.argparser)
70
+ def do_func(inner_self, args, cmd=command):
71
+ try:
72
+ cmd.run(args)
73
+ except Exception as e:
74
+ inner_self.exception(e)
75
+
76
+ # cmd2's decorator changes both the argparser.prog and the function name
77
+ # Fix them back:
78
+ do_func.argparser.prog = desired_prog # fixes Usage: text
79
+ do_func.__name__ = f"do_{name}" # fixes command name in help
80
+ do_func.__qualname__ = f"do_{name}" # also for introspection
81
+
82
+ # finally bind the method to your instance
83
+ setattr(self, f"do_{name}", types.MethodType(do_func, self))
84
+
85
+ @property
86
+ def scr(self):
87
+ return self.utils.scr
88
+
89
+ @property
90
+ def prompt(self):
91
+ prompt = self.script.events.emit("prompt") or self.script.prompt
92
+ return prompt() if callable(prompt) else prompt
93
+
94
+ # --- commands
95
+ def do_clear(self, _):
96
+ self.scr.clear()
97
+
98
+ def do_ls(self, line):
99
+ sh(f"ls --color {line}").run()
100
+
101
+ def do_pwd(self, _):
102
+ self.scr.print(f"You are in: {os.getcwd()}")
103
+
104
+ def do_exit(self, _):
105
+ return True
106
+
107
+ def complete_cd(self, text, line, begidx, endidx):
108
+ return [
109
+ str(path) for path in self.path_complete(text, line, begidx, endidx)
110
+ if os.path.isdir(path)
111
+ ]
112
+
113
+ # Change Directory
114
+ @with_argparser(make_cd_parser())
115
+ def do_cd(self, args):
116
+ try:
117
+ os.chdir(args.path)
118
+ except FileNotFoundError:
119
+ self.exception(f"No such directory: '[green]{args.path}[/green]'")
120
+ except Exception as e:
121
+ self.exception(e)
122
+
123
+ # ---- script
124
+ @with_argparser(make_options_parser())
125
+ def do_options(self, args):
126
+ if args.subcommand == "required":
127
+ self.utils.print_options(True)
128
+ else:
129
+ self.utils.print_options(False)
130
+
131
+ @with_argparser(make_commands_parser())
132
+ def do_commands(self, args):
133
+ if args.subcommand == "all":
134
+ self.utils.print_commands_cmd2()
135
+ else:
136
+ self.utils._print_commands(False)
137
+
138
+ #--- setting option
139
+ def help_zset(self):
140
+ self.scr.print("[red]Usage[/red]: zset <name> <value>\n\nSET ZOption\n")
141
+
142
+ def complete_zset(self, text, line, begidx, endidx):
143
+ tokens = line.split()
144
+
145
+ # Suggest option names when typing the option name
146
+ if len(tokens) <= 1 or (begidx <= len(tokens[0]) + 1):
147
+ return [name for name in self.script.options._options if name.startswith(text)]
148
+
149
+ opt_name = tokens[1]
150
+
151
+ # If typing the value
152
+ if opt_name in self.script.options._options:
153
+ opt = self.script.options._options[opt_name]
154
+
155
+ # If the option has choices, show choices matching text
156
+ if opt.choices:
157
+ return [str(c) for c in opt.choices if str(c).startswith(text)]
158
+
159
+ # Otherwise, use cmd2 built-in path completer
160
+ return self.path_complete(text, line, begidx, endidx)
161
+
162
+ return []
163
+
164
+ def do_zset(self, line):
165
+ if not line:
166
+ self.scr.print("[red]Usage[/red]: zset <name> <value>\n")
167
+
168
+ elif len(line.arg_list) < 2:
169
+ self.scr.print("[red]Missing[/red]: <value>\n")
170
+ else:
171
+ try:
172
+ # set
173
+ name = line.arg_list[0]
174
+ value = " ".join(line.arg_list[1:])
175
+
176
+ self.script.options.set(name, value)
177
+ self.scr.print(f"[red]Updated[/red]: {name}={value}\n")
178
+ except Exception as e:
179
+ self.exception(e.args[0])
180
+ # ---
181
+
182
+ def complete_help(self, text, line, begidx, endidx):
183
+ commands = [name[3:] for name in dir(self) if name.startswith('do_') and not name.startswith("do__")]
184
+ if not text:
185
+ return commands
186
+ return [cmd for cmd in commands if cmd.startswith(text)]
187
+
188
+ def do_help(self, line):
189
+ if line:
190
+ return super().do_help(line)
191
+
192
+ self.scr.print_panel("[red]Usage[/red]: command [ARGS] [-h]", padding=False)
193
+ self.utils.print_commands_cmd2()
194
+ self.utils.print_options()
195
+
196
+ # ---- default
197
+ def default(self, line):
198
+ event = self.script.events.get("it:default")
199
+ if event:
200
+ return event.emit(line)
201
+ self.scr.print(f"[blue]luvz[/blue]: Unknown command: [red]{line.command}[/red]")
202
+
203
+ # ---- starts from here
204
+ def run(self, clear: bool = True, intro: bool = True):
205
+ self.script.events.emit("run")
206
+ if clear:
207
+ self.scr.clear()
208
+
209
+ if intro:
210
+ self.utils.print_intro()
211
+
212
+ self.cmdloop()
213
+ self.script.events.emit("run")
214
+
215
+ # Exception handling
216
+ def exception(self, text):
217
+ self.utils.exception(text)
@@ -0,0 +1,188 @@
1
+ import os
2
+ import sys
3
+ import time
4
+ import atexit
5
+ import select
6
+
7
+ from rich.text import Text
8
+ from rich.panel import Panel
9
+ from rich.align import Align
10
+ from rich.table import Table
11
+ from rich.console import Console
12
+ from rich.columns import Columns
13
+
14
+ from typing import Literal
15
+
16
+
17
+ class AdvConsole(Console):
18
+ def __init__(self) -> None:
19
+ super().__init__()
20
+
21
+ atexit.register(self.__on_exit)
22
+
23
+ # hide / show cursor
24
+ def hide_cursor(self, hide: bool = True) -> None:
25
+ if hide:
26
+ sys.stdout.write("\033[?25l") # Hide cursor
27
+ else:
28
+ sys.stdout.write("\033[?25h") # Show cursor when stopping
29
+
30
+ # clear screen
31
+ def clear(self) -> None:
32
+ os.system("cls" if os.name == "nt" else "clear") # Windows: cls, Linux/macOS: clear
33
+
34
+ # print empty lines
35
+ def br(self, lines: int = 1) -> None:
36
+ self.print(end="\n"*lines)
37
+
38
+ # print panel
39
+ def print_panel(
40
+ self,
41
+ content,
42
+ title: str = "",
43
+ subtitle: str = "",
44
+ justify: Literal['left', 'right', 'center'] = "left",
45
+ padding: bool = True,
46
+ expand: bool = True,
47
+ markup: bool = True
48
+ ) -> None:
49
+ # Add padding if requested
50
+ if padding:
51
+ content = f"\n{content}\n"
52
+
53
+ # Create Text safely depending on markup flag
54
+ if markup:
55
+ text_obj = Text.from_markup(content, justify=justify)
56
+ else:
57
+ text_obj = Text(content, justify=justify)
58
+
59
+ # Print Panel with text object
60
+ self.print(
61
+ Panel(
62
+ text_obj,
63
+ title=title,
64
+ subtitle=subtitle,
65
+ expand=expand
66
+ )
67
+ )
68
+
69
+ # print text with align
70
+ def print_text(self, text, markup: bool = True, align: Literal['left', 'right', 'center'] = "left"):
71
+ text = Text.from_markup(text) if markup else Text(text)
72
+ self.print(Align(text, align = align))
73
+
74
+ def print_center(self, text: str, markup: bool = True):
75
+ self.print_text(text, markup, "center")
76
+
77
+ # equal=True, expand=True
78
+ def columns(self, *args, **kwargs):
79
+ return Columns(*args, **kwargs)
80
+
81
+ def panel(self, *args, **kwargs):
82
+ return Panel(*args, **kwargs)
83
+
84
+ def print_list(
85
+ self,
86
+ items,
87
+ border: bool = True,
88
+ multi: bool = False,
89
+ title: str | None = None,
90
+ expand: bool = True,
91
+ equal: bool = True,
92
+ style: str = "white",
93
+ index_color: str = "cyan"
94
+ ) -> None:
95
+ if not items:
96
+ raise Exception("Items list cannot be empty")
97
+
98
+ # Numbered items with style
99
+ numbered_items = [
100
+ f"[bold {index_color}]{i}.[/bold {index_color}] [bold {style}]{item}[/bold {style}]"
101
+ for i, item in enumerate(items, start=1)
102
+ ]
103
+
104
+ # Build layout
105
+ if multi:
106
+ cpanel = Columns(numbered_items, expand=expand, equal=equal)
107
+ else:
108
+ cpanel = "\n".join(numbered_items)
109
+
110
+ # Render with or without panel
111
+ if border:
112
+ self.print(Panel(cpanel, title=title, expand=expand))
113
+ else:
114
+ self.print(cpanel)
115
+
116
+ def print_table(
117
+ self,
118
+ columns,
119
+ rows,
120
+ title=None,
121
+ header_style="bold cyan",
122
+ border_style="blue",
123
+ col_style="green"
124
+ ):
125
+ table = Table(
126
+ title=f"[bold magenta]{title}[/bold magenta]" if title else None,
127
+ header_style=header_style,
128
+ border_style=border_style
129
+ )
130
+
131
+ # Add columns
132
+ for col in columns:
133
+ table.add_column(col, style=col_style)
134
+
135
+ # Add rows
136
+ for row in rows:
137
+ table.add_row(*[str(item) for item in row])
138
+
139
+ self.print(table)
140
+
141
+ # True - completed, False - canceled
142
+ def wait_basic(self, seconds: float = 5, message="(Ctrl + C to stop)") -> bool:
143
+ """Waits with a countdown, allows interruption with Ctrl+C."""
144
+ try:
145
+ self.hide_cursor() # Hide cursor
146
+ for i in reversed(range(0, seconds)):
147
+ print(f"{message} : {i} ", end="\r", flush=True)
148
+ time.sleep(1)
149
+ return True # Completed successfully
150
+ except KeyboardInterrupt:
151
+ # print("\nInterrupted!")
152
+ return False # Interrupted by user
153
+ finally:
154
+ self.hide_cursor(False) # Show cursor
155
+ sys.stdout.flush()
156
+
157
+ def wait(self, timeout: float = 5.0, message: str = "Time remaining") -> bool:
158
+ try:
159
+ self.hide_cursor() # Hide
160
+ start_time = time.time()
161
+ while True:
162
+ elapsed = time.time() - start_time
163
+ remaining = max(0, int(timeout - elapsed))
164
+ # print(f"{message} : {remaining}", end="\r", flush=True)
165
+ sys.stdout.write(f"\r{message} : {remaining}")
166
+ sys.stdout.flush()
167
+
168
+ # Check for input every 0.1s
169
+ if sys.stdin in select.select([sys.stdin], [], [], 0.1)[0]:
170
+ line = sys.stdin.readline()
171
+ print() # Move to next line after user input
172
+ return True if line == '\n' else False
173
+
174
+ if elapsed >= timeout:
175
+ return True
176
+ except KeyboardInterrupt:
177
+ return False
178
+ finally:
179
+ self.hide_cursor(False) # Show cursor
180
+ sys.stdout.write("\r\033[K")
181
+ sys.stdout.flush()
182
+
183
+ # clean up
184
+ def __on_exit(self) -> None:
185
+ self.hide_cursor(False)
186
+
187
+ def convert_markup_to_text(markup_text) -> str:
188
+ return Text.from_markup(markup_text).plain
@@ -0,0 +1,56 @@
1
+ import sys
2
+
3
+ from os import getcwd
4
+ from subprocess import Popen, PIPE
5
+
6
+
7
+ class Process:
8
+ def __init__(self, proc: Popen):
9
+ self._process = proc
10
+
11
+ def wait(self) -> 'Process':
12
+ self._process.wait()
13
+ return self
14
+
15
+ def output(self) -> str:
16
+ if self._process.stdout:
17
+ output = self._process.stdout.read().decode("utf-8").strip()
18
+ return output
19
+ return ""
20
+
21
+ @property
22
+ def returncode(self) -> int:
23
+ return self._process.returncode
24
+
25
+ class ProcessBuilder:
26
+ def __init__(self, cmd: str) -> None:
27
+ self.cmd: str = cmd
28
+ self.stdin = PIPE
29
+ self.stdout = PIPE
30
+ self.stderr = PIPE
31
+ self.shell: bool = True
32
+ self.cwd: str = getcwd()
33
+
34
+ def pipe(self) -> Process:
35
+ return self._run()
36
+
37
+ def run(self) -> Process:
38
+ self.stdin = sys.stdin
39
+ self.stdout = sys.stdout
40
+ self.stderr = sys.stderr
41
+ return self._run()
42
+
43
+ def _run(self) -> Process:
44
+ proc = Popen(
45
+ self.cmd,
46
+ cwd=self.cwd,
47
+ shell=self.shell,
48
+ stdin=self.stdin,
49
+ stdout=self.stdout,
50
+ stderr=self.stderr,
51
+ )
52
+ return Process(proc).wait()
53
+
54
+
55
+ def sh(cmd: str) -> ProcessBuilder:
56
+ return ProcessBuilder(cmd)
@@ -0,0 +1 @@
1
+ from .path import ensure_dir
@@ -0,0 +1,9 @@
1
+ import sys
2
+
3
+ from typing import Optional
4
+
5
+ def read_from_stdin() -> Optional[str]:
6
+ if sys.stdin.isatty(): # True if running in terminal, no pipe
7
+ return None
8
+ return sys.stdin.read()
9
+
@@ -0,0 +1,44 @@
1
+ import json
2
+ from pathlib import Path
3
+ from typing import IO, Any, Optional, Type, Union
4
+ from pydantic import BaseModel, ValidationError
5
+
6
+ def parse_file(
7
+ file: IO | dict,
8
+ model: Optional[Type[BaseModel]] = None,
9
+ parse_type: str = "json"
10
+ ) -> Any:
11
+ try:
12
+ if parse_type == "dict":
13
+ data = file
14
+ elif parse_type == "json":
15
+ data = json.load(file)
16
+ else:
17
+ raise Exception(f"Unsupported parse type: {parse_type}")
18
+ except Exception as e:
19
+ raise Exception(f"Failed to parse file as {parse_type}: {e}")
20
+
21
+ if model is None:
22
+ return data
23
+
24
+ try:
25
+ return model.model_validate(data)
26
+ except ValidationError:
27
+ raise Exception(f"Invalid structure for model {model.__name__}")
28
+
29
+ def parse_config(
30
+ file_path: Union[str, Path, dict],
31
+ model: Optional[Type[BaseModel]] = None,
32
+ parse_type: str = "json"
33
+ ) -> Any:
34
+ try:
35
+ if isinstance(file_path, dict):
36
+ return parse_file(file_path, model, "dict")
37
+ with open(file_path, "r") as file:
38
+ return parse_file(file, model, parse_type)
39
+ except FileNotFoundError:
40
+ raise FileNotFoundError(f"Config file not found: {file_path}")
41
+ except json.JSONDecodeError:
42
+ raise json.JSONDecodeError(f"Invalid JSON format: {file_path}")
43
+ except Exception as e:
44
+ raise Exception(f"Unexpected error parsing {file_path}: {e}")
@@ -0,0 +1,7 @@
1
+ from pathlib import Path
2
+
3
+ from typing import Union
4
+
5
+ def ensure_dir(path: Union[Path, str]) -> Path:
6
+ Path(path).mkdir(parents=True, exist_ok=True)
7
+ return path
@@ -0,0 +1,52 @@
1
+ Metadata-Version: 2.4
2
+ Name: luvz
3
+ Version: 0.0.1.dev0
4
+ Summary: Lazy script builder
5
+ Author-email: luvbyte <lovemelong@protonmail.com>
6
+ License: MIT
7
+ Requires-Python: >=3.8
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENCE
10
+ Requires-Dist: cmd2
11
+ Requires-Dist: rich
12
+ Requires-Dist: pydantic
13
+ Dynamic: license-file
14
+
15
+ # luvz 💤
16
+ A lightweight framework for building interactive Python scripts
17
+
18
+ `luvz` is a Python package built on top of [`cmd2`](https://cmd2.readthedocs.io) that makes it easy to build interactive, command-driven Python programs.
19
+ It gives you a simple API for defining commands, handling user input, and extending functionality — perfect for creating REPL-like tools, admin shells, or prototypes.
20
+
21
+ ---
22
+
23
+ ## ✨ Features
24
+
25
+ - Simple API to create interactive command-line applications
26
+ - Built on `cmd2` for history, tab completion, transcripts, and more
27
+ - Minimal setup — just subclass and add commands
28
+ - Great for prototypes, admin utilities, or sharing interactive tools
29
+
30
+ ---
31
+
32
+ ## 📦 Installation
33
+
34
+ ```bash
35
+ pip install luvz
36
+ ```
37
+
38
+ (or clone and install locally:)
39
+ ```bash
40
+ git clone https://github.com/luvbyte/luvz.git
41
+ cd luvz
42
+ pip install .
43
+ ```
44
+ ---
45
+ ## ⚡ Why luvz?
46
+
47
+ Save time — skip boilerplate when building interactive shells
48
+
49
+ Enjoy rich features from cmd2 without the setup
50
+
51
+ Create professional-feeling CLI tools quickly
52
+
@@ -0,0 +1,25 @@
1
+ LICENCE
2
+ README.md
3
+ pyproject.toml
4
+ luvz/__init__.py
5
+ luvz.egg-info/PKG-INFO
6
+ luvz.egg-info/SOURCES.txt
7
+ luvz.egg-info/dependency_links.txt
8
+ luvz.egg-info/requires.txt
9
+ luvz.egg-info/top_level.txt
10
+ luvz/core/__init__.py
11
+ luvz/core/context/__init__.py
12
+ luvz/core/context/command.py
13
+ luvz/core/context/script.py
14
+ luvz/core/context/task.py
15
+ luvz/core/models/script.py
16
+ luvz/core/runner/__init__.py
17
+ luvz/core/runner/base.py
18
+ luvz/core/runner/cli.py
19
+ luvz/core/runner/interactive.py
20
+ luvz/modules/console.py
21
+ luvz/modules/process.py
22
+ luvz/utils/__init__.py
23
+ luvz/utils/cli.py
24
+ luvz/utils/parser.py
25
+ luvz/utils/path.py
@@ -0,0 +1,3 @@
1
+ cmd2
2
+ rich
3
+ pydantic
@@ -0,0 +1 @@
1
+ luvz
@@ -0,0 +1,17 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "luvz"
7
+ version = "0.0.1.dev0"
8
+ description = "Lazy script builder"
9
+ authors = [{name="luvbyte", email="lovemelong@protonmail.com"}]
10
+ license = {text = "MIT"}
11
+ readme = "README.md"
12
+ requires-python = ">=3.8"
13
+ dependencies = ["cmd2", "rich", "pydantic"]
14
+
15
+ [tool.setuptools.packages.find]
16
+ where = ["."]
17
+ include = ["luvz*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+