diff --git a/storage/google/cloud/storage/blob.py b/storage/google/cloud/storage/blob.py index 778166df92492..aad2f47295aaf 100644 --- a/storage/google/cloud/storage/blob.py +++ b/storage/google/cloud/storage/blob.py @@ -340,10 +340,14 @@ def exists(self, client=None): :returns: True if the blob exists in Cloud Storage. """ client = self._require_client(client) + # We only need the status code (200 or not) so we seek to + # minimize the returned payload. + query_params = {'fields': 'name'} + + if self.user_project is not None: + query_params['userProject'] = self.user_project + try: - # We only need the status code (200 or not) so we seek to - # minimize the returned payload. - query_params = {'fields': 'name'} # We intentionally pass `_target_object=None` since fields=name # would limit the local properties. client._connection.api_request( @@ -403,6 +407,8 @@ def _get_download_url(self): download_url = _DOWNLOAD_URL_TEMPLATE.format(path=self.path) if self.generation is not None: download_url += u'&generation={:d}'.format(self.generation) + if self.user_project is not None: + download_url += u'&userProject={}'.format(self.user_project) return download_url else: return self.media_link @@ -654,6 +660,10 @@ def _do_multipart_upload(self, client, stream, content_type, upload_url = _MULTIPART_URL_TEMPLATE.format( bucket_path=self.bucket.path) + + if self.user_project is not None: + upload_url += '&userProject={}'.format(self.user_project) + upload = MultipartUpload(upload_url, headers=headers) if num_retries is not None: @@ -726,6 +736,10 @@ def _initiate_resumable_upload(self, client, stream, content_type, upload_url = _RESUMABLE_URL_TEMPLATE.format( bucket_path=self.bucket.path) + + if self.user_project is not None: + upload_url += '&userProject={}'.format(self.user_project) + upload = ResumableUpload(upload_url, chunk_size, headers=headers) if num_retries is not None: @@ -1079,9 +1093,16 @@ def get_iam_policy(self, client=None): the ``getIamPolicy`` API request. """ client = self._require_client(client) + + query_params = {} + + if self.user_project is not None: + query_params['userProject'] = self.user_project + info = client._connection.api_request( method='GET', path='%s/iam' % (self.path,), + query_params=query_params, _target_object=None) return Policy.from_api_repr(info) @@ -1104,11 +1125,18 @@ def set_iam_policy(self, policy, client=None): the ``setIamPolicy`` API request. """ client = self._require_client(client) + + query_params = {} + + if self.user_project is not None: + query_params['userProject'] = self.user_project + resource = policy.to_api_repr() resource['resourceId'] = self.path info = client._connection.api_request( method='PUT', path='%s/iam' % (self.path,), + query_params=query_params, data=resource, _target_object=None) return Policy.from_api_repr(info) @@ -1132,12 +1160,17 @@ def test_iam_permissions(self, permissions, client=None): request. """ client = self._require_client(client) - query = {'permissions': permissions} + query_params = {'permissions': permissions} + + if self.user_project is not None: + query_params['userProject'] = self.user_project + path = '%s/iam/testPermissions' % (self.path,) resp = client._connection.api_request( method='GET', path=path, - query_params=query) + query_params=query_params) + return resp.get('permissions', []) def make_public(self, client=None): @@ -1167,13 +1200,22 @@ def compose(self, sources, client=None): """ if self.content_type is None: raise ValueError("Destination 'content_type' not set.") + client = self._require_client(client) + query_params = {} + + if self.user_project is not None: + query_params['userProject'] = self.user_project + request = { 'sourceObjects': [{'name': source.name} for source in sources], 'destination': self._properties.copy(), } api_response = client._connection.api_request( - method='POST', path=self.path + '/compose', data=request, + method='POST', + path=self.path + '/compose', + query_params=query_params, + data=request, _target_object=self) self._set_properties(api_response) @@ -1205,14 +1247,20 @@ def rewrite(self, source, token=None, client=None): headers.update(_get_encryption_headers( source._encryption_key, source=True)) + query_params = {} + if token: - query_params = {'rewriteToken': token} - else: - query_params = {} + query_params['rewriteToken'] = token + + if self.user_project is not None: + query_params['userProject'] = self.user_project api_response = client._connection.api_request( - method='POST', path=source.path + '/rewriteTo' + self.path, - query_params=query_params, data=self._properties, headers=headers, + method='POST', + path=source.path + '/rewriteTo' + self.path, + query_params=query_params, + data=self._properties, + headers=headers, _target_object=self) rewritten = int(api_response['totalBytesRewritten']) size = int(api_response['objectSize']) @@ -1243,13 +1291,22 @@ def update_storage_class(self, new_class, client=None): raise ValueError("Invalid storage class: %s" % (new_class,)) client = self._require_client(client) + + query_params = {} + + if self.user_project is not None: + query_params['userProject'] = self.user_project + headers = _get_encryption_headers(self._encryption_key) headers.update(_get_encryption_headers( self._encryption_key, source=True)) api_response = client._connection.api_request( - method='POST', path=self.path + '/rewriteTo' + self.path, - data={'storageClass': new_class}, headers=headers, + method='POST', + path=self.path + '/rewriteTo' + self.path, + query_params=query_params, + data={'storageClass': new_class}, + headers=headers, _target_object=self) self._set_properties(api_response['resource']) diff --git a/storage/tests/unit/test_blob.py b/storage/tests/unit/test_blob.py index 084745ebb54d9..1c31e9ea1b0f7 100644 --- a/storage/tests/unit/test_blob.py +++ b/storage/tests/unit/test_blob.py @@ -317,16 +317,31 @@ def test_exists_miss(self): bucket = _Bucket(client) blob = self._make_one(NONESUCH, bucket=bucket) self.assertFalse(blob.exists()) + self.assertEqual(len(connection._requested), 1) + self.assertEqual(connection._requested[0], { + 'method': 'GET', + 'path': '/b/name/o/{}'.format(NONESUCH), + 'query_params': {'fields': 'name'}, + '_target_object': None, + }) - def test_exists_hit(self): + def test_exists_hit_w_user_project(self): BLOB_NAME = 'blob-name' + USER_PROJECT = 'user-project-123' found_response = ({'status': http_client.OK}, b'') connection = _Connection(found_response) client = _Client(connection) - bucket = _Bucket(client) + bucket = _Bucket(client, user_project=USER_PROJECT) blob = self._make_one(BLOB_NAME, bucket=bucket) bucket._blobs[BLOB_NAME] = 1 self.assertTrue(blob.exists()) + self.assertEqual(len(connection._requested), 1) + self.assertEqual(connection._requested[0], { + 'method': 'GET', + 'path': '/b/name/o/{}'.format(BLOB_NAME), + 'query_params': {'fields': 'name', 'userProject': USER_PROJECT}, + '_target_object': None, + }) def test_delete(self): BLOB_NAME = 'blob-name' @@ -362,7 +377,7 @@ def test__get_download_url_with_media_link(self): def test__get_download_url_on_the_fly(self): blob_name = 'bzzz-fly.txt' - bucket = mock.Mock(path='/b/buhkit', spec=['path']) + bucket = _Bucket(name='buhkit') blob = self._make_one(blob_name, bucket=bucket) self.assertIsNone(blob.media_link) @@ -374,7 +389,7 @@ def test__get_download_url_on_the_fly(self): def test__get_download_url_on_the_fly_with_generation(self): blob_name = 'pretend.txt' - bucket = mock.Mock(path='/b/fictional', spec=['path']) + bucket = _Bucket(name='fictional') blob = self._make_one(blob_name, bucket=bucket) generation = 1493058489532987 # Set the media link on the blob @@ -387,6 +402,20 @@ def test__get_download_url_on_the_fly_with_generation(self): 'fictional/o/pretend.txt?alt=media&generation=1493058489532987') self.assertEqual(download_url, expected_url) + def test__get_download_url_on_the_fly_with_user_project(self): + blob_name = 'pretend.txt' + user_project = 'user-project-123' + bucket = _Bucket(name='fictional', user_project=user_project) + blob = self._make_one(blob_name, bucket=bucket) + + self.assertIsNone(blob.media_link) + download_url = blob._get_download_url() + expected_url = ( + 'https://www.googleapis.com/download/storage/v1/b/' + 'fictional/o/pretend.txt?alt=media&userProject={}'.format( + user_project)) + self.assertEqual(download_url, expected_url) + @staticmethod def _mock_requests_response(status_code, headers, content=b''): return mock.Mock( @@ -778,8 +807,8 @@ def _mock_transport(self, status_code, headers, content=b''): return fake_transport def _do_multipart_success(self, mock_get_boundary, size=None, - num_retries=None): - bucket = mock.Mock(path='/b/w00t', spec=[u'path']) + num_retries=None, user_project=None): + bucket = _Bucket(name='w00t', user_project=user_project) blob = self._make_one(u'blob-name', bucket=bucket) self.assertIsNone(blob.chunk_size) @@ -811,6 +840,8 @@ def _do_multipart_success(self, mock_get_boundary, size=None, 'https://www.googleapis.com/upload/storage/v1' + bucket.path + '/o?uploadType=multipart') + if user_project is not None: + upload_url += '&userProject={}'.format(user_project) payload = ( b'--==0==\r\n' + b'content-type: application/json; charset=UTF-8\r\n\r\n' + @@ -833,6 +864,13 @@ def test__do_multipart_upload_no_size(self, mock_get_boundary): def test__do_multipart_upload_with_size(self, mock_get_boundary): self._do_multipart_success(mock_get_boundary, size=10) + @mock.patch(u'google.resumable_media._upload.get_boundary', + return_value=b'==0==') + def test__do_multipart_upload_with_user_project(self, mock_get_boundary): + user_project = 'user-project-123' + self._do_multipart_success( + mock_get_boundary, user_project=user_project) + @mock.patch(u'google.resumable_media._upload.get_boundary', return_value=b'==0==') def test__do_multipart_upload_with_retry(self, mock_get_boundary): @@ -854,11 +892,12 @@ def test__do_multipart_upload_bad_size(self): 'was specified but the file-like object only had', exc_contents) self.assertEqual(stream.tell(), len(data)) - def _initiate_resumable_helper(self, size=None, extra_headers=None, - chunk_size=None, num_retries=None): + def _initiate_resumable_helper( + self, size=None, extra_headers=None, chunk_size=None, + num_retries=None, user_project=None): from google.resumable_media.requests import ResumableUpload - bucket = mock.Mock(path='/b/whammy', spec=[u'path']) + bucket = _Bucket(name='whammy', user_project=user_project) blob = self._make_one(u'blob-name', bucket=bucket) blob.metadata = {'rook': 'takes knight'} blob.chunk_size = 3 * blob._CHUNK_SIZE_MULTIPLE @@ -892,6 +931,8 @@ def _initiate_resumable_helper(self, size=None, extra_headers=None, 'https://www.googleapis.com/upload/storage/v1' + bucket.path + '/o?uploadType=resumable') + if user_project is not None: + upload_url += '&userProject={}'.format(user_project) self.assertEqual(upload.upload_url, upload_url) if extra_headers is None: self.assertEqual(upload._headers, {}) @@ -944,6 +985,10 @@ def test__initiate_resumable_upload_no_size(self): def test__initiate_resumable_upload_with_size(self): self._initiate_resumable_helper(size=10000) + def test__initiate_resumable_upload_with_user_project(self): + user_project = 'user-project-123' + self._initiate_resumable_helper(user_project=user_project) + def test__initiate_resumable_upload_with_chunk_size(self): one_mb = 1048576 self._initiate_resumable_helper(chunk_size=one_mb) @@ -1023,7 +1068,7 @@ def _do_resumable_upload_call2(blob, content_type, data, 'PUT', resumable_url, data=payload, headers=expected_headers) def _do_resumable_helper(self, use_size=False, num_retries=None): - bucket = mock.Mock(path='/b/yesterday', spec=[u'path']) + bucket = _Bucket(name='yesterday') blob = self._make_one(u'blob-name', bucket=bucket) blob.chunk_size = blob._CHUNK_SIZE_MULTIPLE self.assertIsNotNone(blob.chunk_size) @@ -1266,7 +1311,7 @@ def test_upload_from_string_w_text(self): def _create_resumable_upload_session_helper(self, origin=None, side_effect=None): - bucket = mock.Mock(path='/b/alex-trebek', spec=[u'path']) + bucket = _Bucket(name='alex-trebek') blob = self._make_one('blob-name', bucket=bucket) chunk_size = 99 * blob._CHUNK_SIZE_MULTIPLE blob.chunk_size = chunk_size @@ -1377,8 +1422,49 @@ def test_get_iam_policy(self): kw = connection._requested self.assertEqual(len(kw), 1) - self.assertEqual(kw[0]['method'], 'GET') - self.assertEqual(kw[0]['path'], '%s/iam' % (PATH,)) + self.assertEqual(kw[0], { + 'method': 'GET', + 'path': '%s/iam' % (PATH,), + 'query_params': {}, + '_target_object': None, + }) + + def test_get_iam_policy_w_user_project(self): + from google.cloud.iam import Policy + + BLOB_NAME = 'blob-name' + USER_PROJECT = 'user-project-123' + PATH = '/b/name/o/%s' % (BLOB_NAME,) + ETAG = 'DEADBEEF' + VERSION = 17 + RETURNED = { + 'resourceId': PATH, + 'etag': ETAG, + 'version': VERSION, + 'bindings': [], + } + after = ({'status': http_client.OK}, RETURNED) + EXPECTED = {} + connection = _Connection(after) + client = _Client(connection) + bucket = _Bucket(client=client, user_project=USER_PROJECT) + blob = self._make_one(BLOB_NAME, bucket=bucket) + + policy = blob.get_iam_policy() + + self.assertIsInstance(policy, Policy) + self.assertEqual(policy.etag, RETURNED['etag']) + self.assertEqual(policy.version, RETURNED['version']) + self.assertEqual(dict(policy), EXPECTED) + + kw = connection._requested + self.assertEqual(len(kw), 1) + self.assertEqual(kw[0], { + 'method': 'GET', + 'path': '%s/iam' % (PATH,), + 'query_params': {'userProject': USER_PROJECT}, + '_target_object': None, + }) def test_set_iam_policy(self): import operator @@ -1427,6 +1513,7 @@ def test_set_iam_policy(self): self.assertEqual(len(kw), 1) self.assertEqual(kw[0]['method'], 'PUT') self.assertEqual(kw[0]['path'], '%s/iam' % (PATH,)) + self.assertEqual(kw[0]['query_params'], {}) sent = kw[0]['data'] self.assertEqual(sent['resourceId'], PATH) self.assertEqual(len(sent['bindings']), len(BINDINGS)) @@ -1438,6 +1525,41 @@ def test_set_iam_policy(self): self.assertEqual( sorted(found['members']), sorted(expected['members'])) + def test_set_iam_policy_w_user_project(self): + from google.cloud.iam import Policy + + BLOB_NAME = 'blob-name' + USER_PROJECT = 'user-project-123' + PATH = '/b/name/o/%s' % (BLOB_NAME,) + ETAG = 'DEADBEEF' + VERSION = 17 + BINDINGS = [] + RETURNED = { + 'etag': ETAG, + 'version': VERSION, + 'bindings': BINDINGS, + } + after = ({'status': http_client.OK}, RETURNED) + policy = Policy() + + connection = _Connection(after) + client = _Client(connection) + bucket = _Bucket(client=client, user_project=USER_PROJECT) + blob = self._make_one(BLOB_NAME, bucket=bucket) + + returned = blob.set_iam_policy(policy) + + self.assertEqual(returned.etag, ETAG) + self.assertEqual(returned.version, VERSION) + self.assertEqual(dict(returned), dict(policy)) + + kw = connection._requested + self.assertEqual(len(kw), 1) + self.assertEqual(kw[0]['method'], 'PUT') + self.assertEqual(kw[0]['path'], '%s/iam' % (PATH,)) + self.assertEqual(kw[0]['query_params'], {'userProject': USER_PROJECT}) + self.assertEqual(kw[0]['data'], {'resourceId': PATH}) + def test_test_iam_permissions(self): from google.cloud.storage.iam import STORAGE_OBJECTS_LIST from google.cloud.storage.iam import STORAGE_BUCKETS_GET @@ -1468,6 +1590,39 @@ def test_test_iam_permissions(self): self.assertEqual(kw[0]['path'], '%s/iam/testPermissions' % (PATH,)) self.assertEqual(kw[0]['query_params'], {'permissions': PERMISSIONS}) + def test_test_iam_permissions_w_user_project(self): + from google.cloud.storage.iam import STORAGE_OBJECTS_LIST + from google.cloud.storage.iam import STORAGE_BUCKETS_GET + from google.cloud.storage.iam import STORAGE_BUCKETS_UPDATE + + BLOB_NAME = 'blob-name' + USER_PROJECT = 'user-project-123' + PATH = '/b/name/o/%s' % (BLOB_NAME,) + PERMISSIONS = [ + STORAGE_OBJECTS_LIST, + STORAGE_BUCKETS_GET, + STORAGE_BUCKETS_UPDATE, + ] + ALLOWED = PERMISSIONS[1:] + RETURNED = {'permissions': ALLOWED} + after = ({'status': http_client.OK}, RETURNED) + connection = _Connection(after) + client = _Client(connection) + bucket = _Bucket(client=client, user_project=USER_PROJECT) + blob = self._make_one(BLOB_NAME, bucket=bucket) + + allowed = blob.test_iam_permissions(PERMISSIONS) + + self.assertEqual(allowed, ALLOWED) + + kw = connection._requested + self.assertEqual(len(kw), 1) + self.assertEqual(kw[0]['method'], 'GET') + self.assertEqual(kw[0]['path'], '%s/iam/testPermissions' % (PATH,)) + self.assertEqual( + kw[0]['query_params'], + {'permissions': PERMISSIONS, 'userProject': USER_PROJECT}) + def test_make_public(self): from google.cloud.storage.acl import _ACLEntity @@ -1502,17 +1657,18 @@ def test_compose_wo_content_type_set(self): with self.assertRaises(ValueError): destination.compose(sources=[source_1, source_2]) - def test_compose_minimal(self): + def test_compose_minimal_w_user_project(self): SOURCE_1 = 'source-1' SOURCE_2 = 'source-2' DESTINATION = 'destinaton' RESOURCE = { 'etag': 'DEADBEEF' } + USER_PROJECT = 'user-project-123' after = ({'status': http_client.OK}, RESOURCE) connection = _Connection(after) client = _Client(connection) - bucket = _Bucket(client=client) + bucket = _Bucket(client=client, user_project=USER_PROJECT) source_1 = self._make_one(SOURCE_1, bucket=bucket) source_2 = self._make_one(SOURCE_2, bucket=bucket) destination = self._make_one(DESTINATION, bucket=bucket) @@ -1522,20 +1678,23 @@ def test_compose_minimal(self): self.assertEqual(destination.etag, 'DEADBEEF') - SENT = { - 'sourceObjects': [ - {'name': source_1.name}, - {'name': source_2.name}, - ], - 'destination': { - 'contentType': 'text/plain', - }, - } kw = connection._requested self.assertEqual(len(kw), 1) - self.assertEqual(kw[0]['method'], 'POST') - self.assertEqual(kw[0]['path'], '/b/name/o/%s/compose' % DESTINATION) - self.assertEqual(kw[0]['data'], SENT) + self.assertEqual(kw[0], { + 'method': 'POST', + 'path': '/b/name/o/%s/compose' % DESTINATION, + 'query_params': {'userProject': USER_PROJECT}, + 'data': { + 'sourceObjects': [ + {'name': source_1.name}, + {'name': source_2.name}, + ], + 'destination': { + 'contentType': 'text/plain', + }, + }, + '_target_object': destination, + }) def test_compose_w_additional_property_changes(self): SOURCE_1 = 'source-1' @@ -1559,24 +1718,27 @@ def test_compose_w_additional_property_changes(self): self.assertEqual(destination.etag, 'DEADBEEF') - SENT = { - 'sourceObjects': [ - {'name': source_1.name}, - {'name': source_2.name}, - ], - 'destination': { - 'contentType': 'text/plain', - 'contentLanguage': 'en-US', - 'metadata': { - 'my-key': 'my-value', - } - }, - } kw = connection._requested self.assertEqual(len(kw), 1) - self.assertEqual(kw[0]['method'], 'POST') - self.assertEqual(kw[0]['path'], '/b/name/o/%s/compose' % DESTINATION) - self.assertEqual(kw[0]['data'], SENT) + self.assertEqual(kw[0], { + 'method': 'POST', + 'path': '/b/name/o/%s/compose' % DESTINATION, + 'query_params': {}, + 'data': { + 'sourceObjects': [ + {'name': source_1.name}, + {'name': source_2.name}, + ], + 'destination': { + 'contentType': 'text/plain', + 'contentLanguage': 'en-US', + 'metadata': { + 'my-key': 'my-value', + } + }, + }, + '_target_object': destination, + }) def test_rewrite_response_without_resource(self): SOURCE_BLOB = 'source' @@ -1648,7 +1810,7 @@ def test_rewrite_other_bucket_other_name_no_encryption_partial(self): self.assertNotIn('X-Goog-Encryption-Key', headers) self.assertNotIn('X-Goog-Encryption-Key-Sha256', headers) - def test_rewrite_same_name_no_old_key_new_key_done(self): + def test_rewrite_same_name_no_old_key_new_key_done_w_user_project(self): import base64 import hashlib @@ -1657,6 +1819,7 @@ def test_rewrite_same_name_no_old_key_new_key_done(self): KEY_HASH = hashlib.sha256(KEY).digest() KEY_HASH_B64 = base64.b64encode(KEY_HASH).rstrip().decode('ascii') BLOB_NAME = 'blob' + USER_PROJECT = 'user-project-123' RESPONSE = { 'totalBytesRewritten': 42, 'objectSize': 42, @@ -1666,7 +1829,7 @@ def test_rewrite_same_name_no_old_key_new_key_done(self): response = ({'status': http_client.OK}, RESPONSE) connection = _Connection(response) client = _Client(connection) - bucket = _Bucket(client=client) + bucket = _Bucket(client=client, user_project=USER_PROJECT) plain = self._make_one(BLOB_NAME, bucket=bucket) encrypted = self._make_one(BLOB_NAME, bucket=bucket, encryption_key=KEY) @@ -1682,7 +1845,7 @@ def test_rewrite_same_name_no_old_key_new_key_done(self): self.assertEqual(kw[0]['method'], 'POST') PATH = '/b/name/o/%s/rewriteTo/b/name/o/%s' % (BLOB_NAME, BLOB_NAME) self.assertEqual(kw[0]['path'], PATH) - self.assertEqual(kw[0]['query_params'], {}) + self.assertEqual(kw[0]['query_params'], {'userProject': USER_PROJECT}) SENT = {} self.assertEqual(kw[0]['data'], SENT) @@ -1785,7 +1948,7 @@ def test_update_storage_class_wo_encryption_key(self): self.assertEqual(kw[0]['method'], 'POST') PATH = '/b/name/o/%s/rewriteTo/b/name/o/%s' % (BLOB_NAME, BLOB_NAME) self.assertEqual(kw[0]['path'], PATH) - self.assertNotIn('query_params', kw[0]) + self.assertEqual(kw[0]['query_params'], {}) SENT = {'storageClass': STORAGE_CLASS} self.assertEqual(kw[0]['data'], SENT) @@ -1799,7 +1962,7 @@ def test_update_storage_class_wo_encryption_key(self): self.assertNotIn('X-Goog-Encryption-Key', headers) self.assertNotIn('X-Goog-Encryption-Key-Sha256', headers) - def test_update_storage_class_w_encryption_key(self): + def test_update_storage_class_w_encryption_key_w_user_project(self): import base64 import hashlib @@ -1810,13 +1973,14 @@ def test_update_storage_class_w_encryption_key(self): BLOB_KEY_HASH_B64 = base64.b64encode( BLOB_KEY_HASH).rstrip().decode('ascii') STORAGE_CLASS = u'NEARLINE' + USER_PROJECT = 'user-project-123' RESPONSE = { 'resource': {'storageClass': STORAGE_CLASS}, } response = ({'status': http_client.OK}, RESPONSE) connection = _Connection(response) client = _Client(connection) - bucket = _Bucket(client=client) + bucket = _Bucket(client=client, user_project=USER_PROJECT) blob = self._make_one( BLOB_NAME, bucket=bucket, encryption_key=BLOB_KEY) @@ -1829,7 +1993,7 @@ def test_update_storage_class_w_encryption_key(self): self.assertEqual(kw[0]['method'], 'POST') PATH = '/b/name/o/%s/rewriteTo/b/name/o/%s' % (BLOB_NAME, BLOB_NAME) self.assertEqual(kw[0]['path'], PATH) - self.assertNotIn('query_params', kw[0]) + self.assertEqual(kw[0]['query_params'], {'userProject': USER_PROJECT}) SENT = {'storageClass': STORAGE_CLASS} self.assertEqual(kw[0]['data'], SENT)