diff options
-rw-r--r-- | .pre-commit-config.yaml | 1 | ||||
-rw-r--r-- | changelog.rst | 21 | ||||
-rw-r--r-- | pgcli/__init__.py | 2 | ||||
-rw-r--r-- | pgcli/main.py | 61 | ||||
-rw-r--r-- | pgcli/pgclirc | 6 | ||||
-rw-r--r-- | pgcli/pgexecute.py | 2 | ||||
-rw-r--r-- | setup.py | 2 | ||||
-rw-r--r-- | tests/features/steps/auto_vertical.py | 10 | ||||
-rw-r--r-- | tests/features/steps/basic_commands.py | 10 | ||||
-rw-r--r-- | tests/features/steps/expanded.py | 10 | ||||
-rw-r--r-- | tests/test_main.py | 120 |
11 files changed, 193 insertions, 52 deletions
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9e27ab8..ca4e36b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,5 +3,4 @@ repos: rev: 21.5b0 hooks: - id: black - language_version: python3.7 diff --git a/changelog.rst b/changelog.rst index d732088..7ce5132 100644 --- a/changelog.rst +++ b/changelog.rst @@ -1,13 +1,30 @@ TBD -===== +================ Features: --------- + Bug fixes: ---------- -3.2.0 + + +3.3.0 (2022/01/11) +================ + +Features: +--------- + +* Add `max_field_width` setting to config, to enable more control over field truncation ([related issue](https://github.com/dbcli/pgcli/issues/1250)). +* Re-run last query via bare `\watch`. (Thanks: `Saif Hakim`_) + +Bug fixes: +---------- + +* Pin the version of pygments to prevent breaking change + +3.2.0 ===== Release date: 2021/08/23 diff --git a/pgcli/__init__.py b/pgcli/__init__.py index 1173108..88c513e 100644 --- a/pgcli/__init__.py +++ b/pgcli/__init__.py @@ -1 +1 @@ -__version__ = "3.2.0" +__version__ = "3.3.0" diff --git a/pgcli/main.py b/pgcli/main.py index 5135f6f..5395f67 100644 --- a/pgcli/main.py +++ b/pgcli/main.py @@ -86,6 +86,7 @@ from textwrap import dedent # Ref: https://stackoverflow.com/questions/30425105/filter-special-chars-such-as-color-codes-from-shell-output COLOR_CODE_REGEX = re.compile(r"\x1b(\[.*?[@-~]|\].*?(\x07|\x1b\\))") +DEFAULT_MAX_FIELD_WIDTH = 500 # Query tuples are used for maintaining history MetaQuery = namedtuple( @@ -106,7 +107,7 @@ MetaQuery.__new__.__defaults__ = ("", False, 0, 0, False, False, False, False) OutputSettings = namedtuple( "OutputSettings", - "table_format dcmlfmt floatfmt missingval expanded max_width case_function style_output", + "table_format dcmlfmt floatfmt missingval expanded max_width case_function style_output max_field_width", ) OutputSettings.__new__.__defaults__ = ( None, @@ -117,6 +118,7 @@ OutputSettings.__new__.__defaults__ = ( None, lambda x: x, None, + DEFAULT_MAX_FIELD_WIDTH, ) @@ -201,6 +203,16 @@ class PGCli: else: self.row_limit = c["main"].as_int("row_limit") + # if not specified, set to DEFAULT_MAX_FIELD_WIDTH + # if specified but empty, set to None to disable truncation + # ellipsis will take at least 3 symbols, so this can't be less than 3 if specified and > 0 + max_field_width = c["main"].get("max_field_width", DEFAULT_MAX_FIELD_WIDTH) + if max_field_width and max_field_width.lower() != "none": + max_field_width = max(3, abs(int(max_field_width))) + else: + max_field_width = None + self.max_field_width = max_field_width + self.min_num_menu_lines = c["main"].as_int("min_num_menu_lines") self.multiline_continuation_char = c["main"]["multiline_continuation_char"] self.table_format = c["main"]["table_format"] @@ -760,18 +772,7 @@ class PGCli: click.secho(str(e), err=True, fg="red") continue - # Initialize default metaquery in case execution fails - self.watch_command, timing = special.get_watch_command(text) - if self.watch_command: - while self.watch_command: - try: - query = self.execute_command(self.watch_command) - click.echo(f"Waiting for {timing} seconds before repeating") - sleep(timing) - except KeyboardInterrupt: - self.watch_command = None - else: - query = self.execute_command(text) + self.handle_watch_command(text) self.now = dt.datetime.today() @@ -779,12 +780,40 @@ class PGCli: with self._completer_lock: self.completer.extend_query_history(text) - self.query_history.append(query) - except (PgCliQuitError, EOFError): if not self.less_chatty: print("Goodbye!") + def handle_watch_command(self, text): + # Initialize default metaquery in case execution fails + self.watch_command, timing = special.get_watch_command(text) + + # If we run \watch without a command, apply it to the last query run. + if self.watch_command is not None and not self.watch_command.strip(): + try: + self.watch_command = self.query_history[-1].query + except IndexError: + click.secho( + "\\watch cannot be used with an empty query", err=True, fg="red" + ) + self.watch_command = None + + # If there's a command to \watch, run it in a loop. + if self.watch_command: + while self.watch_command: + try: + query = self.execute_command(self.watch_command) + click.echo(f"Waiting for {timing} seconds before repeating") + sleep(timing) + except KeyboardInterrupt: + self.watch_command = None + + # Otherwise, execute it as a regular command. + else: + query = self.execute_command(text) + + self.query_history.append(query) + def _build_cli(self, history): key_bindings = pgcli_bindings(self) @@ -934,6 +963,7 @@ class PGCli: else lambda x: x ), style_output=self.style_output, + max_field_width=self.max_field_width, ) execution = time() - start formatted = format_output(title, cur, headers, status, settings) @@ -1444,6 +1474,7 @@ def format_output(title, cur, headers, status, settings): "disable_numparse": True, "preserve_whitespace": True, "style": settings.style_output, + "max_field_width": settings.max_field_width, } if not settings.floatfmt: output_kwargs["preprocessors"] = (align_decimals,) diff --git a/pgcli/pgclirc b/pgcli/pgclirc index 15c10f5..6654ce9 100644 --- a/pgcli/pgclirc +++ b/pgcli/pgclirc @@ -119,6 +119,12 @@ on_error = STOP # Set threshold for row limit. Use 0 to disable limiting. row_limit = 1000 +# Truncate long text fields to this value for tabular display (does not apply to csv). +# Leave unset to disable truncation. Example: "max_field_width = " +# Be aware that formatting might get slow with values larger than 500 and tables with +# lots of records. +max_field_width = 500 + # Skip intro on startup and goodbye on exit less_chatty = False diff --git a/pgcli/pgexecute.py b/pgcli/pgexecute.py index a013b55..22e9ea4 100644 --- a/pgcli/pgexecute.py +++ b/pgcli/pgexecute.py @@ -430,7 +430,7 @@ class PGExecute: for sql in sqlparse.split(statement): # Remove spaces, eol and semi-colons. sql = sql.rstrip(";") - sql = sqlparse.format(sql, strip_comments=True).strip() + sql = sqlparse.format(sql, strip_comments=False).strip() if not sql: continue try: @@ -8,7 +8,7 @@ description = "CLI for Postgres Database. With auto-completion and syntax highli install_requirements = [ "pgspecial>=1.11.8", "click >= 4.1", - "Pygments >= 2.0", # Pygments has to be Capitalcased. WTF? + "Pygments>=2.0,<=2.11.1", # Pygments has to be Capitalcased. WTF? # We still need to use pt-2 unless pt-3 released on Fedora32 # see: https://github.com/dbcli/pgcli/pull/1197 "prompt_toolkit>=2.0.6,<4.0.0", diff --git a/tests/features/steps/auto_vertical.py b/tests/features/steps/auto_vertical.py index 1643ea5..d7cdccd 100644 --- a/tests/features/steps/auto_vertical.py +++ b/tests/features/steps/auto_vertical.py @@ -24,11 +24,11 @@ def step_see_small_results(context): context, dedent( """\ - +------------+\r - | ?column? |\r - |------------|\r - | 1 |\r - +------------+\r + +----------+\r + | ?column? |\r + |----------|\r + | 1 |\r + +----------+\r SELECT 1\r """ ), diff --git a/tests/features/steps/basic_commands.py b/tests/features/steps/basic_commands.py index 07e9ec1..7ca20f0 100644 --- a/tests/features/steps/basic_commands.py +++ b/tests/features/steps/basic_commands.py @@ -118,11 +118,11 @@ def step_see_found(context): + "\r" + dedent( """ - +------------+\r - | ?column? |\r - |------------|\r - | found |\r - +------------+\r + +----------+\r + | ?column? |\r + |----------|\r + | found |\r + +----------+\r SELECT 1\r """ ) diff --git a/tests/features/steps/expanded.py b/tests/features/steps/expanded.py index 265ea39..ac84c41 100644 --- a/tests/features/steps/expanded.py +++ b/tests/features/steps/expanded.py @@ -58,11 +58,11 @@ def step_see_data(context, which): context, dedent( """\ - +-----+-----+--------+\r - | x | y | z |\r - |-----+-----+--------|\r - | 1 | 1.0 | 1.0000 |\r - +-----+-----+--------+\r + +---+-----+--------+\r + | x | y | z |\r + |---+-----+--------|\r + | 1 | 1.0 | 1.0000 |\r + +---+-----+--------+\r SELECT 1\r """ ), diff --git a/tests/test_main.py b/tests/test_main.py index c48accb..9b3a84b 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -61,16 +61,47 @@ def test_format_output(): ) expected = [ "Title", - "+---------+---------+", - "| head1 | head2 |", - "|---------+---------|", - "| abc | def |", - "+---------+---------+", + "+-------+-------+", + "| head1 | head2 |", + "|-------+-------|", + "| abc | def |", + "+-------+-------+", "test status", ] assert list(results) == expected +def test_format_output_truncate_on(): + settings = OutputSettings( + table_format="psql", dcmlfmt="d", floatfmt="g", max_field_width=10 + ) + results = format_output( + None, + [("first field value", "second field value")], + ["head1", "head2"], + None, + settings, + ) + expected = [ + "+------------+------------+", + "| head1 | head2 |", + "|------------+------------|", + "| first f... | second ... |", + "+------------+------------+", + ] + assert list(results) == expected + + +def test_format_output_truncate_off(): + settings = OutputSettings( + table_format="psql", dcmlfmt="d", floatfmt="g", max_field_width=None + ) + long_field_value = ("first field " * 100).strip() + results = format_output(None, [(long_field_value,)], ["head1"], None, settings) + lines = list(results) + assert lines[3] == f"| {long_field_value} |" + + @dbtest def test_format_array_output(executor): statement = """ @@ -83,12 +114,12 @@ def test_format_array_output(executor): """ results = run(executor, statement) expected = [ - "+----------------+------------------------+--------------+", - "| bigint_array | nested_numeric_array | 配列 |", - "|----------------+------------------------+--------------|", - "| {1,2,3} | {{1,2},{3,4}} | {å,魚,текст} |", - "| {} | <null> | {<null>} |", - "+----------------+------------------------+--------------+", + "+--------------+----------------------+--------------+", + "| bigint_array | nested_numeric_array | 配列 |", + "|--------------+----------------------+--------------|", + "| {1,2,3} | {{1,2},{3,4}} | {å,魚,текст} |", + "| {} | <null> | {<null>} |", + "+--------------+----------------------+--------------+", "SELECT 2", ] assert list(results) == expected @@ -128,11 +159,11 @@ def test_format_output_auto_expand(): ) table = [ "Title", - "+---------+---------+", - "| head1 | head2 |", - "|---------+---------|", - "| abc | def |", - "+---------+---------+", + "+-------+-------+", + "| head1 | head2 |", + "|-------+-------|", + "| abc | def |", + "+-------+-------+", "test status", ] assert list(table_results) == table @@ -266,6 +297,63 @@ def test_i_works(tmpdir, executor): run(executor, statement, pgspecial=cli.pgspecial) +@dbtest +def test_watch_works(executor): + cli = PGCli(pgexecute=executor) + + def run_with_watch( + query, target_call_count=1, expected_output="", expected_timing=None + ): + """ + :param query: Input to the CLI + :param target_call_count: Number of times the user lets the command run before Ctrl-C + :param expected_output: Substring expected to be found for each executed query + :param expected_timing: value `time.sleep` expected to be called with on every invocation + """ + with mock.patch.object(cli, "echo_via_pager") as mock_echo, mock.patch( + "pgcli.main.sleep" + ) as mock_sleep: + mock_sleep.side_effect = [None] * (target_call_count - 1) + [ + KeyboardInterrupt + ] + cli.handle_watch_command(query) + # Validate that sleep was called with the right timing + for i in range(target_call_count - 1): + assert mock_sleep.call_args_list[i][0][0] == expected_timing + # Validate that the output of the query was expected + assert mock_echo.call_count == target_call_count + for i in range(target_call_count): + assert expected_output in mock_echo.call_args_list[i][0][0] + + # With no history, it errors. + with mock.patch("pgcli.main.click.secho") as mock_secho: + cli.handle_watch_command(r"\watch 2") + mock_secho.assert_called() + assert ( + r"\watch cannot be used with an empty query" + in mock_secho.call_args_list[0][0][0] + ) + + # Usage 1: Run a query and then re-run it with \watch across two prompts. + run_with_watch("SELECT 111", expected_output="111") + run_with_watch( + "\\watch 10", target_call_count=2, expected_output="111", expected_timing=10 + ) + + # Usage 2: Run a query and \watch via the same prompt. + run_with_watch( + "SELECT 222; \\watch 4", + target_call_count=3, + expected_output="222", + expected_timing=4, + ) + + # Usage 3: Re-run the last watched command with a new timing + run_with_watch( + "\\watch 5", target_call_count=4, expected_output="222", expected_timing=5 + ) + + def test_missing_rc_dir(tmpdir): rcfile = str(tmpdir.join("subdir").join("rcfile")) |