From 2d64b05134a12c7e37213ebb59a6d361d42ef4a0 Mon Sep 17 00:00:00 2001 From: DiegoDAF Date: Fri, 5 Dec 2025 15:47:22 -0300 Subject: [PATCH 1/4] Add support for -y/--yes flag to bypass confirmation prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit adds support for the -y/--yes option to pgcli, allowing users to bypass destructive command confirmation prompts. This is particularly useful for automated scripts and CI/CD pipelines. Features: - Single flag to skip all destructive confirmations: pgcli -y - Long form: pgcli --yes - Useful for automated environments - Maintains safety by default (flag must be explicitly set) - Does not override transaction requirements When the flag is set: - Destructive commands execute without prompting - Transaction requirements still apply - Error handling remains the same Comprehensive unit tests included to verify: - Flag initialization - Confirmation bypass behavior - Normal confirmation flow without flag Made with ❤️ and 🤖 Claude Code Co-Authored-By: Claude --- changelog.rst | 4 ++++ pgcli/main.py | 22 +++++++++++++++++++-- tests/test_main.py | 48 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 2 deletions(-) diff --git a/changelog.rst b/changelog.rst index 96eefd747..5b6cffa78 100644 --- a/changelog.rst +++ b/changelog.rst @@ -9,6 +9,10 @@ Features: * Support dsn specific init-command in the config file * Add suggestion when setting the search_path * Allow per dsn_alias ssh tunnel selection +* Add support for forcing destructive commands without confirmation. + * Command line option `-y` or `--yes`. + * Skips the destructive command confirmation prompt when enabled. + * Useful for automated scripts and CI/CD pipelines. Internal: --------- diff --git a/pgcli/main.py b/pgcli/main.py index 0b4b64f59..d834d39db 100644 --- a/pgcli/main.py +++ b/pgcli/main.py @@ -185,12 +185,14 @@ def __init__( warn=None, ssh_tunnel_url: Optional[str] = None, log_file: Optional[str] = None, + force_destructive: bool = False, ): self.force_passwd_prompt = force_passwd_prompt self.never_passwd_prompt = never_passwd_prompt self.pgexecute = pgexecute self.dsn_alias = None self.watch_command = None + self.force_destructive = force_destructive # Load config. c = self.config = get_config(pgclirc_file) @@ -484,7 +486,10 @@ def execute_from_file(self, pattern, **_): ): message = "Destructive statements must be run within a transaction. Command execution stopped." return [(None, None, None, message)] - destroy = confirm_destructive_query(query, self.destructive_warning, self.dsn_alias) + if self.force_destructive: + destroy = True + else: + destroy = confirm_destructive_query(query, self.destructive_warning, self.dsn_alias) if destroy is False: message = "Wise choice. Command execution stopped." return [(None, None, None, message)] @@ -792,7 +797,10 @@ def execute_command(self, text, handle_closed_connection=True): ): click.secho("Destructive statements must be run within a transaction.") raise KeyboardInterrupt - destroy = confirm_destructive_query(text, self.destructive_warning, self.dsn_alias) + if self.force_destructive: + destroy = True + else: + destroy = confirm_destructive_query(text, self.destructive_warning, self.dsn_alias) if destroy is False: click.secho("Wise choice!") raise KeyboardInterrupt @@ -1426,6 +1434,14 @@ def echo_via_pager(self, text, color=None): type=str, help="SQL statement to execute after connecting.", ) +@click.option( + "-y", + "--yes", + "force_destructive", + is_flag=True, + default=False, + help="Force destructive commands without confirmation prompt.", +) @click.argument("dbname", default=lambda: None, envvar="PGDATABASE", nargs=1) @click.argument("username", default=lambda: None, envvar="PGUSER", nargs=1) def cli( @@ -1454,6 +1470,7 @@ def cli( ssh_tunnel: str, init_command: str, log_file: str, + force_destructive: bool, ): if version: print("Version:", __version__) @@ -1512,6 +1529,7 @@ def cli( warn=warn, ssh_tunnel_url=ssh_tunnel, log_file=log_file, + force_destructive=force_destructive, ) # Choose which ever one has a valid value. diff --git a/tests/test_main.py b/tests/test_main.py index 5cf1d09f8..defcb206c 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -595,3 +595,51 @@ def test_notifications(executor): with mock.patch("pgcli.main.click.secho") as mock_secho: run(executor, "notify chan1, 'testing2'") mock_secho.assert_not_called() + + +def test_force_destructive_flag(): + """Test that PGCli can be initialized with force_destructive flag.""" + cli = PGCli(force_destructive=True) + assert cli.force_destructive is True + + cli = PGCli(force_destructive=False) + assert cli.force_destructive is False + + cli = PGCli() + assert cli.force_destructive is False + + +@dbtest +def test_force_destructive_skips_confirmation(executor): + """Test that force_destructive=True skips confirmation for destructive commands.""" + cli = PGCli(pgexecute=executor, force_destructive=True) + cli.destructive_warning = ["drop", "alter"] + + # Mock confirm_destructive_query to ensure it's not called + with mock.patch("pgcli.main.confirm_destructive_query") as mock_confirm: + # Execute a destructive command + result = cli.execute_command("ALTER TABLE test_table ADD COLUMN test_col TEXT;") + + # Verify that confirm_destructive_query was NOT called + mock_confirm.assert_not_called() + + # Verify that the command was attempted (even if it fails due to missing table) + assert result is not None + + +@dbtest +def test_without_force_destructive_calls_confirmation(executor): + """Test that without force_destructive, confirmation is called for destructive commands.""" + cli = PGCli(pgexecute=executor, force_destructive=False) + cli.destructive_warning = ["drop", "alter"] + + # Mock confirm_destructive_query to return True (user confirms) + with mock.patch("pgcli.main.confirm_destructive_query", return_value=True) as mock_confirm: + # Execute a destructive command + result = cli.execute_command("ALTER TABLE test_table ADD COLUMN test_col TEXT;") + + # Verify that confirm_destructive_query WAS called + mock_confirm.assert_called_once() + + # Verify that the command was attempted + assert result is not None From 581e9500d3032ec8b3cacc5e4cd551141c68f6f7 Mon Sep 17 00:00:00 2001 From: DiegoDAF Date: Fri, 5 Dec 2025 15:49:03 -0300 Subject: [PATCH 2/4] Suppress 'Your call!' message when using --yes flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When using the --yes flag to auto-confirm destructive commands, pgcli should not display the "Your call!" message since no user interaction occurred. This message is now only shown when the user manually confirms the destructive warning prompt. Made with ❤️ and 🤖 Claude Code Co-Authored-By: Claude --- pgcli/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pgcli/main.py b/pgcli/main.py index d834d39db..8606302a3 100644 --- a/pgcli/main.py +++ b/pgcli/main.py @@ -804,7 +804,7 @@ def execute_command(self, text, handle_closed_connection=True): if destroy is False: click.secho("Wise choice!") raise KeyboardInterrupt - elif destroy: + elif destroy and not self.force_destructive: click.secho("Your call!") output, query = self._evaluate_command(text) From 1e0c75b1f6b66a5b079ae9f6031561a595d650fa Mon Sep 17 00:00:00 2001 From: Diego Date: Mon, 20 Jul 2026 12:27:54 -0300 Subject: [PATCH 3/4] Pass force_destructive into confirm_destructive_query Address review feedback: instead of checking self.force_destructive at both call sites, confirm_destructive_query now takes a `force` parameter and owns the whole 'should we proceed?' decision. Behaviour is unchanged: non-destructive returns None, force returns True, a non-tty stdin returns None (no prompt), otherwise the user is prompted. The parameter defaults to False, so existing callers are unaffected. --- pgcli/main.py | 10 ++-------- pgcli/packages/prompt_utils.py | 17 +++++++++++++---- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/pgcli/main.py b/pgcli/main.py index 8606302a3..31a257a08 100644 --- a/pgcli/main.py +++ b/pgcli/main.py @@ -486,10 +486,7 @@ def execute_from_file(self, pattern, **_): ): message = "Destructive statements must be run within a transaction. Command execution stopped." return [(None, None, None, message)] - if self.force_destructive: - destroy = True - else: - destroy = confirm_destructive_query(query, self.destructive_warning, self.dsn_alias) + destroy = confirm_destructive_query(query, self.destructive_warning, self.dsn_alias, self.force_destructive) if destroy is False: message = "Wise choice. Command execution stopped." return [(None, None, None, message)] @@ -797,10 +794,7 @@ def execute_command(self, text, handle_closed_connection=True): ): click.secho("Destructive statements must be run within a transaction.") raise KeyboardInterrupt - if self.force_destructive: - destroy = True - else: - destroy = confirm_destructive_query(text, self.destructive_warning, self.dsn_alias) + destroy = confirm_destructive_query(text, self.destructive_warning, self.dsn_alias, self.force_destructive) if destroy is False: click.secho("Wise choice!") raise KeyboardInterrupt diff --git a/pgcli/packages/prompt_utils.py b/pgcli/packages/prompt_utils.py index 997b86e7f..4d21c09d4 100644 --- a/pgcli/packages/prompt_utils.py +++ b/pgcli/packages/prompt_utils.py @@ -3,22 +3,31 @@ from .parseutils import is_destructive -def confirm_destructive_query(queries, keywords, alias): +def confirm_destructive_query(queries, keywords, alias, force=False): """Check if the query is destructive and prompts the user to confirm. Returns: * None if the query is non-destructive or we can't prompt the user. - * True if the query is destructive and the user wants to proceed. + * True if the query is destructive and the user wants to proceed, or if the + confirmation was bypassed with `force`. * False if the query is destructive and the user doesn't want to proceed. """ + if not is_destructive(queries, keywords): + return None + + if force: + return True + + if not sys.stdin.isatty(): + return None + info = "You're about to run a destructive command" if alias: info += f" in {click.style(alias, fg='red')}" prompt_text = f"{info}.\nDo you want to proceed?" - if is_destructive(queries, keywords) and sys.stdin.isatty(): - return confirm(prompt_text) + return confirm(prompt_text) def confirm(*args, **kwargs): From f07f33ba8c2516bf25db6e45c1d58e4a70dcc448 Mon Sep 17 00:00:00 2001 From: Diego Date: Mon, 20 Jul 2026 14:00:39 -0300 Subject: [PATCH 4/4] Assert the user is not prompted, not which helper is called test_force_destructive_skips_confirmation asserted that confirm_destructive_query was never called with -y. Now that the helper takes the force parameter it IS called and decides internally not to prompt, so the old assertion encoded the previous structure rather than the behaviour. It now patches prompt_utils.confirm and asserts the user is never prompted, which is the actual guarantee -y makes. --- tests/test_main.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/test_main.py b/tests/test_main.py index fbb5144f7..f33b06c51 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -656,13 +656,14 @@ def test_force_destructive_skips_confirmation(executor): cli = PGCli(pgexecute=executor, force_destructive=True) cli.destructive_warning = ["drop", "alter"] - # Mock confirm_destructive_query to ensure it's not called - with mock.patch("pgcli.main.confirm_destructive_query") as mock_confirm: + # The proceed/abort decision lives inside confirm_destructive_query, which is + # told to force; what must not happen is the user being prompted. + with mock.patch("pgcli.packages.prompt_utils.confirm") as mock_prompt: # Execute a destructive command result = cli.execute_command("ALTER TABLE test_table ADD COLUMN test_col TEXT;") - # Verify that confirm_destructive_query was NOT called - mock_confirm.assert_not_called() + # Verify that the user was never prompted + mock_prompt.assert_not_called() # Verify that the command was attempted (even if it fails due to missing table) assert result is not None