Skip to content

Commit b1263ba

Browse files
committed
Merge pull request #195 from Syncano/release-v5.1.0
[Release v5.1.0] Next release
2 parents c0361e5 + bc93492 commit b1263ba

17 files changed

+608
-39
lines changed

docs/source/getting_started.rst

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -72,16 +72,17 @@ Making Connections
7272
>>> import syncano
7373
>>> connection = syncano.connect(email='YOUR_EMAIL', password='YOUR_PASSWORD')
7474

75-
If you want to connect directly to chosen instance you can use :func:`~syncano.connect_instance` function::
75+
If you want to use instance in connection you can use :func:`~syncano.connect` function,
76+
then you can omit the instance_name in other calls::
7677

7778
>>> import syncano
78-
>>> connection = syncano.connect_instance('instance_name', email='YOUR_EMAIL', password='YOUR_PASSWORD')
79+
>>> connection = syncano.connect(instance_name='instance_name', email='YOUR_EMAIL', password='YOUR_PASSWORD')
7980

8081
If you have obtained your ``Account Key`` from the website you can omit ``email`` & ``password`` and pass ``Account Key`` directly to connection:
8182

8283
>>> import syncano
8384
>>> connection = syncano.connect(api_key='YOUR_API_KEY')
84-
>>> connection = syncano.connect_instance('instance_name', api_key='YOUR_API_KEY')
85+
>>> connection = syncano.connect(instance_name='instance_name', api_key='YOUR_API_KEY')
8586

8687

8788
Troubleshooting Connections
@@ -127,8 +128,8 @@ Each model has a different set of fields and commands. For more information chec
127128
Next Steps
128129
----------
129130

130-
If you'd like more information on interacting with Syncano, check out the :ref:`interacting tutorial<interacting>` or if you
131-
want to know what kind of models are available check out the :ref:`available models <models>` list.
131+
If you'd like more information on interacting with Syncano, check out the :ref:`interacting tutorial<interacting>`
132+
or if you want to know what kind of models are available check out the :ref:`available models <models>` list.
132133

133134

134135

docs/source/interacting.rst

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -206,8 +206,6 @@ to :meth:`~syncano.models.manager.Manager.list` method::
206206

207207
>>> ApiKey.please.list(instance_name='test-one')
208208
[<ApiKey 1>...]
209-
>>> ApiKey.please.list('test-one')
210-
[<ApiKey 1>...]
211209

212210
This performs a **GET** request to ``/v1/instances/test-one/api_keys/``.
213211

@@ -226,7 +224,7 @@ all :class:`~syncano.models.base.Instance` objects will have backward relation t
226224
>>> instance = Instance.please.get('test-one')
227225
>>> instance.api_keys.list()
228226
[<ApiKey 1>...]
229-
>>> instance.api_keys.get(1)
227+
>>> instance.api_keys.get(id=1)
230228
<ApiKey 1>
231229

232230
.. note::

syncano/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import os
33

44
__title__ = 'Syncano Python'
5-
__version__ = '5.0.2'
5+
__version__ = '5.1.0'
66
__author__ = "Daniel Kopka, Michal Kobus, and Sebastian Opalczynski"
77
__credits__ = ["Daniel Kopka",
88
"Michal Kobus",

syncano/connection.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,8 @@ def make_request(self, method_name, path, **kwargs):
235235
data = kwargs.get('data', {})
236236
files = data.pop('files', None)
237237

238+
self._check_batch_files(data)
239+
238240
if files is None:
239241
files = {k: v for k, v in six.iteritems(data) if hasattr(v, 'read')}
240242
if data:
@@ -402,6 +404,14 @@ def get_user_info(self, api_key=None, user_key=None):
402404
return self.make_request('GET', self.USER_INFO_SUFFIX.format(name=self.instance_name), headers={
403405
'X-API-KEY': self.api_key, 'X-USER-KEY': self.user_key})
404406

407+
@classmethod
408+
def _check_batch_files(cls, data):
409+
if 'requests' in data: # batch requests
410+
for request in data['requests']:
411+
per_request_files = request.get('body', {}).get('files', {})
412+
if per_request_files:
413+
raise SyncanoValueError('Batch do not support files upload.')
414+
405415
def _process_apns_cert_files(self, files):
406416
files = files.copy()
407417
for key in [file_name for file_name in files.keys()]:

syncano/models/archetypes.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,9 @@ def to_python(self, data):
235235
value = data[field_name]
236236
setattr(self, field.name, value)
237237

238+
if isinstance(field, fields.RelationField):
239+
setattr(self, "{}_set".format(field_name), field(instance=self, field_name=field_name))
240+
238241
def to_native(self):
239242
"""Converts the current instance to raw data which
240243
can be serialized to JSON and send to API.

syncano/models/base.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@
88
from .data_views import * # NOQA
99
from .incentives import * # NOQA
1010
from .traces import * # NOQA
11-
from .push_notification import * # NOQA
11+
from .push_notification import * # NOQA
12+
from .geo import * # NOQA

syncano/models/classes.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ class Class(Model):
4747
description = fields.StringField(read_only=False, required=False)
4848
objects_count = fields.Field(read_only=True, required=False)
4949

50-
schema = fields.SchemaField(read_only=False, required=True)
50+
schema = fields.SchemaField(read_only=False)
5151
links = fields.LinksField()
5252
status = fields.Field()
5353
metadata = fields.JSONField(read_only=False, required=False)

syncano/models/fields.py

Lines changed: 178 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@
88
from syncano.exceptions import SyncanoFieldError, SyncanoValueError
99
from syncano.utils import force_text
1010

11+
from .geo import Distance, GeoPoint
1112
from .manager import SchemaManager
1213
from .registry import registry
14+
from .relations import RelationManager, RelationValidatorMixin
1315

1416

1517
class JSONToPythonMixin(object):
@@ -96,8 +98,8 @@ def __get__(self, instance, owner):
9698

9799
def __set__(self, instance, value):
98100
if self.read_only and value and instance._raw_data.get(self.name):
99-
logger.warning('Field "{0}"" is read only, '
100-
'your changes will not be saved.'.format(self.name))
101+
logger.debug('Field "{0}"" is read only, '
102+
'your changes will not be saved.'.format(self.name))
101103

102104
instance._raw_data[self.name] = self.to_python(value)
103105

@@ -136,7 +138,7 @@ def to_native(self, value):
136138
"""
137139
return value
138140

139-
def to_query(self, value, lookup_type):
141+
def to_query(self, value, lookup_type, **kwargs):
140142
"""
141143
Returns field's value prepared for usage in HTTP request query.
142144
"""
@@ -598,6 +600,7 @@ def validate(self, value, model_instance):
598600

599601

600602
class SchemaField(JSONField):
603+
required = False
601604
query_allowed = False
602605
not_indexable_types = ['text', 'file']
603606
schema = {
@@ -621,8 +624,10 @@ class SchemaField(JSONField):
621624
'datetime',
622625
'file',
623626
'reference',
627+
'relation',
624628
'array',
625629
'object',
630+
'geopoint',
626631
],
627632
},
628633
'order_index': {
@@ -642,6 +647,9 @@ class SchemaField(JSONField):
642647
}
643648

644649
def validate(self, value, model_instance):
650+
if value is None:
651+
return
652+
645653
if isinstance(value, SchemaManager):
646654
value = value.schema
647655

@@ -685,12 +693,178 @@ def to_native(self, value):
685693
return value
686694

687695

696+
class GeoPointField(Field):
697+
698+
def validate(self, value, model_instance):
699+
super(GeoPointField, self).validate(value, model_instance)
700+
701+
if not self.required and not value:
702+
return
703+
704+
if isinstance(value, six.string_types):
705+
try:
706+
value = json.loads(value)
707+
except (ValueError, TypeError):
708+
raise SyncanoValueError('Expected an object')
709+
710+
if not isinstance(value, GeoPoint):
711+
raise SyncanoValueError('Expected a GeoPoint')
712+
713+
def to_native(self, value):
714+
if value is None:
715+
return
716+
717+
if isinstance(value, bool):
718+
return value # exists lookup
719+
720+
if isinstance(value, dict):
721+
value = GeoPoint(latitude=value['latitude'], longitude=value['longitude'])
722+
723+
if isinstance(value, tuple):
724+
geo_struct = value[0].to_native()
725+
else:
726+
geo_struct = value.to_native()
727+
728+
geo_struct = json.dumps(geo_struct)
729+
730+
return geo_struct
731+
732+
def to_query(self, value, lookup_type, **kwargs):
733+
"""
734+
Returns field's value prepared for usage in HTTP request query.
735+
"""
736+
super(GeoPointField, self).to_query(value, lookup_type, **kwargs)
737+
738+
if lookup_type not in ['near', 'exists']:
739+
raise SyncanoValueError('Lookup {} not supported for geopoint field'.format(lookup_type))
740+
741+
if lookup_type in ['exists']:
742+
if isinstance(value, bool):
743+
return value
744+
else:
745+
raise SyncanoValueError('Bool expected in {} lookup.'.format(lookup_type))
746+
747+
if isinstance(value, dict):
748+
value = (
749+
GeoPoint(latitude=value.pop('latitude'), longitude=value.pop('longitude')),
750+
Distance(**value)
751+
)
752+
753+
if len(value) != 2 or not isinstance(value[0], GeoPoint) or not isinstance(value[1], Distance):
754+
raise SyncanoValueError('This lookup should be a tuple with GeoPoint and Distance: '
755+
'<field_name>__near=(GeoPoint(52.12, 22.12), Distance(kilometers=100))')
756+
757+
query_dict = value[0].to_native()
758+
query_dict.update(value[1].to_native())
759+
760+
return query_dict
761+
762+
def to_python(self, value):
763+
if value is None:
764+
return
765+
766+
value = self._process_string_types(value)
767+
768+
if isinstance(value, GeoPoint):
769+
return value
770+
771+
latitude, longitude = self._process_value(value)
772+
773+
if not latitude or not longitude:
774+
raise SyncanoValueError('Expected the `longitude` and `latitude` fields.')
775+
776+
return GeoPoint(latitude=latitude, longitude=longitude)
777+
778+
@classmethod
779+
def _process_string_types(cls, value):
780+
if isinstance(value, six.string_types):
781+
try:
782+
return json.loads(value)
783+
except (ValueError, TypeError):
784+
raise SyncanoValueError('Invalid value: can not be parsed.')
785+
return value
786+
787+
@classmethod
788+
def _process_value(cls, value):
789+
longitude = None
790+
latitude = None
791+
792+
if isinstance(value, dict):
793+
latitude = value.get('latitude')
794+
longitude = value.get('longitude')
795+
elif isinstance(value, (tuple, list)):
796+
try:
797+
latitude = value[0]
798+
longitude = value[1]
799+
except IndexError:
800+
raise SyncanoValueError('Can not parse the geo point.')
801+
802+
return latitude, longitude
803+
804+
805+
class RelationField(RelationValidatorMixin, WritableField):
806+
query_allowed = True
807+
808+
def __call__(self, instance, field_name):
809+
return RelationManager(instance=instance, field_name=field_name)
810+
811+
def to_python(self, value):
812+
if not value:
813+
return None
814+
815+
if isinstance(value, dict) and 'type' in value and 'value' in value:
816+
value = value['value']
817+
818+
if isinstance(value, dict) and ('_add' in value or '_remove' in value):
819+
return value
820+
821+
if not isinstance(value, (list, tuple)):
822+
return [value]
823+
824+
return value
825+
826+
def to_query(self, value, lookup_type, related_field_name=None, related_field_lookup=None, **kwargs):
827+
828+
if not self.query_allowed:
829+
raise self.ValidationError('Query on this field is not supported.')
830+
831+
if lookup_type not in ['contains', 'is']:
832+
raise SyncanoValueError('Lookup {} not supported for relation field.'.format(lookup_type))
833+
834+
query_dict = {}
835+
836+
if lookup_type == 'contains':
837+
if self._check_relation_value(value):
838+
value = [obj.id for obj in value]
839+
query_dict = value
840+
841+
if lookup_type == 'is':
842+
query_dict = {related_field_name: {"_{0}".format(related_field_lookup): value}}
843+
844+
return query_dict
845+
846+
def to_native(self, value):
847+
if not value:
848+
return None
849+
850+
if isinstance(value, dict) and ('_add' in value or '_remove' in value):
851+
return value
852+
853+
if not isinstance(value, (list, tuple)):
854+
value = [value]
855+
856+
if self._check_relation_value(value):
857+
value = [obj.id for obj in value]
858+
return value
859+
860+
688861
MAPPING = {
689862
'string': StringField,
690863
'text': StringField,
691864
'file': FileField,
692865
'ref': StringField,
693866
'reference': ReferenceField,
867+
'relation': RelationField,
694868
'integer': IntegerField,
695869
'float': FloatField,
696870
'boolean': BooleanField,
@@ -708,4 +882,5 @@ def to_native(self, value):
708882
'schema': SchemaField,
709883
'array': ArrayField,
710884
'object': ObjectField,
885+
'geopoint': GeoPointField,
711886
}

syncano/models/geo.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# -*- coding: utf-8 -*-
2+
from syncano.exceptions import SyncanoValueError
3+
4+
5+
class GeoPoint(object):
6+
7+
def __init__(self, latitude, longitude):
8+
self.latitude = latitude
9+
self.longitude = longitude
10+
11+
def __repr__(self):
12+
return "GeoPoint(latitude={}, longitude={})".format(self.latitude, self.longitude)
13+
14+
def to_native(self):
15+
geo_struct_dump = {'latitude': self.latitude, 'longitude': self.longitude}
16+
return geo_struct_dump
17+
18+
19+
class Distance(object):
20+
21+
KILOMETERS = '_in_kilometers'
22+
MILES = '_in_miles'
23+
24+
def __init__(self, kilometers=None, miles=None):
25+
if kilometers is not None and miles is not None:
26+
raise SyncanoValueError('`kilometers` and `miles` can not be set at the same time.')
27+
28+
if kilometers is None and miles is None:
29+
raise SyncanoValueError('`kilometers` or `miles` attribute should be specified.')
30+
31+
self.distance = kilometers or miles
32+
self.unit = self.KILOMETERS if kilometers is not None else self.MILES
33+
34+
def to_native(self):
35+
return {
36+
'distance{}'.format(self.unit): self.distance
37+
}

0 commit comments

Comments
 (0)