diff --git a/src/platformdirs/macos.py b/src/platformdirs/macos.py index 30ab368..0754c0d 100644 --- a/src/platformdirs/macos.py +++ b/src/platformdirs/macos.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os import os.path import sys from typing import TYPE_CHECKING @@ -22,12 +23,20 @@ class MacOS(PlatformDirsABC): `version `, `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: @@ -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 [] @@ -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: @@ -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: @@ -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 diff --git a/tests/test_macos.py b/tests/test_macos.py index 65b3196..4f9b011 100644 --- a/tests/test_macos.py +++ b/tests/test_macos.py @@ -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"