From 3b8c44a6f79fbb85c1b57f118090582d54e8346d Mon Sep 17 00:00:00 2001 From: Jaap de Ruyter van Steveninck <32810691+deruyter92@users.noreply.github.com> Date: Fri, 29 May 2026 09:51:52 +0200 Subject: [PATCH 1/7] fix path resolution on network drives. Replace Path(..).resolve() with Path(..).absolute() --- deeplabcut/create_project/new.py | 2 +- deeplabcut/create_project/new_3d.py | 2 +- deeplabcut/generate_training_dataset/frame_extraction.py | 4 ++-- deeplabcut/gui/tabs/analyze_videos.py | 2 +- deeplabcut/utils/auxiliaryfunctions.py | 4 ++-- 5 files changed, 7 insertions(+), 7 deletions(-) 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/utils/auxiliaryfunctions.py b/deeplabcut/utils/auxiliaryfunctions.py index d726905dd..4036fe9d0 100644 --- a/deeplabcut/utils/auxiliaryfunctions.py +++ b/deeplabcut/utils/auxiliaryfunctions.py @@ -208,7 +208,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] @@ -221,7 +221,7 @@ def read_config(configname): cfg["detector_batch_size"] = 1 if cfg["project_path"] != curr_dir: - cfg["project_path"] = curr_dir + cfg["project_path"] = str(curr_dir) write_config(configname, cfg) except Exception as err: if len(err.args) > 2: From fddcbaa46f0f2a3273d332dd2429ebf199a5b6c2 Mon Sep 17 00:00:00 2001 From: Jaap de Ruyter van Steveninck <32810691+deruyter92@users.noreply.github.com> Date: Fri, 29 May 2026 09:53:04 +0200 Subject: [PATCH 2/7] Add safe_resolve helper for path resolution --- .../modelzoo/memory_replay.py | 9 ++- deeplabcut/utils/auxiliaryfunctions.py | 18 +++++ tests/test_auxiliaryfunctions.py | 77 +++++++++++++++++++ 3 files changed, 101 insertions(+), 3 deletions(-) diff --git a/deeplabcut/pose_estimation_pytorch/modelzoo/memory_replay.py b/deeplabcut/pose_estimation_pytorch/modelzoo/memory_replay.py index c0baeca0b..d38dd965d 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(af.safe_resolve(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 4036fe9d0..416999b58 100644 --- a/deeplabcut/utils/auxiliaryfunctions.py +++ b/deeplabcut/utils/auxiliaryfunctions.py @@ -200,6 +200,24 @@ 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: + open(resolved).close() + return resolved + except OSError: + return path.absolute() + + def read_config(configname): """Reads structured config file defining a project.""" ruamelFile = YAML() diff --git a/tests/test_auxiliaryfunctions.py b/tests/test_auxiliaryfunctions.py index ff16dc1c1..cfe6b8714 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,78 @@ 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_not_written_as_volume_guid(self, tmp_path): + """If resolve() would produce a Volume GUID path, read_config must fall + back to abspath and write a usable path to config.yaml.""" + project_dir = tmp_path / "my_project" + project_dir.mkdir() + config_file = project_dir / "config.yaml" + + auxiliaryfunctions.write_config(config_file, {"project_path": str(project_dir)}) + + fake_volume_guid = Path(r"\\?\Volume{DEADBEEF-0000-0000-0000-000000000000}\my_project") + + with patch.object(Path, "resolve", return_value=fake_volume_guid): + cfg = auxiliaryfunctions.read_config(config_file) + + assert "Volume{" not in cfg["project_path"] + # The stored value must be openable as a plain string + 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 From 65f8dfb6196821c4d195bf16c0174b67489e4f8d Mon Sep 17 00:00:00 2001 From: Jaap de Ruyter van Steveninck <32810691+deruyter92@users.noreply.github.com> Date: Fri, 29 May 2026 11:16:38 +0200 Subject: [PATCH 3/7] fix redundant resolution of resolved path in memory_replay.py --- deeplabcut/pose_estimation_pytorch/modelzoo/memory_replay.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deeplabcut/pose_estimation_pytorch/modelzoo/memory_replay.py b/deeplabcut/pose_estimation_pytorch/modelzoo/memory_replay.py index d38dd965d..147593627 100644 --- a/deeplabcut/pose_estimation_pytorch/modelzoo/memory_replay.py +++ b/deeplabcut/pose_estimation_pytorch/modelzoo/memory_replay.py @@ -245,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(af.safe_resolve(project_root / 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) From 6bbe26d553e0f5ea78c8b0125f164c43c19fc011 Mon Sep 17 00:00:00 2001 From: Jaap de Ruyter van Steveninck <32810691+deruyter92@users.noreply.github.com> Date: Fri, 29 May 2026 11:22:55 +0200 Subject: [PATCH 4/7] fix open -> os.stat --- deeplabcut/utils/auxiliaryfunctions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deeplabcut/utils/auxiliaryfunctions.py b/deeplabcut/utils/auxiliaryfunctions.py index 416999b58..289ba8923 100644 --- a/deeplabcut/utils/auxiliaryfunctions.py +++ b/deeplabcut/utils/auxiliaryfunctions.py @@ -212,7 +212,7 @@ def safe_resolve(path: Path) -> Path: """ resolved = path.resolve() try: - open(resolved).close() + os.stat(str(resolved)) return resolved except OSError: return path.absolute() From 3f93a7692fc8c7ab055a2dba796c7319025da4dc Mon Sep 17 00:00:00 2001 From: Jaap de Ruyter van Steveninck <32810691+deruyter92@users.noreply.github.com> Date: Fri, 29 May 2026 11:32:31 +0200 Subject: [PATCH 5/7] replace os.stat -> open for files, listdir for dirs. --- deeplabcut/utils/auxiliaryfunctions.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/deeplabcut/utils/auxiliaryfunctions.py b/deeplabcut/utils/auxiliaryfunctions.py index 289ba8923..58cb46543 100644 --- a/deeplabcut/utils/auxiliaryfunctions.py +++ b/deeplabcut/utils/auxiliaryfunctions.py @@ -212,7 +212,10 @@ def safe_resolve(path: Path) -> Path: """ resolved = path.resolve() try: - os.stat(str(resolved)) + if resolved.is_dir(): + os.listdir(str(resolved)) + else: + open(str(resolved)).close() return resolved except OSError: return path.absolute() @@ -238,7 +241,7 @@ def read_config(configname): if cfg.get("detector_batch_size") is None: cfg["detector_batch_size"] = 1 - if 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: From 87cd6aad47f490aac5b15b4cd918152bdc3ca9c0 Mon Sep 17 00:00:00 2001 From: Jaap de Ruyter van Steveninck <32810691+deruyter92@users.noreply.github.com> Date: Fri, 29 May 2026 11:35:01 +0200 Subject: [PATCH 6/7] fix TestReadConfigProjectPath --- tests/test_auxiliaryfunctions.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/tests/test_auxiliaryfunctions.py b/tests/test_auxiliaryfunctions.py index cfe6b8714..5e3ec56c4 100644 --- a/tests/test_auxiliaryfunctions.py +++ b/tests/test_auxiliaryfunctions.py @@ -315,22 +315,23 @@ 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_not_written_as_volume_guid(self, tmp_path): - """If resolve() would produce a Volume GUID path, read_config must fall - back to abspath and write a usable path to config.yaml.""" + 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" - auxiliaryfunctions.write_config(config_file, {"project_path": str(project_dir)}) - - fake_volume_guid = Path(r"\\?\Volume{DEADBEEF-0000-0000-0000-000000000000}\my_project") + bad_path = r"\\?\Volume{DEADBEEF-0000-0000-0000-000000000000}\my_project" + auxiliaryfunctions.write_config(config_file, {"project_path": bad_path}) - with patch.object(Path, "resolve", return_value=fake_volume_guid): - cfg = auxiliaryfunctions.read_config(config_file) + cfg = auxiliaryfunctions.read_config(config_file) assert "Volume{" not in cfg["project_path"] - # The stored value must be openable as a plain string assert os.path.isdir(cfg["project_path"]) def test_project_path_updated_when_moved(self, tmp_path): From 8be1c265e6a2bc6336d2beab0a6adf76bf572ffd Mon Sep 17 00:00:00 2001 From: Jaap de Ruyter van Steveninck <32810691+deruyter92@users.noreply.github.com> Date: Fri, 29 May 2026 15:10:32 +0200 Subject: [PATCH 7/7] Update deeplabcut/utils/auxiliaryfunctions.py --- deeplabcut/utils/auxiliaryfunctions.py | 1 + 1 file changed, 1 insertion(+) diff --git a/deeplabcut/utils/auxiliaryfunctions.py b/deeplabcut/utils/auxiliaryfunctions.py index 58cb46543..f3043fad3 100644 --- a/deeplabcut/utils/auxiliaryfunctions.py +++ b/deeplabcut/utils/auxiliaryfunctions.py @@ -213,6 +213,7 @@ def safe_resolve(path: Path) -> Path: 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()