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
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""``base team`` — club record and team-level statistics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .. import output
|
|
6
|
+
from .. import stats as statcols
|
|
7
|
+
from ..api import current_season
|
|
8
|
+
from ..lookup import resolve_team
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def register(subparsers) -> None:
|
|
12
|
+
parser = subparsers.add_parser(
|
|
13
|
+
"team",
|
|
14
|
+
help="show a team's record and statistics",
|
|
15
|
+
description=(
|
|
16
|
+
"Look up a team by abbreviation, city, nickname, or full name and "
|
|
17
|
+
"print its record and season stat totals."
|
|
18
|
+
),
|
|
19
|
+
)
|
|
20
|
+
parser.add_argument("name", help="team name, e.g. NYY, Toronto, 'Blue Jays'")
|
|
21
|
+
parser.add_argument(
|
|
22
|
+
"-s", "--season", type=int, help="season to show (default: current season)"
|
|
23
|
+
)
|
|
24
|
+
parser.add_argument(
|
|
25
|
+
"-g",
|
|
26
|
+
"--group",
|
|
27
|
+
choices=[*statcols.GROUPS, "all"],
|
|
28
|
+
help="stat group to show (default: hitting and pitching)",
|
|
29
|
+
)
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
"-f",
|
|
32
|
+
"--full",
|
|
33
|
+
action="store_true",
|
|
34
|
+
help="list every stat the API returns, not just the common ones",
|
|
35
|
+
)
|
|
36
|
+
parser.set_defaults(func=run)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def run(args, ctx) -> int:
|
|
40
|
+
season = args.season or current_season()
|
|
41
|
+
team = resolve_team(ctx.client, args.name, season)
|
|
42
|
+
|
|
43
|
+
groups = (
|
|
44
|
+
list(statcols.GROUPS)
|
|
45
|
+
if args.group == "all"
|
|
46
|
+
else ([args.group] if args.group else ["hitting", "pitching"])
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
collected = {
|
|
50
|
+
group: ctx.client.team_stats(team["id"], group=group, season=season)
|
|
51
|
+
for group in groups
|
|
52
|
+
}
|
|
53
|
+
record = _team_record(ctx, team["id"], season)
|
|
54
|
+
|
|
55
|
+
if ctx.json:
|
|
56
|
+
output.emit_json(
|
|
57
|
+
{
|
|
58
|
+
"team": _team_json(team),
|
|
59
|
+
"season": season,
|
|
60
|
+
"record": record,
|
|
61
|
+
"stats": collected,
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
return 0
|
|
65
|
+
|
|
66
|
+
print(output.heading(team.get("name", args.name), _team_subtitle(team, season)))
|
|
67
|
+
output.blank()
|
|
68
|
+
print(output.key_values(_team_pairs(team, record)))
|
|
69
|
+
|
|
70
|
+
for group in groups:
|
|
71
|
+
splits = collected[group]
|
|
72
|
+
output.blank()
|
|
73
|
+
print(output.colorize(f"{group.title()} — {season}", "bold"))
|
|
74
|
+
if not splits:
|
|
75
|
+
print(f" No {group} stats for {season}.")
|
|
76
|
+
continue
|
|
77
|
+
print(_render(splits[0], group, args.full))
|
|
78
|
+
|
|
79
|
+
return 0
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _render(split: dict, group: str, full: bool) -> str:
|
|
83
|
+
if full:
|
|
84
|
+
return output.table(["Stat", "Value"], statcols.full_rows(split), aligns=["l", "r"])
|
|
85
|
+
columns = statcols.columns_for(group)
|
|
86
|
+
row = statcols.split_row(split, columns)
|
|
87
|
+
aligns = ["r"] * len(columns)
|
|
88
|
+
if group == "fielding":
|
|
89
|
+
aligns[0] = "l"
|
|
90
|
+
return output.table(statcols.headers_for(columns), [row], aligns=aligns)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _team_record(ctx, team_id: int, season: int) -> dict | None:
|
|
94
|
+
"""Find a team's standings line for the season.
|
|
95
|
+
|
|
96
|
+
Returns None outside the regular season, or for a season the standings
|
|
97
|
+
endpoint has no data for yet, rather than treating that as an error.
|
|
98
|
+
"""
|
|
99
|
+
for division in ctx.client.standings(season=season):
|
|
100
|
+
for entry in division.get("teamRecords", []):
|
|
101
|
+
if (entry.get("team") or {}).get("id") == team_id:
|
|
102
|
+
league_record = entry.get("leagueRecord", {})
|
|
103
|
+
return {
|
|
104
|
+
"wins": league_record.get("wins"),
|
|
105
|
+
"losses": league_record.get("losses"),
|
|
106
|
+
"pct": league_record.get("pct"),
|
|
107
|
+
"divisionRank": entry.get("divisionRank"),
|
|
108
|
+
"gamesBack": entry.get("gamesBack"),
|
|
109
|
+
"streak": (entry.get("streak") or {}).get("streakCode"),
|
|
110
|
+
}
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _team_subtitle(team: dict, season: int) -> str:
|
|
115
|
+
parts = [
|
|
116
|
+
str(season),
|
|
117
|
+
(team.get("league") or {}).get("name", ""),
|
|
118
|
+
(team.get("division") or {}).get("name", ""),
|
|
119
|
+
]
|
|
120
|
+
return " · ".join(part for part in parts if part)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _team_pairs(team: dict, record: dict | None) -> list[tuple]:
|
|
124
|
+
pairs = [
|
|
125
|
+
("Abbreviation", team.get("abbreviation")),
|
|
126
|
+
("Venue", (team.get("venue") or {}).get("name")),
|
|
127
|
+
("First season", team.get("firstYearOfPlay")),
|
|
128
|
+
]
|
|
129
|
+
if record:
|
|
130
|
+
line = f"{record['wins']}-{record['losses']} ({record['pct']})"
|
|
131
|
+
pairs.insert(0, ("Record", line))
|
|
132
|
+
rank = record.get("divisionRank")
|
|
133
|
+
# The standings payload names divisions by id only, but the team record
|
|
134
|
+
# we already fetched carries the readable name.
|
|
135
|
+
division = (team.get("division") or {}).get("name")
|
|
136
|
+
if rank and division:
|
|
137
|
+
back = record.get("gamesBack")
|
|
138
|
+
suffix = "" if back in (None, "-") else f", {back} GB"
|
|
139
|
+
pairs.insert(1, ("Division", f"{_ordinal(rank)} in {division}{suffix}"))
|
|
140
|
+
if record.get("streak"):
|
|
141
|
+
pairs.insert(2, ("Streak", record["streak"]))
|
|
142
|
+
return pairs
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _ordinal(rank) -> str:
|
|
146
|
+
try:
|
|
147
|
+
number = int(rank)
|
|
148
|
+
except (TypeError, ValueError):
|
|
149
|
+
return str(rank)
|
|
150
|
+
if 10 <= number % 100 <= 20:
|
|
151
|
+
suffix = "th"
|
|
152
|
+
else:
|
|
153
|
+
suffix = {1: "st", 2: "nd", 3: "rd"}.get(number % 10, "th")
|
|
154
|
+
return f"{number}{suffix}"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _team_json(team: dict) -> dict:
|
|
158
|
+
return {
|
|
159
|
+
"id": team.get("id"),
|
|
160
|
+
"name": team.get("name"),
|
|
161
|
+
"abbreviation": team.get("abbreviation"),
|
|
162
|
+
"league": (team.get("league") or {}).get("name"),
|
|
163
|
+
"division": (team.get("division") or {}).get("name"),
|
|
164
|
+
"venue": (team.get("venue") or {}).get("name"),
|
|
165
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""``base teams`` — the list of clubs, so lookups are discoverable."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .. import output
|
|
6
|
+
from ..api import LEAGUE_AL, LEAGUE_NL, current_season
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def register(subparsers) -> None:
|
|
10
|
+
parser = subparsers.add_parser(
|
|
11
|
+
"teams",
|
|
12
|
+
help="list all MLB teams and their abbreviations",
|
|
13
|
+
description=(
|
|
14
|
+
"List every MLB club with the abbreviation, city, and division. "
|
|
15
|
+
"Any of these can be passed to commands that take a team."
|
|
16
|
+
),
|
|
17
|
+
)
|
|
18
|
+
parser.add_argument(
|
|
19
|
+
"-s", "--season", type=int, help="season to list (default: current season)"
|
|
20
|
+
)
|
|
21
|
+
parser.add_argument(
|
|
22
|
+
"-l", "--league", choices=["al", "nl", "both"], default="both", help="restrict to a league"
|
|
23
|
+
)
|
|
24
|
+
parser.set_defaults(func=run)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def run(args, ctx) -> int:
|
|
28
|
+
season = args.season or current_season()
|
|
29
|
+
teams = ctx.client.teams(season)
|
|
30
|
+
|
|
31
|
+
wanted = {"al": LEAGUE_AL, "nl": LEAGUE_NL}.get(args.league)
|
|
32
|
+
if wanted:
|
|
33
|
+
teams = [team for team in teams if (team.get("league") or {}).get("id") == wanted]
|
|
34
|
+
|
|
35
|
+
teams.sort(
|
|
36
|
+
key=lambda team: (
|
|
37
|
+
(team.get("league") or {}).get("name", ""),
|
|
38
|
+
(team.get("division") or {}).get("name", ""),
|
|
39
|
+
team.get("name", ""),
|
|
40
|
+
)
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
if ctx.json:
|
|
44
|
+
output.emit_json(
|
|
45
|
+
{
|
|
46
|
+
"season": season,
|
|
47
|
+
"teams": [
|
|
48
|
+
{
|
|
49
|
+
"id": team.get("id"),
|
|
50
|
+
"name": team.get("name"),
|
|
51
|
+
"abbreviation": team.get("abbreviation"),
|
|
52
|
+
"clubName": team.get("clubName"),
|
|
53
|
+
"locationName": team.get("locationName"),
|
|
54
|
+
"league": (team.get("league") or {}).get("name"),
|
|
55
|
+
"division": (team.get("division") or {}).get("name"),
|
|
56
|
+
"venue": (team.get("venue") or {}).get("name"),
|
|
57
|
+
}
|
|
58
|
+
for team in teams
|
|
59
|
+
],
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
return 0
|
|
63
|
+
|
|
64
|
+
print(output.heading(f"MLB teams — {season}", f"{len(teams)} clubs"))
|
|
65
|
+
output.blank()
|
|
66
|
+
rows = [
|
|
67
|
+
[
|
|
68
|
+
team.get("abbreviation", ""),
|
|
69
|
+
team.get("name", ""),
|
|
70
|
+
(team.get("division") or {}).get("name", ""),
|
|
71
|
+
(team.get("venue") or {}).get("name", ""),
|
|
72
|
+
team.get("id"),
|
|
73
|
+
]
|
|
74
|
+
for team in teams
|
|
75
|
+
]
|
|
76
|
+
print(
|
|
77
|
+
output.table(
|
|
78
|
+
["Abbr", "Team", "Division", "Venue", "Id"],
|
|
79
|
+
rows,
|
|
80
|
+
aligns=["l", "l", "l", "l", "r"],
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
return 0
|
baseballcommand/dates.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Date parsing and time formatting helpers.
|
|
2
|
+
|
|
3
|
+
Users type ``yesterday`` far more often than ``2026-08-13``, so anything taking
|
|
4
|
+
a date accepts both. Game times come back from the API in UTC; they are shown in
|
|
5
|
+
the machine's local timezone, which is what a person checking tonight's slate
|
|
6
|
+
actually wants.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from datetime import date, datetime, timedelta, timezone
|
|
13
|
+
|
|
14
|
+
from .errors import UsageError
|
|
15
|
+
|
|
16
|
+
ISO_FORMAT = "%Y-%m-%d"
|
|
17
|
+
|
|
18
|
+
_RELATIVE = {
|
|
19
|
+
"today": 0,
|
|
20
|
+
"yesterday": -1,
|
|
21
|
+
"tomorrow": 1,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def parse_date(value: str | None, today: date | None = None) -> date:
|
|
26
|
+
"""Parse a user-supplied date.
|
|
27
|
+
|
|
28
|
+
Accepts ``YYYY-MM-DD``, ``MM/DD`` and ``MM-DD`` (current year), the words
|
|
29
|
+
``today``/``yesterday``/``tomorrow``, and signed day offsets like ``-3`` or
|
|
30
|
+
``+2``. ``None`` or an empty string means today.
|
|
31
|
+
|
|
32
|
+
Raises:
|
|
33
|
+
UsageError: If the string isn't a date in any accepted form.
|
|
34
|
+
"""
|
|
35
|
+
today = today or date.today()
|
|
36
|
+
if not value:
|
|
37
|
+
return today
|
|
38
|
+
|
|
39
|
+
text = value.strip().lower()
|
|
40
|
+
|
|
41
|
+
if text in _RELATIVE:
|
|
42
|
+
return today + timedelta(days=_RELATIVE[text])
|
|
43
|
+
|
|
44
|
+
if re.fullmatch(r"[+-]\d+", text):
|
|
45
|
+
return today + timedelta(days=int(text))
|
|
46
|
+
|
|
47
|
+
for fmt in (ISO_FORMAT, "%Y/%m/%d", "%m/%d/%Y", "%m-%d-%Y"):
|
|
48
|
+
try:
|
|
49
|
+
return datetime.strptime(text, fmt).date()
|
|
50
|
+
except ValueError:
|
|
51
|
+
continue
|
|
52
|
+
|
|
53
|
+
# Month/day without a year: assume the current year.
|
|
54
|
+
match = re.fullmatch(r"(\d{1,2})[/-](\d{1,2})", text)
|
|
55
|
+
if match:
|
|
56
|
+
month, day = int(match.group(1)), int(match.group(2))
|
|
57
|
+
try:
|
|
58
|
+
return date(today.year, month, day)
|
|
59
|
+
except ValueError as exc:
|
|
60
|
+
raise UsageError(f"{value!r} is not a real date.") from exc
|
|
61
|
+
|
|
62
|
+
raise UsageError(
|
|
63
|
+
f"Could not read {value!r} as a date. Use YYYY-MM-DD, MM/DD, "
|
|
64
|
+
"today/yesterday/tomorrow, or an offset like -3."
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def iso(value: date) -> str:
|
|
69
|
+
"""Format a date the way the API expects it."""
|
|
70
|
+
return value.strftime(ISO_FORMAT)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def human_date(value: date) -> str:
|
|
74
|
+
"""Format a date for a heading, e.g. ``Thursday, August 14, 2026``."""
|
|
75
|
+
# %-d is not portable to every libc, so strip the zero by hand.
|
|
76
|
+
return value.strftime("%A, %B ") + str(value.day) + value.strftime(", %Y")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def local_time(iso_timestamp: str | None) -> str | None:
|
|
80
|
+
"""Convert a UTC ISO-8601 timestamp to a local clock time like ``7:10 PM``.
|
|
81
|
+
|
|
82
|
+
Returns None if the timestamp is missing or unparseable, so callers can fall
|
|
83
|
+
back to whatever text the API gave them.
|
|
84
|
+
"""
|
|
85
|
+
if not iso_timestamp:
|
|
86
|
+
return None
|
|
87
|
+
text = iso_timestamp.replace("Z", "+00:00")
|
|
88
|
+
try:
|
|
89
|
+
moment = datetime.fromisoformat(text)
|
|
90
|
+
except ValueError:
|
|
91
|
+
return None
|
|
92
|
+
if moment.tzinfo is None:
|
|
93
|
+
moment = moment.replace(tzinfo=timezone.utc)
|
|
94
|
+
local = moment.astimezone()
|
|
95
|
+
hour = local.hour % 12 or 12
|
|
96
|
+
return f"{hour}:{local.minute:02d} {'AM' if local.hour < 12 else 'PM'}"
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Exceptions raised by BaseballCommand.
|
|
2
|
+
|
|
3
|
+
Every error that is expected to reach the user inherits from
|
|
4
|
+
:class:`BaseballCommandError`. ``cli.main`` catches that base class and prints
|
|
5
|
+
``str(exc)`` without a traceback, so error messages should be written for a
|
|
6
|
+
human reading a terminal.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BaseballCommandError(Exception):
|
|
11
|
+
"""Base class for all errors that should be reported to the user."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class APIError(BaseballCommandError):
|
|
15
|
+
"""The MLB Stats API returned an error or could not be reached."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class NotFoundError(BaseballCommandError):
|
|
19
|
+
"""A player, team, or other entity could not be found."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AmbiguousError(BaseballCommandError):
|
|
23
|
+
"""A lookup matched more than one entity.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
message: The headline shown to the user.
|
|
27
|
+
candidates: Pre-formatted lines describing each match.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, message: str, candidates=()):
|
|
31
|
+
self.candidates = list(candidates)
|
|
32
|
+
if self.candidates:
|
|
33
|
+
listing = "\n".join(" " + c for c in self.candidates)
|
|
34
|
+
message = f"{message}\n{listing}"
|
|
35
|
+
super().__init__(message)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class UsageError(BaseballCommandError):
|
|
39
|
+
"""The command was invoked with arguments that cannot be satisfied."""
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Turning what a person types into the ids the API wants.
|
|
2
|
+
|
|
3
|
+
Users type ``nyy``, ``yanks``, or ``New York Yankees``; they type ``judge`` or
|
|
4
|
+
``Aaron Judge``. The API wants ``147`` and ``592450``. This module is the
|
|
5
|
+
translation layer, and it is deliberately forgiving: exact matches win, then
|
|
6
|
+
well-known nicknames, then unique substring matches. When a query genuinely
|
|
7
|
+
matches several entities we raise :class:`~.errors.AmbiguousError` with the
|
|
8
|
+
candidates rather than guessing, because silently picking the wrong Will Smith
|
|
9
|
+
is worse than asking.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
|
|
16
|
+
from .api import StatsAPI
|
|
17
|
+
from .errors import AmbiguousError, NotFoundError
|
|
18
|
+
|
|
19
|
+
#: Nicknames and shorthands that no amount of substring matching would catch.
|
|
20
|
+
#: Keys are normalized (lowercase, alphanumeric only); values are the club name
|
|
21
|
+
#: as the API spells it.
|
|
22
|
+
TEAM_ALIASES: dict[str, str] = {
|
|
23
|
+
"dbacks": "Diamondbacks",
|
|
24
|
+
"snakes": "Diamondbacks",
|
|
25
|
+
"ari": "Diamondbacks",
|
|
26
|
+
"az": "Diamondbacks",
|
|
27
|
+
"bravos": "Braves",
|
|
28
|
+
"os": "Orioles",
|
|
29
|
+
"birds": "Orioles",
|
|
30
|
+
"bosox": "Red Sox",
|
|
31
|
+
"redsox": "Red Sox",
|
|
32
|
+
"chisox": "White Sox",
|
|
33
|
+
"whitesox": "White Sox",
|
|
34
|
+
"cws": "White Sox",
|
|
35
|
+
"chw": "White Sox",
|
|
36
|
+
"northsiders": "Cubs",
|
|
37
|
+
"tribe": "Guardians",
|
|
38
|
+
"guards": "Guardians",
|
|
39
|
+
"rox": "Rockies",
|
|
40
|
+
"tigs": "Tigers",
|
|
41
|
+
"stros": "Astros",
|
|
42
|
+
"halos": "Angels",
|
|
43
|
+
"laa": "Angels",
|
|
44
|
+
"lad": "Dodgers",
|
|
45
|
+
"dodgers": "Dodgers",
|
|
46
|
+
"fish": "Marlins",
|
|
47
|
+
"brew": "Brewers",
|
|
48
|
+
"brewcrew": "Brewers",
|
|
49
|
+
"twinkies": "Twins",
|
|
50
|
+
"yanks": "Yankees",
|
|
51
|
+
"nyy": "Yankees",
|
|
52
|
+
"nym": "Mets",
|
|
53
|
+
"as": "Athletics",
|
|
54
|
+
"athletics": "Athletics",
|
|
55
|
+
"oak": "Athletics",
|
|
56
|
+
"phils": "Phillies",
|
|
57
|
+
"bucs": "Pirates",
|
|
58
|
+
"buccos": "Pirates",
|
|
59
|
+
"pads": "Padres",
|
|
60
|
+
"sd": "Padres",
|
|
61
|
+
"friars": "Padres",
|
|
62
|
+
"giants": "Giants",
|
|
63
|
+
"sf": "Giants",
|
|
64
|
+
"ms": "Mariners",
|
|
65
|
+
"cards": "Cardinals",
|
|
66
|
+
"rays": "Rays",
|
|
67
|
+
"tb": "Rays",
|
|
68
|
+
"jays": "Blue Jays",
|
|
69
|
+
"bluejays": "Blue Jays",
|
|
70
|
+
"nats": "Nationals",
|
|
71
|
+
"wsh": "Nationals",
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def normalize(text: str) -> str:
|
|
76
|
+
"""Lowercase ``text`` and strip everything that isn't a letter or digit."""
|
|
77
|
+
return re.sub(r"[^a-z0-9]", "", text.lower())
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def resolve_team(client: StatsAPI, query: str, season: int | None = None) -> dict:
|
|
81
|
+
"""Return the team record matching ``query``.
|
|
82
|
+
|
|
83
|
+
Accepts a numeric team id, an abbreviation (``NYY``), a nickname (``yanks``),
|
|
84
|
+
a club name (``Yankees``), a city (``Toronto``), or a full name.
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
NotFoundError: If nothing matches.
|
|
88
|
+
AmbiguousError: If several teams match equally well.
|
|
89
|
+
"""
|
|
90
|
+
teams = client.teams(season)
|
|
91
|
+
if not teams:
|
|
92
|
+
raise NotFoundError("Could not load the team list from the MLB Stats API.")
|
|
93
|
+
|
|
94
|
+
raw = query.strip()
|
|
95
|
+
key = normalize(raw)
|
|
96
|
+
if not key:
|
|
97
|
+
raise NotFoundError("No team name given.")
|
|
98
|
+
|
|
99
|
+
if raw.isdigit():
|
|
100
|
+
for team in teams:
|
|
101
|
+
if team.get("id") == int(raw):
|
|
102
|
+
return team
|
|
103
|
+
raise NotFoundError(f"No MLB team has id {raw}.")
|
|
104
|
+
|
|
105
|
+
# An alias rewrites the query to a club name, then falls through to the
|
|
106
|
+
# regular exact-match pass below.
|
|
107
|
+
key = normalize(TEAM_ALIASES.get(key, key))
|
|
108
|
+
|
|
109
|
+
exact_fields = ("abbreviation", "teamName", "clubName", "shortName", "name", "locationName")
|
|
110
|
+
for field in exact_fields:
|
|
111
|
+
matches = [team for team in teams if normalize(str(team.get(field, ""))) == key]
|
|
112
|
+
if len(matches) == 1:
|
|
113
|
+
return matches[0]
|
|
114
|
+
if len(matches) > 1:
|
|
115
|
+
raise AmbiguousError(
|
|
116
|
+
f"{query!r} matches more than one team:", _team_candidates(matches)
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# Last resort: unique substring match anywhere in the full name.
|
|
120
|
+
matches = [team for team in teams if key in normalize(str(team.get("name", "")))]
|
|
121
|
+
if len(matches) == 1:
|
|
122
|
+
return matches[0]
|
|
123
|
+
if len(matches) > 1:
|
|
124
|
+
raise AmbiguousError(f"{query!r} matches more than one team:", _team_candidates(matches))
|
|
125
|
+
|
|
126
|
+
raise NotFoundError(
|
|
127
|
+
f"No MLB team matches {query!r}. Try an abbreviation (NYY), a city "
|
|
128
|
+
"(Toronto), or a club name (Blue Jays); `base teams` lists them all."
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _team_candidates(teams: list[dict]) -> list[str]:
|
|
133
|
+
return [f"{team.get('abbreviation', '?'):>4} {team.get('name', '?')}" for team in teams]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def resolve_player(client: StatsAPI, query: str, prefer_active: bool = True) -> dict:
|
|
137
|
+
"""Return the player record matching ``query``.
|
|
138
|
+
|
|
139
|
+
Accepts an MLBAM player id or any part of a name. Historical players are
|
|
140
|
+
searchable too — ``base player "ken griffey jr"`` works.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
prefer_active: When a name matches several players and exactly one of
|
|
144
|
+
them is active, take the active one. Turn this off to force the
|
|
145
|
+
ambiguity to surface.
|
|
146
|
+
|
|
147
|
+
Raises:
|
|
148
|
+
NotFoundError: If nothing matches.
|
|
149
|
+
AmbiguousError: If the name matches several players.
|
|
150
|
+
"""
|
|
151
|
+
raw = query.strip()
|
|
152
|
+
if not raw:
|
|
153
|
+
raise NotFoundError("No player name given.")
|
|
154
|
+
|
|
155
|
+
if raw.isdigit():
|
|
156
|
+
person = client.person(int(raw))
|
|
157
|
+
if person is None:
|
|
158
|
+
raise NotFoundError(f"No player has MLBAM id {raw}.")
|
|
159
|
+
return person
|
|
160
|
+
|
|
161
|
+
people = client.search_people(raw)
|
|
162
|
+
if not people:
|
|
163
|
+
raise NotFoundError(
|
|
164
|
+
f"No player matches {query!r}. Check the spelling, or pass an "
|
|
165
|
+
"MLBAM id directly."
|
|
166
|
+
)
|
|
167
|
+
if len(people) == 1:
|
|
168
|
+
return people[0]
|
|
169
|
+
|
|
170
|
+
# An exact full-name match beats every partial one: searching "Will Smith"
|
|
171
|
+
# should not be ambiguous just because "Will Smithson" also exists.
|
|
172
|
+
key = normalize(raw)
|
|
173
|
+
exact = [p for p in people if normalize(p.get("fullName", "")) == key]
|
|
174
|
+
if len(exact) == 1:
|
|
175
|
+
return exact[0]
|
|
176
|
+
if exact:
|
|
177
|
+
people = exact
|
|
178
|
+
|
|
179
|
+
if prefer_active:
|
|
180
|
+
active = [p for p in people if p.get("active")]
|
|
181
|
+
if len(active) == 1:
|
|
182
|
+
return active[0]
|
|
183
|
+
|
|
184
|
+
raise AmbiguousError(
|
|
185
|
+
f"{query!r} matches {len(people)} players — rerun with a fuller name or an id:",
|
|
186
|
+
_player_candidates(people),
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _player_candidates(people: list[dict], limit: int = 12) -> list[str]:
|
|
191
|
+
lines = []
|
|
192
|
+
for person in people[:limit]:
|
|
193
|
+
team = (person.get("currentTeam") or {}).get("name", "")
|
|
194
|
+
position = (person.get("primaryPosition") or {}).get("abbreviation", "")
|
|
195
|
+
status = "" if person.get("active") else " (retired)"
|
|
196
|
+
detail = " — ".join(part for part in (position, team) if part)
|
|
197
|
+
lines.append(f"{person.get('id')} {person.get('fullName')}{status}"
|
|
198
|
+
+ (f" [{detail}]" if detail else ""))
|
|
199
|
+
if len(people) > limit:
|
|
200
|
+
lines.append(f"... and {len(people) - limit} more")
|
|
201
|
+
return lines
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def player_display_team(person: dict) -> str | None:
|
|
205
|
+
"""Return the club a player record is associated with, if any."""
|
|
206
|
+
team = person.get("currentTeam") or {}
|
|
207
|
+
return team.get("name")
|