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,193 @@
1
+ """Terminal rendering: colors, tables, and small layout helpers.
2
+
3
+ Deliberately dependency-free. Everything here degrades to plain ASCII when
4
+ output is piped or when the user asks for no color, so ``base scores | grep``
5
+ behaves the way a shell user expects.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import sys
13
+ from collections.abc import Iterable, Sequence
14
+ from typing import Any
15
+
16
+ # ANSI SGR codes. Kept as raw strings rather than an enum so that disabling
17
+ # color is a matter of swapping in empty strings.
18
+ _CODES = {
19
+ "reset": "\033[0m",
20
+ "bold": "\033[1m",
21
+ "dim": "\033[2m",
22
+ "red": "\033[31m",
23
+ "green": "\033[32m",
24
+ "yellow": "\033[33m",
25
+ "blue": "\033[34m",
26
+ "magenta": "\033[35m",
27
+ "cyan": "\033[36m",
28
+ "white": "\033[37m",
29
+ }
30
+
31
+ _color_enabled = True
32
+
33
+
34
+ def supports_color(stream=None) -> bool:
35
+ """Return whether ``stream`` should get ANSI escapes.
36
+
37
+ Respects the informal `NO_COLOR <https://no-color.org/>`_ convention, and
38
+ treats a non-TTY (a pipe, a file, CI) as "no color".
39
+ """
40
+ stream = stream or sys.stdout
41
+ if os.environ.get("NO_COLOR") is not None:
42
+ return False
43
+ if os.environ.get("TERM") == "dumb":
44
+ return False
45
+ return bool(getattr(stream, "isatty", lambda: False)())
46
+
47
+
48
+ def set_color(enabled: bool | None = None) -> None:
49
+ """Force color on or off; pass None to auto-detect from stdout."""
50
+ global _color_enabled
51
+ _color_enabled = supports_color() if enabled is None else bool(enabled)
52
+
53
+
54
+ def color_enabled() -> bool:
55
+ """Return whether output is currently being colorized."""
56
+ return _color_enabled
57
+
58
+
59
+ def colorize(text: str, *styles: str) -> str:
60
+ """Wrap ``text`` in the named styles, or return it unchanged if color is off."""
61
+ if not _color_enabled or not styles:
62
+ return text
63
+ prefix = "".join(_CODES.get(style, "") for style in styles)
64
+ return f"{prefix}{text}{_CODES['reset']}" if prefix else text
65
+
66
+
67
+ def visible_width(text: str) -> int:
68
+ """Return the printed width of ``text``, ignoring ANSI escape sequences."""
69
+ width = 0
70
+ in_escape = False
71
+ for char in text:
72
+ if in_escape:
73
+ # SGR sequences end with 'm'; that's all we ever emit.
74
+ if char.isalpha():
75
+ in_escape = False
76
+ elif char == "\033":
77
+ in_escape = True
78
+ else:
79
+ width += 1
80
+ return width
81
+
82
+
83
+ def _pad(text: str, width: int, align: str) -> str:
84
+ """Pad ``text`` to ``width`` columns, measuring around any escape codes."""
85
+ padding = max(0, width - visible_width(text))
86
+ if align == "r":
87
+ return " " * padding + text
88
+ if align == "c":
89
+ left = padding // 2
90
+ return " " * left + text + " " * (padding - left)
91
+ return text + " " * padding
92
+
93
+
94
+ def table(
95
+ headers: Sequence[str],
96
+ rows: Iterable[Sequence[Any]],
97
+ aligns: Sequence[str] | None = None,
98
+ indent: str = " ",
99
+ gap: str = " ",
100
+ ) -> str:
101
+ """Render an aligned text table.
102
+
103
+ Args:
104
+ headers: Column headings. Numeric columns read best right-aligned.
105
+ rows: Row sequences. Cells are stringified; None becomes an em dash.
106
+ aligns: Per-column alignment, ``"l"``, ``"r"``, or ``"c"``. Defaults to
107
+ left for the first column and right for the rest, which is the
108
+ shape almost every stat table wants.
109
+ indent: Prefix for every line.
110
+ gap: Separator between columns.
111
+
112
+ Returns:
113
+ The rendered table, without a trailing newline. Empty ``rows`` yields
114
+ an empty string so callers can test the result for emptiness.
115
+ """
116
+ rows = [list(row) for row in rows]
117
+ if not rows:
118
+ return ""
119
+
120
+ columns = max(len(headers), max(len(row) for row in rows))
121
+ if aligns is None:
122
+ aligns = ["l"] + ["r"] * (columns - 1)
123
+ aligns = list(aligns) + ["l"] * (columns - len(aligns))
124
+
125
+ headers = list(headers) + [""] * (columns - len(headers))
126
+ cells = [[_cell(value) for value in row] + [""] * (columns - len(row)) for row in rows]
127
+
128
+ widths = [visible_width(header) for header in headers]
129
+ for row in cells:
130
+ for index, value in enumerate(row):
131
+ widths[index] = max(widths[index], visible_width(value))
132
+
133
+ lines = [
134
+ indent
135
+ + gap.join(
136
+ colorize(_pad(header, widths[i], aligns[i]), "bold")
137
+ for i, header in enumerate(headers)
138
+ ).rstrip()
139
+ ]
140
+ lines.append(indent + colorize(gap.join("-" * width for width in widths), "dim"))
141
+ for row in cells:
142
+ lines.append(
143
+ indent
144
+ + gap.join(_pad(value, widths[i], aligns[i]) for i, value in enumerate(row)).rstrip()
145
+ )
146
+ return "\n".join(lines)
147
+
148
+
149
+ def _cell(value: Any) -> str:
150
+ if value is None:
151
+ return "-"
152
+ if isinstance(value, float):
153
+ return f"{value:g}"
154
+ return str(value)
155
+
156
+
157
+ def heading(text: str, subtitle: str | None = None) -> str:
158
+ """Render a section heading, optionally with a dimmed subtitle beneath it."""
159
+ line = colorize(text, "bold", "cyan")
160
+ if subtitle:
161
+ line += "\n" + colorize(subtitle, "dim")
162
+ return line
163
+
164
+
165
+ def key_values(pairs: Sequence[tuple], indent: str = " ") -> str:
166
+ """Render ``(label, value)`` pairs as an aligned two-column block."""
167
+ pairs = [(label, value) for label, value in pairs if value not in (None, "")]
168
+ if not pairs:
169
+ return ""
170
+ width = max(len(label) for label, _ in pairs)
171
+ return "\n".join(
172
+ f"{indent}{colorize(label.ljust(width), 'dim')} {value}" for label, value in pairs
173
+ )
174
+
175
+
176
+ def warn(message: str) -> None:
177
+ """Print a warning to stderr so it stays out of piped stdout."""
178
+ print(colorize(f"warning: {message}", "yellow"), file=sys.stderr)
179
+
180
+
181
+ def emit_json(payload: Any) -> None:
182
+ """Print ``payload`` as indented JSON, for ``--json``."""
183
+ print(json.dumps(payload, indent=2, default=str))
184
+
185
+
186
+ def blank() -> None:
187
+ print()
188
+
189
+
190
+ def paragraphs(*blocks: str) -> None:
191
+ """Print non-empty blocks separated by blank lines."""
192
+ rendered: list[str] = [block for block in blocks if block]
193
+ print("\n\n".join(rendered))
@@ -0,0 +1,171 @@
1
+ """Which stats to show, in which order, under which headings.
2
+
3
+ The API returns every stat it has for a given group — 30-odd keys for hitting,
4
+ 50-odd for pitching. Dumping all of them makes an unreadable wall of numbers, so
5
+ each group gets a curated column list in the order a box score or a baseball
6
+ reference page would use. ``--full`` bypasses the curation and prints
7
+ everything.
8
+
9
+ Column lists are ``(heading, api_key)`` pairs. Missing keys render as ``-``
10
+ rather than raising, because the API omits stats that don't apply to a given
11
+ player, season, or split.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import Sequence
17
+ from typing import Any
18
+
19
+ Column = tuple[str, str]
20
+
21
+ #: The three stat groups the API exposes for players and teams.
22
+ GROUPS = ("hitting", "pitching", "fielding")
23
+
24
+ HITTING_COLUMNS: list[Column] = [
25
+ ("G", "gamesPlayed"),
26
+ ("PA", "plateAppearances"),
27
+ ("AB", "atBats"),
28
+ ("R", "runs"),
29
+ ("H", "hits"),
30
+ ("2B", "doubles"),
31
+ ("3B", "triples"),
32
+ ("HR", "homeRuns"),
33
+ ("RBI", "rbi"),
34
+ ("BB", "baseOnBalls"),
35
+ ("SO", "strikeOuts"),
36
+ ("SB", "stolenBases"),
37
+ ("CS", "caughtStealing"),
38
+ ("AVG", "avg"),
39
+ ("OBP", "obp"),
40
+ ("SLG", "slg"),
41
+ ("OPS", "ops"),
42
+ ]
43
+
44
+ PITCHING_COLUMNS: list[Column] = [
45
+ ("W", "wins"),
46
+ ("L", "losses"),
47
+ ("ERA", "era"),
48
+ ("G", "gamesPlayed"),
49
+ ("GS", "gamesStarted"),
50
+ ("CG", "completeGames"),
51
+ ("SHO", "shutouts"),
52
+ ("SV", "saves"),
53
+ ("HLD", "holds"),
54
+ ("IP", "inningsPitched"),
55
+ ("H", "hits"),
56
+ ("R", "runs"),
57
+ ("ER", "earnedRuns"),
58
+ ("HR", "homeRuns"),
59
+ ("BB", "baseOnBalls"),
60
+ ("SO", "strikeOuts"),
61
+ ("WHIP", "whip"),
62
+ ("K/9", "strikeoutsPer9Inn"),
63
+ ("BB/9", "walksPer9Inn"),
64
+ ]
65
+
66
+ FIELDING_COLUMNS: list[Column] = [
67
+ ("POS", "_position"),
68
+ ("G", "gamesPlayed"),
69
+ ("GS", "gamesStarted"),
70
+ ("INN", "innings"),
71
+ ("TC", "chances"),
72
+ ("PO", "putOuts"),
73
+ ("A", "assists"),
74
+ ("E", "errors"),
75
+ ("DP", "doublePlays"),
76
+ ("FPCT", "fielding"),
77
+ ("RF/9", "rangeFactorPer9Inn"),
78
+ ]
79
+
80
+ COLUMNS: dict[str, list[Column]] = {
81
+ "hitting": HITTING_COLUMNS,
82
+ "pitching": PITCHING_COLUMNS,
83
+ "fielding": FIELDING_COLUMNS,
84
+ }
85
+
86
+ #: Human-readable names for the curated column headings, used by
87
+ #: ``base glossary`` and the docs.
88
+ GLOSSARY: dict[str, str] = {
89
+ "G": "Games played",
90
+ "GS": "Games started",
91
+ "PA": "Plate appearances",
92
+ "AB": "At bats",
93
+ "R": "Runs scored",
94
+ "H": "Hits",
95
+ "2B": "Doubles",
96
+ "3B": "Triples",
97
+ "HR": "Home runs",
98
+ "RBI": "Runs batted in",
99
+ "BB": "Walks (bases on balls)",
100
+ "SO": "Strikeouts",
101
+ "SB": "Stolen bases",
102
+ "CS": "Caught stealing",
103
+ "AVG": "Batting average (H/AB)",
104
+ "OBP": "On-base percentage",
105
+ "SLG": "Slugging percentage (TB/AB)",
106
+ "OPS": "On-base plus slugging",
107
+ "W": "Wins",
108
+ "L": "Losses",
109
+ "ERA": "Earned run average (9 x ER/IP)",
110
+ "CG": "Complete games",
111
+ "SHO": "Shutouts",
112
+ "SV": "Saves",
113
+ "HLD": "Holds",
114
+ "IP": "Innings pitched",
115
+ "ER": "Earned runs",
116
+ "WHIP": "Walks plus hits per inning pitched",
117
+ "K/9": "Strikeouts per nine innings",
118
+ "BB/9": "Walks per nine innings",
119
+ "POS": "Primary position for the split",
120
+ "INN": "Innings played in the field",
121
+ "TC": "Total chances (PO + A + E)",
122
+ "PO": "Putouts",
123
+ "A": "Assists",
124
+ "E": "Errors",
125
+ "DP": "Double plays turned",
126
+ "FPCT": "Fielding percentage ((PO + A)/TC)",
127
+ "RF/9": "Range factor per nine innings",
128
+ }
129
+
130
+
131
+ def columns_for(group: str) -> list[Column]:
132
+ """Return the curated columns for a stat group."""
133
+ return COLUMNS.get(group, HITTING_COLUMNS)
134
+
135
+
136
+ def split_row(split: dict, columns: Sequence[Column]) -> list[Any]:
137
+ """Extract one table row from a stat split.
138
+
139
+ Underscore-prefixed keys are synthetic: they come from the split itself
140
+ rather than from its ``stat`` payload.
141
+ """
142
+ stat = split.get("stat", {}) or {}
143
+ row: list[Any] = []
144
+ for _, key in columns:
145
+ if key == "_position":
146
+ row.append((stat.get("position") or {}).get("abbreviation", "-"))
147
+ elif key == "_season":
148
+ row.append(split.get("season", "-"))
149
+ elif key == "_team":
150
+ # A year-by-year split with no team is the season total for a
151
+ # player who changed clubs mid-year.
152
+ row.append((split.get("team") or {}).get("name") or "(total)")
153
+ else:
154
+ row.append(stat.get(key))
155
+ return row
156
+
157
+
158
+ def headers_for(columns: Sequence[Column]) -> list[str]:
159
+ return [heading for heading, _ in columns]
160
+
161
+
162
+ def full_rows(split: dict) -> list[tuple[str, Any]]:
163
+ """Return every stat in a split as sorted ``(key, value)`` pairs, for ``--full``."""
164
+ stat = split.get("stat", {}) or {}
165
+ rows = []
166
+ for key in sorted(stat):
167
+ value = stat[key]
168
+ if isinstance(value, dict):
169
+ value = value.get("abbreviation") or value.get("name") or value.get("displayName")
170
+ rows.append((key, value))
171
+ return rows