Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,21 @@

.. towncrier release notes start

********************
*********************
4.11.8 (2026-09-08)
********************
*********************

- Make :func:`~platformdirs.user_data_path`, :func:`~platformdirs.user_config_path`,
:func:`~platformdirs.user_preference_path` and :func:`~platformdirs.user_applications_path` return the first site entry
when root is redirected by ``use_site_for_root`` under ``multipath``, matching their ``site_*_path`` twins. They passed
the whole joined list to :class:`~pathlib.Path`, giving one unusable path such as ``/xdg/a/foo:/xdg/b/foo`` - by
:user:`darrenhuai`. :pr:`538`
:func:`~platformdirs.user_preference_path` and :func:`~platformdirs.user_applications_path` return the first site
entry when root is redirected by ``use_site_for_root`` under ``multipath``, matching their ``site_*_path`` twins. They
passed the whole joined list to :class:`~pathlib.Path`, giving one unusable path such as ``/xdg/a/foo:/xdg/b/foo`` -
by :user:`darrenhuai`. :pr:`538`
- Ignore relative paths in XDG Base Directory environment variables and use the existing platform fallback. Relative
entries in ``$XDG_DATA_DIRS`` and ``$XDG_CONFIG_DIRS`` are skipped. :pr:`540`
- Preserve literal percent signs in Unix ``user-dirs.dirs`` paths, including ``100% complete``, ``100%%`` and
``%(XDG_DESKTOP_DIR)s``. Continue to expand ``$HOME``. :pr:`542`
- Use the base Python installation to locate Homebrew site directories on macOS, preserving shared data, config, cache and
state paths inside virtual environments. :pr:`543`
- Use the base Python installation to locate Homebrew site directories on macOS, preserving shared data, config, cache
and state paths inside virtual environments. :pr:`543`

*********************
4.11.7 (2026-09-01)
Expand Down
5 changes: 5 additions & 0 deletions docs/changelog/547.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Raise a descriptive ``RuntimeError`` when the Android app storage folder cannot be detected, instead of crashing with
``TypeError: expected str, bytes or os.PathLike object, not NoneType``. The ``_android_folder`` helper is documented to
return ``None`` when it cannot find the base folder, but every directory property blindly passed that value to path
joining, producing an opaque error. Callers now get an actionable message and can catch the ``RuntimeError`` to fall
back (e.g. to the Unix implementation).
25 changes: 21 additions & 4 deletions src/platformdirs/android.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ class Android(PlatformDirsABC): # ruff:ignore[too-many-public-methods]
@property
def user_data_dir(self) -> str:
"""Data directory tied to the user, e.g. ``/data/user/<userid>/<packagename>/files/<AppName>``."""
return self._append_app_name_and_version(cast("str", _android_folder()), "files")
return self._append_app_name_and_version(_require_android_folder(), "files")

@property
def site_data_dir(self) -> str:
Expand All @@ -36,7 +36,7 @@ def site_data_dir(self) -> str:
@property
def user_config_dir(self) -> str:
"""Config directory tied to the user, e.g. ``/data/user/<userid>/<packagename>/shared_prefs/<AppName>``."""
return self._append_app_name_and_version(cast("str", _android_folder()), "shared_prefs")
return self._append_app_name_and_version(_require_android_folder(), "shared_prefs")

@property
def site_config_dir(self) -> str:
Expand All @@ -46,7 +46,7 @@ def site_config_dir(self) -> str:
@property
def user_cache_dir(self) -> str:
"""Cache directory tied to the user, e.g.,``/data/user/<userid>/<packagename>/cache/<AppName>``."""
return self._append_app_name_and_version(cast("str", _android_folder()), "cache")
return self._append_app_name_and_version(_require_android_folder(), "cache")

@property
def site_cache_dir(self) -> str:
Expand Down Expand Up @@ -135,7 +135,7 @@ def user_preference_dir(self) -> str:
@property
def user_bin_dir(self) -> str:
"""Bin directory tied to the user, e.g. ``/data/user/<userid>/<packagename>/files/bin``."""
return os.path.join(cast("str", _android_folder()), "files", "bin") # ruff:ignore[os-path-join]
return os.path.join(_require_android_folder(), "files", "bin") # ruff:ignore[os-path-join]

@property
def site_bin_dir(self) -> str:
Expand Down Expand Up @@ -215,6 +215,23 @@ def _android_folder() -> str | None: # ruff:ignore[complex-structure]
return result


def _require_android_folder() -> str:
"""Base folder for the Android OS.

:raises RuntimeError: if the base folder cannot be found (e.g. neither python4android nor pyjnius is available and
no Android app folder can be located on the ``sys.path``).

"""
folder = _android_folder()
if folder is None:
msg = (
"Cannot determine the base Android app folder - not running inside an Android app environment "
"(python4android or pyjnius unavailable and no Android app folder found on sys.path)"
)
raise RuntimeError(msg)
return folder


@lru_cache(maxsize=1)
def _android_documents_folder() -> str:
""":returns: documents folder for the Android OS"""
Expand Down
66 changes: 66 additions & 0 deletions tests/test_android_undetectable_folder.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
from __future__ import annotations

import sys
from typing import TYPE_CHECKING

import pytest

from platformdirs.android import Android

if TYPE_CHECKING:
from collections.abc import Iterator

_PROP_FAMILY = [
"user_data_dir",
"site_data_dir",
"user_config_dir",
"site_config_dir",
"user_cache_dir",
"site_cache_dir",
"user_state_dir",
"site_state_dir",
"user_log_dir",
"site_log_dir",
"user_runtime_dir",
"site_runtime_dir",
"user_bin_dir",
"site_bin_dir",
"user_preference_dir",
"user_applications_dir",
"site_applications_dir",
]


@pytest.fixture
def undetectable_android_folder(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
"""Make the real _android_folder detection return None, as documented, when the base folder cannot be found."""
from platformdirs.android import _android_folder # ruff:ignore[import-outside-top-level]

# A None entry in sys.modules makes ``import android``/``import jnius`` raise ImportError.
monkeypatch.setitem(sys.modules, "android", None)
monkeypatch.setitem(sys.modules, "jnius", None)
# No recognizable /data/.../files entry on sys.path either.
monkeypatch.setattr(sys, "path", [])

_android_folder.cache_clear()
yield
_android_folder.cache_clear() # do not leak the cached None into other tests


@pytest.mark.usefixtures("undetectable_android_folder")
def test_android_folder_is_none_when_undetectable() -> None:
"""Sanity check through the real detection code path."""
from platformdirs.android import _android_folder # ruff:ignore[import-outside-top-level]

assert _android_folder() is None # documented: base folder not found


@pytest.mark.usefixtures("undetectable_android_folder")
@pytest.mark.parametrize("prop", _PROP_FAMILY)
def test_dirs_do_not_raise_when_android_folder_undetectable(prop: str) -> None:
"""The properties must raise a descriptive RuntimeError instead of an opaque TypeError when undetectable."""
android = Android(appname="foo")

# Before the fix this raised TypeError: expected str, bytes or os.PathLike object, not NoneType.
with pytest.raises(RuntimeError, match="Cannot determine the base Android app folder"):
getattr(android, prop)