From 366cafc26216b13f7d5dcdb8bf03cf335606235e Mon Sep 17 00:00:00 2001 From: 9Kun <91123978+9Kun@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:26:59 +0800 Subject: [PATCH 1/3] fix: preserve Windows site applications paths with semicolons --- .../+windows-applications-path.bugfix.rst | 2 ++ docs/platforms.rst | 3 +++ src/platformdirs/windows.py | 5 +++++ tests/test_windows.py | 17 +++++++++++++++++ 4 files changed, 27 insertions(+) create mode 100644 docs/changelog/+windows-applications-path.bugfix.rst diff --git a/docs/changelog/+windows-applications-path.bugfix.rst b/docs/changelog/+windows-applications-path.bugfix.rst new file mode 100644 index 00000000..39979555 --- /dev/null +++ b/docs/changelog/+windows-applications-path.bugfix.rst @@ -0,0 +1,2 @@ +Preserve semicolons in Windows ``site_applications_path`` when ``multipath=True`` instead of truncating the directory at +the first semicolon. Windows always returns a single applications directory. diff --git a/docs/platforms.rst b/docs/platforms.rst index f09e831a..bfae1e9e 100644 --- a/docs/platforms.rst +++ b/docs/platforms.rst @@ -886,6 +886,9 @@ See also: :ref:`api:Shared applications directory` ``C:\ProgramData\Microsoft\Windows\Start Menu\Programs`` + ``site_applications_path`` returns this single directory unchanged, even with ``multipath=True``. Semicolons + in directory names are preserved. + .. tab-item:: Android :sync: android diff --git a/src/platformdirs/windows.py b/src/platformdirs/windows.py index e587f943..baaacfca 100644 --- a/src/platformdirs/windows.py +++ b/src/platformdirs/windows.py @@ -181,6 +181,11 @@ def site_applications_dir(self) -> str: r"""Applications directory shared by users, e.g. ``C:\ProgramData\Microsoft\Windows\Start Menu\Programs``.""" return os.path.normpath(get_win_folder("CSIDL_COMMON_PROGRAMS")) + @property + def site_applications_path(self) -> Path: + """Applications path shared by users. The ``multipath`` option is ignored on Windows.""" + return Path(self.site_applications_dir) + @property def user_runtime_dir(self) -> str: r"""Runtime directory tied to the user, e.g. ``%USERPROFILE%\AppData\Local\Temp\$appauthor\$appname``.""" diff --git a/tests/test_windows.py b/tests/test_windows.py index f66d7645..73962c0c 100644 --- a/tests/test_windows.py +++ b/tests/test_windows.py @@ -114,6 +114,23 @@ def test_windows(params: dict[str, Any], func: str) -> None: assert result == expected_map[func] +@pytest.mark.parametrize("directory", ["Programs", "Programs;Shared"]) +@pytest.mark.parametrize("multipath", [False, True]) +@pytest.mark.parametrize("api", ["instance", "function"]) +def test_site_applications_path_preserves_directory( + tmp_path: Path, mocker: MockerFixture, directory: str, multipath: bool, api: str +) -> None: + expected = tmp_path / directory + mocker.patch.dict(_WIN_FOLDERS, {"CSIDL_COMMON_PROGRAMS": str(expected)}) + mocker.patch("platformdirs.PlatformDirs", Windows) + dirs = Windows(multipath=multipath) + assert dirs.site_applications_dir == str(expected) + result = ( + dirs.site_applications_path if api == "instance" else platformdirs.site_applications_path(multipath=multipath) + ) + assert result == expected + + @pytest.mark.parametrize( ("func", "csidl_name"), [ From 7c49d9fb7847fd4f1cb3841ff58633508f5c3d8b Mon Sep 17 00:00:00 2001 From: 9Kun <91123978+9Kun@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:30:04 +0800 Subject: [PATCH 2/3] docs: link changelog fragment to PR 604 --- .../{+windows-applications-path.bugfix.rst => 604.bugfix.rst} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/changelog/{+windows-applications-path.bugfix.rst => 604.bugfix.rst} (100%) diff --git a/docs/changelog/+windows-applications-path.bugfix.rst b/docs/changelog/604.bugfix.rst similarity index 100% rename from docs/changelog/+windows-applications-path.bugfix.rst rename to docs/changelog/604.bugfix.rst From d2b388c5893a31bea5fb6c2c69ac9178dbbed7ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Tue, 29 Sep 2026 09:27:56 -0700 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=90=9B=20fix(api):=20build=20site=20p?= =?UTF-8?q?aths=20from=20site=20lists?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The base site_applications_path split site_applications_dir on os.pathsep under multipath. Outside Unix and macOS that value is one path, so a name holding the separator got cut, and Android with appname="a:b" gave files/a. The Windows override in this PR left Android and iOS broken. Each site kind that can span several directories now has a list in the base class, defaulting to its single *_dir, and the base *_path and iter_*_dirs read that list. Unix and macOS supply their search paths and drop their own copies, so no accessor parses a joined string. isolated_dirs restores the base lists, which then follow the redirected dirs for every kind. --- docs/changelog/604.bugfix.rst | 3 +-- docs/platforms.rst | 3 --- src/platformdirs/api.py | 36 ++++++++++++++---------------- src/platformdirs/macos.py | 42 +---------------------------------- src/platformdirs/testing.py | 13 ++++------- src/platformdirs/unix.py | 16 ------------- src/platformdirs/windows.py | 5 ----- tests/test_android.py | 8 +++++++ tests/test_macos.py | 2 +- tests/test_windows.py | 19 ++++------------ 10 files changed, 36 insertions(+), 111 deletions(-) diff --git a/docs/changelog/604.bugfix.rst b/docs/changelog/604.bugfix.rst index 39979555..2e2acdea 100644 --- a/docs/changelog/604.bugfix.rst +++ b/docs/changelog/604.bugfix.rst @@ -1,2 +1 @@ -Preserve semicolons in Windows ``site_applications_path`` when ``multipath=True`` instead of truncating the directory at -the first semicolon. Windows always returns a single applications directory. +Keep ``os.pathsep`` in ``site_applications_path`` under ``multipath=True`` on platforms with one applications directory. diff --git a/docs/platforms.rst b/docs/platforms.rst index bfae1e9e..f09e831a 100644 --- a/docs/platforms.rst +++ b/docs/platforms.rst @@ -886,9 +886,6 @@ See also: :ref:`api:Shared applications directory` ``C:\ProgramData\Microsoft\Windows\Start Menu\Programs`` - ``site_applications_path`` returns this single directory unchanged, even with ``multipath=True``. Semicolons - in directory names are preserved. - .. tab-item:: Android :sync: android diff --git a/src/platformdirs/api.py b/src/platformdirs/api.py index 454ac994..632a6738 100644 --- a/src/platformdirs/api.py +++ b/src/platformdirs/api.py @@ -177,12 +177,6 @@ def _first_site_dir_as_path(self, dirs: list[str]) -> Path: self._optionally_create_directory(path, private=False) return Path(path) - def _first_item_as_path_if_multipath(self, directory: str) -> Path: - if self.multipath: - # If multipath is True, the first path is returned. - directory = directory.partition(os.pathsep)[0] - return Path(directory) - @property @abstractmethod def user_data_dir(self) -> str: @@ -195,7 +189,7 @@ def site_data_dir(self) -> str: @property def _site_data_dirs(self) -> list[str]: - raise NotImplementedError + return [self.site_data_dir] @property @abstractmethod @@ -209,7 +203,7 @@ def site_config_dir(self) -> str: @property def _site_config_dirs(self) -> list[str]: - raise NotImplementedError + return [self.site_config_dir] @property @abstractmethod @@ -221,6 +215,10 @@ def user_cache_dir(self) -> str: def site_cache_dir(self) -> str: """Cache directory shared by users.""" + @property + def _site_cache_dirs(self) -> list[str]: + return [self.site_cache_dir] + @property @abstractmethod def user_state_dir(self) -> str: @@ -318,7 +316,7 @@ def site_applications_dir(self) -> str: @property def _site_applications_dirs(self) -> list[str]: - raise NotImplementedError + return [self.site_applications_dir] @property @abstractmethod @@ -337,8 +335,8 @@ def user_data_path(self) -> Path: @property def site_data_path(self) -> Path: - """Data path shared by users.""" - return Path(self.site_data_dir) + """Data path shared by users. Only return the first item, even if ``multipath`` is set to ``True``.""" + return self._first_site_dir_as_path(self._site_data_dirs) @property def user_config_path(self) -> Path: @@ -347,8 +345,8 @@ def user_config_path(self) -> Path: @property def site_config_path(self) -> Path: - """Config path shared by users.""" - return Path(self.site_config_dir) + """Config path shared by users. Only return the first item, even if ``multipath`` is set to ``True``.""" + return self._first_site_dir_as_path(self._site_config_dirs) @property def user_cache_path(self) -> Path: @@ -357,8 +355,8 @@ def user_cache_path(self) -> Path: @property def site_cache_path(self) -> Path: - """Cache path shared by users.""" - return Path(self.site_cache_dir) + """Cache path shared by users. Only return the first item, even if ``multipath`` is set to ``True``.""" + return self._first_site_dir_as_path(self._site_cache_dirs) @property def user_state_path(self) -> Path: @@ -453,7 +451,7 @@ def user_applications_path(self) -> Path: @property def site_applications_path(self) -> Path: """Applications path shared by users. Only return the first item, even if ``multipath`` is set to ``True``.""" - return self._first_item_as_path_if_multipath(self.site_applications_dir) + return Path(self._site_applications_dirs[0]) @property def user_runtime_path(self) -> Path: @@ -471,7 +469,7 @@ def iter_config_dirs(self) -> Iterator[str]: def _iter_config_dirs(self) -> Iterator[str]: yield self.user_config_dir - yield self.site_config_dir + yield from self._create_as_yielded(self._site_config_dirs) def iter_data_dirs(self) -> Iterator[str]: """:yield: all user and site data directories.""" @@ -479,7 +477,7 @@ def iter_data_dirs(self) -> Iterator[str]: def _iter_data_dirs(self) -> Iterator[str]: yield self.user_data_dir - yield self.site_data_dir + yield from self._create_as_yielded(self._site_data_dirs) def iter_cache_dirs(self) -> Iterator[str]: """:yield: all user and site cache directories.""" @@ -487,7 +485,7 @@ def iter_cache_dirs(self) -> Iterator[str]: def _iter_cache_dirs(self) -> Iterator[str]: yield self.user_cache_dir - yield self.site_cache_dir + yield from self._create_as_yielded(self._site_cache_dirs) def iter_state_dirs(self) -> Iterator[str]: """:yield: all user and site state directories.""" diff --git a/src/platformdirs/macos.py b/src/platformdirs/macos.py index 6cb9371a..f6b40d22 100644 --- a/src/platformdirs/macos.py +++ b/src/platformdirs/macos.py @@ -2,20 +2,13 @@ from __future__ import annotations -import os.path import re import sys -from typing import TYPE_CHECKING, Final - -if TYPE_CHECKING: - from collections.abc import Iterator +from typing import Final from ._xdg import XDGMixin, _expand_user from .api import PlatformDirsABC -if TYPE_CHECKING: - from pathlib import Path - class _MacOSDefaults(PlatformDirsABC): # ruff:ignore[too-many-public-methods] """Default platform directories for macOS without XDG environment variable overrides. @@ -43,16 +36,6 @@ def user_data_dir(self) -> str: def _site_data_dirs(self) -> list[str]: return self._base_site_dirs() - @property - def site_data_path(self) -> Path: - """Data path shared by users. Only return the first item, even if ``multipath`` is set to ``True``.""" - return self._first_site_dir_as_path(self._site_data_dirs) - - @property - def site_config_path(self) -> Path: - """Config path shared by users. Only return the first item, even if ``multipath`` is set to ``True``.""" - return self._first_site_dir_as_path(self._site_config_dirs) - @property def user_config_dir(self) -> str: """Config directory tied to the user, same as `user_data_dir`.""" @@ -78,11 +61,6 @@ def site_cache_dir(self) -> str: """Cache directory shared by users, e.g. ``/Library/Caches/$appname/$version``. If we're using a Python binary managed by `Homebrew `_, the directory will be under the Homebrew prefix, e.g. ``$homebrew_prefix/var/cache/$appname/$version``. If `multipath ` is enabled, and we're in Homebrew, the response is a multi-path string separated by ":", e.g. ``$homebrew_prefix/var/cache/$appname/$version:/Library/Caches/$appname/$version``.""" return self._select_site_dirs(self._site_cache_dirs) - @property - def site_cache_path(self) -> Path: - """Cache path shared by users. Only return the first item, even if ``multipath`` is set to ``True``.""" - return self._first_site_dir_as_path(self._site_cache_dirs) - @property def user_state_dir(self) -> str: """State directory tied to the user, same as `user_data_dir` without ``$XDG_DATA_HOME``, e.g. ``~/Library/Application Support/$appname/$version``.""" @@ -179,12 +157,6 @@ def user_applications_dir(self) -> str: def _site_applications_dirs(self) -> list[str]: return ["/Applications"] - @property - def site_applications_dir(self) -> str: - """Applications directory shared by users, e.g. ``/Applications``.""" - dirs = self._site_applications_dirs - return os.pathsep.join(dirs) if self.multipath else dirs[0] - @property def user_runtime_dir(self) -> str: """Runtime directory tied to the user, e.g. ``~/Library/Caches/TemporaryItems/$appname/$version``.""" @@ -195,18 +167,6 @@ def site_runtime_dir(self) -> str: """Runtime directory shared by users, same as `user_runtime_dir`.""" return self.user_runtime_dir - def _iter_config_dirs(self) -> Iterator[str]: - yield self.user_config_dir - yield from self._create_as_yielded(self._site_config_dirs) - - def _iter_data_dirs(self) -> Iterator[str]: - yield self.user_data_dir - yield from self._create_as_yielded(self._site_data_dirs) - - def _iter_cache_dirs(self) -> Iterator[str]: - yield self.user_cache_dir - yield from self._create_as_yielded(self._site_cache_dirs) - class MacOS(XDGMixin, _MacOSDefaults): """Platform directories for the macOS operating system. diff --git a/src/platformdirs/testing.py b/src/platformdirs/testing.py index 929f6c32..a603822a 100644 --- a/src/platformdirs/testing.py +++ b/src/platformdirs/testing.py @@ -51,9 +51,11 @@ def isolated_dirs(root: str | os.PathLike[str]) -> Iterator[Path]: ) for name in PlatformDirsABC.__abstractmethods__ } - # The *_path and iter_* accessors of the Unix and macOS site kinds read these lists instead of the *_dir property. + # Unix and macOS build the site lists from search paths; the base versions derive them from the redirected dirs. patches |= { - f"_site_{kind}_dirs": _redirect_site_list(base / f"site_{kind}") for kind in ("data", "config", "cache") + name: value + for name, value in vars(PlatformDirsABC).items() + if name.startswith("_site_") and name.endswith("_dirs") } cls = platformdirs.PlatformDirs saved = {name: vars(cls)[name] for name in patches if name in vars(cls)} @@ -76,13 +78,6 @@ def resolve(self: PlatformDirsABC) -> str: return property(resolve) -def _redirect_site_list(path: Path) -> property: - def resolve(self: PlatformDirsABC) -> list[str]: - return [self._join_app_name_and_version(str(path))] - - return property(resolve) - - __all__ = [ "isolated_dirs", ] diff --git a/src/platformdirs/unix.py b/src/platformdirs/unix.py index 31008e59..e7e78b2a 100644 --- a/src/platformdirs/unix.py +++ b/src/platformdirs/unix.py @@ -175,12 +175,6 @@ def user_applications_dir(self) -> str: def _site_applications_dirs(self) -> list[str]: return [os.path.join(p, "applications") for p in ["/usr/local/share", "/usr/share"]] # ruff:ignore[os-path-join] - @property - def site_applications_dir(self) -> str: - """Applications directory shared by users, e.g. ``/usr/local/share/applications``.""" - dirs = self._site_applications_dirs - return os.pathsep.join(dirs) if self.multipath else dirs[0] - def _default_runtime_dir(self) -> str: if sys.platform.startswith("openbsd"): path = f"/tmp/run/user/{getuid()}" # ruff:ignore[hardcoded-temp-file] @@ -222,16 +216,6 @@ def site_runtime_dir(self) -> str: path = "/run" return self._append_app_name_and_version(path, private=False) - @property - def site_data_path(self) -> Path: - """Data path shared by users. Only return the first item, even if ``multipath`` is set to ``True``.""" - return self._first_site_dir_as_path(self._site_data_dirs) - - @property - def site_config_path(self) -> Path: - """Config path shared by users, returns the first item, even if ``multipath`` is set to ``True``.""" - return self._first_site_dir_as_path(self._site_config_dirs) - def _iter_config_dirs(self) -> Iterator[str]: # Under multipath the user dir is an os.pathsep-joined string that no single site entry matches, so the # dedupe in iter_config_dirs cannot drop it. Skip it here instead. diff --git a/src/platformdirs/windows.py b/src/platformdirs/windows.py index baaacfca..e587f943 100644 --- a/src/platformdirs/windows.py +++ b/src/platformdirs/windows.py @@ -181,11 +181,6 @@ def site_applications_dir(self) -> str: r"""Applications directory shared by users, e.g. ``C:\ProgramData\Microsoft\Windows\Start Menu\Programs``.""" return os.path.normpath(get_win_folder("CSIDL_COMMON_PROGRAMS")) - @property - def site_applications_path(self) -> Path: - """Applications path shared by users. The ``multipath`` option is ignored on Windows.""" - return Path(self.site_applications_dir) - @property def user_runtime_dir(self) -> str: r"""Runtime directory tied to the user, e.g. ``%USERPROFILE%\AppData\Local\Temp\$appauthor\$appname``.""" diff --git a/tests/test_android.py b/tests/test_android.py index d0a5544e..9d0781aa 100644 --- a/tests/test_android.py +++ b/tests/test_android.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os import sys from pathlib import Path from typing import TYPE_CHECKING, Any, Final @@ -324,6 +325,13 @@ def test_android_applications_path_function_takes_app_arguments(mocker: MockerFi assert getattr(platformdirs, func)(appname="foo", version="1.0") == Path(_SCOPED_APPLICATIONS_DIR) +@pytest.mark.usefixtures("_example_android_folder") +def test_android_site_applications_path_multipath_keeps_pathsep() -> None: + appname = f"foo{os.pathsep}bar" + expected = Path(f"/data/data/com.example/files/{appname}") + assert Android(appname=appname, multipath=True).site_applications_path == expected + + @pytest.mark.skipif(sys.platform == "win32", reason="Windows ignores POSIX mode bits") @pytest.mark.usefixtures("_umask") @pytest.mark.parametrize("prop", ["user_log_dir", "user_runtime_dir"]) diff --git a/tests/test_macos.py b/tests/test_macos.py index 46195631..2e7dc0de 100644 --- a/tests/test_macos.py +++ b/tests/test_macos.py @@ -550,7 +550,7 @@ def test_macos_site_runtime_path(home: str) -> None: @pytest.mark.usefixtures("_clear_xdg_env", "_builtin_py_prefix") def test_macos_ensure_exists_preexisting_dir(mocker: MockerFixture, tmp_path: Path) -> None: - mocker.patch("platformdirs.macos.os.path.expanduser", lambda p: str(tmp_path / p.lstrip("~/"))) + mocker.patch("platformdirs._xdg.os.path.expanduser", lambda p: str(tmp_path / p.lstrip("~/"))) dirs = MacOS(appname="foo", ensure_exists=True) first = dirs.user_data_dir assert Path(first).exists() diff --git a/tests/test_windows.py b/tests/test_windows.py index 73962c0c..98890fe2 100644 --- a/tests/test_windows.py +++ b/tests/test_windows.py @@ -114,21 +114,10 @@ def test_windows(params: dict[str, Any], func: str) -> None: assert result == expected_map[func] -@pytest.mark.parametrize("directory", ["Programs", "Programs;Shared"]) -@pytest.mark.parametrize("multipath", [False, True]) -@pytest.mark.parametrize("api", ["instance", "function"]) -def test_site_applications_path_preserves_directory( - tmp_path: Path, mocker: MockerFixture, directory: str, multipath: bool, api: str -) -> None: - expected = tmp_path / directory - mocker.patch.dict(_WIN_FOLDERS, {"CSIDL_COMMON_PROGRAMS": str(expected)}) - mocker.patch("platformdirs.PlatformDirs", Windows) - dirs = Windows(multipath=multipath) - assert dirs.site_applications_dir == str(expected) - result = ( - dirs.site_applications_path if api == "instance" else platformdirs.site_applications_path(multipath=multipath) - ) - assert result == expected +def test_site_applications_path_multipath_keeps_pathsep(mocker: MockerFixture) -> None: + folder = rf"C:\ProgramData\Start{os.pathsep}Menu" + mocker.patch.dict(_WIN_FOLDERS, {"CSIDL_COMMON_PROGRAMS": folder}) + assert Windows(multipath=True).site_applications_path == Path(folder) @pytest.mark.parametrize(