From 163a0410a5a465edcd4b12147a7e9c9ca5c6e784 Mon Sep 17 00:00:00 2001 From: Fabio Kayser Date: Wed, 11 Nov 2026 17:24:58 +0100 Subject: [PATCH] Remove empty parent directories after cleaning their children --- docs/backend-contract.md | 2 ++ src/datam8/utils/__init__.py | 4 ++-- tests/test_020_helper.py | 27 +++++++++++++++++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/docs/backend-contract.md b/docs/backend-contract.md index 63ccac5..29b83a7 100644 --- a/docs/backend-contract.md +++ b/docs/backend-contract.md @@ -116,6 +116,8 @@ editor. Runtime configuration changes and process failure may require a restart. unrelated files keep their source directories in place. Function-directory moves are preflighted and rolled back if the model move fails. Saving a deleted folder also removes child functions. + Empty directory cleanup checks each directory after removing its children, so + a deleted folder does not remain solely because it contained empty subdirectories. - Function source paths are relative to their model entity. Absolute/drive-qualified paths, traversal, empty segments and symlink escapes are rejected. - A bare function filename first resolves beside the entity JSON, then in its diff --git a/src/datam8/utils/__init__.py b/src/datam8/utils/__init__.py index 2074401..e0599ad 100644 --- a/src/datam8/utils/__init__.py +++ b/src/datam8/utils/__init__.py @@ -139,8 +139,8 @@ def cleanup_directory(path: Path, up_to: Path) -> None: """ # remove children - for dir_path, dir_names, file_names in path.walk(top_down=False): - if len(dir_names) == 0 and len(file_names) == 0: + for dir_path, _, _ in path.walk(top_down=False): + if not any(dir_path.iterdir()): delete_path(dir_path) # remove empty parents diff --git a/tests/test_020_helper.py b/tests/test_020_helper.py index 50deb6c..ef29425 100644 --- a/tests/test_020_helper.py +++ b/tests/test_020_helper.py @@ -24,9 +24,36 @@ UuidCases, ) +from datam8.utils import cleanup_directory from datam8.utils import hasher as hashutils +@pytest.mark.parametrize("folder", ["Root", "Core/Nested"]) +def test_cleanup_removes_parent_after_empty_descendants(tmp_path, folder): + target = tmp_path / "Model" / folder + (target / "Empty" / "Nested").mkdir(parents=True) + retained = tmp_path / "Model" / "Neighbour" / "keep.txt" + retained.parent.mkdir(parents=True) + retained.write_text("keep", encoding="utf-8") + + cleanup_directory(target, tmp_path) + + assert not target.exists() + assert retained.read_text(encoding="utf-8") == "keep" + + +def test_cleanup_preserves_directories_with_remaining_files(tmp_path): + target = tmp_path / "Model" / "Root" + (target / "Empty" / "Nested").mkdir(parents=True) + retained = target / "keep.txt" + retained.write_text("keep", encoding="utf-8") + + cleanup_directory(target, tmp_path) + + assert not (target / "Empty").exists() + assert retained.read_text(encoding="utf-8") == "keep" + + @parametrize_with_cases("algorithm", cases=AlgorithmCases, glob="*_valid") @parametrize_with_cases("input", cases=HashCases, glob="*_valid") def test_hasher_hash(input, algorithm):