Skip to content
Merged
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
34 changes: 34 additions & 0 deletions ATTRIBUTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <s@muelcolvin.com>, Eric Jolibois <em.jolibois@gmail.com>, Hasan Ramezani <hasan.r67@gmail.com>

### 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
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ requires-python = ">=3.11, <3.15"

dependencies = [
"pydantic>=2,<3",
"pydantic-settings>=2,<3",
"rich>=14,<15",
]

Expand Down
14 changes: 14 additions & 0 deletions src/aignostics_foundry_core/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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**
Expand Down
122 changes: 122 additions & 0 deletions src/aignostics_foundry_core/settings.py
Original file line number Diff line number Diff line change
@@ -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)
183 changes: 183 additions & 0 deletions tests/aignostics_foundry_core/settings_test.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading