Skip to content

Commit

Permalink
Add JsonValue type (#7998)
Browse files Browse the repository at this point in the history
Co-authored-by: sydney-runkle <sydneymarierunkle@gmail.com>
  • Loading branch information
dmontagu and sydney-runkle committed Nov 6, 2023
1 parent 5576936 commit 76c68fa
Show file tree
Hide file tree
Showing 3 changed files with 134 additions and 1 deletion.
2 changes: 2 additions & 0 deletions pydantic/__init__.py
Expand Up @@ -183,6 +183,7 @@
'GetPydanticSchema',
'Tag',
'CallableDiscriminator',
'JsonValue',
# type_adapter
'TypeAdapter',
# version
Expand Down Expand Up @@ -324,6 +325,7 @@
'GetPydanticSchema': (__package__, '.types'),
'Tag': (__package__, '.types'),
'CallableDiscriminator': (__package__, '.types'),
'JsonValue': (__package__, '.types'),
# type_adapter
'TypeAdapter': (__package__, '.type_adapter'),
# warnings
Expand Down
112 changes: 111 additions & 1 deletion pydantic/types.py
Expand Up @@ -14,21 +14,23 @@
Any,
Callable,
ClassVar,
Dict,
FrozenSet,
Generic,
Hashable,
Iterator,
List,
Set,
TypeVar,
Union,
cast,
)
from uuid import UUID

import annotated_types
from annotated_types import BaseMetadata, MaxLen, MinLen
from pydantic_core import CoreSchema, PydanticCustomError, core_schema
from typing_extensions import Annotated, Literal, Protocol, deprecated
from typing_extensions import Annotated, Literal, Protocol, TypeAlias, TypeAliasType, deprecated

from ._internal import (
_core_utils,
Expand Down Expand Up @@ -101,6 +103,7 @@
'StringConstraints',
'Tag',
'CallableDiscriminator',
'JsonValue',
)


Expand Down Expand Up @@ -2659,3 +2662,110 @@ def _convert_schema(self, original_schema: core_schema.CoreSchema) -> core_schem
metadata=original_schema.get('metadata'),
serialization=original_schema.get('serialization'),
)


_JSON_TYPES = {int, float, str, bool, list, dict, type(None)}


def _get_type_name(x: Any) -> str:
type_ = type(x)
if type_ in _JSON_TYPES:
return type_.__name__

# Handle proper subclasses; note we don't need to handle None here
if isinstance(x, bool):
return 'bool'
if isinstance(x, int):
return 'int'
if isinstance(x, float):
return 'float'
if isinstance(x, str):
return 'str'
if isinstance(x, list):
return 'list'
if isinstance(x, dict):
return 'dict'

# Fail by returning the type's actual name
return getattr(type_, '__name__', '<no type name>')


class _AllowAnyJson:
@classmethod
def __get_pydantic_core_schema__(cls, source_type: Any, handler: GetCoreSchemaHandler) -> CoreSchema:
python_schema = handler(source_type)
return core_schema.json_or_python_schema(json_schema=core_schema.any_schema(), python_schema=python_schema)


if TYPE_CHECKING:
# This seems to only be necessary for mypy
JsonValue: TypeAlias = Union[
List['JsonValue'],
Dict[str, 'JsonValue'],
str,
int,
float,
bool,
None,
]

else:
JsonValue = TypeAliasType(
'JsonValue',
Annotated[
Union[
Annotated[List['JsonValue'], Tag('list')],
Annotated[Dict[str, 'JsonValue'], Tag('dict')],
Annotated[str, Tag('str')],
Annotated[int, Tag('int')],
Annotated[float, Tag('float')],
Annotated[bool, Tag('bool')],
Annotated[None, Tag('NoneType')],
],
CallableDiscriminator(
_get_type_name,
custom_error_type='invalid-json-value',
custom_error_message='input was not a valid JSON value',
),
_AllowAnyJson,
],
)
"""
That is, a `JsonValue` is used to represent a value that can be serialized to JSON.
It may be one of:
* List['JsonValue']
* Dict[str, 'JsonValue']
* str
* int
* float
* bool
* None
The following example demonstrates how to use `JsonValue` to validate JSON data,
and what kind of errors to expect when input data is not json serializable.
```py
import json
from pydantic import JsonValue, TypeAdapter, ValidationError
adapter = TypeAdapter(JsonValue)
valid_json_data = {'a': {'b': {'c': 1, 'd': [2, None]}}}
invalid_json_data = {'a': {'b': ...}}
assert adapter.validate_python(valid_json_data) == valid_json_data
assert adapter.validate_json(json.dumps(valid_json_data)) == valid_json_data
try:
adapter.validate_python(invalid_json_data)
except ValidationError as exc_info:
assert exc_info.errors() == [
{
'input': Ellipsis,
'loc': ('dict', 'a', 'dict', 'b'),
'msg': 'input was not a valid JSON value',
'type': 'invalid-json-value',
}
]
```
"""
21 changes: 21 additions & 0 deletions tests/test_types.py
Expand Up @@ -66,6 +66,7 @@
GetCoreSchemaHandler,
InstanceOf,
Json,
JsonValue,
NaiveDatetime,
NameEmail,
NegativeFloat,
Expand Down Expand Up @@ -6084,3 +6085,23 @@ def test_union_tags_in_errors():
'url': 'https://errors.pydantic.dev/2.4/v/dict_type',
},
]


def test_json_value():
adapter = TypeAdapter(JsonValue)
valid_json_data = {'a': {'b': {'c': 1, 'd': [2, None]}}}
invalid_json_data = {'a': {'b': ...}} # would pass validation as a dict[str, Any]

assert adapter.validate_python(valid_json_data) == valid_json_data
assert adapter.validate_json(json.dumps(valid_json_data)) == valid_json_data

with pytest.raises(ValidationError) as exc_info:
adapter.validate_python(invalid_json_data)
assert exc_info.value.errors() == [
{
'input': Ellipsis,
'loc': ('dict', 'a', 'dict', 'b'),
'msg': 'input was not a valid JSON value',
'type': 'invalid-json-value',
}
]

0 comments on commit 76c68fa

Please sign in to comment.