From 9f698543a8b539087889aad4a49649e779d1be84 Mon Sep 17 00:00:00 2001 From: "T. Franzel" Date: Tue, 1 Jun 2021 01:27:33 +0200 Subject: [PATCH] improve type hint detection for Iterable and NamedTuple #404 --- drf_spectacular/plumbing.py | 15 ++++++++++++--- tests/test_plumbing.py | 21 ++++++++++++++++++++- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/drf_spectacular/plumbing.py b/drf_spectacular/plumbing.py index 4ac666a3..e9f6fc7d 100644 --- a/drf_spectacular/plumbing.py +++ b/drf_spectacular/plumbing.py @@ -1,3 +1,4 @@ +import collections import hashlib import inspect import json @@ -7,7 +8,6 @@ import urllib.parse from abc import ABCMeta from collections import OrderedDict, defaultdict -from collections.abc import Hashable from decimal import Decimal from enum import Enum from typing import DefaultDict, Generic, List, Optional, Type, TypeVar, Union @@ -84,7 +84,7 @@ def is_field(obj): def is_basic_type(obj, allow_none=True): - if not isinstance(obj, Hashable): + if not isinstance(obj, collections.abc.Hashable): return False if not allow_none and (obj is None or obj is OpenApiTypes.NONE): return False @@ -927,9 +927,16 @@ def resolve_type_hint(hint): if origin is None and is_basic_type(hint, allow_none=False): return build_basic_type(hint) + elif origin is None and inspect.isclass(hint) and issubclass(hint, tuple): + # a convoluted way to catch NamedTuple. suggestions welcome. + if typing.get_type_hints(hint): + properties = {k: resolve_type_hint(v) for k, v in typing.get_type_hints(hint).items()} + else: + properties = {k: build_basic_type(OpenApiTypes.ANY) for k in hint._fields} + return build_object_type(properties=properties, required=properties.keys()) elif origin is list or hint is list: return build_array_type( - resolve_type_hint(args[0]) if args else build_basic_type(OpenApiTypes.OBJECT) + resolve_type_hint(args[0]) if args else build_basic_type(OpenApiTypes.ANY) ) elif origin is tuple: return build_array_type( @@ -967,5 +974,7 @@ def resolve_type_hint(hint): if type(None) in args: schema['nullable'] = True return schema + elif origin is collections.abc.Iterable: + return build_array_type(resolve_type_hint(args[0])) else: raise UnableToProceedError() diff --git a/tests/test_plumbing.py b/tests/test_plumbing.py index 09261638..716217ca 100644 --- a/tests/test_plumbing.py +++ b/tests/test_plumbing.py @@ -1,3 +1,4 @@ +import collections import json import re import sys @@ -83,6 +84,11 @@ def test_detype_patterns_with_module_includes(no_warnings): ) +class NamedTupleB(typing.NamedTuple): + a: int + b: str + + TYPE_HINT_TEST_PARAMS = [ ( typing.Optional[int], @@ -95,7 +101,20 @@ def test_detype_patterns_with_module_includes(no_warnings): {'type': 'array', 'items': {'type': 'object', 'additionalProperties': {'type': 'integer'}}} ), ( list, - {'type': 'array', 'items': {'type': 'object', 'additionalProperties': {}}} + {'type': 'array', 'items': {}} + ), ( + typing.Iterable[collections.namedtuple("NamedTupleA", "a, b")], # noqa + { + 'type': 'array', + 'items': {'type': 'object', 'properties': {'a': {}, 'b': {}}, 'required': ['a', 'b']} + } + ), ( + NamedTupleB, + { + 'type': 'object', + 'properties': {'a': {'type': 'integer'}, 'b': {'type': 'string'}}, + 'required': ['a', 'b'] + } ), ( typing.Tuple[int, int, int], {'type': 'array', 'items': {'type': 'integer'}, 'minLength': 3, 'maxLength': 3}