From 9210830691765fe825a6510d5f64fe8ffe795798 Mon Sep 17 00:00:00 2001 From: Tres Seaver Date: Thu, 8 Oct 2015 14:57:25 -0400 Subject: [PATCH 1/9] Add document/field/value support. --- gcloud/search/document.py | 303 +++++++++++++++++ gcloud/search/test_document.py | 603 +++++++++++++++++++++++++++++++++ 2 files changed, 906 insertions(+) create mode 100644 gcloud/search/document.py create mode 100644 gcloud/search/test_document.py diff --git a/gcloud/search/document.py b/gcloud/search/document.py new file mode 100644 index 000000000000..ff820d96723e --- /dev/null +++ b/gcloud/search/document.py @@ -0,0 +1,303 @@ +# Copyright 2015 Google Inc. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Define API Document.""" + +import datetime + +import six + +from gcloud._helpers import UTC, _RFC3339_MICROS +from gcloud.exceptions import NotFound + + +class StringValue(object): + """Values hold individual values for a given field""" + + value_type = 'string' + + def __init__(self, string_value, string_format=None, language=None): + self.string_value = string_value + self.string_format = string_format + self.language = language + + +class NumberValue(object): + """Values hold individual values for a given field""" + + value_type = 'number' + + def __init__(self, number_value): + self.number_value = number_value + + +class TimestampValue(object): + """Values hold individual values for a given field""" + + value_type = 'timestamp' + + def __init__(self, timestamp_value): + self.timestamp_value = timestamp_value + + +class GeoValue(object): + """Values hold individual values for a given field""" + + value_type = 'geo' + + def __init__(self, geo_value): + self.geo_value = geo_value + + +class Field(object): + """Fields hold values for a given document""" + + def __init__(self, name): + self.name = name + self.values = [] + + def add_value(self, value, **kw): + """Add a value to the field. + + Selects type of value instance based on type of ``value``. + + :type value: string, integer, float, datetime, or tuple (float, float) + :param value: the field value to add. + + :param kw: extra keyword arguments to be passed to the value instance + constructor. + + :raises: ValueError if unable to match the type of ``value``. + """ + if isinstance(value, six.string_types): + self.values.append(StringValue(value, **kw)) + elif isinstance(value, (six.integer_types, float)): + self.values.append(NumberValue(value, **kw)) + elif isinstance(value, datetime.datetime): + self.values.append(TimestampValue(value, **kw)) + elif isinstance(value, tuple): + self.values.append(GeoValue(value, **kw)) + else: + raise ValueError("Couldn't determine value type: %s" % value) + + +class Document(object): + """Documents hold values for search within indexes. + + See: + https://cloud.google.com/search/reference/rest/v1/projects/indexes/documents + + :type name: string + :param name: the name of the document + + :type index: :class:`gcloud.search.index.Index` + :param index: the index to which the document belongs. + + :type rank: int + :param rank: the default rank for ordering the document. + """ + def __init__(self, name, index, rank=None): + self.name = name + self.index = index + self.rank = rank + self.fields = {} + + @classmethod + def from_api_repr(cls, resource, index): + """Factory: construct a document given its API representation + + :type resource: dict + :param resource: document resource representation returned from the API + + :type index: :class:`gcloud.search.index.Index` + :param index: Index holding the document. + + :rtype: :class:`gcloud.search.document.Document` + :returns: Document parsed from ``resource``. + """ + name = resource['docId'] + rank = resource.get('rank') + instance = cls(name, index, rank) + instance._parse_fields_resource(resource) + return instance + + def _parse_value_resource(self, resource): + """Helper for _parse_fields_resource""" + if 'stringValue' in resource: + string_format = resource.get('stringFormat') + language = resource.get('lang') + value = resource['stringValue'] + return StringValue(value, string_format, language) + if 'numberValue' in resource: + value = resource['numberValue'] + if isinstance(value, six.string_types): + if '.' in value: + value = float(value) + else: + value = int(value) + return NumberValue(value) + if 'timestampValue' in resource: + stamp = resource['timestampValue'] + value = datetime.datetime.strptime(stamp, _RFC3339_MICROS) + value = value.replace(tzinfo=UTC) + return TimestampValue(value) + if 'geoValue' in resource: + lat_long = resource['geoValue'] + lat, long = [float(coord.strip()) for coord in lat_long.split(',')] + return GeoValue((lat, long)) + raise ValueError("Unknown value type") + + def _parse_fields_resource(self, resource): + """Helper for from_api_repr, create, reload""" + self.fields.clear() + for field_name, val_obj in resource.get('fields', {}).items(): + field = self.field(field_name) + for value in val_obj['values']: + field.values.append(self._parse_value_resource(value)) + + @property + def path(self): + """URL path for the document's APIs""" + return '%s/documents/%s' % (self.index.path, self.name) + + def field(self, name): + """Construct a Field instance. + + :type name: string + :param name: field's name + """ + field = self.fields[name] = Field(name) + return field + + def _require_client(self, client): + """Check client or verify over-ride. + + :type client: :class:`gcloud.search.client.Client` or ``NoneType`` + :param client: the client to use. If not passed, falls back to the + ``client`` stored on the index of the + current document. + + :rtype: :class:`gcloud.search.client.Client` + :returns: The client passed in or the currently bound client. + """ + if client is None: + client = self.index._client + return client + + def _build_value_resource(self, value): + """Helper for _build_fields_resource""" + result = {} + if value.value_type == 'string': + result['stringValue'] = value.string_value + if value.string_format is not None: + result['stringFormat'] = value.string_format + if value.language is not None: + result['lang'] = value.language + elif value.value_type == 'number': + result['numberValue'] = value.number_value + elif value.value_type == 'timestamp': + stamp = value.timestamp_value.strftime(_RFC3339_MICROS) + result['timestampValue'] = stamp + elif value.value_type == 'geo': + result['geoValue'] = '%s, %s' % value.geo_value + else: + raise ValueError('Unknown value_type: %s' % value.value_type) + return result + + def _build_fields_resource(self): + """Helper for create""" + fields = {} + for field_name, field in self.fields.items(): + if field.values: + values = [] + fields[field_name] = {'values': values} + for value in field.values: + values.append(self._build_value_resource(value)) + return fields + + def _set_properties(self, api_response): + """Helper for create, reload""" + self.rank = api_response.get('rank') + self._parse_fields_resource(api_response) + + def create(self, client=None): + """API call: create the document via a PUT request + + See: + https://cloud.google.com/search/reference/rest/v1/projects/indexes/documents/create + + :type client: :class:`gcloud.search.client.Client` or ``NoneType`` + :param client: the client to use. If not passed, falls back to the + ``client`` stored on the current document's index. + """ + data = {'docId': self.name} + + if self.rank is not None: + data['rank'] = self.rank + + fields = self._build_fields_resource() + if fields: + data['fields'] = fields + + client = self._require_client(client) + api_response = client.connection.api_request( + method='PUT', path=self.path, data=data) + + self._set_properties(api_response) + + def exists(self, client=None): + """API call: test existence of the document via a GET request + + See + https://cloud.google.com/search/reference/rest/v1/projects/indexes/documents/get + + :type client: :class:`gcloud.search.client.Client` or ``NoneType`` + :param client: the client to use. If not passed, falls back to the + ``client`` stored on the current document's index. + """ + client = self._require_client(client) + try: + client.connection.api_request(method='GET', path=self.path) + except NotFound: + return False + else: + return True + + def reload(self, client=None): + """API call: sync local document configuration via a GET request + + See + https://cloud.google.com/search/reference/rest/v1/projects/indexes/documents/get + + :type client: :class:`gcloud.search.client.Client` or ``NoneType`` + :param client: the client to use. If not passed, falls back to the + ``client`` stored on the current document's index. + """ + client = self._require_client(client) + api_response = client.connection.api_request( + method='GET', path=self.path) + self._set_properties(api_response) + + def delete(self, client=None): + """API call: delete the document via a DELETE request. + + See: + https://cloud.google.com/search/reference/rest/v1/projects/indexes/documents/delete + + :type client: :class:`gcloud.search.client.Client` or ``NoneType`` + :param client: the client to use. If not passed, falls back to the + ``client`` stored on the current document's index. + """ + client = self._require_client(client) + client.connection.api_request(method='DELETE', path=self.path) diff --git a/gcloud/search/test_document.py b/gcloud/search/test_document.py new file mode 100644 index 000000000000..fa9a12432887 --- /dev/null +++ b/gcloud/search/test_document.py @@ -0,0 +1,603 @@ +# Copyright 2015 Google Inc. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest2 + + +class TestStringValue(unittest2.TestCase): + + def _getTargetClass(self): + from gcloud.search.document import StringValue + return StringValue + + def _makeOne(self, *args, **kw): + return self._getTargetClass()(*args, **kw) + + def test_ctor_defaults(self): + sv = self._makeOne('abcde') + self.assertEqual(sv.string_value, 'abcde') + self.assertEqual(sv.string_format, None) + self.assertEqual(sv.language, None) + + def test_ctor_explicit(self): + sv = self._makeOne('abcde', 'text', 'en') + self.assertEqual(sv.string_value, 'abcde') + self.assertEqual(sv.string_format, 'text') + self.assertEqual(sv.language, 'en') + + +class TestNumberValue(unittest2.TestCase): + + def _getTargetClass(self): + from gcloud.search.document import NumberValue + return NumberValue + + def _makeOne(self, *args, **kw): + return self._getTargetClass()(*args, **kw) + + def test_ctor(self): + nv = self._makeOne(42) + self.assertEqual(nv.number_value, 42) + + +class TestTimestampValue(unittest2.TestCase): + + def _getTargetClass(self): + from gcloud.search.document import TimestampValue + return TimestampValue + + def _makeOne(self, *args, **kw): + return self._getTargetClass()(*args, **kw) + + def test_ctor(self): + import datetime + from gcloud._helpers import UTC + NOW = datetime.datetime.utcnow().replace(tzinfo=UTC) + tv = self._makeOne(NOW) + self.assertEqual(tv.timestamp_value, NOW) + + +class TestGeoValue(unittest2.TestCase): + + LATITUDE, LONGITUDE = 38.301931, -77.458722 + + def _getTargetClass(self): + from gcloud.search.document import GeoValue + return GeoValue + + def _makeOne(self, *args, **kw): + return self._getTargetClass()(*args, **kw) + + def test_ctor(self): + gv = self._makeOne((self.LATITUDE, self.LONGITUDE)) + self.assertEqual(gv.geo_value, (self.LATITUDE, self.LONGITUDE)) + + +class TestField(unittest2.TestCase): + + def _getTargetClass(self): + from gcloud.search.document import Field + return Field + + def _makeOne(self, *args, **kw): + return self._getTargetClass()(*args, **kw) + + def test_ctor(self): + field = self._makeOne('field_name') + self.assertEqual(field.name, 'field_name') + self.assertEqual(len(field.values), 0) + + def test_add_value_unknown(self): + field = self._makeOne('field_name') + with self.assertRaises(ValueError): + field.add_value(object()) + + def test_add_value_string_defaults(self): + from gcloud.search.document import StringValue + field = self._makeOne('field_name') + field.add_value('this is a string') + self.assertEqual(len(field.values), 1) + value = field.values[0] + self.assertTrue(isinstance(value, StringValue)) + self.assertEqual(value.string_value, 'this is a string') + self.assertEqual(value.string_format, None) + self.assertEqual(value.language, None) + + def test_add_value_string_explicit(self): + from gcloud.search.document import StringValue + field = self._makeOne('field_name') + field.add_value('this is a string', + string_format='text', language='en') + self.assertEqual(len(field.values), 1) + value = field.values[0] + self.assertTrue(isinstance(value, StringValue)) + self.assertEqual(value.string_value, 'this is a string') + self.assertEqual(value.string_format, 'text') + self.assertEqual(value.language, 'en') + + def test_add_value_integer(self): + from gcloud.search.document import NumberValue + field = self._makeOne('field_name') + field.add_value(42) + self.assertEqual(len(field.values), 1) + value = field.values[0] + self.assertTrue(isinstance(value, NumberValue)) + self.assertEqual(value.number_value, 42) + + def test_add_value_datetime(self): + import datetime + from gcloud._helpers import UTC + from gcloud.search.document import TimestampValue + NOW = datetime.datetime.utcnow().replace(tzinfo=UTC) + field = self._makeOne('field_name') + field.add_value(NOW) + self.assertEqual(len(field.values), 1) + value = field.values[0] + self.assertTrue(isinstance(value, TimestampValue)) + self.assertEqual(value.timestamp_value, NOW) + + def test_add_value_geo(self): + from gcloud.search.document import GeoValue + LATITUDE, LONGITUDE = 38.301931, -77.458722 + field = self._makeOne('field_name') + field.add_value((LATITUDE, LONGITUDE)) + self.assertEqual(len(field.values), 1) + value = field.values[0] + self.assertTrue(isinstance(value, GeoValue)) + self.assertEqual(value.geo_value, (LATITUDE, LONGITUDE)) + + +class TestDocument(unittest2.TestCase): + + PROJECT = 'PROJECT' + DOC_NAME = 'doc_name' + INDEX_NAME = 'index_name' + DOC_PATH = 'projects/%s/indexes/%s/documents/%s' % ( + PROJECT, INDEX_NAME, DOC_NAME) + RANK = 42 + + def _getTargetClass(self): + from gcloud.search.document import Document + return Document + + def _makeOne(self, *args, **kw): + return self._getTargetClass()(*args, **kw) + + def test_ctor_defaults(self): + index = object() + document = self._makeOne(self.DOC_NAME, index) + self.assertEqual(document.name, self.DOC_NAME) + self.assertTrue(document.index is index) + self.assertEqual(document.rank, None) + self.assertEqual(document.fields, {}) + + def test_ctor_explicit(self): + index = object() + document = self._makeOne(self.DOC_NAME, index, self.RANK) + self.assertEqual(document.name, self.DOC_NAME) + self.assertTrue(document.index is index) + self.assertEqual(document.rank, self.RANK) + self.assertEqual(document.fields, {}) + + def test_from_api_repr(self): + import datetime + from gcloud._helpers import UTC, _RFC3339_MICROS + VALUE = 'The quick brown fox' + HTML_VALUE = 'jumped over the lazy fence.' + NOW = datetime.datetime.utcnow().replace(tzinfo=UTC) + NOW_STR = NOW.strftime(_RFC3339_MICROS) + LATITUDE, LONGITUDE = 38.301931, -77.458722 + resource = { + 'docId': self.DOC_NAME, + 'rank': self.RANK, + 'fields': { + 'title': { + 'values': [ + {'stringFormat': 'text', + 'lang': 'en', + 'stringValue': VALUE}, + {'stringFormat': 'html', + 'lang': 'en', + 'stringValue': HTML_VALUE}, + {'numberValue': 42}, + {'numberValue': '42'}, + {'numberValue': '3.1415926'}, + {'timestampValue': NOW_STR}, + {'geoValue': '%s, %s' % (LATITUDE, LONGITUDE)}, + ], + } + } + } + klass = self._getTargetClass() + index = object() + + document = klass.from_api_repr(resource, index) + + self.assertEqual(document.name, self.DOC_NAME) + self.assertTrue(document.index is index) + self.assertEqual(document.rank, self.RANK) + + self.assertEqual(list(document.fields), ['title']) + field = document.fields['title'] + self.assertEqual(field.name, 'title') + self.assertEqual(len(field.values), 7) + + value = field.values[0] + self.assertEqual(value.value_type, 'string') + self.assertEqual(value.language, 'en') + self.assertEqual(value.string_format, 'text') + self.assertEqual(value.string_value, VALUE) + + value = field.values[1] + self.assertEqual(value.value_type, 'string') + self.assertEqual(value.language, 'en') + self.assertEqual(value.string_format, 'html') + self.assertEqual(value.string_value, + 'jumped over the lazy fence.') + + value = field.values[2] + self.assertEqual(value.value_type, 'number') + self.assertEqual(value.number_value, 42) + + value = field.values[3] + self.assertEqual(value.value_type, 'number') + self.assertEqual(value.number_value, 42) + + value = field.values[4] + self.assertEqual(value.value_type, 'number') + self.assertEqual(value.number_value, 3.1415926) + + value = field.values[5] + self.assertEqual(value.value_type, 'timestamp') + self.assertEqual(value.timestamp_value, NOW) + + value = field.values[6] + self.assertEqual(value.value_type, 'geo') + self.assertEqual(value.geo_value, (LATITUDE, LONGITUDE)) + + def test__parse_value_resource_invalid(self): + conn = _Connection() + client = _Client(project=self.PROJECT, connection=conn) + index = _Index(self.INDEX_NAME, client=client) + document = self._makeOne(self.DOC_NAME, index) + with self.assertRaises(ValueError): + document._parse_value_resource({}) + + def test__build_value_resource_invalid(self): + class _UnknownValue(object): + value_type = 'nonesuch' + conn = _Connection() + client = _Client(project=self.PROJECT, connection=conn) + index = _Index(self.INDEX_NAME, client=client) + document = self._makeOne(self.DOC_NAME, index) + with self.assertRaises(ValueError): + document._build_value_resource(_UnknownValue()) + + def test__build_field_resources_field_wo_values(self): + conn = _Connection() + client = _Client(project=self.PROJECT, connection=conn) + index = _Index(self.INDEX_NAME, client=client) + document = self._makeOne(self.DOC_NAME, index) + _ = document.field('testing') # no values + self.assertEqual(document._build_fields_resource(), {}) + + def test_create_wo_fields(self): + import copy + BODY = {'docId': self.DOC_NAME} + RESPONSE = copy.deepcopy(BODY) + RESPONSE['rank'] = self.RANK + conn = _Connection(RESPONSE) + client = _Client(project=self.PROJECT, connection=conn) + index = _Index(self.INDEX_NAME, client=client) + document = self._makeOne(self.DOC_NAME, index) + + document.create() + + self.assertEqual(list(document.fields), []) + + self.assertEqual(len(conn._requested), 1) + req = conn._requested[0] + self.assertEqual(req['method'], 'PUT') + self.assertEqual(req['path'], '/%s' % self.DOC_PATH) + self.assertEqual(req['data'], BODY) + + def test_create_wo_rank_w_bound_client(self): + import copy + VALUE = 'The quick brown fox' + BODY = { + 'docId': self.DOC_NAME, + 'fields': { + 'testing': { + 'values': [ + {'stringValue': VALUE}, + ], + } + } + } + RESPONSE = copy.deepcopy(BODY) + RESPONSE['rank'] = self.RANK + response_value = RESPONSE['fields']['testing']['values'][0] + response_value['stringFormat'] = 'auto' + conn = _Connection(RESPONSE) + client = _Client(project=self.PROJECT, connection=conn) + index = _Index(self.INDEX_NAME, client=client) + document = self._makeOne(self.DOC_NAME, index) + field = document.field('testing') + field.add_value(VALUE) + + document.create() + + self.assertEqual(list(document.fields), ['testing']) + field = document.fields['testing'] + self.assertEqual(len(field.values), 1) + + value = field.values[0] + self.assertEqual(value.value_type, 'string') + self.assertEqual(value.string_format, 'auto') + self.assertEqual(value.string_value, VALUE) + self.assertEqual(value.language, None) + + self.assertEqual(len(conn._requested), 1) + req = conn._requested[0] + self.assertEqual(req['method'], 'PUT') + self.assertEqual(req['path'], '/%s' % self.DOC_PATH) + self.assertEqual(req['data'], BODY) + + def test_create_w_rank_w_alternate_client(self): + import datetime + from gcloud._helpers import UTC, _RFC3339_MICROS + VALUE = 'The quick brown fox' + NOW = datetime.datetime.utcnow().replace(tzinfo=UTC) + NOW_STR = NOW.strftime(_RFC3339_MICROS) + LATITUDE, LONGITUDE = 38.301931, -77.458722 + BODY = { + 'docId': self.DOC_NAME, + 'rank': self.RANK, + 'fields': { + 'title': { + 'values': [ + {'stringValue': VALUE, + 'stringFormat': 'text', + 'lang': 'en'}, + {'numberValue': 17.5}, + {'timestampValue': NOW_STR}, + {'geoValue': '%s, %s' % (LATITUDE, LONGITUDE)}, + ], + } + } + } + RESPONSE = BODY.copy() + RESPONSE['rank'] = self.RANK + conn1 = _Connection() + client1 = _Client(project=self.PROJECT, connection=conn1) + conn2 = _Connection(BODY) + client2 = _Client(project=self.PROJECT, connection=conn2) + index = _Index(self.INDEX_NAME, client=client1) + document = self._makeOne(self.DOC_NAME, index, rank=self.RANK) + field = document.field('title') + field.add_value(VALUE, string_format='text', language='en') + field.add_value(17.5) + field.add_value(NOW) + field.add_value((LATITUDE, LONGITUDE)) + + document.create(client=client2) + + self.assertEqual(list(document.fields), ['title']) + field = document.fields['title'] + self.assertEqual(len(field.values), 4) + + value = field.values[0] + self.assertEqual(value.value_type, 'string') + self.assertEqual(value.string_format, 'text') + self.assertEqual(value.string_value, VALUE) + self.assertEqual(value.language, 'en') + + value = field.values[1] + self.assertEqual(value.value_type, 'number') + self.assertEqual(value.number_value, 17.5) + + value = field.values[2] + self.assertEqual(value.value_type, 'timestamp') + self.assertEqual(value.timestamp_value, NOW) + + value = field.values[3] + self.assertEqual(value.value_type, 'geo') + self.assertEqual(value.geo_value, (LATITUDE, LONGITUDE)) + + self.assertEqual(len(conn1._requested), 0) + self.assertEqual(len(conn2._requested), 1) + + req = conn2._requested[0] + self.assertEqual(req['method'], 'PUT') + self.assertEqual(req['path'], '/%s' % self.DOC_PATH) + self.assertEqual(req['data'], BODY) + + def test_exists_miss_w_bound_client(self): + conn = _Connection() + client = _Client(project=self.PROJECT, connection=conn) + index = _Index(self.INDEX_NAME, client=client) + document = self._makeOne(self.DOC_NAME, index) + + self.assertFalse(document.exists()) + + self.assertEqual(len(conn._requested), 1) + req = conn._requested[0] + self.assertEqual(req['method'], 'GET') + self.assertEqual(req['path'], '/%s' % self.DOC_PATH) + self.assertEqual(req.get('query_params'), None) + + def test_exists_hit_w_alternate_client(self): + BODY = {'docId': self.DOC_NAME, 'rank': self.RANK} + conn1 = _Connection() + client1 = _Client(project=self.PROJECT, connection=conn1) + conn2 = _Connection(BODY) + client2 = _Client(project=self.PROJECT, connection=conn2) + index = _Index(self.INDEX_NAME, client=client1) + document = self._makeOne(self.DOC_NAME, index) + + self.assertTrue(document.exists(client=client2)) + + self.assertEqual(len(conn1._requested), 0) + self.assertEqual(len(conn2._requested), 1) + req = conn2._requested[0] + self.assertEqual(req['method'], 'GET') + self.assertEqual(req['path'], '/%s' % self.DOC_PATH) + self.assertEqual(req.get('query_params'), None) + + def test_reload_w_bound_client(self): + VALUE = 'The quick brown fox' + BODY = { + 'docId': self.DOC_NAME, + 'rank': self.RANK, + 'fields': { + 'title': { + 'values': [ + {'stringFormat': 'text', + 'lang': 'en', + 'stringValue': VALUE}, + ], + } + } + } + conn = _Connection(BODY) + client = _Client(project=self.PROJECT, connection=conn) + index = _Index(self.INDEX_NAME, client=client) + document = self._makeOne(self.DOC_NAME, index) + + document.reload() + + self.assertEqual(document.rank, self.RANK) + + self.assertEqual(list(document.fields), ['title']) + field = document.fields['title'] + self.assertEqual(len(field.values), 1) + self.assertEqual(field.name, 'title') + self.assertEqual(len(field.values), 1) + + value = field.values[0] + self.assertEqual(value.value_type, 'string') + self.assertEqual(value.language, 'en') + self.assertEqual(value.string_format, 'text') + self.assertEqual(value.string_value, VALUE) + + self.assertEqual(len(conn._requested), 1) + req = conn._requested[0] + self.assertEqual(req['method'], 'GET') + self.assertEqual(req['path'], '/%s' % self.DOC_PATH) + + def test_reload_w_alternate_client(self): + VALUE = 'The quick brown fox' + BODY = { + 'docId': self.DOC_NAME, + 'rank': self.RANK, + 'fields': { + 'title': { + 'values': [ + {'stringFormat': 'text', + 'lang': 'en', + 'stringValue': VALUE}, + ], + } + } + } + conn1 = _Connection() + client1 = _Client(project=self.PROJECT, connection=conn1) + conn2 = _Connection(BODY) + client2 = _Client(project=self.PROJECT, connection=conn2) + index = _Index(self.INDEX_NAME, client=client1) + document = self._makeOne(self.DOC_NAME, index) + + document.reload(client=client2) + + self.assertEqual(document.rank, self.RANK) + + self.assertEqual(list(document.fields), ['title']) + field = document.fields['title'] + self.assertEqual(field.name, 'title') + self.assertEqual(len(field.values), 1) + + value = field.values[0] + self.assertEqual(value.value_type, 'string') + self.assertEqual(value.language, 'en') + self.assertEqual(value.string_format, 'text') + self.assertEqual(value.string_value, VALUE) + + self.assertEqual(len(conn1._requested), 0) + self.assertEqual(len(conn2._requested), 1) + req = conn2._requested[0] + self.assertEqual(req['method'], 'GET') + self.assertEqual(req['path'], '/%s' % self.DOC_PATH) + + def test_delete_w_bound_client(self): + conn = _Connection({}) + client = _Client(project=self.PROJECT, connection=conn) + index = _Index(self.INDEX_NAME, client=client) + document = self._makeOne(self.DOC_NAME, index) + + document.delete() + + self.assertEqual(len(conn._requested), 1) + req = conn._requested[0] + self.assertEqual(req['method'], 'DELETE') + self.assertEqual(req['path'], '/%s' % self.DOC_PATH) + + def test_delete_w_alternate_client(self): + conn1 = _Connection({}) + client1 = _Client(project=self.PROJECT, connection=conn1) + conn2 = _Connection({}) + client2 = _Client(project=self.PROJECT, connection=conn2) + index = _Index(self.INDEX_NAME, client=client1) + document = self._makeOne(self.DOC_NAME, index) + + document.delete(client=client2) + + self.assertEqual(len(conn1._requested), 0) + self.assertEqual(len(conn2._requested), 1) + req = conn2._requested[0] + self.assertEqual(req['method'], 'DELETE') + self.assertEqual(req['path'], '/%s' % self.DOC_PATH) + + +class _Connection(object): + + def __init__(self, *responses): + self._responses = responses + self._requested = [] + + def api_request(self, **kw): + from gcloud.exceptions import NotFound + self._requested.append(kw) + + try: + response, self._responses = self._responses[0], self._responses[1:] + except: + raise NotFound('miss') + else: + return response + + +class _Index(object): + + def __init__(self, name, client): + self.name = name + self._client = client + self.project = client.project + self.path = '/projects/%s/indexes/%s' % (client.project, name) + + +class _Client(object): + + def __init__(self, project, connection=None): + self.project = project + self.connection = connection From 178e9e30f167cd59ad5535c8680a6ce4a0b572c4 Mon Sep 17 00:00:00 2001 From: Tres Seaver Date: Thu, 8 Oct 2015 16:01:30 -0400 Subject: [PATCH 2/9] One line per import. Addresses: https://github.com/GoogleCloudPlatform/gcloud-python/pull/1173#discussion_r41557822 --- gcloud/search/document.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/gcloud/search/document.py b/gcloud/search/document.py index ff820d96723e..c6c4c694f607 100644 --- a/gcloud/search/document.py +++ b/gcloud/search/document.py @@ -18,7 +18,8 @@ import six -from gcloud._helpers import UTC, _RFC3339_MICROS +from gcloud._helpers import UTC +from gcloud._helpers import _RFC3339_MICROS from gcloud.exceptions import NotFound From bcdab30570888fd6ee4e7fee8531700a3da1046b Mon Sep 17 00:00:00 2001 From: Tres Seaver Date: Thu, 8 Oct 2015 16:03:46 -0400 Subject: [PATCH 3/9] Pass a tuple when using '%' formatting. Addresses: https://github.com/GoogleCloudPlatform/gcloud-python/pull/1173#discussion_r41558179. --- gcloud/search/document.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gcloud/search/document.py b/gcloud/search/document.py index c6c4c694f607..3e0fccfbd054 100644 --- a/gcloud/search/document.py +++ b/gcloud/search/document.py @@ -90,7 +90,7 @@ def add_value(self, value, **kw): elif isinstance(value, tuple): self.values.append(GeoValue(value, **kw)) else: - raise ValueError("Couldn't determine value type: %s" % value) + raise ValueError("Couldn't determine value type: %s" % (value,)) class Document(object): From 3d7240d9f14cf625d174daa063bf2514a028c952 Mon Sep 17 00:00:00 2001 From: Tres Seaver Date: Thu, 8 Oct 2015 16:13:54 -0400 Subject: [PATCH 4/9] Expand value/field docstrings to show params, reference URL. Addresses: https://github.com/GoogleCloudPlatform/gcloud-python/pull/1173#discussion_r41558326 --- gcloud/search/document.py | 52 +++++++++++++++++++++++++++++++++++---- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/gcloud/search/document.py b/gcloud/search/document.py index 3e0fccfbd054..68397b8e5ec6 100644 --- a/gcloud/search/document.py +++ b/gcloud/search/document.py @@ -24,7 +24,23 @@ class StringValue(object): - """Values hold individual values for a given field""" + """StringValues hold individual text values for a given field + + See: + https://cloud.google.com/search/reference/rest/google/cloudsearch/v1/FieldValue + + :type string_value: string + :param string_value: the actual value. + + :type string_format: string + :param string_format: how the value should be indexed: one of + 'ATOM', 'TEXT', 'HTML' (leave as ``None`` to + use the server-supplied default). + + :type language: string + :param language: Human language of the text. Should be an ISO 639-1 + language code. + """ value_type = 'string' @@ -35,7 +51,14 @@ def __init__(self, string_value, string_format=None, language=None): class NumberValue(object): - """Values hold individual values for a given field""" + """NumberValues hold individual numeric values for a given field + + See: + https://cloud.google.com/search/reference/rest/google/cloudsearch/v1/FieldValue + + :type number_value: integer, float (long on Python2) + :param number_value: the actual value. + """ value_type = 'number' @@ -44,7 +67,13 @@ def __init__(self, number_value): class TimestampValue(object): - """Values hold individual values for a given field""" + """TimestampValues hold individual datetime values for a given field + See: + https://cloud.google.com/search/reference/rest/google/cloudsearch/v1/FieldValue + + :type timestamp_value: class:``datetime.datetime`` + :param timestamp_value: the actual value. + """ value_type = 'timestamp' @@ -53,7 +82,13 @@ def __init__(self, timestamp_value): class GeoValue(object): - """Values hold individual values for a given field""" + """GeoValues hold individual latitude/longitude values for a given field + See: + https://cloud.google.com/search/reference/rest/google/cloudsearch/v1/FieldValue + + :type geo_value: tuple, (float, float) + :param geo_value: latitude, longitude + """ value_type = 'geo' @@ -62,7 +97,14 @@ def __init__(self, geo_value): class Field(object): - """Fields hold values for a given document""" + """Fields hold values for a given document + + See: + https://cloud.google.com/search/reference/rest/google/cloudsearch/v1/FieldValueList + + :type name: string + :param name: field name + """ def __init__(self, name): self.name = name From 3dbb92a3187414ca0ff350e943dc3cf825dda8ae Mon Sep 17 00:00:00 2001 From: Tres Seaver Date: Thu, 8 Oct 2015 16:16:49 -0400 Subject: [PATCH 5/9] Elaborate 'rank' usage, point to docs. Addresses: https://github.com/GoogleCloudPlatform/gcloud-python/pull/1173#discussion_r41558401 --- gcloud/search/document.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/gcloud/search/document.py b/gcloud/search/document.py index 68397b8e5ec6..ee6f72e53e1c 100644 --- a/gcloud/search/document.py +++ b/gcloud/search/document.py @@ -147,8 +147,10 @@ class Document(object): :type index: :class:`gcloud.search.index.Index` :param index: the index to which the document belongs. - :type rank: int - :param rank: the default rank for ordering the document. + :type rank: positive integer + :param rank: the default rank for ordering the document in queries: if + not passed, assigned a timestamp-based value by the server. + See the ``rank`` entry on the page above for details. """ def __init__(self, name, index, rank=None): self.name = name From 89554a7192dd71847184c6522e79502a61762ad1 Mon Sep 17 00:00:00 2001 From: Tres Seaver Date: Thu, 8 Oct 2015 16:19:49 -0400 Subject: [PATCH 6/9] Consistency with 'Index.from_api_repr'. Addresses: https://github.com/GoogleCloudPlatform/gcloud-python/pull/1173#discussion_r41558482 --- gcloud/search/document.py | 5 ++++- gcloud/search/test_document.py | 6 ++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/gcloud/search/document.py b/gcloud/search/document.py index ee6f72e53e1c..41a1ab1df8c8 100644 --- a/gcloud/search/document.py +++ b/gcloud/search/document.py @@ -171,7 +171,10 @@ def from_api_repr(cls, resource, index): :rtype: :class:`gcloud.search.document.Document` :returns: Document parsed from ``resource``. """ - name = resource['docId'] + name = resource.get('docId') + if name is None: + raise KeyError( + 'Resource lacks required identity information: ["docId"]') rank = resource.get('rank') instance = cls(name, index, rank) instance._parse_fields_resource(resource) diff --git a/gcloud/search/test_document.py b/gcloud/search/test_document.py index fa9a12432887..688fefd4520f 100644 --- a/gcloud/search/test_document.py +++ b/gcloud/search/test_document.py @@ -190,6 +190,12 @@ def test_ctor_explicit(self): self.assertEqual(document.rank, self.RANK) self.assertEqual(document.fields, {}) + def test_from_api_repr_invalid(self): + klass = self._getTargetClass() + index = object() + with self.assertRaises(KeyError): + klass.from_api_repr({}, index) + def test_from_api_repr(self): import datetime from gcloud._helpers import UTC, _RFC3339_MICROS From 06b828c0d14987d5f3bbefcfa7391fd538112392 Mon Sep 17 00:00:00 2001 From: Tres Seaver Date: Thu, 8 Oct 2015 16:20:45 -0400 Subject: [PATCH 7/9] Name created instance 'document'. Addresses: https://github.com/GoogleCloudPlatform/gcloud-python/pull/1173#discussion_r41558538 --- gcloud/search/document.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gcloud/search/document.py b/gcloud/search/document.py index 41a1ab1df8c8..f587b19aab63 100644 --- a/gcloud/search/document.py +++ b/gcloud/search/document.py @@ -176,9 +176,9 @@ def from_api_repr(cls, resource, index): raise KeyError( 'Resource lacks required identity information: ["docId"]') rank = resource.get('rank') - instance = cls(name, index, rank) - instance._parse_fields_resource(resource) - return instance + document = cls(name, index, rank) + document._parse_fields_resource(resource) + return document def _parse_value_resource(self, resource): """Helper for _parse_fields_resource""" From c5c89f481ec68c3576c90ceaa975831648ca8459 Mon Sep 17 00:00:00 2001 From: Tres Seaver Date: Thu, 8 Oct 2015 20:53:09 -0400 Subject: [PATCH 8/9] Clarify the sense of 'rank' for documents. Addresses: https://github.com/GoogleCloudPlatform/gcloud-python/pull/1173/files#r41574680 --- gcloud/search/document.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/gcloud/search/document.py b/gcloud/search/document.py index f587b19aab63..222c64d3f106 100644 --- a/gcloud/search/document.py +++ b/gcloud/search/document.py @@ -148,9 +148,10 @@ class Document(object): :param index: the index to which the document belongs. :type rank: positive integer - :param rank: the default rank for ordering the document in queries: if - not passed, assigned a timestamp-based value by the server. - See the ``rank`` entry on the page above for details. + :param rank: override the server-generated rank for ordering the document + within in queries. If not passed, the server generates a + timestamp-based value. See the ``rank`` entry on the + page above for details. """ def __init__(self, name, index, rank=None): self.name = name From 89f1a1e0daeefeff34ce6417de97b0982167eb1d Mon Sep 17 00:00:00 2001 From: Tres Seaver Date: Thu, 8 Oct 2015 20:55:24 -0400 Subject: [PATCH 9/9] Explain that '**kw' arg to 'add_value' is only useful for string values. Addresses: https://github.com/GoogleCloudPlatform/gcloud-python/pull/1173/files#r41574050 --- gcloud/search/document.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/gcloud/search/document.py b/gcloud/search/document.py index 222c64d3f106..5e7164c7995b 100644 --- a/gcloud/search/document.py +++ b/gcloud/search/document.py @@ -119,7 +119,8 @@ def add_value(self, value, **kw): :param value: the field value to add. :param kw: extra keyword arguments to be passed to the value instance - constructor. + constructor. Currently, only :class:`StringValue` + expects / honors additional parameters. :raises: ValueError if unable to match the type of ``value``. """