backcaster 0.3.6__tar.gz → 0.4.0__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.
- {backcaster-0.3.6/src/backcaster.egg-info → backcaster-0.4.0}/PKG-INFO +51 -7
- {backcaster-0.3.6 → backcaster-0.4.0}/README.md +50 -6
- {backcaster-0.3.6 → backcaster-0.4.0}/src/backcaster/__init__.py +1 -1
- backcaster-0.4.0/src/backcaster/html_report.py +205 -0
- backcaster-0.4.0/src/backcaster/outputs.py +93 -0
- {backcaster-0.3.6 → backcaster-0.4.0/src/backcaster.egg-info}/PKG-INFO +51 -7
- {backcaster-0.3.6 → backcaster-0.4.0}/src/backcaster.egg-info/SOURCES.txt +2 -0
- backcaster-0.4.0/tests/test_html_report.py +160 -0
- backcaster-0.4.0/tests/test_outputs.py +142 -0
- backcaster-0.3.6/src/backcaster/outputs.py +0 -24
- backcaster-0.3.6/tests/test_outputs.py +0 -29
- {backcaster-0.3.6 → backcaster-0.4.0}/LICENSE +0 -0
- {backcaster-0.3.6 → backcaster-0.4.0}/THIRD_PARTY_NOTICES.md +0 -0
- {backcaster-0.3.6 → backcaster-0.4.0}/pyproject.toml +0 -0
- {backcaster-0.3.6 → backcaster-0.4.0}/setup.cfg +0 -0
- {backcaster-0.3.6 → backcaster-0.4.0}/src/backcaster/batters.py +0 -0
- {backcaster-0.3.6 → backcaster-0.4.0}/src/backcaster/cli.py +0 -0
- {backcaster-0.3.6 → backcaster-0.4.0}/src/backcaster/data.py +0 -0
- {backcaster-0.3.6 → backcaster-0.4.0}/src/backcaster/lahman.py +0 -0
- {backcaster-0.3.6 → backcaster-0.4.0}/src/backcaster/marcel.py +0 -0
- {backcaster-0.3.6 → backcaster-0.4.0}/src/backcaster/normalize.py +0 -0
- {backcaster-0.3.6 → backcaster-0.4.0}/src/backcaster/schemas.py +0 -0
- {backcaster-0.3.6 → backcaster-0.4.0}/src/backcaster.egg-info/dependency_links.txt +0 -0
- {backcaster-0.3.6 → backcaster-0.4.0}/src/backcaster.egg-info/entry_points.txt +0 -0
- {backcaster-0.3.6 → backcaster-0.4.0}/src/backcaster.egg-info/requires.txt +0 -0
- {backcaster-0.3.6 → backcaster-0.4.0}/src/backcaster.egg-info/top_level.txt +0 -0
- {backcaster-0.3.6 → backcaster-0.4.0}/tests/test_batters.py +0 -0
- {backcaster-0.3.6 → backcaster-0.4.0}/tests/test_cli.py +0 -0
- {backcaster-0.3.6 → backcaster-0.4.0}/tests/test_lahman.py +0 -0
- {backcaster-0.3.6 → backcaster-0.4.0}/tests/test_marcel_math.py +0 -0
- {backcaster-0.3.6 → backcaster-0.4.0}/tests/test_normalize.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: backcaster
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
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
|
|
@@ -56,10 +56,10 @@ Upgrade to the latest release available on PyPI:
|
|
|
56
56
|
python -m pip install --upgrade backcaster
|
|
57
57
|
```
|
|
58
58
|
|
|
59
|
-
To install a specific release, such as 0.
|
|
59
|
+
To install a specific release, such as 0.4.0:
|
|
60
60
|
|
|
61
61
|
```powershell
|
|
62
|
-
python -m pip install --upgrade backcaster==0.
|
|
62
|
+
python -m pip install --upgrade backcaster==0.4.0
|
|
63
63
|
```
|
|
64
64
|
|
|
65
65
|
Run the command using the same Python environment where you run Backcaster.
|
|
@@ -234,19 +234,63 @@ backcaster team --team LAN --year 2026 --kind pitching --format html --out ./out
|
|
|
234
234
|
|
|
235
235
|
### CLI table output (`--format cli`, default)
|
|
236
236
|
|
|
237
|
-
|
|
237
|
+
Selected columns from a 2025 Mookie Betts projection (actual output includes
|
|
238
|
+
additional counting statistics, reliability, and age):
|
|
238
239
|
|
|
239
240
|
```text
|
|
240
|
-
|
|
241
|
-
|
|
241
|
+
PlayerID PA AB H HR AVG OBP SLG OPS
|
|
242
|
+
bettsmo01 527 461 128 24 0.278 0.362 0.507 0.869
|
|
242
243
|
```
|
|
243
244
|
|
|
245
|
+
### Number formatting and baseball innings
|
|
246
|
+
|
|
247
|
+
Terminal, CSV, and HTML outputs use the same display rules:
|
|
248
|
+
|
|
249
|
+
- Counting statistics are rounded to the nearest whole number, with halves rounded
|
|
250
|
+
up. Age and year are displayed as whole numbers too.
|
|
251
|
+
- AVG, OBP, SLG, and OPS always show three decimal places, including `1.048` OPS.
|
|
252
|
+
- ERA and WHIP always show two decimal places, such as `3.46` and `1.23`.
|
|
253
|
+
- IP is rounded to the nearest out and displayed in baseball notation:
|
|
254
|
+
`161.0` is 161 innings, `161.1` is 161 innings plus one out, and `161.2` is
|
|
255
|
+
161 innings plus two outs. Three outs carry into the next whole inning.
|
|
256
|
+
|
|
257
|
+
Calculations and Python projection DataFrames retain full precision. Rates are
|
|
258
|
+
computed from unrounded totals, then formatted; they are not recalculated from
|
|
259
|
+
rounded displayed counts. Independently rounded totals may therefore not add up
|
|
260
|
+
exactly. Reliability formatting is unchanged.
|
|
261
|
+
|
|
262
|
+
**CSV IP uses baseball notation too.** Convert `161.1` to `161 + 1/3` before doing
|
|
263
|
+
arithmetic; treating it as 161.1 decimal innings gives incorrect results. Internally,
|
|
264
|
+
Backcaster uses decimal innings (`IPouts / 3`), so ERA and WHIP calculations use
|
|
265
|
+
true innings rather than the displayed notation.
|
|
266
|
+
|
|
244
267
|
### CSV output (`--format csv`)
|
|
245
268
|
A single-row CSV for `player`; multi-row CSV for `team`, `batch`, `hitters`, and `pitchers`.
|
|
246
269
|
Both populations include `PlayerID` for matching records across files.
|
|
247
270
|
|
|
248
271
|
### HTML output (`--format html`)
|
|
249
|
-
A
|
|
272
|
+
A standalone report with embedded CSS and JavaScript, ready to open in a browser
|
|
273
|
+
or share as one file. No server, CDN, fonts, or internet connection are required.
|
|
274
|
+
|
|
275
|
+
```powershell
|
|
276
|
+
backcaster hitters --year 2025 --format html --out hitters_2025.html
|
|
277
|
+
backcaster pitchers --year 2025 --format html --out pitchers_2025.html
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
The first four columns are **Name, Year, Player ID, Age**, followed by counting
|
|
281
|
+
statistics and batting/pitching rates in a consistent order. The report includes:
|
|
282
|
+
|
|
283
|
+
- A player-count, projection-year, and projection-type summary.
|
|
284
|
+
- Search across player names, IDs, and statistics.
|
|
285
|
+
- Sortable column headings, with numeric sorting for statistics.
|
|
286
|
+
- A sticky name column/header and horizontal scrolling on narrow screens.
|
|
287
|
+
- A reset control and print layout for browser printing or saving as PDF.
|
|
288
|
+
|
|
289
|
+
Your display precision rules apply, including baseball innings notation. Rates
|
|
290
|
+
retain their trailing decimal places and OPS values above 1.000 display normally.
|
|
291
|
+
Search filters the visible rows; printing respects that current view. Without
|
|
292
|
+
JavaScript, the complete table is still readable. See
|
|
293
|
+
[HTML report customization](docs/HTML_REPORTS.md) for theme and column constants.
|
|
250
294
|
|
|
251
295
|
---
|
|
252
296
|
|
|
@@ -25,10 +25,10 @@ Upgrade to the latest release available on PyPI:
|
|
|
25
25
|
python -m pip install --upgrade backcaster
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
-
To install a specific release, such as 0.
|
|
28
|
+
To install a specific release, such as 0.4.0:
|
|
29
29
|
|
|
30
30
|
```powershell
|
|
31
|
-
python -m pip install --upgrade backcaster==0.
|
|
31
|
+
python -m pip install --upgrade backcaster==0.4.0
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
Run the command using the same Python environment where you run Backcaster.
|
|
@@ -203,19 +203,63 @@ backcaster team --team LAN --year 2026 --kind pitching --format html --out ./out
|
|
|
203
203
|
|
|
204
204
|
### CLI table output (`--format cli`, default)
|
|
205
205
|
|
|
206
|
-
|
|
206
|
+
Selected columns from a 2025 Mookie Betts projection (actual output includes
|
|
207
|
+
additional counting statistics, reliability, and age):
|
|
207
208
|
|
|
208
209
|
```text
|
|
209
|
-
|
|
210
|
-
|
|
210
|
+
PlayerID PA AB H HR AVG OBP SLG OPS
|
|
211
|
+
bettsmo01 527 461 128 24 0.278 0.362 0.507 0.869
|
|
211
212
|
```
|
|
212
213
|
|
|
214
|
+
### Number formatting and baseball innings
|
|
215
|
+
|
|
216
|
+
Terminal, CSV, and HTML outputs use the same display rules:
|
|
217
|
+
|
|
218
|
+
- Counting statistics are rounded to the nearest whole number, with halves rounded
|
|
219
|
+
up. Age and year are displayed as whole numbers too.
|
|
220
|
+
- AVG, OBP, SLG, and OPS always show three decimal places, including `1.048` OPS.
|
|
221
|
+
- ERA and WHIP always show two decimal places, such as `3.46` and `1.23`.
|
|
222
|
+
- IP is rounded to the nearest out and displayed in baseball notation:
|
|
223
|
+
`161.0` is 161 innings, `161.1` is 161 innings plus one out, and `161.2` is
|
|
224
|
+
161 innings plus two outs. Three outs carry into the next whole inning.
|
|
225
|
+
|
|
226
|
+
Calculations and Python projection DataFrames retain full precision. Rates are
|
|
227
|
+
computed from unrounded totals, then formatted; they are not recalculated from
|
|
228
|
+
rounded displayed counts. Independently rounded totals may therefore not add up
|
|
229
|
+
exactly. Reliability formatting is unchanged.
|
|
230
|
+
|
|
231
|
+
**CSV IP uses baseball notation too.** Convert `161.1` to `161 + 1/3` before doing
|
|
232
|
+
arithmetic; treating it as 161.1 decimal innings gives incorrect results. Internally,
|
|
233
|
+
Backcaster uses decimal innings (`IPouts / 3`), so ERA and WHIP calculations use
|
|
234
|
+
true innings rather than the displayed notation.
|
|
235
|
+
|
|
213
236
|
### CSV output (`--format csv`)
|
|
214
237
|
A single-row CSV for `player`; multi-row CSV for `team`, `batch`, `hitters`, and `pitchers`.
|
|
215
238
|
Both populations include `PlayerID` for matching records across files.
|
|
216
239
|
|
|
217
240
|
### HTML output (`--format html`)
|
|
218
|
-
A
|
|
241
|
+
A standalone report with embedded CSS and JavaScript, ready to open in a browser
|
|
242
|
+
or share as one file. No server, CDN, fonts, or internet connection are required.
|
|
243
|
+
|
|
244
|
+
```powershell
|
|
245
|
+
backcaster hitters --year 2025 --format html --out hitters_2025.html
|
|
246
|
+
backcaster pitchers --year 2025 --format html --out pitchers_2025.html
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
The first four columns are **Name, Year, Player ID, Age**, followed by counting
|
|
250
|
+
statistics and batting/pitching rates in a consistent order. The report includes:
|
|
251
|
+
|
|
252
|
+
- A player-count, projection-year, and projection-type summary.
|
|
253
|
+
- Search across player names, IDs, and statistics.
|
|
254
|
+
- Sortable column headings, with numeric sorting for statistics.
|
|
255
|
+
- A sticky name column/header and horizontal scrolling on narrow screens.
|
|
256
|
+
- A reset control and print layout for browser printing or saving as PDF.
|
|
257
|
+
|
|
258
|
+
Your display precision rules apply, including baseball innings notation. Rates
|
|
259
|
+
retain their trailing decimal places and OPS values above 1.000 display normally.
|
|
260
|
+
Search filters the visible rows; printing respects that current view. Without
|
|
261
|
+
JavaScript, the complete table is still readable. See
|
|
262
|
+
[HTML report customization](docs/HTML_REPORTS.md) for theme and column constants.
|
|
219
263
|
|
|
220
264
|
---
|
|
221
265
|
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Self-contained HTML projection reports, with no network assets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from html import escape
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import pandas as pd
|
|
9
|
+
|
|
10
|
+
IDENTITY_COLUMNS = ("Name", "Year", "PlayerID", "Age")
|
|
11
|
+
BATTING_COLUMNS = (
|
|
12
|
+
"PA",
|
|
13
|
+
"AB",
|
|
14
|
+
"H",
|
|
15
|
+
"2B",
|
|
16
|
+
"3B",
|
|
17
|
+
"HR",
|
|
18
|
+
"R",
|
|
19
|
+
"RBI",
|
|
20
|
+
"BB",
|
|
21
|
+
"SO",
|
|
22
|
+
"HBP",
|
|
23
|
+
"SF",
|
|
24
|
+
"SH",
|
|
25
|
+
"SB",
|
|
26
|
+
"CS",
|
|
27
|
+
"AVG",
|
|
28
|
+
"OBP",
|
|
29
|
+
"SLG",
|
|
30
|
+
"OPS",
|
|
31
|
+
"Reliability",
|
|
32
|
+
)
|
|
33
|
+
PITCHING_COLUMNS = ("IP", "ER", "H", "HR", "BB", "SO", "BF", "ERA", "WHIP", "Reliability")
|
|
34
|
+
COLUMN_LABELS = {"PlayerID": "Player ID"}
|
|
35
|
+
COLUMN_HELP = {
|
|
36
|
+
"IP": "Innings in baseball notation: .1 = one out, .2 = two outs",
|
|
37
|
+
"Reliability": "Observed-evidence weight; not a probability of prediction accuracy",
|
|
38
|
+
"Age": "Projection year minus birth year",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
REPORT_CSS = r"""
|
|
42
|
+
:root { --brand:#153e3b; --brand-dark:#102e2c; --accent:#78bd82; --canvas:#f3f6f5; --surface:#fff; --ink:#183634; --muted:#617571; --line:#dce5e2; --stripe:#f8faf9; --radius:12px; }
|
|
43
|
+
* { box-sizing:border-box; }
|
|
44
|
+
body { margin:0; background:var(--canvas); color:var(--ink); font:14px/1.5 'Segoe UI',system-ui,sans-serif; }
|
|
45
|
+
.topbar { background:var(--brand-dark); color:white; padding:18px 32px; display:flex; align-items:center; justify-content:space-between; gap:20px; }
|
|
46
|
+
.brand { font-size:19px; font-weight:700; letter-spacing:-.4px; }
|
|
47
|
+
.brand-mark { display:inline-block; width:12px; height:12px; background:var(--accent); border-radius:3px; transform:rotate(-15deg); margin-right:11px; }
|
|
48
|
+
.topbar small { color:#bcd4cb; letter-spacing:.08em; font-size:11px; }
|
|
49
|
+
main { max-width:1720px; margin:auto; padding:32px; }
|
|
50
|
+
.eyebrow { color:var(--muted); font-weight:700; letter-spacing:.13em; text-transform:uppercase; font-size:11px; margin:0 0 7px; }
|
|
51
|
+
h1 { font-size:30px; line-height:1.2; margin:0 0 10px; font-weight:650; letter-spacing:-.8px; }
|
|
52
|
+
.subtitle { color:var(--muted); margin:0; }
|
|
53
|
+
.intro { margin-bottom:24px; }
|
|
54
|
+
.cards { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:16px; margin-bottom:26px; }
|
|
55
|
+
.card { background:var(--surface); border:1px solid var(--line); border-radius:var(--radius); padding:18px 22px; border-top:3px solid var(--accent); }
|
|
56
|
+
.card-label { color:var(--muted); font-size:12px; margin-bottom:4px; }
|
|
57
|
+
.card-value { font-size:25px; font-weight:650; letter-spacing:-.5px; overflow-wrap:anywhere; }
|
|
58
|
+
.card-note { color:var(--muted); font-size:11px; margin-top:3px; }
|
|
59
|
+
.panel { border:1px solid var(--line); border-radius:var(--radius); background:white; overflow:hidden; box-shadow:0 3px 14px #18363405; }
|
|
60
|
+
.toolbar { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:14px; padding:20px; border-bottom:1px solid var(--line); }
|
|
61
|
+
h2 { margin:0; font-size:16px; font-weight:650; }
|
|
62
|
+
.table-hint { margin:3px 0 0; color:var(--muted); font-size:12px; }
|
|
63
|
+
.controls { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
|
|
64
|
+
label { font-size:12px; font-weight:600; }
|
|
65
|
+
input { border:1px solid #baccc6; border-radius:7px; padding:9px 11px; font:inherit; width:245px; background:#fff; }
|
|
66
|
+
button { cursor:pointer; font:inherit; }
|
|
67
|
+
.action { border:1px solid #baccc6; border-radius:7px; padding:9px 13px; background:white; color:var(--brand); font-weight:600; font-size:12px; }
|
|
68
|
+
.action.primary { background:var(--brand); border-color:var(--brand); color:white; }
|
|
69
|
+
button:hover { filter:brightness(.95); }
|
|
70
|
+
input:focus,button:focus-visible,.table-scroll:focus-visible { outline:3px solid #78bd82; outline-offset:2px; }
|
|
71
|
+
.table-scroll { overflow:auto; max-height:70vh; }
|
|
72
|
+
table { border-collapse:separate; border-spacing:0; min-width:100%; width:max-content; font-size:12px; font-variant-numeric:tabular-nums; }
|
|
73
|
+
caption { text-align:left; padding:0; }
|
|
74
|
+
.sr-only { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
|
|
75
|
+
th,td { padding:11px 13px; white-space:nowrap; text-align:right; border-bottom:1px solid #eaf0ed; }
|
|
76
|
+
th { position:sticky; top:0; background:#eaf0ed; color:#49605b; z-index:2; font-size:11px; letter-spacing:.02em; }
|
|
77
|
+
th button { background:none; color:inherit; border:0; padding:0; font-weight:700; white-space:nowrap; }
|
|
78
|
+
th button::after { content:' ↕'; opacity:.4; font-weight:400; }
|
|
79
|
+
th[aria-sort=ascending] button::after { content:' ↑'; opacity:1; }
|
|
80
|
+
th[aria-sort=descending] button::after { content:' ↓'; opacity:1; }
|
|
81
|
+
td:first-child,th:first-child { text-align:left; position:sticky; left:0; }
|
|
82
|
+
td:first-child { background:inherit; font-weight:600; z-index:1; min-width:175px; }
|
|
83
|
+
th:first-child { z-index:3; }
|
|
84
|
+
td:nth-child(3),th:nth-child(3) { text-align:left; }
|
|
85
|
+
td:nth-child(3) { font-family:ui-monospace,Consolas,monospace; font-size:11px; color:var(--muted); }
|
|
86
|
+
tbody tr { background:white; }
|
|
87
|
+
tbody tr:nth-child(even) { background:var(--stripe); }
|
|
88
|
+
tbody tr:hover { background:#eaf5ed; }
|
|
89
|
+
.rate { color:var(--brand); font-weight:650; background-color:#78bd8209; }
|
|
90
|
+
.table-footer { padding:12px 20px; font-size:12px; color:var(--muted); display:flex; justify-content:space-between; gap:16px; border-top:1px solid var(--line); }
|
|
91
|
+
.report-notes { display:grid; grid-template-columns:1fr 1fr; gap:20px; margin-top:22px; color:var(--muted); font-size:12px; }
|
|
92
|
+
.report-notes strong { display:block; color:var(--ink); margin-bottom:4px; font-weight:600; }
|
|
93
|
+
footer { display:flex; justify-content:space-between; flex-wrap:wrap; gap:8px; margin-top:28px; padding-top:16px; border-top:1px solid var(--line); font-size:11px; color:var(--muted); }
|
|
94
|
+
.empty { text-align:center; padding:28px; color:var(--muted); }
|
|
95
|
+
[hidden] { display:none!important; }
|
|
96
|
+
@media(max-width:700px) { main { padding:20px 12px; } .topbar { padding:16px; } .cards { grid-template-columns:1fr; gap:10px; } .card { padding:12px 16px; } .card-value { font-size:21px; } h1 { font-size:25px; } .report-notes { grid-template-columns:1fr; } input { width:190px; } }
|
|
97
|
+
@page { size:landscape; margin:12mm; }
|
|
98
|
+
@media print { body { background:white; font-size:10px; } .topbar { background:white; color:var(--ink); padding:0 0 12px; border-bottom:2px solid var(--brand); } .topbar small { color:var(--muted); } main { padding:18px 0; max-width:none; } .controls,.table-hint { display:none; } .cards { gap:10px; margin-bottom:14px; } .card { padding:10px; } .panel { box-shadow:none; overflow:visible; } .table-scroll { overflow:visible; max-height:none; } table { width:100%; min-width:0; font-size:8px; } th,td { padding:5px 4px; position:static!important; } td:first-child { min-width:0; } thead { display:table-header-group; } tr { break-inside:avoid; } .report-notes { font-size:9px; } }
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
REPORT_JS = r"""
|
|
102
|
+
(() => {
|
|
103
|
+
const table=document.getElementById('projections');
|
|
104
|
+
const body=table.tBodies[0];
|
|
105
|
+
const rows=Array.from(body.querySelectorAll('tr[data-player]'));
|
|
106
|
+
const search=document.getElementById('search');
|
|
107
|
+
const status=document.getElementById('visible-count');
|
|
108
|
+
const empty=document.getElementById('no-matches');
|
|
109
|
+
let active=-1, ascending=true;
|
|
110
|
+
function filter() {
|
|
111
|
+
const query=search.value.trim().toLocaleLowerCase();
|
|
112
|
+
let visible=0;
|
|
113
|
+
rows.forEach(row=>{row.hidden=!row.textContent.toLocaleLowerCase().includes(query); if(!row.hidden)visible++;});
|
|
114
|
+
status.textContent=`Showing ${visible} of ${rows.length} players`;
|
|
115
|
+
empty.hidden=visible!==0;
|
|
116
|
+
}
|
|
117
|
+
table.querySelectorAll('thead button').forEach((button,index)=>{
|
|
118
|
+
button.addEventListener('click',()=>{
|
|
119
|
+
ascending=active===index?!ascending:true; active=index;
|
|
120
|
+
const sorted=[...rows].sort((a,b)=>{
|
|
121
|
+
const x=a.cells[index].textContent.trim(), y=b.cells[index].textContent.trim();
|
|
122
|
+
const numeric=x!==''&&y!==''&&Number.isFinite(Number(x))&&Number.isFinite(Number(y));
|
|
123
|
+
return (numeric?Number(x)-Number(y):x.localeCompare(y,undefined,{numeric:true}))*(ascending?1:-1);
|
|
124
|
+
});
|
|
125
|
+
table.querySelectorAll('th').forEach(th=>th.setAttribute('aria-sort','none'));
|
|
126
|
+
button.parentElement.setAttribute('aria-sort',ascending?'ascending':'descending');
|
|
127
|
+
sorted.forEach(row=>body.appendChild(row)); filter();
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
search.addEventListener('input',filter);
|
|
131
|
+
document.getElementById('reset').addEventListener('click',()=>{
|
|
132
|
+
search.value='';active=-1;ascending=true;rows.forEach(row=>body.appendChild(row));
|
|
133
|
+
table.querySelectorAll('th').forEach(th=>th.setAttribute('aria-sort','none'));filter();
|
|
134
|
+
});
|
|
135
|
+
document.getElementById('print').addEventListener('click',()=>window.print());
|
|
136
|
+
filter();
|
|
137
|
+
})();
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _cell(value: Any) -> str:
|
|
142
|
+
if pd.isna(value):
|
|
143
|
+
return ""
|
|
144
|
+
if isinstance(value, float):
|
|
145
|
+
return f"{value:.6f}"
|
|
146
|
+
return str(value)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _table(frame: pd.DataFrame, columns: list[str]) -> str:
|
|
150
|
+
headers = []
|
|
151
|
+
for column in columns:
|
|
152
|
+
label = escape(COLUMN_LABELS.get(column, column))
|
|
153
|
+
title = escape(
|
|
154
|
+
COLUMN_HELP.get(column, f"Sort by {COLUMN_LABELS.get(column, column)}"), quote=True
|
|
155
|
+
)
|
|
156
|
+
headers.append(
|
|
157
|
+
f'<th scope="col" aria-sort="none"><button type="button" title="{title}">{label}</button></th>'
|
|
158
|
+
)
|
|
159
|
+
rows = []
|
|
160
|
+
for row in frame.itertuples(index=False, name=None):
|
|
161
|
+
cells = []
|
|
162
|
+
for column, value in zip(columns, row):
|
|
163
|
+
style = ' class="rate"' if column in {"AVG", "OBP", "SLG", "OPS", "ERA", "WHIP"} else ""
|
|
164
|
+
cells.append(f"<td{style}>{escape(_cell(value))}</td>")
|
|
165
|
+
rows.append('<tr data-player="true">' + "".join(cells) + "</tr>")
|
|
166
|
+
return (
|
|
167
|
+
'<table id="projections"><caption class="sr-only">Player projection statistics</caption><thead><tr>'
|
|
168
|
+
+ "".join(headers)
|
|
169
|
+
+ "</tr></thead><tbody>"
|
|
170
|
+
+ "".join(rows)
|
|
171
|
+
+ "</tbody></table>"
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def render_report(display: pd.DataFrame) -> str:
|
|
176
|
+
pitching = "IP" in display.columns
|
|
177
|
+
kind = "Pitching" if pitching else "Batting"
|
|
178
|
+
stat_order = PITCHING_COLUMNS if pitching else BATTING_COLUMNS
|
|
179
|
+
columns = [*IDENTITY_COLUMNS, *[c for c in stat_order if c in display]]
|
|
180
|
+
columns += [c for c in display.columns if c not in columns]
|
|
181
|
+
frame = display.reindex(columns=columns, fill_value="")
|
|
182
|
+
years = ", ".join(sorted({_cell(v) for v in frame["Year"] if _cell(v)})) or "—"
|
|
183
|
+
title = f"{kind} projections"
|
|
184
|
+
count = len(frame)
|
|
185
|
+
notes = (
|
|
186
|
+
"IP uses baseball notation: .1 is one out; .2 is two outs. ERA and WHIP use true innings before rounding."
|
|
187
|
+
if pitching
|
|
188
|
+
else "AVG, OBP, SLG, and OPS use three decimal places. Counting statistics are rounded to whole numbers."
|
|
189
|
+
)
|
|
190
|
+
return f'''<!doctype html>
|
|
191
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
192
|
+
<title>Backcaster · {title} · {escape(years)}</title><style>{REPORT_CSS}</style></head>
|
|
193
|
+
<body><header class="topbar"><div class="brand"><span class="brand-mark" aria-hidden="true"></span>Backcaster</div><small>PLAYER PROJECTION REPORT</small></header>
|
|
194
|
+
<main><section class="intro"><p class="eyebrow">Season outlook</p><h1>{title}</h1><p class="subtitle">A clear view of projected player performance.</p></section>
|
|
195
|
+
<section class="cards" aria-label="Report summary"><div class="card"><div class="card-label">Players in report</div><div class="card-value">{count:,}</div><div class="card-note">One row per projected player</div></div>
|
|
196
|
+
<div class="card"><div class="card-label">Projection year</div><div class="card-value">{escape(years)}</div><div class="card-note">The season being projected</div></div>
|
|
197
|
+
<div class="card"><div class="card-label">Projection type</div><div class="card-value">{kind}</div><div class="card-note">Marcel-style statistical baseline</div></div></section>
|
|
198
|
+
<section class="panel" aria-label="Player projections"><div class="toolbar"><div><h2>Player projections</h2><p class="table-hint">Select a column heading to sort.</p></div>
|
|
199
|
+
<div class="controls"><label for="search">Find players</label><input id="search" type="search" placeholder="Search name, ID, or statistic…" autocomplete="off"><button type="button" class="action" id="reset">Reset</button><button type="button" class="action primary" id="print">Print report</button></div></div>
|
|
200
|
+
<div class="table-scroll" tabindex="0" aria-label="Scrollable projection statistics">{_table(frame, columns)}</div>
|
|
201
|
+
<p class="empty" id="no-matches"{"" if count == 0 else " hidden"}>No players to display.</p>
|
|
202
|
+
<div class="table-footer"><span id="visible-count" role="status" aria-live="polite">Showing {count} of {count} players</span><span>Rounded for presentation</span></div></section>
|
|
203
|
+
<section class="report-notes" aria-label="Reading this report"><div><strong>Reading the numbers</strong>{notes}</div><div><strong>Interpreting projections</strong>Reliability is an observed-evidence weight, not an accuracy probability. Historical eligibility does not represent a current roster.</div></section>
|
|
204
|
+
<footer><span>Backcaster · Marcel-style projections</span><span>Methodology: Tom Tango · Reference inspiration: Ben Dilday’s marcelR</span></footer></main>
|
|
205
|
+
<script>{REPORT_JS}</script></body></html>'''
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from decimal import ROUND_HALF_UP, Decimal
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import pandas as pd
|
|
8
|
+
|
|
9
|
+
from backcaster.html_report import render_report
|
|
10
|
+
|
|
11
|
+
COUNT_COLUMNS = {
|
|
12
|
+
"PA",
|
|
13
|
+
"AB",
|
|
14
|
+
"H",
|
|
15
|
+
"2B",
|
|
16
|
+
"3B",
|
|
17
|
+
"HR",
|
|
18
|
+
"BB",
|
|
19
|
+
"SO",
|
|
20
|
+
"HBP",
|
|
21
|
+
"SF",
|
|
22
|
+
"SH",
|
|
23
|
+
"ER",
|
|
24
|
+
"BF",
|
|
25
|
+
"R",
|
|
26
|
+
"RBI",
|
|
27
|
+
"SB",
|
|
28
|
+
"CS",
|
|
29
|
+
"IBB",
|
|
30
|
+
"GDP",
|
|
31
|
+
"GIDP",
|
|
32
|
+
"W",
|
|
33
|
+
"L",
|
|
34
|
+
"SV",
|
|
35
|
+
"G",
|
|
36
|
+
"GS",
|
|
37
|
+
"CG",
|
|
38
|
+
"SHO",
|
|
39
|
+
"WP",
|
|
40
|
+
"BK",
|
|
41
|
+
"Age",
|
|
42
|
+
"Year",
|
|
43
|
+
}
|
|
44
|
+
RATE_PLACES = {"AVG": 3, "OBP": 3, "SLG": 3, "OPS": 3, "ERA": 2, "WHIP": 2}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _format_number(value: Any, places: int) -> str:
|
|
48
|
+
if pd.isna(value):
|
|
49
|
+
return ""
|
|
50
|
+
number = Decimal(str(value)).quantize(Decimal(1).scaleb(-places), rounding=ROUND_HALF_UP)
|
|
51
|
+
return f"{number:.{places}f}"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _format_innings(value: Any) -> str:
|
|
55
|
+
if pd.isna(value):
|
|
56
|
+
return ""
|
|
57
|
+
innings = Decimal(str(value))
|
|
58
|
+
if not innings.is_finite() or innings < 0:
|
|
59
|
+
raise ValueError("IP must be a finite, nonnegative number of decimal innings.")
|
|
60
|
+
outs = int((innings * 3).to_integral_value(rounding=ROUND_HALF_UP))
|
|
61
|
+
whole, remainder = divmod(outs, 3)
|
|
62
|
+
return f"{whole}.{remainder}"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _display_frame(df: pd.DataFrame) -> pd.DataFrame:
|
|
66
|
+
"""Format a copy at the output boundary; preserve full-precision calculations."""
|
|
67
|
+
display = df.copy()
|
|
68
|
+
for column in display.columns:
|
|
69
|
+
if column == "IP":
|
|
70
|
+
display[column] = display[column].map(_format_innings)
|
|
71
|
+
elif column in COUNT_COLUMNS or column in RATE_PLACES:
|
|
72
|
+
places = RATE_PLACES.get(column, 0)
|
|
73
|
+
display[column] = display[column].map(lambda value: _format_number(value, places))
|
|
74
|
+
return display
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def to_cli_table(df: pd.DataFrame) -> str:
|
|
78
|
+
return _display_frame(df).to_string(index=False)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def to_csv(df: pd.DataFrame, out_path: str | Path) -> Path:
|
|
82
|
+
out = Path(out_path)
|
|
83
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
_display_frame(df).to_csv(out, index=False)
|
|
85
|
+
return out
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def to_html(df: pd.DataFrame, out_path: str | Path) -> Path:
|
|
89
|
+
out = Path(out_path)
|
|
90
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
91
|
+
html = render_report(_display_frame(df))
|
|
92
|
+
out.write_text(html, encoding="utf-8")
|
|
93
|
+
return out
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: backcaster
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
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
|
|
@@ -56,10 +56,10 @@ Upgrade to the latest release available on PyPI:
|
|
|
56
56
|
python -m pip install --upgrade backcaster
|
|
57
57
|
```
|
|
58
58
|
|
|
59
|
-
To install a specific release, such as 0.
|
|
59
|
+
To install a specific release, such as 0.4.0:
|
|
60
60
|
|
|
61
61
|
```powershell
|
|
62
|
-
python -m pip install --upgrade backcaster==0.
|
|
62
|
+
python -m pip install --upgrade backcaster==0.4.0
|
|
63
63
|
```
|
|
64
64
|
|
|
65
65
|
Run the command using the same Python environment where you run Backcaster.
|
|
@@ -234,19 +234,63 @@ backcaster team --team LAN --year 2026 --kind pitching --format html --out ./out
|
|
|
234
234
|
|
|
235
235
|
### CLI table output (`--format cli`, default)
|
|
236
236
|
|
|
237
|
-
|
|
237
|
+
Selected columns from a 2025 Mookie Betts projection (actual output includes
|
|
238
|
+
additional counting statistics, reliability, and age):
|
|
238
239
|
|
|
239
240
|
```text
|
|
240
|
-
|
|
241
|
-
|
|
241
|
+
PlayerID PA AB H HR AVG OBP SLG OPS
|
|
242
|
+
bettsmo01 527 461 128 24 0.278 0.362 0.507 0.869
|
|
242
243
|
```
|
|
243
244
|
|
|
245
|
+
### Number formatting and baseball innings
|
|
246
|
+
|
|
247
|
+
Terminal, CSV, and HTML outputs use the same display rules:
|
|
248
|
+
|
|
249
|
+
- Counting statistics are rounded to the nearest whole number, with halves rounded
|
|
250
|
+
up. Age and year are displayed as whole numbers too.
|
|
251
|
+
- AVG, OBP, SLG, and OPS always show three decimal places, including `1.048` OPS.
|
|
252
|
+
- ERA and WHIP always show two decimal places, such as `3.46` and `1.23`.
|
|
253
|
+
- IP is rounded to the nearest out and displayed in baseball notation:
|
|
254
|
+
`161.0` is 161 innings, `161.1` is 161 innings plus one out, and `161.2` is
|
|
255
|
+
161 innings plus two outs. Three outs carry into the next whole inning.
|
|
256
|
+
|
|
257
|
+
Calculations and Python projection DataFrames retain full precision. Rates are
|
|
258
|
+
computed from unrounded totals, then formatted; they are not recalculated from
|
|
259
|
+
rounded displayed counts. Independently rounded totals may therefore not add up
|
|
260
|
+
exactly. Reliability formatting is unchanged.
|
|
261
|
+
|
|
262
|
+
**CSV IP uses baseball notation too.** Convert `161.1` to `161 + 1/3` before doing
|
|
263
|
+
arithmetic; treating it as 161.1 decimal innings gives incorrect results. Internally,
|
|
264
|
+
Backcaster uses decimal innings (`IPouts / 3`), so ERA and WHIP calculations use
|
|
265
|
+
true innings rather than the displayed notation.
|
|
266
|
+
|
|
244
267
|
### CSV output (`--format csv`)
|
|
245
268
|
A single-row CSV for `player`; multi-row CSV for `team`, `batch`, `hitters`, and `pitchers`.
|
|
246
269
|
Both populations include `PlayerID` for matching records across files.
|
|
247
270
|
|
|
248
271
|
### HTML output (`--format html`)
|
|
249
|
-
A
|
|
272
|
+
A standalone report with embedded CSS and JavaScript, ready to open in a browser
|
|
273
|
+
or share as one file. No server, CDN, fonts, or internet connection are required.
|
|
274
|
+
|
|
275
|
+
```powershell
|
|
276
|
+
backcaster hitters --year 2025 --format html --out hitters_2025.html
|
|
277
|
+
backcaster pitchers --year 2025 --format html --out pitchers_2025.html
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
The first four columns are **Name, Year, Player ID, Age**, followed by counting
|
|
281
|
+
statistics and batting/pitching rates in a consistent order. The report includes:
|
|
282
|
+
|
|
283
|
+
- A player-count, projection-year, and projection-type summary.
|
|
284
|
+
- Search across player names, IDs, and statistics.
|
|
285
|
+
- Sortable column headings, with numeric sorting for statistics.
|
|
286
|
+
- A sticky name column/header and horizontal scrolling on narrow screens.
|
|
287
|
+
- A reset control and print layout for browser printing or saving as PDF.
|
|
288
|
+
|
|
289
|
+
Your display precision rules apply, including baseball innings notation. Rates
|
|
290
|
+
retain their trailing decimal places and OPS values above 1.000 display normally.
|
|
291
|
+
Search filters the visible rows; printing respects that current view. Without
|
|
292
|
+
JavaScript, the complete table is still readable. See
|
|
293
|
+
[HTML report customization](docs/HTML_REPORTS.md) for theme and column constants.
|
|
250
294
|
|
|
251
295
|
---
|
|
252
296
|
|
|
@@ -6,6 +6,7 @@ src/backcaster/__init__.py
|
|
|
6
6
|
src/backcaster/batters.py
|
|
7
7
|
src/backcaster/cli.py
|
|
8
8
|
src/backcaster/data.py
|
|
9
|
+
src/backcaster/html_report.py
|
|
9
10
|
src/backcaster/lahman.py
|
|
10
11
|
src/backcaster/marcel.py
|
|
11
12
|
src/backcaster/normalize.py
|
|
@@ -19,6 +20,7 @@ src/backcaster.egg-info/requires.txt
|
|
|
19
20
|
src/backcaster.egg-info/top_level.txt
|
|
20
21
|
tests/test_batters.py
|
|
21
22
|
tests/test_cli.py
|
|
23
|
+
tests/test_html_report.py
|
|
22
24
|
tests/test_lahman.py
|
|
23
25
|
tests/test_marcel_math.py
|
|
24
26
|
tests/test_normalize.py
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
from html.parser import HTMLParser
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import pandas as pd
|
|
5
|
+
|
|
6
|
+
from backcaster.outputs import to_html
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TableParser(HTMLParser):
|
|
10
|
+
def __init__(self):
|
|
11
|
+
super().__init__()
|
|
12
|
+
self.headers = []
|
|
13
|
+
self.cells = []
|
|
14
|
+
self.tags = []
|
|
15
|
+
self.header = False
|
|
16
|
+
self.cell = False
|
|
17
|
+
|
|
18
|
+
def handle_starttag(self, tag, attrs):
|
|
19
|
+
self.tags.append(tag)
|
|
20
|
+
if tag == "th":
|
|
21
|
+
self.header = True
|
|
22
|
+
self.headers.append("")
|
|
23
|
+
if tag == "td":
|
|
24
|
+
self.cell = True
|
|
25
|
+
self.cells.append("")
|
|
26
|
+
|
|
27
|
+
def handle_endtag(self, tag):
|
|
28
|
+
if tag == "th":
|
|
29
|
+
self.header = False
|
|
30
|
+
if tag == "td":
|
|
31
|
+
self.cell = False
|
|
32
|
+
|
|
33
|
+
def handle_data(self, data):
|
|
34
|
+
if self.header:
|
|
35
|
+
self.headers[-1] += data
|
|
36
|
+
if self.cell:
|
|
37
|
+
self.cells[-1] += data
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_identity_first_and_batting_order(tmp_path: Path) -> None:
|
|
41
|
+
frame = pd.DataFrame(
|
|
42
|
+
[
|
|
43
|
+
dict(
|
|
44
|
+
HR=45.6,
|
|
45
|
+
OPS=1.048,
|
|
46
|
+
Age=33,
|
|
47
|
+
PA=598.2,
|
|
48
|
+
PlayerID="judgeaa01",
|
|
49
|
+
Year=2025,
|
|
50
|
+
Name="Aaron Judge",
|
|
51
|
+
AVG=0.293,
|
|
52
|
+
Reliability=0.86,
|
|
53
|
+
)
|
|
54
|
+
]
|
|
55
|
+
)
|
|
56
|
+
original = frame.copy(deep=True)
|
|
57
|
+
text = to_html(frame, tmp_path / "hitters.html").read_text()
|
|
58
|
+
parser = TableParser()
|
|
59
|
+
parser.feed(text)
|
|
60
|
+
assert parser.headers == [
|
|
61
|
+
"Name",
|
|
62
|
+
"Year",
|
|
63
|
+
"Player ID",
|
|
64
|
+
"Age",
|
|
65
|
+
"PA",
|
|
66
|
+
"HR",
|
|
67
|
+
"AVG",
|
|
68
|
+
"OPS",
|
|
69
|
+
"Reliability",
|
|
70
|
+
]
|
|
71
|
+
assert parser.cells == [
|
|
72
|
+
"Aaron Judge",
|
|
73
|
+
"2025",
|
|
74
|
+
"judgeaa01",
|
|
75
|
+
"33",
|
|
76
|
+
"598",
|
|
77
|
+
"46",
|
|
78
|
+
"0.293",
|
|
79
|
+
"1.048",
|
|
80
|
+
"0.860000",
|
|
81
|
+
]
|
|
82
|
+
assert "<!doctype html>" in text
|
|
83
|
+
assert "<style>" in text
|
|
84
|
+
assert "Batting projections" in text
|
|
85
|
+
assert "Showing 1 of 1 players" in text
|
|
86
|
+
assert "https://" not in text
|
|
87
|
+
pd.testing.assert_frame_equal(frame, original)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_pitching_order_precision_and_notes(tmp_path: Path) -> None:
|
|
91
|
+
frame = pd.DataFrame(
|
|
92
|
+
[
|
|
93
|
+
dict(
|
|
94
|
+
WHIP=1.234,
|
|
95
|
+
ERA=3.456,
|
|
96
|
+
Name="Example",
|
|
97
|
+
Age=30,
|
|
98
|
+
Year=2025,
|
|
99
|
+
PlayerID="test01",
|
|
100
|
+
BF=700.4,
|
|
101
|
+
SO=200.5,
|
|
102
|
+
BB=50.3,
|
|
103
|
+
HR=20.3,
|
|
104
|
+
H=130.2,
|
|
105
|
+
ER=60.7,
|
|
106
|
+
IP=161 + 1 / 3,
|
|
107
|
+
)
|
|
108
|
+
]
|
|
109
|
+
)
|
|
110
|
+
text = to_html(frame, tmp_path / "pitchers.html").read_text()
|
|
111
|
+
parser = TableParser()
|
|
112
|
+
parser.feed(text)
|
|
113
|
+
assert parser.headers == [
|
|
114
|
+
"Name",
|
|
115
|
+
"Year",
|
|
116
|
+
"Player ID",
|
|
117
|
+
"Age",
|
|
118
|
+
"IP",
|
|
119
|
+
"ER",
|
|
120
|
+
"H",
|
|
121
|
+
"HR",
|
|
122
|
+
"BB",
|
|
123
|
+
"SO",
|
|
124
|
+
"BF",
|
|
125
|
+
"ERA",
|
|
126
|
+
"WHIP",
|
|
127
|
+
]
|
|
128
|
+
assert parser.cells[4:] == ["161.1", "61", "130", "20", "50", "201", "700", "3.46", "1.23"]
|
|
129
|
+
assert "Pitching projections" in text
|
|
130
|
+
assert ".1 is one out" in text
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_missing_metadata_unknown_columns_and_empty_report(tmp_path: Path) -> None:
|
|
134
|
+
frame = pd.DataFrame([{"Name": None, "PA": 100, "Other": 1.23456789}])
|
|
135
|
+
parser = TableParser()
|
|
136
|
+
parser.feed(to_html(frame, tmp_path / "partial.html").read_text())
|
|
137
|
+
assert parser.headers[:4] == ["Name", "Year", "Player ID", "Age"]
|
|
138
|
+
assert parser.cells[:4] == ["", "", "", ""]
|
|
139
|
+
assert parser.cells[-1] == "1.234568"
|
|
140
|
+
text = to_html(pd.DataFrame(), tmp_path / "empty.html").read_text()
|
|
141
|
+
assert "No players to display." in text
|
|
142
|
+
assert "Showing 0 of 0 players" in text
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_html_escapes_values_and_column_names(tmp_path: Path) -> None:
|
|
146
|
+
name = '<script>alert("bad")</script>'
|
|
147
|
+
column = '<img src=x onerror="bad()">'
|
|
148
|
+
frame = pd.DataFrame([{"Name": name, "Year": "<b>2025</b>", column: "<svg/onload=bad()>"}])
|
|
149
|
+
# Year is numeric in projections; use a valid year and inject through a generic column.
|
|
150
|
+
frame["Year"] = 2025
|
|
151
|
+
text = to_html(frame, tmp_path / "escaped.html").read_text()
|
|
152
|
+
parser = TableParser()
|
|
153
|
+
parser.feed(text)
|
|
154
|
+
assert parser.cells[0] == name
|
|
155
|
+
assert parser.headers[-1] == column
|
|
156
|
+
assert parser.cells[-1] == "<svg/onload=bad()>"
|
|
157
|
+
assert "img" not in parser.tags
|
|
158
|
+
assert "svg" not in parser.tags
|
|
159
|
+
assert parser.tags.count("script") == 1 # Only the trusted report script.
|
|
160
|
+
assert "<script>alert(" not in text
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import pandas as pd
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from backcaster.outputs import _display_frame, _format_innings, to_cli_table, to_csv, to_html
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_csv_output_creation(tmp_path: Path) -> None:
|
|
11
|
+
df = pd.DataFrame([{"Name": "A", "PA": 100}])
|
|
12
|
+
out = tmp_path / "out.csv"
|
|
13
|
+
to_csv(df, out)
|
|
14
|
+
assert out.exists()
|
|
15
|
+
content = out.read_text(encoding="utf-8")
|
|
16
|
+
assert "Name,PA" in content
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_html_output_creation(tmp_path: Path) -> None:
|
|
20
|
+
df = pd.DataFrame([{"Name": "A", "PA": 100}])
|
|
21
|
+
out = tmp_path / "out.html"
|
|
22
|
+
to_html(df, out)
|
|
23
|
+
assert out.exists()
|
|
24
|
+
content = out.read_text(encoding="utf-8")
|
|
25
|
+
assert "<table" in content
|
|
26
|
+
assert "A" in content
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_cli_table_output() -> None:
|
|
30
|
+
df = pd.DataFrame([{"Name": "A", "PA": 100}])
|
|
31
|
+
assert to_cli_table(df) == df.to_string(index=False)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@pytest.mark.parametrize(
|
|
35
|
+
"innings, expected",
|
|
36
|
+
[
|
|
37
|
+
(161, "161.0"),
|
|
38
|
+
(161 + 1 / 3, "161.1"),
|
|
39
|
+
(161 + 2 / 3, "161.2"),
|
|
40
|
+
(161.999999, "162.0"),
|
|
41
|
+
(161.5, "161.2"),
|
|
42
|
+
(0, "0.0"),
|
|
43
|
+
(1 / 3, "0.1"),
|
|
44
|
+
(float("nan"), ""),
|
|
45
|
+
],
|
|
46
|
+
)
|
|
47
|
+
def test_baseball_innings_notation(innings: float, expected: str) -> None:
|
|
48
|
+
assert _format_innings(innings) == expected
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@pytest.mark.parametrize("innings", [-1, float("inf")])
|
|
52
|
+
def test_invalid_display_innings(innings: float) -> None:
|
|
53
|
+
with pytest.raises(ValueError, match="finite, nonnegative"):
|
|
54
|
+
_format_innings(innings)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_display_precision_and_no_mutation() -> None:
|
|
58
|
+
frame = pd.DataFrame(
|
|
59
|
+
[
|
|
60
|
+
dict(
|
|
61
|
+
Name="Judge",
|
|
62
|
+
PlayerID="judgeaa01",
|
|
63
|
+
PA=598.5,
|
|
64
|
+
HR=45.6,
|
|
65
|
+
H=143.2,
|
|
66
|
+
AVG=0.293,
|
|
67
|
+
OBP=0.415,
|
|
68
|
+
SLG=0.633,
|
|
69
|
+
OPS=1.048,
|
|
70
|
+
ER=60.5,
|
|
71
|
+
BB=51.1,
|
|
72
|
+
SO=199.8,
|
|
73
|
+
BF=700.4,
|
|
74
|
+
IP=161 + 1 / 3,
|
|
75
|
+
ERA=3.456,
|
|
76
|
+
WHIP=1.234,
|
|
77
|
+
Age=33.0,
|
|
78
|
+
Year=2025,
|
|
79
|
+
Other=1.234567,
|
|
80
|
+
)
|
|
81
|
+
]
|
|
82
|
+
)
|
|
83
|
+
original = frame.copy(deep=True)
|
|
84
|
+
result = _display_frame(frame).iloc[0]
|
|
85
|
+
for column, expected in {
|
|
86
|
+
"PA": "599",
|
|
87
|
+
"HR": "46",
|
|
88
|
+
"H": "143",
|
|
89
|
+
"AVG": "0.293",
|
|
90
|
+
"OBP": "0.415",
|
|
91
|
+
"SLG": "0.633",
|
|
92
|
+
"OPS": "1.048",
|
|
93
|
+
"ER": "61",
|
|
94
|
+
"BB": "51",
|
|
95
|
+
"SO": "200",
|
|
96
|
+
"BF": "700",
|
|
97
|
+
"IP": "161.1",
|
|
98
|
+
"ERA": "3.46",
|
|
99
|
+
"WHIP": "1.23",
|
|
100
|
+
"Age": "33",
|
|
101
|
+
"Year": "2025",
|
|
102
|
+
}.items():
|
|
103
|
+
assert result[column] == expected
|
|
104
|
+
assert result.Other == 1.234567
|
|
105
|
+
pd.testing.assert_frame_equal(frame, original)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_missing_numbers_and_fixed_trailing_zeros() -> None:
|
|
109
|
+
result = _display_frame(
|
|
110
|
+
pd.DataFrame(
|
|
111
|
+
[{"PA": float("nan"), "AVG": 0.3, "OBP": 0.0, "OPS": 1.0, "ERA": 4.0, "WHIP": 1.1}]
|
|
112
|
+
)
|
|
113
|
+
).iloc[0]
|
|
114
|
+
assert result.PA == ""
|
|
115
|
+
assert result.AVG == "0.300"
|
|
116
|
+
assert result.OBP == "0.000"
|
|
117
|
+
assert result.OPS == "1.000"
|
|
118
|
+
assert result.ERA == "4.00"
|
|
119
|
+
assert result.WHIP == "1.10"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@pytest.mark.parametrize("output", ["cli", "csv", "html"])
|
|
123
|
+
def test_all_outputs_share_display_format(tmp_path: Path, output: str) -> None:
|
|
124
|
+
frame = pd.DataFrame(
|
|
125
|
+
[dict(Name="Example", HR=24.6, OPS=1.048, IP=161 + 2 / 3, ERA=3.456, WHIP=1.234)]
|
|
126
|
+
)
|
|
127
|
+
if output == "cli":
|
|
128
|
+
text = to_cli_table(frame)
|
|
129
|
+
for value in ["25", "1.048", "161.2", "3.46", "1.23"]:
|
|
130
|
+
assert value in text
|
|
131
|
+
elif output == "csv":
|
|
132
|
+
path = to_csv(frame, tmp_path / "out.csv")
|
|
133
|
+
row = pd.read_csv(path, dtype=str).iloc[0]
|
|
134
|
+
assert row.HR == "25"
|
|
135
|
+
assert row.OPS == "1.048"
|
|
136
|
+
assert row.IP == "161.2"
|
|
137
|
+
assert row.ERA == "3.46"
|
|
138
|
+
assert row.WHIP == "1.23"
|
|
139
|
+
else:
|
|
140
|
+
text = to_html(frame, tmp_path / "out.html").read_text()
|
|
141
|
+
for value in ["25", "1.048", "161.2", "3.46", "1.23"]:
|
|
142
|
+
assert re.search(r"<td(?: [^>]*)?>" + re.escape(value) + r"</td>", text)
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from pathlib import Path
|
|
4
|
-
|
|
5
|
-
import pandas as pd
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def to_cli_table(df: pd.DataFrame) -> str:
|
|
9
|
-
return df.to_string(index=False)
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def to_csv(df: pd.DataFrame, out_path: str | Path) -> Path:
|
|
13
|
-
out = Path(out_path)
|
|
14
|
-
out.parent.mkdir(parents=True, exist_ok=True)
|
|
15
|
-
df.to_csv(out, index=False)
|
|
16
|
-
return out
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def to_html(df: pd.DataFrame, out_path: str | Path) -> Path:
|
|
20
|
-
out = Path(out_path)
|
|
21
|
-
out.parent.mkdir(parents=True, exist_ok=True)
|
|
22
|
-
html = df.to_html(index=False, border=0)
|
|
23
|
-
out.write_text(html, encoding="utf-8")
|
|
24
|
-
return out
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
from pathlib import Path
|
|
2
|
-
|
|
3
|
-
import pandas as pd
|
|
4
|
-
|
|
5
|
-
from backcaster.outputs import to_cli_table, to_csv, to_html
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def test_csv_output_creation(tmp_path: Path) -> None:
|
|
9
|
-
df = pd.DataFrame([{"Name": "A", "PA": 100}])
|
|
10
|
-
out = tmp_path / "out.csv"
|
|
11
|
-
to_csv(df, out)
|
|
12
|
-
assert out.exists()
|
|
13
|
-
content = out.read_text(encoding="utf-8")
|
|
14
|
-
assert "Name,PA" in content
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def test_html_output_creation(tmp_path: Path) -> None:
|
|
18
|
-
df = pd.DataFrame([{"Name": "A", "PA": 100}])
|
|
19
|
-
out = tmp_path / "out.html"
|
|
20
|
-
to_html(df, out)
|
|
21
|
-
assert out.exists()
|
|
22
|
-
content = out.read_text(encoding="utf-8")
|
|
23
|
-
assert "<table" in content
|
|
24
|
-
assert "A" in content
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def test_cli_table_output() -> None:
|
|
28
|
-
df = pd.DataFrame([{"Name": "A", "PA": 100}])
|
|
29
|
-
assert to_cli_table(df) == df.to_string(index=False)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|