baseballcommand 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.
Potentially problematic release.
This version of baseballcommand might be problematic. Click here for more details.
- baseballcommand/__init__.py +8 -0
- baseballcommand/__main__.py +8 -0
- baseballcommand/api.py +395 -0
- baseballcommand/cache.py +115 -0
- baseballcommand/cli.py +121 -0
- baseballcommand/commands/__init__.py +38 -0
- baseballcommand/commands/cache.py +65 -0
- baseballcommand/commands/compare.py +149 -0
- baseballcommand/commands/glossary.py +48 -0
- baseballcommand/commands/leaders.py +222 -0
- baseballcommand/commands/player.py +209 -0
- baseballcommand/commands/roster.py +225 -0
- baseballcommand/commands/scores.py +214 -0
- baseballcommand/commands/standings.py +193 -0
- baseballcommand/commands/team.py +165 -0
- baseballcommand/commands/teams.py +83 -0
- baseballcommand/dates.py +96 -0
- baseballcommand/errors.py +39 -0
- baseballcommand/lookup.py +207 -0
- baseballcommand/output.py +193 -0
- baseballcommand/stats.py +171 -0
- baseballcommand-0.1.0.dist-info/METADATA +359 -0
- baseballcommand-0.1.0.dist-info/RECORD +26 -0
- baseballcommand-0.1.0.dist-info/WHEEL +4 -0
- baseballcommand-0.1.0.dist-info/entry_points.txt +3 -0
- baseballcommand-0.1.0.dist-info/licenses/LICENSE +21 -0
baseballcommand/api.py
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
"""Client for the public MLB Stats API.
|
|
2
|
+
|
|
3
|
+
The API at ``statsapi.mlb.com`` needs no key and no account, which is what makes
|
|
4
|
+
this tool free to run. Everything here is stdlib ``urllib`` — no request
|
|
5
|
+
library, no auth, no state beyond the on-disk cache.
|
|
6
|
+
|
|
7
|
+
Only :class:`StatsAPI` talks to the network. Command modules take a client and
|
|
8
|
+
never build URLs themselves, so retries, caching, and error translation stay in
|
|
9
|
+
one place.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import ssl
|
|
16
|
+
import time
|
|
17
|
+
import urllib.error
|
|
18
|
+
import urllib.parse
|
|
19
|
+
import urllib.request
|
|
20
|
+
from collections.abc import Iterable
|
|
21
|
+
from datetime import date
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from . import __version__
|
|
25
|
+
from .cache import TTL_STATIC, TTL_STATS, Cache
|
|
26
|
+
from .errors import APIError
|
|
27
|
+
|
|
28
|
+
BASE_URL = "https://statsapi.mlb.com/api"
|
|
29
|
+
USER_AGENT = f"BaseballCommand/{__version__} (+https://github.com/opowell28/BaseballCommand)"
|
|
30
|
+
|
|
31
|
+
#: ``sportId`` 1 is Major League Baseball. The API also carries the minors,
|
|
32
|
+
#: college, and international ball under other ids.
|
|
33
|
+
SPORT_MLB = 1
|
|
34
|
+
|
|
35
|
+
#: League ids used when a caller asks for one league's worth of data.
|
|
36
|
+
LEAGUE_AL = 103
|
|
37
|
+
LEAGUE_NL = 104
|
|
38
|
+
|
|
39
|
+
#: Regular season games. The API also has 'S' (spring), 'P'/'D'/'L'/'W'
|
|
40
|
+
#: (postseason rounds), and 'A' (all-star).
|
|
41
|
+
GAME_TYPE_REGULAR = "R"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
#: Shown when TLS verification fails, which on macOS almost always means a
|
|
45
|
+
#: python.org interpreter whose root certificates were never installed.
|
|
46
|
+
CERT_HELP = (
|
|
47
|
+
"TLS certificate verification failed. Your Python installation has no "
|
|
48
|
+
"usable root certificates.\n"
|
|
49
|
+
" macOS (python.org build): run "
|
|
50
|
+
"'/Applications/Python 3.x/Install Certificates.command'\n"
|
|
51
|
+
" any platform: 'pip install certifi' — BaseballCommand uses it "
|
|
52
|
+
"automatically when present."
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _ssl_context() -> ssl.SSLContext:
|
|
57
|
+
"""Build a verifying TLS context, preferring certifi's roots if installed.
|
|
58
|
+
|
|
59
|
+
certifi is not a dependency — the tool works without it wherever the system
|
|
60
|
+
trust store is wired up. When it is present we use it, which fixes the
|
|
61
|
+
common macOS case at no cost to everyone else. Verification is never
|
|
62
|
+
disabled.
|
|
63
|
+
"""
|
|
64
|
+
try:
|
|
65
|
+
import certifi # noqa: PLC0415 — optional, imported only if installed
|
|
66
|
+
|
|
67
|
+
return ssl.create_default_context(cafile=certifi.where())
|
|
68
|
+
except ImportError:
|
|
69
|
+
return ssl.create_default_context()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def current_season(today: date | None = None) -> int:
|
|
73
|
+
"""Return the season a bare ``base`` invocation should default to.
|
|
74
|
+
|
|
75
|
+
January and February have no baseball, and stat lines for the new year are
|
|
76
|
+
empty until Opening Day in late March. During those months the season the
|
|
77
|
+
user almost certainly means is the one that just finished.
|
|
78
|
+
"""
|
|
79
|
+
today = today or date.today()
|
|
80
|
+
return today.year if today.month >= 3 else today.year - 1
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class StatsAPI:
|
|
84
|
+
"""A thin, cached wrapper over the MLB Stats API.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
cache: Response cache. A disabled :class:`~.cache.Cache` turns every
|
|
88
|
+
request into a live fetch.
|
|
89
|
+
timeout: Per-request socket timeout, in seconds.
|
|
90
|
+
retries: Extra attempts after the first failure. Retries apply to
|
|
91
|
+
timeouts, connection errors, and 5xx responses — not to 4xx, which
|
|
92
|
+
will fail identically no matter how often we ask.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def __init__(
|
|
96
|
+
self,
|
|
97
|
+
cache: Cache | None = None,
|
|
98
|
+
timeout: float = 15.0,
|
|
99
|
+
retries: int = 2,
|
|
100
|
+
):
|
|
101
|
+
self.cache = cache if cache is not None else Cache()
|
|
102
|
+
self.timeout = timeout
|
|
103
|
+
self.retries = retries
|
|
104
|
+
self.ssl_context = _ssl_context()
|
|
105
|
+
|
|
106
|
+
# ------------------------------------------------------------------
|
|
107
|
+
# Transport
|
|
108
|
+
# ------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
def get(
|
|
111
|
+
self,
|
|
112
|
+
path: str,
|
|
113
|
+
params: dict[str, Any] | None = None,
|
|
114
|
+
ttl: int = TTL_STATS,
|
|
115
|
+
version: str = "v1",
|
|
116
|
+
) -> Any:
|
|
117
|
+
"""GET ``path`` and return the decoded JSON body.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
path: Path below the version segment, e.g. ``"schedule"``.
|
|
121
|
+
params: Query parameters. ``None`` values are dropped; lists and
|
|
122
|
+
tuples are joined with commas, which is how the API expects
|
|
123
|
+
multi-valued parameters such as ``leagueId=103,104``.
|
|
124
|
+
ttl: Seconds a cached copy stays fresh. Pass 0 to bypass the cache.
|
|
125
|
+
version: API version segment (``v1``, or ``v1.1`` for game feeds).
|
|
126
|
+
|
|
127
|
+
Raises:
|
|
128
|
+
APIError: On network failure or a non-2xx response.
|
|
129
|
+
"""
|
|
130
|
+
url = self._build_url(path, params, version)
|
|
131
|
+
|
|
132
|
+
cached = self.cache.get(url, ttl)
|
|
133
|
+
if cached is not None:
|
|
134
|
+
return cached
|
|
135
|
+
|
|
136
|
+
payload = self._fetch(url)
|
|
137
|
+
self.cache.set(url, payload)
|
|
138
|
+
return payload
|
|
139
|
+
|
|
140
|
+
@staticmethod
|
|
141
|
+
def _build_url(path: str, params: dict[str, Any] | None, version: str) -> str:
|
|
142
|
+
query: list[tuple] = []
|
|
143
|
+
for key, value in (params or {}).items():
|
|
144
|
+
if value is None:
|
|
145
|
+
continue
|
|
146
|
+
if isinstance(value, (list, tuple, set)):
|
|
147
|
+
value = ",".join(str(v) for v in value)
|
|
148
|
+
elif isinstance(value, bool):
|
|
149
|
+
value = "true" if value else "false"
|
|
150
|
+
query.append((key, str(value)))
|
|
151
|
+
url = f"{BASE_URL}/{version}/{path.lstrip('/')}"
|
|
152
|
+
if query:
|
|
153
|
+
url = f"{url}?{urllib.parse.urlencode(query)}"
|
|
154
|
+
return url
|
|
155
|
+
|
|
156
|
+
def _fetch(self, url: str) -> Any:
|
|
157
|
+
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
|
158
|
+
last_error: Exception | None = None
|
|
159
|
+
|
|
160
|
+
for attempt in range(self.retries + 1):
|
|
161
|
+
try:
|
|
162
|
+
with urllib.request.urlopen(
|
|
163
|
+
request, timeout=self.timeout, context=self.ssl_context
|
|
164
|
+
) as response:
|
|
165
|
+
return json.loads(response.read().decode("utf-8"))
|
|
166
|
+
except urllib.error.HTTPError as exc:
|
|
167
|
+
if exc.code < 500 or attempt == self.retries:
|
|
168
|
+
raise APIError(
|
|
169
|
+
f"MLB Stats API returned HTTP {exc.code} ({exc.reason}) for {url}"
|
|
170
|
+
) from exc
|
|
171
|
+
last_error = exc
|
|
172
|
+
except urllib.error.URLError as exc:
|
|
173
|
+
# A missing trust store fails identically on every retry, and
|
|
174
|
+
# the fix is a local one, so report it immediately.
|
|
175
|
+
if isinstance(exc.reason, ssl.SSLCertVerificationError):
|
|
176
|
+
raise APIError(CERT_HELP) from exc
|
|
177
|
+
if attempt == self.retries:
|
|
178
|
+
raise APIError(
|
|
179
|
+
f"Could not reach the MLB Stats API: {exc.reason}. "
|
|
180
|
+
"Check your network connection."
|
|
181
|
+
) from exc
|
|
182
|
+
last_error = exc
|
|
183
|
+
except json.JSONDecodeError as exc:
|
|
184
|
+
raise APIError(f"MLB Stats API returned malformed JSON for {url}") from exc
|
|
185
|
+
except TimeoutError as exc:
|
|
186
|
+
if attempt == self.retries:
|
|
187
|
+
raise APIError(
|
|
188
|
+
f"Request to the MLB Stats API timed out after {self.timeout:g}s"
|
|
189
|
+
) from exc
|
|
190
|
+
last_error = exc
|
|
191
|
+
|
|
192
|
+
# Back off before retrying: 0.5s, then 1.0s.
|
|
193
|
+
time.sleep(0.5 * (attempt + 1))
|
|
194
|
+
|
|
195
|
+
raise APIError(f"Request to the MLB Stats API failed: {last_error}")
|
|
196
|
+
|
|
197
|
+
# ------------------------------------------------------------------
|
|
198
|
+
# Endpoints
|
|
199
|
+
# ------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
def schedule(
|
|
202
|
+
self,
|
|
203
|
+
game_date: str | None = None,
|
|
204
|
+
start_date: str | None = None,
|
|
205
|
+
end_date: str | None = None,
|
|
206
|
+
team_id: int | None = None,
|
|
207
|
+
ttl: int = 60,
|
|
208
|
+
) -> dict:
|
|
209
|
+
"""Return the schedule for a date or a date range, with line scores."""
|
|
210
|
+
return self.get(
|
|
211
|
+
"schedule",
|
|
212
|
+
{
|
|
213
|
+
"sportId": SPORT_MLB,
|
|
214
|
+
"date": game_date,
|
|
215
|
+
"startDate": start_date,
|
|
216
|
+
"endDate": end_date,
|
|
217
|
+
"teamId": team_id,
|
|
218
|
+
"hydrate": "linescore,probablePitcher,team",
|
|
219
|
+
},
|
|
220
|
+
ttl=ttl,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
def teams(self, season: int | None = None) -> list[dict]:
|
|
224
|
+
"""Return all 30 MLB clubs for a season."""
|
|
225
|
+
payload = self.get(
|
|
226
|
+
"teams",
|
|
227
|
+
{"sportId": SPORT_MLB, "season": season},
|
|
228
|
+
ttl=TTL_STATIC,
|
|
229
|
+
)
|
|
230
|
+
return payload.get("teams", [])
|
|
231
|
+
|
|
232
|
+
def search_people(self, name: str) -> list[dict]:
|
|
233
|
+
"""Search players by name, active and historical."""
|
|
234
|
+
payload = self.get(
|
|
235
|
+
"people/search",
|
|
236
|
+
{"names": name, "hydrate": "currentTeam", "limit": 50},
|
|
237
|
+
ttl=TTL_STATIC,
|
|
238
|
+
)
|
|
239
|
+
return payload.get("people", [])
|
|
240
|
+
|
|
241
|
+
def person(self, player_id: int) -> dict | None:
|
|
242
|
+
"""Return one player's biographical record, or None if the id is unknown."""
|
|
243
|
+
payload = self.get(
|
|
244
|
+
f"people/{player_id}",
|
|
245
|
+
{"hydrate": "currentTeam"},
|
|
246
|
+
ttl=TTL_STATIC,
|
|
247
|
+
)
|
|
248
|
+
people = payload.get("people", [])
|
|
249
|
+
return people[0] if people else None
|
|
250
|
+
|
|
251
|
+
def player_stats(
|
|
252
|
+
self,
|
|
253
|
+
player_id: int,
|
|
254
|
+
group: str,
|
|
255
|
+
stat_type: str = "season",
|
|
256
|
+
season: int | None = None,
|
|
257
|
+
game_type: str = GAME_TYPE_REGULAR,
|
|
258
|
+
) -> list[dict]:
|
|
259
|
+
"""Return stat splits for a player.
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
group: ``hitting``, ``pitching``, or ``fielding``.
|
|
263
|
+
stat_type: ``season``, ``career``, or ``yearByYear``.
|
|
264
|
+
season: Only meaningful for ``season`` splits.
|
|
265
|
+
"""
|
|
266
|
+
payload = self.get(
|
|
267
|
+
f"people/{player_id}/stats",
|
|
268
|
+
{
|
|
269
|
+
"stats": stat_type,
|
|
270
|
+
"group": group,
|
|
271
|
+
"season": season if stat_type == "season" else None,
|
|
272
|
+
"gameType": game_type,
|
|
273
|
+
"sportId": SPORT_MLB,
|
|
274
|
+
},
|
|
275
|
+
ttl=TTL_STATS,
|
|
276
|
+
)
|
|
277
|
+
return _splits(payload)
|
|
278
|
+
|
|
279
|
+
def team_stats(
|
|
280
|
+
self,
|
|
281
|
+
team_id: int,
|
|
282
|
+
group: str,
|
|
283
|
+
season: int | None = None,
|
|
284
|
+
stat_type: str = "season",
|
|
285
|
+
) -> list[dict]:
|
|
286
|
+
"""Return team-level stat splits for a season."""
|
|
287
|
+
payload = self.get(
|
|
288
|
+
f"teams/{team_id}/stats",
|
|
289
|
+
{
|
|
290
|
+
"stats": stat_type,
|
|
291
|
+
"group": group,
|
|
292
|
+
"season": season,
|
|
293
|
+
"sportId": SPORT_MLB,
|
|
294
|
+
},
|
|
295
|
+
ttl=TTL_STATS,
|
|
296
|
+
)
|
|
297
|
+
return _splits(payload)
|
|
298
|
+
|
|
299
|
+
def standings(
|
|
300
|
+
self,
|
|
301
|
+
season: int | None = None,
|
|
302
|
+
league_ids: Iterable[int] = (LEAGUE_AL, LEAGUE_NL),
|
|
303
|
+
standings_type: str = "regularSeason",
|
|
304
|
+
) -> list[dict]:
|
|
305
|
+
"""Return division records, one entry per division."""
|
|
306
|
+
payload = self.get(
|
|
307
|
+
"standings",
|
|
308
|
+
{
|
|
309
|
+
"leagueId": list(league_ids),
|
|
310
|
+
"season": season,
|
|
311
|
+
"standingsTypes": standings_type,
|
|
312
|
+
"hydrate": "team",
|
|
313
|
+
},
|
|
314
|
+
ttl=TTL_STATS,
|
|
315
|
+
)
|
|
316
|
+
return payload.get("records", [])
|
|
317
|
+
|
|
318
|
+
def leaders(
|
|
319
|
+
self,
|
|
320
|
+
category: str,
|
|
321
|
+
season: int | None = None,
|
|
322
|
+
group: str | None = None,
|
|
323
|
+
league_id: int | None = None,
|
|
324
|
+
limit: int = 10,
|
|
325
|
+
) -> list[dict]:
|
|
326
|
+
"""Return league leader groups for a statistical category."""
|
|
327
|
+
payload = self.get(
|
|
328
|
+
"stats/leaders",
|
|
329
|
+
{
|
|
330
|
+
"leaderCategories": category,
|
|
331
|
+
"season": season,
|
|
332
|
+
"statGroup": group,
|
|
333
|
+
"leagueId": league_id,
|
|
334
|
+
"sportId": SPORT_MLB,
|
|
335
|
+
"limit": limit,
|
|
336
|
+
},
|
|
337
|
+
ttl=TTL_STATS,
|
|
338
|
+
)
|
|
339
|
+
return payload.get("leagueLeaders", [])
|
|
340
|
+
|
|
341
|
+
def leader_categories(self) -> list[str]:
|
|
342
|
+
"""Return every category name accepted by :meth:`leaders`."""
|
|
343
|
+
payload = self.get("leagueLeaderTypes", ttl=TTL_STATIC)
|
|
344
|
+
# This endpoint returns a bare list rather than the usual envelope.
|
|
345
|
+
items = payload if isinstance(payload, list) else payload.get("leagueLeaderTypes", [])
|
|
346
|
+
return [item["displayName"] for item in items if "displayName" in item]
|
|
347
|
+
|
|
348
|
+
def roster(
|
|
349
|
+
self,
|
|
350
|
+
team_id: int,
|
|
351
|
+
season: int | None = None,
|
|
352
|
+
roster_type: str = "active",
|
|
353
|
+
stat_groups: Iterable[str] = (),
|
|
354
|
+
) -> list[dict]:
|
|
355
|
+
"""Return a team's roster.
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
roster_type: ``active``, ``40Man``, ``fullSeason``, or ``depthChart``.
|
|
359
|
+
stat_groups: Stat groups to hydrate onto each player. The API does
|
|
360
|
+
this server-side, so a whole team's stat lines cost one request
|
|
361
|
+
instead of one per player. The groups must be named explicitly:
|
|
362
|
+
left to itself the API returns only the one matching each
|
|
363
|
+
player's position, so fielding never appears.
|
|
364
|
+
"""
|
|
365
|
+
hydrate = None
|
|
366
|
+
groups = list(stat_groups)
|
|
367
|
+
if groups:
|
|
368
|
+
season_arg = season or current_season()
|
|
369
|
+
hydrate = (
|
|
370
|
+
f"person(stats(type=season,season={season_arg},group=[{','.join(groups)}]))"
|
|
371
|
+
)
|
|
372
|
+
payload = self.get(
|
|
373
|
+
f"teams/{team_id}/roster",
|
|
374
|
+
{"season": season, "rosterType": roster_type, "hydrate": hydrate},
|
|
375
|
+
ttl=TTL_STATS,
|
|
376
|
+
)
|
|
377
|
+
return payload.get("roster", [])
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _splits(payload: dict) -> list[dict]:
|
|
381
|
+
"""Flatten the API's ``stats -> splits`` envelope into a list of splits.
|
|
382
|
+
|
|
383
|
+
Each split is annotated with the group and stat type it came from so that a
|
|
384
|
+
caller holding a mixed list can still tell hitting from pitching.
|
|
385
|
+
"""
|
|
386
|
+
results: list[dict] = []
|
|
387
|
+
for block in payload.get("stats", []):
|
|
388
|
+
group = block.get("group", {}).get("displayName")
|
|
389
|
+
stat_type = block.get("type", {}).get("displayName")
|
|
390
|
+
for split in block.get("splits", []):
|
|
391
|
+
split = dict(split)
|
|
392
|
+
split["_group"] = group
|
|
393
|
+
split["_type"] = stat_type
|
|
394
|
+
results.append(split)
|
|
395
|
+
return results
|
baseballcommand/cache.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""A small on-disk response cache.
|
|
2
|
+
|
|
3
|
+
The MLB Stats API is free and unauthenticated, which makes it easy to hammer by
|
|
4
|
+
accident: a single ``base standings`` run resolves 30 teams, and a shell loop can
|
|
5
|
+
issue hundreds of identical requests a minute. Caching responses on disk keeps
|
|
6
|
+
repeated runs fast and keeps us from being a bad citizen.
|
|
7
|
+
|
|
8
|
+
Entries are plain JSON files under the user cache directory, keyed by a hash of
|
|
9
|
+
the request URL. Each file records the time it was written; reads past the
|
|
10
|
+
caller-supplied TTL are treated as misses. Anything unreadable or corrupt is
|
|
11
|
+
also a miss, so a damaged cache degrades to "slower", never to "broken".
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import shutil
|
|
20
|
+
import time
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
# TTLs in seconds, named so call sites read as intent rather than magic numbers.
|
|
25
|
+
TTL_LIVE = 30 # in-progress games; short enough to feel live
|
|
26
|
+
TTL_SCHEDULE = 300 # a day's schedule once games are final
|
|
27
|
+
TTL_STATS = 3600 # season/career stat lines
|
|
28
|
+
TTL_STATIC = 86400 # team lists, leader categories, biographical data
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def default_cache_dir() -> Path:
|
|
32
|
+
"""Return the directory used for cached responses.
|
|
33
|
+
|
|
34
|
+
Honors ``BASEBALLCOMMAND_CACHE_DIR`` first, then ``XDG_CACHE_HOME``, then
|
|
35
|
+
falls back to ``~/.cache``.
|
|
36
|
+
"""
|
|
37
|
+
override = os.environ.get("BASEBALLCOMMAND_CACHE_DIR")
|
|
38
|
+
if override:
|
|
39
|
+
return Path(override).expanduser()
|
|
40
|
+
xdg = os.environ.get("XDG_CACHE_HOME")
|
|
41
|
+
base = Path(xdg).expanduser() if xdg else Path.home() / ".cache"
|
|
42
|
+
return base / "baseballcommand"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Cache:
|
|
46
|
+
"""A TTL cache for JSON API responses.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
directory: Where to store entries. Defaults to :func:`default_cache_dir`.
|
|
50
|
+
enabled: When false, every read misses and every write is dropped. This
|
|
51
|
+
is how ``--no-cache`` is implemented, so callers need no branches.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, directory: Path | None = None, enabled: bool = True):
|
|
55
|
+
self.directory = Path(directory) if directory else default_cache_dir()
|
|
56
|
+
self.enabled = enabled
|
|
57
|
+
|
|
58
|
+
def _path_for(self, key: str) -> Path:
|
|
59
|
+
digest = hashlib.sha256(key.encode("utf-8")).hexdigest()
|
|
60
|
+
# Shard by the first two hex characters so no single directory ends up
|
|
61
|
+
# with thousands of entries.
|
|
62
|
+
return self.directory / digest[:2] / f"{digest}.json"
|
|
63
|
+
|
|
64
|
+
def get(self, key: str, ttl: int) -> Any | None:
|
|
65
|
+
"""Return the cached payload for ``key``, or None if absent or stale."""
|
|
66
|
+
if not self.enabled or ttl <= 0:
|
|
67
|
+
return None
|
|
68
|
+
path = self._path_for(key)
|
|
69
|
+
try:
|
|
70
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
71
|
+
entry = json.load(handle)
|
|
72
|
+
if time.time() - entry["stored_at"] > ttl:
|
|
73
|
+
return None
|
|
74
|
+
return entry["payload"]
|
|
75
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
76
|
+
# Missing, unreadable, or corrupt: all just cache misses.
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
def set(self, key: str, payload: Any) -> None:
|
|
80
|
+
"""Store ``payload`` under ``key``. Failures are silently ignored."""
|
|
81
|
+
if not self.enabled:
|
|
82
|
+
return
|
|
83
|
+
path = self._path_for(key)
|
|
84
|
+
entry = {"stored_at": time.time(), "key": key, "payload": payload}
|
|
85
|
+
try:
|
|
86
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
# Write to a temp file in the same directory, then rename, so a
|
|
88
|
+
# concurrent reader never sees a half-written entry.
|
|
89
|
+
tmp = path.with_suffix(".tmp")
|
|
90
|
+
with tmp.open("w", encoding="utf-8") as handle:
|
|
91
|
+
json.dump(entry, handle)
|
|
92
|
+
os.replace(tmp, path)
|
|
93
|
+
except OSError:
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
def clear(self) -> int:
|
|
97
|
+
"""Delete every cached entry. Returns the number of files removed."""
|
|
98
|
+
if not self.directory.exists():
|
|
99
|
+
return 0
|
|
100
|
+
count = sum(1 for _ in self.directory.rglob("*.json"))
|
|
101
|
+
shutil.rmtree(self.directory, ignore_errors=True)
|
|
102
|
+
return count
|
|
103
|
+
|
|
104
|
+
def stats(self) -> dict:
|
|
105
|
+
"""Return ``{'entries': int, 'bytes': int, 'path': str}`` for the cache."""
|
|
106
|
+
entries = 0
|
|
107
|
+
total = 0
|
|
108
|
+
if self.directory.exists():
|
|
109
|
+
for path in self.directory.rglob("*.json"):
|
|
110
|
+
entries += 1
|
|
111
|
+
try:
|
|
112
|
+
total += path.stat().st_size
|
|
113
|
+
except OSError:
|
|
114
|
+
pass
|
|
115
|
+
return {"entries": entries, "bytes": total, "path": str(self.directory)}
|
baseballcommand/cli.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Argument parsing and dispatch for the ``base`` command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from . import __version__, commands, output
|
|
10
|
+
from .api import StatsAPI
|
|
11
|
+
from .cache import Cache
|
|
12
|
+
from .errors import BaseballCommandError, UsageError
|
|
13
|
+
|
|
14
|
+
EPILOG = """\
|
|
15
|
+
examples:
|
|
16
|
+
base scores today's games
|
|
17
|
+
base scores yesterday -l yesterday's games with line scores
|
|
18
|
+
base player "Aaron Judge" this season's line for one player
|
|
19
|
+
base player Ohtani --years every season a player has played
|
|
20
|
+
base team NYY a club's record and season totals
|
|
21
|
+
base standings --league al American League standings
|
|
22
|
+
base leaders hr -n 5 the season's top five home run hitters
|
|
23
|
+
base roster LAD --stats --sort HR a roster sorted by home runs
|
|
24
|
+
base compare Judge Ohtani two players side by side
|
|
25
|
+
|
|
26
|
+
Data comes from the free public MLB Stats API. No key or account is needed.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Context:
|
|
32
|
+
"""Everything a command handler needs beyond its own arguments."""
|
|
33
|
+
|
|
34
|
+
client: StatsAPI
|
|
35
|
+
json: bool = False
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def live_ttl(self) -> int:
|
|
39
|
+
"""Cache lifetime for schedule data.
|
|
40
|
+
|
|
41
|
+
Scores change while you watch, so they get a much shorter TTL than
|
|
42
|
+
season stats — short enough to feel live, long enough that a watch loop
|
|
43
|
+
doesn't turn into a request per second.
|
|
44
|
+
"""
|
|
45
|
+
return 30 if self.client.cache.enabled else 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
49
|
+
parser = argparse.ArgumentParser(
|
|
50
|
+
prog="base",
|
|
51
|
+
description="BaseballCommand — baseball scores and statistics in your terminal.",
|
|
52
|
+
epilog=EPILOG,
|
|
53
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
54
|
+
)
|
|
55
|
+
parser.add_argument(
|
|
56
|
+
"-V", "--version", action="version", version=f"BaseballCommand {__version__}"
|
|
57
|
+
)
|
|
58
|
+
parser.add_argument(
|
|
59
|
+
"--json", action="store_true", help="print raw JSON instead of formatted tables"
|
|
60
|
+
)
|
|
61
|
+
parser.add_argument("--no-color", action="store_true", help="disable ANSI color output")
|
|
62
|
+
parser.add_argument(
|
|
63
|
+
"--no-cache", action="store_true", help="bypass the cache and always fetch fresh data"
|
|
64
|
+
)
|
|
65
|
+
parser.add_argument(
|
|
66
|
+
"--timeout",
|
|
67
|
+
type=float,
|
|
68
|
+
default=15.0,
|
|
69
|
+
metavar="SECONDS",
|
|
70
|
+
help="network timeout per request (default: 15)",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
subparsers = parser.add_subparsers(dest="command", metavar="<command>")
|
|
74
|
+
for module in commands.MODULES:
|
|
75
|
+
module.register(subparsers)
|
|
76
|
+
return parser
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def main(argv: list[str] | None = None) -> int:
|
|
80
|
+
"""Entry point. Returns a process exit status.
|
|
81
|
+
|
|
82
|
+
Expected failures print a one-line message and return 1; unexpected ones
|
|
83
|
+
keep their traceback, because those are bugs worth reporting.
|
|
84
|
+
"""
|
|
85
|
+
parser = build_parser()
|
|
86
|
+
args = parser.parse_args(argv)
|
|
87
|
+
|
|
88
|
+
# Color is decided before anything prints, and --json always means clean,
|
|
89
|
+
# parseable output with no escape codes.
|
|
90
|
+
output.set_color(False if (args.no_color or args.json) else None)
|
|
91
|
+
|
|
92
|
+
if not args.command:
|
|
93
|
+
parser.print_help()
|
|
94
|
+
return 0
|
|
95
|
+
|
|
96
|
+
client = StatsAPI(cache=Cache(enabled=not args.no_cache), timeout=args.timeout)
|
|
97
|
+
ctx = Context(client=client, json=args.json)
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
return args.func(args, ctx) or 0
|
|
101
|
+
except UsageError as exc:
|
|
102
|
+
print(f"base: {exc}", file=sys.stderr)
|
|
103
|
+
return 2
|
|
104
|
+
except BaseballCommandError as exc:
|
|
105
|
+
print(f"base: {exc}", file=sys.stderr)
|
|
106
|
+
return 1
|
|
107
|
+
except KeyboardInterrupt:
|
|
108
|
+
print(file=sys.stderr)
|
|
109
|
+
return 130
|
|
110
|
+
except BrokenPipeError:
|
|
111
|
+
# Happens routinely under `base ... | head`. Exit quietly rather than
|
|
112
|
+
# letting Python print an ignored-exception warning at shutdown.
|
|
113
|
+
try:
|
|
114
|
+
sys.stdout.close()
|
|
115
|
+
except OSError:
|
|
116
|
+
pass
|
|
117
|
+
return 0
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
if __name__ == "__main__": # pragma: no cover
|
|
121
|
+
sys.exit(main())
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Command implementations.
|
|
2
|
+
|
|
3
|
+
Each module here registers one subcommand. A module exposes ``register(sub)``,
|
|
4
|
+
which builds its argparse parser and attaches its handler via
|
|
5
|
+
``set_defaults(func=...)``. The handler signature is ``run(args, ctx)`` where
|
|
6
|
+
``ctx`` is a :class:`~baseballcommand.cli.Context`.
|
|
7
|
+
|
|
8
|
+
Adding a command means writing a module and appending it to ``MODULES``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from . import ( # noqa: F401
|
|
12
|
+
cache,
|
|
13
|
+
compare,
|
|
14
|
+
glossary,
|
|
15
|
+
leaders,
|
|
16
|
+
player,
|
|
17
|
+
roster,
|
|
18
|
+
scores,
|
|
19
|
+
standings,
|
|
20
|
+
team,
|
|
21
|
+
teams,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
#: Registration order determines the order subcommands appear in ``base --help``.
|
|
25
|
+
MODULES = (
|
|
26
|
+
scores,
|
|
27
|
+
player,
|
|
28
|
+
team,
|
|
29
|
+
standings,
|
|
30
|
+
leaders,
|
|
31
|
+
roster,
|
|
32
|
+
compare,
|
|
33
|
+
teams,
|
|
34
|
+
glossary,
|
|
35
|
+
cache,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
__all__ = ["MODULES"]
|