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
29 changes: 27 additions & 2 deletions src/graphon/variables/input_entities.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
from collections.abc import Sequence
from enum import StrEnum
from typing import Any
Expand All @@ -11,6 +12,11 @@
)


def _reject_non_standard_json_constant(constant: str) -> None:
msg = f"json_schema is not valid JSON: invalid constant {constant}"
raise ValueError(msg)


class VariableEntityType(StrEnum):
TEXT_INPUT = "text-input"
SELECT = "select"
Expand Down Expand Up @@ -53,14 +59,33 @@ def convert_none_description(cls, value: Any) -> str:
def convert_none_options(cls, value: Any) -> Sequence[str]:
return value or []

@field_validator("json_schema")
@field_validator("json_schema", mode="before")
@classmethod
def validate_json_schema(
cls,
schema: dict[str, Any] | None,
schema: str | dict[str, Any] | None,
) -> dict[str, Any] | None:
# The schema is persisted as raw editor text on the frontend, so accept
# either a parsed object, a JSON string, or empty/None inputs.
if schema is None:
return None
if isinstance(schema, str):
schema = schema.strip()
if not schema:
return None
try:
schema = json.loads(
schema,
parse_constant=_reject_non_standard_json_constant,
)
except json.JSONDecodeError as error:
msg = f"json_schema is not valid JSON: {error.msg}"
raise ValueError(msg) from error
if not isinstance(schema, dict):
# Pydantic only wraps ValueError/AssertionError into ValidationError,
# so we deliberately keep ValueError instead of TypeError here.
msg = f"json_schema must be a JSON object, got {type(schema).__name__}"
raise ValueError(msg) # noqa: TRY004
try:
Draft7Validator.check_schema(schema)
except SchemaError as error:
Expand Down
Empty file added tests/variables/__init__.py
Empty file.
89 changes: 89 additions & 0 deletions tests/variables/test_input_entities.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
from typing import Any

import pytest
from pydantic import ValidationError

from graphon.variables.input_entities import VariableEntity, VariableEntityType

_VALID_SCHEMA: dict = {
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer", "minimum": 0},
},
"required": ["name"],
}


def _make_payload(json_schema: Any) -> dict[str, Any]:
return {
"variable": "profile",
"label": "profile",
"type": VariableEntityType.JSON_OBJECT,
"json_schema": json_schema,
}


class TestValidateJsonSchema:
def test_accepts_dict_input(self) -> None:
entity = VariableEntity.model_validate(_make_payload(_VALID_SCHEMA))
assert entity.json_schema == _VALID_SCHEMA

def test_accepts_json_string_input(self) -> None:
# The frontend's code editor persists the schema as a raw JSON string.
raw = (
"{\n"
' "type": "object",\n'
' "properties": {\n'
' "name": {"type": "string"},\n'
' "age": {"type": "integer", "minimum": 0}\n'
" },\n"
' "required": ["name"]\n'
"}"
)
entity = VariableEntity.model_validate(_make_payload(raw))
assert entity.json_schema == _VALID_SCHEMA

def test_treats_none_as_none(self) -> None:
entity = VariableEntity.model_validate(_make_payload(None))
assert entity.json_schema is None

def test_treats_empty_string_as_none(self) -> None:
# Frontend "clear schema" sends "" rather than removing the field;
# treating it as None matches the user's intent of "no constraint".
entity = VariableEntity.model_validate(_make_payload(""))
assert entity.json_schema is None

def test_treats_whitespace_string_as_none(self) -> None:
entity = VariableEntity.model_validate(_make_payload(" \n\t "))
assert entity.json_schema is None

def test_rejects_malformed_json_string(self) -> None:
with pytest.raises(ValidationError) as exc_info:
VariableEntity.model_validate(_make_payload('{"type": "object"'))
assert "not valid JSON" in str(exc_info.value)

@pytest.mark.parametrize("constant", ["NaN", "Infinity", "-Infinity"])
def test_rejects_non_standard_json_constant(self, constant: str) -> None:
with pytest.raises(ValidationError) as exc_info:
VariableEntity.model_validate(
_make_payload(f'{{"type": "number", "minimum": {constant}}}'),
)
assert "invalid constant" in str(exc_info.value)

def test_rejects_non_object_non_string_input(self) -> None:
with pytest.raises(ValidationError) as exc_info:
VariableEntity.model_validate(_make_payload(123))
assert "must be a JSON object" in str(exc_info.value)

def test_rejects_semantically_invalid_schema(self) -> None:
bad_schema = {"type": "not_a_real_type"}
with pytest.raises(ValidationError) as exc_info:
VariableEntity.model_validate(_make_payload(bad_schema))
assert "Invalid JSON schema" in str(exc_info.value)

def test_rejects_semantically_invalid_schema_from_string(self) -> None:
bad_schema_str = '{"type": "not_a_real_type"}'
with pytest.raises(ValidationError) as exc_info:
VariableEntity.model_validate(_make_payload(bad_schema_str))
assert "Invalid JSON schema" in str(exc_info.value)
Loading