From addeda0c47faf7012ba58cbc6170afac40851826 Mon Sep 17 00:00:00 2001 From: Bobronium Date: Fri, 5 Aug 2022 23:30:36 +0400 Subject: [PATCH 1/8] Add .venv/ to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) 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 From fd298f3d3a1acf40a805b4e4a1d90afd6c825912 Mon Sep 17 00:00:00 2001 From: Bobronium Date: Fri, 5 Aug 2022 23:33:25 +0400 Subject: [PATCH 2/8] Allow typecheckers to infer Json inner type --- pydantic/types.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pydantic/types.py b/pydantic/types.py index d93aad1648..5b6b5cda53 100644 --- a/pydantic/types.py +++ b/pydantic/types.py @@ -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 @@ -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): From d1421f5f9efea8f81d42d198125a97ad3fd5566f Mon Sep 17 00:00:00 2001 From: Bobronium Date: Fri, 5 Aug 2022 23:34:15 +0400 Subject: [PATCH 3/8] Fix and improve mypy tests --- tests/mypy/modules/fail1.py | 3 +++ tests/mypy/modules/success.py | 5 ++++- tests/mypy/outputs/fail1.txt | 3 ++- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/mypy/modules/fail1.py b/tests/mypy/modules/fail1.py index 6b1ebea034..afc53e411d 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..789bca8221 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 From 45df1e342667aadff7be77d5714146f97f70030b Mon Sep 17 00:00:00 2001 From: Bobronium Date: Fri, 5 Aug 2022 23:34:45 +0400 Subject: [PATCH 4/8] Add type tests --- tests/test_types.py | 56 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/tests/test_types.py b/tests/test_types.py index d21d45fe22..d67b76fb43 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, @@ -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 @@ -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 @@ -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 @@ -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]] From 20bd0040d8875b08ee0c1b388375941522851697 Mon Sep 17 00:00:00 2001 From: Bobronium Date: Fri, 5 Aug 2022 23:35:07 +0400 Subject: [PATCH 5/8] Add Json[Any] case to schema test --- tests/test_schema.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_schema.py b/tests/test_schema.py index 52f9dff3c5..2262d213ac 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -949,6 +949,7 @@ def test_json_type(): class Model(BaseModel): a: Json b: Json[int] + c: Json[Any] assert Model.schema() == { 'title': 'Model', @@ -956,6 +957,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'], } From bec6ebf884fe4d83f6bc8c286ac99c8c6e2607c5 Mon Sep 17 00:00:00 2001 From: Bobronium Date: Fri, 5 Aug 2022 23:35:16 +0400 Subject: [PATCH 6/8] Update example in docs --- docs/examples/types_json_type.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) 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) From 4da79660b9207b956ce556ff20cf91bc625a6e4a Mon Sep 17 00:00:00 2001 From: Bobronium Date: Fri, 5 Aug 2022 23:57:29 +0400 Subject: [PATCH 7/8] Add changes file --- changes/4332-Bobronium.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changes/4332-Bobronium.md 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. From 5f226d26ca1ec3a24bf2f97a5548b008a804440d Mon Sep 17 00:00:00 2001 From: Bobronium Date: Fri, 5 Aug 2022 23:59:02 +0400 Subject: [PATCH 8/8] Use <3.9 compatible annotations for tests --- tests/mypy/modules/fail1.py | 2 +- tests/mypy/modules/success.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/mypy/modules/fail1.py b/tests/mypy/modules/fail1.py index afc53e411d..8c99bcc754 100644 --- a/tests/mypy/modules/fail1.py +++ b/tests/mypy/modules/fail1.py @@ -14,7 +14,7 @@ class Model(BaseModel): last_name: NoneStr = None signup_ts: Optional[datetime] = None list_of_ints: List[int] - json_list_of_ints: Json[list[int]] + json_list_of_ints: Json[List[int]] m = Model(age=42, list_of_ints=[1, '2', b'3']) diff --git a/tests/mypy/modules/success.py b/tests/mypy/modules/success.py index 789bca8221..ab74b79ba3 100644 --- a/tests/mypy/modules/success.py +++ b/tests/mypy/modules/success.py @@ -230,8 +230,8 @@ class PydanticTypes(BaseModel): my_dir_path: DirectoryPath = Path('.') my_dir_path_str: DirectoryPath = '.' # type: ignore # Json - my_json: Json[dict[str, str]] = '{"hello": "world"}' # type: ignore - my_json_list: Json[list[str]] = '["hello", "world"]' # type: ignore + 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)