diff --git a/docs/api.rst b/docs/api.rst index 7f7a765..2d84dd0 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -72,6 +72,25 @@ User desktop directory .. autofunction:: platformdirs.user_desktop_dir .. autofunction:: platformdirs.user_desktop_path +User applications directory +---------------------------- + +Where application launchers and shortcuts are registered — ``.desktop`` files on Linux, +the per-user ``Applications`` folder on macOS, or Start Menu shortcuts on Windows. +These entries make applications discoverable in menus and app launchers. + +.. autofunction:: platformdirs.user_applications_dir +.. autofunction:: platformdirs.user_applications_path + +User binary directory +---------------------- + +Where user-installed executables and scripts are placed so they appear on ``$PATH`` — +``~/.local/bin`` on Linux/macOS or ``%LOCALAPPDATA%\Programs`` on Windows. + +.. autofunction:: platformdirs.user_bin_dir +.. autofunction:: platformdirs.user_bin_path + Runtime directory ------------------- diff --git a/docs/platforms.rst b/docs/platforms.rst index 0ba5fcb..9320663 100644 --- a/docs/platforms.rst +++ b/docs/platforms.rst @@ -279,6 +279,47 @@ Default paths * - Android - ``/storage/emulated/0/Desktop`` +``user_applications_dir`` +~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. list-table:: + :widths: 20 80 + + * - Linux + - ``~/.local/share/applications`` + * - macOS + - ``~/Applications`` + * - Windows + - ``C:\Users\\AppData\Roaming\Microsoft\Windows\Start Menu\Programs`` + * - Android + - same as ``user_data_dir`` + +.. note:: + + This property does not append ``appname`` or ``version``. It returns the shared + applications directory where ``.desktop`` files (Linux), app bundles (macOS), or + Start Menu shortcuts (Windows) are placed. + +``user_bin_dir`` +~~~~~~~~~~~~~~~~ + +.. list-table:: + :widths: 20 80 + + * - Linux + - ``~/.local/bin`` + * - macOS + - ``~/.local/bin`` + * - Windows + - ``C:\Users\\AppData\Local\Programs`` + * - Android + - ``/data/data//files/bin`` + +.. note:: + + This property does not append ``appname`` or ``version``. It returns the directory + where user-installed executables and scripts are placed. + macOS ----- @@ -349,6 +390,8 @@ The override variable name is ``WIN_PD_OVERRIDE_`` followed by the CSIDL suffix: - Music * - ``WIN_PD_OVERRIDE_DESKTOPDIRECTORY`` - Desktop + * - ``WIN_PD_OVERRIDE_PROGRAMS`` + - Applications (Start Menu Programs) Example — redirect cache to a separate drive: diff --git a/src/platformdirs/__init__.py b/src/platformdirs/__init__.py index e16ae40..487e753 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 user_applications_dir() -> str: + """:returns: applications directory tied to the user""" + return PlatformDirs().user_applications_dir + + def user_runtime_dir( # noqa: PLR0913, PLR0917 appname: str | None = None, appauthor: str | Literal[False] | None = None, @@ -678,6 +683,11 @@ def user_bin_path() -> Path: return PlatformDirs().user_bin_path +def user_applications_path() -> Path: + """:returns: applications path tied to the user""" + return PlatformDirs().user_applications_path + + def user_runtime_path( # noqa: PLR0913, PLR0917 appname: str | None = None, appauthor: str | Literal[False] | None = None, @@ -747,6 +757,8 @@ def site_runtime_path( "site_runtime_path", "site_state_dir", "site_state_path", + "user_applications_dir", + "user_applications_path", "user_bin_dir", "user_bin_path", "user_cache_dir", diff --git a/src/platformdirs/__main__.py b/src/platformdirs/__main__.py index a4a3db7..c15572c 100644 --- a/src/platformdirs/__main__.py +++ b/src/platformdirs/__main__.py @@ -16,6 +16,7 @@ "user_videos_dir", "user_music_dir", "user_bin_dir", + "user_applications_dir", "user_runtime_dir", "site_data_dir", "site_config_dir", diff --git a/src/platformdirs/_xdg.py b/src/platformdirs/_xdg.py index 59765eb..c9b5e60 100644 --- a/src/platformdirs/_xdg.py +++ b/src/platformdirs/_xdg.py @@ -118,6 +118,13 @@ def user_desktop_dir(self) -> str: return os.path.expanduser(path) # noqa: PTH111 return super().user_desktop_dir + @property + def user_applications_dir(self) -> str: + """:return: applications directory tied to the user, from ``$XDG_DATA_HOME`` if set, else platform default""" + if path := os.environ.get("XDG_DATA_HOME", "").strip(): + return os.path.join(os.path.expanduser(path), "applications") # noqa: PTH111, PTH118 + return super().user_applications_dir + __all__ = [ "XDGMixin", diff --git a/src/platformdirs/android.py b/src/platformdirs/android.py index 314ac30..5320898 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 user_applications_dir(self) -> str: + """:return: applications directory tied to the user, same as `user_data_dir`""" + return self.user_data_dir + @property def user_runtime_dir(self) -> str: """ diff --git a/src/platformdirs/api.py b/src/platformdirs/api.py index dd605ed..579295f 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 user_applications_dir(self) -> str: + """:return: applications directory tied to the user""" + @property @abstractmethod def user_runtime_dir(self) -> str: @@ -304,6 +309,11 @@ def user_bin_path(self) -> Path: """:return: bin path tied to the user""" return Path(self.user_bin_dir) + @property + def user_applications_path(self) -> Path: + """:return: applications path tied to the user""" + return Path(self.user_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 85df223..c77699e 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 user_applications_dir(self) -> str: + """:return: applications directory tied to the user, e.g. ``~/Applications``""" + return os.path.expanduser("~/Applications") # noqa: PTH111 + @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 69622f9..f1623c9 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 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 user_runtime_dir(self) -> str: """ diff --git a/src/platformdirs/windows.py b/src/platformdirs/windows.py index 89210f6..3535896 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 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 user_runtime_dir(self) -> str: """ @@ -182,7 +187,7 @@ 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: +def get_win_folder_if_csidl_name_not_env_var(csidl_name: str) -> str | None: # noqa: PLR0911 """Get a folder for a CSIDL name that does not exist as an environment variable.""" if csidl_name == "CSIDL_PERSONAL": return os.path.join(os.path.normpath(os.environ["USERPROFILE"]), "Documents") # noqa: PTH118 @@ -198,6 +203,15 @@ def get_win_folder_if_csidl_name_not_env_var(csidl_name: str) -> str | None: if csidl_name == "CSIDL_MYMUSIC": return os.path.join(os.path.normpath(os.environ["USERPROFILE"]), "Music") # noqa: PTH118 + + if csidl_name == "CSIDL_PROGRAMS": + return os.path.join( # noqa: PTH118 + os.path.normpath(os.environ["APPDATA"]), + "Microsoft", + "Windows", + "Start Menu", + "Programs", + ) return None @@ -221,6 +235,7 @@ def get_win_folder_from_registry(csidl_name: str) -> str: "CSIDL_MYPICTURES": "My Pictures", "CSIDL_MYVIDEO": "My Video", "CSIDL_MYMUSIC": "My Music", + "CSIDL_PROGRAMS": "Programs", }.get(csidl_name) if shell_folder_name is None: msg = f"Unknown CSIDL name: {csidl_name}" @@ -247,6 +262,7 @@ def get_win_folder_from_registry(csidl_name: str) -> str: "CSIDL_MYMUSIC": "{4BD8D571-6D19-48D3-BE97-422220080E43}", "CSIDL_DOWNLOADS": "{374DE290-123F-4565-9164-39C4925E467B}", "CSIDL_DESKTOPDIRECTORY": "{B4BFCC3A-DB2C-424C-B029-7FE99A87C641}", + "CSIDL_PROGRAMS": "{A77F5D77-2E2B-44C3-A6A2-ABA601054A51}", } diff --git a/tests/conftest.py b/tests/conftest.py index bcda36a..017d303 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,6 +19,7 @@ "user_videos_dir", "user_music_dir", "user_bin_dir", + "user_applications_dir", "user_runtime_dir", "site_data_dir", "site_config_dir", diff --git a/tests/test_android.py b/tests/test_android.py index 6a09f23..e8db823 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", + "user_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 0744cdc..6f842db 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", + "user_applications_dir": f"{home}/Applications", "user_runtime_dir": f"{home}/Library/Caches/TemporaryItems{suffix}", "site_runtime_dir": f"{home}/Library/Caches/TemporaryItems{suffix}", } @@ -268,6 +269,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", + "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 80969eb..9146acc 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, + "user_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 86235af..a134973 100644 --- a/tests/test_windows.py +++ b/tests/test_windows.py @@ -32,6 +32,7 @@ "CSIDL_MYVIDEO": r"C:\Users\Test\Videos", "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", } _LOCAL = os.path.normpath(_WIN_FOLDERS["CSIDL_LOCAL_APPDATA"]) @@ -89,6 +90,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 + "user_applications_dir": os.path.normpath(_WIN_FOLDERS["CSIDL_PROGRAMS"]), "user_runtime_dir": temp, "site_runtime_dir": temp, } @@ -149,6 +151,12 @@ def test_get_win_folder_from_env_vars_user_folders( assert get_win_folder_from_env_vars(csidl_name).endswith(subfolder) +def test_get_win_folder_from_env_vars_programs(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("APPDATA", r"C:\Users\Test\AppData\Roaming") + result = get_win_folder_from_env_vars("CSIDL_PROGRAMS") + assert result.endswith("Programs") + + def test_get_win_folder_from_env_vars_unknown() -> None: with pytest.raises(ValueError, match="Unknown CSIDL name"): get_win_folder_from_env_vars("CSIDL_BOGUS") @@ -174,6 +182,13 @@ def test_get_win_folder_if_csidl_name_not_env_var( assert result.endswith(subfolder) +def test_get_win_folder_if_csidl_name_not_env_var_programs(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("APPDATA", r"C:\Users\Test\AppData\Roaming") + result = get_win_folder_if_csidl_name_not_env_var("CSIDL_PROGRAMS") + assert result is not None + assert result.endswith("Programs") + + def _setup_ctypes_mocks(mocker: MockerFixture, *, win_dll: MagicMock | None = None) -> None: """Mock ctypes internals so get_win_folder_via_ctypes can be tested on non-Windows.""" for attr in ("HRESULT", "WinDLL"): @@ -286,6 +301,7 @@ def test_known_folder_guids_has_all_csidl_names() -> None: "CSIDL_MYMUSIC", "CSIDL_DOWNLOADS", "CSIDL_DESKTOPDIRECTORY", + "CSIDL_PROGRAMS", } assert set(_KNOWN_FOLDER_GUIDS.keys()) == expected @@ -314,6 +330,7 @@ def test_pick_get_win_folder_ctypes(mocker: MockerFixture) -> None: pytest.param("CSIDL_MYVIDEO", "MYVIDEO", id="myvideo"), pytest.param("CSIDL_MYMUSIC", "MYMUSIC", id="mymusic"), pytest.param("CSIDL_DESKTOPDIRECTORY", "DESKTOPDIRECTORY", id="desktop"), + pytest.param("CSIDL_PROGRAMS", "PROGRAMS", id="programs"), ], ) def test_get_win_folder_override(monkeypatch: pytest.MonkeyPatch, csidl_name: str, env_suffix: str) -> None: