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 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
@@ -0,0 +1,6 @@
1
+ """
2
+ Custom exceptions for the MLB CLI application.
3
+ """
4
+
5
+ class APIError(Exception):
6
+ """Raised when an error occurs while fetching data from the MLB API."""
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