backcaster 0.4.1__tar.gz → 0.4.2__tar.gz

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.
Files changed (30) hide show
  1. {backcaster-0.4.1 → backcaster-0.4.2}/PKG-INFO +42 -14
  2. backcaster-0.4.1/src/backcaster.egg-info/PKG-INFO → backcaster-0.4.2/README.md +37 -43
  3. {backcaster-0.4.1 → backcaster-0.4.2}/pyproject.toml +4 -1
  4. {backcaster-0.4.1 → backcaster-0.4.2}/src/backcaster/__init__.py +1 -1
  5. {backcaster-0.4.1 → backcaster-0.4.2}/src/backcaster/batters.py +1 -1
  6. {backcaster-0.4.1 → backcaster-0.4.2}/src/backcaster/cli.py +19 -6
  7. {backcaster-0.4.1 → backcaster-0.4.2}/src/backcaster/data.py +2 -2
  8. {backcaster-0.4.1 → backcaster-0.4.2}/src/backcaster/lahman.py +61 -34
  9. {backcaster-0.4.1 → backcaster-0.4.2}/src/backcaster/marcel.py +93 -71
  10. {backcaster-0.4.1 → backcaster-0.4.2}/src/backcaster/outputs.py +16 -2
  11. {backcaster-0.4.1 → backcaster-0.4.2}/src/backcaster/schemas.py +4 -5
  12. backcaster-0.4.1/README.md → backcaster-0.4.2/src/backcaster.egg-info/PKG-INFO +71 -12
  13. {backcaster-0.4.1 → backcaster-0.4.2}/src/backcaster.egg-info/requires.txt +4 -1
  14. {backcaster-0.4.1 → backcaster-0.4.2}/tests/test_batters.py +15 -2
  15. {backcaster-0.4.1 → backcaster-0.4.2}/tests/test_cli.py +58 -2
  16. {backcaster-0.4.1 → backcaster-0.4.2}/tests/test_lahman.py +129 -10
  17. {backcaster-0.4.1 → backcaster-0.4.2}/tests/test_marcel_math.py +159 -152
  18. {backcaster-0.4.1 → backcaster-0.4.2}/tests/test_outputs.py +51 -0
  19. {backcaster-0.4.1 → backcaster-0.4.2}/LICENSE +0 -0
  20. {backcaster-0.4.1 → backcaster-0.4.2}/THIRD_PARTY_NOTICES.md +0 -0
  21. {backcaster-0.4.1 → backcaster-0.4.2}/setup.cfg +0 -0
  22. {backcaster-0.4.1 → backcaster-0.4.2}/src/backcaster/html_report.py +0 -0
  23. {backcaster-0.4.1 → backcaster-0.4.2}/src/backcaster/normalize.py +0 -0
  24. {backcaster-0.4.1 → backcaster-0.4.2}/src/backcaster/stat_columns.py +0 -0
  25. {backcaster-0.4.1 → backcaster-0.4.2}/src/backcaster.egg-info/SOURCES.txt +0 -0
  26. {backcaster-0.4.1 → backcaster-0.4.2}/src/backcaster.egg-info/dependency_links.txt +0 -0
  27. {backcaster-0.4.1 → backcaster-0.4.2}/src/backcaster.egg-info/entry_points.txt +0 -0
  28. {backcaster-0.4.1 → backcaster-0.4.2}/src/backcaster.egg-info/top_level.txt +0 -0
  29. {backcaster-0.4.1 → backcaster-0.4.2}/tests/test_html_report.py +0 -0
  30. {backcaster-0.4.1 → backcaster-0.4.2}/tests/test_normalize.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: backcaster
3
- Version: 0.4.1
3
+ Version: 0.4.2
4
4
  Summary: Marcel-style MLB projection CLI using local Lahman CSV data
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/jeffreywevans/backcaster
@@ -18,7 +18,7 @@ Requires-Python: >=3.13
18
18
  Description-Content-Type: text/markdown
19
19
  License-File: LICENSE
20
20
  License-File: THIRD_PARTY_NOTICES.md
21
- Requires-Dist: pandas>=2.0
21
+ Requires-Dist: pandas>=2.2.2
22
22
  Requires-Dist: numpy>=2.0
23
23
  Provides-Extra: dev
24
24
  Requires-Dist: pytest>=8.0; extra == "dev"
@@ -27,6 +27,9 @@ Requires-Dist: ruff>=0.11.0; extra == "dev"
27
27
  Requires-Dist: mypy>=1.10; extra == "dev"
28
28
  Requires-Dist: pandas-stubs>=2.2; extra == "dev"
29
29
  Requires-Dist: build>=1.2; extra == "dev"
30
+ Requires-Dist: setuptools>=77; extra == "dev"
31
+ Requires-Dist: wheel; extra == "dev"
32
+ Requires-Dist: twine; extra == "dev"
30
33
  Dynamic: license-file
31
34
 
32
35
  # Backcaster
@@ -56,10 +59,10 @@ Upgrade to the latest release available on PyPI:
56
59
  python -m pip install --upgrade backcaster
57
60
  ```
58
61
 
59
- To install a specific release, such as 0.4.1:
62
+ To install a specific release, such as 0.4.2:
60
63
 
61
64
  ```powershell
62
- python -m pip install --upgrade backcaster==0.4.1
65
+ python -m pip install --upgrade backcaster==0.4.2
63
66
  ```
64
67
 
65
68
  Run the command using the same Python environment where you run Backcaster.
@@ -73,7 +76,7 @@ python -m pip show backcaster
73
76
 
74
77
  ```bash
75
78
  # from repo root
76
- uv sync
79
+ uv sync --locked
77
80
  ```
78
81
 
79
82
  This installs runtime dependencies and the `backcaster` CLI entrypoint defined in `pyproject.toml`.
@@ -81,10 +84,10 @@ This installs runtime dependencies and the `backcaster` CLI entrypoint defined i
81
84
  ### Install in editable/dev mode
82
85
 
83
86
  ```bash
84
- uv sync --extra dev
87
+ uv sync --locked --extra dev
85
88
  ```
86
89
 
87
- This adds test/lint/type-check/build tooling (`pytest`, `pytest-cov`, `ruff`, `mypy`, `pandas-stubs`, `build`).
90
+ This adds test/lint/type-check/build tooling (`pytest`, `pytest-cov`, `ruff`, `mypy`, `pandas-stubs`, `build`, `setuptools`, `wheel`, `twine`).
88
91
 
89
92
  ### Build/install as a package
90
93
 
@@ -102,7 +105,7 @@ The `player`, `team`, and `batch` commands also require `--kind batting|pitching
102
105
  The `hitters` and `pitchers` commands choose their kind automatically.
103
106
 
104
107
  Subcommand-specific required options:
105
- - `player` requires `--name`
108
+ - `player` requires exactly one of `--name` or `--player-id`
106
109
  - `team` requires `--team`
107
110
  - `batch`, `hitters`, and `pitchers` require no player or team option
108
111
 
@@ -133,7 +136,9 @@ Both commands support `--data-dir`, `--format cli|csv|html`, and `--out`.
133
136
  They are shortcuts for `batch --kind batting` and `batch --kind pitching`.
134
137
  For a different data directory, add `--data-dir "C:\Baseball\lahman"`.
135
138
  For HTML, use `--format html --out hitters_2026.html` (or a pitcher output filename).
136
- File formats require `--out`; the CLI format prints to the terminal.
139
+ File formats require `--out`; the CLI format prints to the terminal and rejects
140
+ `--out`. Invalid output options fail before data is loaded. Unwritable output
141
+ paths report an error on stderr and exit with status 2.
137
142
 
138
143
  Players qualify for selection by meeting the existing PA/IP threshold in at least
139
144
  one of those three seasons. This is a historical eligibility list, not an active
@@ -195,7 +200,15 @@ Blank numeric counting-stat cells are also treated as zero; use a dataset with
195
200
  complete records for the seasons you intend to project.
196
201
  Pitching `IPouts` is divided by three for decimal innings, and `BFP` supplies
197
202
  batters faced. Lahman player IDs keep different players with the same name separate;
198
- ambiguous name lookups report the matching IDs.
203
+ ambiguous name lookups report the matching IDs. Select one with `--player-id`
204
+ instead of `--name`, for example:
205
+
206
+ ```bash
207
+ backcaster player --player-id bettsmo01 --year 2026 --kind batting
208
+ ```
209
+
210
+ IDs match Lahman `People.csv.playerID` exactly and retain the same qualification
211
+ rules as name lookups.
199
212
 
200
213
  Batter league regression and rebaselining use all non-pitcher player-seasons;
201
214
  qualification selects the displayed players. Pitching retains its qualified-only
@@ -226,7 +239,7 @@ backcaster player --name "Mookie Betts" --year 2026 --kind batting --format csv
226
239
  backcaster team --team LAN --year 2026 --kind pitching --format html --out ./out/lan_pitching_2026.html
227
240
  ```
228
241
 
229
- > `--out` is required for `csv` and `html` formats.
242
+ > `--out` is required for `csv` and `html` formats and cannot be used with `cli`.
230
243
 
231
244
  ---
232
245
 
@@ -250,6 +263,8 @@ Terminal, CSV, and HTML outputs use the same display rules:
250
263
  up. Age and year are displayed as whole numbers too.
251
264
  - BA, OBP, SLG, and OPS always show three decimal places, including `1.048` OPS.
252
265
  BA is the canonical batter output label (previously AVG).
266
+ Rates with zero denominators remain undefined (NaN) and display blank;
267
+ OPS is undefined when OBP or SLG is undefined.
253
268
  - ERA shows two decimals; WHIP shows three. W-L% shows a three-decimal
254
269
  fraction, such as `0.524`. H9, HR9, BB9, and SO9 show one decimal; SO/W shows two.
255
270
  W-L% without any decisions and SO/W without walks are undefined and display blank.
@@ -260,7 +275,7 @@ Terminal, CSV, and HTML outputs use the same display rules:
260
275
  Calculations and Python projection DataFrames retain full precision. Rates are
261
276
  computed from unrounded totals, then formatted; they are not recalculated from
262
277
  rounded displayed counts. Independently rounded totals may therefore not add up
263
- exactly. Reliability formatting is unchanged.
278
+ exactly. Reliability shows six decimal places in CLI, CSV, and HTML output.
264
279
 
265
280
  **CSV IP uses baseball notation too.** Convert `161.1` to `161 + 1/3` before doing
266
281
  arithmetic; treating it as 161.1 decimal innings gives incorrect results. Internally,
@@ -326,6 +341,13 @@ Lahman distribution from the same edition. Existing online caches are ignored.
326
341
 
327
342
  ---
328
343
 
344
+ ### Python data-loading errors
345
+
346
+ Local data-loading and validation failures raise
347
+ `backcaster.data.DataLoadError` (renamed from `DataFetchError`). Player lookup
348
+ failures continue to raise `PlayerLookupError`. The CLI reports these failures
349
+ and filesystem errors on stderr with exit status 2.
350
+
329
351
  ## Dependency notes
330
352
 
331
353
  Runtime dependencies are intentionally minimal:
@@ -339,7 +361,12 @@ Development dependencies (`--extra dev`) include:
339
361
 
340
362
  ### Lockfile policy (`uv.lock`)
341
363
 
342
- `uv.lock` pins transitive versions for reproducible environments when working from a Git checkout.
364
+ `uv.lock` is committed and pins runtime, development, and build dependencies. CI uses
365
+ `uv sync --locked --extra dev` and fails if the lockfile is out of date. CI first
366
+ installs the locked tools with `--no-install-project`, then installs Backcaster
367
+ with `--no-build-isolation` to keep build dependencies pinned. CI builds
368
+ without build isolation using the locked build tools; wheel smoke tests install
369
+ hash-checked runtime dependencies exported from the same lockfile.
343
370
 
344
371
  Release/source ZIP archives may not include `uv.lock`. If it is missing in your environment, regenerate it locally:
345
372
 
@@ -347,7 +374,8 @@ Release/source ZIP archives may not include `uv.lock`. If it is missing in your
347
374
  uv lock
348
375
  ```
349
376
 
350
- Do not hand-edit `uv.lock`; regenerate it when dependencies change.
377
+ Do not hand-edit `uv.lock`; run `uv lock` and commit the updated lockfile when
378
+ dependencies change. To deliberately refresh all pins, run `uv lock --upgrade`.
351
379
 
352
380
  ---
353
381
 
@@ -1,34 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: backcaster
3
- Version: 0.4.1
4
- Summary: Marcel-style MLB projection CLI using local Lahman CSV data
5
- License-Expression: MIT
6
- Project-URL: Homepage, https://github.com/jeffreywevans/backcaster
7
- Project-URL: Repository, https://github.com/jeffreywevans/backcaster
8
- Project-URL: Issues, https://github.com/jeffreywevans/backcaster/issues
9
- Classifier: Programming Language :: Python :: 3
10
- Classifier: Programming Language :: Python :: 3.13
11
- Classifier: Programming Language :: Python :: 3.14
12
- Classifier: Operating System :: OS Independent
13
- Classifier: Environment :: Console
14
- Classifier: Development Status :: 4 - Beta
15
- Classifier: Intended Audience :: Science/Research
16
- Classifier: Topic :: Scientific/Engineering :: Information Analysis
17
- Requires-Python: >=3.13
18
- Description-Content-Type: text/markdown
19
- License-File: LICENSE
20
- License-File: THIRD_PARTY_NOTICES.md
21
- Requires-Dist: pandas>=2.0
22
- Requires-Dist: numpy>=2.0
23
- Provides-Extra: dev
24
- Requires-Dist: pytest>=8.0; extra == "dev"
25
- Requires-Dist: pytest-cov>=5.0; extra == "dev"
26
- Requires-Dist: ruff>=0.11.0; extra == "dev"
27
- Requires-Dist: mypy>=1.10; extra == "dev"
28
- Requires-Dist: pandas-stubs>=2.2; extra == "dev"
29
- Requires-Dist: build>=1.2; extra == "dev"
30
- Dynamic: license-file
31
-
32
1
  # Backcaster
33
2
 
34
3
  Backcaster is a Python CLI that produces **Marcel-style MLB projections** from the prior three seasons of data using local Lahman CSV files and `pandas`.
@@ -56,10 +25,10 @@ Upgrade to the latest release available on PyPI:
56
25
  python -m pip install --upgrade backcaster
57
26
  ```
58
27
 
59
- To install a specific release, such as 0.4.1:
28
+ To install a specific release, such as 0.4.2:
60
29
 
61
30
  ```powershell
62
- python -m pip install --upgrade backcaster==0.4.1
31
+ python -m pip install --upgrade backcaster==0.4.2
63
32
  ```
64
33
 
65
34
  Run the command using the same Python environment where you run Backcaster.
@@ -73,7 +42,7 @@ python -m pip show backcaster
73
42
 
74
43
  ```bash
75
44
  # from repo root
76
- uv sync
45
+ uv sync --locked
77
46
  ```
78
47
 
79
48
  This installs runtime dependencies and the `backcaster` CLI entrypoint defined in `pyproject.toml`.
@@ -81,10 +50,10 @@ This installs runtime dependencies and the `backcaster` CLI entrypoint defined i
81
50
  ### Install in editable/dev mode
82
51
 
83
52
  ```bash
84
- uv sync --extra dev
53
+ uv sync --locked --extra dev
85
54
  ```
86
55
 
87
- This adds test/lint/type-check/build tooling (`pytest`, `pytest-cov`, `ruff`, `mypy`, `pandas-stubs`, `build`).
56
+ This adds test/lint/type-check/build tooling (`pytest`, `pytest-cov`, `ruff`, `mypy`, `pandas-stubs`, `build`, `setuptools`, `wheel`, `twine`).
88
57
 
89
58
  ### Build/install as a package
90
59
 
@@ -102,7 +71,7 @@ The `player`, `team`, and `batch` commands also require `--kind batting|pitching
102
71
  The `hitters` and `pitchers` commands choose their kind automatically.
103
72
 
104
73
  Subcommand-specific required options:
105
- - `player` requires `--name`
74
+ - `player` requires exactly one of `--name` or `--player-id`
106
75
  - `team` requires `--team`
107
76
  - `batch`, `hitters`, and `pitchers` require no player or team option
108
77
 
@@ -133,7 +102,9 @@ Both commands support `--data-dir`, `--format cli|csv|html`, and `--out`.
133
102
  They are shortcuts for `batch --kind batting` and `batch --kind pitching`.
134
103
  For a different data directory, add `--data-dir "C:\Baseball\lahman"`.
135
104
  For HTML, use `--format html --out hitters_2026.html` (or a pitcher output filename).
136
- File formats require `--out`; the CLI format prints to the terminal.
105
+ File formats require `--out`; the CLI format prints to the terminal and rejects
106
+ `--out`. Invalid output options fail before data is loaded. Unwritable output
107
+ paths report an error on stderr and exit with status 2.
137
108
 
138
109
  Players qualify for selection by meeting the existing PA/IP threshold in at least
139
110
  one of those three seasons. This is a historical eligibility list, not an active
@@ -195,7 +166,15 @@ Blank numeric counting-stat cells are also treated as zero; use a dataset with
195
166
  complete records for the seasons you intend to project.
196
167
  Pitching `IPouts` is divided by three for decimal innings, and `BFP` supplies
197
168
  batters faced. Lahman player IDs keep different players with the same name separate;
198
- ambiguous name lookups report the matching IDs.
169
+ ambiguous name lookups report the matching IDs. Select one with `--player-id`
170
+ instead of `--name`, for example:
171
+
172
+ ```bash
173
+ backcaster player --player-id bettsmo01 --year 2026 --kind batting
174
+ ```
175
+
176
+ IDs match Lahman `People.csv.playerID` exactly and retain the same qualification
177
+ rules as name lookups.
199
178
 
200
179
  Batter league regression and rebaselining use all non-pitcher player-seasons;
201
180
  qualification selects the displayed players. Pitching retains its qualified-only
@@ -226,7 +205,7 @@ backcaster player --name "Mookie Betts" --year 2026 --kind batting --format csv
226
205
  backcaster team --team LAN --year 2026 --kind pitching --format html --out ./out/lan_pitching_2026.html
227
206
  ```
228
207
 
229
- > `--out` is required for `csv` and `html` formats.
208
+ > `--out` is required for `csv` and `html` formats and cannot be used with `cli`.
230
209
 
231
210
  ---
232
211
 
@@ -250,6 +229,8 @@ Terminal, CSV, and HTML outputs use the same display rules:
250
229
  up. Age and year are displayed as whole numbers too.
251
230
  - BA, OBP, SLG, and OPS always show three decimal places, including `1.048` OPS.
252
231
  BA is the canonical batter output label (previously AVG).
232
+ Rates with zero denominators remain undefined (NaN) and display blank;
233
+ OPS is undefined when OBP or SLG is undefined.
253
234
  - ERA shows two decimals; WHIP shows three. W-L% shows a three-decimal
254
235
  fraction, such as `0.524`. H9, HR9, BB9, and SO9 show one decimal; SO/W shows two.
255
236
  W-L% without any decisions and SO/W without walks are undefined and display blank.
@@ -260,7 +241,7 @@ Terminal, CSV, and HTML outputs use the same display rules:
260
241
  Calculations and Python projection DataFrames retain full precision. Rates are
261
242
  computed from unrounded totals, then formatted; they are not recalculated from
262
243
  rounded displayed counts. Independently rounded totals may therefore not add up
263
- exactly. Reliability formatting is unchanged.
244
+ exactly. Reliability shows six decimal places in CLI, CSV, and HTML output.
264
245
 
265
246
  **CSV IP uses baseball notation too.** Convert `161.1` to `161 + 1/3` before doing
266
247
  arithmetic; treating it as 161.1 decimal innings gives incorrect results. Internally,
@@ -326,6 +307,13 @@ Lahman distribution from the same edition. Existing online caches are ignored.
326
307
 
327
308
  ---
328
309
 
310
+ ### Python data-loading errors
311
+
312
+ Local data-loading and validation failures raise
313
+ `backcaster.data.DataLoadError` (renamed from `DataFetchError`). Player lookup
314
+ failures continue to raise `PlayerLookupError`. The CLI reports these failures
315
+ and filesystem errors on stderr with exit status 2.
316
+
329
317
  ## Dependency notes
330
318
 
331
319
  Runtime dependencies are intentionally minimal:
@@ -339,7 +327,12 @@ Development dependencies (`--extra dev`) include:
339
327
 
340
328
  ### Lockfile policy (`uv.lock`)
341
329
 
342
- `uv.lock` pins transitive versions for reproducible environments when working from a Git checkout.
330
+ `uv.lock` is committed and pins runtime, development, and build dependencies. CI uses
331
+ `uv sync --locked --extra dev` and fails if the lockfile is out of date. CI first
332
+ installs the locked tools with `--no-install-project`, then installs Backcaster
333
+ with `--no-build-isolation` to keep build dependencies pinned. CI builds
334
+ without build isolation using the locked build tools; wheel smoke tests install
335
+ hash-checked runtime dependencies exported from the same lockfile.
343
336
 
344
337
  Release/source ZIP archives may not include `uv.lock`. If it is missing in your environment, regenerate it locally:
345
338
 
@@ -347,7 +340,8 @@ Release/source ZIP archives may not include `uv.lock`. If it is missing in your
347
340
  uv lock
348
341
  ```
349
342
 
350
- Do not hand-edit `uv.lock`; regenerate it when dependencies change.
343
+ Do not hand-edit `uv.lock`; run `uv lock` and commit the updated lockfile when
344
+ dependencies change. To deliberately refresh all pins, run `uv lock --upgrade`.
351
345
 
352
346
  ---
353
347
 
@@ -21,7 +21,7 @@ classifiers = [
21
21
  ]
22
22
  requires-python = ">=3.13"
23
23
  dependencies = [
24
- "pandas>=2.0",
24
+ "pandas>=2.2.2",
25
25
  "numpy>=2.0",
26
26
  ]
27
27
 
@@ -38,6 +38,9 @@ dev = [
38
38
  "mypy>=1.10",
39
39
  "pandas-stubs>=2.2",
40
40
  "build>=1.2",
41
+ "setuptools>=77",
42
+ "wheel",
43
+ "twine",
41
44
  ]
42
45
 
43
46
  [project.scripts]
@@ -1,4 +1,4 @@
1
1
  """backcaster package."""
2
2
 
3
3
  __all__ = ["__version__"]
4
- __version__ = "0.4.1"
4
+ __version__ = "0.4.2"
@@ -144,7 +144,7 @@ def _summarize(projected: pd.DataFrame, pa: pd.Series) -> pd.DataFrame:
144
144
  ).replace(0, np.nan)
145
145
  out["SLG"] = out["TB"] / out["AB"].replace(0, np.nan)
146
146
  out["OPS"] = out["OBP"] + out["SLG"]
147
- return out.fillna(0)
147
+ return out
148
148
 
149
149
 
150
150
  def project_batters(seasons: pd.DataFrame, year: int) -> pd.DataFrame:
@@ -7,7 +7,7 @@ from pathlib import Path
7
7
 
8
8
  import pandas as pd
9
9
 
10
- from backcaster.data import DataFetchError, PlayerLookupError
10
+ from backcaster.data import DataLoadError, PlayerLookupError
11
11
  from backcaster.lahman import LahmanSource
12
12
  from backcaster.marcel import ProjectionError
13
13
  from backcaster.outputs import to_cli_table, to_csv, to_html
@@ -25,12 +25,19 @@ def _default_data_dir() -> str:
25
25
  return os.environ.get("LAHMAN_DATA_DIR", default)
26
26
 
27
27
 
28
+ def _validate_output_options(output_format: str, out: str | None) -> None:
29
+ if output_format == "cli" and out is not None:
30
+ raise ValueError("--out cannot be used with --format cli; choose --format csv or html")
31
+ if output_format in _FILE_RENDERERS and not out:
32
+ raise ValueError(f"--out is required for {output_format} output")
33
+
34
+
28
35
  def _render(df: pd.DataFrame, output_format: str, out: str | None) -> None:
36
+ _validate_output_options(output_format, out)
29
37
  if output_format == "cli":
30
38
  print(to_cli_table(df))
31
39
  elif output_format in _FILE_RENDERERS:
32
- if not out:
33
- raise ValueError(f"--out is required for {output_format} output")
40
+ assert out is not None
34
41
  _FILE_RENDERERS[output_format](df, out)
35
42
  else:
36
43
  raise ValueError(f"Unsupported format: {output_format}")
@@ -47,7 +54,7 @@ def build_parser() -> argparse.ArgumentParser:
47
54
  else:
48
55
  sp.set_defaults(kind=kind)
49
56
  sp.add_argument("--format", choices=_OUTPUT_FORMAT_CHOICES, default="cli")
50
- sp.add_argument("--out")
57
+ sp.add_argument("--out", help="Output path (required for csv/html; invalid with cli)")
51
58
  sp.add_argument(
52
59
  "--data-dir",
53
60
  default=_default_data_dir(),
@@ -56,7 +63,11 @@ def build_parser() -> argparse.ArgumentParser:
56
63
 
57
64
  pp = sub.add_parser("player")
58
65
  add_common(pp)
59
- pp.add_argument("--name", required=True)
66
+ player_selector = pp.add_mutually_exclusive_group(required=True)
67
+ player_selector.add_argument("--name", help="Player name (must identify one qualified player)")
68
+ player_selector.add_argument(
69
+ "--player-id", help="Exact Lahman player ID (for example, bettsmo01)"
70
+ )
60
71
 
61
72
  tp = sub.add_parser("team")
62
73
  add_common(tp)
@@ -75,16 +86,18 @@ def main(argv: list[str] | None = None) -> int:
75
86
  parser = build_parser()
76
87
  args = parser.parse_args(argv)
77
88
  try:
89
+ _validate_output_options(args.format, args.out)
78
90
  result = LahmanSource(args.data_dir).project(
79
91
  args.command,
80
92
  args.year,
81
93
  args.kind,
82
94
  name=getattr(args, "name", None),
83
95
  team=getattr(args, "team", None),
96
+ player_id=getattr(args, "player_id", None),
84
97
  )
85
98
  _render(result, args.format, args.out)
86
99
  return 0
87
- except (DataFetchError, PlayerLookupError, ProjectionError, ValueError) as exc:
100
+ except (DataLoadError, PlayerLookupError, ProjectionError, ValueError, OSError) as exc:
88
101
  print(f"Error: {exc}", file=sys.stderr)
89
102
  return 2
90
103
 
@@ -1,8 +1,8 @@
1
1
  """Errors reported when local source data cannot be loaded or resolved."""
2
2
 
3
3
 
4
- class DataFetchError(RuntimeError):
5
- pass
4
+ class DataLoadError(RuntimeError):
5
+ """Local source files are missing, unreadable, or invalid."""
6
6
 
7
7
 
8
8
  class PlayerLookupError(RuntimeError):
@@ -7,7 +7,7 @@ from pathlib import Path
7
7
  import pandas as pd
8
8
 
9
9
  from backcaster.batters import project_batters
10
- from backcaster.data import DataFetchError, PlayerLookupError
10
+ from backcaster.data import DataLoadError, PlayerLookupError
11
11
  from backcaster.marcel import ProjectionError, project_player, project_team
12
12
 
13
13
 
@@ -15,32 +15,41 @@ class LahmanSource:
15
15
  def __init__(self, directory: str) -> None:
16
16
  self.directory = Path(directory)
17
17
  self.tables: dict[str, pd.DataFrame] = {}
18
+ self._requested_columns: dict[str, set[str]] = {}
19
+ self._people: pd.DataFrame | None = None
18
20
 
19
21
  def read(self, name: str, required: set[str]) -> pd.DataFrame:
20
- if name not in self.tables:
22
+ optional = {"Batting": {"HBP", "SF", "SH"}, "People": {"birthYear"}}.get(name, set())
23
+ columns = required | optional
24
+ if name not in self.tables or columns - self._requested_columns[name]:
25
+ columns |= self._requested_columns.get(name, set())
21
26
  paths = [
22
27
  p for p in self.directory.glob("*") if p.name.casefold() == f"{name}.csv".casefold()
23
28
  ]
24
29
  if len(paths) != 1:
25
- raise DataFetchError(
30
+ raise DataLoadError(
26
31
  f"Expected {name}.csv in Lahman directory '{self.directory}'. "
27
32
  "Extract the Lahman CSVs there or select their directory with --data-dir."
28
33
  )
29
34
  try:
30
- self.tables[name] = pd.read_csv(paths[0], encoding="utf-8-sig", low_memory=False)
35
+ self.tables[name] = pd.read_csv(
36
+ paths[0],
37
+ encoding="utf-8-sig",
38
+ low_memory=False,
39
+ usecols=lambda column: column in columns,
40
+ )
41
+ self._requested_columns[name] = columns
31
42
  except (OSError, ValueError) as exc:
32
- raise DataFetchError(f"Unable to read Lahman {name}.csv: {exc}") from exc
43
+ raise DataLoadError(f"Unable to read Lahman {name}.csv: {exc}") from exc
33
44
  frame = self.tables[name]
34
45
  missing = required - set(frame.columns)
35
46
  if missing:
36
- raise DataFetchError(
37
- f"Lahman {name}.csv missing columns: {', '.join(sorted(missing))}."
38
- )
47
+ raise DataLoadError(f"Lahman {name}.csv missing columns: {', '.join(sorted(missing))}.")
39
48
  return frame
40
49
 
41
50
  def season(self, year: int, kind: str, *, qualified: bool = True) -> pd.DataFrame:
42
51
  if kind not in {"batting", "pitching"}:
43
- raise DataFetchError(f"Invalid kind {kind!r}.")
52
+ raise DataLoadError(f"Invalid kind {kind!r}.")
44
53
  name = "Batting" if kind == "batting" else "Pitching"
45
54
  components = (
46
55
  ["AB", "H", "2B", "3B", "HR", "BB", "SO", "R", "RBI", "SB", "CS", "IBB", "GIDP"]
@@ -66,7 +75,7 @@ class LahmanSource:
66
75
  stats = self.read(name, {"playerID", "yearID", "stint", "teamID", *components})
67
76
  stats = stats[stats["yearID"] == year].copy()
68
77
  if stats.empty:
69
- raise DataFetchError(
78
+ raise DataLoadError(
70
79
  f"Lahman {name}.csv has no data for {year}; choose a covered year or update the dataset."
71
80
  )
72
81
  games = self._season_games(year)
@@ -79,7 +88,7 @@ class LahmanSource:
79
88
  stats[components] = stats[components].apply(pd.to_numeric, errors="raise").fillna(0)
80
89
  stats["stint"] = pd.to_numeric(stats["stint"], errors="raise")
81
90
  except (ValueError, TypeError) as exc:
82
- raise DataFetchError(
91
+ raise DataLoadError(
83
92
  f"Invalid numeric data in Lahman {name}.csv for {year}: {exc}"
84
93
  ) from exc
85
94
  totals = stats.groupby("playerID", sort=False)[components].sum()
@@ -100,7 +109,7 @@ class LahmanSource:
100
109
  if qualified:
101
110
  totals = totals[totals["Qualified"]]
102
111
  if totals.empty:
103
- raise DataFetchError(f"No qualified {kind} players in Lahman for {year}.")
112
+ raise DataLoadError(f"No qualified {kind} players in Lahman for {year}.")
104
113
  totals = totals.rename(columns={"GIDP": "GDP"})
105
114
  totals = self._attach_names(totals)
106
115
  totals["PlayerID"] = totals["playerID"]
@@ -111,32 +120,39 @@ class LahmanSource:
111
120
  teams = self.read("Teams", {"yearID", "teamID", "G"})
112
121
  games = pd.to_numeric(teams.loc[teams["yearID"] == year, "G"], errors="coerce").max()
113
122
  if pd.isna(games) or games <= 0:
114
- raise DataFetchError(f"Lahman Teams.csv has no valid game counts for {year}.")
123
+ raise DataLoadError(f"Lahman Teams.csv has no valid game counts for {year}.")
115
124
  return float(games)
116
125
 
117
126
  def _attach_names(self, totals: pd.DataFrame) -> pd.DataFrame:
118
- people = self.read("People", {"playerID", "nameFirst", "nameLast"}).copy()
119
- people["Name"] = (
120
- people["nameFirst"].fillna("") + " " + people["nameLast"].fillna("")
121
- ).str.strip()
122
- try:
123
- totals = totals.reset_index().merge(
124
- people[["playerID", "Name", *(["birthYear"] if "birthYear" in people else [])]],
125
- on="playerID",
126
- how="left",
127
- validate="many_to_one",
128
- )
129
- except pd.errors.MergeError as exc:
130
- raise DataFetchError("Duplicate player IDs in Lahman People.csv.") from exc
127
+ if self._people is None:
128
+ people = self.read("People", {"playerID", "nameFirst", "nameLast"})
129
+ if people["playerID"].duplicated().any():
130
+ raise DataLoadError("Duplicate player IDs in Lahman People.csv.")
131
+ names = (
132
+ people["nameFirst"].fillna("") + " " + people["nameLast"].fillna("")
133
+ ).str.strip()
134
+ self._people = people.assign(Name=names).set_index("playerID")
135
+ self._people = self._people[["Name", *(["birthYear"] if "birthYear" in people else [])]]
136
+ totals = totals.reset_index()
137
+ for column in self._people:
138
+ totals[column] = totals["playerID"].map(self._people[column])
131
139
  if totals["Name"].isna().any():
132
- raise DataFetchError(
140
+ raise DataLoadError(
133
141
  "Lahman People.csv is missing players present in season statistics."
134
142
  )
135
143
  return totals
136
144
 
137
145
  def project(
138
- self, command: str, year: int, kind: str, name: str | None = None, team: str | None = None
146
+ self,
147
+ command: str,
148
+ year: int,
149
+ kind: str,
150
+ name: str | None = None,
151
+ team: str | None = None,
152
+ player_id: str | None = None,
139
153
  ) -> pd.DataFrame:
154
+ if name is not None and player_id is not None:
155
+ raise ValueError("Specify either name or player_id, not both.")
140
156
  batting = pd.DataFrame()
141
157
  if kind == "batting":
142
158
  seasons, batting = self._batting_population(year)
@@ -148,7 +164,7 @@ class LahmanSource:
148
164
  )
149
165
  eligible = seasons[seasons["Qualified"]]
150
166
  if command == "player":
151
- candidates = self._player_seasons(eligible, name)
167
+ candidates = self._player_seasons(eligible, name, player_id)
152
168
  matches = seasons[seasons["PlayerID"] == candidates["PlayerID"].iloc[0]]
153
169
  if kind == "batting":
154
170
  return batting[batting["PlayerID"] == matches["PlayerID"].iloc[0]].reset_index(
@@ -182,15 +198,15 @@ class LahmanSource:
182
198
  pd.to_numeric, errors="raise"
183
199
  )
184
200
  except (ValueError, TypeError) as exc:
185
- raise DataFetchError("Invalid game counts in Lahman Appearances.csv.") from exc
201
+ raise DataLoadError("Invalid game counts in Lahman Appearances.csv.") from exc
186
202
  counts = appearances[["G_all", "G_p"]]
187
203
  invalid = counts.replace([float("inf"), -float("inf")], float("nan")).isna() | (counts < 0)
188
204
  if invalid.any().any() or (appearances["G_p"] > appearances["G_all"]).any():
189
- raise DataFetchError("Invalid game counts in Lahman Appearances.csv.")
205
+ raise DataLoadError("Invalid game counts in Lahman Appearances.csv.")
190
206
  games = appearances.groupby(["playerID", "yearID"])[["G_all", "G_p"]].sum(min_count=1)
191
207
  seasons = seasons.merge(games, left_on=["PlayerID", "Season"], right_index=True, how="left")
192
208
  if seasons[["G_all", "G_p"]].isna().any().any():
193
- raise DataFetchError(
209
+ raise DataLoadError(
194
210
  "Appearances.csv must cover every batting player-season with game counts."
195
211
  )
196
212
  # A primary pitcher appears as pitcher in more than half of their games.
@@ -202,7 +218,17 @@ class LahmanSource:
202
218
  raise ProjectionError("No qualified non-pitcher batters in the prior three seasons.")
203
219
  return seasons, projected
204
220
 
205
- def _player_seasons(self, seasons: pd.DataFrame, name: str | None) -> pd.DataFrame:
221
+ def _player_seasons(
222
+ self, seasons: pd.DataFrame, name: str | None, player_id: str | None = None
223
+ ) -> pd.DataFrame:
224
+ if player_id is not None:
225
+ matches = seasons[seasons["PlayerID"] == player_id]
226
+ if matches.empty:
227
+ raise PlayerLookupError(
228
+ f"No qualified Lahman player found for player ID '{player_id}' "
229
+ "in the prior three seasons."
230
+ )
231
+ return matches
206
232
  normalized = " ".join((name or "").casefold().split())
207
233
  matches = seasons[
208
234
  seasons["Name"].map(lambda value: " ".join(value.casefold().split())) == normalized
@@ -214,6 +240,7 @@ class LahmanSource:
214
240
  )
215
241
  if len(ids) > 1:
216
242
  raise PlayerLookupError(
217
- f"Ambiguous Lahman name '{name}'; matching player IDs: {', '.join(ids)}."
243
+ f"Ambiguous Lahman name '{name}'; matching player IDs: {', '.join(ids)}. "
244
+ "Select one with --player-id instead of --name."
218
245
  )
219
246
  return matches