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
app/__init__.py
ADDED
|
File without changes
|
app/config.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration module for the MLB CLI application.
|
|
3
|
+
Centralizes all constants and environment-specific settings.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
# TUI Settings
|
|
7
|
+
STATIC_WIDTH = 80
|
|
8
|
+
INITIAL_HEIGHT = 36
|
|
9
|
+
|
|
10
|
+
# Redis Settings
|
|
11
|
+
REDIS_HOST = 'localhost'
|
|
12
|
+
REDIS_PORT = 6379
|
|
13
|
+
REDIS_DB = 0
|
|
14
|
+
|
|
15
|
+
# Data Service Settings
|
|
16
|
+
LIVE_DATA_TTL = 300 # 5 minutes
|
|
17
|
+
|
|
18
|
+
# Division name mapping
|
|
19
|
+
DIVISION_NAMES = {
|
|
20
|
+
200: "AL West",
|
|
21
|
+
201: "AL East",
|
|
22
|
+
202: "AL Central",
|
|
23
|
+
203: "NL West",
|
|
24
|
+
204: "NL East",
|
|
25
|
+
205: "NL Central"
|
|
26
|
+
}
|
app/exceptions.py
ADDED
app/logger.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Logging configuration for the MLB CLI application.
|
|
3
|
+
Provides a standard logger that writes to a local log file.
|
|
4
|
+
"""
|
|
5
|
+
import os
|
|
6
|
+
import logging
|
|
7
|
+
from logging.handlers import RotatingFileHandler
|
|
8
|
+
|
|
9
|
+
# Ensure log directory exists
|
|
10
|
+
LOG_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
11
|
+
LOG_FILE = os.path.join(LOG_DIR, "mlb_cli.log")
|
|
12
|
+
|
|
13
|
+
def get_logger(name):
|
|
14
|
+
"""
|
|
15
|
+
Configures and returns a logger instance.
|
|
16
|
+
"""
|
|
17
|
+
logger = logging.getLogger(name)
|
|
18
|
+
if not logger.handlers:
|
|
19
|
+
logger.setLevel(logging.DEBUG)
|
|
20
|
+
|
|
21
|
+
# Rotating file handler (5MB per file, keeps 2 backups)
|
|
22
|
+
handler = RotatingFileHandler(LOG_FILE, maxBytes=5*1024*1024, backupCount=2)
|
|
23
|
+
formatter = logging.Formatter(
|
|
24
|
+
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
|
25
|
+
)
|
|
26
|
+
handler.setFormatter(formatter)
|
|
27
|
+
logger.addHandler(handler)
|
|
28
|
+
|
|
29
|
+
return logger
|
app/mlb_cli.py
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Main entry point for the MLB CLI application.
|
|
3
|
+
This module initializes the Terminal UI and manages the primary window and global keybindings.
|
|
4
|
+
"""
|
|
5
|
+
from datetime import datetime, timedelta
|
|
6
|
+
import pytermgui as ptg
|
|
7
|
+
from .models.data_service import (
|
|
8
|
+
fetch_teams,
|
|
9
|
+
format_date
|
|
10
|
+
)
|
|
11
|
+
from .screens import (
|
|
12
|
+
ScheduleScreen,
|
|
13
|
+
StandingsScreen,
|
|
14
|
+
CalendarScreen,
|
|
15
|
+
ErrorScreen
|
|
16
|
+
)
|
|
17
|
+
from .widgets import CalendarWidget, slide_transition
|
|
18
|
+
from .config import STATIC_WIDTH, INITIAL_HEIGHT
|
|
19
|
+
from .state import ApplicationState
|
|
20
|
+
from .exceptions import APIError
|
|
21
|
+
from .logger import get_logger
|
|
22
|
+
|
|
23
|
+
logger = get_logger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class MLBApp:
|
|
27
|
+
"""
|
|
28
|
+
TUI Manager class for the MLB CLI application.
|
|
29
|
+
Handles window management, screen transitions, and global keybindings.
|
|
30
|
+
"""
|
|
31
|
+
# pylint: disable=too-many-instance-attributes
|
|
32
|
+
|
|
33
|
+
def __init__(self):
|
|
34
|
+
"""Initializes the application UI and logic state."""
|
|
35
|
+
fetch_teams()
|
|
36
|
+
self.manager = ptg.WindowManager()
|
|
37
|
+
self.state = ApplicationState()
|
|
38
|
+
self.static_width = STATIC_WIDTH
|
|
39
|
+
self.static_height = 0 # Will be set in run()
|
|
40
|
+
self.main_window = None
|
|
41
|
+
self.is_initialized = False
|
|
42
|
+
|
|
43
|
+
def set_window_data(self, widgets, title, page_name, on_finish=None):
|
|
44
|
+
"""Sets content for the main window, using animation if already initialized."""
|
|
45
|
+
if self.state.active_page == page_name:
|
|
46
|
+
if on_finish:
|
|
47
|
+
on_finish()
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
# Calculate required height based on content
|
|
51
|
+
max_height = self.manager.terminal.height - 2
|
|
52
|
+
temp_window = ptg.Window(*widgets, width=self.static_width)
|
|
53
|
+
temp_window.set_title(title)
|
|
54
|
+
target_height = min(max_height, temp_window.height)
|
|
55
|
+
|
|
56
|
+
self.state.set_active_page(page_name)
|
|
57
|
+
if not self.is_initialized:
|
|
58
|
+
self.is_initialized = True
|
|
59
|
+
self.main_window.set_widgets(widgets)
|
|
60
|
+
self.main_window.set_title(title)
|
|
61
|
+
self.main_window.width = self.static_width
|
|
62
|
+
self.main_window.height = target_height
|
|
63
|
+
self.main_window.styles.border = "green"
|
|
64
|
+
self.main_window.styles.corner = "green"
|
|
65
|
+
self.main_window.center()
|
|
66
|
+
if on_finish:
|
|
67
|
+
on_finish()
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
slide_transition(
|
|
71
|
+
self.main_window,
|
|
72
|
+
self.manager,
|
|
73
|
+
widgets,
|
|
74
|
+
title,
|
|
75
|
+
on_finish=on_finish,
|
|
76
|
+
new_height=target_height
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
@staticmethod
|
|
80
|
+
def handle_errors(func):
|
|
81
|
+
"""Decorator to handle errors during screen transitions."""
|
|
82
|
+
def wrapper(self, *args, **kwargs):
|
|
83
|
+
try:
|
|
84
|
+
return func(self, *args, **kwargs)
|
|
85
|
+
except (APIError, Exception) as e: # pylint: disable=broad-exception-caught
|
|
86
|
+
logger.error("Error in %s: %s", func.__name__, e, exc_info=True)
|
|
87
|
+
# Provide a user-friendly message for generic exceptions
|
|
88
|
+
msg = str(e) if isinstance(e, APIError) else "An unexpected error occurred"
|
|
89
|
+
widgets, title = ErrorScreen.get_widgets(msg)
|
|
90
|
+
self.set_window_data(widgets, title, "error")
|
|
91
|
+
return False
|
|
92
|
+
return wrapper
|
|
93
|
+
|
|
94
|
+
@handle_errors
|
|
95
|
+
def update_to_schedule(self, *_args, **_kwargs):
|
|
96
|
+
"""Transitions the main window to show the schedule for current_date."""
|
|
97
|
+
date_str = format_date(self.state.current_date)
|
|
98
|
+
widgets, title = ScheduleScreen.get_widgets(date_str)
|
|
99
|
+
self.set_window_data(widgets, title, f"schedule:{date_str}")
|
|
100
|
+
return True
|
|
101
|
+
|
|
102
|
+
def go_to_previous_day(self, *_args, **_kwargs):
|
|
103
|
+
"""Decrements the current date or page."""
|
|
104
|
+
if self.state.on_calendar_screen:
|
|
105
|
+
return self.go_to_previous_page()
|
|
106
|
+
|
|
107
|
+
if self.state.decrement_date():
|
|
108
|
+
return self.update_to_schedule()
|
|
109
|
+
return True
|
|
110
|
+
|
|
111
|
+
def go_to_next_day(self, *_args, **_kwargs):
|
|
112
|
+
"""Increments the current date or page."""
|
|
113
|
+
if self.state.on_calendar_screen:
|
|
114
|
+
return self.go_to_next_page()
|
|
115
|
+
|
|
116
|
+
if self.state.increment_date():
|
|
117
|
+
return self.update_to_schedule()
|
|
118
|
+
return True
|
|
119
|
+
|
|
120
|
+
@handle_errors
|
|
121
|
+
def toggle_standings(self, *_args, **_kwargs):
|
|
122
|
+
"""Toggles between standings and schedule/calendar."""
|
|
123
|
+
if self.state.on_standings_screen:
|
|
124
|
+
return self.update_to_schedule()
|
|
125
|
+
|
|
126
|
+
widgets, title = StandingsScreen.get_widgets()
|
|
127
|
+
self.set_window_data(widgets, title, "standings")
|
|
128
|
+
return True
|
|
129
|
+
|
|
130
|
+
@handle_errors
|
|
131
|
+
def update_to_calendar(self, *_args, sync_page=True, focus_target=None, **_kwargs):
|
|
132
|
+
"""Transitions to the calendar view."""
|
|
133
|
+
if self.state.on_standings_screen:
|
|
134
|
+
self.state.reset_to_today()
|
|
135
|
+
|
|
136
|
+
if sync_page:
|
|
137
|
+
# Ensure calendar_page matches current_date
|
|
138
|
+
self.state.determine_initial_calendar_page()
|
|
139
|
+
|
|
140
|
+
pages = [
|
|
141
|
+
[3, 4, 5],
|
|
142
|
+
[6, 7, 8],
|
|
143
|
+
[9, 10]
|
|
144
|
+
]
|
|
145
|
+
months = pages[self.state.calendar_page]
|
|
146
|
+
widgets, title = CalendarScreen.get_widgets(
|
|
147
|
+
2026,
|
|
148
|
+
months,
|
|
149
|
+
self.on_calendar_date_selected,
|
|
150
|
+
selected_date=self.state.current_date
|
|
151
|
+
)
|
|
152
|
+
self.set_window_data(
|
|
153
|
+
widgets,
|
|
154
|
+
title,
|
|
155
|
+
f"calendar:{self.state.calendar_page}",
|
|
156
|
+
on_finish=lambda: self._focus_current_date_in_calendar(target=focus_target)
|
|
157
|
+
)
|
|
158
|
+
return True
|
|
159
|
+
|
|
160
|
+
def _focus_current_date_in_calendar(self, target=None):
|
|
161
|
+
# pylint: disable=too-many-branches
|
|
162
|
+
"""
|
|
163
|
+
Helper to find and focus a date in the calendar view.
|
|
164
|
+
If target is None, focuses current_date.
|
|
165
|
+
If target is 'first', focuses first date of first month.
|
|
166
|
+
If target is 'last', focuses last date of last month.
|
|
167
|
+
"""
|
|
168
|
+
calendar_widgets = []
|
|
169
|
+
for widget in self.main_window:
|
|
170
|
+
if not isinstance(widget, ptg.Container):
|
|
171
|
+
continue
|
|
172
|
+
for sub in widget:
|
|
173
|
+
if isinstance(sub, CalendarWidget):
|
|
174
|
+
calendar_widgets.append(sub)
|
|
175
|
+
|
|
176
|
+
if not calendar_widgets:
|
|
177
|
+
return False
|
|
178
|
+
|
|
179
|
+
target_btn = None
|
|
180
|
+
if target == "first":
|
|
181
|
+
sub = calendar_widgets[0]
|
|
182
|
+
first_day = min(sub.day_to_button.keys())
|
|
183
|
+
target_btn = sub.day_to_button[first_day]
|
|
184
|
+
elif target == "last":
|
|
185
|
+
sub = calendar_widgets[-1]
|
|
186
|
+
last_day = max(sub.day_to_button.keys())
|
|
187
|
+
target_btn = sub.day_to_button[last_day]
|
|
188
|
+
else:
|
|
189
|
+
# Default: focus current_date
|
|
190
|
+
for sub in calendar_widgets:
|
|
191
|
+
if sub.month == self.state.current_date.month:
|
|
192
|
+
day = self.state.current_date.day
|
|
193
|
+
if day in sub.day_to_button:
|
|
194
|
+
target_btn = sub.day_to_button[day]
|
|
195
|
+
break
|
|
196
|
+
|
|
197
|
+
if target_btn:
|
|
198
|
+
for i, (selectable, _) in enumerate(self.main_window.selectables):
|
|
199
|
+
if selectable is target_btn:
|
|
200
|
+
self.main_window.select(i)
|
|
201
|
+
self.manager.focused = target_btn
|
|
202
|
+
return True
|
|
203
|
+
return False
|
|
204
|
+
|
|
205
|
+
def on_calendar_date_selected(self, year, month, day):
|
|
206
|
+
"""Callback for when a date is selected in the calendar."""
|
|
207
|
+
self.state.current_date = datetime(year, month, day)
|
|
208
|
+
return self.update_to_schedule()
|
|
209
|
+
|
|
210
|
+
def _navigate_calendar(self, direction):
|
|
211
|
+
# pylint: disable=too-many-branches
|
|
212
|
+
"""
|
|
213
|
+
Global WASD navigation for the calendar.
|
|
214
|
+
Moves focus between buttons based on date logic.
|
|
215
|
+
"""
|
|
216
|
+
if not self.state.on_calendar_screen:
|
|
217
|
+
return False
|
|
218
|
+
|
|
219
|
+
focused = self.manager.focused
|
|
220
|
+
if focused is None:
|
|
221
|
+
return False
|
|
222
|
+
|
|
223
|
+
# Find which CalendarWidget the focused button belongs to
|
|
224
|
+
target_widget = None
|
|
225
|
+
for widget in self.main_window:
|
|
226
|
+
if not isinstance(widget, ptg.Container):
|
|
227
|
+
continue
|
|
228
|
+
for sub in widget:
|
|
229
|
+
if isinstance(sub, CalendarWidget) and focused in sub.button_to_day:
|
|
230
|
+
target_widget = sub
|
|
231
|
+
break
|
|
232
|
+
if target_widget:
|
|
233
|
+
break
|
|
234
|
+
|
|
235
|
+
if not target_widget:
|
|
236
|
+
return False
|
|
237
|
+
|
|
238
|
+
day = target_widget.button_to_day[focused]
|
|
239
|
+
current_date = datetime(target_widget.year, target_widget.month, day)
|
|
240
|
+
|
|
241
|
+
delta = {
|
|
242
|
+
"w": timedelta(days=-7),
|
|
243
|
+
"a": timedelta(days=-1),
|
|
244
|
+
"s": timedelta(days=7),
|
|
245
|
+
"d": timedelta(days=1),
|
|
246
|
+
}.get(direction)
|
|
247
|
+
|
|
248
|
+
if not delta:
|
|
249
|
+
return False
|
|
250
|
+
|
|
251
|
+
target_date = current_date + delta
|
|
252
|
+
|
|
253
|
+
# Look for the target date in any CalendarWidget in the main window
|
|
254
|
+
for widget in self.main_window:
|
|
255
|
+
if not isinstance(widget, ptg.Container):
|
|
256
|
+
continue
|
|
257
|
+
for sub in widget:
|
|
258
|
+
if not (isinstance(sub, CalendarWidget) and
|
|
259
|
+
sub.year == target_date.year and
|
|
260
|
+
sub.month == target_date.month):
|
|
261
|
+
continue
|
|
262
|
+
|
|
263
|
+
if target_date.day in sub.day_to_button:
|
|
264
|
+
target_btn = sub.day_to_button[target_date.day]
|
|
265
|
+
# Find and select the button index in the window
|
|
266
|
+
for i, (selectable, _) in enumerate(self.main_window.selectables):
|
|
267
|
+
if selectable is target_btn:
|
|
268
|
+
self.main_window.select(i)
|
|
269
|
+
self.manager.focused = target_btn
|
|
270
|
+
return True
|
|
271
|
+
return False
|
|
272
|
+
|
|
273
|
+
def go_to_previous_page(self, *_args, **_kwargs):
|
|
274
|
+
"""Moves calendar view to the previous page with wrapping."""
|
|
275
|
+
self.state.prev_calendar_page()
|
|
276
|
+
return self.update_to_calendar(sync_page=False, focus_target="last")
|
|
277
|
+
|
|
278
|
+
def go_to_next_page(self, *_args, **_kwargs):
|
|
279
|
+
"""Moves calendar view to the next page with wrapping."""
|
|
280
|
+
self.state.next_calendar_page()
|
|
281
|
+
return self.update_to_calendar(sync_page=False, focus_target="first")
|
|
282
|
+
|
|
283
|
+
def go_to_today(self, *_args, **_kwargs):
|
|
284
|
+
"""Resets the current date to today and updates the view."""
|
|
285
|
+
self.state.reset_to_today()
|
|
286
|
+
return self.update_to_schedule()
|
|
287
|
+
|
|
288
|
+
def exit_app(self, *_args, **_kwargs):
|
|
289
|
+
"""Stops the WindowManager and exits the application."""
|
|
290
|
+
self.manager.stop()
|
|
291
|
+
return True
|
|
292
|
+
|
|
293
|
+
def run(self):
|
|
294
|
+
"""Starts the application main loop."""
|
|
295
|
+
with self.manager:
|
|
296
|
+
self.static_height = min(INITIAL_HEIGHT, self.manager.terminal.height - 2)
|
|
297
|
+
|
|
298
|
+
self.main_window = ptg.Window(
|
|
299
|
+
width=self.static_width,
|
|
300
|
+
height=self.static_height,
|
|
301
|
+
is_static=True,
|
|
302
|
+
is_noresize=True)
|
|
303
|
+
self.manager.add(self.main_window)
|
|
304
|
+
|
|
305
|
+
# Initial content: Calendar
|
|
306
|
+
self.update_to_calendar()
|
|
307
|
+
|
|
308
|
+
# Global bindings
|
|
309
|
+
self.manager.bind("[", self.go_to_previous_day)
|
|
310
|
+
self.manager.bind("]", self.go_to_next_day)
|
|
311
|
+
self.manager.bind("t", self.go_to_today)
|
|
312
|
+
self.manager.bind("c", self.update_to_calendar)
|
|
313
|
+
self.manager.bind("x", self.toggle_standings)
|
|
314
|
+
self.manager.bind(ptg.keys.ESC, self.exit_app)
|
|
315
|
+
|
|
316
|
+
# WASD for Calendar Navigation
|
|
317
|
+
self.manager.bind("w", lambda *_: self._navigate_calendar("w"))
|
|
318
|
+
self.manager.bind("a", lambda *_: self._navigate_calendar("a"))
|
|
319
|
+
self.manager.bind("s", lambda *_: self._navigate_calendar("s"))
|
|
320
|
+
self.manager.bind("d", lambda *_: self._navigate_calendar("d"))
|
|
321
|
+
|
|
322
|
+
self.manager.run()
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def main():
|
|
326
|
+
"""Entry point for the application."""
|
|
327
|
+
app = MLBApp()
|
|
328
|
+
app.run()
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
if __name__ == "__main__": # pragma: no cover
|
|
332
|
+
main()
|
app/models/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base module for data source abstractions.
|
|
3
|
+
Provides the interface for fetching MLB data.
|
|
4
|
+
"""
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BaseDataSource(ABC):
|
|
9
|
+
"""
|
|
10
|
+
Abstract base class for all MLB data sources.
|
|
11
|
+
Defines the contract for fetching team info, schedules, and standings.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
@abstractmethod
|
|
15
|
+
def fetch_teams(self):
|
|
16
|
+
"""Fetches all MLB teams and returns a mapping of ID to abbreviation."""
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def fetch_schedule(self, date_str):
|
|
20
|
+
"""Fetches the MLB schedule for a specific date."""
|
|
21
|
+
|
|
22
|
+
@abstractmethod
|
|
23
|
+
def fetch_standings(self):
|
|
24
|
+
"""Fetches current MLB standings."""
|
|
25
|
+
|
|
26
|
+
@abstractmethod
|
|
27
|
+
def fetch_wild_card(self, league_id):
|
|
28
|
+
"""Fetches wild card standings for a league."""
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Caching service module for the MLB CLI application.
|
|
3
|
+
Uses Redis to store API responses with specific TTL policies.
|
|
4
|
+
"""
|
|
5
|
+
import json
|
|
6
|
+
import redis
|
|
7
|
+
|
|
8
|
+
from app.config import REDIS_HOST, REDIS_PORT, REDIS_DB
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
# pylint: disable=invalid-name
|
|
12
|
+
_redis_client = redis.Redis(
|
|
13
|
+
host=REDIS_HOST, port=REDIS_PORT, db=REDIS_DB, decode_responses=True
|
|
14
|
+
)
|
|
15
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
16
|
+
_redis_client = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_cached_data(key):
|
|
20
|
+
"""
|
|
21
|
+
Retrieves data from the cache.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
key (str): The cache key.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
dict/list/None: The cached data if found and valid, else None.
|
|
28
|
+
"""
|
|
29
|
+
if _redis_client is None:
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
data = _redis_client.get(key)
|
|
34
|
+
return json.loads(data) if data else None
|
|
35
|
+
except (redis.RedisError, json.JSONDecodeError):
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def set_cached_data(key, data, ttl=None):
|
|
40
|
+
"""
|
|
41
|
+
Stores data in the cache.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
key (str): The cache key.
|
|
45
|
+
data (dict/list): The data to cache.
|
|
46
|
+
ttl (int, optional): Time-to-live in seconds.
|
|
47
|
+
"""
|
|
48
|
+
if _redis_client is None:
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
json_data = json.dumps(data)
|
|
53
|
+
if ttl:
|
|
54
|
+
_redis_client.setex(key, ttl, json_data)
|
|
55
|
+
else:
|
|
56
|
+
_redis_client.set(key, json_data)
|
|
57
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
58
|
+
pass
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Data service module for fetching MLB data.
|
|
3
|
+
Delegates to a BaseDataSource implementation (defaulting to StatsApiDataSource).
|
|
4
|
+
"""
|
|
5
|
+
from datetime import datetime, timedelta
|
|
6
|
+
from app.config import LIVE_DATA_TTL
|
|
7
|
+
from app.models.cache_service import get_cached_data, set_cached_data
|
|
8
|
+
from .statsapi_source import StatsApiDataSource
|
|
9
|
+
|
|
10
|
+
# Global cache for team abbreviations
|
|
11
|
+
TEAMS = {}
|
|
12
|
+
|
|
13
|
+
# Default data source
|
|
14
|
+
_data_source = StatsApiDataSource()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def fetch_teams():
|
|
18
|
+
"""
|
|
19
|
+
Fetches all MLB teams and populates the global TEAMS cache with abbreviations.
|
|
20
|
+
"""
|
|
21
|
+
TEAMS.clear()
|
|
22
|
+
TEAMS.update(_data_source.fetch_teams())
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_team_abbr(team_id):
|
|
26
|
+
"""
|
|
27
|
+
Retrieves the abbreviation for a given team ID.
|
|
28
|
+
Populates the TEAMS cache if it is empty.
|
|
29
|
+
"""
|
|
30
|
+
if not TEAMS:
|
|
31
|
+
fetch_teams()
|
|
32
|
+
return TEAMS.get(team_id, str(team_id))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def fetch_schedule(date_str):
|
|
36
|
+
"""
|
|
37
|
+
Fetches the MLB schedule for a specific date.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
date_str (str): The date string in MM/DD/YYYY format.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
list: A list of game dictionaries.
|
|
44
|
+
"""
|
|
45
|
+
cache_key = f"schedule:{date_str}"
|
|
46
|
+
cached = get_cached_data(cache_key)
|
|
47
|
+
if cached is not None:
|
|
48
|
+
return cached
|
|
49
|
+
|
|
50
|
+
data = _data_source.fetch_schedule(date_str)
|
|
51
|
+
|
|
52
|
+
# Policy: Today has TTL, other dates don't
|
|
53
|
+
ttl = LIVE_DATA_TTL if date_str == get_today_date() else None
|
|
54
|
+
set_cached_data(cache_key, data, ttl=ttl)
|
|
55
|
+
|
|
56
|
+
return data
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def format_date(dt):
|
|
60
|
+
"""Formats a datetime object to MM/DD/YYYY."""
|
|
61
|
+
return dt.strftime('%m/%d/%Y')
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def parse_date(date_str):
|
|
65
|
+
"""Parses a MM/DD/YYYY string into a datetime object."""
|
|
66
|
+
return datetime.strptime(date_str, '%m/%d/%Y')
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def get_yesterday_date():
|
|
70
|
+
"""
|
|
71
|
+
Returns yesterday's date as a formatted string.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
str: Date in MM/DD/YYYY format.
|
|
75
|
+
"""
|
|
76
|
+
return (datetime.now() - timedelta(days=1)).strftime('%m/%d/%Y')
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def get_today_date():
|
|
80
|
+
"""
|
|
81
|
+
Returns today's date as a formatted string.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
str: Date in MM/DD/YYYY format.
|
|
85
|
+
"""
|
|
86
|
+
return datetime.now().strftime('%m/%d/%Y')
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _parse_team_record(tr, is_wild_card=False):
|
|
90
|
+
"""Parses a team record from the API into a simplified dictionary."""
|
|
91
|
+
# Find last 10 record
|
|
92
|
+
l10 = "-"
|
|
93
|
+
if 'records' in tr and 'splitRecords' in tr['records']:
|
|
94
|
+
for split in tr['records']['splitRecords']:
|
|
95
|
+
if split['type'] == 'lastTen':
|
|
96
|
+
l10 = f"{split['wins']}-{split['losses']}"
|
|
97
|
+
break
|
|
98
|
+
|
|
99
|
+
# Format winning percentage (e.g., .500 -> 50.0%)
|
|
100
|
+
pct_val = tr.get('winningPercentage', '0')
|
|
101
|
+
try:
|
|
102
|
+
pct_float = float(pct_val) * 100
|
|
103
|
+
pct_str = f"{pct_float:.1f}%"
|
|
104
|
+
except (ValueError, TypeError):
|
|
105
|
+
pct_str = "-"
|
|
106
|
+
|
|
107
|
+
# Prioritize wildCardGamesBack for wild card view, else use gamesBack
|
|
108
|
+
if is_wild_card:
|
|
109
|
+
gb = tr.get('wildCardGamesBack', tr.get('gamesBack', '-'))
|
|
110
|
+
else:
|
|
111
|
+
gb = tr.get('gamesBack', '-')
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
'team_id': tr['team']['id'],
|
|
115
|
+
'w': tr['wins'],
|
|
116
|
+
'l': tr['losses'],
|
|
117
|
+
'gb': gb,
|
|
118
|
+
'pct': pct_str,
|
|
119
|
+
'l10': l10
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def fetch_wild_card(league_id):
|
|
124
|
+
"""
|
|
125
|
+
Fetches the wild card standings for a specific league.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
league_id (int): The ID of the league (103 for AL, 104 for NL).
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
dict: Wild card standings data.
|
|
132
|
+
"""
|
|
133
|
+
cache_key = f"wildcard:{league_id}"
|
|
134
|
+
cached = get_cached_data(cache_key)
|
|
135
|
+
if cached is not None:
|
|
136
|
+
return cached
|
|
137
|
+
|
|
138
|
+
record = _data_source.fetch_wild_card(league_id)
|
|
139
|
+
if not record:
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
teams = [_parse_team_record(tr, is_wild_card=True) for tr in record.get('teamRecords', [])]
|
|
143
|
+
|
|
144
|
+
league_name = "AL" if league_id == 103 else "NL"
|
|
145
|
+
result = {
|
|
146
|
+
'div_name': f"{league_name} Wild Card",
|
|
147
|
+
'teams': teams[:7]
|
|
148
|
+
}
|
|
149
|
+
set_cached_data(cache_key, result, ttl=LIVE_DATA_TTL)
|
|
150
|
+
return result
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def fetch_standings():
|
|
154
|
+
"""
|
|
155
|
+
Fetches the current MLB standings for the American and National Leagues,
|
|
156
|
+
including divisions and wild card.
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
tuple: (al_divs, nl_divs, al_wc, nl_wc)
|
|
160
|
+
"""
|
|
161
|
+
cache_key = "standings:full"
|
|
162
|
+
cached = get_cached_data(cache_key)
|
|
163
|
+
if cached is not None:
|
|
164
|
+
return tuple(cached)
|
|
165
|
+
|
|
166
|
+
div_results = _data_source.fetch_standings()
|
|
167
|
+
if not div_results:
|
|
168
|
+
return [], [], None, None
|
|
169
|
+
|
|
170
|
+
div_map = {}
|
|
171
|
+
for div in div_results:
|
|
172
|
+
div_map[div['id']] = {
|
|
173
|
+
'div_name': div['name'],
|
|
174
|
+
'teams': [
|
|
175
|
+
_parse_team_record(tr, is_wild_card=False)
|
|
176
|
+
for tr in div['teams']
|
|
177
|
+
]
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
# AL IDs: East(201), Central(202), West(200)
|
|
181
|
+
# NL IDs: East(204), Central(205), West(203)
|
|
182
|
+
al_divs = [div_map.get(201), div_map.get(202), div_map.get(200)]
|
|
183
|
+
nl_divs = [div_map.get(204), div_map.get(205), div_map.get(203)]
|
|
184
|
+
|
|
185
|
+
al_wc = fetch_wild_card(103)
|
|
186
|
+
nl_wc = fetch_wild_card(104)
|
|
187
|
+
|
|
188
|
+
result = (al_divs, nl_divs, al_wc, nl_wc)
|
|
189
|
+
set_cached_data(cache_key, result, ttl=LIVE_DATA_TTL)
|
|
190
|
+
return result
|