From 6256ba8a58f09f3c08f78ba20684a6ae04f54b65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 13 Feb 2026 15:50:49 -0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(windows):=20add=20PLATFORMDIRS?= =?UTF-8?q?=5F*=20env=20var=20overrides?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Windows the folder resolution (ctypes/registry) provides no way for users to redirect directories at runtime. This is needed when large data (ML models, package caches) should live on a different drive without changing the system-wide APPDATA/LOCALAPPDATA variables. Each CSIDL folder can now be overridden via a PLATFORMDIRS_ prefixed environment variable (e.g. PLATFORMDIRS_LOCAL_APPDATA), checked before falling back to the existing resolution. The lru_cache on get_win_folder is removed so overrides are picked up dynamically. Closes #347 --- docs/platforms.rst | 50 +++++++++++++++++++++++++++++++++++++ src/platformdirs/windows.py | 17 +++++++++++-- tests/test_windows.py | 40 +++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 2 deletions(-) diff --git a/docs/platforms.rst b/docs/platforms.rst index b9e9c83..b13caaa 100644 --- a/docs/platforms.rst +++ b/docs/platforms.rst @@ -313,6 +313,56 @@ Key behaviors: which syncs across machines in a Windows domain - **OPINION**: ``user_cache_dir`` appends ``\Cache``, ``user_log_dir`` appends ``\Logs`` +Environment variable overrides +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Unlike Linux/macOS where ``XDG_*`` variables are a platform standard, Windows has no built-in +convention for overriding folder locations at the application level. To fill this gap, +``platformdirs`` checks ``PLATFORMDIRS_*`` environment variables before querying the Shell Folder +APIs. This is useful when large data (ML models, package caches) should live on a different drive +without changing the system-wide ``APPDATA`` / ``LOCALAPPDATA`` variables that other applications +rely on. + +The override variable name is ``PLATFORMDIRS_`` followed by the CSIDL suffix: + +.. list-table:: + :widths: 40 60 + :header-rows: 1 + + * - Environment variable + - Overrides + * - ``PLATFORMDIRS_APPDATA`` + - Roaming user data (``AppData\Roaming``) + * - ``PLATFORMDIRS_LOCAL_APPDATA`` + - Local user data, config, cache, state (``AppData\Local``) + * - ``PLATFORMDIRS_COMMON_APPDATA`` + - Site-wide data, config, cache, state (``ProgramData``) + * - ``PLATFORMDIRS_PERSONAL`` + - Documents + * - ``PLATFORMDIRS_DOWNLOADS`` + - Downloads + * - ``PLATFORMDIRS_MYPICTURES`` + - Pictures + * - ``PLATFORMDIRS_MYVIDEO`` + - Videos + * - ``PLATFORMDIRS_MYMUSIC`` + - Music + * - ``PLATFORMDIRS_DESKTOPDIRECTORY`` + - Desktop + +Example — redirect cache to a separate drive: + +.. code-block:: python + + import os + os.environ["PLATFORMDIRS_LOCAL_APPDATA"] = r"X:\appdata" + + import platformdirs + print(platformdirs.user_cache_dir("MyApp", "Acme")) + # X:\appdata\Acme\MyApp\Cache + +Empty or whitespace-only values are ignored and the normal resolution applies. + .. note:: **Windows Store Python (MSIX)** Python installed from the Microsoft Store runs in a sandboxed (AppContainer) environment. diff --git a/src/platformdirs/windows.py b/src/platformdirs/windows.py index c3d17d5..675b665 100644 --- a/src/platformdirs/windows.py +++ b/src/platformdirs/windows.py @@ -4,7 +4,6 @@ import os import sys -from functools import lru_cache from typing import TYPE_CHECKING, Final from .api import PlatformDirsABC @@ -320,7 +319,21 @@ def _pick_get_win_folder() -> Callable[[str], str]: return get_win_folder_from_registry -get_win_folder = lru_cache(maxsize=None)(_pick_get_win_folder()) +_resolve_win_folder = _pick_get_win_folder() + + +def get_win_folder(csidl_name: str) -> str: + """ + Get a Windows folder path, checking for ``PLATFORMDIRS_*`` environment variable overrides first. + + For example, ``CSIDL_LOCAL_APPDATA`` can be overridden by setting ``PLATFORMDIRS_LOCAL_APPDATA``. + + """ + env_var = f"PLATFORMDIRS_{csidl_name.removeprefix('CSIDL_')}" + if override := os.environ.get(env_var, "").strip(): + return override + return _resolve_win_folder(csidl_name) + __all__ = [ "Windows", diff --git a/tests/test_windows.py b/tests/test_windows.py index a9fc14a..fb13d2b 100644 --- a/tests/test_windows.py +++ b/tests/test_windows.py @@ -14,6 +14,7 @@ _KF_FLAG_DONT_VERIFY, _KNOWN_FOLDER_GUIDS, Windows, + get_win_folder, get_win_folder_from_env_vars, get_win_folder_if_csidl_name_not_env_var, ) @@ -298,3 +299,42 @@ def test_pick_get_win_folder_ctypes(mocker: MockerFixture) -> None: finally: if sys.platform != "win32": _cleanup_ctypes_mocks() + + +@pytest.mark.parametrize( + ("csidl_name", "env_suffix"), + [ + pytest.param("CSIDL_APPDATA", "APPDATA", id="appdata"), + pytest.param("CSIDL_LOCAL_APPDATA", "LOCAL_APPDATA", id="local_appdata"), + pytest.param("CSIDL_COMMON_APPDATA", "COMMON_APPDATA", id="common_appdata"), + pytest.param("CSIDL_PERSONAL", "PERSONAL", id="personal"), + pytest.param("CSIDL_DOWNLOADS", "DOWNLOADS", id="downloads"), + pytest.param("CSIDL_MYPICTURES", "MYPICTURES", id="mypictures"), + pytest.param("CSIDL_MYVIDEO", "MYVIDEO", id="myvideo"), + pytest.param("CSIDL_MYMUSIC", "MYMUSIC", id="mymusic"), + pytest.param("CSIDL_DESKTOPDIRECTORY", "DESKTOPDIRECTORY", id="desktop"), + ], +) +def test_get_win_folder_override(monkeypatch: pytest.MonkeyPatch, csidl_name: str, env_suffix: str) -> None: + override_path = r"X:\custom\override" + monkeypatch.setattr("platformdirs.windows._resolve_win_folder", lambda _csidl: _WIN_FOLDERS[_csidl]) + monkeypatch.setenv(f"PLATFORMDIRS_{env_suffix}", override_path) + assert get_win_folder(csidl_name) == override_path + + +def test_get_win_folder_override_whitespace_only_ignored(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("platformdirs.windows._resolve_win_folder", lambda csidl: _WIN_FOLDERS[csidl]) + monkeypatch.setenv("PLATFORMDIRS_LOCAL_APPDATA", " ") + assert get_win_folder("CSIDL_LOCAL_APPDATA") == _WIN_FOLDERS["CSIDL_LOCAL_APPDATA"] + + +def test_get_win_folder_override_not_set_falls_back(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("platformdirs.windows._resolve_win_folder", lambda csidl: _WIN_FOLDERS[csidl]) + monkeypatch.delenv("PLATFORMDIRS_LOCAL_APPDATA", raising=False) + assert get_win_folder("CSIDL_LOCAL_APPDATA") == _WIN_FOLDERS["CSIDL_LOCAL_APPDATA"] + + +def test_get_win_folder_override_strips_whitespace(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("platformdirs.windows._resolve_win_folder", lambda csidl: _WIN_FOLDERS[csidl]) + monkeypatch.setenv("PLATFORMDIRS_LOCAL_APPDATA", " X:\\custom ") + assert get_win_folder("CSIDL_LOCAL_APPDATA") == r"X:\custom"