From 0b469598617ada2570c7974ba2fa23fd5896fbdc Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 9 Feb 2026 22:51:11 +0000 Subject: [PATCH 1/5] feat(cli): handle Ctrl+C gracefully and improve profile prompt - Added a `try...except KeyboardInterrupt` block around interactive prompts and cache warmup to prevent ugly tracebacks when users cancel. - Updated the Profile ID prompt to include the direct URL to the Control D dashboard. - Fixed `tests/test_plan_details.py` to correctly patch `main.USE_COLORS` when `main` module is reloaded by other tests. Co-authored-by: abhimehro <84992105+abhimehro@users.noreply.github.com> --- main.py | 87 +++++++++++++++++++++----------------- tests/test_plan_details.py | 20 ++++++--- 2 files changed, 61 insertions(+), 46 deletions(-) diff --git a/main.py b/main.py index 4b766144..7c39d7f9 100644 --- a/main.py +++ b/main.py @@ -1478,53 +1478,62 @@ def main(): folder_urls = args.folder_url if args.folder_url else DEFAULT_FOLDER_URLS # Interactive prompts for missing config - if not args.dry_run and sys.stdin.isatty(): - if not profile_ids: - print(f"{Colors.CYAN}ℹ Profile ID is missing.{Colors.ENDC}") - print( - f"{Colors.CYAN} You can find this in the URL of your profile in the Control D Dashboard (or just paste the URL).{Colors.ENDC}" - ) + try: + if not args.dry_run and sys.stdin.isatty(): + if not profile_ids: + print(f"{Colors.CYAN}ℹ Profile ID is missing.{Colors.ENDC}") + print( + f"{Colors.CYAN} You can find this in the URL of your profile in the Control D Dashboard at https://controld.com/dashboard/profiles (or just paste the URL).{Colors.ENDC}" + ) + + def validate_profile_input(value: str) -> bool: + ids = [extract_profile_id(p) for p in value.split(",") if p.strip()] + return bool(ids) and all( + validate_profile_id(pid, log_errors=False) for pid in ids + ) - def validate_profile_input(value: str) -> bool: - ids = [extract_profile_id(p) for p in value.split(",") if p.strip()] - return bool(ids) and all( - validate_profile_id(pid, log_errors=False) for pid in ids + p_input = get_validated_input( + f"{Colors.BOLD}Enter Control D Profile ID:{Colors.ENDC} ", + validate_profile_input, + "Invalid ID(s) or URL(s). Must be a valid Profile ID or a Control D Profile URL. Comma-separate for multiple.", ) + profile_ids = [ + extract_profile_id(p) for p in p_input.split(",") if p.strip() + ] - p_input = get_validated_input( - f"{Colors.BOLD}Enter Control D Profile ID:{Colors.ENDC} ", - validate_profile_input, - "Invalid ID(s) or URL(s). Must be a valid Profile ID or a Control D Profile URL. Comma-separate for multiple.", - ) - profile_ids = [ - extract_profile_id(p) for p in p_input.split(",") if p.strip() - ] - - if not TOKEN: - print(f"{Colors.CYAN}ℹ API Token is missing.{Colors.ENDC}") - print( - f"{Colors.CYAN} You can generate one at: https://controld.com/account/manage-account{Colors.ENDC}" - ) + if not TOKEN: + print(f"{Colors.CYAN}ℹ API Token is missing.{Colors.ENDC}") + print( + f"{Colors.CYAN} You can generate one at: https://controld.com/account/manage-account{Colors.ENDC}" + ) - t_input = get_validated_input( - f"{Colors.BOLD}Enter Control D API Token:{Colors.ENDC} ", - lambda x: len(x) > 8, - "Token seems too short. Please check your API token.", - is_password=True, + t_input = get_validated_input( + f"{Colors.BOLD}Enter Control D API Token:{Colors.ENDC} ", + lambda x: len(x) > 8, + "Token seems too short. Please check your API token.", + is_password=True, + ) + TOKEN = t_input + + if not profile_ids and not args.dry_run: + log.error( + "PROFILE missing and --dry-run not set. Provide --profiles or set PROFILE env." ) - TOKEN = t_input + exit(1) - if not profile_ids and not args.dry_run: - log.error( - "PROFILE missing and --dry-run not set. Provide --profiles or set PROFILE env." - ) - exit(1) + if not TOKEN and not args.dry_run: + log.error( + "TOKEN missing and --dry-run not set. Set TOKEN env for live sync." + ) + exit(1) - if not TOKEN and not args.dry_run: - log.error("TOKEN missing and --dry-run not set. Set TOKEN env for live sync.") - exit(1) + warm_up_cache(folder_urls) - warm_up_cache(folder_urls) + except KeyboardInterrupt: + sys.stderr.write( + f"\n{Colors.WARNING}⚠️ Sync cancelled by user.{Colors.ENDC}\n" + ) + sys.exit(130) plan: List[Dict[str, Any]] = [] success_count = 0 diff --git a/tests/test_plan_details.py b/tests/test_plan_details.py index 12cacb2c..2005a1fc 100644 --- a/tests/test_plan_details.py +++ b/tests/test_plan_details.py @@ -1,13 +1,17 @@ """Tests for the print_plan_details dry-run output function.""" +import sys from unittest.mock import patch - import main +# Helper to get current main module (handles reloading by other tests) +def get_main(): + return sys.modules.get("main", main) def test_print_plan_details_no_colors(capsys): """Test print_plan_details output when colors are disabled.""" - with patch("main.USE_COLORS", False): + current_main = get_main() + with patch.object(current_main, "USE_COLORS", False): plan_entry = { "profile": "test_profile", "folders": [ @@ -15,7 +19,7 @@ def test_print_plan_details_no_colors(capsys): {"name": "Folder A", "rules": 10}, ], } - main.print_plan_details(plan_entry) + current_main.print_plan_details(plan_entry) captured = capsys.readouterr() output = captured.out @@ -29,9 +33,10 @@ def test_print_plan_details_no_colors(capsys): def test_print_plan_details_empty_folders(capsys): """Test print_plan_details with no folders.""" - with patch("main.USE_COLORS", False): + current_main = get_main() + with patch.object(current_main, "USE_COLORS", False): plan_entry = {"profile": "test_profile", "folders": []} - main.print_plan_details(plan_entry) + current_main.print_plan_details(plan_entry) captured = capsys.readouterr() output = captured.out @@ -42,12 +47,13 @@ def test_print_plan_details_empty_folders(capsys): def test_print_plan_details_with_colors(capsys): """Test print_plan_details output when colors are enabled.""" - with patch("main.USE_COLORS", True): + current_main = get_main() + with patch.object(current_main, "USE_COLORS", True): plan_entry = { "profile": "test_profile", "folders": [{"name": "Folder A", "rules": 10}], } - main.print_plan_details(plan_entry) + current_main.print_plan_details(plan_entry) captured = capsys.readouterr() output = captured.out From 50e89cf80dd7c21a612be88dd43ac9667446f6a1 Mon Sep 17 00:00:00 2001 From: Abhi Mehrotra Date: Thu, 12 Feb 2026 21:05:31 -0600 Subject: [PATCH 2/5] Update test_plan_details.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/test_plan_details.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_plan_details.py b/tests/test_plan_details.py index 2005a1fc..0addee8f 100644 --- a/tests/test_plan_details.py +++ b/tests/test_plan_details.py @@ -4,10 +4,10 @@ from unittest.mock import patch import main + # Helper to get current main module (handles reloading by other tests) def get_main(): return sys.modules.get("main", main) - def test_print_plan_details_no_colors(capsys): """Test print_plan_details output when colors are disabled.""" current_main = get_main() From 508bb0a31870a1cc600bc5f2949375348c923182 Mon Sep 17 00:00:00 2001 From: Abhi Mehrotra Date: Thu, 12 Feb 2026 21:13:53 -0600 Subject: [PATCH 3/5] Update main.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.py b/main.py index 7c39d7f9..0bd4ab16 100644 --- a/main.py +++ b/main.py @@ -1533,7 +1533,7 @@ def validate_profile_input(value: str) -> bool: sys.stderr.write( f"\n{Colors.WARNING}⚠️ Sync cancelled by user.{Colors.ENDC}\n" ) - sys.exit(130) + exit(130) plan: List[Dict[str, Any]] = [] success_count = 0 From 1ac52a0591913fd717331b25c3ccd9372043932c Mon Sep 17 00:00:00 2001 From: Abhi Mehrotra Date: Thu, 12 Feb 2026 21:13:59 -0600 Subject: [PATCH 4/5] Update main.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.py b/main.py index 0bd4ab16..da672176 100644 --- a/main.py +++ b/main.py @@ -1529,7 +1529,7 @@ def validate_profile_input(value: str) -> bool: warm_up_cache(folder_urls) - except KeyboardInterrupt: + except (KeyboardInterrupt, EOFError): sys.stderr.write( f"\n{Colors.WARNING}⚠️ Sync cancelled by user.{Colors.ENDC}\n" ) From 62ce54e2451d68ea87ef4354bee150278aab3e8e Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 22:03:39 -0600 Subject: [PATCH 5/5] Address PR #184 review feedback: fix line lengths and add test coverage (#197) --- main.py | 10 +++++++-- test_main.py | 59 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/main.py b/main.py index da672176..47b135f3 100644 --- a/main.py +++ b/main.py @@ -1483,10 +1483,15 @@ def main(): if not profile_ids: print(f"{Colors.CYAN}ℹ Profile ID is missing.{Colors.ENDC}") print( - f"{Colors.CYAN} You can find this in the URL of your profile in the Control D Dashboard at https://controld.com/dashboard/profiles (or just paste the URL).{Colors.ENDC}" + f"{Colors.CYAN} Find it in your profile URL at " + f"https://controld.com/dashboard/profiles{Colors.ENDC}" + ) + print( + f"{Colors.CYAN} (You can also paste the full profile URL).{Colors.ENDC}" ) def validate_profile_input(value: str) -> bool: + """Validate comma-separated profile IDs or URLs.""" ids = [extract_profile_id(p) for p in value.split(",") if p.strip()] return bool(ids) and all( validate_profile_id(pid, log_errors=False) for pid in ids @@ -1495,7 +1500,8 @@ def validate_profile_input(value: str) -> bool: p_input = get_validated_input( f"{Colors.BOLD}Enter Control D Profile ID:{Colors.ENDC} ", validate_profile_input, - "Invalid ID(s) or URL(s). Must be a valid Profile ID or a Control D Profile URL. Comma-separate for multiple.", + "Invalid ID(s) or URL(s). Must be a valid Profile ID or " + "a Control D Profile URL. Comma-separate for multiple.", ) profile_ids = [ extract_profile_id(p) for p in p_input.split(",") if p.strip() diff --git a/test_main.py b/test_main.py index 66337173..eaeb7b0f 100644 --- a/test_main.py +++ b/test_main.py @@ -244,10 +244,65 @@ def test_interactive_prompts_show_hints(monkeypatch, capsys): captured = capsys.readouterr() stdout = captured.out - assert "You can find this in the URL of your profile" in stdout + # Verify both hint URLs are displayed + assert "https://controld.com/dashboard/profiles" in stdout assert "https://controld.com/account/manage-account" in stdout +def test_keyboard_interrupt_during_setup(monkeypatch, capsys): + """Test that KeyboardInterrupt during setup phase exits gracefully with code 130.""" + # Ensure environment is clean + monkeypatch.delenv("PROFILE", raising=False) + monkeypatch.delenv("TOKEN", raising=False) + + # Prevent dotenv from loading .env file + import dotenv + + monkeypatch.setattr(dotenv, "load_dotenv", lambda: None) + + # Reload main with isatty=True to trigger interactive mode + m = reload_main_with_env(monkeypatch, isatty=True) + + # Mock sys.stdin.isatty to return True + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + + # Mock input to raise KeyboardInterrupt (simulating Ctrl+C) + monkeypatch.setattr( + "builtins.input", MagicMock(side_effect=KeyboardInterrupt()) + ) + + # Mock parse_args + mock_args = MagicMock() + mock_args.profiles = None + mock_args.folder_url = None + mock_args.dry_run = False + mock_args.no_delete = False + mock_args.plan_json = None + monkeypatch.setattr(m, "parse_args", lambda: mock_args) + + # Mock exit to capture the exit code + exit_code = None + + def mock_exit(code): + nonlocal exit_code + exit_code = code + raise SystemExit(code) + + monkeypatch.setattr("builtins.exit", mock_exit) + + # Run main and expect SystemExit + with pytest.raises(SystemExit): + m.main() + + # Verify exit code is 130 (standard for Ctrl+C) + assert exit_code == 130 + + # Verify user-friendly cancellation message + captured = capsys.readouterr() + stderr = captured.err + assert "Sync cancelled by user" in stderr + + # Case 7: verify_access_and_get_folders handles success and errors correctly def test_verify_access_and_get_folders_success(monkeypatch): m = reload_main_with_env(monkeypatch) @@ -451,7 +506,7 @@ def test_interactive_input_extracts_id(monkeypatch, capsys): # Verify prompt text update captured = capsys.readouterr() - assert "(or just paste the URL)" in captured.out + assert "(You can also paste the full profile URL)" in captured.out # Case 10: validate_profile_id respects log_errors flag