Skip to content

Use SHGetKnownFolderPath() instead of SHGetFolderPathW() #350

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
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
271 changes: 172 additions & 99 deletions src/platformdirs/windows.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@
if TYPE_CHECKING:
from collections.abc import Callable

try: # noqa: SIM105
import ctypes
except ImportError:
pass
try: # noqa: SIM105
import winreg
except ImportError:
pass
Comment on lines +15 to +22
Copy link
Collaborator

@ofek ofek Apr 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Upon taking a closer look, let's do what the rest of the file does and lazily import when necessary.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rest of the file does not do lazy importing at the moment because this pull request removes all lazy importing from windows.py. Are you saying that I should drop the “Simplify logic for choosing get_win_folder’s value” commit?



class Windows(PlatformDirsABC):
"""
Expand All @@ -31,7 +40,7 @@ def user_data_dir(self) -> str:
``%USERPROFILE%\\AppData\\Local\\$appauthor\\$appname`` (not roaming) or
``%USERPROFILE%\\AppData\\Roaming\\$appauthor\\$appname`` (roaming)
"""
const = "CSIDL_APPDATA" if self.roaming else "CSIDL_LOCAL_APPDATA"
const = "FOLDERID_RoamingAppData" if self.roaming else "FOLDERID_LocalAppData"
path = os.path.normpath(get_win_folder(const))
return self._append_parts(path)

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

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

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

@property
Expand All @@ -98,40 +107,40 @@ def user_log_dir(self) -> str:
@property
def user_documents_dir(self) -> str:
""":return: documents directory tied to the user e.g. ``%USERPROFILE%\\Documents``"""
return os.path.normpath(get_win_folder("CSIDL_PERSONAL"))
return os.path.normpath(get_win_folder("FOLDERID_Documents"))

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

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

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

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

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

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

@property
Expand All @@ -140,19 +149,19 @@ def site_runtime_dir(self) -> str:
return self.user_runtime_dir


def get_win_folder_from_env_vars(csidl_name: str) -> str:
def get_win_folder_from_env_vars(folderid_name: str) -> str:
"""Get folder from environment variables."""
result = get_win_folder_if_csidl_name_not_env_var(csidl_name)
result = get_win_folder_if_folderid_name_not_env_var(folderid_name)
if result is not None:
return result

env_var_name = {
"CSIDL_APPDATA": "APPDATA",
"CSIDL_COMMON_APPDATA": "ALLUSERSPROFILE",
"CSIDL_LOCAL_APPDATA": "LOCALAPPDATA",
}.get(csidl_name)
"FOLDERID_RoamingAppData": "APPDATA",
"FOLDERID_ProgramData": "ALLUSERSPROFILE",
"FOLDERID_LocalAppData": "LOCALAPPDATA",
}.get(folderid_name)
if env_var_name is None:
msg = f"Unknown CSIDL name: {csidl_name}"
msg = f"Unknown FOLDERID name: {folderid_name}"
raise ValueError(msg)
result = os.environ.get(env_var_name)
if result is None:
Expand All @@ -161,108 +170,172 @@ def get_win_folder_from_env_vars(csidl_name: str) -> str:
return result


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

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

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

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

if csidl_name == "CSIDL_MYMUSIC":
if folderid_name == "FOLDERID_Music":
return os.path.join(os.path.normpath(os.environ["USERPROFILE"]), "Music") # noqa: PTH118
return None


def get_win_folder_from_registry(csidl_name: str) -> str:
"""
Get folder from the registry.

This is a fallback technique at best. I'm not sure if using the registry for these guarantees us the correct answer
for all CSIDL_* names.
FOLDERID_Downloads_guid_string = "374DE290-123F-4565-9164-39C4925E467B"

"""
shell_folder_name = {
"CSIDL_APPDATA": "AppData",
"CSIDL_COMMON_APPDATA": "Common AppData",
"CSIDL_LOCAL_APPDATA": "Local AppData",
"CSIDL_PERSONAL": "Personal",
"CSIDL_DOWNLOADS": "{374DE290-123F-4565-9164-39C4925E467B}",
"CSIDL_MYPICTURES": "My Pictures",
"CSIDL_MYVIDEO": "My Video",
"CSIDL_MYMUSIC": "My Music",
}.get(csidl_name)
if shell_folder_name is None:
msg = f"Unknown CSIDL name: {csidl_name}"
raise ValueError(msg)
if sys.platform != "win32": # only needed for mypy type checker to know that this code runs only on Windows
raise NotImplementedError
import winreg # noqa: PLC0415

key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Software\Microsoft\Windows\CurrentVersion\Explorer\Shell Folders")
directory, _ = winreg.QueryValueEx(key, shell_folder_name)
return str(directory)


def get_win_folder_via_ctypes(csidl_name: str) -> str:
"""Get folder with ctypes."""
# There is no 'CSIDL_DOWNLOADS'.
# Use 'CSIDL_PROFILE' (40) and append the default folder 'Downloads' instead.
# https://learn.microsoft.com/en-us/windows/win32/shell/knownfolderid

import ctypes # noqa: PLC0415

csidl_const = {
"CSIDL_APPDATA": 26,
"CSIDL_COMMON_APPDATA": 35,
"CSIDL_LOCAL_APPDATA": 28,
"CSIDL_PERSONAL": 5,
"CSIDL_MYPICTURES": 39,
"CSIDL_MYVIDEO": 14,
"CSIDL_MYMUSIC": 13,
"CSIDL_DOWNLOADS": 40,
"CSIDL_DESKTOPDIRECTORY": 16,
}.get(csidl_name)
if csidl_const is None:
msg = f"Unknown CSIDL name: {csidl_name}"
raise ValueError(msg)
if "winreg" in globals():

buf = ctypes.create_unicode_buffer(1024)
windll = getattr(ctypes, "windll") # noqa: B009 # using getattr to avoid false positive with mypy type checker
windll.shell32.SHGetFolderPathW(None, csidl_const, None, 0, buf)
def get_win_folder_from_registry(folderid_name: str) -> str:
"""
Get folder from the registry.

# Downgrade to short path name if it has high-bit chars.
if any(ord(c) > 255 for c in buf): # noqa: PLR2004
buf2 = ctypes.create_unicode_buffer(1024)
if windll.kernel32.GetShortPathNameW(buf.value, buf2, 1024):
buf = buf2
This is a fallback technique at best. I'm not sure if using the registry for these guarantees us the correct
answer for all FOLDERID_* names.

if csidl_name == "CSIDL_DOWNLOADS":
return os.path.join(buf.value, "Downloads") # noqa: PTH118
"""
shell_folder_name = {
"FOLDERID_RoamingAppData": "AppData",
"FOLDERID_ProgramData": "Common AppData",
"FOLDERID_LocalAppData": "Local AppData",
"FOLDERID_Documents": "Personal",
"FOLDERID_Downloads": "{" + FOLDERID_Downloads_guid_string + "}",
"FOLDERID_Pictures": "My Pictures",
"FOLDERID_Videos": "My Video",
"FOLDERID_Music": "My Music",
}.get(folderid_name)
if shell_folder_name is None:
msg = f"Unknown FOLDERID name: {folderid_name}"
raise ValueError(msg)
if sys.platform != "win32": # only needed for mypy type checker to know that this code runs only on Windows
raise NotImplementedError

key = winreg.OpenKey(
winreg.HKEY_CURRENT_USER, r"Software\Microsoft\Windows\CurrentVersion\Explorer\Shell Folders"
)
directory, _ = winreg.QueryValueEx(key, shell_folder_name)
return str(directory)


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

class GUID(ctypes.Structure):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we avoid defining this at import time and postpone the logic until necessary?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, like, by defining the GUID class inside of the get_win_folder_via_ctypes function?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, I don't think we need this class as I mentioned elsewhere the checks aren't necessary nor do we require a __repr__ that would be different than the string UUID. We can just put the necessary logic where it is used. I believe it's just used once but if not then you can create a helper function.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based on my research, I don’t think that it’s possible to make this pull request work without having a GUID class. When I did my research, the first thing that I had to look at was Windows’s SHGetKnownFolderPath() function.

Sidenote

If you want to take a look at these header files yourself, then you need to install the Windows SDK. Here’s how I made sure that the Windows SDK was installed:

  1. Make sure that Visual Studio Build Tools 2022 is installed by running this command in an administrator Command Prompt:

    winget install --exact --id=Microsoft.VisualStudio.2022.BuildTools
  2. Make sure that the “Desktop development with C++” workload is installed by running this command in an administrator Command Prompt:

    "%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\setup.exe" modify^
      --passive^
      --channelId VisualStudio.17.Release^
      --productId Microsoft.VisualStudio.Product.BuildTools^
      --add Microsoft.VisualStudio.Workload.VCTools;includeRecommended

The prototype for the SHGetKnownFolderPath() function is in the %ProgramFiles(x86)%\Windows Kits\10\Include\10.0.22621.0\um\ShlObj_core.h header file:

STDAPI SHGetKnownFolderPath(_In_ REFKNOWNFOLDERID rfid,
                            _In_ DWORD /* KNOWN_FOLDER_FLAG */ dwFlags,
                            _In_opt_ HANDLE hToken,
                            _Outptr_ PWSTR *ppszPath); // free *ppszPath with CoTaskMemFree

The rfid parameter’s type is REFKNOWNFOLDERID. The REFKNOWNFOLDERID type is defined in the %ProgramFiles(x86)%\Windows Kits\10\Include\10.0.22621.0\um\shtypes.h header file:

#ifdef __cplusplus
#define REFKNOWNFOLDERID const KNOWNFOLDERID &
#else // !__cplusplus
#define REFKNOWNFOLDERID const KNOWNFOLDERID * __MIDL_CONST
#endif // __cplusplus

This means that REFKNOWNFOLDERID is going to either be a reference or pointer to a KNOWNFOLDERID value (as long as it’s not a null pointer). The KNOWNFOLDERID type is defined in the %ProgramFiles(x86)%\Windows Kits\10\Include\10.0.22621.0\um\shtypes.h header file:

typedef GUID KNOWNFOLDERID;

This means that a KNOWNFOLDERID value is the same thing as a GUID value. The GUID type is defined in the %ProgramFiles(x86)%\Windows Kits\10\Include\10.0.22621.0\shared\guiddef.h header file:

#if defined(__midl)
typedef struct {
    unsigned long  Data1;
    unsigned short Data2;
    unsigned short Data3;
    byte           Data4[ 8 ];
} GUID;
#else
typedef struct _GUID {
    unsigned long  Data1;
    unsigned short Data2;
    unsigned short Data3;
    unsigned char  Data4[ 8 ];
} GUID;
#endif

This means that GUID is a type of structure. So, in order to call the SHGetKnownFolderPath() function, we need a REFKNOWNFOLDERID value which is actually a reference or pointer to a structure value. Here’s what Python’s ctypes module’s documentation has to say about structures:

Structures and unions must derive from the Structure and Union base classes which are defined in the ctypes module.

The ctypes module’s documentation also says:

class ctypes.Structure(*args, **kw)

Abstract base class for structures in native byte order.

Concrete structure and union types must be created by subclassing one of these types, and at least define a _fields_ class variable.


Based on that research, it seems like I need to create a structure value in order to call the SHGetKnownFolderPath() function, and it seems like I need to define a class before I can create a structure value. That being said, I’m definitely not an expert in this area (this is the first time I’ve ever used the ctypes module, and this is the second time that I’ve ever written code that uses something from the Windows API directly) so there’s a good chance that I’m missing something. Do you know of any ways that I could call the SHGetKnownFolderPath() function without defining a class?

Copy link
Collaborator

@ofek ofek Apr 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't done extensive testing but the following seems to work. Save it somewhere then try running python script.py:

import ctypes

def guid_str_to_bytes(guid_str):
    parts = guid_str.split('-')
    data1 = int(parts[0], 16).to_bytes(4, 'little')
    data2 = int(parts[1], 16).to_bytes(2, 'little')
    data3 = int(parts[2], 16).to_bytes(2, 'little')
    data4 = bytes.fromhex(parts[3] + parts[4])
    return data1 + data2 + data3 + data4

def get_win_folder_via_ctypes(folderid_name: str) -> str:
    GUID_STRINGS = {
        "FOLDERID_RoamingAppData": "3EB685DB-65F9-4CF6-A03A-E3EF65729F3D",
        "FOLDERID_ProgramData": "62AB5D82-FDC1-4DC3-A9DD-070D1D495D97",
        "FOLDERID_LocalAppData": "F1B32785-6FBA-4FCF-9D55-7B8E7F157091",
        "FOLDERID_Documents": "FDD39AD0-238F-46AF-ADB4-6C85480369C7",
        "FOLDERID_Pictures": "33E28130-4E1E-4676-835A-98395C3BC3BB",
        "FOLDERID_Videos": "18989B1D-99B5-455B-841C-AB7C74E4DDFC",
        "FOLDERID_Music": "4BD8D571-6D19-48D3-BE97-422220080E43",
        "FOLDERID_Downloads": "374DE290-123F-4565-9164-39C4925E467B",
        "FOLDERID_Desktop": "B4BFCC3A-DB2C-424C-B029-7FE99A87C641",
    }
    if folderid_name not in GUID_STRINGS:
        raise ValueError(f"Unknown FOLDERID name: {folderid_name}")
    guid_str = GUID_STRINGS[folderid_name]
    folderid_bytes = guid_str_to_bytes(guid_str)
    folderid_array = (ctypes.c_char * 16).from_buffer_copy(folderid_bytes)
    kf_flag_default = 0
    s_ok = 0
    pointer_to_pointer_to_wchars = ctypes.pointer(ctypes.c_wchar_p())
    windll = ctypes.windll
    error_code = windll.shell32.SHGetKnownFolderPath(
        folderid_array,
        kf_flag_default,
        None,
        pointer_to_pointer_to_wchars
    )
    return_value = pointer_to_pointer_to_wchars.contents.value
    windll.ole32.CoTaskMemFree(pointer_to_pointer_to_wchars.contents)
    del pointer_to_pointer_to_wchars
    if error_code != s_ok:
        raise RuntimeError(f"SHGetKnownFolderPath() failed with this error code: 0x{error_code:08X}")
    if return_value is None:
        raise RuntimeError("SHGetKnownFolderPath() succeeded, but it gave us a null pointer. This should never happen.")
    if any(ord(c) > 255 for c in return_value):
        buf = ctypes.create_unicode_buffer(len(return_value))
        if windll.kernel32.GetShortPathNameW(return_value, buf, len(buf)):
            return_value = buf.value
    return return_value

if __name__ == "__main__":
    print(get_win_folder_via_ctypes("FOLDERID_Downloads"))

I asked Gemini 2.5 Pro (experimental), Grok 3 + thinking, ChatGPT o4-mini-high and Claude 3.7 + thinking. This was from Grok which in my opinion had the best answer. Claude had something very similar but stored the GUID strings as precomputed byte strings which I think might be an overoptimization for the trade-off of reduced readability (although in my own code I would do that actually).

Edit: If you want to see this is the relevant part of what Claude had. Note that if you want to do this don't copy exactly because this is still a computation, so save as raw bytes b"...":

folderid_guids = {
    "FOLDERID_RoamingAppData": bytes([0xDB, 0x85, 0xB6, 0x3E, 0xF9, 0x65, 0xF6, 0x4C, 0xA0, 0x3A, 0xE3, 0xEF, 0x65, 0x72, 0x9F, 0x3D]),
    "FOLDERID_ProgramData": bytes([0x82, 0x5D, 0xAB, 0x62, 0xC1, 0xFD, 0xC3, 0x4D, 0xA9, 0xDD, 0x07, 0x0D, 0x1D, 0x49, 0x5D, 0x97]),
    "FOLDERID_LocalAppData": bytes([0x85, 0x27, 0xB3, 0xF1, 0xBA, 0x6F, 0xCF, 0x4F, 0x9D, 0x55, 0x7B, 0x8E, 0x7F, 0x15, 0x70, 0x91]),
    "FOLDERID_Documents": bytes([0xD0, 0x9A, 0xD3, 0xFD, 0x8F, 0x23, 0xAF, 0x46, 0xAD, 0xB4, 0x6C, 0x85, 0x48, 0x03, 0x69, 0xC7]),
    "FOLDERID_Pictures": bytes([0x30, 0x81, 0xE2, 0x33, 0x1E, 0x4E, 0x76, 0x46, 0x83, 0x5A, 0x98, 0x39, 0x5C, 0x3B, 0xC3, 0xBB]),
    "FOLDERID_Videos": bytes([0x1D, 0x9B, 0x98, 0x18, 0xB5, 0x99, 0x5B, 0x45, 0x84, 0x1C, 0xAB, 0x7C, 0x74, 0xE4, 0xDD, 0xFC]),
    "FOLDERID_Music": bytes([0x71, 0xD5, 0xD8, 0x4B, 0x19, 0x6D, 0xD3, 0x48, 0xBE, 0x97, 0x42, 0x22, 0x20, 0x08, 0x0E, 0x43]),
    "FOLDERID_Downloads": bytes([0x90, 0xE2, 0x4D, 0x37, 0x3F, 0x12, 0x65, 0x45, 0x91, 0x64, 0x39, 0xC4, 0x92, 0x5E, 0x46, 0x7B]),
    "FOLDERID_Desktop": bytes([0x3A, 0xCC, 0xBF, 0xB4, 0x2C, 0xDB, 0x4C, 0x42, 0xB0, 0x29, 0x7F, 0xE9, 0x9A, 0x87, 0xC6, 0x41]),
}

if folderid_name not in folderid_guids:
    msg = f"Unknown FOLDERID name: {folderid_name}"
    raise ValueError(msg)

# Create GUID buffer
guid_buffer = ctypes.create_string_buffer(folderid_guids[folderid_name])

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’m a little bit skeptical of those solutions. According to the relevant header files, SHGetKnownFolderPath()’s rfid parameter is supposed to be a pointer to a struct. Those two possible solutions don’t set rfid to a pointer to a struct. Instead, those two examples set rfid to a pointer to a char array. Purposefully using the wrong datatype seems like the wrong thing to do here, even if the code still works fine. It especially seems like the wrong option when you consider the fact that this project explicitly opts into static type checking via mypy.

Do you think that it would be better to set rfid to a pointer to a char array here, even though that’s the wrong datatype?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a struct. It's one manually created, but it is a struct. You could do exactly the same thing with the struct module, though I assume that would be more expensive than building it manually like this. If you did that, it would end up as a sequence of bytes as well. That's all structs are.

I wouldn't worry about static type checkers. Externally, it doesn't matter - users don't care about what the internals do when type checking. For internals, type checkers can't really do much with CTypes, as it is dynamic.

A few things that do matter, though: I noticed "in native byte order" in the ctypes.Structure docs, but the solution above does little endian (the struct module supports either). Also, there are padding rules, which I think are probably fine here, due to the way the data is organized, but something to keep in mind when making a struct manually (the struct module supports this too).

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have actual measurements of the costs of these methods?

"""
`
The GUID structure from Windows's guiddef.h header
<https://learn.microsoft.com/en-us/windows/win32/api/guiddef/ns-guiddef-guid>`_.
"""

return buf.value
Data4Type = ctypes.c_ubyte * 8

_fields_ = (
("Data1", ctypes.c_ulong),
("Data2", ctypes.c_ushort),
("Data3", ctypes.c_ushort),
("Data4", Data4Type),
)

def __init__(self, guid_string: str) -> None:
digit_groups = guid_string.split("-")
expected_digit_groups = 5
if len(digit_groups) != expected_digit_groups:
msg = f"The guid_string, {guid_string!r}, does not contain {expected_digit_groups} groups of digits."
raise ValueError(msg)
for digit_group, expected_length in zip(digit_groups, (8, 4, 4, 4, 12)):
if len(digit_group) != expected_length:
msg = (
f"The digit group, {digit_group!r}, in the guid_string, {guid_string!r}, was the wrong length. "
f"It should have been {expected_length} digits long."
)
raise ValueError(msg)
Comment on lines +247 to +257
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Although I appreciate the thoroughness of the edge case handling, these checks are superfluous because we control the inputs. Let's remove these for better performance.

data_4_as_bytes = bytes.fromhex(digit_groups[3]) + bytes.fromhex(digit_groups[4])

super().__init__(
int(digit_groups[0], base=16),
int(digit_groups[1], base=16),
int(digit_groups[2], base=16),
self.Data4Type(*(eight_bit_int for eight_bit_int in data_4_as_bytes)),
)

def __repr__(self) -> str:
guid_string = f"{self.Data1:08X}-{self.Data2:04X}-{self.Data3:04X}-"
for i in range(len(self.Data4)):
guid_string += f"{self.Data4[i]:02X}"
if i == 1:
guid_string += "-"
return f"{type(self).__qualname__}({guid_string!r})"
Comment on lines +267 to +273
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would only be used during debugging so I don't see the use (we should remove this class as I'm about to mention).


def get_win_folder_via_ctypes(folderid_name: str) -> str: # noqa: C901, PLR0912
"""Get folder with ctypes."""
# https://learn.microsoft.com/en-us/windows/win32/shell/knownfolderid
if folderid_name == "FOLDERID_RoamingAppData":
folderid_const = GUID("3EB685DB-65F9-4CF6-A03A-E3EF65729F3D")
elif folderid_name == "FOLDERID_ProgramData":
folderid_const = GUID("62AB5D82-FDC1-4DC3-A9DD-070D1D495D97")
elif folderid_name == "FOLDERID_LocalAppData":
folderid_const = GUID("F1B32785-6FBA-4FCF-9D55-7B8E7F157091")
elif folderid_name == "FOLDERID_Documents":
folderid_const = GUID("FDD39AD0-238F-46AF-ADB4-6C85480369C7")
elif folderid_name == "FOLDERID_Pictures":
folderid_const = GUID("33E28130-4E1E-4676-835A-98395C3BC3BB")
elif folderid_name == "FOLDERID_Videos":
folderid_const = GUID("18989B1D-99B5-455B-841C-AB7C74E4DDFC")
elif folderid_name == "FOLDERID_Music":
folderid_const = GUID("4BD8D571-6D19-48D3-BE97-422220080E43")
elif folderid_name == "FOLDERID_Downloads":
folderid_const = GUID(FOLDERID_Downloads_guid_string)
elif folderid_name == "FOLDERID_Desktop":
folderid_const = GUID("B4BFCC3A-DB2C-424C-B029-7FE99A87C641")
else:
msg = f"Unknown FOLDERID name: {folderid_name}"
raise ValueError(msg)
# https://learn.microsoft.com/en-us/windows/win32/api/shlobj_core/ne-shlobj_core-known_folder_flag
kf_flag_default = 0
# https://learn.microsoft.com/en-us/windows/win32/seccrypto/common-hresult-values
s_ok = 0

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

# Downgrade to short path name if it has high-bit chars.
if any(ord(c) > 255 for c in return_value): # noqa: PLR2004
buf = ctypes.create_unicode_buffer(len(return_value))
if windll.kernel32.GetShortPathNameW(return_value, buf, len(buf)):
return_value = buf.value

return return_value


def _pick_get_win_folder() -> Callable[[str], str]:
try:
import ctypes # noqa: PLC0415
except ImportError:
pass
else:
if hasattr(ctypes, "windll"):
return get_win_folder_via_ctypes
try:
import winreg # noqa: PLC0415, F401
except ImportError:
return get_win_folder_from_env_vars
else:
if "get_win_folder_via_ctypes" in globals():
return get_win_folder_via_ctypes
if "get_win_folder_from_registry" in globals():
return get_win_folder_from_registry
return get_win_folder_from_env_vars


get_win_folder = lru_cache(maxsize=None)(_pick_get_win_folder())
Expand Down