diff --git a/src/platformdirs/api.py b/src/platformdirs/api.py index 7b291734..626099a8 100644 --- a/src/platformdirs/api.py +++ b/src/platformdirs/api.py @@ -20,6 +20,7 @@ def __init__( version: Optional[str] = None, roaming: bool = False, multipath: bool = False, + force_xdg: bool = None, opinion: bool = True, ): """ @@ -54,6 +55,14 @@ def __init__( An optional parameter only applicable to Unix/Linux which indicates that the entire list of data dirs should be returned. By default, the first item would only be returned. """ + self.xdg_fallback = opinion if force_xdg is None else force_xdg + """ + Whether to use XDG's fallback behavior on all platforms for + consistency. Defaults to the value of `opinion`. + + This has no effect on the interpretation of `XDG_*_HOME` environment + variables, which are always used if set. + """ self.opinion = opinion #: A flag to indicating to use opinionated values. def _append_app_name_and_version(self, *base: str) -> str: diff --git a/src/platformdirs/macos.py b/src/platformdirs/macos.py index 4d4d5b02..34dfb8ca 100644 --- a/src/platformdirs/macos.py +++ b/src/platformdirs/macos.py @@ -1,9 +1,30 @@ import os +from functools import wraps +from typing import Callable from .api import PlatformDirsABC +from .unix import SUPPORTS_XDG, Unix -class MacOS(PlatformDirsABC): +def _xdg_fallback(func: Callable[[], str]) -> Callable[[], str]: + if func.__name__ not in SUPPORTS_XDG: + return func + + @wraps(func) + def wrapper(self: PlatformDirsABC) -> str: + if SUPPORTS_XDG.get(func.__name__) in os.environ: + return getattr(super(MacOS, self), func.__name__) + path = func.__get__(self, MacOS)() + if os.path.exists(path): + return path + if self.xdg_fallback: + return getattr(super(MacOS, self), func.__name__) + return path + + return wrapper + + +class MacOS(Unix, PlatformDirsABC): """ Platform directories for the macOS operating system. Follows the guidance from `Apple documentation `_. @@ -12,36 +33,43 @@ class MacOS(PlatformDirsABC): """ @property + @_xdg_fallback 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/")) @property + @_xdg_fallback def site_data_dir(self) -> str: """:return: data directory shared by users, e.g. ``/Library/Application Support/$appname/$version``""" return self._append_app_name_and_version("/Library/Application Support") @property + @_xdg_fallback def user_config_dir(self) -> str: """:return: config directory tied to the user, e.g. ``~/Library/Preferences/$appname/$version``""" return self._append_app_name_and_version(os.path.expanduser("~/Library/Preferences/")) @property + @_xdg_fallback def site_config_dir(self) -> str: """:return: config directory shared by the users, e.g. ``/Library/Preferences/$appname``""" return self._append_app_name_and_version("/Library/Preferences") @property + @_xdg_fallback 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")) @property + @_xdg_fallback def user_state_dir(self) -> str: """:return: state directory tied to the user, same as `user_data_dir`""" return self.user_data_dir @property + @_xdg_fallback def user_log_dir(self) -> str: """:return: log directory tied to the user, e.g. ``~/Library/Logs/$appname/$version``""" return self._append_app_name_and_version(os.path.expanduser("~/Library/Logs")) diff --git a/src/platformdirs/unix.py b/src/platformdirs/unix.py index 02beca29..4c21a91a 100644 --- a/src/platformdirs/unix.py +++ b/src/platformdirs/unix.py @@ -3,6 +3,17 @@ from .api import PlatformDirsABC +# Mapping between function name and relevant XDG var +SUPPORTS_XDG = { + "user_data_dir": "XDG_DATA_HOME", + # "site_data_dir": "", + "user_config_dir": "XDG_CONFIG_HOME", + # "site_config_dir": "", + "user_cache_dir": "XDG_CACHE_HOME", + "user_state_dir": "XDG_STATE_HOME", + "user_log_dir": "XDG_CACHE_HOME", +} + class Unix(PlatformDirsABC): """ @@ -99,6 +110,7 @@ def user_state_dir(self) -> str: path = os.path.expanduser("~/.local/state") return self._append_app_name_and_version(path) + # TODO: As per XDG spec, logs should be placed under XDG_STATE_HOME @property def user_log_dir(self) -> str: """ diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/common.py b/tests/common.py new file mode 100644 index 00000000..4b97272c --- /dev/null +++ b/tests/common.py @@ -0,0 +1,24 @@ +from platformdirs.macos import MacOS +from platformdirs.unix import Unix + +PARAMS = { + "no_args": {}, + "app_name": {"appname": "foo"}, + "app_name_with_app_author": {"appname": "foo", "appauthor": "bar"}, + "app_name_author_version": { + "appname": "foo", + "appauthor": "bar", + "version": "v1.0", + }, + "app_name_author_version_false_opinion": { + "appname": "foo", + "appauthor": "bar", + "version": "v1.0", + "opinion": False, + }, +} + +OS = { + "darwin": MacOS, + "unix": Unix, +} diff --git a/tests/conftest.py b/tests/conftest.py index d3e9eae6..7b306c7f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,10 @@ -from typing import Tuple, cast +import os +from pathlib import Path +from typing import Any, Dict, Tuple, cast import pytest from _pytest.fixtures import SubRequest +from pytest_mock import MockerFixture PROPS = ( "user_data_dir", @@ -29,3 +32,27 @@ def func_path(request: SubRequest) -> str: @pytest.fixture() def props() -> Tuple[str, ...]: return PROPS + + +@pytest.fixture +def mock_environ(mocker: MockerFixture) -> Dict[str, Any]: + mocker.patch("os.environ", {}) + return os.environ + + +@pytest.fixture +def mock_homedir( + mocker: MockerFixture, + mock_environ: dict, + tmp_path: Path, +) -> Path: + def _expanduser(s: str) -> str: + if s == "~": + return str(tmp_path) + if s.startswith("~/"): + return str(tmp_path / s[2:]) + return s + + mocker.patch("os.path.expanduser", _expanduser) + mock_environ["HOME"] = str(tmp_path) + return tmp_path diff --git a/tests/test_android.py b/tests/test_android.py index 19f9c410..a7085f36 100644 --- a/tests/test_android.py +++ b/tests/test_android.py @@ -8,23 +8,13 @@ from platformdirs.android import Android +from .common import PARAMS + @pytest.mark.parametrize( "params", - [ - {}, - {"appname": "foo"}, - {"appname": "foo", "appauthor": "bar"}, - {"appname": "foo", "appauthor": "bar", "version": "v1.0"}, - {"appname": "foo", "appauthor": "bar", "version": "v1.0", "opinion": False}, - ], - ids=[ - "no_args", - "app_name", - "app_name_with_app_author", - "app_name_author_version", - "app_name_author_version_false_opinion", - ], + PARAMS.values(), + ids=PARAMS.keys(), ) def test_android(mocker: MockerFixture, params: Dict[str, Any], func: str) -> None: mocker.patch("platformdirs.android._android_folder", return_value="/data/data/com.example", autospec=True) diff --git a/tests/test_xdg.py b/tests/test_xdg.py new file mode 100644 index 00000000..8bc42b05 --- /dev/null +++ b/tests/test_xdg.py @@ -0,0 +1,35 @@ +import os +from pathlib import Path +from typing import Any, Dict, Tuple, Union + +import pytest +from _pytest.fixtures import SubRequest +from pytest_mock import MockerFixture + +from platformdirs.macos import MacOS +from platformdirs.unix import SUPPORTS_XDG, Unix + +from .common import OS, PARAMS + + +@pytest.mark.parametrize("params", PARAMS.values(), ids=PARAMS.keys()) +@pytest.mark.parametrize("klass", OS.values(), ids=OS.keys()) +@pytest.mark.parametrize( + "case", + SUPPORTS_XDG.items(), + ids=(s.lower() for s in SUPPORTS_XDG.keys()), +) +def test_xdg_compliance_on_unix( + mocker: MockerFixture, + params: Dict[str, Any], + klass: Union[MacOS, Unix], + case: Tuple[str, str], + mock_environ: Dict[str, str], + mock_homedir: Path, +): + func, key = case + prefix = mock_environ[key] = os.path.expanduser(f"~/{func}") + + result: str = getattr(klass(**params), func) + + assert result.startswith(prefix)