diff --git a/ATTRIBUTIONS.md b/ATTRIBUTIONS.md index 8d12b5a..145b853 100644 --- a/ATTRIBUTIONS.md +++ b/ATTRIBUTIONS.md @@ -5191,6 +5191,40 @@ SOFTWARE. ``` +## pydantic-settings (2.13.1) - MIT License + +Settings management using Pydantic + +* URL: https://github.com/pydantic/pydantic-settings +* Author(s): Samuel Colvin , Eric Jolibois , Hasan Ramezani + +### License Text + +``` +The MIT License (MIT) + +Copyright (c) 2022 Samuel Colvin and other contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +``` + ## pydantic_core (2.41.5) - UNKNOWN Core functionality for Pydantic validation and serialization diff --git a/pyproject.toml b/pyproject.toml index 1c0056d..a6d2ad8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,7 @@ requires-python = ">=3.11, <3.15" dependencies = [ "pydantic>=2,<3", + "pydantic-settings>=2,<3", "rich>=14,<15", ] diff --git a/src/aignostics_foundry_core/AGENTS.md b/src/aignostics_foundry_core/AGENTS.md index b6ac971..b5f9560 100644 --- a/src/aignostics_foundry_core/AGENTS.md +++ b/src/aignostics_foundry_core/AGENTS.md @@ -10,6 +10,7 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei |--------|---------|-------------| | **console** | Themed terminal output | Module-level `console` object (Rich `Console`) with colour theme and `_get_console()` factory | | **health** | Service health checks | `Health` model and `HealthStatus` enum for tree-structured health status | +| **settings** | Pydantic settings loading | `OpaqueSettings`, `load_settings`, `strip_to_none_before_validator`, `UNHIDE_SENSITIVE_INFO` for env-based settings with secret masking and user-friendly validation errors | ## Module Descriptions @@ -28,6 +29,19 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei - **Location**: `aignostics_foundry_core/console.py` - **Dependencies**: `rich>=13` +### settings + +**Pydantic settings loading with secret masking and user-friendly validation errors** + +- **Purpose**: Provides reusable infrastructure for loading `pydantic-settings` classes from the environment, with secret masking and Rich-formatted validation error output +- **Key Features**: + - `UNHIDE_SENSITIVE_INFO: str` — context key constant to reveal secrets in `model_dump()` + - `strip_to_none_before_validator(v)` — before-validator that strips whitespace and converts empty strings to `None` + - `OpaqueSettings(BaseSettings)` — base class with `serialize_sensitive_info` (masks `SecretStr` fields) and `serialize_path_resolve` (resolves `Path` fields to absolute strings) + - `load_settings(settings_class)` — instantiates settings; on `ValidationError` prints a Rich `Panel` listing each invalid field and calls `sys.exit(78)` +- **Location**: `aignostics_foundry_core/settings.py` +- **Dependencies**: `pydantic>=2`, `pydantic-settings>=2`, `rich>=14` + ### health **Tree-structured health status for service health checks** diff --git a/src/aignostics_foundry_core/settings.py b/src/aignostics_foundry_core/settings.py new file mode 100644 index 0000000..01cd211 --- /dev/null +++ b/src/aignostics_foundry_core/settings.py @@ -0,0 +1,122 @@ +"""Utilities around Pydantic settings.""" + +import sys +from pathlib import Path +from typing import TypeVar + +from pydantic import FieldSerializationInfo, SecretStr, ValidationError +from pydantic_settings import BaseSettings +from rich.panel import Panel +from rich.text import Text + +from aignostics_foundry_core.console import console + +_T = TypeVar("_T", bound=BaseSettings) + +UNHIDE_SENSITIVE_INFO = "unhide_sensitive_info" + + +def strip_to_none_before_validator(v: str | None) -> str | None: + """Strip whitespace and return None for empty strings. + + Args: + v: The string to process, or None. + + Returns: + None if the input is None or whitespace-only, otherwise the stripped string. + """ + if v is None: + return None + v = v.strip() + if not v: + return None + return v + + +class OpaqueSettings(BaseSettings): + """Base settings class with secret masking and path resolution serializers.""" + + @staticmethod + def serialize_sensitive_info(input_value: SecretStr | None, info: FieldSerializationInfo) -> str | None: + """Serialize a SecretStr, masking it unless context requests unhiding. + + Args: + input_value: The secret value to serialize. + info: Pydantic serialization info, may carry context. + + Returns: + None for empty secrets, the secret value if unhide is requested, + otherwise the masked representation. + """ + if not input_value: + return None + if info.context and info.context.get(UNHIDE_SENSITIVE_INFO, False): + return input_value.get_secret_value() + return str(input_value) + + @staticmethod + def serialize_path_resolve(input_value: Path | None, _info: FieldSerializationInfo) -> str | None: + """Serialize a Path by resolving it to an absolute string. + + Args: + input_value: The path to resolve. + _info: Pydantic serialization info (unused). + + Returns: + None if input is `None` or has no path components (e.g. empty string), + otherwise the resolved absolute path string. + """ + if input_value is None or not input_value.parts: + return None + return str(input_value.resolve()) + + +def load_settings(settings_class: type[_T]) -> _T: + """Load settings with error handling and nice formatting. + + Args: + settings_class: The Pydantic settings class to instantiate. + + Returns: + Instance of the settings class. + + Raises: + SystemExit: If settings validation fails (exit code 78). + """ + try: + return settings_class() + except ValidationError as e: + errors = e.errors() + text = Text() + text.append( + "Validation error(s): \n\n", + style="debug", + ) + + prefix = settings_class.model_config.get("env_prefix", "") + for error in errors: + if error["loc"] and isinstance(error["loc"][0], str): + env_var = f"{prefix}{error['loc'][0]}".upper() + else: + env_var = prefix.rstrip("_").upper() + text.append(f"• {env_var}", style="yellow bold") + text.append(f": {error['msg']}\n") + + text.append( + "\nCheck settings defined in the process environment and in file ", + style="info", + ) + env_file = str(settings_class.model_config.get("env_file", ".env") or ".env") + text.append( + str(Path.cwd() / env_file), + style="bold blue underline", + ) + + console.print( + Panel( + text, + title="Configuration invalid!", + border_style="error", + ), + ) + sys.exit(78) diff --git a/tests/aignostics_foundry_core/settings_test.py b/tests/aignostics_foundry_core/settings_test.py new file mode 100644 index 0000000..97fff10 --- /dev/null +++ b/tests/aignostics_foundry_core/settings_test.py @@ -0,0 +1,183 @@ +"""Tests for the settings module.""" + +import os +from pathlib import Path +from typing import Any, cast +from unittest.mock import MagicMock, patch + +import pytest +from pydantic import RootModel, SecretStr, ValidationError +from pydantic_settings import SettingsConfigDict + +from aignostics_foundry_core.settings import ( + UNHIDE_SENSITIVE_INFO, + OpaqueSettings, + load_settings, + strip_to_none_before_validator, +) + +_SECRET_VALUE = "sensitive" # noqa: S105 +_MASKED_VALUE = "**********" + + +class _TheTestSettings(OpaqueSettings): + """Test settings class.""" + + test_value: str = "default" + secret_value: SecretStr | None = None + required_value: str + + +class _TheTestSettingsWithEnvPrefix(OpaqueSettings): + """Test settings class with an environment prefix.""" + + model_config = SettingsConfigDict(env_prefix="TEST_") + + value: str + + +def _make_info(context: dict[str, Any] | None) -> MagicMock: + """Create a mock FieldSerializationInfo with the given context.""" + info = MagicMock() + info.context = context + return info + + +class TestStripToNoneBeforeValidator: + """Tests for strip_to_none_before_validator.""" + + @pytest.mark.unit + def test_none_returns_none(self) -> None: + """Test that None is returned when None is passed.""" + assert strip_to_none_before_validator(None) is None + + @pytest.mark.unit + def test_empty_string_returns_none(self) -> None: + """Test that None is returned when an empty string is passed.""" + assert strip_to_none_before_validator("") is None + + @pytest.mark.unit + def test_whitespace_returns_none(self) -> None: + """Test that None is returned when a whitespace string is passed.""" + assert strip_to_none_before_validator(" \t\n ") is None + + @pytest.mark.unit + def test_valid_string_returns_stripped(self) -> None: + """Test that a stripped string is returned when a valid string is passed.""" + assert strip_to_none_before_validator(" test ") == "test" + + +class TestOpaqueSettings: + """Tests for OpaqueSettings static serializers.""" + + @pytest.mark.unit + def test_serialize_sensitive_info_unhide_true(self) -> None: + """Test that sensitive info is revealed when unhide_sensitive_info is True.""" + secret = SecretStr(_SECRET_VALUE) + result = OpaqueSettings.serialize_sensitive_info(secret, _make_info({UNHIDE_SENSITIVE_INFO: True})) + assert result == _SECRET_VALUE + + @pytest.mark.unit + def test_serialize_sensitive_info_unhide_false(self) -> None: + """Test that sensitive info is hidden when unhide_sensitive_info is False.""" + secret = SecretStr(_SECRET_VALUE) + result = OpaqueSettings.serialize_sensitive_info(secret, _make_info({UNHIDE_SENSITIVE_INFO: False})) + assert result == _MASKED_VALUE + + @pytest.mark.unit + def test_serialize_sensitive_info_empty_secret(self) -> None: + """Test that None is returned when the SecretStr is empty.""" + result = OpaqueSettings.serialize_sensitive_info(SecretStr(""), _make_info({})) + assert result is None + + @pytest.mark.unit + def test_serialize_sensitive_info_none_input(self) -> None: + """Test that None is returned when input_value is None.""" + result = OpaqueSettings.serialize_sensitive_info(cast("SecretStr", None), _make_info({})) + assert result is None + + @pytest.mark.unit + def test_serialize_sensitive_info_no_context(self) -> None: + """Test that sensitive info is hidden when no context is provided.""" + secret = SecretStr(_SECRET_VALUE) + result = OpaqueSettings.serialize_sensitive_info(secret, _make_info(None)) + assert result == _MASKED_VALUE + + @pytest.mark.unit + def test_serialize_path_resolve(self, tmp_path: Path) -> None: + """Test that Path is resolved correctly.""" + test_path = tmp_path / "test_file.txt" + test_path.touch() + result = OpaqueSettings.serialize_path_resolve(test_path, _make_info(None)) + assert result == str(test_path.resolve()) + + @pytest.mark.unit + def test_serialize_path_resolve_none(self) -> None: + """Test that None is returned when Path is None.""" + result = OpaqueSettings.serialize_path_resolve(cast("Path", None), _make_info(None)) + assert result is None + + @pytest.mark.unit + def test_serialize_path_resolve_empty_path(self) -> None: + """Test that None is returned for Path("") rather than resolving to CWD.""" + result = OpaqueSettings.serialize_path_resolve(Path(), _make_info(None)) + assert result is None + + +class TestLoadSettings: + """Tests for load_settings.""" + + @pytest.mark.unit + @patch.dict(os.environ, {"REQUIRED_VALUE": "test_value"}) + def test_load_settings_success(self) -> None: + """Test successful settings loading.""" + settings = load_settings(_TheTestSettings) + assert settings.test_value == "default" + assert settings.required_value == "test_value" + + @pytest.mark.unit + @patch.dict(os.environ, {"TEST_VALUE": "prefixed_value"}) + def test_load_settings_with_env_prefix(self) -> None: + """Test that settings with environment prefix work correctly.""" + settings = load_settings(_TheTestSettingsWithEnvPrefix) + assert settings.value == "prefixed_value" + + @pytest.mark.unit + @patch("sys.exit") + @patch("aignostics_foundry_core.settings.console.print") + def test_load_settings_validation_error_exits(self, mock_console_print: MagicMock, mock_exit: MagicMock) -> None: + """Test that validation error prints a Rich Panel and calls sys.exit(78).""" + from rich.panel import Panel + + load_settings(_TheTestSettings) + + mock_exit.assert_called_once_with(78) + assert mock_console_print.call_count == 1 + panel_arg = mock_console_print.call_args[0][0] + assert isinstance(panel_arg, Panel) + + @pytest.mark.unit + @patch("sys.exit") + @patch("aignostics_foundry_core.settings.console.print") + def test_load_settings_validation_error_integer_loc( + self, mock_console_print: MagicMock, mock_exit: MagicMock + ) -> None: + """Test that integer loc[0] falls back to the model prefix instead of "PREFIX_0".""" + # RootModel[list[int]] produces loc=(0,) where loc[0] is an integer + int_loc_error: ValidationError | None = None + try: + RootModel[list[int]].model_validate(["not_an_int"]) + except ValidationError as e: + int_loc_error = e + + assert int_loc_error is not None + assert isinstance(int_loc_error.errors()[0]["loc"][0], int) + + with patch.object(_TheTestSettingsWithEnvPrefix, "__new__", side_effect=int_loc_error): + load_settings(_TheTestSettingsWithEnvPrefix) + + mock_exit.assert_called_once_with(78) + panel_arg = mock_console_print.call_args[0][0] + panel_text = str(panel_arg.renderable) + assert "TEST_0" not in panel_text + assert "• TEST:" in panel_text diff --git a/uv.lock b/uv.lock index f15646f..9e21c6d 100644 --- a/uv.lock +++ b/uv.lock @@ -11,6 +11,7 @@ version = "0.0.0" source = { editable = "." } dependencies = [ { name = "pydantic" }, + { name = "pydantic-settings" }, { name = "rich" }, ] @@ -46,6 +47,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "pydantic", specifier = ">=2,<3" }, + { name = "pydantic-settings", specifier = ">=2,<3" }, { name = "rich", specifier = ">=14,<15" }, ] @@ -1297,6 +1299,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, ] +[[package]] +name = "pydantic-settings" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, +] + [[package]] name = "pygments" version = "2.19.2"