Skip to content
Open
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
104 changes: 80 additions & 24 deletions src/platformdirs/macos.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import os
import os.path
import sys
from typing import TYPE_CHECKING
Expand All @@ -22,12 +23,20 @@ class MacOS(PlatformDirsABC):
`version <platformdirs.api.PlatformDirsABC.version>`,
`ensure_exists <platformdirs.api.PlatformDirsABC.ensure_exists>`.

Note: Also understands XDG_* environment variables similar to Unix; if these are set, they override the
corresponding user directories.
"""

@property
def user_data_dir(self) -> str:
""":return: data directory tied to the user, e.g. ``~/Library/Application Support/$appname/$version``"""
return self._append_app_name_and_version(os.path.expanduser("~/Library/Application Support")) # noqa: PTH111
"""
:return: data directory tied to the user, e.g. ``~/Library/Application Support/$appname/$version`` or
``$XDG_DATA_HOME/$appname/$version`` if set
"""
path = os.environ.get("XDG_DATA_HOME", "")
if not path.strip():
path = os.path.expanduser("~/Library/Application Support") # noqa: PTH111
return self._append_app_name_and_version(path)

@property
def site_data_dir(self) -> str:
Expand All @@ -39,6 +48,10 @@ def site_data_dir(self) -> str:
the response is a multi-path string separated by ":", e.g.
``$homebrew_prefix/share/$appname/$version:/Library/Application Support/$appname/$version``
"""
xdg_dirs = os.environ.get("XDG_DATA_DIRS", "").strip()
if xdg_dirs:
dirs = [self._append_app_name_and_version(p) for p in xdg_dirs.split(os.pathsep) if p]
return os.pathsep.join(dirs) if self.multipath else dirs[0]
is_homebrew = "/opt/python" in sys.prefix
homebrew_prefix = sys.prefix.split("/opt/python")[0] if is_homebrew else ""
path_list = [self._append_app_name_and_version(f"{homebrew_prefix}/share")] if is_homebrew else []
Expand All @@ -54,18 +67,37 @@ def site_data_path(self) -> Path:

@property
def user_config_dir(self) -> str:
""":return: config directory tied to the user, same as `user_data_dir`"""
return self.user_data_dir
"""
:return: config directory tied to the user, e.g. ``~/Library/Application Support/$appname/$version`` or
``$XDG_CONFIG_HOME/$appname/$version`` if set. If ``XDG_CONFIG_HOME`` is not set, returns ``user_data_dir``.
"""
path = os.environ.get("XDG_CONFIG_HOME", "").strip()
if not path:
return self.user_data_dir
return self._append_app_name_and_version(path)

@property
def site_config_dir(self) -> str:
""":return: config directory shared by the users, same as `site_data_dir`"""
"""
:return: config directory shared by the users.
Honors ``XDG_CONFIG_DIRS`` if set (supports multipath), otherwise same as `site_data_dir`.
"""
xdg_dirs = os.environ.get("XDG_CONFIG_DIRS", "").strip()
if xdg_dirs:
dirs = [self._append_app_name_and_version(p) for p in xdg_dirs.split(os.pathsep) if p]
return os.pathsep.join(dirs) if self.multipath else dirs[0]
return self.site_data_dir

@property
def user_cache_dir(self) -> str:
""":return: cache directory tied to the user, e.g. ``~/Library/Caches/$appname/$version``"""
return self._append_app_name_and_version(os.path.expanduser("~/Library/Caches")) # noqa: PTH111
"""
:return: cache directory tied to the user, e.g. ``~/Library/Caches/$appname/$version`` or
``$XDG_CACHE_HOME/$appname/$version`` if set
"""
path = os.environ.get("XDG_CACHE_HOME", "")
if not path.strip():
path = os.path.expanduser("~/Library/Caches") # noqa: PTH111
return self._append_app_name_and_version(path)

@property
def site_cache_dir(self) -> str:
Expand All @@ -92,8 +124,14 @@ def site_cache_path(self) -> Path:

@property
def user_state_dir(self) -> str:
""":return: state directory tied to the user, same as `user_data_dir`"""
return self.user_data_dir
"""
:return: state directory tied to the user, e.g. ``~/Library/Application Support/$appname/$version`` or
``$XDG_STATE_HOME/$appname/$version`` if set. If ``XDG_STATE_HOME`` is not set, returns ``user_data_dir``.
"""
path = os.environ.get("XDG_STATE_HOME", "").strip()
if not path:
return self.user_data_dir
return self._append_app_name_and_version(path)

@property
def user_log_dir(self) -> str:
Expand All @@ -102,42 +140,60 @@ def user_log_dir(self) -> str:

@property
def user_documents_dir(self) -> str:
""":return: documents directory tied to the user, e.g. ``~/Documents``"""
return os.path.expanduser("~/Documents") # noqa: PTH111
""":return: documents directory tied to the user, e.g. ``~/Documents``, or ``$XDG_DOCUMENTS_DIR`` if set"""
env = os.environ.get("XDG_DOCUMENTS_DIR", "").strip()
return os.path.expanduser(env or "~/Documents") # noqa: PTH111

@property
def user_downloads_dir(self) -> str:
""":return: downloads directory tied to the user, e.g. ``~/Downloads``"""
return os.path.expanduser("~/Downloads") # noqa: PTH111
""":return: downloads directory tied to the user, e.g. ``~/Downloads``, or ``$XDG_DOWNLOAD_DIR`` if set"""
env = os.environ.get("XDG_DOWNLOAD_DIR", "").strip()
return os.path.expanduser(env or "~/Downloads") # noqa: PTH111

@property
def user_pictures_dir(self) -> str:
""":return: pictures directory tied to the user, e.g. ``~/Pictures``"""
return os.path.expanduser("~/Pictures") # noqa: PTH111
""":return: pictures directory tied to the user, e.g. ``~/Pictures``, or ``$XDG_PICTURES_DIR`` if set"""
env = os.environ.get("XDG_PICTURES_DIR", "").strip()
return os.path.expanduser(env or "~/Pictures") # noqa: PTH111

@property
def user_videos_dir(self) -> str:
""":return: videos directory tied to the user, e.g. ``~/Movies``"""
return os.path.expanduser("~/Movies") # noqa: PTH111
""":return: videos directory tied to the user, e.g. ``~/Movies``, or ``$XDG_VIDEOS_DIR`` if set"""
env = os.environ.get("XDG_VIDEOS_DIR", "").strip()
return os.path.expanduser(env or "~/Movies") # noqa: PTH111

@property
def user_music_dir(self) -> str:
""":return: music directory tied to the user, e.g. ``~/Music``"""
return os.path.expanduser("~/Music") # noqa: PTH111
""":return: music directory tied to the user, e.g. ``~/Music``, or ``$XDG_MUSIC_DIR`` if set"""
env = os.environ.get("XDG_MUSIC_DIR", "").strip()
return os.path.expanduser(env or "~/Music") # noqa: PTH111

@property
def user_desktop_dir(self) -> str:
""":return: desktop directory tied to the user, e.g. ``~/Desktop``"""
return os.path.expanduser("~/Desktop") # noqa: PTH111
""":return: desktop directory tied to the user, e.g. ``~/Desktop``, or ``$XDG_DESKTOP_DIR`` if set"""
env = os.environ.get("XDG_DESKTOP_DIR", "").strip()
return os.path.expanduser(env or "~/Desktop") # noqa: PTH111

@property
def user_runtime_dir(self) -> str:
""":return: runtime directory tied to the user, e.g. ``~/Library/Caches/TemporaryItems/$appname/$version``"""
return self._append_app_name_and_version(os.path.expanduser("~/Library/Caches/TemporaryItems")) # noqa: PTH111
"""
:return: runtime directory tied to the user, e.g. ``~/Library/Caches/TemporaryItems/$appname/$version`` or
``$XDG_RUNTIME_DIR/$appname/$version`` if set
"""
path = os.environ.get("XDG_RUNTIME_DIR", "").strip()
if not path:
path = os.path.expanduser("~/Library/Caches/TemporaryItems") # noqa: PTH111
return self._append_app_name_and_version(path)

@property
def site_runtime_dir(self) -> str:
""":return: runtime directory shared by users, same as `user_runtime_dir`"""
"""
:return: runtime directory shared by users. Honors ``$XDG_RUNTIME_DIR`` if set, otherwise same as
`user_runtime_dir`.
"""
path = os.environ.get("XDG_RUNTIME_DIR", "").strip()
if path:
return self._append_app_name_and_version(path)
return self.user_runtime_dir


Expand Down
108 changes: 108 additions & 0 deletions tests/test_macos.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,3 +128,111 @@ def test_macos_homebrew(mocker: MockerFixture, params: dict[str, Any], multipath
expected = expected_path_map[site_func] if site_func.endswith("_path") else expected_map[site_func]

assert result == expected


@pytest.fixture
def not_homebrew(mocker: MockerFixture) -> None:
"""Patch sys.prefix to something that is not Homebrew so defaults are macOS standard."""
py_version = sys.version_info
builtin_py_prefix = (
"/Applications/Xcode.app/Contents/Developer/Library/Frameworks/Python3.framework"
f"/Versions/{py_version.major}.{py_version.minor}"
)
mocker.patch("sys.prefix", builtin_py_prefix)


def test_user_data_dir_uses_xdg_data_home(mocker: MockerFixture, tmp_path: Path) -> None:
xdg = tmp_path / "xdg-data"
mocker.patch.dict(os.environ, {"XDG_DATA_HOME": str(xdg)}, clear=False)
got = MacOS(appname="app", version="1").user_data_dir
assert got == str(xdg / "app" / "1")


def test_user_cache_dir_uses_xdg_cache_home(mocker: MockerFixture, tmp_path: Path) -> None:
xdg = tmp_path / "xdg-cache"
mocker.patch.dict(os.environ, {"XDG_CACHE_HOME": str(xdg)}, clear=False)
got = MacOS(appname="app", version="1").user_cache_dir
assert got == str(xdg / "app" / "1")


def test_user_config_dir_uses_xdg_config_home_else_user_data(mocker: MockerFixture) -> None:
# When set
mocker.patch.dict(os.environ, {"XDG_CONFIG_HOME": "/cfg"}, clear=False)
got = MacOS(appname="a", version="v").user_config_dir
assert got == "/cfg/a/v"
# When not set (or empty/whitespace) it should return user_data_dir
mocker.patch.dict(os.environ, {"XDG_CONFIG_HOME": " "}, clear=False)
mac = MacOS(appname="a", version="v")
assert mac.user_config_dir == mac.user_data_dir


def test_user_state_dir_uses_xdg_state_home_else_user_data(mocker: MockerFixture) -> None:
mocker.patch.dict(os.environ, {"XDG_STATE_HOME": "/state"}, clear=False)
got = MacOS(appname="a", version="v").user_state_dir
assert got == "/state/a/v"
# whitespace -> fallback to user_data_dir
mocker.patch.dict(os.environ, {"XDG_STATE_HOME": "\t"}, clear=False)
mac = MacOS(appname="a", version="v")
assert mac.user_state_dir == mac.user_data_dir


@pytest.mark.parametrize(
("env_var", "func", "default"),
[
("XDG_DOCUMENTS_DIR", "user_documents_dir", "~/Documents"),
("XDG_DOWNLOAD_DIR", "user_downloads_dir", "~/Downloads"),
("XDG_PICTURES_DIR", "user_pictures_dir", "~/Pictures"),
("XDG_VIDEOS_DIR", "user_videos_dir", "~/Movies"),
("XDG_MUSIC_DIR", "user_music_dir", "~/Music"),
("XDG_DESKTOP_DIR", "user_desktop_dir", "~/Desktop"),
],
)
def test_media_dirs_use_xdg_and_strip_whitespace(mocker: MockerFixture, env_var: str, func: str, default: str) -> None:
# Uses provided value
mocker.patch.dict(os.environ, {env_var: "/XDG/MEDIA"}, clear=False)
assert getattr(MacOS(), func) == "/XDG/MEDIA"

# Whitespace-only should fallback to default
mocker.patch.dict(os.environ, {env_var: " "}, clear=False)
assert getattr(MacOS(), func) == str(Path(default).expanduser())


def test_user_runtime_dir_uses_xdg_runtime_when_set(mocker: MockerFixture, tmp_path: Path) -> None:
run = tmp_path / "run"
mocker.patch.dict(os.environ, {"XDG_RUNTIME_DIR": str(run)}, clear=False)
got = MacOS(appname="app", version="1").user_runtime_dir
assert got == str(run / "app" / "1")


@pytest.mark.usefixtures("not_homebrew")
def test_site_runtime_dir_uses_xdg_runtime_when_set(mocker: MockerFixture, tmp_path: Path) -> None:
run = tmp_path / "run"
mocker.patch.dict(os.environ, {"XDG_RUNTIME_DIR": str(run)}, clear=False)
got = MacOS(appname="app", version="1").site_runtime_dir
assert got == str(run / "app" / "1")


@pytest.mark.parametrize("multipath", [pytest.param(True, id="multipath"), pytest.param(False, id="single")])
@pytest.mark.usefixtures("not_homebrew")
def test_site_data_dir_honors_xdg_data_dirs(mocker: MockerFixture, multipath: bool) -> None:
env = "/share/one:/share/two"
mocker.patch.dict(os.environ, {"XDG_DATA_DIRS": env}, clear=False)
mac = MacOS(appname="a", version="v", multipath=multipath)
got = mac.site_data_dir
if multipath:
assert got == "/share/one/a/v:/share/two/a/v"
else:
assert got == "/share/one/a/v"


@pytest.mark.parametrize("multipath", [pytest.param(True, id="multipath"), pytest.param(False, id="single")])
@pytest.mark.usefixtures("not_homebrew")
def test_site_config_dir_honors_xdg_config_dirs(mocker: MockerFixture, multipath: bool) -> None:
env = "/etc/one:/etc/two"
mocker.patch.dict(os.environ, {"XDG_CONFIG_DIRS": env}, clear=False)
mac = MacOS(appname="a", version="v", multipath=multipath)
got = mac.site_config_dir
if multipath:
assert got == "/etc/one/a/v:/etc/two/a/v"
else:
assert got == "/etc/one/a/v"