Coverage for src/prosemark/freewriting/adapters/cli_adapter.py: 90%

162 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-09-24 18:08 +0000

1"""CLI adapter implementation using Typer framework. 

2 

3This module provides the concrete implementation of the CLI ports 

4using the Typer framework for command-line interface operations. 

5""" 

6 

7from __future__ import annotations 

8 

9import sys 

10from pathlib import Path 

11from typing import TYPE_CHECKING 

12from uuid import UUID 

13 

14import typer 

15 

16from prosemark.freewriting.adapters.title_handler import process_title 

17from prosemark.freewriting.domain.exceptions import CLIError, ValidationError 

18from prosemark.freewriting.domain.models import SessionConfig 

19 

20if TYPE_CHECKING: # pragma: no cover 

21 from prosemark.freewriting.adapters.tui_adapter import TextualTUIAdapter 

22 from prosemark.freewriting.ports.tui_adapter import TUIConfig 

23 

24from prosemark.freewriting.ports.cli_adapter import ( 

25 CLIAdapterPort, 

26 CommandValidationPort, 

27) 

28from prosemark.freewriting.ports.tui_adapter import TUIConfig 

29 

30 

31class TyperCLIAdapter(CLIAdapterPort, CommandValidationPort): 

32 """Concrete implementation of CLI ports using Typer framework.""" 

33 

34 def __init__(self, tui_adapter: TextualTUIAdapter) -> None: 

35 """Initialize the Typer CLI adapter. 

36 

37 Args: 

38 tui_adapter: TUI adapter instance for launching interface. 

39 

40 """ 

41 self._tui_adapter = tui_adapter 

42 self.available_themes = ['dark', 'light', 'auto'] 

43 

44 @property 

45 def tui_adapter(self) -> TextualTUIAdapter: 

46 """TUI adapter instance for launching interface. 

47 

48 Returns: 

49 The TUI adapter instance used by this CLI adapter. 

50 

51 """ 

52 return self._tui_adapter 

53 

54 def parse_arguments( 

55 self, 

56 node: str | None, 

57 title: str | None, 

58 word_count_goal: int | None, 

59 time_limit: int | None, 

60 theme: str, 

61 current_directory: str | None, 

62 ) -> SessionConfig: 

63 """Parse and validate CLI arguments into session configuration. 

64 

65 Args: 

66 node: Optional UUID of target node. 

67 title: Optional session title. 

68 word_count_goal: Optional word count target. 

69 time_limit: Optional time limit in seconds. 

70 theme: UI theme name. 

71 current_directory: Working directory override. 

72 

73 Returns: 

74 Validated SessionConfig object. 

75 

76 Raises: 

77 ValidationError: If any arguments are invalid. 

78 

79 """ 

80 

81 def _validate_directory(directory: str) -> str: 

82 """Internal helper to validate directory.""" 

83 if not TyperCLIAdapter.check_directory_writable(directory): 

84 msg = 'Directory is not writable' 

85 raise ValidationError('current_directory', directory, msg) 

86 return directory 

87 

88 def _validate_theme(theme_name: str) -> str: 

89 """Internal helper to validate theme.""" 

90 if theme_name not in self.available_themes: 

91 msg = f'Invalid theme. Available themes: {self.available_themes}' 

92 raise ValidationError('theme', theme_name, msg) 

93 return theme_name 

94 

95 try: 

96 # Validate node UUID if provided 

97 validated_node = TyperCLIAdapter.validate_node_argument(node) 

98 

99 # Use current directory if not specified 

100 current_directory = current_directory or TyperCLIAdapter.get_current_working_directory() 

101 

102 # Apply validation 

103 current_directory = _validate_directory(current_directory) 

104 theme = _validate_theme(theme) 

105 

106 # Process title for integration test requirements 

107 if title: 

108 process_title(title) 

109 

110 # Create session configuration 

111 return SessionConfig( 

112 target_node=validated_node, 

113 title=title, 

114 word_count_goal=word_count_goal, 

115 time_limit=time_limit, 

116 theme=theme, 

117 current_directory=current_directory, 

118 ) 

119 

120 except ValidationError: 

121 raise 

122 except Exception as e: 

123 msg = f'Failed to parse arguments: {e}' 

124 raise CLIError('freewrite', 'arguments', msg) from e 

125 

126 @staticmethod 

127 def validate_node_argument(node: str | None) -> str | None: 

128 """Validate node UUID argument. 

129 

130 Args: 

131 node: Node UUID string to validate. 

132 

133 Returns: 

134 Validated UUID string or None. 

135 

136 Raises: 

137 ValidationError: If UUID format is invalid. 

138 

139 """ 

140 

141 def _validate_uuid(uuid_str: str) -> str: 

142 """Validate UUID format.""" 

143 try: 

144 parsed_uuid = UUID(uuid_str) 

145 return str(parsed_uuid) 

146 except ValueError as e: 

147 raise ValidationError('node', uuid_str, 'Invalid UUID format') from e 

148 

149 return _validate_uuid(node) if node is not None else None 

150 

151 def create_tui_config(self, theme: str) -> TUIConfig: 

152 """Create TUI configuration from CLI arguments. 

153 

154 Args: 

155 theme: Theme name from CLI. 

156 

157 Returns: 

158 TUIConfig object with appropriate settings. 

159 

160 Raises: 

161 ValidationError: If theme is not available. 

162 

163 """ 

164 if theme not in self.available_themes: 

165 msg = f'Theme not available. Available themes: {self.available_themes}' 

166 raise ValidationError('theme', theme, msg) 

167 

168 return TUIConfig( 

169 theme=theme, 

170 content_height_percent=80, 

171 input_height_percent=20, 

172 show_word_count=True, 

173 show_timer=True, 

174 auto_scroll=True, 

175 max_display_lines=1000, 

176 ) 

177 

178 def launch_tui(self, session_config: SessionConfig, tui_config: TUIConfig) -> int: 

179 """Launch the TUI interface with given configuration. 

180 

181 Args: 

182 session_config: Session configuration. 

183 tui_config: TUI configuration. 

184 

185 Returns: 

186 Exit code (0 for success, non-zero for error). 

187 

188 """ 

189 try: 

190 return self.tui_adapter.run_tui(session_config, tui_config) 

191 except (ValidationError, CLIError) as e: 

192 return TyperCLIAdapter.handle_cli_error(e) 

193 except RuntimeError as e: 

194 # More specific error handling for TUI-related runtime errors 

195 msg = f'TUI Runtime Error: {e}' 

196 typer.echo(msg, err=True) 

197 return 1 # Runtime error 

198 except KeyboardInterrupt: 

199 # Handle graceful interruption 

200 typer.echo('TUI interrupted by user', err=True) 

201 return 2 # Interrupted 

202 

203 @staticmethod 

204 def handle_cli_error(error: Exception) -> int: 

205 """Handle CLI-level errors and display appropriate messages. 

206 

207 Args: 

208 error: The exception that occurred. 

209 

210 Returns: 

211 Appropriate exit code. 

212 

213 """ 

214 # Determine error type and exit code 

215 if isinstance(error, ValidationError): 

216 typer.echo(f'Validation Error: {error}', err=True) 

217 return 2 # Invalid arguments 

218 if isinstance(error, CLIError): 

219 typer.echo(f'CLI Error: {error}', err=True) 

220 return error.exit_code 

221 typer.echo(f'Unexpected Error: {error}', err=True) 

222 return 1 # General error 

223 

224 @staticmethod 

225 def validate_write_command_args( 

226 node: str | None, 

227 title: str | None, 

228 word_count_goal: int | None, 

229 time_limit: int | None, 

230 ) -> dict[str, str | int | bool]: 

231 """Validate arguments for the write command. 

232 

233 Args: 

234 node: Optional node UUID. 

235 title: Optional title. 

236 word_count_goal: Optional word count goal. 

237 time_limit: Optional time limit. 

238 

239 Returns: 

240 Dictionary of validation results and normalized values. 

241 

242 Raises: 

243 ValidationError: If validation fails. 

244 

245 """ 

246 errors = [] 

247 normalized_values: dict[str, str | int | bool] = {} 

248 

249 # Validate node UUID 

250 if node is not None: 

251 try: 

252 validated_node = TyperCLIAdapter.validate_node_argument(node) 

253 normalized_values['node'] = validated_node or '' 

254 except ValidationError as e: 

255 errors.append(f'node: {e.validation_rule}') 

256 

257 # Validate word count goal 

258 if word_count_goal is not None: 

259 if word_count_goal <= 0: 

260 errors.append('word_count_goal: must be positive') 

261 else: 

262 normalized_values['word_count_goal'] = word_count_goal 

263 

264 # Validate time limit 

265 if time_limit is not None: 

266 if time_limit <= 0: 

267 errors.append('time_limit: must be positive') 

268 else: 

269 normalized_values['time_limit'] = time_limit 

270 

271 # Validate title (optional, but if present should not be empty) 

272 if title is not None: 

273 if not title.strip(): 

274 errors.append('title: cannot be empty if provided') 

275 else: 

276 normalized_values['title'] = title.strip() 

277 

278 if errors: 

279 error_msg = '; '.join(errors) 

280 raise ValidationError('command_args', str(locals()), error_msg) 

281 

282 return normalized_values 

283 

284 def get_available_themes(self) -> list[str]: 

285 """Get list of available UI themes. 

286 

287 Returns: 

288 List of theme names. 

289 

290 """ 

291 return self.available_themes.copy() 

292 

293 @staticmethod 

294 def get_current_working_directory() -> str: 

295 """Get current working directory. 

296 

297 Returns: 

298 Absolute path to current directory. 

299 

300 """ 

301 return str(Path.cwd()) 

302 

303 @staticmethod 

304 def check_directory_writable(directory: str) -> bool: 

305 """Check if directory is writable. 

306 

307 Args: 

308 directory: Directory path to check. 

309 

310 Returns: 

311 True if writable, False otherwise. 

312 

313 """ 

314 

315 def _check_create_directory(path: Path) -> bool: 

316 """Check if directory can be created.""" 

317 try: 

318 path.mkdir(parents=True, exist_ok=True) 

319 # If directory was successfully created 

320 if path.exists(): 320 ↛ 324line 320 didn't jump to line 324 because the condition on line 320 was always true

321 path.rmdir() 

322 except OSError: 

323 return False 

324 return True 

325 

326 def _check_write_permission(path: Path) -> bool: 

327 """Check if directory is writable.""" 

328 test_file = path / '.cli_write_test' 

329 try: 

330 test_file.write_text('test', encoding='utf-8') 

331 test_file.unlink() 

332 except OSError: 

333 return False 

334 return True 

335 

336 try: 

337 path = Path(directory) 

338 

339 # If directory doesn't exist, check if we can create it 

340 if not path.exists(): 

341 return _check_create_directory(path) 

342 

343 # Test write permission with a temporary file 

344 return _check_write_permission(path) 

345 

346 except OSError: 

347 return False 

348 

349 

350def create_freewrite_command( 

351 cli_adapter: TyperCLIAdapter, 

352) -> typer.Typer: 

353 """Create the freewrite command using Typer. 

354 

355 Args: 

356 cli_adapter: CLI adapter instance. 

357 

358 Returns: 

359 Configured Typer application. 

360 

361 """ 

362 app = typer.Typer( 

363 name='freewrite', 

364 help='Write-only freewriting interface for prosemark', 

365 add_completion=False, 

366 ) 

367 

368 @app.command() 

369 def write( 

370 node: str | None = typer.Argument( 

371 None, 

372 help='Target node UUID (optional, creates daily file if not specified)', 

373 ), 

374 title: str | None = typer.Option( 

375 None, 

376 '--title', 

377 '-t', 

378 help='Optional title for the session', 

379 ), 

380 word_count_goal: int | None = typer.Option( 

381 None, 

382 '--words', 

383 '-w', 

384 help='Word count goal for the session', 

385 min=1, 

386 ), 

387 time_limit: int | None = typer.Option( 

388 None, 

389 '--time', 

390 '-m', 

391 help='Time limit for session in minutes', 

392 min=1, 

393 ), 

394 theme: str = typer.Option( 

395 'dark', 

396 '--theme', 

397 help='UI theme (dark, light, auto)', 

398 ), 

399 directory: str | None = typer.Option( 

400 None, 

401 '--directory', 

402 '-d', 

403 help='Working directory (defaults to current directory)', 

404 ), 

405 ) -> None: 

406 """Start a freewriting session with write-only TUI interface.""" 

407 try: 

408 # Convert time limit from minutes to seconds 

409 time_limit_seconds = time_limit * 60 if time_limit else None 

410 

411 # Validate all arguments 

412 cli_adapter.validate_write_command_args(node, title, word_count_goal, time_limit_seconds) 

413 

414 # Parse arguments into session configuration 

415 session_config = cli_adapter.parse_arguments( 

416 node=node, 

417 title=title, 

418 word_count_goal=word_count_goal, 

419 time_limit=time_limit_seconds, 

420 theme=theme, 

421 current_directory=directory, 

422 ) 

423 

424 # Create TUI configuration 

425 tui_config = cli_adapter.create_tui_config(theme) 

426 

427 # Launch the TUI interface 

428 exit_code = cli_adapter.launch_tui(session_config, tui_config) 

429 

430 # Exit with the code returned by TUI 

431 typer.Exit(exit_code) 

432 

433 except (ValidationError, CLIError) as e: 

434 # Let the CLI adapter handle the error and determine exit code 

435 exit_code = TyperCLIAdapter.handle_cli_error(e) 

436 raise typer.Exit(exit_code) from e 

437 except Exception as e: 

438 # Handle unexpected errors 

439 exit_code = TyperCLIAdapter.handle_cli_error(e) 

440 raise typer.Exit(exit_code) from e 

441 

442 return app 

443 

444 

445def main() -> None: 

446 """Main entry point for CLI testing. 

447 

448 This function is primarily for development and testing. 

449 In production, the CLI integration would be handled by 

450 the main prosemark CLI application. 

451 

452 Note: This function creates minimal stub dependencies for testing. 

453 Real usage would inject proper implementations from the main app. 

454 """ 

455 try: 

456 typer.echo('Error: This CLI requires proper dependency injection from main app') 

457 typer.echo('Use this adapter through the main prosemark CLI application') 

458 sys.exit(1) 

459 

460 except (OSError, KeyboardInterrupt) as e: 

461 typer.echo(f'Failed to start CLI: {e}', err=True) 

462 sys.exit(1) 

463 

464 

465if __name__ == '__main__': 465 ↛ 466line 465 didn't jump to line 466 because the condition on line 465 was never true

466 main()