Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion deeplabcut/create_project/new.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion deeplabcut/create_project/new_3d.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions deeplabcut/generate_training_dataset/frame_extraction.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.")

Expand Down Expand Up @@ -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())
Expand Down
2 changes: 1 addition & 1 deletion deeplabcut/gui/tabs/analyze_videos.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 6 additions & 3 deletions deeplabcut/pose_estimation_pytorch/modelzoo/memory_replay.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
28 changes: 25 additions & 3 deletions deeplabcut/utils/auxiliaryfunctions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Comment thread
deruyter92 marked this conversation as resolved.
else:
open(str(resolved)).close()
return resolved
except OSError:
return path.absolute()
Comment thread
deruyter92 marked this conversation as resolved.


def read_config(configname):
"""Reads structured config file defining a project."""
ruamelFile = YAML()
Expand All @@ -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]
Expand All @@ -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)
Comment thread
deruyter92 marked this conversation as resolved.
except Exception as err:
if len(err.args) > 2:
Expand Down
78 changes: 78 additions & 0 deletions tests/test_auxiliaryfunctions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Loading