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
30 changes: 29 additions & 1 deletion src/palace/manager/integration/settings.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import json
import typing
from collections.abc import Callable, Mapping
from dataclasses import dataclass
Expand Down Expand Up @@ -52,6 +53,7 @@ class FormFieldType(Enum):
ANNOUNCEMENTS = "announcements"
COLOR = "color-picker"
IMAGE = "image"
JSON = "json"


FormOptionsType = Mapping[Enum | str | bool | None, str | LazyString]
Expand Down Expand Up @@ -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"] = 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:
Expand Down Expand Up @@ -200,6 +205,29 @@ def extra_args(cls, values: dict[str, Any]) -> dict[str, Any]:
if isinstance(value, str) and value.strip() == "":
values[key] = None

# 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:
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
Expand Down
81 changes: 80 additions & 1 deletion tests/manager/integration/test_settings.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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
Expand All @@ -145,6 +156,74 @@ 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_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")
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",
[
{"key": "value"},
[1, 2, 3],
"hello",
42,
True,
],
ids=["dict", "list", "str", "int", "bool"],
)
def test_json_field_default_passed_through_in_form(self, default_val: Any) -> None:
class DefaultJsonSettings(BaseSettings):
data: Annotated[
Any,
FormMetadata(label="Data", type=FormFieldType.JSON),
] = default_val

form = DefaultJsonSettings.configuration_form(MagicMock())
assert form[0]["default"] == default_val

def test_field_validator_return_pd_exception(
self, base_settings_fixture: BaseSettingsFixture
) -> None:
Expand Down
Loading