From 2e5e435d25788dfcaf987c448ff8d7c1a0e91948 Mon Sep 17 00:00:00 2001 From: Jeremy Stepp Date: Sat, 25 Sep 2021 14:23:27 -0500 Subject: [PATCH] Add user_documents_dir (#39) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add user_documents_dir property * Implement user_documents_dir for Unix * Implement user_documents_dir for MacOS * Implement user_documents_dir for Windows * Implement user_documents_dir on Android * Add appdirs compatibility tests for properties and public functions * Only run appdirs test_compatibility test when func is in appdirs * FInish get_user_dirs_folder doc string * Add user_documents_dir Unix tests * Only do xdg_variable tests for functions with a mapping in _func_to_path * Add user_documents_dir documentation * Fix mypy type complaint * Fix CI complaints * Make test_user_documents_dir_default unix test work on Windows * Add defaults for Windows CSIDL values The main reason for this is that there is no environment variable for the user documents directory. * Fix api.rst syling * Improve user_documents_dir in android.py * Simplify user_documents_dir in unix.py * Revert to .get() with if is None rather than try-except in get_win_folder_from_registry * Add informative link to get_user_dirs_folder doc string * Revert Windows default values * Revert to .get with is none in get_win_folder_from_env_vars * Remove user_documents_dir and path arguments * Switch Android `/data/media/0` for `/storage/emulated/0/Documents` * Remove unneded comment shortening * Fix tests * PR Feedback Signed-off-by: Bernát Gábor * Minor formatting and design changes in unix.py * Switch user_documents_dir Unix tests to mocker * Cache Android user_documents_dir * Use Context for Andoird user_documents_folder * Remove platformdirs.unix import in test_unix.py * Use lru_cache instead of cached_property cached_property isn't available in Python versions earlier than 3.8 * Put user_documents_dir fallback in get_win_folder_from_env_vars Revert None return and checks * fix: Move user_documents_dir env var fallback to before dict Co-authored-by: Bernát Gábor --- README.rst | 14 +++++++++- docs/api.rst | 6 +++++ src/platformdirs/__init__.py | 16 +++++++++++ src/platformdirs/__main__.py | 1 + src/platformdirs/android.py | 23 ++++++++++++++++ src/platformdirs/api.py | 10 +++++++ src/platformdirs/macos.py | 5 ++++ src/platformdirs/unix.py | 36 +++++++++++++++++++++++++ src/platformdirs/windows.py | 20 +++++++++----- tests/conftest.py | 1 + tests/test_android.py | 1 + tests/test_api.py | 4 +-- tests/test_comp_with_appdirs.py | 25 +++++++++++++++--- tests/test_unix.py | 47 +++++++++++++++++++++++++++++++-- whitelist.txt | 1 + 15 files changed, 196 insertions(+), 14 deletions(-) diff --git a/README.rst b/README.rst index b1a3b30..0ac81e3 100644 --- a/README.rst +++ b/README.rst @@ -40,6 +40,7 @@ This kind of thing is what the ``platformdirs`` module is for. - site data dir (``site_data_dir``) - site config dir (``site_config_dir``) - user log dir (``user_log_dir``) +- user documents dir (``user_documents_dir``) - user runtime dir (``user_runtime_dir``) And also: @@ -66,6 +67,8 @@ On macOS: '/Users/trentm/Library/Caches/SuperApp' >>> user_log_dir(appname, appauthor) '/Users/trentm/Library/Logs/SuperApp' + >>> user_documents_dir() + '/Users/trentm/Documents' >>> user_runtime_dir(appname, appauthor) '/Users/trentm/Library/Caches/TemporaryItems/SuperApp' @@ -84,6 +87,8 @@ On Windows 7: 'C:\\Users\\trentm\\AppData\\Local\\Acme\\SuperApp\\Cache' >>> user_log_dir(appname, appauthor) 'C:\\Users\\trentm\\AppData\\Local\\Acme\\SuperApp\\Logs' + >>> user_documents_dir() + 'C:\\Users\\trentm\\Documents' >>> user_runtime_dir(appname, appauthor) 'C:\\Users\\trentm\\AppData\\Local\\Temp\\Acme\\SuperApp' @@ -106,6 +111,8 @@ On Linux: '/home/trentm/.cache/SuperApp/log' >>> user_config_dir(appname) '/home/trentm/.config/SuperApp' + >>> user_documents_dir() + '/home/trentm/Documents' >>> user_runtime_dir(appname, appauthor) '/run/user/{os.getuid()}/SuperApp' >>> site_config_dir(appname) @@ -127,10 +134,11 @@ On Android:: '/data/data/com.termux/cache/SuperApp/log' >>> user_config_dir(appname) '/data/data/com.termux/shared_prefs/SuperApp' + >>> user_documents_dir() + '/storage/emulated/0/Documents' >>> user_runtime_dir(appname, appauthor) '/data/data/com.termux/cache/SuperApp/tmp' - ``PlatformDirs`` for convenience ================================ @@ -146,6 +154,8 @@ On Android:: '/Users/trentm/Library/Caches/SuperApp' >>> dirs.user_log_dir '/Users/trentm/Library/Logs/SuperApp' + >>> dirs.user_documents_dir + '/Users/trentm/Documents' >>> dirs.user_runtime_dir '/Users/trentm/Library/Caches/TemporaryItems/SuperApp' @@ -166,6 +176,8 @@ dirs:: '/Users/trentm/Library/Caches/SuperApp/1.0' >>> dirs.user_log_dir '/Users/trentm/Library/Logs/SuperApp/1.0' + >>> dirs.user_documents_dir + '/Users/trentm/Documents' >>> dirs.user_runtime_dir '/Users/trentm/Library/Caches/TemporaryItems/SuperApp/1.0' diff --git a/docs/api.rst b/docs/api.rst index c7649a3..44ab153 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -36,6 +36,12 @@ Logs directory .. autofunction:: platformdirs.user_log_dir .. autofunction:: platformdirs.user_log_path +User documents directory +------------------------ + +.. autofunction:: platformdirs.user_documents_dir +.. autofunction:: platformdirs.user_documents_path + Runtime directory ------------------- diff --git a/src/platformdirs/__init__.py b/src/platformdirs/__init__.py index 693b648..26032a3 100644 --- a/src/platformdirs/__init__.py +++ b/src/platformdirs/__init__.py @@ -144,6 +144,13 @@ def user_log_dir( return PlatformDirs(appname=appname, appauthor=appauthor, version=version, opinion=opinion).user_log_dir +def user_documents_dir() -> str: + """ + :returns: documents directory tied to the user + """ + return PlatformDirs().user_documents_dir + + def user_runtime_dir( appname: Optional[str] = None, appauthor: Union[str, None, "Literal[False]"] = None, @@ -272,6 +279,13 @@ def user_log_path( return PlatformDirs(appname=appname, appauthor=appauthor, version=version, opinion=opinion).user_log_path +def user_documents_path() -> Path: + """ + :returns: documents path tied to the user + """ + return PlatformDirs().user_documents_path + + def user_runtime_path( appname: Optional[str] = None, appauthor: Union[str, None, "Literal[False]"] = None, @@ -299,6 +313,7 @@ def user_runtime_path( "user_cache_dir", "user_state_dir", "user_log_dir", + "user_documents_dir", "user_runtime_dir", "site_data_dir", "site_config_dir", @@ -307,6 +322,7 @@ def user_runtime_path( "user_cache_path", "user_state_path", "user_log_path", + "user_documents_path", "user_runtime_path", "site_data_path", "site_config_path", diff --git a/src/platformdirs/__main__.py b/src/platformdirs/__main__.py index 039fcbb..ad22937 100644 --- a/src/platformdirs/__main__.py +++ b/src/platformdirs/__main__.py @@ -6,6 +6,7 @@ "user_cache_dir", "user_state_dir", "user_log_dir", + "user_documents_dir", "user_runtime_dir", "site_data_dir", "site_config_dir", diff --git a/src/platformdirs/android.py b/src/platformdirs/android.py index 2267559..7599045 100644 --- a/src/platformdirs/android.py +++ b/src/platformdirs/android.py @@ -56,6 +56,13 @@ def user_log_dir(self) -> str: path = os.path.join(path, "log") return path + @property + def user_documents_dir(self) -> str: + """ + :return: documents directory tied to the user e.g. ``/storage/emulated/0/Documents`` + """ + return _android_documents_folder() + @property def user_runtime_dir(self) -> str: """ @@ -89,6 +96,22 @@ def _android_folder() -> str: return result +@lru_cache(maxsize=1) +def _android_documents_folder() -> str: + """:return: documents folder for the Android OS""" + # Get directories with pyjnius + try: + from jnius import autoclass # noqa: SC200 + + Context = autoclass("android.content.Context") # noqa: SC200 + Environment = autoclass("android.os.Environment") + documents_dir: str = Context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS).getAbsolutePath() + except Exception: + documents_dir = "/storage/emulated/0/Documents" + + return documents_dir + + __all__ = [ "Android", ] diff --git a/src/platformdirs/api.py b/src/platformdirs/api.py index 43804ce..ebd96af 100644 --- a/src/platformdirs/api.py +++ b/src/platformdirs/api.py @@ -99,6 +99,11 @@ def user_state_dir(self) -> str: def user_log_dir(self) -> str: """:return: log directory tied to the user""" + @property + @abstractmethod + def user_documents_dir(self) -> str: + """:return: documents directory tied to the user""" + @property @abstractmethod def user_runtime_dir(self) -> str: @@ -139,6 +144,11 @@ def user_log_path(self) -> Path: """:return: log path tied to the user""" return Path(self.user_log_dir) + @property + def user_documents_path(self) -> Path: + """:return: documents path tied to the user""" + return Path(self.user_documents_dir) + @property def user_runtime_path(self) -> Path: """:return: runtime path tied to the user""" diff --git a/src/platformdirs/macos.py b/src/platformdirs/macos.py index acd562b..fffd1a7 100644 --- a/src/platformdirs/macos.py +++ b/src/platformdirs/macos.py @@ -46,6 +46,11 @@ 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")) + @property + def user_documents_dir(self) -> str: + """:return: documents directory tied to the user, e.g. ``~/Documents``""" + return os.path.expanduser("~/Documents") + @property def user_runtime_dir(self) -> str: """:return: runtime directory tied to the user, e.g. ``~/Library/Caches/TemporaryItems/$appname/$version``""" diff --git a/src/platformdirs/unix.py b/src/platformdirs/unix.py index 9f3fe7b..688b279 100644 --- a/src/platformdirs/unix.py +++ b/src/platformdirs/unix.py @@ -1,6 +1,8 @@ import os import sys +from configparser import ConfigParser from pathlib import Path +from typing import Optional from .api import PlatformDirsABC @@ -111,6 +113,19 @@ def user_log_dir(self) -> str: path = os.path.join(path, "log") return path + @property + def user_documents_dir(self) -> str: + """ + :return: documents directory tied to the user, e.g. ``~/Documents`` + """ + documents_dir = _get_user_dirs_folder("XDG_DOCUMENTS_DIR") + if documents_dir is None: + documents_dir = os.environ.get("XDG_DOCUMENTS_DIR", "").strip() + if not documents_dir: + documents_dir = os.path.expanduser("~/Documents") + + return documents_dir + @property def user_runtime_dir(self) -> str: """ @@ -139,6 +154,27 @@ def _first_item_as_path_if_multipath(self, directory: str) -> Path: return Path(directory) +def _get_user_dirs_folder(key: str) -> Optional[str]: + """Return directory from user-dirs.dirs config file. See https://freedesktop.org/wiki/Software/xdg-user-dirs/""" + user_dirs_config_path = os.path.join(Unix().user_config_dir, "user-dirs.dirs") + if os.path.exists(user_dirs_config_path): + parser = ConfigParser() + + with open(user_dirs_config_path) as stream: + # Add fake section header, so ConfigParser doesn't complain + parser.read_string(f"[top]\n{stream.read()}") + + if key not in parser["top"]: + return None + + path = parser["top"][key].strip('"') + # Handle relative home paths + path = path.replace("$HOME", os.path.expanduser("~")) + return path + + return None + + __all__ = [ "Unix", ] diff --git a/src/platformdirs/windows.py b/src/platformdirs/windows.py index 2944d0e..c75cf99 100644 --- a/src/platformdirs/windows.py +++ b/src/platformdirs/windows.py @@ -80,6 +80,13 @@ def user_log_dir(self) -> str: path = os.path.join(path, "Logs") return path + @property + def user_documents_dir(self) -> str: + """ + :return: documents directory tied to the user e.g. ``%USERPROFILE%\\Documents`` + """ + return os.path.normpath(get_win_folder("CSIDL_PERSONAL")) + @property def user_runtime_dir(self) -> str: """ @@ -92,6 +99,9 @@ def user_runtime_dir(self) -> str: def get_win_folder_from_env_vars(csidl_name: str) -> str: """Get folder from environment variables.""" + if csidl_name == "CSIDL_PERSONAL": # does not have an environment name + return os.path.join(os.path.normpath(os.environ["USERPROFILE"]), "Documents") + env_var_name = { "CSIDL_APPDATA": "APPDATA", "CSIDL_COMMON_APPDATA": "ALLUSERSPROFILE", @@ -116,6 +126,7 @@ def get_win_folder_from_registry(csidl_name: str) -> str: "CSIDL_APPDATA": "AppData", "CSIDL_COMMON_APPDATA": "Common AppData", "CSIDL_LOCAL_APPDATA": "Local AppData", + "CSIDL_PERSONAL": "Personal", }.get(csidl_name) if shell_folder_name is None: raise ValueError(f"Unknown CSIDL name: {csidl_name}") @@ -133,6 +144,7 @@ def get_win_folder_via_ctypes(csidl_name: str) -> str: "CSIDL_APPDATA": 26, "CSIDL_COMMON_APPDATA": 35, "CSIDL_LOCAL_APPDATA": 28, + "CSIDL_PERSONAL": 5, }.get(csidl_name) if csidl_const is None: raise ValueError(f"Unknown CSIDL name: {csidl_name}") @@ -141,12 +153,8 @@ def get_win_folder_via_ctypes(csidl_name: str) -> str: windll = getattr(ctypes, "windll") # noqa: B009 # using getattr to avoid false positive with mypy type checker windll.shell32.SHGetFolderPathW(None, csidl_const, None, 0, buf) - has_high_char = False # Downgrade to short path name if it has highbit chars. - for c in buf: - if ord(c) > 255: - has_high_char = True - break - if has_high_char: + # Downgrade to short path name if it has highbit chars. + if any(ord(c) > 255 for c in buf): buf2 = ctypes.create_unicode_buffer(1024) if windll.kernel32.GetShortPathNameW(buf.value, buf2, 1024): buf = buf2 diff --git a/tests/conftest.py b/tests/conftest.py index 18a3213..8d49f81 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,6 +9,7 @@ "user_cache_dir", "user_state_dir", "user_log_dir", + "user_documents_dir", "user_runtime_dir", "site_data_dir", "site_config_dir", diff --git a/tests/test_android.py b/tests/test_android.py index 2e69c60..90983db 100644 --- a/tests/test_android.py +++ b/tests/test_android.py @@ -48,6 +48,7 @@ def test_android(mocker: MockerFixture, params: Dict[str, Any], func: str) -> No "user_cache_dir": f"/data/data/com.example/cache{suffix}", "user_state_dir": f"/data/data/com.example/files{suffix}", "user_log_dir": f"/data/data/com.example/cache{suffix}{'' if params.get('opinion', True) is False else '/log'}", + "user_documents_dir": "/storage/emulated/0/Documents", "user_runtime_dir": f"/data/data/com.example/cache{suffix}{'' if not params.get('opinion', True) else '/tmp'}", } expected = expected_map[func] diff --git a/tests/test_api.py b/tests/test_api.py index 3eab854..cd9bf67 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -16,7 +16,7 @@ def test_package_metadata() -> None: def test_method_result_is_str(func: str) -> None: method = getattr(platformdirs, func) - result = method("MyApp", "MyCompany") + result = method() assert isinstance(result, str) @@ -28,7 +28,7 @@ def test_property_result_is_str(func: str) -> None: def test_method_result_is_path(func_path: str) -> None: method = getattr(platformdirs, func_path) - result = method("MyApp", "MyCompany") + result = method() assert isinstance(result, Path) diff --git a/tests/test_comp_with_appdirs.py b/tests/test_comp_with_appdirs.py index dcf107f..7190762 100644 --- a/tests/test_comp_with_appdirs.py +++ b/tests/test_comp_with_appdirs.py @@ -1,4 +1,5 @@ import sys +from inspect import getmembers, isfunction from typing import Any, Dict import appdirs @@ -6,8 +7,6 @@ import platformdirs -NEW_IN_PLATFORMDIRS = {"user_runtime_dir"} - def test_has_backward_compatible_class() -> None: from platformdirs import AppDirs @@ -15,6 +14,24 @@ def test_has_backward_compatible_class() -> None: assert AppDirs is platformdirs.PlatformDirs +def test_has_all_functions() -> None: + # Get all public function names from appdirs + appdirs_function_names = [f[0] for f in getmembers(appdirs, isfunction) if not f[0].startswith("_")] + + # Exception will be raised if any appdirs functions aren't in platformdirs. + for function_name in appdirs_function_names: + getattr(platformdirs, function_name) + + +def test_has_all_properties() -> None: + # Get names of all the properties of appdirs.AppDirs + appdirs_property_names = [p[0] for p in getmembers(appdirs.AppDirs, lambda member: isinstance(member, property))] + + # Exception will be raised if any appdirs.AppDirs properties aren't in platformdirs.AppDirs + for property_name in appdirs_property_names: + getattr(platformdirs.AppDirs, property_name) + + @pytest.mark.parametrize( "params", [ @@ -31,8 +48,10 @@ def test_has_backward_compatible_class() -> None: ], ) def test_compatibility(params: Dict[str, Any], func: str) -> None: - if func in NEW_IN_PLATFORMDIRS: + # Only test functions that are part of appdirs + if getattr(appdirs, func, None) is None: pytest.skip(f"`{func}` does not exist in `appdirs`") + if sys.platform == "darwin": msg = { # pragma: no cover "user_log_dir": "without appname produces NoneType error", diff --git a/tests/test_unix.py b/tests/test_unix.py index 66e85f9..b13d658 100644 --- a/tests/test_unix.py +++ b/tests/test_unix.py @@ -10,12 +10,46 @@ from platformdirs.unix import Unix +def test_user_documents_dir(mocker: MockerFixture) -> None: + example_path = "/home/example/ExampleDocumentsFolder" + mock = mocker.patch("platformdirs.unix._get_user_dirs_folder") + mock.return_value = example_path + assert Unix().user_documents_dir == example_path + + +def test_user_documents_dir_env_var(mocker: MockerFixture) -> None: + # Mock documents dir not being in user-dirs.dirs file + mock = mocker.patch("platformdirs.unix._get_user_dirs_folder") + mock.return_value = None + + example_path = "/home/example/ExampleDocumentsFolder" + mocker.patch.dict(os.environ, {"XDG_DOCUMENTS_DIR": example_path}) + + assert Unix().user_documents_dir == example_path + + +def test_user_documents_dir_default(mocker: MockerFixture) -> None: + # Mock documents dir not being in user-dirs.dirs file + mock = mocker.patch("platformdirs.unix._get_user_dirs_folder") + mock.return_value = None + + # Mock no XDG_DOCUMENTS_DIR env variable being set + mocker.patch.dict(os.environ, {"XDG_DOCUMENTS_DIR": ""}) + + # Mock home directory + mocker.patch.dict(os.environ, {"HOME": "/home/example"}) + # Mock home directory for running the test on Windows + mocker.patch.dict(os.environ, {"USERPROFILE": "/home/example"}) + + assert Unix().user_documents_dir == "/home/example/Documents" + + class XDGVariable(typing.NamedTuple): name: str default_value: str -def _func_to_path(func: str) -> XDGVariable: +def _func_to_path(func: str) -> typing.Optional[XDGVariable]: mapping = { "user_data_dir": XDGVariable("XDG_DATA_HOME", "~/.local/share"), "site_data_dir": XDGVariable("XDG_DATA_DIRS", f"/usr/local/share{os.pathsep}/usr/share"), @@ -26,7 +60,7 @@ def _func_to_path(func: str) -> XDGVariable: "user_log_dir": XDGVariable("XDG_CACHE_HOME", "~/.cache"), "user_runtime_dir": XDGVariable("XDG_RUNTIME_DIR", "/run/user/1234"), } - return mapping[func] + return mapping.get(func) @pytest.fixture() @@ -42,6 +76,9 @@ def _getuid(mocker: MockerFixture) -> None: @pytest.mark.usefixtures("_getuid") def test_xdg_variable_not_set(monkeypatch: MonkeyPatch, dirs_instance: Unix, func: str) -> None: xdg_variable = _func_to_path(func) + if xdg_variable is None: + return + monkeypatch.delenv(xdg_variable.name, raising=False) result = getattr(dirs_instance, func) assert result == os.path.expanduser(xdg_variable.default_value) @@ -50,6 +87,9 @@ def test_xdg_variable_not_set(monkeypatch: MonkeyPatch, dirs_instance: Unix, fun @pytest.mark.usefixtures("_getuid") def test_xdg_variable_empty_value(monkeypatch: MonkeyPatch, dirs_instance: Unix, func: str) -> None: xdg_variable = _func_to_path(func) + if xdg_variable is None: + return + monkeypatch.setenv(xdg_variable.name, "") result = getattr(dirs_instance, func) assert result == os.path.expanduser(xdg_variable.default_value) @@ -58,6 +98,9 @@ def test_xdg_variable_empty_value(monkeypatch: MonkeyPatch, dirs_instance: Unix, @pytest.mark.usefixtures("_getuid") def test_xdg_variable_custom_value(monkeypatch: MonkeyPatch, dirs_instance: Unix, func: str) -> None: xdg_variable = _func_to_path(func) + if xdg_variable is None: + return + monkeypatch.setenv(xdg_variable.name, "/tmp/custom-dir") result = getattr(dirs_instance, func) assert result == "/tmp/custom-dir" diff --git a/whitelist.txt b/whitelist.txt index 31e3ea5..1dd1935 100644 --- a/whitelist.txt +++ b/whitelist.txt @@ -10,6 +10,7 @@ csidl delenv dirs func +getmembers getuid highbit hkey