mlb-cli-py 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,71 @@
1
+ """
2
+ Implementation of BaseDataSource using the statsapi library.
3
+ """
4
+ import statsapi
5
+ from app.config import DIVISION_NAMES
6
+ from app.logger import get_logger
7
+ from app.exceptions import APIError
8
+ from .base_data_source import BaseDataSource
9
+
10
+ logger = get_logger(__name__)
11
+
12
+
13
+ class StatsApiDataSource(BaseDataSource):
14
+ """
15
+ Data source implementation that fetches data from the MLB StatsAPI.
16
+ """
17
+
18
+ def fetch_teams(self):
19
+ """Fetches all MLB teams and returns a mapping of ID to abbreviation."""
20
+ try:
21
+ teams_data = statsapi.get('teams', {'sportId': 1})['teams']
22
+ return {t['id']: t.get('abbreviation', t['name'][:3].upper()) for t in teams_data}
23
+ except (ValueError, KeyError, IndexError, RuntimeError, TypeError, AttributeError) as e:
24
+ logger.error("Failed to fetch teams: %s", e)
25
+ # Fallback to some common ones if API fails
26
+ return {147: 'NYY', 110: 'BAL', 119: 'LAD'}
27
+
28
+ def fetch_schedule(self, date_str):
29
+ """Fetches the MLB schedule for a specific date."""
30
+ try:
31
+ return statsapi.schedule(date=date_str)
32
+ except (ValueError, KeyError, IndexError, RuntimeError, TypeError, AttributeError) as e:
33
+ logger.error("Failed to fetch schedule for %s: %s", date_str, e)
34
+ raise APIError(f"Unable to fetch schedule for {date_str}") from e
35
+
36
+ def fetch_standings(self):
37
+ """Fetches current MLB standings."""
38
+ try:
39
+ data = statsapi.get('standings', {'leagueId': '103,104'})
40
+ if not data or not data.get('records'):
41
+ return []
42
+
43
+ div_results = []
44
+ for record in data['records']:
45
+ div_id = record.get('division', {}).get('id')
46
+ if div_id:
47
+ div_results.append({
48
+ 'id': div_id,
49
+ 'name': DIVISION_NAMES.get(
50
+ div_id, record.get('division', {}).get('name', 'Unknown')
51
+ ),
52
+ 'teams': record.get('teamRecords', [])
53
+ })
54
+ return div_results
55
+ except (ValueError, KeyError, IndexError, RuntimeError, TypeError, AttributeError) as e:
56
+ logger.error("Failed to fetch standings: %s", e)
57
+ raise APIError("Unable to fetch standings") from e
58
+
59
+ def fetch_wild_card(self, league_id):
60
+ """Fetches wild card standings for a league."""
61
+ try:
62
+ data = statsapi.get('standings', {
63
+ 'leagueId': league_id,
64
+ 'standingsTypes': 'wildCard'
65
+ })
66
+ if not data or not data.get('records'):
67
+ return None
68
+ return data['records'][0]
69
+ except (ValueError, KeyError, IndexError, RuntimeError, TypeError, AttributeError) as e:
70
+ logger.error("Failed to fetch wild card for league %s: %s", league_id, e)
71
+ raise APIError(f"Unable to fetch wild card standings for league {league_id}") from e
@@ -0,0 +1,9 @@
1
+ """
2
+ Screens package for the MLB CLI application.
3
+ """
4
+ from .schedule_screen import ScheduleScreen
5
+ from .standings_screen import StandingsScreen
6
+ from .calendar_screen import CalendarScreen
7
+ from .error_screen import ErrorScreen
8
+
9
+ __all__ = ["ScheduleScreen", "StandingsScreen", "CalendarScreen", "ErrorScreen"]
@@ -0,0 +1,58 @@
1
+ """
2
+ Calendar screen for the MLB CLI application.
3
+ """
4
+ import calendar
5
+ import pytermgui as ptg
6
+ from app.widgets import NavigationWidget, CalendarWidget
7
+
8
+
9
+ class CalendarScreen:
10
+ # pylint: disable=too-few-public-methods
11
+ """
12
+ Screen class for displaying the MLB calendar.
13
+ """
14
+
15
+ @staticmethod
16
+ def get_widgets(year, months, on_date_selected, selected_date=None):
17
+ """
18
+ Generates the widget list and title for the calendar view showing multiple months.
19
+
20
+ Args:
21
+ year (int): Year to display.
22
+ months (list): List of month integers to display.
23
+ on_date_selected (callable): Callback for date selection.
24
+ selected_date (datetime, optional): The currently selected date for highlighting.
25
+
26
+ Returns:
27
+ tuple: (list of widgets, title string)
28
+ """
29
+ month_widgets = []
30
+ month_names = []
31
+
32
+ for month in months:
33
+ name = calendar.month_name[month]
34
+ month_names.append(name)
35
+
36
+ selected_day = None
37
+ if selected_date and selected_date.year == year and \
38
+ selected_date.month == month:
39
+ selected_day = selected_date.day
40
+
41
+ container = ptg.Container(
42
+ ptg.Label(f"[bold]{name}[/]", parent_align=ptg.HorizontalAlignment.CENTER),
43
+ CalendarWidget(year, month, on_date_selected, selected_day=selected_day),
44
+ box="EMPTY"
45
+ )
46
+ month_widgets.append(container)
47
+
48
+ # Fill with empty if less than 3
49
+ while len(month_widgets) < 3:
50
+ month_widgets.append(ptg.Label(""))
51
+
52
+ widgets = [
53
+ NavigationWidget(active_page="calendar"),
54
+ ]
55
+ widgets.extend(month_widgets)
56
+
57
+ title_range = f"{month_names[0]} - {month_names[-1]}"
58
+ return widgets, f"[green]Calendar - {title_range} {year}[/]"
@@ -0,0 +1,42 @@
1
+ """
2
+ Error screen for the MLB CLI application.
3
+ Displays error messages and recovery instructions.
4
+ """
5
+ import pytermgui as ptg
6
+ from app.widgets import NavigationWidget
7
+
8
+
9
+ class ErrorScreen:
10
+ # pylint: disable=too-few-public-methods
11
+ """
12
+ Screen class for displaying error messages.
13
+ """
14
+
15
+ @staticmethod
16
+ def get_widgets(error_message):
17
+ """
18
+ Generates the widget list and title for an error message.
19
+
20
+ Args:
21
+ error_message (str): The error message to display.
22
+
23
+ Returns:
24
+ tuple: (list of widgets, title string)
25
+ """
26
+ widgets = [
27
+ NavigationWidget(), # Default nav for recovery
28
+ ptg.Label(""),
29
+ ptg.Label("[bold red]ERROR[/]", parent_align=ptg.HorizontalAlignment.CENTER),
30
+ ptg.Label(""),
31
+ ptg.Label(f"[italic]{error_message}[/]", parent_align=ptg.HorizontalAlignment.CENTER),
32
+ ptg.Label(""),
33
+ ptg.Label("Please check your connection or try again later.",
34
+ parent_align=ptg.HorizontalAlignment.CENTER),
35
+ ptg.Label(""),
36
+ ptg.Label("Press [bold cyan]t[/] to return to Today's schedule.",
37
+ parent_align=ptg.HorizontalAlignment.CENTER),
38
+ ptg.Label("Press [bold cyan]ESC[/] to exit.",
39
+ parent_align=ptg.HorizontalAlignment.CENTER),
40
+ ptg.Label(""),
41
+ ]
42
+ return widgets, "[red]Application Error[/]"
@@ -0,0 +1,33 @@
1
+ """
2
+ Schedule screen for the MLB CLI application.
3
+ """
4
+ import pytermgui as ptg
5
+ from app.widgets import create_grid, NavigationWidget
6
+ from app.models.data_service import fetch_schedule
7
+
8
+
9
+ class ScheduleScreen:
10
+ # pylint: disable=too-few-public-methods
11
+ """
12
+ Screen class for displaying the MLB schedule.
13
+ """
14
+
15
+ @staticmethod
16
+ def get_widgets(date_str):
17
+ """
18
+ Generates the widget list and title for a specific date's schedule.
19
+
20
+ Args:
21
+ date_str (str): The date string in MM/DD/YYYY format.
22
+
23
+ Returns:
24
+ tuple: (list of widgets, title string)
25
+ """
26
+ games = fetch_schedule(date_str)
27
+
28
+ widgets = [
29
+ NavigationWidget(active_page="schedule"),
30
+ ptg.Label(f"[bold]MLB Schedule - {date_str}[/]"),
31
+ *create_grid(games),
32
+ ]
33
+ return widgets, f"[green]MLB Schedule - {date_str}[/]"
@@ -0,0 +1,39 @@
1
+ """
2
+ Standings screen for the MLB CLI application.
3
+ """
4
+ import pytermgui as ptg
5
+ from app.widgets import StandingWidget, NavigationWidget
6
+ from app.models.data_service import fetch_standings
7
+
8
+
9
+ class StandingsScreen:
10
+ # pylint: disable=too-few-public-methods
11
+ """
12
+ Screen class for displaying the MLB standings.
13
+ """
14
+
15
+ @staticmethod
16
+ def get_widgets():
17
+ """
18
+ Generates the widget list and title for the 'MLB Standings' screen.
19
+
20
+ Returns:
21
+ tuple: (list of widgets, title string)
22
+ """
23
+ al_divs, nl_divs, al_wc, nl_wc = fetch_standings()
24
+
25
+ # Define row structure with weights to ensure uniform width
26
+ # 2 columns for divisions, centered 1 column (using padding) for Wild Cards
27
+ widgets = [
28
+ NavigationWidget(active_page="standings"),
29
+ ptg.Label("[bold]MLB Standings[/]"),
30
+ # East Row
31
+ ptg.Splitter(StandingWidget(al_divs[0]), StandingWidget(nl_divs[0])),
32
+ # Central Row
33
+ ptg.Splitter(StandingWidget(al_divs[1]), StandingWidget(nl_divs[1])),
34
+ # West Row
35
+ ptg.Splitter(StandingWidget(al_divs[2]), StandingWidget(nl_divs[2])),
36
+ # Wild Card Row (AL and NL)
37
+ ptg.Splitter(StandingWidget(al_wc), StandingWidget(nl_wc)),
38
+ ]
39
+ return widgets, "[green]MLB Standings[/]"
app/state.py ADDED
@@ -0,0 +1,74 @@
1
+ """
2
+ Application state management for the MLB CLI application.
3
+ Handles dates, page tracking, and navigation logic.
4
+ """
5
+ from datetime import datetime, timedelta
6
+
7
+
8
+ class ApplicationState:
9
+ """
10
+ Manages the persistent state of the application.
11
+ Tracks the current date, active page, and calendar pagination.
12
+ """
13
+
14
+ def __init__(self):
15
+ self.current_date = datetime.now()
16
+ self.active_page = None
17
+ self.calendar_page = 0
18
+ self.determine_initial_calendar_page()
19
+
20
+ def determine_initial_calendar_page(self):
21
+ """Determines the calendar page based on current_date."""
22
+ month = self.current_date.month
23
+ if month <= 5:
24
+ self.calendar_page = 0
25
+ elif month <= 8:
26
+ self.calendar_page = 1
27
+ else:
28
+ self.calendar_page = 2
29
+
30
+ def increment_date(self):
31
+ """Increments the current date, with season boundary logic."""
32
+ if self.current_date.year < 2026:
33
+ self.current_date = datetime(2026, 1, 1)
34
+
35
+ if self.current_date < datetime(2026, 12, 31):
36
+ self.current_date += timedelta(days=1)
37
+ return True
38
+ return False
39
+
40
+ def decrement_date(self):
41
+ """Decrements the current date, with season boundary logic."""
42
+ if self.current_date.year > 2026:
43
+ self.current_date = datetime(2026, 12, 31)
44
+
45
+ if self.current_date > datetime(2026, 1, 1):
46
+ self.current_date -= timedelta(days=1)
47
+ return True
48
+ return False
49
+
50
+ def next_calendar_page(self):
51
+ """Moves to the next calendar page with wrapping."""
52
+ self.calendar_page = (self.calendar_page + 1) % 3
53
+
54
+ def prev_calendar_page(self):
55
+ """Moves to the previous calendar page with wrapping."""
56
+ self.calendar_page = (self.calendar_page - 1) % 3
57
+
58
+ def reset_to_today(self):
59
+ """Resets current_date to the actual today."""
60
+ self.current_date = datetime.now()
61
+
62
+ def set_active_page(self, page_name):
63
+ """Updates the active page name."""
64
+ self.active_page = page_name
65
+
66
+ @property
67
+ def on_calendar_screen(self):
68
+ """Returns True if currently on a calendar page."""
69
+ return self.active_page and self.active_page.startswith("calendar")
70
+
71
+ @property
72
+ def on_standings_screen(self):
73
+ """Returns True if currently on the standings page."""
74
+ return self.active_page == "standings"
@@ -0,0 +1,22 @@
1
+ """
2
+ Widget package for the MLB CLI application.
3
+ Contains various UI components for games, standings, navigation, and more.
4
+ """
5
+ from .separator import Separator
6
+ from .game_widget import GameWidget, create_grid, chunk_list
7
+ from .standing_widget import StandingWidget
8
+ from .navigation_widget import NavigationWidget
9
+ from .calendar_widget import CalendarWidget, CalendarButton
10
+ from .animations import slide_transition
11
+
12
+ __all__ = [
13
+ "Separator",
14
+ "GameWidget",
15
+ "create_grid",
16
+ "chunk_list",
17
+ "StandingWidget",
18
+ "NavigationWidget",
19
+ "CalendarWidget",
20
+ "CalendarButton",
21
+ "slide_transition",
22
+ ]
@@ -0,0 +1,66 @@
1
+ """
2
+ Animation utilities for the MLB CLI application.
3
+ Provides functions for sliding windows and other transitions.
4
+ """
5
+ import pytermgui as ptg
6
+ from pytermgui.animations import Direction
7
+
8
+
9
+ # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals
10
+ def slide_transition(window, manager, widgets, title, duration=290, on_finish=None,
11
+ new_width=None, new_height=None):
12
+ """
13
+ Performs a slide-out and slide-in transition for a window.
14
+
15
+ Args:
16
+ window (ptg.Window): The window to animate.
17
+ manager (ptg.WindowManager): The manager containing the window.
18
+ widgets (list): The new widgets to set after sliding out.
19
+ title (str): The new title to set after sliding out.
20
+ duration (int): Duration of each slide stage in milliseconds.
21
+ on_finish (callable, optional): Callback to run when the entire transition finishes.
22
+ new_width (int, optional): The target width for the window.
23
+ new_height (int, optional): The target height for the window.
24
+ """
25
+ # Use provided dimensions or fallback to current ones
26
+ target_width = new_width if new_width is not None else window.width
27
+ target_height = new_height if new_height is not None else window.height
28
+
29
+ # Calculate starting position for slide-in (centered)
30
+ final_target_x = (manager.terminal.width - target_width) // 2
31
+ _, start_y = window.pos
32
+ off_screen_right = manager.terminal.width
33
+
34
+ def slide_step(anim, off_x, current_target_x):
35
+ """Updates the window position based on animation state and off-screen target."""
36
+ curr_x = int(current_target_x + (off_x - current_target_x) * anim.state)
37
+ window.pos = (curr_x, start_y)
38
+ return False
39
+
40
+ def on_slide_in_finish(_):
41
+ """Runs the external on_finish callback if provided."""
42
+ if on_finish:
43
+ on_finish()
44
+
45
+ def on_slide_out_finish(_):
46
+ """Updates window content and starts the slide-in animation using Direction.BACKWARD."""
47
+ window.set_widgets(widgets)
48
+ window.set_title(title)
49
+ window.width = target_width
50
+ window.height = target_height
51
+ window.styles.border = "green"
52
+ window.styles.corner = "green"
53
+
54
+ ptg.animator.animate_float(
55
+ duration=duration,
56
+ direction=Direction.BACKWARD,
57
+ on_step=lambda anim: slide_step(anim, off_screen_right, final_target_x),
58
+ on_finish=on_slide_in_finish
59
+ )
60
+
61
+ # Start the slide-out animation using Direction.FORWARD
62
+ ptg.animator.animate_float(
63
+ duration=duration,
64
+ direction=Direction.FORWARD,
65
+ on_finish=on_slide_out_finish
66
+ )
@@ -0,0 +1,78 @@
1
+ """
2
+ Calendar widget for the MLB CLI application.
3
+ """
4
+ import calendar
5
+ import pytermgui as ptg
6
+ from .separator import Separator
7
+
8
+
9
+ class CalendarButton(ptg.Button):
10
+ """
11
+ A button that only responds to keyboard events.
12
+ """
13
+
14
+ def handle_mouse(self, event):
15
+ """Ignores mouse events."""
16
+ return False
17
+
18
+
19
+ class CalendarWidget(ptg.Container):
20
+ """
21
+ A widget that displays a calendar for a specific month.
22
+ Allows selecting a date and confirming with ENTER.
23
+ """
24
+
25
+ def __init__(self, year, month, on_date_selected, selected_day=None, **kwargs):
26
+ """
27
+ Initializes the CalendarWidget.
28
+
29
+ Args:
30
+ year (int): The year to display.
31
+ month (int): The month to display.
32
+ on_date_selected (callable): Callback when a date is selected with ENTER.
33
+ selected_day (int, optional): The day to highlight as selected.
34
+ **kwargs: Additional arguments for ptg.Container.
35
+ """
36
+ super().__init__(**kwargs)
37
+ self.year = year
38
+ self.month = month
39
+ self.on_date_selected = on_date_selected
40
+ self.day_to_button = {}
41
+ self.button_to_day = {}
42
+ self.border = ptg.boxes.SINGLE
43
+
44
+ cal = calendar.Calendar(firstweekday=6) # Sunday
45
+ month_days = cal.monthdayscalendar(year, month)
46
+
47
+ widgets = []
48
+ # Header: Su Mo Tu We Th Fr Sa
49
+ days_header = ptg.Splitter(
50
+ *[ptg.Label(d, parent_align=ptg.HorizontalAlignment.CENTER)
51
+ for d in ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"]]
52
+ )
53
+ widgets.append(days_header)
54
+ widgets.append(Separator())
55
+
56
+ for week in month_days:
57
+ week_widgets = []
58
+ for day in week:
59
+ if day == 0:
60
+ week_widgets.append(ptg.Label(""))
61
+ else:
62
+ btn = CalendarButton(
63
+ str(day),
64
+ lambda b, d=day: self.on_date_selected(self.year, self.month, d)
65
+ )
66
+ if day == selected_day:
67
+ btn.styles.label = "inverse"
68
+ else:
69
+ btn.styles.label = "white"
70
+
71
+ btn.styles.highlight = "inverse"
72
+
73
+ self.day_to_button[day] = btn
74
+ self.button_to_day[btn] = day
75
+ week_widgets.append(btn)
76
+ widgets.append(ptg.Splitter(*week_widgets))
77
+
78
+ self.set_widgets(widgets)
@@ -0,0 +1,139 @@
1
+ """
2
+ Game widget for the MLB CLI application.
3
+ """
4
+ from datetime import datetime, timezone
5
+ import pytermgui as ptg
6
+ from app.models.data_service import get_team_abbr
7
+
8
+
9
+ class GameWidget(ptg.Container):
10
+ """
11
+ A container widget displaying the score and teams for a single MLB game.
12
+ """
13
+
14
+ def __init__(self, game, **kwargs):
15
+ """
16
+ Initializes the GameWidget with game data.
17
+
18
+ Args:
19
+ game (dict): Dictionary containing game details (teams, scores, status).
20
+ **kwargs: Additional arguments for ptg.Container.
21
+ """
22
+ super().__init__(**kwargs)
23
+ self.game_id = game['game_id']
24
+ self._selectables_length = 1
25
+
26
+ away_abbr = get_team_abbr(game['away_id'])
27
+ home_abbr = get_team_abbr(game['home_id'])
28
+
29
+ status = game.get('status', '')
30
+ away_score, home_score = self._get_scores(game, status)
31
+ inning_text = self._get_inning_text(game, status)
32
+
33
+ self.away_info, self.home_info = self._create_rows(
34
+ (away_abbr, away_score),
35
+ (home_abbr, home_score),
36
+ inning_text
37
+ )
38
+
39
+ self.set_widgets([self.away_info, self.home_info])
40
+ self.border = ptg.boxes.SINGLE
41
+
42
+ def _get_scores(self, game, status):
43
+ """Determines the away and home scores based on game status."""
44
+ if status in ['Scheduled', 'Preview', 'Pre-Game']:
45
+ return "-", "-"
46
+
47
+ away_score = game.get('away_score', "-")
48
+ home_score = game.get('home_score', "-")
49
+ return (str(away_score) if away_score is not None else "-",
50
+ str(home_score) if home_score is not None else "-")
51
+
52
+ def _get_inning_text(self, game, status):
53
+ """Formats the inning or start time text."""
54
+ if status in ['Scheduled', 'Preview', 'Pre-Game']:
55
+ try:
56
+ dt_naive = datetime.strptime(game['game_datetime'], '%Y-%m-%dT%H:%M:%SZ')
57
+ dt_aware = dt_naive.replace(tzinfo=timezone.utc)
58
+ return dt_aware.astimezone().strftime('%-I:%M %p')
59
+ except (ValueError, KeyError, TypeError):
60
+ return ""
61
+
62
+ if status == 'Final':
63
+ return "FINAL"
64
+
65
+ inning_val = game.get('current_inning')
66
+ inning_state = game.get('inning_state', '')
67
+ if inning_val:
68
+ prefix = ""
69
+ if inning_state.lower().startswith('top'):
70
+ prefix = "TOP"
71
+ elif inning_state.lower().startswith('bottom'):
72
+ prefix = "BOT"
73
+ elif inning_state.lower().startswith('middle'):
74
+ prefix = "MID"
75
+ elif inning_state.lower().startswith('end'):
76
+ prefix = "END"
77
+ return f"{prefix} {inning_val}"
78
+
79
+ return ""
80
+
81
+ def _create_rows(self, away_data, home_data, inning_text):
82
+ """Creates the splitter rows for away and home teams."""
83
+ away_abbr, away_score = away_data
84
+ home_abbr, home_score = home_data
85
+
86
+ away_row = ptg.Splitter(
87
+ ptg.Label(f"[bold]{away_abbr:3}[/] {away_score:>2}"),
88
+ ptg.Label("", parent_align=ptg.HorizontalAlignment.RIGHT)
89
+ )
90
+
91
+ home_row = ptg.Splitter(
92
+ ptg.Label(f"[bold]{home_abbr:3}[/] {home_score:>2}"),
93
+ ptg.Label(f"[italic]{inning_text}[/]", parent_align=ptg.HorizontalAlignment.CENTER)
94
+ )
95
+ return away_row, home_row
96
+
97
+ def handle_key(self, key):
98
+ """
99
+ Handles key events for the widget.
100
+
101
+ Args:
102
+ key (str): The key that was pressed.
103
+ """
104
+ if key == ptg.keys.RETURN:
105
+ # Future: show box score
106
+ pass
107
+ return super().handle_key(key)
108
+
109
+
110
+ def chunk_list(lst, n):
111
+ """
112
+ Yields successive n-sized chunks from a list.
113
+
114
+ Args:
115
+ lst (list): The list to chunk.
116
+ n (int): The size of each chunk.
117
+ """
118
+ for i in range(0, len(lst), n):
119
+ yield lst[i:i + n]
120
+
121
+
122
+ def create_grid(games):
123
+ """
124
+ Creates a grid layout of GameWidgets from a list of games.
125
+
126
+ Args:
127
+ games (list): List of game data dictionaries.
128
+
129
+ Returns:
130
+ list: A list of tuples, each representing a row of widgets for the grid.
131
+ """
132
+ grid_rows = []
133
+ for game_group in chunk_list(games, 3):
134
+ widgets = [GameWidget(game) for game in game_group]
135
+ # Fill empty slots if less than 3 games in row
136
+ while len(widgets) < 3:
137
+ widgets.append(ptg.Label(""))
138
+ grid_rows.append(tuple(widgets))
139
+ return grid_rows