Skip to content

Commit 8402a32

Browse files
Refactor copy paths
1 parent 34c3dd5 commit 8402a32

3 files changed

Lines changed: 113 additions & 70 deletions

File tree

mokelumne/dags/copy_files.py

Lines changed: 49 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,10 @@
1515

1616
from mokelumne.util.storage import run_dir
1717
from mokelumne.util.file_transfer import (
18+
DESTINATION_VOLUMES,
19+
SOURCE_VOLUMES,
1820
build_file_manifest,
21+
build_volume_path,
1922
clean_destination_path,
2023
copy_files_from_manifest,
2124
load_json,
@@ -25,40 +28,6 @@
2528

2629
logger = logging.getLogger(__name__)
2730

28-
SOURCE_VOLUMES = [
29-
"/srv/da",
30-
"/srv/pa",
31-
"/srv/lpsdata4",
32-
]
33-
34-
DESTINATION_VOLUMES = [
35-
"/srv/pa",
36-
"/srv/da",
37-
]
38-
39-
40-
def build_volume_path(
41-
volume: str,
42-
subdirectory: str,
43-
label: str,
44-
) -> Path:
45-
"""Build a path from a volume and relative subdirectory."""
46-
47-
if subdirectory == "":
48-
raise AirflowFailException(
49-
f"{label} subdirectory is required"
50-
)
51-
52-
subdirectory_path = Path(subdirectory)
53-
54-
if subdirectory_path.is_absolute():
55-
raise AirflowFailException(
56-
f"{label} subdirectory must be relative: {subdirectory}"
57-
)
58-
59-
return Path(volume) / subdirectory_path
60-
61-
6231
@dag(
6332
description="Transfers files from one location to another",
6433
schedule=None,
@@ -88,7 +57,7 @@ def build_volume_path(
8857
default="",
8958
type="string",
9059
title="Destination subdirectory",
91-
description="Relative path within the destination volume. Do not include leading '/'.",
60+
description="Relative path within the destination volume. Do not include leading '/' or the final '/incoming' to the path.",
9261
),
9362
"exclude_regex": Param(
9463
default="",
@@ -105,18 +74,39 @@ def copy_files():
10574
"""Copy files from a source directory to an empty destination directory."""
10675

10776
@task
108-
def validate_source() -> str:
109-
"""Checks that the source is a valid directory."""
77+
def build_copy_paths() -> dict[str, str]:
78+
"""Build the resolved source and destination paths."""
11079

11180
ctx = get_current_context()
112-
source_volume = ctx["params"]["source_volume"]
113-
source_subdirectory = ctx["params"]["source_subdirectory"]
11481

115-
source_path = build_volume_path(
116-
source_volume,
117-
source_subdirectory,
118-
"Source",
119-
)
82+
try:
83+
source_path = build_volume_path(
84+
ctx["params"]["source_volume"],
85+
ctx["params"]["source_subdirectory"],
86+
"Source",
87+
)
88+
89+
destination_path = build_volume_path(
90+
ctx["params"]["destination_volume"],
91+
ctx["params"]["destination_subdirectory"],
92+
"Destination",
93+
)
94+
95+
destination_path = clean_destination_path(destination_path)
96+
except ValueError as ex:
97+
raise AirflowFailException(str(ex)) from ex
98+
99+
return {
100+
"source": str(source_path),
101+
"destination": str(destination_path)
102+
}
103+
104+
105+
@task
106+
def validate_source(copy_paths: dict[str, str]) -> str:
107+
"""Checks that the source is a valid directory."""
108+
109+
source_path = Path(copy_paths["source"])
120110

121111
if not source_path.exists():
122112
raise AirflowFailException(f"Source path does not exist: {source_path}")
@@ -127,19 +117,10 @@ def validate_source() -> str:
127117
return str(source_path)
128118

129119
@task
130-
def prepare_destination() -> str:
120+
def prepare_destination(copy_paths: dict[str, str]) -> str:
131121
"""Prepare the destination directory."""
132122

133-
ctx = get_current_context()
134-
destination_volume = ctx["params"]["destination_volume"]
135-
destination_subdirectory = ctx["params"]["destination_subdirectory"]
136-
137-
destination_path = build_volume_path(
138-
destination_volume,
139-
destination_subdirectory,
140-
"Destination",
141-
)
142-
destination_path = clean_destination_path(destination_path)
123+
destination_path = Path(copy_paths["destination"])
143124

144125
if not destination_path.exists():
145126
logger.info("Creating destination directory: %s", destination_path)
@@ -153,6 +134,7 @@ def prepare_destination() -> str:
153134

154135
return str(destination_path)
155136

137+
156138
@task
157139
def build_manifest(source: str) -> str:
158140
"""Build a manifest of all files under the source directory."""
@@ -218,19 +200,24 @@ def verify_manifest(destination: str, manifest_path: str) -> None:
218200
logger.info("Verification report written to: %s", verification_report_path)
219201
logger.info("Verified %s copied file(s)", len(verification_report))
220202

221-
"""The user needs to confirm the file copy paths before proceeding"""
203+
204+
# Need to run this before defining confirm_copy since we need the resolved paths:
205+
copy_paths = build_copy_paths()
206+
207+
# The user needs to confirm the file copy paths before proceeding
222208
confirm_copy = ApprovalOperator(
223209
task_id="confirm_copy",
224210
subject="Review the copy operation and approve it to continue.",
225211
body=(
226212
"Approve file copy from "
227-
"**{{ params.source_volume }}/{{ params.source_subdirectory }}** "
213+
f"**{copy_paths['source']}** "
228214
"to "
229-
"**{{ params.destination_volume }}/{{ params.destination_subdirectory }}**"
215+
f"**{copy_paths['destination']}**"
230216
),
231217
)
232-
validated_source = validate_source()
233-
prepared_destination = prepare_destination()
218+
219+
validated_source = validate_source(copy_paths)
220+
prepared_destination = prepare_destination(copy_paths)
234221
manifest = build_manifest(validated_source)
235222
copied_manifest_files = copy_manifest_files(
236223
validated_source,
@@ -240,7 +227,8 @@ def verify_manifest(destination: str, manifest_path: str) -> None:
240227
verified_manifest = verify_manifest(prepared_destination, manifest)
241228

242229
(
243-
validated_source
230+
copy_paths
231+
>> validated_source
244232
>> prepared_destination
245233
>> manifest
246234
>> confirm_copy

mokelumne/util/file_transfer.py

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,37 @@
1010
ManifestEntry = dict[str, int | str]
1111
Manifest = dict[str, str | list[ManifestEntry]]
1212

13+
SOURCE_VOLUMES = [
14+
"/srv/lpsdata4",
15+
"/srv/dcushare",
16+
"/srv/lpsdata2",
17+
"/srv/ealdata",
18+
"/srv/rohoshare",
19+
]
20+
21+
DESTINATION_VOLUMES = [
22+
"/srv/da",
23+
"/srv/pa",
24+
]
25+
26+
27+
def build_volume_path(
28+
volume: str,
29+
subdirectory: str,
30+
label: str,
31+
) -> Path:
32+
"""Build a path from a volume and relative subdirectory."""
33+
34+
if subdirectory == "":
35+
raise ValueError(f"{label} subdirectory is required")
36+
37+
subdirectory_path = Path(subdirectory)
38+
39+
if subdirectory_path.is_absolute():
40+
raise ValueError(f"{label} subdirectory must be relative: {subdirectory}")
41+
42+
return Path(volume) / subdirectory_path
43+
1344

1445
def sha256_for_file(path: Path) -> str:
1546
"""Calculate the SHA256 checksum for a file."""
@@ -61,10 +92,7 @@ def clean_destination_path(destination_path: Path) -> Path:
6192
return Path(destination_path / "incoming")
6293

6394

64-
def verify_file_manifest(
65-
destination_path: Path,
66-
manifest_path: Path
67-
) -> list[ManifestEntry]:
95+
def verify_file_manifest(destination_path: Path, manifest_path: Path) -> list[ManifestEntry]:
6896
"""Verify all copied files exist at the destination."""
6997

7098
verification_report: list[ManifestEntry] = []
@@ -111,11 +139,7 @@ def verify_file_manifest(
111139
return verification_report
112140

113141

114-
def copy_files_from_manifest(
115-
source_path: Path,
116-
destination_path: Path,
117-
manifest_path: Path,
118-
) -> None:
142+
def copy_files_from_manifest(source_path: Path, destination_path: Path, manifest_path: Path) -> None:
119143
"""Copy all files in manifest to destination directory."""
120144

121145
manifest = load_json(manifest_path)

test/unit/test_file_transfer.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,37 @@ def test_save_and_load_json(self, tmp_path: Path):
6060

6161
assert result == data
6262

63+
def test_build_volume_path(self):
64+
"""Ensure build_volume_path joins a volume and relative subdirectory."""
65+
66+
result = file_transfer.build_volume_path(
67+
"tmp/srv/pa",
68+
"aerial/ucb",
69+
"Source",
70+
)
71+
72+
assert result == Path("tmp/srv/pa/aerial/ucb")
73+
74+
def test_build_volume_path_requires_subdirectory(self):
75+
"""Ensure build_volume_path requires a subdirectory."""
76+
77+
with pytest.raises(ValueError, match="Source subdirectory is required"):
78+
file_transfer.build_volume_path(
79+
"tmp/srv/pa",
80+
"",
81+
"Source",
82+
)
83+
84+
def test_build_volume_path_rejects_absolute_subdirectory(self):
85+
"""Ensure build_volume_path rejects absolute subdirectories."""
86+
87+
with pytest.raises(ValueError, match="Source subdirectory must be relative"):
88+
file_transfer.build_volume_path(
89+
"tmp/srv/pa",
90+
"/aerial/ucb",
91+
"Source",
92+
)
93+
6394
def test_build_manifest_empty_directory(self, tmp_path: Path):
6495
"""Ensure that build_manifest returns an empty list for an empty directory."""
6596
result = file_transfer.build_file_manifest(tmp_path)

0 commit comments

Comments
 (0)