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
4 changes: 4 additions & 0 deletions docs/changelog/545.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Read Unix ``user-dirs.dirs`` the way ``xdg-user-dir`` does: the last assignment to a directory wins, backslash escapes
inside the quotes are undone, text after the closing quote is ignored, and values that are neither ``$HOME``-relative
nor absolute are skipped. It was parsed as INI, so a repeated or stray line raised an exception and comments and escapes
ended up in :func:`~platformdirs.user_documents_dir` and the other media directories - by :user:`darrenhuai`.
4 changes: 3 additions & 1 deletion docs/platforms.rst
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,9 @@ See also: :ref:`api:User binary directory`

See also: :ref:`api:User documents directory`

On Unix, use percent signs as literal characters in ``user-dirs.dirs`` paths, for example ``$HOME/100% complete``.
On Unix, use percent signs as literal characters in ``user-dirs.dirs`` paths, for example ``$HOME/100% complete``. The
file is read the way ``xdg-user-dir`` reads it: the last line for a directory wins, shell escapes such as ``\"`` inside
the quotes are undone, and values that are neither ``$HOME``-relative nor absolute are ignored.

.. tab-set::

Expand Down
48 changes: 33 additions & 15 deletions src/platformdirs/unix.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@
from __future__ import annotations

import os
import re
import sys
from configparser import ConfigParser
from functools import cached_property
from pathlib import Path
from tempfile import gettempdir
from typing import TYPE_CHECKING, NoReturn
from typing import TYPE_CHECKING, Final, NoReturn

from ._xdg import XDGMixin, _xdg_dir
from .api import PlatformDirsABC
Expand Down Expand Up @@ -337,27 +337,45 @@ def _get_user_media_dir(env_var: str, fallback_tilde_path: str) -> str:
return os.path.expanduser(fallback_tilde_path) # ruff:ignore[os-path-expanduser]


_USER_DIRS_LINE: Final = re.compile(
r'[ \t]*(?P<key>\w+)[ \t]*=[ \t]*(?:"(?P<quoted>(?:[^"\\]|\\.)*)"|(?P<bare>[^"\s].*?)[ \t]*$)'
)


def _get_user_dirs_folder(key: str) -> str | None:
"""Return directory from user-dirs.dirs config file.

The file holds shell assignments, not INI, so it is read line by line the way ``xdg-user-dir`` reads it: the last
assignment to ``key`` wins, backslash escapes inside the quotes are undone, text after the closing quote is ignored,
and lines that do not parse or whose value is neither ``$HOME``-relative nor absolute are skipped.

See https://freedesktop.org/wiki/Software/xdg-user-dirs/.

"""
config_home = _xdg_dir("XDG_CONFIG_HOME") or os.path.expanduser("~/.config") # ruff:ignore[os-path-expanduser]
user_dirs_config_path = Path(config_home) / "user-dirs.dirs"
if user_dirs_config_path.exists():
parser = ConfigParser(interpolation=None)

with user_dirs_config_path.open() as stream:
parser.read_string(f"[top]\n{stream.read()}")

if key not in parser["top"]:
return None

path = parser["top"][key].strip('"')
return path.replace("$HOME", os.path.expanduser("~")) # ruff:ignore[os-path-expanduser]

return None
if not user_dirs_config_path.exists():
return None
folder = None
with user_dirs_config_path.open() as stream:
for line in stream:
if (entry := _USER_DIRS_LINE.match(line)) and entry["key"] == key:
folder = _resolve_user_dirs_value(entry) or folder
return folder


def _resolve_user_dirs_value(entry: re.Match[str]) -> str | None:
value: str = entry["bare"] if entry["quoted"] is None else entry["quoted"]
if value == "$HOME" or value.startswith("$HOME/"):
prefix, value = os.path.expanduser("~"), value.removeprefix("$HOME") # ruff:ignore[os-path-expanduser]
elif value.startswith("/"):
prefix = ""
else:
return None
if entry["quoted"] is not None:
# xdg-user-dirs-update backslash-escapes $, `, " and \ inside the quotes.
value = re.sub(r"\\(.)", r"\1", value)
return prefix + value


__all__ = [
Expand Down
43 changes: 43 additions & 0 deletions tests/test_unix.py
Original file line number Diff line number Diff line change
Expand Up @@ -747,3 +747,46 @@ def test_user_dirs_preserves_percent_signs(
f'XDG_DOCUMENTS_DIR="{base}/{folder}"\nXDG_DESKTOP_DIR="$HOME/Desktop"\n', encoding="utf-8"
)
assert Unix().user_documents_path == Path(tmp_path if base == "$HOME" else base) / folder


@pytest.mark.parametrize(
("content", "expected"),
[
pytest.param(
'XDG_DOCUMENTS_DIR="$HOME/Old"\nXDG_DOCUMENTS_DIR="$HOME/New"\n', "~/New", id="last-assignment-wins"
),
pytest.param(
'XDG_DESKTOP_DIR="$HOME/A"\nXDG_DESKTOP_DIR="$HOME/B"\nXDG_DOCUMENTS_DIR="$HOME/Docs"\n',
"~/Docs",
id="other-key-assigned-twice",
),
pytest.param('XDG_DOCUMENTS_DIR="$HOME/Docs"\nnot an assignment\n', "~/Docs", id="stray-line"),
pytest.param('XDG_DESKTOP_DIR="$HOME/Desktop"\n XDG_DOCUMENTS_DIR="$HOME/Docs"\n', "~/Docs", id="indented"),
pytest.param('XDG_DOCUMENTS_DIR="$HOME/Docs" # was "$HOME/Old"\n', "~/Docs", id="trailing-comment"),
pytest.param(
'XDG_DOCUMENTS_DIR="$HOME/My \\"Docs\\" \\$1 \\`x\\` a\\\\b"\n',
r'~/My "Docs" $1 `x` a\b',
id="shell-escapes",
),
pytest.param('XDG_DOCUMENTS_DIR="/data/$HOMEWORK"\n', "/data/$HOMEWORK", id="home-only-as-prefix"),
pytest.param('XDG_DOCUMENTS_DIR="$HOMEWORK/Docs"\n', "~/Documents", id="home-prefix-needs-slash"),
pytest.param('XDG_DOCUMENTS_DIR="$HOME"\n', "~", id="home-itself"),
pytest.param('XDG_DOCUMENTS_DIR="Docs"\n', "~/Documents", id="relative-ignored"),
pytest.param(
'XDG_DOCUMENTS_DIR="$HOME/Docs"\nXDG_DOCUMENTS_DIR="Docs"\n', "~/Docs", id="relative-reassignment-ignored"
),
pytest.param('XDG_DOCUMENTS_DIR="$HOME/Docs\n', "~/Documents", id="unterminated-ignored"),
pytest.param("XDG_DOCUMENTS_DIR=$HOME/Docs\n", "~/Docs", id="unquoted"),
],
)
def test_user_dirs_read_like_xdg_user_dir(
content: str, expected: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# user-dirs.dirs is shell, not INI: xdg-user-dir-lookup.c is the reference reader.
monkeypatch.delenv("XDG_DOCUMENTS_DIR", raising=False)
monkeypatch.setenv("XDG_CONFIG_HOME", "")
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
(tmp_path / ".config").mkdir()
(tmp_path / ".config" / "user-dirs.dirs").write_text(content, encoding="utf-8")
assert Unix().user_documents_dir == expected.replace("~", str(tmp_path), 1)