From c93f5472aac60622ebd008b66ac6d8e602187379 Mon Sep 17 00:00:00 2001 From: Glen Beane <356266+gbeane@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:56:41 -0400 Subject: [PATCH] Exclude dotfiles from project video and pose file discovery --- src/jabs/project/project_paths.py | 12 +++++++-- src/jabs/project/video_manager.py | 16 ++++++++++-- tests/project/test_project_paths.py | 39 +++++++++++++++++++++++++++++ tests/project/test_video_manager.py | 16 ++++++++++++ 4 files changed, 79 insertions(+), 4 deletions(-) create mode 100644 tests/project/test_project_paths.py diff --git a/src/jabs/project/project_paths.py b/src/jabs/project/project_paths.py index f875c448..95a51d6b 100644 --- a/src/jabs/project/project_paths.py +++ b/src/jabs/project/project_paths.py @@ -107,10 +107,18 @@ def create_directories(self, validate: bool = True) -> None: the jabs directory. Defaults to True. """ if validate and not self._jabs_dir.exists(): + # Skip dotfiles (e.g. macOS AppleDouble "._*" sidecars created on + # exFAT/NTFS external drives), which are not real videos or valid + # pose HDF5 files and must not make a directory look like a project. has_video = any( - file for ext in ("*.mp4", "*.avi") for file in self._video_dir.glob(ext) + file + for ext in ("*.mp4", "*.avi") + for file in self._video_dir.glob(ext) + if not file.name.startswith(".") + ) + has_pose = any( + p for p in self._pose_dir.glob("*_pose_est_v*.h5") if not p.name.startswith(".") ) - has_pose = any(self._pose_dir.glob("*_pose_est_v*.h5")) if not has_video or not has_pose: raise ValueError( f"{self._base_path} does not appear to be a valid JABS project. " diff --git a/src/jabs/project/video_manager.py b/src/jabs/project/video_manager.py index aafea1d6..e8ae67ba 100644 --- a/src/jabs/project/video_manager.py +++ b/src/jabs/project/video_manager.py @@ -151,8 +151,20 @@ def check_video_name(self, video_filename: str): @staticmethod def get_videos(dir_path: Path): - """Get list of video filenames (without path) in a directory""" - return [f.name for f in dir_path.glob("*") if f.suffix in [".avi", ".mp4"]] + """Get list of video filenames (without path) in a directory. + + Dotfiles are skipped. In particular this excludes macOS AppleDouble + sidecar files (``._``) that the OS creates when writing to + non-APFS/HFS+ volumes (e.g. exFAT/NTFS external drives). Those are not + real videos, and ``pathlib.Path.glob`` matches them (unlike the shell); + their companion ``._..._pose_est_v*.h5`` sidecars are not valid HDF5 and + would otherwise break the project video scan. + """ + return [ + f.name + for f in dir_path.glob("*") + if f.suffix in [".avi", ".mp4"] and not f.name.startswith(".") + ] def get_video_identity_count(self, video_name: str) -> int: """Get the number of identity count for a specific video. diff --git a/tests/project/test_project_paths.py b/tests/project/test_project_paths.py new file mode 100644 index 00000000..6433aeb9 --- /dev/null +++ b/tests/project/test_project_paths.py @@ -0,0 +1,39 @@ +"""Tests for jabs.project.project_paths.ProjectPaths.""" + +import shutil +from pathlib import Path + +import pytest + +from jabs.project.project_paths import ProjectPaths + + +def test_create_directories_ignores_dotfile_videos_and_poses(tmp_path): + """AppleDouble '._*' sidecars must not make a directory look like a JABS project. + + macOS creates a ``._`` sidecar for every file on non-APFS/HFS+ volumes + (e.g. exFAT external drives). Those match ``pathlib`` globs but are not real + videos or valid pose HDF5 files, so a directory containing only sidecars is + not a valid project. + """ + (tmp_path / "._vid.mp4").touch() + (tmp_path / "._vid_pose_est_v8.h5").touch() + + paths = ProjectPaths(base_path=tmp_path) + with pytest.raises(ValueError, match="does not appear to be a valid JABS project"): + paths.create_directories(validate=True) + + +def test_create_directories_validates_real_project(tmp_path): + """A directory with a real video and pose file passes validation even alongside sidecars.""" + (tmp_path / "vid.mp4").touch() + data_dir = Path(__file__).parent.parent / "data" + shutil.copy(data_dir / "sample_pose_est_v6.h5", tmp_path / "vid_pose_est_v6.h5") + # AppleDouble sidecars sitting next to the real files must not interfere. + (tmp_path / "._vid.mp4").touch() + (tmp_path / "._vid_pose_est_v6.h5").touch() + + paths = ProjectPaths(base_path=tmp_path) + paths.create_directories(validate=True) # should not raise + + assert paths.jabs_dir.is_dir() diff --git a/tests/project/test_video_manager.py b/tests/project/test_video_manager.py index b45dc38e..a3ad0e4f 100644 --- a/tests/project/test_video_manager.py +++ b/tests/project/test_video_manager.py @@ -77,6 +77,22 @@ def test_get_videos(video_manager, project_paths): assert len(videos) == 2 +def test_get_videos_excludes_dotfiles(tmp_path): + """get_videos ignores dotfiles, including macOS AppleDouble ('._*') sidecars.""" + (tmp_path / "real1.mp4").touch() + (tmp_path / "real2.avi").touch() + # macOS AppleDouble sidecars written when copying to exFAT/NTFS volumes + (tmp_path / "._real1.mp4").touch() + (tmp_path / "._real2.avi").touch() + # other hidden files that should never be treated as videos + (tmp_path / ".DS_Store").touch() + (tmp_path / ".hidden.mp4").touch() + + videos = VideoManager.get_videos(tmp_path) + + assert sorted(videos) == ["real1.mp4", "real2.avi"] + + def test_check_video_name(video_manager): """Test checking if a video name is valid.""" video_manager.check_video_name("video1.avi") # Should not raise an exception