diff --git a/deeplabcut/create_project/new.py b/deeplabcut/create_project/new.py index 11e1f26ba..9fdbe4480 100644 --- a/deeplabcut/create_project/new.py +++ b/deeplabcut/create_project/new.py @@ -147,7 +147,7 @@ def create_new_project( date = dt.today().strftime("%Y-%m-%d") if working_directory is None: working_directory = "." - wd = Path(working_directory).resolve() + wd = Path(working_directory).absolute() project_name = f"{project}-{experimenter}-{date}" project_path = wd / project_name diff --git a/deeplabcut/create_project/new_3d.py b/deeplabcut/create_project/new_3d.py index 24be797a2..780a6c44d 100644 --- a/deeplabcut/create_project/new_3d.py +++ b/deeplabcut/create_project/new_3d.py @@ -59,7 +59,7 @@ def create_new_project_3d(project, experimenter, num_cameras=2, working_director if working_directory is None: working_directory = "." - wd = Path(working_directory).resolve() + wd = Path(working_directory).absolute() project_name = "{pn}-{exp}-{date}-{triangulate}".format(pn=project, exp=experimenter, date=date, triangulate="3d") project_path = wd / project_name # Create project and sub-directories diff --git a/deeplabcut/generate_training_dataset/frame_extraction.py b/deeplabcut/generate_training_dataset/frame_extraction.py index 3605b9aca..1d4bae857 100755 --- a/deeplabcut/generate_training_dataset/frame_extraction.py +++ b/deeplabcut/generate_training_dataset/frame_extraction.py @@ -262,7 +262,7 @@ def extract_frames( from deeplabcut.utils import auxiliaryfunctions, frameselectiontools - config_file = Path(config).resolve() + config_file = Path(config) cfg = auxiliaryfunctions.read_config(config_file) print("Config file read successfully.") @@ -456,7 +456,7 @@ def extract_frames( elif mode == "match": import cv2 - config_file = Path(config).resolve() + config_file = Path(config) cfg = auxiliaryfunctions.read_config(config_file) print("Config file read successfully.") videos = sorted(cfg["video_sets"].keys()) diff --git a/deeplabcut/gui/tabs/analyze_videos.py b/deeplabcut/gui/tabs/analyze_videos.py index 1131ebfc3..78153a837 100644 --- a/deeplabcut/gui/tabs/analyze_videos.py +++ b/deeplabcut/gui/tabs/analyze_videos.py @@ -337,7 +337,7 @@ def _get_unique_video_parent_folders(self, batches: list[tuple[str, list[str]]]) for _, videos in batches: for video in videos: - parent = str(Path(video).parent.resolve()) + parent = str(Path(video).parent.absolute()) if parent not in seen: seen.add(parent) folders.append(parent) diff --git a/deeplabcut/pose_estimation_pytorch/modelzoo/memory_replay.py b/deeplabcut/pose_estimation_pytorch/modelzoo/memory_replay.py index c0baeca0b..147593627 100644 --- a/deeplabcut/pose_estimation_pytorch/modelzoo/memory_replay.py +++ b/deeplabcut/pose_estimation_pytorch/modelzoo/memory_replay.py @@ -135,8 +135,11 @@ def prepare_memory_replay_dataset( device: str | None = None, ): """Need to first run inference on the source project train file.""" - project_root = loader.project_path.resolve() - source_dataset_folder = Path(source_dataset_folder).resolve() + # Use safe_resolve so that symlinks are followed where possible, but we + # fall back to abspath on Windows 11 SMB drives that produce unusable + # \\?\Volume{GUID}\... paths. See https://github.com/DeepLabCut/DeepLabCut/issues/3348 + project_root = af.safe_resolve(loader.project_path) + source_dataset_folder = af.safe_resolve(Path(source_dataset_folder)) # Contains the ground truth annotations for the DeepLabCut project # .../dlc-models-pytorch/.../...shuffle0/train/memory_replay/annotations/train.json @@ -242,7 +245,7 @@ def optimal_match(gts_list, preds_list): # parse the GT to put the image paths back into OS-specific format for image in project_gt["images"]: image_rel_path = image["file_name"].split("/") - image["file_name"] = str(project_root.resolve() / Path(*image_rel_path)) + image["file_name"] = str(project_root / Path(*image_rel_path)) with open(memory_replay_train_file_path, "w") as f: json.dump(project_gt, f, indent=4) diff --git a/deeplabcut/utils/auxiliaryfunctions.py b/deeplabcut/utils/auxiliaryfunctions.py index d726905dd..f3043fad3 100644 --- a/deeplabcut/utils/auxiliaryfunctions.py +++ b/deeplabcut/utils/auxiliaryfunctions.py @@ -200,6 +200,28 @@ def create_config_template_3d(): return cfg_file_3d, ruamelFile_3d +def safe_resolve(path: Path) -> Path: + """Return a resolved Path that is safe to use with str-based I/O. + + Prefers Path.resolve() so that symlinks are followed (useful on Linux). + Falls back to Path.absolute() when the resolved path cannot be opened + as a plain string — e.g. on Windows 11 + SMB network drives where + resolve() may return an unusable \\\\?\\Volume{GUID}\\... form. + + See https://github.com/DeepLabCut/DeepLabCut/issues/3348 + """ + resolved = path.resolve() + try: + if resolved.is_dir(): + # test if os.listdir works after str-conversion + os.listdir(str(resolved)) + else: + open(str(resolved)).close() + return resolved + except OSError: + return path.absolute() + + def read_config(configname): """Reads structured config file defining a project.""" ruamelFile = YAML() @@ -208,7 +230,7 @@ def read_config(configname): try: with open(path) as f: cfg = ruamelFile.load(f) - curr_dir = str(Path(configname).parent.resolve()) + curr_dir = Path(configname).parent.absolute() if cfg.get("engine") is None: cfg["engine"] = Engine.TF.aliases[0] @@ -220,8 +242,8 @@ def read_config(configname): if cfg.get("detector_batch_size") is None: cfg["detector_batch_size"] = 1 - if cfg["project_path"] != curr_dir: - cfg["project_path"] = curr_dir + if cfg["project_path"] != str(curr_dir): + cfg["project_path"] = str(curr_dir) write_config(configname, cfg) except Exception as err: if len(err.args) > 2: diff --git a/tests/test_auxiliaryfunctions.py b/tests/test_auxiliaryfunctions.py index ff16dc1c1..5e3ec56c4 100644 --- a/tests/test_auxiliaryfunctions.py +++ b/tests/test_auxiliaryfunctions.py @@ -8,7 +8,9 @@ # # Licensed under GNU Lesser General Public License v3.0 # +import os from pathlib import Path +from unittest.mock import patch import pytest @@ -269,3 +271,79 @@ def test_get_snapshots_from_folder_none(mock_no_snapshots_folder): """Test raises ValueError if no snapshots are found.""" with pytest.raises(FileNotFoundError): auxiliaryfunctions.get_snapshots_from_folder(mock_no_snapshots_folder) + + +# --------------------------------------------------------------------------- +# Tests for safe_resolve() and read_config() network-drive path safety +# https://github.com/DeepLabCut/DeepLabCut/issues/3348 +# --------------------------------------------------------------------------- + + +class TestSafeResolve: + """safe_resolve() must return a Path whose str() representation can be + opened by plain string-based I/O — i.e. it must not return Windows 11 SMB + Volume GUID paths like \\\\?\\Volume{...}\\... + """ + + def test_normal_path_is_returned_unchanged(self, tmp_path): + """On a normal local filesystem, safe_resolve returns the resolved path.""" + f = tmp_path / "config.yaml" + f.touch() + result = auxiliaryfunctions.safe_resolve(f) + assert result == f.resolve() + assert result.exists() + + def test_fallback_when_resolve_produces_unusable_path(self, tmp_path): + """When resolve() returns a path that cannot be opened as a string, + safe_resolve must fall back to abspath.""" + f = tmp_path / "config.yaml" + f.write_text("project_path: .") + + fake_volume_guid = Path(r"\\?\Volume{DEADBEEF-0000-0000-0000-000000000000}\fake") + + with patch.object(Path, "resolve", return_value=fake_volume_guid): + result = auxiliaryfunctions.safe_resolve(f) + + # Must NOT return the unusable Volume GUID path + assert "Volume{" not in str(result) + # Fallback must be the absolute (non-resolved) path, which exists and is openable + assert result == f.absolute() + open(result).close() + + +class TestReadConfigProjectPath: + """read_config() must never persist a \\\\?\\Volume{GUID}\\... path into + project_path, even on Windows 11 SMB network drives.""" + + def test_project_path_corrected_when_persisted_as_volume_guid(self, tmp_path): + """Regression test for https://github.com/DeepLabCut/DeepLabCut/issues/3348. + + A config.yaml whose project_path was previously corrupted to a + \\\\?\\Volume{GUID}\\... form (e.g. by an older buggy read_config()) + must be corrected to the real directory on the next read. + """ + project_dir = tmp_path / "my_project" + project_dir.mkdir() + config_file = project_dir / "config.yaml" + + bad_path = r"\\?\Volume{DEADBEEF-0000-0000-0000-000000000000}\my_project" + auxiliaryfunctions.write_config(config_file, {"project_path": bad_path}) + + cfg = auxiliaryfunctions.read_config(config_file) + + assert "Volume{" not in cfg["project_path"] + assert os.path.isdir(cfg["project_path"]) + + def test_project_path_updated_when_moved(self, tmp_path): + """read_config() must still update project_path when a project is moved + to a new directory (the original feature that resolve() was meant for).""" + project_dir = tmp_path / "original_location" + project_dir.mkdir() + config_file = project_dir / "config.yaml" + + auxiliaryfunctions.write_config(config_file, {"project_path": "/some/old/path/that/no/longer/exists"}) + + cfg = auxiliaryfunctions.read_config(config_file) + + expected = str(project_dir.absolute()) + assert cfg["project_path"] == expected