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.

@@ -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
+ }