Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
**.log
.coverage
**/__pycache__
htmlcov/
Expand Down
26 changes: 26 additions & 0 deletions app/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""
Configuration module for the MLB CLI application.
Centralizes all constants and environment-specific settings.
"""

# TUI Settings
STATIC_WIDTH = 80
INITIAL_HEIGHT = 36

# Redis Settings
REDIS_HOST = 'localhost'
REDIS_PORT = 6379
REDIS_DB = 0

# Data Service Settings
LIVE_DATA_TTL = 300 # 5 minutes

# Division name mapping
DIVISION_NAMES = {
200: "AL West",
201: "AL East",
202: "AL Central",
203: "NL West",
204: "NL East",
205: "NL Central"
}
6 changes: 6 additions & 0 deletions app/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""
Custom exceptions for the MLB CLI application.
"""

class APIError(Exception):
"""Raised when an error occurs while fetching data from the MLB API."""
29 changes: 29 additions & 0 deletions app/logger.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""
Logging configuration for the MLB CLI application.
Provides a standard logger that writes to a local log file.
"""
import os
import logging
from logging.handlers import RotatingFileHandler

# Ensure log directory exists
LOG_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
LOG_FILE = os.path.join(LOG_DIR, "mlb_cli.log")

def get_logger(name):
"""
Configures and returns a logger instance.
"""
logger = logging.getLogger(name)
if not logger.handlers:
logger.setLevel(logging.DEBUG)

# Rotating file handler (5MB per file, keeps 2 backups)
handler = RotatingFileHandler(LOG_FILE, maxBytes=5*1024*1024, backupCount=2)
formatter = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
handler.setFormatter(formatter)
logger.addHandler(handler)

return logger
28 changes: 28 additions & 0 deletions app/models/base_data_source.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""
Base module for data source abstractions.
Provides the interface for fetching MLB data.
"""
from abc import ABC, abstractmethod


class BaseDataSource(ABC):
"""
Abstract base class for all MLB data sources.
Defines the contract for fetching team info, schedules, and standings.
"""

@abstractmethod
def fetch_teams(self):
"""Fetches all MLB teams and returns a mapping of ID to abbreviation."""

@abstractmethod
def fetch_schedule(self, date_str):
"""Fetches the MLB schedule for a specific date."""

@abstractmethod
def fetch_standings(self):
"""Fetches current MLB standings."""

@abstractmethod
def fetch_wild_card(self, league_id):
"""Fetches wild card standings for a league."""
5 changes: 1 addition & 4 deletions app/models/cache_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,7 @@
import json
import redis

# Default Redis configuration
REDIS_HOST = 'localhost'
REDIS_PORT = 6379
REDIS_DB = 0
from app.config import REDIS_HOST, REDIS_PORT, REDIS_DB

try:
# pylint: disable=invalid-name
Expand Down
120 changes: 45 additions & 75 deletions app/models/data_service.py
Original file line number Diff line number Diff line change
@@ -1,41 +1,25 @@
"""
Data service module for fetching MLB data via the statsapi library.
Provides functions for team abbreviations, schedules, and standings.
Data service module for fetching MLB data.
Delegates to a BaseDataSource implementation (defaulting to StatsApiDataSource).
"""
from datetime import datetime, timedelta
import statsapi
from app.config import LIVE_DATA_TTL
from app.models.cache_service import get_cached_data, set_cached_data
from .statsapi_source import StatsApiDataSource

# Global cache for team abbreviations
TEAMS = {}

# TTL Policies (in seconds)
LIVE_DATA_TTL = 300 # 5 minutes


# Division name mapping
DIVISION_NAMES = {
200: "AL West",
201: "AL East",
202: "AL Central",
203: "NL West",
204: "NL East",
205: "NL Central"
}
# Default data source
_data_source = StatsApiDataSource()


def fetch_teams():
"""
Fetches all MLB teams and populates the global TEAMS cache with abbreviations.
Defaults to specific common teams if the API request fails.
"""
try:
teams_data = statsapi.get('teams', {'sportId': 1})['teams']
for t in teams_data:
TEAMS[t['id']] = t.get('abbreviation', t['name'][:3].upper())
except (ValueError, KeyError, IndexError, RuntimeError, TypeError, AttributeError):
# Fallback to some common ones if API fails
TEAMS.update({147: 'NYY', 110: 'BAL', 119: 'LAD'})
TEAMS.clear()
TEAMS.update(_data_source.fetch_teams())


def get_team_abbr(team_id):
Expand Down Expand Up @@ -63,7 +47,7 @@ def fetch_schedule(date_str):
if cached is not None:
return cached

data = statsapi.schedule(date=date_str)
data = _data_source.fetch_schedule(date_str)

# Policy: Today has TTL, other dates don't
ttl = LIVE_DATA_TTL if date_str == get_today_date() else None
Expand Down Expand Up @@ -151,27 +135,20 @@ def fetch_wild_card(league_id):
if cached is not None:
return cached

try:
data = statsapi.get('standings', {
'leagueId': league_id,
'standingsTypes': 'wildCard'
})
if not data or not data.get('records'):
return None

record = data['records'][0]
teams = [_parse_team_record(tr, is_wild_card=True) for tr in record.get('teamRecords', [])]

league_name = "AL" if league_id == 103 else "NL"
result = {
'div_name': f"{league_name} Wild Card",
'teams': teams[:7]
}
set_cached_data(cache_key, result, ttl=LIVE_DATA_TTL)
return result
except (ValueError, KeyError, IndexError, RuntimeError, TypeError, AttributeError):
record = _data_source.fetch_wild_card(league_id)
if not record:
return None

teams = [_parse_team_record(tr, is_wild_card=True) for tr in record.get('teamRecords', [])]

league_name = "AL" if league_id == 103 else "NL"
result = {
'div_name': f"{league_name} Wild Card",
'teams': teams[:7]
}
set_cached_data(cache_key, result, ttl=LIVE_DATA_TTL)
return result


def fetch_standings():
"""
Expand All @@ -186,35 +163,28 @@ def fetch_standings():
if cached is not None:
return tuple(cached)

try:
data = statsapi.get('standings', {'leagueId': '103,104'})
if not data or not data.get('records'):
return [], [], None, None

div_map = {}
for record in data['records']:
div_id = record.get('division', {}).get('id')
if div_id:
div_map[div_id] = {
'div_name': DIVISION_NAMES.get(
div_id, record.get('division', {}).get('name', 'Unknown')
),
'teams': [
_parse_team_record(tr, is_wild_card=False)
for tr in record.get('teamRecords', [])
]
}

# AL IDs: East(201), Central(202), West(200)
# NL IDs: East(204), Central(205), West(203)
al_divs = [div_map.get(201), div_map.get(202), div_map.get(200)]
nl_divs = [div_map.get(204), div_map.get(205), div_map.get(203)]

al_wc = fetch_wild_card(103)
nl_wc = fetch_wild_card(104)

result = (al_divs, nl_divs, al_wc, nl_wc)
set_cached_data(cache_key, result, ttl=LIVE_DATA_TTL)
return result
except (ValueError, KeyError, IndexError, RuntimeError, TypeError, AttributeError):
div_results = _data_source.fetch_standings()
if not div_results:
return [], [], None, None

div_map = {}
for div in div_results:
div_map[div['id']] = {
'div_name': div['name'],
'teams': [
_parse_team_record(tr, is_wild_card=False)
for tr in div['teams']
]
}

# AL IDs: East(201), Central(202), West(200)
# NL IDs: East(204), Central(205), West(203)
al_divs = [div_map.get(201), div_map.get(202), div_map.get(200)]
nl_divs = [div_map.get(204), div_map.get(205), div_map.get(203)]

al_wc = fetch_wild_card(103)
nl_wc = fetch_wild_card(104)

result = (al_divs, nl_divs, al_wc, nl_wc)
set_cached_data(cache_key, result, ttl=LIVE_DATA_TTL)
return result
71 changes: 71 additions & 0 deletions app/models/statsapi_source.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""
Implementation of BaseDataSource using the statsapi library.
"""
import statsapi
from app.config import DIVISION_NAMES
from app.logger import get_logger
from app.exceptions import APIError
from .base_data_source import BaseDataSource

logger = get_logger(__name__)


class StatsApiDataSource(BaseDataSource):
"""
Data source implementation that fetches data from the MLB StatsAPI.
"""

def fetch_teams(self):
"""Fetches all MLB teams and returns a mapping of ID to abbreviation."""
try:
teams_data = statsapi.get('teams', {'sportId': 1})['teams']
return {t['id']: t.get('abbreviation', t['name'][:3].upper()) for t in teams_data}
except (ValueError, KeyError, IndexError, RuntimeError, TypeError, AttributeError) as e:
logger.error("Failed to fetch teams: %s", e)
# Fallback to some common ones if API fails
return {147: 'NYY', 110: 'BAL', 119: 'LAD'}

def fetch_schedule(self, date_str):
"""Fetches the MLB schedule for a specific date."""
try:
return statsapi.schedule(date=date_str)
except (ValueError, KeyError, IndexError, RuntimeError, TypeError, AttributeError) as e:
logger.error("Failed to fetch schedule for %s: %s", date_str, e)
raise APIError(f"Unable to fetch schedule for {date_str}") from e

def fetch_standings(self):
"""Fetches current MLB standings."""
try:
data = statsapi.get('standings', {'leagueId': '103,104'})
if not data or not data.get('records'):
return []

div_results = []
for record in data['records']:
div_id = record.get('division', {}).get('id')
if div_id:
div_results.append({
'id': div_id,
'name': DIVISION_NAMES.get(
div_id, record.get('division', {}).get('name', 'Unknown')
),
'teams': record.get('teamRecords', [])
})
return div_results
except (ValueError, KeyError, IndexError, RuntimeError, TypeError, AttributeError) as e:
logger.error("Failed to fetch standings: %s", e)
raise APIError("Unable to fetch standings") from e

def fetch_wild_card(self, league_id):
"""Fetches wild card standings for a league."""
try:
data = statsapi.get('standings', {
'leagueId': league_id,
'standingsTypes': 'wildCard'
})
if not data or not data.get('records'):
return None
return data['records'][0]
except (ValueError, KeyError, IndexError, RuntimeError, TypeError, AttributeError) as e:
logger.error("Failed to fetch wild card for league %s: %s", league_id, e)
raise APIError(f"Unable to fetch wild card standings for league {league_id}") from e
3 changes: 2 additions & 1 deletion app/screens/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@
from .schedule_screen import ScheduleScreen
from .standings_screen import StandingsScreen
from .calendar_screen import CalendarScreen
from .error_screen import ErrorScreen

__all__ = ["ScheduleScreen", "StandingsScreen", "CalendarScreen"]
__all__ = ["ScheduleScreen", "StandingsScreen", "CalendarScreen", "ErrorScreen"]
42 changes: 42 additions & 0 deletions app/screens/error_screen.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""
Error screen for the MLB CLI application.
Displays error messages and recovery instructions.
"""
import pytermgui as ptg
from app.widgets import NavigationWidget


class ErrorScreen:
# pylint: disable=too-few-public-methods
"""
Screen class for displaying error messages.
"""

@staticmethod
def get_widgets(error_message):
"""
Generates the widget list and title for an error message.

Args:
error_message (str): The error message to display.

Returns:
tuple: (list of widgets, title string)
"""
widgets = [
NavigationWidget(), # Default nav for recovery
ptg.Label(""),
ptg.Label("[bold red]ERROR[/]", parent_align=ptg.HorizontalAlignment.CENTER),
ptg.Label(""),
ptg.Label(f"[italic]{error_message}[/]", parent_align=ptg.HorizontalAlignment.CENTER),
ptg.Label(""),
ptg.Label("Please check your connection or try again later.",
parent_align=ptg.HorizontalAlignment.CENTER),
ptg.Label(""),
ptg.Label("Press [bold cyan]t[/] to return to Today's schedule.",
parent_align=ptg.HorizontalAlignment.CENTER),
ptg.Label("Press [bold cyan]ESC[/] to exit.",
parent_align=ptg.HorizontalAlignment.CENTER),
ptg.Label(""),
]
return widgets, "[red]Application Error[/]"
Loading
Loading