Skip to content

Commit e8b97fd

Browse files
authored
feat: register browser-downloaded bulk files, read them offline, and read CRDF workbooks (#72)
register_bulk_file() puts a bulk file downloaded in a browser into the cache under the key a download would use, and BulkDownloadChallengeError prints that call. The CRS, DAC1, DAC2a, DAC2b and multisystem bulk readers and bulk_download_parquet take offline=True, which reads the cached copy with no network call. An expired download is deleted only once a newer copy of the same dataset is cached. The new oda_reader.crdf module reads the OECD climate-related development finance workbooks into one set of columns with file provenance. The docs state that scripted access to the OECD bulk files is not a committed service.
1 parent 0614f85 commit e8b97fd

31 files changed

Lines changed: 3966 additions & 180 deletions

‎CHANGELOG.md‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,51 @@
11
# Changelog for oda_reader
22

3+
## Unreleased
4+
5+
- **Adds `download_crdf()`, `download_crdf_file()` and `list_crdf_files()`** for the OECD
6+
Climate-Related Development Finance workbooks at `webfs.oecd.org/climate/`, read over plain
7+
HTTP from either perspective. File names come from the live folder listing, and each year is
8+
read from the narrowest workbook that holds it. Every header layout, current and archived,
9+
maps to one set of columns through `schemas/mappings/crdf_schema.json`, with `price_year`,
10+
`vintage`, `source_file`, `file_version` and `file_version_kind` added to each row.
11+
`file_version` is the file host's validator for the cached copy read, and `file_version_kind`
12+
says whether it is an `etag` or a `last_modified` date. Each workbook is read from a private
13+
snapshot of the cached copy, so a concurrent refresh cannot change the rows under that label. An unknown layout raises
14+
`CRDFSchemaError`, which lists the unmapped and missing columns. A folder listing entry that is
15+
not a plain file name also raises `CRDFSchemaError`.
16+
- **Adds `register_bulk_file()` for bulk files downloaded in a browser.** It validates the file,
17+
copies it into the bulk file cache under the key a download would use, and returns the cached
18+
path. A registered entry never expires, so the TTL sweep never deletes it. Online reads serve it
19+
while the file host's version token matches the one recorded, or cannot be read, and download
20+
a republished file when the token changes. `dataset` takes `"crs"`, `"crs-reduced"`,
21+
`"dac1"`, `"dac2a"`, `"dac2b"` or `"multisystem"`. Bulk files download whole, a single-byte
22+
request reads the version token first, and a renamed file still resolves: online reads take
23+
the name from the SDMX dataflow annotations and offline reads find the copy by dataset.
24+
- **Adds `offline=True` to `bulk_download_crs()`, the DAC1, DAC2a, DAC2b and multisystem bulk
25+
readers, and `bulk_download_parquet()`.** The reader makes no network request and reads the
26+
cached or registered copy whatever its age. With nothing cached it raises the new
27+
`BulkFileNotCachedError`, which prints the `register_bulk_file` call. A CRS year file
28+
registers with `url=` and reads offline through `bulk_download_parquet(url=..., offline=True)`.
29+
When two cached copies match a dataset, the one stored last is read, even within the same second.
30+
- **`BulkDownloadChallengeError` prints the fix.** Its message names the file to download in a
31+
browser and the `register_bulk_file` call that registers it, including the version token the
32+
reader saw. The error gains `filename`, `dataset` and `version` attributes.
33+
- **Bulk downloads honour `Retry-After` on 429 and 503.** A wait of up to 300 seconds is taken
34+
and the request retried, up to three times. A longer wait, a missing header or a fourth
35+
throttled response raises `BulkDownloadHTTPError`, which now carries `retry_after`.
36+
- **Adds `CRS_BULK_LABEL` and `CRS_REDUCED_BULK_LABEL`** to `oda_reader.crs`. `CacheManager`
37+
gains `register()`, `find_entry()` and `snapshot()`, and `list_records()` reports each entry's `source` and
38+
`url`.
39+
- **The bulk-file cache keeps a dataset's only copy past its TTL.** In 1.11.4 the startup sweep
40+
deleted every downloaded file past its TTL, including a dataset's only copy, which a scripted
41+
download may not be able to replace. The sweep now deletes an expired download only when a newer
42+
copy of the same dataset is cached. An only copy stays: an online read refetches it and an offline read uses it.
43+
A file downloaded by URL alone stays until a download replaces it or the cache is cleared.
44+
- **A cache hit fills in the URL and dataset a cached record lacks**, so a file downloaded before
45+
they were recorded becomes readable with `offline=True`.
46+
- **A bulk URL with spaces and its `%20` form share one cache entry.** Registration, downloads
47+
and offline reads all key the cache on the percent-encoded URL.
48+
349
## 1.11.4 (2026-10-21)
450

551
- **The bulk-file cache keeps every file it downloads until its TTL.** It used to keep only the

‎docs/docs/bulk-downloads.md‎

Lines changed: 79 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,25 @@ For large-scale analysis, bulk downloads are faster and more reliable than repea
2323
- Exploratory analysis with changing queries
2424
- You only need recent data
2525

26+
## How ODA Reader fetches bulk files
27+
28+
OECD publishes the bulk files on `webfs-dcd.oecd.org`. ODA Reader downloads each file whole,
29+
keeps a local copy in the [bulk file cache](caching.md#bulk-file-cache) and answers every query
30+
from that copy. It never reads a bulk file remotely.
31+
32+
Before a download, ODA Reader sends one single-byte request to read the file's version token
33+
(its `ETag`, or `Last-Modified` when no `ETag` is sent). It downloads the file only when it has
34+
no copy, when the token has changed, or when the copy is past its 90-day TTL. Otherwise it reads
35+
the copy it has. See [Cache invalidation on republish](caching.md#cache-invalidation-on-republish).
36+
37+
OECD may change bulk file names, for example by adding a timestamp. ODA Reader does not hard-code
38+
them. On each online read it looks up the current file name in the SDMX dataflow annotations,
39+
and an offline read finds the cached copy by dataset, whatever name it was stored under.
40+
41+
A scripted request can meet a Cloudflare challenge; see
42+
[Cloudflare challenge](#cloudflare-challenge-bulkdownloadchallengeerror). The supported route
43+
then is to download the file in a browser and pass it to `register_bulk_file`.
44+
2645
## CRS bulk downloads
2746

2847
The full Creditor Reporting System dataset is available as a bare parquet file (1.18 GB, not zipped). ODA Reader can download and load it for you.
@@ -450,20 +469,71 @@ See [DAC2b bulk download](#dac2b-bulk-download) above for the full column list a
450469

451470
**Slow download**: Bulk downloads depend on OECD's file server speed. Try again later if slow. Once downloaded, files are cached. Each attempt has a 10-second connect / 60-second read timeout, so a stalled connection fails with an error.
452471

453-
### Cloudflare challenge (`BulkDownloadChallengeError`)
472+
**HTTP 429 or 503**: When the file server answers with a `Retry-After` of 300 seconds or less, ODA Reader waits that long and tries again, up to three times. A longer wait, a missing `Retry-After`, or a fourth throttled response raises `BulkDownloadHTTPError`, and its `retry_after` attribute holds the wait the server asked for in seconds.
454473

455-
Cloudflare can require an interactive browser check on an OECD bulk-file
456-
request. These checks require browser execution. ODA Reader raises
457-
`BulkDownloadChallengeError` and includes the `cf_ray` value supplied by
458-
OECD/Cloudflare. Include that value when reporting the failure to OECD.
474+
### Cloudflare challenge (`BulkDownloadChallengeError`)
459475

460476
OECD supports downloading the bulk files in a browser. Scripted downloads,
461477
including the ones ODA Reader makes, are not a service OECD has committed
462478
to, so OECD's file server can challenge them at any time. A pipeline that
463479
must run unattended should be ready to handle `BulkDownloadChallengeError`.
464480

465-
When bulk access is unavailable, choose a narrowly filtered API request
466-
explicitly.
481+
When ODA Reader meets a challenge it raises `BulkDownloadChallengeError`. Its message names
482+
the file and prints the call that registers a copy downloaded in a browser:
483+
484+
```text
485+
Cloudflare presented an interactive challenge for https://webfs-dcd.oecd.org/.../CRS.parquet.
486+
Open that URL in a browser to download CRS.parquet, then register the file so
487+
oda-reader reads it from the cache:
488+
489+
import oda_reader
490+
oda_reader.register_bulk_file('~/Downloads/CRS.parquet', dataset='crs', url='https://webfs-dcd.oecd.org/.../CRS.parquet', version='"5f1c..."')
491+
```
492+
493+
The error also carries these values as attributes: `url`, `filename`,
494+
`dataset` (set when a named reader such as `bulk_download_crs()` made the
495+
request), `version` (the host's token, when it could be read) and `cf_ray`.
496+
497+
#### Register a file downloaded in a browser
498+
499+
Open the URL in a browser, let it save the file, then register it:
500+
501+
```python
502+
import oda_reader
503+
504+
oda_reader.register_bulk_file(
505+
"~/Downloads/CRS.parquet", dataset="crs", url="<url from the error>"
506+
)
507+
crs = oda_reader.bulk_download_crs() # reads the registered copy
508+
```
509+
510+
`register_bulk_file` checks that the file is a valid zip or parquet payload,
511+
copies it into the bulk file cache and returns the cached path. You can
512+
delete the download afterwards. The registered copy does not expire. Online
513+
reads keep serving it until OECD publishes a new version of the file.
514+
`bulk_download_crs(offline=True)` reads it with no network request. A CRS year file registers with `url=` and reads
515+
offline through `bulk_download_parquet(url=..., offline=True)`. See
516+
[Registered files](caching.md#registered-files) and
517+
[Reading offline](caching.md#reading-offline).
518+
519+
`dataset` takes one of these names:
520+
521+
| `dataset` | Reader |
522+
|---|---|
523+
| `"crs"` | `bulk_download_crs()` |
524+
| `"crs-reduced"` | `bulk_download_crs(reduced_version=True)` |
525+
| `"dac1"` | `bulk_download_dac1()` |
526+
| `"dac2a"` | `bulk_download_dac2a()` |
527+
| `"dac2b"` | `bulk_download_dac2b()` |
528+
| `"multisystem"` | `bulk_download_multisystem()` |
529+
530+
Without `url`, ODA Reader looks the URL up in OECD's SDMX metadata, which
531+
needs network access to `sdmx.oecd.org`. `version` is the file host's
532+
version token. The call the error prints includes it when the reader could
533+
read it.
534+
535+
If the browser download is also blocked, report the `cf_ray` value to OECD
536+
and use a narrowly filtered API request instead.
467537

468538
```python
469539
from oda_reader import download_dac1
@@ -495,8 +565,8 @@ dimension.
495565

496566
**`BulkPayloadCorruptError`**: The OECD's bulk endpoint occasionally serves a
497567
truncated or malformed file (a bad zip, or a bare parquet file that fails to
498-
parse). The corrupt entry is removed automatically before the exception is
499-
raised, so the next call cleanly re-downloads. Retry the call, or pass
568+
parse). The corrupt download is deleted before the exception is raised and
569+
never replaces the cached copy, so the next call cleanly re-downloads. Retry the call, or pass
500570
`use_raw_cache=False` to skip the cache for that invocation.
501571

502572
## Next steps

‎docs/docs/caching.md‎

Lines changed: 90 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -145,28 +145,37 @@ earlier versions stays on disk.
145145

146146
### Bulk file cache
147147

148-
The bulk file cache (used by `bulk_download_crs`, `download_crs_file`,
149-
`bulk_download_dac1`, `bulk_download_dac2a`, `bulk_download_dac2b`, and `bulk_download_multisystem`)
150-
is governed separately
151-
because the files are large (up to 1.18 GB each):
152-
153-
- **Expiry**: every bulk file you download stays cached until its TTL
154-
below runs out, so disk use grows with the number of distinct files you
155-
fetch. Expired files are deleted the first time a Python session makes a
156-
bulk download call.
148+
The bulk file cache is used by `bulk_download_crs`, `download_crs_file`, `bulk_download_dac1`,
149+
`bulk_download_dac2a`, `bulk_download_dac2b`, `bulk_download_multisystem` and `download_crdf`.
150+
ODA Reader downloads each bulk file whole, keeps the copy here and answers queries from it. It
151+
never reads a bulk file remotely. A scripted request can meet a Cloudflare challenge; see
152+
[Cloudflare challenge](bulk-downloads.md#cloudflare-challenge-bulkdownloadchallengeerror). A
153+
browser download registered with `register_bulk_file` then fills the cache (see
154+
[Registered files](#registered-files)).
155+
156+
The cache is governed separately because the files are large (up to 1.18 GB each):
157+
158+
- **Expiry**: a downloaded bulk file past its TTL below is deleted the first
159+
time a Python session makes a bulk download call, but only once a newer
160+
copy of the same dataset is cached. A dataset's only copy stays: an online
161+
read refetches it and an offline read uses it. A file downloaded by URL
162+
alone, with no dataset, stays until a download replaces it or you clear
163+
the cache. Registered files never expire (see
164+
[Registered files](#registered-files)).
157165
- **Publication-version invalidation**: a cached file is refetched as soon
158166
as OECD republishes it, not just after the TTL below expires. See
159167
[Cache invalidation on republish](#cache-invalidation-on-republish).
160168
- **Per-entry TTL**: 90 days. A file older than that is refetched even if
161169
OECD hasn't republished it, which covers the cases where the
162170
publication-version check above couldn't be made (offline, server error,
163-
or the relevant headers absent).
171+
or the relevant headers absent). Registered files have no TTL.
164172
- **Integrity validation**: every freshly downloaded payload is end-to-end
165173
checked before being trusted (a zip CRC walk, or a footer-magic check for
166174
the bare-parquet CRS files). Truncation is also caught earlier, during the
167175
download itself: streamed bytes are compared against the response's
168176
`Content-Length` and a mismatch is retried before it ever reaches the
169-
cache. A corrupt download is removed from the cache and raises
177+
cache. A corrupt download is deleted before it replaces anything, so a
178+
copy already in the cache stays readable, and raises
170179
`BulkPayloadCorruptError` so you can simply retry. Cached files are
171180
trusted on hit (no recheck on every call).
172181
- **Self-healing**: temp files left behind by interrupted downloads (older
@@ -201,8 +210,70 @@ recently it was fetched. The token comes from the file host:
201210
still works offline. A blocked request (for example a Cloudflare
202211
challenge) logs a warning naming the HTTP status or Cloudflare Ray ID.
203212

204-
You don't need to call anything for this. It runs automatically on every
205-
bulk download.
213+
A download happens only when the cache has no copy, the token has changed, or the copy is past
214+
its TTL. You don't need to call anything for this. It runs automatically on every bulk download.
215+
216+
OECD may change bulk file names, for example by adding a timestamp. Each online read looks up the
217+
current name in the SDMX dataflow annotations, so a renamed file is found without an ODA Reader
218+
update (see [Stale dataflow metadata](#stale-dataflow-metadata) for how a cached annotation that
219+
names the old file is handled). An offline read finds the cached copy by dataset, whatever name it was stored under.
220+
221+
#### Registered files
222+
223+
`register_bulk_file()` puts a file you downloaded yourself into the bulk
224+
file cache, for when OECD's Cloudflare front end blocks the download (see
225+
[Cloudflare challenge](bulk-downloads.md#cloudflare-challenge-bulkdownloadchallengeerror)):
226+
227+
```python
228+
import oda_reader
229+
230+
oda_reader.register_bulk_file(
231+
"~/Downloads/CRS.parquet", dataset="crs", url="<url from the error>"
232+
)
233+
```
234+
235+
A registered file is stored under the same key a download of that URL
236+
would use. Online reads treat it like any cached file: the reader resolves
237+
the URL, reads the host's version token, and serves the registered copy
238+
when the token equals the one recorded at registration or cannot be read.
239+
A different token means OECD has republished, so the reader downloads the
240+
new file and it replaces the registration.
241+
242+
The recorded token is the `version` you pass. The challenge error prints
243+
the token the reader saw just before the blocked download, so copying its
244+
call records the right one. Without `version`, `register_bulk_file` asks
245+
the host for the token itself, and records none if that request fails too.
246+
With no token recorded, the registered copy is served until a token
247+
request succeeds, which triggers a download.
248+
249+
A registered entry has no TTL, so the 90-day expiry never removes it. It
250+
is replaced only by another registration, a completed
251+
download of the same file, or a cache clear such as
252+
`oda_data.cache.clear('raw')` or `bulk_cache_manager().clear()`.
253+
`bulk_cache_manager().list_records()` reports `source="registered"` for a
254+
registered entry and `source="download"` for the rest.
255+
256+
#### Reading offline
257+
258+
`bulk_download_crs`, the DAC1, DAC2a, DAC2b and multisystem bulk readers, and
259+
`bulk_download_parquet` take `offline=True`. The reader then makes no network
260+
request, neither the URL lookup nor the version-token request, and reads whatever copy of that dataset the cache holds, registered or
261+
downloaded, however old:
262+
263+
```python
264+
from oda_reader import bulk_download_crs
265+
266+
crs = bulk_download_crs(offline=True)
267+
```
268+
269+
With nothing cached it raises `BulkFileNotCachedError`, which prints the
270+
`register_bulk_file` call to run. Offline reads find a registered file by
271+
its `dataset`, so register with `dataset` when you plan to read offline. A
272+
CRS year file has no `dataset` name. Register it with `url=` and read it
273+
offline through `bulk_download_parquet(url=..., offline=True)`.
274+
275+
Set `ODA_READER_CACHE_DIR` before registering to put the file in a cache
276+
other than the default one.
206277

207278
#### Bypassing the bulk file cache
208279

@@ -245,17 +316,18 @@ call anything to get this behaviour. It runs automatically for
245316
#### Handling corrupt downloads
246317

247318
The OECD's bulk endpoint occasionally serves a truncated or malformed file.
248-
When that happens, a `BulkPayloadCorruptError` is raised and the bad entry is
249-
already removed from disk by the time you see it, so the next call cleanly
250-
re-downloads:
319+
When that happens, a `BulkPayloadCorruptError` is raised and the bad download is
320+
already removed from disk by the time you see it. Any copy the cache already
321+
held, including a registered file, stays in place with its record, and the
322+
next call cleanly re-downloads:
251323

252324
```python
253325
from oda_reader import bulk_download_crs, BulkPayloadCorruptError
254326

255327
try:
256328
crs = bulk_download_crs()
257329
except BulkPayloadCorruptError:
258-
# Bad entry already removed — just retry
330+
# Bad download already removed, just retry
259331
crs = bulk_download_crs()
260332
```
261333

@@ -377,7 +449,7 @@ from oda_reader import download_dac1
377449
data = download_dac1(start_year=2022, end_year=2022) # instant
378450
```
379451

380-
Cache persists until you clear it. HTTP responses expire after 7 days and bulk files after 30.
452+
Cache persists until you clear it. HTTP responses expire after 7 days. Bulk files are refetched after 90 days.
381453

382454
### Filter aggressively to reduce API load
383455

0 commit comments

Comments
 (0)