Repository: softlayer/softlayer-python
Pull request #1646 (2022-05-18 18:21:54)

Rich tables
Adds colors to tables, and basically re-writes how the table stuff is handled on the backend. 

Default Table colors
![image](https://user-images.githubusercontent.com/7408017/169116588-63a86499-cfa0-43d2-9d60-c79778ad4fa8.png)

JSON Colors
![image](https://user-images.githubusercontent.com/7408017/169116851-ab87d784-0411-4bed-b11c-e78822e6387a.png)

Colored Debug Output:
![image](https://user-images.githubusercontent.com/7408017/169116950-03fc9f0a-20f7-4353-aa70-a3b23376b556.png)


## TODO

Still need to get exceptions piped through Rich Text
![image](https://user-images.githubusercontent.com/7408017/169117079-4dcab3eb-bee8-47ba-b973-3e7d13579da7.png)

Get the unit tests to run on py3.10, not 3.1...


Patch:
diff --git a/SoftLayer/CLI/call_api.py b/SoftLayer/CLI/call_api.py
index c197f95b..7b23038f 100644
--- a/SoftLayer/CLI/call_api.py
+++ b/SoftLayer/CLI/call_api.py
@@ -172,7 +172,7 @@ def cli(env, service, method, parameters, _id, _filters, mask, limit, offset, or
     }
 
     if output_python:
-        env.out(_build_python_example(args, kwargs))
+        env.python_output(_build_python_example(args, kwargs))
     else:
         result = env.client.call(*args, **kwargs)
         env.fout(formatting.iter_to_table(result))
diff --git a/SoftLayer/CLI/core.py b/SoftLayer/CLI/core.py
index 7ce7c50f..e79fc288 100644
--- a/SoftLayer/CLI/core.py
+++ b/SoftLayer/CLI/core.py
@@ -13,6 +13,7 @@
 
 import click
 import requests
+from rich.markup import escape
 
 import SoftLayer
 from SoftLayer.CLI.command import CommandLoader
@@ -143,9 +144,9 @@ def output_diagnostics(env, result, verbose=0, **kwargs):
         diagnostic_table = formatting.Table(['name', 'value'])
         diagnostic_table.add_row(['execution_time', '%fs' % (time.time() - START_TIME)])
 
-        api_call_value = []
+        api_call_value = formatting.Table(['API Calls'], title=None, align="left")
         for call in env.client.transport.get_last_calls():
-            api_call_value.append("%s::%s (%fs)" % (call.service, call.method, call.end_time - call.start_time))
+            api_call_value.add_row(["%s::%s (%fs)" % (call.service, call.method, call.end_time - call.start_time)])
 
         diagnostic_table.add_row(['api_calls', api_call_value])
         diagnostic_table.add_row(['version', consts.USER_AGENT])
@@ -156,13 +157,13 @@ def output_diagnostics(env, result, verbose=0, **kwargs):
 
     if verbose > 1:
         for call in env.client.transport.get_last_calls():
-            call_table = formatting.Table(['', '{}::{}'.format(call.service, call.method)])
+            call_table = formatting.Table(['', '{}::{}'.format(call.service, call.method)], align="left")
             nice_mask = ''
             if call.mask is not None:
                 nice_mask = call.mask
-
             call_table.add_row(['id', call.identifier])
-            call_table.add_row(['mask', nice_mask])
+            #  Need to escape this so Rich doesn't think the mask is a tag and hide it.
+            call_table.add_row(['mask', escape(nice_mask)])
             call_table.add_row(['filter', call.filter])
             call_table.add_row(['limit', call.limit])
             call_table.add_row(['offset', call.offset])
diff --git a/SoftLayer/CLI/dns/zone_import.py b/SoftLayer/CLI/dns/zone_import.py
index 20b39690..2fed0cdc 100644
--- a/SoftLayer/CLI/dns/zone_import.py
+++ b/SoftLayer/CLI/dns/zone_import.py
@@ -5,6 +5,7 @@
 import click
 
 import SoftLayer
+from SoftLayer.CLI.command import SLCommand as SLCommand
 from SoftLayer.CLI import environment
 from SoftLayer.CLI import exceptions
 from SoftLayer.CLI import helpers
@@ -17,9 +18,8 @@
 RECORD_FMT = "type={type}, record={record}, data={data}, ttl={ttl}"
 
 
-@click.command(cls=SoftLayer.CLI.command.SLCommand, )
-@click.argument('zonefile',
-                type=click.Path(exists=True, readable=True, resolve_path=True))
+@click.command(cls=SLCommand)
+@click.argument('zonefile', type=click.Path(exists=True, readable=True, resolve_path=True))
 @click.option('--dry-run', is_flag=True, help="Don't actually create records")
 @environment.pass_env
 def cli(env, zonefile, dry_run):
@@ -31,12 +31,12 @@ def cli(env, zonefile, dry_run):
 
     zone, records, bad_lines = parse_zone_details(zone_contents)
 
-    env.out("Parsed: zone=%s" % zone)
+    click.secho("Parsed: zone=%s" % zone)
     for record in records:
-        env.out("Parsed: %s" % RECORD_FMT.format(**record))
+        click.secho("Parsed: %s" % RECORD_FMT.format(**record), fg="green")
 
     for line in bad_lines:
-        env.out("Unparsed: %s" % line)
+        click.secho("Unparsed: %s" % line, fg="yellow")
 
     if dry_run:
         return
@@ -47,25 +47,19 @@ def cli(env, zonefile, dry_run):
                                      name='zone')
     except exceptions.CLIAbort:
         zone_id = manager.create_zone(zone)['id']
-        env.out(click.style("Created: %s" % zone, fg='green'))
+        click.secho(click.style("Created: %s" % zone, fg='green'))
 
     # Attempt to create each record
     for record in records:
         try:
-            manager.create_record(zone_id,
-                                  record['record'],
-                                  record['type'],
-                                  record['data'],
-                                  record['ttl'])
-
-            env.out(click.style("Created: %s" % RECORD_FMT.format(**record),
-                                fg='green'))
+            manager.create_record(zone_id, record['record'], record['type'], record['data'], record['ttl'])
+
+            click.secho(click.style("Created: %s" % RECORD_FMT.format(**record), fg='green'))
         except SoftLayer.SoftLayerAPIError as ex:
-            env.out(click.style("Failed: %s" % RECORD_FMT.format(**record),
-                                fg='red'))
-            env.out(click.style(str(ex), fg='red'))
+            click.secho(click.style("Failed: %s" % RECORD_FMT.format(**record), fg='red'))
+            click.secho(click.style(str(ex), fg='red'))
 
-    env.out(click.style("Finished", fg='green'))
+    click.secho(click.style("Finished", fg='green'))
 
 
 def parse_zone_details(zone_contents):
@@ -75,7 +69,10 @@ def parse_zone_details(zone_contents):
     zone_lines = [line.strip() for line in zone_contents.split('\n')]
 
     zone_search = re.search(r'^\$ORIGIN (?P<zone>.*)\.', zone_lines[0])
-    zone = zone_search.group('zone')
+    if zone_search:
+        zone = zone_search.group('zone')
+    else:
+        raise exceptions.ArgumentError("Invalid Zone File")
 
     for line in zone_lines[1:]:
         record_search = re.search(RECORD_REGEX, line)
diff --git a/SoftLayer/CLI/environment.py b/SoftLayer/CLI/environment.py
index c96f4a7c..5744b759 100644
--- a/SoftLayer/CLI/environment.py
+++ b/SoftLayer/CLI/environment.py
@@ -6,9 +6,12 @@
     :license: MIT, see LICENSE for more details.
 """
 import importlib
+from json.decoder import JSONDecodeError
 
 import click
 import pkg_resources
+from rich.console import Console
+from rich.syntax import Syntax
 
 import SoftLayer
 from SoftLayer.CLI import formatting
@@ -32,19 +35,38 @@ def __init__(self):
         self.vars = {}
 
         self.client = None
+        self.console = Console()
+        self.err_console = Console(stderr=True)
         self.format = 'table'
         self.skip_confirmations = False
         self.config_file = None
 
         self._modules_loaded = False
 
-    def out(self, output, newline=True):
+    def out(self, output):
         """Outputs a string to the console (stdout)."""
-        click.echo(output, nl=newline)
+        if self.format == 'json':
+            try:
+                self.console.print_json(output)
+            # Tried to print not-json, so just print it out normally...
+            except JSONDecodeError:
+                click.echo(output)
+        elif self.format == 'jsonraw':
+            #  Using Rich here is problematic because in the unit tests it thinks the terminal is 80 characters wide
+            #  and only prints out that many characters.
+            click.echo(output)
+        else:
+            # If we want to print a list of tables, Rich doens't handle that well.
+            if isinstance(output, list):
+                for line in output:
+                    self.console.print(line, overflow='ignore')
+            else:
+                self.console.print(output,  overflow='ignore')
 
     def err(self, output, newline=True):
         """Outputs an error string to the console (stderr)."""
-        click.echo(output, nl=newline, err=True)
+
+        self.err_console.print(output, new_line_start=newline)
 
     def fmt(self, output, fmt=None):
         """Format output based on current the environment format."""
@@ -56,14 +78,18 @@ def format_output_is_json(self):
         """Return True if format output is json or jsonraw"""
         return 'json' in self.format
 
-    def fout(self, output, newline=True):
+    def fout(self, output):
         """Format the input and output to the console (stdout)."""
         if output is not None:
             try:
-                self.out(self.fmt(output), newline=newline)
+                self.out(self.fmt(output))
             except UnicodeEncodeError:
                 # If we hit an undecodeable entry, just try outputting as json.
-                self.out(self.fmt(output, 'json'), newline=newline)
+                self.out(self.fmt(output, 'json'))
+
+    def python_output(self, output):
+        """Prints out python code"""
+        self.console.print(Syntax(output, "python"))
 
     def input(self, prompt, default=None, show_default=True):
         """Provide a command prompt."""
diff --git a/SoftLayer/CLI/event_log/get.py b/SoftLayer/CLI/event_log/get.py
index 2ef96888..56002a8b 100644
--- a/SoftLayer/CLI/event_log/get.py
+++ b/SoftLayer/CLI/event_log/get.py
@@ -47,7 +47,7 @@ def cli(env, date_min, date_max, obj_event, obj_id, obj_type, utc_offset, metada
     row_count = 0
     click.secho(", ".join(columns))
     for log in logs:
-        if isinstance(log, str) or log is None:
+        if log is None:
             click.secho('No logs available for filter %s.' % request_filter, fg='red')
             return
 
diff --git a/SoftLayer/CLI/firewall/add.py b/SoftLayer/CLI/firewall/add.py
index 44e6b3f8..a612d289 100644
--- a/SoftLayer/CLI/firewall/add.py
+++ b/SoftLayer/CLI/firewall/add.py
@@ -1,4 +1,4 @@
-"""Create new firewall."""
+"""Add a server to the firewall"""
 # :license: MIT, see LICENSE for more details.
 
 import click
@@ -36,16 +36,14 @@ def cli(env, target, firewall_type, high_availability):
             pkg = mgr.get_standard_package(target, is_virt=False)
 
         if not pkg:
-            exceptions.CLIAbort(
-                "Unable to add firewall - Is network public enabled?")
+            exceptions.CLIAbort("Unable to add firewall - Is network public enabled?")
 
-        env.out("******************")
-        env.out("Product: %s" % pkg[0]['description'])
-        env.out("Price: $%s monthly" % pkg[0]['prices'][0]['recurringFee'])
-        env.out("******************")
+        click.echo("******************")
+        click.echo("Product: %s" % pkg[0]['description'])
+        click.echo("Price: $%s monthly" % pkg[0]['prices'][0]['recurringFee'])
+        click.echo("******************")
 
-        if not formatting.confirm("This action will incur charges on your "
-                                  "account. Continue?"):
+        if not formatting.confirm("This action will incur charges on your account. Continue?"):
             raise exceptions.CLIAbort('Aborted.')
 
     if firewall_type == 'vlan':
@@ -55,4 +53,4 @@ def cli(env, target, firewall_type, high_availability):
     elif firewall_type == 'server':
         mgr.add_standard_firewall(target, is_virt=False)
 
-    env.fout("Firewall is being created!")
+    click.echo("Firewall is being created!")
diff --git a/SoftLayer/CLI/formatting.py b/SoftLayer/CLI/formatting.py
index fcb5fe62..afe25fff 100644
--- a/SoftLayer/CLI/formatting.py
+++ b/SoftLayer/CLI/formatting.py
@@ -10,8 +10,8 @@
 import os
 
 import click
-
-import prettytable
+from rich import box
+from rich.table import Table as rTable
 
 from SoftLayer.CLI import exceptions
 from SoftLayer import utils
@@ -22,32 +22,27 @@
 def format_output(data, fmt='table'):  # pylint: disable=R0911,R0912
     """Given some data, will format it for console output.
 
-    :param data: One of: String, Table, FormattedItem, List, Tuple,
-                 SequentialOutput
+    :param data: One of: String, Table, FormattedItem, List, Tuple, SequentialOutput
     :param string fmt (optional): One of: table, raw, json, python
     """
-    if isinstance(data, str):
-        if fmt in ('json', 'jsonraw'):
-            return json.dumps(data)
+    if fmt == 'json':
+        return json.dumps(data, indent=4, cls=CLIJSONEncoder)
+    elif fmt == 'jsonraw':
+        return json.dumps(data, cls=CLIJSONEncoder)
+
+    if isinstance(data, str) or isinstance(data, rTable):
         return data
 
     # responds to .prettytable()
-    if hasattr(data, 'prettytable'):
-        if fmt == 'table':
-            return str(format_prettytable(data))
-        elif fmt == 'raw':
-            return str(format_no_tty(data))
+    if hasattr(data, 'prettytable') and fmt in ('table', 'raw'):
+        return format_prettytable(data)
 
     # responds to .to_python()
     if hasattr(data, 'to_python'):
         if fmt == 'json':
-            return json.dumps(
-                format_output(data, fmt='python'),
-                indent=4,
-                cls=CLIJSONEncoder)
+            return json.dumps(format_output(data, fmt='python'), indent=4, cls=CLIJSONEncoder)
         elif fmt == 'jsonraw':
-            return json.dumps(format_output(data, fmt='python'),
-                              cls=CLIJSONEncoder)
+            return json.dumps(format_output(data, fmt='python'), cls=CLIJSONEncoder)
         elif fmt == 'python':
             return data.to_python()
 
@@ -66,10 +61,11 @@ def format_output(data, fmt='table'):  # pylint: disable=R0911,R0912
         output = [format_output(d, fmt=fmt) for d in data]
         if fmt == 'python':
             return output
-        return format_output(listing(output, separator=os.linesep))
+        return output
 
     # fallback, convert this odd object to a string
-    return data
+    # print(f"Casting this to string {data}")
+    return str(data)
 
 
 def format_prettytable(table):
@@ -77,31 +73,7 @@ def format_prettytable(table):
     for i, row in enumerate(table.rows):
         for j, item in enumerate(row):
             table.rows[i][j] = format_output(item)
-
-    ptable = table.prettytable()
-    ptable.hrules = prettytable.FRAME
-    ptable.horizontal_char = '.'
-    ptable.vertical_char = ':'
-    ptable.junction_char = ':'
-    return ptable
-
-
-def format_no_tty(table):
-    """Converts SoftLayer.CLI.formatting.Table instance to a prettytable."""
-
-    for i, row in enumerate(table.rows):
-        for j, item in enumerate(row):
-            table.rows[i][j] = format_output(item, fmt='raw')
     ptable = table.prettytable()
-
-    for col in table.columns:
-        ptable.align[col] = 'l'
-
-    ptable.hrules = prettytable.NONE
-    ptable.border = False
-    ptable.header = False
-    ptable.left_padding_width = 0
-    ptable.right_padding_width = 2
     return ptable
 
 
@@ -118,8 +90,7 @@ def b_to_gb(_bytes):
 
     :param int _bytes: number of bytes
     """
-    return FormattedItem(_bytes,
-                         "%.2fG" % (float(_bytes) / 1024 / 1024 / 1024))
+    return FormattedItem(_bytes, "%.2fG" % (float(_bytes) / 1024 / 1024 / 1024))
 
 
 def gb(gigabytes):  # pylint: disable=C0103
@@ -127,8 +98,7 @@ def gb(gigabytes):  # pylint: disable=C0103
 
     :param int gigabytes: number of gigabytes
     """
-    return FormattedItem(int(float(gigabytes)) * 1024,
-                         "%dG" % int(float(gigabytes)))
+    return FormattedItem(int(float(gigabytes)) * 1024, "%dG" % int(float(gigabytes)))
 
 
 def blank():
@@ -238,6 +208,7 @@ def to_python(self):
         return self
 
     def __str__(self):
+        print("CASTSDFSDFSDFSDFSDF")
         return self.separator.join(str(x) for x in self)
 
 
@@ -257,7 +228,7 @@ class Table(object):
     :param list columns: a list of column names
     """
 
-    def __init__(self, columns, title=None):
+    def __init__(self, columns, title=None, align=None):
         duplicated_cols = [col for col, count
                            in collections.Counter(columns).items()
                            if count > 1]
@@ -267,7 +238,7 @@ def __init__(self, columns, title=None):
 
         self.columns = columns
         self.rows = []
-        self.align = {}
+        self.align = align or {}
         self.sortby = None
         self.title = title
 
@@ -288,28 +259,40 @@ def to_python(self):
         return items
 
     def prettytable(self):
-        """Returns a new prettytable instance."""
-        table = prettytable.PrettyTable(self.columns)
-
+        """Returns a RICH table instance."""
+        table = rTable(title=self.title, box=box.SQUARE, header_style="bright_cyan")
         if self.sortby:
-            if self.sortby in self.columns:
-                table.sortby = self.sortby
-            else:
+            try:
+                # https://docs.python.org/3/howto/sorting.html#key-functions
+                sort_index = self.columns.index(self.sortby)
+                # All the values in `rows` are strings, so we need to cast to int for sorting purposes.
+                self.rows.sort(key=lambda the_row: the_row[sort_index] if not the_row[sort_index].isdigit()
+                               else int(the_row[sort_index]))
+            except ValueError as ex:
                 msg = "Column (%s) doesn't exist to sort by" % self.sortby
-                raise exceptions.CLIAbort(msg)
-
-        if isinstance(self.align, str):
-            table.align = self.align
-        else:
-            # Required because PrettyTable has a strict setter function for alignment
-            for a_col, alignment in self.align.items():
-                table.align[a_col] = alignment
+                raise exceptions.CLIAbort(msg) from ex
+
+        for col in self.columns:
+            justify = "center"
+            style = None
+            # This case aligns all columns in a table
+            if isinstance(self.align, str):
+                justify = self.align
+            # This case alings a specific column
+            elif isinstance(self.align, dict) and self.align.get(col, False):
+                justify = self.align.get(col)
+            # Backwards compatibility with PrettyTable style alignments
+            if justify == 'r':
+                justify = 'right'
+            if justify == 'l':
+                justify = 'left'
+            # Special coloring for some columns
+            if col in ('id', 'Id', 'ID'):
+                style = "pale_violet_red1"
+            table.add_column(col, justify=justify, style=style)
 
-        if self.title:
-            table.title = self.title
-        # Adding rows
         for row in self.rows:
-            table.add_row(row)
+            table.add_row(*row)
         return table
 
 
@@ -346,7 +329,7 @@ def __str__(self):
         """returns the formatted value."""
         # If the original value is None, represent this as 'NULL'
         if self.original is None:
-            return 'NULL'
+            return "NULL"
 
         try:
             return str(self.original)
diff --git a/SoftLayer/CLI/order/preset_list.py b/SoftLayer/CLI/order/preset_list.py
index 68393692..e832cf24 100644
--- a/SoftLayer/CLI/order/preset_list.py
+++ b/SoftLayer/CLI/order/preset_list.py
@@ -35,9 +35,6 @@ def cli(env, package_keyname, keyword, prices):
         slcli order preset-list BARE_METAL_SERVER --keyword gpu
 
     """
-
-    tables = []
-    table = formatting.Table(COLUMNS)
     manager = ordering.OrderingManager(env.client)
 
     _filter = {}
@@ -46,7 +43,7 @@ def cli(env, package_keyname, keyword, prices):
     presets = manager.list_presets(package_keyname, filter=_filter)
 
     if prices:
-        table_prices = formatting.Table(['keyName', 'priceId', 'Hourly', 'Monthly', 'Restriction', 'Location'])
+        table = formatting.Table(['keyName', 'priceId', 'Hourly', 'Monthly', 'Restriction', 'Location'])
         for price in presets:
             locations = []
             if price['locations'] != []:
@@ -55,21 +52,21 @@ def cli(env, package_keyname, keyword, prices):
             cr_max = get_item_price_data(price['prices'][0], 'capacityRestrictionMaximum')
             cr_min = get_item_price_data(price['prices'][0], 'capacityRestrictionMinimum')
             cr_type = get_item_price_data(price['prices'][0], 'capacityRestrictionType')
-            table_prices.add_row([price['keyName'], price['id'],
-                                  get_item_price_data(price['prices'][0], 'hourlyRecurringFee'),
-                                  get_item_price_data(price['prices'][0], 'recurringFee'),
-                                  "%s - %s %s" % (cr_min, cr_max, cr_type), str(locations)])
-        tables.append(table_prices)
+            table.add_row([price['keyName'], price['id'],
+                          get_item_price_data(price['prices'][0], 'hourlyRecurringFee'),
+                          get_item_price_data(price['prices'][0], 'recurringFee'),
+                          "%s - %s %s" % (cr_min, cr_max, cr_type), str(locations)])
 
     else:
+        table = formatting.Table(COLUMNS)
         for preset in presets:
             table.add_row([
                 str(preset['name']).strip(),
                 str(preset['keyName']).strip(),
                 str(preset['description']).strip()
             ])
-        tables.append(table)
-    env.fout(tables)
+
+    env.fout(table)
 
 
 def get_item_price_data(price, item_attribute):
diff --git a/SoftLayer/CLI/virt/bandwidth.py b/SoftLayer/CLI/virt/bandwidth.py
index 298b176d..ecc0e492 100644
--- a/SoftLayer/CLI/virt/bandwidth.py
+++ b/SoftLayer/CLI/virt/bandwidth.py
@@ -32,17 +32,11 @@ def cli(env, identifier, start_date, end_date, summary_period, quite_summary):
 
     Example::
 
-        slcli hw bandwidth 1234 -s 2019-05-01T00:01 -e 2019-05-02T00:00:01.00000-12:00
+        slcli vs bandwidth 1234 -s 2019-05-01T00:01 -e 2019-05-02T00:00:01.00000-12:00
     """
     vsi = SoftLayer.VSManager(env.client)
     vsi_id = helpers.resolve_id(vsi.resolve_ids, identifier, 'VS')
 
-    # Summary period is broken for virtual guests, check VIRT-11733 for a resolution.
-    # For now, we are going to ignore summary_period and set it to the default the API imposes
-    if summary_period != 300:
-        click.secho("""The Summary Period option is currently set to the 300s as the backend API will throw an exception
-any other value. This should be resolved in the next version of the slcli.""", fg='yellow')
-    summary_period = 300
     data = vsi.get_bandwidth_data(vsi_id, start_date, end_date, None, None)
 
     title = "Bandwidth Report: %s - %s" % (start_date, end_date)
diff --git a/SoftLayer/CLI/vpn/ipsec/configure.py b/SoftLayer/CLI/vpn/ipsec/configure.py
index ef5f5a75..7a2cc1f1 100644
--- a/SoftLayer/CLI/vpn/ipsec/configure.py
+++ b/SoftLayer/CLI/vpn/ipsec/configure.py
@@ -24,8 +24,6 @@ def cli(env, context_id):
 
     succeeded = manager.apply_configuration(context_id)
     if succeeded:
-        env.out('Configuration request received for context #{}'
-                .format(context_id))
+        click.echo('Configuration request received for context #{}'.format(context_id))
     else:
-        raise CLIHalt('Failed to enqueue configuration request for context #{}'
-                      .format(context_id))
+        raise CLIHalt('Failed to enqueue configuration request for context #{}'.format(context_id))
diff --git a/SoftLayer/CLI/vpn/ipsec/detail.py b/SoftLayer/CLI/vpn/ipsec/detail.py
index 356e3d50..a0038356 100644
--- a/SoftLayer/CLI/vpn/ipsec/detail.py
+++ b/SoftLayer/CLI/vpn/ipsec/detail.py
@@ -38,26 +38,19 @@ def cli(env, context_id, include):
     manager = SoftLayer.IPSECManager(env.client)
     context = manager.get_tunnel_context(context_id, mask=mask)
 
-    env.out('Context Details:')
     env.fout(_get_context_table(context))
 
     for relation in include:
         if relation == 'at':
-            env.out('Address Translations:')
-            env.fout(_get_address_translations_table(
-                context.get('addressTranslations', [])))
+            env.fout(_get_address_translations_table(context.get('addressTranslations', [])))
         elif relation == 'is':
-            env.out('Internal Subnets:')
-            env.fout(_get_subnets_table(context.get('internalSubnets', [])))
+            env.fout(_get_subnets_table(context.get('internalSubnets', []), title="Internal Subnets"))
         elif relation == 'rs':
-            env.out('Remote Subnets:')
-            env.fout(_get_subnets_table(context.get('customerSubnets', [])))
+            env.fout(_get_subnets_table(context.get('customerSubnets', []), title="Remote Subnets"))
         elif relation == 'sr':
-            env.out('Static Subnets:')
-            env.fout(_get_subnets_table(context.get('staticRouteSubnets', [])))
+            env.fout(_get_subnets_table(context.get('staticRouteSubnets', []), title="Static Subnets"))
         elif relation == 'ss':
-            env.out('Service Subnets:')
-            env.fout(_get_subnets_table(context.get('serviceSubnets', [])))
+            env.fout(_get_subnets_table(context.get('serviceSubnets', []), title="Service Subnets"))
 
 
 def _get_address_translations_table(address_translations):
@@ -71,29 +64,24 @@ def _get_address_translations_table(address_translations):
                               'static IP address id',
                               'remote IP address',
                               'remote IP address id',
-                              'note'])
+                              'note'], title="Address Translations")
     for address_translation in address_translations:
         table.add_row([address_translation.get('id', ''),
-                       address_translation.get('internalIpAddressRecord', {})
-                       .get('ipAddress', ''),
+                       address_translation.get('internalIpAddressRecord', {}).get('ipAddress', ''),
                        address_translation.get('internalIpAddressId', ''),
-                       address_translation.get('customerIpAddressRecord', {})
-                       .get('ipAddress', ''),
+                       address_translation.get('customerIpAddressRecord', {}).get('ipAddress', ''),
                        address_translation.get('customerIpAddressId', ''),
                        address_translation.get('notes', '')])
     return table
 
 
-def _get_subnets_table(subnets):
+def _get_subnets_table(subnets, title):
     """Yields a formatted table to print subnet details.
 
     :param List[dict] subnets: List of subnets.
     :return Table: Formatted for subnet output.
     """
-    table = formatting.Table(['id',
-                              'network identifier',
-                              'cidr',
-                              'note'])
+    table = formatting.Table(['id', 'network identifier', 'cidr', 'note'], title=title)
     for subnet in subnets:
         table.add_row([subnet.get('id', ''),
                        subnet.get('networkIdentifier', ''),
@@ -163,34 +151,26 @@ def _get_context_table(context):
     :param dict context: The tunnel context
     :return Table: Formatted for tunnel context output
     """
-    table = formatting.KeyValueTable(['name', 'value'])
+    table = formatting.KeyValueTable(['name', 'value'], title='Context Details')
     table.align['name'] = 'r'
     table.align['value'] = 'l'
 
     table.add_row(['id', context.get('id', '')])
     table.add_row(['name', context.get('name', '')])
     table.add_row(['friendly name', context.get('friendlyName', '')])
-    table.add_row(['internal peer IP address',
-                   context.get('internalPeerIpAddress', '')])
-    table.add_row(['remote peer IP address',
-                   context.get('customerPeerIpAddress', '')])
-    table.add_row(['advanced configuration flag',
-                   context.get('advancedConfigurationFlag', '')])
+    table.add_row(['internal peer IP address', context.get('internalPeerIpAddress', '')])
+    table.add_row(['remote peer IP address', context.get('customerPeerIpAddress', '')])
+    table.add_row(['advanced configuration flag', context.get('advancedConfigurationFlag', '')])
     table.add_row(['preshared key', context.get('presharedKey', '')])
-    table.add_row(['phase 1 authentication',
-                   context.get('phaseOneAuthentication', '')])
-    table.add_row(['phase 1 diffie hellman group',
-                   context.get('phaseOneDiffieHellmanGroup', '')])
+    table.add_row(['phase 1 authentication', context.get('phaseOneAuthentication', '')])
+    table.add_row(['phase 1 diffie hellman group', context.get('phaseOneDiffieHellmanGroup', '')])
     table.add_row(['phase 1 encryption', context.get('phaseOneEncryption', '')])
     table.add_row(['phase 1 key life', context.get('phaseOneKeylife', '')])
-    table.add_row(['phase 2 authentication',
-                   context.get('phaseTwoAuthentication', '')])
-    table.add_row(['phase 2 diffie hellman group',
-                   context.get('phaseTwoDiffieHellmanGroup', '')])
+    table.add_row(['phase 2 authentication', context.get('phaseTwoAuthentication', '')])
+    table.add_row(['phase 2 diffie hellman group', context.get('phaseTwoDiffieHellmanGroup', '')])
     table.add_row(['phase 2 encryption', context.get('phaseTwoEncryption', '')])
     table.add_row(['phase 2 key life', context.get('phaseTwoKeylife', '')])
-    table.add_row(['phase 2 perfect forward secrecy',
-                   context.get('phaseTwoPerfectForwardSecrecy', '')])
+    table.add_row(['phase 2 perfect forward secrecy', context.get('phaseTwoPerfectForwardSecrecy', '')])
     table.add_row(['created', context.get('createDate')])
     table.add_row(['modified', context.get('modifyDate')])
     return table
diff --git a/SoftLayer/utils.py b/SoftLayer/utils.py
index d34befb8..56781d8a 100644
--- a/SoftLayer/utils.py
+++ b/SoftLayer/utils.py
@@ -7,9 +7,11 @@
 """
 import collections
 import datetime
+from json import JSONDecoder
 import re
 import time
 
+
 # pylint: disable=no-member, invalid-name
 
 UUID_RE = re.compile(r'^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$', re.I)
@@ -432,3 +434,26 @@ def format_comment(comment, max_line_length=60):
 def clean_dict(dictionary):
     """Removes any `None` entires from the dictionary"""
     return {k: v for k, v in dictionary.items() if v}
+
+
+NOT_WHITESPACE = re.compile(r'[^\s]')
+
+
+def decode_stacked(document, pos=0, decoder=JSONDecoder()):
+    """Used for converting CLI output to JSON datastructures. Specially for unit tests
+
+    https://stackoverflow.com/questions/27907633/how-to-extract-multiple-json-objects-from-one-file
+    Example::
+        split_output = []
+        # Converts Rich JSON output to actual JSON data. JSON UTIL
+        for table in utils.decode_stacked(result.output):
+            split_output.append(table)
+    """
+    while True:
+        match = NOT_WHITESPACE.search(document, pos)
+        if not match:
+            return
+        pos = match.start()
+        obj, pos = decoder.raw_decode(document, pos)
+
+        yield obj
diff --git a/tox.ini b/tox.ini
index 54cfb633..b62d1797 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,5 +1,5 @@
 [tox]
-envlist = py36,py37,py38,py39,pypy3,analysis,coverage,docs
+envlist = py37,py38,py39,py310,pypy3,analysis,coverage,docs
 
 
 [flake8]


Test patch:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 7d6d35a6..0e6d546b 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -10,7 +10,7 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        python-version: [3.6,3.7,3.8,3.9]
+        python-version: [3.7,3.8,3.9,'3.10']
 
     steps:
     - uses: actions/checkout@v2
diff --git a/tests/CLI/environment_tests.py b/tests/CLI/environment_tests.py
index 8829bf93..e0b739b1 100644
--- a/tests/CLI/environment_tests.py
+++ b/tests/CLI/environment_tests.py
@@ -9,6 +9,7 @@
 from unittest import mock as mock
 
 from SoftLayer.CLI import environment
+from SoftLayer.CLI import formatting
 from SoftLayer import testing
 
 
@@ -44,9 +45,7 @@ def test_get_command(self):
     @mock.patch('click.prompt')
     def test_input(self, prompt_mock):
         r = self.env.input('input')
-        prompt_mock.assert_called_with('input',
-                                       default=None,
-                                       show_default=True)
+        prompt_mock.assert_called_with('input', default=None, show_default=True)
         self.assertEqual(prompt_mock(), r)
 
     @mock.patch('click.prompt')
@@ -71,17 +70,18 @@ def test_resolve_alias(self):
         r = self.env.resolve_alias('realname')
         self.assertEqual(r, 'realname')
 
-    @mock.patch('click.echo')
-    def test_print_unicode(self, echo):
-        output = "\u3010TEST\u3011 image"
-        # https://docs.python.org/3.6/library/exceptions.html#UnicodeError
-        echo.side_effect = [
-            UnicodeEncodeError('utf8', output, 0, 1, "Test Exception"),
-            output
-        ]
-        self.env.fout(output)
-        self.assertEqual(2, echo.call_count)
-
     def test_format_output_is_json(self):
         self.env.format = 'jsonraw'
         self.assertTrue(self.env.format_output_is_json())
+
+    @mock.patch('rich.console.Console.print')
+    def test_multiple_tables(self, console):
+        tables = []
+        table1 = formatting.Table(["First", "Second"])
+        table1.add_row(["1", 2])
+        table2 = formatting.Table(["T2-1", "T2-2"])
+        table2.add_row(["zzzzzzz", "123123123"])
+        tables.append(table1)
+        tables.append(table2)
+        self.env.out(tables)
+        self.assertEqual(2, len(console.call_args_list))
diff --git a/tests/CLI/helper_tests.py b/tests/CLI/helper_tests.py
index e3a6218c..cecb3293 100644
--- a/tests/CLI/helper_tests.py
+++ b/tests/CLI/helper_tests.py
@@ -11,6 +11,7 @@
 import tempfile
 
 import click
+from rich.table import Table
 from unittest import mock as mock
 
 from SoftLayer.CLI import core
@@ -266,19 +267,6 @@ def test_format_output_string(self):
         t = formatting.format_output('just a string', 'raw')
         self.assertEqual('just a string', t)
 
-        t = formatting.format_output(b'just a string', 'raw')
-        self.assertEqual(b'just a string', t)
-
-    def test_format_output_raw(self):
-        t = formatting.Table(['nothing'])
-        t.align['nothing'] = 'c'
-        t.add_row(['testdata'])
-        t.sortby = 'nothing'
-        ret = formatting.format_output(t, 'raw')
-
-        self.assertNotIn('nothing', str(ret))
-        self.assertIn('testdata', str(ret))
-
     def test_format_output_json(self):
         t = formatting.Table(['nothing'])
         t.align['nothing'] = 'c'
@@ -342,7 +330,7 @@ def test_format_output_formatted_item(self):
     def test_format_output_list(self):
         item = ['this', 'is', 'a', 'list']
         ret = formatting.format_output(item, 'table')
-        self.assertEqual(os.linesep.join(item), ret)
+        self.assertEqual("['this', 'is', 'a', 'list']", str(ret))
 
     def test_format_output_table(self):
         t = formatting.Table(['nothing'])
@@ -350,13 +338,11 @@ def test_format_output_table(self):
         t.add_row(['testdata'])
         t.sortby = 'nothing'
         ret = formatting.format_output(t, 'table')
-
-        self.assertIn('nothing', str(ret))
-        self.assertIn('testdata', str(ret))
+        self.assertIsInstance(ret, Table)
 
     def test_unknown(self):
         t = formatting.format_output({}, 'raw')
-        self.assertEqual({}, t)
+        self.assertEqual('{}', t)
 
     def test_sequentialoutput(self):
         # specifying the separator prevents windows from using \n\r
@@ -380,7 +366,7 @@ def test_format_output_python(self):
         self.assertEqual(['just a string'], t)
 
         t = formatting.format_output({'test_key': 'test_value'}, 'python')
-        self.assertEqual({'test_key': 'test_value'}, t)
+        self.assertEqual("{'test_key': 'test_value'}", t)
 
     def test_format_output_python_keyvaluetable(self):
         t = formatting.KeyValueTable(['key', 'value'])
diff --git a/tests/CLI/modules/account_tests.py b/tests/CLI/modules/account_tests.py
index 8b4fe973..7e0792c0 100644
--- a/tests/CLI/modules/account_tests.py
+++ b/tests/CLI/modules/account_tests.py
@@ -44,11 +44,14 @@ def test_event_jsonraw_output(self):
         command = '--format jsonraw account events'
         command_params = command.split()
         result = self.run_command(command_params)
+
         json_text_tables = result.stdout.split('\n')
+        print("RESULT: {}".format(result.output))
         # removing an extra item due to an additional Newline at the end of the output
         json_text_tables.pop()
         # each item in the json_text_tables should be a list
         for json_text_table in json_text_tables:
+            print("TESTING THIS: \n{}\n".format(json_text_table))
             json_table = json.loads(json_text_table)
             self.assertIsInstance(json_table, list)
 
diff --git a/tests/CLI/modules/block_tests.py b/tests/CLI/modules/block_tests.py
index 9f449978..8241087b 100644
--- a/tests/CLI/modules/block_tests.py
+++ b/tests/CLI/modules/block_tests.py
@@ -5,7 +5,6 @@
     :license: MIT, see LICENSE for more details.
 """
 from SoftLayer.CLI import exceptions
-from SoftLayer.CLI import formatting
 from SoftLayer import SoftLayerAPIError
 from SoftLayer import testing
 
@@ -166,13 +165,11 @@ def test_volume_list_reduced_notes_format_output_table(self, list_mock):
         list_mock.return_value = [
             {'notes': note_mock}
         ]
-        expected_table = formatting.Table(['notes'])
-        expected_table.add_row([expected_reduced_note])
-        expected_output = formatting.format_output(expected_table)+'\n'
         result = self.run_command(['--format', 'table', 'block', 'volume-list', '--columns', 'notes'])
 
         self.assert_no_fail(result)
-        self.assertEqual(expected_output, result.output)
+        self.assertIn(expected_reduced_note, result.output)
+        self.assertNotIn(note_mock, result.output)
 
     def test_volume_list_order(self):
         result = self.run_command(['block', 'volume-list', '--order=1234567'])
diff --git a/tests/CLI/modules/call_api_tests.py b/tests/CLI/modules/call_api_tests.py
index 03c861d2..f7e5855a 100644
--- a/tests/CLI/modules/call_api_tests.py
+++ b/tests/CLI/modules/call_api_tests.py
@@ -93,8 +93,9 @@ def test_python_output(self):
                                    '--id=100',
                                    '-f nested.property=5432',
                                    '--output-python'])
-
+        print("OUTPUT: \n{}".format(result.exception))
         self.assert_no_fail(result)
+
         self.assertIsNotNone(result.output, """import SoftLayer
 
 client = SoftLayer.create_client_from_env()
@@ -162,14 +163,12 @@ def test_object_table(self):
 
         self.assert_no_fail(result)
         # NOTE(kmcdonald): Order is not guaranteed
-        self.assertIn(":........:........:", result.output)
-        self.assertIn(":   name : value  :", result.output)
-        self.assertIn(":    int : 10     :", result.output)
-        self.assertIn(":   None : None   :", result.output)
-        self.assertIn(":  float : 1.0    :", result.output)
-        self.assertIn(":   Bool : True   :", result.output)
-        self.assertIn(": string : string :", result.output)
-        self.assertIn(":........:........:", result.output)
+        self.assertIn("│   name │ value  │", result.output)
+        self.assertIn("│    int │ 10     │", result.output)
+        self.assertIn("│   None │ None   │", result.output)
+        self.assertIn("│  float │ 1.0    │", result.output)
+        self.assertIn("│   Bool │ True   │", result.output)
+        self.assertIn("│ string │ string │", result.output)
 
     def test_object_nested(self):
         mock = self.set_mock('SoftLayer_Service', 'method')
@@ -210,11 +209,11 @@ def test_list_table(self):
 
         self.assert_no_fail(result)
         self.assertEqual(result.output,
-                         """:......:......:.......:.....:........:
-: Bool : None : float : int : string :
-:......:......:.......:.....:........:
-: True : None :  1.0  :  10 : string :
-:......:......:.......:.....:........:
+                         """┌──────┬──────┬───────┬─────┬────────┐
+│ Bool │ None │ float │ int │ string │
+├──────┼──────┼───────┼─────┼────────┤
+│ True │ None │  1.0  │ 10  │ string │
+└──────┴──────┴───────┴─────┴────────┘
 """)
 
     def test_parameters(self):
@@ -294,7 +293,6 @@ def test_filter_with_filter(self):
         self.assertIsInstance(result.exception, exceptions.CLIAbort)
 
     def test_json_filter(self):
-        pass
         result = self.run_command(['call-api', 'Account', 'getObject', '--json-filter={"test":"something"}'])
         self.assert_no_fail(result)
         self.assert_called_with('SoftLayer_Account', 'getObject', filter={"test": "something"})
@@ -316,3 +314,11 @@ def test_call_api_orderBy(self):
                                                     'value': ['DESC']}]},
                                             'typeId': {'operation': 1}}
                                 })
+
+    def test_very_verbose(self):
+        result = self.run_command(['-vvv', 'call-api', 'Account', 'getObject'])
+        self.assert_no_fail(result)
+        self.assert_called_with('SoftLayer_Account', 'getObject')
+        self.assertIn("ORIGIN_PULL", result.output)
+        self.assertIn("python_version", result.output)
+        self.assertIn("offset", result.output)
diff --git a/tests/CLI/modules/file_tests.py b/tests/CLI/modules/file_tests.py
index 06595dee..6bd56bd0 100644
--- a/tests/CLI/modules/file_tests.py
+++ b/tests/CLI/modules/file_tests.py
@@ -5,7 +5,6 @@
     :license: MIT, see LICENSE for more details.
 """
 from SoftLayer.CLI import exceptions
-from SoftLayer.CLI import formatting
 from SoftLayer import SoftLayerError
 from SoftLayer import testing
 
@@ -87,13 +86,11 @@ def test_volume_list_reduced_notes_format_output_table(self, list_mock):
         list_mock.return_value = [
             {'notes': note_mock}
         ]
-        expected_table = formatting.Table(['notes'])
-        expected_table.add_row([expected_reduced_note])
-        expected_output = formatting.format_output(expected_table) + '\n'
         result = self.run_command(['--format', 'table', 'file', 'volume-list', '--columns', 'notes'])
 
         self.assert_no_fail(result)
-        self.assertEqual(expected_output, result.output)
+        self.assertIn(expected_reduced_note, result.output)
+        self.assertNotIn(note_mock, result.output)
 
     @mock.patch('SoftLayer.FileStorageManager.list_file_volumes')
     def test_volume_count(self, list_mock):
diff --git a/tests/CLI/modules/ipsec_tests.py b/tests/CLI/modules/ipsec_tests.py
index 502a244d..777df8b9 100644
--- a/tests/CLI/modules/ipsec_tests.py
+++ b/tests/CLI/modules/ipsec_tests.py
@@ -10,6 +10,7 @@
 from SoftLayer.CLI.exceptions import ArgumentError
 from SoftLayer.CLI.exceptions import CLIHalt
 from SoftLayer import testing
+from SoftLayer import utils
 
 
 class IPSECTests(testing.TestCase):
@@ -60,8 +61,7 @@ def test_ipsec_detail(self):
                  'addressTranslations[internalIpAddressRecord[ipAddress],'
                  'customerIpAddressRecord[ipAddress]],internalSubnets,'
                  'customerSubnets,staticRouteSubnets,serviceSubnets]]')
-        mock = self.set_mock('SoftLayer_Account',
-                             'getNetworkTunnelContexts')
+        mock = self.set_mock('SoftLayer_Account', 'getNetworkTunnelContexts')
         mock.return_value = [{
             'id': 445,
             'name': 'der tunnel',
@@ -114,19 +114,16 @@ def test_ipsec_detail(self):
                 'note': 'Static Network'
             }]
         }]
-        result = self.run_command(['ipsec', 'detail', '445', '-iat', '-iis',
-                                   '-irs', '-isr', '-iss'])
-        empty, output = result.output.split('Context Details:\n')
-        context, output = output.split('Address Translations:\n')
-        translations, output = output.split('Internal Subnets:\n')
-        internal_subnets, output = output.split('Remote Subnets:\n')
-        remote_subnets, output = output.split('Static Subnets:\n')
-        static_subnets, service_subnets = output.split('Service Subnets:\n')
+        result = self.run_command(['ipsec', 'detail', '445', '-iat', '-iis', '-irs', '-isr', '-iss'])
+
+        split_output = []
+        # Converts Rich JSON output to actual JSON data. JSON UTIL
+        for table in utils.decode_stacked(result.output):
+            split_output.append(table)
 
+        self.assertEqual(6, len(split_output))
         self.assert_no_fail(result)
-        self.assert_called_with('SoftLayer_Account',
-                                'getNetworkTunnelContexts',
-                                mask=_mask)
+        self.assert_called_with('SoftLayer_Account', 'getNetworkTunnelContexts', mask=_mask)
         self.assertEqual({'id': 445,
                           'name': 'der tunnel',
                           'friendly name': 'the tunnel',
@@ -145,34 +142,34 @@ def test_ipsec_detail(self):
                           'phase 2 perfect forward secrecy': 0,
                           'created': '2017-05-17T12:00:00-06:00',
                           'modified': '2017-05-17T12:01:00-06:00'},
-                         json.loads(context))
+                         split_output[0])
         self.assertEqual([{'id': 872341,
                            'remote IP address': '50.0.0.1',
                            'remote IP address id': 872422,
                            'static IP address': '10.0.0.1',
                            'static IP address id': 982341,
                            'note': 'surprise!'}],
-                         json.loads(translations))
+                         split_output[1])
         self.assertEqual([{'id': 324113,
                            'network identifier': '10.20.0.0',
                            'cidr': 29,
                            'note': 'Private Network'}],
-                         json.loads(internal_subnets))
+                         split_output[2])
         self.assertEqual([{'id': 873411,
                            'network identifier': '50.0.0.0',
                            'cidr': 26,
                            'note': 'Offsite Network'}],
-                         json.loads(remote_subnets))
+                         split_output[3])
         self.assertEqual([{'id': 998232,
                            'network identifier': '50.50.0.0',
                            'cidr': 29,
                            'note': 'Static Network'}],
-                         json.loads(static_subnets))
+                         split_output[4])
         self.assertEqual([{'id': 565312,
                            'network identifier': '100.10.0.0',
                            'cidr': 26,
                            'note': 'Service Network'}],
-                         json.loads(service_subnets))
+                         split_output[5])
 
     def test_ipsec_list(self):
         mock = self.set_mock('SoftLayer_Account', 'getNetworkTunnelContexts')
diff --git a/tests/CLI/modules/object_storage_tests.py b/tests/CLI/modules/object_storage_tests.py
index b5a219f6..e76fddff 100644
--- a/tests/CLI/modules/object_storage_tests.py
+++ b/tests/CLI/modules/object_storage_tests.py
@@ -52,7 +52,7 @@ def test_create_credential_by_username(self, resolve_id_mock):
     def test_delete_credential(self):
         result = self.run_command(['object-storage', 'credential', 'delete', '-c', 100, '100'])
         self.assert_no_fail(result)
-        self.assertEqual(result.output, 'True\n')
+        self.assertEqual(result.output, 'true\n')
 
     @mock.patch('SoftLayer.CLI.helpers.resolve_id')
     def test_delete_credential_by_username(self, resolve_id_mock):
diff --git a/tests/CLI/modules/order_tests.py b/tests/CLI/modules/order_tests.py
index 5eec6c18..d493b67f 100644
--- a/tests/CLI/modules/order_tests.py
+++ b/tests/CLI/modules/order_tests.py
@@ -141,9 +141,8 @@ def test_place(self):
 
         self.assert_no_fail(result)
         self.assert_called_with('SoftLayer_Product_Order', 'placeOrder')
-        self.assertEqual('Warning: Closed soon: ams01.pod01, wdc07.pod01, TEST00.pod2\n'
-                         '{\n    "id": 1234,\n    "created": "2017-04-04 07:39:20",\n    "status": "APPROVED"\n}\n',
-                         str(result.output))
+        self.assertIn('Warning: Closed soon: ams01.pod01, wdc07.pod01, TEST00.pod2', result.output)
+        self.assertIn('"status": "APPROVED"', result.output)
 
     def test_place_with_quantity(self):
         order_date = '2017-04-04 07:39:20'
@@ -161,9 +160,8 @@ def test_place_with_quantity(self):
 
         self.assert_no_fail(result)
         self.assert_called_with('SoftLayer_Product_Order', 'placeOrder')
-        self.assertEqual('Warning: Closed soon: ams01.pod01, wdc07.pod01, TEST00.pod2\n'
-                         '{\n    "id": 1234,\n    "created": "2017-04-04 07:39:20",\n    "status": "APPROVED"\n}\n',
-                         str(result.output))
+        self.assertIn('Warning: Closed soon: ams01.pod01, wdc07.pod01, TEST00.pod2', result.output)
+        self.assertIn('"status": "APPROVED"', result.output)
 
     def test_place_extras_parameter_fail(self):
         result = self.run_command(['-y', 'order', 'place', 'package', 'DALLAS13', 'ITEM1',
@@ -279,8 +277,7 @@ def test_preset_list(self):
 
         self.assert_no_fail(result)
         self.assert_called_with('SoftLayer_Product_Package', 'getActivePresets')
-        self.assert_called_with('SoftLayer_Product_Package',
-                                'getAccountRestrictedActivePresets')
+        self.assert_called_with('SoftLayer_Product_Package', 'getAccountRestrictedActivePresets')
         self.assertEqual([{'name': 'active1',
                            'keyName': 'PRESET1',
                            'description': 'description1'},
diff --git a/tests/CLI/modules/server_tests.py b/tests/CLI/modules/server_tests.py
index 8e7382e1..9f8653c0 100644
--- a/tests/CLI/modules/server_tests.py
+++ b/tests/CLI/modules/server_tests.py
@@ -17,6 +17,7 @@
 from SoftLayer.fixtures import SoftLayer_Product_Order
 from SoftLayer import SoftLayerError
 from SoftLayer import testing
+from SoftLayer import utils
 
 
 class ServerCLITests(testing.TestCase):
@@ -397,9 +398,8 @@ def test_create_server(self, order_mock):
                                    ])
 
         self.assert_no_fail(result)
-        self.assertEqual(
-            str(result.output),
-            'Warning: Closed soon: TEST00.pod2\n{\n    "id": 98765,\n    "created": "2013-08-02 15:23:47"\n}\n')
+        self.assertIn('Warning: Closed soon: TEST00.pod2', result.output)
+        self.assertIn('"id": 98765', result.output)
 
     @mock.patch('SoftLayer.CLI.template.export_to_template')
     def test_create_server_with_export(self, export_mock):
@@ -622,13 +622,13 @@ def test_toggle_ipmi_on(self):
         mock.return_value = True
         result = self.run_command(['server', 'toggle-ipmi', '--enable', '12345'])
         self.assert_no_fail(result)
-        self.assertEqual(result.output, 'True\n')
+        self.assertEqual(result.output, 'true\n')
 
     def test_toggle_ipmi_off(self):
         mock.return_value = True
         result = self.run_command(['server', 'toggle-ipmi', '--disable', '12345'])
         self.assert_no_fail(result)
-        self.assertEqual(result.output, 'True\n')
+        self.assertEqual(result.output, 'true\n')
 
     def test_bandwidth_hw(self):
         if sys.version_info < (3, 6):
@@ -637,17 +637,17 @@ def test_bandwidth_hw(self):
         self.assert_no_fail(result)
 
         date = '2019-05-20 23:00'
-        # number of characters from the end of output to break so json can parse properly
-        pivot = 157
         # only pyhon 3.7 supports the timezone format slapi uses
         if sys.version_info < (3, 7):
             date = '2019-05-20T23:00:00-06:00'
-            pivot = 166
-        # Since this is 2 tables, it gets returned as invalid json like "[{}][{}]"" instead of "[[{}],[{}]]"
-        # so we just do some hacky string substitution to pull out the respective arrays that can be jsonifyied
 
-        output_summary = json.loads(result.output[0:-pivot])
-        output_list = json.loads(result.output[-pivot:])
+        split_output = []
+        for table in utils.decode_stacked(result.output):
+            split_output.append(table)
+
+        self.assertEqual(len(split_output), 2)
+        output_summary = split_output[0]
+        output_list = split_output[1]
 
         self.assertEqual(output_summary[0]['Average MBps'], 0.3841)
         self.assertEqual(output_summary[1]['Max Date'], date)