From 422a82441d994cf53745439b472c960d82e9af7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 14 Feb 2026 08:39:44 -0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(api):=20add=20site=5Fapplicati?= =?UTF-8?q?ons=5Fdir=20property?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applications need to discover both user-specific and system-wide application directories for desktop integration. The library already provides user_applications_dir but lacked the corresponding site-level counterpart, creating an asymmetry with other directory pairs. This adds site_applications_dir following platform conventions: - Linux: /usr/share/applications (via XDG_DATA_DIRS) - macOS: /Applications - Windows: C:\ProgramData\Microsoft\Windows\Start Menu\Programs - Android: same as user_applications_dir (no distinction) The implementation follows the established pattern of other site_*_dir properties and respects the use_site_for_root option on Unix systems. Closes #435 --- docs/platforms.rst | 21 +++++++++++++++++++++ src/platformdirs/__init__.py | 32 ++++++++++++++++++++++++++++++++ src/platformdirs/__main__.py | 1 + src/platformdirs/_xdg.py | 12 ++++++++++++ src/platformdirs/android.py | 7 ++++++- src/platformdirs/api.py | 10 ++++++++++ src/platformdirs/macos.py | 10 ++++++++++ src/platformdirs/unix.py | 15 +++++++++++++++ src/platformdirs/windows.py | 22 +++++++++++++++++++++- tests/conftest.py | 1 + tests/test_android.py | 1 + tests/test_macos.py | 1 + tests/test_unix.py | 1 + tests/test_windows.py | 3 +++ 14 files changed, 135 insertions(+), 2 deletions(-) diff --git a/docs/platforms.rst b/docs/platforms.rst index 9320663..d00d320 100644 --- a/docs/platforms.rst +++ b/docs/platforms.rst @@ -300,6 +300,27 @@ Default paths applications directory where ``.desktop`` files (Linux), app bundles (macOS), or Start Menu shortcuts (Windows) are placed. +``site_applications_dir`` +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. list-table:: + :widths: 20 80 + + * - Linux + - ``/usr/share/applications`` + * - macOS + - ``/Applications`` + * - Windows + - ``C:\ProgramData\Microsoft\Windows\Start Menu\Programs`` + * - Android + - same as ``user_applications_dir`` + +.. note:: + + This property does not append ``appname`` or ``version``. It returns the system-wide + applications directory where ``.desktop`` files (Linux), app bundles (macOS), or + Start Menu shortcuts (Windows) are installed for all users. + ``user_bin_dir`` ~~~~~~~~~~~~~~~~ diff --git a/src/platformdirs/__init__.py b/src/platformdirs/__init__.py index 487e753..84c9df5 100644 --- a/src/platformdirs/__init__.py +++ b/src/platformdirs/__init__.py @@ -345,6 +345,21 @@ def user_applications_dir() -> str: return PlatformDirs().user_applications_dir +def site_applications_dir( + multipath: bool = False, # noqa: FBT001, FBT002 + ensure_exists: bool = False, # noqa: FBT001, FBT002 +) -> str: + """ + :param multipath: See `multipath `. + :param ensure_exists: See `ensure_exists `. + :returns: applications directory shared by users + """ + return PlatformDirs( + multipath=multipath, + ensure_exists=ensure_exists, + ).site_applications_dir + + def user_runtime_dir( # noqa: PLR0913, PLR0917 appname: str | None = None, appauthor: str | Literal[False] | None = None, @@ -688,6 +703,21 @@ def user_applications_path() -> Path: return PlatformDirs().user_applications_path +def site_applications_path( + multipath: bool = False, # noqa: FBT001, FBT002 + ensure_exists: bool = False, # noqa: FBT001, FBT002 +) -> Path: + """ + :param multipath: See `multipath `. + :param ensure_exists: See `ensure_exists `. + :returns: applications path shared by users + """ + return PlatformDirs( + multipath=multipath, + ensure_exists=ensure_exists, + ).site_applications_path + + def user_runtime_path( # noqa: PLR0913, PLR0917 appname: str | None = None, appauthor: str | Literal[False] | None = None, @@ -745,6 +775,8 @@ def site_runtime_path( "PlatformDirsABC", "__version__", "__version_info__", + "site_applications_dir", + "site_applications_path", "site_cache_dir", "site_cache_path", "site_config_dir", diff --git a/src/platformdirs/__main__.py b/src/platformdirs/__main__.py index c15572c..be875b2 100644 --- a/src/platformdirs/__main__.py +++ b/src/platformdirs/__main__.py @@ -23,6 +23,7 @@ "site_cache_dir", "site_state_dir", "site_log_dir", + "site_applications_dir", "site_runtime_dir", ) diff --git a/src/platformdirs/_xdg.py b/src/platformdirs/_xdg.py index c9b5e60..4060e89 100644 --- a/src/platformdirs/_xdg.py +++ b/src/platformdirs/_xdg.py @@ -125,6 +125,18 @@ def user_applications_dir(self) -> str: return os.path.join(os.path.expanduser(path), "applications") # noqa: PTH111, PTH118 return super().user_applications_dir + @property + def _site_applications_dirs(self) -> list[str]: + if xdg_dirs := os.environ.get("XDG_DATA_DIRS", "").strip(): + return [os.path.join(p, "applications") for p in xdg_dirs.split(os.pathsep) if p.strip()] # noqa: PTH118 + return super()._site_applications_dirs # type: ignore[misc] + + @property + def site_applications_dir(self) -> str: + """:return: applications directories shared by users, from ``$XDG_DATA_DIRS`` if set, else platform default""" + dirs = self._site_applications_dirs + return os.pathsep.join(dirs) if self.multipath else dirs[0] + __all__ = [ "XDGMixin", diff --git a/src/platformdirs/android.py b/src/platformdirs/android.py index 5320898..f9c4538 100644 --- a/src/platformdirs/android.py +++ b/src/platformdirs/android.py @@ -11,7 +11,7 @@ from .api import PlatformDirsABC -class Android(PlatformDirsABC): +class Android(PlatformDirsABC): # noqa: PLR0904 """ Platform directories for Android. @@ -123,6 +123,11 @@ def user_applications_dir(self) -> str: """:return: applications directory tied to the user, same as `user_data_dir`""" return self.user_data_dir + @property + def site_applications_dir(self) -> str: + """:return: applications directory shared by users, same as `user_applications_dir`""" + return self.user_applications_dir + @property def user_runtime_dir(self) -> str: """ diff --git a/src/platformdirs/api.py b/src/platformdirs/api.py index 579295f..ae90557 100644 --- a/src/platformdirs/api.py +++ b/src/platformdirs/api.py @@ -214,6 +214,11 @@ def user_bin_dir(self) -> str: def user_applications_dir(self) -> str: """:return: applications directory tied to the user""" + @property + @abstractmethod + def site_applications_dir(self) -> str: + """:return: applications directory shared by users""" + @property @abstractmethod def user_runtime_dir(self) -> str: @@ -314,6 +319,11 @@ def user_applications_path(self) -> Path: """:return: applications path tied to the user""" return Path(self.user_applications_dir) + @property + def site_applications_path(self) -> Path: + """:return: applications path shared by users""" + return Path(self.site_applications_dir) + @property def user_runtime_path(self) -> Path: """:return: runtime path tied to the user""" diff --git a/src/platformdirs/macos.py b/src/platformdirs/macos.py index c77699e..4be30bf 100644 --- a/src/platformdirs/macos.py +++ b/src/platformdirs/macos.py @@ -140,6 +140,16 @@ def user_applications_dir(self) -> str: """:return: applications directory tied to the user, e.g. ``~/Applications``""" return os.path.expanduser("~/Applications") # noqa: PTH111 + @property + def _site_applications_dirs(self) -> list[str]: + return ["/Applications"] + + @property + def site_applications_dir(self) -> str: + """:return: 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: """:return: runtime directory tied to the user, e.g. ``~/Library/Caches/TemporaryItems/$appname/$version``""" diff --git a/src/platformdirs/unix.py b/src/platformdirs/unix.py index f1623c9..856afcb 100644 --- a/src/platformdirs/unix.py +++ b/src/platformdirs/unix.py @@ -145,6 +145,16 @@ def user_applications_dir(self) -> str: """:return: applications directory tied to the user, e.g. ``~/.local/share/applications``""" return os.path.join(os.path.expanduser("~/.local/share"), "applications") # noqa: PTH111, PTH118 + @property + def _site_applications_dirs(self) -> list[str]: + return [os.path.join(p, "applications") for p in ["/usr/local/share", "/usr/share"]] # noqa: PTH118 + + @property + def site_applications_dir(self) -> str: + """:return: applications directory shared by users, e.g. ``/usr/share/applications``""" + dirs = self._site_applications_dirs + return os.pathsep.join(dirs) if self.multipath else dirs[0] + @property def user_runtime_dir(self) -> str: """ @@ -244,6 +254,11 @@ def user_log_dir(self) -> str: """:return: log directory tied to the user, or site equivalent when root with ``use_site_for_root``""" return self.site_log_dir if self._use_site else super().user_log_dir + @property + def user_applications_dir(self) -> str: + """:return: applications directory tied to the user, or site equivalent when root with ``use_site_for_root``""" + return self.site_applications_dir if self._use_site else super().user_applications_dir + @property def user_runtime_dir(self) -> str: """:return: runtime directory tied to the user, or site equivalent when root with ``use_site_for_root``""" diff --git a/src/platformdirs/windows.py b/src/platformdirs/windows.py index 3535896..ad09afb 100644 --- a/src/platformdirs/windows.py +++ b/src/platformdirs/windows.py @@ -15,7 +15,7 @@ _KF_FLAG_DONT_VERIFY: Final[int] = 0x00004000 -class Windows(PlatformDirsABC): +class Windows(PlatformDirsABC): # noqa: PLR0904 """ `MSDN on where to store app data files `_. @@ -151,6 +151,14 @@ def user_applications_dir(self) -> str: """:return: applications directory tied to the user, e.g. ``Start Menu\\Programs``""" return os.path.normpath(get_win_folder("CSIDL_PROGRAMS")) + @property + def site_applications_dir(self) -> str: + """ + :return: 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 user_runtime_dir(self) -> str: """ @@ -212,6 +220,15 @@ def get_win_folder_if_csidl_name_not_env_var(csidl_name: str) -> str | None: # "Start Menu", "Programs", ) + + if csidl_name == "CSIDL_COMMON_PROGRAMS": + return os.path.join( # noqa: PTH118 + os.path.normpath(os.environ.get("PROGRAMDATA", os.environ.get("ALLUSERSPROFILE", "C:\\ProgramData"))), + "Microsoft", + "Windows", + "Start Menu", + "Programs", + ) return None @@ -225,6 +242,7 @@ def get_win_folder_from_registry(csidl_name: str) -> str: """ machine_names = { "CSIDL_COMMON_APPDATA", + "CSIDL_COMMON_PROGRAMS", } shell_folder_name = { "CSIDL_APPDATA": "AppData", @@ -236,6 +254,7 @@ def get_win_folder_from_registry(csidl_name: str) -> str: "CSIDL_MYVIDEO": "My Video", "CSIDL_MYMUSIC": "My Music", "CSIDL_PROGRAMS": "Programs", + "CSIDL_COMMON_PROGRAMS": "Common Programs", }.get(csidl_name) if shell_folder_name is None: msg = f"Unknown CSIDL name: {csidl_name}" @@ -263,6 +282,7 @@ def get_win_folder_from_registry(csidl_name: str) -> str: "CSIDL_DOWNLOADS": "{374DE290-123F-4565-9164-39C4925E467B}", "CSIDL_DESKTOPDIRECTORY": "{B4BFCC3A-DB2C-424C-B029-7FE99A87C641}", "CSIDL_PROGRAMS": "{A77F5D77-2E2B-44C3-A6A2-ABA601054A51}", + "CSIDL_COMMON_PROGRAMS": "{0139D44E-6AFE-49F2-8690-3DAFCAE6FFB8}", } diff --git a/tests/conftest.py b/tests/conftest.py index 017d303..d262d29 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -26,6 +26,7 @@ "site_cache_dir", "site_state_dir", "site_log_dir", + "site_applications_dir", "site_runtime_dir", ) diff --git a/tests/test_android.py b/tests/test_android.py index e8db823..3bb7a05 100644 --- a/tests/test_android.py +++ b/tests/test_android.py @@ -63,6 +63,7 @@ def test_android(mocker: MockerFixture, params: dict[str, Any], func: str) -> No "user_desktop_dir": "/storage/emulated/0/Desktop", "user_bin_dir": "/data/data/com.example/files/bin", "user_applications_dir": f"/data/data/com.example/files{suffix}", + "site_applications_dir": f"/data/data/com.example/files{suffix}", "user_runtime_dir": f"/data/data/com.example/cache{suffix}{'' if not params.get('opinion', True) else val}", "site_runtime_dir": f"/data/data/com.example/cache{suffix}{'' if not params.get('opinion', True) else val}", } diff --git a/tests/test_macos.py b/tests/test_macos.py index 6f842db..daf8b35 100644 --- a/tests/test_macos.py +++ b/tests/test_macos.py @@ -85,6 +85,7 @@ def test_macos(mocker: MockerFixture, params: dict[str, Any], func: str) -> None "user_desktop_dir": f"{home}/Desktop", "user_bin_dir": f"{home}/.local/bin", "user_applications_dir": f"{home}/Applications", + "site_applications_dir": "/Applications", "user_runtime_dir": f"{home}/Library/Caches/TemporaryItems{suffix}", "site_runtime_dir": f"{home}/Library/Caches/TemporaryItems{suffix}", } diff --git a/tests/test_unix.py b/tests/test_unix.py index 9146acc..b3edd9c 100644 --- a/tests/test_unix.py +++ b/tests/test_unix.py @@ -107,6 +107,7 @@ def _func_to_path(func: str) -> XDGVariable | None: "user_runtime_dir": XDGVariable("XDG_RUNTIME_DIR", f"{gettempdir()}/runtime-1234"), "user_bin_dir": None, "user_applications_dir": None, + "site_applications_dir": None, "site_log_dir": None, "site_state_dir": None, "site_runtime_dir": XDGVariable("XDG_RUNTIME_DIR", "/run"), diff --git a/tests/test_windows.py b/tests/test_windows.py index a134973..5e123a0 100644 --- a/tests/test_windows.py +++ b/tests/test_windows.py @@ -33,6 +33,7 @@ "CSIDL_MYMUSIC": r"C:\Users\Test\Music", "CSIDL_DESKTOPDIRECTORY": r"C:\Users\Test\Desktop", "CSIDL_PROGRAMS": r"C:\Users\Test\AppData\Roaming\Microsoft\Windows\Start Menu\Programs", + "CSIDL_COMMON_PROGRAMS": r"C:\ProgramData\Microsoft\Windows\Start Menu\Programs", } _LOCAL = os.path.normpath(_WIN_FOLDERS["CSIDL_LOCAL_APPDATA"]) @@ -91,6 +92,7 @@ def test_windows(params: dict[str, Any], func: str) -> None: "user_desktop_dir": os.path.normpath(_WIN_FOLDERS["CSIDL_DESKTOPDIRECTORY"]), "user_bin_dir": os.path.join(_LOCAL, "Programs"), # noqa: PTH118 "user_applications_dir": os.path.normpath(_WIN_FOLDERS["CSIDL_PROGRAMS"]), + "site_applications_dir": os.path.normpath(_WIN_FOLDERS["CSIDL_COMMON_PROGRAMS"]), "user_runtime_dir": temp, "site_runtime_dir": temp, } @@ -302,6 +304,7 @@ def test_known_folder_guids_has_all_csidl_names() -> None: "CSIDL_DOWNLOADS", "CSIDL_DESKTOPDIRECTORY", "CSIDL_PROGRAMS", + "CSIDL_COMMON_PROGRAMS", } assert set(_KNOWN_FOLDER_GUIDS.keys()) == expected