Skip to content

Commit

Permalink
Add JsonDict type
Browse files Browse the repository at this point in the history
  • Loading branch information
dmontagu committed Nov 2, 2023
1 parent 9868b45 commit 27a4d88
Show file tree
Hide file tree
Showing 3 changed files with 85 additions and 1 deletion.
1 change: 1 addition & 0 deletions pydantic/__init__.py
Expand Up @@ -324,6 +324,7 @@
'GetPydanticSchema': (__package__, '.types'),
'Tag': (__package__, '.types'),
'CallableDiscriminator': (__package__, '.types'),
'JsonValue': (__package__, '.types'),
# type_adapter
'TypeAdapter': (__package__, '.type_adapter'),
# warnings
Expand Down
56 changes: 55 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, 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,54 @@ 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)


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),
_AllowAnyJson,
],
)
29 changes: 29 additions & 0 deletions tests/test_types.py
Expand Up @@ -65,6 +65,7 @@
GetCoreSchemaHandler,
InstanceOf,
Json,
JsonValue,
NaiveDatetime,
NameEmail,
NegativeFloat,
Expand Down Expand Up @@ -6025,3 +6026,31 @@ class Model(BaseModel):
value: str

assert Model.model_validate_json(f'{{"value": {number}}}').model_dump() == {'value': expected_str}


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() == [
{
'ctx': {
'discriminator': '_get_type_name()',
'expected_tags': "'list', 'dict', 'str', 'int', 'float', 'bool', " "'NoneType'",
'tag': 'ellipsis',
},
'input': Ellipsis,
'loc': ('dict', 'a', 'dict', 'b'),
'msg': "Input tag 'ellipsis' found using _get_type_name() does not match any "
"of the expected tags: 'list', 'dict', 'str', 'int', 'float', 'bool', "
"'NoneType'",
'type': 'union_tag_invalid',
'url': 'https://errors.pydantic.dev/2.4/v/union_tag_invalid',
}
]

0 comments on commit 27a4d88

Please sign in to comment.