Skip to content

Commit 4eb528c

Browse files
authored
AP-768: make file copy exclusions configurable as a Param (#81)
1 parent 3d6c360 commit 4eb528c

3 files changed

Lines changed: 64 additions & 50 deletions

File tree

mokelumne/dags/copy_files.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""File copy DAG to transfer files from one location to another."""
22

3+
# pyright: reportTypedDictNotRequiredAccess=false
4+
35
from __future__ import annotations
46

57
import logging
@@ -41,6 +43,14 @@
4143
description="Directory where source files will be copied to",
4244
type="string",
4345
),
46+
"exclude_regex": Param(
47+
default="",
48+
title="Filename exclusion pattern",
49+
description_md="""Regular expression to exclude files from the copy process. Defaults to excluding Thumbs.db and file or path name that starts with a period: `^(\.(.*)|(?i:Thumbs\.db))$`""", # pyright: ignore[reportInvalidStringEscapeSequence]
50+
type=["string", "null"],
51+
format="regex",
52+
section="Exclude Files"
53+
),
4454
},
4555
tags=["file-transfer"],
4656
)
@@ -88,7 +98,9 @@ def build_manifest(source: str) -> str:
8898
"""Build a manifest of all files under the source directory."""
8999

90100
source_path = Path(source)
91-
manifest = build_file_manifest(source_path)
101+
ctx = get_current_context()
102+
exclude_regex = ctx["params"].get("exclude_regex")
103+
manifest = build_file_manifest(source_path, exclude_regex=exclude_regex)
92104

93105
if not manifest:
94106
raise AirflowFailException(f"No files found in source: {source_path}")

mokelumne/util/file_transfer.py

Lines changed: 15 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import hashlib
44
import json
5+
import re
56
import shutil
67

78
from pathlib import Path
@@ -20,34 +21,20 @@ def sha256_for_file(path: Path) -> str:
2021
return sha256.hexdigest()
2122

2223

23-
def _should_include_manifest_entry(relative_path: Path, item: Path) -> bool:
24-
"""Return True when a path should be added to the manifest."""
25-
if any(part.startswith(".") for part in relative_path.parts):
26-
return False
24+
def build_file_manifest(
25+
source_path: Path,
26+
exclude_regex: str | None = None
27+
) -> Manifest:
28+
exregex = re.compile(exclude_regex or r"^(\.(.*)|(?i:Thumbs\.db))$")
2729

28-
if item.name.casefold() in {"thumbs.db"}:
29-
return False
30-
31-
return True
32-
33-
def build_file_manifest(source_path: Path) -> Manifest:
34-
files = []
35-
36-
for item in source_path.rglob("*"):
37-
if not item.is_file():
38-
continue
39-
40-
relative_path = item.relative_to(source_path)
41-
if not _should_include_manifest_entry(relative_path, item):
42-
continue
43-
44-
files.append(
45-
{
46-
"path": str(relative_path),
47-
"size": item.stat().st_size,
48-
"sha256": sha256_for_file(item),
49-
}
30+
files = [
31+
{ "path": str(f.relative_to(source_path)), "size": f.stat().st_size, "sha256": sha256_for_file(f) }
32+
for f in source_path.rglob("*")
33+
if (
34+
f.is_file()
35+
and not any(re.search(exregex, p) for p in f.relative_to(source_path).parts)
5036
)
37+
]
5138

5239
return {
5340
"source_root": str(source_path),
@@ -74,11 +61,11 @@ def clean_destination_path(destination_path: Path) -> Path:
7461
def verify_file_manifest(destination_path: Path, manifest_path: Path) -> list[ManifestEntry]:
7562
"""Verify all copied files exist at the destination."""
7663

77-
verification_report: ManifestEntry = []
64+
verification_report: list[ManifestEntry] = []
7865
manifest = load_json(manifest_path)
7966

8067
for entry in manifest["files"]:
81-
relative_path = Path(str(entry["path"]))
68+
relative_path = Path(str(entry.get("path")))
8269
expected_size = entry["size"]
8370
expected_sha256 = entry["sha256"]
8471

test/unit/test_file_transfer.py

Lines changed: 36 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,27 @@
66

77
from mokelumne.util import file_transfer
88

9+
@pytest.fixture(scope="session")
10+
def manifest_source_path(tmpdir_factory):
11+
tmp_path = Path(tmpdir_factory.mktemp("manifest_source"))
12+
visible_file = tmp_path / "visible.tif"
13+
visible_file.write_text("hello", encoding="utf-8")
14+
15+
hidden_file = tmp_path / ".hidden.txt"
16+
hidden_file.write_text("secret", encoding="utf-8")
17+
18+
hidden_dir = tmp_path / ".hidden"
19+
hidden_dir.mkdir()
20+
hidden_dir_file = hidden_dir / "nested.txt"
21+
hidden_dir_file.write_text("nested", encoding="utf-8")
22+
23+
ds_store = tmp_path / ".DS_Store"
24+
ds_store.write_text("folder metadata", encoding="utf-8")
25+
26+
thumbs_db = tmp_path / "Thumbs.db"
27+
thumbs_db.write_text("thumbnail images db", encoding="utf-8")
28+
29+
return tmp_path
930

1031
class TestFileTransfer:
1132
"""Tests for the Mokelumne file transfer module."""
@@ -69,31 +90,25 @@ def test_build_manifest_includes_nested_files(self, tmp_path: Path):
6990
}
7091
assert len(result) == 2
7192

72-
def test_build_manifest_excludes_hidden_and_excluded_files(self, tmp_path: Path):
93+
@pytest.mark.parametrize(
94+
"pattern,expected",
95+
[
96+
pytest.param(
97+
None, {"visible.tif"}, id="with_default_regex"
98+
),
99+
pytest.param(
100+
r"^(visible\.tif|\.(.*))$", {"Thumbs.db"}, id="with_custom_regex"
101+
)
102+
]
103+
)
104+
def test_build_manifest_excludes_hidden_and_excluded_files(self, manifest_source_path, pattern, expected):
73105
"""Ensure that build_manifest skips hidden and system junk files."""
74-
visible_file = tmp_path / "visible.tif"
75-
visible_file.write_text("hello", encoding="utf-8")
76-
77-
hidden_file = tmp_path / ".hidden.txt"
78-
hidden_file.write_text("secret", encoding="utf-8")
79-
80-
hidden_dir = tmp_path / ".hidden"
81-
hidden_dir.mkdir()
82-
hidden_dir_file = hidden_dir / "nested.txt"
83-
hidden_dir_file.write_text("nested", encoding="utf-8")
84-
85-
ds_store = tmp_path / ".DS_Store"
86-
ds_store.write_text("folder metadata", encoding="utf-8")
87-
88-
thumbs_db = tmp_path / "Thumbs.db"
89-
thumbs_db.write_text("thumbnail images db", encoding="utf-8")
90-
91-
result = file_transfer.build_file_manifest(tmp_path)
106+
result = file_transfer.build_file_manifest(manifest_source_path, exclude_regex=pattern)
92107

93108
paths = {entry["path"] for entry in result["files"]}
94109

95-
assert paths == {"visible.tif"}
96-
assert len(result["files"]) == 1
110+
assert paths == expected
111+
assert len(result["files"]) == len(expected)
97112

98113
@pytest.mark.parametrize(
99114
("input_path", "expected_path"),

0 commit comments

Comments
 (0)