From 7aa3d01b44dfa02e2ae7e614743afc07ed13393d Mon Sep 17 00:00:00 2001 From: Grzegorz Bokota Date: Sat, 30 Aug 2025 20:32:02 +0200 Subject: [PATCH 1/3] Add support for `XDG_*` environment variables --- src/platformdirs/macos.py | 97 +++++++++++++++++++++++++--------- tests/test_macos.py | 106 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 179 insertions(+), 24 deletions(-) diff --git a/src/platformdirs/macos.py b/src/platformdirs/macos.py index 30ab368..bd5731a 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,19 @@ 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 +47,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 +66,34 @@ 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 +120,13 @@ 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 +135,58 @@ 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..2dabc09 100644 --- a/tests/test_macos.py +++ b/tests/test_macos.py @@ -128,3 +128,109 @@ 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) -> None: + mocker.patch.dict(os.environ, {"XDG_DATA_HOME": "/tmp/xdg-data"}, clear=False) + got = MacOS(appname="app", version="1").user_data_dir + assert got == "/tmp/xdg-data/app/1" + + +def test_user_cache_dir_uses_xdg_cache_home(mocker: MockerFixture) -> None: + mocker.patch.dict(os.environ, {"XDG_CACHE_HOME": "/tmp/xdg-cache"}, clear=False) + got = MacOS(appname="app", version="1").user_cache_dir + assert got == "/tmp/xdg-cache/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) -> None: + mocker.patch.dict(os.environ, {"XDG_RUNTIME_DIR": "/tmp/run"}, clear=False) + got = MacOS(appname="app", version="1").user_runtime_dir + assert got == "/tmp/run/app/1" + + +@pytest.mark.usefixtures("not_homebrew") +def test_site_runtime_dir_uses_xdg_runtime_when_set(mocker: MockerFixture) -> None: + mocker.patch.dict(os.environ, {"XDG_RUNTIME_DIR": "/tmp/run"}, clear=False) + got = MacOS(appname="app", version="1").site_runtime_dir + assert got == "/tmp/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" \ No newline at end of file From 7c232a3949a95b206414367efac32e6584ee8b7f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 30 Aug 2025 18:33:45 +0000 Subject: [PATCH 2/3] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/platformdirs/macos.py | 33 ++++++++++++++++++++------------- tests/test_macos.py | 10 ++++------ 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/src/platformdirs/macos.py b/src/platformdirs/macos.py index bd5731a..0754c0d 100644 --- a/src/platformdirs/macos.py +++ b/src/platformdirs/macos.py @@ -29,8 +29,9 @@ class MacOS(PlatformDirsABC): @property def user_data_dir(self) -> str: - """:return: data directory tied to the user, e.g. ``~/Library/Application Support/$appname/$version`` or - ``$XDG_DATA_HOME/$appname/$version`` if set + """ + :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(): @@ -66,8 +67,9 @@ def site_data_path(self) -> Path: @property def user_config_dir(self) -> str: - """: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``. + """ + :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: @@ -76,8 +78,9 @@ def user_config_dir(self) -> str: @property def site_config_dir(self) -> str: - """:return: config directory shared by the users. - Honors ``XDG_CONFIG_DIRS`` if set (supports multipath), otherwise 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: @@ -87,8 +90,9 @@ def site_config_dir(self) -> str: @property def user_cache_dir(self) -> str: - """:return: cache directory tied to the user, e.g. ``~/Library/Caches/$appname/$version`` or - ``$XDG_CACHE_HOME/$appname/$version`` if set + """ + :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(): @@ -120,8 +124,9 @@ def site_cache_path(self) -> Path: @property def user_state_dir(self) -> str: - """: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``. + """ + :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: @@ -171,8 +176,9 @@ def user_desktop_dir(self) -> str: @property def user_runtime_dir(self) -> str: - """:return: runtime directory tied to the user, e.g. ``~/Library/Caches/TemporaryItems/$appname/$version`` or - ``$XDG_RUNTIME_DIR/$appname/$version`` if set + """ + :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: @@ -181,7 +187,8 @@ def user_runtime_dir(self) -> str: @property def site_runtime_dir(self) -> str: - """:return: runtime directory shared by users. Honors ``$XDG_RUNTIME_DIR`` if set, otherwise same as + """ + :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() diff --git a/tests/test_macos.py b/tests/test_macos.py index 2dabc09..0e05341 100644 --- a/tests/test_macos.py +++ b/tests/test_macos.py @@ -130,7 +130,7 @@ def test_macos_homebrew(mocker: MockerFixture, params: dict[str, Any], multipath assert result == expected -@pytest.fixture() +@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 @@ -175,7 +175,7 @@ def test_user_state_dir_uses_xdg_state_home_else_user_data(mocker: MockerFixture @pytest.mark.parametrize( - "env_var,func,default", + ("env_var", "func", "default"), [ ("XDG_DOCUMENTS_DIR", "user_documents_dir", "~/Documents"), ("XDG_DOWNLOAD_DIR", "user_downloads_dir", "~/Downloads"), @@ -185,9 +185,7 @@ def test_user_state_dir_uses_xdg_state_home_else_user_data(mocker: MockerFixture ("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: +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" @@ -233,4 +231,4 @@ def test_site_config_dir_honors_xdg_config_dirs(mocker: MockerFixture, multipath if multipath: assert got == "/etc/one/a/v:/etc/two/a/v" else: - assert got == "/etc/one/a/v" \ No newline at end of file + assert got == "/etc/one/a/v" From e820fbafaa3985dd839d788621bb54fba589f816 Mon Sep 17 00:00:00 2001 From: Grzegorz Bokota Date: Sat, 30 Aug 2025 20:44:07 +0200 Subject: [PATCH 3/3] fix pre-commit --- tests/test_macos.py | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/tests/test_macos.py b/tests/test_macos.py index 0e05341..4f9b011 100644 --- a/tests/test_macos.py +++ b/tests/test_macos.py @@ -141,16 +141,18 @@ def not_homebrew(mocker: MockerFixture) -> None: mocker.patch("sys.prefix", builtin_py_prefix) -def test_user_data_dir_uses_xdg_data_home(mocker: MockerFixture) -> None: - mocker.patch.dict(os.environ, {"XDG_DATA_HOME": "/tmp/xdg-data"}, clear=False) +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 == "/tmp/xdg-data/app/1" + assert got == str(xdg / "app" / "1") -def test_user_cache_dir_uses_xdg_cache_home(mocker: MockerFixture) -> None: - mocker.patch.dict(os.environ, {"XDG_CACHE_HOME": "/tmp/xdg-cache"}, clear=False) +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 == "/tmp/xdg-cache/app/1" + assert got == str(xdg / "app" / "1") def test_user_config_dir_uses_xdg_config_home_else_user_data(mocker: MockerFixture) -> None: @@ -195,17 +197,19 @@ def test_media_dirs_use_xdg_and_strip_whitespace(mocker: MockerFixture, env_var: assert getattr(MacOS(), func) == str(Path(default).expanduser()) -def test_user_runtime_dir_uses_xdg_runtime_when_set(mocker: MockerFixture) -> None: - mocker.patch.dict(os.environ, {"XDG_RUNTIME_DIR": "/tmp/run"}, clear=False) +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 == "/tmp/run/app/1" + assert got == str(run / "app" / "1") @pytest.mark.usefixtures("not_homebrew") -def test_site_runtime_dir_uses_xdg_runtime_when_set(mocker: MockerFixture) -> None: - mocker.patch.dict(os.environ, {"XDG_RUNTIME_DIR": "/tmp/run"}, clear=False) +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 == "/tmp/run/app/1" + assert got == str(run / "app" / "1") @pytest.mark.parametrize("multipath", [pytest.param(True, id="multipath"), pytest.param(False, id="single")])