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,8 @@
1
+ """BaseballCommand — a free, open-source baseball CLI.
2
+
3
+ Scores, player stats, team stats, standings and league leaders, straight from
4
+ the public MLB Stats API. No API key, no account, no dependencies.
5
+ """
6
+
7
+ __version__ = "0.1.0"
8
+ __all__ = ["__version__"]
@@ -0,0 +1,8 @@
1
+ """Allow ``python -m baseballcommand`` as well as the installed ``base`` script."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
baseballcommand/api.py ADDED
@@ -0,0 +1,395 @@
1
+ """Client for the public MLB Stats API.
2
+
3
+ The API at ``statsapi.mlb.com`` needs no key and no account, which is what makes
4
+ this tool free to run. Everything here is stdlib ``urllib`` — no request
5
+ library, no auth, no state beyond the on-disk cache.
6
+
7
+ Only :class:`StatsAPI` talks to the network. Command modules take a client and
8
+ never build URLs themselves, so retries, caching, and error translation stay in
9
+ one place.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import ssl
16
+ import time
17
+ import urllib.error
18
+ import urllib.parse
19
+ import urllib.request
20
+ from collections.abc import Iterable
21
+ from datetime import date
22
+ from typing import Any
23
+
24
+ from . import __version__
25
+ from .cache import TTL_STATIC, TTL_STATS, Cache
26
+ from .errors import APIError
27
+
28
+ BASE_URL = "https://statsapi.mlb.com/api"
29
+ USER_AGENT = f"BaseballCommand/{__version__} (+https://github.com/opowell28/BaseballCommand)"
30
+
31
+ #: ``sportId`` 1 is Major League Baseball. The API also carries the minors,
32
+ #: college, and international ball under other ids.
33
+ SPORT_MLB = 1
34
+
35
+ #: League ids used when a caller asks for one league's worth of data.
36
+ LEAGUE_AL = 103
37
+ LEAGUE_NL = 104
38
+
39
+ #: Regular season games. The API also has 'S' (spring), 'P'/'D'/'L'/'W'
40
+ #: (postseason rounds), and 'A' (all-star).
41
+ GAME_TYPE_REGULAR = "R"
42
+
43
+
44
+ #: Shown when TLS verification fails, which on macOS almost always means a
45
+ #: python.org interpreter whose root certificates were never installed.
46
+ CERT_HELP = (
47
+ "TLS certificate verification failed. Your Python installation has no "
48
+ "usable root certificates.\n"
49
+ " macOS (python.org build): run "
50
+ "'/Applications/Python 3.x/Install Certificates.command'\n"
51
+ " any platform: 'pip install certifi' — BaseballCommand uses it "
52
+ "automatically when present."
53
+ )
54
+
55
+
56
+ def _ssl_context() -> ssl.SSLContext:
57
+ """Build a verifying TLS context, preferring certifi's roots if installed.
58
+
59
+ certifi is not a dependency — the tool works without it wherever the system
60
+ trust store is wired up. When it is present we use it, which fixes the
61
+ common macOS case at no cost to everyone else. Verification is never
62
+ disabled.
63
+ """
64
+ try:
65
+ import certifi # noqa: PLC0415 — optional, imported only if installed
66
+
67
+ return ssl.create_default_context(cafile=certifi.where())
68
+ except ImportError:
69
+ return ssl.create_default_context()
70
+
71
+
72
+ def current_season(today: date | None = None) -> int:
73
+ """Return the season a bare ``base`` invocation should default to.
74
+
75
+ January and February have no baseball, and stat lines for the new year are
76
+ empty until Opening Day in late March. During those months the season the
77
+ user almost certainly means is the one that just finished.
78
+ """
79
+ today = today or date.today()
80
+ return today.year if today.month >= 3 else today.year - 1
81
+
82
+
83
+ class StatsAPI:
84
+ """A thin, cached wrapper over the MLB Stats API.
85
+
86
+ Args:
87
+ cache: Response cache. A disabled :class:`~.cache.Cache` turns every
88
+ request into a live fetch.
89
+ timeout: Per-request socket timeout, in seconds.
90
+ retries: Extra attempts after the first failure. Retries apply to
91
+ timeouts, connection errors, and 5xx responses — not to 4xx, which
92
+ will fail identically no matter how often we ask.
93
+ """
94
+
95
+ def __init__(
96
+ self,
97
+ cache: Cache | None = None,
98
+ timeout: float = 15.0,
99
+ retries: int = 2,
100
+ ):
101
+ self.cache = cache if cache is not None else Cache()
102
+ self.timeout = timeout
103
+ self.retries = retries
104
+ self.ssl_context = _ssl_context()
105
+
106
+ # ------------------------------------------------------------------
107
+ # Transport
108
+ # ------------------------------------------------------------------
109
+
110
+ def get(
111
+ self,
112
+ path: str,
113
+ params: dict[str, Any] | None = None,
114
+ ttl: int = TTL_STATS,
115
+ version: str = "v1",
116
+ ) -> Any:
117
+ """GET ``path`` and return the decoded JSON body.
118
+
119
+ Args:
120
+ path: Path below the version segment, e.g. ``"schedule"``.
121
+ params: Query parameters. ``None`` values are dropped; lists and
122
+ tuples are joined with commas, which is how the API expects
123
+ multi-valued parameters such as ``leagueId=103,104``.
124
+ ttl: Seconds a cached copy stays fresh. Pass 0 to bypass the cache.
125
+ version: API version segment (``v1``, or ``v1.1`` for game feeds).
126
+
127
+ Raises:
128
+ APIError: On network failure or a non-2xx response.
129
+ """
130
+ url = self._build_url(path, params, version)
131
+
132
+ cached = self.cache.get(url, ttl)
133
+ if cached is not None:
134
+ return cached
135
+
136
+ payload = self._fetch(url)
137
+ self.cache.set(url, payload)
138
+ return payload
139
+
140
+ @staticmethod
141
+ def _build_url(path: str, params: dict[str, Any] | None, version: str) -> str:
142
+ query: list[tuple] = []
143
+ for key, value in (params or {}).items():
144
+ if value is None:
145
+ continue
146
+ if isinstance(value, (list, tuple, set)):
147
+ value = ",".join(str(v) for v in value)
148
+ elif isinstance(value, bool):
149
+ value = "true" if value else "false"
150
+ query.append((key, str(value)))
151
+ url = f"{BASE_URL}/{version}/{path.lstrip('/')}"
152
+ if query:
153
+ url = f"{url}?{urllib.parse.urlencode(query)}"
154
+ return url
155
+
156
+ def _fetch(self, url: str) -> Any:
157
+ request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
158
+ last_error: Exception | None = None
159
+
160
+ for attempt in range(self.retries + 1):
161
+ try:
162
+ with urllib.request.urlopen(
163
+ request, timeout=self.timeout, context=self.ssl_context
164
+ ) as response:
165
+ return json.loads(response.read().decode("utf-8"))
166
+ except urllib.error.HTTPError as exc:
167
+ if exc.code < 500 or attempt == self.retries:
168
+ raise APIError(
169
+ f"MLB Stats API returned HTTP {exc.code} ({exc.reason}) for {url}"
170
+ ) from exc
171
+ last_error = exc
172
+ except urllib.error.URLError as exc:
173
+ # A missing trust store fails identically on every retry, and
174
+ # the fix is a local one, so report it immediately.
175
+ if isinstance(exc.reason, ssl.SSLCertVerificationError):
176
+ raise APIError(CERT_HELP) from exc
177
+ if attempt == self.retries:
178
+ raise APIError(
179
+ f"Could not reach the MLB Stats API: {exc.reason}. "
180
+ "Check your network connection."
181
+ ) from exc
182
+ last_error = exc
183
+ except json.JSONDecodeError as exc:
184
+ raise APIError(f"MLB Stats API returned malformed JSON for {url}") from exc
185
+ except TimeoutError as exc:
186
+ if attempt == self.retries:
187
+ raise APIError(
188
+ f"Request to the MLB Stats API timed out after {self.timeout:g}s"
189
+ ) from exc
190
+ last_error = exc
191
+
192
+ # Back off before retrying: 0.5s, then 1.0s.
193
+ time.sleep(0.5 * (attempt + 1))
194
+
195
+ raise APIError(f"Request to the MLB Stats API failed: {last_error}")
196
+
197
+ # ------------------------------------------------------------------
198
+ # Endpoints
199
+ # ------------------------------------------------------------------
200
+
201
+ def schedule(
202
+ self,
203
+ game_date: str | None = None,
204
+ start_date: str | None = None,
205
+ end_date: str | None = None,
206
+ team_id: int | None = None,
207
+ ttl: int = 60,
208
+ ) -> dict:
209
+ """Return the schedule for a date or a date range, with line scores."""
210
+ return self.get(
211
+ "schedule",
212
+ {
213
+ "sportId": SPORT_MLB,
214
+ "date": game_date,
215
+ "startDate": start_date,
216
+ "endDate": end_date,
217
+ "teamId": team_id,
218
+ "hydrate": "linescore,probablePitcher,team",
219
+ },
220
+ ttl=ttl,
221
+ )
222
+
223
+ def teams(self, season: int | None = None) -> list[dict]:
224
+ """Return all 30 MLB clubs for a season."""
225
+ payload = self.get(
226
+ "teams",
227
+ {"sportId": SPORT_MLB, "season": season},
228
+ ttl=TTL_STATIC,
229
+ )
230
+ return payload.get("teams", [])
231
+
232
+ def search_people(self, name: str) -> list[dict]:
233
+ """Search players by name, active and historical."""
234
+ payload = self.get(
235
+ "people/search",
236
+ {"names": name, "hydrate": "currentTeam", "limit": 50},
237
+ ttl=TTL_STATIC,
238
+ )
239
+ return payload.get("people", [])
240
+
241
+ def person(self, player_id: int) -> dict | None:
242
+ """Return one player's biographical record, or None if the id is unknown."""
243
+ payload = self.get(
244
+ f"people/{player_id}",
245
+ {"hydrate": "currentTeam"},
246
+ ttl=TTL_STATIC,
247
+ )
248
+ people = payload.get("people", [])
249
+ return people[0] if people else None
250
+
251
+ def player_stats(
252
+ self,
253
+ player_id: int,
254
+ group: str,
255
+ stat_type: str = "season",
256
+ season: int | None = None,
257
+ game_type: str = GAME_TYPE_REGULAR,
258
+ ) -> list[dict]:
259
+ """Return stat splits for a player.
260
+
261
+ Args:
262
+ group: ``hitting``, ``pitching``, or ``fielding``.
263
+ stat_type: ``season``, ``career``, or ``yearByYear``.
264
+ season: Only meaningful for ``season`` splits.
265
+ """
266
+ payload = self.get(
267
+ f"people/{player_id}/stats",
268
+ {
269
+ "stats": stat_type,
270
+ "group": group,
271
+ "season": season if stat_type == "season" else None,
272
+ "gameType": game_type,
273
+ "sportId": SPORT_MLB,
274
+ },
275
+ ttl=TTL_STATS,
276
+ )
277
+ return _splits(payload)
278
+
279
+ def team_stats(
280
+ self,
281
+ team_id: int,
282
+ group: str,
283
+ season: int | None = None,
284
+ stat_type: str = "season",
285
+ ) -> list[dict]:
286
+ """Return team-level stat splits for a season."""
287
+ payload = self.get(
288
+ f"teams/{team_id}/stats",
289
+ {
290
+ "stats": stat_type,
291
+ "group": group,
292
+ "season": season,
293
+ "sportId": SPORT_MLB,
294
+ },
295
+ ttl=TTL_STATS,
296
+ )
297
+ return _splits(payload)
298
+
299
+ def standings(
300
+ self,
301
+ season: int | None = None,
302
+ league_ids: Iterable[int] = (LEAGUE_AL, LEAGUE_NL),
303
+ standings_type: str = "regularSeason",
304
+ ) -> list[dict]:
305
+ """Return division records, one entry per division."""
306
+ payload = self.get(
307
+ "standings",
308
+ {
309
+ "leagueId": list(league_ids),
310
+ "season": season,
311
+ "standingsTypes": standings_type,
312
+ "hydrate": "team",
313
+ },
314
+ ttl=TTL_STATS,
315
+ )
316
+ return payload.get("records", [])
317
+
318
+ def leaders(
319
+ self,
320
+ category: str,
321
+ season: int | None = None,
322
+ group: str | None = None,
323
+ league_id: int | None = None,
324
+ limit: int = 10,
325
+ ) -> list[dict]:
326
+ """Return league leader groups for a statistical category."""
327
+ payload = self.get(
328
+ "stats/leaders",
329
+ {
330
+ "leaderCategories": category,
331
+ "season": season,
332
+ "statGroup": group,
333
+ "leagueId": league_id,
334
+ "sportId": SPORT_MLB,
335
+ "limit": limit,
336
+ },
337
+ ttl=TTL_STATS,
338
+ )
339
+ return payload.get("leagueLeaders", [])
340
+
341
+ def leader_categories(self) -> list[str]:
342
+ """Return every category name accepted by :meth:`leaders`."""
343
+ payload = self.get("leagueLeaderTypes", ttl=TTL_STATIC)
344
+ # This endpoint returns a bare list rather than the usual envelope.
345
+ items = payload if isinstance(payload, list) else payload.get("leagueLeaderTypes", [])
346
+ return [item["displayName"] for item in items if "displayName" in item]
347
+
348
+ def roster(
349
+ self,
350
+ team_id: int,
351
+ season: int | None = None,
352
+ roster_type: str = "active",
353
+ stat_groups: Iterable[str] = (),
354
+ ) -> list[dict]:
355
+ """Return a team's roster.
356
+
357
+ Args:
358
+ roster_type: ``active``, ``40Man``, ``fullSeason``, or ``depthChart``.
359
+ stat_groups: Stat groups to hydrate onto each player. The API does
360
+ this server-side, so a whole team's stat lines cost one request
361
+ instead of one per player. The groups must be named explicitly:
362
+ left to itself the API returns only the one matching each
363
+ player's position, so fielding never appears.
364
+ """
365
+ hydrate = None
366
+ groups = list(stat_groups)
367
+ if groups:
368
+ season_arg = season or current_season()
369
+ hydrate = (
370
+ f"person(stats(type=season,season={season_arg},group=[{','.join(groups)}]))"
371
+ )
372
+ payload = self.get(
373
+ f"teams/{team_id}/roster",
374
+ {"season": season, "rosterType": roster_type, "hydrate": hydrate},
375
+ ttl=TTL_STATS,
376
+ )
377
+ return payload.get("roster", [])
378
+
379
+
380
+ def _splits(payload: dict) -> list[dict]:
381
+ """Flatten the API's ``stats -> splits`` envelope into a list of splits.
382
+
383
+ Each split is annotated with the group and stat type it came from so that a
384
+ caller holding a mixed list can still tell hitting from pitching.
385
+ """
386
+ results: list[dict] = []
387
+ for block in payload.get("stats", []):
388
+ group = block.get("group", {}).get("displayName")
389
+ stat_type = block.get("type", {}).get("displayName")
390
+ for split in block.get("splits", []):
391
+ split = dict(split)
392
+ split["_group"] = group
393
+ split["_type"] = stat_type
394
+ results.append(split)
395
+ return results
@@ -0,0 +1,115 @@
1
+ """A small on-disk response cache.
2
+
3
+ The MLB Stats API is free and unauthenticated, which makes it easy to hammer by
4
+ accident: a single ``base standings`` run resolves 30 teams, and a shell loop can
5
+ issue hundreds of identical requests a minute. Caching responses on disk keeps
6
+ repeated runs fast and keeps us from being a bad citizen.
7
+
8
+ Entries are plain JSON files under the user cache directory, keyed by a hash of
9
+ the request URL. Each file records the time it was written; reads past the
10
+ caller-supplied TTL are treated as misses. Anything unreadable or corrupt is
11
+ also a miss, so a damaged cache degrades to "slower", never to "broken".
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ import json
18
+ import os
19
+ import shutil
20
+ import time
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ # TTLs in seconds, named so call sites read as intent rather than magic numbers.
25
+ TTL_LIVE = 30 # in-progress games; short enough to feel live
26
+ TTL_SCHEDULE = 300 # a day's schedule once games are final
27
+ TTL_STATS = 3600 # season/career stat lines
28
+ TTL_STATIC = 86400 # team lists, leader categories, biographical data
29
+
30
+
31
+ def default_cache_dir() -> Path:
32
+ """Return the directory used for cached responses.
33
+
34
+ Honors ``BASEBALLCOMMAND_CACHE_DIR`` first, then ``XDG_CACHE_HOME``, then
35
+ falls back to ``~/.cache``.
36
+ """
37
+ override = os.environ.get("BASEBALLCOMMAND_CACHE_DIR")
38
+ if override:
39
+ return Path(override).expanduser()
40
+ xdg = os.environ.get("XDG_CACHE_HOME")
41
+ base = Path(xdg).expanduser() if xdg else Path.home() / ".cache"
42
+ return base / "baseballcommand"
43
+
44
+
45
+ class Cache:
46
+ """A TTL cache for JSON API responses.
47
+
48
+ Args:
49
+ directory: Where to store entries. Defaults to :func:`default_cache_dir`.
50
+ enabled: When false, every read misses and every write is dropped. This
51
+ is how ``--no-cache`` is implemented, so callers need no branches.
52
+ """
53
+
54
+ def __init__(self, directory: Path | None = None, enabled: bool = True):
55
+ self.directory = Path(directory) if directory else default_cache_dir()
56
+ self.enabled = enabled
57
+
58
+ def _path_for(self, key: str) -> Path:
59
+ digest = hashlib.sha256(key.encode("utf-8")).hexdigest()
60
+ # Shard by the first two hex characters so no single directory ends up
61
+ # with thousands of entries.
62
+ return self.directory / digest[:2] / f"{digest}.json"
63
+
64
+ def get(self, key: str, ttl: int) -> Any | None:
65
+ """Return the cached payload for ``key``, or None if absent or stale."""
66
+ if not self.enabled or ttl <= 0:
67
+ return None
68
+ path = self._path_for(key)
69
+ try:
70
+ with path.open("r", encoding="utf-8") as handle:
71
+ entry = json.load(handle)
72
+ if time.time() - entry["stored_at"] > ttl:
73
+ return None
74
+ return entry["payload"]
75
+ except (OSError, ValueError, KeyError, TypeError):
76
+ # Missing, unreadable, or corrupt: all just cache misses.
77
+ return None
78
+
79
+ def set(self, key: str, payload: Any) -> None:
80
+ """Store ``payload`` under ``key``. Failures are silently ignored."""
81
+ if not self.enabled:
82
+ return
83
+ path = self._path_for(key)
84
+ entry = {"stored_at": time.time(), "key": key, "payload": payload}
85
+ try:
86
+ path.parent.mkdir(parents=True, exist_ok=True)
87
+ # Write to a temp file in the same directory, then rename, so a
88
+ # concurrent reader never sees a half-written entry.
89
+ tmp = path.with_suffix(".tmp")
90
+ with tmp.open("w", encoding="utf-8") as handle:
91
+ json.dump(entry, handle)
92
+ os.replace(tmp, path)
93
+ except OSError:
94
+ pass
95
+
96
+ def clear(self) -> int:
97
+ """Delete every cached entry. Returns the number of files removed."""
98
+ if not self.directory.exists():
99
+ return 0
100
+ count = sum(1 for _ in self.directory.rglob("*.json"))
101
+ shutil.rmtree(self.directory, ignore_errors=True)
102
+ return count
103
+
104
+ def stats(self) -> dict:
105
+ """Return ``{'entries': int, 'bytes': int, 'path': str}`` for the cache."""
106
+ entries = 0
107
+ total = 0
108
+ if self.directory.exists():
109
+ for path in self.directory.rglob("*.json"):
110
+ entries += 1
111
+ try:
112
+ total += path.stat().st_size
113
+ except OSError:
114
+ pass
115
+ return {"entries": entries, "bytes": total, "path": str(self.directory)}
baseballcommand/cli.py ADDED
@@ -0,0 +1,121 @@
1
+ """Argument parsing and dispatch for the ``base`` command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from dataclasses import dataclass
8
+
9
+ from . import __version__, commands, output
10
+ from .api import StatsAPI
11
+ from .cache import Cache
12
+ from .errors import BaseballCommandError, UsageError
13
+
14
+ EPILOG = """\
15
+ examples:
16
+ base scores today's games
17
+ base scores yesterday -l yesterday's games with line scores
18
+ base player "Aaron Judge" this season's line for one player
19
+ base player Ohtani --years every season a player has played
20
+ base team NYY a club's record and season totals
21
+ base standings --league al American League standings
22
+ base leaders hr -n 5 the season's top five home run hitters
23
+ base roster LAD --stats --sort HR a roster sorted by home runs
24
+ base compare Judge Ohtani two players side by side
25
+
26
+ Data comes from the free public MLB Stats API. No key or account is needed.
27
+ """
28
+
29
+
30
+ @dataclass
31
+ class Context:
32
+ """Everything a command handler needs beyond its own arguments."""
33
+
34
+ client: StatsAPI
35
+ json: bool = False
36
+
37
+ @property
38
+ def live_ttl(self) -> int:
39
+ """Cache lifetime for schedule data.
40
+
41
+ Scores change while you watch, so they get a much shorter TTL than
42
+ season stats — short enough to feel live, long enough that a watch loop
43
+ doesn't turn into a request per second.
44
+ """
45
+ return 30 if self.client.cache.enabled else 0
46
+
47
+
48
+ def build_parser() -> argparse.ArgumentParser:
49
+ parser = argparse.ArgumentParser(
50
+ prog="base",
51
+ description="BaseballCommand — baseball scores and statistics in your terminal.",
52
+ epilog=EPILOG,
53
+ formatter_class=argparse.RawDescriptionHelpFormatter,
54
+ )
55
+ parser.add_argument(
56
+ "-V", "--version", action="version", version=f"BaseballCommand {__version__}"
57
+ )
58
+ parser.add_argument(
59
+ "--json", action="store_true", help="print raw JSON instead of formatted tables"
60
+ )
61
+ parser.add_argument("--no-color", action="store_true", help="disable ANSI color output")
62
+ parser.add_argument(
63
+ "--no-cache", action="store_true", help="bypass the cache and always fetch fresh data"
64
+ )
65
+ parser.add_argument(
66
+ "--timeout",
67
+ type=float,
68
+ default=15.0,
69
+ metavar="SECONDS",
70
+ help="network timeout per request (default: 15)",
71
+ )
72
+
73
+ subparsers = parser.add_subparsers(dest="command", metavar="<command>")
74
+ for module in commands.MODULES:
75
+ module.register(subparsers)
76
+ return parser
77
+
78
+
79
+ def main(argv: list[str] | None = None) -> int:
80
+ """Entry point. Returns a process exit status.
81
+
82
+ Expected failures print a one-line message and return 1; unexpected ones
83
+ keep their traceback, because those are bugs worth reporting.
84
+ """
85
+ parser = build_parser()
86
+ args = parser.parse_args(argv)
87
+
88
+ # Color is decided before anything prints, and --json always means clean,
89
+ # parseable output with no escape codes.
90
+ output.set_color(False if (args.no_color or args.json) else None)
91
+
92
+ if not args.command:
93
+ parser.print_help()
94
+ return 0
95
+
96
+ client = StatsAPI(cache=Cache(enabled=not args.no_cache), timeout=args.timeout)
97
+ ctx = Context(client=client, json=args.json)
98
+
99
+ try:
100
+ return args.func(args, ctx) or 0
101
+ except UsageError as exc:
102
+ print(f"base: {exc}", file=sys.stderr)
103
+ return 2
104
+ except BaseballCommandError as exc:
105
+ print(f"base: {exc}", file=sys.stderr)
106
+ return 1
107
+ except KeyboardInterrupt:
108
+ print(file=sys.stderr)
109
+ return 130
110
+ except BrokenPipeError:
111
+ # Happens routinely under `base ... | head`. Exit quietly rather than
112
+ # letting Python print an ignored-exception warning at shutdown.
113
+ try:
114
+ sys.stdout.close()
115
+ except OSError:
116
+ pass
117
+ return 0
118
+
119
+
120
+ if __name__ == "__main__": # pragma: no cover
121
+ sys.exit(main())
@@ -0,0 +1,38 @@
1
+ """Command implementations.
2
+
3
+ Each module here registers one subcommand. A module exposes ``register(sub)``,
4
+ which builds its argparse parser and attaches its handler via
5
+ ``set_defaults(func=...)``. The handler signature is ``run(args, ctx)`` where
6
+ ``ctx`` is a :class:`~baseballcommand.cli.Context`.
7
+
8
+ Adding a command means writing a module and appending it to ``MODULES``.
9
+ """
10
+
11
+ from . import ( # noqa: F401
12
+ cache,
13
+ compare,
14
+ glossary,
15
+ leaders,
16
+ player,
17
+ roster,
18
+ scores,
19
+ standings,
20
+ team,
21
+ teams,
22
+ )
23
+
24
+ #: Registration order determines the order subcommands appear in ``base --help``.
25
+ MODULES = (
26
+ scores,
27
+ player,
28
+ team,
29
+ standings,
30
+ leaders,
31
+ roster,
32
+ compare,
33
+ teams,
34
+ glossary,
35
+ cache,
36
+ )
37
+
38
+ __all__ = ["MODULES"]