diff --git a/cutekit/__init__.py b/cutekit/__init__.py index dba77cc..f54e67d 100644 --- a/cutekit/__init__.py +++ b/cutekit/__init__.py @@ -28,40 +28,52 @@ def ensure(version: tuple[int, int, int]): ) -def setupLogger(verbose: bool): - if verbose: - logging.basicConfig( - level=logging.DEBUG, - format=f"{vt100.CYAN}%(asctime)s{vt100.RESET} {vt100.YELLOW}%(levelname)s{vt100.RESET} %(name)s: %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", - ) - else: - projectRoot = model.Project.topmost() - logFile = const.GLOBAL_LOG_FILE - if projectRoot is not None: - logFile = os.path.join(projectRoot.dirname(), const.PROJECT_LOG_FILE) +class LoggerArgs: + verbose = cli.Arg[bool]("v", "verbose", "Enable verbose logging", default=False) - shell.mkdir(os.path.dirname(logFile)) - logging.basicConfig( - level=logging.INFO, - filename=logFile, - filemode="w", - format="%(asctime)s %(levelname)s %(name)s: %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", - ) +class logger: + @staticmethod + def setup(args: LoggerArgs): + if args.verbose: + logging.basicConfig( + level=logging.DEBUG, + format=f"{vt100.CYAN}%(asctime)s{vt100.RESET} {vt100.YELLOW}%(levelname)s{vt100.RESET} %(name)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + else: + projectRoot = model.Project.topmost() + logFile = const.GLOBAL_LOG_FILE + if projectRoot is not None: + logFile = os.path.join(projectRoot.dirname(), const.PROJECT_LOG_FILE) + + shell.mkdir(os.path.dirname(logFile)) + + logging.basicConfig( + level=logging.INFO, + filename=logFile, + filemode="w", + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + + +class MainArgs(pods.PodArgs, plugins.PluginArgs, LoggerArgs): + pass + + +@cli.command(None, "/", const.DESCRIPTION) +def _(args: MainArgs): + shell.mkdir(const.GLOBAL_CK_DIR) + logger.setup(args) + const.setup() + plugins.setup(args) + pods.setup(args, sys.argv[1:]) def main() -> int: try: - shell.mkdir(const.GLOBAL_CK_DIR) - args = cli.parse(sys.argv[1:], cli.CutekitArgs) - setupLogger(args.verbose) - - const.setup() - plugins.setup(args) - pods.setup(args, sys.argv[1:]) - cli.exec(args.cmd, sys.argv[1:]) + cli.exec(sys.argv) return 0 except RuntimeError as e: diff --git a/cutekit/builder.py b/cutekit/builder.py index 3919508..df3ce7d 100644 --- a/cutekit/builder.py +++ b/cutekit/builder.py @@ -350,25 +350,24 @@ def _(args: Any): build(scope, component if component is not None else "all")[0] -class RunArgs: - debug: cli.Arg[bool] = cli.Arg("d", "debug", "Enable debug mode", default=False) - profile: cli.Arg[bool] = cli.Arg("p", "profile", "Enable profiling", default=False) - wait: cli.Arg[bool] = cli.Arg("w", "wait", "Wait for debugger to attach", default=False) - debugger: cli.Arg[str] = cli.Arg("g", "debugger", "Debugger to use", default="lldb") - mixins: cli.Arg[str] = cli.Arg("m", "mixins", "Mixins to apply", default="") - - componentSpec: cli.FreeFormArg[str] = cli.FreeFormArg("Component to run", default="__main__") +class RunArgs(model.RegistryArgs, shell.DebuggerArgs): + debug = cli.Arg[bool]("d", "debug", "Enable debug mode", default=False) + profile = cli.Arg[bool]("p", "profile", "Enable profiling", default=False) + component = cli.FreeFormArg( + "component", "Component to run", default="__main__" + ) extra: cli.RawArg + @cli.command("r", "builder/run", "Run a component") def runCmd(args: RunArgs): scope = TargetScope.use(args, {"debug": args.debug}) component = scope.registry.lookup( - args.componentSpec, model.Component, includeProvides=True + args.component, model.Component, includeProvides=True ) if component is None: - raise RuntimeError(f"Component {args.componentSpec} not found") + raise RuntimeError(f"Component {args.component} not found") product = build(scope, component)[0] @@ -393,8 +392,7 @@ def runCmd(args: RunArgs): def _(args: RunArgs): # This is just a wrapper around the `run` command that try # to run a special hook component named __tests__. - - args.componentSpec = "__tests__" + args.component = "__tests__" runCmd(args) diff --git a/cutekit/cli.py b/cutekit/cli.py index b35af43..cb7cabc 100644 --- a/cutekit/cli.py +++ b/cutekit/cli.py @@ -13,6 +13,7 @@ from typing import ( Union, Callable, Generic, + cast, get_origin, get_args, ) @@ -40,6 +41,9 @@ class Arg(Generic[utils.T]): return instance.__dict__.get(self.longName, self.default) + def __set__(self, instance, value: utils.T): + instance.__dict__[self.longName] = value + @dt.dataclass class FreeFormArg(Generic[utils.T]): @@ -53,23 +57,30 @@ class FreeFormArg(Generic[utils.T]): return instance.__dict__.get(self.longName, self.default) + def __set__(self, instance, value: utils.T): + instance.__dict__[self.longName] = value + + +@dt.dataclass +class RawArg: + longName: str + description: str + + def __get__(self, instance, owner) -> list[str]: + if instance is None: + return self # type: ignore + + return instance.__dict__.get(self.longName, []) + + def __set__(self, instance, value): + instance.__dict__[self.longName] = value + class ParserState(enum.Enum): FreeForm = enum.auto() ShortArg = enum.auto() -RawArg = NewType("RawArg", str) - - -class CutekitArgs: - cmd: FreeFormArg[str] = FreeFormArg("Command to execute") - verbose: Arg[bool] = Arg("v", "verbose", "Enable verbose logging") - safemode: Arg[bool] = Arg("s", "safe", "Enable safe mode") - pod: Arg[bool] = Arg("p", "enable-pod", "Enable pod", default=False) - podName: Arg[str] = Arg("n", "pod-name", "The name of the pod", default="") - - def parse(argv: list[str], argType: type[utils.T]) -> utils.T: def set_value(options: dict[str, Any], name: str, value: Any): if name is not options: @@ -183,6 +194,7 @@ def parse(argv: list[str], argType: type[utils.T]) -> utils.T: raise RuntimeError(f"Missing arguments: {', '.join(missing)}") for key, value in options.items(): + # print(getattr(argType, key).type()) field_type = get_args(argType.__annotations__[key])[0] setattr(result, key, field_type(value)) @@ -210,34 +222,56 @@ class Command: subcommands: dict[str, "Command"] = dt.field(default_factory=dict) + def help(self): + print(f"{self.longName} - {self.helpText}") + print() + self.usage() + + def usage(self): + usage = f"Usage: {self.longName}" + + if self.argType is not None: + usage += " [args...]" + + if len(self.subcommands) > 0: + usage += " [args...]" + + print(usage) + commands: dict[str, Command] = {} +rootCommand: Optional[Command] = None def command(shortName: Optional[str], longName: str, helpText: str): curframe = inspect.currentframe() calframe = inspect.getouterframes(curframe, 2) - def wrap(fn: Callback): + def wrap(fn: utils.T) -> utils.T: _logger.debug(f"Registering command {longName}") if len(fn.__annotations__) == 0: argType = None else: argType = list(fn.__annotations__.values())[0] - path = longName.split("/") - parent = commands - for p in path[:-1]: - parent = parent[p].subcommands - parent[path[-1]] = Command( + command = Command( shortName, - path[-1], + path[-1] if longName != "/" else "/", helpText, Path(calframe[1].filename).parent != Path(__file__).parent, - fn, + cast(Callback, fn), argType, ) + if longName == "/": + global rootCommand + rootCommand = command + else: + parent = commands + for p in path[:-1]: + parent = parent[p].subcommands + parent[path[-1]] = command + return fn return wrap @@ -327,17 +361,49 @@ def versionCmd(): print(f"CuteKit v{const.VERSION_STR}") -def exec(cmd: str, args: list[str], cmds: dict[str, Command] = commands): - for c in cmds.values(): - if c.shortName == cmd or c.longName == cmd: - if len(c.subcommands) > 0: - exec(args[0], args[1:], c.subcommands) - return - else: - if c.argType is not None: - c.callback(parse(args[1:], c.argType)) # type: ignore - else: - c.callback() # type: ignore +def _exec(args: list[str], cmd: Command): + # let's slice the arguments for this command and the sub command + # [-a -b] [sub-cmd -c -d] + + selfArgs = [] + if len(cmd.subcommands) > 0: + while len(args) > 0 and args[0].startswith("-"): + selfArgs.append(args.pop(0)) + else: + selfArgs = args + + if "-h" in selfArgs or "--help" in selfArgs: + cmd.help() + return + + if "-u" in selfArgs or "--usage" in selfArgs: + cmd.usage() + return + + if cmd.argType is not None: + parsedArgs = parse(selfArgs, cmd.argType) + cmd.callback(parsedArgs) # type: ignore + else: + cmd.callback() # type: ignore + + if cmd.subcommands: + if len(args) == 0: + error("Missing subcommand") + cmd.usage() + return + + for c in cmd.subcommands.values(): + if c.shortName == args[0] or c.longName == args[0]: + _exec(args, c) return - raise RuntimeError(f"Unknown command {cmd}") + raise RuntimeError(f"Unknown command {args[0]}") + + +def exec(args: list[str]): + if not rootCommand: + raise RuntimeError("No root command") + + rootCommand.longName = Path(args[0]).name + rootCommand.subcommands = commands + _exec(args[1:], rootCommand) diff --git a/cutekit/graph.py b/cutekit/graph.py index 9ff6eb1..b1a27d4 100644 --- a/cutekit/graph.py +++ b/cutekit/graph.py @@ -83,15 +83,20 @@ def view( g.view(filename=os.path.join(target.builddir, "graph.gv")) -class GraphCmd: - mixins: cli.Arg[str] = cli.Arg("m", "mixins", "Mixins to apply", default="") - scope: cli.Arg[str] = cli.Arg("s", "scope", "Scope to show", default="") - onlyLibs: cli.Arg[bool] = cli.Arg("l", "only-libs", "Only show libraries", default=False) - showDisabled: cli.Arg[bool] = cli.Arg("d", "show-disabled", "Show disabled components", default=False) +class GraphCmd(model.TargetArgs): + scope = cli.Arg("s", "scope", "Scope to show", default="") + onlyLibs = cli.Arg("l", "only-libs", "Only show libraries", default=False) + showDisabled = cli.Arg( + "d", "show-disabled", "Show disabled components", default=False + ) + @cli.command("g", "graph", "Show the dependency graph") def _(args: GraphCmd): - registry = model.Registry.use(args) - target = model.Target.use(args) - - view(registry, target, scope=args.scope, showExe=not args.onlyLibs, showDisabled=args.showDisabled) + view( + model.Registry.use(args), + model.Target.use(args), + scope=args.scope, + showExe=not args.onlyLibs, + showDisabled=args.showDisabled, + ) diff --git a/cutekit/model.py b/cutekit/model.py index c05af9b..63f827a 100644 --- a/cutekit/model.py +++ b/cutekit/model.py @@ -190,18 +190,15 @@ def _(): class InitArgs: - repo: cli.Arg[str] = cli.Arg( + repo = cli.Arg( "r", "repo", "Repository to use for templates", default=const.DEFAULT_REPO_TEMPLATES, ) - list: cli.Arg[bool] = cli.Arg( - "l", "list", "List available templates", default=False - ) - - template: cli.FreeFormArg[str] = cli.FreeFormArg("Template to use") - name: cli.FreeFormArg[Optional[str]] = cli.FreeFormArg("Name of the project") + list = cli.Arg("l", "list", "List available templates", default=False) + template = cli.FreeFormArg("template", "Template to use") + name = cli.FreeFormArg("name", "Name of the project") @cli.command("I", "model/init", "Initialize a new project") @@ -274,8 +271,8 @@ DEFAULT_TOOLS: Tools = { class RegistryArgs: - mixins: cli.Arg[str] = cli.Arg("m", "mixins", "Mixins to apply", default="") - # props: cli.Arg[dict[str]] = cli.Arg("p", "props", "Properties to set", default="") + mixins = cli.Arg[list[str]]("m", "mixins", "Mixins to apply", default=[]) + # props = cli.Arg[dict[str]]("p", "props", "Properties to set", default="") class TargetArgs(RegistryArgs): diff --git a/cutekit/plugins.py b/cutekit/plugins.py index 67958fd..9105770 100644 --- a/cutekit/plugins.py +++ b/cutekit/plugins.py @@ -51,6 +51,10 @@ def loadAll(): load(os.path.join(pluginDir, files)) -def setup(args: cli.CutekitArgs): +class PluginArgs: + safemode = cli.Arg[bool]("s", "safemode", "Skip loading plugins", default=False) + + +def setup(args: PluginArgs): if not args.safemode: loadAll() diff --git a/cutekit/pods.py b/cutekit/pods.py index 8f12ef0..b942689 100644 --- a/cutekit/pods.py +++ b/cutekit/pods.py @@ -77,7 +77,12 @@ IMAGES: dict[str, Image] = { } -def setup(args: cli.CutekitArgs, argv: list[str]): +class PodArgs: + pod = cli.Arg[bool]("p", "enable-pod", "Enable pod", default=False) + podName = cli.Arg[str]("n", "pod-name", "The name of the pod", default="") + + +def setup(args: PodArgs, argv: list[str]): """ Reincarnate cutekit within a docker container, this is useful for cross-compiling @@ -118,12 +123,15 @@ def tryDecode(data: Optional[bytes], default: str = "") -> str: return data.decode() -class PodArgs: - name: cli.Arg[str] = cli.Arg("n", "name", "The name of the pod to use", default=defaultPodName) - image = cli.Arg("i", "image", "The image to use", default=defaultPodImage) +class PodCreateArgs: + name: cli.Arg[str] = cli.Arg( + "n", "name", "The name of the pod to use", default="default" + ) + image = cli.Arg[str]("i", "image", "The image to use", default=defaultPodImage) + @cli.command("c", "pod/create", "Create a new pod") -def _(args: PodArgs): +def _(args: PodCreateArgs): """ Create a new development pod with cutekit installed and the current project mounted at /project @@ -177,7 +185,10 @@ def _(args: PodArgs): class KillPodArgs: - name: cli.Arg[str] = cli.Arg("n", "name", "The name of the pod to kill", default=defaultPodName) + name: cli.Arg[str] = cli.Arg( + "n", "name", "The name of the pod to kill", default=defaultPodName + ) + @cli.command("k", "pod/kill", "Stop and remove a pod") def _(args: KillPodArgs): @@ -211,9 +222,10 @@ def _(): class PodExecArgs: - name: cli.Arg[str] = cli.Arg("n", "name", "The name of the pod to use", default=defaultPodName) - cmd: cli.FreeFormArg[str] = cli.FreeFormArg("The command to execute") - extra: cli.RawArg + name = cli.Arg("n", "name", "The name of the pod to use", default=defaultPodName) + cmd = cli.FreeFormArg("cmd", "The command to execute", default="/bin/bash") + args = cli.FreeFormArg("args", "The arguments to pass to the command") + @cli.command("e", "pod/exec", "Execute a command in a pod") def podExecCmd(args: PodExecArgs): @@ -223,7 +235,7 @@ def podExecCmd(args: PodExecArgs): name = args.name try: - shell.exec("docker", "exec", "-it", name, args.cmd, *args.extra) + shell.exec("docker", "exec", "-it", name, args.cmd, *args.args) except Exception: raise RuntimeError(f"Pod '{name[len(podPrefix):]}' does not exist") diff --git a/cutekit/shell.py b/cutekit/shell.py index 68c5a7f..5ccb85c 100644 --- a/cutekit/shell.py +++ b/cutekit/shell.py @@ -395,35 +395,51 @@ def compress(path: str, dest: Optional[str] = None, format: str = "zstd") -> str @cli.command("s", "scripts", "Manage scripts") -def _(args: cli.Args): +def _(): pass -class DebugArgs: +class DebuggerArgs: debugger = cli.Arg[str]( "d", "debugger", "Debugger to use (lldb, gdb)", default="lldb" ) wait = cli.Arg[bool]("w", "wait", "Wait for debugger to attach") - extra = cli.RawArg + + +class DebugArgs(DebuggerArgs): + cmd = cli.FreeFormArg[str]("cmd", "Command to debug", default="a.out") + args = cli.RawArg("args", "Arguments to pass to the command") @cli.command("d", "debug", "Debug a program") def _(args: DebugArgs): - wait = args.consumeOpt("wait", False) is True - debugger = args.consumeOpt("debugger", "lldb") - command = [str(args.consumeArg()), *args.extra] - debug(command, debugger=debugger, wait=wait) + command = [args.cmd, *args.args] + debug(command, args.debugger, args.wait) + + +class ProfilerArgs: + pass + + +class ProfileArgs: + cmd = cli.FreeFormArg[str]("cmd", "Command to profile", default="a.out") + extra: cli.RawArg @cli.command("p", "profile", "Profile a program") -def _(args: cli.Args): - command = [str(args.consumeArg()), *args.extra] +def _(args: ProfileArgs): + command = [args.cmd, *args.extra] profile(command) +class CompressArgs: + dest = cli.Arg[str]("d", "dest", "Destination file") + format = cli.Arg[str]( + "f", "format", "Compression format (zip, zstd, gzip)", default="zstd" + ) + path = cli.FreeFormArg[str]("path", "Path to compress") + + @cli.command("c", "compress", "Compress a file or directory") -def _(args: cli.Args): - path = str(args.consumeArg()) - dest = args.consumeOpt("dest", None) - format = args.consumeOpt("format", "zstd") - compress(path, dest, format) +def _(args: CompressArgs): + compress(args.path, args.dest, args.format)