Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
-------------------

Expand Down
43 changes: 43 additions & 0 deletions docs/platforms.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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\<User>\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\<User>\AppData\Local\Programs``
* - Android
- ``/data/data/<pkg>/files/bin``

.. note::

This property does not append ``appname`` or ``version``. It returns the directory
where user-installed executables and scripts are placed.

macOS
-----

Expand Down Expand Up @@ -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:

Expand Down
12 changes: 12 additions & 0 deletions src/platformdirs/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/platformdirs/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions src/platformdirs/_xdg.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions src/platformdirs/android.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,11 @@ def user_bin_dir(self) -> str:
""":return: bin directory tied to the user, e.g. ``/data/user/<userid>/<packagename>/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:
"""
Expand Down
10 changes: 10 additions & 0 deletions src/platformdirs/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"""
Expand Down
5 changes: 5 additions & 0 deletions src/platformdirs/macos.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``"""
Expand Down
5 changes: 5 additions & 0 deletions src/platformdirs/unix.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"""
Expand Down
18 changes: 17 additions & 1 deletion src/platformdirs/windows.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"""
Expand Down Expand Up @@ -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
Expand All @@ -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


Expand All @@ -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}"
Expand All @@ -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}",
}


Expand Down
1 change: 1 addition & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions tests/test_android.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
}
Expand Down
2 changes: 2 additions & 0 deletions tests/test_macos.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
}
Expand Down Expand Up @@ -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]

Expand Down
1 change: 1 addition & 0 deletions tests/test_unix.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
17 changes: 17 additions & 0 deletions tests/test_windows.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -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")
Expand All @@ -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"):
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down