strat-gameplay-webapp/backend/terminal_client/update_docs/phase_2.md
Cal Corum 1c32787195 CLAUDE: Refactor game models and modularize terminal client
This commit includes cleanup from model refactoring and terminal client
modularization for better code organization and maintainability.

## Game Models Refactor

**Removed RunnerState class:**
- Eliminated separate RunnerState model (was redundant)
- Replaced runners: List[RunnerState] with direct base references:
  - on_first: Optional[LineupPlayerState]
  - on_second: Optional[LineupPlayerState]
  - on_third: Optional[LineupPlayerState]
- Updated helper methods:
  - get_runner_at_base() now returns LineupPlayerState directly
  - get_all_runners() returns List[Tuple[int, LineupPlayerState]]
  - is_runner_on_X() simplified to direct None checks

**Benefits:**
- Matches database structure (plays table has on_first_id, etc.)
- Simpler state management (direct references vs list management)
- Better type safety (LineupPlayerState vs generic runner)
- Easier to work with in game engine logic

**Updated files:**
- app/models/game_models.py - Removed RunnerState, updated GameState
- app/core/play_resolver.py - Use get_all_runners() instead of state.runners
- app/core/validators.py - Updated runner access patterns
- tests/unit/models/test_game_models.py - Updated test assertions
- tests/unit/core/test_play_resolver.py - Updated test data
- tests/unit/core/test_validators.py - Updated test data

## Terminal Client Refactor

**Modularization (DRY principle):**
Created separate modules for better code organization:

1. **terminal_client/commands.py** (10,243 bytes)
   - Shared command functions for game operations
   - Used by both CLI (main.py) and REPL (repl.py)
   - Functions: submit_defensive_decision, submit_offensive_decision,
     resolve_play, quick_play_sequence
   - Single source of truth for command logic

2. **terminal_client/arg_parser.py** (7,280 bytes)
   - Centralized argument parsing and validation
   - Handles defensive/offensive decision arguments
   - Validates formats (alignment, depths, hold runners, steal attempts)

3. **terminal_client/completions.py** (10,357 bytes)
   - TAB completion support for REPL mode
   - Command completions, option completions, dynamic completions
   - Game ID completions, defensive/offensive option suggestions

4. **terminal_client/help_text.py** (10,839 bytes)
   - Centralized help text and command documentation
   - Detailed command descriptions
   - Usage examples for all commands

**Updated main modules:**
- terminal_client/main.py - Simplified by using shared commands module
- terminal_client/repl.py - Cleaner with shared functions and completions

**Benefits:**
- DRY: Behavior consistent between CLI and REPL modes
- Maintainability: Changes in one place affect both interfaces
- Testability: Can test commands module independently
- Organization: Clear separation of concerns

## Documentation

**New files:**
- app/models/visual_model_relationships.md
  - Visual documentation of model relationships
  - Helps understand data flow between models
- terminal_client/update_docs/ (6 phase documentation files)
  - Phased documentation for terminal client evolution
  - Historical context for implementation decisions

## Tests

**New test files:**
- tests/unit/terminal_client/__init__.py
- tests/unit/terminal_client/test_arg_parser.py
- tests/unit/terminal_client/test_commands.py
- tests/unit/terminal_client/test_completions.py
- tests/unit/terminal_client/test_help_text.py

**Updated tests:**
- Integration tests updated for new runner model
- Unit tests updated for model changes
- All tests passing with new structure

## Summary

-  Simplified game state model (removed RunnerState)
-  Better alignment with database structure
-  Modularized terminal client (DRY principle)
-  Shared command logic between CLI and REPL
-  Comprehensive test coverage
-  Improved documentation

Total changes: 26 files modified/created

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-28 14:16:38 -05:00

20 KiB

● Terminal Client Improvement Plan - Part 2: Robust Argument Parsing

Overview

Replace manual string splitting with shlex for robust argument parsing that handles quoted strings, edge cases, and complex arguments properly.

Files to Create

  1. Create backend/terminal_client/arg_parser.py

""" Argument parsing utilities for terminal client commands.

Provides robust parsing for both REPL and CLI commands using shlex to handle quoted strings, spaces, and complex arguments.

Author: Claude Date: 2025-10-27 """ import shlex import logging from typing import Dict, Any, List, Optional, Tuple

logger = logging.getLogger(f'{name}.arg_parser')

class ArgumentParseError(Exception): """Raised when argument parsing fails.""" pass

class CommandArgumentParser: """Parse command-line style arguments for terminal client."""

  @staticmethod
  def parse_args(arg_string: str, schema: Dict[str, Any]) -> Dict[str, Any]:
      """
      Parse argument string according to schema.
      
      Args:
          arg_string: Raw argument string from command
          schema: Dictionary defining expected arguments
              {
                  'league': {'type': str, 'default': 'sba'},
                  'home-team': {'type': int, 'default': 1},
                  'count': {'type': int, 'default': 1, 'positional': True},
                  'verbose': {'type': bool, 'flag': True}
              }
      
      Returns:
          Dictionary of parsed arguments with defaults applied
          
      Raises:
          ArgumentParseError: If parsing fails or validation fails
      """
      try:
          # Use shlex for robust parsing
          tokens = shlex.split(arg_string) if arg_string.strip() else []
      except ValueError as e:
          raise ArgumentParseError(f"Invalid argument syntax: {e}")

      # Initialize result with defaults
      result = {}
      for key, spec in schema.items():
          if 'default' in spec:
              result[key] = spec['default']

      # Track which positional arg we're on
      positional_keys = [k for k, v in schema.items() if v.get('positional', False)]
      positional_index = 0

      i = 0
      while i < len(tokens):
          token = tokens[i]

          # Handle flags (--option or -o)
          if token.startswith('--'):
              option_name = token[2:]

              # Convert hyphen to underscore for Python compatibility
              option_key = option_name.replace('-', '_')

              if option_key not in schema:
                  raise ArgumentParseError(f"Unknown option: {token}")

              spec = schema[option_key]

              # Boolean flags don't need a value
              if spec.get('flag', False):
                  result[option_key] = True
                  i += 1
                  continue

              # Option requires a value
              if i + 1 >= len(tokens):
                  raise ArgumentParseError(f"Option {token} requires a value")

              value_str = tokens[i + 1]

              # Type conversion
              try:
                  if spec['type'] == int:
                      result[option_key] = int(value_str)
                  elif spec['type'] == float:
                      result[option_key] = float(value_str)
                  elif spec['type'] == list:
                      # Parse comma-separated list
                      result[option_key] = [item.strip() for item in value_str.split(',')]
                  elif spec['type'] == 'int_list':
                      # Parse comma-separated integers
                      result[option_key] = [int(item.strip()) for item in value_str.split(',')]
                  else:
                      result[option_key] = value_str
              except ValueError as e:
                  raise ArgumentParseError(
                      f"Invalid value for {token}: expected {spec['type'].__name__}, got '{value_str}'"
                  )

              i += 2

          # Handle positional arguments
          else:
              if positional_index >= len(positional_keys):
                  raise ArgumentParseError(f"Unexpected positional argument: {token}")

              key = positional_keys[positional_index]
              spec = schema[key]

              try:
                  if spec['type'] == int:
                      result[key] = int(token)
                  elif spec['type'] == float:
                      result[key] = float(token)
                  else:
                      result[key] = token
              except ValueError as e:
                  raise ArgumentParseError(
                      f"Invalid value for {key}: expected {spec['type'].__name__}, got '{token}'"
                  )

              positional_index += 1
              i += 1

      return result

  @staticmethod
  def parse_game_id(arg_string: str) -> Optional[str]:
      """
      Parse a game ID from argument string.
      
      Args:
          arg_string: Raw argument string
          
      Returns:
          Game ID string or None
      """
      try:
          tokens = shlex.split(arg_string) if arg_string.strip() else []

          # Look for --game-id option
          for i, token in enumerate(tokens):
              if token == '--game-id' and i + 1 < len(tokens):
                  return tokens[i + 1]

          # If no option, check if there's a positional UUID-like argument
          if tokens and len(tokens[0]) == 36:  # UUID length
              return tokens[0]

          return None

      except ValueError:
          return None

Predefined schemas for common commands

NEW_GAME_SCHEMA = { 'league': {'type': str, 'default': 'sba'}, 'home_team': {'type': int, 'default': 1}, 'away_team': {'type': int, 'default': 2} }

DEFENSIVE_SCHEMA = { 'alignment': {'type': str, 'default': 'normal'}, 'infield': {'type': str, 'default': 'normal'}, 'outfield': {'type': str, 'default': 'normal'}, 'hold': {'type': 'int_list', 'default': []} }

OFFENSIVE_SCHEMA = { 'approach': {'type': str, 'default': 'normal'}, 'steal': {'type': 'int_list', 'default': []}, 'hit_run': {'type': bool, 'flag': True, 'default': False}, 'bunt': {'type': bool, 'flag': True, 'default': False} }

QUICK_PLAY_SCHEMA = { 'count': {'type': int, 'default': 1, 'positional': True} }

USE_GAME_SCHEMA = { 'game_id': {'type': str, 'positional': True} }

def parse_new_game_args(arg_string: str) -> Dict[str, Any]: """Parse arguments for new_game command.""" return CommandArgumentParser.parse_args(arg_string, NEW_GAME_SCHEMA)

def parse_defensive_args(arg_string: str) -> Dict[str, Any]: """Parse arguments for defensive command.""" return CommandArgumentParser.parse_args(arg_string, DEFENSIVE_SCHEMA)

def parse_offensive_args(arg_string: str) -> Dict[str, Any]: """Parse arguments for offensive command.""" return CommandArgumentParser.parse_args(arg_string, OFFENSIVE_SCHEMA)

def parse_quick_play_args(arg_string: str) -> Dict[str, Any]: """Parse arguments for quick_play command.""" return CommandArgumentParser.parse_args(arg_string, QUICK_PLAY_SCHEMA)

def parse_use_game_args(arg_string: str) -> Dict[str, Any]: """Parse arguments for use_game command.""" return CommandArgumentParser.parse_args(arg_string, USE_GAME_SCHEMA)

Files to Update

  1. Update backend/terminal_client/repl.py

Add import at top

from terminal_client.arg_parser import ( parse_new_game_args, parse_defensive_args, parse_offensive_args, parse_quick_play_args, parse_use_game_args, ArgumentParseError )

Replace do_new_game method:

def do_new_game(self, arg): """ Create a new game with lineups and start it.

  Usage: new_game [--league sba|pd] [--home-team N] [--away-team N]
  
  Examples:
    new_game
    new_game --league pd
    new_game --home-team 5 --away-team 3
  """
  async def _new_game():
      try:
          # Parse arguments with robust parser
          args = parse_new_game_args(arg)

          # Use shared command
          gid, success = await game_commands.create_new_game(
              league=args['league'],
              home_team=args['home_team'],
              away_team=args['away_team'],
              set_current=True
          )

          if success:
              self.current_game_id = gid

      except ArgumentParseError as e:
          display.print_error(f"Invalid arguments: {e}")
      except Exception as e:
          display.print_error(f"Failed to create game: {e}")
          logger.exception("New game error")

  self._run_async(_new_game())

Replace do_defensive method:

def do_defensive(self, arg): """ Submit defensive decision.

  Usage: defensive [--alignment TYPE] [--infield DEPTH] [--outfield DEPTH] [--hold BASES]
  
  Options:
    --alignment    normal, shifted_left, shifted_right, extreme_shift
    --infield      in, normal, back, double_play
    --outfield     in, normal, back
    --hold         Comma-separated bases (e.g., 1,3)
  
  Examples:
    defensive
    defensive --alignment shifted_left
    defensive --infield double_play --hold 1,3
  """
  async def _defensive():
      try:
          gid = self._ensure_game()
          await self._ensure_game_loaded(gid)

          # Parse arguments
          args = parse_defensive_args(arg)

          # Submit decision
          await game_commands.submit_defensive_decision(
              game_id=gid,
              alignment=args['alignment'],
              infield=args['infield'],
              outfield=args['outfield'],
              hold_runners=args['hold']
          )

      except ArgumentParseError as e:
          display.print_error(f"Invalid arguments: {e}")
      except ValueError:
          pass  # Already printed error
      except Exception as e:
          display.print_error(f"Failed: {e}")
          logger.exception("Defensive error")

  self._run_async(_defensive())

Replace do_offensive method:

def do_offensive(self, arg): """ Submit offensive decision.

  Usage: offensive [--approach TYPE] [--steal BASES] [--hit-run] [--bunt]
  
  Options:
    --approach     normal, contact, power, patient
    --steal        Comma-separated bases (e.g., 2,3)
    --hit-run      Enable hit-and-run (flag)
    --bunt         Attempt bunt (flag)
  
  Examples:
    offensive
    offensive --approach power
    offensive --steal 2 --hit-run
  """
  async def _offensive():
      try:
          gid = self._ensure_game()
          await self._ensure_game_loaded(gid)

          # Parse arguments
          args = parse_offensive_args(arg)

          # Submit decision
          await game_commands.submit_offensive_decision(
              game_id=gid,
              approach=args['approach'],
              steal_attempts=args['steal'],
              hit_and_run=args['hit_run'],
              bunt_attempt=args['bunt']
          )

      except ArgumentParseError as e:
          display.print_error(f"Invalid arguments: {e}")
      except ValueError:
          pass
      except Exception as e:
          display.print_error(f"Failed: {e}")
          logger.exception("Offensive error")

  self._run_async(_offensive())

Replace do_quick_play method:

def do_quick_play(self, arg): """ Auto-play multiple plays with default decisions.

  Usage: quick_play [COUNT]
  
  Examples:
    quick_play        # Play 1 play
    quick_play 10     # Play 10 plays
    quick_play 27     # Play ~3 innings
  """
  async def _quick_play():
      try:
          gid = self._ensure_game()
          await self._ensure_game_loaded(gid)

          # Parse arguments
          args = parse_quick_play_args(arg)

          # Execute quick play
          plays_completed = await game_commands.quick_play_rounds(
              game_id=gid,
              count=args['count']
          )

          display.print_success(f"Completed {plays_completed} plays")

      except ArgumentParseError as e:
          display.print_error(f"Invalid arguments: {e}")
      except ValueError:
          pass
      except Exception as e:
          display.print_error(f"Failed: {e}")
          logger.exception("Quick play error")

  self._run_async(_quick_play())

Replace do_use_game method:

def do_use_game(self, arg): """ Switch to a different game.

  Usage: use_game <game_id>
  
  Example:
    use_game a1b2c3d4-e5f6-7890-abcd-ef1234567890
  """
  try:
      # Parse arguments
      args = parse_use_game_args(arg)

      gid = UUID(args['game_id'])
      self.current_game_id = gid
      Config.set_current_game(gid)
      display.print_success(f"Switched to game: {gid}")

  except ArgumentParseError as e:
      display.print_error(f"Invalid arguments: {e}")
  except ValueError:
      display.print_error(f"Invalid UUID: {arg}")

Testing Plan

  1. Create backend/tests/unit/terminal_client/test_arg_parser.py

""" Unit tests for argument parser. """ import pytest

from terminal_client.arg_parser import ( CommandArgumentParser, ArgumentParseError, parse_new_game_args, parse_defensive_args, parse_offensive_args )

class TestCommandArgumentParser: """Tests for CommandArgumentParser."""

  def test_parse_simple_string_arg(self):
      """Test parsing simple string argument."""
      schema = {'name': {'type': str, 'default': 'default'}}
      result = CommandArgumentParser.parse_args('--name test', schema)
      assert result['name'] == 'test'

  def test_parse_integer_arg(self):
      """Test parsing integer argument."""
      schema = {'count': {'type': int, 'default': 1}}
      result = CommandArgumentParser.parse_args('--count 42', schema)
      assert result['count'] == 42

  def test_parse_flag_arg(self):
      """Test parsing boolean flag."""
      schema = {
          'verbose': {'type': bool, 'flag': True, 'default': False}
      }
      result = CommandArgumentParser.parse_args('--verbose', schema)
      assert result['verbose'] is True

  def test_parse_int_list(self):
      """Test parsing comma-separated integer list."""
      schema = {'bases': {'type': 'int_list', 'default': []}}
      result = CommandArgumentParser.parse_args('--bases 1,2,3', schema)
      assert result['bases'] == [1, 2, 3]

  def test_parse_quoted_string(self):
      """Test parsing quoted string with spaces."""
      schema = {'message': {'type': str, 'default': ''}}
      result = CommandArgumentParser.parse_args('--message "hello world"', schema)
      assert result['message'] == 'hello world'

  def test_parse_positional_arg(self):
      """Test parsing positional argument."""
      schema = {
          'count': {'type': int, 'positional': True, 'default': 1}
      }
      result = CommandArgumentParser.parse_args('10', schema)
      assert result['count'] == 10

  def test_parse_mixed_args(self):
      """Test parsing mix of options and positional."""
      schema = {
          'count': {'type': int, 'positional': True, 'default': 1},
          'league': {'type': str, 'default': 'sba'}
      }
      result = CommandArgumentParser.parse_args('5 --league pd', schema)
      assert result['count'] == 5
      assert result['league'] == 'pd'

  def test_parse_unknown_option_raises(self):
      """Test that unknown option raises error."""
      schema = {'name': {'type': str, 'default': 'default'}}
      with pytest.raises(ArgumentParseError, match="Unknown option"):
          CommandArgumentParser.parse_args('--invalid test', schema)

  def test_parse_missing_value_raises(self):
      """Test that missing value for option raises error."""
      schema = {'name': {'type': str, 'default': 'default'}}
      with pytest.raises(ArgumentParseError, match="requires a value"):
          CommandArgumentParser.parse_args('--name', schema)

  def test_parse_invalid_type_raises(self):
      """Test that invalid type conversion raises error."""
      schema = {'count': {'type': int, 'default': 1}}
      with pytest.raises(ArgumentParseError, match="expected int"):
          CommandArgumentParser.parse_args('--count abc', schema)

  def test_parse_empty_string(self):
      """Test parsing empty string returns defaults."""
      schema = {
          'name': {'type': str, 'default': 'default'},
          'count': {'type': int, 'default': 1}
      }
      result = CommandArgumentParser.parse_args('', schema)
      assert result['name'] == 'default'
      assert result['count'] == 1

  def test_parse_hyphen_to_underscore(self):
      """Test that hyphens in options convert to underscores."""
      schema = {'home_team': {'type': int, 'default': 1}}
      result = CommandArgumentParser.parse_args('--home-team 5', schema)
      assert result['home_team'] == 5

class TestPrebuiltParsers: """Tests for pre-built parser functions."""

  def test_parse_new_game_args_defaults(self):
      """Test new_game parser with defaults."""
      result = parse_new_game_args('')
      assert result['league'] == 'sba'
      assert result['home_team'] == 1
      assert result['away_team'] == 2

  def test_parse_new_game_args_custom(self):
      """Test new_game parser with custom values."""
      result = parse_new_game_args('--league pd --home-team 5 --away-team 3')
      assert result['league'] == 'pd'
      assert result['home_team'] == 5
      assert result['away_team'] == 3

  def test_parse_defensive_args_defaults(self):
      """Test defensive parser with defaults."""
      result = parse_defensive_args('')
      assert result['alignment'] == 'normal'
      assert result['infield'] == 'normal'
      assert result['outfield'] == 'normal'
      assert result['hold'] == []

  def test_parse_defensive_args_with_hold(self):
      """Test defensive parser with hold runners."""
      result = parse_defensive_args('--alignment shifted_left --hold 1,3')
      assert result['alignment'] == 'shifted_left'
      assert result['hold'] == [1, 3]

  def test_parse_offensive_args_flags(self):
      """Test offensive parser with flags."""
      result = parse_offensive_args('--approach power --hit-run --bunt')
      assert result['approach'] == 'power'
      assert result['hit_run'] is True
      assert result['bunt'] is True

  def test_parse_offensive_args_steal(self):
      """Test offensive parser with steal attempts."""
      result = parse_offensive_args('--steal 2,3')
      assert result['steal'] == [2, 3]

Benefits of Improved Parsing

  1. Handles quoted strings: --message "double steal attempt" works correctly
  2. Better error messages: Clear feedback on what went wrong
  3. Type validation: Automatic conversion with helpful errors
  4. Consistent behavior: Same parsing logic for REPL and CLI
  5. Extensible: Easy to add new argument types
  6. Edge case handling: Properly handles empty strings, trailing spaces, etc.

Example Usage

Before (manual parsing, breaks on spaces):

> defensive --hold 1,3 # Works > defensive --alignment shifted left # Breaks! (sees 'shifted' and 'left' separately)

After (shlex parsing, handles correctly):

> defensive --hold 1,3 # Still works > defensive --alignment "shifted left" # Now works with quotes! > quick_play 10 # Positional arg works > offensive --hit-run --bunt # Multiple flags work