Skip to content

Commit

Permalink
New dev and rm-all command (#28)
Browse files Browse the repository at this point in the history
* new dev and rm-all command

* add response tests

* update readme
  • Loading branch information
jaykv authored Aug 2, 2024
1 parent 6853d13 commit d97a129
Show file tree
Hide file tree
Showing 18 changed files with 531 additions and 338 deletions.
5 changes: 3 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,12 @@ shell:
source .venv/bin/activate

generate-all:
pip install requests six rich
pip install requests "six<1.0.0" rich
cli load examples/*.yaml
cp cliffy/clis/*.py examples/generated/

generate-cleanup:
pip uninstall -y requests six rich
cli rm db environ hello pydev requires template town penv
cli rm-all

.PHONY: test clean
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@
![GitHub](https://img.shields.io/github/license/jaykv/cliffy)

# cliffy :mountain:
cliffy is a YAML-defined CLI generator, manager, and builder for python. It offers dynamic abstractions to rapidly build, test, and deploy CLIs.
cliffy is a YAML-defined CLI generator, manager, and builder for Python. It offers features to rapidly build, test, and deploy CLIs.

## Features
* Rapidly build CLIs with YAML-defined manifests
* Write CLIs with YAML manifests
* Manage CLIs- load, list, update, and remove
* Built-in shell and Python scripting support
* Supports Jinja2 templating
* Build CLIs into self-contained, portable zipapps
* Hot-reload CLIs on manifest changes for easier development
* Build CLIs into self-contained, single-file portable zipapps for sharing

### Load

Expand Down Expand Up @@ -87,6 +88,7 @@ Builds a portable zipapp containing the CLI and its package requirements.
* `run <manifest> -- <args>`: Runs a CLI manifest as a one-time operation
* `build <cli name or manifest>`: Build a CLI manifest or a loaded CLI into a self-contained zipapp
* `info <cli name>`: Display CLI metadata
* `dev <manifest>`: Start hot-reloader for a manifest for active development

## How it works
1. Define CLI manifests in YAML files
Expand Down
37 changes: 36 additions & 1 deletion cliffy/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from .loader import Loader
from .manifests import Manifest, set_manifest_version
from .transformer import Transformer
from .reloader import CLIManifestReloader

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

Expand Down Expand Up @@ -156,6 +157,15 @@ 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():
remove_metadata(metadata.cli_name)
Loader.unload_cli(metadata.cli_name)
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")
Expand Down Expand Up @@ -207,4 +217,29 @@ def info(cli_name: str):
out(f"{click.style('manifest:', fg='blue')}\n{indent_block(metadata.manifest, spaces=2)}")


ALIASES = {"add": load, "rm": remove, "ls": cliffy_list, "reload": update}
@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",
type=str,
default=False,
help="If passed, runs CLI on each reload. Useful for syntax checks or testing command execution on each reload",
is_flag=True,
)
@click.argument("run-cli-args", type=str, nargs=-1)
def dev(manifest: str, run_cli: bool, run_cli_args: tuple[str]) -> None:
"""Start hot-reloader for a manifest for active development.
Examples:
- cli dev examples/hello.yaml
- cli dev examples/hello.yaml --run-cli -- -h
- cli dev examples/hello.yaml --run-cli hello
"""
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}
20 changes: 11 additions & 9 deletions cliffy/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,16 @@ def is_param_required(self, param_type: str) -> bool:
)

def is_param_option(self, param_name: str) -> bool:
return "-" in param_name
return param_name.startswith("-")

def get_default_param_val(self, param_type: str) -> str:
return param_type.split("=")[1].strip() if "=" in param_type else ""

def capture_param_aliases(self, param_name: str) -> Tuple[str, list[str]]:
if "|" not in param_name:
return param_name.replace("-", ""), []
return param_name.lstrip("-"), []

base_param_name = param_name.split("|")[0].replace("-", "").strip()
base_param_name = param_name.split("|")[0].lstrip("-").strip()
aliases = param_name.split("|")[1:]

return base_param_name, aliases
Expand Down Expand Up @@ -65,7 +65,6 @@ def build_param_type(
is_required: bool = False,
) -> str:
parsed_arg_type = f"{arg_name}: {arg_type} = typer.{typer_cls}"

if not default_val:
# Required param needs ...
parsed_arg_type += "(..." if is_required else "(None"
Expand All @@ -90,14 +89,17 @@ def parse_arg(self, arg_name: str, arg_type: str) -> str:
if "=" in arg_type:
arg_type = arg_type.split("=")[0].strip()

# strip - before parsing it
# lstrip - before parsing it
if self.is_param_option(arg_name):
arg_name, arg_aliases = self.capture_param_aliases(arg_name)

# strip ! before parsing it
# rstrip ! before parsing it
if is_required:
arg_type = arg_type[:-1]

# replace - with _ for arg name
arg_name = arg_name.replace("-", "_")

# check for a type def that matches arg_type
if arg_type in self.manifest.types:
return f"{arg_name}: {self.manifest.types[arg_type]},"
Expand Down Expand Up @@ -128,11 +130,11 @@ def parse_args(self, command) -> str:
return parsed_command_args[:-2]

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

def get_parsed_command_name(self, command) -> str:
"""a.b -> b or a -> a"""
"""a -> a, a.b -> b"""
return command.name.split(".")[-1] if "." in command.name else command.name

def to_args(self, d: dict) -> str:
Expand Down
64 changes: 64 additions & 0 deletions cliffy/reloader.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
from .transformer import Transformer
from .loader import Loader
from .homer import save_metadata
from .helper import out
from .builder import run_cli as cli_runner

import time
from datetime import datetime, timedelta
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler, FileModifiedEvent
from pathlib import Path
import threading


class CLIManifestReloader(FileSystemEventHandler):
def __init__(self, manifest_path: str, run_cli: bool, run_cli_args: tuple[str]) -> None:
self.manifest_path = manifest_path
self.run_cli = run_cli
self.run_cli_args = run_cli_args
self.last_modified = datetime.now()

super().__init__()

def on_modified(self, event):
if event.is_directory or not event.src_path.endswith(self.manifest_path):
return

if not isinstance(event, FileModifiedEvent):
return

if datetime.now() - self.last_modified < timedelta(seconds=1):
return

self.last_modified = datetime.now()

t = threading.Thread(target=self.reload, args=(self.manifest_path, self.run_cli, self.run_cli_args))
t.daemon = True
t.start()

@classmethod
def watch(cls, manifest_path: str, run_cli: bool, run_cli_args: tuple[str]):
event_handler = cls(manifest_path, run_cli, run_cli_args)
observer = Observer()
observer.schedule(event_handler, path=Path(manifest_path).parent, recursive=False)
observer.start()

try:
while True:
time.sleep(1)
except KeyboardInterrupt:
observer.stop()
observer.join()

@staticmethod
def reload(manifest_path: str, run_cli: bool, run_cli_args: tuple[str]):
manifest_io = open(manifest_path, "r")

T = Transformer(manifest_io)
Loader.load_from_cli(T.cli)
save_metadata(manifest_io.name, T.cli)
out(f"✨ Reloaded {T.cli.name} CLI v{T.cli.version} ✨", fg="green")

if run_cli:
cli_runner(T.cli.name, T.cli.code, run_cli_args)
13 changes: 10 additions & 3 deletions examples/environ.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,15 @@ imports:

vars:
# var setup on load time
env_var: "{{ env['ENVIRON_LOAD_VAR'] or 'hello' }}"
default_env_var: "{{ env['ENVIRON_LOAD_VAR'] or 'hello' }}"

args:
read:
- env-var: str!

commands:
hello: $ echo {{ env_var }}
bye: $ echo f{os.environ['ENVIRON_BYE_TEXT']} # on run time
read: $ echo f{os.environ[env_var]}
hello: $ echo {{ default_env_var }}
bye: $ echo f{os.environ['ENVIRON_BYE_TEXT']} # on run time
hello-bye: |
$ echo {{ default_env_var }} bye
2 changes: 1 addition & 1 deletion examples/generated/db.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
## Generated db on 2023-06-08 22:05:51.135912
## Generated db on 2024-08-02 14:08:54.018361
import typer
import subprocess
from typing import Optional
Expand Down
14 changes: 12 additions & 2 deletions examples/generated/environ.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
## Generated environ on 2023-06-08 22:05:51.144432
## Generated environ on 2024-08-02 14:08:54.024887
import typer
import subprocess
from typing import Optional
import os

env_var = 'hello'
default_env_var = 'hello'


CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help'])
Expand All @@ -24,6 +24,11 @@ def main(version: Optional[bool] = typer.Option(None, '--version', callback=vers
pass


@cli.command("read")
def read(env_var: str = typer.Argument(...)):
subprocess.run(["echo",f"""{os.environ[env_var]}"""])


@cli.command("hello")
def hello():
subprocess.run(["echo","hello"])
Expand All @@ -34,5 +39,10 @@ def bye():
subprocess.run(["echo",f"""{os.environ['ENVIRON_BYE_TEXT']}"""])


@cli.command("hello-bye")
def hello_bye():
subprocess.run(["echo","hello","bye"])


if __name__ == "__main__":
cli()
2 changes: 1 addition & 1 deletion examples/generated/hello.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
## Generated hello on 2023-06-08 22:05:51.146878
## Generated hello on 2024-08-02 14:08:54.027610
import typer
import subprocess
from typing import Optional
Expand Down
71 changes: 71 additions & 0 deletions examples/generated/penv.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
## Generated penv on 2024-08-02 14:08:54.032251
import typer
import subprocess
from typing import Optional
import os
from pathlib import Path
from shutil import rmtree


HOME_PATH = str(Path.home())
DEFAULT_VENV_STORE = f"{HOME_PATH}/venv-store/"


CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help'])
cli = typer.Typer(context_settings=CONTEXT_SETTINGS, help="""~ Virtualenv store ~
Simplify virtualenvs
""")
__version__ = '0.1.0'
__cli_name__ = 'penv'


def version_callback(value: bool):
if value:
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)):
pass

def get_venv_path(name: str) -> str:
return f"{DEFAULT_VENV_STORE}{name}"

def venv_exists(name: str) -> bool:
return os.path.exists(get_venv_path(name))



@cli.command("ls")
def ls():
"""
List venvs in the store
"""
subprocess.run(["ls",f"""{DEFAULT_VENV_STORE}"""])


@cli.command("rm")
def rm(name: str = typer.Argument(...)):
"""
Remove a venv
"""
rmtree(get_venv_path(name))


@cli.command("go")
def go(name: str = typer.Argument(...), interpreter: str = typer.Option("python", "--interpreter", "-i")):
"""
Activate a venv
"""
if venv_exists(name):
print(f"~ sourcing {name}")
else:
print(f"~ creating {name}")
subprocess.run([f"""{interpreter}""","-m","venv",f"""{os.path.join(DEFAULT_VENV_STORE, name)}"""])

os.system(f"""bash -c ". {get_venv_path(name)}/bin/activate; env PS1='\[\e[38;5;211m\]({name})\[\e[\033[00m\] \w $ ' bash --norc\"""")


if __name__ == "__main__":
cli()
2 changes: 1 addition & 1 deletion examples/generated/pydev.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
## Generated pydev on 2023-06-08 22:05:51.153712
## Generated pydev on 2024-08-02 14:08:54.038822
import typer
import subprocess
from typing import Optional
Expand Down
2 changes: 1 addition & 1 deletion examples/generated/requires.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
## Generated requires on 2023-06-08 22:05:51.376293
## Generated requires on 2024-08-02 14:08:54.271747
import typer
import subprocess
from typing import Optional
Expand Down
2 changes: 1 addition & 1 deletion examples/generated/template.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
## Generated template on 2023-06-08 22:05:51.380201
## Generated template on 2024-08-02 14:08:54.276078
import typer
import subprocess
from typing import Optional
Expand Down
Loading

0 comments on commit d97a129

Please sign in to comment.