Coverage for src/prosemark/templates/adapters/cli_user_prompter.py: 95%
224 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-09-30 23:09 +0000
« prev ^ index » next coverage.py v7.8.0, created at 2025-09-30 23:09 +0000
1"""CLI-based user prompter adapter for interactive placeholder input."""
3import sys
4from typing import Final, TextIO
6from prosemark.templates.domain.entities.placeholder import Placeholder, PlaceholderValue
7from prosemark.templates.domain.exceptions.template_exceptions import (
8 InvalidPlaceholderValueError,
9 UserCancelledError,
10)
11from prosemark.templates.ports.user_prompter_port import UserPrompterPort
13MAX_DISPLAY_LENGTH: Final[int] = 50
16class CLIUserPrompter(UserPrompterPort):
17 """CLI-based implementation of user prompter for placeholder values."""
19 def __init__(
20 self,
21 input_stream: TextIO = sys.stdin,
22 output_stream: TextIO = sys.stdout,
23 error_stream: TextIO = sys.stderr,
24 ) -> None:
25 """Initialize CLI prompter with I/O streams.
27 Args:
28 input_stream: Stream to read user input from
29 output_stream: Stream to write prompts to
30 error_stream: Stream to write errors to
32 """
33 self._input = input_stream
34 self._output = output_stream
35 self._error = error_stream
36 self.MAX_DISPLAY_LENGTH = MAX_DISPLAY_LENGTH
38 @staticmethod
39 def _format_placeholder_prompt(placeholder: Placeholder) -> str:
40 """Format prompt message for a placeholder.
42 Args:
43 placeholder: Placeholder to create prompt for
45 Returns:
46 Formatted prompt message
48 """
49 prompt_parts = [f"Enter value for '{placeholder.name}'"]
51 if placeholder.description:
52 prompt_parts.append(f' ({placeholder.description})')
54 if not placeholder.required and placeholder.default_value is not None:
55 prompt_parts.append(f' [default: {placeholder.default_value}]')
56 elif placeholder.required: 56 ↛ 59line 56 didn't jump to line 59 because the condition on line 56 was always true
57 prompt_parts.append(' [required]')
59 prompt_parts.append(': ')
60 return ''.join(prompt_parts)
62 def _get_user_input(self, prompt_message: str) -> str:
63 """Get user input with potential keyboard interrupts.
65 Args:
66 prompt_message: Prompt message to display
68 Returns:
69 User input string
71 Raises:
72 UserCancelledError: If user cancels input
74 """
75 self._output.write(prompt_message)
76 self._output.flush()
78 try:
79 user_input = self._input.readline()
80 except KeyboardInterrupt:
81 self._output.write('\n')
82 self._output.flush()
83 raise UserCancelledError('User cancelled input') from None
85 if not user_input: # EOF
86 raise UserCancelledError('User cancelled input (EOF)')
88 return user_input.strip()
90 def prompt_for_placeholder_value(self, placeholder: Placeholder) -> str:
91 """Prompt user for a placeholder value.
93 Args:
94 placeholder: Placeholder to prompt for
96 Returns:
97 User-provided value
99 Raises:
100 UserCancelledError: If user cancels input
102 """
103 prompt_message = self._format_placeholder_prompt(placeholder)
105 while True:
106 try:
107 value = self._get_user_input(prompt_message)
109 # Handle empty input
110 if not value:
111 if placeholder.required:
112 self._error.write(f"Error: '{placeholder.name}' is required\n")
113 self._error.flush()
114 continue
115 return placeholder.get_effective_value()
117 # Validate the value
118 placeholder.validate_value(value)
119 except (ValueError, InvalidPlaceholderValueError) as e:
120 self._error.write(f'Error: {e}\n')
121 self._error.flush()
122 continue
123 else:
124 return value
126 def prompt_for_single_value(self, placeholder: Placeholder) -> PlaceholderValue:
127 """Prompt user for a single placeholder value.
129 Args:
130 placeholder: Placeholder requiring a value
132 Returns:
133 PlaceholderValue with user input
135 Raises:
136 UserCancelledError: If user cancels the operation
137 InvalidPlaceholderValueError: If user provides invalid value
139 """
140 value = self.prompt_for_placeholder_value(placeholder)
141 return PlaceholderValue.from_user_input(placeholder.name, value)
143 def prompt_for_placeholder_values(self, placeholders: list[Placeholder]) -> dict[str, PlaceholderValue]:
144 """Prompt user for values for all placeholders.
146 Args:
147 placeholders: List of placeholders requiring values
149 Returns:
150 Dictionary mapping placeholder names to their values
152 Raises:
153 UserCancelledError: If user cancels the operation
154 InvalidPlaceholderValueError: If user provides invalid value
156 """
157 if not placeholders:
158 return {}
160 self._output.write(f'\nPlease provide values for {len(placeholders)} placeholder(s):\n\n')
161 self._output.flush()
163 values: dict[str, PlaceholderValue] = {}
165 for i, placeholder in enumerate(placeholders, 1):
166 self._output.write(f'[{i}/{len(placeholders)}] ')
167 self._output.flush()
169 try:
170 value = self.prompt_for_placeholder_value(placeholder)
171 values[placeholder.name] = PlaceholderValue.from_user_input(placeholder.name, value)
172 except KeyboardInterrupt:
173 self._output.write('\n')
174 self._output.flush()
175 raise UserCancelledError('User cancelled input') from None
177 self._output.write('\n')
178 self._output.flush()
180 self._output.write('All placeholder values collected successfully.\n\n')
181 self._output.flush()
182 return values
184 def prompt_for_multiple_placeholder_values(self, placeholders: list[Placeholder]) -> dict[str, str]:
185 """Prompt user for multiple placeholder values.
187 Args:
188 placeholders: List of placeholders to prompt for
190 Returns:
191 Dictionary mapping placeholder names to user-provided values
193 Raises:
194 UserCancelledError: If user cancels input
196 """
197 if not placeholders:
198 return {}
200 self._output.write(f'\nPlease provide values for {len(placeholders)} placeholder(s):\n\n')
201 self._output.flush()
203 values: dict[str, str] = {}
205 for i, placeholder in enumerate(placeholders, 1):
206 self._output.write(f'[{i}/{len(placeholders)}] ')
207 self._output.flush()
209 try:
210 value = self.prompt_for_placeholder_value(placeholder)
211 except KeyboardInterrupt:
212 self._output.write('\n')
213 self._output.flush()
214 raise UserCancelledError('User cancelled input') from None
216 values[placeholder.name] = value
217 self._output.write('\n')
218 self._output.flush()
220 self._output.write('All placeholder values collected successfully.\n\n')
221 self._output.flush()
222 return values
224 def confirm_placeholder_values(self, values: dict[str, str]) -> bool:
225 """Show placeholder values and ask user to confirm.
227 Args:
228 values: Dictionary of placeholder values to confirm
230 Returns:
231 True if user confirms, False if they want to re-enter
233 Raises:
234 UserCancelledError: If user cancels
236 """
237 try:
238 if not values:
239 return True
241 self._output.write('\nPlaceholder values summary:\n')
242 self._output.write('-' * 40 + '\n')
244 for name, value in values.items():
245 # Truncate very long values for display
246 display_value = value
247 if len(display_value) > self.MAX_DISPLAY_LENGTH:
248 display_value = display_value[:47] + '...'
250 self._output.write(f' {name}: {display_value}\n')
252 self._output.write('-' * 40 + '\n')
253 self._output.write('Proceed with these values? (y/n) [y]: ')
254 self._output.flush()
256 try:
257 response = self._input.readline()
258 except KeyboardInterrupt:
259 self._output.write('\n')
260 self._output.flush()
261 raise UserCancelledError('User cancelled confirmation') from None
263 if not response: # EOF
264 raise UserCancelledError('User cancelled confirmation (EOF)')
266 response = response.strip().lower()
268 # Default to 'yes' if empty
269 if not response or response in {'y', 'yes'}:
270 return True
271 if response in {'n', 'no'}:
272 return False
273 self._output.write("Please enter 'y' or 'n'\n")
274 self._output.flush()
275 # Retry
276 return self.confirm_placeholder_values(values)
278 except KeyboardInterrupt:
279 self._output.write('\n')
280 self._output.flush()
281 raise UserCancelledError('User cancelled confirmation') from None
283 def show_error_message(self, message: str) -> None:
284 """Display an error message to the user.
286 Args:
287 message: Error message to display
289 """
290 self._error.write(f'Error: {message}\n')
291 self._error.flush()
293 def show_success_message(self, message: str) -> None:
294 """Display success message to user.
296 Args:
297 message: Success message to display
299 """
300 self._output.write(f'✓ {message}\n')
301 self._output.flush()
303 def confirm_template_selection(self, template_name: str) -> bool:
304 """Confirm with user that they want to use the selected template.
306 Args:
307 template_name: Name of template to confirm
309 Returns:
310 True if user confirms, False otherwise
312 """
313 return self.prompt_for_yes_no(f"Use template '{template_name}'?", default=True)
315 def display_template_list(self, templates: list[str]) -> None:
316 """Display list of available templates to user.
318 Args:
319 templates: List of template names to display
321 """
322 if not templates:
323 self._output.write('No templates available.\n')
324 self._output.flush()
325 return
327 self._output.write('\nAvailable templates:\n')
328 for i, template in enumerate(templates, 1):
329 self._output.write(f' {i}. {template}\n')
330 self._output.write('\n')
331 self._output.flush()
333 def show_info_message(self, message: str) -> None:
334 """Display an info message to the user.
336 Args:
337 message: Info message to display
339 """
340 self._output.write(f'{message}\n')
341 self._output.flush()
343 def show_warning_message(self, message: str) -> None:
344 """Display a warning message to the user.
346 Args:
347 message: Warning message to display
349 """
350 self._error.write(f'Warning: {message}\n')
351 self._error.flush()
353 def prompt_for_yes_no(self, question: str, *, default: bool | None = None) -> bool:
354 """Prompt user for a yes/no answer.
356 Args:
357 question: Question to ask the user
358 default: Default answer if user just presses enter
360 Returns:
361 True for yes, False for no
363 Raises:
364 UserCancelledError: If user cancels input
366 """
367 try:
368 # Handle default when None is provided
369 prompt_default: bool = True if default is None else default
370 default_text = 'Y/n' if default is None or default else 'y/N'
371 prompt = f'{question} ({default_text}): '
373 self._output.write(prompt)
374 self._output.flush()
376 try:
377 response = self._input.readline()
378 except KeyboardInterrupt:
379 self._output.write('\n')
380 self._output.flush()
381 raise UserCancelledError('User cancelled input') from None
383 if not response: # EOF
384 raise UserCancelledError('User cancelled input (EOF)')
386 response = response.strip().lower()
388 if not response:
389 return prompt_default
390 if response in {'y', 'yes'}:
391 return True
392 if response in {'n', 'no'}:
393 return False
394 self._output.write("Please enter 'y' or 'n'\n")
395 self._output.flush()
396 # Retry
397 return self.prompt_for_yes_no(question, default=default)
399 except KeyboardInterrupt:
400 self._output.write('\n')
401 self._output.flush()
402 raise UserCancelledError('User cancelled input') from None
404 def prompt_for_choice(self, question: str, choices: list[str], default: int = 0) -> str:
405 """Prompt user to select from a list of choices.
407 Args:
408 question: Question to ask the user
409 choices: List of available choices
410 default: Default choice index (0-based)
412 Returns:
413 Selected choice string
415 Raises:
416 UserCancelledError: If user cancels input
417 ValueError: If choices is empty or default is invalid
419 """
420 if not choices:
421 raise ValueError('Choices list cannot be empty')
422 if default < 0 or default >= len(choices):
423 msg = f'Default index {default} out of range for choices'
424 raise ValueError(msg)
426 try:
427 self._output.write(f'{question}\n')
429 for i, choice in enumerate(choices):
430 marker = '*' if i == default else ' '
431 self._output.write(f'{marker} {i + 1}. {choice}\n')
433 prompt = f'Select (1-{len(choices)}) [{default + 1}]: '
434 self._output.write(prompt)
435 self._output.flush()
437 try:
438 response = self._input.readline()
439 except KeyboardInterrupt:
440 self._output.write('\n')
441 self._output.flush()
442 raise UserCancelledError('User cancelled input') from None
444 if not response: # EOF
445 raise UserCancelledError('User cancelled input (EOF)')
447 response = response.strip()
449 if not response:
450 return choices[default]
452 try:
453 choice_index = int(response) - 1
454 if 0 <= choice_index < len(choices):
455 return choices[choice_index]
456 self._output.write(f'Please enter a number between 1 and {len(choices)}\n')
457 self._output.flush()
458 # Retry
459 return self.prompt_for_choice(question, choices, default)
460 except ValueError:
461 self._output.write('Please enter a valid number\n')
462 self._output.flush()
463 # Retry
464 return self.prompt_for_choice(question, choices, default)
466 except KeyboardInterrupt:
467 self._output.write('\n')
468 self._output.flush()
469 raise UserCancelledError('User cancelled input') from None