diff --git a/.flake8 b/.flake8 index 3f63603..5939e8f 100644 --- a/.flake8 +++ b/.flake8 @@ -19,9 +19,12 @@ per-file-ignores= hyperglass/models/*/__init__.py:F401 # Disable assertion and docstring checks on tests. hyperglass/**/test_*.py:S101,D103 - hyperglass/api/*.py:B008 hyperglass/state/hooks.py:F811 -ignore=W503,C0330,R504,D202,S403,S301,S404,E731,D402,IF100 + # Ignore whitespace in docstrings + hyperglass/cli/static.py:W293 + # Ignore docstring standards + hyperglass/cli/main.py:D400,D403 +ignore=W503,C0330,R504,D202,S403,S301,S404,E731,D402,IF100,B008 select=B, BLK, C, D, E, F, I, II, N, P, PIE, S, R, W disable-noqa=False hang-closing=False diff --git a/hyperglass/cli/__init__.py b/hyperglass/cli/__init__.py index 86ab7f8..be23627 100644 --- a/hyperglass/cli/__init__.py +++ b/hyperglass/cli/__init__.py @@ -1,6 +1,6 @@ """hyperglass cli module.""" -# Project -from hyperglass.cli.commands import hg +# Local +from .main import cli, run -CLI = hg +__all__ = ("cli", "run") diff --git a/hyperglass/cli/commands.py b/hyperglass/cli/commands.py deleted file mode 100644 index 89a349d..0000000 --- a/hyperglass/cli/commands.py +++ /dev/null @@ -1,200 +0,0 @@ -"""CLI Command definitions.""" - -# Standard Library -import sys -from pathlib import Path - -# Third Party -from click import group, option, help_option - -# Project -from hyperglass.util import cpu_count - -# Local -from .echo import error, label, success, warning, cmd_help -from .util import build_ui -from .static import LABEL, CLI_HELP, E -from .installer import Installer -from .formatting import HelpColorsGroup, HelpColorsCommand, random_colors - -# Define working directory -WORKING_DIR = Path(__file__).parent - -supports_color = "utf" in sys.getfilesystemencoding().lower() - - -def _print_version(ctx, param, value): - # Project - from hyperglass import __version__ - - if not value or ctx.resilient_parsing: - return - label("hyperglass version: {v}", v=__version__) - ctx.exit() - - -@group( - cls=HelpColorsGroup, - help=CLI_HELP, - context_settings={"help_option_names": ["-h", "--help"], "color": supports_color}, - help_headers_color=LABEL, - help_options_custom_colors=random_colors( - "build-ui", "start", "secret", "setup", "system-info", "clear-cache" - ), -) -@option( - "-v", - "--version", - is_flag=True, - callback=_print_version, - expose_value=False, - is_eager=True, - help=cmd_help(E.NUMBERS, "hyperglass version", supports_color), -) -@help_option( - "-h", "--help", help=cmd_help(E.FOLDED_HANDS, "Show this help message", supports_color), -) -def hg(): - """Initialize Click Command Group.""" - pass - - -@hg.command("build-ui", help=cmd_help(E.BUTTERFLY, "Create a new UI build", supports_color)) -@option("-t", "--timeout", required=False, default=180, help="Timeout in seconds") -def build_frontend(timeout): - """Create a new UI build.""" - return build_ui(timeout) - - -@hg.command( # noqa: C901 - "start", - help=cmd_help(E.ROCKET, "Start web server", supports_color), - cls=HelpColorsCommand, - help_options_custom_colors=random_colors("-b", "-d", "-w"), -) -@option("-b", "--build", is_flag=True, help="Render theme & build frontend assets") -@option( - "-d", - "--direct", - is_flag=True, - default=False, - help="Start hyperglass directly instead of through process manager", -) -@option( - "-w", - "--workers", - type=int, - required=False, - default=0, - help=f"Number of workers. By default, calculated from CPU cores [{cpu_count(2)}]", -) -def start(build, direct, workers): # noqa: C901 - """Start web server and optionally build frontend assets.""" - # Project - from hyperglass.api import start as uvicorn_start - from hyperglass.main import start - - kwargs = {} - if workers != 0: - kwargs["workers"] = workers - - try: - - if build: - build_complete = build_ui(timeout=180) - - if build_complete and not direct: - start(**kwargs) - elif build_complete and direct: - uvicorn_start(**kwargs) - - if not build and not direct: - start(**kwargs) - - elif not build and direct: - uvicorn_start(**kwargs) - - except (KeyboardInterrupt, SystemExit) as err: - error_message = str(err) - if (len(error_message)) > 1: - warning(str(err)) - error("Stopping hyperglass due to keyboard interrupt.") - - -@hg.command( - "secret", - help=cmd_help(E.LOCK, "Generate agent secret", supports_color), - cls=HelpColorsCommand, - help_options_custom_colors=random_colors("-l"), -) -@option("-l", "--length", "length", default=32, help="Number of characters [default: 32]") -def generate_secret(length): - """Generate secret for hyperglass-agent. - - Arguments: - length {int} -- Length of secret - """ - # Standard Library - import secrets - - gen_secret = secrets.token_urlsafe(length) - label("Secret: {s}", s=gen_secret) - - -@hg.command( - "setup", - help=cmd_help(E.TOOLBOX, "Run the setup wizard", supports_color), - cls=HelpColorsCommand, - help_options_custom_colors=random_colors("-d"), -) -@option( - "-d", - "--use-defaults", - "unattended", - default=False, - is_flag=True, - help="Use hyperglass defaults (requires no input)", -) -def setup(unattended): - """Define application directory, move example files, generate systemd service.""" - - installer = Installer(unattended=unattended) - installer.install() - - success( - """Completed hyperglass installation. -After adding your {devices} file, you should run the {build_cmd} command.""", # noqa: E501 - devices="devices.yaml", - build_cmd="hyperglass build-ui", - ) - - -@hg.command( - "system-info", - help=cmd_help(E.THERMOMETER, " Get system information for a bug report", supports_color), - cls=HelpColorsCommand, -) -def get_system_info(): - """Get CPU, Memory, Disk, Python, & hyperglass version.""" - # Project - from hyperglass.cli.util import system_info - - system_info() - - -@hg.command( - "clear-cache", - help=cmd_help(E.SOAP, "Clear the Redis cache", supports_color), - cls=HelpColorsCommand, -) -def clear_cache(): - """Clear the Redis Cache.""" - # Project - from hyperglass.state import use_state - - state = use_state() - try: - state.clear() - success("Cleared Redis Cache") - except Exception as err: - error(str(err)) diff --git a/hyperglass/cli/echo.py b/hyperglass/cli/echo.py index b020633..642c1ce 100644 --- a/hyperglass/cli/echo.py +++ b/hyperglass/cli/echo.py @@ -1,138 +1,42 @@ """Helper functions for CLI message printing.""" # Standard Library -import re - -# Third Party -from click import echo, style +import typing as t # Project -from hyperglass.cli.static import CMD_HELP, Message -from hyperglass.cli.exceptions import CliError +from hyperglass.log import HyperglassConsole -def cmd_help(emoji="", help_text="", supports_color=False): - """Print formatted command help.""" - if supports_color: - help_str = emoji + style(help_text, **CMD_HELP) - else: - help_str = help_text - return help_str +class Echo: + """Container for console-printing functions.""" + + _console = HyperglassConsole + + def _fmt(self, message: t.Any, *args: t.Any, **kwargs: t.Any) -> t.Any: + if isinstance(message, str): + args = (f"[bold]{arg}[/bold]" for arg in args) + kwargs = {k: f"[bold]{v}[/bold]" for k, v in kwargs.items()} + return message.format(*args, **kwargs) + return message + + def error(self, message: str, *args, **kwargs): + """Print an error message.""" + return self._console.print(self._fmt(message, *args, **kwargs), style="error") + + def info(self, message: str, *args, **kwargs): + """Print an informational message.""" + return self._console.print(self._fmt(message, *args, **kwargs), style="info") + + def warning(self, message: str, *args, **kwargs): + """Print a warning message.""" + return self._console.print(self._fmt(message, *args, **kwargs), style="info") + + def success(self, message: str, *args, **kwargs): + """Print a success message.""" + return self._console.print(self._fmt(message, *args, **kwargs), style="success") + + def plain(self, message: str, *args, **kwargs): + """Print an unformatted message.""" + return self._console.print(self._fmt(message, *args, **kwargs)) -def _base_formatter(_text, _state, _callback, *args, **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 - - nargs = () - for i in args: - if not isinstance(i, str): - nargs += (str(i),) - else: - nargs += (i,) - - 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(*nargs, **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 info(text, *args, **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=echo, *args, **kwargs) - - -def error(text, *args, **kwargs): - """Generate formatted exception. - - Arguments: - text {str} -- Text to format - callback {callable} -- Callback function (default: {echo}) - - Raises: - ClickException: Raised after formatting - """ - raise _base_formatter(text, "error", CliError, *args, **kwargs) - - -def success(text, *args, **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=echo, *args, **kwargs) - - -def warning(text, *args, **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=echo, *args, **kwargs) - - -def label(text, *args, **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=echo, *args, **kwargs) - - -def status(text, *args, **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=echo, *args, **kwargs) +echo = Echo() diff --git a/hyperglass/cli/exceptions.py b/hyperglass/cli/exceptions.py deleted file mode 100644 index 3b44806..0000000 --- a/hyperglass/cli/exceptions.py +++ /dev/null @@ -1,15 +0,0 @@ -"""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/formatting.py b/hyperglass/cli/formatting.py deleted file mode 100644 index d79ab5e..0000000 --- a/hyperglass/cli/formatting.py +++ /dev/null @@ -1,171 +0,0 @@ -"""Help formatting. - -https://github.com/click-contrib/click-help-colors -MIT License - -Copyright (c) 2016 Roman Tonkonozhko - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -""" - -# Standard Library -import random - -# Third Party -import click - - -def random_colors(*commands): - """From tuple of commands, generate random but unique colors.""" - colors = ["blue", "green", "red", "yellow", "magenta", "cyan", "white"] - num_colors = len(colors) - num_commands = len(commands) - - if num_commands >= num_colors: - colors += colors - - unique_colors = random.sample(colors, num_commands) - commands_fmt = {} - for i, cmd in enumerate(commands): - commands_fmt.update({cmd: {"fg": unique_colors[i], "bold": True}}) - commands_fmt.update({"--help": {"fg": "white"}}) - return commands_fmt - - -class HelpColorsFormatter(click.HelpFormatter): - """Click help formatting plugin. See file docstring for license. - - Modified from original copy to support click.style() instead of - direct ANSII string formatting. - """ - - def __init__( - self, headers_color=None, options_color=None, options_custom_colors=None, *args, **kwargs - ): - """Initialize help formatter. - - Keyword Arguments: - headers_color {dict} -- click.style() paramters for header - options_color {dict} -- click.style() paramters for options - options_custom_colors {dict} -- click.style() paramters for options by name - """ - self.headers_color = headers_color or {} - self.options_color = options_color or {} - self.options_custom_colors = options_custom_colors or {} - - super().__init__(indent_increment=3, *args, **kwargs) - - def _pick_color(self, option_name): - """Filter options and pass relevant click.style() options for command.""" - opt = option_name.split()[0].strip(",") - color = {} - if self.options_custom_colors and opt in self.options_custom_colors.keys(): - color = self.options_custom_colors[opt] - else: - color = self.options_color - return color - - def write_usage(self, prog, args="", prefix="Usage: "): - """Write Usage: section.""" - prefix_fmt = click.style(prefix, **self.headers_color) - super().write_usage(prog, args, prefix=prefix_fmt) - - def write_heading(self, heading): - """Write Heading section.""" - heading_fmt = click.style(heading, **self.headers_color) - super().write_heading(heading_fmt) - - def write_dl(self, rows, **kwargs): - """Write Options section.""" - colorized_rows = [(click.style(row[0], **self._pick_color(row[0])), row[1]) for row in rows] - super().write_dl(colorized_rows, **kwargs) - - -class HelpColorsMixin: - """Click help formatting plugin. See file docstring for license. - - Modified from original copy to support click.style() instead of - direct ANSII string formatting. - """ - - def __init__( - self, - help_headers_color=None, - help_options_color=None, - help_options_custom_colors=None, - *args, - **kwargs - ): - """Initialize help mixin.""" - self.help_headers_color = help_headers_color or {} - self.help_options_color = help_options_color or {} - self.help_options_custom_colors = help_options_custom_colors or {} - super().__init__(*args, **kwargs) - - def get_help(self, ctx): - """Format help.""" - formatter = HelpColorsFormatter( - width=ctx.terminal_width, - max_width=ctx.max_content_width, - headers_color=self.help_headers_color, - options_color=self.help_options_color, - options_custom_colors=self.help_options_custom_colors, - ) - self.format_help(ctx, formatter) - return formatter.getvalue().rstrip("\n") - - -class HelpColorsGroup(HelpColorsMixin, click.Group): - """Click help formatting plugin. See file docstring for license. - - Modified from original copy to support click.style() instead of - direct ANSII string formatting. - """ - - def __init__(self, *args, **kwargs): - """Initialize group formatter.""" - super().__init__(*args, **kwargs) - - def command(self, *args, **kwargs): - """Set command values.""" - kwargs.setdefault("cls", HelpColorsCommand) - kwargs.setdefault("help_headers_color", self.help_headers_color) - kwargs.setdefault("help_options_color", self.help_options_color) - kwargs.setdefault("help_options_custom_colors", self.help_options_custom_colors) - return super().command(*args, **kwargs) - - def group(self, *args, **kwargs): - """Set group values.""" - kwargs.setdefault("cls", HelpColorsGroup) - kwargs.setdefault("help_headers_color", self.help_headers_color) - kwargs.setdefault("help_options_color", self.help_options_color) - kwargs.setdefault("help_options_custom_colors", self.help_options_custom_colors) - return super().group(*args, **kwargs) - - -class HelpColorsCommand(HelpColorsMixin, click.Command): - """Click help formatting plugin. See file docstring for license. - - Modified from original copy to support click.style() instead of - direct ANSII string formatting. - """ - - def __init__(self, *args, **kwargs): - """Initialize command formatter.""" - super().__init__(*args, **kwargs) diff --git a/hyperglass/cli/installer.py b/hyperglass/cli/installer.py index f684fb9..eeb760d 100644 --- a/hyperglass/cli/installer.py +++ b/hyperglass/cli/installer.py @@ -2,89 +2,144 @@ # Standard Library import os +import time import shutil +import typing as t +import getpass +from types import TracebackType from filecmp import dircmp from pathlib import Path # Third Party -import inquirer +import typer +from rich.progress import Progress + +# Project +from hyperglass.util import compare_lists +from hyperglass.settings import Settings +from hyperglass.constants import __version__ # Local -from .echo import error, success, warning -from .util import create_dir +from .echo import echo -USER_PATH = Path.home() / "hyperglass" -ROOT_PATH = Path("/etc/hyperglass/") ASSET_DIR = Path(__file__).parent.parent / "images" IGNORED_FILES = [".DS_Store"] -INSTALL_PATHS = [ - inquirer.List( - "install_path", message="Choose a directory for hyperglass", choices=[USER_PATH, ROOT_PATH], - ) -] - - -def prompt_for_path() -> str: - """Recursively prompt the user for an app path until one is provided.""" - - answer = inquirer.prompt(INSTALL_PATHS) - - if answer is None: - warning("A directory for hyperglass is required") - answer = prompt_for_path() - - return answer["install_path"] - class Installer: """Install hyperglass.""" - def __init__(self, unattended: bool): - """Initialize installer.""" + app_path: Path + progress: Progress + user: str + assets: int - self.unattended = unattended + def __init__(self): + """Start hyperglass installer.""" + self.app_path = Settings.app_path + self.progress: Progress = Progress(console=echo._console) + self.user = getpass.getuser() + self.assets = len([p for p in ASSET_DIR.iterdir() if p.name not in IGNORED_FILES]) def install(self) -> None: - """Complete the installation.""" + """Initialize tasks and start installer.""" + permissions_task = self.progress.add_task("[bright purple]Checking System", total=2) + scaffold_task = self.progress.add_task( + "[bright blue]Creating Directory Structures", total=3 + ) + asset_task = self.progress.add_task( + "[bright cyan]Migrating Static Assets", total=self.assets + ) + ui_task = self.progress.add_task("[bright teal]Initialzing UI", total=1, start=False) - self.app_path = self._get_app_path() - self._scaffold() - self._migrate_static_assets() + self.progress.start() - def _get_app_path(self) -> Path: - """Find the app path from env variables or a prompt.""" + self.check_permissions(task_id=permissions_task) + self.scaffold(task_id=scaffold_task) + self.migrate_static_assets(task_id=asset_task) + self.init_ui(task_id=ui_task) - if self.unattended: - return USER_PATH + def __enter__(self) -> t.Callable[[], None]: + """Initialize tasks.""" + self.progress.print(f"Starting hyperglass {__version__} setup") + return self.install - app_path = os.environ.get("HYPERGLASS_PATH", None) + def __exit__( + self, + exc_type: t.Optional[t.Type[BaseException]] = None, + exc_value: t.Optional[BaseException] = None, + exc_traceback: t.Optional[TracebackType] = None, + ): + """Print errors on exit.""" + self.progress.stop() + if exc_type is not None: + echo._console.print_exception(show_locals=True) + raise typer.Exit(1) + raise typer.Exit(0) - if app_path is None: - app_path = prompt_for_path() + def check_permissions(self, task_id: int) -> None: + """Ensure the executing user has permissions to the app path.""" + read = os.access(self.app_path, os.R_OK) + if not read: + self.progress.print( + f"User {self.user!r} does not have read access to {self.app_path!s}", style="error" + ) + raise typer.Exit(1) - return app_path + self.progress.advance(task_id) + time.sleep(0.4) - def _scaffold(self) -> None: + write = os.access(self.app_path, os.W_OK) + if not write: + self.progress.print( + f"User {self.user!r} does not have write access to {self.app_path!s}", style="error" + ) + raise typer.Exit(1) + self.progress.advance(task_id) + + def scaffold(self, task_id: int) -> None: """Create the file structure necessary for hyperglass to run.""" + if not self.app_path.exists(): + self.progress.print("Created {!s}".format(self.app_path), style="info") + self.app_path.mkdir(parents=True) + + self.progress.print(f"hyperglass path is {self.app_path!s}", style="subtle") + self.progress.advance(task_id) + ui_dir = self.app_path / "static" / "ui" - images_dir = self.app_path / "static" / "images" - favicon_dir = images_dir / "favicons" - custom_dir = self.app_path / "static" / "custom" + favicon_dir = self.app_path / "static" / "images" / "favicons" - create_dir(self.app_path) + for path in (ui_dir, favicon_dir): + if not path.exists(): + self.progress.print("Created {!s}".format(path), style="info") + path.mkdir(parents=True) - for path in (ui_dir, images_dir, favicon_dir, custom_dir): - create_dir(path, parents=True) + self.progress.advance(task_id) + time.sleep(0.4) - def _migrate_static_assets(self) -> bool: + def migrate_static_assets(self, task_id: int) -> None: """Synchronize the project assets with the installation assets.""" target_dir = self.app_path / "static" / "images" + def copy_func(src: str, dst: str): + time.sleep(self.assets / 10) + + exists = Path(dst).exists() + if not exists: + copied = shutil.copy2(src, dst) + self.progress.print(f"Copied {copied!s}", style="info") + self.progress.advance(task_id) + return dst + if not target_dir.exists(): - shutil.copytree(ASSET_DIR, target_dir) + shutil.copytree( + ASSET_DIR, + target_dir, + ignore=shutil.ignore_patterns(*IGNORED_FILES), + copy_function=copy_func, + ) # Compare the contents of the project's asset directory (considered # the source of truth) with the installation directory. If they do @@ -92,19 +147,41 @@ class Installer: # re-copy it. compare_initial = dircmp(ASSET_DIR, target_dir, ignore=IGNORED_FILES) - if not compare_initial.left_list == compare_initial.right_list: + if not compare_lists( + compare_initial.left_list, + compare_initial.right_list, + ignore=["hyperglass-opengraph.jpg"], + ): shutil.rmtree(target_dir) - shutil.copytree(ASSET_DIR, target_dir) + shutil.copytree( + ASSET_DIR, + target_dir, + copy_function=copy_func, + ignore=shutil.ignore_patterns(*IGNORED_FILES), + ) # Re-compare the source and destination directory contents to # ensure they match. compare_post = dircmp(ASSET_DIR, target_dir, ignore=IGNORED_FILES) - if not compare_post.left_list == compare_post.right_list: - error( - "Files in {a} do not match files in {b}", a=str(ASSET_DIR), b=str(target_dir), - ) - return False + if not compare_lists( + compare_post.left_list, compare_post.right_list, ignore=["hyperglass-opengraph.jpg"] + ): + echo.error("Files in {!s} do not match files in {!s}", ASSET_DIR, target_dir) + raise typer.Exit(1) + else: + self.progress.update(task_id, completed=self.assets, refresh=True) - success("Migrated assets from {a} to {b}", a=str(ASSET_DIR), b=str(target_dir)) - return True + def init_ui(self, task_id: int) -> None: + """Initialize UI.""" + # Project + from hyperglass.log import log + + # Local + from .util import build_ui + + with self.progress.console.capture(): + log.disable("hyperglass") + build_ui(timeout=180) + log.enable("hyperglass") + self.progress.advance(task_id) diff --git a/hyperglass/cli/main.py b/hyperglass/cli/main.py new file mode 100644 index 0000000..7c0595a --- /dev/null +++ b/hyperglass/cli/main.py @@ -0,0 +1,148 @@ +"""hyperglass Command Line Interface.""" + +# Standard Library +import sys +import typing as t + +# Third Party +import typer + +# Local +from .echo import echo + + +def _version(value: bool) -> None: + # Project + from hyperglass import __version__ + + if value: + echo.info(__version__) + raise typer.Exit() + + +cli = typer.Typer(name="hyperglass", help="hyperglass Command Line Interface", no_args_is_help=True) + + +def run(): + """Run the hyperglass CLI.""" + return typer.run(cli()) + + +@cli.callback() +def version( + version: t.Optional[bool] = typer.Option( + None, "--version", help="hyperglass version", callback=_version + ) +) -> None: + """hyperglass""" + pass + + +@cli.command() +def start(build: bool = False, workers: t.Optional[int] = None) -> None: + """Start hyperglass""" + # Project + from hyperglass.main import run + + # Local + from .util import build_ui + + kwargs = {} + if workers != 0: + kwargs["workers"] = workers + + try: + if build: + build_complete = build_ui(timeout=180) + if build_complete: + run(workers) + else: + run(workers) + + except (KeyboardInterrupt, SystemExit) as err: + error_message = str(err) + if (len(error_message)) > 1: + echo.warning(str(err)) + echo.error("Stopping hyperglass due to keyboard interrupt.") + raise typer.Exit(0) + + +@cli.command() +def build_ui(timeout: int = typer.Option(180, help="Timeout in seconds")) -> None: + """Create a new UI build.""" + # Local + from .util import build_ui as _build_ui + + with echo._console.status( + f"Starting new UI build with a {timeout} second timeout...", spinner="aesthetic" + ): + + _build_ui() + + +@cli.command() +def system_info(): + """Get system information for a bug report""" + # Third Party + from rich.table import Table + + # Project + from hyperglass.util.system_info import get_system_info + + # Local + from .static import MD_BOX + + data = get_system_info() + + rows = tuple( + (f"**{title}**", f"`{value!s}`" if mod == "code" else str(value)) + for title, (value, mod) in data.items() + ) + + table = Table("Metric", "Value", box=MD_BOX) + for title, metric in rows: + table.add_row(title, metric) + + echo.info("Please copy & paste this table in your bug report:\n") + echo.plain(table) + + +@cli.command() +def clear_cache(): + """Clear the Redis cache""" + # Project + from hyperglass.state import use_state + + state = use_state() + + try: + state.clear() + echo.success("Cleared Redis Cache") + + except Exception as err: + if not sys.stdout.isatty(): + echo._console.print_exception(show_locals=True) + raise typer.Exit(1) + + echo.error("Error clearing cache: {!s}", err) + raise typer.Exit(1) + + +@cli.command() +def setup(): + """Initialize hyperglass setup.""" + # Local + from .installer import Installer + + with Installer() as start: + start() + + +@cli.command() +def settings(): + """Show hyperglass system settings (environment variables)""" + + # Project + from hyperglass.settings import Settings + + echo.plain(Settings) diff --git a/hyperglass/cli/static.py b/hyperglass/cli/static.py index 3005759..d6b9c82 100644 --- a/hyperglass/cli/static.py +++ b/hyperglass/cli/static.py @@ -1,6 +1,21 @@ """Static string definitions.""" + # Third Party -import click +from rich.box import Box + +MD_BOX = Box( + """\ + +| || +|-|| +| || +| | +| | +| || + +""", + ascii=True, +) class Char: @@ -27,99 +42,6 @@ class Char: return str(self.char) + str(other) -class Emoji: - """Helper class for unicode emoji.""" - - BUTTERFLY = "\U0001F98B " - CHECK = "\U00002705 " - INFO = "\U00002755 " - ERROR = "\U0000274C " - WARNING = "\U000026A0\U0000FE0F " - TOOLBOX = "\U0001F9F0 " - NUMBERS = "\U0001F522 " - FOLDED_HANDS = "\U0001F64F " - ROCKET = "\U0001F680 " - SPARKLES = "\U00002728 " - PAPERCLIP = "\U0001F4CE " - KEY = "\U0001F511 " - LOCK = "\U0001F512 " - CLAMP = "\U0001F5DC " - BOOKS = "\U0001F4DA " - THERMOMETER = "\U0001F321 " - SOAP = "\U0001F9FC " - - WS = Char(" ") NL = Char("\n") CL = Char(":") -E = Emoji() - -CLI_HELP = ( - click.style("hyperglass", fg="magenta", bold=True) - + WS[1] - + 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) diff --git a/hyperglass/cli/util.py b/hyperglass/cli/util.py index 9d94723..9f0988b 100644 --- a/hyperglass/cli/util.py +++ b/hyperglass/cli/util.py @@ -1,183 +1,47 @@ """CLI utility functions.""" # Standard Library -import os +import sys import asyncio -from pathlib import Path # Third Party -from click import echo, style +import typer -# Project -from hyperglass.cli.echo import info, error, status, success -from hyperglass.cli.static import CL, NL, WS, E - -PROJECT_ROOT = Path(__file__).parent.parent - - -def async_command(func) -> None: - """Decororator for to make async functions runable from synchronous code.""" - # Standard Library - import asyncio - from functools import update_wrapper - - func = asyncio.coroutine(func) - - def wrapper(*args, **kwargs): - loop = asyncio.get_event_loop() - return loop.run_until_complete(func(*args, **kwargs)) - - return update_wrapper(wrapper, func) - - -def start_web_server(start, params): - """Start web server.""" - msg_start = "Starting hyperglass web server on" - msg_uri = "http://" - msg_host = str(params["host"]) - msg_port = str(params["port"]) - msg_len = len("".join([msg_start, WS[1], msg_uri, msg_host, CL[1], msg_port])) - try: - echo( - NL[1] - + WS[msg_len + 8] - + E.ROCKET - + NL[1] - + E.CHECK - + style(msg_start, fg="green", bold=True) - + WS[1] - + style(msg_uri, fg="white") - + style(msg_host, fg="blue", bold=True) - + style(CL[1], fg="white") - + style(msg_port, fg="magenta", bold=True) - + WS[1] - + E.ROCKET - + NL[1] - + WS[1] - + NL[1] - ) - start() - - except Exception as e: - error("Failed to start web server: {e}", e=e) +# Local +from .echo import echo def build_ui(timeout: int) -> None: """Create a new UI build.""" - try: - # Project - from hyperglass.state import use_state - from hyperglass.util.frontend import build_frontend - except ImportError as e: - error("Error importing UI builder: {e}", e=e) + # Project + from hyperglass.state import use_state + from hyperglass.util.frontend import build_frontend state = use_state() - status("Starting new UI build with a {t} second timeout...", t=timeout) - - if state.params.developer_mode: + dev_mode = "production" + if state.settings.dev_mode: dev_mode = "development" - else: - dev_mode = "production" try: build_success = asyncio.run( build_frontend( + app_path=state.settings.app_path, dev_mode=state.settings.dev_mode, dev_url=f"http://localhost:{state.settings.port!s}/", - prod_url="/api/", - params=state.ui_params, force=True, - app_path=state.settings.app_path, + params=state.ui_params, + prod_url="/api/", + timeout=timeout, ) ) if build_success: - success("Completed UI build in {m} mode", m=dev_mode) + echo.success("Completed UI build in {} mode", dev_mode) except Exception as e: - error("Error building UI: {e}", e=e) + if not sys.stdout.isatty(): + echo._console.print_exception(show_locals=True) + raise typer.Exit(1) - return True - - -def create_dir(path, **kwargs) -> bool: - """Validate and attempt to create a directory, if it does not exist.""" - - # If input path is not a path object, try to make it one - if not isinstance(path, Path): - try: - path = Path(path) - except TypeError: - error("{p} is not a valid path", p=path) - - # If path does not exist, try to create it - if not path.exists(): - try: - path.mkdir(**kwargs) - except PermissionError: - error( - "{u} does not have permission to create {p}. Try running with sudo?", - u=os.getlogin(), - p=path, - ) - - # Verify the path was actually created - if path.exists(): - success("Created {p}", p=path) - - # If the path already exists, inform the user - elif path.exists(): - info("{p} already exists", p=path) - - return True - - -def write_to_file(file, data) -> bool: - """Write string data to a file.""" - try: - with file.open("w+") as f: - f.write(data.strip()) - except PermissionError: - error( - "{u} does not have permission to write to {f}. Try running with sudo?", - u=os.getlogin(), - f=file, - ) - if not file.exists(): - error("Error writing file {f}", f=file) - elif file.exists(): - success("Wrote systemd file {f}", f=file) - return True - - -def system_info() -> None: - """Create a markdown table of various system information.""" - # Project - from hyperglass.util.system_info import get_system_info - - data = get_system_info() - - def _code(val): - return f"`{str(val)}`" - - def _bold(val): - return f"**{str(val)}**" - - md_table_lines = ("| Metric | Value |", "| :----- | :---- |") - - for title, metric in data.items(): - value, mod = metric - - title = _bold(title) - - if mod == "code": - value = _code(value) - - md_table_lines += (f"| {title} | {value} |",) - - md_table = "\n".join(md_table_lines) - - info("Please copy & paste this table in your bug report:\n") - echo(md_table + "\n") - - return None + echo.error("Error building UI: {!s}", e) + raise typer.Exit(1) diff --git a/hyperglass/console.py b/hyperglass/console.py index a1a7aed..bdc2d07 100755 --- a/hyperglass/console.py +++ b/hyperglass/console.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 """hyperglass CLI management tool.""" -# Project -from hyperglass.cli import CLI +# Local +from .cli import run if __name__ == "__main__": - CLI() + run() diff --git a/hyperglass/log.py b/hyperglass/log.py index bddb6e9..2cbcce6 100644 --- a/hyperglass/log.py +++ b/hyperglass/log.py @@ -8,6 +8,8 @@ from datetime import datetime # Third Party from loguru import logger as _loguru_logger +from rich.theme import Theme +from rich.console import Console from rich.logging import RichHandler from gunicorn.glogging import Logger as GunicornLogger # type: ignore @@ -43,6 +45,18 @@ _LOG_LEVELS = [ {"name": "CRITICAL", "color": ""}, ] +HyperglassConsole = Console( + theme=Theme( + { + "info": "bold cyan", + "warning": "bold yellow", + "error": "bold red", + "success": "bold green", + "subtle": "rgb(128,128,128)", + } + ) +) + class LibIntercentHandler(logging.Handler): """Custom log handler for integrating third party library logging with hyperglass's logger.""" @@ -133,6 +147,7 @@ def init_logger(level: str = "INFO"): # Use Rich for logging if hyperglass started from a TTY. _loguru_logger.add( sink=RichHandler( + console=HyperglassConsole, rich_tracebacks=True, level=level, tracebacks_show_locals=True, diff --git a/hyperglass/main.py b/hyperglass/main.py index 9da0bf3..dc8c414 100644 --- a/hyperglass/main.py +++ b/hyperglass/main.py @@ -164,7 +164,8 @@ def start(*, log_level: str, workers: int, **kwargs) -> None: ).run() -if __name__ == "__main__": +def run(_workers: int = None): + """Run hyperglass.""" try: init_user_config() @@ -175,6 +176,9 @@ if __name__ == "__main__": if Settings.debug is False: workers, log_level = cpu_count(2), "WARNING" + if _workers is not None: + workers = _workers + setup_lib_logging(log_level) start(log_level=log_level, workers=workers) except Exception as error: @@ -188,3 +192,7 @@ if __name__ == "__main__": except SystemExit: # Handle Gunicorn exit. sys.exit(4) + + +if __name__ == "__main__": + run() diff --git a/hyperglass/models/system.py b/hyperglass/models/system.py index a85f1ad..d55f297 100644 --- a/hyperglass/models/system.py +++ b/hyperglass/models/system.py @@ -18,6 +18,10 @@ from pydantic import ( # Project from hyperglass.util import at_least, cpu_count +if t.TYPE_CHECKING: + # Third Party + from rich.console import Console, RenderResult, ConsoleOptions + ListenHost = t.Union[None, IPvAnyAddress, t.Literal["localhost"]] @@ -41,6 +45,34 @@ class HyperglassSettings(BaseSettings): host: IPvAnyAddress = None port: int = 8001 + def __rich_console__(self, console: "Console", options: "ConsoleOptions") -> "RenderResult": + """Render a Rich table representation of hyperglass settings.""" + # Third Party + from rich.panel import Panel + from rich.style import Style + from rich.table import Table, box + from rich.pretty import Pretty + + table = Table(box=box.MINIMAL, border_style="subtle") + table.add_column("Environment Variable", style=Style(color="#118ab2", bold=True)) + table.add_column("Value") + params = sorted( + ( + "debug", + "dev_mode", + "app_path", + "redis_host", + "redis_db", + "redis_dsn", + "host", + "port", + ) + ) + for attr in params: + table.add_row(f"hyperglass_{attr}".upper(), Pretty(getattr(self, attr))) + + yield Panel.fit(table, title="hyperglass settings", border_style="subtle") + @validator("host", pre=True, always=True) def validate_host( cls: "HyperglassSettings", value: t.Any, values: t.Dict[str, t.Any] diff --git a/hyperglass/util/__init__.py b/hyperglass/util/__init__.py index c9b78be..1dc9b47 100644 --- a/hyperglass/util/__init__.py +++ b/hyperglass/util/__init__.py @@ -385,3 +385,10 @@ def run_coroutine_in_new_thread(coroutine: t.Coroutine) -> t.Any: thread.start() thread.join() return thread.result + + +def compare_lists(left: t.List[t.Any], right: t.List[t.Any], *, ignore: Series[t.Any] = ()) -> bool: + """Determine if all items in left list exist in right list.""" + left_ignored = [i for i in left if i not in ignore] + diff_ignored = [i for i in left if i in right and i not in ignore] + return len(left_ignored) == len(diff_ignored) diff --git a/poetry.lock b/poetry.lock index da7ebbf..afafe8b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,18 +1,10 @@ [[package]] name = "aiofiles" -version = "0.6.0" +version = "0.7.0" description = "File support for asyncio." category = "main" optional = false -python-versions = "*" - -[[package]] -name = "ansicon" -version = "1.89.0" -description = "Python wrapper for loading Jason Hood's ANSICON" -category = "main" -optional = false -python-versions = "*" +python-versions = ">=3.6,<4.0" [[package]] name = "appdirs" @@ -126,19 +118,6 @@ typed-ast = ">=1.4.0" [package.extras] d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] -[[package]] -name = "blessed" -version = "1.17.6" -description = "Easy, practical library for making terminal apps, by providing an elegant, well-documented interface to Colors, Keyboard input, and screen Positioning capabilities." -category = "main" -optional = false -python-versions = "*" - -[package.dependencies] -jinxed = {version = ">=0.5.4", markers = "platform_system == \"Windows\""} -six = ">=1.9.0" -wcwidth = ">=0.1.4" - [[package]] name = "certifi" version = "2020.6.20" @@ -273,7 +252,7 @@ test = ["pytest (==5.4.3)", "pytest-cov (==2.10.0)", "pytest-asyncio (>=0.14.0,< [[package]] name = "favicons" -version = "0.1.0" +version = "0.1.1" description = "Favicon generator for Python 3 with strongly typed sync & async APIs, CLI, & HTML generation." category = "main" optional = false @@ -283,7 +262,7 @@ python-versions = ">=3.6.1,<4.0" pillow = ">=7.2,<9.0" rich = ">=6.0,<11.0" svglib = ">=1.0.0,<2.0.0" -typer = ">=0.3.1,<0.4.0" +typer = ">=0.3.1,<1.0" [[package]] name = "filelock" @@ -606,19 +585,6 @@ category = "dev" optional = false python-versions = "*" -[[package]] -name = "inquirer" -version = "2.7.0" -description = "Collection of common interactive command line user interfaces, based on Inquirer.js" -category = "main" -optional = false -python-versions = "*" - -[package.dependencies] -blessed = "1.17.6" -python-editor = "1.0.4" -readchar = "2.0.1" - [[package]] name = "isort" version = "5.5.4" @@ -632,17 +598,6 @@ pipfile_deprecated_finder = ["pipreqs", "requirementslib"] requirements_deprecated_finder = ["pipreqs", "pip-api"] colors = ["colorama (>=0.4.3,<0.5.0)"] -[[package]] -name = "jinxed" -version = "1.0.1" -description = "Jinxed Terminal Library" -category = "main" -optional = false -python-versions = "*" - -[package.dependencies] -ansicon = {version = "*", markers = "platform_system == \"Windows\""} - [[package]] name = "loguru" version = "0.5.3" @@ -998,14 +953,6 @@ python-versions = "*" [package.extras] cli = ["click (>=5.0)"] -[[package]] -name = "python-editor" -version = "1.0.4" -description = "Programmatically open an editor, capture the result." -category = "main" -optional = false -python-versions = "*" - [[package]] name = "pyyaml" version = "5.4.1" @@ -1014,14 +961,6 @@ category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" -[[package]] -name = "readchar" -version = "2.0.1" -description = "Utilities to read single characters and key-strokes" -category = "main" -optional = false -python-versions = "*" - [[package]] name = "redis" version = "3.5.3" @@ -1276,20 +1215,20 @@ python-versions = "*" [[package]] name = "typer" -version = "0.3.2" +version = "0.4.0" description = "Typer, build great CLIs. Easy to code. Based on Python type hints." category = "main" optional = false python-versions = ">=3.6" [package.dependencies] -click = ">=7.1.1,<7.2.0" +click = ">=7.1.1,<9.0.0" [package.extras] -test = ["pytest-xdist (>=1.32.0,<2.0.0)", "pytest-sugar (>=0.9.4,<0.10.0)", "mypy (==0.782)", "black (>=19.10b0,<20.0b0)", "isort (>=5.0.6,<6.0.0)", "shellingham (>=1.3.0,<2.0.0)", "pytest (>=4.4.0,<5.4.0)", "pytest-cov (>=2.10.0,<3.0.0)", "coverage (>=5.2,<6.0)"] all = ["colorama (>=0.4.3,<0.5.0)", "shellingham (>=1.3.0,<2.0.0)"] dev = ["autoflake (>=1.3.1,<2.0.0)", "flake8 (>=3.8.3,<4.0.0)"] doc = ["mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=5.4.0,<6.0.0)", "markdown-include (>=0.5.1,<0.6.0)"] +test = ["shellingham (>=1.3.0,<2.0.0)", "pytest (>=4.4.0,<5.4.0)", "pytest-cov (>=2.10.0,<3.0.0)", "coverage (>=5.2,<6.0)", "pytest-xdist (>=1.32.0,<2.0.0)", "pytest-sugar (>=0.9.4,<0.10.0)", "mypy (==0.910)", "black (>=19.10b0,<20.0b0)", "isort (>=5.0.6,<6.0.0)"] [[package]] name = "typing-extensions" @@ -1355,14 +1294,6 @@ category = "main" optional = false python-versions = ">=3.5" -[[package]] -name = "wcwidth" -version = "0.2.5" -description = "Measures the displayed width of unicode strings in a terminal" -category = "main" -optional = false -python-versions = "*" - [[package]] name = "webencodings" version = "0.5.1" @@ -1401,16 +1332,12 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [metadata] lock-version = "1.1" python-versions = ">=3.8.1,<4.0" -content-hash = "1f1c9a87755507045ca8f1ec1132c48e637bb8f1d701caed3a48f280198e02e1" +content-hash = "d4a0600f54f56ba3641943af10908d325a459f301c073ec0f2f354ca7869d0ed" [metadata.files] aiofiles = [ - {file = "aiofiles-0.6.0-py3-none-any.whl", hash = "sha256:bd3019af67f83b739f8e4053c6c0512a7f545b9a8d91aaeab55e6e0f9d123c27"}, - {file = "aiofiles-0.6.0.tar.gz", hash = "sha256:e0281b157d3d5d59d803e3f4557dcc9a3dff28a4dd4829a9ff478adae50ca092"}, -] -ansicon = [ - {file = "ansicon-1.89.0-py2.py3-none-any.whl", hash = "sha256:f1def52d17f65c2c9682cf8370c03f541f410c1752d6a14029f97318e4b9dfec"}, - {file = "ansicon-1.89.0.tar.gz", hash = "sha256:e4d039def5768a47e4afec8e89e83ec3ae5a26bf00ad851f914d1240b444d2b1"}, + {file = "aiofiles-0.7.0-py3-none-any.whl", hash = "sha256:c67a6823b5f23fcab0a2595a289cec7d8c863ffcb4322fb8cd6b90400aedfdbc"}, + {file = "aiofiles-0.7.0.tar.gz", hash = "sha256:a1c4fc9b2ff81568c83e21392a82f344ea9d23da906e4f6a52662764545e19d4"}, ] appdirs = [ {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, @@ -1449,10 +1376,6 @@ black = [ {file = "black-19.10b0-py36-none-any.whl", hash = "sha256:1b30e59be925fafc1ee4565e5e08abef6b03fe455102883820fe5ee2e4734e0b"}, {file = "black-19.10b0.tar.gz", hash = "sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539"}, ] -blessed = [ - {file = "blessed-1.17.6-py2.py3-none-any.whl", hash = "sha256:8371d69ac55558e4b1591964873d6721136e9ea17a730aeb3add7d27761b134b"}, - {file = "blessed-1.17.6.tar.gz", hash = "sha256:a9a774fc6eda05248735b0d86e866d640ca2fef26038878f7e4d23f7749a1e40"}, -] certifi = [ {file = "certifi-2020.6.20-py2.py3-none-any.whl", hash = "sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41"}, {file = "certifi-2020.6.20.tar.gz", hash = "sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3"}, @@ -1552,8 +1475,8 @@ fastapi = [ {file = "fastapi-0.63.0.tar.gz", hash = "sha256:63c4592f5ef3edf30afa9a44fa7c6b7ccb20e0d3f68cd9eba07b44d552058dcb"}, ] favicons = [ - {file = "favicons-0.1.0-py3-none-any.whl", hash = "sha256:1d8e9d6990c08a5e3dd5e00506278e30c7ee24eb43cc478f7ecd77685fd7ae2a"}, - {file = "favicons-0.1.0.tar.gz", hash = "sha256:d70ccfdf6d8ae1315dbb83a9d62e792a60e968442fa23b8faa816d4b05771b9e"}, + {file = "favicons-0.1.1-py3-none-any.whl", hash = "sha256:54b704c558414a67f43b5441869f4f82d8c5d88ddd1abcaba593977a57d90b47"}, + {file = "favicons-0.1.1.tar.gz", hash = "sha256:76fe51870153c31ebe9ee8d88440919a7354e3194ae075e14275883e44917314"}, ] filelock = [ {file = "filelock-3.0.12-py3-none-any.whl", hash = "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836"}, @@ -1673,18 +1596,10 @@ iniconfig = [ {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, ] -inquirer = [ - {file = "inquirer-2.7.0-py2.py3-none-any.whl", hash = "sha256:d15e15de1ad5696f1967e7a23d8e2fce69d2e41a70b008948d676881ed94c3a5"}, - {file = "inquirer-2.7.0.tar.gz", hash = "sha256:e819188de0ca7985a99c282176c6f50fb08b0d33867fd1965d3f3e97d6c8f83f"}, -] isort = [ {file = "isort-5.5.4-py3-none-any.whl", hash = "sha256:36f0c6659b9000597e92618d05b72d4181104cf59472b1c6a039e3783f930c95"}, {file = "isort-5.5.4.tar.gz", hash = "sha256:ba040c24d20aa302f78f4747df549573ae1eaf8e1084269199154da9c483f07f"}, ] -jinxed = [ - {file = "jinxed-1.0.1-py2.py3-none-any.whl", hash = "sha256:602f2cb3523c1045456f7b6d79ac19297fd8e933ae3bd9159845dc857f2d519c"}, - {file = "jinxed-1.0.1.tar.gz", hash = "sha256:bc523c74fe676c99ccc69c68c2dcd7d4d2d7b2541f6dbef74ef211aedd8ad0d3"}, -] loguru = [ {file = "loguru-0.5.3-py3-none-any.whl", hash = "sha256:f8087ac396b5ee5f67c963b495d615ebbceac2796379599820e324419d53667c"}, {file = "loguru-0.5.3.tar.gz", hash = "sha256:b28e72ac7a98be3d28ad28570299a393dfcd32e5e3f6a353dec94675767b6319"}, @@ -1909,13 +1824,6 @@ python-dotenv = [ {file = "python-dotenv-0.17.0.tar.gz", hash = "sha256:471b782da0af10da1a80341e8438fca5fadeba2881c54360d5fd8d03d03a4f4a"}, {file = "python_dotenv-0.17.0-py2.py3-none-any.whl", hash = "sha256:49782a97c9d641e8a09ae1d9af0856cc587c8d2474919342d5104d85be9890b2"}, ] -python-editor = [ - {file = "python-editor-1.0.4.tar.gz", hash = "sha256:51fda6bcc5ddbbb7063b2af7509e43bd84bfc32a4ff71349ec7847713882327b"}, - {file = "python_editor-1.0.4-py2-none-any.whl", hash = "sha256:5f98b069316ea1c2ed3f67e7f5df6c0d8f10b689964a4a811ff64f0106819ec8"}, - {file = "python_editor-1.0.4-py2.7.egg", hash = "sha256:ea87e17f6ec459e780e4221f295411462e0d0810858e055fc514684350a2f522"}, - {file = "python_editor-1.0.4-py3-none-any.whl", hash = "sha256:1bf6e860a8ad52a14c3ee1252d5dc25b2030618ed80c022598f00176adc8367d"}, - {file = "python_editor-1.0.4-py3.5.egg", hash = "sha256:c3da2053dbab6b29c94e43c486ff67206eafbe7eb52dbec7390b5e2fb05aac77"}, -] pyyaml = [ {file = "PyYAML-5.4.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:3b2b1824fe7112845700f815ff6a489360226a5609b96ec2190a45e62a9fc922"}, {file = "PyYAML-5.4.1-cp27-cp27m-win32.whl", hash = "sha256:129def1b7c1bf22faffd67b8f3724645203b79d8f4cc81f674654d9902cb4393"}, @@ -1947,10 +1855,6 @@ pyyaml = [ {file = "PyYAML-5.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db"}, {file = "PyYAML-5.4.1.tar.gz", hash = "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e"}, ] -readchar = [ - {file = "readchar-2.0.1-py2-none-any.whl", hash = "sha256:ed00b7a49bb12f345319d9fa393f289f03670310ada2beb55e8c3f017c648f1e"}, - {file = "readchar-2.0.1-py3-none-any.whl", hash = "sha256:3ac34aab28563bc895f73233d5c08b28f951ca190d5850b8d4bec973132a8dca"}, -] redis = [ {file = "redis-3.5.3-py2.py3-none-any.whl", hash = "sha256:432b788c4530cfe16d8d943a09d40ca6c16149727e4afe8c2c9d5580c59d9f24"}, {file = "redis-3.5.3.tar.gz", hash = "sha256:0e7e0cfca8660dea8b7d5cd8c4f6c5e29e11f31158c0b0ae91a397f00e5a05a2"}, @@ -2129,8 +2033,8 @@ typed-ast = [ {file = "typed_ast-1.4.1.tar.gz", hash = "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b"}, ] typer = [ - {file = "typer-0.3.2-py3-none-any.whl", hash = "sha256:ba58b920ce851b12a2d790143009fa00ac1d05b3ff3257061ff69dbdfc3d161b"}, - {file = "typer-0.3.2.tar.gz", hash = "sha256:5455d750122cff96745b0dec87368f56d023725a7ebc9d2e54dd23dc86816303"}, + {file = "typer-0.4.0-py3-none-any.whl", hash = "sha256:d81169725140423d072df464cad1ff25ee154ef381aaf5b8225352ea187ca338"}, + {file = "typer-0.4.0.tar.gz", hash = "sha256:63c3aeab0549750ffe40da79a1b524f60e08a2cbc3126c520ebf2eeaf507f5dd"}, ] typing-extensions = [ {file = "typing_extensions-3.7.4.3-py2-none-any.whl", hash = "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"}, @@ -2160,10 +2064,6 @@ watchgod = [ {file = "watchgod-0.7-py3-none-any.whl", hash = "sha256:d6c1ea21df37847ac0537ca0d6c2f4cdf513562e95f77bb93abbcf05573407b7"}, {file = "watchgod-0.7.tar.gz", hash = "sha256:48140d62b0ebe9dd9cf8381337f06351e1f2e70b2203fa9c6eff4e572ca84f29"}, ] -wcwidth = [ - {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, - {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"}, -] webencodings = [ {file = "webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78"}, {file = "webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"}, diff --git a/pyproject.toml b/pyproject.toml index d91e61d..1ec992f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,15 +32,13 @@ hyperglass = "hyperglass.console:CLI" Pillow = "^7.2" PyJWT = "^2.0.1" PyYAML = "^5.4.1" -aiofiles = "^0.6.0" -click = "^7.1.2" +aiofiles = "^0.7.0" cryptography = "3.0.0" distro = "^1.5.0" fastapi = "^0.63.0" favicons = ">=0.1.0,<1.0" gunicorn = "^20.1.0" httpx = "^0.17.1" -inquirer = "^2.6.3" loguru = "^0.5.3" netmiko = "^3.4.0" paramiko = "^2.7.2" @@ -49,12 +47,13 @@ py-cpuinfo = "^7.0.0" pydantic = {extras = ["dotenv"], version = "^1.8.2"} python = ">=3.8.1,<4.0" redis = "^3.5.3" +rich = "^10.11.0" scrapli = {version = "2021.07.30", extras = ["asyncssh"]} +typer = "^0.4.0" typing-extensions = "^3.7.4" uvicorn = {extras = ["standard"], version = "^0.13.4"} uvloop = "^0.14.0" xmltodict = "^0.12.0" -rich = "^10.11.0" [tool.poetry.dev-dependencies] bandit = "^1.6.2" diff --git a/version.py b/version.py index a074946..a6f3165 100755 --- a/version.py +++ b/version.py @@ -7,7 +7,7 @@ from typing import Tuple, Union, Pattern from pathlib import Path # Third Party -import click +import typer PACKAGE_JSON = Path(__file__).parent / "hyperglass" / "ui" / "package.json" PACKAGE_JSON_PATTERN = re.compile(r"\s+\"version\"\:\s\"(.+)\"\,$") @@ -24,6 +24,8 @@ UPGRADES = ( ("constants.py", CONSTANTS, CONSTANT_PATTERN), ) +cli = typer.Typer(name="version", no_args_is_help=True) + class Version: """Upgrade a file's version from one version to another.""" @@ -115,20 +117,15 @@ class Version: return (self.old_version, self.new_version) -@click.command( - name="Upgrade hyperglass Version", - help="Update pyproject.toml, constants.py, and package.json version statements", -) -@click.argument("new-version", nargs=1) def update_versions(new_version: str) -> None: - """Upgrade versions in pre-configured files to new version.""" + """Update hyperglass version in all package files.""" for name, file, pattern in UPGRADES: with Version( name=name, file=file, line_pattern=pattern, new_version=new_version, ) as version: version.upgrade() - click.echo(str(version)) + typer.echo(str(version)) if __name__ == "__main__": - update_versions() + typer.run(update_versions)