Skip to content

Commit 3d9c80d

Browse files
author
Dylan Huang
committed
Add secret selection prompt for Fireworks upload in CLI
- Introduced `_prompt_select_secrets` function to allow users to select environment variables for upload as secrets. - Implemented fallback selection method for non-interactive environments. - Updated `upload_command` to utilize the new secret selection logic. - Enhanced user experience with improved prompts and error handling. - Added `_get_questionary_style` function for consistent CLI styling.
1 parent cf6625d commit 3d9c80d

File tree

2 files changed

+173
-45
lines changed

2 files changed

+173
-45
lines changed

eval_protocol/cli_commands/upload.py

Lines changed: 131 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import argparse
2+
from eval_protocol.cli_commands.utils import DiscoveredTest
23
import importlib.util
34
import os
45
import re
@@ -14,10 +15,9 @@
1415
_build_entry_point,
1516
_build_evaluator_dashboard_url,
1617
_discover_and_select_tests,
17-
_discover_tests,
1818
_ensure_account_id,
19+
_get_questionary_style,
1920
_normalize_evaluator_id,
20-
_prompt_select,
2121
)
2222

2323

@@ -170,6 +170,109 @@ def _mask_secret_value(value: str) -> str:
170170
return "<masked>"
171171

172172

173+
def _prompt_select_secrets(
174+
secrets: Dict[str, str],
175+
secrets_from_env_file: Dict[str, str],
176+
non_interactive: bool,
177+
) -> Dict[str, str]:
178+
"""
179+
Prompt user to select which environment variables to upload as secrets.
180+
Returns the selected secrets.
181+
"""
182+
if not secrets:
183+
return {}
184+
185+
if non_interactive:
186+
return secrets
187+
188+
# Check if running in a non-TTY environment (e.g., CI/CD)
189+
if not sys.stdin.isatty():
190+
return secrets
191+
192+
try:
193+
import questionary
194+
195+
custom_style = _get_questionary_style()
196+
197+
# Build choices with source info and masked values
198+
choices = []
199+
for key, value in secrets.items():
200+
source = ".env" if key in secrets_from_env_file else "env"
201+
masked = _mask_secret_value(value)
202+
label = f"{key} ({source}: {masked})"
203+
choices.append(questionary.Choice(title=label, value=key, checked=True))
204+
205+
if len(choices) == 0:
206+
return {}
207+
208+
print("\nFound environment variables to upload as Fireworks secrets:")
209+
selected_keys = questionary.checkbox(
210+
"Select secrets to upload:",
211+
choices=choices,
212+
style=custom_style,
213+
pointer=">",
214+
instruction="(↑↓ move, space select, enter confirm)",
215+
).ask()
216+
217+
if selected_keys is None:
218+
# User cancelled with Ctrl+C
219+
print("\nSecret upload cancelled.")
220+
return {}
221+
222+
return {k: v for k, v in secrets.items() if k in selected_keys}
223+
224+
except ImportError:
225+
# Fallback to simple text-based selection
226+
return _prompt_select_secrets_fallback(secrets, secrets_from_env_file)
227+
except KeyboardInterrupt:
228+
print("\n\nSecret upload cancelled.")
229+
return {}
230+
231+
232+
def _prompt_select_secrets_fallback(
233+
secrets: Dict[str, str],
234+
secrets_from_env_file: Dict[str, str],
235+
) -> Dict[str, str]:
236+
"""Fallback prompt selection for when questionary is not available."""
237+
print("\n" + "=" * 60)
238+
print("Found environment variables to upload as Fireworks secrets:")
239+
print("=" * 60)
240+
print("\nTip: Install questionary for better UX: pip install questionary\n")
241+
242+
secret_list = list(secrets.items())
243+
for idx, (key, value) in enumerate(secret_list, 1):
244+
source = ".env" if key in secrets_from_env_file else "env"
245+
masked = _mask_secret_value(value)
246+
print(f" [{idx}] {key} ({source}: {masked})")
247+
248+
print("\n" + "=" * 60)
249+
print("Enter numbers to select (comma-separated), 'all' for all, or 'none' to skip:")
250+
251+
try:
252+
choice = input("Selection: ").strip().lower()
253+
except KeyboardInterrupt:
254+
print("\nSecret upload cancelled.")
255+
return {}
256+
257+
if not choice or choice == "none":
258+
return {}
259+
260+
if choice == "all":
261+
return secrets
262+
263+
try:
264+
indices = [int(x.strip()) for x in choice.split(",")]
265+
selected = {}
266+
for idx in indices:
267+
if 1 <= idx <= len(secret_list):
268+
key, value = secret_list[idx - 1]
269+
selected[key] = value
270+
return selected
271+
except ValueError:
272+
print("Invalid input. Skipping secret upload.")
273+
return {}
274+
275+
173276
def upload_command(args: argparse.Namespace) -> int:
174277
root = os.path.abspath(getattr(args, "path", "."))
175278
entries_arg = getattr(args, "entry", None)
@@ -181,7 +284,7 @@ def upload_command(args: argparse.Namespace) -> int:
181284
qualname, resolved_path = _resolve_entry_to_qual_and_source(e, root)
182285
selected_specs.append((qualname, resolved_path))
183286
else:
184-
selected_tests = _discover_and_select_tests(root, non_interactive=non_interactive)
287+
selected_tests: list[DiscoveredTest] | None = _discover_and_select_tests(root, non_interactive=non_interactive)
185288
if not selected_tests:
186289
return 1
187290
selected_specs = [(t.qualname, t.file_path) for t in selected_tests]
@@ -212,24 +315,34 @@ def upload_command(args: argparse.Namespace) -> int:
212315
secrets_from_file["FIREWORKS_API_KEY"] = fw_api_key_value
213316

214317
if fw_account_id and secrets_from_file:
215-
print(f"Found {len(secrets_from_file)} API keys to upload as Fireworks secrets...")
216318
if secrets_from_env_file and os.path.exists(env_file_path):
217319
print(f"Loading secrets from: {env_file_path}")
218320

219-
for secret_name, secret_value in secrets_from_file.items():
220-
source = ".env" if secret_name in secrets_from_env_file else "environment"
221-
print(
222-
f"Ensuring {secret_name} is registered as a secret on Fireworks for rollout... "
223-
f"({source}: {_mask_secret_value(secret_value)})"
224-
)
225-
if create_or_update_fireworks_secret(
226-
account_id=fw_account_id,
227-
key_name=secret_name,
228-
secret_value=secret_value,
229-
):
230-
print(f"✓ {secret_name} secret created/updated on Fireworks.")
231-
else:
232-
print(f"Warning: Failed to create/update {secret_name} secret on Fireworks.")
321+
# Prompt user to select which secrets to upload
322+
selected_secrets = _prompt_select_secrets(
323+
secrets_from_file,
324+
secrets_from_env_file,
325+
non_interactive,
326+
)
327+
328+
if selected_secrets:
329+
print(f"\nUploading {len(selected_secrets)} selected secret(s) to Fireworks...")
330+
for secret_name, secret_value in selected_secrets.items():
331+
source = ".env" if secret_name in secrets_from_env_file else "environment"
332+
print(
333+
f"Ensuring {secret_name} is registered as a secret on Fireworks for rollout... "
334+
f"({source}: {_mask_secret_value(secret_value)})"
335+
)
336+
if create_or_update_fireworks_secret(
337+
account_id=fw_account_id,
338+
key_name=secret_name,
339+
secret_value=secret_value,
340+
):
341+
print(f"✓ {secret_name} secret created/updated on Fireworks.")
342+
else:
343+
print(f"Warning: Failed to create/update {secret_name} secret on Fireworks.")
344+
else:
345+
print("No secrets selected for upload.")
233346
else:
234347
if not fw_account_id:
235348
print(

eval_protocol/cli_commands/utils.py

Lines changed: 42 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,28 @@
2323
from ..fireworks_rft import _map_api_host_to_app_host
2424

2525

26+
def _get_questionary_style():
27+
"""Get the shared questionary style for CLI prompts - minimal and clean."""
28+
try:
29+
from questionary import Style
30+
31+
return Style(
32+
[
33+
("qmark", "fg:#888888"),
34+
("question", "bold"),
35+
("answer", "noinherit"),
36+
("pointer", "noinherit"),
37+
("highlighted", "noinherit"),
38+
("selected", "noinherit"),
39+
("separator", "noinherit"),
40+
("instruction", "noinherit fg:#888888"),
41+
("text", "noinherit"),
42+
]
43+
)
44+
except ImportError:
45+
return None
46+
47+
2648
@dataclass
2749
class DiscoveredTest:
2850
module_path: str
@@ -233,22 +255,8 @@ def _prompt_select_interactive(tests: list[DiscoveredTest]) -> list[DiscoveredTe
233255
"""Interactive selection with arrow keys using questionary."""
234256
try:
235257
import questionary
236-
from questionary import Style
237258

238-
# Custom style similar to Vercel CLI
239-
custom_style = Style(
240-
[
241-
("qmark", "fg:#673ab7 bold"),
242-
("question", "bold"),
243-
("answer", "fg:#2196f3 bold"),
244-
("pointer", "fg:#673ab7 bold"),
245-
("highlighted", "fg:#673ab7 bold"),
246-
("selected", "fg:#cc5454"),
247-
("separator", "fg:#cc5454"),
248-
("instruction", ""),
249-
("text", ""),
250-
]
251-
)
259+
custom_style = _get_questionary_style()
252260

253261
# Check if only one test - auto-select it
254262
if len(tests) == 1:
@@ -259,25 +267,31 @@ def _prompt_select_interactive(tests: list[DiscoveredTest]) -> list[DiscoveredTe
259267
else:
260268
return []
261269

262-
# Single-select UX
263-
print("\n")
264-
print("Tip: Use ↑/↓ arrows to navigate and press ENTER to select.\n")
265-
270+
# Build checkbox choices
266271
choices = []
267272
for idx, t in enumerate(tests, 1):
268273
choice_text = _format_test_choice(t, idx)
269-
choices.append({"name": choice_text, "value": idx - 1})
274+
choices.append(questionary.Choice(title=choice_text, value=idx - 1, checked=False))
270275

271-
selected = questionary.select(
272-
"Select an evaluation test to upload:", choices=choices, style=custom_style
276+
print()
277+
selected_indices = questionary.checkbox(
278+
"Select evaluation tests to upload:",
279+
choices=choices,
280+
style=custom_style,
281+
pointer=">",
282+
instruction="(↑↓ move, space select, enter confirm)",
273283
).ask()
274284

275-
if selected is None: # Ctrl+C
285+
if selected_indices is None: # Ctrl+C
276286
print("\nUpload cancelled.")
277287
return []
278288

279-
print("\n✓ Selected 1 test")
280-
return [tests[selected]]
289+
if not selected_indices:
290+
return []
291+
292+
selected_tests = [tests[i] for i in selected_indices]
293+
print(f"\n✓ Selected {len(selected_tests)} test(s)")
294+
return selected_tests
281295

282296
except ImportError:
283297
# Fallback to simpler implementation
@@ -355,8 +369,9 @@ def _discover_and_select_tests(project_root: str, non_interactive: bool) -> Opti
355369

356370
try:
357371
selected_tests = _prompt_select(tests, non_interactive=non_interactive)
358-
except Exception:
359-
print("Error: Failed to open selector UI. Please pass --evaluator or --entry explicitly.")
372+
except Exception as e:
373+
print(f"Error: Failed to open selector UI: {e}")
374+
print("Please pass --evaluator or --entry explicitly.")
360375
return None
361376

362377
if not selected_tests:

0 commit comments

Comments
 (0)