Python downloader (taxi_download) — design spec (2026-07-26)
Goal: Replace the 488-line bash downloader (downloader/download_taxi_data.sh) with a Python
package taxi_download that ships in the wheel and is driven by a taxi-download console script.
This makes taxi-seed's download stage installable and runnable from a plain pip install taxi-seed
(no repo checkout, no bash/curl shell-out), and easier to explain and maintain.
Piece 2 of 3 (k6 removal ✅ → Python downloader → docs sweep). Ships as a PR into dev.
Decisions locked (from brainstorming)
- New runtime dependency:
httpx>=0.27(deps becomeduckdb,pyyaml,httpx). Chosen for a clean streaming/timeout/redirect API over hand-rolledurllib. - Clean reimplementation, not a 1:1 bash port: keep the essential domain behavior, drop the bash-isms.
- Package
downloader/src/taxi_download/→ console scripttaxi-download; added to the wheelpackageslist. The orchestrator invokes it viapython -m taxi_download.cli(like normalize/load).
Behavior to preserve (from the bash script)
- CloudFront URL scheme:
https://d37ci6vzurychx.cloudfront.net/trip-data/<type>_tripdata_<year>-<mm>.parquet. - Four types + per-type start dates:
yellow2009-01,green2013-08,fhv2015-01,fhvhv2019-02. - Full-history mode: forward walk from the type's start date through the previous calendar month. Skip months whose file already exists locally. If a month 404s after data has been seen, treat it as end-of-series and stop that type; a 404 before any data is "pre-series", skip forward.
--recent [N]mode: backward walk from the previous month; only successful downloads count toward N; stop early when an already-present local file is hit; cap total months examined atMAX_LOOKBACK = 18.- Output layout:
<data-dir>/raw/<type>/<year>/<file>; skip files that already exist. - PAR1 corrupt-file validation: a valid parquet has magic bytes
PAR1at both the first and last 4 bytes. Run an upfront cleanup pass (scan<data-dir>/raw, delete any parquet failing the check) before downloading, and re-validate each freshly downloaded file (delete + treat as a failed fetch if invalid). - Rate-limit resilience: TLC's CloudFront rate-limits and sometimes returns a
200HTML intercept page instead of the parquet. Handle both.
Design
Package structure (downloader/src/taxi_download/)
__init__.pydates.py— pure month arithmetic:previous_month(today) -> (year, month);START_DATES; iteratorsmonths_forward(start, end)andmonths_backward(start). No I/O.download.py— the fetch + validation + walker core (see below). Takes an injectedhttpx.Clientand anow/todayvalue so it is unit-testable without network or wall-clock.cli.py— argparse +main(argv=None) -> int.
Constants (download.py / dates.py)
Core functions (download.py)
filename(type, year, month) -> str→f"{type}_tripdata_{year}-{month:02d}.parquet".url_for(type, year, month) -> str→f"{BASE_URL}/{filename(...)}".target_path(raw_dir, type, year, month) -> Path→raw_dir/type/str(year)/filename(...).is_valid_parquet(path) -> bool→ file ≥ 8 bytes and first 4 == last 4 ==PAR1.clean_corrupt(raw_dir) -> int→ scanraw_dir.rglob("*.parquet"), delete invalid, return count.FetchResult = Enum("OK", "NOTFOUND", "RATELIMIT", "NETERROR").fetch_one(client, url, dest) -> FetchResult— stream the GET to a temp file; on200validate PAR1 (invalid →RATELIMIT, the intercept-page case); rename into place on success. Map status:404/403(AccessDenied|NoSuchKey)→NOTFOUND;429/5xx/403(cloudfront/blocked)→RATELIMIT; transport errors →NETERROR. No retry here — the caller owns retry/backoff.download_month(client, type, year, month, raw_dir, sleeper) -> FetchResult— existence-skip; elsefetch_onewith capped-exponential-backoff retry onRATELIMIT/NETERRORup toMAX_RETRIES, then give up (returns the last result).sleeperis an injected sleep fn (no-op in tests).download_full(client, type, raw_dir, today, sleeper) -> int— the forward walker (returns count).download_recent(client, type, raw_dir, n, today, sleeper) -> int— the backward walker.
CLI (cli.py)
argparse: positionaldata_type(optional, choices = the four types; omit = all four),--recent [N](nargs="?",const=3, i.e. default 3 months when N omitted — matching the bash default),--data-dir DIR(default".").main: resolveraw_dir = Path(data_dir) / "raw";clean_corrupt(raw_dir); build anhttpx.Client(follow redirects, sane timeout); for each selected type rundownload_recentordownload_full; print per-type + total summary; return exit code.- Exit codes:
0success (including skip-only / nothing-new);2on bad args or when a rate-limit give-up prevented all progress for a requested type (so the orchestrator can halt). A transient single-month failure that still lets the walk continue is not fatal.
Dropped from the bash version
- The separate two-phase HEAD/range probe (folded into a single GET + PAR1 validation).
- The exact
300/900/3600sescalation → a clean capped exponential backoff. - The WSL-VHDX interactive warning.
- The legacy
OUTPUT_DIRenv override (only--data-dirremains).
Orchestrator integration (orchestrator/src/taxi_orchestrate/)
stages.py::build_download_cmd— change from["bash", str(repo_root/"downloader"/"download_taxi_data.sh"), …]to[sys.executable, "-m", "taxi_download.cli", …], preserving the arg surface: append--recent(+str(N)whenN > 0), thedata_typewhen set, and--data-dir <data_dir>. Therepo_rootparameter is no longer needed for the download command (the module is importable) — keep the stage running withcwd=repo_rootfor consistency, but the command itself no longer references the repo.cli.py::find_repo_root— its markerdownloader/download_taxi_data.shis going away; change the marker topyproject.tomlandnormalize/mappings/(still present in the repo; still needed ascwdsonormalizefinds its mappings).- Update the existing
tests/taxi_orchestrate/test_stages.pydownload-command assertions to the newpython -m taxi_download.cliform.
Packaging (pyproject.toml)
- Add
"httpx>=0.27"to[project.dependencies]. - Add
"downloader/src/taxi_download"to[tool.hatch.build.targets.wheel].packages. - Add
taxi-download = "taxi_download.cli:main"to[project.scripts]. - Regenerate
uv.lock(uv sync).
Removals
- Delete
downloader/download_taxi_data.sh. - Delete/replace
tests/downloader/test_output_dir.py(it drives the bash script) with the new Python tests. downloader/README.md— update its pointer to the Python module (kept minimal; the full guide is authored in the docs sweep, Piece 3).
Testing strategy
- Pure units (no network):
dates.py(previous_month wrap, forward/backward iterators),filename/url_for/target_path,is_valid_parquet(good/short/bad-magic),clean_corrupt(deletes only invalid), and the--data-dir→raw/resolution. - Fetch/walker with a local stub HTTP server (stdlib
http.serveron127.0.0.1:0, no real network): serve a tiny valid parquet (built with DuckDBCOPY … TO) for a set of months,404for others, and one429to exercise a backoff-then-succeed path (with an injected no-opsleeper). Assert: correct files land underraw/<type>/<year>/, existing files are skipped,--recent Nstops at the right count / on an existing file, and a persistent429yields the give-up exit code. - Determinism: inject
todayandsleeperso walkers and backoff are wall-clock-free and instant. - Full suite stays green;
taxi-run --dry-runshows the newpython -m taxi_download.clicommand.
Out of scope (explicit)
- Packaging the normalize mappings so a pip-installed
taxi-runcan normalize end-to-end — this is the separate Piece 2.5 (mappings viaimportlib.resources). The downloader piece does not touch normalize. - Parallel/concurrent downloads (TLC rate-limits; sequential-with-backoff is intentional).
- Re-authoring the downloader guide / broader docs (that's Piece 3).
- Any change to the
raw/layout or the--data-dirsemantics established earlier.
Notes for the plan
- Keep
download.pyfunctions injectable (client,today,sleeper) so every branch is testable offline and instantly. - The stub-server test builds its sample parquet with DuckDB (already a dep) to guarantee real PAR1 magic bytes.
- Confirm
httpxresolves cleanly inuv.lockand doesn't conflict withduckdb/pyyaml. - The
--recent [N]constdefault must be 3 to match the bash default whenNis omitted.