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
9 changes: 9 additions & 0 deletions .Jules/palette.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Palette's Journal - Critical UX/Accessibility Learnings

## 2024-05-22 - Initial Setup
**Learning:** This journal was created to track UX learnings.
**Action:** Will document impactful UX discoveries here.

## 2024-05-22 - CLI UX: Graceful Exits
**Learning:** Users often use Ctrl+C to exit interactive prompts. Showing a Python traceback is hostile UX.
**Action:** Always wrap interactive CLI entry points in `try...except KeyboardInterrupt` to show a clean "Cancelled" message.
92 changes: 41 additions & 51 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,25 +159,6 @@
return safe


def render_progress_bar(
current: int, total: int, label: str, prefix: str = "🚀"
) -> None:
if not USE_COLORS or total == 0:
return

width = 20
progress = min(1.0, current / total)
filled = int(width * progress)
bar = "█" * filled + "░" * (width - filled)
percent = int(progress * 100)

# Use \033[K to clear line residue
sys.stderr.write(
f"\r\033[K{Colors.CYAN}{prefix} {label}: [{bar}] {percent}% ({current}/{total}){Colors.ENDC}"
)
sys.stderr.flush()


def countdown_timer(seconds: int, message: str = "Waiting") -> None:
"""Shows a countdown timer if strictly in a TTY, otherwise just sleeps."""
if not USE_COLORS:
Expand Down Expand Up @@ -1214,7 +1195,10 @@
# 5. Entry-point
# --------------------------------------------------------------------------- #
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Control D folder sync")
parser = argparse.ArgumentParser(
description="Control D folder sync",
epilog="Example: python main.py --dry-run --profiles 12345abc",
)
parser.add_argument(
"--profiles", help="Comma-separated list of profile IDs", default=None
)
Expand All @@ -1239,42 +1223,48 @@
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 (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()

Check warning

Code scanning / Pylint (reported by Codacy)

Line too long (143/100) Warning

Line too long (143/100)
]

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:

Check warning

Code scanning / Pylint (reported by Codacy)

Missing function docstring Warning

Missing function docstring
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,
)
TOKEN = t_input
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.",

Check warning

Code scanning / Pylint (reported by Codacy)

Line too long (127/100) Warning

Line too long (127/100)
is_password=True,
)
TOKEN = t_input
except KeyboardInterrupt:
sys.stderr.write(
f"\n{Colors.WARNING}⚠️ Sync cancelled by user.{Colors.ENDC}\n"
)
exit(130)

Check warning

Code scanning / Pylintpython3 (reported by Codacy)

Consider using sys.exit() Warning

Consider using sys.exit()

Check warning

Code scanning / Prospector (reported by Codacy)

Consider using sys.exit() (consider-using-sys-exit) Warning

Consider using sys.exit() (consider-using-sys-exit)
Comment on lines +1226 to +1266
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new KeyboardInterrupt handler around the interactive prompts (lines 1226–1266) introduces user-visible behavior (exit code 130 and a specific warning message to stderr) but there are no tests covering this path, even though other interactive CLI behaviors in main() are well covered in test_main.py. Consider adding a test that simulates a KeyboardInterrupt during get_validated_input (e.g., via a side-effect on input/getpass) and asserts that main() exits with code 130 and emits the expected "Sync cancelled by user" message to stderr, to prevent regressions in this UX-critical flow.

Copilot uses AI. Check for mistakes.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

For better portability and explicitness, it's recommended to use sys.exit() instead of the built-in exit(). The exit() function is part of the site module and is primarily intended for use in the interactive interpreter; it may not be available in all Python environments (e.g., when running with the -S flag). Since sys is already imported, using sys.exit() is a more robust choice for scripts.

Suggested change
exit(130)
sys.exit(130)


Check warning

Code scanning / Pylint (reported by Codacy)

Line too long (115/100) Warning

Line too long (115/100)
if not profile_ids and not args.dry_run:
log.error(
"PROFILE missing and --dry-run not set. Provide --profiles or set PROFILE env."
Expand Down
Loading