From 3d46ff43805182188c5a298ad8437a1fb6b6e79d Mon Sep 17 00:00:00 2001 From: checktheroads Date: Fri, 14 Feb 2020 16:28:45 -0700 Subject: [PATCH] improve cli formatting; add setup wizard --- hyperglass/cli/__init__.py | 8 +- hyperglass/cli/commands.py | 120 ++++++++++++-------------- hyperglass/cli/echo.py | 160 +++++++++++++++++++++++------------ hyperglass/cli/exceptions.py | 15 ++++ hyperglass/cli/static.py | 61 ++++++++++++- 5 files changed, 240 insertions(+), 124 deletions(-) create mode 100644 hyperglass/cli/exceptions.py diff --git a/hyperglass/cli/__init__.py b/hyperglass/cli/__init__.py index 58a8a84..86ab7f8 100644 --- a/hyperglass/cli/__init__.py +++ b/hyperglass/cli/__init__.py @@ -1,10 +1,6 @@ """hyperglass cli module.""" -# Third Party -import stackprinter # Project -from hyperglass.cli import commands +from hyperglass.cli.commands import hg -stackprinter.set_excepthook(style="darkbg2") - -CLI = commands.hg +CLI = hg diff --git a/hyperglass/cli/commands.py b/hyperglass/cli/commands.py index 8bd7f68..ec579a5 100644 --- a/hyperglass/cli/commands.py +++ b/hyperglass/cli/commands.py @@ -1,21 +1,16 @@ """CLI Command definitions.""" # Standard Library +import os from pathlib import Path # Third Party -import click +import inquirer +from click import group, option, confirm # Project -from hyperglass.cli.echo import error, value, cmd_help -from hyperglass.cli.util import ( - build_ui, - fix_ownership, - migrate_config, - fix_permissions, - migrate_systemd, - start_web_server, -) +from hyperglass.cli.echo import error, label, cmd_help +from hyperglass.cli.util import build_ui, start_web_server from hyperglass.cli.static import LABEL, CLI_HELP, E from hyperglass.cli.formatting import HelpColorsGroup, HelpColorsCommand, random_colors @@ -23,13 +18,11 @@ from hyperglass.cli.formatting import HelpColorsGroup, HelpColorsCommand, random WORKING_DIR = Path(__file__).parent -@click.group( +@group( cls=HelpColorsGroup, help=CLI_HELP, help_headers_color=LABEL, - help_options_custom_colors=random_colors( - "build-ui", "start", "migrate-examples", "systemd", "permissions", "secret" - ), + help_options_custom_colors=random_colors("build-ui", "start", "secret", "setup"), ) def hg(): """Initialize Click Command Group.""" @@ -52,15 +45,13 @@ def build_frontend(): cls=HelpColorsCommand, help_options_custom_colors=random_colors("-b"), ) -@click.option( - "-b", "--build", is_flag=True, help="Render theme & build frontend assets" -) +@option("-b", "--build", is_flag=True, help="Render theme & build frontend assets") def start(build): """Start web server and optionally build frontend assets.""" try: from hyperglass.api import start, ASGI_PARAMS except ImportError as e: - error("Error importing hyperglass", e) + error("Error importing hyperglass: {e}", e=e) if build: build_complete = build_ui() @@ -72,57 +63,13 @@ def start(build): start_web_server(start, ASGI_PARAMS) -@hg.command( - "migrate-examples", - short_help=cmd_help(E.PAPERCLIP, "Copy example configs to production config files"), - help=cmd_help(E.PAPERCLIP, "Copy example configs to production config files"), - cls=HelpColorsCommand, - help_options_custom_colors=random_colors(), -) -@click.option("-d", "--directory", required=True, help="Target directory") -def migrateconfig(directory): - """Copy example configuration files to usable config files.""" - migrate_config(Path(directory)) - - -@hg.command( - "systemd", - help=cmd_help(E.CLAMP, " Copy systemd example to file system"), - cls=HelpColorsCommand, - help_options_custom_colors=random_colors("-d"), -) -@click.option( - "-d", - "--directory", - default="/etc/systemd/system", - help="Destination Directory [default: 'etc/systemd/system']", -) -def migratesystemd(directory): - """Copy example systemd service file to /etc/systemd/system/.""" - migrate_systemd(WORKING_DIR / "hyperglass/hyperglass.service.example", directory) - - -@hg.command( - "permissions", - help=cmd_help(E.KEY, "Fix ownership & permissions of 'hyperglass/'"), - cls=HelpColorsCommand, - help_options_custom_colors=random_colors("--user", "--group"), -) -@click.option("--user", default="www-data") -@click.option("--group", default="www-data") -def permissions(user, group): - """Run `chmod` and `chown` on the hyperglass/hyperglass directory.""" - fix_permissions(user, group, WORKING_DIR) - fix_ownership(WORKING_DIR) - - @hg.command( "secret", help=cmd_help(E.LOCK, "Generate agent secret"), cls=HelpColorsCommand, help_options_custom_colors=random_colors("-l"), ) -@click.option( +@option( "-l", "--length", "length", default=32, help="Number of characters [default: 32]" ) def generate_secret(length): @@ -134,4 +81,49 @@ def generate_secret(length): import secrets gen_secret = secrets.token_urlsafe(length) - value("Secret", gen_secret) + label("Secret: {s}", s=gen_secret) + + +@hg.command( + "setup", help=cmd_help(E.TOOLBOX, "Run the setup wizard"), cls=HelpColorsCommand +) +def setup(): + """Define application directory, move example files, generate systemd service.""" + from hyperglass.cli.util import create_dir, move_files, make_systemd, write_to_file + + user_path = Path.home() / "hyperglass" + root_path = Path("/etc/hyperglass/") + + install_paths = [ + inquirer.List( + "install_path", + message="Choose a directory for hyperglass", + choices=[user_path, root_path], + ) + ] + answer = inquirer.prompt(install_paths) + install_path = answer["install_path"] + ui_dir = install_path / "static" / "ui" + custom_dir = install_path / "static" / "custom" + + create_dir(install_path) + create_dir(ui_dir, parents=True) + create_dir(custom_dir, parents=True) + + example_dir = WORKING_DIR.parent / "examples" + files = example_dir.iterdir() + + if confirm( + "Do you want to install example configuration files? (This is non-destructive)" + ): + move_files(example_dir, install_path, files) + + if install_path == user_path: + user = os.getlogin() + else: + user = "root" + + if confirm("Do you want to generate a systemd service file?"): + systemd_file = install_path / "hyperglass.service" + systemd = make_systemd(user) + write_to_file(systemd_file, systemd) diff --git a/hyperglass/cli/echo.py b/hyperglass/cli/echo.py index de9b13a..8c5c4ff 100644 --- a/hyperglass/cli/echo.py +++ b/hyperglass/cli/echo.py @@ -1,73 +1,127 @@ """Helper functions for CLI message printing.""" +# Standard Library +import re + # Third Party -import click +from click import echo, style # Project -from hyperglass.cli.static import ( - CL, - NL, - WS, - INFO, - ERROR, - LABEL, - VALUE, - STATUS, - SUCCESS, - CMD_HELP, - E, -) +from hyperglass.cli.static import CMD_HELP, Message +from hyperglass.cli.exceptions import CliError def cmd_help(emoji="", help_text=""): """Print formatted command help.""" - return emoji + click.style(help_text, **CMD_HELP) + return emoji + style(help_text, **CMD_HELP) -def success(msg): - """Print formatted success messages.""" - click.echo(E.CHECK + click.style(str(msg), **SUCCESS)) +def _base_formatter(state, text, callback, **kwargs): + """Format text block, replace template strings with keyword arguments. + + Arguments: + state {dict} -- Text format attributes + label {dict} -- Keyword format attributes + text {[type]} -- Text to format + callback {function} -- Callback function + + Returns: + {str|ClickException} -- Formatted output + """ + fmt = Message(state) + + if callback is None: + callback = style + + for k, v in kwargs.items(): + if not isinstance(v, str): + v = str(v) + kwargs[k] = style(v, **fmt.kw) + + text_all = re.split(r"(\{\w+\})", text) + text_all = [style(i, **fmt.msg) for i in text_all] + text_all = [i.format(**kwargs) for i in text_all] + + if fmt.emoji: + text_all.insert(0, fmt.emoji) + + text_fmt = "".join(text_all) + + return callback(text_fmt) -def success_info(label, msg): - """Print formatted labeled success messages.""" - click.echo( - E.CHECK - + click.style(str(label), **SUCCESS) - + CL[1] - + WS[1] - + click.style(str(msg), **INFO) - ) +def info(text, callback=echo, **kwargs): + """Generate formatted informational text. + + Arguments: + text {str} -- Text to format + callback {callable} -- Callback function (default: {echo}) + + Returns: + {str} -- Informational output + """ + return _base_formatter(state="info", text=text, callback=callback, **kwargs) -def info(msg): - """Print formatted informational messages.""" - click.echo(E.INFO + click.style(str(msg), **INFO)) +def error(text, callback=CliError, **kwargs): + """Generate formatted exception. + + Arguments: + text {str} -- Text to format + callback {callable} -- Callback function (default: {echo}) + + Raises: + ClickException: Raised after formatting + """ + raise _base_formatter(state="error", text=text, callback=callback, **kwargs) -def status(msg): - """Print formatted status messages.""" - click.echo(click.style(str(msg), **STATUS)) +def success(text, callback=echo, **kwargs): + """Generate formatted success text. + + Arguments: + text {str} -- Text to format + callback {callable} -- Callback function (default: {echo}) + + Returns: + {str} -- Success output + """ + return _base_formatter(state="success", text=text, callback=callback, **kwargs) -def error(msg, exc): - """Raise click exception with formatted output.""" - raise click.ClickException( - NL - + E.ERROR - + click.style(str(msg), **LABEL) - + CL[1] - + WS[1] - + click.style(str(exc), **ERROR) - ) from None +def warning(text, callback=echo, **kwargs): + """Generate formatted warning text. + + Arguments: + text {str} -- Text to format + callback {callable} -- Callback function (default: {echo}) + + Returns: + {str} -- Warning output + """ + return _base_formatter(state="warning", text=text, callback=callback, **kwargs) -def value(label, msg): - """Print formatted label: value.""" - click.echo( - NL[1] - + click.style(str(label), **LABEL) - + CL[1] - + WS[1] - + click.style(str(msg), **VALUE) - + NL[1] - ) +def label(text, callback=echo, **kwargs): + """Generate formatted info text with accented labels. + + Arguments: + text {str} -- Text to format + callback {callable} -- Callback function (default: {echo}) + + Returns: + {str} -- Label output + """ + return _base_formatter(state="label", text=text, callback=callback, **kwargs) + + +def status(text, callback=echo, **kwargs): + """Generate formatted status text. + + Arguments: + text {str} -- Text to format + callback {callable} -- Callback function (default: {echo}) + + Returns: + {str} -- Status output + """ + return _base_formatter(state="status", text=text, callback=callback, **kwargs) diff --git a/hyperglass/cli/exceptions.py b/hyperglass/cli/exceptions.py new file mode 100644 index 0000000..3b44806 --- /dev/null +++ b/hyperglass/cli/exceptions.py @@ -0,0 +1,15 @@ +"""hyperglass CLI custom exceptions.""" + +# Third Party +from click import ClickException, echo +from click._compat import get_text_stderr + + +class CliError(ClickException): + """Custom exception to exclude the 'Error:' prefix from echos.""" + + def show(self, file=None): + """Exclude 'Error:' prefix from raised exceptions.""" + if file is None: + file = get_text_stderr() + echo(self.format_message()) diff --git a/hyperglass/cli/static.py b/hyperglass/cli/static.py index 3e1f1ec..406dd7d 100644 --- a/hyperglass/cli/static.py +++ b/hyperglass/cli/static.py @@ -34,6 +34,8 @@ class Emoji: CHECK = "\U00002705 " INFO = "\U00002755 " ERROR = "\U0000274C " + WARNING = "\U000026A0\U0000FE0F " + TOOLBOX = "\U0001F9F0 " ROCKET = "\U0001F680 " SPARKLES = "\U00002728 " PAPERCLIP = "\U0001F4CE " @@ -51,14 +53,71 @@ E = Emoji() CLI_HELP = ( click.style("hyperglass", fg="magenta", bold=True) + WS[1] - + click.style("CLI Management Tool", fg="white") + + click.style("Command Line Interface", fg="white") ) # Click Style Helpers SUCCESS = {"fg": "green", "bold": True} +WARNING = {"fg": "yellow"} ERROR = {"fg": "red", "bold": True} LABEL = {"fg": "white"} INFO = {"fg": "blue", "bold": True} STATUS = {"fg": "black"} VALUE = {"fg": "magenta", "bold": True} CMD_HELP = {"fg": "white"} + + +class Message: + """Helper class for single-character strings.""" + + colors = { + "warning": "yellow", + "success": "green", + "error": "red", + "info": "blue", + "status": "black", + "label": "white", + } + label_colors = { + "warning": "yellow", + "success": "green", + "error": "red", + "info": "blue", + "status": "black", + "label": "magenta", + } + emojis = { + "warning": E.WARNING, + "success": E.CHECK, + "error": E.ERROR, + "info": E.INFO, + "status": "", + "label": "", + } + + def __init__(self, state): + """Set instance character.""" + self.state = state + self.color = self.colors[self.state] + self.label_color = self.label_colors[self.state] + + @property + def msg(self): + """Click style attributes for message text.""" + return {"fg": self.color} + + @property + def kw(self): + """Click style attributes for keywords.""" + return {"fg": self.label_color, "bold": True, "underline": True} + + @property + def emoji(self): + """Match emoji from state.""" + return self.emojis[self.state] + + def __repr__(self): + """Stringify the instance character for representation.""" + return "Message(msg={m}, kw={k}, emoji={e})".format( + m=self.msg, k=self.kw, e=self.emoji + )