Skip to content

Commit

Permalink
Typecheck Json inner type (#4332)
Browse files Browse the repository at this point in the history
* Add .venv/ to .gitignore

* Allow typecheckers to infer Json inner type

* Fix and improve mypy tests

* Add type tests

* Add Json[Any] case to schema test

* Update example in docs

* Add changes file

* Use <3.9 compatible annotations for tests
  • Loading branch information
Bobronium committed Aug 9, 2022
1 parent f41ac92 commit 0500610
Show file tree
Hide file tree
Showing 9 changed files with 86 additions and 12 deletions.
1 change: 1 addition & 0 deletions .gitignore
@@ -1,6 +1,7 @@
.idea/
env/
venv/
.venv/
env3*/
Pipfile
*.lock
Expand Down
3 changes: 3 additions & 0 deletions changes/4332-Bobronium.md
@@ -0,0 +1,3 @@
Allow type checkers to infer inner type of `Json` type. `Json[list[str]]` will be now inferred as `list[str]`.
`Json[Any]` should be used instead of plain `Json`.
Runtime behaviour is not changed.
18 changes: 9 additions & 9 deletions docs/examples/types_json_type.py
@@ -1,29 +1,29 @@
from typing import List
from typing import Any, List

from pydantic import BaseModel, Json, ValidationError


class SimpleJsonModel(BaseModel):
json_obj: Json
class AnyJsonModel(BaseModel):
json_obj: Json[Any]


class ComplexJsonModel(BaseModel):
class ConstrainedJsonModel(BaseModel):
json_obj: Json[List[int]]


print(SimpleJsonModel(json_obj='{"b": 1}'))
print(ComplexJsonModel(json_obj='[1, 2, 3]'))
print(AnyJsonModel(json_obj='{"b": 1}'))
print(ConstrainedJsonModel(json_obj='[1, 2, 3]'))
try:
ComplexJsonModel(json_obj=12)
ConstrainedJsonModel(json_obj=12)
except ValidationError as e:
print(e)

try:
ComplexJsonModel(json_obj='[a, b]')
ConstrainedJsonModel(json_obj='[a, b]')
except ValidationError as e:
print(e)

try:
ComplexJsonModel(json_obj='["a", "b"]')
ConstrainedJsonModel(json_obj='["a", "b"]')
except ValidationError as e:
print(e)
7 changes: 6 additions & 1 deletion pydantic/types.py
Expand Up @@ -114,6 +114,8 @@
StrIntFloat = Union[str, int, float]

if TYPE_CHECKING:
from typing_extensions import Annotated

from .dataclasses import Dataclass
from .main import BaseModel
from .typing import CallableGenerator
Expand Down Expand Up @@ -791,11 +793,14 @@ class JsonWrapper:

class JsonMeta(type):
def __getitem__(self, t: Type[Any]) -> Type[JsonWrapper]:
if t is Any:
return Json # allow Json[Any] to replecate plain Json
return _registered(type('JsonWrapperValue', (JsonWrapper,), {'inner_type': t}))


if TYPE_CHECKING:
Json = str
Json = Annotated[T, ...] # Json[list[str]] will be recognized by type checkers as list[str]

else:

class Json(metaclass=JsonMeta):
Expand Down
3 changes: 3 additions & 0 deletions tests/mypy/modules/fail1.py
Expand Up @@ -5,6 +5,7 @@
from typing import List, Optional

from pydantic import BaseModel, NoneStr
from pydantic.types import Json


class Model(BaseModel):
Expand All @@ -13,8 +14,10 @@ class Model(BaseModel):
last_name: NoneStr = None
signup_ts: Optional[datetime] = None
list_of_ints: List[int]
json_list_of_ints: Json[List[int]]


m = Model(age=42, list_of_ints=[1, '2', b'3'])

print(m.age + 'not integer')
m.json_list_of_ints[0] + 'not integer'
5 changes: 4 additions & 1 deletion tests/mypy/modules/success.py
Expand Up @@ -230,7 +230,8 @@ class PydanticTypes(BaseModel):
my_dir_path: DirectoryPath = Path('.')
my_dir_path_str: DirectoryPath = '.' # type: ignore
# Json
my_json: Json = '{"hello": "world"}'
my_json: Json[Dict[str, str]] = '{"hello": "world"}' # type: ignore
my_json_list: Json[List[str]] = '["hello", "world"]' # type: ignore
# Date
my_past_date: PastDate = date.today() - timedelta(1)
my_future_date: FutureDate = date.today() + timedelta(1)
Expand All @@ -248,6 +249,8 @@ class Config:
validated.my_file_path_str.absolute()
validated.my_dir_path.absolute()
validated.my_dir_path_str.absolute()
validated.my_json['hello'].capitalize()
validated.my_json_list[0].capitalize()

stricturl(allowed_schemes={'http'})
stricturl(allowed_schemes=frozenset({'http'}))
Expand Down
3 changes: 2 additions & 1 deletion tests/mypy/outputs/fail1.txt
@@ -1 +1,2 @@
20: error: Unsupported operand types for + ("int" and "str") [operator]
22: error: Unsupported operand types for + ("int" and "str") [operator]
23: error: Unsupported operand types for + ("int" and "str") [operator]
2 changes: 2 additions & 0 deletions tests/test_schema.py
Expand Up @@ -951,13 +951,15 @@ def test_json_type():
class Model(BaseModel):
a: Json
b: Json[int]
c: Json[Any]

assert Model.schema() == {
'title': 'Model',
'type': 'object',
'properties': {
'a': {'title': 'A', 'type': 'string', 'format': 'json-string'},
'b': {'title': 'B', 'type': 'integer'},
'c': {'title': 'C', 'type': 'string', 'format': 'json-string'},
},
'required': ['b'],
}
Expand Down
56 changes: 56 additions & 0 deletions tests/test_types.py
Expand Up @@ -9,6 +9,7 @@
from enum import Enum, IntEnum
from pathlib import Path
from typing import (
Any,
Deque,
Dict,
FrozenSet,
Expand Down Expand Up @@ -2342,6 +2343,11 @@ class Model(BaseModel):
]


def test_json_any_is_json():
"""Mypy doesn't allow plain Json, so Json[Any] must behave just as Json did."""
assert Json[Any] is Json


def test_valid_simple_json():
class JsonModel(BaseModel):
json_obj: Json
Expand All @@ -2350,6 +2356,14 @@ class JsonModel(BaseModel):
assert JsonModel(json_obj=obj).dict() == {'json_obj': {'a': 1, 'b': [2, 3]}}


def test_valid_simple_json_any():
class JsonModel(BaseModel):
json_obj: Json[Any]

obj = '{"a": 1, "b": [2, 3]}'
assert JsonModel(json_obj=obj).dict() == {'json_obj': {'a': 1, 'b': [2, 3]}}


def test_invalid_simple_json():
class JsonModel(BaseModel):
json_obj: Json
Expand All @@ -2360,6 +2374,16 @@ class JsonModel(BaseModel):
assert exc_info.value.errors()[0] == {'loc': ('json_obj',), 'msg': 'Invalid JSON', 'type': 'value_error.json'}


def test_invalid_simple_json_any():
class JsonModel(BaseModel):
json_obj: Json[Any]

obj = '{a: 1, b: [2, 3]}'
with pytest.raises(ValidationError) as exc_info:
JsonModel(json_obj=obj)
assert exc_info.value.errors()[0] == {'loc': ('json_obj',), 'msg': 'Invalid JSON', 'type': 'value_error.json'}


def test_valid_simple_json_bytes():
class JsonModel(BaseModel):
json_obj: Json
Expand Down Expand Up @@ -2394,6 +2418,38 @@ class JsonDetailedModel(BaseModel):
assert JsonDetailedModel(json_obj=obj).dict() == {'json_obj': [1, 2, 3]}


def test_valid_model_json():
class Model(BaseModel):
a: int
b: List[int]

class JsonDetailedModel(BaseModel):
json_obj: Json[Model]

obj = '{"a": 1, "b": [2, 3]}'
m = JsonDetailedModel(json_obj=obj)
assert isinstance(m.json_obj, Model)
assert m.json_obj.a == 1
assert m.dict() == {'json_obj': {'a': 1, 'b': [2, 3]}}


def test_invalid_model_json():
class Model(BaseModel):
a: int
b: List[int]

class JsonDetailedModel(BaseModel):
json_obj: Json[Model]

obj = '{"a": 1, "c": [2, 3]}'
with pytest.raises(ValidationError) as exc_info:
JsonDetailedModel(json_obj=obj)

assert exc_info.value.errors() == [
{'loc': ('json_obj', 'b'), 'msg': 'field required', 'type': 'value_error.missing'}
]


def test_invalid_detailed_json_type_error():
class JsonDetailedModel(BaseModel):
json_obj: Json[List[int]]
Expand Down

0 comments on commit 0500610

Please sign in to comment.