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

Typecheck Json inner type #4332

Merged
merged 8 commits into from Aug 9, 2022
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
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 @@ -113,6 +113,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 @@ -782,11 +784,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
Comment on lines +233 to +234
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Humm, problem is this makes the output type compatibility better, but makes the input type compatibility worse.

Hence the "type ignore" here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, indeed. I thought that it's alright since it's kind of consistent throughout pydantic: you can even see same type: ignore's above for other types.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, agreed, also presumably it allows bytes.

Hence why I said this is better than the status quo on balance.

# 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 @@ -949,13 +949,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 @@ -2312,6 +2313,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 @@ -2320,6 +2326,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 @@ -2330,6 +2344,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 @@ -2364,6 +2388,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