Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Command aliases #30

Merged
merged 12 commits into from
Aug 7, 2024
Merged
21 changes: 14 additions & 7 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 Down Expand Up @@ -27,12 +26,14 @@
from .reloader import CLIManifestReloader

CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
ALIASES = {"ls": "list", "add": "load", "reload": "update", "rm": "remove", "rm-all": "remove-all"}


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
if cmd_name in ALIASES:
return super().get_command(ctx, ALIASES[cmd_name])

return super().get_command(ctx, cmd_name or "")


Expand All @@ -51,9 +52,18 @@ def convert( # type: ignore[override]
return value


def aliases_callback(ctx: Any, param: Any, val: bool):
jaykv marked this conversation as resolved.
Show resolved Hide resolved
if val:
out("")
for alias, command in ALIASES.items():
out(f"{command}: {alias}")
ctx.exit()


@click.group(context_settings=CONTEXT_SETTINGS, cls=AliasedGroup) # type: ignore[arg-type]
@click.version_option()
def cli() -> None:
@click.option("--aliases", type=bool, is_flag=True, is_eager=True, callback=aliases_callback)
def cli(aliases: bool) -> None:
pass


Expand Down Expand Up @@ -240,6 +250,3 @@ def dev(manifest: str, run_cli: bool, run_cli_args: tuple[str]) -> None:
"""
out(f"Watching {manifest} for changes\n", fg="magenta")
CLIManifestReloader.watch(manifest, run_cli, run_cli_args)


ALIASES = {"add": load, "rm": remove, "rm-all": remove_all, "ls": cliffy_list, "reload": update}
56 changes: 52 additions & 4 deletions cliffy/commander.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations

from collections import defaultdict
from typing import DefaultDict
from typing import DefaultDict, Optional

from pybash.transformer import transform as transform_bash
from pydantic import BaseModel
Expand Down Expand Up @@ -42,12 +42,24 @@ class Group(BaseModel):
name: str
commands: list[Command]
help: str = ""
command_aliases: Optional[dict[str, str]]


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,32 @@ 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.command_aliases: dict[str, str] = {}
self.base_imports: set[str] = {"import typer", "import subprocess", "from typing import Optional, Any"}
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:
self.base_imports.add("from typer.core import TyperGroup")
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:]:
self.command_aliases[alias] = aliases[0]
self.aliases_by_commands[command.name].append(alias)

def build_groups(self) -> None:
jaykv marked this conversation as resolved.
Show resolved Hide resolved
groups: DefaultDict[str, list[Command]] = defaultdict(list)
group_help_dict: dict[str, str] = {}
group_command_aliases: DefaultDict[str, dict[str, str]] = defaultdict(dict)

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

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

if "|" in command.name:
self.base_imports.add("from typer.core import TyperGroup")
command_aliases = command.name.rsplit(".", 1)[1].split("|")
root_alias = command_aliases[0]
command_name_sub_alias = command.name.split("|", 1)[0]
for alias in command_aliases[1:]:
group_command_aliases[group_name][alias] = root_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, ""),
command_aliases=group_command_aliases.get(group_name),
)

def generate_cli(self) -> None:
Expand Down
68 changes: 58 additions & 10 deletions cliffy/commanders/typer.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,21 @@ class TyperCommander(Commander):
"""Generates commands based on the command config"""

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:
if self.command_aliases:
self.cli += f"""BASE_ALIASES = {str(self.command_aliases)}
class BaseAliasGroup(TyperGroup):
def get_command(self, ctx: Any, cmd_name: str) -> Optional[Any]:
if cmd_name in BASE_ALIASES:
return self.commands.get(BASE_ALIASES[cmd_name])

return super().get_command(ctx, cmd_name)
"""

self.cli += """
CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help'])
cli = typer.Typer(context_settings=CONTEXT_SETTINGS"""
Expand All @@ -22,20 +30,44 @@ def add_base_cli(self) -> None:
self.cli += f",{self.parser.to_args(self.manifest.cli_options)}"
if self.manifest.help:
self.cli += f', help="""{self.manifest.help}"""'

if self.command_aliases:
self.cli += ", cls=BaseAliasGroup"
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(\"\"\"
"""
for command, alias_list in self.aliases_by_commands.items():
self.cli += f"{command}: "
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

"""
Expand All @@ -48,7 +80,23 @@ def {self.parser.get_command_func_name(command)}({self.parser.parse_args(command
"""

def add_group(self, group: Group) -> None:
self.cli += f"""{group.name}_app = typer.Typer()
if group.command_aliases:
self.cli += f"""
{group.name.upper()}_ALIASES = {str(group.command_aliases)}
class {group.name.capitalize()}AliasGroup(TyperGroup):
def get_command(self, ctx: Any, cmd_name: str) -> Optional[Any]:
if cmd_name in {group.name.upper()}_ALIASES:
return self.commands.get({group.name.upper()}_ALIASES[cmd_name])

return super().get_command(ctx, cmd_name)

"""
self.cli += f"{group.name}_app = typer.Typer("

if group.command_aliases:
self.cli += f"cls={group.name.capitalize()}AliasGroup"

self.cli += f""")
cli.add_typer({group.name}_app, name="{group.name}", help="{group.help}")
"""

Expand Down
7 changes: 4 additions & 3 deletions cliffy/manifests/v1.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ class CLIManifest(BaseModel):
description="A mapping containing the command definitions for the CLI. "
"Each command should have a unique key- which can be either a group command or nested subcommands. "
"Nested subcommands are joined by '.' in between each level. "
"Aliases for commands can be separated in the key by '|'. "
"A special (*) wildcard can be used to spread the subcommand to all group-level commands. "
"The value is the python code to run when the command is called "
"OR a list of bash commands to run (prefixed with $).",
Expand Down Expand Up @@ -110,7 +111,7 @@ def get_template(cls, name: str) -> str:
{cls.get_field_description('vars')}
vars:
default_mood: happy
debug_mode: "{{ env['DEBUG'] or 'False' }}"
debug_mode: "{{{{ env['DEBUG'] or 'False' }}}}"

{cls.get_field_description('imports')}
imports:
Expand Down Expand Up @@ -139,8 +140,8 @@ def greet_name(name: str):

{cls.get_field_description('commands')}
commands:
# this is a parent command that will get invoked with: hello world
world:
# this is a parent command that will get invoked with: hello world or hello wld
world|wld:
- |
\"\"\"
Help text for list
Expand Down
4 changes: 2 additions & 2 deletions cliffy/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,8 +130,8 @@ def parse_args(self, command) -> str:
return parsed_command_args[:-2]

def get_command_func_name(self, command) -> str:
"""a -> a, a.b -> a_b, a-b -> a_b"""
return command.name.replace(".", "_").replace("-", "_")
"""a -> a, a.b -> a_b, a-b -> a_b, a|b -> a_b"""
return command.name.replace(".", "_").replace("-", "_").replace("|", "_")

def get_parsed_command_name(self, command) -> str:
"""a -> a, a.b -> b"""
Expand Down
8 changes: 4 additions & 4 deletions examples/db.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,20 @@ imports: |
console = Console()

commands:
create: |
create|mk: |
"""Create a new database"""
console.print(f"Creating database {name}", style="green")
delete: |
delete|rm: |
"""Delete a database"""
sure = typer.confirm("Are you really really really sure?")
if sure:
console.print(f"Deleting database {name}", style="red")
else:
console.print(f"Back to safety!", style="green")
list: |
list|ls: |
"""List databases"""
print("Listing all databases")
view: |
view|v: |
"""View database table"""
console.print(f"Viewing {table} table for {name} DB")

Expand Down
28 changes: 24 additions & 4 deletions examples/generated/db.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
## Generated db on 2024-08-02 14:08:54.018361
## Generated db on 2024-08-06 21:09:20.229514
from typing import Optional, Any
import typer
from typer.core import TyperGroup
import subprocess
from typing import Optional
from rich.console import Console
console = Console()


BASE_ALIASES = {'mk': 'create', 'rm': 'delete', 'ls': 'list', 'v': 'view'}
class BaseAliasGroup(TyperGroup):
def get_command(self, ctx: Any, cmd_name: str) -> Optional[Any]:
jaykv marked this conversation as resolved.
Show resolved Hide resolved
if cmd_name in BASE_ALIASES:
return self.commands.get(BASE_ALIASES[cmd_name])

return super().get_command(ctx, cmd_name)

CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help'])
cli = typer.Typer(context_settings=CONTEXT_SETTINGS, add_completion=False, help="""Database CLI""")
cli = typer.Typer(context_settings=CONTEXT_SETTINGS, add_completion=False, help="""Database CLI""", cls=BaseAliasGroup)
__version__ = '0.1.0'
__cli_name__ = 'db'

Expand All @@ -18,9 +26,21 @@ def version_callback(value: bool):
print(f"{__cli_name__}, {__version__}")
raise typer.Exit()

def aliases_callback(value: bool):
if value:
print("""
create: mk
delete: rm
list: ls
view: v
""")
raise typer.Exit()

@cli.callback()
def main(version: Optional[bool] = typer.Option(None, '--version', callback=version_callback, is_eager=True)):
def main(
aliases: Optional[bool] = typer.Option(None, '--aliases', callback=aliases_callback, is_eager=True),
version: Optional[bool] = typer.Option(None, '--version', callback=version_callback, is_eager=True)
):
pass


Expand Down
9 changes: 5 additions & 4 deletions examples/generated/environ.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
## Generated environ on 2024-08-02 14:08:54.024887
## Generated environ on 2024-08-06 21:09:20.234970
from typing import Optional, Any
import typer
import subprocess
from typing import Optional
import os

default_env_var = 'hello'
Expand All @@ -18,9 +18,10 @@ def version_callback(value: bool):
print(f"{__cli_name__}, {__version__}")
raise typer.Exit()


@cli.callback()
def main(version: Optional[bool] = typer.Option(None, '--version', callback=version_callback, is_eager=True)):
def main(
version: Optional[bool] = typer.Option(None, '--version', callback=version_callback, is_eager=True)
):
pass


Expand Down
Loading
Loading