From 6ae9717415a867cf06e4c8cc2acae3be21822455 Mon Sep 17 00:00:00 2001 From: Syrus Akbary Date: Sun, 23 Jul 2017 18:57:17 -0700 Subject: [PATCH] Improved automatic resolver args from annotations --- UPGRADE-v2.0.md | 61 ++++++++++++++ examples/starwars/schema.py | 8 +- examples/starwars_relay/schema.py | 8 +- graphene/__init__.py | 2 + graphene/types/__init__.py | 2 +- graphene/types/field.py | 3 +- graphene/types/tests/test_query.py | 43 +++++++++- graphene/utils/annotate.py | 6 +- graphene/utils/auto_resolver.py | 4 +- graphene/utils/deprecated.py | 80 +++++++++++++++++++ graphene/utils/resolve_only_args.py | 19 ++++- graphene/utils/resolver_from_annotations.py | 16 +++- .../utils/tests/test_resolve_only_args.py | 7 +- setup.py | 1 + 14 files changed, 235 insertions(+), 25 deletions(-) create mode 100644 graphene/utils/deprecated.py diff --git a/UPGRADE-v2.0.md b/UPGRADE-v2.0.md index b03c9732..e966a5db 100644 --- a/UPGRADE-v2.0.md +++ b/UPGRADE-v2.0.md @@ -80,3 +80,64 @@ class Query(ObjectType): user_connection = relay.ConnectionField(UserConnection) ``` + +## New Features + +### InputObjectType + +`InputObjectType`s are now a first class citizen in Graphene. +That means, if you are using a custom InputObjectType, you can access +it's fields via `getattr` (`my_input.myattr`) when resolving, instead of +the classic way `my_input['myattr']`. + +And also use custom defined properties on your input class. + +Example. Before: + +```python +class User(ObjectType): + name = String() + +class UserInput(InputObjectType): + id = ID() + +def is_user_id(id): + return id.startswith('userid_') + +class Query(ObjectType): + user = graphene.Field(User, id=UserInput()) + + @resolve_only_args + def resolve_user(self, input): + user_id = input.get('id') + if is_user_id(user_id): + return get_user(user_id) +``` + +With 2.0: + +```python +class User(ObjectType): + id = ID() + +class UserInput(InputObjectType): + id = ID() + + @property + def is_user_id(self): + return id.startswith('userid_') + +class Query(ObjectType): + user = graphene.Field(User, id=UserInput()) + + @annotate(input=UserInput) + def resolve_user(self, input): + if input.is_user_id: + return get_user(input.id) + + # You can also do in Python 3: + def resolve_user(self, input: UserInput): + if input.is_user_id: + return get_user(input.id) + +``` diff --git a/examples/starwars/schema.py b/examples/starwars/schema.py index 939f7253..b3c1a63b 100644 --- a/examples/starwars/schema.py +++ b/examples/starwars/schema.py @@ -1,5 +1,5 @@ import graphene -from graphene import resolve_only_args +from graphene import annotate from .data import get_character, get_droid, get_hero, get_human @@ -46,15 +46,15 @@ class Query(graphene.ObjectType): id=graphene.String() ) - @resolve_only_args + @annotate(episode=Episode) def resolve_hero(self, episode=None): return get_hero(episode) - @resolve_only_args + @annotate(id=str) def resolve_human(self, id): return get_human(id) - @resolve_only_args + @annotate(id=str) def resolve_droid(self, id): return get_droid(id) diff --git a/examples/starwars_relay/schema.py b/examples/starwars_relay/schema.py index 103576c4..69a8dba7 100644 --- a/examples/starwars_relay/schema.py +++ b/examples/starwars_relay/schema.py @@ -1,5 +1,5 @@ import graphene -from graphene import relay, resolve_only_args +from graphene import annotate, relay, annotate from .data import create_ship, get_empire, get_faction, get_rebels, get_ship @@ -32,7 +32,7 @@ class Faction(graphene.ObjectType): name = graphene.String(description='The name of the faction.') ships = relay.ConnectionField(ShipConnection, description='The ships used by the faction.') - @resolve_only_args + @annotate def resolve_ships(self, **args): # Transform the instance ship_ids into real instances return [get_ship(ship_id) for ship_id in self.ships] @@ -65,11 +65,11 @@ class Query(graphene.ObjectType): empire = graphene.Field(Faction) node = relay.Node.Field() - @resolve_only_args + @annotate def resolve_rebels(self): return get_rebels() - @resolve_only_args + @annotate def resolve_empire(self): return get_empire() diff --git a/graphene/__init__.py b/graphene/__init__.py index 8a4714d8..98dfe058 100644 --- a/graphene/__init__.py +++ b/graphene/__init__.py @@ -46,6 +46,7 @@ if not __SETUP__: ) from .utils.resolve_only_args import resolve_only_args from .utils.module_loading import lazy_import + from .utils.annotate import annotate __all__ = [ 'ObjectType', @@ -76,6 +77,7 @@ if not __SETUP__: 'ConnectionField', 'PageInfo', 'lazy_import', + 'annotate', 'Context', 'ResolveInfo', diff --git a/graphene/types/__init__.py b/graphene/types/__init__.py index e623506f..ee5d4981 100644 --- a/graphene/types/__init__.py +++ b/graphene/types/__init__.py @@ -1,5 +1,5 @@ # flake8: noqa -from graphql.execution.base import ResolveInfo +from graphql import ResolveInfo from .objecttype import ObjectType from .interface import Interface diff --git a/graphene/types/field.py b/graphene/types/field.py index 9e699a12..8de94bed 100644 --- a/graphene/types/field.py +++ b/graphene/types/field.py @@ -7,6 +7,7 @@ from .mountedtype import MountedType from .structures import NonNull from .unmountedtype import UnmountedType from .utils import get_type +from ..utils.auto_resolver import auto_resolver base_type = type @@ -63,4 +64,4 @@ class Field(MountedType): return get_type(self._type) def get_resolver(self, parent_resolver): - return self.resolver or parent_resolver + return auto_resolver(self.resolver or parent_resolver) diff --git a/graphene/types/tests/test_query.py b/graphene/types/tests/test_query.py index 254570e1..ebc78c1d 100644 --- a/graphene/types/tests/test_query.py +++ b/graphene/types/tests/test_query.py @@ -1,7 +1,7 @@ import json from functools import partial -from graphql import GraphQLError, Source, execute, parse +from graphql import GraphQLError, Source, execute, parse, ResolveInfo from ..dynamic import Dynamic from ..field import Field @@ -13,6 +13,8 @@ from ..scalars import Int, String from ..schema import Schema from ..structures import List from ..union import Union +from ..context import Context +from ...utils.annotate import annotate def test_query(): @@ -406,3 +408,42 @@ def test_big_list_of_containers_multiple_fields_custom_resolvers_query_benchmark result = benchmark(big_list_query) assert not result.errors assert result.data == {'allContainers': [{'x': c.x, 'y': c.y, 'z': c.z, 'o': c.o} for c in big_container_list]} + + +def test_query_annotated_resolvers(): + import json + + context = Context(key="context") + + class Query(ObjectType): + annotated = String(id=String()) + context = String() + info = String() + + @annotate + def resolve_annotated(self, id): + return "{}-{}".format(self, id) + + @annotate(context=Context) + def resolve_context(self, context): + assert isinstance(context, Context) + return "{}-{}".format(self, context.key) + + @annotate(info=ResolveInfo) + def resolve_info(self, info): + assert isinstance(info, ResolveInfo) + return "{}-{}".format(self, info.field_name) + + test_schema = Schema(Query) + + result = test_schema.execute('{ annotated(id:"self") }', "base") + assert not result.errors + assert result.data == {'annotated': 'base-self'} + + result = test_schema.execute('{ context }', "base", context_value=context) + assert not result.errors + assert result.data == {'context': 'base-context'} + + result = test_schema.execute('{ info }', "base") + assert not result.errors + assert result.data == {'info': 'base-info'} diff --git a/graphene/utils/annotate.py b/graphene/utils/annotate.py index c58c7544..5298e40b 100644 --- a/graphene/utils/annotate.py +++ b/graphene/utils/annotate.py @@ -2,14 +2,16 @@ import six from functools import wraps from ..pyutils.compat import signature +from .deprecated import warn_deprecation + def annotate(_func=None, _trigger_warning=True, **annotations): if not six.PY2 and _trigger_warning: - print( + warn_deprecation( "annotate is intended for use in Python 2 only, as you can use type annotations Python 3.\n" "Read more in https://docs.python.org/3/library/typing.html" ) - + if not _func: def _func(f): return annotate(f, **annotations) diff --git a/graphene/utils/auto_resolver.py b/graphene/utils/auto_resolver.py index 6308271d..dd551731 100644 --- a/graphene/utils/auto_resolver.py +++ b/graphene/utils/auto_resolver.py @@ -1,11 +1,11 @@ -from .resolver_from_annotations import resolver_from_annotations +from .resolver_from_annotations import resolver_from_annotations, is_wrapped_from_annotations def auto_resolver(func=None): annotations = getattr(func, '__annotations__', {}) is_annotated = getattr(func, '_is_annotated', False) - if annotations or is_annotated: + if (annotations or is_annotated) and not is_wrapped_from_annotations(func): # Is a Graphene 2.0 resolver function return resolver_from_annotations(func) else: diff --git a/graphene/utils/deprecated.py b/graphene/utils/deprecated.py new file mode 100644 index 00000000..f70b2e7d --- /dev/null +++ b/graphene/utils/deprecated.py @@ -0,0 +1,80 @@ +import functools +import inspect +import warnings + +string_types = (type(b''), type(u'')) + + +def warn_deprecation(text): + warnings.simplefilter('always', DeprecationWarning) + warnings.warn( + text, + category=DeprecationWarning, + stacklevel=2 + ) + warnings.simplefilter('default', DeprecationWarning) + + +def deprecated(reason): + """ + This is a decorator which can be used to mark functions + as deprecated. It will result in a warning being emitted + when the function is used. + """ + + if isinstance(reason, string_types): + + # The @deprecated is used with a 'reason'. + # + # .. code-block:: python + # + # @deprecated("please, use another function") + # def old_function(x, y): + # pass + + def decorator(func1): + + if inspect.isclass(func1): + fmt1 = "Call to deprecated class {name} ({reason})." + else: + fmt1 = "Call to deprecated function {name} ({reason})." + + @functools.wraps(func1) + def new_func1(*args, **kwargs): + warn_deprecation( + fmt1.format(name=func1.__name__, reason=reason), + ) + return func1(*args, **kwargs) + + return new_func1 + + return decorator + + elif inspect.isclass(reason) or inspect.isfunction(reason): + + # The @deprecated is used without any 'reason'. + # + # .. code-block:: python + # + # @deprecated + # def old_function(x, y): + # pass + + func2 = reason + + if inspect.isclass(func2): + fmt2 = "Call to deprecated class {name}." + else: + fmt2 = "Call to deprecated function {name}." + + @functools.wraps(func2) + def new_func2(*args, **kwargs): + warn_deprecation( + fmt2.format(name=func2.__name__), + ) + return func2(*args, **kwargs) + + return new_func2 + + else: + raise TypeError(repr(type(reason))) diff --git a/graphene/utils/resolve_only_args.py b/graphene/utils/resolve_only_args.py index 93a9ab7e..93a5a0fb 100644 --- a/graphene/utils/resolve_only_args.py +++ b/graphene/utils/resolve_only_args.py @@ -1,8 +1,19 @@ +from six import PY2 from functools import wraps +from .annotate import annotate +from .deprecated import deprecated +if PY2: + deprecation_reason = ( + 'The decorator @resolve_only_args is deprecated.\n' + 'Please use @annotate instead.' + ) +else: + deprecation_reason = ( + 'The decorator @resolve_only_args is deprecated.\n' + 'Please use Python 3 type annotations instead. Read more: https://docs.python.org/3/library/typing.html' + ) +@deprecated(deprecation_reason) def resolve_only_args(func): - @wraps(func) - def inner(root, args, context, info): - return func(root, **args) - return inner + return annotate(func) diff --git a/graphene/utils/resolver_from_annotations.py b/graphene/utils/resolver_from_annotations.py index d1c6fb94..49aee652 100644 --- a/graphene/utils/resolver_from_annotations.py +++ b/graphene/utils/resolver_from_annotations.py @@ -1,10 +1,15 @@ from ..pyutils.compat import signature from functools import wraps -from ..types import Context, ResolveInfo - def resolver_from_annotations(func): + from ..types import Context, ResolveInfo + + _is_wrapped_from_annotations = is_wrapped_from_annotations(func) + assert not _is_wrapped_from_annotations, "The function {func_name} is already wrapped.".format( + func_name=func.func_name + ) + func_signature = signature(func) _context_var = None @@ -32,5 +37,10 @@ def resolver_from_annotations(func): else: def inner(root, args, context, info): return func(root, **args) - + + inner._is_wrapped_from_annotations = True return wraps(func)(inner) + + +def is_wrapped_from_annotations(func): + return getattr(func, '_is_wrapped_from_annotations', False) diff --git a/graphene/utils/tests/test_resolve_only_args.py b/graphene/utils/tests/test_resolve_only_args.py index 8c3ec248..d0d06e51 100644 --- a/graphene/utils/tests/test_resolve_only_args.py +++ b/graphene/utils/tests/test_resolve_only_args.py @@ -1,12 +1,13 @@ from ..resolve_only_args import resolve_only_args +from .. import deprecated -def test_resolve_only_args(): - +def test_resolve_only_args(mocker): + mocker.patch.object(deprecated, 'warn_deprecation') def resolver(*args, **kwargs): return kwargs my_data = {'one': 1, 'two': 2} wrapped = resolve_only_args(resolver) - assert wrapped(None, my_data, None, None) == my_data + deprecated.warn_deprecation.assert_called_once() diff --git a/setup.py b/setup.py index 3405e0c5..f01f0d9b 100644 --- a/setup.py +++ b/setup.py @@ -41,6 +41,7 @@ tests_require = [ 'pytest>=2.7.2', 'pytest-benchmark', 'pytest-cov', + 'pytest-mock', 'snapshottest', 'coveralls', 'six',