Skip to content

Commit 8cbb0f0

Browse files
authored
Cdc-ap66 adding save methods and ledger updates (#32)
* new save methods and test * version bump and making ledger an append type * test update * updating how save_dir_to_blob works
1 parent b44a1ee commit 8cbb0f0

6 files changed

Lines changed: 680 additions & 6 deletions

File tree

cfa/dataops/catalog.py

Lines changed: 150 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
from io import BytesIO
99
from types import SimpleNamespace
1010
from typing import Any, List, Sequence
11-
from uuid import uuid4
1211

1312
import pandas as pd
1413
import polars as pl
@@ -28,7 +27,7 @@
2827
ValidationError,
2928
)
3029
from .reporting.catalog import report_dict_to_sn
31-
from .utils import get_dataset_dot_path, get_timestamp, get_user
30+
from .utils import get_dataset_dot_path, get_date, get_timestamp, get_user
3231

3332
_here = os.path.abspath(os.path.dirname(__file__))
3433
_config = ConfigParser()
@@ -184,6 +183,7 @@ def write_blob(
184183
file_buffer: bytes | Sequence[bytes],
185184
path_after_prefix: str,
186185
auto_version: bool = False,
186+
append: bool = False,
187187
) -> None:
188188
"""For writing file buffers to blob storage. Remember to include
189189
the a version to the path (i.e., {version}/{file}) or use
@@ -195,8 +195,9 @@ def write_blob(
195195
file_buffer (bytes or List[bytes]): the file buffer or list of buffers
196196
path_under_prefix (str): everything beyond the prefix
197197
auto_version (bool, optional): whether to automatically version
198+
append (bool, optional): whether to append to existing file (only for single file writes).
198199
"""
199-
if auto_version:
200+
if auto_version and not append:
200201
path_after_prefix = (
201202
f"{get_timestamp()}/{path_after_prefix.lstrip('/')}"
202203
)
@@ -206,7 +207,7 @@ def write_blob(
206207
file_buffer = [file_buffer]
207208
total_partitions = len(file_buffer)
208209
for idx, fb_i in enumerate(file_buffer):
209-
if total_partitions > 1:
210+
if total_partitions > 1 and not append:
210211
url_parts = os.path.splitext(full_path)
211212
auto_full_path = f"{url_parts[0]}_{str(idx).zfill(len(str(total_partitions)))}{url_parts[1]}"
212213
else:
@@ -216,6 +217,7 @@ def write_blob(
216217
blob_url=auto_full_path,
217218
account_name=self.account,
218219
container_name=self.container,
220+
append_blob=append,
219221
)
220222
self.ledger_entry(action="write")
221223
# print(f"file written to: {full_path}")
@@ -383,11 +385,154 @@ def ledger_entry(self, action: str) -> None:
383385
log_data = (json.dumps(log_entry) + "\n").encode("utf-8")
384386
write_blob_stream( # TODO: make this a streaming write to a single file (one per day parsed from get_timestamp())
385387
data=log_data,
386-
blob_url=f"{self.ledger_location['prefix']}/{get_timestamp()}_{uuid4().hex}.jsonl",
388+
blob_url=f"{self.ledger_location['prefix']}/{get_date()}.jsonl",
387389
account_name=self.ledger_location["account"],
388390
container_name=self.ledger_location["container"],
391+
append_blob=True,
392+
overwrite=False,
389393
)
390394

395+
def save_dataframe(
396+
self,
397+
df: pd.DataFrame | pl.DataFrame,
398+
path_after_prefix: str,
399+
file_format: str = "parquet",
400+
auto_version: bool = False,
401+
) -> None:
402+
"""Save a dataframe to the blob endpoint
403+
404+
Args:
405+
df (pd.DataFrame | pl.DataFrame): the dataframe to save
406+
path_after_prefix (str): the path after the prefix to save to
407+
file_format (str, optional): the file format to save as.
408+
Defaults to "parquet".
409+
auto_version (bool, optional): whether to automatically version
410+
the data. Defaults to True.
411+
partition_cols (List[str], optional): columns to partition by
412+
when saving. Defaults to None.
413+
"""
414+
if file_format not in ["parquet", "csv", "json", "jsonl"]:
415+
raise ValueError(
416+
f"File format {file_format} not supported. Use 'parquet', 'csv', 'json', or 'jsonl'."
417+
)
418+
if file_format in ["json", "jsonl"] and path_after_prefix.endswith(
419+
".json"
420+
):
421+
path_after_prefix = path_after_prefix[:-5] + ".jsonl"
422+
print("Changing file extension to .jsonl for line-delimited JSON.")
423+
if isinstance(df, pd.DataFrame):
424+
if file_format == "parquet":
425+
pq_bytes = df.to_parquet(index=False, compression="snappy")
426+
self.write_blob(
427+
file_buffer=pq_bytes,
428+
path_after_prefix=path_after_prefix
429+
if path_after_prefix.endswith(".parquet")
430+
else path_after_prefix + ".parquet",
431+
auto_version=auto_version,
432+
)
433+
elif file_format == "csv":
434+
csv_bytes = df.to_csv(index=False).encode("utf-8")
435+
self.write_blob(
436+
file_buffer=csv_bytes,
437+
path_after_prefix=path_after_prefix
438+
if path_after_prefix.endswith(".csv")
439+
else path_after_prefix + ".csv",
440+
auto_version=auto_version,
441+
)
442+
elif file_format in ["json", "jsonl"]:
443+
json_bytes = df.to_json(orient="records", lines=True).encode(
444+
"utf-8"
445+
)
446+
self.write_blob(
447+
file_buffer=json_bytes,
448+
path_after_prefix=path_after_prefix
449+
if path_after_prefix.endswith(".jsonl")
450+
else path_after_prefix + ".jsonl",
451+
auto_version=auto_version,
452+
)
453+
elif isinstance(df, pl.DataFrame):
454+
if file_format == "parquet":
455+
buffer = BytesIO()
456+
df.write_parquet(buffer, compression="snappy")
457+
pq_bytes = buffer.getvalue()
458+
self.write_blob(
459+
file_buffer=pq_bytes,
460+
path_after_prefix=path_after_prefix
461+
if path_after_prefix.endswith(".parquet")
462+
else path_after_prefix + ".parquet",
463+
auto_version=auto_version,
464+
)
465+
elif file_format == "csv":
466+
csv_bytes = df.write_csv().encode("utf-8")
467+
self.write_blob(
468+
file_buffer=csv_bytes,
469+
path_after_prefix=path_after_prefix
470+
if path_after_prefix.endswith(".csv")
471+
else path_after_prefix + ".csv",
472+
auto_version=auto_version,
473+
)
474+
elif file_format in ["json", "jsonl"]:
475+
json_bytes = df.write_ndjson().encode("utf-8")
476+
self.write_blob(
477+
file_buffer=json_bytes,
478+
path_after_prefix=path_after_prefix
479+
if path_after_prefix.endswith(".jsonl")
480+
else path_after_prefix + ".jsonl",
481+
auto_version=auto_version,
482+
)
483+
484+
def save_file_to_blob(
485+
self,
486+
file_path: str,
487+
path_after_prefix: str,
488+
auto_version: bool = False,
489+
) -> None:
490+
"""Save a local file to the blob endpoint
491+
492+
Args:
493+
file_path (str): the local file path to save
494+
path_after_prefix (str): the path after the prefix to save to
495+
auto_version (bool, optional): whether to automatically version
496+
the data. Defaults to False.
497+
"""
498+
if not os.path.isfile(file_path):
499+
raise ValueError(f"File {file_path} does not exist.")
500+
with open(file_path, "rb") as f:
501+
file_bytes = f.read()
502+
self.write_blob(
503+
file_buffer=file_bytes,
504+
path_after_prefix=path_after_prefix,
505+
auto_version=auto_version,
506+
)
507+
508+
def save_dir_to_blob(
509+
self,
510+
dir_path: str,
511+
path_after_prefix: str,
512+
auto_version: bool = False,
513+
) -> None:
514+
"""Save a local directory to the blob endpoint
515+
516+
Args:
517+
dir_path (str): the local directory path to save
518+
path_after_prefix (str): the path after the prefix to save to
519+
auto_version (bool, optional): whether to automatically version
520+
the data. Defaults to False.
521+
"""
522+
if not os.path.isdir(dir_path):
523+
raise ValueError(f"Directory {dir_path} does not exist.")
524+
for root, _, files in os.walk(dir_path):
525+
for file in files:
526+
file_path = os.path.join(root, file)
527+
with open(file_path, "rb") as f:
528+
file_buffer = f.read()
529+
rel_path = f"{'/'.join([i for i in os.path.split(root) if i])}/{'/'.join([i for i in os.path.split(file) if i])}"
530+
self.write_blob(
531+
file_buffer=file_buffer,
532+
path_after_prefix=f"{path_after_prefix}/{rel_path}",
533+
auto_version=auto_version,
534+
)
535+
391536

392537
def dict_to_sn(d: Any, defaults: dict = None, ns: str = "") -> SimpleNamespace:
393538
"""Simple recursive namespace construction

cfa/dataops/utils.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,15 @@ def get_timestamp(make_standard: bool = False) -> str:
105105
return datetime.now().strftime("%Y-%m-%dT%H-%M-%S")
106106

107107

108+
def get_date() -> str:
109+
"""Get the current date in YYYY-MM-DD format.
110+
111+
Returns:
112+
str: the current date as a string in YYYY-MM-DD format.
113+
"""
114+
return datetime.now().strftime("%Y-%m-%d")
115+
116+
108117
def get_user() -> str:
109118
"""Get the current system user
110119

changelog.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,16 @@ The versioning pattern is `YYYY.MM.DD.micro(a/b/{none if release})
88

99
---
1010

11+
## [2025.10.03.0a]
12+
13+
### Added
14+
15+
- Save helper methods to BlobEndpoint
16+
17+
### Updated
18+
19+
- Made ledger append to date files
20+
1121
## [2025.09.25.0a]
1222

1323
### Updated

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 = "2025.09.25.0a"
3+
version = "2025.10.03.0a"
44
description = "Data cataloging, ETL, modeling, verification, and validation for CFA"
55
authors = [
66
{ name = "Phil Rogers", email = "ap66@cdc.gov" },

tests/conftest.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ def mock_write_blob_stream(
1919
blob_url: str,
2020
account_name: str,
2121
container_name: str,
22+
append_blob: bool = False,
23+
overwrite: bool = True,
2224
) -> None:
2325
return None
2426

0 commit comments

Comments
 (0)