From 7cdd014142e9ed895c48700f0a48491741e75d79 Mon Sep 17 00:00:00 2001 From: Tim DiLauro Date: Sat, 23 May 2026 17:07:57 -0400 Subject: [PATCH 1/5] Basic JSON UI form field support (PP-4438) --- src/palace/manager/integration/settings.py | 32 ++++++++- tests/manager/integration/test_settings.py | 79 +++++++++++++++++++++- 2 files changed, 109 insertions(+), 2 deletions(-) diff --git a/src/palace/manager/integration/settings.py b/src/palace/manager/integration/settings.py index 07dd1f6193..73577cf210 100644 --- a/src/palace/manager/integration/settings.py +++ b/src/palace/manager/integration/settings.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import typing from collections.abc import Callable, Mapping from dataclasses import dataclass @@ -52,6 +53,7 @@ class FormFieldType(Enum): ANNOUNCEMENTS = "announcements" COLOR = "color-picker" IMAGE = "image" + JSON = "json" FormOptionsType = Mapping[Enum | str | bool | None, str | LazyString] @@ -137,7 +139,10 @@ def to_dict( ) if default is not None and default is not PydanticUndefined: - form_entry["default"] = self.get_form_value(default) + if self.type == FormFieldType.JSON: + form_entry["default"] = json.dumps(default) + else: + form_entry["default"] = self.get_form_value(default) if self.type.value is not None: form_entry["type"] = self.type.value if self.description is not None: @@ -200,6 +205,31 @@ def extra_args(cls, values: dict[str, Any]) -> dict[str, Any]: if isinstance(value, str) and value.strip() == "": values[key] = None + # For FormFieldType.JSON fields, the admin interface sends the textarea + # content as a string. Parse it as JSON so downstream validators and + # callers receive the actual Python value. + for name, field_info in cls.model_fields.items(): + fm = _get_form_metadata(field_info) + if fm is None or fm.type != FormFieldType.JSON: + continue + key = ( + field_info.alias + if field_info.alias is not None and field_info.alias in values + else name + ) + if key not in values: + continue + v = values[key] + if isinstance(v, str): + try: + values[key] = json.loads(v) + except json.JSONDecodeError as exc: + raise SettingsValidationError( + problem_detail=INVALID_CONFIGURATION_OPTION.detailed( + f"'{fm.label}' must be valid JSON: {exc}" + ) + ) + return values # Custom validation can be done by adding additional validation methods diff --git a/tests/manager/integration/test_settings.py b/tests/manager/integration/test_settings.py index f115af5784..42bf77853d 100644 --- a/tests/manager/integration/test_settings.py +++ b/tests/manager/integration/test_settings.py @@ -1,6 +1,6 @@ import logging from functools import partial -from typing import Annotated, Self +from typing import Annotated, Any, Self from unittest.mock import MagicMock import pytest @@ -64,6 +64,13 @@ def secret_number(self) -> Self: ] = Field(default=-1.1, alias="foo") +class JsonMockSettings(BaseSettings): + data: Annotated[ + dict | list | str | int | float | bool | None, + FormMetadata(label="Data", type=FormFieldType.JSON), + ] = None + + class BaseSettingsFixture: def __init__(self): self.test_config_dict = { @@ -131,6 +138,10 @@ def test_settings_validation( settings = base_settings_fixture.settings(test=" foo ") assert settings.model_dump() == {"test": "foo", "number": 1} + # Empty string is normalized to None for all fields, including JSON fields. + settings = JsonMockSettings(data="") + assert settings.data is None + def test_whitespace_only_required_field_treated_as_missing(self) -> None: # A whitespace-only string submitted for a required field should be # treated the same as an empty string (i.e. missing), because the admin @@ -145,6 +156,72 @@ class RequiredStrSettings(BaseSettings): with raises_problem_detail(detail="Required field 'Username' is missing."): RequiredStrSettings(username=" ") + @pytest.mark.parametrize( + "input_val, expected_val", + [ + ('{"a": 1}', {"a": 1}), + ("[1, 2, 3]", [1, 2, 3]), + ("42", 42), + ("true", True), + ({"a": 1}, {"a": 1}), + ("null", None), + (None, None), + ], + ids=[ + "dict-string", + "list-string", + "int-string", + "bool-string", + "already-parsed-dict", + "json-null-string", + "none-passthrough", + ], + ) + def test_json_field_parsing(self, input_val: Any, expected_val: Any) -> None: + settings = JsonMockSettings(data=input_val) + assert settings.data == expected_val + + def test_json_field_invalid_raises_error(self) -> None: + with raises_problem_detail() as info: + JsonMockSettings(data="not json") + assert ( + info.value.detail is not None + and "'Data' must be valid JSON:" in info.value.detail + ) + + def test_json_field_alias_parsing(self) -> None: + class AliasedJsonSettings(BaseSettings): + data: Annotated[ + dict | None, + FormMetadata(label="Data", type=FormFieldType.JSON), + ] = Field(default=None, alias="cfg") + + settings = AliasedJsonSettings(**{"cfg": '{"x": 1}'}) + assert settings.data == {"x": 1} + + @pytest.mark.parametrize( + "default_val, expected_json", + [ + ({"key": "value"}, '{"key": "value"}'), + ([1, 2, 3], "[1, 2, 3]"), + ("hello", '"hello"'), + (42, "42"), + (True, "true"), + ], + ids=["dict", "list", "str", "int", "bool"], + ) + def test_json_field_default_serialized_in_form( + self, default_val: Any, expected_json: str + ) -> None: + class DefaultJsonSettings(BaseSettings): + data: Annotated[ + Any, + FormMetadata(label="Data", type=FormFieldType.JSON), + ] = default_val + + form = DefaultJsonSettings.configuration_form(MagicMock()) + assert form[0]["default"] == expected_json + def test_field_validator_return_pd_exception( self, base_settings_fixture: BaseSettingsFixture ) -> None: From 2fff0752630d56a24c5da74448cb9b00a7ebe5b6 Mon Sep 17 00:00:00 2001 From: Tim DiLauro Date: Sat, 23 May 2026 22:43:01 -0400 Subject: [PATCH 2/5] AI local review feedback --- src/palace/manager/integration/settings.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/palace/manager/integration/settings.py b/src/palace/manager/integration/settings.py index 73577cf210..896231bbf2 100644 --- a/src/palace/manager/integration/settings.py +++ b/src/palace/manager/integration/settings.py @@ -205,9 +205,7 @@ def extra_args(cls, values: dict[str, Any]) -> dict[str, Any]: if isinstance(value, str) and value.strip() == "": values[key] = None - # For FormFieldType.JSON fields, the admin interface sends the textarea - # content as a string. Parse it as JSON so downstream validators and - # callers receive the actual Python value. + # The admin interface submits JSON field values as raw strings. for name, field_info in cls.model_fields.items(): fm = _get_form_metadata(field_info) if fm is None or fm.type != FormFieldType.JSON: From 80709ba70c42d4af5a895f84b4e78bd0d71b6266 Mon Sep 17 00:00:00 2001 From: Tim DiLauro Date: Sun, 24 May 2026 11:48:37 -0400 Subject: [PATCH 3/5] AI CI feedback --- src/palace/manager/integration/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/palace/manager/integration/settings.py b/src/palace/manager/integration/settings.py index 896231bbf2..5e75f6a77d 100644 --- a/src/palace/manager/integration/settings.py +++ b/src/palace/manager/integration/settings.py @@ -140,7 +140,7 @@ def to_dict( if default is not None and default is not PydanticUndefined: if self.type == FormFieldType.JSON: - form_entry["default"] = json.dumps(default) + form_entry["default"] = json.dumps(default, ensure_ascii=False) else: form_entry["default"] = self.get_form_value(default) if self.type.value is not None: From 764b68c5737a0897bc5c71361b58eb27c81e7ec7 Mon Sep 17 00:00:00 2001 From: Tim DiLauro Date: Sun, 24 May 2026 11:59:07 -0400 Subject: [PATCH 4/5] Add test --- tests/manager/integration/test_settings.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/manager/integration/test_settings.py b/tests/manager/integration/test_settings.py index 42bf77853d..c28aa8e517 100644 --- a/tests/manager/integration/test_settings.py +++ b/tests/manager/integration/test_settings.py @@ -181,6 +181,10 @@ def test_json_field_parsing(self, input_val: Any, expected_val: Any) -> None: settings = JsonMockSettings(data=input_val) assert settings.data == expected_val + def test_json_field_omitted_uses_default_without_raising(self) -> None: + settings = JsonMockSettings() + assert settings.data is None + def test_json_field_invalid_raises_error(self) -> None: with raises_problem_detail() as info: JsonMockSettings(data="not json") From 88260363e7d44e3207a77e0e55025e877b1cae39 Mon Sep 17 00:00:00 2001 From: Tim DiLauro Date: Tue, 26 May 2026 16:50:58 -0400 Subject: [PATCH 5/5] Fix JSON form field default serialization for primitive types --- src/palace/manager/integration/settings.py | 2 +- tests/manager/integration/test_settings.py | 18 ++++++++---------- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/src/palace/manager/integration/settings.py b/src/palace/manager/integration/settings.py index 5e75f6a77d..364aa8fa90 100644 --- a/src/palace/manager/integration/settings.py +++ b/src/palace/manager/integration/settings.py @@ -140,7 +140,7 @@ def to_dict( if default is not None and default is not PydanticUndefined: if self.type == FormFieldType.JSON: - form_entry["default"] = json.dumps(default, ensure_ascii=False) + form_entry["default"] = default else: form_entry["default"] = self.get_form_value(default) if self.type.value is not None: diff --git a/tests/manager/integration/test_settings.py b/tests/manager/integration/test_settings.py index c28aa8e517..c68ca6be6f 100644 --- a/tests/manager/integration/test_settings.py +++ b/tests/manager/integration/test_settings.py @@ -204,19 +204,17 @@ class AliasedJsonSettings(BaseSettings): assert settings.data == {"x": 1} @pytest.mark.parametrize( - "default_val, expected_json", + "default_val", [ - ({"key": "value"}, '{"key": "value"}'), - ([1, 2, 3], "[1, 2, 3]"), - ("hello", '"hello"'), - (42, "42"), - (True, "true"), + {"key": "value"}, + [1, 2, 3], + "hello", + 42, + True, ], ids=["dict", "list", "str", "int", "bool"], ) - def test_json_field_default_serialized_in_form( - self, default_val: Any, expected_json: str - ) -> None: + def test_json_field_default_passed_through_in_form(self, default_val: Any) -> None: class DefaultJsonSettings(BaseSettings): data: Annotated[ Any, @@ -224,7 +222,7 @@ class DefaultJsonSettings(BaseSettings): ] = default_val form = DefaultJsonSettings.configuration_form(MagicMock()) - assert form[0]["default"] == expected_json + assert form[0]["default"] == default_val def test_field_validator_return_pd_exception( self, base_settings_fixture: BaseSettingsFixture