diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d92dba7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2017 Ernesto Perez Amigo + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..e79e4f6 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,3 @@ +include README.md +include README.rst +include LICENSE \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..646e4d7 --- /dev/null +++ b/README.md @@ -0,0 +1,236 @@ + +--- + +# ![Graphene Logo](http://graphene-python.org/favicon.png) Graphene-Django-Subscriptions [![PyPI version](https://badge.fury.io/py/graphene-django-subscriptions.svg)](https://badge.fury.io/py/graphene-django-subscriptions) + + +This package adds support to Subscription's requests and its integration with websockets using Channels package. + +## Installation + +For installing graphene-django-subscriptions, just run this command in your shell: + +```bash +pip install graphene-django-subscriptions +``` + +## Documentation: + +### Extra functionalities (Subscriptions): + 1. **Subscription** (*Abstract class to define subscriptions to a DjangoSerializerMutation class*) + 2. **GraphqlAPIDemultiplexer** (*Custom WebSocket consumer subclass that handles demultiplexing streams*) + + +### **Subscriptions:** + +This first approach to add Graphql subscriptions support with Channels in **graphene-django**, use **channels-api** package. + +#### 1- Defining custom Subscriptions classes: + +You must to have defined a DjangoSerializerMutation class for each model that you want to define a Subscription class: + +```python +# app/graphql/subscriptions.py +import graphene +from graphene_django_extras.subscription import Subscription +from .mutations import UserMutation, GroupMutation + + +class UserSubscription(Subscription): + class Meta: + mutation_class = UserMutation + stream = 'users' + description = 'User Subscription' + + +class GroupSubscription(Subscription): + class Meta: + mutation_class = GroupMutation + stream = 'groups' + description = 'Group Subscription' + +``` + +Add the subscriptions definitions into your app's schema: + +```python +# app/graphql/schema.py +import graphene +from .subscriptions import UserSubscription, GroupSubscription + + +class Subscriptions(graphene.ObjectType): + user_subscription = UserSubscription.Field() + GroupSubscription = PersonSubscription.Field() +``` + +Add the app's schema into your project root schema: + +```python +# schema.py +import graphene +import custom.app.route.graphql.schema + + +class RootQuery(custom.app.route.graphql.schema.Query, graphene.ObjectType): + class Meta: + description = 'The project root query definition' + + +class RootSubscription(custom.app.route.graphql.schema.Mutation, graphene.ObjectType): + class Meta: + description = 'The project root mutation definition' + + +class RootSubscription(custom.app.route.graphql.schema.Subscriptions, graphene.ObjectType): + class Meta: + description = 'The project root subscription definition' + + +schema = graphene.Schema( + query=RootQuery, + mutation=RootMutation, + subscription=RootSubscription +) +``` + +#### 2- Defining Channels settings and custom routing config ( *For more information see Channels documentation* ): + +We define app routing, as if they were app urls: + +```python +# app/routing.py +from graphene_django_extras.subscriptions import GraphqlAPIDemultiplexer +from channels.routing import route_class +from .graphql.subscriptions import UserSubscription, GroupSubscription + + +class CustomAppDemultiplexer(GraphqlAPIDemultiplexer): + consumers = { + 'users': UserSubscription.get_binding().consumer, + 'groups': GroupSubscription.get_binding().consumer + } + + +app_routing = [ + route_class(CustomAppDemultiplexer) +] +``` + +We define project routing, as if they were project urls: + +```python +# project/routing.py +from channels import include + + +project_routing = [ + include("custom.app.folder.routing.app_routing", path=r"^/custom_websocket_path"), +] + +``` + +You should put into your INSTALLED_APPS the **channels** and **channels_api** modules and you +must to add your project's routing definition into the CHANNEL_LAYERS setting: + +```python +# settings.py +... +INSTALLED_APPS = ( + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.sites', + ... + 'channels', + 'channels_api', + + 'custom_app' +) + +CHANNEL_LAYERS = { + "default": { + "BACKEND": "asgiref.inmemory.ChannelLayer", + "ROUTING": "myproject.routing.project_routing", # Our project routing + }, +} +... +``` + +#### 3- Subscription's examples: + +In your WEB client you must define websocket connection to: *'ws://host:port/custom_websocket_path'*. +When the connection is established, the server return a websocket's message like this: +*{"channel_id": "GthKdsYVrK!WxRCdJQMPi", "connect": "success"}*, where you must store the **channel_id** value to later use in your graphql subscriptions request for subscribe or unsubscribe operations. + +The graphql's subscription request accept five possible parameters: + 1. **operation**: Operation to perform: subscribe or unsubscribe. (*required*) + 2. **action**: Action to which you wish to subscribe: create, update, delete or all_actions. (*required*) + 3. **channelId**: Identification of the connection by websocket. (*required*) + 4. **id**: Object's ID field value that you wish to subscribe to. (*optional*) + 5. **data**: Model's fields that you want to appear in the subscription notifications. (*optional*) + +```js +subscription{ + userSubscription( + action: UPDATE, + operation: SUBSCRIBE, + channelId: "GthKdsYVrK!WxRCdJQMPi", + id: 5, + data: [ID, USERNAME, FIRST_NAME, LAST_NAME, EMAIL, IS_SUPERUSER] + ){ + ok + error + stream + } +} +``` + +In this case, the subscription request sent return a websocket message to client like this: +*{"action": "update", "operation": "subscribe", "ok": true, "stream": "users", "error": null}* +and from that moment each time than the user with id=5 get modified, you will receive a message +through websocket's connection with the following format: + +```js +{ + "stream": "users", + "payload": { + "action": "update", + "model": "auth.user", + "data": { + "id": 5, + "username": "meaghan90", + "first_name": "Meaghan", + "last_name": "Ackerman", + "email": "meaghan@gmail.com", + "is_superuser": false + } + } +} +``` + +For unsubscribe you must send a graphql request like this: + +```js +subscription{ + userSubscription( + action: UPDATE, + operation: UNSUBSCRIBE, + channelId: "GthKdsYVrK!WxRCdJQMPi", + id: 5 + ){ + ok + error + stream + } +} +``` + +**NOTE:** Each time than the graphql's server restart, you must to reestablish the websocket +connection and resend the graphql's subscription request with the new websocket connection id. + + +## Change Log: + +#### v0.0.1: + 1. First commit. diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..97bf5e7 --- /dev/null +++ b/README.rst @@ -0,0 +1,252 @@ + +Graphene-Django-Subscriptions +============================= + +This package adds support to Subscription's requests and its integration with websockets using Channels package. + +Installation: +------------- + +For installing graphene-django-subscriptions, just run this command in your shell: + +.. code:: bash + + pip install "graphene-django-subscriptions" + +Documentation: +-------------- + +*************************************** +Extra functionalities (Subscriptions): +*************************************** + 1. Subscription (Abstract class to define subscriptions to a DjangoSerializerMutation) + 2. GraphqlAPIDemultiplexer (Custom WebSocket consumer subclass that handles demultiplexing streams) + + +Subscriptions: +-------------- + +This first approach to add Graphql subscriptions support with Channels in graphene-django, use channels-api package. + +***************************************** +1- Defining custom Subscriptions classes: +***************************************** + +You must to have defined a DjangoSerializerMutation class for each model that you want to define a Subscription class: + +.. code:: python + + # app/graphql/subscriptions.py + import graphene + from graphene_django_extras.subscription import Subscription + from .mutations import UserMutation, GroupMutation + + + class UserSubscription(Subscription): + class Meta: + mutation_class = UserMutation + stream = 'users' + description = 'User Subscription' + + + class GroupSubscription(Subscription): + class Meta: + mutation_class = GroupMutation + stream = 'groups' + description = 'Group Subscription' + + +Add the subscriptions definitions into your app's schema: + +.. code:: python + + # app/graphql/schema.py + import graphene + from .subscriptions import UserSubscription, GroupSubscription + + + class Subscriptions(graphene.ObjectType): + user_subscription = UserSubscription.Field() + GroupSubscription = PersonSubscription.Field() + + +Add the app's schema into your project root schema: + +.. code:: python + + # schema.py + import graphene + import custom.app.route.graphql.schema + + + class RootQuery(custom.app.route.graphql.schema.Query, graphene.ObjectType): + class Meta: + description = 'The project root query definition' + + + class RootSubscription(custom.app.route.graphql.schema.Mutation, graphene.ObjectType): + class Meta: + description = 'The project root mutation definition' + + + class RootSubscription(custom.app.route.graphql.schema.Subscriptions, graphene.ObjectType): + class Meta: + description = 'The project root subscription definition' + + + schema = graphene.Schema( + query=RootQuery, + mutation=RootMutation, + subscription=RootSubscription + ) + + +******************************************************** +2- Defining Channels settings and custom routing config: +******************************************************** +**Note**: For more information about this step see Channels documentation. + +You must to have defined a DjangoSerializerMutation class for each model that you want to define a Subscription class: + +We define app routing, as if they were app urls: + +.. code:: python + + # app/routing.py + from graphene_django_extras.subscriptions import GraphqlAPIDemultiplexer + from channels.routing import route_class + from .graphql.subscriptions import UserSubscription, GroupSubscription + + + class CustomAppDemultiplexer(GraphqlAPIDemultiplexer): + consumers = { + 'users': UserSubscription.get_binding().consumer, + 'groups': GroupSubscription.get_binding().consumer + } + + + app_routing = [ + route_class(CustomAppDemultiplexer) + ] + + +Defining our project routing, like custom root project urls: + +.. code:: python + + # project/routing.py + from channels import include + + project_routing = [ + include("custom.app.folder.routing.app_routing", path=r"^/custom_websocket_path"), + ] + + +You should put into your INSTALLED_APPS the channels and channels_api modules and you must to add your project's routing definition into the CHANNEL_LAYERS setting: + +.. code:: python + + # settings.py + ... + INSTALLED_APPS = ( + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.sites', + ... + 'channels', + 'channels_api', + + 'custom_app' + ) + + CHANNEL_LAYERS = { + "default": { + "BACKEND": "asgiref.inmemory.ChannelLayer", + "ROUTING": "myproject.routing.project_routing", # Our project routing + }, + } + ... + + +*************************** +3- Subscription's examples: +*************************** + +In your WEB client you must define websocket connection to: 'ws://host:port/custom_websocket_path'. +When the connection is established, the server return a websocket's message like this: +{"channel_id": "GthKdsYVrK!WxRCdJQMPi", "connect": "success"}, where you must store the channel_id value to later use in your graphql subscriptions request for subscribe or unsubscribe operations. + +The graphql's subscription request accept five possible parameters: +1. **operation**: Operation to perform: subscribe or unsubscribe. (required) +2. **action**: Action to which you wish to subscribe: create, update, delete or all_actions. (required) +3. **channelId**: Identification of the connection by websocket. (required) +4. **id**: Object's ID field value that you wish to subscribe to. (optional) +5. **data**: Model's fields that you want to appear in the subscription notifications. (optional) + +.. code:: python + + subscription{ + userSubscription( + action: UPDATE, + operation: SUBSCRIBE, + channelId: "GthKdsYVrK!WxRCdJQMPi", + id: 5, + data: [ID, USERNAME, FIRST_NAME, LAST_NAME, EMAIL, IS_SUPERUSER] + ){ + ok + error + stream + } + } + + +In this case, the subscription request sent return a websocket message to client like this: *{"action": "update", "operation": "subscribe", "ok": true, "stream": "users", "error": null}* and from that moment each time than the user with id=5 get modified, you will receive a message through websocket's connection with the following format: + +.. code:: python + + { + "stream": "users", + "payload": { + "action": "update", + "model": "auth.user", + "data": { + "id": 5, + "username": "meaghan90", + "first_name": "Meaghan", + "last_name": "Ackerman", + "email": "meaghan@gmail.com", + "is_superuser": false + } + } + } + + +For unsubscribe you must send a graphql request like this: + +.. code:: python + + subscription{ + userSubscription( + action: UPDATE, + operation: UNSUBSCRIBE, + channelId: "GthKdsYVrK!WxRCdJQMPi", + id: 5 + ){ + ok + error + stream + } + } + + +*NOTE*: Each time than the graphql's server restart, you must to reestablish the websocket connection and resend the graphql's subscription request with the new websocket connection id. + + +Change Log: +----------- + +******* +v0.0.1: +******* +1. First commit \ No newline at end of file diff --git a/graphene_django_subscriptions/__init__.py b/graphene_django_subscriptions/__init__.py new file mode 100644 index 0000000..b3ac521 --- /dev/null +++ b/graphene_django_subscriptions/__init__.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +from graphene import get_version + +from .consumers import GraphqlAPIDemultiplexer +from .subscription import Subscription + +__author__ = 'Ernesto' + +VERSION = (0, 0, 1, 'final', '') + +__version__ = get_version(VERSION) + +__all__ = ( + '__version__', + + 'Subscription', + 'GraphqlAPIDemultiplexer' +) diff --git a/graphene_django_subscriptions/bindings.py b/graphene_django_subscriptions/bindings.py new file mode 100644 index 0000000..6ac334c --- /dev/null +++ b/graphene_django_subscriptions/bindings.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +from channels_api.bindings import ResourceBindingBase, ResourceBinding + +from .mixins import DjangoGraphqlBindingMixin, UnsubscribeMixin + + +class SubscriptionResourceBinding(DjangoGraphqlBindingMixin, ResourceBindingBase): + # mark as abstract + model = None + + +class ExtraResourceBinding(UnsubscribeMixin, ResourceBinding): + # mark as abstract + model = None diff --git a/graphene_django_subscriptions/consumers.py b/graphene_django_subscriptions/consumers.py new file mode 100644 index 0000000..8a0ef51 --- /dev/null +++ b/graphene_django_subscriptions/consumers.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +from channels.generic.websockets import WebsocketDemultiplexer + + +class GraphqlAPIDemultiplexer(WebsocketDemultiplexer): + + def connect(self, message, **kwargs): + import json + """ Forward connection to all consumers.""" + resp = json.dumps({ + "channel_id": self.message.reply_channel.name.split('.')[-1], + "connect": 'success' + }) + self.message.reply_channel.send({"accept": True, "text": resp}) + for stream, consumer in self.consumers.items(): + kwargs['multiplexer'] = self.multiplexer_class(stream, self.message.reply_channel) + consumer(message, **kwargs) diff --git a/graphene_django_subscriptions/mixins.py b/graphene_django_subscriptions/mixins.py new file mode 100644 index 0000000..915fd94 --- /dev/null +++ b/graphene_django_subscriptions/mixins.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +import json + +from channels import Group +from channels_api import detail_action +from rest_framework.exceptions import ValidationError + + +class UnsubscribeMixin(object): + + @detail_action() + def unsubscribe(self, pk, data, **kwargs): + if 'action' not in data: + raise ValidationError('action required') + action = data['action'] + group_name = self._group_name(action, id=pk) + Group(group_name).discard(self.message.reply_channel) + return {'action': action}, 200 + + +class DjangoGraphqlBindingMixin(object): + + def deserialize(self, message): + body = json.loads(message['text']) + self.request_id = body.get("request_id") + action = body.get('action') + data = body.get('data', None) + pk = data.get('id', None) + return action, pk, data + + def serialize(self, instance, action): + payload = { + "action": action, + "data": self.serialize_data(instance), + "model": self.model_label, + } + return payload + + def serialize_data(self, instance): + data = self.get_serializer(instance).data + only_fields = hasattr(self.get_serializer_class().Meta, 'only_fields') + if only_fields: + data = {k: v for k, v in data.items() if k in self.get_serializer_class().Meta.only_fields} + return data diff --git a/graphene_django_subscriptions/subscription.py b/graphene_django_subscriptions/subscription.py new file mode 100644 index 0000000..e50ea12 --- /dev/null +++ b/graphene_django_subscriptions/subscription.py @@ -0,0 +1,170 @@ +# -*- coding: utf-8 -*- +import copy +import json + +from channels import Group +from graphene import Field, Argument, Enum, String, ObjectType, Boolean, List, ID +from graphene.types.base import BaseOptions +from graphene.utils.str_converters import to_snake_case +from graphene_django_extras.mutation import DjangoSerializerMutation +from six import string_types + +from .bindings import SubscriptionResourceBinding + + +class ActionSubscriptionEnum(Enum): + CREATE = 'create' + UPDATE = 'update' + DELETE = 'delete' + ALL_ACTIONS = 'all_actions' + + +class OperationSubscriptionEnum(Enum): + SUBSCRIBE = 'subscribe' + UNSUBSCRIBE = 'unsubscribe' + + +class SubscriptionOptions(BaseOptions): + output = None + fields = None + arguments = None + model = None + stream = None + serializer_class = None + queryset = None + mutation_class = None + + +class Subscription(ObjectType): + """ + Subscription Type Definition + """ + ok = Boolean(description='Boolean field that return subscription request result.') + error = String(description='Subscribe or unsubscribe operation request error .') + stream = String(description='Stream name.') + operation = OperationSubscriptionEnum(description='Subscription operation.') + action = ActionSubscriptionEnum(description='Subscription action.') + + class Meta: + abstract = True + + @classmethod + def __init_subclass_with_meta__(cls, mutation_class=None, stream=None, queryset=None, description='', **options): + + assert issubclass(mutation_class, DjangoSerializerMutation), \ + 'You need to pass a valid DjangoSerializerMutation subclass in {}.Meta, received "mutation_class = {}"'\ + .format(cls.__name__, mutation_class) + + assert isinstance(stream, string_types), \ + 'You need to pass a valid string stream name in {}.Meta, received "{}"'.format( + cls.__name__, stream) + + if queryset: + assert mutation_class._meta.model == queryset.model, \ + 'The queryset model must correspond with the mutation_class model passed on Meta class, received ' \ + '"{}", expected "{}"'.format(queryset.model.__name__, mutation_class._meta.model.__name__) + + description = description or 'Subscription Type for {} model'.format(mutation_class._meta.model.__name__) + + _meta = SubscriptionOptions(cls) + + _meta.output = cls + _meta.fields = None + _meta.model = mutation_class._meta.model + _meta.stream = stream + _meta.serializer_class = copy.deepcopy(mutation_class._meta.serializer_class) + _meta.mutation_class = mutation_class + + serializer_fields = [(to_snake_case(field.strip('_')).upper(), to_snake_case(field)) + for field in _meta.serializer_class.Meta.fields] + model_fields_enum = Enum('{}Fields'.format(mutation_class._meta.model.__name__), serializer_fields, + description='Desired {} fields in subscription\'s notification.' + .format(mutation_class._meta.model.__name__)) + + arguments = { + 'channel_id': Argument(String, required=True, description='Websocket\'s channel connection identification'), + 'action': Argument(ActionSubscriptionEnum, required=True, + description='Subscribe or unsubscribe action : (create, update or delete)'), + 'operation': Argument(OperationSubscriptionEnum, required=True, description='Operation to do'), + 'id': Argument(ID, description='ID field value that has the object to which you wish to subscribe'), + 'data': List(model_fields_enum, required=False) + } + + _meta.arguments = arguments + + super(Subscription, cls).__init_subclass_with_meta__(_meta=_meta, description=description, **options) + + @classmethod + def model_label(cls): + return u'{}.{}'.format(cls._meta.model._meta.app_label.lower(), cls._meta.model._meta.object_name.lower()) + + @classmethod + def _group_name(cls, action, id=None): + """ Formatting helper for group names. """ + if id: + # return '{}-{}-{}-{}'.format(cls.model_label(), cls._meta.stream.lower(), action, id) + return '{}-{}-{}'.format(cls.model_label(), action, id) + else: + # return '{}-{}-{}'.format(cls.model_label(), cls._meta.stream.lower(), action) + return '{}-{}'.format(cls.model_label(), action) + + @classmethod + def subscription_resolver(cls, root, info, **kwargs): + # Manage the subscribe or unsubscribe operations + action = kwargs.get('action') + operation = kwargs.get('operation') + data = kwargs.get('data', None) + obj_id = kwargs.get('id', None) + + response = { + 'stream': cls._meta.stream, + 'operation': operation, + 'action': action + } + + try: + channel = copy.copy(info.context.reply_channel) + channel.name = u'daphne.response.{}'.format(kwargs.get('channel_id')) + + if action == 'all_actions': + for act in ('create', 'update', 'delete'): + group_name = cls._group_name(act, id=obj_id) + + if operation == 'subscribe': + Group(group_name).add(channel) + elif operation == 'unsubscribe': + Group(group_name).discard(channel) + else: + group_name = cls._group_name(action, id=obj_id) + + if operation == 'subscribe': + Group(group_name).add(channel) + elif operation == 'unsubscribe': + Group(group_name).discard(channel) + + if data is not None: + setattr(cls._meta.serializer_class.Meta, 'only_fields', data) + + response.update(dict(ok=True, error=None)) + channel.send({'text': json.dumps(response)}) + + except Exception as e: + response.update(dict(ok=False, error=e.__str__())) + + return cls(**response) + + @classmethod + def get_binding(cls): + + class ResourceBinding(SubscriptionResourceBinding): + model = cls._meta.model + stream = cls._meta.stream + serializer_class = cls._meta.serializer_class + queryset = cls._meta.queryset + + return ResourceBinding + + @classmethod + def Field(cls, *args, **kwargs): + kwargs.update({'description': 'Subscription for {} model'.format(cls._meta.model.__name__)}) + return Field(cls._meta.output, args=cls._meta.arguments, resolver=cls.subscription_resolver, **kwargs) diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..2a9acf1 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[bdist_wheel] +universal = 1 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..4084d3f --- /dev/null +++ b/setup.py @@ -0,0 +1,65 @@ +import ast +import os +import re + +from setuptools import setup + + +_name = 'graphene_django_subscriptions' +_version_re = re.compile(r'VERSION\s+=\s+(.*)') + +with open('{}/__init__.py'.format(_name), 'rb') as f: + version = ast.literal_eval(_version_re.search( + f.read().decode('utf-8')).group(1)) + version = ".".join([str(v) for v in version]) + version = version.split('.final')[0] if 'final' in version else version + + +def get_packages(): + return [dirpath + for dirpath, dirnames, filenames in os.walk(_name) + if os.path.exists(os.path.join(dirpath, '__init__.py'))] + + +setup( + name=_name, + version=version, + + description='Graphene-Django-Subscriptions add subscriptions support to graphene-django through ' + 'Channels module', + long_description=open('README.rst').read(), + + url='https://github.com/eamigo86/graphene-django-subscriptions', + + author='Ernesto Perez Amigo', + author_email='eamigo@nauta.cu', + + license='MIT', + + classifiers=[ + 'Intended Audience :: Developers', + 'Topic :: Software Development :: Libraries', + 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.3', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: Implementation :: PyPy', + ], + + keywords='api graphql subscription rest graphene django channels', + + packages=get_packages(), + install_requires=[ + 'graphql-core==2.0.dev20171009101843', + 'graphene==2.0.dev20170802065539', + 'graphene-django==2.0.dev2017083101', + 'graphene-django-extras>=0.1.0', + 'channels-api>=0.4.0' + ], + include_package_data=True, + zip_safe=False, + platforms='any', +)