From 5b4999f12aeb6a9eabf910fbf570adcb4f1fee7e Mon Sep 17 00:00:00 2001 From: Nick Chambers Date: Fri, 19 Aug 2022 12:53:59 -0500 Subject: [PATCH] Improve error reporting and table generation --- vultron/cmd.py | 52 ++++++++--- vultron/cmds/account.py | 5 +- vultron/cmds/help.py | 1 + vultron/cmds/region.py | 16 +++- vultron/cmds/vpc.py | 5 +- vultron/display.py | 190 +++++++++++++++++++++++----------------- vultron/err.py | 4 + vultron/vultron.py | 12 +-- 8 files changed, 174 insertions(+), 111 deletions(-) create mode 100644 vultron/err.py diff --git a/vultron/cmd.py b/vultron/cmd.py index 55822c5..8a23364 100644 --- a/vultron/cmd.py +++ b/vultron/cmd.py @@ -1,20 +1,32 @@ +import inspect import os import vultron.api +import vultron.display +import vultron.err -class CmdNotFound(Exception): +class CmdNotFound(vultron.err.Error): def __str__(self): - return f"error: command not found: {self.args[0]}" + return f"command not found: {self.args[0]}" -class NoApiKey(Exception): - # FIXME: __str__ message - pass +class FeatureMissing(vultron.err.Error): + def __str__(self): + return f"feature not yet implemented: {self.args[0]}" -class FeatureMissing(Exception): - # FIXME: __str__ message - pass +class NoApiKey(vultron.err.Error): + def __str__(self): + return f"no api key provided" -# FIXME: Move command execution into Command class, so wrapper can do pre/post -# work. +class NotEnoughArgs(vultron.err.Error): + def __str__(self): + name = self.args[0] + more = self.args[1] + + if self.args[1] > 1: + return f"sub-command requires {more} more arguments: {name}" + else: + return f"sub-command requires 1 more argument: {name}" + +# FIXME: Handle paging class Command: """A nop Vultron command. This feature is not implemented yet.""" @@ -33,8 +45,19 @@ class Command: self.api = vultron.api.Client(api_key) - def init(self): - pass + def exec_shortcut(self, shortcut, *args): + fn = self.find(shortcut) + sig = inspect.signature(fn) + + if len(args) < len(sig.parameters): + name = fn.__name__[len(self.FTR_PRFX):].lower() + raise NotEnoughArgs(name, len(sig.parameters) - len(args)) + + data = fn(*args) + + if data is not None: + display = vultron.display.FORMATS[self.out](data) + print(display.render()) def find(self, name): needle = f"{self.FTR_PRFX}{name}".lower() @@ -51,7 +74,10 @@ class Command: raise CmdNotFound(name) - def vultron_help(self, *args): + def init(self): + pass + + def vultron_help(self): """This help message.""" prog = os.path.basename(self.prog) diff --git a/vultron/cmds/account.py b/vultron/cmds/account.py index 45ae4e9..b238df7 100644 --- a/vultron/cmds/account.py +++ b/vultron/cmds/account.py @@ -1,4 +1,5 @@ import vultron.cmd +import vultron.display class Account(vultron.cmd.Command): """Query for account information.""" @@ -7,6 +8,4 @@ class Account(vultron.cmd.Command): """Display your account details.""" account = self.api.get("account") - display = vultron.display.render(self.out, [account["account"]]) - - print(display) + return [account["account"]] diff --git a/vultron/cmds/help.py b/vultron/cmds/help.py index a1f7b00..c4bd3cf 100644 --- a/vultron/cmds/help.py +++ b/vultron/cmds/help.py @@ -1,4 +1,5 @@ import vultron.cmd +import vultron.display class Help(vultron.cmd.Command): def init(self): diff --git a/vultron/cmds/region.py b/vultron/cmds/region.py index 3ae43ce..436840e 100644 --- a/vultron/cmds/region.py +++ b/vultron/cmds/region.py @@ -1,10 +1,18 @@ import vultron.cmd +import vultron.display class Region(vultron.cmd.Command): """Query for information on Vultr regions.""" - def vultron_list(self, *args): - regions = self.api.get("regions") - display = vultron.display.render(self.out, regions["regions"]) + def vultron_list(self): + """Display all available regions.""" - print(display) + regions = self.api.get("regions") + return regions["regions"] + + def vultron_plan(self, region_id): + """Display available plans for a given region.""" + + plans = self.api.get("regions", region_id, "availability") + table = [{"plan": plan} for plan in plans["available_plans"]] + return table diff --git a/vultron/cmds/vpc.py b/vultron/cmds/vpc.py index 82d4b50..c9a7400 100644 --- a/vultron/cmds/vpc.py +++ b/vultron/cmds/vpc.py @@ -1,4 +1,5 @@ import vultron.cmd +import vultron.display class Vpc(vultron.cmd.Command): """Query for information on VPCs.""" @@ -7,6 +8,4 @@ class Vpc(vultron.cmd.Command): """Display all VPCs.""" vpcs = self.api.get("vpcs") - display = vultron.display.render(self.out, vpcs["vpcs"]) - - print(display) + return vpcs["vpcs"] diff --git a/vultron/display.py b/vultron/display.py index bdfd4cd..11cef29 100644 --- a/vultron/display.py +++ b/vultron/display.py @@ -1,99 +1,125 @@ -# FIXME: Make this whole package less fragile -TYPES = ["tbl", "chrt", "csv", "json"] +class OutFormat: + def __init__(self, data): + self.spans = { } + self.rows = [ ] -def wall(len, fill="\u2501"): - return fill * len + for row in data: + vals = { } -# FIXME: normalize/analyze table data before building for optimization -def table(data): - lens = dict() + for field in row: + vals[field] = self.normalize(row[field]) - for row in data: - for col in row: - col_len = len(col) - val_len = len(str(row[col])) - max_len = 0 + if field in self.spans: + self.spans[field] = max(self.spans[field], len(vals[field])) + else: + self.spans[field] = max(len(field), len(vals[field])) - if col in lens: - max_len = lens[col] + self.rows.append(vals) - lens[col] = max(col_len, val_len, max_len) + self.cols = list(self.spans.keys()) - col_hdrs = [{"column": col_hdr, "length": lens[col_hdr]} for col_hdr in lens] - out = f"\u250F{wall(col_hdrs[0]['length'] + 2)}" - - for col_hdr in col_hdrs[1:]: - out += f"\u2533{wall(col_hdr['length'] + 2)}" - - out += "\u2513\n" - - for col_hdr in col_hdrs: - out += f"\u2503 {col_hdr['column'].ljust(col_hdr['length'])} " - - out += f"\u2503\n\u2523{wall(col_hdrs[0]['length'] + 2)}" - - for col_hdr in col_hdrs[1:]: - out += f"\u254B{wall(col_hdr['length'] + 2)}" - - out += "\u252B\n" - - for row in data[:-1]: - for col_hdr in col_hdrs: - out += "\u2503 " - - if col_hdr["column"] in row: - out += str(row[col_hdr["column"]]).ljust(col_hdr["length"]) - else: - out += " " * col_hdr["length"] - - out += " " - - out += f"\u2503\n\u2523{wall(col_hdrs[0]['length'] + 2)}" - - for col_hdr in col_hdrs[1:]: - out += f"\u254B{wall(col_hdr['length'] + 2)}" - - out += "\u252B\n" - - for col_hdr in col_hdrs: - out += "\u2503 " - - if col_hdr["column"] in data[-1]: - out += str(data[-1][col_hdr["column"]]).ljust(col_hdr["length"]) + def normalize(self, field): + if type(field) == list: + return ", ".join(field) else: - out += " " * col_hdr["length"] + return str(field) - out += " " + def render(self): + # FIXME: consider if this should do anything + pass - out += "\u2503\n" +class Basic(OutFormat): + def __init__(self, data): + super().__init__(data) - out += f"\u2517{wall(col_hdrs[0]['length'] + 2)}" + self.top = { + "left": "+", "right": "+", + "divide": "+", "fill": "-" + } - for col_hdr in col_hdrs[1:]: - out += f"\u253B{wall(col_hdr['length'] + 2)}" + self.base = { + "left": "+", "right": "+", + "divide": "+", "fill": "-" + } - out += "\u251B" + self.sep = { + "left": "|", "right": "|", + "divide": "+", "fill": "-" + } - return out + self.row = { + "left": "|", "right": "|", + "divide": "|", "fill": " " + } -def chrt(data): - return data + def render(self): + sep = "" + top = "" + hdr = "" + base = "" + fill = self.row["fill"] -def csv(data): - return data + for col in self.cols: + width = self.spans[col] + 2 + sep += self.sep["divide"] + self.sep["fill"] * width + top += self.top["divide"] + self.top["fill"] * width + hdr += self.row["divide"] + fill + col.ljust(width - 1, fill) + base += self.base["divide"] + self.base["fill"] * width -def json(data): - return data + sep = self.sep["left"] + sep[1:] + self.sep["right"] + "\n" + top = self.top["left"] + top[1:] + self.top["right"] + "\n" + hdr = self.row["left"] + hdr[1:] + self.row["right"] + "\n" + base = self.base["left"] + base[1:] + self.base["right"] + lines = [ ] -def render(out, data): - # FIXME: option to pipe into pager, cut off at max column, squeeze, or do - # nothing (depends on TTY/PAGER) - if out == "tbl": - return table(data) - elif out == "chrt": - return chart(data) - elif out == "csv": - return csv(data) - elif out == "json": - return json(data) - # FIXME: error unrecognized out format + for row in self.rows: + line = "" + + for col in self.cols: + width = self.spans[col] + 2 + + if col in row: + line += self.row["divide"] + fill + row[col].ljust(width - 1, fill) + else: + line += slef.row["divide"] + fill * width + + lines.append(self.row["left"] + line[1:] + self.row["right"] + "\n") + + return top + sep.join([hdr, *lines]) + base + +class Table(Basic): + def __init__(self, data): + super().__init__(data) + + self.top = { + "left": "\u250F", "right": "\u2513", + "divide": "\u2533", "fill": "\u2501" + } + + self.base = { + "left": "\u2517", "right": "\u251B", + "divide": "\u253B", "fill": "\u2501" + } + + self.sep = { + "left": "\u2523", "right": "\u252B", + "divide": "\u254B", "fill": "\u2501" + } + + self.row = { + "left": "\u2503", "right": "\u2503", + "divide": "\u2503", "fill": " " + } + +class Json(OutFormat): + pass + +class Csv(OutFormat): + pass + +FORMATS = { + "basic": Basic, + "table": Table, + "json": Json, + "csv": Csv +} diff --git a/vultron/err.py b/vultron/err.py new file mode 100644 index 0000000..b09e5d8 --- /dev/null +++ b/vultron/err.py @@ -0,0 +1,4 @@ +class Error(Exception): + def __str__(self): + # FIXME: should show full traceback? + return f"something went wrong: {self.args}" diff --git a/vultron/vultron.py b/vultron/vultron.py index 10ce1bc..ed748dc 100644 --- a/vultron/vultron.py +++ b/vultron/vultron.py @@ -3,13 +3,15 @@ import os import sys import vultron.cmd import vultron.display +import vultron.err from vultron.cmds import * @click.command() @click.option("--help", is_flag=True) @click.option("--api-key", type=str) -@click.option("--out", type=click.Choice(vultron.display.TYPES), default="tbl") +@click.option("--out", + type=click.Choice(vultron.display.FORMATS.keys()), default="table") @click.argument("args", nargs=-1) # FIXME: click should not show any help messages def app(help, api_key, out, args): @@ -34,9 +36,7 @@ def app(help, api_key, out, args): else: shortcut = vultron_api.default - vultron_fn = vultron_api.find(shortcut) - vultron_fn(*args) - except vultron.cmd.CmdNotFound as err: - # FIXME: Base Vultron error class - print(err, file=sys.stderr) + vultron_api.exec_shortcut(shortcut, *args) + except vultron.err.Error as err: + print(f"error: {err}", file=sys.stderr) # FIXME: Activate help command