Skip to content

Commit

Permalink
feat: Command aliases (#30)
Browse files Browse the repository at this point in the history
  • Loading branch information
jaykv authored Aug 7, 2024
1 parent d97a129 commit 7c078e5
Show file tree
Hide file tree
Showing 19 changed files with 258 additions and 100 deletions.
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ test:
pytest -vv --capture=tee-sys

clean:
rm -rf build/ dist/ *.egg-info .*_cache
rm -rf build/ dist/ *.egg-info .*_cache test-builds test-manifest-builds
find . -name '*.pyc' -type f -exec rm -rf {} +
find . -name '__pycache__' -exec rm -rf {} +

Expand Down
62 changes: 39 additions & 23 deletions cliffy/cli.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
## CLI to generate CLIs
import contextlib
from io import TextIOWrapper
import os
from typing import IO, Any, Optional, TextIO, Union, cast
Expand All @@ -8,7 +7,6 @@
from click.types import _is_file_like

from .rich import click, Console, print_rich_table
from .rich import ClickGroup # type: ignore

from .builder import build_cli, build_cli_from_manifest, run_cli
from .helper import (
Expand All @@ -27,13 +25,14 @@
from .reloader import CLIManifestReloader

CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])


class AliasedGroup(ClickGroup):
def get_command(self, ctx: click.Context, cmd_name: Optional[str]) -> Optional[click.Command]:
with contextlib.suppress(KeyError):
cmd_name = ALIASES[cmd_name].name # type: ignore
return super().get_command(ctx, cmd_name or "")
ALIASES = {
"ls": "list",
"add": "load",
"reload": "update",
"rm": "remove",
"rm-all": "remove-all",
"rmall": "remove-all",
}


class ManifestOrCLI(click.File):
Expand All @@ -51,13 +50,21 @@ def convert( # type: ignore[override]
return value


@click.group(context_settings=CONTEXT_SETTINGS, cls=AliasedGroup) # type: ignore[arg-type]
def show_aliases_callback(ctx: Any, param: Any, val: bool):
if val:
out("Aliases:")
for alias, command in ALIASES.items():
out(f" {alias.ljust(10)} Alias for {command}")
ctx.exit()


@click.group(context_settings=CONTEXT_SETTINGS)
@click.version_option()
def cli() -> None:
@click.option("--aliases", type=bool, is_flag=True, is_eager=True, callback=show_aliases_callback)
def cli(aliases: bool) -> None:
pass


@cli.command() # type: ignore[arg-type]
@click.argument("manifests", type=click.File("rb"), nargs=-1)
def load(manifests: list[TextIO]) -> None:
"""Load CLI for given manifest(s)"""
Expand All @@ -70,7 +77,6 @@ def load(manifests: list[TextIO]) -> None:
out(f" {T.cli.name} -h")


@cli.command() # type: ignore[arg-type]
@click.argument("cli_names", type=str, nargs=-1)
def update(cli_names: list[str]) -> None:
"""Reloads CLI by name"""
Expand All @@ -86,7 +92,6 @@ def update(cli_names: list[str]) -> None:
out_err(f"~ {cli_name} not found")


@cli.command() # type: ignore[arg-type]
@click.argument("manifest", type=click.File("rb"))
def render(manifest: TextIO) -> None:
"""Render the CLI manifest generation as code"""
Expand All @@ -96,7 +101,6 @@ def render(manifest: TextIO) -> None:
out(f"# Rendered {T.cli.name} CLI v{T.cli.version} ~", fg="green")


@cli.command("run") # type: ignore[arg-type]
@click.argument("manifest", type=click.File("rb"))
@click.argument("cli_args", type=str, nargs=-1)
def cliffy_run(manifest: TextIO, cli_args: tuple[str]) -> None:
Expand All @@ -105,7 +109,6 @@ def cliffy_run(manifest: TextIO, cli_args: tuple[str]) -> None:
run_cli(T.cli.name, T.cli.code, cli_args)


@cli.command() # type: ignore[arg-type]
@click.argument("cli_name", type=str, default="cliffy")
@click.option("--version", "-v", type=str, show_default=True, default="v1", help="Manifest version")
@click.option("--render", is_flag=True, show_default=True, default=False, help="Render template to terminal directly")
Expand Down Expand Up @@ -133,7 +136,6 @@ def init(cli_name: str, version: str, render: bool, raw: bool) -> None:
out(f"+ {cli_name}.yaml", fg="green")


@cli.command("list") # type: ignore[arg-type]
def cliffy_list() -> None:
"""List all CLIs loaded"""
cols = ["Name", "Version", "Age", "Manifest"]
Expand All @@ -144,7 +146,6 @@ def cliffy_list() -> None:
print_rich_table(cols, rows, styles=["cyan", "magenta", "green", "blue"])


@cli.command() # type: ignore[arg-type]
@click.argument("cli_names", type=str, nargs=-1)
def remove(cli_names: list[str]) -> None:
"""Remove a loaded CLI by name"""
Expand All @@ -157,7 +158,6 @@ def remove(cli_names: list[str]) -> None:
out_err(f"~ {cli_name} not loaded")


@cli.command() # type: ignore[arg-type]
def remove_all() -> None:
"""Remove all loaded CLIs"""
for metadata in get_clis():
Expand All @@ -166,7 +166,6 @@ def remove_all() -> None:
out(f"~ {metadata.cli_name} removed 💥", fg="green")


@cli.command() # type: ignore[arg-type]
@click.argument("cli_or_manifests", type=ManifestOrCLI(), nargs=-1)
@click.option("--output-dir", "-o", type=click.Path(file_okay=False, dir_okay=True, writable=True), help="Output dir")
@click.option(
Expand Down Expand Up @@ -205,7 +204,6 @@ def build(cli_or_manifests: list[Union[TextIOWrapper, str]], output_dir: str, py
out(f"+ {cli_name} built 📦", fg="green")


@cli.command() # type: ignore[arg-type]
@click.argument("cli_name", type=str)
def info(cli_name: str):
"""Display CLI info"""
Expand All @@ -217,7 +215,6 @@ def info(cli_name: str):
out(f"{click.style('manifest:', fg='blue')}\n{indent_block(metadata.manifest, spaces=2)}")


@cli.command() # type: ignore[arg-type]
@click.argument("manifest", type=click.Path(exists=True, file_okay=True, dir_okay=False, readable=True))
@click.option(
"--run-cli",
Expand All @@ -242,4 +239,23 @@ def dev(manifest: str, run_cli: bool, run_cli_args: tuple[str]) -> None:
CLIManifestReloader.watch(manifest, run_cli, run_cli_args)


ALIASES = {"add": load, "rm": remove, "rm-all": remove_all, "ls": cliffy_list, "reload": update}
# register commands
load_command = cli.command("load")(load)
build_command = cli.command("build")(build)
dev_command = cli.command("dev")(dev)
info_command = cli.command("info")(info)
init_command = cli.command("init")(init)
list_command = cli.command("list")(cliffy_list)
render_command = cli.command("render")(render)
remove_command = cli.command("remove")(remove)
remove_all_command = cli.command("remove-all")(remove_all)
run_command = cli.command("run")(cliffy_run)
update_command = cli.command("update")(update)

# register aliases
cli.command("add", hidden=True, epilog="Alias for load")(load)
cli.command("ls", hidden=True, epilog="Alias for list")(cliffy_list)
cli.command("rm", hidden=True, epilog="Alias for remove")(remove)
cli.command("rm-all", hidden=True, epilog="Alias for remove-all")(remove_all)
cli.command("rmall", hidden=True, epilog="Alias for remove-all")(remove_all)
cli.command("reload", hidden=True, epilog="Alias for update")(update)
50 changes: 46 additions & 4 deletions cliffy/commander.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from typing import DefaultDict

from pybash.transformer import transform as transform_bash
from pydantic import BaseModel
from pydantic import BaseModel, Field

from .manifests import CommandBlock, Manifest
from .parser import Parser
Expand All @@ -13,6 +13,7 @@
class Command(BaseModel):
name: str
script: CommandBlock
aliases: list[str] = Field(default_factory=list)

@classmethod
def from_greedy_make_lazy(cls, greedy_command: Command, group: str) -> Command:
Expand Down Expand Up @@ -47,7 +48,18 @@ class Group(BaseModel):
class Commander:
"""Generates commands based on the command config"""

__slots__ = ("manifest", "parser", "cli", "groups", "greedy", "commands", "root_commands")
__slots__ = (
"manifest",
"parser",
"cli",
"groups",
"greedy",
"commands",
"root_commands",
"command_aliases",
"base_imports",
"aliases_by_commands",
)

def __init__(self, manifest: Manifest) -> None:
self.manifest = manifest
Expand All @@ -59,11 +71,29 @@ def __init__(self, manifest: Manifest) -> None:
Command(name=name, script=script) for name, script in self.manifest.commands.items()
]
self.root_commands: list[Command] = [command for command in self.commands if "." not in command.name]
self.base_imports: set[str] = set()
self.aliases_by_commands: dict[str, list[str]] = defaultdict(list)
self.build_groups()
self.setup_command_aliases()

def setup_command_aliases(self) -> None:
for command in self.commands:
if "|" in command.name:
aliases = command.name.split("|")

# skip group command aliases
if "." in aliases[0]:
continue

command.name = aliases[0] # update command.name without the alias part
for alias in aliases[1:]:
command.aliases.append(alias)
self.aliases_by_commands[command.name].append(alias)

def build_groups(self) -> None:
groups: DefaultDict[str, list[Command]] = defaultdict(list)
group_help_dict: dict[str, str] = {}

for command in self.commands:
# Check for greedy commands- evaluate them at the end
if self.is_greedy(command.name):
Expand All @@ -72,17 +102,29 @@ def build_groups(self) -> None:

if "." in command.name:
group_name = command.name.split(".")[:-1][-1]

if "|" in command.name:
command_aliases = command.name.rsplit(".", 1)[1].split("|")
command_name_sub_alias = command.name.split("|", 1)[0]
for alias in command_aliases[1:]:
command.aliases.append(alias)
self.aliases_by_commands[command_name_sub_alias].append(alias)

command.name = command_name_sub_alias

groups[group_name].append(command)
else:
group_help_dict = {
group_help_dict |= {
command.name: script_block["help"]
for script_block in command.script
if isinstance(script_block, dict) and script_block.get("help")
}

for group_name, commands in groups.items():
self.groups[group_name] = Group(
name=group_name, commands=commands, help=group_help_dict.get(group_name, "")
name=group_name,
commands=commands,
help=group_help_dict.get(group_name, ""),
)

def generate_cli(self) -> None:
Expand Down
74 changes: 60 additions & 14 deletions cliffy/commanders/typer.py
Original file line number Diff line number Diff line change
@@ -1,50 +1,88 @@
import datetime

from ..commander import Command, Commander, Group
from ..manifests import Manifest


class TyperCommander(Commander):
"""Generates commands based on the command config"""

def __init__(self, manifest: Manifest) -> None:
super().__init__(manifest)
self.base_imports.add("import typer")
self.base_imports.add("import subprocess")
self.base_imports.add("from typing import Optional, Any")

def add_base_imports(self):
self.cli = f"""## Generated {self.manifest.name} on {datetime.datetime.now()}
import typer
import subprocess
from typing import Optional
"""
self.cli = f"""## Generated {self.manifest.name} on {datetime.datetime.now()}\n"""
for imp in self.base_imports:
self.cli += imp + "\n"

def add_base_cli(self) -> None:
self.cli += """
CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help'])
cli = typer.Typer(context_settings=CONTEXT_SETTINGS"""

if self.manifest.cli_options:
self.cli += f",{self.parser.to_args(self.manifest.cli_options)}"
if self.manifest.help:
self.cli += f', help="""{self.manifest.help}"""'

self.cli += f""")
__version__ = '{self.manifest.version}'
__cli_name__ = '{self.manifest.name}'
"""

self.cli += """
def version_callback(value: bool):
if value:
print(f"{{__cli_name__}}, {{__version__}}")
print(f"{__cli_name__}, {__version__}")
raise typer.Exit()
"""

if self.aliases_by_commands:
self.cli += """
def aliases_callback(value: bool):
if value:
print(\"\"\""""
max_command_length = max(len(x) for x in self.aliases_by_commands.keys())
self.cli += f"""
{"Command".ljust(max_command_length + 7)}Aliases
{"--------".ljust(max_command_length + 7)}--------
"""
for command, alias_list in self.aliases_by_commands.items():
self.cli += f"{command.ljust(max_command_length + 7)}"
self.cli += ", ".join(alias_list)
self.cli += "\n"
self.cli += """\"\"\")
raise typer.Exit()
"""
self.cli += """
@cli.callback()
def main(version: Optional[bool] = typer.Option(None, '--version', callback=version_callback, is_eager=True)):
def main("""
if self.aliases_by_commands:
self.cli += """
aliases: Optional[bool] = typer.Option(None, '--aliases', callback=aliases_callback, is_eager=True),"""

self.cli += """
version: Optional[bool] = typer.Option(None, '--version', callback=version_callback, is_eager=True)
):
pass
"""

def add_root_command(self, command: Command) -> None:
parsed_command_func_name = self.parser.get_command_func_name(command)
parsed_command_name = self.parser.get_parsed_command_name(command)
self.cli += f"""
@cli.command("{self.parser.get_parsed_command_name(command)}")
def {self.parser.get_command_func_name(command)}({self.parser.parse_args(command)}):
def {parsed_command_func_name}({self.parser.parse_args(command)}):
{self.parser.parse_command(command.script)}
cli.command("{parsed_command_name}")({parsed_command_func_name})
"""

for alias in command.aliases:
self.cli += f"""
cli.command("{alias}", hidden=True, epilog="Alias for {parsed_command_name}")({parsed_command_func_name})
"""

def add_group(self, group: Group) -> None:
Expand All @@ -53,10 +91,18 @@ def add_group(self, group: Group) -> None:
"""

def add_sub_command(self, command: Command, group: Group) -> None:
parsed_command_func_name = self.parser.get_command_func_name(command)
parsed_command_name = self.parser.get_parsed_command_name(command)
self.cli += f"""
@{group.name}_app.command("{self.parser.get_parsed_command_name(command)}")
def {self.parser.get_command_func_name(command)}({self.parser.parse_args(command)}):
def {parsed_command_func_name}({self.parser.parse_args(command)}):
{self.parser.parse_command(command.script)}
{group.name}_app.command("{parsed_command_name}")({parsed_command_func_name})
"""

for alias in command.aliases:
self.cli += f"""
{group.name}_app.command("{alias}", hidden=True, epilog="Alias for {parsed_command_name}")({parsed_command_func_name})
"""

def add_main_block(self) -> None:
Expand Down
Loading

0 comments on commit 7c078e5

Please sign in to comment.