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
11 changes: 7 additions & 4 deletions confuse/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
10 changes: 7 additions & 3 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
<https://github.com/beetbox/confuse/issues/98>`_

..
For plugin developers
Expand Down
13 changes: 8 additions & 5 deletions docs/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
21 changes: 16 additions & 5 deletions test/test_paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import tempfile
import unittest
from typing import ClassVar
from unittest.mock import patch

import confuse
import confuse.yaml_util
Expand Down Expand Up @@ -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)

Expand All @@ -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",
]
Expand All @@ -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",
Expand Down Expand Up @@ -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
Expand Down
214 changes: 214 additions & 0 deletions test/test_xdg_priority.py
Original file line number Diff line number Diff line change
@@ -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"]
2 changes: 1 addition & 1 deletion test/test_yaml.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading