From 1c9324a17c915bbfc6ce8a119127a35f079b431b Mon Sep 17 00:00:00 2001 From: Oliver Meyer Date: Tue, 24 Mar 2026 16:02:26 +0100 Subject: [PATCH 1/4] 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 --- ATTRIBUTIONS.md | 34 ++++ pyproject.toml | 1 + src/aignostics_foundry_core/AGENTS.md | 14 ++ src/aignostics_foundry_core/settings.py | 119 ++++++++++++++ .../aignostics_foundry_core/settings_test.py | 151 ++++++++++++++++++ uv.lock | 16 ++ 6 files changed, 335 insertions(+) create mode 100644 src/aignostics_foundry_core/settings.py create mode 100644 tests/aignostics_foundry_core/settings_test.py 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..ef41cde --- /dev/null +++ b/src/aignostics_foundry_core/settings.py @@ -0,0 +1,119 @@ +"""Utilities around Pydantic settings.""" + +import json +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, 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, _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 falsy, otherwise the resolved absolute path string. + """ + if not input_value: + 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 = json.loads(e.json()) + text = Text() + text.append( + "Validation error(s): \n\n", + style="debug", + ) + + prefix = settings_class.model_config.get("env_prefix", "") + for error in errors: + env_var = f"{prefix}{error['loc'][0]}".upper() if error["loc"] else 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..79a86e6 --- /dev/null +++ b/tests/aignostics_foundry_core/settings_test.py @@ -0,0 +1,151 @@ +"""Tests for the settings module.""" + +import os +from pathlib import Path +from typing import cast +from unittest.mock import MagicMock, patch + +import pytest +from pydantic import SecretStr +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 | None) -> MagicMock: # type: ignore[type-arg] + """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 + + +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) 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" From 11f72b16674df7da701fcbe67c4f8503af032287 Mon Sep 17 00:00:00 2001 From: Oliver Meyer Date: Tue, 24 Mar 2026 17:29:49 +0100 Subject: [PATCH 2/4] fix: review comments --- src/aignostics_foundry_core/settings.py | 7 +++---- tests/aignostics_foundry_core/settings_test.py | 4 ++-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/aignostics_foundry_core/settings.py b/src/aignostics_foundry_core/settings.py index ef41cde..c9df1c0 100644 --- a/src/aignostics_foundry_core/settings.py +++ b/src/aignostics_foundry_core/settings.py @@ -1,6 +1,5 @@ """Utilities around Pydantic settings.""" -import json import sys from pathlib import Path from typing import TypeVar @@ -38,7 +37,7 @@ class OpaqueSettings(BaseSettings): """Base settings class with secret masking and path resolution serializers.""" @staticmethod - def serialize_sensitive_info(input_value: SecretStr, info: FieldSerializationInfo) -> str | None: + def serialize_sensitive_info(input_value: SecretStr | None, info: FieldSerializationInfo) -> str | None: """Serialize a SecretStr, masking it unless context requests unhiding. Args: @@ -56,7 +55,7 @@ def serialize_sensitive_info(input_value: SecretStr, info: FieldSerializationInf return str(input_value) @staticmethod - def serialize_path_resolve(input_value: Path, _info: FieldSerializationInfo) -> str | None: + def serialize_path_resolve(input_value: Path | None, _info: FieldSerializationInfo) -> str | None: """Serialize a Path by resolving it to an absolute string. Args: @@ -86,7 +85,7 @@ def load_settings(settings_class: type[_T]) -> _T: try: return settings_class() except ValidationError as e: - errors = json.loads(e.json()) + errors = e.errors() text = Text() text.append( "Validation error(s): \n\n", diff --git a/tests/aignostics_foundry_core/settings_test.py b/tests/aignostics_foundry_core/settings_test.py index 79a86e6..b564a6b 100644 --- a/tests/aignostics_foundry_core/settings_test.py +++ b/tests/aignostics_foundry_core/settings_test.py @@ -2,7 +2,7 @@ import os from pathlib import Path -from typing import cast +from typing import Any, cast from unittest.mock import MagicMock, patch import pytest @@ -36,7 +36,7 @@ class _TheTestSettingsWithEnvPrefix(OpaqueSettings): value: str -def _make_info(context: dict | None) -> MagicMock: # type: ignore[type-arg] +def _make_info(context: dict[str, Any] | None) -> MagicMock: """Create a mock FieldSerializationInfo with the given context.""" info = MagicMock() info.context = context From 490b1b3203fd66ac80841d21cd32bf4e18d45039 Mon Sep 17 00:00:00 2001 From: Oliver Meyer Date: Wed, 25 Mar 2026 09:45:18 +0100 Subject: [PATCH 3/4] fix(settings): use explicit None/empty guard for Path serializer `Path` has no `__bool__`, making all instances truthy. The previous `if not input_value:` guard was therefore never entered for any `Path`, so `Path("")` silently resolved to the current working directory. Replace the falsy check with an explicit guard: if input_value is None or not input_value.parts: This correctly returns `None` for both `None` and empty/no-parts paths (e.g. `Path("")` / `Path()`), while resolving all other paths as before. Co-Authored-By: Claude Sonnet 4.6 --- src/aignostics_foundry_core/settings.py | 5 +++-- tests/aignostics_foundry_core/settings_test.py | 6 ++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/aignostics_foundry_core/settings.py b/src/aignostics_foundry_core/settings.py index c9df1c0..0a0104a 100644 --- a/src/aignostics_foundry_core/settings.py +++ b/src/aignostics_foundry_core/settings.py @@ -63,9 +63,10 @@ def serialize_path_resolve(input_value: Path | None, _info: FieldSerializationIn _info: Pydantic serialization info (unused). Returns: - None if input is falsy, otherwise the resolved absolute path string. + None if input is `None` or has no path components (e.g. empty string), + otherwise the resolved absolute path string. """ - if not input_value: + if input_value is None or not input_value.parts: return None return str(input_value.resolve()) diff --git a/tests/aignostics_foundry_core/settings_test.py b/tests/aignostics_foundry_core/settings_test.py index b564a6b..c89829d 100644 --- a/tests/aignostics_foundry_core/settings_test.py +++ b/tests/aignostics_foundry_core/settings_test.py @@ -117,6 +117,12 @@ def test_serialize_path_resolve_none(self) -> 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.""" From 5fd9178af8d1aaf014dc5fc49b37681ebbfb80db Mon Sep 17 00:00:00 2001 From: Oliver Meyer Date: Wed, 25 Mar 2026 14:28:46 +0100 Subject: [PATCH 4/4] fix(settings): handle non-string loc in validation error formatting In Pydantic v2, `loc` entries can be integers (e.g. for list-typed fields validated via RootModel). The previous single-expression format f"{prefix}{error['loc'][0]}".upper() produced meaningless env-var names like "MY_PREFIX_0" for integer locs. Add an explicit isinstance guard so integer locs fall back to the model prefix (e.g. "MY_PREFIX") rather than appending the raw integer. Co-Authored-By: Claude Sonnet 4.6 --- src/aignostics_foundry_core/settings.py | 5 +++- .../aignostics_foundry_core/settings_test.py | 28 ++++++++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/aignostics_foundry_core/settings.py b/src/aignostics_foundry_core/settings.py index 0a0104a..01cd211 100644 --- a/src/aignostics_foundry_core/settings.py +++ b/src/aignostics_foundry_core/settings.py @@ -95,7 +95,10 @@ def load_settings(settings_class: type[_T]) -> _T: prefix = settings_class.model_config.get("env_prefix", "") for error in errors: - env_var = f"{prefix}{error['loc'][0]}".upper() if error["loc"] else prefix.rstrip("_").upper() + 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") diff --git a/tests/aignostics_foundry_core/settings_test.py b/tests/aignostics_foundry_core/settings_test.py index c89829d..97fff10 100644 --- a/tests/aignostics_foundry_core/settings_test.py +++ b/tests/aignostics_foundry_core/settings_test.py @@ -6,7 +6,7 @@ from unittest.mock import MagicMock, patch import pytest -from pydantic import SecretStr +from pydantic import RootModel, SecretStr, ValidationError from pydantic_settings import SettingsConfigDict from aignostics_foundry_core.settings import ( @@ -155,3 +155,29 @@ def test_load_settings_validation_error_exits(self, mock_console_print: MagicMoc 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