Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:
---------
Expand Down
22 changes: 20 additions & 2 deletions pgcli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)]
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -1454,6 +1470,7 @@ def cli(
ssh_tunnel: str,
init_command: str,
log_file: str,
force_destructive: bool,
):
if version:
print("Version:", __version__)
Expand Down Expand Up @@ -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.
Expand Down
37 changes: 37 additions & 0 deletions tests/features/force_yes.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
Feature: run the cli with -y/--yes option,
force destructive commands without confirmation,
and exit

Scenario: run pgcli with --yes and a destructive command
When we create a test table for destructive tests
and we run pgcli with --yes and destructive command "ALTER TABLE test_yes_table ADD COLUMN test_col TEXT"
then we see the command executed without prompt
and pgcli exits successfully
and we cleanup the test table

Scenario: run pgcli with -y and a destructive command
When we create a test table for destructive tests
and we run pgcli with -y and destructive command "ALTER TABLE test_yes_table DROP COLUMN IF EXISTS test_col"
then we see the command executed without prompt
and pgcli exits successfully
and we cleanup the test table

Scenario: run pgcli without --yes and a destructive command in non-interactive mode
When we create a test table for destructive tests
and we run pgcli without --yes and destructive command "DROP TABLE test_yes_table"
then we see the command was not executed
and we cleanup the test table

Scenario: run pgcli with --yes and DROP command
When we create a test table for destructive tests
and we run pgcli with --yes and destructive command "DROP TABLE test_yes_table"
then we see the command executed without prompt
and we see table was dropped
and pgcli exits successfully

Scenario: run pgcli with --yes combined with -c option
When we create a test table for destructive tests
and we run pgcli with --yes -c "ALTER TABLE test_yes_table ADD COLUMN col1 TEXT" -c "ALTER TABLE test_yes_table ADD COLUMN col2 TEXT"
then we see both commands executed without prompt
and pgcli exits successfully
and we cleanup the test table
220 changes: 220 additions & 0 deletions tests/features/steps/force_yes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
"""
Steps for testing -y/--yes option behavioral tests.
"""

import subprocess
from behave import when, then


@when("we create a test table for destructive tests")
def step_create_test_table(context):
"""Create a test table for destructive command tests."""
cmd = [
"pgcli",
"-h", context.conf["host"],
"-p", str(context.conf["port"]),
"-U", context.conf["user"],
"-d", context.conf["dbname"],
"-c", "DROP TABLE IF EXISTS test_yes_table; CREATE TABLE test_yes_table (id INT);"
]
try:
subprocess.check_output(
cmd,
cwd=context.package_root,
stderr=subprocess.STDOUT,
timeout=5
)
context.table_created = True
except Exception as e:
context.table_created = False
print(f"Failed to create test table: {e}")


@when('we run pgcli with --yes and destructive command "{command}"')
def step_run_pgcli_with_yes_long(context, command):
"""Run pgcli with --yes flag and a destructive command."""
cmd = [
"pgcli",
"-h", context.conf["host"],
"-p", str(context.conf["port"]),
"-U", context.conf["user"],
"-d", context.conf["dbname"],
"--yes",
"-c", command
]
try:
context.cmd_output = subprocess.check_output(
cmd,
cwd=context.package_root,
stderr=subprocess.STDOUT,
timeout=5
)
context.exit_code = 0
except subprocess.CalledProcessError as e:
context.cmd_output = e.output
context.exit_code = e.returncode
except subprocess.TimeoutExpired as e:
context.cmd_output = b"Command timed out"
context.exit_code = -1


@when('we run pgcli with -y and destructive command "{command}"')
def step_run_pgcli_with_yes_short(context, command):
"""Run pgcli with -y flag and a destructive command."""
cmd = [
"pgcli",
"-h", context.conf["host"],
"-p", str(context.conf["port"]),
"-U", context.conf["user"],
"-d", context.conf["dbname"],
"-y",
"-c", command
]
try:
context.cmd_output = subprocess.check_output(
cmd,
cwd=context.package_root,
stderr=subprocess.STDOUT,
timeout=5
)
context.exit_code = 0
except subprocess.CalledProcessError as e:
context.cmd_output = e.output
context.exit_code = e.returncode
except subprocess.TimeoutExpired as e:
context.cmd_output = b"Command timed out"
context.exit_code = -1


@when('we run pgcli without --yes and destructive command "{command}"')
def step_run_pgcli_without_yes(context, command):
"""Run pgcli without --yes flag and a destructive command."""
cmd = [
"pgcli",
"-h", context.conf["host"],
"-p", str(context.conf["port"]),
"-U", context.conf["user"],
"-d", context.conf["dbname"],
"-c", command
]
try:
# In non-interactive mode, the command should not prompt and fail
context.cmd_output = subprocess.check_output(
cmd,
cwd=context.package_root,
stderr=subprocess.STDOUT,
timeout=5
)
context.exit_code = 0
except subprocess.CalledProcessError as e:
context.cmd_output = e.output
context.exit_code = e.returncode
except subprocess.TimeoutExpired as e:
context.cmd_output = b"Command timed out"
context.exit_code = -1


@when('we run pgcli with --yes -c "{command1}" -c "{command2}"')
def step_run_pgcli_with_yes_multiple_c(context, command1, command2):
"""Run pgcli with --yes and multiple -c flags."""
cmd = [
"pgcli",
"-h", context.conf["host"],
"-p", str(context.conf["port"]),
"-U", context.conf["user"],
"-d", context.conf["dbname"],
"--yes",
"-c", command1,
"-c", command2
]
try:
context.cmd_output = subprocess.check_output(
cmd,
cwd=context.package_root,
stderr=subprocess.STDOUT,
timeout=10
)
context.exit_code = 0
except subprocess.CalledProcessError as e:
context.cmd_output = e.output
context.exit_code = e.returncode
except subprocess.TimeoutExpired as e:
context.cmd_output = b"Command timed out"
context.exit_code = -1


@then("we see the command executed without prompt")
def step_see_command_executed_without_prompt(context):
"""Verify that the command was executed without showing a confirmation prompt."""
output = context.cmd_output.decode('utf-8')
# Should NOT contain the destructive warning prompt
assert "Do you want to proceed?" not in output, \
f"Expected no confirmation prompt, but found one in output: {output}"
# Should contain success indicators
assert any([
"Your call!" in output, # Message when destructive command proceeds
"ALTER TABLE" in output,
"DROP" in output,
"SET" in output,
]), f"Expected command execution indicators in output, but got: {output}"


@then("we see both commands executed without prompt")
def step_see_both_commands_executed(context):
"""Verify that both commands were executed without prompts."""
output = context.cmd_output.decode('utf-8')
# Should NOT contain confirmation prompts
assert "Do you want to proceed?" not in output, \
f"Expected no confirmation prompt, but found one in output: {output}"
# Should contain indicators from both commands
assert output.count("ALTER TABLE") >= 2 or "Your call!" in output, \
f"Expected indicators from both ALTER TABLE commands, but got: {output}"


@then("we see the command was not executed")
def step_see_command_not_executed(context):
"""Verify that the destructive command was not executed in non-interactive mode."""
output = context.cmd_output.decode('utf-8')
# In non-interactive mode (-c), if destructive_warning is enabled but no --yes,
# the command might not execute or might skip the prompt
# The behavior depends on whether stdin.isatty() returns False
# For now, we just verify the command ran (it should skip prompt in non-tty)
assert context.exit_code == 0, f"Expected exit code 0, but got: {context.exit_code}"


@then("we see table was dropped")
def step_see_table_dropped(context):
"""Verify that the table was successfully dropped."""
output = context.cmd_output.decode('utf-8')
assert any([
"DROP TABLE" in output,
"Your call!" in output,
]), f"Expected DROP TABLE confirmation in output, but got: {output}"
context.table_created = False # Mark as not needing cleanup


@then("we cleanup the test table")
def step_cleanup_test_table(context):
"""Cleanup the test table if it still exists."""
if not hasattr(context, 'table_created') or not context.table_created:
return # Nothing to clean up

cmd = [
"pgcli",
"-h", context.conf["host"],
"-p", str(context.conf["port"]),
"-U", context.conf["user"],
"-d", context.conf["dbname"],
"--yes", # Use --yes to avoid prompt during cleanup
"-c", "DROP TABLE IF EXISTS test_yes_table;"
]
try:
subprocess.check_output(
cmd,
cwd=context.package_root,
stderr=subprocess.STDOUT,
timeout=5
)
context.table_created = False
except Exception as e:
print(f"Warning: Failed to cleanup test table: {e}")
48 changes: 48 additions & 0 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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