From f2de0cfc9ff776f26d444dbeb745888a49dd1494 Mon Sep 17 00:00:00 2001 From: Herman Brunborg Date: Wed, 29 Jul 2026 18:32:56 -0700 Subject: [PATCH 1/2] Add support for global TOML configuration - Read user-level settings from ~/.config/furu/furu.toml - Verify precedence beneath pyproject and environment configuration --- src/furu/config.py | 7 ++++++ tests/test_config.py | 58 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/src/furu/config.py b/src/furu/config.py index 3554c8b0..c80c4dc6 100644 --- a/src/furu/config.py +++ b/src/furu/config.py @@ -8,6 +8,7 @@ PydanticBaseSettingsSource, PyprojectTomlConfigSettingsSource, SettingsConfigDict, + TomlConfigSettingsSource, ) _WORKER_JSON_CONFIG_FILE_ENV_VAR = "_FURU_WORKER_JSON_CONFIG_FILE" @@ -84,6 +85,12 @@ def settings_customise_sources( env_settings, dotenv_settings, PyprojectTomlConfigSettingsSource(settings_cls), + TomlConfigSettingsSource( + settings_cls, + Path(os.getenv("XDG_CONFIG_HOME", "~/.config")).expanduser() + / "furu" + / "furu.toml", + ), file_secret_settings, ) diff --git a/tests/test_config.py b/tests/test_config.py index aa6e9f5c..72485501 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -137,6 +137,64 @@ def test_config_discovers_pyproject_toml_in_parent_directory( ) +def test_config_reads_global_toml(tmp_path, monkeypatch) -> None: + config_home = tmp_path / ".config" + config_file = config_home / "furu" / "furu.toml" + config_file.parent.mkdir(parents=True) + config_file.write_text( + """ +debug_mode = true + +[worker] +connect_host = "login01.cluster" +idle_timeout_seconds = 7.5 +""", + encoding="utf-8", + ) + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + + config = _Config() + + assert config.debug_mode is True + assert config.worker.connect_host == "login01.cluster" + assert config.worker.idle_timeout_seconds == 7.5 + + +def test_pyproject_toml_overrides_global_toml(tmp_path, monkeypatch) -> None: + config_home = tmp_path / "config" + config_file = config_home / "furu" / "furu.toml" + config_file.parent.mkdir(parents=True) + config_file.write_text( + """ +debug_mode = false + +[worker] +idle_timeout_seconds = 7.5 +max_failed_restarts = 7 +""", + encoding="utf-8", + ) + (tmp_path / "pyproject.toml").write_text( + """ +[tool.furu] +debug_mode = true + +[tool.furu.worker] +idle_timeout_seconds = 12.5 +""", + encoding="utf-8", + ) + monkeypatch.setenv("XDG_CONFIG_HOME", str(config_home)) + monkeypatch.chdir(tmp_path) + + config = _Config() + + assert config.debug_mode is True + assert config.worker.idle_timeout_seconds == 12.5 + assert config.worker.max_failed_restarts == 7 + + def test_environment_overrides_pyproject_toml(tmp_path, monkeypatch) -> None: pyproject = tmp_path / "pyproject.toml" pyproject.write_text( From 2caa612c833d1cd61fe5ba0b103e6287e4536a94 Mon Sep 17 00:00:00 2001 From: Herman Brunborg Date: Thu, 30 Jul 2026 06:27:35 -0700 Subject: [PATCH 2/2] Restrict global TOML configuration to supported settings - Validate global configuration sections and keys - Add coverage for rejecting project-only settings --- src/furu/config.py | 29 +++++++++++++++++++++++- tests/test_config.py | 54 +++++++++++++++++++++++++++++++------------- 2 files changed, 66 insertions(+), 17 deletions(-) diff --git a/src/furu/config.py b/src/furu/config.py index c80c4dc6..6f9f7228 100644 --- a/src/furu/config.py +++ b/src/furu/config.py @@ -1,5 +1,6 @@ import os from pathlib import Path +from typing import Any from pydantic import BaseModel, ByteSize, ConfigDict, Field from pydantic_settings import ( @@ -7,11 +8,37 @@ JsonConfigSettingsSource, PydanticBaseSettingsSource, PyprojectTomlConfigSettingsSource, + SettingsError, SettingsConfigDict, TomlConfigSettingsSource, ) _WORKER_JSON_CONFIG_FILE_ENV_VAR = "_FURU_WORKER_JSON_CONFIG_FILE" +_GLOBAL_CONFIG_KEYS = { + "worker": { + "connect_host", + "idle_timeout_seconds", + "max_failed_restarts", + "max_retries_per_object", + }, + "provenance": {"max_snapshot_bytes"}, +} + + +class _GlobalTomlConfigSettingsSource(TomlConfigSettingsSource): + def __call__(self) -> dict[str, Any]: + values = super().__call__() + unsupported = set(values) - _GLOBAL_CONFIG_KEYS.keys() + for section, allowed in _GLOBAL_CONFIG_KEYS.items(): + if isinstance(section_values := values.get(section), dict): + unsupported |= { + f"{section}.{key}" for key in section_values if key not in allowed + } + if unsupported: + raise SettingsError( + f"Unsupported global Furu setting(s): {', '.join(sorted(unsupported))}" + ) + return values class _FuruDirectories(BaseModel): @@ -85,7 +112,7 @@ def settings_customise_sources( env_settings, dotenv_settings, PyprojectTomlConfigSettingsSource(settings_cls), - TomlConfigSettingsSource( + _GlobalTomlConfigSettingsSource( settings_cls, Path(os.getenv("XDG_CONFIG_HOME", "~/.config")).expanduser() / "furu" diff --git a/tests/test_config.py b/tests/test_config.py index 72485501..9a8472e4 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -2,6 +2,7 @@ import pytest from pydantic import ValidationError +from pydantic_settings import SettingsError from furu.config import ( _Config, @@ -143,11 +144,14 @@ def test_config_reads_global_toml(tmp_path, monkeypatch) -> None: config_file.parent.mkdir(parents=True) config_file.write_text( """ -debug_mode = true - [worker] connect_host = "login01.cluster" -idle_timeout_seconds = 7.5 +idle_timeout_seconds = 120 +max_failed_restarts = 8 +max_retries_per_object = 5 + +[provenance] +max_snapshot_bytes = "1GiB" """, encoding="utf-8", ) @@ -156,9 +160,11 @@ def test_config_reads_global_toml(tmp_path, monkeypatch) -> None: config = _Config() - assert config.debug_mode is True assert config.worker.connect_host == "login01.cluster" - assert config.worker.idle_timeout_seconds == 7.5 + assert config.worker.idle_timeout_seconds == 120 + assert config.worker.max_failed_restarts == 8 + assert config.worker.max_retries_per_object == 5 + assert config.provenance.max_snapshot_bytes == 1024**3 def test_pyproject_toml_overrides_global_toml(tmp_path, monkeypatch) -> None: @@ -167,21 +173,15 @@ def test_pyproject_toml_overrides_global_toml(tmp_path, monkeypatch) -> None: config_file.parent.mkdir(parents=True) config_file.write_text( """ -debug_mode = false - [worker] -idle_timeout_seconds = 7.5 -max_failed_restarts = 7 +connect_host = "global.cluster" """, encoding="utf-8", ) (tmp_path / "pyproject.toml").write_text( """ -[tool.furu] -debug_mode = true - [tool.furu.worker] -idle_timeout_seconds = 12.5 +connect_host = "project.cluster" """, encoding="utf-8", ) @@ -190,9 +190,31 @@ def test_pyproject_toml_overrides_global_toml(tmp_path, monkeypatch) -> None: config = _Config() - assert config.debug_mode is True - assert config.worker.idle_timeout_seconds == 12.5 - assert config.worker.max_failed_restarts == 7 + assert config.worker.connect_host == "project.cluster" + + +@pytest.mark.parametrize( + ("contents", "unsupported"), + [ + ("debug_mode = true", "debug_mode"), + ('[directories]\nobjects = "/tmp/furu-objects"', "directories"), + ("[provenance]\nsnapshot = false", "provenance.snapshot"), + ("[worker]\nunknown_setting = 5", "worker.unknown_setting"), + ], +) +def test_global_toml_rejects_project_settings( + contents, unsupported, tmp_path, monkeypatch +) -> None: + config_home = tmp_path / "config" + config_file = config_home / "furu" / "furu.toml" + config_file.parent.mkdir(parents=True) + config_file.write_text(contents, encoding="utf-8") + monkeypatch.setenv("XDG_CONFIG_HOME", str(config_home)) + + with pytest.raises(SettingsError) as error: + _Config() + + assert unsupported in str(error.value) def test_environment_overrides_pyproject_toml(tmp_path, monkeypatch) -> None: