From 970ab9a54cc4004e91d3ad22983c076aa354dff9 Mon Sep 17 00:00:00 2001 From: kkey <134298472+Imkkey@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:16:24 -0800 Subject: [PATCH] Prefer explicit XDG_CONFIG_HOME during configuration discovery --- confuse/util.py | 11 +- docs/changelog.rst | 10 +- docs/usage.rst | 13 ++- test/test_paths.py | 21 +++- test/test_xdg_priority.py | 214 ++++++++++++++++++++++++++++++++++++++ test/test_yaml.py | 2 +- 6 files changed, 253 insertions(+), 18 deletions(-) create mode 100644 test/test_xdg_priority.py diff --git a/confuse/util.py b/confuse/util.py index f8ffc34..41a2c2a 100644 --- a/confuse/util.py +++ b/confuse/util.py @@ -169,17 +169,20 @@ def config_dirs() -> list[str]: configuration directories on the system. The candidates are in order of priority, from highest to lowest. The - last element is the "fallback" location to be used when no - higher-priority config file exists. + first element is the "fallback" location to be used when no + configuration file exists in any candidate directory. """ paths = [] + system = platform.system() + if system != "Windows" and (config_home := os.environ.get("XDG_CONFIG_HOME")): + paths.append(config_home) - if platform.system() == "Darwin": + if system == "Darwin": paths.append(UNIX_DIR_FALLBACK) paths.append(MAC_DIR) paths.extend(xdg_config_dirs()) - elif platform.system() == "Windows": + elif system == "Windows": paths.append(WINDOWS_DIR_FALLBACK) if WINDOWS_DIR_VAR in os.environ: paths.append(os.environ[WINDOWS_DIR_VAR]) diff --git a/docs/changelog.rst b/docs/changelog.rst index 680346e..d7e7718 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -13,9 +13,13 @@ Unreleased New features ~~~~~~~~~~~~ -.. - Bug fixes - ~~~~~~~~~ +Bug fixes +~~~~~~~~~ + +- Give nonempty ``XDG_CONFIG_HOME`` priority over legacy configuration paths on + Unix and macOS, including the fallback for new configurations. Preserve the + remaining search paths and Windows behavior. `#98 + `_ .. For plugin developers diff --git a/docs/usage.rst b/docs/usage.rst index 598f293..aef0a85 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -321,16 +321,19 @@ list of directories in which it looks for a directory named after the application. For example, the first search location on Unix-y systems is ``$XDG_CONFIG_HOME/AppName`` for an application called ``AppName``. -Here are the default search paths for each platform: +Here are the default user-specific search paths for each platform: - macOS: ``~/.config/app`` and ``~/Library/Application Support/app`` -- Other Unix: ``~/.config/app`` and ``/etc/app`` +- Other Unix: ``~/.config/app`` - Windows: ``%APPDATA%\app`` where the ``APPDATA`` environment variable falls back to ``%HOME%\AppData\Roaming`` if undefined -Both macOS and other Unix operating sytems also try to use the -``XDG_CONFIG_HOME`` and ``XDG_CONFIG_DIRS`` environment variables if set then -search those directories as well. +On macOS and other Unix operating systems, a nonempty ``XDG_CONFIG_HOME`` is +searched before the default paths above. Those default locations remain in the +search path, followed by the directories in ``XDG_CONFIG_DIRS`` (or ``/etc/xdg`` +if unset) and ``/etc``. If no configuration file exists in any of these +locations, the first directory is used for a new configuration. Windows does not +use the XDG environment variables. Users can also add an override configuration directory with an environment variable. The variable name is the application name in capitals with "DIR" diff --git a/test/test_paths.py b/test/test_paths.py index 085a9f1..819c093 100644 --- a/test/test_paths.py +++ b/test/test_paths.py @@ -6,6 +6,7 @@ import tempfile import unittest from typing import ClassVar +from unittest.mock import patch import confuse import confuse.yaml_util @@ -33,10 +34,20 @@ class FakeHome(unittest.TestCase): def setUp(self): super().setUp() self.home = tempfile.mkdtemp() - os.environ["HOME"] = self.home - os.environ["USERPROFILE"] = self.home + self.environment = patch.dict( + "os.environ", + { + "HOME": self.home, + "USERPROFILE": self.home, + "XDG_CONFIG_HOME": os.path.join(self.home, "xdgconfig"), + "XDG_CONFIG_DIRS": os.path.join(self.home, "system"), + }, + clear=True, + ) + self.environment.start() def tearDown(self): + self.environment.stop() super().tearDown() shutil.rmtree(self.home) @@ -63,8 +74,8 @@ class LinuxTestCases(FakeSystem): def test_both_xdg_and_fallback_dirs(self): assert confuse.config_dirs() == [ - "/home/test/.config", "/home/test/xdgconfig", + "/home/test/.config", "/etc/xdg", "/etc", ] @@ -80,8 +91,8 @@ def test_xdg_matching_fallback_not_duplicated(self): def test_xdg_config_dirs(self): os.environ["XDG_CONFIG_DIRS"] = "/usr/local/etc/xdg:/etc/xdg" assert confuse.config_dirs() == [ - "/home/test/.config", "/home/test/xdgconfig", + "/home/test/.config", "/usr/local/etc/xdg", "/etc/xdg", "/etc", @@ -182,7 +193,7 @@ def setUp(self): self.config = confuse.Configuration("test", read=False) def test_create_dir_if_none_exists(self): - path = os.path.join(self.home, ".config", "test") + path = os.path.join(self.home, "xdgconfig", "test") assert not os.path.exists(path) assert self.config.config_dir() == path diff --git a/test/test_xdg_priority.py b/test/test_xdg_priority.py new file mode 100644 index 0000000..1ac1050 --- /dev/null +++ b/test/test_xdg_priority.py @@ -0,0 +1,214 @@ +"""Test config discovery in subprocesses with temporary homes and synthetic envs.""" + +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.skipif( + os.name == "nt", reason="Subprocess fixtures use POSIX temporary paths" +) + +SCRIPT = """ +import json +import ntpath +import os +import sys +from unittest.mock import patch + +import confuse + +system, action = sys.argv[1:] +real_isfile = os.path.isfile +root = os.getcwd() + os.sep + +def local_isfile(path): + return str(path).startswith(root) and real_isfile(path) + +with patch('platform.system', return_value=system): + if system == 'Windows': + os.path = ntpath + with patch('os.path.isfile', side_effect=local_isfile): + if action == 'dirs': + result = confuse.config_dirs() + else: + config = confuse.Configuration('xdg_priority_test', read=False) + if action == 'read': + config.read(user=True, defaults=False) + result = config['origin'].get() + else: + result = config.config_dir() + print(json.dumps(result)) +""" + + +@pytest.fixture +def run_config(tmp_path): + def run(system="Linux", *, env=None, action="dirs"): + environment = { + "HOME": str(tmp_path / "home"), + "USERPROFILE": str(tmp_path / "home"), + "PYTHONPATH": str(Path(__file__).resolve().parents[1]), + "PYTHONDONTWRITEBYTECODE": "1", + "XDG_CONFIG_DIRS": str(tmp_path / "system"), + } + environment.update(env or {}) + process = subprocess.run( + [sys.executable, "-c", SCRIPT, system, action], + cwd=tmp_path, + env=environment, + capture_output=True, + text=True, + check=True, + ) + return json.loads(process.stdout) + + return run + + +def write_config(directory, value): + path = directory / "xdg_priority_test" + path.mkdir(parents=True) + (path / "config.yaml").write_text(f"origin: {value}\n") + return path + + +@pytest.mark.parametrize("system", ["Linux", "FreeBSD", "Darwin"]) +def test_explicit_xdg_home_first_preserves_other_locations( + tmp_path, run_config, system +): + xdg = tmp_path / "xdg" + first_system = tmp_path / "system-one" + second_system = tmp_path / "system-two" + expected = [str(xdg), str(tmp_path / "home" / ".config")] + if system == "Darwin": + expected.append(str(tmp_path / "home" / "Library" / "Application Support")) + expected.extend([str(first_system), str(second_system), "/etc"]) + assert ( + run_config( + system, + env={ + "XDG_CONFIG_HOME": str(xdg), + "XDG_CONFIG_DIRS": f"{first_system}:{second_system}", + }, + ) + == expected + ) + + +@pytest.mark.parametrize("system", ["Linux", "Darwin"]) +@pytest.mark.parametrize("env", [{}, {"XDG_CONFIG_HOME": ""}]) +def test_unset_or_empty_keeps_legacy_first(tmp_path, run_config, system, env): + paths = run_config(system, env=env) + expected = [str(tmp_path / "home" / ".config")] + if system == "Darwin": + expected.append(str(tmp_path / "home" / "Library" / "Application Support")) + assert paths[: len(expected)] == expected + assert paths[-2:] == [str(tmp_path / "system"), "/etc"] + + +@pytest.mark.parametrize("system", ["Linux", "Darwin"]) +@pytest.mark.parametrize("xdg", ["~/.config", "~/xdg/../.config"]) +def test_expanded_duplicate_paths_still_deduplicated(tmp_path, run_config, system, xdg): + paths = run_config(system, env={"XDG_CONFIG_HOME": xdg}) + assert paths[0] == str(tmp_path / "home" / ".config") + assert len(paths) == len(set(paths)) + + +@pytest.mark.parametrize("system", ["Linux", "Darwin"]) +def test_config_from_xdg_wins_when_legacy_also_exists(tmp_path, run_config, system): + write_config(tmp_path / "home" / ".config", "legacy") + write_config(tmp_path / "home" / "Library" / "Application Support", "mac") + write_config(tmp_path / "xdg", "xdg") + assert ( + run_config( + system, env={"XDG_CONFIG_HOME": str(tmp_path / "xdg")}, action="read" + ) + == "xdg" + ) + + +@pytest.mark.parametrize("system", ["Linux", "Darwin"]) +def test_create_new_config_directory_under_xdg(tmp_path, run_config, system): + xdg = tmp_path / "xdg" + expected = xdg / "xdg_priority_test" + assert run_config( + system, env={"XDG_CONFIG_HOME": str(xdg)}, action="directory" + ) == str(expected) + assert expected.is_dir() + assert not (tmp_path / "home" / ".config").exists() + + +@pytest.mark.parametrize("system", ["Linux", "Darwin"]) +@pytest.mark.parametrize("env", [{}, {"XDG_CONFIG_HOME": ""}]) +def test_unset_or_empty_creation_keeps_legacy_fallback( + tmp_path, run_config, system, env +): + expected = tmp_path / "home" / ".config" / "xdg_priority_test" + assert run_config(system, env=env, action="directory") == str(expected) + assert expected.is_dir() + + +@pytest.mark.parametrize("system", ["Linux", "Darwin"]) +def test_existing_legacy_config_remains_available(tmp_path, run_config, system): + write_config(tmp_path / "home" / ".config", "legacy") + assert ( + run_config( + system, env={"XDG_CONFIG_HOME": str(tmp_path / "xdg")}, action="read" + ) + == "legacy" + ) + assert not (tmp_path / "xdg").exists() + + +def test_existing_macos_location_remains_available(tmp_path, run_config): + write_config(tmp_path / "home" / "Library" / "Application Support", "mac") + assert ( + run_config( + "Darwin", env={"XDG_CONFIG_HOME": str(tmp_path / "xdg")}, action="read" + ) + == "mac" + ) + + +@pytest.mark.parametrize("system", ["Linux", "Darwin"]) +def test_existing_system_config_remains_available(tmp_path, run_config, system): + write_config(tmp_path / "system", "system") + assert ( + run_config( + system, env={"XDG_CONFIG_HOME": str(tmp_path / "xdg")}, action="read" + ) + == "system" + ) + assert not (tmp_path / "xdg").exists() + + +@pytest.mark.parametrize("system", ["Linux", "Darwin"]) +def test_application_override_still_has_priority(tmp_path, run_config, system): + expected = tmp_path / "override" + assert run_config( + system, + env={ + "XDG_CONFIG_HOME": str(tmp_path / "xdg"), + "XDG_PRIORITY_TESTDIR": str(expected), + }, + action="directory", + ) == str(expected) + assert expected.is_dir() + assert not (tmp_path / "xdg").exists() + + +@pytest.mark.parametrize("xdg", ["", "C:\\xdg"]) +def test_windows_unchanged(run_config, xdg): + assert run_config( + "Windows", + env={ + "HOME": "C:\\Users\\test", + "USERPROFILE": "C:\\Users\\test", + "APPDATA": "C:\\custom", + "XDG_CONFIG_HOME": xdg, + }, + ) == ["C:\\Users\\test\\AppData\\Roaming", "C:\\custom"] diff --git a/test/test_yaml.py b/test/test_yaml.py index 7b636ac..0753633 100644 --- a/test/test_yaml.py +++ b/test/test_yaml.py @@ -41,7 +41,7 @@ def test_syntax_error(self): def test_reload_conf(self): with TempDir() as temp: path = temp.sub("test_config.yaml", b"foo: bar") - config = confuse.Configuration("test", __name__) + config = confuse.Configuration("test", __name__, read=False) config.set_file(filename=path) assert config["foo"].get() == "bar" temp.sub("test_config.yaml", b"foo: bar2\ntest: hello world")