diff --git a/src/furu/config.py b/src/furu/config.py index 3554c8b0..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,10 +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): @@ -84,6 +112,12 @@ def settings_customise_sources( env_settings, dotenv_settings, PyprojectTomlConfigSettingsSource(settings_cls), + _GlobalTomlConfigSettingsSource( + 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..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, @@ -137,6 +138,85 @@ 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( + """ +[worker] +connect_host = "login01.cluster" +idle_timeout_seconds = 120 +max_failed_restarts = 8 +max_retries_per_object = 5 + +[provenance] +max_snapshot_bytes = "1GiB" +""", + encoding="utf-8", + ) + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + + config = _Config() + + assert config.worker.connect_host == "login01.cluster" + 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: + config_home = tmp_path / "config" + config_file = config_home / "furu" / "furu.toml" + config_file.parent.mkdir(parents=True) + config_file.write_text( + """ +[worker] +connect_host = "global.cluster" +""", + encoding="utf-8", + ) + (tmp_path / "pyproject.toml").write_text( + """ +[tool.furu.worker] +connect_host = "project.cluster" +""", + encoding="utf-8", + ) + monkeypatch.setenv("XDG_CONFIG_HOME", str(config_home)) + monkeypatch.chdir(tmp_path) + + config = _Config() + + 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: pyproject = tmp_path / "pyproject.toml" pyproject.write_text(