Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add JsonValue type #7998

Merged
merged 6 commits into from Nov 6, 2023
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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',
}
]