Skip to content

Commit 9784ab5

Browse files
authored
Add Polars tabular file reader (#238)
2 parents 638c4cc + 5f41d4c commit 9784ab5

3 files changed

Lines changed: 147 additions & 1 deletion

File tree

cfa/stf/forecasttools/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from .augment_samples_with_observations import augment_samples_with_observations
1616
from .create_proportions import create_proportions
1717
from .location_table import LOCATION_LIST
18-
from .utils import coalesce_common_columns
18+
from .utils import coalesce_common_columns, read_tabular
1919

2020

2121
def __getattr__(name):
@@ -28,6 +28,7 @@ def __getattr__(name):
2828

2929
__all__ = [
3030
"coalesce_common_columns",
31+
"read_tabular",
3132
"append_prop_data",
3233
"augment_samples_with_observations",
3334
"create_proportions",

cfa/stf/forecasttools/utils.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,65 @@
1+
from pathlib import Path
2+
from typing import Any
3+
14
import polars as pl
25
import polars.selectors as cs
36

47

8+
def _read_parquet_with_timezone_correction(
9+
path_to_file: str | Path, **kwargs: Any
10+
) -> pl.DataFrame:
11+
"""Read Parquet data, interpreting timezone-naive timestamps as UTC."""
12+
df = pl.read_parquet(path_to_file, **kwargs)
13+
timestamp_columns_without_timezone = [
14+
name
15+
for name, dtype in df.schema.items()
16+
if isinstance(dtype, pl.Datetime) and dtype.time_zone is None
17+
]
18+
19+
return df.with_columns(
20+
pl.col(timestamp_columns_without_timezone).dt.convert_time_zone("UTC")
21+
)
22+
23+
24+
def read_tabular(path_to_file: str | Path, **kwargs: Any) -> pl.DataFrame:
25+
"""Read a tabular file, inferring its format from the file extension.
26+
27+
Parameters
28+
----------
29+
path_to_file
30+
Path to a ``.csv``, ``.tsv``, or ``.parquet`` file. The extension is
31+
matched case-insensitively.
32+
**kwargs
33+
Additional keyword arguments passed to :func:`polars.read_csv` or
34+
:func:`polars.read_parquet`, depending on the inferred format.
35+
36+
Returns
37+
-------
38+
pl.DataFrame
39+
The contents of the file.
40+
41+
Raises
42+
------
43+
ValueError
44+
If the path does not have a supported file extension.
45+
"""
46+
file_format = Path(path_to_file).suffix.removeprefix(".").lower()
47+
48+
if file_format == "csv":
49+
return pl.read_csv(path_to_file, **kwargs)
50+
if file_format == "tsv":
51+
kwargs.setdefault("separator", "\t")
52+
return pl.read_csv(path_to_file, **kwargs)
53+
if file_format == "parquet":
54+
return _read_parquet_with_timezone_correction(path_to_file, **kwargs)
55+
56+
supported_formats = ".csv, .tsv, and .parquet"
57+
raise ValueError(
58+
f"Unsupported file extension {Path(path_to_file).suffix!r}; "
59+
f"expected one of {supported_formats}."
60+
)
61+
62+
563
def coalesce_common_columns(
664
df: pl.DataFrame, suffix: str, new_colname: str | None = None
765
) -> pl.DataFrame:
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
from datetime import datetime
2+
from zoneinfo import ZoneInfo
3+
4+
import polars as pl
5+
import polars.testing as plt
6+
import pytest
7+
8+
import cfa.stf.forecasttools as ft
9+
10+
11+
@pytest.fixture
12+
def tabular_data():
13+
return pl.DataFrame(
14+
{
15+
"location": ["US", "01"],
16+
"value": [1.5, 2.5],
17+
}
18+
)
19+
20+
21+
@pytest.mark.parametrize("file_format", ["csv", "tsv", "parquet"])
22+
def test_read_tabular_reads_supported_formats(tmp_path, tabular_data, file_format):
23+
path = tmp_path / f"data.{file_format}"
24+
if file_format == "csv":
25+
tabular_data.write_csv(path)
26+
elif file_format == "tsv":
27+
tabular_data.write_csv(path, separator="\t")
28+
else:
29+
tabular_data.write_parquet(path)
30+
31+
result = ft.read_tabular(path)
32+
33+
plt.assert_frame_equal(result, tabular_data)
34+
35+
36+
def test_read_tabular_matches_extension_case_insensitively(tmp_path, tabular_data):
37+
path = tmp_path / "data.CSV"
38+
tabular_data.write_csv(path)
39+
40+
result = ft.read_tabular(path)
41+
42+
plt.assert_frame_equal(result, tabular_data)
43+
44+
45+
def test_read_tabular_forwards_reader_options(tmp_path):
46+
path = tmp_path / "data.csv"
47+
path.write_text("value\n1\n2\n3\n")
48+
49+
result = ft.read_tabular(path, n_rows=2)
50+
51+
assert result.get_column("value").to_list() == [1, 2]
52+
53+
54+
def test_read_tabular_corrects_timezone_naive_parquet_timestamps(tmp_path):
55+
path = tmp_path / "timestamps.parquet"
56+
tokyo = ZoneInfo("Asia/Tokyo")
57+
data = pl.DataFrame(
58+
{
59+
"timestamp_without_timezone": [datetime(2026, 1, 15, 12, 30)],
60+
"timestamp_with_timezone": [datetime(2026, 1, 15, 12, 30, tzinfo=tokyo)],
61+
},
62+
schema={
63+
"timestamp_without_timezone": pl.Datetime("us"),
64+
"timestamp_with_timezone": pl.Datetime("us", "Asia/Tokyo"),
65+
},
66+
)
67+
data.write_parquet(path)
68+
69+
result = ft.read_tabular(path)
70+
71+
assert result.schema == pl.Schema(
72+
{
73+
"timestamp_without_timezone": pl.Datetime("us", "UTC"),
74+
"timestamp_with_timezone": pl.Datetime("us", "Asia/Tokyo"),
75+
}
76+
)
77+
assert result.select(pl.all().to_physical()).row(0) == data.select(
78+
pl.all().to_physical()
79+
).row(0)
80+
81+
82+
@pytest.mark.parametrize("filename", ["data.json", "data"])
83+
def test_read_tabular_rejects_unsupported_extensions(tmp_path, filename):
84+
path = tmp_path / filename
85+
86+
with pytest.raises(ValueError, match="Unsupported file extension"):
87+
ft.read_tabular(path)

0 commit comments

Comments
 (0)