Skip to content

Commit

Permalink
Add user_documents_dir (#39)
Browse files Browse the repository at this point in the history
* 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 <gaborjbernat@gmail.com>

* 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 <gaborjbernat@gmail.com>
  • Loading branch information
Jeremy Stepp and gaborbernat committed Sep 25, 2021
1 parent 90e9f20 commit 2e5e435
Show file tree
Hide file tree
Showing 15 changed files with 196 additions and 14 deletions.
14 changes: 13 additions & 1 deletion README.rst
Expand Up @@ -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:
Expand All @@ -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'
Expand All @@ -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'
Expand All @@ -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)
Expand All @@ -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
================================

Expand All @@ -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'
Expand All @@ -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'

Expand Down
6 changes: 6 additions & 0 deletions docs/api.rst
Expand Up @@ -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
-------------------

Expand Down
16 changes: 16 additions & 0 deletions src/platformdirs/__init__.py
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/platformdirs/__main__.py
Expand Up @@ -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",
Expand Down
23 changes: 23 additions & 0 deletions src/platformdirs/android.py
Expand Up @@ -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:
"""
Expand Down Expand Up @@ -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",
]
10 changes: 10 additions & 0 deletions src/platformdirs/api.py
Expand Up @@ -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:
Expand Down Expand Up @@ -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"""
Expand Down
5 changes: 5 additions & 0 deletions src/platformdirs/macos.py
Expand Up @@ -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``"""
Expand Down
36 changes: 36 additions & 0 deletions 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

Expand Down Expand Up @@ -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:
"""
Expand Down Expand Up @@ -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",
]
20 changes: 14 additions & 6 deletions src/platformdirs/windows.py
Expand Up @@ -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:
"""
Expand All @@ -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",
Expand All @@ -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}")
Expand All @@ -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}")
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions tests/conftest.py
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions tests/test_android.py
Expand Up @@ -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]
Expand Down
4 changes: 2 additions & 2 deletions tests/test_api.py
Expand Up @@ -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)


Expand All @@ -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)


Expand Down

0 comments on commit 2e5e435

Please sign in to comment.