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,225 @@
|
|
|
1
|
+
"""``base roster`` — a club's roster, optionally with everyone's stat line."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .. import output
|
|
8
|
+
from .. import stats as statcols
|
|
9
|
+
from ..api import current_season
|
|
10
|
+
from ..errors import UsageError
|
|
11
|
+
from ..lookup import resolve_team
|
|
12
|
+
|
|
13
|
+
#: Friendly names for the API's ``rosterType`` values.
|
|
14
|
+
ROSTER_TYPES = {
|
|
15
|
+
"active": "active",
|
|
16
|
+
"40man": "40Man",
|
|
17
|
+
"full": "fullSeason",
|
|
18
|
+
"depth": "depthChart",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
#: Columns where a smaller number is better, so ``--sort`` orders them ascending.
|
|
22
|
+
LOWER_IS_BETTER = {"ERA", "WHIP", "BB/9", "L", "E", "CS"}
|
|
23
|
+
|
|
24
|
+
#: Default sort per group: the stat that best approximates "how much did this
|
|
25
|
+
#: player actually play".
|
|
26
|
+
DEFAULT_SORT = {"hitting": "PA", "pitching": "IP", "fielding": "INN"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def register(subparsers) -> None:
|
|
30
|
+
parser = subparsers.add_parser(
|
|
31
|
+
"roster",
|
|
32
|
+
help="list a team's roster, with stats if asked",
|
|
33
|
+
description=(
|
|
34
|
+
"List a team's roster. With --stats, every player's season line is "
|
|
35
|
+
"included; the API returns them all in a single request, so this "
|
|
36
|
+
"costs no more than the plain listing."
|
|
37
|
+
),
|
|
38
|
+
)
|
|
39
|
+
parser.add_argument("name", help="team name, e.g. NYY, Toronto, 'Blue Jays'")
|
|
40
|
+
parser.add_argument(
|
|
41
|
+
"-s", "--season", type=int, help="season to show (default: current season)"
|
|
42
|
+
)
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"-t",
|
|
45
|
+
"--type",
|
|
46
|
+
choices=sorted(ROSTER_TYPES),
|
|
47
|
+
default="active",
|
|
48
|
+
help="which roster to show (default: active)",
|
|
49
|
+
)
|
|
50
|
+
parser.add_argument(
|
|
51
|
+
"--stats", action="store_true", help="include each player's season stat line"
|
|
52
|
+
)
|
|
53
|
+
parser.add_argument(
|
|
54
|
+
"-g",
|
|
55
|
+
"--group",
|
|
56
|
+
choices=["hitting", "pitching", "fielding"],
|
|
57
|
+
help="with --stats, show only this group (default: hitting and pitching)",
|
|
58
|
+
)
|
|
59
|
+
parser.add_argument(
|
|
60
|
+
"--sort",
|
|
61
|
+
help="with --stats, sort by a column heading such as HR, OPS, or ERA",
|
|
62
|
+
)
|
|
63
|
+
parser.set_defaults(func=run)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def run(args, ctx) -> int:
|
|
67
|
+
season = args.season or current_season()
|
|
68
|
+
team = resolve_team(ctx.client, args.name, season)
|
|
69
|
+
|
|
70
|
+
groups = [args.group] if args.group else ["hitting", "pitching"]
|
|
71
|
+
if ctx.json:
|
|
72
|
+
# The JSON shape carries all three groups regardless of --group, so
|
|
73
|
+
# scripts get a consistent structure.
|
|
74
|
+
groups = list(statcols.GROUPS)
|
|
75
|
+
|
|
76
|
+
entries = ctx.client.roster(
|
|
77
|
+
team["id"],
|
|
78
|
+
season=season,
|
|
79
|
+
roster_type=ROSTER_TYPES[args.type],
|
|
80
|
+
stat_groups=groups if args.stats else (),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
if ctx.json:
|
|
84
|
+
output.emit_json(
|
|
85
|
+
{
|
|
86
|
+
"team": {"id": team["id"], "name": team.get("name")},
|
|
87
|
+
"season": season,
|
|
88
|
+
"rosterType": ROSTER_TYPES[args.type],
|
|
89
|
+
"players": [_player_json(entry, args.stats) for entry in entries],
|
|
90
|
+
}
|
|
91
|
+
)
|
|
92
|
+
return 0
|
|
93
|
+
|
|
94
|
+
# Build every table before printing anything, so a bad --sort column fails
|
|
95
|
+
# cleanly rather than half-way through the output.
|
|
96
|
+
tables = (
|
|
97
|
+
{group: _stat_table(entries, group, args.sort) for group in groups}
|
|
98
|
+
if args.stats and entries
|
|
99
|
+
else {}
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
subtitle = f"{season} · {args.type} roster · {len(entries)} players"
|
|
103
|
+
print(output.heading(team.get("name", args.name), subtitle))
|
|
104
|
+
|
|
105
|
+
if not entries:
|
|
106
|
+
output.blank()
|
|
107
|
+
print(" No players on this roster.")
|
|
108
|
+
return 0
|
|
109
|
+
|
|
110
|
+
if not args.stats:
|
|
111
|
+
output.blank()
|
|
112
|
+
rows = [
|
|
113
|
+
[
|
|
114
|
+
entry.get("jerseyNumber") or "",
|
|
115
|
+
(entry.get("person") or {}).get("fullName", "?"),
|
|
116
|
+
(entry.get("position") or {}).get("abbreviation", ""),
|
|
117
|
+
(entry.get("status") or {}).get("description", ""),
|
|
118
|
+
]
|
|
119
|
+
for entry in entries
|
|
120
|
+
]
|
|
121
|
+
rows.sort(key=lambda row: row[1])
|
|
122
|
+
print(output.table(["#", "Player", "Pos", "Status"], rows, aligns=["r", "l", "l", "l"]))
|
|
123
|
+
return 0
|
|
124
|
+
|
|
125
|
+
for group in groups:
|
|
126
|
+
output.blank()
|
|
127
|
+
print(output.colorize(f"{group.title()} — {season}", "bold"))
|
|
128
|
+
print(tables[group] or f" No {group} stats on this roster for {season}.")
|
|
129
|
+
return 0
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _stat_table(entries: list[dict], group: str, sort_by: str | None) -> str:
|
|
133
|
+
columns = [("Player", "_player"), ("Pos", "_pos")] + list(statcols.columns_for(group))
|
|
134
|
+
if group == "fielding":
|
|
135
|
+
# The synthetic POS column from the fielding split duplicates the roster
|
|
136
|
+
# position we already show.
|
|
137
|
+
columns = [column for column in columns if column[1] != "_position"]
|
|
138
|
+
|
|
139
|
+
rows: list[list[Any]] = []
|
|
140
|
+
for entry in entries:
|
|
141
|
+
split = _season_split(entry, group)
|
|
142
|
+
if split is None:
|
|
143
|
+
continue
|
|
144
|
+
row = statcols.split_row(split, columns)
|
|
145
|
+
row[0] = (entry.get("person") or {}).get("fullName", "?")
|
|
146
|
+
row[1] = (entry.get("position") or {}).get("abbreviation", "")
|
|
147
|
+
rows.append(row)
|
|
148
|
+
|
|
149
|
+
if not rows:
|
|
150
|
+
return ""
|
|
151
|
+
|
|
152
|
+
headers = statcols.headers_for(columns)
|
|
153
|
+
rows = _sort_rows(rows, headers, sort_by or DEFAULT_SORT.get(group, ""))
|
|
154
|
+
return output.table(headers, rows, aligns=["l", "l"] + ["r"] * (len(headers) - 2))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _season_split(entry: dict, group: str) -> dict | None:
|
|
158
|
+
"""Pull one season split for ``group`` out of a hydrated roster entry.
|
|
159
|
+
|
|
160
|
+
A player traded mid-season has one split per club. The roster we are
|
|
161
|
+
printing belongs to a specific team, so prefer that team's split and fall
|
|
162
|
+
back to the first one otherwise.
|
|
163
|
+
"""
|
|
164
|
+
person = entry.get("person") or {}
|
|
165
|
+
team_id = (entry.get("parentTeamId") or person.get("currentTeam", {}).get("id"))
|
|
166
|
+
for block in person.get("stats", []) or []:
|
|
167
|
+
if (block.get("group") or {}).get("displayName") != group:
|
|
168
|
+
continue
|
|
169
|
+
splits = block.get("splits") or []
|
|
170
|
+
if not splits:
|
|
171
|
+
continue
|
|
172
|
+
for split in splits:
|
|
173
|
+
if (split.get("team") or {}).get("id") == team_id:
|
|
174
|
+
return split
|
|
175
|
+
return splits[0]
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _sort_rows(rows: list[list[Any]], headers: list[str], sort_by: str) -> list[list[Any]]:
|
|
180
|
+
if not sort_by:
|
|
181
|
+
return sorted(rows, key=lambda row: str(row[0]))
|
|
182
|
+
|
|
183
|
+
lookup = {header.upper(): index for index, header in enumerate(headers)}
|
|
184
|
+
index = lookup.get(sort_by.upper())
|
|
185
|
+
if index is None:
|
|
186
|
+
raise UsageError(
|
|
187
|
+
f"Cannot sort by {sort_by!r}. Available columns: {', '.join(headers[2:])}."
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
ascending = headers[index].upper() in LOWER_IS_BETTER
|
|
191
|
+
return sorted(rows, key=lambda row: _sort_key(row[index], ascending), reverse=not ascending)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _sort_key(value: Any, ascending: bool) -> float:
|
|
195
|
+
"""Coerce a stat cell to a float for sorting.
|
|
196
|
+
|
|
197
|
+
Missing stats sort to the bottom either way, which is why the sentinel
|
|
198
|
+
flips with the sort direction.
|
|
199
|
+
"""
|
|
200
|
+
missing = float("inf") if ascending else float("-inf")
|
|
201
|
+
if value is None or value == "":
|
|
202
|
+
return missing
|
|
203
|
+
try:
|
|
204
|
+
# float() already handles the API's leading-dot rates (".331") and its
|
|
205
|
+
# innings notation ("112.2"), which orders correctly as a decimal.
|
|
206
|
+
return float(value)
|
|
207
|
+
except (TypeError, ValueError):
|
|
208
|
+
return missing
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _player_json(entry: dict, with_stats: bool) -> dict[str, Any]:
|
|
212
|
+
person = entry.get("person") or {}
|
|
213
|
+
record = {
|
|
214
|
+
"id": person.get("id"),
|
|
215
|
+
"name": person.get("fullName"),
|
|
216
|
+
"number": entry.get("jerseyNumber"),
|
|
217
|
+
"position": (entry.get("position") or {}).get("abbreviation"),
|
|
218
|
+
"status": (entry.get("status") or {}).get("description"),
|
|
219
|
+
}
|
|
220
|
+
if with_stats:
|
|
221
|
+
record["stats"] = {
|
|
222
|
+
group: (_season_split(entry, group) or {}).get("stat")
|
|
223
|
+
for group in ("hitting", "pitching", "fielding")
|
|
224
|
+
}
|
|
225
|
+
return record
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""``base scores`` — the day's games, with line scores on request."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .. import output
|
|
6
|
+
from ..dates import human_date, iso, local_time, parse_date
|
|
7
|
+
from ..lookup import resolve_team
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def register(subparsers) -> None:
|
|
11
|
+
parser = subparsers.add_parser(
|
|
12
|
+
"scores",
|
|
13
|
+
help="show scores for a day's games",
|
|
14
|
+
description="Show the scores and status of every MLB game on a given day.",
|
|
15
|
+
)
|
|
16
|
+
parser.add_argument(
|
|
17
|
+
"date",
|
|
18
|
+
nargs="?",
|
|
19
|
+
help="date to show: YYYY-MM-DD, MM/DD, today/yesterday/tomorrow, "
|
|
20
|
+
"or an offset like -3 (default: today)",
|
|
21
|
+
)
|
|
22
|
+
parser.add_argument("-t", "--team", help="only show games involving this team")
|
|
23
|
+
parser.add_argument(
|
|
24
|
+
"-l",
|
|
25
|
+
"--linescore",
|
|
26
|
+
action="store_true",
|
|
27
|
+
help="also print the inning-by-inning line score for each game",
|
|
28
|
+
)
|
|
29
|
+
parser.set_defaults(func=run)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def run(args, ctx) -> int:
|
|
33
|
+
day = parse_date(args.date)
|
|
34
|
+
team_id: int | None = None
|
|
35
|
+
team_label = None
|
|
36
|
+
if args.team:
|
|
37
|
+
team = resolve_team(ctx.client, args.team)
|
|
38
|
+
team_id = team["id"]
|
|
39
|
+
team_label = team.get("name")
|
|
40
|
+
|
|
41
|
+
payload = ctx.client.schedule(game_date=iso(day), team_id=team_id, ttl=ctx.live_ttl)
|
|
42
|
+
games = _games_for(payload)
|
|
43
|
+
|
|
44
|
+
if ctx.json:
|
|
45
|
+
output.emit_json({"date": iso(day), "games": [_game_json(g) for g in games]})
|
|
46
|
+
return 0
|
|
47
|
+
|
|
48
|
+
subtitle = f"{len(games)} game{'s' if len(games) != 1 else ''}"
|
|
49
|
+
if team_label:
|
|
50
|
+
subtitle += f" — {team_label}"
|
|
51
|
+
print(output.heading(f"Scores — {human_date(day)}", subtitle))
|
|
52
|
+
output.blank()
|
|
53
|
+
|
|
54
|
+
if not games:
|
|
55
|
+
print(" No games scheduled.")
|
|
56
|
+
return 0
|
|
57
|
+
|
|
58
|
+
rows = [_score_row(game) for game in games]
|
|
59
|
+
print(
|
|
60
|
+
output.table(
|
|
61
|
+
["Status", "Away", "R", "Home", "R", "Notes"],
|
|
62
|
+
rows,
|
|
63
|
+
aligns=["l", "l", "r", "l", "r", "l"],
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
if args.linescore:
|
|
68
|
+
for game in games:
|
|
69
|
+
block = _linescore_block(game)
|
|
70
|
+
if block:
|
|
71
|
+
output.blank()
|
|
72
|
+
print(block)
|
|
73
|
+
|
|
74
|
+
return 0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _games_for(payload: dict) -> list[dict]:
|
|
78
|
+
games: list[dict] = []
|
|
79
|
+
for entry in payload.get("dates", []):
|
|
80
|
+
games.extend(entry.get("games", []))
|
|
81
|
+
return games
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _team_name(side: dict) -> str:
|
|
85
|
+
team = side.get("team", {}) or {}
|
|
86
|
+
return team.get("name") or team.get("teamName") or "?"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _status_text(game: dict) -> str:
|
|
90
|
+
status = game.get("status", {}) or {}
|
|
91
|
+
abstract = status.get("abstractGameState")
|
|
92
|
+
detailed = status.get("detailedState", abstract or "?")
|
|
93
|
+
linescore = game.get("linescore", {}) or {}
|
|
94
|
+
|
|
95
|
+
if abstract == "Live":
|
|
96
|
+
half = (linescore.get("inningState") or "").replace("Middle", "Mid")[:3]
|
|
97
|
+
inning = linescore.get("currentInningOrdinal") or ""
|
|
98
|
+
text = f"{half} {inning}".strip() or detailed
|
|
99
|
+
return output.colorize(text, "green", "bold")
|
|
100
|
+
|
|
101
|
+
if abstract == "Final":
|
|
102
|
+
innings = linescore.get("currentInning")
|
|
103
|
+
scheduled = linescore.get("scheduledInnings") or 9
|
|
104
|
+
# Extra-inning games get the familiar "F/11" shorthand.
|
|
105
|
+
if detailed in ("Final", "Game Over") and innings and innings != scheduled:
|
|
106
|
+
return f"F/{innings}"
|
|
107
|
+
return output.colorize(detailed, "dim")
|
|
108
|
+
|
|
109
|
+
if detailed in ("Scheduled", "Pre-Game", "Warmup", "Delayed Start"):
|
|
110
|
+
start = local_time(game.get("gameDate"))
|
|
111
|
+
return start or detailed
|
|
112
|
+
return output.colorize(detailed, "yellow")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _score_row(game: dict) -> list:
|
|
116
|
+
away = game.get("teams", {}).get("away", {}) or {}
|
|
117
|
+
home = game.get("teams", {}).get("home", {}) or {}
|
|
118
|
+
started = game.get("status", {}).get("abstractGameState") in ("Live", "Final")
|
|
119
|
+
|
|
120
|
+
away_runs = _format_runs(away, started)
|
|
121
|
+
home_runs = _format_runs(home, started)
|
|
122
|
+
|
|
123
|
+
return [
|
|
124
|
+
_status_text(game),
|
|
125
|
+
_team_name(away),
|
|
126
|
+
away_runs,
|
|
127
|
+
_team_name(home),
|
|
128
|
+
home_runs,
|
|
129
|
+
_notes(game),
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _format_runs(side: dict, started: bool) -> str:
|
|
134
|
+
if not started:
|
|
135
|
+
return ""
|
|
136
|
+
runs = side.get("score")
|
|
137
|
+
if runs is None:
|
|
138
|
+
return ""
|
|
139
|
+
text = str(runs)
|
|
140
|
+
return output.colorize(text, "bold") if side.get("isWinner") else text
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _notes(game: dict) -> str:
|
|
144
|
+
"""Probable starters before first pitch; otherwise nothing worth a column."""
|
|
145
|
+
if game.get("status", {}).get("abstractGameState") != "Preview":
|
|
146
|
+
return ""
|
|
147
|
+
teams = game.get("teams", {})
|
|
148
|
+
away = (teams.get("away", {}).get("probablePitcher") or {}).get("fullName")
|
|
149
|
+
home = (teams.get("home", {}).get("probablePitcher") or {}).get("fullName")
|
|
150
|
+
if not away and not home:
|
|
151
|
+
return ""
|
|
152
|
+
return f"{_last_name(away)} vs {_last_name(home)}"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _last_name(full_name: str | None) -> str:
|
|
156
|
+
if not full_name:
|
|
157
|
+
return "TBD"
|
|
158
|
+
return full_name.split()[-1]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _linescore_block(game: dict) -> str:
|
|
162
|
+
"""Render one game's inning-by-inning line score, R/H/E included."""
|
|
163
|
+
linescore = game.get("linescore", {}) or {}
|
|
164
|
+
innings = linescore.get("innings") or []
|
|
165
|
+
if not innings:
|
|
166
|
+
return ""
|
|
167
|
+
|
|
168
|
+
headers = [""] + [str(inning.get("num", "")) for inning in innings] + ["R", "H", "E"]
|
|
169
|
+
rows = []
|
|
170
|
+
for side in ("away", "home"):
|
|
171
|
+
name = _team_name(game.get("teams", {}).get(side, {}))
|
|
172
|
+
cells = [name]
|
|
173
|
+
for inning in innings:
|
|
174
|
+
half = inning.get(side, {}) or {}
|
|
175
|
+
runs = half.get("runs")
|
|
176
|
+
# A blank cell means the half-inning has not been played — the
|
|
177
|
+
# standard way a bottom-of-the-9th walk-off is shown.
|
|
178
|
+
cells.append("" if runs is None else str(runs))
|
|
179
|
+
totals = linescore.get("teams", {}).get(side, {}) or {}
|
|
180
|
+
cells += [totals.get("runs", 0), totals.get("hits", 0), totals.get("errors", 0)]
|
|
181
|
+
rows.append(cells)
|
|
182
|
+
|
|
183
|
+
return output.table(headers, rows, aligns=["l"] + ["r"] * (len(headers) - 1))
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _game_json(game: dict) -> dict:
|
|
187
|
+
"""Flatten a game into the stable shape ``--json`` promises."""
|
|
188
|
+
teams = game.get("teams", {})
|
|
189
|
+
status = game.get("status", {})
|
|
190
|
+
linescore = game.get("linescore", {}) or {}
|
|
191
|
+
return {
|
|
192
|
+
"gamePk": game.get("gamePk"),
|
|
193
|
+
"gameDate": game.get("gameDate"),
|
|
194
|
+
"status": status.get("detailedState"),
|
|
195
|
+
"abstractState": status.get("abstractGameState"),
|
|
196
|
+
"inning": linescore.get("currentInning"),
|
|
197
|
+
"inningState": linescore.get("inningState"),
|
|
198
|
+
"venue": (game.get("venue") or {}).get("name"),
|
|
199
|
+
"away": _side_json(teams.get("away", {})),
|
|
200
|
+
"home": _side_json(teams.get("home", {})),
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _side_json(side: dict) -> dict:
|
|
205
|
+
team = side.get("team", {}) or {}
|
|
206
|
+
return {
|
|
207
|
+
"id": team.get("id"),
|
|
208
|
+
"name": team.get("name"),
|
|
209
|
+
"abbreviation": team.get("abbreviation"),
|
|
210
|
+
"score": side.get("score"),
|
|
211
|
+
"isWinner": side.get("isWinner"),
|
|
212
|
+
"record": side.get("leagueRecord"),
|
|
213
|
+
"probablePitcher": (side.get("probablePitcher") or {}).get("fullName"),
|
|
214
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""``base standings`` — division and wild card standings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .. import output
|
|
8
|
+
from ..api import LEAGUE_AL, LEAGUE_NL, current_season
|
|
9
|
+
|
|
10
|
+
#: The standings payload identifies divisions by id only. Hydrated team records
|
|
11
|
+
#: usually carry the name, but this map keeps the output readable when they
|
|
12
|
+
#: don't.
|
|
13
|
+
DIVISION_NAMES = {
|
|
14
|
+
200: "American League West",
|
|
15
|
+
201: "American League East",
|
|
16
|
+
202: "American League Central",
|
|
17
|
+
203: "National League West",
|
|
18
|
+
204: "National League East",
|
|
19
|
+
205: "National League Central",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
#: Wild card records span a whole league, and the payload names it by id only.
|
|
23
|
+
LEAGUE_NAMES = {LEAGUE_AL: "American League", LEAGUE_NL: "National League"}
|
|
24
|
+
|
|
25
|
+
LEAGUE_IDS = {"al": (LEAGUE_AL,), "nl": (LEAGUE_NL,), "both": (LEAGUE_AL, LEAGUE_NL)}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def register(subparsers) -> None:
|
|
29
|
+
parser = subparsers.add_parser(
|
|
30
|
+
"standings",
|
|
31
|
+
help="show division or wild card standings",
|
|
32
|
+
description="Show the current standings, by division or by wild card race.",
|
|
33
|
+
)
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"-s", "--season", type=int, help="season to show (default: current season)"
|
|
36
|
+
)
|
|
37
|
+
parser.add_argument(
|
|
38
|
+
"-l",
|
|
39
|
+
"--league",
|
|
40
|
+
choices=["al", "nl", "both"],
|
|
41
|
+
default="both",
|
|
42
|
+
help="restrict to one league (default: both)",
|
|
43
|
+
)
|
|
44
|
+
parser.add_argument(
|
|
45
|
+
"-w",
|
|
46
|
+
"--wildcard",
|
|
47
|
+
action="store_true",
|
|
48
|
+
help="show the wild card race instead of division standings",
|
|
49
|
+
)
|
|
50
|
+
parser.set_defaults(func=run)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def run(args, ctx) -> int:
|
|
54
|
+
season = args.season or current_season()
|
|
55
|
+
standings_type = "wildCard" if args.wildcard else "regularSeason"
|
|
56
|
+
records = ctx.client.standings(
|
|
57
|
+
season=season,
|
|
58
|
+
league_ids=LEAGUE_IDS[args.league],
|
|
59
|
+
standings_type=standings_type,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
if ctx.json:
|
|
63
|
+
output.emit_json(
|
|
64
|
+
{
|
|
65
|
+
"season": season,
|
|
66
|
+
"type": standings_type,
|
|
67
|
+
"groups": [
|
|
68
|
+
{
|
|
69
|
+
"name": _group_name(record, args.wildcard),
|
|
70
|
+
"teams": [_team_json(entry) for entry in record.get("teamRecords", [])],
|
|
71
|
+
}
|
|
72
|
+
for record in records
|
|
73
|
+
],
|
|
74
|
+
}
|
|
75
|
+
)
|
|
76
|
+
return 0
|
|
77
|
+
|
|
78
|
+
label = "Wild Card" if args.wildcard else "Standings"
|
|
79
|
+
print(output.heading(f"{label} — {season}"))
|
|
80
|
+
|
|
81
|
+
if not records:
|
|
82
|
+
output.blank()
|
|
83
|
+
print(f" No standings available for {season}.")
|
|
84
|
+
return 0
|
|
85
|
+
|
|
86
|
+
for record in sorted(records, key=lambda r: _group_name(r, args.wildcard)):
|
|
87
|
+
entries = record.get("teamRecords", [])
|
|
88
|
+
if not entries:
|
|
89
|
+
continue
|
|
90
|
+
output.blank()
|
|
91
|
+
print(output.colorize(_group_name(record, args.wildcard), "bold"))
|
|
92
|
+
headers = ["Team", "W", "L", "PCT", "GB", "L10", "STRK", "RS", "RA", "DIFF"]
|
|
93
|
+
rows = [_row(entry, args.wildcard) for entry in entries]
|
|
94
|
+
print(output.table(headers, rows, aligns=["l"] + ["r"] * (len(headers) - 1)))
|
|
95
|
+
|
|
96
|
+
output.blank()
|
|
97
|
+
print(
|
|
98
|
+
output.colorize(
|
|
99
|
+
" z = division champion y = division leader "
|
|
100
|
+
"w = wild card e = eliminated",
|
|
101
|
+
"dim",
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
return 0
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _group_name(record: dict, wildcard: bool = False) -> str:
|
|
108
|
+
"""Title the block of teams in one standings record.
|
|
109
|
+
|
|
110
|
+
A wild card record covers an entire league, but the payload still carries a
|
|
111
|
+
leftover ``division`` from the regular-season shape — using it would label
|
|
112
|
+
twelve teams "American League East".
|
|
113
|
+
"""
|
|
114
|
+
league = record.get("league") or {}
|
|
115
|
+
if wildcard:
|
|
116
|
+
return league.get("name") or LEAGUE_NAMES.get(league.get("id"), "Wild Card")
|
|
117
|
+
|
|
118
|
+
division = record.get("division") or {}
|
|
119
|
+
if division.get("name"):
|
|
120
|
+
return division["name"]
|
|
121
|
+
# Fall back to the first team's hydrated division, then to the static map.
|
|
122
|
+
for entry in record.get("teamRecords", []):
|
|
123
|
+
team_division = (entry.get("team") or {}).get("division") or {}
|
|
124
|
+
if team_division.get("name"):
|
|
125
|
+
return team_division["name"]
|
|
126
|
+
return DIVISION_NAMES.get(division.get("id"), league.get("name", "—"))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _first(*values) -> Any:
|
|
130
|
+
"""Return the first value that isn't None. Zero counts as a value."""
|
|
131
|
+
for value in values:
|
|
132
|
+
if value is not None:
|
|
133
|
+
return value
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _row(entry: dict, wildcard: bool) -> list[Any]:
|
|
138
|
+
league_record = entry.get("leagueRecord", {}) or {}
|
|
139
|
+
wins = _first(entry.get("wins"), league_record.get("wins"))
|
|
140
|
+
losses = _first(entry.get("losses"), league_record.get("losses"))
|
|
141
|
+
pct = _first(entry.get("winningPercentage"), league_record.get("pct"))
|
|
142
|
+
games_back = entry.get("wildCardGamesBack") if wildcard else entry.get("gamesBack")
|
|
143
|
+
|
|
144
|
+
name = (entry.get("team") or {}).get("name", "?")
|
|
145
|
+
indicator = entry.get("clinchIndicator")
|
|
146
|
+
if indicator:
|
|
147
|
+
name = f"{name} - {indicator}"
|
|
148
|
+
|
|
149
|
+
differential = entry.get("runDifferential")
|
|
150
|
+
return [
|
|
151
|
+
name,
|
|
152
|
+
wins,
|
|
153
|
+
losses,
|
|
154
|
+
pct,
|
|
155
|
+
games_back,
|
|
156
|
+
_split_record(entry, "lastTen"),
|
|
157
|
+
(entry.get("streak") or {}).get("streakCode"),
|
|
158
|
+
entry.get("runsScored"),
|
|
159
|
+
entry.get("runsAllowed"),
|
|
160
|
+
_signed(differential),
|
|
161
|
+
]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _split_record(entry: dict, split_type: str) -> str | None:
|
|
165
|
+
for split in (entry.get("records") or {}).get("splitRecords", []):
|
|
166
|
+
if split.get("type") == split_type:
|
|
167
|
+
return f"{split.get('wins')}-{split.get('losses')}"
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _signed(value) -> str | None:
|
|
172
|
+
if value is None:
|
|
173
|
+
return None
|
|
174
|
+
return f"+{value}" if value > 0 else str(value)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _team_json(entry: dict) -> dict:
|
|
178
|
+
league_record = entry.get("leagueRecord", {}) or {}
|
|
179
|
+
return {
|
|
180
|
+
"id": (entry.get("team") or {}).get("id"),
|
|
181
|
+
"name": (entry.get("team") or {}).get("name"),
|
|
182
|
+
"wins": _first(entry.get("wins"), league_record.get("wins")),
|
|
183
|
+
"losses": _first(entry.get("losses"), league_record.get("losses")),
|
|
184
|
+
"pct": _first(entry.get("winningPercentage"), league_record.get("pct")),
|
|
185
|
+
"gamesBack": entry.get("gamesBack"),
|
|
186
|
+
"wildCardGamesBack": entry.get("wildCardGamesBack"),
|
|
187
|
+
"divisionRank": entry.get("divisionRank"),
|
|
188
|
+
"streak": (entry.get("streak") or {}).get("streakCode"),
|
|
189
|
+
"runsScored": entry.get("runsScored"),
|
|
190
|
+
"runsAllowed": entry.get("runsAllowed"),
|
|
191
|
+
"runDifferential": entry.get("runDifferential"),
|
|
192
|
+
"clinchIndicator": entry.get("clinchIndicator"),
|
|
193
|
+
}
|