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.
Files changed (31) hide show
  1. {backcaster-0.3.6/src/backcaster.egg-info → backcaster-0.4.0}/PKG-INFO +51 -7
  2. {backcaster-0.3.6 → backcaster-0.4.0}/README.md +50 -6
  3. {backcaster-0.3.6 → backcaster-0.4.0}/src/backcaster/__init__.py +1 -1
  4. backcaster-0.4.0/src/backcaster/html_report.py +205 -0
  5. backcaster-0.4.0/src/backcaster/outputs.py +93 -0
  6. {backcaster-0.3.6 → backcaster-0.4.0/src/backcaster.egg-info}/PKG-INFO +51 -7
  7. {backcaster-0.3.6 → backcaster-0.4.0}/src/backcaster.egg-info/SOURCES.txt +2 -0
  8. backcaster-0.4.0/tests/test_html_report.py +160 -0
  9. backcaster-0.4.0/tests/test_outputs.py +142 -0
  10. backcaster-0.3.6/src/backcaster/outputs.py +0 -24
  11. backcaster-0.3.6/tests/test_outputs.py +0 -29
  12. {backcaster-0.3.6 → backcaster-0.4.0}/LICENSE +0 -0
  13. {backcaster-0.3.6 → backcaster-0.4.0}/THIRD_PARTY_NOTICES.md +0 -0
  14. {backcaster-0.3.6 → backcaster-0.4.0}/pyproject.toml +0 -0
  15. {backcaster-0.3.6 → backcaster-0.4.0}/setup.cfg +0 -0
  16. {backcaster-0.3.6 → backcaster-0.4.0}/src/backcaster/batters.py +0 -0
  17. {backcaster-0.3.6 → backcaster-0.4.0}/src/backcaster/cli.py +0 -0
  18. {backcaster-0.3.6 → backcaster-0.4.0}/src/backcaster/data.py +0 -0
  19. {backcaster-0.3.6 → backcaster-0.4.0}/src/backcaster/lahman.py +0 -0
  20. {backcaster-0.3.6 → backcaster-0.4.0}/src/backcaster/marcel.py +0 -0
  21. {backcaster-0.3.6 → backcaster-0.4.0}/src/backcaster/normalize.py +0 -0
  22. {backcaster-0.3.6 → backcaster-0.4.0}/src/backcaster/schemas.py +0 -0
  23. {backcaster-0.3.6 → backcaster-0.4.0}/src/backcaster.egg-info/dependency_links.txt +0 -0
  24. {backcaster-0.3.6 → backcaster-0.4.0}/src/backcaster.egg-info/entry_points.txt +0 -0
  25. {backcaster-0.3.6 → backcaster-0.4.0}/src/backcaster.egg-info/requires.txt +0 -0
  26. {backcaster-0.3.6 → backcaster-0.4.0}/src/backcaster.egg-info/top_level.txt +0 -0
  27. {backcaster-0.3.6 → backcaster-0.4.0}/tests/test_batters.py +0 -0
  28. {backcaster-0.3.6 → backcaster-0.4.0}/tests/test_cli.py +0 -0
  29. {backcaster-0.3.6 → backcaster-0.4.0}/tests/test_lahman.py +0 -0
  30. {backcaster-0.3.6 → backcaster-0.4.0}/tests/test_marcel_math.py +0 -0
  31. {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.6
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.3.6:
59
+ To install a specific release, such as 0.4.0:
60
60
 
61
61
  ```powershell
62
- python -m pip install --upgrade backcaster==0.3.6
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
- Example shape (columns vary by batting vs pitching):
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
- PA AB H 2B 3B HR BB SO HBP SF AVG OBP SLG OPS Reliability Name Year
241
- 610.2 545.1 162.4 34.1 2.8 32.6 67.9 119.3 5.6 4.7 0.298 0.384 0.531 0.915 0.89 Mookie Betts 2026
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 basic HTML table (no custom CSS) suitable for quick sharing or embedding.
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.3.6:
28
+ To install a specific release, such as 0.4.0:
29
29
 
30
30
  ```powershell
31
- python -m pip install --upgrade backcaster==0.3.6
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
- Example shape (columns vary by batting vs pitching):
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
- PA AB H 2B 3B HR BB SO HBP SF AVG OBP SLG OPS Reliability Name Year
210
- 610.2 545.1 162.4 34.1 2.8 32.6 67.9 119.3 5.6 4.7 0.298 0.384 0.531 0.915 0.89 Mookie Betts 2026
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 basic HTML table (no custom CSS) suitable for quick sharing or embedding.
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
 
@@ -1,4 +1,4 @@
1
1
  """backcaster package."""
2
2
 
3
3
  __all__ = ["__version__"]
4
- __version__ = "0.3.6"
4
+ __version__ = "0.4.0"
@@ -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.6
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.3.6:
59
+ To install a specific release, such as 0.4.0:
60
60
 
61
61
  ```powershell
62
- python -m pip install --upgrade backcaster==0.3.6
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
- Example shape (columns vary by batting vs pitching):
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
- PA AB H 2B 3B HR BB SO HBP SF AVG OBP SLG OPS Reliability Name Year
241
- 610.2 545.1 162.4 34.1 2.8 32.6 67.9 119.3 5.6 4.7 0.298 0.384 0.531 0.915 0.89 Mookie Betts 2026
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 basic HTML table (no custom CSS) suitable for quick sharing or embedding.
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