Skip to content

Commit fd81ce7

Browse files
Merge pull request #65 from CDCgov/rr-61-polarslazyframe
Rr 61 polarslazyframe
2 parents dc784c4 + 9134d1c commit fd81ce7

4 files changed

Lines changed: 164 additions & 26 deletions

File tree

cfa/dataops/catalog.py

Lines changed: 90 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@
66
from configparser import ConfigParser
77
from importlib import import_module
88
from io import BytesIO
9+
from pathlib import PurePosixPath
910
from types import SimpleNamespace
10-
from typing import Any, List, Sequence
11+
from typing import Any, List, Literal, Sequence, overload
1112

1213
import pandas as pd
1314
import polars as pl
1415
import tomli
16+
from azure.identity import ManagedIdentityCredential
1517

1618
from cfa.cloudops.blob_helpers import (
1719
read_blob_stream,
@@ -386,37 +388,111 @@ def download_version_to_local(
386388
self.ledger_entry(action="read")
387389
return written
388390

391+
@overload
389392
def get_dataframe(
390393
self,
391-
output="pandas",
392-
version="latest",
393-
pl_lazy: bool = False,
394+
output: Literal["pandas", "pd"] = "pandas",
395+
version: str = "latest",
396+
newest: bool = True,
397+
) -> pd.DataFrame: ...
398+
399+
@overload
400+
def get_dataframe(
401+
self,
402+
output: Literal["polars", "pl"],
403+
version: str = "latest",
404+
newest: bool = True,
405+
) -> pl.DataFrame: ...
406+
407+
@overload
408+
def get_dataframe(
409+
self,
410+
output: Literal["pl_lazy", "lazy"],
411+
version: str = "latest",
394412
newest: bool = True,
395-
) -> pd.DataFrame | pl.DataFrame:
413+
) -> pl.LazyFrame: ...
414+
415+
def get_dataframe(
416+
self,
417+
output: Literal[
418+
"pandas", "pd", "polars", "pl", "pl_lazy", "lazy"
419+
] = "pandas",
420+
version: str = "latest",
421+
newest: bool = True,
422+
) -> pd.DataFrame | pl.DataFrame | pl.LazyFrame:
396423
"""Get the data as a pandas or polars dataframe
397424
398425
Args:
399426
output (str, optional): the type of dataframe to return,
400-
either 'pandas' or 'polars'. Defaults to "pandas".
427+
either 'pandas' or 'polars' or 'pl_lazy'. Defaults to "pandas".
401428
version (str, optional): the version of the data to get.
402429
Defaults to "latest".
403-
pl_lazy (bool, optional): whether to return a lazy polars dataframe.
404-
Defaults to False.
405430
newest (bool, optional): whether to get the newest matching version. Defaults to True.
406431
False returns the oldest matching version.
407432
408433
Raises:
409-
ValueError: if output is not 'pandas' or 'polars'
434+
ValueError: if output is not one of
435+
'pandas', 'pd', 'polars', 'pl', 'pl_lazy', or 'lazy'
410436
411437
Returns:
412-
pd.DataFrame | pl.DataFrame: the dataframe
438+
pd.DataFrame | pl.DataFrame | pl.LazyFrame: the dataframe
413439
"""
414-
if output not in ["pandas", "polars", "pd", "pl"]:
440+
if output not in ["pandas", "polars", "pd", "pl", "pl_lazy", "lazy"]:
441+
raise ValueError(
442+
f"Output {output} needs to be 'pandas', 'polars', 'pd', 'pl', 'pl_lazy', or 'lazy'."
443+
)
444+
# Fetch version blobs once and validate before deriving file extension.
445+
version_blobs = self._get_version_blobs(version=version, newest=newest)
446+
if not version_blobs:
415447
raise ValueError(
416-
f"Output {output} needs to be 'pandas', 'polars', 'pd, or 'pl'."
448+
f"No blobs found for version '{version}' in container '{self.container}'."
417449
)
450+
name = version_blobs[0]["name"]
451+
file_ext = PurePosixPath(name).suffix.lstrip(".").lower()
452+
if output in ["pl_lazy", "lazy"]:
453+
if file_ext in ["parquet", "parq"]:
454+
path = str(PurePosixPath(name).parent / f"*.{file_ext}")
455+
fullpath = f"az://{self.container}/{path}"
456+
df = pl.scan_parquet(
457+
fullpath,
458+
storage_options={"account_name": self.account},
459+
credential_provider=pl.CredentialProviderAzure(
460+
credential=ManagedIdentityCredential()
461+
),
462+
)
463+
self.ledger_entry(action="read")
464+
return df
465+
elif file_ext == "csv":
466+
path = str(PurePosixPath(name).parent / f"*.{file_ext}")
467+
fullpath = f"az://{self.container}/{path}"
468+
df = pl.scan_csv(
469+
fullpath,
470+
infer_schema_length=None,
471+
storage_options={"account_name": self.account},
472+
credential_provider=pl.CredentialProviderAzure(
473+
credential=ManagedIdentityCredential()
474+
),
475+
)
476+
self.ledger_entry(action="read")
477+
return df
478+
elif file_ext == "ndjson" or file_ext == "jsonl":
479+
path = str(PurePosixPath(name).parent / f"*.{file_ext}")
480+
fullpath = f"az://{self.container}/{path}"
481+
df = pl.scan_ndjson(
482+
fullpath,
483+
infer_schema_length=None,
484+
storage_options={"account_name": self.account},
485+
credential_provider=pl.CredentialProviderAzure(
486+
credential=ManagedIdentityCredential()
487+
),
488+
)
489+
self.ledger_entry(action="read")
490+
return df
491+
else:
492+
raise ValueError(
493+
f"Lazy loading not supported for {file_ext} files."
494+
)
418495
blobs = self.read_blobs(version, newest=newest)
419-
file_ext = self.get_file_ext(version=version)
420496
blob_bytes = [blob.content_as_bytes() for blob in blobs]
421497
blob_files = [BytesIO(pq) for pq in blob_bytes]
422498
if file_ext == "csv":
@@ -431,8 +507,6 @@ def get_dataframe(
431507
],
432508
how="diagonal",
433509
)
434-
if pl_lazy:
435-
df = df.lazy()
436510
return df
437511
elif file_ext == "json":
438512
if output in ["pandas", "pd"]:
@@ -446,10 +520,8 @@ def get_dataframe(
446520
],
447521
how="diagonal",
448522
)
449-
if pl_lazy:
450-
df = df.lazy()
451523
return df
452-
elif file_ext == "jsonl":
524+
elif file_ext == "jsonl" or file_ext == "ndjson":
453525
if output in ["pandas", "pd"]:
454526
df = pd.concat(
455527
[pd.read_json(blob, lines=True) for blob in blob_files]
@@ -460,8 +532,6 @@ def get_dataframe(
460532
[pl.read_ndjson(blob) for blob in blob_files],
461533
how="diagonal",
462534
)
463-
if pl_lazy:
464-
df = df.lazy()
465535
return df
466536
elif file_ext == "parquet" or file_ext == "parq":
467537
if output in ["pandas", "pd"]:
@@ -474,8 +544,6 @@ def get_dataframe(
474544
[pl.read_parquet(pq_file) for pq_file in blob_files],
475545
how="diagonal",
476546
)
477-
if pl_lazy:
478-
df = df.lazy()
479547
return df
480548

481549
def ledger_entry(self, action: str) -> None:

changelog.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Calendar Versioning](https://calver.org/).
77
The versioning pattern is `YYYY.MM.DD.micro(a/b/{none if release})
88

99
---
10+
## [2026.03.31.0]
11+
12+
- updated polars lazyframe loading to correctly reference the Blob file without downloading
13+
1014
## [2026.03.02.0]
1115

1216
### Added

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "cfa.dataops"
3-
version = "2026.03.02.0"
3+
version = "2026.03.31.0"
44
description = "Data cataloging, ETL, modeling, verification, and validation for CFA"
55
authors = [
66
{ name = "Phil Rogers", email = "ap66@cdc.gov" },

tests/test_datasets_catalog.py

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,73 @@ def mock_read_blob_stream_json_df(
205205
assert isinstance(blobs_df, pd.DataFrame)
206206
blobs_df = datacat.tests.etl_test.load.get_dataframe(output="pl")
207207
assert isinstance(blobs_df, pl.DataFrame)
208-
blobs_df = datacat.tests.etl_test.load.get_dataframe(
209-
output="pl", pl_lazy=True
208+
blobs_df = datacat.tests.etl_test.load.get_dataframe(output="pl")
209+
assert isinstance(blobs_df, pl.DataFrame)
210+
211+
212+
def test_datasets_catalog_get_dataframe_pl_lazy(
213+
mocker, mock_write_blob_stream, dataset_ns_map, dataset_defaults
214+
):
215+
datacat = dict_to_sn(dataset_ns_map, dataset_defaults)
216+
dataset_namespaces = get_dataset_dot_path(dataset_ns_map)
217+
datacat.__setattr__("__namespace_list__", dataset_namespaces)
218+
219+
mocker.patch(
220+
"cfa.dataops.catalog.write_blob_stream",
221+
mock_write_blob_stream,
222+
)
223+
mocker.patch.object(
224+
datacat.tests.etl_test.load,
225+
"get_versions",
226+
return_value=["2025-06-03T17-56-50"],
227+
)
228+
mocker.patch.object(
229+
datacat.tests.etl_test.load,
230+
"_get_version_blobs",
231+
return_value=[
232+
{
233+
"name": "prefix_test/transformed/test_dataset/2025-06-03T17-56-50/data.parquet",
234+
"container": "container_test",
235+
}
236+
],
237+
)
238+
mocker.patch.object(
239+
datacat.tests.etl_test.load,
240+
"ledger_entry",
241+
return_value=None,
242+
)
243+
244+
# Avoid real auth wiring during test
245+
mocker.patch(
246+
"cfa.dataops.catalog.ManagedIdentityCredential", return_value=object()
247+
)
248+
mocker.patch(
249+
"cfa.dataops.catalog.pl.CredentialProviderAzure",
250+
side_effect=lambda credential: object(),
251+
)
252+
253+
scan_calls = []
254+
255+
def mock_scan_parquet(path, **kwargs):
256+
scan_calls.append((path, kwargs))
257+
return pl.DataFrame([{"test": 1, "data": 2}]).lazy()
258+
259+
mocker.patch(
260+
"cfa.dataops.catalog.pl.scan_parquet",
261+
side_effect=mock_scan_parquet,
262+
)
263+
264+
out_pl_lazy = datacat.tests.etl_test.load.get_dataframe(output="pl_lazy")
265+
out_lazy = datacat.tests.etl_test.load.get_dataframe(output="lazy")
266+
267+
assert isinstance(out_pl_lazy, pl.LazyFrame)
268+
assert isinstance(out_lazy, pl.LazyFrame)
269+
assert len(scan_calls) == 2
270+
assert (
271+
scan_calls[0][0]
272+
== "az://container_test/prefix_test/transformed/test_dataset/2025-06-03T17-56-50/*.parquet"
273+
)
274+
assert (
275+
scan_calls[1][0]
276+
== "az://container_test/prefix_test/transformed/test_dataset/2025-06-03T17-56-50/*.parquet"
210277
)
211-
assert isinstance(blobs_df, pl.LazyFrame)

0 commit comments

Comments
 (0)