From 0500610ec520c7170445bd7031f2d4f37a07bb46 Mon Sep 17 00:00:00 2001 From: Arseny Boykov <36469655+Bobronium@users.noreply.github.com> Date: Tue, 9 Aug 2022 14:49:27 +0400 Subject: [PATCH] Typecheck Json inner type (#4332) * 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 --- .gitignore | 1 + changes/4332-Bobronium.md | 3 ++ docs/examples/types_json_type.py | 18 +++++----- pydantic/types.py | 7 +++- tests/mypy/modules/fail1.py | 3 ++ tests/mypy/modules/success.py | 5 ++- tests/mypy/outputs/fail1.txt | 3 +- tests/test_schema.py | 2 ++ tests/test_types.py | 56 ++++++++++++++++++++++++++++++++ 9 files changed, 86 insertions(+), 12 deletions(-) create mode 100644 changes/4332-Bobronium.md diff --git a/.gitignore b/.gitignore index 766bf1e03a..653f3d51f1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .idea/ env/ venv/ +.venv/ env3*/ Pipfile *.lock diff --git a/changes/4332-Bobronium.md b/changes/4332-Bobronium.md new file mode 100644 index 0000000000..d66b8bca54 --- /dev/null +++ b/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. diff --git a/docs/examples/types_json_type.py b/docs/examples/types_json_type.py index 752718156d..7f4826a94f 100644 --- a/docs/examples/types_json_type.py +++ b/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) diff --git a/pydantic/types.py b/pydantic/types.py index e329967d27..d8fa028176 100644 --- a/pydantic/types.py +++ b/pydantic/types.py @@ -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 @@ -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): diff --git a/tests/mypy/modules/fail1.py b/tests/mypy/modules/fail1.py index 6b1ebea034..8c99bcc754 100644 --- a/tests/mypy/modules/fail1.py +++ b/tests/mypy/modules/fail1.py @@ -5,6 +5,7 @@ from typing import List, Optional from pydantic import BaseModel, NoneStr +from pydantic.types import Json class Model(BaseModel): @@ -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' diff --git a/tests/mypy/modules/success.py b/tests/mypy/modules/success.py index 11e3db10ce..ab74b79ba3 100644 --- a/tests/mypy/modules/success.py +++ b/tests/mypy/modules/success.py @@ -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) @@ -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'})) diff --git a/tests/mypy/outputs/fail1.txt b/tests/mypy/outputs/fail1.txt index fb2ce98c1e..edeb7d739d 100644 --- a/tests/mypy/outputs/fail1.txt +++ b/tests/mypy/outputs/fail1.txt @@ -1 +1,2 @@ -20: error: Unsupported operand types for + ("int" and "str") [operator] \ No newline at end of file +22: error: Unsupported operand types for + ("int" and "str") [operator] +23: error: Unsupported operand types for + ("int" and "str") [operator] \ No newline at end of file diff --git a/tests/test_schema.py b/tests/test_schema.py index f8db3129ab..1bfe146b4a 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -951,6 +951,7 @@ def test_json_type(): class Model(BaseModel): a: Json b: Json[int] + c: Json[Any] assert Model.schema() == { 'title': 'Model', @@ -958,6 +959,7 @@ class Model(BaseModel): 'properties': { 'a': {'title': 'A', 'type': 'string', 'format': 'json-string'}, 'b': {'title': 'B', 'type': 'integer'}, + 'c': {'title': 'C', 'type': 'string', 'format': 'json-string'}, }, 'required': ['b'], } diff --git a/tests/test_types.py b/tests/test_types.py index acf722b463..e90c0bfe29 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -9,6 +9,7 @@ from enum import Enum, IntEnum from pathlib import Path from typing import ( + Any, Deque, Dict, FrozenSet, @@ -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 @@ -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 @@ -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 @@ -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]]