diff --git a/.travis.yml b/.travis.yml index d254bd80..0435df42 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,7 +3,7 @@ sudo: false python: - 2.7 install: -- pip install pytest pytest-cov coveralls flake8 six +- pip install pytest pytest-cov coveralls flake8 six blinker - pip install git+https://github.com/dittos/graphqllib.git # Last version of graphqllib - pip install graphql-relay - python setup.py develop diff --git a/README.md b/README.md index 73290509..187481ed 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,62 @@ query = ''' result = Schema.execute(query) ``` +### Relay Schema + +Graphene also supports Relay, check the (Starwars Relay example)[/tests/starwars_relay]! + +```python +import graphene +from graphene import relay + +class Ship(relay.Node): + '''A ship in the Star Wars saga''' + name = graphene.StringField(description='The name of the ship.') + + @classmethod + def get_node(cls, id): + ship = getShip(id) + if ship: + return Ship(ship) + + +class Faction(relay.Node): + '''A faction in the Star Wars saga''' + name = graphene.StringField(description='The name of the faction.') + ships = relay.ConnectionField(Ship, description='The ships used by the faction.') + + @resolve_only_args + def resolve_ships(self, **kwargs): + return [Ship(getShip(ship)) for ship in self.instance.ships] + + @classmethod + def get_node(cls, id): + faction = getFaction(id) + if faction: + return Faction(faction) + + +class Query(graphene.ObjectType): + rebels = graphene.Field(Faction) + empire = graphene.Field(Faction) + node = relay.NodeField() + + @resolve_only_args + def resolve_rebels(self): + return Faction(getRebels()) + + @resolve_only_args + def resolve_empire(self): + return Faction(getEmpire()) + + +Schema = graphene.Schema(query=Query) + +# Later on, for querying +Schema.execute('''rebels { name }''') + +``` + ## Contributing After cloning this repo, ensure dependencies are installed by running: diff --git a/graphene/__init__.py b/graphene/__init__.py index 42442952..c84eeb8c 100644 --- a/graphene/__init__.py +++ b/graphene/__init__.py @@ -1,7 +1,6 @@ from graphql.core.type import ( GraphQLEnumType as Enum, GraphQLArgument as Argument, - # GraphQLSchema as Schema, GraphQLString as String, GraphQLInt as Int, GraphQLID as ID diff --git a/graphene/core/fields.py b/graphene/core/fields.py index 876416b1..478e3630 100644 --- a/graphene/core/fields.py +++ b/graphene/core/fields.py @@ -1,4 +1,3 @@ -import inspect from graphql.core.type import ( GraphQLField, GraphQLList, @@ -9,8 +8,8 @@ from graphql.core.type import ( GraphQLID, GraphQLArgument, ) -from graphene.core.types import ObjectType, Interface from graphene.utils import cached_property +from graphene.core.utils import get_object_type class Field(object): def __init__(self, field_type, resolve=None, null=True, args=None, description='', **extra_args): @@ -45,16 +44,11 @@ class Field(object): @cached_property def type(self): - field_type = self.field_type - _is_class = inspect.isclass(field_type) - if _is_class and issubclass(field_type, ObjectType): - field_type = field_type._meta.type - elif isinstance(field_type, Field): - field_type = field_type.type - elif field_type == 'self': - field_type = self.object_type._meta.type + if isinstance(self.field_type, Field): + field_type = self.field_type.type + else: + field_type = get_object_type(self.field_type, self.object_type) field_type = self.type_wrapper(field_type) - return field_type def type_wrapper(self, field_type): @@ -105,6 +99,12 @@ class Field(object): return '<%s>' % path +class NativeField(Field): + def __init__(self, field=None): + super(NativeField, self).__init__(None) + self.field = field or getattr(self, 'field') + + class TypeField(Field): def __init__(self, *args, **kwargs): super(TypeField, self).__init__(self.field_type, *args, **kwargs) diff --git a/graphene/core/options.py b/graphene/core/options.py index f55cd494..c3dc13af 100644 --- a/graphene/core/options.py +++ b/graphene/core/options.py @@ -1,15 +1,18 @@ from graphene.utils import cached_property -DEFAULT_NAMES = ('description', 'name', 'interface', 'type_name', 'interfaces', 'proxy') +DEFAULT_NAMES = ('app_label', 'description', 'name', 'interface', + 'type_name', 'interfaces', 'proxy') + class Options(object): - def __init__(self, meta=None): + def __init__(self, meta=None, app_label=None): self.meta = meta self.local_fields = [] self.interface = False self.proxy = False self.interfaces = [] self.parents = [] + self.app_label = app_label def contribute_to_class(self, cls, name): cls._meta = self @@ -51,7 +54,6 @@ class Options(object): def add_field(self, field): self.local_fields.append(field) - setattr(self.parent, field.field_name, field) @cached_property def fields(self): @@ -62,7 +64,7 @@ class Options(object): @cached_property def fields_map(self): - return {f.field_name:f for f in self.fields} + return {f.field_name: f for f in self.fields} @cached_property def type(self): diff --git a/graphene/core/types.py b/graphene/core/types.py index 250559e5..3ffb466d 100644 --- a/graphene/core/types.py +++ b/graphene/core/types.py @@ -8,8 +8,10 @@ from graphql.core.type import ( ) from graphql.core import graphql +from graphene import signals from graphene.core.options import Options + class ObjectTypeMeta(type): def __new__(cls, name, bases, attrs): super_new = super(ObjectTypeMeta, cls).__new__ @@ -20,7 +22,10 @@ class ObjectTypeMeta(type): module = attrs.pop('__module__') doc = attrs.pop('__doc__', None) - new_class = super_new(cls, name, bases, {'__module__': module, '__doc__': doc}) + new_class = super_new(cls, name, bases, { + '__module__': module, + '__doc__': doc + }) attr_meta = attrs.pop('Meta', None) if not attr_meta: meta = getattr(new_class, 'Meta', None) @@ -28,7 +33,12 @@ class ObjectTypeMeta(type): meta = attr_meta base_meta = getattr(new_class, '_meta', None) - new_class.add_to_class('_meta', Options(meta)) + if '.' in module: + app_label, _ = module.rsplit('.', 1) + else: + app_label = module + + new_class.add_to_class('_meta', Options(meta, app_label)) if base_meta and base_meta.proxy: new_class._meta.interface = base_meta.interface # Add all attributes to the class. @@ -51,7 +61,7 @@ class ObjectTypeMeta(type): # moment). for field in parent_fields: if field.field_name in field_names: - raise FieldError( + raise Exception( 'Local field %r in class %r clashes ' 'with field of similar name from ' 'base class %r' % (field.field_name, name, base.__name__) @@ -61,8 +71,12 @@ class ObjectTypeMeta(type): new_class._meta.interfaces.append(base) # new_class._meta.parents.extend(base._meta.parents) + new_class._prepare() return new_class + def _prepare(cls): + signals.class_prepared.send(cls) + def add_to_class(cls, name, value): # We should call the contribute_to_class method only if it's bound if not inspect.isclass(value) and hasattr(value, 'contribute_to_class'): @@ -73,15 +87,21 @@ class ObjectTypeMeta(type): class ObjectType(six.with_metaclass(ObjectTypeMeta)): def __init__(self, instance=None): + signals.pre_init.send(self.__class__, instance=instance) self.instance = instance + signals.post_init.send(self.__class__, instance=self) + + def __getattr__(self, name): + if self.instance: + return getattr(self.instance, name) def get_field(self, field): return getattr(self.instance, field, None) def resolve(self, field_name, args, info): if field_name not in self._meta.fields_map.keys(): - raise Exception('Field %s not found in model'%field_name) - custom_resolve_fn = 'resolve_%s'%field_name + raise Exception('Field %s not found in model' % field_name) + custom_resolve_fn = 'resolve_%s' % field_name if hasattr(self, custom_resolve_fn): resolve_fn = getattr(self, custom_resolve_fn) return resolve_fn(args, info) @@ -104,13 +124,13 @@ class ObjectType(six.with_metaclass(ObjectTypeMeta)): cls._meta.type_name, description=cls._meta.description, resolve_type=cls.resolve_type, - fields=lambda: {name:field.field for name, field in fields.items()} + fields=lambda: {name: field.field for name, field in fields.items()} ) return GraphQLObjectType( cls._meta.type_name, description=cls._meta.description, interfaces=[i._meta.type for i in cls._meta.interfaces], - fields=lambda: {name:field.field for name, field in fields.items()} + fields=lambda: {name: field.field for name, field in fields.items()} ) @@ -125,7 +145,7 @@ class Schema(object): self.query = query self.query_type = query._meta.type self._schema = GraphQLSchema(query=self.query_type, mutation=mutation) - + def execute(self, request='', root=None, vars=None, operation_name=None): return graphql( self._schema, diff --git a/graphene/core/utils.py b/graphene/core/utils.py new file mode 100644 index 00000000..b50a7664 --- /dev/null +++ b/graphene/core/utils.py @@ -0,0 +1,54 @@ +import inspect + +from graphene.core.types import ObjectType +from graphene import signals + +registered_object_types = [] + + +def get_object_type(field_type, object_type=None): + native_type = get_type(field_type, object_type) + if native_type: + field_type = native_type._meta.type + return field_type + + +def get_type(field_type, object_type=None): + _is_class = inspect.isclass(field_type) + if _is_class and issubclass(field_type, ObjectType): + return field_type + elif isinstance(field_type, basestring): + if field_type == 'self': + return object_type + else: + object_type = get_registered_object_type(field_type, object_type) + return object_type + return None + +def get_registered_object_type(name, object_type=None): + app_label = None + object_type_name = name + + if '.' in name: + app_label, object_type_name = name.rsplit('.', 1) + elif object_type: + app_label = object_type._meta.app_label + + # Filter all registered object types which have the same name + ots = [ot for ot in registered_object_types if ot._meta.type_name == object_type_name] + # If the list have more than one object type with the name, filter by + # the app_label + if len(ots)>1 and app_label: + ots = [ot for ot in ots if ot._meta.app_label == app_label] + + if len(ots)>1: + raise Exception('Multiple ObjectTypes returned with the name %s' % name) + if not ots: + raise Exception('No ObjectType found with name %s' % name) + + return ots[0] + + +@signals.class_prepared.connect +def object_type_created(sender): + registered_object_types.append(sender) diff --git a/graphene/relay/__init__.py b/graphene/relay/__init__.py index e69de29b..d93aa7ac 100644 --- a/graphene/relay/__init__.py +++ b/graphene/relay/__init__.py @@ -0,0 +1,85 @@ +import collections + +from graphene import signals +from graphene.core.fields import Field, NativeField +from graphene.core.types import Interface +from graphene.core.utils import get_type +from graphene.utils import cached_property + +from graphql_relay.node.node import ( + nodeDefinitions, + globalIdField, + fromGlobalId +) +from graphql_relay.connection.arrayconnection import ( + connectionFromArray +) +from graphql_relay.connection.connection import ( + connectionArgs, + connectionDefinitions +) + +registered_nodes = {} + + +def getNode(globalId, *args): + resolvedGlobalId = fromGlobalId(globalId) + _type, _id = resolvedGlobalId.type, resolvedGlobalId.id + if _type in registered_nodes: + object_type = registered_nodes[_type] + return object_type.get_node(_id) + + +def getNodeType(obj): + return obj._meta.type + + +_nodeDefinitions = nodeDefinitions(getNode, getNodeType) + + +class Node(Interface): + @classmethod + def get_graphql_type(cls): + if cls is Node: + # Return only nodeInterface when is the Node Inerface + return _nodeDefinitions.nodeInterface + return super(Node, cls).get_graphql_type() + + +class NodeField(NativeField): + field = _nodeDefinitions.nodeField + + +class ConnectionField(Field): + def __init__(self, field_type, resolve=None, description=''): + super(ConnectionField, self).__init__(field_type, resolve=resolve, + args=connectionArgs, description=description) + + def resolve(self, instance, args, info): + resolved = super(ConnectionField, self).resolve(instance, args, info) + if resolved: + assert isinstance(resolved, collections.Iterable), 'Resolved value from the connection field have to be iterable' + return connectionFromArray(resolved, args) + + @cached_property + def type(self): + object_type = get_type(self.field_type, self.object_type) + assert issubclass(object_type, Node), 'Only nodes have connections.' + return object_type.connection + + +@signals.class_prepared.connect +def object_type_created(object_type): + if issubclass(object_type, Node): + type_name = object_type._meta.type_name + assert type_name not in registered_nodes, 'Two nodes with the same type_name: %s' % type_name + registered_nodes[type_name] = object_type + # def getId(*args, **kwargs): + # print '**GET ID', args, kwargs + # return 2 + field = NativeField(globalIdField(type_name)) + object_type.add_to_class('id', field) + assert hasattr(object_type, 'get_node'), 'get_node classmethod not found in %s Node' % type_name + + connection = connectionDefinitions(type_name, object_type._meta.type).connectionType + object_type.add_to_class('connection', connection) diff --git a/graphene/signals.py b/graphene/signals.py new file mode 100644 index 00000000..954d02a1 --- /dev/null +++ b/graphene/signals.py @@ -0,0 +1,5 @@ +from blinker import Signal + +class_prepared = Signal() +pre_init = Signal() +post_init = Signal() diff --git a/setup.py b/setup.py index b5222dc0..705d1ac3 100644 --- a/setup.py +++ b/setup.py @@ -48,6 +48,7 @@ setup( install_requires=[ 'six', + 'blinker', 'graphqllib', 'graphql-relay' ], diff --git a/tests/core/test_types.py b/tests/core/test_types.py index e8d3ec69..b17cae48 100644 --- a/tests/core/test_types.py +++ b/tests/core/test_types.py @@ -29,7 +29,7 @@ def test_interface(): assert Character._meta.type_name == 'Character' assert isinstance(object_type, GraphQLInterfaceType) assert object_type.description == 'Character description' - assert object_type.get_fields() == {'name': Character.name.field} + assert object_type.get_fields() == {'name': Character._meta.fields_map['name'].field} def test_object_type(): object_type = Human._meta.type @@ -37,5 +37,5 @@ def test_object_type(): assert Human._meta.type_name == 'Human' assert isinstance(object_type, GraphQLObjectType) assert object_type.description == 'Human description' - assert object_type.get_fields() == {'name': Character.name.field, 'friends': Human.friends.field} + assert object_type.get_fields() == {'name': Character._meta.fields_map['name'].field, 'friends': Human._meta.fields_map['friends'].field} assert object_type.get_interfaces() == [Character._meta.type] diff --git a/tests/relay/test_relay.py b/tests/relay/test_relay.py new file mode 100644 index 00000000..c532949f --- /dev/null +++ b/tests/relay/test_relay.py @@ -0,0 +1,41 @@ +from pytest import raises + +import graphene +from graphene import relay + + +class OtherNode(relay.Node): + name = graphene.StringField() + + @classmethod + def get_node(cls, id): + pass + + +def test_field_no_contributed_raises_error(): + with raises(Exception) as excinfo: + class Part(relay.Node): + x = graphene.StringField() + + assert 'get_node' in str(excinfo.value) + + +def test_node_should_have_connection(): + assert OtherNode.connection + + +def test_node_should_have_id_field(): + assert 'id' in OtherNode._meta.fields_map + + +def test_field_no_contributed_raises_error(): + with raises(Exception) as excinfo: + class Ship(graphene.ObjectType): + name = graphene.StringField() + + + class Faction(relay.Node): + name = graphene.StringField() + ships = relay.ConnectionField(Ship) + + assert 'same type_name' in str(excinfo.value) diff --git a/tests/starwars/schema.py b/tests/starwars/schema.py index 1a7e570d..9fbee302 100644 --- a/tests/starwars/schema.py +++ b/tests/starwars/schema.py @@ -9,12 +9,14 @@ Episode = graphene.Enum('Episode', dict( JEDI = 6 )) + def wrap_character(character): if isinstance(character, _Human): return Human(character) elif isinstance(character, _Droid): return Droid(character) + class Character(graphene.Interface): id = graphene.IDField() name = graphene.StringField() @@ -24,6 +26,7 @@ class Character(graphene.Interface): def resolve_friends(self, args, *_): return [wrap_character(getCharacter(f)) for f in self.instance.friends] + class Human(Character): homePlanet = graphene.StringField() @@ -50,8 +53,6 @@ class Query(graphene.ObjectType): @resolve_only_args def resolve_human(self, id): return wrap_character(getHuman(id)) - if human: - return Human(human) @resolve_only_args def resolve_droid(self, id): diff --git a/tests/starwars_relay/__init__.py b/tests/starwars_relay/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/starwars_relay/data.py b/tests/starwars_relay/data.py new file mode 100644 index 00000000..31ac591d --- /dev/null +++ b/tests/starwars_relay/data.py @@ -0,0 +1,98 @@ +from collections import namedtuple + +Ship = namedtuple('Ship',['id', 'name']) +Faction = namedtuple('Faction',['id', 'name', 'ships']) + +xwing = Ship( + id='1', + name='X-Wing', +) + +ywing = Ship( + id='2', + name='Y-Wing', +) + +awing = Ship( + id='3', + name='A-Wing', +) + +# Yeah, technically it's Corellian. But it flew in the service of the rebels, +# so for the purposes of this demo it's a rebel ship. +falcon = Ship( + id='4', + name='Millenium Falcon', +) + +homeOne = Ship( + id='5', + name='Home One', +) + +tieFighter = Ship( + id='6', + name='TIE Fighter', +) + +tieInterceptor = Ship( + id='7', + name='TIE Interceptor', +) + +executor = Ship( + id='8', + name='Executor', +) + +rebels = Faction( + id='1', + name='Alliance to Restore the Republic', + ships=['1', '2', '3', '4', '5'] +) + +empire = Faction( + id='2', + name='Galactic Empire', + ships= ['6', '7', '8'] +) + +data = { + 'Faction': { + '1': rebels, + '2': empire + }, + 'Ship': { + '1': xwing, + '2': ywing, + '3': awing, + '4': falcon, + '5': homeOne, + '6': tieFighter, + '7': tieInterceptor, + '8': executor + } +} + +def createShip(shipName, factionId): + nextShip = len(data['Ship'].keys())+1 + newShip = Ship( + id=str(nextShip), + name=shipName + ) + data['Ship'][newShip.id] = newShip + data['Faction'][factionId].ships.append(newShip.id) + return newShip + + +def getShip(_id): + return data['Ship'][_id] + +def getFaction(_id): + return data['Faction'][_id] + +def getRebels(): + return rebels + +def getEmpire(): + return empire diff --git a/tests/starwars_relay/schema.py b/tests/starwars_relay/schema.py new file mode 100644 index 00000000..55e03e6c --- /dev/null +++ b/tests/starwars_relay/schema.py @@ -0,0 +1,53 @@ +import graphene +from graphene import resolve_only_args, relay + +from .data import ( + getFaction, + getShip, + getRebels, + getEmpire, +) + + +class Ship(relay.Node): + '''A ship in the Star Wars saga''' + name = graphene.StringField(description='The name of the ship.') + + @classmethod + def get_node(cls, id): + ship = getShip(id) + if ship: + return Ship(ship) + + +class Faction(relay.Node): + '''A faction in the Star Wars saga''' + name = graphene.StringField(description='The name of the faction.') + ships = relay.ConnectionField(Ship, description='The ships used by the faction.') + + @resolve_only_args + def resolve_ships(self, **kwargs): + return [Ship(getShip(ship)) for ship in self.instance.ships] + + @classmethod + def get_node(cls, id): + faction = getFaction(id) + if faction: + return Faction(faction) + + +class Query(graphene.ObjectType): + rebels = graphene.Field(Faction) + empire = graphene.Field(Faction) + node = relay.NodeField() + + @resolve_only_args + def resolve_rebels(self): + return Faction(getRebels()) + + @resolve_only_args + def resolve_empire(self): + return Faction(getEmpire()) + + +Schema = graphene.Schema(query=Query) diff --git a/tests/starwars_relay/schema_other.py b/tests/starwars_relay/schema_other.py new file mode 100644 index 00000000..dd33bba7 --- /dev/null +++ b/tests/starwars_relay/schema_other.py @@ -0,0 +1,60 @@ +import graphene +from graphene import resolve_only_args, relay + +from .data import ( + getHero, getHuman, getCharacter, getDroid, + Human as _Human, Droid as _Droid) + +Episode = graphene.Enum('Episode', dict( + NEWHOPE=4, + EMPIRE=5, + JEDI=6 +)) + +def wrap_character(character): + if isinstance(character, _Human): + return Human(character) + elif isinstance(character, _Droid): + return Droid(character) + + +class Character(graphene.Interface): + name = graphene.StringField() + friends = relay.Connection('Character') + appearsIn = graphene.ListField(Episode) + + def resolve_friends(self, args, *_): + return [wrap_character(getCharacter(f)) for f in self.instance.friends] + + +class Human(relay.Node, Character): + homePlanet = graphene.StringField() + + +class Droid(relay.Node, Character): + primaryFunction = graphene.StringField() + + +class Query(graphene.ObjectType): + hero = graphene.Field(Character, + episode=graphene.Argument(Episode)) + human = graphene.Field(Human, + id=graphene.Argument(graphene.String)) + droid = graphene.Field(Droid, + id=graphene.Argument(graphene.String)) + node = relay.NodeField() + + @resolve_only_args + def resolve_hero(self, episode): + return wrap_character(getHero(episode)) + + @resolve_only_args + def resolve_human(self, id): + return wrap_character(getHuman(id)) + + @resolve_only_args + def resolve_droid(self, id): + return wrap_character(getDroid(id)) + + +Schema = graphene.Schema(query=Query) diff --git a/tests/starwars_relay/test_connections.py b/tests/starwars_relay/test_connections.py new file mode 100644 index 00000000..1bac0a4d --- /dev/null +++ b/tests/starwars_relay/test_connections.py @@ -0,0 +1,37 @@ +from pytest import raises +from graphql.core import graphql + +from .schema import Schema + +def test_correct_fetch_first_ship_rebels(): + query = ''' + query RebelsShipsQuery { + rebels { + name, + ships(first: 1) { + edges { + node { + name + } + } + } + } + } + ''' + expected = { + 'rebels': { + 'name': 'Alliance to Restore the Republic', + 'ships': { + 'edges': [ + { + 'node': { + 'name': 'X-Wing' + } + } + ] + } + } + } + result = Schema.execute(query) + assert result.errors == None + assert result.data == expected diff --git a/tests/starwars_relay/test_objectidentification.py b/tests/starwars_relay/test_objectidentification.py new file mode 100644 index 00000000..85050b6f --- /dev/null +++ b/tests/starwars_relay/test_objectidentification.py @@ -0,0 +1,105 @@ +from pytest import raises +from graphql.core import graphql + +from .schema import Schema + +def test_correctly_fetches_id_name_rebels(): + query = ''' + query RebelsQuery { + rebels { + id + name + } + } + ''' + expected = { + 'rebels': { + 'id': 'RmFjdGlvbjox', + 'name': 'Alliance to Restore the Republic' + } + } + result = Schema.execute(query) + assert result.errors == None + assert result.data == expected + +def test_correctly_refetches_rebels(): + query = ''' + query RebelsRefetchQuery { + node(id: "RmFjdGlvbjox") { + id + ... on Faction { + name + } + } + } + ''' + expected = { + 'node': { + 'id': 'RmFjdGlvbjox', + 'name': 'Alliance to Restore the Republic' + } + } + result = Schema.execute(query) + assert result.errors == None + assert result.data == expected + +def test_correctly_fetches_id_name_empire(): + query = ''' + query EmpireQuery { + empire { + id + name + } + } + ''' + expected = { + 'empire': { + 'id': 'RmFjdGlvbjoy', + 'name': 'Galactic Empire' + } + } + result = Schema.execute(query) + assert result.errors == None + assert result.data == expected + +def test_correctly_refetches_empire(): + query = ''' + query EmpireRefetchQuery { + node(id: "RmFjdGlvbjoy") { + id + ... on Faction { + name + } + } + } + ''' + expected = { + 'node': { + 'id': 'RmFjdGlvbjoy', + 'name': 'Galactic Empire' + } + } + result = Schema.execute(query) + assert result.errors == None + assert result.data == expected + +def test_correctly_refetches_xwing(): + query = ''' + query XWingRefetchQuery { + node(id: "U2hpcDox") { + id + ... on Ship { + name + } + } + } + ''' + expected = { + 'node': { + 'id': 'U2hpcDox', + 'name': 'X-Wing' + } + } + result = Schema.execute(query) + assert result.errors == None + assert result.data == expected diff --git a/tox.ini b/tox.ini index 09e27276..735a042d 100644 --- a/tox.ini +++ b/tox.ini @@ -6,6 +6,8 @@ deps= pytest>=2.7.2 django>=1.8.0,<1.9 flake8 + six + blinker singledispatch commands= py.test