Skip to content

feat(extract,sink): add GitHub raw CDN downloader, modern dlt/polars/pydantic stack - #38

Merged
cbwinslow merged 2 commits into
mainfrom
feat/github-downloader-modern-ingestion
Oct 23, 2026
Merged

cbwinslow merged 2 commits into
mainfrom
feat/github-downloader-modern-ingestion

Conversation

@cbwinslow

Copy link
Copy Markdown
Owner

Summary

  • Introduces GitHub raw CDN mirror (\chadwickbureau/retrosheet) as the primary download source with automatic fallback to
    etrosheet.org.
  • Adds HTTP connection pooling / session reuse via \httpx.Client\ on \Downloader.
  • Implements selective network error handling (skips 404s for split seasons, raises and cleans up partial cache directories on network timeouts/dropouts).
  • Promotes \dlt\ and \polars\ to core dependencies and refactors \dlt\ sources to yield native PyArrow Tables (\pa.Table) directly with zero-copy throughput.
  • Updates \CLAUDE.md, \docs/cli.md, and \docs/DECISIONS.md\ (ADRs 1–7).
  • Full test suite, ruff lint, and strict mypy verification passing with 0 errors.

@coderabbitai

coderabbitai Bot commented Oct 23, 2026

Copy link
Copy Markdown

Review in Change Stack →

Note

Currently processing new changes in this PR. This may take a few minutes, please wait...

⚙️ Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: 43d422af-35d1-444a-a067-a5f7555bb447
📥 Commits

Reviewing files that changed from the base of the PR and between d4b4f0e and 98759cc.

⛔ Files ignored due to path filters: 1
  • uv.lock is excluded by !**/*.lock, !**/*.lock
📒 Files selected for processing: 28
  • CLAUDE.md
  • docs/DECISIONS.md
  • docs/cli.md
  • docs/index.md
  • docs/storage/dlt-pipelines.md
  • openspec/changes/archive/2026-10-23-github-downloader-and-modern-ingestion/.openspec.yaml
  • openspec/changes/archive/2026-10-23-github-downloader-and-modern-ingestion/design.md
  • openspec/changes/archive/2026-10-23-github-downloader-and-modern-ingestion/proposal.md
  • openspec/changes/archive/2026-10-23-github-downloader-and-modern-ingestion/specs/retrosheet-ingestion/spec.md
  • openspec/changes/archive/2026-10-23-github-downloader-and-modern-ingestion/tasks.md
  • openspec/changes/harden-github-downloader-and-docs/.openspec.yaml
  • openspec/changes/harden-github-downloader-and-docs/design.md
  • openspec/changes/harden-github-downloader-and-docs/proposal.md
  • openspec/changes/harden-github-downloader-and-docs/specs/retrosheet-ingestion/spec.md
  • openspec/changes/harden-github-downloader-and-docs/tasks.md
  • openspec/specs/retrosheet-ingestion/spec.md
  • pyproject.toml
  • src/retrosheetpy/api.py
  • src/retrosheetpy/cli.py
  • src/retrosheetpy/extract/__init__.py
  • src/retrosheetpy/extract/config.py
  • src/retrosheetpy/extract/downloader.py
  • src/retrosheetpy/extract/manifest.py
  • src/retrosheetpy/sink/dlt_sink.py
  • tests/test_config.py
  • tests/test_downloader_github.py
  • tests/test_extract.py
  • tests/test_transactions_and_cli.py
+------------------------------------------------------------------------------------------------------------------------------------------------------------+
| Don't program by coincidence. Rely only on reliable things. Beware of accidental complexity, and don't confuse a happy coincidence with a purposeful plan. |
+------------------------------------------------------------------------------------------------------------------------------------------------------------+
  \
   \     /)/)
        ( '')
        /  |
      o(___)
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cbwinslow
cbwinslow merged commit 196a986 into main Oct 23, 2026
14 of 19 checks passed
@cbwinslow
cbwinslow deleted the feat/github-downloader-modern-ingestion branch October 23, 2026 15:35
line = '20240328,0,Thu,LAN,NL,1,SDN,NL,1,5,2,54,D,,,,SEO01,15952,185,"000101003","000000020",37,11,1,0,0,5,0,0,0,1,0,3,1,0,0,0,8,6,2,2,0,0,27,11,1,0,0,0,31,4,0,0,0,2,0,0,1,0,0,7,2,0,0,0,9,5,5,5,1,0,27,9,2,0,0,0,hp_id,hp_name,1b_id,1b_name,2b_id,2b_name,3b_id,3b_name,lf_id,lf_name,rf_id,rf_name,v_mgr_id,v_mgr_name,h_mgr_id,h_mgr_name,w_pit_id,w_pit_name,l_pit_id,l_pit_name,s_pit_id,s_pit_name,gwrbi_id,gwrbi_name,v_sp_id,v_sp_name,h_sp_id,h_sp_name,v_b1_id,v_b1_name,8,v_b2_id,v_b2_name,6,v_b3_id,v_b3_name,7,v_b4_id,v_b4_name,3,v_b5_id,v_b5_name,9,v_b6_id,v_b6_name,4,v_b7_id,v_b7_name,5,v_b8_id,v_b8_name,2,v_b9_id,v_b9_name,1,h_b1_id,h_b1_name,8,h_b2_id,h_b2_name,6,h_b3_id,h_b3_name,7,h_b4_id,h_b4_name,3,h_b5_id,h_b5_name,9,h_b6_id,h_b6_name,4,h_b7_id,h_b7_name,5,h_b8_id,h_b8_name,2,h_b9_id,h_b9_name,1,additional_info,acquisition_info\n'

def mock_fetch(url: str) -> bytes:
assert "raw.githubusercontent.com" in url
)

def mock_fetch(url: str) -> bytes:
if "raw.githubusercontent.com" in url:
@greptile-apps

greptile-apps Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 0/5

[Medium impact] The changes reach the package’s download and data-loading workflows.

Do not merge until credential disclosure, writes outside the cache, cache failures, and constructor compatibility are fixed.

Findings

  1. P1 Security GitHub tokens leave GitHub ▶
  2. P1 Security Mirror files escape the cache ▶
  3. P1 Failed refresh erases saved data ▶
  4. P1 Postseason cache keeps incomplete downloads ▶
  5. P1 Positional timeout calls now fail ▶
  6. P2 Event loads use more memory ▶
  7. P2 Default fallback claim is wrong ▶
Fix with agent prompt
### Issue 1
src/retrosheetpy/extract/downloader.py:51-56
If `GH_TOKEN` or `GITHUB_TOKEN` is set, `DownloaderConfig` reads it automatically and this code adds it to every request from the shared client. An uncached `transactions()` request always goes to `retrosheet.org`, even with the default source. Explicit Retrosheet requests and custom mirrors also receive the secret.

Remove the client-wide `Authorization` header and attach credentials only to trusted GitHub requests.

**How this was verified:** The environment token becomes a shared request header, and `get_transactions()` uses that client to request a Retrosheet URL without overriding the header.

### Issue 2
src/retrosheetpy/extract/downloader.py:170
If a malicious or compromised mirror supplies `/tmp/probe,N` in `TEAM2024`, returns 404 for the event request, then returns roster bytes, `roster_filename` becomes `/tmp/probe2024.ROS`. Joining this absolute path to `extracted_dir` discards the cache path, so the download can overwrite a writable file outside the cache.

Reject team codes containing path separators or absolute paths before fetching or writing files. Also check that each destination remains inside the season directory.

**How this was verified:** The downloaded manifest supplies `team_id` without a path check, and that value starts the filename passed directly to `write_bytes()`.

### Issue 3
src/retrosheetpy/extract/downloader.py:190-193
`get_events(force=True)` passes the existing season directory into `_get_events_github()`. Downloads overwrite files there, and a failed team request unconditionally removes the entire directory. With `source="github"`, the error then escapes with the previously good cache gone.

Download into a temporary directory and replace the cached directory only after the full download succeeds.

### Issue 4
src/retrosheetpy/extract/downloader.py:260-262
If the World Series file downloads and the next request times out with `source="github"`, `get_postseason()` raises but leaves the first file saved. The next call sees one `.EVE` file and returns immediately. `postseason()` then silently parses an incomplete season.

Stage these downloads and publish the directory only after all round requests finish.

### Issue 5
src/retrosheetpy/extract/downloader.py:31-33
Adding `config` before `timeout` breaks the existing positional constructor. A previously valid `Downloader(cache, manifest, 30.0)` now stores the float as `self.config` and fails when reading its attributes.

Keep `timeout` as the third argument and make the new options keyword-only.

### Issue 6
src/retrosheetpy/sink/dlt_sink.py:169-172
The new Arrow path calls `parse_files()` for the whole season before yielding anything. That function keeps every event as a Python dictionary, then builds Arrow arrays while those dictionaries still exist. This uses much more peak memory than the previous row-by-row output and delays the first batch until the season is parsed.

Build and yield bounded Arrow batches instead. The same change applies to postseason and All-Star resources.

### Issue 7
docs/index.md:18
The homepage promises automatic fallback by default, but the default is `source="github"`, which raises on a GitHub failure. Fallback only runs with `source="auto"`.

State that requirement here so users know how to enable it.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

Adds GitHub raw downloads, source selection across the API and CLI, pooled HTTP connections, and transaction warnings with local-file support.

  • Retrosheet downloads use the GitHub raw mirror by default.
  • dlt resources pass typed Arrow tables straight to destinations.
  • Transaction data warns that it is frozen and can use a local file.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[API or CLI request] --> B[Downloader]
  B --> C{Usable cache?}
  C -->|Yes| D[Parse cached files]
  C -->|No| E{Selected source}
  E -->|github or auto| F[GitHub raw files]
  E -->|retrosheet| G[Retrosheet archive]
  F -->|Success| D
  F -->|Failure in auto mode| G
  F -->|Failure in github mode| H[Raise error]
  G --> D
  D --> I[Arrow tables]
  I --> J[DuckDB, files, or dlt]
Loading

Reviews (1) · Last reviewed commit: "feat(extract,docs): harden GitHub raw CD..." · Reviewed by Greptile

Comment on lines +51 to +56
if self.config.github_token:
headers["Authorization"] = f"Bearer {self.config.github_token}"
self._client = httpx.Client(
follow_redirects=True,
timeout=self.timeout,
headers=headers,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security GitHub tokens leave GitHub

If GH_TOKEN or GITHUB_TOKEN is set, DownloaderConfig reads it automatically and this code adds it to every request from the shared client. An uncached transactions() request always goes to retrosheet.org, even with the default source. Explicit Retrosheet requests and custom mirrors also receive the secret.

Remove the client-wide Authorization header and attach credentials only to trusted GitHub requests.

How this was verified: The environment token becomes a shared request header, and get_transactions() uses that client to request a Retrosheet URL without overriding the header.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/retrosheetpy/extract/downloader.py
Line: 51-56

Comment:
**GitHub tokens leave GitHub**

If `GH_TOKEN` or `GITHUB_TOKEN` is set, `DownloaderConfig` reads it automatically and this code adds it to every request from the shared client. An uncached `transactions()` request always goes to `retrosheet.org`, even with the default source. Explicit Retrosheet requests and custom mirrors also receive the secret.

Remove the client-wide `Authorization` header and attach credentials only to trusted GitHub requests.

**How this was verified:** The environment token becomes a shared request header, and `get_transactions()` uses that client to request a Retrosheet URL without overriding the header.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

roster_bytes = self._fetch_bytes(
self.manifest.github_season_file_url(year, roster_filename)
)
(extracted_dir / roster_filename).write_bytes(roster_bytes)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Mirror files escape the cache

If a malicious or compromised mirror supplies /tmp/probe,N in TEAM2024, returns 404 for the event request, then returns roster bytes, roster_filename becomes /tmp/probe2024.ROS. Joining this absolute path to extracted_dir discards the cache path, so the download can overwrite a writable file outside the cache.

Reject team codes containing path separators or absolute paths before fetching or writing files. Also check that each destination remains inside the season directory.

How this was verified: The downloaded manifest supplies team_id without a path check, and that value starts the filename passed directly to write_bytes().

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/retrosheetpy/extract/downloader.py
Line: 170

Comment:
**Mirror files escape the cache**

If a malicious or compromised mirror supplies `/tmp/probe,N` in `TEAM2024`, returns 404 for the event request, then returns roster bytes, `roster_filename` becomes `/tmp/probe2024.ROS`. Joining this absolute path to `extracted_dir` discards the cache path, so the download can overwrite a writable file outside the cache.

Reject team codes containing path separators or absolute paths before fetching or writing files. Also check that each destination remains inside the season directory.

**How this was verified:** The downloaded manifest supplies `team_id` without a path check, and that value starts the filename passed directly to `write_bytes()`.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +190 to +193
except Exception:
# Clean up partial directory on failure to avoid cache poisoning
shutil.rmtree(extracted_dir, ignore_errors=True)
raise

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Failed refresh erases saved data

get_events(force=True) passes the existing season directory into _get_events_github(). Downloads overwrite files there, and a failed team request unconditionally removes the entire directory. With source="github", the error then escapes with the previously good cache gone.

Download into a temporary directory and replace the cached directory only after the full download succeeds.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/retrosheetpy/extract/downloader.py
Line: 190-193

Comment:
**Failed refresh erases saved data**

`get_events(force=True)` passes the existing season directory into `_get_events_github()`. Downloads overwrite files there, and a failed team request unconditionally removes the entire directory. With `source="github"`, the error then escapes with the previously good cache gone.

Download into a temporary directory and replace the cached directory only after the full download succeeds.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +260 to +262
except Exception as e:
if self.source == "github":
raise

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Postseason cache keeps incomplete downloads

If the World Series file downloads and the next request times out with source="github", get_postseason() raises but leaves the first file saved. The next call sees one .EVE file and returns immediately. postseason() then silently parses an incomplete season.

Stage these downloads and publish the directory only after all round requests finish.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/retrosheetpy/extract/downloader.py
Line: 260-262

Comment:
**Postseason cache keeps incomplete downloads**

If the World Series file downloads and the next request times out with `source="github"`, `get_postseason()` raises but leaves the first file saved. The next call sees one `.EVE` file and returns immediately. `postseason()` then silently parses an incomplete season.

Stage these downloads and publish the directory only after all round requests finish.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +31 to +33
config: DownloaderConfig | None = None,
source: Literal["github", "retrosheet", "auto"] | None = None,
timeout: float | None = None,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Positional timeout calls now fail

Adding config before timeout breaks the existing positional constructor. A previously valid Downloader(cache, manifest, 30.0) now stores the float as self.config and fails when reading its attributes.

Keep timeout as the third argument and make the new options keyword-only.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/retrosheetpy/extract/downloader.py
Line: 31-33

Comment:
**Positional timeout calls now fail**

Adding `config` before `timeout` breaks the existing positional constructor. A previously valid `Downloader(cache, manifest, 30.0)` now stores the float as `self.config` and fails when reading its attributes.

Keep `timeout` as the third argument and make the new options keyword-only.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +169 to +172
if files:
tbl = parser.parse_files(files, year=y)
if tbl.num_rows > 0:
yield tbl

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Event loads use more memory

The new Arrow path calls parse_files() for the whole season before yielding anything. That function keeps every event as a Python dictionary, then builds Arrow arrays while those dictionaries still exist. This uses much more peak memory than the previous row-by-row output and delays the first batch until the season is parsed.

Build and yield bounded Arrow batches instead. The same change applies to postseason and All-Star resources.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/retrosheetpy/sink/dlt_sink.py
Line: 169-172

Comment:
**Event loads use more memory**

The new Arrow path calls `parse_files()` for the whole season before yielding anything. That function keeps every event as a Python dictionary, then builds Arrow arrays while those dictionaries still exist. This uses much more peak memory than the previous row-by-row output and delays the first batch until the season is parsed.

Build and yield bounded Arrow batches instead. The same change applies to postseason and All-Star resources.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment thread docs/index.md
`retrosheetpy` brings modern data workflows to [Retrosheet](https://www.retrosheet.org) baseball archives. It enables baseball researchers, analysts, professors, and DBAs to download, parse, and ingest play-by-play events, historical game logs, rosters, and ballparks directly into **DuckDB, Parquet, Polars, pandas, and SQL databases** with **strongly-typed schemas** (integers, dates, booleans—never raw text dumps).

Powered by [`chadwickpy`](https://github.com/cbwinslow/chadwickpy) for multi-core play-by-play parsing with **zero C compiler dependencies**.
Downloads default to the fast, rate-limit-free GitHub Fastly raw CDN mirror (`chadwickbureau/retrosheet`) with automatic fallback to `retrosheet.org`. Powered by [`chadwickpy`](https://github.com/cbwinslow/chadwickpy) for multi-core play-by-play parsing with **zero C compiler dependencies**.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Default fallback claim is wrong

The homepage promises automatic fallback by default, but the default is source="github", which raises on a GitHub failure. Fallback only runs with source="auto".

State that requirement here so users know how to enable it.

Prompt To Fix With AI
This is a comment left during a code review.
Path: docs/index.md
Line: 18

Comment:
**Default fallback claim is wrong**

The homepage promises automatic fallback by default, but the default is `source="github"`, which raises on a GitHub failure. Fallback only runs with `source="auto"`.

State that requirement here so users know how to enable it.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants