Compare commits
10 Commits
37eaee3dbe
...
b4989d372a
| Author | SHA1 | Date | |
|---|---|---|---|
| b4989d372a | |||
| 7ac20ea8b4 | |||
| d9dd069d50 | |||
| e49a865008 | |||
| a9912c1ccf | |||
| f98844efaa | |||
| 8ef38cd8eb | |||
| 8ffa6d5aae | |||
| 8acbf2da36 | |||
| e34136305c |
1
.gitignore
vendored
1
.gitignore
vendored
@ -3,4 +3,3 @@ __pycache__/
|
|||||||
build/
|
build/
|
||||||
nut.egg-info/
|
nut.egg-info/
|
||||||
*.zip
|
*.zip
|
||||||
*.nut
|
|
||||||
3
MANIFEST.in
Normal file
3
MANIFEST.in
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
recursive-include nut/templates *
|
||||||
|
global-exclude __pycache__ *
|
||||||
|
global-exclude *.py[cod]
|
||||||
@ -1,28 +1,49 @@
|
|||||||
from .cli import CLI
|
from .cli import CLI
|
||||||
|
from .commands.setup import is_nut_workspace, find_template_conflicts, copy_template_files
|
||||||
|
|
||||||
def help_text():
|
def help_text():
|
||||||
return [
|
return [
|
||||||
"%bold%%cyan%nut CLI ... you expected a help menu? cute :3%reset%"
|
"%bold%%cyan%nut CLI: tiny shell, big squirrel energy :3%reset%",
|
||||||
]
|
]
|
||||||
|
|
||||||
def main():
|
def main() -> int:
|
||||||
cli = CLI(help_callback=help_text)
|
cli = CLI(help_callback=help_text)
|
||||||
|
|
||||||
cli.command("run") \
|
cli.command(
|
||||||
.flag("debug", "d") \
|
"init",
|
||||||
.handle(cmd_run)
|
help="Create an empty nut workspace or reinitialize with --force",
|
||||||
|
description=(
|
||||||
cli.command("build") \
|
"Initialize a nut development workspace by creating a Nutfile and default project files. "
|
||||||
.handle(cmd_build)
|
"If a Nutfile already exists, initialization should fail unless --force is used."
|
||||||
|
),
|
||||||
|
).flag(
|
||||||
|
"force",
|
||||||
|
"f",
|
||||||
|
help="Reinitialize even when a Nutfile already exists",
|
||||||
|
).arg(
|
||||||
|
"path",
|
||||||
|
nargs="?",
|
||||||
|
default=".",
|
||||||
|
help="Directory to initialize (default: current directory)",
|
||||||
|
).handle(cmd_init)
|
||||||
|
|
||||||
return cli.run()
|
return cli.run()
|
||||||
|
|
||||||
def cmd_run(args, flags):
|
def cmd_init(args, flags) -> int:
|
||||||
print("RUN", args, flags)
|
dest_dir = args[0]
|
||||||
return 0
|
force = flags["force"]
|
||||||
|
|
||||||
|
conflicts = find_template_conflicts(dest_dir)
|
||||||
|
if conflicts and not force:
|
||||||
|
print("Some files already exist that would be overwritten:")
|
||||||
|
for file in conflicts:
|
||||||
|
print(f" {file}")
|
||||||
|
|
||||||
|
print("Use --force to overwrite existing files.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
copy_template_files(dest_dir, force=force, checked=True)
|
||||||
|
|
||||||
def cmd_build(args, flags):
|
|
||||||
print("BUILD", args, flags)
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
593
nut/cli.py
593
nut/cli.py
@ -1,44 +1,49 @@
|
|||||||
import argparse
|
import argparse
|
||||||
from colorama import Fore, Style, init
|
import difflib
|
||||||
|
import sys
|
||||||
|
import traceback
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple
|
||||||
|
|
||||||
init(autoreset=True)
|
try:
|
||||||
|
from colorama import Fore, Style, init as _colorama_init
|
||||||
|
except Exception: # pragma: no cover - fallback for missing optional dependency
|
||||||
|
_COLORAMA_AVAILABLE = False
|
||||||
|
|
||||||
class CLI:
|
class _NoColor: # pragma: no cover - simple value holder for fallback
|
||||||
def __init__(self, prog="nut", help_callback=None):
|
BLACK = ""
|
||||||
self.parser = argparse.ArgumentParser(prog=prog, add_help=False)
|
BLUE = ""
|
||||||
self.subparsers = self.parser.add_subparsers(dest="command")
|
CYAN = ""
|
||||||
|
GREEN = ""
|
||||||
|
MAGENTA = ""
|
||||||
|
RED = ""
|
||||||
|
RESET_ALL = ""
|
||||||
|
WHITE = ""
|
||||||
|
YELLOW = ""
|
||||||
|
BRIGHT = ""
|
||||||
|
|
||||||
self.help_callback = help_callback
|
Fore = _NoColor() # type: ignore[assignment]
|
||||||
|
Style = _NoColor() # type: ignore[assignment]
|
||||||
|
|
||||||
self.parser.add_argument("-h", "--help", action="store_true")
|
def _colorama_init(**_: Any) -> None:
|
||||||
|
return None
|
||||||
def command(self, name):
|
|
||||||
parser = self.subparsers.add_parser(name, add_help=False)
|
|
||||||
cmd = _Command(parser)
|
|
||||||
return cmd
|
|
||||||
|
|
||||||
def run(self):
|
|
||||||
args = self.parser.parse_args()
|
|
||||||
|
|
||||||
if getattr(args, "help", False) or not getattr(args, "command", None):
|
|
||||||
if self.help_callback:
|
|
||||||
for line in self.help_callback():
|
|
||||||
print(self._color(line))
|
|
||||||
else:
|
else:
|
||||||
self.parser.print_help()
|
_COLORAMA_AVAILABLE = True
|
||||||
return 0
|
_colorama_init(autoreset=True)
|
||||||
|
|
||||||
args_list = getattr(args, "_args", [])
|
_COLOR_TOKENS = (
|
||||||
|
"%red%",
|
||||||
|
"%green%",
|
||||||
|
"%yellow%",
|
||||||
|
"%blue%",
|
||||||
|
"%magenta%",
|
||||||
|
"%cyan%",
|
||||||
|
"%white%",
|
||||||
|
"%reset%",
|
||||||
|
"%bold%",
|
||||||
|
)
|
||||||
|
|
||||||
flags = {
|
_COLOR_TOKEN_MAP = {
|
||||||
k: v for k, v in vars(args).items()
|
|
||||||
if k not in ("command", "func", "_args", "help")
|
|
||||||
}
|
|
||||||
|
|
||||||
return args.func(args_list, flags)
|
|
||||||
|
|
||||||
def _color(self, text: str) -> str:
|
|
||||||
mapping = {
|
|
||||||
"%red%": Fore.RED,
|
"%red%": Fore.RED,
|
||||||
"%green%": Fore.GREEN,
|
"%green%": Fore.GREEN,
|
||||||
"%yellow%": Fore.YELLOW,
|
"%yellow%": Fore.YELLOW,
|
||||||
@ -50,35 +55,519 @@ class CLI:
|
|||||||
"%bold%": Style.BRIGHT,
|
"%bold%": Style.BRIGHT,
|
||||||
}
|
}
|
||||||
|
|
||||||
for key, value in mapping.items():
|
_NON_VALUE_ACTIONS = (
|
||||||
text = text.replace(key, value)
|
argparse._StoreTrueAction,
|
||||||
|
argparse._StoreFalseAction,
|
||||||
|
argparse._CountAction,
|
||||||
|
argparse._StoreConstAction,
|
||||||
|
argparse._AppendConstAction,
|
||||||
|
argparse._HelpAction,
|
||||||
|
)
|
||||||
|
|
||||||
|
_INTERNAL_NAMESPACE_FIELDS = frozenset({"command_help"})
|
||||||
|
_RESERVED_COMMANDS = frozenset({"help"})
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ParsedCommandInput:
|
||||||
|
"""Structured payload passed to parsed-mode handlers."""
|
||||||
|
|
||||||
|
command: str
|
||||||
|
invoked_as: str
|
||||||
|
args: List[Any]
|
||||||
|
flags: Dict[str, Any]
|
||||||
|
passthrough: List[str]
|
||||||
|
namespace: argparse.Namespace
|
||||||
|
argv: List[str]
|
||||||
|
|
||||||
|
class CLIError(Exception):
|
||||||
|
exit_code = 2
|
||||||
|
|
||||||
|
def __init__(self, message: str, usage_parser: Optional[argparse.ArgumentParser] = None):
|
||||||
|
super().__init__(message)
|
||||||
|
self.message = message
|
||||||
|
self.usage_parser = usage_parser
|
||||||
|
|
||||||
|
class CLIUsageError(CLIError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class CLIConfigError(CLIError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class CLIContractError(CLIError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class _CLIArgumentParser(argparse.ArgumentParser):
|
||||||
|
def error(self, message: str) -> None:
|
||||||
|
raise CLIUsageError(message, usage_parser=self)
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _CommandSpec:
|
||||||
|
name: str
|
||||||
|
parser: _CLIArgumentParser
|
||||||
|
help: Optional[str]
|
||||||
|
description: Optional[str]
|
||||||
|
aliases: List[str] = field(default_factory=list)
|
||||||
|
passthrough: bool = False
|
||||||
|
handler: Optional[Callable[..., Any]] = None
|
||||||
|
handler_mode: str = "simple"
|
||||||
|
positional_dests: List[str] = field(default_factory=list)
|
||||||
|
option_dests: List[str] = field(default_factory=list)
|
||||||
|
short_options: Dict[str, bool] = field(default_factory=dict) # True => takes value
|
||||||
|
|
||||||
|
class CLI:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
prog: str = "nut",
|
||||||
|
help_callback: Optional[Callable[[], Iterable[str]]] = None,
|
||||||
|
):
|
||||||
|
self.prog = prog
|
||||||
|
self.help_callback = help_callback
|
||||||
|
self._color_enabled = True
|
||||||
|
self._cli_debug = False
|
||||||
|
|
||||||
|
self.parser = _CLIArgumentParser(prog=prog, add_help=False, allow_abbrev=False)
|
||||||
|
self.subparsers = self.parser.add_subparsers(
|
||||||
|
dest="command",
|
||||||
|
metavar="command",
|
||||||
|
parser_class=_CLIArgumentParser,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.parser.add_argument("-h", "--help", dest="global_help", action="store_true", help="Show help")
|
||||||
|
self.parser.add_argument(
|
||||||
|
"--cli-debug",
|
||||||
|
action="store_true",
|
||||||
|
help="Show tracebacks for CLI internals",
|
||||||
|
)
|
||||||
|
self.parser.add_argument(
|
||||||
|
"--no-color",
|
||||||
|
action="store_true",
|
||||||
|
help="Disable colored output",
|
||||||
|
)
|
||||||
|
|
||||||
|
self._global_short_options = {"h": False}
|
||||||
|
|
||||||
|
self._commands: Dict[str, _CommandSpec] = {}
|
||||||
|
self._command_lookup: Dict[str, str] = {}
|
||||||
|
|
||||||
|
self._help_parser = self._create_help_command_parser()
|
||||||
|
|
||||||
|
def command(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
help: Optional[str] = None,
|
||||||
|
description: Optional[str] = None,
|
||||||
|
aliases: Optional[Sequence[str]] = None,
|
||||||
|
passthrough: bool = False,
|
||||||
|
) -> "_Command":
|
||||||
|
normalized_aliases = list(aliases or [])
|
||||||
|
self._validate_command_registration(name, normalized_aliases)
|
||||||
|
|
||||||
|
command_parser = self.subparsers.add_parser(
|
||||||
|
name,
|
||||||
|
add_help=False,
|
||||||
|
allow_abbrev=False,
|
||||||
|
help=help,
|
||||||
|
description=description,
|
||||||
|
aliases=normalized_aliases,
|
||||||
|
)
|
||||||
|
command_parser.add_argument(
|
||||||
|
"-h",
|
||||||
|
"--help",
|
||||||
|
dest="command_help",
|
||||||
|
action="store_true",
|
||||||
|
help="Show help for this command",
|
||||||
|
)
|
||||||
|
|
||||||
|
spec = _CommandSpec(
|
||||||
|
name=name,
|
||||||
|
parser=command_parser,
|
||||||
|
help=help,
|
||||||
|
description=description,
|
||||||
|
aliases=normalized_aliases,
|
||||||
|
passthrough=bool(passthrough),
|
||||||
|
)
|
||||||
|
|
||||||
|
self._commands[name] = spec
|
||||||
|
self._register_command_lookup(name, name)
|
||||||
|
for alias in normalized_aliases:
|
||||||
|
self._register_command_lookup(alias, name)
|
||||||
|
|
||||||
|
return _Command(spec)
|
||||||
|
|
||||||
|
def run(self, argv: Optional[Sequence[str]] = None) -> int:
|
||||||
|
raw_argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
self._cli_debug = "--cli-debug" in raw_argv
|
||||||
|
|
||||||
|
try:
|
||||||
|
return self._run_internal(raw_argv)
|
||||||
|
except CLIError as exc:
|
||||||
|
return self._handle_framework_error(exc)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive safety net
|
||||||
|
return self._handle_unexpected_error(exc)
|
||||||
|
|
||||||
|
def _run_internal(self, raw_argv: List[str]) -> int:
|
||||||
|
global_tokens, command_token, command_tokens = self._split_global_and_command_tokens(raw_argv)
|
||||||
|
global_tokens = self._expand_short_flag_clusters(
|
||||||
|
global_tokens,
|
||||||
|
self._global_short_options,
|
||||||
|
context="global options",
|
||||||
|
)
|
||||||
|
|
||||||
|
global_args = self.parser.parse_args(global_tokens)
|
||||||
|
self._cli_debug = bool(global_args.cli_debug)
|
||||||
|
self._color_enabled = _COLORAMA_AVAILABLE and (not bool(global_args.no_color))
|
||||||
|
|
||||||
|
if bool(global_args.global_help):
|
||||||
|
self._print_top_help()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if command_token is None:
|
||||||
|
self._print_top_help()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if command_token == "help":
|
||||||
|
return self._run_help_command(command_tokens)
|
||||||
|
|
||||||
|
canonical_name = self._resolve_command_name(command_token)
|
||||||
|
command_spec = self._commands[canonical_name]
|
||||||
|
return self._run_command(command_spec, command_token, command_tokens)
|
||||||
|
|
||||||
|
def _run_help_command(self, command_tokens: List[str]) -> int:
|
||||||
|
help_args = self._help_parser.parse_args(command_tokens)
|
||||||
|
|
||||||
|
if bool(help_args.command_help):
|
||||||
|
self._print_help_for_help_command()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
topic = getattr(help_args, "topic", None)
|
||||||
|
if topic is None:
|
||||||
|
self._print_top_help()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
canonical_name = self._resolve_command_name(topic)
|
||||||
|
spec = self._commands[canonical_name]
|
||||||
|
self._print_command_help(spec)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def _run_command(self, spec: _CommandSpec, invoked_as: str, command_tokens: List[str]) -> int:
|
||||||
|
expanded_tokens = self._expand_short_flag_clusters(
|
||||||
|
command_tokens,
|
||||||
|
spec.short_options,
|
||||||
|
context="command '{}'".format(spec.name),
|
||||||
|
)
|
||||||
|
|
||||||
|
if spec.passthrough:
|
||||||
|
namespace, passthrough = spec.parser.parse_known_args(expanded_tokens)
|
||||||
|
else:
|
||||||
|
namespace = spec.parser.parse_args(expanded_tokens)
|
||||||
|
passthrough = []
|
||||||
|
|
||||||
|
if bool(getattr(namespace, "command_help", False)):
|
||||||
|
self._print_command_help(spec)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if spec.handler is None:
|
||||||
|
raise CLIConfigError("No handler defined for command '{}'".format(spec.name))
|
||||||
|
|
||||||
|
positional_args = self._collect_positional_args(namespace, spec)
|
||||||
|
flag_values = {dest: getattr(namespace, dest) for dest in spec.option_dests}
|
||||||
|
|
||||||
|
if spec.handler_mode == "simple":
|
||||||
|
runtime_args = list(positional_args)
|
||||||
|
runtime_args.extend(passthrough)
|
||||||
|
result = spec.handler(runtime_args, flag_values)
|
||||||
|
else:
|
||||||
|
parsed_payload = ParsedCommandInput(
|
||||||
|
command=spec.name,
|
||||||
|
invoked_as=invoked_as,
|
||||||
|
args=positional_args,
|
||||||
|
flags=flag_values,
|
||||||
|
passthrough=list(passthrough),
|
||||||
|
namespace=namespace,
|
||||||
|
argv=list(command_tokens),
|
||||||
|
)
|
||||||
|
result = spec.handler(parsed_payload)
|
||||||
|
|
||||||
|
return self._normalize_exit_code(result, spec.name)
|
||||||
|
|
||||||
|
def _normalize_exit_code(self, value: Any, command_name: str) -> int:
|
||||||
|
if not isinstance(value, int):
|
||||||
|
raise CLIContractError(
|
||||||
|
"Command '{}' must return an int exit code, got {}".format(
|
||||||
|
command_name,
|
||||||
|
type(value).__name__,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
def _split_global_and_command_tokens(
|
||||||
|
self, argv: List[str]
|
||||||
|
) -> Tuple[List[str], Optional[str], List[str]]:
|
||||||
|
global_tokens: List[str] = []
|
||||||
|
command_token: Optional[str] = None
|
||||||
|
command_tokens: List[str] = []
|
||||||
|
|
||||||
|
index = 0
|
||||||
|
while index < len(argv):
|
||||||
|
token = argv[index]
|
||||||
|
if token == "--":
|
||||||
|
global_tokens.append(token)
|
||||||
|
index += 1
|
||||||
|
break
|
||||||
|
if token.startswith("-") and token != "-":
|
||||||
|
global_tokens.append(token)
|
||||||
|
index += 1
|
||||||
|
continue
|
||||||
|
break
|
||||||
|
|
||||||
|
if index < len(argv):
|
||||||
|
command_token = argv[index]
|
||||||
|
command_tokens = argv[index + 1 :]
|
||||||
|
|
||||||
|
return global_tokens, command_token, command_tokens
|
||||||
|
|
||||||
|
def _resolve_command_name(self, token: str) -> str:
|
||||||
|
canonical = self._command_lookup.get(token)
|
||||||
|
if canonical is not None:
|
||||||
|
return canonical
|
||||||
|
|
||||||
|
available = sorted(self._command_lookup.keys())
|
||||||
|
suggestions = difflib.get_close_matches(token, available, n=3, cutoff=0.45)
|
||||||
|
|
||||||
|
lines = ["Unknown command '{}'".format(token)]
|
||||||
|
if suggestions:
|
||||||
|
lines.append("Did you mean: {}".format(", ".join(suggestions)))
|
||||||
|
lines.append("Run '{} -h' to see available commands.".format(self.prog))
|
||||||
|
|
||||||
|
raise CLIUsageError("\n".join(lines), usage_parser=self.parser)
|
||||||
|
|
||||||
|
def _expand_short_flag_clusters(
|
||||||
|
self,
|
||||||
|
tokens: List[str],
|
||||||
|
short_options: Dict[str, bool],
|
||||||
|
context: str,
|
||||||
|
) -> List[str]:
|
||||||
|
expanded: List[str] = []
|
||||||
|
|
||||||
|
for token in tokens:
|
||||||
|
if token == "--":
|
||||||
|
expanded.append(token)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not token.startswith("-") or token.startswith("--") or len(token) <= 2:
|
||||||
|
expanded.append(token)
|
||||||
|
continue
|
||||||
|
|
||||||
|
cluster = token[1:]
|
||||||
|
|
||||||
|
if any(ch.isdigit() for ch in cluster):
|
||||||
|
expanded.append(token)
|
||||||
|
continue
|
||||||
|
|
||||||
|
known = [ch for ch in cluster if ch in short_options]
|
||||||
|
if not known:
|
||||||
|
expanded.append(token)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if any(short_options.get(ch, False) for ch in cluster if ch in short_options):
|
||||||
|
bad = [ch for ch in cluster if short_options.get(ch, False)]
|
||||||
|
raise CLIUsageError(
|
||||||
|
"Cannot group short option(s) {} in {} because they require values: '{}'".format(
|
||||||
|
", ".join("-{}".format(ch) for ch in bad),
|
||||||
|
context,
|
||||||
|
token,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
unknown = [ch for ch in cluster if ch not in short_options]
|
||||||
|
if unknown:
|
||||||
|
expanded.append(token)
|
||||||
|
continue
|
||||||
|
|
||||||
|
expanded.extend("-{}".format(ch) for ch in cluster)
|
||||||
|
|
||||||
|
return expanded
|
||||||
|
|
||||||
|
def _collect_positional_args(self, namespace: argparse.Namespace, spec: _CommandSpec) -> List[Any]:
|
||||||
|
values: List[Any] = []
|
||||||
|
for dest in spec.positional_dests:
|
||||||
|
value = getattr(namespace, dest)
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
if isinstance(value, list):
|
||||||
|
values.extend(value)
|
||||||
|
continue
|
||||||
|
values.append(value)
|
||||||
|
return values
|
||||||
|
|
||||||
|
def _handle_framework_error(self, exc: CLIError) -> int:
|
||||||
|
if self._cli_debug:
|
||||||
|
traceback.print_exc()
|
||||||
|
else:
|
||||||
|
self._print_error_message(exc.message)
|
||||||
|
if exc.usage_parser is not None:
|
||||||
|
usage = exc.usage_parser.format_usage().strip()
|
||||||
|
if usage:
|
||||||
|
self._print_plain(usage)
|
||||||
|
return exc.exit_code
|
||||||
|
|
||||||
|
def _handle_unexpected_error(self, exc: Exception) -> int:
|
||||||
|
if self._cli_debug:
|
||||||
|
traceback.print_exc()
|
||||||
|
else:
|
||||||
|
self._print_error_message(str(exc))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
def _validate_command_registration(self, name: str, aliases: Sequence[str]) -> None:
|
||||||
|
self._validate_command_name(name)
|
||||||
|
|
||||||
|
if name in _RESERVED_COMMANDS:
|
||||||
|
raise CLIConfigError("'{}' is reserved by the CLI".format(name))
|
||||||
|
|
||||||
|
for alias in aliases:
|
||||||
|
self._validate_command_name(alias)
|
||||||
|
if alias in _RESERVED_COMMANDS:
|
||||||
|
raise CLIConfigError("Alias '{}' is reserved by the CLI".format(alias))
|
||||||
|
|
||||||
|
tokens = [name] + list(aliases)
|
||||||
|
for token in tokens:
|
||||||
|
if token in self._command_lookup:
|
||||||
|
raise CLIConfigError("Duplicate command or alias '{}'".format(token))
|
||||||
|
|
||||||
|
def _validate_command_name(self, name: str) -> None:
|
||||||
|
if not name:
|
||||||
|
raise CLIConfigError("Command name cannot be empty")
|
||||||
|
if name.startswith("-"):
|
||||||
|
raise CLIConfigError("Command name cannot start with '-': '{}'".format(name))
|
||||||
|
|
||||||
|
def _register_command_lookup(self, token: str, canonical_name: str) -> None:
|
||||||
|
self._command_lookup[token] = canonical_name
|
||||||
|
|
||||||
|
def _create_help_command_parser(self) -> _CLIArgumentParser:
|
||||||
|
help_parser = self.subparsers.add_parser(
|
||||||
|
"help",
|
||||||
|
add_help=False,
|
||||||
|
allow_abbrev=False,
|
||||||
|
help="Show help for commands",
|
||||||
|
description="Show help for commands",
|
||||||
|
)
|
||||||
|
help_parser.add_argument("topic", nargs="?", help="Command to show help for")
|
||||||
|
help_parser.add_argument(
|
||||||
|
"-h",
|
||||||
|
"--help",
|
||||||
|
dest="command_help",
|
||||||
|
action="store_true",
|
||||||
|
help="Show help for the help command",
|
||||||
|
)
|
||||||
|
return help_parser
|
||||||
|
|
||||||
|
def _print_banner(self) -> None:
|
||||||
|
if self.help_callback is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
for line in self.help_callback():
|
||||||
|
self._print_colored(str(line))
|
||||||
|
|
||||||
|
def _print_top_help(self) -> None:
|
||||||
|
self._print_banner()
|
||||||
|
self._print_plain(self.parser.format_help().rstrip())
|
||||||
|
|
||||||
|
def _print_help_for_help_command(self) -> None:
|
||||||
|
self._print_banner()
|
||||||
|
self._print_plain(self._help_parser.format_help().rstrip())
|
||||||
|
|
||||||
|
def _print_command_help(self, spec: _CommandSpec) -> None:
|
||||||
|
self._print_banner()
|
||||||
|
self._print_plain(spec.parser.format_help().rstrip())
|
||||||
|
|
||||||
|
def _print_error_message(self, message: str) -> None:
|
||||||
|
self._print_colored("%red%error:%reset% {}".format(message))
|
||||||
|
|
||||||
|
def _print_colored(self, text: str) -> None:
|
||||||
|
self._print_plain(self._render_colors(text))
|
||||||
|
|
||||||
|
def _print_plain(self, text: str) -> None:
|
||||||
|
if text:
|
||||||
|
print(text)
|
||||||
|
|
||||||
|
def _render_colors(self, text: str) -> str:
|
||||||
|
if not self._color_enabled:
|
||||||
|
for token in _COLOR_TOKENS:
|
||||||
|
text = text.replace(token, "")
|
||||||
|
return text
|
||||||
|
|
||||||
|
for token, ansi_code in _COLOR_TOKEN_MAP.items():
|
||||||
|
text = text.replace(token, ansi_code)
|
||||||
return text
|
return text
|
||||||
|
|
||||||
class _Command:
|
class _Command:
|
||||||
def __init__(self, parser):
|
def __init__(self, spec: _CommandSpec):
|
||||||
self.parser = parser
|
self.spec = spec
|
||||||
self.parser.set_defaults(func=self._missing)
|
self.parser = spec.parser
|
||||||
|
|
||||||
self.parser.add_argument("_args", nargs="*")
|
def arg(self, name: str, **kwargs: Any) -> "_Command":
|
||||||
self.parser.add_argument("-h", "--help", action="store_true")
|
action = self.parser.add_argument(name, **kwargs)
|
||||||
|
self._track_action(action)
|
||||||
def arg(self, name, **kwargs):
|
|
||||||
self.parser.add_argument(name, **kwargs)
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def flag(self, name, short=None):
|
def flag(self, name: str, short: Optional[str] = None, **kwargs: Any) -> "_Command":
|
||||||
opts = [f"--{name}"]
|
options = [self._normalize_long_option(name)]
|
||||||
if short:
|
if short is not None:
|
||||||
opts.append(f"-{short}")
|
short_char = self._validate_short_option(short)
|
||||||
|
options.append("-{}".format(short_char))
|
||||||
|
|
||||||
self.parser.add_argument(*opts, action="store_true")
|
action = self.parser.add_argument(*options, action="store_true", **kwargs)
|
||||||
|
self._track_action(action)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def handle(self, func):
|
def option(self, name: str, short: Optional[str] = None, **kwargs: Any) -> "_Command":
|
||||||
self.parser.set_defaults(func=func)
|
options = [self._normalize_long_option(name)]
|
||||||
|
if short is not None:
|
||||||
|
short_char = self._validate_short_option(short)
|
||||||
|
options.append("-{}".format(short_char))
|
||||||
|
|
||||||
|
action = self.parser.add_argument(*options, **kwargs)
|
||||||
|
self._track_action(action)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def _missing(self, *_):
|
def handle(self, func: Callable[..., Any], mode: str = "simple") -> "_Command":
|
||||||
raise RuntimeError("No handler defined")
|
if mode not in {"simple", "parsed"}:
|
||||||
|
raise CLIConfigError("Unknown handler mode '{}'".format(mode))
|
||||||
|
|
||||||
|
self.spec.handler = func
|
||||||
|
self.spec.handler_mode = mode
|
||||||
|
return self
|
||||||
|
|
||||||
|
def _track_action(self, action: argparse.Action) -> None:
|
||||||
|
if action.dest in _INTERNAL_NAMESPACE_FIELDS:
|
||||||
|
return
|
||||||
|
|
||||||
|
if action.option_strings:
|
||||||
|
if action.dest not in self.spec.option_dests:
|
||||||
|
self.spec.option_dests.append(action.dest)
|
||||||
|
self._track_short_options(action)
|
||||||
|
return
|
||||||
|
|
||||||
|
if action.dest not in self.spec.positional_dests:
|
||||||
|
self.spec.positional_dests.append(action.dest)
|
||||||
|
|
||||||
|
def _track_short_options(self, action: argparse.Action) -> None:
|
||||||
|
takes_value = self._action_takes_value(action)
|
||||||
|
|
||||||
|
for option in action.option_strings:
|
||||||
|
if option.startswith("--"):
|
||||||
|
continue
|
||||||
|
if option.startswith("-") and len(option) == 2:
|
||||||
|
self.spec.short_options[option[1]] = takes_value
|
||||||
|
|
||||||
|
def _action_takes_value(self, action: argparse.Action) -> bool:
|
||||||
|
return not isinstance(action, _NON_VALUE_ACTIONS)
|
||||||
|
|
||||||
|
def _normalize_long_option(self, name: str) -> str:
|
||||||
|
return name if name.startswith("--") else "--{}".format(name)
|
||||||
|
|
||||||
|
def _validate_short_option(self, short: str) -> str:
|
||||||
|
value = short.lstrip("-")
|
||||||
|
if len(value) != 1:
|
||||||
|
raise CLIConfigError("Short option must be exactly one character, got '{}'".format(short))
|
||||||
|
return value
|
||||||
|
|||||||
47
nut/commands/setup.py
Normal file
47
nut/commands/setup.py
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
from nut.paths import get_templates_path
|
||||||
|
import shutil
|
||||||
|
import os
|
||||||
|
|
||||||
|
def find_template_conflicts(dest_dir: str) -> list[str]:
|
||||||
|
templates_path = get_templates_path() / "project"
|
||||||
|
|
||||||
|
if not os.path.exists(templates_path):
|
||||||
|
raise RuntimeError(f"Project template path does not exist: {templates_path}")
|
||||||
|
|
||||||
|
conflicts = []
|
||||||
|
|
||||||
|
for root, _, files in os.walk(templates_path):
|
||||||
|
rel = os.path.relpath(root, templates_path)
|
||||||
|
target_root = os.path.join(dest_dir, rel) if rel != "." else dest_dir
|
||||||
|
|
||||||
|
for file in files:
|
||||||
|
target_file = os.path.join(target_root, file)
|
||||||
|
if os.path.exists(target_file):
|
||||||
|
conflicts.append(target_file)
|
||||||
|
|
||||||
|
return conflicts
|
||||||
|
|
||||||
|
def copy_template_files(dest_dir: str, force: bool = False, checked: bool = False) -> None:
|
||||||
|
templates_path = get_templates_path() / "project"
|
||||||
|
|
||||||
|
if not os.path.exists(templates_path):
|
||||||
|
raise RuntimeError(f"Project template path does not exist: {templates_path}")
|
||||||
|
|
||||||
|
os.makedirs(dest_dir, exist_ok=True)
|
||||||
|
|
||||||
|
if not checked:
|
||||||
|
conflicts = find_template_conflicts(dest_dir)
|
||||||
|
if conflicts and not force:
|
||||||
|
raise FileExistsError(
|
||||||
|
"Refusing to overwrite existing files:\n" +
|
||||||
|
"\n".join(conflicts)
|
||||||
|
)
|
||||||
|
|
||||||
|
shutil.copytree(
|
||||||
|
templates_path,
|
||||||
|
dest_dir,
|
||||||
|
dirs_exist_ok=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def is_nut_workspace(path: str) -> bool:
|
||||||
|
return os.path.isfile(os.path.join(path, "Nutfile"))
|
||||||
7
nut/paths.py
Normal file
7
nut/paths.py
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
def get_root_path() -> Path:
|
||||||
|
return Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
def get_templates_path() -> Path:
|
||||||
|
return get_root_path() / "templates"
|
||||||
19
nut/templates/project/Nutfile
Normal file
19
nut/templates/project/Nutfile
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
# Example Nutfile for a simple project.
|
||||||
|
# This file was generated by Nut and can be edited to customize the build process.
|
||||||
|
# For more information on the Nutfile format and available options, see the documentation:
|
||||||
|
# https://nut.chipperfluff.at/docs/nutfile
|
||||||
|
# (Note: The documentation server does not exist yet, but it will be available in the future.)
|
||||||
|
# (no lol)
|
||||||
|
|
||||||
|
build:
|
||||||
|
# Name of the resulting build artifact.
|
||||||
|
# "Example Build" becomes "example_build.nut".
|
||||||
|
name: Example Build
|
||||||
|
|
||||||
|
# Folder used for build output (temporary files + final artifacts).
|
||||||
|
# Created automatically if it does not exist.
|
||||||
|
buildFolder: build
|
||||||
|
|
||||||
|
# Project entry file (relative to this Nutfile).
|
||||||
|
# Execution starts from this file in the built output.
|
||||||
|
entry: src/main.nut
|
||||||
0
nut/templates/project/src/main.nut
Normal file
0
nut/templates/project/src/main.nut
Normal file
1
setup.py
1
setup.py
@ -6,6 +6,7 @@ setup(
|
|||||||
description="Core package and CLI entrypoint for nut",
|
description="Core package and CLI entrypoint for nut",
|
||||||
author="Chipperfluff",
|
author="Chipperfluff",
|
||||||
packages=find_packages(),
|
packages=find_packages(),
|
||||||
|
include_package_data=True,
|
||||||
python_requires=">=3.8",
|
python_requires=">=3.8",
|
||||||
install_requires=["chipenv", "colorama", "PyYAML"],
|
install_requires=["chipenv", "colorama", "PyYAML"],
|
||||||
)
|
)
|
||||||
Loading…
x
Reference in New Issue
Block a user