From 591a76b1abf16537abf9579f2607cfbb71142735 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 14 Feb 2026 08:46:28 -0800 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20feat(api):=20add=20site=5Fbin?= =?UTF-8?q?=5Fdir=20property?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applications that install system-wide executables need a standard location that mirrors user_bin_dir. Currently, user_bin_dir provides ~/.local/bin (Unix/macOS) or %LOCALAPPDATA%\Programs (Windows), but there's no corresponding site-wide equivalent. This creates API inconsistency since all other directory types (data, config, cache, state, log, runtime) have both user and site variants. Package managers like Chocolatey, pip, and uv need a consistent answer for system-wide binary installation. Add site_bin_dir following platform conventions: - Unix/Linux: /usr/local/bin (per FHS 3.0 for locally-installed software) - macOS: /usr/local/bin (standard Homebrew/user installation location) - Windows: %ProgramData%\bin (consistent with site_data_dir pattern) - Android: alias to user_bin_dir (no system-wide installation concept) Also implement use_site_for_root support on Unix, allowing user_bin_dir to redirect to site_bin_dir when running as root, matching the behavior of other user_* properties. Closes #434 --- src/platformdirs/__init__.py | 12 ++++++++++++ src/platformdirs/__main__.py | 1 + src/platformdirs/android.py | 5 +++++ src/platformdirs/api.py | 10 ++++++++++ src/platformdirs/macos.py | 5 +++++ src/platformdirs/unix.py | 10 ++++++++++ src/platformdirs/windows.py | 5 +++++ tests/conftest.py | 1 + tests/test_android.py | 1 + tests/test_macos.py | 2 ++ tests/test_unix.py | 2 ++ tests/test_windows.py | 1 + 12 files changed, 55 insertions(+) diff --git a/src/platformdirs/__init__.py b/src/platformdirs/__init__.py index 84c9df5..fb530c9 100644 --- a/src/platformdirs/__init__.py +++ b/src/platformdirs/__init__.py @@ -340,6 +340,11 @@ def user_bin_dir() -> str: return PlatformDirs().user_bin_dir +def site_bin_dir() -> str: + """:returns: bin directory shared by users""" + return PlatformDirs().site_bin_dir + + def user_applications_dir() -> str: """:returns: applications directory tied to the user""" return PlatformDirs().user_applications_dir @@ -698,6 +703,11 @@ def user_bin_path() -> Path: return PlatformDirs().user_bin_path +def site_bin_path() -> Path: + """:returns: bin path shared by users""" + return PlatformDirs().site_bin_path + + def user_applications_path() -> Path: """:returns: applications path tied to the user""" return PlatformDirs().user_applications_path @@ -777,6 +787,8 @@ def site_runtime_path( "__version_info__", "site_applications_dir", "site_applications_path", + "site_bin_dir", + "site_bin_path", "site_cache_dir", "site_cache_path", "site_config_dir", diff --git a/src/platformdirs/__main__.py b/src/platformdirs/__main__.py index be875b2..2490ffb 100644 --- a/src/platformdirs/__main__.py +++ b/src/platformdirs/__main__.py @@ -16,6 +16,7 @@ "user_videos_dir", "user_music_dir", "user_bin_dir", + "site_bin_dir", "user_applications_dir", "user_runtime_dir", "site_data_dir", diff --git a/src/platformdirs/android.py b/src/platformdirs/android.py index f9c4538..708355b 100644 --- a/src/platformdirs/android.py +++ b/src/platformdirs/android.py @@ -118,6 +118,11 @@ def user_bin_dir(self) -> str: """:return: bin directory tied to the user, e.g. ``/data/user///files/bin``""" return os.path.join(cast("str", _android_folder()), "files", "bin") # noqa: PTH118 + @property + def site_bin_dir(self) -> str: + """:return: bin directory shared by users, same as `user_bin_dir`""" + return self.user_bin_dir + @property def user_applications_dir(self) -> str: """:return: applications directory tied to the user, same as `user_data_dir`""" diff --git a/src/platformdirs/api.py b/src/platformdirs/api.py index ae90557..46afe0a 100644 --- a/src/platformdirs/api.py +++ b/src/platformdirs/api.py @@ -209,6 +209,11 @@ def user_desktop_dir(self) -> str: def user_bin_dir(self) -> str: """:return: bin directory tied to the user""" + @property + @abstractmethod + def site_bin_dir(self) -> str: + """:return: bin directory shared by users""" + @property @abstractmethod def user_applications_dir(self) -> str: @@ -314,6 +319,11 @@ def user_bin_path(self) -> Path: """:return: bin path tied to the user""" return Path(self.user_bin_dir) + @property + def site_bin_path(self) -> Path: + """:return: bin path shared by users""" + return Path(self.site_bin_dir) + @property def user_applications_path(self) -> Path: """:return: applications path tied to the user""" diff --git a/src/platformdirs/macos.py b/src/platformdirs/macos.py index 4be30bf..3343a0b 100644 --- a/src/platformdirs/macos.py +++ b/src/platformdirs/macos.py @@ -135,6 +135,11 @@ def user_bin_dir(self) -> str: """:return: bin directory tied to the user, e.g. ``~/.local/bin``""" return os.path.expanduser("~/.local/bin") # noqa: PTH111 + @property + def site_bin_dir(self) -> str: + """:return: bin directory shared by users, e.g. ``/usr/local/bin``""" + return "/usr/local/bin" + @property def user_applications_dir(self) -> str: """:return: applications directory tied to the user, e.g. ``~/Applications``""" diff --git a/src/platformdirs/unix.py b/src/platformdirs/unix.py index 84f6d6f..e388fcb 100644 --- a/src/platformdirs/unix.py +++ b/src/platformdirs/unix.py @@ -140,6 +140,11 @@ def user_bin_dir(self) -> str: """:return: bin directory tied to the user, e.g. ``~/.local/bin``""" return os.path.expanduser("~/.local/bin") # noqa: PTH111 + @property + def site_bin_dir(self) -> str: + """:return: bin directory shared by users, e.g. ``/usr/local/bin``""" + return "/usr/local/bin" + @property def user_applications_dir(self) -> str: """:return: applications directory tied to the user, e.g. ``~/.local/share/applications``""" @@ -266,6 +271,11 @@ def user_runtime_dir(self) -> str: """:return: runtime directory tied to the user, or site equivalent when root with ``use_site_for_root``""" return self.site_runtime_dir if self._use_site else super().user_runtime_dir + @property + def user_bin_dir(self) -> str: + """:return: bin directory tied to the user, or site equivalent when root with ``use_site_for_root``""" + return self.site_bin_dir if self._use_site else super().user_bin_dir + def _get_user_media_dir(env_var: str, fallback_tilde_path: str) -> str: if media_dir := _get_user_dirs_folder(env_var): diff --git a/src/platformdirs/windows.py b/src/platformdirs/windows.py index ad09afb..84afaa7 100644 --- a/src/platformdirs/windows.py +++ b/src/platformdirs/windows.py @@ -146,6 +146,11 @@ def user_bin_dir(self) -> str: """:return: bin directory tied to the user, e.g. ``%LOCALAPPDATA%\\Programs``""" return os.path.normpath(os.path.join(get_win_folder("CSIDL_LOCAL_APPDATA"), "Programs")) # noqa: PTH118 + @property + def site_bin_dir(self) -> str: + """:return: bin directory shared by users, e.g. ``C:\\ProgramData\\bin``""" + return os.path.normpath(os.path.join(get_win_folder("CSIDL_COMMON_APPDATA"), "bin")) # noqa: PTH118 + @property def user_applications_dir(self) -> str: """:return: applications directory tied to the user, e.g. ``Start Menu\\Programs``""" diff --git a/tests/conftest.py b/tests/conftest.py index d262d29..5f9edb9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,6 +19,7 @@ "user_videos_dir", "user_music_dir", "user_bin_dir", + "site_bin_dir", "user_applications_dir", "user_runtime_dir", "site_data_dir", diff --git a/tests/test_android.py b/tests/test_android.py index 3bb7a05..829bacb 100644 --- a/tests/test_android.py +++ b/tests/test_android.py @@ -62,6 +62,7 @@ def test_android(mocker: MockerFixture, params: dict[str, Any], func: str) -> No "user_music_dir": "/storage/emulated/0/Music", "user_desktop_dir": "/storage/emulated/0/Desktop", "user_bin_dir": "/data/data/com.example/files/bin", + "site_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}", diff --git a/tests/test_macos.py b/tests/test_macos.py index daf8b35..4690860 100644 --- a/tests/test_macos.py +++ b/tests/test_macos.py @@ -84,6 +84,7 @@ def test_macos(mocker: MockerFixture, params: dict[str, Any], func: str) -> None "user_music_dir": f"{home}/Music", "user_desktop_dir": f"{home}/Desktop", "user_bin_dir": f"{home}/.local/bin", + "site_bin_dir": "/usr/local/bin", "user_applications_dir": f"{home}/Applications", "site_applications_dir": "/Applications", "user_runtime_dir": f"{home}/Library/Caches/TemporaryItems{suffix}", @@ -270,6 +271,7 @@ def test_macos_xdg_empty_falls_back( "user_music_dir": f"{home}/Music", "user_desktop_dir": f"{home}/Desktop", "user_bin_dir": f"{home}/.local/bin", + "site_bin_dir": "/usr/local/bin", "user_applications_dir": f"{home}/Applications", } assert getattr(MacOS(), prop) == expected_map[prop] diff --git a/tests/test_unix.py b/tests/test_unix.py index 82b6d26..856ce57 100644 --- a/tests/test_unix.py +++ b/tests/test_unix.py @@ -106,6 +106,7 @@ def _func_to_path(func: str) -> XDGVariable | None: "user_log_dir": XDGVariable("XDG_STATE_HOME", "~/.local/state"), "user_runtime_dir": XDGVariable("XDG_RUNTIME_DIR", f"{gettempdir()}/runtime-1234"), "user_bin_dir": None, + "site_bin_dir": None, "user_applications_dir": None, "site_applications_dir": None, "site_log_dir": None, @@ -346,6 +347,7 @@ def test_user_media_dir_no_user_dirs_file( ("user_state_dir", os.path.join("/var/lib", "foo")), # noqa: PTH118 ("user_log_dir", os.path.join("/var/log", "foo")), # noqa: PTH118 ("user_runtime_dir", os.path.join("/run", "foo")), # noqa: PTH118 + ("user_bin_dir", "/usr/local/bin"), ] diff --git a/tests/test_windows.py b/tests/test_windows.py index 5e123a0..4a4a704 100644 --- a/tests/test_windows.py +++ b/tests/test_windows.py @@ -91,6 +91,7 @@ def test_windows(params: dict[str, Any], func: str) -> None: "user_music_dir": os.path.normpath(_WIN_FOLDERS["CSIDL_MYMUSIC"]), "user_desktop_dir": os.path.normpath(_WIN_FOLDERS["CSIDL_DESKTOPDIRECTORY"]), "user_bin_dir": os.path.join(_LOCAL, "Programs"), # noqa: PTH118 + "site_bin_dir": os.path.join(_COMMON, "bin"), # 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, From 4243a817a806b026e45084971afc508630b45762 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 14 Feb 2026 08:47:55 -0800 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=93=9D=20docs(platforms):=20document?= =?UTF-8?q?=20site=5Fbin=5Fdir=20property?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add documentation for the new site_bin_dir property with platform-specific paths and links to official standards (FHS 3.0 for Unix/Linux, Chocolatey for Windows precedent). --- docs/platforms.rst | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/platforms.rst b/docs/platforms.rst index d00d320..57c9a44 100644 --- a/docs/platforms.rst +++ b/docs/platforms.rst @@ -341,6 +341,30 @@ Default paths This property does not append ``appname`` or ``version``. It returns the directory where user-installed executables and scripts are placed. +``site_bin_dir`` +~~~~~~~~~~~~~~~~ + +.. list-table:: + :widths: 20 80 + + * - Linux + - ``/usr/local/bin`` + * - macOS + - ``/usr/local/bin`` + * - Windows + - ``C:\ProgramData\bin`` + * - Android + - Same as ``user_bin_dir`` + +.. note:: + + This property does not append ``appname`` or ``version``. It returns the directory + where system-wide executables and scripts are placed. On Unix/Linux, this follows + the `FHS 3.0 `_ + standard for locally-installed software. On Windows, it mirrors the ``site_data_dir`` + pattern using ``%ProgramData%``, following the precedent set by + `Chocolatey `_. + macOS -----