Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
5fed98d
new function
ryanraaschCDC Jul 10, 2026
baacc44
get specific version
ryanraaschCDC Jul 10, 2026
868562b
fix version name
ryanraaschCDC Jul 10, 2026
5e2d39c
add print_version arg to get_dataframe()
ryanraaschCDC Jul 10, 2026
9813723
add metadata to pd df
ryanraaschCDC Jul 10, 2026
a41d00d
add metadata functionality
ryanraaschCDC Jul 15, 2026
a835854
Merge branch 'main' of https://github.com/CDCgov/cfa-dataops into rr-…
ryanraaschCDC Jul 15, 2026
ef450d4
updates from main
ryanraaschCDC Jul 15, 2026
d5dfeca
Potential fix for pull request finding
ryanraaschCDC Jul 15, 2026
462f8e4
Potential fix for pull request finding
ryanraaschCDC Jul 15, 2026
1dca896
Fix lazy ndjson metadata handling
Copilot Jul 15, 2026
9506bf6
Potential fix for pull request finding
ryanraaschCDC Jul 16, 2026
a0c6c1f
update docs
ryanraaschCDC Jul 16, 2026
331ec40
Potential fix for pull request finding
ryanraaschCDC Jul 16, 2026
725d5f6
update tests
ryanraaschCDC Jul 16, 2026
c213f6d
Potential fix for pull request finding
ryanraaschCDC Jul 16, 2026
190a260
fix version spec
ryanraaschCDC Jul 16, 2026
5f4297e
Merge branch 'rr-114-expose-metadata' of https://github.com/CDCgov/cf…
ryanraaschCDC Jul 16, 2026
5082bf1
remove with_metadata"
ryanraaschCDC Jul 20, 2026
c9cad95
docs updates
ryanraaschCDC Jul 20, 2026
42d6a51
update tests
ryanraaschCDC Jul 20, 2026
6858a67
Merge branch 'main' of https://github.com/CDCgov/cfa-dataops into rr-…
ryanraaschCDC Jul 20, 2026
f4d2068
updates from main
ryanraaschCDC Jul 20, 2026
47cfbc4
update changelog, pyproject
ryanraaschCDC Jul 20, 2026
324c18e
create dataclass
ryanraaschCDC Jul 20, 2026
da80991
get version from _get_version_blobs
ryanraaschCDC Jul 20, 2026
092404f
fix docs
ryanraaschCDC Jul 20, 2026
cd81530
use resolve_version in get_dataframe
ryanraaschCDC Jul 20, 2026
d4e9baa
Potential fix for pull request finding
ryanraaschCDC Jul 20, 2026
2faa0df
Fix ledger version initialization
Copilot Jul 20, 2026
f2937c1
add version for ledger
ryanraaschCDC Jul 20, 2026
80055a5
Merge branch 'rr-114-expose-metadata' of https://github.com/CDCgov/cf…
ryanraaschCDC Jul 20, 2026
42e93ac
copilot fix
ryanraaschCDC Jul 20, 2026
d171de4
Potential fix for pull request finding
ryanraaschCDC Jul 21, 2026
8567c26
Merge branch 'main' of https://github.com/CDCgov/cfa-dataops into rr-…
ryanraaschCDC Jul 21, 2026
c5647ab
doc updates for version string
ryanraaschCDC Jul 21, 2026
3e6523d
Merge branch 'main' of https://github.com/CDCgov/cfa-dataops into rr-…
ryanraaschCDC Jul 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 98 additions & 55 deletions cfa/dataops/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import pkgutil
from collections.abc import Sequence
from configparser import ConfigParser
from dataclasses import dataclass
from importlib import import_module
from io import BytesIO
from pathlib import PurePosixPath
Expand Down Expand Up @@ -44,6 +45,18 @@
_config.read(os.path.join(_here, "config.ini"))

logger = logging.getLogger(__name__)


@dataclass(frozen=True)
class VersionMetadata:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall, I feel like there is a lot of confusion introduced by the idea that a version may be a string or a list of strings. Can we disambiguate troughout?

Ideally, we decide either

  1. A resolved version is always a list of versions (though a length one list is always fine) OR
  2. A resolved version is a single string.

I don't really see the utility of (1), but we currently support it throughout. Do we need it?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ryanraaschCDC If this feels out of scope for the PR, feel free to move to an issue (or if you don't like the idea, feel free to not address it)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this probably belongs in a separate issue I can address immediately after this PR merges. The only problem with going with #2 is that version_matcher() allows for selection = "all", which is the only time a list of versions is returned. This functionality is not allowed for get_dataframe() so I think it would be possible to remove "all" from the selection, and just keep "newest" or "oldest" as options in selection.

what do you think of this?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If selection = "all" isn't used anywhere in the codebase, then it could be removed! The other option would be to break the version resolver into two functions: one function returns "all" the versions that match the specification. Then a wrapper function chooses the newest or oldest.

Then you clearly have one function that returns a list of a versions and one that returns a single version.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can actually incorporate that change into this PR. It would make sense with the version metadata dataclass to have it sorted it out first. I'll remove the ability to select "all" so that version is always a string.

"""Result of resolving a version specification against available versions."""

version: str | None
blob_url: str | None
version_spec: str | None
selection: str


if not logger.handlers:
logger.addHandler(logging.NullHandler())

Expand Down Expand Up @@ -235,18 +248,18 @@ def write_blob(
def read_blobs(
self,
version_spec: str | None = None,
selection: Literal["newest", "oldest", "all"] = "newest",
selection: Literal["newest", "oldest"] = "newest",
print_version: bool = True,
) -> list[bytes]:
"""Read a blob in as bytes so it can be loaded into a dataframe

Args:
version_spec (str | None, optional): the version of the data to read.
Defaults to "latest".
selection (Literal["newest", "oldest", "all"], optional): whether to get the newest, oldest, or all matching versions. Defaults to "newest".
selection (Literal["newest", "oldest"], optional): whether to get the newest or oldest matching versions. Defaults to "newest".
print_version (bool, optional): whether to print the version being used. Defaults to True.
"""
blobs = self._get_version_blobs(
blobs, _ = self._get_version_blobs(
version_spec=version_spec, selection=selection, print_version=print_version
)
blob_bytes = [
Expand Down Expand Up @@ -296,48 +309,51 @@ def get_versions(self) -> list:
def get_file_ext(
self,
version_spec: str | None = None,
selection: Literal["newest", "oldest", "all"] = "newest",
selection: Literal["newest", "oldest"] = "newest",
) -> str:
"""returns the file extension for handy routing of read byte types for
DataFrame reading

Args:
version_spec (str | None, optional): the version specifier to get.
selection (Literal["newest", "oldest", "all"], optional): which version to select. Defaults to "newest".
selection (Literal["newest", "oldest"], optional): which version to select. Defaults to "newest".
Returns:
str: the file extension
"""
return self._get_version_blobs(
blobs, _ = self._get_version_blobs(
version_spec=version_spec, selection=selection, print_version=False
)[0]["name"].split(".")[-1]
)
return blobs[0]["name"].split(".")[-1]

def _get_version_blobs(
self,
version_spec: str | None = None,
selection: Literal["newest", "oldest", "all"] = "newest",
selection: Literal["newest", "oldest"] = "newest",
print_version=True,
) -> list:
) -> tuple[list, str | None]:
"""Return blob metadata for the requested version selection.

Args:
version_spec (str | None, optional): Version specifier to pass through to
``version_matcher``. Defaults to ``None``.
selection (Literal["newest", "oldest", "all"], optional): When matching multiple versions, choose the
newest matching version when ``"newest"``, the oldest matching version
when ``"oldest"``, or all matching versions when ``"all"``.
selection (Literal["newest", "oldest"], optional): When matching multiple versions, choose the
newest matching version when ``"newest"``, or the oldest matching version
when ``"oldest"``.
print_version (bool, optional): Whether to print the resolved version
before fetching blobs.

Returns:
list: Blob metadata dictionaries sorted by creation time for the
resolved version or versions.
resolved version.
str | None: version string for resolved version

Raises:
ValueError: If the requested version cannot be resolved.
"""
# check credential access
if not check_ext_env():
raise RuntimeError("No EXT access configured.")
version = None
if not self.is_ledger:
available_versions = self.get_versions()
version = version_matcher(
Expand All @@ -350,58 +366,42 @@ def _get_version_blobs(
logger.info(f"Using version: {version}")
if print_version:
print(f"Using version: {version}")
if isinstance(version, list):
walk_path = [f"{self.prefix}/{v}/" for v in version]
else:
walk_path = f"{self.prefix}/{version}/"
walk_path = f"{self.prefix}/{version}/"
else:
walk_path = f"{self.prefix.removesuffix('/')}/"
if isinstance(walk_path, list):
all_blobs = []
for wp in walk_path:
all_blobs.extend(
walk_blobs_in_container(
name_starts_with=wp,
account_name=self.account,
container_name=self.container,
)
return sorted(
list(
walk_blobs_in_container(
name_starts_with=walk_path,
account_name=self.account,
container_name=self.container,
)
return sorted(
all_blobs,
key=lambda x: x["creation_time"],
)
else:
return sorted(
list(
walk_blobs_in_container(
name_starts_with=walk_path,
account_name=self.account,
container_name=self.container,
)
),
key=lambda x: x["creation_time"],
)
),
key=lambda x: x["creation_time"],
), version

def download_version_to_local(
self,
local_path: str,
version_spec: str | None = None,
force: bool = False,
selection: Literal["newest", "oldest", "all"] = "newest",
selection: Literal["newest", "oldest"] = "newest",
) -> bool:
"""Download a specific version of the data to a local path

Args:
local_path (str): the local path to download to
version_spec (str | None, optional): the version specifier to download. Defaults to None.
force (bool, optional): whether to force re-download if local.
selection (Literal["newest", "oldest", "all"], optional): which version to select. Defaults to "newest".
selection (Literal["newest", "oldest"], optional): which version to select. Defaults to "newest".
Returns:
bool: whether any files were written
"""

written = False
blobs = self._get_version_blobs(version_spec=version_spec, selection=selection)
blobs, _ = self._get_version_blobs(
version_spec=version_spec, selection=selection
)
for blob in blobs:
blob_data = read_blob_stream(
blob_url=blob["name"],
Expand Down Expand Up @@ -467,8 +467,8 @@ def get_dataframe(
either 'pandas' or 'polars' or 'pl_lazy'. Defaults to "pandas".
version_spec (str, optional): the version of the data to get.
Defaults to "latest".
selection (Literal["newest", "oldest", "all"], optional): whether to get the newest, oldest, or all matching versions. Defaults to "newest".
print_version (bool, optional): whether to log the resolved version information. Defaults to False.
Comment thread
ryanraaschCDC marked this conversation as resolved.
selection (Literal["newest", "oldest"], optional): whether to get the newest or oldest matching versions. Defaults to "newest".
print_version (bool, optional): whether to print the version being used. Defaults to False.

Raises:
ValueError: if output is not one of
Expand All @@ -483,20 +483,23 @@ def get_dataframe(
raise ValueError(
f"Output {output} needs to be 'pandas', 'polars', 'pd', 'pl', 'pl_lazy', or 'lazy'."
)

# Fetch version blobs once and validate before deriving file extension.
version_blobs = self._get_version_blobs(
version_blobs, _ = self._get_version_blobs(
version_spec=version_spec, selection=selection, print_version=print_version
)
version_meta = self.resolve_version(
version_spec=version_spec, selection=selection
)
if not version_blobs:
raise ValueError(
f"No blobs found for version '{version_spec}' in container '{self.container}'."
)
name = version_blobs[0]["name"]
file_ext = PurePosixPath(name).suffix.lstrip(".").lower()

file_ext = version_meta.blob_url.split(".")[-1].lower()
fullpath = version_meta.blob_url
if output in ["pl_lazy", "lazy"]:
if file_ext in ["parquet", "parq"]:
path = str(PurePosixPath(name).parent / f"*.{file_ext}")
fullpath = f"az://{self.container}/{path}"
df = pl.scan_parquet(
fullpath,
storage_options={"account_name": self.account},
Expand All @@ -507,8 +510,6 @@ def get_dataframe(
# self.ledger_entry(action="read")
return df
elif file_ext == "csv":
path = str(PurePosixPath(name).parent / f"*.{file_ext}")
fullpath = f"az://{self.container}/{path}"
df = pl.scan_csv(
fullpath,
infer_schema_length=None,
Expand All @@ -520,8 +521,6 @@ def get_dataframe(
##self.ledger_entry(action="read")
return df
elif file_ext == "ndjson" or file_ext == "jsonl":
path = str(PurePosixPath(name).parent / f"*.{file_ext}")
fullpath = f"az://{self.container}/{path}"
df = pl.scan_ndjson(
fullpath,
infer_schema_length=None,
Expand Down Expand Up @@ -615,6 +614,50 @@ def ledger_entry(self, action: str) -> None:
overwrite=False,
)

def resolve_version(
Comment thread
ryanraaschCDC marked this conversation as resolved.
self,
version_spec: str | None = None,
selection: Literal["newest", "oldest"] = "newest",
) -> VersionMetadata:
Comment thread
ryanraaschCDC marked this conversation as resolved.
"""Resolve the version of the dataset based on the version specification and selection criteria.

Args:
version_spec (str | None): the version specification to resolve
selection (Literal["newest", "oldest"]): whether to select the newest or oldest version

Returns:
VersionMetadata: Resolution containing resolved version, blob URL, and selection details.
"""
try:
version_blobs, version = self._get_version_blobs(
version_spec=version_spec, selection=selection, print_version=False
)
except ValueError:
return VersionMetadata(
version=None,
blob_url=None,
version_spec=version_spec,
selection=selection,
)

if not version_blobs:
return VersionMetadata(
version=None,
blob_url=None,
version_spec=version_spec,
selection=selection,
)
name = version_blobs[0]["name"]
file_ext = PurePosixPath(name).suffix.lstrip(".").lower()
path = str(PurePosixPath(name).parent / f"*.{file_ext}")
fullpath = f"az://{self.container}/{path}"
return VersionMetadata(
version=version,
blob_url=fullpath,
version_spec=version_spec,
selection=selection,
)

def save_dataframe(
self,
df: pd.DataFrame | pl.DataFrame,
Expand Down
8 changes: 0 additions & 8 deletions cfa/dataops/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,12 +133,6 @@ def save_data_locally():
help="download the oldest version of data instead of the newest",
action="store_true",
)
parser.add_argument(
"--full_range",
"-r",
help="download the full range of data versions that meet the version criteria",
action="store_true",
)
args = parser.parse_args()
dataset = args.dataset
stage = args.stage
Expand All @@ -152,8 +146,6 @@ def save_data_locally():
local_path = os.path.abspath(args.location)
if args.oldest:
selection = "oldest"
elif args.full_range:
selection = "all"
else:
selection = "newest"
written = eval(
Expand Down
6 changes: 3 additions & 3 deletions cfa/dataops/stub_templates/catalog.pyi.mako
Original file line number Diff line number Diff line change
Expand Up @@ -36,22 +36,22 @@ class BlobEndpoint:
def read_blobs(
self,
version_spec: str | None = None,
selection: Literal["newest", "oldest", "all"] = "newest",
selection: Literal["newest", "oldest"] = "newest",
print_version: bool = True,
) -> list[bytes]: ...
def read_csv(self, suffix: str) -> pd.DataFrame: ...
def get_versions(self) -> list[str]: ...
def get_file_ext(
self,
version_spec: str | None = None,
selection: Literal["newest", "oldest", "all"] = "newest",
selection: Literal["newest", "oldest"] = "newest",
) -> str: ...
def download_version_to_local(
self,
local_path: str,
version_spec: str | None = None,
force: bool = False,
selection: Literal["newest", "oldest", "all"] = "newest",
selection: Literal["newest", "oldest"] = "newest",
) -> bool: ...
@overload
def get_dataframe(
Expand Down
27 changes: 12 additions & 15 deletions cfa/dataops/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,30 +215,31 @@ def construct_version_spec(version: str | None) -> str | None:
def version_matcher(
version_spec: str | None,
available_versions: list[str],
selection: Literal["newest", "oldest", "all"] = "newest",
) -> str | list[str] | None:
selection: Literal["newest", "oldest"] = "newest",
) -> str | None:
"""Select version strings from a list using an optional specifier.

Args:
version_spec (str | None): Optional packaging-compatible version specifier such as
``"==2025-12-15"`` or ``">=2025-01-01,<2026-01-01"``. If ``None``,
all available versions are considered.
available_versions (list[str]): Version strings to evaluate.
selection (Literal["newest", "oldest", "all"]): Controls the return shape.
``"newest"`` returns the newest matching version, ``"oldest"`` returns
the oldest matching version, and ``"all"`` returns all matching versions
in descending order.
selection (Literal["newest", "oldest"]): Controls which matching
version is returned.

Returns:
str | list[str] | None: A single matching version, all matching versions in
descending order, or ``None`` if no match is found.
str | None: A single matching version, or ``None`` if no match is found.

Raises:
ValueError: If ``selection`` is not one of ``"newest"`` or ``"oldest"``.
packaging.specifiers.InvalidSpecifier: If ``version_spec`` is not ``None`` and is not
a valid packaging specifier after normalization.
packaging.version.InvalidVersion: If any version string cannot be parsed by
``packaging.version.Version``.
"""
if selection not in {"newest", "oldest"}:
raise ValueError("selection must be 'newest' or 'oldest'")

versions = sorted(
(Version(normalize(version)), version) for version in available_versions
)
Expand All @@ -251,10 +252,6 @@ def version_matcher(

originals = [original for _, original in versions]

match selection:
case "newest":
return originals[-1] if originals else None
case "oldest":
return originals[0] if originals else None
case "all":
return originals[::-1]
if selection == "newest":
return originals[-1] if originals else None
return originals[0] if originals else None
Loading