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.
- app/__init__.py +0 -0
- app/config.py +26 -0
- app/exceptions.py +6 -0
- app/logger.py +29 -0
- app/mlb_cli.py +332 -0
- app/models/__init__.py +0 -0
- app/models/base_data_source.py +28 -0
- app/models/cache_service.py +58 -0
- app/models/data_service.py +190 -0
- app/models/statsapi_source.py +71 -0
- app/screens/__init__.py +9 -0
- app/screens/calendar_screen.py +58 -0
- app/screens/error_screen.py +42 -0
- app/screens/schedule_screen.py +33 -0
- app/screens/standings_screen.py +39 -0
- app/state.py +74 -0
- app/widgets/__init__.py +22 -0
- app/widgets/animations.py +66 -0
- app/widgets/calendar_widget.py +78 -0
- app/widgets/game_widget.py +139 -0
- app/widgets/navigation_widget.py +73 -0
- app/widgets/separator.py +11 -0
- app/widgets/standing_widget.py +66 -0
- mlb_cli_py-0.1.0.dist-info/METADATA +160 -0
- mlb_cli_py-0.1.0.dist-info/RECORD +29 -0
- mlb_cli_py-0.1.0.dist-info/WHEEL +5 -0
- mlb_cli_py-0.1.0.dist-info/entry_points.txt +2 -0
- mlb_cli_py-0.1.0.dist-info/licenses/LICENSE +7 -0
- mlb_cli_py-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
app/screens/__init__.py
ADDED
|
@@ -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"
|
app/widgets/__init__.py
ADDED
|
@@ -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
|