diff --git a/patroni/ctl.py b/patroni/ctl.py index d1e7e127..483c77fe 100644 --- a/patroni/ctl.py +++ b/patroni/ctl.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- ''' Patroni Control ''' @@ -32,7 +33,7 @@ from patroni.utils import cluster_as_json, patch_config, polling_loop from patroni.request import PatroniRequest from patroni.version import __version__ from six.moves.urllib_parse import urlparse -from terminaltables import SingleTable +from texttable import Texttable CONFIG_DIR_PATH = click.get_app_dir('patroni') CONFIG_FILE_PATH = os.path.join(CONFIG_DIR_PATH, 'patronictl.yaml') @@ -46,6 +47,44 @@ class PatroniCtlException(ClickException): pass +class PatronictlTexttable(Texttable): + + def __init__(self, header=None): + Texttable.__init__(self, 0) + self.__table_header = header + self.__hline_num = 0 + + if sys.platform != 'win32': + self._char_horiz = u'─' + self._char_vert = u'│' + self._hline_header = self._hline + self._char_header = self._char_horiz + + def _is_first_hline(self): + return self.__hline_num == 0 + + def _is_last_hline(self): + return self.__hline_num > (len(self._rows) if self._has_hlines() else int(bool(self._header))) + + def _hline(self): + if sys.platform == 'win32': + left = right = self._char_corner + elif self._is_first_hline(): + left, self._char_corner, right = u'┌', u'┬', u'┐' + elif not self._is_last_hline(): + left, self._char_corner, right = u'├', u'┼', u'┤' + else: + left, self._char_corner, right = u'└', u'┴', u'┘' + + line = self._build_hline() + + if self._is_first_hline() and self.__table_header: + left += self.__table_header + + self.__hline_num += 1 + return left + line[len(left):-2] + right + '\n' + + def parse_dcs(dcs): if dcs is None: return None @@ -145,20 +184,25 @@ def print_output(columns, rows, alignment=None, fmt='pretty', header=None, delim for row in rows: if row[i]: row[i] = format_config_for_editing(row[i], fmt == 'tsv').strip() - s = int(list_cluster and fmt == 'pretty') # skip cluster name if pretty-printing - table_data = ([columns[s:]] if columns else []) + [row[s:] for row in rows] + if list_cluster and fmt == 'pretty': # skip cluster name if pretty-printing + columns = columns[1:] if columns else [] + rows = [row[1:] for row in rows] if fmt == 'tsv': - for r in table_data: + for r in [columns] + rows: click.echo(delimiter.join(map(str, r))) else: - table = SingleTable(table_data, header) - table.inner_row_border = any(any(isinstance(c, six.string_types) and '\n' in c for c in r) for r in rows) - for i, name in enumerate(table_data[0]): - default = 'left' - jmap = {m[0]: m for m in ('center', default, 'right')} - table.justify_columns[i] = jmap.get((alignment or {}).get(name, default)[0], default) - click.echo(table.table) + table = PatronictlTexttable(header) + if not any(any(isinstance(c, six.string_types) and '\n' in c for c in r) for r in rows): + table.set_deco(Texttable.VLINES | Texttable.BORDER | Texttable.HEADER) + if rows: + if columns: + table.header(columns) + table.set_cols_align([(alignment or {}).get(c, 'l') for c in columns]) + table.add_rows(rows, header=False) + else: + table.add_rows([columns], header=False) + click.echo(table.draw()) def watching(w, watch, max_count=None, clear=True): diff --git a/requirements.txt b/requirements.txt index 1b3b2928..b4cc3796 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ kazoo>=1.3.1 python-etcd>=0.4.3,<0.5 python-consul>=0.7.1 click>=4.1 -terminaltables>=3.1.0 +texttable python-dateutil psutil>=2.0.0 cdiff diff --git a/tests/test_ctl.py b/tests/test_ctl.py index 8d09be8e..989d1181 100644 --- a/tests/test_ctl.py +++ b/tests/test_ctl.py @@ -7,7 +7,7 @@ from datetime import datetime, timedelta from mock import patch, Mock from patroni.ctl import ctl, store_config, load_config, output_members, get_dcs, parse_dcs, \ get_all_members, get_any_member, get_cursor, query_member, configure, PatroniCtlException, apply_config_changes, \ - format_config_for_editing, show_diff, invoke_editor, format_pg_version, find_executable + format_config_for_editing, show_diff, invoke_editor, format_pg_version, find_executable, print_output from patroni.dcs.etcd import Client, Failover from patroni.utils import tzutc from psycopg2 import OperationalError @@ -32,7 +32,8 @@ def test_rw_config(): @patch('patroni.ctl.load_config', - Mock(return_value={'scope': 'alpha', 'postgresql': {'data_dir': '.', 'pgpass': './pgpass', 'parameters': {}, 'retry_timeout': 5}, + Mock(return_value={'scope': 'alpha', 'postgresql': {'data_dir': '.', 'pgpass': './pgpass', + 'parameters': {}, 'retry_timeout': 5}, 'restapi': {'listen': '::', 'certfile': 'a'}, 'etcd': {'host': 'localhost:2379'}})) class TestCtl(unittest.TestCase): @@ -161,6 +162,12 @@ class TestCtl(unittest.TestCase): def test_get_dcs(self): self.assertRaises(PatroniCtlException, get_dcs, {'dummy': {}}, 'dummy') + @patch('sys.platform', 'win32') + @patch('click.echo') + def test_print_output(self, mock_click_echo): + print_output(['a'], []) + mock_click_echo.assert_called_once_with('+---+\n| a |\n+---+') + @patch('psycopg2.connect', psycopg2_connect) @patch('patroni.ctl.query_member', Mock(return_value=([['mock column']], None))) @patch('patroni.ctl.get_dcs')