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,65 @@
|
|
|
1
|
+
"""``base cache`` — inspect or clear the on-disk response cache."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .. import output
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def register(subparsers) -> None:
|
|
9
|
+
parser = subparsers.add_parser(
|
|
10
|
+
"cache",
|
|
11
|
+
help="show or clear the cached API responses",
|
|
12
|
+
description=(
|
|
13
|
+
"BaseballCommand caches API responses on disk so repeated commands "
|
|
14
|
+
"are fast and the free public API is not hammered. This shows what "
|
|
15
|
+
"is cached and can throw it away."
|
|
16
|
+
),
|
|
17
|
+
)
|
|
18
|
+
parser.add_argument(
|
|
19
|
+
"action",
|
|
20
|
+
nargs="?",
|
|
21
|
+
choices=["info", "clear"],
|
|
22
|
+
default="info",
|
|
23
|
+
help="'info' shows size and location, 'clear' deletes every entry",
|
|
24
|
+
)
|
|
25
|
+
parser.set_defaults(func=run)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def run(args, ctx) -> int:
|
|
29
|
+
cache = ctx.client.cache
|
|
30
|
+
|
|
31
|
+
if args.action == "clear":
|
|
32
|
+
removed = cache.clear()
|
|
33
|
+
if ctx.json:
|
|
34
|
+
output.emit_json({"cleared": removed, "path": str(cache.directory)})
|
|
35
|
+
else:
|
|
36
|
+
print(f"Cleared {removed} cached response{'s' if removed != 1 else ''}.")
|
|
37
|
+
return 0
|
|
38
|
+
|
|
39
|
+
info = cache.stats()
|
|
40
|
+
if ctx.json:
|
|
41
|
+
output.emit_json(info)
|
|
42
|
+
return 0
|
|
43
|
+
|
|
44
|
+
print(output.heading("Response cache"))
|
|
45
|
+
output.blank()
|
|
46
|
+
print(
|
|
47
|
+
output.key_values(
|
|
48
|
+
[
|
|
49
|
+
("Location", info["path"]),
|
|
50
|
+
("Entries", info["entries"]),
|
|
51
|
+
("Size", _human_bytes(info["bytes"])),
|
|
52
|
+
("Enabled", "yes" if cache.enabled else "no (--no-cache)"),
|
|
53
|
+
]
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
return 0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _human_bytes(count: int) -> str:
|
|
60
|
+
size = float(count)
|
|
61
|
+
for unit in ("B", "KB", "MB", "GB"):
|
|
62
|
+
if size < 1024 or unit == "GB":
|
|
63
|
+
return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
|
|
64
|
+
size /= 1024
|
|
65
|
+
return f"{size:.1f} GB"
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""``base compare`` — put two or more players' stat lines side by side."""
|
|
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 ..errors import UsageError
|
|
9
|
+
from ..lookup import resolve_player
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def register(subparsers) -> None:
|
|
13
|
+
parser = subparsers.add_parser(
|
|
14
|
+
"compare",
|
|
15
|
+
help="compare players' stats side by side",
|
|
16
|
+
description=(
|
|
17
|
+
"Compare two or more players. Stats run down the rows and players "
|
|
18
|
+
"across the columns, so the numbers line up for reading."
|
|
19
|
+
),
|
|
20
|
+
)
|
|
21
|
+
parser.add_argument("names", nargs="+", help="two or more player names or MLBAM ids")
|
|
22
|
+
parser.add_argument(
|
|
23
|
+
"-s", "--season", type=int, help="season to compare (default: current season)"
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"-g",
|
|
27
|
+
"--group",
|
|
28
|
+
choices=list(statcols.GROUPS),
|
|
29
|
+
default="hitting",
|
|
30
|
+
help="stat group to compare (default: hitting)",
|
|
31
|
+
)
|
|
32
|
+
parser.add_argument(
|
|
33
|
+
"-c", "--career", action="store_true", help="compare career totals instead of one season"
|
|
34
|
+
)
|
|
35
|
+
parser.set_defaults(func=run)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def run(args, ctx) -> int:
|
|
39
|
+
if len(args.names) < 2:
|
|
40
|
+
# argparse's nargs="+" accepts one; the command only means something
|
|
41
|
+
# with at least two.
|
|
42
|
+
raise UsageError("Give at least two players to compare.")
|
|
43
|
+
|
|
44
|
+
season = args.season or current_season()
|
|
45
|
+
stat_type = "career" if args.career else "season"
|
|
46
|
+
|
|
47
|
+
people = [resolve_player(ctx.client, name) for name in args.names]
|
|
48
|
+
lines = []
|
|
49
|
+
for person in people:
|
|
50
|
+
splits = ctx.client.player_stats(
|
|
51
|
+
person["id"],
|
|
52
|
+
group=args.group,
|
|
53
|
+
stat_type=stat_type,
|
|
54
|
+
season=None if args.career else season,
|
|
55
|
+
)
|
|
56
|
+
lines.append(splits[0].get("stat", {}) if splits else {})
|
|
57
|
+
|
|
58
|
+
scope = "career" if args.career else str(season)
|
|
59
|
+
|
|
60
|
+
if ctx.json:
|
|
61
|
+
output.emit_json(
|
|
62
|
+
{
|
|
63
|
+
"group": args.group,
|
|
64
|
+
"scope": scope,
|
|
65
|
+
"players": [
|
|
66
|
+
{
|
|
67
|
+
"id": person.get("id"),
|
|
68
|
+
"name": person.get("fullName"),
|
|
69
|
+
"stats": stat,
|
|
70
|
+
}
|
|
71
|
+
for person, stat in zip(people, lines)
|
|
72
|
+
],
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
return 0
|
|
76
|
+
|
|
77
|
+
print(output.heading(f"{args.group.title()} comparison — {scope}"))
|
|
78
|
+
output.blank()
|
|
79
|
+
|
|
80
|
+
columns = statcols.columns_for(args.group)
|
|
81
|
+
headers = ["Stat"] + [_short_name(person) for person in people]
|
|
82
|
+
rows: list[list] = []
|
|
83
|
+
for heading, key in columns:
|
|
84
|
+
if key.startswith("_"):
|
|
85
|
+
continue
|
|
86
|
+
values = [stat.get(key) for stat in lines]
|
|
87
|
+
if all(value is None for value in values):
|
|
88
|
+
continue
|
|
89
|
+
if key in NEUTRAL:
|
|
90
|
+
rows.append([heading] + ["-" if v is None else str(v) for v in values])
|
|
91
|
+
continue
|
|
92
|
+
lower_better = key in LOWER_IS_BETTER
|
|
93
|
+
rows.append(
|
|
94
|
+
[heading]
|
|
95
|
+
+ [_highlight(values, index, lower_better) for index in range(len(values))]
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
if not rows:
|
|
99
|
+
print(f" No {args.group} stats for these players in {scope}.")
|
|
100
|
+
return 0
|
|
101
|
+
|
|
102
|
+
print(output.table(headers, rows, aligns=["l"] + ["r"] * len(people)))
|
|
103
|
+
if output.color_enabled():
|
|
104
|
+
output.blank()
|
|
105
|
+
print(output.colorize(" Green marks the better figure in each row.", "dim"))
|
|
106
|
+
return 0
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _short_name(person: dict) -> str:
|
|
110
|
+
"""Use 'F. Lastname' so several columns still fit an 80-column terminal."""
|
|
111
|
+
name = person.get("fullName", "?")
|
|
112
|
+
parts = name.split()
|
|
113
|
+
if len(parts) < 2:
|
|
114
|
+
return name
|
|
115
|
+
return f"{parts[0][0]}. {' '.join(parts[1:])}"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
#: Rows where the smaller number is the better one.
|
|
119
|
+
LOWER_IS_BETTER = {"era", "whip", "walksPer9Inn", "losses", "errors", "caughtStealing"}
|
|
120
|
+
|
|
121
|
+
#: Rows that measure opportunity rather than performance. Marking a "winner"
|
|
122
|
+
#: for these would say nothing except who played more.
|
|
123
|
+
NEUTRAL = {"gamesPlayed", "gamesStarted", "plateAppearances", "atBats"}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _highlight(values: list, index: int, lower_is_better: bool) -> str:
|
|
127
|
+
"""Render one cell, bolding it if it is the best value in its row.
|
|
128
|
+
|
|
129
|
+
A row where everyone is tied gets no highlight — marking every column
|
|
130
|
+
"best" would just be noise.
|
|
131
|
+
"""
|
|
132
|
+
value = values[index]
|
|
133
|
+
if value is None:
|
|
134
|
+
return "-"
|
|
135
|
+
numeric = [number for number in (_number(v) for v in values) if number is not None]
|
|
136
|
+
if len(numeric) < 2:
|
|
137
|
+
return str(value)
|
|
138
|
+
best = min(numeric) if lower_is_better else max(numeric)
|
|
139
|
+
mine = _number(value)
|
|
140
|
+
if mine is not None and mine == best and any(number != best for number in numeric):
|
|
141
|
+
return output.colorize(str(value), "bold", "green")
|
|
142
|
+
return str(value)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _number(value):
|
|
146
|
+
try:
|
|
147
|
+
return float(value)
|
|
148
|
+
except (TypeError, ValueError):
|
|
149
|
+
return None
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""``base glossary`` — what the column headings mean."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .. import output
|
|
6
|
+
from .. import stats as statcols
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def register(subparsers) -> None:
|
|
10
|
+
parser = subparsers.add_parser(
|
|
11
|
+
"glossary",
|
|
12
|
+
help="explain the stat abbreviations used in tables",
|
|
13
|
+
description="Explain every abbreviation that appears as a column heading.",
|
|
14
|
+
)
|
|
15
|
+
parser.add_argument(
|
|
16
|
+
"-g",
|
|
17
|
+
"--group",
|
|
18
|
+
choices=list(statcols.GROUPS),
|
|
19
|
+
help="only show abbreviations for one stat group",
|
|
20
|
+
)
|
|
21
|
+
parser.set_defaults(func=run)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def run(args, ctx) -> int:
|
|
25
|
+
groups = [args.group] if args.group else list(statcols.GROUPS)
|
|
26
|
+
|
|
27
|
+
if ctx.json:
|
|
28
|
+
output.emit_json(
|
|
29
|
+
{
|
|
30
|
+
group: {
|
|
31
|
+
heading: statcols.GLOSSARY.get(heading, "")
|
|
32
|
+
for heading, _ in statcols.columns_for(group)
|
|
33
|
+
}
|
|
34
|
+
for group in groups
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
return 0
|
|
38
|
+
|
|
39
|
+
print(output.heading("Stat glossary"))
|
|
40
|
+
for group in groups:
|
|
41
|
+
output.blank()
|
|
42
|
+
print(output.colorize(group.title(), "bold"))
|
|
43
|
+
rows = [
|
|
44
|
+
[heading, statcols.GLOSSARY.get(heading, "")]
|
|
45
|
+
for heading, _ in statcols.columns_for(group)
|
|
46
|
+
]
|
|
47
|
+
print(output.table(["Abbr", "Meaning"], rows, aligns=["l", "l"]))
|
|
48
|
+
return 0
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""``base leaders`` — league leaderboards for any statistical category."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .. import output
|
|
6
|
+
from ..api import LEAGUE_AL, LEAGUE_NL, current_season
|
|
7
|
+
from ..errors import UsageError
|
|
8
|
+
|
|
9
|
+
#: Shorthand a baseball fan would actually type, mapped to the API's category
|
|
10
|
+
#: name and the stat group it belongs to. A group of None lets the API decide,
|
|
11
|
+
#: which matters for categories that exist on both sides of the ball.
|
|
12
|
+
ALIASES = {
|
|
13
|
+
"hr": ("homeRuns", "hitting"),
|
|
14
|
+
"avg": ("battingAverage", "hitting"),
|
|
15
|
+
"ba": ("battingAverage", "hitting"),
|
|
16
|
+
"rbi": ("runsBattedIn", "hitting"),
|
|
17
|
+
"sb": ("stolenBases", "hitting"),
|
|
18
|
+
"obp": ("onBasePercentage", "hitting"),
|
|
19
|
+
"slg": ("sluggingPercentage", "hitting"),
|
|
20
|
+
"ops": ("onBasePlusSlugging", "hitting"),
|
|
21
|
+
"h": ("hits", "hitting"),
|
|
22
|
+
"hits": ("hits", "hitting"),
|
|
23
|
+
"r": ("runs", "hitting"),
|
|
24
|
+
"runs": ("runs", "hitting"),
|
|
25
|
+
"2b": ("doubles", "hitting"),
|
|
26
|
+
"3b": ("triples", "hitting"),
|
|
27
|
+
"xbh": ("extraBaseHits", "hitting"),
|
|
28
|
+
"tb": ("totalBases", "hitting"),
|
|
29
|
+
"bb": ("walks", "hitting"),
|
|
30
|
+
"era": ("earnedRunAverage", "pitching"),
|
|
31
|
+
"whip": ("walksAndHitsPerInningPitched", "pitching"),
|
|
32
|
+
"w": ("wins", "pitching"),
|
|
33
|
+
"wins": ("wins", "pitching"),
|
|
34
|
+
"l": ("losses", "pitching"),
|
|
35
|
+
"sv": ("saves", "pitching"),
|
|
36
|
+
"saves": ("saves", "pitching"),
|
|
37
|
+
"k": ("strikeouts", "pitching"),
|
|
38
|
+
"so": ("strikeouts", "pitching"),
|
|
39
|
+
"ip": ("inningsPitched", "pitching"),
|
|
40
|
+
"cg": ("completeGames", "pitching"),
|
|
41
|
+
"sho": ("shutouts", "pitching"),
|
|
42
|
+
"k9": ("strikeoutsPer9Inn", "pitching"),
|
|
43
|
+
"holds": ("holds", "pitching"),
|
|
44
|
+
"e": ("errors", "fielding"),
|
|
45
|
+
"fpct": ("fieldingPercentage", "fielding"),
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def register(subparsers) -> None:
|
|
50
|
+
parser = subparsers.add_parser(
|
|
51
|
+
"leaders",
|
|
52
|
+
help="show league leaders in a statistical category",
|
|
53
|
+
description=(
|
|
54
|
+
"Show the league leaders in a statistical category. Common "
|
|
55
|
+
"shorthand works: hr, avg, rbi, era, whip, k, sv."
|
|
56
|
+
),
|
|
57
|
+
)
|
|
58
|
+
parser.add_argument(
|
|
59
|
+
"category",
|
|
60
|
+
nargs="?",
|
|
61
|
+
help="category or shorthand, e.g. hr, era, stolenBases",
|
|
62
|
+
)
|
|
63
|
+
parser.add_argument(
|
|
64
|
+
"--list", action="store_true", help="list every category the API accepts and exit"
|
|
65
|
+
)
|
|
66
|
+
parser.add_argument(
|
|
67
|
+
"-s", "--season", type=int, help="season to show (default: current season)"
|
|
68
|
+
)
|
|
69
|
+
parser.add_argument(
|
|
70
|
+
"-g",
|
|
71
|
+
"--group",
|
|
72
|
+
choices=["hitting", "pitching", "fielding"],
|
|
73
|
+
help="stat group (default: inferred from the category)",
|
|
74
|
+
)
|
|
75
|
+
parser.add_argument(
|
|
76
|
+
"-l",
|
|
77
|
+
"--league",
|
|
78
|
+
choices=["al", "nl", "both"],
|
|
79
|
+
default="both",
|
|
80
|
+
help="restrict to one league (default: both)",
|
|
81
|
+
)
|
|
82
|
+
parser.add_argument(
|
|
83
|
+
"-n", "--limit", type=int, default=10, help="how many leaders to show (default: 10)"
|
|
84
|
+
)
|
|
85
|
+
parser.set_defaults(func=run)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def run(args, ctx) -> int:
|
|
89
|
+
if args.list:
|
|
90
|
+
return _list_categories(ctx)
|
|
91
|
+
|
|
92
|
+
if not args.category:
|
|
93
|
+
raise UsageError(
|
|
94
|
+
"Which category? Try `base leaders hr`, or `base leaders --list` to see them all."
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
category, group = _resolve_category(ctx, args.category, args.group)
|
|
98
|
+
season = args.season or current_season()
|
|
99
|
+
league_id = {"al": LEAGUE_AL, "nl": LEAGUE_NL, "both": None}[args.league]
|
|
100
|
+
|
|
101
|
+
groups = ctx.client.leaders(
|
|
102
|
+
category=category,
|
|
103
|
+
season=season,
|
|
104
|
+
group=group,
|
|
105
|
+
league_id=league_id,
|
|
106
|
+
limit=args.limit,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
if ctx.json:
|
|
110
|
+
output.emit_json(
|
|
111
|
+
{
|
|
112
|
+
"category": category,
|
|
113
|
+
"season": season,
|
|
114
|
+
"group": group,
|
|
115
|
+
"leaderboards": [
|
|
116
|
+
{
|
|
117
|
+
"statGroup": board.get("statGroup"),
|
|
118
|
+
"league": (board.get("league") or {}).get("name"),
|
|
119
|
+
"leaders": [_leader_json(leader) for leader in board.get("leaders", [])],
|
|
120
|
+
}
|
|
121
|
+
for board in groups
|
|
122
|
+
],
|
|
123
|
+
}
|
|
124
|
+
)
|
|
125
|
+
return 0
|
|
126
|
+
|
|
127
|
+
print(output.heading(f"{category} leaders — {season}", _subtitle(group, args.league)))
|
|
128
|
+
|
|
129
|
+
printed = False
|
|
130
|
+
boards = [board for board in groups if board.get("leaders")]
|
|
131
|
+
for board in boards:
|
|
132
|
+
leaders = board["leaders"]
|
|
133
|
+
printed = True
|
|
134
|
+
output.blank()
|
|
135
|
+
# With a single leaderboard the heading above already says everything a
|
|
136
|
+
# per-board title would; only label them when there are several.
|
|
137
|
+
if len(boards) > 1:
|
|
138
|
+
title = board.get("statGroup", "").title() or category
|
|
139
|
+
league = (board.get("league") or {}).get("name")
|
|
140
|
+
if league:
|
|
141
|
+
title = f"{title} — {league}"
|
|
142
|
+
print(output.colorize(title, "bold"))
|
|
143
|
+
rows = [
|
|
144
|
+
[
|
|
145
|
+
leader.get("rank"),
|
|
146
|
+
(leader.get("person") or {}).get("fullName"),
|
|
147
|
+
(leader.get("team") or {}).get("name"),
|
|
148
|
+
leader.get("value"),
|
|
149
|
+
]
|
|
150
|
+
for leader in leaders
|
|
151
|
+
]
|
|
152
|
+
print(output.table(["#", "Player", "Team", category], rows, aligns=["r", "l", "l", "r"]))
|
|
153
|
+
|
|
154
|
+
if not printed:
|
|
155
|
+
output.blank()
|
|
156
|
+
print(f" No leaders returned for {category} in {season}.")
|
|
157
|
+
return 0
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _resolve_category(ctx, requested: str, group: str | None) -> tuple[str, str | None]:
|
|
161
|
+
"""Map user input to an API category name, and infer a stat group.
|
|
162
|
+
|
|
163
|
+
Checks the alias table first, then matches the API's own category list
|
|
164
|
+
case-insensitively so both ``homeRuns`` and ``homeruns`` work.
|
|
165
|
+
"""
|
|
166
|
+
key = requested.strip().lower().replace("/", "").replace("-", "").replace("_", "")
|
|
167
|
+
if key in ALIASES:
|
|
168
|
+
category, inferred = ALIASES[key]
|
|
169
|
+
return category, group or inferred
|
|
170
|
+
|
|
171
|
+
valid = ctx.client.leader_categories()
|
|
172
|
+
for name in valid:
|
|
173
|
+
if name.lower() == key:
|
|
174
|
+
return name, group
|
|
175
|
+
|
|
176
|
+
near = [name for name in valid if key in name.lower()]
|
|
177
|
+
hint = f" Did you mean: {', '.join(near[:5])}?" if near else ""
|
|
178
|
+
raise UsageError(
|
|
179
|
+
f"{requested!r} is not a leader category.{hint} "
|
|
180
|
+
"Run `base leaders --list` to see all of them."
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _list_categories(ctx) -> int:
|
|
185
|
+
categories = sorted(ctx.client.leader_categories())
|
|
186
|
+
if ctx.json:
|
|
187
|
+
output.emit_json({"categories": categories, "aliases": ALIASES})
|
|
188
|
+
return 0
|
|
189
|
+
print(output.heading("Leader categories", f"{len(categories)} available"))
|
|
190
|
+
output.blank()
|
|
191
|
+
# Three columns keeps a 70-item list to a screenful. This is a plain grid
|
|
192
|
+
# rather than a table: with no meaningful headings, the rule line above the
|
|
193
|
+
# first row would just be noise.
|
|
194
|
+
per_column = (len(categories) + 2) // 3
|
|
195
|
+
columns = [categories[i : i + per_column] for i in range(0, len(categories), per_column)]
|
|
196
|
+
width = max(len(name) for name in categories) + 2
|
|
197
|
+
for index in range(per_column):
|
|
198
|
+
cells = [column[index] if index < len(column) else "" for column in columns]
|
|
199
|
+
print(" " + "".join(cell.ljust(width) for cell in cells).rstrip())
|
|
200
|
+
output.blank()
|
|
201
|
+
print(output.colorize(" Shorthand: " + ", ".join(sorted(ALIASES)), "dim"))
|
|
202
|
+
return 0
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _subtitle(group: str | None, league: str) -> str:
|
|
206
|
+
parts = []
|
|
207
|
+
if group:
|
|
208
|
+
parts.append(group)
|
|
209
|
+
if league != "both":
|
|
210
|
+
parts.append(league.upper())
|
|
211
|
+
return " · ".join(parts)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _leader_json(leader: dict) -> dict:
|
|
215
|
+
return {
|
|
216
|
+
"rank": leader.get("rank"),
|
|
217
|
+
"value": leader.get("value"),
|
|
218
|
+
"playerId": (leader.get("person") or {}).get("id"),
|
|
219
|
+
"player": (leader.get("person") or {}).get("fullName"),
|
|
220
|
+
"teamId": (leader.get("team") or {}).get("id"),
|
|
221
|
+
"team": (leader.get("team") or {}).get("name"),
|
|
222
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""``base player`` — biographical info and stat lines for one player."""
|
|
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_player
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def register(subparsers) -> None:
|
|
12
|
+
parser = subparsers.add_parser(
|
|
13
|
+
"player",
|
|
14
|
+
help="show a player's statistics",
|
|
15
|
+
description=(
|
|
16
|
+
"Look up a player by name or MLBAM id and print their statistics. "
|
|
17
|
+
"By default this shows the current season in the group that fits "
|
|
18
|
+
"their position; two-way players get both."
|
|
19
|
+
),
|
|
20
|
+
)
|
|
21
|
+
parser.add_argument("name", help="player name (or MLBAM id), e.g. 'Aaron Judge' or 592450")
|
|
22
|
+
parser.add_argument(
|
|
23
|
+
"-s", "--season", type=int, help="season to show (default: current season)"
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"-g",
|
|
27
|
+
"--group",
|
|
28
|
+
choices=[*statcols.GROUPS, "all"],
|
|
29
|
+
help="stat group to show (default: chosen from the player's position)",
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument(
|
|
32
|
+
"-c", "--career", action="store_true", help="show career totals instead of one season"
|
|
33
|
+
)
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"-y",
|
|
36
|
+
"--years",
|
|
37
|
+
action="store_true",
|
|
38
|
+
help="show a year-by-year table of every season played",
|
|
39
|
+
)
|
|
40
|
+
parser.add_argument(
|
|
41
|
+
"-f",
|
|
42
|
+
"--full",
|
|
43
|
+
action="store_true",
|
|
44
|
+
help="list every stat the API returns, not just the common ones",
|
|
45
|
+
)
|
|
46
|
+
parser.add_argument(
|
|
47
|
+
"--postseason",
|
|
48
|
+
action="store_true",
|
|
49
|
+
help="use postseason games instead of the regular season",
|
|
50
|
+
)
|
|
51
|
+
parser.set_defaults(func=run)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def run(args, ctx) -> int:
|
|
55
|
+
person = resolve_player(ctx.client, args.name)
|
|
56
|
+
season = args.season or current_season()
|
|
57
|
+
game_type = "P" if args.postseason else "R"
|
|
58
|
+
|
|
59
|
+
groups = _groups_for(person, args.group)
|
|
60
|
+
stat_type = "career" if args.career else ("yearByYear" if args.years else "season")
|
|
61
|
+
|
|
62
|
+
collected = {}
|
|
63
|
+
for group in groups:
|
|
64
|
+
collected[group] = ctx.client.player_stats(
|
|
65
|
+
person["id"],
|
|
66
|
+
group=group,
|
|
67
|
+
stat_type=stat_type,
|
|
68
|
+
season=None if stat_type != "season" else season,
|
|
69
|
+
game_type=game_type,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
if ctx.json:
|
|
73
|
+
output.emit_json(
|
|
74
|
+
{
|
|
75
|
+
"player": _bio_json(person),
|
|
76
|
+
"statType": stat_type,
|
|
77
|
+
"season": season if stat_type == "season" else None,
|
|
78
|
+
"gameType": game_type,
|
|
79
|
+
"stats": collected,
|
|
80
|
+
}
|
|
81
|
+
)
|
|
82
|
+
return 0
|
|
83
|
+
|
|
84
|
+
print(output.heading(person.get("fullName", args.name), _bio_subtitle(person)))
|
|
85
|
+
output.blank()
|
|
86
|
+
print(output.key_values(_bio_pairs(person)))
|
|
87
|
+
|
|
88
|
+
scope = _scope_label(stat_type, season, args.postseason)
|
|
89
|
+
for group in groups:
|
|
90
|
+
splits = collected[group]
|
|
91
|
+
output.blank()
|
|
92
|
+
label = f"{group.title()} — {scope}"
|
|
93
|
+
if not splits:
|
|
94
|
+
print(output.colorize(label, "bold"))
|
|
95
|
+
print(f" No {group} stats for this scope.")
|
|
96
|
+
continue
|
|
97
|
+
print(output.colorize(label, "bold"))
|
|
98
|
+
print(_render_splits(splits, group, stat_type, args.full))
|
|
99
|
+
|
|
100
|
+
return 0
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _groups_for(person: dict, requested) -> list[str]:
|
|
104
|
+
"""Pick which stat groups to fetch.
|
|
105
|
+
|
|
106
|
+
An explicit ``--group`` always wins. Otherwise the player's primary position
|
|
107
|
+
decides: pitchers get pitching, two-way players get both, everyone else gets
|
|
108
|
+
hitting. Fetching all three by default would triple the requests to show
|
|
109
|
+
mostly empty tables.
|
|
110
|
+
"""
|
|
111
|
+
if requested == "all":
|
|
112
|
+
return list(statcols.GROUPS)
|
|
113
|
+
if requested:
|
|
114
|
+
return [requested]
|
|
115
|
+
|
|
116
|
+
position = person.get("primaryPosition") or {}
|
|
117
|
+
if position.get("abbreviation") == "TWP" or position.get("code") == "Y":
|
|
118
|
+
return ["hitting", "pitching"]
|
|
119
|
+
if position.get("code") == "1" or position.get("abbreviation") == "P":
|
|
120
|
+
return ["pitching"]
|
|
121
|
+
return ["hitting"]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _scope_label(stat_type: str, season: int, postseason: bool) -> str:
|
|
125
|
+
base = {
|
|
126
|
+
"career": "career",
|
|
127
|
+
"yearByYear": "year by year",
|
|
128
|
+
"season": str(season),
|
|
129
|
+
}[stat_type]
|
|
130
|
+
return f"{base} (postseason)" if postseason else base
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _render_splits(splits: list[dict], group: str, stat_type: str, full: bool) -> str:
|
|
134
|
+
if full:
|
|
135
|
+
# One block per split, every key the API returned.
|
|
136
|
+
blocks = []
|
|
137
|
+
for split in splits:
|
|
138
|
+
title = split.get("season") or "career"
|
|
139
|
+
rows = statcols.full_rows(split)
|
|
140
|
+
blocks.append(
|
|
141
|
+
output.colorize(f" {title}", "dim")
|
|
142
|
+
+ "\n"
|
|
143
|
+
+ output.table(["Stat", "Value"], rows, aligns=["l", "r"], indent=" ")
|
|
144
|
+
)
|
|
145
|
+
return "\n\n".join(blocks)
|
|
146
|
+
|
|
147
|
+
columns = list(statcols.columns_for(group))
|
|
148
|
+
if stat_type == "yearByYear":
|
|
149
|
+
# Year-by-year needs to say which year and which club each line is.
|
|
150
|
+
columns = [("Season", "_season"), ("Team", "_team")] + columns
|
|
151
|
+
|
|
152
|
+
rows = [statcols.split_row(split, columns) for split in splits]
|
|
153
|
+
aligns = ["l"] * (2 if stat_type == "yearByYear" else 0)
|
|
154
|
+
aligns += ["r"] * (len(columns) - len(aligns))
|
|
155
|
+
if stat_type != "yearByYear" and group == "fielding":
|
|
156
|
+
aligns[0] = "l" # the POS column
|
|
157
|
+
return output.table(statcols.headers_for(columns), rows, aligns=aligns)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _bio_subtitle(person: dict) -> str:
|
|
161
|
+
position = (person.get("primaryPosition") or {}).get("name", "")
|
|
162
|
+
team = (person.get("currentTeam") or {}).get("name", "")
|
|
163
|
+
number = person.get("primaryNumber")
|
|
164
|
+
parts = [part for part in (f"#{number}" if number else "", position, team) if part]
|
|
165
|
+
return " · ".join(parts)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _bio_pairs(person: dict) -> list[tuple]:
|
|
169
|
+
bats = (person.get("batSide") or {}).get("description")
|
|
170
|
+
throws = (person.get("pitchHand") or {}).get("description")
|
|
171
|
+
birth_place = ", ".join(
|
|
172
|
+
part
|
|
173
|
+
for part in (
|
|
174
|
+
person.get("birthCity"),
|
|
175
|
+
person.get("birthStateProvince"),
|
|
176
|
+
person.get("birthCountry"),
|
|
177
|
+
)
|
|
178
|
+
if part
|
|
179
|
+
)
|
|
180
|
+
born = person.get("birthDate")
|
|
181
|
+
if born and birth_place:
|
|
182
|
+
born = f"{born} ({birth_place})"
|
|
183
|
+
|
|
184
|
+
height, weight = person.get("height"), person.get("weight")
|
|
185
|
+
return [
|
|
186
|
+
("Bats/Throws", f"{bats}/{throws}" if bats and throws else bats or throws),
|
|
187
|
+
("Height/Weight", f"{height}, {weight} lb" if height and weight else None),
|
|
188
|
+
("Age", person.get("currentAge")),
|
|
189
|
+
("Born", born),
|
|
190
|
+
("MLB debut", person.get("mlbDebutDate")),
|
|
191
|
+
("Last played", person.get("lastPlayedDate")),
|
|
192
|
+
("Status", "Active" if person.get("active") else "Retired / inactive"),
|
|
193
|
+
("MLBAM id", person.get("id")),
|
|
194
|
+
]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _bio_json(person: dict) -> dict:
|
|
198
|
+
return {
|
|
199
|
+
"id": person.get("id"),
|
|
200
|
+
"fullName": person.get("fullName"),
|
|
201
|
+
"position": (person.get("primaryPosition") or {}).get("abbreviation"),
|
|
202
|
+
"team": (person.get("currentTeam") or {}).get("name"),
|
|
203
|
+
"number": person.get("primaryNumber"),
|
|
204
|
+
"bats": (person.get("batSide") or {}).get("code"),
|
|
205
|
+
"throws": (person.get("pitchHand") or {}).get("code"),
|
|
206
|
+
"birthDate": person.get("birthDate"),
|
|
207
|
+
"active": person.get("active"),
|
|
208
|
+
"mlbDebutDate": person.get("mlbDebutDate"),
|
|
209
|
+
}
|