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

1"""CLI-based user prompter adapter for interactive placeholder input.""" 

2 

3import sys 

4from typing import Final, TextIO 

5 

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 

12 

13MAX_DISPLAY_LENGTH: Final[int] = 50 

14 

15 

16class CLIUserPrompter(UserPrompterPort): 

17 """CLI-based implementation of user prompter for placeholder values.""" 

18 

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. 

26 

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 

31 

32 """ 

33 self._input = input_stream 

34 self._output = output_stream 

35 self._error = error_stream 

36 self.MAX_DISPLAY_LENGTH = MAX_DISPLAY_LENGTH 

37 

38 @staticmethod 

39 def _format_placeholder_prompt(placeholder: Placeholder) -> str: 

40 """Format prompt message for a placeholder. 

41 

42 Args: 

43 placeholder: Placeholder to create prompt for 

44 

45 Returns: 

46 Formatted prompt message 

47 

48 """ 

49 prompt_parts = [f"Enter value for '{placeholder.name}'"] 

50 

51 if placeholder.description: 

52 prompt_parts.append(f' ({placeholder.description})') 

53 

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]') 

58 

59 prompt_parts.append(': ') 

60 return ''.join(prompt_parts) 

61 

62 def _get_user_input(self, prompt_message: str) -> str: 

63 """Get user input with potential keyboard interrupts. 

64 

65 Args: 

66 prompt_message: Prompt message to display 

67 

68 Returns: 

69 User input string 

70 

71 Raises: 

72 UserCancelledError: If user cancels input 

73 

74 """ 

75 self._output.write(prompt_message) 

76 self._output.flush() 

77 

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 

84 

85 if not user_input: # EOF 

86 raise UserCancelledError('User cancelled input (EOF)') 

87 

88 return user_input.strip() 

89 

90 def prompt_for_placeholder_value(self, placeholder: Placeholder) -> str: 

91 """Prompt user for a placeholder value. 

92 

93 Args: 

94 placeholder: Placeholder to prompt for 

95 

96 Returns: 

97 User-provided value 

98 

99 Raises: 

100 UserCancelledError: If user cancels input 

101 

102 """ 

103 prompt_message = self._format_placeholder_prompt(placeholder) 

104 

105 while True: 

106 try: 

107 value = self._get_user_input(prompt_message) 

108 

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() 

116 

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 

125 

126 def prompt_for_single_value(self, placeholder: Placeholder) -> PlaceholderValue: 

127 """Prompt user for a single placeholder value. 

128 

129 Args: 

130 placeholder: Placeholder requiring a value 

131 

132 Returns: 

133 PlaceholderValue with user input 

134 

135 Raises: 

136 UserCancelledError: If user cancels the operation 

137 InvalidPlaceholderValueError: If user provides invalid value 

138 

139 """ 

140 value = self.prompt_for_placeholder_value(placeholder) 

141 return PlaceholderValue.from_user_input(placeholder.name, value) 

142 

143 def prompt_for_placeholder_values(self, placeholders: list[Placeholder]) -> dict[str, PlaceholderValue]: 

144 """Prompt user for values for all placeholders. 

145 

146 Args: 

147 placeholders: List of placeholders requiring values 

148 

149 Returns: 

150 Dictionary mapping placeholder names to their values 

151 

152 Raises: 

153 UserCancelledError: If user cancels the operation 

154 InvalidPlaceholderValueError: If user provides invalid value 

155 

156 """ 

157 if not placeholders: 

158 return {} 

159 

160 self._output.write(f'\nPlease provide values for {len(placeholders)} placeholder(s):\n\n') 

161 self._output.flush() 

162 

163 values: dict[str, PlaceholderValue] = {} 

164 

165 for i, placeholder in enumerate(placeholders, 1): 

166 self._output.write(f'[{i}/{len(placeholders)}] ') 

167 self._output.flush() 

168 

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 

176 

177 self._output.write('\n') 

178 self._output.flush() 

179 

180 self._output.write('All placeholder values collected successfully.\n\n') 

181 self._output.flush() 

182 return values 

183 

184 def prompt_for_multiple_placeholder_values(self, placeholders: list[Placeholder]) -> dict[str, str]: 

185 """Prompt user for multiple placeholder values. 

186 

187 Args: 

188 placeholders: List of placeholders to prompt for 

189 

190 Returns: 

191 Dictionary mapping placeholder names to user-provided values 

192 

193 Raises: 

194 UserCancelledError: If user cancels input 

195 

196 """ 

197 if not placeholders: 

198 return {} 

199 

200 self._output.write(f'\nPlease provide values for {len(placeholders)} placeholder(s):\n\n') 

201 self._output.flush() 

202 

203 values: dict[str, str] = {} 

204 

205 for i, placeholder in enumerate(placeholders, 1): 

206 self._output.write(f'[{i}/{len(placeholders)}] ') 

207 self._output.flush() 

208 

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 

215 

216 values[placeholder.name] = value 

217 self._output.write('\n') 

218 self._output.flush() 

219 

220 self._output.write('All placeholder values collected successfully.\n\n') 

221 self._output.flush() 

222 return values 

223 

224 def confirm_placeholder_values(self, values: dict[str, str]) -> bool: 

225 """Show placeholder values and ask user to confirm. 

226 

227 Args: 

228 values: Dictionary of placeholder values to confirm 

229 

230 Returns: 

231 True if user confirms, False if they want to re-enter 

232 

233 Raises: 

234 UserCancelledError: If user cancels 

235 

236 """ 

237 try: 

238 if not values: 

239 return True 

240 

241 self._output.write('\nPlaceholder values summary:\n') 

242 self._output.write('-' * 40 + '\n') 

243 

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] + '...' 

249 

250 self._output.write(f' {name}: {display_value}\n') 

251 

252 self._output.write('-' * 40 + '\n') 

253 self._output.write('Proceed with these values? (y/n) [y]: ') 

254 self._output.flush() 

255 

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 

262 

263 if not response: # EOF 

264 raise UserCancelledError('User cancelled confirmation (EOF)') 

265 

266 response = response.strip().lower() 

267 

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) 

277 

278 except KeyboardInterrupt: 

279 self._output.write('\n') 

280 self._output.flush() 

281 raise UserCancelledError('User cancelled confirmation') from None 

282 

283 def show_error_message(self, message: str) -> None: 

284 """Display an error message to the user. 

285 

286 Args: 

287 message: Error message to display 

288 

289 """ 

290 self._error.write(f'Error: {message}\n') 

291 self._error.flush() 

292 

293 def show_success_message(self, message: str) -> None: 

294 """Display success message to user. 

295 

296 Args: 

297 message: Success message to display 

298 

299 """ 

300 self._output.write(f'{message}\n') 

301 self._output.flush() 

302 

303 def confirm_template_selection(self, template_name: str) -> bool: 

304 """Confirm with user that they want to use the selected template. 

305 

306 Args: 

307 template_name: Name of template to confirm 

308 

309 Returns: 

310 True if user confirms, False otherwise 

311 

312 """ 

313 return self.prompt_for_yes_no(f"Use template '{template_name}'?", default=True) 

314 

315 def display_template_list(self, templates: list[str]) -> None: 

316 """Display list of available templates to user. 

317 

318 Args: 

319 templates: List of template names to display 

320 

321 """ 

322 if not templates: 

323 self._output.write('No templates available.\n') 

324 self._output.flush() 

325 return 

326 

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() 

332 

333 def show_info_message(self, message: str) -> None: 

334 """Display an info message to the user. 

335 

336 Args: 

337 message: Info message to display 

338 

339 """ 

340 self._output.write(f'{message}\n') 

341 self._output.flush() 

342 

343 def show_warning_message(self, message: str) -> None: 

344 """Display a warning message to the user. 

345 

346 Args: 

347 message: Warning message to display 

348 

349 """ 

350 self._error.write(f'Warning: {message}\n') 

351 self._error.flush() 

352 

353 def prompt_for_yes_no(self, question: str, *, default: bool | None = None) -> bool: 

354 """Prompt user for a yes/no answer. 

355 

356 Args: 

357 question: Question to ask the user 

358 default: Default answer if user just presses enter 

359 

360 Returns: 

361 True for yes, False for no 

362 

363 Raises: 

364 UserCancelledError: If user cancels input 

365 

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}): ' 

372 

373 self._output.write(prompt) 

374 self._output.flush() 

375 

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 

382 

383 if not response: # EOF 

384 raise UserCancelledError('User cancelled input (EOF)') 

385 

386 response = response.strip().lower() 

387 

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) 

398 

399 except KeyboardInterrupt: 

400 self._output.write('\n') 

401 self._output.flush() 

402 raise UserCancelledError('User cancelled input') from None 

403 

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. 

406 

407 Args: 

408 question: Question to ask the user 

409 choices: List of available choices 

410 default: Default choice index (0-based) 

411 

412 Returns: 

413 Selected choice string 

414 

415 Raises: 

416 UserCancelledError: If user cancels input 

417 ValueError: If choices is empty or default is invalid 

418 

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) 

425 

426 try: 

427 self._output.write(f'{question}\n') 

428 

429 for i, choice in enumerate(choices): 

430 marker = '*' if i == default else ' ' 

431 self._output.write(f'{marker} {i + 1}. {choice}\n') 

432 

433 prompt = f'Select (1-{len(choices)}) [{default + 1}]: ' 

434 self._output.write(prompt) 

435 self._output.flush() 

436 

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 

443 

444 if not response: # EOF 

445 raise UserCancelledError('User cancelled input (EOF)') 

446 

447 response = response.strip() 

448 

449 if not response: 

450 return choices[default] 

451 

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) 

465 

466 except KeyboardInterrupt: 

467 self._output.write('\n') 

468 self._output.flush() 

469 raise UserCancelledError('User cancelled input') from None