Skip to content

Commit 68a203e

Browse files
authored
extend to .csv (#29)
1 parent dc96c69 commit 68a203e

5 files changed

Lines changed: 93 additions & 22 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
# Changelog for oda_reader
22

3+
## 1.4.1 (2025-12-19)
4+
- Extends bulk download auto-detection to support `.csv` files in addition to `.txt` files.
5+
36
## 1.4.0 (2025-12-19)
47
- Adds `bulk_download_dac2a()` function for bulk downloading the full DAC2A dataset.
5-
- Auto-detects file types (parquet vs txt/csv) in bulk downloads, removing the need for the `is_txt` parameter.
8+
- Auto-detects file types (parquet or txt) in bulk downloads, removing the need for the `is_txt` parameter.
69
- Auto-detects CSV delimiters (comma, pipe, tab, semicolon) when reading txt files from bulk downloads.
710
- Deprecates the `is_txt` parameter in `bulk_download_parquet()`. The parameter is still accepted for backward compatibility but emits a deprecation warning and will be removed in a future major release.
811
- Adds pytest and pytest-mock to dev dependencies for improved testing support.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "oda_reader"
3-
version = "1.4.0"
3+
version = "1.4.1"
44
description = "A simple package to import ODA data from the OECD's API and AidData's database"
55
readme = "README.md"
66
license = "MIT"

src/oda_reader/download/download_tools.py

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -226,16 +226,16 @@ def _save_or_return_parquet_files_from_content(
226226
*,
227227
as_iterator: bool = False,
228228
) -> list[pd.DataFrame] | None | typing.Iterator[pd.DataFrame]:
229-
"""Extract parquet or txt (CSV) files from a zip archive.
229+
"""Extract parquet, csv, or txt files from a zip archive.
230230
231231
If `save_to_path` is provided the files are extracted and written
232232
to disk. Otherwise the contents are returned either as a list of
233233
`DataFrame` objects or, when `as_iterator` is `True`, as an iterator
234234
yielding one `DataFrame` per row group (parquet only).
235235
236-
The function auto-detects whether the zip contains parquet or txt files.
237-
Txt files have their delimiter auto-detected (comma or pipe) and are
238-
converted to parquet when saving.
236+
The function auto-detects whether the zip contains parquet, csv, or txt files.
237+
CSV/txt files have their delimiter auto-detected (comma, pipe, tab, etc.) and
238+
are converted to parquet when saving.
239239
240240
Args:
241241
response_content: Bytes or `Path` pointing to the zipped file.
@@ -252,7 +252,11 @@ def _save_or_return_parquet_files_from_content(
252252

253253
with _open_zip(response_content=response_content) as z:
254254
parquet_files = [name for name in z.namelist() if name.endswith(".parquet")]
255-
txt_files = [name for name in z.namelist() if name.endswith(".txt")]
255+
csv_files = [
256+
name
257+
for name in z.namelist()
258+
if name.endswith(".txt") or name.endswith(".csv")
259+
]
256260

257261
# Determine which file type we're dealing with
258262
if parquet_files:
@@ -276,15 +280,18 @@ def _save_or_return_parquet_files_from_content(
276280
logger.info(f"Reading {len(parquet_files)} parquet files.")
277281
return [pd.read_parquet(z.open(file)) for file in parquet_files]
278282

279-
elif txt_files:
283+
elif csv_files:
280284
if as_iterator:
281-
raise ValueError("Streaming not supported for txt files.")
285+
raise ValueError("Streaming not supported for csv/txt files.")
282286

283287
if save_to_path:
284288
save_to_path.mkdir(parents=True, exist_ok=True)
285-
for file_name in txt_files:
289+
for file_name in csv_files:
286290
clean_name = (
287-
file_name.replace(".txt", ".parquet").lower().replace(" ", "_")
291+
file_name.replace(".txt", ".parquet")
292+
.replace(".csv", ".parquet")
293+
.lower()
294+
.replace(" ", "_")
288295
)
289296
logger.info(f"Saving {clean_name}")
290297
with z.open(file_name) as f_in:
@@ -299,9 +306,9 @@ def _save_or_return_parquet_files_from_content(
299306
).to_parquet(save_to_path / clean_name)
300307
return None
301308

302-
logger.info(f"Reading {len(txt_files)} txt files.")
309+
logger.info(f"Reading {len(csv_files)} csv/txt files.")
303310
dfs = []
304-
for file_name in txt_files:
311+
for file_name in csv_files:
305312
with z.open(file_name) as f_in:
306313
delimiter = _detect_delimiter(f_in)
307314
logger.info(f"Detected delimiter for {file_name}: '{delimiter}'")
@@ -317,14 +324,14 @@ def _save_or_return_parquet_files_from_content(
317324
return dfs
318325

319326
else:
320-
raise ValueError("No parquet or txt files found in the zip archive.")
327+
raise ValueError("No parquet, csv, or txt files found in the zip archive.")
321328

322329

323330
def _save_or_return_parquet_files_from_txt_in_zip(
324331
response_content: bytes | Path,
325332
save_to_path: Path | str | None = None,
326333
) -> list[pd.DataFrame] | None:
327-
"""Extract a `.txt` file from a zipped archive supplied as bytes or a file path.
334+
"""Extract csv or txt files from a zipped archive supplied as bytes or a file path.
328335
329336
The file is read as CSV (with auto-detected delimiter) and optionally saved
330337
as a parquet file.
@@ -341,15 +348,22 @@ def _save_or_return_parquet_files_from_txt_in_zip(
341348
save_to_path = Path(save_to_path).expanduser().resolve() if save_to_path else None
342349

343350
with _open_zip(response_content=response_content) as z:
344-
# Find all txt files in the zip archive
345-
files = [name for name in z.namelist() if name.endswith(".txt")]
351+
# Find all csv/txt files in the zip archive
352+
files = [
353+
name
354+
for name in z.namelist()
355+
if name.endswith(".txt") or name.endswith(".csv")
356+
]
346357

347358
# If save_to_path is provided, save the files to the path
348359
if save_to_path:
349360
save_to_path.mkdir(parents=True, exist_ok=True)
350361
for file_name in files:
351362
clean_name = (
352-
file_name.replace(".txt", ".parquet").lower().replace(" ", "_")
363+
file_name.replace(".txt", ".parquet")
364+
.replace(".csv", ".parquet")
365+
.lower()
366+
.replace(" ", "_")
353367
)
354368
logger.info(f"Saving {clean_name}")
355369
with z.open(file_name) as f_in:
@@ -501,7 +515,7 @@ def bulk_download_parquet(
501515
"""Download data from the stats.oecd.org file download service.
502516
503517
Certain data files are available as a bulk download. This function
504-
downloads the files (parquet or txt/csv) and returns a single DataFrame.
518+
downloads the files (parquet, csv, or txt) and returns a single DataFrame.
505519
The file type is auto-detected from the zip contents.
506520
507521
Args:

tests/download/unit/test_download_tools.py

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ def _create_zip_with_parquet(self) -> bytes:
140140
return zip_buffer.getvalue()
141141

142142
def _create_zip_with_txt(self, delimiter: str = ",") -> bytes:
143-
"""Create a zip file containing a txt/CSV file."""
143+
"""Create a zip file containing a txt file."""
144144
if delimiter == "|":
145145
csv_content = "col1|col2|col3\n1|2|3\n4|5|6"
146146
else:
@@ -151,6 +151,18 @@ def _create_zip_with_txt(self, delimiter: str = ",") -> bytes:
151151
z.writestr("test_data.txt", csv_content.encode("utf-8"))
152152
return zip_buffer.getvalue()
153153

154+
def _create_zip_with_csv(self, delimiter: str = ",") -> bytes:
155+
"""Create a zip file containing a .csv file."""
156+
if delimiter == "|":
157+
csv_content = "col1|col2|col3\n1|2|3\n4|5|6"
158+
else:
159+
csv_content = "col1,col2,col3\n1,2,3\n4,5,6"
160+
161+
zip_buffer = io.BytesIO()
162+
with zipfile.ZipFile(zip_buffer, "w") as z:
163+
z.writestr("test_data.csv", csv_content.encode("utf-8"))
164+
return zip_buffer.getvalue()
165+
154166
def test_auto_detect_parquet_files(self):
155167
"""Test that parquet files are auto-detected and read correctly."""
156168
zip_content = self._create_zip_with_parquet()
@@ -187,6 +199,48 @@ def test_auto_detect_txt_files_pipe(self):
187199
assert list(df.columns) == ["col1", "col2", "col3"]
188200
assert len(df) == 2
189201

202+
def test_auto_detect_csv_files(self):
203+
"""Test that .csv files are auto-detected and read correctly."""
204+
zip_content = self._create_zip_with_csv(delimiter=",")
205+
206+
result = _save_or_return_parquet_files_from_content(zip_content)
207+
208+
assert result is not None
209+
assert len(result) == 1
210+
assert isinstance(result[0], pd.DataFrame)
211+
assert list(result[0].columns) == ["col1", "col2", "col3"]
212+
assert len(result[0]) == 2
213+
214+
def test_auto_detect_csv_files_pipe(self):
215+
"""Test that pipe-delimited .csv files are auto-detected."""
216+
zip_content = self._create_zip_with_csv(delimiter="|")
217+
218+
result = _save_or_return_parquet_files_from_content(zip_content)
219+
220+
assert result is not None
221+
assert len(result) == 1
222+
df = result[0]
223+
assert isinstance(df, pd.DataFrame)
224+
assert list(df.columns) == ["col1", "col2", "col3"]
225+
assert len(df) == 2
226+
227+
def test_save_csv_as_parquet_to_path(self, tmp_path):
228+
"""Test that .csv files are converted to parquet when saving."""
229+
zip_content = self._create_zip_with_csv()
230+
231+
result = _save_or_return_parquet_files_from_content(
232+
zip_content, save_to_path=tmp_path
233+
)
234+
235+
assert result is None
236+
saved_files = list(tmp_path.glob("*.parquet"))
237+
assert len(saved_files) == 1
238+
# Verify conversion to parquet with correct name
239+
assert saved_files[0].suffix == ".parquet"
240+
assert "test_data" in saved_files[0].name
241+
df = pd.read_parquet(saved_files[0])
242+
assert len(df) == 2
243+
190244
def test_save_parquet_to_path(self, tmp_path):
191245
"""Test saving parquet files to a path."""
192246
zip_content = self._create_zip_with_parquet()
@@ -224,7 +278,7 @@ def test_raises_on_empty_zip(self):
224278
with zipfile.ZipFile(zip_buffer, "w") as z:
225279
z.writestr("readme.md", "Not a data file")
226280

227-
with pytest.raises(ValueError, match="No parquet or txt files"):
281+
with pytest.raises(ValueError, match="No parquet, csv, or txt files"):
228282
_save_or_return_parquet_files_from_content(zip_buffer.getvalue())
229283

230284
def test_txt_iterator_raises(self):

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)