Skip to content

Commit 1c9324a

Browse files
olivermeyerclaude
andcommitted
feat(settings): add OpaqueSettings and load_settings
Port OpaqueSettings, load_settings, strip_to_none_before_validator, and UNHIDE_SENSITIVE_INFO from bridge into foundry-python-core so downstream Foundry components can use shared settings infrastructure without coupling to bridge. - Add pydantic-settings>=2,<3 runtime dependency - Create aignostics_foundry_core/settings.py with the four public names - 14 unit tests with 100% branch coverage - Update AGENTS.md module index with settings entry Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 29ef19e commit 1c9324a

6 files changed

Lines changed: 335 additions & 0 deletions

File tree

ATTRIBUTIONS.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5191,6 +5191,40 @@ SOFTWARE.
51915191
51925192
```
51935193

5194+
## pydantic-settings (2.13.1) - MIT License
5195+
5196+
Settings management using Pydantic
5197+
5198+
* URL: https://github.com/pydantic/pydantic-settings
5199+
* Author(s): Samuel Colvin <s@muelcolvin.com>, Eric Jolibois <em.jolibois@gmail.com>, Hasan Ramezani <hasan.r67@gmail.com>
5200+
5201+
### License Text
5202+
5203+
```
5204+
The MIT License (MIT)
5205+
5206+
Copyright (c) 2022 Samuel Colvin and other contributors
5207+
5208+
Permission is hereby granted, free of charge, to any person obtaining a copy
5209+
of this software and associated documentation files (the "Software"), to deal
5210+
in the Software without restriction, including without limitation the rights
5211+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
5212+
copies of the Software, and to permit persons to whom the Software is
5213+
furnished to do so, subject to the following conditions:
5214+
5215+
The above copyright notice and this permission notice shall be included in all
5216+
copies or substantial portions of the Software.
5217+
5218+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
5219+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
5220+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
5221+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
5222+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
5223+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
5224+
SOFTWARE.
5225+
5226+
```
5227+
51945228
## pydantic_core (2.41.5) - UNKNOWN
51955229

51965230
Core functionality for Pydantic validation and serialization

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ requires-python = ">=3.11, <3.15"
5151

5252
dependencies = [
5353
"pydantic>=2,<3",
54+
"pydantic-settings>=2,<3",
5455
"rich>=14,<15",
5556
]
5657

src/aignostics_foundry_core/AGENTS.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei
1010
|--------|---------|-------------|
1111
| **console** | Themed terminal output | Module-level `console` object (Rich `Console`) with colour theme and `_get_console()` factory |
1212
| **health** | Service health checks | `Health` model and `HealthStatus` enum for tree-structured health status |
13+
| **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 |
1314

1415
## Module Descriptions
1516

@@ -28,6 +29,19 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei
2829
- **Location**: `aignostics_foundry_core/console.py`
2930
- **Dependencies**: `rich>=13`
3031

32+
### settings
33+
34+
**Pydantic settings loading with secret masking and user-friendly validation errors**
35+
36+
- **Purpose**: Provides reusable infrastructure for loading `pydantic-settings` classes from the environment, with secret masking and Rich-formatted validation error output
37+
- **Key Features**:
38+
- `UNHIDE_SENSITIVE_INFO: str` — context key constant to reveal secrets in `model_dump()`
39+
- `strip_to_none_before_validator(v)` — before-validator that strips whitespace and converts empty strings to `None`
40+
- `OpaqueSettings(BaseSettings)` — base class with `serialize_sensitive_info` (masks `SecretStr` fields) and `serialize_path_resolve` (resolves `Path` fields to absolute strings)
41+
- `load_settings(settings_class)` — instantiates settings; on `ValidationError` prints a Rich `Panel` listing each invalid field and calls `sys.exit(78)`
42+
- **Location**: `aignostics_foundry_core/settings.py`
43+
- **Dependencies**: `pydantic>=2`, `pydantic-settings>=2`, `rich>=14`
44+
3145
### health
3246

3347
**Tree-structured health status for service health checks**
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
"""Utilities around Pydantic settings."""
2+
3+
import json
4+
import sys
5+
from pathlib import Path
6+
from typing import TypeVar
7+
8+
from pydantic import FieldSerializationInfo, SecretStr, ValidationError
9+
from pydantic_settings import BaseSettings
10+
from rich.panel import Panel
11+
from rich.text import Text
12+
13+
from aignostics_foundry_core.console import console
14+
15+
_T = TypeVar("_T", bound=BaseSettings)
16+
17+
UNHIDE_SENSITIVE_INFO = "unhide_sensitive_info"
18+
19+
20+
def strip_to_none_before_validator(v: str | None) -> str | None:
21+
"""Strip whitespace and return None for empty strings.
22+
23+
Args:
24+
v: The string to process, or None.
25+
26+
Returns:
27+
None if the input is None or whitespace-only, otherwise the stripped string.
28+
"""
29+
if v is None:
30+
return None
31+
v = v.strip()
32+
if not v:
33+
return None
34+
return v
35+
36+
37+
class OpaqueSettings(BaseSettings):
38+
"""Base settings class with secret masking and path resolution serializers."""
39+
40+
@staticmethod
41+
def serialize_sensitive_info(input_value: SecretStr, info: FieldSerializationInfo) -> str | None:
42+
"""Serialize a SecretStr, masking it unless context requests unhiding.
43+
44+
Args:
45+
input_value: The secret value to serialize.
46+
info: Pydantic serialization info, may carry context.
47+
48+
Returns:
49+
None for empty secrets, the secret value if unhide is requested,
50+
otherwise the masked representation.
51+
"""
52+
if not input_value:
53+
return None
54+
if info.context and info.context.get(UNHIDE_SENSITIVE_INFO, False):
55+
return input_value.get_secret_value()
56+
return str(input_value)
57+
58+
@staticmethod
59+
def serialize_path_resolve(input_value: Path, _info: FieldSerializationInfo) -> str | None:
60+
"""Serialize a Path by resolving it to an absolute string.
61+
62+
Args:
63+
input_value: The path to resolve.
64+
_info: Pydantic serialization info (unused).
65+
66+
Returns:
67+
None if input is falsy, otherwise the resolved absolute path string.
68+
"""
69+
if not input_value:
70+
return None
71+
return str(input_value.resolve())
72+
73+
74+
def load_settings(settings_class: type[_T]) -> _T:
75+
"""Load settings with error handling and nice formatting.
76+
77+
Args:
78+
settings_class: The Pydantic settings class to instantiate.
79+
80+
Returns:
81+
Instance of the settings class.
82+
83+
Raises:
84+
SystemExit: If settings validation fails (exit code 78).
85+
"""
86+
try:
87+
return settings_class()
88+
except ValidationError as e:
89+
errors = json.loads(e.json())
90+
text = Text()
91+
text.append(
92+
"Validation error(s): \n\n",
93+
style="debug",
94+
)
95+
96+
prefix = settings_class.model_config.get("env_prefix", "")
97+
for error in errors:
98+
env_var = f"{prefix}{error['loc'][0]}".upper() if error["loc"] else prefix.rstrip("_").upper()
99+
text.append(f"• {env_var}", style="yellow bold")
100+
text.append(f": {error['msg']}\n")
101+
102+
text.append(
103+
"\nCheck settings defined in the process environment and in file ",
104+
style="info",
105+
)
106+
env_file = str(settings_class.model_config.get("env_file", ".env") or ".env")
107+
text.append(
108+
str(Path.cwd() / env_file),
109+
style="bold blue underline",
110+
)
111+
112+
console.print(
113+
Panel(
114+
text,
115+
title="Configuration invalid!",
116+
border_style="error",
117+
),
118+
)
119+
sys.exit(78)
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
"""Tests for the settings module."""
2+
3+
import os
4+
from pathlib import Path
5+
from typing import cast
6+
from unittest.mock import MagicMock, patch
7+
8+
import pytest
9+
from pydantic import SecretStr
10+
from pydantic_settings import SettingsConfigDict
11+
12+
from aignostics_foundry_core.settings import (
13+
UNHIDE_SENSITIVE_INFO,
14+
OpaqueSettings,
15+
load_settings,
16+
strip_to_none_before_validator,
17+
)
18+
19+
_SECRET_VALUE = "sensitive" # noqa: S105
20+
_MASKED_VALUE = "**********"
21+
22+
23+
class _TheTestSettings(OpaqueSettings):
24+
"""Test settings class."""
25+
26+
test_value: str = "default"
27+
secret_value: SecretStr | None = None
28+
required_value: str
29+
30+
31+
class _TheTestSettingsWithEnvPrefix(OpaqueSettings):
32+
"""Test settings class with an environment prefix."""
33+
34+
model_config = SettingsConfigDict(env_prefix="TEST_")
35+
36+
value: str
37+
38+
39+
def _make_info(context: dict | None) -> MagicMock: # type: ignore[type-arg]
40+
"""Create a mock FieldSerializationInfo with the given context."""
41+
info = MagicMock()
42+
info.context = context
43+
return info
44+
45+
46+
class TestStripToNoneBeforeValidator:
47+
"""Tests for strip_to_none_before_validator."""
48+
49+
@pytest.mark.unit
50+
def test_none_returns_none(self) -> None:
51+
"""Test that None is returned when None is passed."""
52+
assert strip_to_none_before_validator(None) is None
53+
54+
@pytest.mark.unit
55+
def test_empty_string_returns_none(self) -> None:
56+
"""Test that None is returned when an empty string is passed."""
57+
assert strip_to_none_before_validator("") is None
58+
59+
@pytest.mark.unit
60+
def test_whitespace_returns_none(self) -> None:
61+
"""Test that None is returned when a whitespace string is passed."""
62+
assert strip_to_none_before_validator(" \t\n ") is None
63+
64+
@pytest.mark.unit
65+
def test_valid_string_returns_stripped(self) -> None:
66+
"""Test that a stripped string is returned when a valid string is passed."""
67+
assert strip_to_none_before_validator(" test ") == "test"
68+
69+
70+
class TestOpaqueSettings:
71+
"""Tests for OpaqueSettings static serializers."""
72+
73+
@pytest.mark.unit
74+
def test_serialize_sensitive_info_unhide_true(self) -> None:
75+
"""Test that sensitive info is revealed when unhide_sensitive_info is True."""
76+
secret = SecretStr(_SECRET_VALUE)
77+
result = OpaqueSettings.serialize_sensitive_info(secret, _make_info({UNHIDE_SENSITIVE_INFO: True}))
78+
assert result == _SECRET_VALUE
79+
80+
@pytest.mark.unit
81+
def test_serialize_sensitive_info_unhide_false(self) -> None:
82+
"""Test that sensitive info is hidden when unhide_sensitive_info is False."""
83+
secret = SecretStr(_SECRET_VALUE)
84+
result = OpaqueSettings.serialize_sensitive_info(secret, _make_info({UNHIDE_SENSITIVE_INFO: False}))
85+
assert result == _MASKED_VALUE
86+
87+
@pytest.mark.unit
88+
def test_serialize_sensitive_info_empty_secret(self) -> None:
89+
"""Test that None is returned when the SecretStr is empty."""
90+
result = OpaqueSettings.serialize_sensitive_info(SecretStr(""), _make_info({}))
91+
assert result is None
92+
93+
@pytest.mark.unit
94+
def test_serialize_sensitive_info_none_input(self) -> None:
95+
"""Test that None is returned when input_value is None."""
96+
result = OpaqueSettings.serialize_sensitive_info(cast("SecretStr", None), _make_info({}))
97+
assert result is None
98+
99+
@pytest.mark.unit
100+
def test_serialize_sensitive_info_no_context(self) -> None:
101+
"""Test that sensitive info is hidden when no context is provided."""
102+
secret = SecretStr(_SECRET_VALUE)
103+
result = OpaqueSettings.serialize_sensitive_info(secret, _make_info(None))
104+
assert result == _MASKED_VALUE
105+
106+
@pytest.mark.unit
107+
def test_serialize_path_resolve(self, tmp_path: Path) -> None:
108+
"""Test that Path is resolved correctly."""
109+
test_path = tmp_path / "test_file.txt"
110+
test_path.touch()
111+
result = OpaqueSettings.serialize_path_resolve(test_path, _make_info(None))
112+
assert result == str(test_path.resolve())
113+
114+
@pytest.mark.unit
115+
def test_serialize_path_resolve_none(self) -> None:
116+
"""Test that None is returned when Path is None."""
117+
result = OpaqueSettings.serialize_path_resolve(cast("Path", None), _make_info(None))
118+
assert result is None
119+
120+
121+
class TestLoadSettings:
122+
"""Tests for load_settings."""
123+
124+
@pytest.mark.unit
125+
@patch.dict(os.environ, {"REQUIRED_VALUE": "test_value"})
126+
def test_load_settings_success(self) -> None:
127+
"""Test successful settings loading."""
128+
settings = load_settings(_TheTestSettings)
129+
assert settings.test_value == "default"
130+
assert settings.required_value == "test_value"
131+
132+
@pytest.mark.unit
133+
@patch.dict(os.environ, {"TEST_VALUE": "prefixed_value"})
134+
def test_load_settings_with_env_prefix(self) -> None:
135+
"""Test that settings with environment prefix work correctly."""
136+
settings = load_settings(_TheTestSettingsWithEnvPrefix)
137+
assert settings.value == "prefixed_value"
138+
139+
@pytest.mark.unit
140+
@patch("sys.exit")
141+
@patch("aignostics_foundry_core.settings.console.print")
142+
def test_load_settings_validation_error_exits(self, mock_console_print: MagicMock, mock_exit: MagicMock) -> None:
143+
"""Test that validation error prints a Rich Panel and calls sys.exit(78)."""
144+
from rich.panel import Panel
145+
146+
load_settings(_TheTestSettings)
147+
148+
mock_exit.assert_called_once_with(78)
149+
assert mock_console_print.call_count == 1
150+
panel_arg = mock_console_print.call_args[0][0]
151+
assert isinstance(panel_arg, Panel)

uv.lock

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)