diff --git a/docs/changelog.rst b/docs/changelog.rst index 9aee8104..c07ee3e6 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -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) diff --git a/docs/changelog/545.bugfix.rst b/docs/changelog/545.bugfix.rst new file mode 100644 index 00000000..6933ab66 --- /dev/null +++ b/docs/changelog/545.bugfix.rst @@ -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`. diff --git a/docs/platforms.rst b/docs/platforms.rst index d1a74dbd..324c3863 100644 --- a/docs/platforms.rst +++ b/docs/platforms.rst @@ -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:: diff --git a/src/platformdirs/unix.py b/src/platformdirs/unix.py index 15f6aaaf..744c85cb 100644 --- a/src/platformdirs/unix.py +++ b/src/platformdirs/unix.py @@ -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 @@ -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\w+)[ \t]*=[ \t]*(?:"(?P(?:[^"\\]|\\.)*)"|(?P[^"\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__ = [ diff --git a/tests/test_unix.py b/tests/test_unix.py index c4484fe4..474c0c32 100644 --- a/tests/test_unix.py +++ b/tests/test_unix.py @@ -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)