Skip to content

Commit b95d815

Browse files
authored
adding cli functionality (#36)
1 parent c40f17c commit b95d815

11 files changed

Lines changed: 1499 additions & 8 deletions

File tree

.github/ISSUE_TEMPLATE/new_issue.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,15 @@ assignees: ''
77

88
---
99

10-
## Description
10+
### Summary
1111
A clear and concise description of what the problem is.
1212

13-
## Considerations
13+
### Considerations
1414
Make sure not to forget about these.
1515

16-
## Sub-tasks
16+
### Sub-tasks
1717
List any sub-tasks that might help create a clear flow or sub-issues.
1818

19-
## Definition of Done
19+
### Definition of Done
2020
This might help in cases when what is to be delivered is more than just one
2121
piece of functional code.

cfa/dataops/catalog.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,44 @@ def _get_version_blobs(self, version: str = "latest") -> list:
300300
key=lambda x: x["creation_time"],
301301
)
302302

303+
def download_version_to_local(
304+
self, local_path: str, version: str = "latest", force: bool = False
305+
) -> bool:
306+
"""Download a specific version of the data to a local path
307+
308+
Args:
309+
local_path (str): the local path to download to
310+
version (str, optional): the version to download. Defaults to "latest".
311+
force (bool, optional): whether to force re-download if local.
312+
Returns:
313+
bool: whether any files were written
314+
"""
315+
written = False
316+
blobs = self._get_version_blobs(version)
317+
for blob in blobs:
318+
blob_data = read_blob_stream(
319+
blob_url=blob["name"],
320+
account_name=self.account,
321+
container_name=self.container,
322+
)
323+
relative_path = blob["name"].removeprefix(f"{self.prefix}/")
324+
local_file_path = os.path.join(local_path, relative_path)
325+
local_dir = os.path.dirname(local_file_path)
326+
os.makedirs(local_dir, exist_ok=True)
327+
if os.path.exists(local_file_path) and not force:
328+
continue
329+
# Handle both raw bytes and objects with content_as_bytes() method
330+
if isinstance(blob_data, bytes):
331+
file_bytes = blob_data
332+
else:
333+
file_bytes = blob_data.content_as_bytes()
334+
with open(local_file_path, "wb") as f:
335+
f.write(file_bytes)
336+
written = True
337+
if written:
338+
self.ledger_entry(action="read")
339+
return written
340+
303341
def get_dataframe(
304342
self, output="pandas", version="latest", pl_lazy: bool = False
305343
) -> pd.DataFrame | pl.DataFrame:

cfa/dataops/command.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
"""
2+
CLI tools for managing data operations in the CFA project.
3+
"""
4+
5+
import os
6+
from argparse import ArgumentParser
7+
8+
from rich.console import Console
9+
10+
from .catalog import datacat
11+
from .utils import tree
12+
13+
14+
def _get_dataset_namespaces() -> list[str]:
15+
"""
16+
Helper function to get a list of dataset namespaces.
17+
"""
18+
return datacat.__namespace_list__
19+
20+
21+
def _get_stages_list(dataset_namespace: str) -> list[str]:
22+
"""
23+
Helper function to get a list of stages for a given dataset namespace.
24+
"""
25+
stages_start_with = ["load", "extract", "stage"]
26+
if dataset_namespace not in _get_dataset_namespaces():
27+
Console().print(
28+
f"[bold red]Error:[/bold red] Dataset namespace '{dataset_namespace}' not found. Available datasets are:\n"
29+
+ "\n".join(f"- {ds}" for ds in _get_dataset_namespaces())
30+
)
31+
return
32+
dataset_dict = eval(f"datacat.{dataset_namespace}.__dict__")
33+
stages = [
34+
key
35+
for key in dataset_dict.keys()
36+
if any(key.startswith(prefix) for prefix in stages_start_with)
37+
]
38+
return sorted(stages)
39+
40+
41+
def _get_versions_list(dataset_namespace: str, stage: str) -> list[str]:
42+
"""
43+
Helper function to get a list of versions for a given dataset namespace and stage.
44+
"""
45+
stages = _get_stages_list(dataset_namespace)
46+
if stage not in stages:
47+
Console().print(
48+
f"[bold red]Error:[/bold red] Stage '{stage}' not found for dataset '{dataset_namespace}'. Available stages are:\n"
49+
+ "\n".join(f"- {s}" for s in stages)
50+
)
51+
return
52+
versions = eval(f"datacat.{dataset_namespace}.{stage}.get_versions()")
53+
return versions
54+
55+
56+
def get_available_data():
57+
"""
58+
Retrieve a list of available datasets for CFA.
59+
"""
60+
parser = ArgumentParser(description="Get list of available datasets")
61+
parser.add_argument(
62+
"-p", "--prefix", help="optional prefix filter", default=None
63+
)
64+
args = parser.parse_args()
65+
datasets = datacat.__namespace_list__
66+
if args.prefix:
67+
datasets = [ds for ds in datasets if ds.startswith(args.prefix)]
68+
formatted_list = "\n".join(f"- {dataset}" for dataset in sorted(datasets))
69+
Console().print(f"[bold]Available Datasets:[/bold]\n{formatted_list}")
70+
71+
72+
def get_dataset_stages():
73+
"""
74+
Retrieve stages of available datasets for CFA.
75+
"""
76+
parser = ArgumentParser(description="Get dataset stages")
77+
parser.add_argument("dataset", help="full dataset namespace")
78+
args = parser.parse_args()
79+
dataset = args.dataset
80+
stages = _get_stages_list(dataset)
81+
formatted_stages = "\n".join(
82+
f"- [red]{stage}[/red]" if stage == stages[-1] else f"- {stage}"
83+
for stage in stages
84+
)
85+
disclaimer = "[italic yellow]Note: Stages in [red]red[/red] indicate the default stage for loading the dataset.[/italic yellow]"
86+
Console().print(
87+
f"[bold]Stages for {dataset}:[/bold]\n{formatted_stages}\n{disclaimer}"
88+
)
89+
90+
91+
def get_dataset_versions():
92+
"""
93+
Retrieve versions of available datasets for CFA.
94+
"""
95+
parser = ArgumentParser(description="Get dataset versions")
96+
parser.add_argument("dataset", help="full dataset namespace")
97+
parser.add_argument(
98+
"--stage", "-s", help="specific stage to get version for", default=None
99+
)
100+
args = parser.parse_args()
101+
dataset = args.dataset
102+
available_stages = _get_stages_list(dataset)
103+
if args.stage is None:
104+
stage = available_stages[-1]
105+
else:
106+
stage = args.stage
107+
versions = _get_versions_list(dataset, stage)
108+
formatted_versions = "\n".join(
109+
f"- [red]{version}[/red]" if version == versions[0] else f"- {version}"
110+
for version in versions
111+
)
112+
Console().print(f"[bold]{dataset}[/bold]:\n{formatted_versions}")
113+
114+
115+
def save_data_locally():
116+
"""
117+
Save a datasets to the local cache.
118+
"""
119+
parser = ArgumentParser(description="Download a dataset locally")
120+
parser.add_argument("dataset", help="full dataset namespace")
121+
parser.add_argument(
122+
"location",
123+
help="local location (directory) to save dataset to. If it does not exist, it will be created.",
124+
)
125+
parser.add_argument(
126+
"--stage", "-s", help="specific stage to get version for", default=None
127+
)
128+
parser.add_argument(
129+
"--version", "-v", help="specific version to get", default=None
130+
)
131+
parser.add_argument(
132+
"--force", "-f", help="force re-download of data", action="store_true"
133+
)
134+
args = parser.parse_args()
135+
dataset = args.dataset
136+
stage = args.stage
137+
version = args.version
138+
if stage is None:
139+
stages = _get_stages_list(dataset)
140+
stage = stages[-1]
141+
if version is None:
142+
versions = _get_versions_list(dataset, stage)
143+
version = versions[0]
144+
local_path = os.path.abspath(args.location)
145+
written = eval(
146+
f"datacat.{dataset}.{stage}.download_version_to_local('{local_path}', version='{version}', force={args.force})"
147+
)
148+
if not written:
149+
Console().print(
150+
f"[bold yellow]Dataset '{dataset}' version '{version}' at stage '{stage}' is already present at location '{local_path}'. Use --force to re-download.[/bold yellow]"
151+
)
152+
return
153+
else:
154+
tree_output = tree(local_path, show_hidden=False)
155+
Console().print(
156+
f"[bold green]Dataset '{dataset}' version '{version}' at stage '{stage}' has been saved locally.[/bold green]\n\n{local_path}\n{tree_output}"
157+
)

cfa/dataops/utils.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
import glob
55
import os
66
from datetime import datetime
7+
from itertools import islice
8+
from pathlib import Path
79
from typing import Optional
810

911

@@ -124,3 +126,66 @@ def get_user() -> str:
124126
return getpass.getuser()
125127
except Exception:
126128
return "unknown_user"
129+
130+
131+
def tree(
132+
dir_path: Path,
133+
level: int = -1,
134+
limit_to_directories: bool = False,
135+
length_limit: int = 1000,
136+
show_hidden: bool = False,
137+
):
138+
"""Given a directory Path object print a visual tree structure
139+
140+
Args:
141+
dir_path (Path): the root directory path
142+
level (int): how many levels deep to traverse, -1 for unlimited
143+
limit_to_directories (bool): whether to only show directories
144+
length_limit (int): maximum number of lines to print
145+
show_hidden (bool): whether to show hidden files and directories
146+
Returns:
147+
str: visual tree structure
148+
"""
149+
space = " "
150+
branch = "│ "
151+
tee = "├── "
152+
last = "└── "
153+
dir_path = Path(dir_path) # accept string coercible to Path
154+
files = 0
155+
directories = 0
156+
157+
def inner(dir_path: Path, prefix: str = "", level=-1):
158+
nonlocal files, directories
159+
if not level:
160+
return # 0, stop iterating
161+
if limit_to_directories:
162+
contents = [d for d in dir_path.iterdir() if d.is_dir()]
163+
else:
164+
contents = list(dir_path.iterdir())
165+
pointers = [tee] * (len(contents) - 1) + [last]
166+
for pointer, path in zip(pointers, contents):
167+
if not show_hidden and path.name.startswith("."):
168+
continue
169+
if path.is_dir():
170+
yield prefix + pointer + path.name
171+
directories += 1
172+
extension = branch if pointer == tee else space
173+
yield from inner(
174+
path, prefix=prefix + extension, level=level - 1
175+
)
176+
elif not limit_to_directories:
177+
yield prefix + pointer + path.name
178+
files += 1
179+
180+
lines = []
181+
lines.append(dir_path.name)
182+
iterator = inner(dir_path, level=level)
183+
for line in islice(iterator, length_limit):
184+
lines.append(line)
185+
if next(iterator, None):
186+
lines.append(f"... length_limit, {length_limit}, reached, counted:")
187+
return (
188+
"\n".join(lines)
189+
+ f"\n{directories} directories"
190+
+ (f", {files} files" if files else "")
191+
)

changelog.md

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

99
---
1010

11+
## [2025.10.31.0a]
12+
13+
### Added
14+
15+
- **New CLI commands** for dataset management:
16+
- `dataops_datasets`: List available datasets with optional prefix filtering
17+
- `dataops_stages`: View available stages for a specific dataset
18+
- `dataops_versions`: List available versions for a dataset stage
19+
- `dataops_save`: Download and save dataset versions locally
20+
- **Tree utility function**: New `tree()` function in `utils.py` for displaying directory structures
21+
- **Local data download**: `download_version_to_local()` method for BlobEndpoint to download datasets to local filesystem
22+
- **Tests**: Comprehensive test coverage for new CLI commands and utilities
23+
- New docs for CLI-tools
24+
25+
### Updated
26+
27+
- Documentation formatting: Changed "Description" to "Summary" with adjusted heading levels
28+
1129
## [2025.10.14.0a]
1230

1331
### Added

0 commit comments

Comments
 (0)