Skip to content

Commit 415bf3f

Browse files
danribesclaude
andcommitted
v1.0.11: Fix Windows symlink error [WinError 1314]
Improved symlink fallback for users without Developer Mode or Admin: - Try hardlinks first (works on same volume without admin) - Properly resolve relative paths using .resolve() - Handle race conditions when source file is still downloading - Clean up existing files before creating new ones - Better error messages when source file not found Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 3fe81ae commit 415bf3f

File tree

4 files changed

+101
-44
lines changed

4 files changed

+101
-44
lines changed

build/hooks/runtime_hook_hf_symlinks.py

Lines changed: 51 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,15 @@
33
44
This hook runs BEFORE any other code, ensuring symlinks are patched
55
before huggingface_hub is imported.
6+
7+
Fixes [WinError 1314] "A required privilege is not held by the client"
8+
which occurs when creating symlinks without Developer Mode or Admin rights.
69
"""
710

811
import os
912
import sys
1013
import shutil
14+
import time
1115
from pathlib import Path
1216

1317
if sys.platform == "win32":
@@ -25,25 +29,53 @@ def _symlink_or_copy(src, dst, target_is_directory=False, *, dir_fd=None):
2529
try:
2630
_original_symlink(src, dst, target_is_directory, dir_fd=dir_fd)
2731
except OSError as e:
28-
if getattr(e, 'winerror', None) == 1314: # ERROR_PRIVILEGE_NOT_HELD
29-
src_path = Path(src)
30-
dst_path = Path(dst)
31-
32-
# Handle relative symlinks (HuggingFace uses these)
33-
if not src_path.is_absolute():
34-
src_path = dst_path.parent / src_path
35-
36-
try:
37-
if src_path.is_dir():
38-
if dst_path.exists():
39-
shutil.rmtree(dst_path)
40-
shutil.copytree(src_path, dst_path)
41-
else:
42-
dst_path.parent.mkdir(parents=True, exist_ok=True)
43-
shutil.copy2(src_path, dst_path)
44-
except Exception:
45-
raise e
46-
else:
32+
if getattr(e, 'winerror', None) != 1314: # Not ERROR_PRIVILEGE_NOT_HELD
4733
raise
4834

35+
# Convert to Path objects for easier handling
36+
dst_path = Path(dst)
37+
src_path = Path(src)
38+
39+
# Resolve relative symlinks (HuggingFace uses paths like "../../blobs/xxx")
40+
if not src_path.is_absolute():
41+
src_path = (dst_path.parent / src_path).resolve()
42+
else:
43+
src_path = src_path.resolve()
44+
45+
# Ensure destination parent directory exists
46+
dst_path.parent.mkdir(parents=True, exist_ok=True)
47+
48+
# Remove existing destination if present
49+
if dst_path.exists() or dst_path.is_symlink():
50+
if dst_path.is_dir() and not dst_path.is_symlink():
51+
shutil.rmtree(dst_path)
52+
else:
53+
dst_path.unlink()
54+
55+
# Wait briefly for source file if it doesn't exist yet (race condition)
56+
# HuggingFace sometimes creates symlinks before the blob is fully written
57+
if not src_path.exists():
58+
for _ in range(10):
59+
time.sleep(0.1)
60+
if src_path.exists():
61+
break
62+
63+
if not src_path.exists():
64+
raise FileNotFoundError(
65+
f"Source file not found for symlink fallback: {src_path}"
66+
) from e
67+
68+
# Try hardlink first (works without admin on same volume, no space usage)
69+
try:
70+
os.link(src_path, dst_path)
71+
return
72+
except OSError:
73+
pass # Hardlink failed, fall back to copy
74+
75+
# Fall back to file copy
76+
if src_path.is_dir():
77+
shutil.copytree(src_path, dst_path)
78+
else:
79+
shutil.copy2(src_path, dst_path)
80+
4981
os.symlink = _symlink_or_copy

build/pdfextractor.spec

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ is_mac = sys.platform == 'darwin'
3737

3838
# Application info
3939
APP_NAME = 'PDF Extractor'
40-
APP_VERSION = '1.0.10'
40+
APP_VERSION = '1.0.11'
4141
BUNDLE_ID = 'com.pdfextractor.app'
4242

4343
# Runtime hooks directory

src/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
# PDF Extractor - Cross-platform document extraction tool
2-
__version__ = "1.0.10"
2+
__version__ = "1.0.11"

src/config.py

Lines changed: 48 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -25,37 +25,62 @@ def _patch_symlinks_for_windows():
2525
if sys.platform != "win32":
2626
return
2727

28+
import time
2829
original_symlink = os.symlink
2930

3031
def symlink_or_copy(src, dst, target_is_directory=False, *, dir_fd=None):
3132
"""Replace symlink with copy on Windows to avoid privilege errors."""
3233
try:
33-
# First try the original symlink (works if user has privileges)
3434
original_symlink(src, dst, target_is_directory, dir_fd=dir_fd)
3535
except OSError as e:
36-
if e.winerror == 1314: # ERROR_PRIVILEGE_NOT_HELD
37-
# Fall back to copying the file/directory
38-
src_path = Path(src) if not os.path.isabs(src) else Path(src)
39-
dst_path = Path(dst)
40-
41-
# Handle relative symlinks (HuggingFace uses these)
42-
if not src_path.is_absolute():
43-
src_path = dst_path.parent / src_path
44-
45-
try:
46-
if src_path.is_dir():
47-
if dst_path.exists():
48-
shutil.rmtree(dst_path)
49-
shutil.copytree(src_path, dst_path)
50-
else:
51-
dst_path.parent.mkdir(parents=True, exist_ok=True)
52-
shutil.copy2(src_path, dst_path)
53-
except Exception:
54-
# If copy also fails, raise the original error
55-
raise e
56-
else:
36+
if getattr(e, 'winerror', None) != 1314: # Not ERROR_PRIVILEGE_NOT_HELD
5737
raise
5838

39+
# Convert to Path objects for easier handling
40+
dst_path = Path(dst)
41+
src_path = Path(src)
42+
43+
# Resolve relative symlinks (HuggingFace uses paths like "../../blobs/xxx")
44+
if not src_path.is_absolute():
45+
src_path = (dst_path.parent / src_path).resolve()
46+
else:
47+
src_path = src_path.resolve()
48+
49+
# Ensure destination parent directory exists
50+
dst_path.parent.mkdir(parents=True, exist_ok=True)
51+
52+
# Remove existing destination if present
53+
if dst_path.exists() or dst_path.is_symlink():
54+
if dst_path.is_dir() and not dst_path.is_symlink():
55+
shutil.rmtree(dst_path)
56+
else:
57+
dst_path.unlink()
58+
59+
# Wait briefly for source file if it doesn't exist yet (race condition)
60+
if not src_path.exists():
61+
for _ in range(10):
62+
time.sleep(0.1)
63+
if src_path.exists():
64+
break
65+
66+
if not src_path.exists():
67+
raise FileNotFoundError(
68+
f"Source file not found for symlink fallback: {src_path}"
69+
) from e
70+
71+
# Try hardlink first (works without admin on same volume, no space usage)
72+
try:
73+
os.link(src_path, dst_path)
74+
return
75+
except OSError:
76+
pass # Hardlink failed, fall back to copy
77+
78+
# Fall back to file copy
79+
if src_path.is_dir():
80+
shutil.copytree(src_path, dst_path)
81+
else:
82+
shutil.copy2(src_path, dst_path)
83+
5984
os.symlink = symlink_or_copy
6085

6186

@@ -153,6 +178,6 @@ def setup_docling_cache():
153178

154179
# Application metadata
155180
APP_NAME = "PDF Extractor"
156-
APP_VERSION = "1.0.8"
181+
APP_VERSION = "1.0.11"
157182
APP_AUTHOR = "Dan Ribes"
158183
APP_IDENTIFIER = "com.pdfextractor.app"

0 commit comments

Comments
 (0)