fix: ContextualLogger crashes when callers pass exc_info=True

ContextualLogger methods forwarded all **kwargs as extra={} to Python's
standard logger. When callers passed exc_info=True, it landed in the
extra dict and Python's LogRecord raised KeyError("Attempt to overwrite
'exc_info' in LogRecord") since exc_info is a reserved attribute.

This caused /submit-scorecard to crash after game data was already
posted, masking the original error and preventing proper rollback.

Fix: Extract exc_info and stack_info from kwargs before passing as extra,
forwarding them as proper logging parameters instead. Also fix direct
callers in submit_scorecard.py and views/players.py to use error=e.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-02-19 21:19:06 -06:00
parent 4137204d9d
commit 313c3f857b
3 changed files with 282 additions and 287 deletions

View File

@ -4,6 +4,7 @@ Scorecard Submission Commands
Implements the /submit-scorecard command for submitting Google Sheets
scorecards with play-by-play data, pitching decisions, and game results.
"""
from typing import Optional, List
import discord
@ -36,24 +37,18 @@ class SubmitScorecardCommands(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
self.logger = get_contextual_logger(f'{__name__}.SubmitScorecardCommands')
self.logger = get_contextual_logger(f"{__name__}.SubmitScorecardCommands")
self.sheets_service = SheetsService() # Will use config automatically
self.logger.info("SubmitScorecardCommands cog initialized")
@app_commands.command(
name="submit-scorecard",
description="Submit a Google Sheets scorecard with game results and play data"
)
@app_commands.describe(
sheet_url="Full URL to the Google Sheets scorecard"
description="Submit a Google Sheets scorecard with game results and play data",
)
@app_commands.describe(sheet_url="Full URL to the Google Sheets scorecard")
@app_commands.checks.has_any_role(get_config().sba_players_role_name)
@logged_command("/submit-scorecard")
async def submit_scorecard(
self,
interaction: discord.Interaction,
sheet_url: str
):
async def submit_scorecard(self, interaction: discord.Interaction, sheet_url: str):
"""
Submit scorecard with full transaction rollback support.
@ -97,7 +92,7 @@ class SubmitScorecardCommands(commands.Cog):
setup_data = await self.sheets_service.read_setup_data(scorecard)
# Validate scorecard version
if setup_data['version'] != current.bet_week:
if setup_data["version"] != current.bet_week:
await interaction.edit_original_response(
content=(
f"❌ This scorecard appears out of date (version {setup_data['version']}, "
@ -113,12 +108,10 @@ class SubmitScorecardCommands(commands.Cog):
)
away_team = await team_service.get_team_by_abbrev(
setup_data['away_team_abbrev'],
current.season
setup_data["away_team_abbrev"], current.season
)
home_team = await team_service.get_team_by_abbrev(
setup_data['home_team_abbrev'],
current.season
setup_data["home_team_abbrev"], current.season
)
if not away_team or not home_team:
@ -129,18 +122,15 @@ class SubmitScorecardCommands(commands.Cog):
# Match managers
away_manager = self._match_manager(
away_team,
setup_data['away_manager_name']
away_team, setup_data["away_manager_name"]
)
home_manager = self._match_manager(
home_team,
setup_data['home_manager_name']
home_team, setup_data["home_manager_name"]
)
# Phase 3: Permission Check
user_team = await get_user_major_league_team(
interaction.user.id,
current.season
interaction.user.id, current.season
)
if user_team is None:
@ -160,30 +150,26 @@ class SubmitScorecardCommands(commands.Cog):
# Phase 4: Duplicate Game Check
duplicate_game = await game_service.find_duplicate_game(
current.season,
setup_data['week'],
setup_data['game_num'],
setup_data["week"],
setup_data["game_num"],
away_team.id,
home_team.id
home_team.id,
)
if duplicate_game:
view = ConfirmationView(
responders=[interaction.user],
timeout=30.0
)
view = ConfirmationView(responders=[interaction.user], timeout=30.0)
await interaction.edit_original_response(
content=(
f"⚠️ This game has already been played!\n"
f"Would you like me to wipe the old one and re-submit?"
),
view=view
view=view,
)
await view.wait()
if view.confirmed:
await interaction.edit_original_response(
content="🗑️ Wiping old game data...",
view=None
content="🗑️ Wiping old game data...", view=None
)
# Delete old data
@ -193,7 +179,9 @@ class SubmitScorecardCommands(commands.Cog):
pass # May not exist
try:
await decision_service.delete_decisions_for_game(duplicate_game.id)
await decision_service.delete_decisions_for_game(
duplicate_game.id
)
except:
pass # May not exist
@ -202,16 +190,13 @@ class SubmitScorecardCommands(commands.Cog):
else:
await interaction.edit_original_response(
content="❌ You think on it some more and get back to me later.",
view=None
view=None,
)
return
# Phase 5: Find Scheduled Game
scheduled_game = await game_service.find_scheduled_game(
current.season,
setup_data['week'],
away_team.id,
home_team.id
current.season, setup_data["week"], away_team.id, home_team.id
)
if not scheduled_game:
@ -234,7 +219,7 @@ class SubmitScorecardCommands(commands.Cog):
# Add game_id to each play
for play in plays_data:
play['game_id'] = game_id
play["game_id"] = game_id
# Phase 7: POST Plays
await interaction.edit_original_response(
@ -244,7 +229,9 @@ class SubmitScorecardCommands(commands.Cog):
try:
if not DRY_RUN:
await play_service.create_plays_batch(plays_data)
self.logger.info(f'Posting plays_data (1 and 2): {plays_data[0]} / {plays_data[1]}')
self.logger.info(
f"Posting plays_data (1 and 2): {plays_data[0]} / {plays_data[1]}"
)
rollback_state = "PLAYS_POSTED"
except APIException as e:
await interaction.edit_original_response(
@ -269,14 +256,16 @@ class SubmitScorecardCommands(commands.Cog):
if not DRY_RUN:
await game_service.update_game_result(
game_id,
box_score['away'][0], # Runs
box_score['home'][0], # Runs
box_score["away"][0], # Runs
box_score["home"][0], # Runs
away_manager.id,
home_manager.id,
setup_data['game_num'],
sheet_url
setup_data["game_num"],
sheet_url,
)
self.logger.info(f'Updating game ID {game_id}, {box_score['away'][0]} @ {box_score['home'][0]}, {away_manager.id} vs {home_manager.id}')
self.logger.info(
f"Updating game ID {game_id}, {box_score['away'][0]} @ {box_score['home'][0]}, {away_manager.id} vs {home_manager.id}"
)
rollback_state = "GAME_PATCHED"
except APIException as e:
# Rollback plays
@ -287,18 +276,21 @@ class SubmitScorecardCommands(commands.Cog):
return
# Phase 10: Read Pitching Decisions
decisions_data = await self.sheets_service.read_pitching_decisions(scorecard)
decisions_data = await self.sheets_service.read_pitching_decisions(
scorecard
)
# Add game metadata to each decision
for decision in decisions_data:
decision['game_id'] = game_id
decision['season'] = current.season
decision['week'] = setup_data['week']
decision['game_num'] = setup_data['game_num']
decision["game_id"] = game_id
decision["season"] = current.season
decision["week"] = setup_data["week"]
decision["game_num"] = setup_data["game_num"]
# Validate WP and LP exist and fetch Player objects
wp, lp, sv, holders, _blown_saves = \
wp, lp, sv, holders, _blown_saves = (
await decision_service.find_winning_losing_pitchers(decisions_data)
)
if wp is None or lp is None:
# Rollback
@ -333,9 +325,7 @@ class SubmitScorecardCommands(commands.Cog):
return
# Phase 12: Create Results Embed
await interaction.edit_original_response(
content="📰 Posting results..."
)
await interaction.edit_original_response(content="📰 Posting results...")
results_embed = await self._create_results_embed(
away_team,
@ -348,7 +338,7 @@ class SubmitScorecardCommands(commands.Cog):
lp,
sv,
holders,
game_id
game_id,
)
# Phase 13: Post to News Channel
@ -356,13 +346,11 @@ class SubmitScorecardCommands(commands.Cog):
self.bot,
get_config().sba_network_news_channel,
content=None,
embed=results_embed
embed=results_embed,
)
# Phase 14: Recalculate Standings
await interaction.edit_original_response(
content="📊 Tallying standings..."
)
await interaction.edit_original_response(content="📊 Tallying standings...")
try:
await standings_service.recalculate_standings(current.season)
@ -371,13 +359,11 @@ class SubmitScorecardCommands(commands.Cog):
self.logger.error("Failed to recalculate standings")
# Success!
await interaction.edit_original_response(
content="✅ You are all set!"
)
await interaction.edit_original_response(content="✅ You are all set!")
except Exception as e:
# Unexpected error - attempt rollback
self.logger.error(f"Unexpected error in scorecard submission: {e}", exc_info=True)
self.logger.error(f"Unexpected error in scorecard submission: {e}", error=e)
if rollback_state and game_id:
try:
@ -421,7 +407,7 @@ class SubmitScorecardCommands(commands.Cog):
lp: Optional[Player],
sv: Optional[Player],
holders: List[Player],
game_id: int
game_id: int,
):
"""
Create rich embed with game results.
@ -444,8 +430,8 @@ class SubmitScorecardCommands(commands.Cog):
"""
# Determine winner and loser
away_score = box_score['away'][0]
home_score = box_score['home'][0]
away_score = box_score["away"][0]
home_score = box_score["home"][0]
if away_score > home_score:
winning_team = away_team
@ -465,7 +451,7 @@ class SubmitScorecardCommands(commands.Cog):
# Create embed
embed = EmbedTemplate.create_base_embed(
title=f"{winner_abbrev} defeats {loser_abbrev} {winner_score}-{loser_score}",
description=f"Season {current.season}, Week {setup_data['week']}, Game {setup_data['game_num']}"
description=f"Season {current.season}, Week {setup_data['week']}, Game {setup_data['game_num']}",
)
embed.color = winning_team.get_color_int()
if winning_team.thumbnail:
@ -498,13 +484,13 @@ class SubmitScorecardCommands(commands.Cog):
decisions_text += f"**SV:** {sv.display_name}\n"
if decisions_text:
embed.add_field(name="Pitching Decisions", value=decisions_text, inline=True)
embed.add_field(
name="Pitching Decisions", value=decisions_text, inline=True
)
# Add scorecard link
embed.add_field(
name="Scorecard",
value=f"[View Full Scorecard]({sheet_url})",
inline=True
name="Scorecard", value=f"[View Full Scorecard]({sheet_url})", inline=True
)
# Try to get key plays (non-critical)
@ -513,7 +499,9 @@ class SubmitScorecardCommands(commands.Cog):
if key_plays:
key_plays_text = format_key_plays(key_plays, away_team, home_team)
if key_plays_text:
embed.add_field(name="Key Plays", value=key_plays_text, inline=False)
embed.add_field(
name="Key Plays", value=key_plays_text, inline=False
)
except Exception as e:
self.logger.warning(f"Failed to get key plays: {e}")

View File

@ -4,6 +4,7 @@ Enhanced Logging Utilities
Provides structured logging with contextual information for Discord bot debugging.
Implements hybrid approach: human-readable console + structured JSON files.
"""
import contextvars
import json
import logging
@ -13,66 +14,82 @@ from datetime import datetime
from typing import Dict, Any, Optional, Union
# Context variable for request tracking across async calls
log_context: contextvars.ContextVar[Dict[str, Any]] = contextvars.ContextVar('log_context', default={})
log_context: contextvars.ContextVar[Dict[str, Any]] = contextvars.ContextVar(
"log_context", default={}
)
logger = logging.getLogger(f'{__name__}.logging_utils')
logger = logging.getLogger(f"{__name__}.logging_utils")
JSONValue = Union[
str,
int,
float,
bool,
None,
dict[str, Any], # nested object
list[Any] # arrays
str, int, float, bool, None, dict[str, Any], list[Any] # nested object # arrays
]
class JSONFormatter(logging.Formatter):
"""Custom JSON formatter for structured file logging."""
def format(self, record) -> str:
"""Format log record as JSON with context information."""
# Base log object
log_obj: dict[str, JSONValue] = {
'timestamp': datetime.now().isoformat() + 'Z',
'level': record.levelname,
'logger': record.name,
'message': record.getMessage()
"timestamp": datetime.now().isoformat() + "Z",
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
}
# Add function/line info if available
if hasattr(record, 'funcName') and record.funcName:
log_obj['function'] = record.funcName
if hasattr(record, 'lineno') and record.lineno:
log_obj['line'] = record.lineno
if hasattr(record, "funcName") and record.funcName:
log_obj["function"] = record.funcName
if hasattr(record, "lineno") and record.lineno:
log_obj["line"] = record.lineno
# Add exception info if present
if record.exc_info:
log_obj['exception'] = {
'type': record.exc_info[0].__name__ if record.exc_info[0] else 'Unknown',
'message': str(record.exc_info[1]) if record.exc_info[1] else 'No message',
'traceback': self.formatException(record.exc_info)
log_obj["exception"] = {
"type": (
record.exc_info[0].__name__ if record.exc_info[0] else "Unknown"
),
"message": (
str(record.exc_info[1]) if record.exc_info[1] else "No message"
),
"traceback": self.formatException(record.exc_info),
}
# Add context from contextvars
context = log_context.get({})
if context:
log_obj['context'] = context.copy()
log_obj["context"] = context.copy()
# Promote trace_id to standard key if available in context
if 'trace_id' in context:
log_obj['trace_id'] = context['trace_id']
if "trace_id" in context:
log_obj["trace_id"] = context["trace_id"]
# Add custom fields from extra parameter
excluded_keys = {
'name', 'msg', 'args', 'levelname', 'levelno', 'pathname',
'filename', 'module', 'lineno', 'funcName', 'created',
'msecs', 'relativeCreated', 'thread', 'threadName',
'processName', 'process', 'getMessage', 'exc_info',
'exc_text', 'stack_info'
"name",
"msg",
"args",
"levelname",
"levelno",
"pathname",
"filename",
"module",
"lineno",
"funcName",
"created",
"msecs",
"relativeCreated",
"thread",
"threadName",
"processName",
"process",
"getMessage",
"exc_info",
"exc_text",
"stack_info",
}
extra_data = {}
for key, value in record.__dict__.items():
if key not in excluded_keys:
@ -82,56 +99,56 @@ class JSONFormatter(logging.Formatter):
extra_data[key] = value
except (TypeError, ValueError):
extra_data[key] = str(value)
if extra_data:
log_obj['extra'] = extra_data
return json.dumps(log_obj, ensure_ascii=False) + '\n'
log_obj["extra"] = extra_data
return json.dumps(log_obj, ensure_ascii=False) + "\n"
class ContextualLogger:
"""
Logger wrapper that provides contextual information and structured logging.
Automatically includes Discord context (user, guild, command) in all log messages.
"""
def __init__(self, logger_name: str):
"""
Initialize contextual logger.
Args:
logger_name: Name for the underlying logger
"""
self.logger = logging.getLogger(logger_name)
self._start_time: Optional[float] = None
def start_operation(self, operation_name: Optional[str] = None) -> str:
"""
Start timing an operation and generate a trace ID.
Args:
operation_name: Optional name for the operation being tracked
Returns:
Generated trace ID for this operation
"""
self._start_time = time.time()
trace_id = str(uuid.uuid4())[:8]
# Add trace_id to context
current_context = log_context.get({})
current_context['trace_id'] = trace_id
current_context["trace_id"] = trace_id
if operation_name:
current_context['operation'] = operation_name
current_context["operation"] = operation_name
log_context.set(current_context)
return trace_id
def end_operation(self, trace_id: str, operation_result: str = "completed") -> None:
"""
End an operation and log the final duration.
Args:
trace_id: The trace ID returned by start_operation
operation_result: Result status (e.g., "completed", "failed", "cancelled")
@ -139,83 +156,99 @@ class ContextualLogger:
if self._start_time is None:
self.warning("end_operation called without corresponding start_operation")
return
duration_ms = int((time.time() - self._start_time) * 1000)
# Get current context
current_context = log_context.get({})
# Log operation completion
self.info(f"Operation {operation_result}",
trace_id=trace_id,
final_duration_ms=duration_ms,
operation_result=operation_result)
self.info(
f"Operation {operation_result}",
trace_id=trace_id,
final_duration_ms=duration_ms,
operation_result=operation_result,
)
# Clear operation-specific context
if 'operation' in current_context:
current_context.pop('operation', None)
if 'trace_id' in current_context and current_context['trace_id'] == trace_id:
current_context.pop('trace_id', None)
if "operation" in current_context:
current_context.pop("operation", None)
if "trace_id" in current_context and current_context["trace_id"] == trace_id:
current_context.pop("trace_id", None)
log_context.set(current_context)
# Reset start time
self._start_time = None
def _get_duration_ms(self) -> Optional[int]:
"""Get operation duration in milliseconds if start_operation was called."""
if self._start_time:
return int((time.time() - self._start_time) * 1000)
return None
@staticmethod
def _extract_logging_params(kwargs: dict) -> dict:
"""Extract standard logging parameters from kwargs to prevent LogRecord conflicts.
Python's LogRecord raises KeyError if reserved attributes like 'exc_info' or
'stack_info' are passed via the 'extra' dict. This extracts them so they can
be passed as direct parameters to the underlying logger instead.
"""
return {
key: kwargs.pop(key) for key in ("exc_info", "stack_info") if key in kwargs
}
def debug(self, message: str, **kwargs):
"""Log debug message with context."""
duration = self._get_duration_ms()
if duration is not None:
kwargs['duration_ms'] = duration
self.logger.debug(message, extra=kwargs)
kwargs["duration_ms"] = duration
log_params = self._extract_logging_params(kwargs)
self.logger.debug(message, extra=kwargs, **log_params)
def info(self, message: str, **kwargs):
"""Log info message with context."""
duration = self._get_duration_ms()
if duration is not None:
kwargs['duration_ms'] = duration
self.logger.info(message, extra=kwargs)
kwargs["duration_ms"] = duration
log_params = self._extract_logging_params(kwargs)
self.logger.info(message, extra=kwargs, **log_params)
def warning(self, message: str, **kwargs):
"""Log warning message with context."""
duration = self._get_duration_ms()
if duration is not None:
kwargs['duration_ms'] = duration
self.logger.warning(message, extra=kwargs)
kwargs["duration_ms"] = duration
log_params = self._extract_logging_params(kwargs)
self.logger.warning(message, extra=kwargs, **log_params)
def error(self, message: str, error: Optional[Exception] = None, **kwargs):
"""
Log error message with context and exception information.
Args:
message: Error message
error: Optional exception object
**kwargs: Additional context
**kwargs: Additional context (exc_info=True is supported)
"""
duration = self._get_duration_ms()
if duration is not None:
kwargs['duration_ms'] = duration
kwargs["duration_ms"] = duration
log_params = self._extract_logging_params(kwargs)
if error:
kwargs['error'] = {
'type': type(error).__name__,
'message': str(error)
}
kwargs["error"] = {"type": type(error).__name__, "message": str(error)}
self.logger.error(message, exc_info=True, extra=kwargs)
else:
self.logger.error(message, extra=kwargs)
self.logger.error(message, extra=kwargs, **log_params)
def exception(self, message: str, **kwargs):
"""Log exception with full traceback and context."""
duration = self._get_duration_ms()
if duration is not None:
kwargs['duration_ms'] = duration
self.logger.exception(message, extra=kwargs)
kwargs["duration_ms"] = duration
log_params = self._extract_logging_params(kwargs)
self.logger.exception(message, extra=kwargs, **log_params)
def set_discord_context(
@ -224,45 +257,45 @@ def set_discord_context(
guild_id: Optional[Union[str, int]] = None,
channel_id: Optional[Union[str, int]] = None,
command: Optional[str] = None,
**additional_context
**additional_context,
):
"""
Set Discord-specific context for logging.
Args:
interaction: Discord interaction object (will extract user/guild/channel)
user_id: Discord user ID
guild_id: Discord guild ID
guild_id: Discord guild ID
channel_id: Discord channel ID
command: Command name (e.g., '/player')
**additional_context: Any additional context to include
"""
context = log_context.get({}).copy()
# Extract from interaction if provided
if interaction:
context['user_id'] = str(interaction.user.id)
context["user_id"] = str(interaction.user.id)
if interaction.guild:
context['guild_id'] = str(interaction.guild.id)
context['guild_name'] = interaction.guild.name
context["guild_id"] = str(interaction.guild.id)
context["guild_name"] = interaction.guild.name
if interaction.channel:
context['channel_id'] = str(interaction.channel.id)
if hasattr(interaction, 'command') and interaction.command:
context['command'] = f"/{interaction.command.name}"
context["channel_id"] = str(interaction.channel.id)
if hasattr(interaction, "command") and interaction.command:
context["command"] = f"/{interaction.command.name}"
# Override with explicit parameters
if user_id:
context['user_id'] = str(user_id)
context["user_id"] = str(user_id)
if guild_id:
context['guild_id'] = str(guild_id)
context["guild_id"] = str(guild_id)
if channel_id:
context['channel_id'] = str(channel_id)
context["channel_id"] = str(channel_id)
if command:
context['command'] = command
context["command"] = command
# Add any additional context
context.update(additional_context)
log_context.set(context)
@ -274,11 +307,11 @@ def clear_context():
def get_contextual_logger(logger_name: str) -> ContextualLogger:
"""
Get a contextual logger instance.
Args:
logger_name: Name for the logger (typically __name__)
Returns:
ContextualLogger instance
"""
return ContextualLogger(logger_name)
return ContextualLogger(logger_name)

View File

@ -3,6 +3,7 @@ Player View Components
Interactive Discord UI components for player information display with toggleable statistics.
"""
from typing import Optional, TYPE_CHECKING
import discord
@ -34,11 +35,11 @@ class PlayerStatsView(BaseView):
def __init__(
self,
player: 'Player',
player: "Player",
season: int,
batting_stats: Optional['BattingStats'] = None,
pitching_stats: Optional['PitchingStats'] = None,
user_id: Optional[int] = None
batting_stats: Optional["BattingStats"] = None,
pitching_stats: Optional["PitchingStats"] = None,
user_id: Optional[int] = None,
):
"""
Initialize the player stats view.
@ -50,7 +51,9 @@ class PlayerStatsView(BaseView):
pitching_stats: Pitching statistics (if available)
user_id: Discord user ID who can interact with this view
"""
super().__init__(timeout=300.0, user_id=user_id, logger_name=f'{__name__}.PlayerStatsView')
super().__init__(
timeout=300.0, user_id=user_id, logger_name=f"{__name__}.PlayerStatsView"
)
self.player = player
self.season = season
@ -69,36 +72,37 @@ class PlayerStatsView(BaseView):
self.remove_item(self.toggle_pitching_button)
self.logger.debug("No pitching stats available, pitching button hidden")
self.logger.info("PlayerStatsView initialized",
player_id=player.id,
player_name=player.name,
season=season,
has_batting=bool(batting_stats),
has_pitching=bool(pitching_stats),
user_id=user_id)
self.logger.info(
"PlayerStatsView initialized",
player_id=player.id,
player_name=player.name,
season=season,
has_batting=bool(batting_stats),
has_pitching=bool(pitching_stats),
user_id=user_id,
)
@discord.ui.button(
label="Show Batting Stats",
style=discord.ButtonStyle.primary,
emoji="💥",
row=0
label="Show Batting Stats", style=discord.ButtonStyle.primary, emoji="💥", row=0
)
async def toggle_batting_button(
self,
interaction: discord.Interaction,
button: discord.ui.Button
self, interaction: discord.Interaction, button: discord.ui.Button
):
"""Toggle batting statistics visibility."""
self.increment_interaction_count()
self.show_batting = not self.show_batting
# Update button label
button.label = "Hide Batting Stats" if self.show_batting else "Show Batting Stats"
button.label = (
"Hide Batting Stats" if self.show_batting else "Show Batting Stats"
)
self.logger.info("Batting stats toggled",
player_id=self.player.id,
show_batting=self.show_batting,
user_id=interaction.user.id)
self.logger.info(
"Batting stats toggled",
player_id=self.player.id,
show_batting=self.show_batting,
user_id=interaction.user.id,
)
# Rebuild and update embed
await self._update_embed(interaction)
@ -107,24 +111,26 @@ class PlayerStatsView(BaseView):
label="Show Pitching Stats",
style=discord.ButtonStyle.primary,
emoji="",
row=0
row=0,
)
async def toggle_pitching_button(
self,
interaction: discord.Interaction,
button: discord.ui.Button
self, interaction: discord.Interaction, button: discord.ui.Button
):
"""Toggle pitching statistics visibility."""
self.increment_interaction_count()
self.show_pitching = not self.show_pitching
# Update button label
button.label = "Hide Pitching Stats" if self.show_pitching else "Show Pitching Stats"
button.label = (
"Hide Pitching Stats" if self.show_pitching else "Show Pitching Stats"
)
self.logger.info("Pitching stats toggled",
player_id=self.player.id,
show_pitching=self.show_pitching,
user_id=interaction.user.id)
self.logger.info(
"Pitching stats toggled",
player_id=self.player.id,
show_pitching=self.show_pitching,
user_id=interaction.user.id,
)
# Rebuild and update embed
await self._update_embed(interaction)
@ -143,20 +149,24 @@ class PlayerStatsView(BaseView):
# Update the message with new embed
await interaction.response.edit_message(embed=embed, view=self)
self.logger.debug("Embed updated successfully",
show_batting=self.show_batting,
show_pitching=self.show_pitching)
self.logger.debug(
"Embed updated successfully",
show_batting=self.show_batting,
show_pitching=self.show_pitching,
)
except Exception as e:
self.logger.error("Failed to update embed", error=e, exc_info=True)
self.logger.error("Failed to update embed", error=e)
# Try to send error message
try:
error_embed = EmbedTemplate.error(
title="Update Failed",
description="Failed to update player statistics. Please try again."
description="Failed to update player statistics. Please try again.",
)
await interaction.response.send_message(
embed=error_embed, ephemeral=True
)
await interaction.response.send_message(embed=error_embed, ephemeral=True)
except Exception:
self.logger.error("Failed to send error message", exc_info=True)
@ -181,23 +191,16 @@ class PlayerStatsView(BaseView):
# Add injury indicator emoji if player is injured
title = f"🤕 {player.name}" if player.il_return is not None else player.name
embed = EmbedTemplate.create_base_embed(
title=title,
color=embed_color
)
embed = EmbedTemplate.create_base_embed(title=title, color=embed_color)
# Basic info section (always visible)
embed.add_field(
name="Position",
value=player.primary_position,
inline=True
)
embed.add_field(name="Position", value=player.primary_position, inline=True)
if hasattr(player, 'team') and player.team:
if hasattr(player, "team") and player.team:
embed.add_field(
name="Team",
value=f"{player.team.abbrev} - {player.team.sname}",
inline=True
inline=True,
)
# Add Major League affiliate if this is a Minor League team
@ -205,55 +208,33 @@ class PlayerStatsView(BaseView):
major_affiliate = await player.team.major_league_affiliate()
if major_affiliate:
embed.add_field(
name="Major Affiliate",
value=major_affiliate,
inline=True
name="Major Affiliate", value=major_affiliate, inline=True
)
embed.add_field(
name="sWAR",
value=f"{player.wara:.2f}",
inline=True
)
embed.add_field(name="sWAR", value=f"{player.wara:.2f}", inline=True)
embed.add_field(
name="Player ID",
value=str(player.id),
inline=True
)
embed.add_field(name="Player ID", value=str(player.id), inline=True)
# All positions if multiple
if len(player.positions) > 1:
embed.add_field(
name="Positions",
value=", ".join(player.positions),
inline=True
name="Positions", value=", ".join(player.positions), inline=True
)
embed.add_field(
name="Season",
value=str(season),
inline=True
)
embed.add_field(name="Season", value=str(season), inline=True)
# Always show injury rating
embed.add_field(
name="Injury Rating",
value=player.injury_rating or "N/A",
inline=True
name="Injury Rating", value=player.injury_rating or "N/A", inline=True
)
# Show injury return date only if player is currently injured
if player.il_return:
embed.add_field(
name="Injury Return",
value=player.il_return,
inline=True
)
embed.add_field(name="Injury Return", value=player.il_return, inline=True)
# Add batting stats if visible and available
if self.show_batting and self.batting_stats:
embed.add_field(name='', value='', inline=False)
embed.add_field(name="", value="", inline=False)
self.logger.debug("Adding batting statistics to embed")
batting_stats = self.batting_stats
@ -269,11 +250,7 @@ class PlayerStatsView(BaseView):
"╰─────────────╯\n"
"```"
)
embed.add_field(
name="Rate Stats",
value=rate_stats,
inline=True
)
embed.add_field(name="Rate Stats", value=rate_stats, inline=True)
count_stats = (
"```\n"
@ -288,15 +265,11 @@ class PlayerStatsView(BaseView):
"╰───────────╯\n"
"```"
)
embed.add_field(
name='Counting Stats',
value=count_stats,
inline=True
)
embed.add_field(name="Counting Stats", value=count_stats, inline=True)
# Add pitching stats if visible and available
if self.show_pitching and self.pitching_stats:
embed.add_field(name='', value='', inline=False)
embed.add_field(name="", value="", inline=False)
self.logger.debug("Adding pitching statistics to embed")
pitching_stats = self.pitching_stats
@ -313,11 +286,7 @@ class PlayerStatsView(BaseView):
"╰─────────────╯\n"
"```"
)
embed.add_field(
name="Record Stats",
value=record_stats,
inline=True
)
embed.add_field(name="Record Stats", value=record_stats, inline=True)
strikeout_stats = (
"```\n"
@ -329,11 +298,7 @@ class PlayerStatsView(BaseView):
"╰──────────╯\n"
"```"
)
embed.add_field(
name='Counting Stats',
value=strikeout_stats,
inline=True
)
embed.add_field(name="Counting Stats", value=strikeout_stats, inline=True)
# Add a note if no stats are visible
if not self.show_batting and not self.show_pitching:
@ -341,37 +306,46 @@ class PlayerStatsView(BaseView):
embed.add_field(
name="📊 Statistics",
value="Click the buttons below to show statistics.",
inline=False
inline=False,
)
else:
embed.add_field(
name="📊 Statistics",
value="No statistics available for this season.",
inline=False
inline=False,
)
# Set player card as main image
if player.image:
embed.set_image(url=player.image)
self.logger.debug("Player card image added to embed", image_url=player.image)
self.logger.debug(
"Player card image added to embed", image_url=player.image
)
# Set thumbnail with priority: fancycard → headshot → team logo
thumbnail_url = None
thumbnail_source = None
if hasattr(player, 'vanity_card') and player.vanity_card:
if hasattr(player, "vanity_card") and player.vanity_card:
thumbnail_url = player.vanity_card
thumbnail_source = "fancycard"
elif hasattr(player, 'headshot') and player.headshot:
elif hasattr(player, "headshot") and player.headshot:
thumbnail_url = player.headshot
thumbnail_source = "headshot"
elif hasattr(player, 'team') and player.team and hasattr(player.team, 'thumbnail') and player.team.thumbnail:
elif (
hasattr(player, "team")
and player.team
and hasattr(player.team, "thumbnail")
and player.team.thumbnail
):
thumbnail_url = player.team.thumbnail
thumbnail_source = "team logo"
if thumbnail_url:
embed.set_thumbnail(url=thumbnail_url)
self.logger.debug(f"Thumbnail set from {thumbnail_source}", thumbnail_url=thumbnail_url)
self.logger.debug(
f"Thumbnail set from {thumbnail_source}", thumbnail_url=thumbnail_url
)
# Footer with player ID
footer_text = f"Player ID: {player.id}"