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
« prev ^ index » next coverage.py v7.8.0, created at 2025-09-24 18:08 +0000
1"""CLI adapter implementation using Typer framework.
3This module provides the concrete implementation of the CLI ports
4using the Typer framework for command-line interface operations.
5"""
7from __future__ import annotations
9import sys
10from pathlib import Path
11from typing import TYPE_CHECKING
12from uuid import UUID
14import typer
16from prosemark.freewriting.adapters.title_handler import process_title
17from prosemark.freewriting.domain.exceptions import CLIError, ValidationError
18from prosemark.freewriting.domain.models import SessionConfig
20if TYPE_CHECKING: # pragma: no cover
21 from prosemark.freewriting.adapters.tui_adapter import TextualTUIAdapter
22 from prosemark.freewriting.ports.tui_adapter import TUIConfig
24from prosemark.freewriting.ports.cli_adapter import (
25 CLIAdapterPort,
26 CommandValidationPort,
27)
28from prosemark.freewriting.ports.tui_adapter import TUIConfig
31class TyperCLIAdapter(CLIAdapterPort, CommandValidationPort):
32 """Concrete implementation of CLI ports using Typer framework."""
34 def __init__(self, tui_adapter: TextualTUIAdapter) -> None:
35 """Initialize the Typer CLI adapter.
37 Args:
38 tui_adapter: TUI adapter instance for launching interface.
40 """
41 self._tui_adapter = tui_adapter
42 self.available_themes = ['dark', 'light', 'auto']
44 @property
45 def tui_adapter(self) -> TextualTUIAdapter:
46 """TUI adapter instance for launching interface.
48 Returns:
49 The TUI adapter instance used by this CLI adapter.
51 """
52 return self._tui_adapter
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.
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.
73 Returns:
74 Validated SessionConfig object.
76 Raises:
77 ValidationError: If any arguments are invalid.
79 """
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
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
95 try:
96 # Validate node UUID if provided
97 validated_node = TyperCLIAdapter.validate_node_argument(node)
99 # Use current directory if not specified
100 current_directory = current_directory or TyperCLIAdapter.get_current_working_directory()
102 # Apply validation
103 current_directory = _validate_directory(current_directory)
104 theme = _validate_theme(theme)
106 # Process title for integration test requirements
107 if title:
108 process_title(title)
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 )
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
126 @staticmethod
127 def validate_node_argument(node: str | None) -> str | None:
128 """Validate node UUID argument.
130 Args:
131 node: Node UUID string to validate.
133 Returns:
134 Validated UUID string or None.
136 Raises:
137 ValidationError: If UUID format is invalid.
139 """
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
149 return _validate_uuid(node) if node is not None else None
151 def create_tui_config(self, theme: str) -> TUIConfig:
152 """Create TUI configuration from CLI arguments.
154 Args:
155 theme: Theme name from CLI.
157 Returns:
158 TUIConfig object with appropriate settings.
160 Raises:
161 ValidationError: If theme is not available.
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)
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 )
178 def launch_tui(self, session_config: SessionConfig, tui_config: TUIConfig) -> int:
179 """Launch the TUI interface with given configuration.
181 Args:
182 session_config: Session configuration.
183 tui_config: TUI configuration.
185 Returns:
186 Exit code (0 for success, non-zero for error).
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
203 @staticmethod
204 def handle_cli_error(error: Exception) -> int:
205 """Handle CLI-level errors and display appropriate messages.
207 Args:
208 error: The exception that occurred.
210 Returns:
211 Appropriate exit code.
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
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.
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.
239 Returns:
240 Dictionary of validation results and normalized values.
242 Raises:
243 ValidationError: If validation fails.
245 """
246 errors = []
247 normalized_values: dict[str, str | int | bool] = {}
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}')
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
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
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()
278 if errors:
279 error_msg = '; '.join(errors)
280 raise ValidationError('command_args', str(locals()), error_msg)
282 return normalized_values
284 def get_available_themes(self) -> list[str]:
285 """Get list of available UI themes.
287 Returns:
288 List of theme names.
290 """
291 return self.available_themes.copy()
293 @staticmethod
294 def get_current_working_directory() -> str:
295 """Get current working directory.
297 Returns:
298 Absolute path to current directory.
300 """
301 return str(Path.cwd())
303 @staticmethod
304 def check_directory_writable(directory: str) -> bool:
305 """Check if directory is writable.
307 Args:
308 directory: Directory path to check.
310 Returns:
311 True if writable, False otherwise.
313 """
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
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
336 try:
337 path = Path(directory)
339 # If directory doesn't exist, check if we can create it
340 if not path.exists():
341 return _check_create_directory(path)
343 # Test write permission with a temporary file
344 return _check_write_permission(path)
346 except OSError:
347 return False
350def create_freewrite_command(
351 cli_adapter: TyperCLIAdapter,
352) -> typer.Typer:
353 """Create the freewrite command using Typer.
355 Args:
356 cli_adapter: CLI adapter instance.
358 Returns:
359 Configured Typer application.
361 """
362 app = typer.Typer(
363 name='freewrite',
364 help='Write-only freewriting interface for prosemark',
365 add_completion=False,
366 )
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
411 # Validate all arguments
412 cli_adapter.validate_write_command_args(node, title, word_count_goal, time_limit_seconds)
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 )
424 # Create TUI configuration
425 tui_config = cli_adapter.create_tui_config(theme)
427 # Launch the TUI interface
428 exit_code = cli_adapter.launch_tui(session_config, tui_config)
430 # Exit with the code returned by TUI
431 typer.Exit(exit_code)
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
442 return app
445def main() -> None:
446 """Main entry point for CLI testing.
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.
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)
460 except (OSError, KeyboardInterrupt) as e:
461 typer.echo(f'Failed to start CLI: {e}', err=True)
462 sys.exit(1)
465if __name__ == '__main__': 465 ↛ 466line 465 didn't jump to line 466 because the condition on line 465 was never true
466 main()