Skip to content

Commit 2d21870

Browse files
committed
Use SHGetKnownFolderPath() instead of SHGetFolderPathW()
The official documentation for `SHGetFolderPathW()` says: > Note As of Windows Vista, this function is merely a wrapper for > SHGetKnownFolderPath. The CSIDL value is translated to its associated > KNOWNFOLDERID and then SHGetKnownFolderPath is called. New > applications should use the known folder system rather than the older > CSIDL system, which is supported only for backward compatibility. Source: <https://learn.microsoft.com/en-us/windows/win32/api/shlobj_core/nf-shlobj_core-shgetfolderpathw> This change makes it so that platformdirs uses `SHGetKnownFolderPath()` instead of `SHGetFolderPathW()`. This change also removes references to the old CSIDL system and replaces them with references wo the FOLDERID system. Closes #348.
1 parent 1ec4261 commit 2d21870

File tree

1 file changed

+139
-66
lines changed

1 file changed

+139
-66
lines changed

src/platformdirs/windows.py

Lines changed: 139 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
from __future__ import annotations
44

5-
import contextlib
65
import os
76
import sys
87
from functools import lru_cache
@@ -13,10 +12,14 @@
1312
if TYPE_CHECKING:
1413
from collections.abc import Callable
1514

16-
with contextlib.suppress(ImportError):
15+
try: # noqa: SIM105
1716
import ctypes
18-
with contextlib.suppress(ImportError):
17+
except ImportError:
18+
pass
19+
try: # noqa: SIM105
1920
import winreg
21+
except ImportError:
22+
pass
2023

2124

2225
class Windows(PlatformDirsABC):
@@ -37,7 +40,7 @@ def user_data_dir(self) -> str:
3740
``%USERPROFILE%\\AppData\\Local\\$appauthor\\$appname`` (not roaming) or
3841
``%USERPROFILE%\\AppData\\Roaming\\$appauthor\\$appname`` (roaming)
3942
"""
40-
const = "CSIDL_APPDATA" if self.roaming else "CSIDL_LOCAL_APPDATA"
43+
const = "FOLDERID_RoamingAppData" if self.roaming else "FOLDERID_LocalAppData"
4144
path = os.path.normpath(get_win_folder(const))
4245
return self._append_parts(path)
4346

@@ -59,7 +62,7 @@ def _append_parts(self, path: str, *, opinion_value: str | None = None) -> str:
5962
@property
6063
def site_data_dir(self) -> str:
6164
""":return: data directory shared by users, e.g. ``C:\\ProgramData\\$appauthor\\$appname``"""
62-
path = os.path.normpath(get_win_folder("CSIDL_COMMON_APPDATA"))
65+
path = os.path.normpath(get_win_folder("FOLDERID_ProgramData"))
6366
return self._append_parts(path)
6467

6568
@property
@@ -78,13 +81,13 @@ def user_cache_dir(self) -> str:
7881
:return: cache directory tied to the user (if opinionated with ``Cache`` folder within ``$appname``) e.g.
7982
``%USERPROFILE%\\AppData\\Local\\$appauthor\\$appname\\Cache\\$version``
8083
"""
81-
path = os.path.normpath(get_win_folder("CSIDL_LOCAL_APPDATA"))
84+
path = os.path.normpath(get_win_folder("FOLDERID_LocalAppData"))
8285
return self._append_parts(path, opinion_value="Cache")
8386

8487
@property
8588
def site_cache_dir(self) -> str:
8689
""":return: cache directory shared by users, e.g. ``C:\\ProgramData\\$appauthor\\$appname\\Cache\\$version``"""
87-
path = os.path.normpath(get_win_folder("CSIDL_COMMON_APPDATA"))
90+
path = os.path.normpath(get_win_folder("FOLDERID_ProgramData"))
8891
return self._append_parts(path, opinion_value="Cache")
8992

9093
@property
@@ -104,40 +107,40 @@ def user_log_dir(self) -> str:
104107
@property
105108
def user_documents_dir(self) -> str:
106109
""":return: documents directory tied to the user e.g. ``%USERPROFILE%\\Documents``"""
107-
return os.path.normpath(get_win_folder("CSIDL_PERSONAL"))
110+
return os.path.normpath(get_win_folder("FOLDERID_Documents"))
108111

109112
@property
110113
def user_downloads_dir(self) -> str:
111114
""":return: downloads directory tied to the user e.g. ``%USERPROFILE%\\Downloads``"""
112-
return os.path.normpath(get_win_folder("CSIDL_DOWNLOADS"))
115+
return os.path.normpath(get_win_folder("FOLDERID_Downloads"))
113116

114117
@property
115118
def user_pictures_dir(self) -> str:
116119
""":return: pictures directory tied to the user e.g. ``%USERPROFILE%\\Pictures``"""
117-
return os.path.normpath(get_win_folder("CSIDL_MYPICTURES"))
120+
return os.path.normpath(get_win_folder("FOLDERID_Pictures"))
118121

119122
@property
120123
def user_videos_dir(self) -> str:
121124
""":return: videos directory tied to the user e.g. ``%USERPROFILE%\\Videos``"""
122-
return os.path.normpath(get_win_folder("CSIDL_MYVIDEO"))
125+
return os.path.normpath(get_win_folder("FOLDERID_Videos"))
123126

124127
@property
125128
def user_music_dir(self) -> str:
126129
""":return: music directory tied to the user e.g. ``%USERPROFILE%\\Music``"""
127-
return os.path.normpath(get_win_folder("CSIDL_MYMUSIC"))
130+
return os.path.normpath(get_win_folder("FOLDERID_Music"))
128131

129132
@property
130133
def user_desktop_dir(self) -> str:
131134
""":return: desktop directory tied to the user, e.g. ``%USERPROFILE%\\Desktop``"""
132-
return os.path.normpath(get_win_folder("CSIDL_DESKTOPDIRECTORY"))
135+
return os.path.normpath(get_win_folder("FOLDERID_Desktop"))
133136

134137
@property
135138
def user_runtime_dir(self) -> str:
136139
"""
137140
:return: runtime directory tied to the user, e.g.
138141
``%USERPROFILE%\\AppData\\Local\\Temp\\$appauthor\\$appname``
139142
"""
140-
path = os.path.normpath(os.path.join(get_win_folder("CSIDL_LOCAL_APPDATA"), "Temp")) # noqa: PTH118
143+
path = os.path.normpath(os.path.join(get_win_folder("FOLDERID_LocalAppData"), "Temp")) # noqa: PTH118
141144
return self._append_parts(path)
142145

143146
@property
@@ -146,19 +149,19 @@ def site_runtime_dir(self) -> str:
146149
return self.user_runtime_dir
147150

148151

149-
def get_win_folder_from_env_vars(csidl_name: str) -> str:
152+
def get_win_folder_from_env_vars(folderid_name: str) -> str:
150153
"""Get folder from environment variables."""
151-
result = get_win_folder_if_csidl_name_not_env_var(csidl_name)
154+
result = get_win_folder_if_folderid_name_not_env_var(folderid_name)
152155
if result is not None:
153156
return result
154157

155158
env_var_name = {
156-
"CSIDL_APPDATA": "APPDATA",
157-
"CSIDL_COMMON_APPDATA": "ALLUSERSPROFILE",
158-
"CSIDL_LOCAL_APPDATA": "LOCALAPPDATA",
159-
}.get(csidl_name)
159+
"FOLDERID_RoamingAppData": "APPDATA",
160+
"FOLDERID_ProgramData": "ALLUSERSPROFILE",
161+
"FOLDERID_LocalAppData": "LOCALAPPDATA",
162+
}.get(folderid_name)
160163
if env_var_name is None:
161-
msg = f"Unknown CSIDL name: {csidl_name}"
164+
msg = f"Unknown FOLDERID name: {folderid_name}"
162165
raise ValueError(msg)
163166
result = os.environ.get(env_var_name)
164167
if result is None:
@@ -167,47 +170,49 @@ def get_win_folder_from_env_vars(csidl_name: str) -> str:
167170
return result
168171

169172

170-
def get_win_folder_if_csidl_name_not_env_var(csidl_name: str) -> str | None:
171-
"""Get a folder for a CSIDL name that does not exist as an environment variable."""
172-
if csidl_name == "CSIDL_PERSONAL":
173+
def get_win_folder_if_folderid_name_not_env_var(folderid_name: str) -> str | None:
174+
"""Get a folder for a FOLDERID name that does not exist as an environment variable."""
175+
if folderid_name == "FOLDERID_Documents":
173176
return os.path.join(os.path.normpath(os.environ["USERPROFILE"]), "Documents") # noqa: PTH118
174177

175-
if csidl_name == "CSIDL_DOWNLOADS":
178+
if folderid_name == "FOLDERID_Downloads":
176179
return os.path.join(os.path.normpath(os.environ["USERPROFILE"]), "Downloads") # noqa: PTH118
177180

178-
if csidl_name == "CSIDL_MYPICTURES":
181+
if folderid_name == "FOLDERID_Pictures":
179182
return os.path.join(os.path.normpath(os.environ["USERPROFILE"]), "Pictures") # noqa: PTH118
180183

181-
if csidl_name == "CSIDL_MYVIDEO":
184+
if folderid_name == "FOLDERID_Videos":
182185
return os.path.join(os.path.normpath(os.environ["USERPROFILE"]), "Videos") # noqa: PTH118
183186

184-
if csidl_name == "CSIDL_MYMUSIC":
187+
if folderid_name == "FOLDERID_Music":
185188
return os.path.join(os.path.normpath(os.environ["USERPROFILE"]), "Music") # noqa: PTH118
186189
return None
187190

188191

192+
FOLDERID_Downloads_guid_string = "374DE290-123F-4565-9164-39C4925E467B"
193+
189194
if "winreg" in globals():
190195

191-
def get_win_folder_from_registry(csidl_name: str) -> str:
196+
def get_win_folder_from_registry(folderid_name: str) -> str:
192197
"""
193198
Get folder from the registry.
194199
195200
This is a fallback technique at best. I'm not sure if using the registry for these guarantees us the correct
196-
answer for all CSIDL_* names.
201+
answer for all FOLDERID_* names.
197202
198203
"""
199204
shell_folder_name = {
200-
"CSIDL_APPDATA": "AppData",
201-
"CSIDL_COMMON_APPDATA": "Common AppData",
202-
"CSIDL_LOCAL_APPDATA": "Local AppData",
203-
"CSIDL_PERSONAL": "Personal",
204-
"CSIDL_DOWNLOADS": "{374DE290-123F-4565-9164-39C4925E467B}",
205-
"CSIDL_MYPICTURES": "My Pictures",
206-
"CSIDL_MYVIDEO": "My Video",
207-
"CSIDL_MYMUSIC": "My Music",
208-
}.get(csidl_name)
205+
"FOLDERID_RoamingAppData": "AppData",
206+
"FOLDERID_ProgramData": "Common AppData",
207+
"FOLDERID_LocalAppData": "Local AppData",
208+
"FOLDERID_Documents": "Personal",
209+
"FOLDERID_Downloads": "{" + FOLDERID_Downloads_guid_string + "}",
210+
"FOLDERID_Pictures": "My Pictures",
211+
"FOLDERID_Videos": "My Video",
212+
"FOLDERID_Music": "My Music",
213+
}.get(folderid_name)
209214
if shell_folder_name is None:
210-
msg = f"Unknown CSIDL name: {csidl_name}"
215+
msg = f"Unknown FOLDERID name: {folderid_name}"
211216
raise ValueError(msg)
212217
if sys.platform != "win32": # only needed for mypy type checker to know that this code runs only on Windows
213218
raise NotImplementedError
@@ -221,40 +226,108 @@ def get_win_folder_from_registry(csidl_name: str) -> str:
221226

222227
if "ctypes" in globals() and hasattr(ctypes, "windll"):
223228

224-
def get_win_folder_via_ctypes(csidl_name: str) -> str:
229+
class GUID(ctypes.Structure):
230+
"""
231+
`
232+
The GUID structure from Windows's guiddef.h header
233+
<https://learn.microsoft.com/en-us/windows/win32/api/guiddef/ns-guiddef-guid>`_.
234+
"""
235+
236+
Data4Type = ctypes.c_ubyte * 8
237+
238+
_fields_ = (
239+
("Data1", ctypes.c_ulong),
240+
("Data2", ctypes.c_ushort),
241+
("Data3", ctypes.c_ushort),
242+
("Data4", Data4Type),
243+
)
244+
245+
def __init__(self, guid_string: str) -> None:
246+
digit_groups = guid_string.split("-")
247+
expected_digit_groups = 5
248+
if len(digit_groups) != expected_digit_groups:
249+
msg = f"The guid_string, {guid_string!r}, does not contain {expected_digit_groups} groups of digits."
250+
raise ValueError(msg)
251+
for digit_group, expected_length in zip(digit_groups, (8, 4, 4, 4, 12)):
252+
if len(digit_group) != expected_length:
253+
msg = (
254+
f"The digit group, {digit_group!r}, in the guid_string, {guid_string!r}, was the wrong length. "
255+
f"It should have been {expected_length} digits long."
256+
)
257+
raise ValueError(msg)
258+
data_4_as_bytes = bytes.fromhex(digit_groups[3]) + bytes.fromhex(digit_groups[4])
259+
260+
super().__init__(
261+
int(digit_groups[0], base=16),
262+
int(digit_groups[1], base=16),
263+
int(digit_groups[2], base=16),
264+
self.Data4Type(*(eight_bit_int for eight_bit_int in data_4_as_bytes)),
265+
)
266+
267+
def __repr__(self) -> str:
268+
guid_string = f"{self.Data1:08X}-{self.Data2:04X}-{self.Data3:04X}-"
269+
for i in range(len(self.Data4)):
270+
guid_string += f"{self.Data4[i]:02X}"
271+
if i == 1:
272+
guid_string += "-"
273+
return f"{type(self).__qualname__}({guid_string!r})"
274+
275+
def get_win_folder_via_ctypes(folderid_name: str) -> str: # noqa: C901, PLR0912
225276
"""Get folder with ctypes."""
226-
# There is no 'CSIDL_DOWNLOADS'.
227-
# Use 'CSIDL_PROFILE' (40) and append the default folder 'Downloads' instead.
228277
# https://learn.microsoft.com/en-us/windows/win32/shell/knownfolderid
229-
csidl_const = {
230-
"CSIDL_APPDATA": 26,
231-
"CSIDL_COMMON_APPDATA": 35,
232-
"CSIDL_LOCAL_APPDATA": 28,
233-
"CSIDL_PERSONAL": 5,
234-
"CSIDL_MYPICTURES": 39,
235-
"CSIDL_MYVIDEO": 14,
236-
"CSIDL_MYMUSIC": 13,
237-
"CSIDL_DOWNLOADS": 40,
238-
"CSIDL_DESKTOPDIRECTORY": 16,
239-
}.get(csidl_name)
240-
if csidl_const is None:
241-
msg = f"Unknown CSIDL name: {csidl_name}"
278+
if folderid_name == "FOLDERID_RoamingAppData":
279+
folderid_const = GUID("3EB685DB-65F9-4CF6-A03A-E3EF65729F3D")
280+
elif folderid_name == "FOLDERID_ProgramData":
281+
folderid_const = GUID("62AB5D82-FDC1-4DC3-A9DD-070D1D495D97")
282+
elif folderid_name == "FOLDERID_LocalAppData":
283+
folderid_const = GUID("F1B32785-6FBA-4FCF-9D55-7B8E7F157091")
284+
elif folderid_name == "FOLDERID_Documents":
285+
folderid_const = GUID("FDD39AD0-238F-46AF-ADB4-6C85480369C7")
286+
elif folderid_name == "FOLDERID_Pictures":
287+
folderid_const = GUID("33E28130-4E1E-4676-835A-98395C3BC3BB")
288+
elif folderid_name == "FOLDERID_Videos":
289+
folderid_const = GUID("18989B1D-99B5-455B-841C-AB7C74E4DDFC")
290+
elif folderid_name == "FOLDERID_Music":
291+
folderid_const = GUID("4BD8D571-6D19-48D3-BE97-422220080E43")
292+
elif folderid_name == "FOLDERID_Downloads":
293+
folderid_const = GUID(FOLDERID_Downloads_guid_string)
294+
elif folderid_name == "FOLDERID_Desktop":
295+
folderid_const = GUID("B4BFCC3A-DB2C-424C-B029-7FE99A87C641")
296+
else:
297+
msg = f"Unknown FOLDERID name: {folderid_name}"
242298
raise ValueError(msg)
299+
# https://learn.microsoft.com/en-us/windows/win32/api/shlobj_core/ne-shlobj_core-known_folder_flag
300+
kf_flag_default = 0
301+
# https://learn.microsoft.com/en-us/windows/win32/seccrypto/common-hresult-values
302+
s_ok = 0
243303

244-
buf = ctypes.create_unicode_buffer(1024)
304+
pointer_to_pointer_to_wchars = ctypes.pointer(ctypes.c_wchar_p())
245305
windll = getattr(ctypes, "windll") # noqa: B009 # using getattr to avoid false positive with mypy type checker
246-
windll.shell32.SHGetFolderPathW(None, csidl_const, None, 0, buf)
306+
error_code = windll.shell32.SHGetKnownFolderPath(
307+
ctypes.pointer(folderid_const), kf_flag_default, None, pointer_to_pointer_to_wchars
308+
)
309+
return_value = pointer_to_pointer_to_wchars.contents.value
310+
# The documentation for SHGetKnownFolderPath() says that this needs to be freed using CoTaskMemFree():
311+
# https://learn.microsoft.com/en-us/windows/win32/api/shlobj_core/nf-shlobj_core-shgetknownfolderpath#parameters
312+
windll.ole32.CoTaskMemFree(pointer_to_pointer_to_wchars.contents)
313+
# Make sure that we don't accidentally use the memory now that we've freed it.
314+
del pointer_to_pointer_to_wchars
315+
if error_code != s_ok:
316+
# I'm using :08X as the format here because that's the format that the official documentation for HRESULT
317+
# uses: https://learn.microsoft.com/en-us/windows/win32/seccrypto/common-hresult-values
318+
msg = f"SHGetKnownFolderPath() failed with this error code: 0x{error_code:08X}"
319+
raise RuntimeError(msg)
320+
if return_value is None:
321+
msg = "SHGetKnownFolderPath() succeeded, but it gave us a null pointer. This should never happen."
322+
raise RuntimeError(msg)
247323

248324
# Downgrade to short path name if it has high-bit chars.
249-
if any(ord(c) > 255 for c in buf): # noqa: PLR2004
250-
buf2 = ctypes.create_unicode_buffer(1024)
251-
if windll.kernel32.GetShortPathNameW(buf.value, buf2, 1024):
252-
buf = buf2
253-
254-
if csidl_name == "CSIDL_DOWNLOADS":
255-
return os.path.join(buf.value, "Downloads") # noqa: PTH118
325+
if any(ord(c) > 255 for c in return_value): # noqa: PLR2004
326+
buf = ctypes.create_unicode_buffer(len(return_value))
327+
if windll.kernel32.GetShortPathNameW(return_value, buf, len(buf)):
328+
return_value = buf.value
256329

257-
return buf.value
330+
return return_value
258331

259332

260333
def _pick_get_win_folder() -> Callable[[str], str]:

0 commit comments

Comments
 (0)