strat-gameplay-webapp/backend/terminal_client/arg_parser.py
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

218 lines
7.1 KiB
Python

"""
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__ if hasattr(spec['type'], '__name__') else spec['type']}, 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)