From 73e4296a389893c750f7c70a477ec828e4360197 Mon Sep 17 00:00:00 2001 From: Paulo Ewerton Date: Tue, 29 Mar 2016 19:10:42 +0000 Subject: [PATCH 001/316] Adding keystoneauth sessions support This patch allows authentication in swiftclient with a keystonauth session. Co-Authored-By: Tim Burke Change-Id: Ia3fd947ff619c11ff0ce474897533dcf7b49d9b3 Closes-Bug: 1518938 --- doc/source/client-api.rst | 22 ++++++++++++++++++ swiftclient/client.py | 35 ++++++++++++++++++++-------- tests/unit/test_swiftclient.py | 42 ++++++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 10 deletions(-) diff --git a/doc/source/client-api.rst b/doc/source/client-api.rst index 5677f70d..13b3056a 100644 --- a/doc/source/client-api.rst +++ b/doc/source/client-api.rst @@ -18,6 +18,28 @@ version are detailed below, but are just a subset of those that can be used to successfully authenticate. These are the most common and recommended combinations. +Keystone Session +~~~~~~~~~~~~~~~~ + +.. code-block:: python + + from keystoneauth1 import session + from keystoneauth1 import v3 + + # Create a password auth plugin + auth = v3.Password(auth_url='http://127.0.0.1:5000/v3/', + username='tester', + password='testing', + user_domain_name='Default', + project_name='Default', + project_domain_name='Default') + + # Create session + session = session.Session(auth=auth) + + # Create swiftclient Connection + swift_conn = Connection(session=session) + Keystone v3 ~~~~~~~~~~~ diff --git a/swiftclient/client.py b/swiftclient/client.py index 744a876b..8370764d 100644 --- a/swiftclient/client.py +++ b/swiftclient/client.py @@ -609,6 +609,7 @@ def get_auth(auth_url, user, key, **kwargs): use of this network path causes no bandwidth charges but requires the client to be running on Rackspace's ServiceNet network. """ + session = kwargs.get('session', None) auth_version = kwargs.get('auth_version', '1') os_options = kwargs.get('os_options', {}) @@ -617,7 +618,14 @@ def get_auth(auth_url, user, key, **kwargs): cert = kwargs.get('cert') cert_key = kwargs.get('cert_key') timeout = kwargs.get('timeout', None) - if auth_version in AUTH_VERSIONS_V1: + + if session: + service_type = os_options.get('service_type', 'object-store') + interface = os_options.get('endpoint_type', 'public') + storage_url = session.get_endpoint(service_type=service_type, + interface=interface) + token = session.get_token() + elif auth_version in AUTH_VERSIONS_V1: storage_url, token = get_auth_1_0(auth_url, user, key, @@ -654,8 +662,8 @@ def get_auth(auth_url, user, key, **kwargs): timeout=timeout, auth_version=auth_version) else: - raise ClientException('Unknown auth_version %s specified.' - % auth_version) + raise ClientException('Unknown auth_version %s specified and no ' + 'session found.' % auth_version) # Override storage url, if necessary if os_options.get('object_storage_url'): @@ -1414,7 +1422,7 @@ def __init__(self, authurl=None, user=None, key=None, retries=5, os_options=None, auth_version="1", cacert=None, insecure=False, cert=None, cert_key=None, ssl_compression=True, retry_on_ratelimit=False, - timeout=None): + timeout=None, session=None): """ :param authurl: authentication URL :param user: user name to authenticate as @@ -1449,7 +1457,9 @@ def __init__(self, authurl=None, user=None, key=None, retries=5, this parameter to True will cause a retry after a backoff. :param timeout: The connect timeout for the HTTP connection. + :param session: A keystoneauth session object. """ + self.session = session self.authurl = authurl self.user = user self.key = key @@ -1493,7 +1503,7 @@ def close(self): def get_auth(self): self.url, self.token = get_auth(self.authurl, self.user, self.key, - snet=self.snet, + session=self.session, snet=self.snet, auth_version=self.auth_version, os_options=self.os_options, cacert=self.cacert, @@ -1512,8 +1522,8 @@ def get_service_auth(self): None) service_user = opts.get('service_username', None) service_key = opts.get('service_key', None) - return get_auth(self.authurl, service_user, - service_key, + return get_auth(self.authurl, service_user, service_key, + session=self.session, snet=self.snet, auth_version=self.auth_version, os_options=service_options, @@ -1577,10 +1587,15 @@ def _retry(self, reset_func, func, *args, **kwargs): logger.exception(err) raise if err.http_status == 401: + if self.session: + should_retry = self.session.invalidate() + else: + # Without a proper session, just check for auth creds + should_retry = all((self.authurl, self.user, self.key)) + self.url = self.token = self.service_token = None - if retried_auth or not all((self.authurl, - self.user, - self.key)): + + if retried_auth or not should_retry: logger.exception(err) raise retried_auth = True diff --git a/tests/unit/test_swiftclient.py b/tests/unit/test_swiftclient.py index cbb95dbe..07ae5021 100644 --- a/tests/unit/test_swiftclient.py +++ b/tests/unit/test_swiftclient.py @@ -561,6 +561,15 @@ def test_get_keystone_client_2_0(self): self.assertTrue(url.startswith("http")) self.assertTrue(token) + def test_auth_with_session(self): + mock_session = mock.MagicMock() + mock_session.get_endpoint.return_value = 'http://storagehost/v1/acct' + mock_session.get_token.return_value = 'token' + url, token = c.get_auth('http://www.test.com', 'asdf', 'asdf', + session=mock_session) + self.assertEqual(url, 'http://storagehost/v1/acct') + self.assertTrue(token) + class TestGetAccount(MockHttpTest): @@ -1868,6 +1877,39 @@ def test_reauth_os_preauth(self): ('HEAD', '/v1/AUTH_test', '', {'x-auth-token': 'token'}), ]) + def test_session_no_invalidate(self): + mock_session = mock.MagicMock() + mock_session.get_endpoint.return_value = 'http://storagehost/v1/acct' + mock_session.get_token.return_value = 'expired' + mock_session.invalidate.return_value = False + conn = c.Connection(session=mock_session) + fake_conn = self.fake_http_connection(401) + with mock.patch.multiple('swiftclient.client', + http_connection=fake_conn, + sleep=mock.DEFAULT): + self.assertRaises(c.ClientException, conn.head_account) + self.assertEqual(mock_session.get_token.mock_calls, [mock.call()]) + self.assertEqual(mock_session.invalidate.mock_calls, [mock.call()]) + + def test_session_can_invalidate(self): + mock_session = mock.MagicMock() + mock_session.get_endpoint.return_value = 'http://storagehost/v1/acct' + mock_session.get_token.side_effect = ['expired', 'token'] + mock_session.invalidate.return_value = True + conn = c.Connection(session=mock_session) + fake_conn = self.fake_http_connection(401, 200) + with mock.patch.multiple('swiftclient.client', + http_connection=fake_conn, + sleep=mock.DEFAULT): + conn.head_account() + self.assertRequests([ + ('HEAD', '/v1/acct', '', {'x-auth-token': 'expired'}), + ('HEAD', '/v1/acct', '', {'x-auth-token': 'token'}), + ]) + self.assertEqual(mock_session.get_token.mock_calls, [ + mock.call(), mock.call()]) + self.assertEqual(mock_session.invalidate.mock_calls, [mock.call()]) + def test_preauth_token_with_no_storage_url_requires_auth(self): conn = c.Connection( 'http://auth.example.com', 'user', 'password', From a62b7ee06c1a300c73d9a01b2c9bc05028f1fabd Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Tue, 7 Jun 2016 15:49:32 +0200 Subject: [PATCH 002/316] client: renew token on 401 even if retries is 0 Gnocchi uses a client with retries=0 to maximize throughtput and not retry N times on e.g. 404 when checking existence of an object. However, this as the side effect of never renewing the token since there' no retry on 401 either. This patches change the behavior so that 401 errors are always retried, whatever the retries value is. Closes-Bug: #1589926 Change-Id: Ie06adf4cf17ea4592b5bbd7bbde9828e5e134e3e --- swiftclient/client.py | 8 ++-- tests/unit/test_swiftclient.py | 78 ++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 4 deletions(-) diff --git a/swiftclient/client.py b/swiftclient/client.py index 744a876b..f556afd9 100644 --- a/swiftclient/client.py +++ b/swiftclient/client.py @@ -1544,7 +1544,7 @@ def _retry(self, reset_func, func, *args, **kwargs): backoff = self.starting_backoff caller_response_dict = kwargs.pop('response_dict', None) self.attempts = kwargs.pop('attempts', 0) - while self.attempts <= self.retries: + while self.attempts <= self.retries or retried_auth: self.attempts += 1 try: if not self.url or not self.token: @@ -1573,9 +1573,6 @@ def _retry(self, reset_func, func, *args, **kwargs): self.http_conn = None except ClientException as err: self._add_response_dict(caller_response_dict, kwargs) - if self.attempts > self.retries or err.http_status is None: - logger.exception(err) - raise if err.http_status == 401: self.url = self.token = self.service_token = None if retried_auth or not all((self.authurl, @@ -1584,6 +1581,9 @@ def _retry(self, reset_func, func, *args, **kwargs): logger.exception(err) raise retried_auth = True + elif self.attempts > self.retries or err.http_status is None: + logger.exception(err) + raise elif err.http_status == 408: self.http_conn = None elif 500 <= err.http_status <= 599: diff --git a/tests/unit/test_swiftclient.py b/tests/unit/test_swiftclient.py index cbb95dbe..4a39458b 100644 --- a/tests/unit/test_swiftclient.py +++ b/tests/unit/test_swiftclient.py @@ -2556,6 +2556,84 @@ def swap_sleep(*args): self.assertEqual('service_project_name', auth_kwargs['os_options']['tenant_name']) + def test_service_token_reauth_retries_0(self): + get_auth_call_list = [] + + def get_auth(url, user, key, **kwargs): + # The real get_auth function will always return the os_option + # dict's object_storage_url which will be overridden by the + # preauthurl parameter to Connection if it is provided. + args = {'url': url, 'user': user, 'key': key, 'kwargs': kwargs} + get_auth_call_list.append(args) + return_dict = {'asdf': 'new', 'service_username': 'newserv'} + storage_url = kwargs['os_options'].get('object_storage_url') + return storage_url, return_dict[user] + + def swap_sleep(*args): + self.swap_sleep_called = True + c.get_auth = get_auth + + with mock.patch('swiftclient.client.http_connection', + self.fake_http_connection(401, 200)): + with mock.patch('swiftclient.client.sleep', swap_sleep): + self.swap_sleep_called = False + + conn = c.Connection('http://www.test.com', 'asdf', 'asdf', + preauthurl='http://www.old.com', + preauthtoken='old', + os_options=self.os_options, + retries=0) + + self.assertEqual(conn.attempts, 0) + self.assertEqual(conn.url, 'http://www.old.com') + self.assertEqual(conn.token, 'old') + + conn.head_account() + + self.assertTrue(self.swap_sleep_called) + self.assertEqual(conn.attempts, 2) + # The original 'preauth' storage URL *must* be preserved + self.assertEqual(conn.url, 'http://www.old.com') + self.assertEqual(conn.token, 'new') + self.assertEqual(conn.service_token, 'newserv') + + # Check get_auth was called with expected args + auth_args = get_auth_call_list[0] + auth_kwargs = get_auth_call_list[0]['kwargs'] + self.assertEqual('asdf', auth_args['user']) + self.assertEqual('asdf', auth_args['key']) + self.assertEqual('service_key', + auth_kwargs['os_options']['service_key']) + self.assertEqual('service_username', + auth_kwargs['os_options']['service_username']) + self.assertEqual('service_project_name', + auth_kwargs['os_options']['service_project_name']) + + auth_args = get_auth_call_list[1] + auth_kwargs = get_auth_call_list[1]['kwargs'] + self.assertEqual('service_username', auth_args['user']) + self.assertEqual('service_key', auth_args['key']) + self.assertEqual('service_project_name', + auth_kwargs['os_options']['tenant_name']) + + # Ensure this is not an endless loop - it fails after the second 401 + with mock.patch('swiftclient.client.http_connection', + self.fake_http_connection(401, 401, 401, 401)): + with mock.patch('swiftclient.client.sleep', swap_sleep): + self.swap_sleep_called = False + + conn = c.Connection('http://www.test.com', 'asdf', 'asdf', + preauthurl='http://www.old.com', + preauthtoken='old', + os_options=self.os_options, + retries=0) + + self.assertEqual(conn.attempts, 0) + self.assertRaises(c.ClientException, conn.head_account) + self.assertEqual(conn.attempts, 2) + unused_responses = list(self.fake_connect.code_iter) + self.assertEqual(unused_responses, [401, 401]) + def test_service_token_get_account(self): with mock.patch('swiftclient.client.http_connection', self.fake_http_connection(200)): From dca4ea5fd6e1a4eb15eeea9879913f64f2851446 Mon Sep 17 00:00:00 2001 From: Tim Burke Date: Wed, 29 Jun 2016 11:07:41 -0700 Subject: [PATCH 003/316] Fix unicode issues in tempurl command Previously, we weren't encoding paths and keys as UTF-8, which would trigger a UnicodeEncodeError on py27. Change-Id: I2fad428369406c2ae32343a5e943ffb2cd1ca6ef --- swiftclient/utils.py | 45 ++++++++----- tests/unit/test_utils.py | 142 ++++++++++++++++++++++++++++++--------- 2 files changed, 137 insertions(+), 50 deletions(-) diff --git a/swiftclient/utils.py b/swiftclient/utils.py index 0abaed6f..10687bf4 100644 --- a/swiftclient/utils.py +++ b/swiftclient/utils.py @@ -78,15 +78,20 @@ def generate_temp_url(path, seconds, key, method, absolute=False): :raises: TypeError if seconds is not an integer :return: the path portion of a temporary URL """ - if seconds < 0: - raise ValueError('seconds must be a positive integer') try: - if not absolute: - expiration = int(time.time() + seconds) - else: - expiration = int(seconds) - except TypeError: + seconds = int(seconds) + except ValueError: raise TypeError('seconds must be an integer') + if seconds < 0: + raise ValueError('seconds must be a positive integer') + + if isinstance(path, six.binary_type): + try: + path_for_body = path.decode('utf-8') + except UnicodeDecodeError: + raise ValueError('path must be representable as UTF-8') + else: + path_for_body = path standard_methods = ['GET', 'PUT', 'HEAD', 'POST', 'DELETE'] if method.upper() not in standard_methods: @@ -94,18 +99,24 @@ def generate_temp_url(path, seconds, key, method, absolute=False): logger.warning('Non default HTTP method %s for tempurl specified, ' 'possibly an error', method.upper()) - hmac_body = '\n'.join([method.upper(), str(expiration), path]) + if not absolute: + expiration = int(time.time() + seconds) + else: + expiration = seconds + hmac_body = u'\n'.join([method.upper(), str(expiration), path_for_body]) # Encode to UTF-8 for py3 compatibility - sig = hmac.new(key.encode(), - hmac_body.encode(), - hashlib.sha1).hexdigest() - - return ('{path}?temp_url_sig=' - '{sig}&temp_url_expires={exp}'.format( - path=path, - sig=sig, - exp=expiration)) + if not isinstance(key, six.binary_type): + key = key.encode('utf-8') + sig = hmac.new(key, hmac_body.encode('utf-8'), hashlib.sha1).hexdigest() + + temp_url = u'{path}?temp_url_sig={sig}&temp_url_expires={exp}'.format( + path=path_for_body, sig=sig, exp=expiration) + # Have return type match path from caller + if isinstance(path, six.binary_type): + return temp_url.encode('utf-8') + else: + return temp_url def parse_api_response(headers, body): diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index aae466c7..0f210a3c 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -17,7 +17,7 @@ import mock import six import tempfile -from hashlib import md5 +from hashlib import md5, sha1 from swiftclient import utils as u @@ -120,48 +120,124 @@ def test_overflow(self): class TestTempURL(unittest.TestCase): - - def setUp(self): - super(TestTempURL, self).setUp() - self.url = '/v1/AUTH_account/c/o' - self.seconds = 3600 - self.key = 'correcthorsebatterystaple' - self.method = 'GET' - - @mock.patch('hmac.HMAC.hexdigest', return_value='temp_url_signature') + url = '/v1/AUTH_account/c/o' + seconds = 3600 + key = 'correcthorsebatterystaple' + method = 'GET' + expected_url = url + ('?temp_url_sig=temp_url_signature' + '&temp_url_expires=1400003600') + expected_body = '\n'.join([ + method, + '1400003600', + url, + ]).encode('utf-8') + + @mock.patch('hmac.HMAC') @mock.patch('time.time', return_value=1400000000) def test_generate_temp_url(self, time_mock, hmac_mock): - expected_url = ( - '/v1/AUTH_account/c/o?' - 'temp_url_sig=temp_url_signature&' - 'temp_url_expires=1400003600') - url = u.generate_temp_url(self.url, self.seconds, self.key, - self.method) - self.assertEqual(url, expected_url) + hmac_mock().hexdigest.return_value = 'temp_url_signature' + url = u.generate_temp_url(self.url, self.seconds, + self.key, self.method) + key = self.key + if not isinstance(key, six.binary_type): + key = key.encode('utf-8') + self.assertEqual(url, self.expected_url) + self.assertEqual(hmac_mock.mock_calls, [ + mock.call(), + mock.call(key, self.expected_body, sha1), + mock.call().hexdigest(), + ]) + self.assertIsInstance(url, type(self.url)) + + def test_generate_temp_url_invalid_path(self): + with self.assertRaises(ValueError) as exc_manager: + u.generate_temp_url(b'/v1/a/c/\xff', self.seconds, self.key, + self.method) + self.assertEqual(exc_manager.exception.args[0], + 'path must be representable as UTF-8') @mock.patch('hmac.HMAC.hexdigest', return_value="temp_url_signature") def test_generate_absolute_expiry_temp_url(self, hmac_mock): - expected_url = ('/v1/AUTH_account/c/o?' - 'temp_url_sig=temp_url_signature&' - 'temp_url_expires=2146636800') + if isinstance(self.expected_url, six.binary_type): + expected_url = self.expected_url.replace( + b'1400003600', b'2146636800') + else: + expected_url = self.expected_url.replace( + u'1400003600', u'2146636800') url = u.generate_temp_url(self.url, 2146636800, self.key, self.method, absolute=True) self.assertEqual(url, expected_url) def test_generate_temp_url_bad_seconds(self): - self.assertRaises(TypeError, - u.generate_temp_url, - self.url, - 'not_an_int', - self.key, - self.method) - - self.assertRaises(ValueError, - u.generate_temp_url, - self.url, - -1, - self.key, - self.method) + with self.assertRaises(TypeError) as exc_manager: + u.generate_temp_url(self.url, 'not_an_int', self.key, self.method) + self.assertEqual(exc_manager.exception.args[0], + 'seconds must be an integer') + + with self.assertRaises(ValueError) as exc_manager: + u.generate_temp_url(self.url, -1, self.key, self.method) + self.assertEqual(exc_manager.exception.args[0], + 'seconds must be a positive integer') + + +class TestTempURLUnicodePathAndKey(TestTempURL): + url = u'/v1/\u00e4/c/\u00f3' + key = u'k\u00e9y' + expected_url = (u'%s?temp_url_sig=temp_url_signature' + u'&temp_url_expires=1400003600') % url + expected_body = u'\n'.join([ + u'GET', + u'1400003600', + url, + ]).encode('utf-8') + + +class TestTempURLUnicodePathBytesKey(TestTempURL): + url = u'/v1/\u00e4/c/\u00f3' + key = u'k\u00e9y'.encode('utf-8') + expected_url = (u'%s?temp_url_sig=temp_url_signature' + u'&temp_url_expires=1400003600') % url + expected_body = '\n'.join([ + u'GET', + u'1400003600', + url, + ]).encode('utf-8') + + +class TestTempURLBytesPathUnicodeKey(TestTempURL): + url = u'/v1/\u00e4/c/\u00f3'.encode('utf-8') + key = u'k\u00e9y' + expected_url = url + (b'?temp_url_sig=temp_url_signature' + b'&temp_url_expires=1400003600') + expected_body = b'\n'.join([ + b'GET', + b'1400003600', + url, + ]) + + +class TestTempURLBytesPathAndKey(TestTempURL): + url = u'/v1/\u00e4/c/\u00f3'.encode('utf-8') + key = u'k\u00e9y'.encode('utf-8') + expected_url = url + (b'?temp_url_sig=temp_url_signature' + b'&temp_url_expires=1400003600') + expected_body = b'\n'.join([ + b'GET', + b'1400003600', + url, + ]) + + +class TestTempURLBytesPathAndNonUtf8Key(TestTempURL): + url = u'/v1/\u00e4/c/\u00f3'.encode('utf-8') + key = b'k\xffy' + expected_url = url + (b'?temp_url_sig=temp_url_signature' + b'&temp_url_expires=1400003600') + expected_body = b'\n'.join([ + b'GET', + b'1400003600', + url, + ]) class TestReadableToIterable(unittest.TestCase): From d460db84edb11cd57ed12b092a21224e870a4228 Mon Sep 17 00:00:00 2001 From: zheng yin Date: Mon, 25 Jul 2016 11:58:28 +0800 Subject: [PATCH 004/316] Modify assert For example: self.assertEqual(a,None) is equal to self.assertIsNone(a) Change-Id: I063145d034979cf3d36645a00a30cc27defac7c4 --- tests/unit/test_service.py | 50 +++++++++++++++++++------------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/tests/unit/test_service.py b/tests/unit/test_service.py index 75f8961a..2e7acf95 100644 --- a/tests/unit/test_service.py +++ b/tests/unit/test_service.py @@ -59,7 +59,7 @@ def test_create(self): spo = self.spo('obj_name') self.assertEqual(spo.object_name, 'obj_name') - self.assertEqual(spo.options, None) + self.assertIsNone(spo.options) def test_create_with_invalid_name(self): # empty strings are not allowed as names @@ -81,10 +81,10 @@ def test_create(self): self.assertEqual(sr._path, 'path') self.assertEqual(sr._body, 'body') - self.assertEqual(sr._content_length, None) - self.assertEqual(sr._expected_etag, None) + self.assertIsNone(sr._content_length) + self.assertIsNone(sr._expected_etag) - self.assertNotEqual(sr._actual_md5, None) + self.assertIsNotNone(sr._actual_md5) self.assertIs(type(sr._actual_md5), self.md5_type) def test_create_with_large_object_headers(self): @@ -92,25 +92,25 @@ def test_create_with_large_object_headers(self): sr = self.sr('path', 'body', {'x-object-manifest': 'test'}) self.assertEqual(sr._path, 'path') self.assertEqual(sr._body, 'body') - self.assertEqual(sr._content_length, None) - self.assertEqual(sr._expected_etag, None) - self.assertEqual(sr._actual_md5, None) + self.assertIsNone(sr._content_length) + self.assertIsNone(sr._expected_etag) + self.assertIsNone(sr._actual_md5) sr = self.sr('path', 'body', {'x-static-large-object': 'test'}) self.assertEqual(sr._path, 'path') self.assertEqual(sr._body, 'body') - self.assertEqual(sr._content_length, None) - self.assertEqual(sr._expected_etag, None) - self.assertEqual(sr._actual_md5, None) + self.assertIsNone(sr._content_length) + self.assertIsNone(sr._expected_etag) + self.assertIsNone(sr._actual_md5) def test_create_with_ignore_checksum(self): # md5 should not be initialized if checksum is False sr = self.sr('path', 'body', {}, False) self.assertEqual(sr._path, 'path') self.assertEqual(sr._body, 'body') - self.assertEqual(sr._content_length, None) - self.assertEqual(sr._expected_etag, None) - self.assertEqual(sr._actual_md5, None) + self.assertIsNone(sr._content_length) + self.assertIsNone(sr._expected_etag) + self.assertIsNone(sr._actual_md5) def test_create_with_content_length(self): sr = self.sr('path', 'body', {'content-length': 5}) @@ -118,9 +118,9 @@ def test_create_with_content_length(self): self.assertEqual(sr._path, 'path') self.assertEqual(sr._body, 'body') self.assertEqual(sr._content_length, 5) - self.assertEqual(sr._expected_etag, None) + self.assertIsNone(sr._expected_etag) - self.assertNotEqual(sr._actual_md5, None) + self.assertIsNotNone(sr._actual_md5) self.assertIs(type(sr._actual_md5), self.md5_type) # Check Contentlength raises error if it isn't an integer @@ -401,10 +401,10 @@ def test_empty_swifterror_creation(self): se = SwiftError(5) self.assertEqual(se.value, 5) - self.assertEqual(se.container, None) - self.assertEqual(se.obj, None) - self.assertEqual(se.segment, None) - self.assertEqual(se.exception, None) + self.assertIsNone(se.container) + self.assertIsNone(se.obj) + self.assertIsNone(se.segment) + self.assertIsNone(se.exception) self.assertEqual(str(se), '5') @@ -526,12 +526,12 @@ def test_create_with_string(self): suo = self.suo('source') self.assertEqual(suo.source, 'source') self.assertEqual(suo.object_name, 'source') - self.assertEqual(suo.options, None) + self.assertIsNone(suo.options) suo = self.suo('source', 'obj_name') self.assertEqual(suo.source, 'source') self.assertEqual(suo.object_name, 'obj_name') - self.assertEqual(suo.options, None) + self.assertIsNone(suo.options) suo = self.suo('source', 'obj_name', {'opt': '123'}) self.assertEqual(suo.source, 'source') @@ -550,7 +550,7 @@ def test_create_with_file(self): suo = self.suo(mock_file, 'obj_name') self.assertEqual(suo.source, mock_file) self.assertEqual(suo.object_name, 'obj_name') - self.assertEqual(suo.options, None) + self.assertIsNone(suo.options) suo = self.suo(mock_file, 'obj_name', {'opt': '123'}) self.assertEqual(suo.source, mock_file) @@ -559,9 +559,9 @@ def test_create_with_file(self): def test_create_with_no_source(self): suo = self.suo(None, 'obj_name') - self.assertEqual(suo.source, None) + self.assertIsNone(suo.source) self.assertEqual(suo.object_name, 'obj_name') - self.assertEqual(suo.options, None) + self.assertIsNone(suo.options) # Check error is raised if source is None without an object name self.assertRaises(SwiftError, self.suo, None) @@ -1868,7 +1868,7 @@ def test_download_with_objects_empty(self, mock_down_obj, mock_down_cont.assert_not_called() next(service.download('c', options=self.opts), None) - self.assertEqual(True, mock_down_cont.called) + self.assertTrue(mock_down_cont.called) def test_download_with_output_dir(self): with mock.patch('swiftclient.service.Connection') as mock_conn: From 335570e511e5c3a748969d921714a8b9e287e1ac Mon Sep 17 00:00:00 2001 From: Tim Burke Date: Thu, 4 Feb 2016 10:25:15 -0800 Subject: [PATCH 005/316] Add --json option to `swift capabilities` / `swift info` This lets us do things like: $ swift info --json | jq '[.swift.policies[].name]' [ "Standard-Replica", "EC" ] Also, escape more dashes in the man page, so they won't be misinterpreted as hyphens. Change-Id: Ic7690bdbcfc55f55e5dde9bc11bb0644085973ce --- doc/manpages/swift.1 | 34 +++++++++++++++++++++------------- swiftclient/shell.py | 25 ++++++++++++++++++------- tests/unit/test_shell.py | 16 ++++++++++++++++ 3 files changed, 55 insertions(+), 20 deletions(-) diff --git a/doc/manpages/swift.1 b/doc/manpages/swift.1 index beefb818..efcb8e47 100644 --- a/doc/manpages/swift.1 +++ b/doc/manpages/swift.1 @@ -61,8 +61,8 @@ of container or objects being listed. With the \-t or \-\-total option they only .RS 4 Uploads to the given container the files and directories specified by the remaining args. The \-c or \-\-changed is an option that will only upload files -that have changed since the last upload. The \-\-object-name is -an option that will upload file and name object to or upload dir +that have changed since the last upload. The \-\-object\-name is +an option that will upload file and name object to or upload dir and use as object prefix. The \-S or \-\-segment\-size and \-\-leave\-segments and others are options as well (see swift upload \-\-help for more). .RE @@ -86,7 +86,7 @@ container, or a list of objects depending on the args given. For a single object download, you may use the \-o [\-\-output] option to redirect the output to a specific file or if "-" then just redirect to stdout or with \-\-no-download actually not to write anything to disk. -The \-\-ignore-checksum is an option that turn off checksum validation. +The \-\-ignore-checksum is an option that turns off checksum validation. You can specify optional headers with the repeatable cURL-like option \-H [\-\-header]. For more details and options see swift download \-\-help. .RE @@ -100,23 +100,31 @@ will be deleted as well, unless you specify the \-\-leave\-segments option. For more details and options see swift delete \-\-help. .RE -\fBcapabilities\fR [\fIproxy-url\fR] +\fBcapabilities\fR [\fIcommand-options\fR] [\fIproxy-url\fR] .RS 4 -Displays cluster capabilities. The output includes the list of the activated -Swift middlewares as well as relevant options for each one. Additionally the -command displays relevant options for the Swift core. If the proxy-url option -is not provided the storage-url retrieved after authentication is used as -proxy-url. +Displays cluster capabilities. If the proxy-url option is not provided the +storage-url retrieved after authentication is used as proxy-url. + +By default, the output includes the list of the activated Swift middlewares as +well as relevant options for each one. Additionally the command displays +relevant options for the Swift core. + +The \-\-json option will print a json representation of the cluster +capabilities. This is typically more suitable for consumption by other +programs, such as jq. + +\fBExample\fR: capabilities https://swift.example.com + capabilities \-\-json .RE \fBtempurl\fR [\fIcommand-option\fR] \fImethod\fR \fIseconds\fR \fIpath\fR \fIkey\fR .RS 4 Generates a temporary URL allowing unauthenticated access to the Swift object at the given path, using the given HTTP method, for the given number of -seconds, using the given TempURL key. If optional --absolute argument is +seconds, using the given TempURL key. If optional \-\-absolute argument is provided, seconds is instead interpreted as a Unix timestamp at which the URL -should expire. \fBExample\fR: tempurl GET $(date -d "Jan 1 2016" +%s) -/v1/AUTH_foo/bar_container/quux.md my_secret_tempurl_key --absolute +should expire. \fBExample\fR: tempurl GET $(date \-d "Jan 1 2016" +%s) +/v1/AUTH_foo/bar_container/quux.md my_secret_tempurl_key \-\-absolute .RE \fBauth\fR @@ -140,7 +148,7 @@ For examples see swift auth \-\-help. .IP "--os-help Show all OpenStack authentication options" .PD .RS 4 -For more options see swift \-\-help and swift \-\-os-help. +For more options see swift \-\-help and swift \-\-os\-help. .RE diff --git a/swiftclient/shell.py b/swiftclient/shell.py index ef165d8d..cc34b7f6 100755 --- a/swiftclient/shell.py +++ b/swiftclient/shell.py @@ -17,6 +17,7 @@ from __future__ import print_function, unicode_literals import argparse +import json import logging import signal import socket @@ -248,7 +249,7 @@ def st_delete(parser, args, output_manager): -H, --header Adds a customized request header to the query, like "Range" or "If-Match". This option may be repeated. - Example --header "content-type:text/plain" + Example: --header "content-type:text/plain" --skip-identical Skip downloading files that are identical on both sides. --ignore-checksum Turn off checksum validation for downloads. @@ -789,7 +790,7 @@ def st_post(parser, args, output_manager): Default is 10. -H, --header Adds a customized request header. This option may be - repeated. Example -H "content-type:text/plain" + repeated. Example: -H "content-type:text/plain" -H "Content-Length: 4000". --use-slo When used in conjunction with --segment-size it will create a Static Large Object instead of the default @@ -840,7 +841,7 @@ def st_upload(parser, args, output_manager): parser.add_argument( '-H', '--header', action='append', dest='header', default=[], help='Set request headers with the syntax header:value. ' - ' This option may be repeated. Example -H "content-type:text/plain" ' + ' This option may be repeated. Example: -H "content-type:text/plain" ' '-H "Content-Length: 4000"') parser.add_argument( '--use-slo', action='store_true', default=False, @@ -995,13 +996,16 @@ def st_upload(parser, args, output_manager): output_manager.error(e.value) -st_capabilities_options = "[]" +st_capabilities_options = "[--json] []" st_info_options = st_capabilities_options st_capabilities_help = ''' Retrieve capability of the proxy. Optional positional arguments: Proxy URL of the cluster to retrieve capabilities. + +Optional arguments: + --json Print the cluster capabilities in JSON format. '''.strip('\n') st_info_help = st_capabilities_help @@ -1017,6 +1021,8 @@ def _print_compo_cap(name, capabilities): key=lambda x: x[0]): output_manager.print_msg(" %s: %s" % (key, value)) + parser.add_argument('--json', action='store_true', + help='print capability information in json') (options, args) = parse_args(parser, args) if args and len(args) > 2: output_manager.error('Usage: %s capabilities %s\n%s', @@ -1034,9 +1040,14 @@ def _print_compo_cap(name, capabilities): capabilities_result = swift.capabilities() capabilities = capabilities_result['capabilities'] - _print_compo_cap('Core', {'swift': capabilities['swift']}) - del capabilities['swift'] - _print_compo_cap('Additional middleware', capabilities) + if options['json']: + output_manager.print_msg( + json.dumps(capabilities, sort_keys=True, indent=2)) + else: + capabilities = dict(capabilities) + _print_compo_cap('Core', {'swift': capabilities['swift']}) + del capabilities['swift'] + _print_compo_cap('Additional middleware', capabilities) except SwiftError as e: output_manager.error(e.value) diff --git a/tests/unit/test_shell.py b/tests/unit/test_shell.py index 59f0d5f7..b786c777 100644 --- a/tests/unit/test_shell.py +++ b/tests/unit/test_shell.py @@ -16,6 +16,7 @@ from genericpath import getmtime import hashlib +import json import logging import mock import os @@ -1285,6 +1286,21 @@ def test_capabilities(self, connection): swiftclient.shell.main(argv) connection.return_value.get_capabilities.assert_called_with(None) + @mock.patch('swiftclient.service.Connection') + def test_capabilities_json(self, connection): + capabilities = { + 'slo': {'min_segment_size': 1000000}, + 'some': [{'arbitrary': 'nested'}, {'crazy': 'structure'}], + 'swift': {'version': '2.5.0'}} + + connection.return_value.get_capabilities.return_value = capabilities + argv = ["", "capabilities", "--json"] + with CaptureOutput(suppress_systemexit=True) as output: + swiftclient.shell.main(argv) + expected = json.dumps(capabilities, sort_keys=True, indent=2) + '\n' + self.assertEqual(expected, output.out) + connection.return_value.get_capabilities.assert_called_with(None) + def test_human_readable_upload_segment_size(self): def _check_expected(x, expected): actual = x.call_args_list[-1][1]["options"]["segment_size"] From ed4ee7927369cdd5056a3347edad674c3e9ee160 Mon Sep 17 00:00:00 2001 From: John Dickinson Date: Thu, 11 Aug 2016 15:15:06 -0700 Subject: [PATCH 006/316] reenable sidebar links uses new method from https://review.openstack.org/#/c/340509/ Change-Id: I4d19b71b632546fc2e5d5dac2b0d8d43152dabed --- doc/source/conf.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/doc/source/conf.py b/doc/source/conf.py index 0b3e7e1a..5af77b2d 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -111,6 +111,8 @@ # documentation. # html_theme_options = {} +html_theme_options = {'show_other_versions': True} + # Add any paths that contain custom themes here, relative to this directory. # html_theme_path = [] From b1539d9c0feaaf26783e7cab3962219e036994ba Mon Sep 17 00:00:00 2001 From: Andreas Jaeger Date: Fri, 12 Aug 2016 21:14:25 +0200 Subject: [PATCH 007/316] Move other-requirements.txt to bindep.txt The default filename for documenting binary dependencies has been changed from "other-requirements.txt" to "bindep.txt" with the release of bindep 2.1.0. While the previous name is still supported, it will be deprecated. Move the file around to follow this change. Note that this change is self-testing, the OpenStack CI infrastructure will use a "bindep.txt" file to setup nodes for testing. For more information about bindep, see also: http://docs.openstack.org/infra/manual/drivers.html#package-requirements http://docs.openstack.org/infra/bindep/ As well as this announcement: http://lists.openstack.org/pipermail/openstack-dev/2016-August/101590.html Change-Id: I384cdb39c1f65218bb5c6f76afc381c1799b623e --- other-requirements.txt => bindep.txt | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename other-requirements.txt => bindep.txt (100%) diff --git a/other-requirements.txt b/bindep.txt similarity index 100% rename from other-requirements.txt rename to bindep.txt From 8fbe118cea8804fe29529a27f3937af412b47fb7 Mon Sep 17 00:00:00 2001 From: Tim Burke Date: Wed, 17 Aug 2016 16:01:23 -0700 Subject: [PATCH 008/316] Strip leading/trailing whitespace from headers New versions of requests will raise an InvalidHeader error otherwise. Change-Id: Idf3bcd8ac359bdda9a847bf99a78988943374134 Closes-Bug: #1614280 Closes-Bug: #1613814 --- swiftclient/service.py | 2 +- tests/unit/test_service.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/swiftclient/service.py b/swiftclient/service.py index 6ccba552..3820ace9 100644 --- a/swiftclient/service.py +++ b/swiftclient/service.py @@ -280,7 +280,7 @@ def split_headers(options, prefix=''): for item in options: split_item = item.split(':', 1) if len(split_item) == 2: - headers[(prefix + split_item[0]).title()] = split_item[1] + headers[(prefix + split_item[0]).title()] = split_item[1].strip() else: raise SwiftError( "Metadata parameter %s must contain a ':'.\n%s" diff --git a/tests/unit/test_service.py b/tests/unit/test_service.py index 2e7acf95..989699f7 100644 --- a/tests/unit/test_service.py +++ b/tests/unit/test_service.py @@ -496,7 +496,7 @@ def test_process_options_new_style_args(self): self.assertEqual(opt_c['key'], 'key') def test_split_headers(self): - mock_headers = ['color:blue', 'size:large'] + mock_headers = ['color:blue', 'SIZE: large'] expected = {'Color': 'blue', 'Size': 'large'} actual = swiftclient.service.split_headers(mock_headers) From 4a2465fb12ff7287b62b6941fb8ae43e100adc25 Mon Sep 17 00:00:00 2001 From: Marek Kaleta Date: Mon, 15 Feb 2016 12:14:17 +0100 Subject: [PATCH 009/316] Add copy object method Implement copy object method in swiftclient Connection, Service and CLI. Although COPY functionality can be accomplished with 'X-Copy-From' header in PUT request, using copy is more convenient especially when using copy for updating object metadata non-destructively. Closes-Bug: 1474939 Change-Id: I1338ac411f418f4adb3d06753d044a484a7f32a4 --- doc/manpages/swift.1 | 13 ++ doc/source/cli.rst | 14 ++ doc/source/service-api.rst | 82 +++++++++- examples/copy.py | 30 ++++ swiftclient/client.py | 71 +++++++++ swiftclient/service.py | 225 ++++++++++++++++++++++++++- swiftclient/shell.py | 102 +++++++++++- tests/functional/test_swiftclient.py | 40 +++++ tests/unit/test_service.py | 106 +++++++++++++ tests/unit/test_shell.py | 133 ++++++++++++++++ tests/unit/test_swiftclient.py | 104 +++++++++++++ 11 files changed, 914 insertions(+), 6 deletions(-) create mode 100644 examples/copy.py diff --git a/doc/manpages/swift.1 b/doc/manpages/swift.1 index b9f99c4d..7c2ee896 100644 --- a/doc/manpages/swift.1 +++ b/doc/manpages/swift.1 @@ -79,6 +79,19 @@ For more details and options see swift post \-\-help. \fBExample\fR: post \-m Color:Blue \-m Size:Large .RE +\fBcopy\fR [\fIcommand-options\fR] \fIcontainer\fR \fIobject\fR +.RS 4 +Copies an object to a new destination or adds user metadata to the object (current +user metadata will be preserved, in contrast with the post command) depending +on the args given. The \-\-destination option sets the destination in the form +/container/object. If not set, the object will be copied onto itself which is useful +for adding metadata. The \-M or \-\-fresh\-metadata option copies the object without +the existing user metadata. The \-m or \-\-meta option is always allowed and is used +to define the user metadata items to set in the form Name:Value (this option +can be repeated). +For more details and options see swift copy \-\-help. +.RE + \fBdownload\fR [\fIcommand-options\fR] [\fIcontainer\fR] [\fIobject\fR] [\fIobject\fR] [...] .RS 4 Downloads everything in the account (with \-\-all), or everything in a diff --git a/doc/source/cli.rst b/doc/source/cli.rst index 12de02ff..76630be9 100644 --- a/doc/source/cli.rst +++ b/doc/source/cli.rst @@ -199,6 +199,20 @@ Delete of manifest objects will be deleted as well, unless you specify the ``--leave-segments`` option. +Copy +---- + + ``copy [command-options] container object`` + + Copies an object to a new destination or adds user metadata to an object. Depending + on the options supplied, you can preserve existing metadata in contrast to the post + command. The ``--destination`` option sets the copy target destination in the form + ``/container/object``. If not set, the object will be copied onto itself which is useful + for adding metadata. You can use the ``-M`` or ``--fresh-metadata`` option to copy + an object without existing user meta data, and the ``-m`` or ``--meta`` option + to define user meta data items to set in the form ``Name:Value``. You can repeat + this option. For example: ``copy -m Color:Blue -m Size:Large``. + Capabilities ------------ diff --git a/doc/source/service-api.rst b/doc/source/service-api.rst index 50dd80e8..aeb3daac 100644 --- a/doc/source/service-api.rst +++ b/doc/source/service-api.rst @@ -217,6 +217,17 @@ Options are downloaded in lexically-sorted order. Setting this option to ``True`` gives the same shuffling behaviour as the CLI. + ``destination``: ``None`` + When copying objects, this specifies the destination where the object + will be copied to. The default of None means copy will be the same as + source. + + ``fresh_metadata``: ``None`` + When copying objects, this specifies that the object metadata on the + source will *not* be applied to the destination object - the + destination object will have a new fresh set of metadata that includes + *only* the metadata specified in the meta option if any at all. + Other available options can be found in ``swiftclient/service.py`` in the source code for ``python-swiftclient``. Each ``SwiftService`` method also allows for an optional dictionary to override those specified at init time, and the @@ -735,13 +746,76 @@ Example The code below demonstrates the use of ``delete`` to remove a given list of objects from a specified container. As the objects are deleted the transaction -id of the relevant request is printed along with the object name and number -of attempts required. By printing the transaction id, the printed operations +ID of the relevant request is printed along with the object name and number +of attempts required. By printing the transaction ID, the printed operations can be easily linked to events in the swift server logs: .. literalinclude:: ../../examples/delete.py :language: python +Copy +~~~~ + +Copy can be called to copy an object or update the metadata on the given items. + +Each element of the object list may be a plain string of the object name, or a +``SwiftCopyObject`` that allows finer control over the options applied to each +of the individual copy operations (destination, fresh_metadata, options). + +Destination should be in format /container/object; if not set, the object will be +copied onto itself. Fresh_metadata sets mode of operation on metadata. If not set, +current object user metadata will be copied/preserved; if set, all current user +metadata will be removed. + +Returns an iterator over the results generated for each object copy (and may +also include the results of creating destination containers). + +When a string is given for the object name, destination and fresh metadata will +default to None and None, which result in adding metadata to existing objects. + +Successful copy results are dictionaries as described below: + +.. code-block:: python + + { + 'action': 'copy_object', + 'success': True, + 'container': , + 'object': , + 'destination': , + 'headers': {}, + 'fresh_metadata': , + 'response_dict': + } + +Any failure in a copy operation will return a failure dictionary as described +below: + +.. code-block:: python + + { + 'action': 'copy_object', + 'success': False, + 'container': , + 'object': , + 'destination': , + 'headers': {}, + 'fresh_metadata': , + 'response_dict': , + 'error': , + 'traceback': , + 'error_timestamp': + } + +Example +------- + +The code below demonstrates the use of ``copy`` to add new user metadata for +objects a and b, and to copy object c to d (with added metadata). + +.. literalinclude:: ../../examples/copy.py + :language: python + Capabilities ~~~~~~~~~~~~ @@ -764,7 +838,7 @@ returned with the contents described below: } The contents of the capabilities dictionary contain the core swift capabilities -under the key ``swift``, all other keys show the configuration options for +under the key ``swift``; all other keys show the configuration options for additional middlewares deployed in the proxy pipeline. An example capabilities dictionary is given below: @@ -819,7 +893,7 @@ Example ^^^^^^^ The code below demonstrates the use of ``capabilities`` to determine if the -Swift cluster supports static large objects, and if so, the maximum number of +Swift cluster supports static large objects, and if so, the maximum number of segments that can be described in a single manifest file, along with the size restrictions on those objects: diff --git a/examples/copy.py b/examples/copy.py new file mode 100644 index 00000000..e928db4e --- /dev/null +++ b/examples/copy.py @@ -0,0 +1,30 @@ +import logging + +from swiftclient.service import SwiftService, SwiftCopyObject, SwiftError + +logging.basicConfig(level=logging.ERROR) +logging.getLogger("requests").setLevel(logging.CRITICAL) +logging.getLogger("swiftclient").setLevel(logging.CRITICAL) +logger = logging.getLogger(__name__) + +with SwiftService() as swift: + try: + obj = SwiftCopyObject("c", {"Destination": "/cont/d"}) + for i in swift.copy( + "cont", ["a", "b", obj], + {"meta": ["foo:bar"], "Destination": "/cc"}): + if i["success"]: + if i["action"] == "copy_object": + print( + "object %s copied from /%s/%s" % + (i["destination"], i["container"], i["object"]) + ) + if i["action"] == "create_container": + print( + "container %s created" % i["container"] + ) + else: + if "error" in i and isinstance(i["error"], Exception): + raise i["error"] + except SwiftError as e: + logger.error(e.value) diff --git a/swiftclient/client.py b/swiftclient/client.py index 744a876b..5b3fa725 100644 --- a/swiftclient/client.py +++ b/swiftclient/client.py @@ -1319,6 +1319,70 @@ def post_object(url, token, container, name, headers, http_conn=None, raise ClientException.from_response(resp, 'Object POST failed', body) +def copy_object(url, token, container, name, destination=None, + headers=None, fresh_metadata=None, http_conn=None, + response_dict=None, service_token=None): + """ + Copy object + + :param url: storage URL + :param token: auth token; if None, no token will be sent + :param container: container name that the source object is in + :param name: source object name + :param destination: The container and object name of the destination object + in the form of /container/object; if None, the copy + will use the source as the destination. + :param headers: additional headers to include in the request + :param fresh_metadata: Enables object creation that omits existing user + metadata, default None + :param http_conn: HTTP connection object (If None, it will create the + conn object) + :param response_dict: an optional dictionary into which to place + the response - status, reason and headers + :param service_token: service auth token + :raises ClientException: HTTP COPY request failed + """ + if http_conn: + parsed, conn = http_conn + else: + parsed, conn = http_connection(url) + + path = parsed.path + container = quote(container) + name = quote(name) + path = '%s/%s/%s' % (path.rstrip('/'), container, name) + + headers = dict(headers) if headers else {} + + if destination is not None: + headers['Destination'] = quote(destination) + elif container and name: + headers['Destination'] = '/%s/%s' % (container, name) + + if token is not None: + headers['X-Auth-Token'] = token + if service_token is not None: + headers['X-Service-Token'] = service_token + + if fresh_metadata is not None: + # remove potential fresh metadata headers + for fresh_hdr in [hdr for hdr in headers.keys() + if hdr.lower() == 'x-fresh-metadata']: + headers.pop(fresh_hdr) + headers['X-Fresh-Metadata'] = 'true' if fresh_metadata else 'false' + + conn.request('COPY', path, '', headers) + resp = conn.getresponse() + body = resp.read() + http_log(('%s%s' % (url.replace(parsed.path, ''), path), 'COPY',), + {'headers': headers}, resp, body) + + store_response(resp, response_dict) + + if resp.status < 200 or resp.status >= 300: + raise ClientException.from_response(resp, 'Object COPY failed', body) + + def delete_object(url, token=None, container=None, name=None, http_conn=None, headers=None, proxy=None, query_string=None, response_dict=None, service_token=None): @@ -1711,6 +1775,13 @@ def post_object(self, container, obj, headers, response_dict=None): return self._retry(None, post_object, container, obj, headers, response_dict=response_dict) + def copy_object(self, container, obj, destination=None, headers=None, + fresh_metadata=None, response_dict=None): + """Wrapper for :func:`copy_object`""" + return self._retry(None, copy_object, container, obj, destination, + headers, fresh_metadata, + response_dict=response_dict) + def delete_object(self, container, obj, query_string=None, response_dict=None): """Wrapper for :func:`delete_object`""" diff --git a/swiftclient/service.py b/swiftclient/service.py index 12d3f210..a813d163 100644 --- a/swiftclient/service.py +++ b/swiftclient/service.py @@ -200,7 +200,9 @@ def _build_default_global_options(): 'human': False, 'dir_marker': False, 'checksum': True, - 'shuffle': False + 'shuffle': False, + 'destination': None, + 'fresh_metadata': False, } POLICY = 'X-Storage-Policy' @@ -330,6 +332,42 @@ def __init__(self, object_name, options=None): self.options = options +class SwiftCopyObject(object): + """ + Class for specifying an object copy, + allowing the destination/headers/metadata/fresh_metadata to be specified + separately for each individual object. + destination and fresh_metadata should be set in options + """ + def __init__(self, object_name, options=None): + if not isinstance(object_name, string_types) or not object_name: + raise SwiftError( + "Object names must be specified as non-empty strings" + ) + + self.object_name = object_name + self.options = options + + if self.options is None: + self.destination = None + self.fresh_metadata = False + else: + self.destination = self.options.get('destination') + self.fresh_metadata = self.options.get('fresh_metadata', False) + + if self.destination is not None: + destination_components = self.destination.split('/') + if destination_components[0] or len(destination_components) < 2: + raise SwiftError("destination must be in format /cont[/obj]") + if not destination_components[-1]: + raise SwiftError("destination must not end in a slash") + if len(destination_components) == 2: + # only container set in destination + self.destination = "{0}/{1}".format( + self.destination, object_name + ) + + class _SwiftReader(object): """ Class for downloading objects from swift and raising appropriate @@ -2389,6 +2427,191 @@ def _bulkdelete(conn, container, objects, options): return res + # Copy related methods + # + def copy(self, container, objects, options=None): + """ + Copy operations on a list of objects in a container. Destination + containers will be created. + + :param container: The container from which to copy the objects. + :param objects: A list of object names (strings) or SwiftCopyObject + instances containing an object name and an + options dict (can be None) to override the options for + that individual copy operation:: + + [ + 'object_name', + SwiftCopyObject( + 'object_name', + options={ + 'destination': '/container/object', + 'fresh_metadata': False, + ... + }), + ... + ] + + The options dict is described below. + :param options: A dictionary containing options to override the global + options specified during the service object creation. + These options are applied to all copy operations + performed by this call, unless overridden on a per + object basis. + The options "destination" and "fresh_metadata" do + not need to be set, in this case objects will be + copied onto themselves and metadata will not be + refreshed. + The option "destination" can also be specified in the + format '/container', in which case objects without an + explicit destination will be copied to the destination + /container/original_object_name. Combinations of + multiple objects and a destination in the format + '/container/object' is invalid. Possible options are + given below:: + + { + 'meta': [], + 'header': [], + 'destination': '/container/object', + 'fresh_metadata': False, + } + + :returns: A generator returning the results of copying the given list + of objects. + + :raises: SwiftError + """ + if options is not None: + options = dict(self._options, **options) + else: + options = self._options + + # Try to create the container, just in case it doesn't exist. If this + # fails, it might just be because the user doesn't have container PUT + # permissions, so we'll ignore any error. If there's really a problem, + # it'll surface on the first object COPY. + containers = set( + next(p for p in obj.destination.split("/") if p) + for obj in objects + if isinstance(obj, SwiftCopyObject) and obj.destination + ) + if options.get('destination'): + destination_split = options['destination'].split('/') + if destination_split[0]: + raise SwiftError("destination must be in format /cont[/obj]") + _str_objs = [ + o for o in objects if not isinstance(o, SwiftCopyObject) + ] + if len(destination_split) > 2 and len(_str_objs) > 1: + # TODO (clayg): could be useful to copy multiple objects into + # a destination like "/container/common/prefix/for/objects/" + # where the trailing "/" indicates the destination option is a + # prefix! + raise SwiftError("Combination of multiple objects and " + "destination including object is invalid") + if destination_split[-1] == '': + # N.B. this protects the above case + raise SwiftError("destination can not end in a slash") + containers.add(destination_split[1]) + + policy_header = {} + _header = split_headers(options["header"]) + if POLICY in _header: + policy_header[POLICY] = _header[POLICY] + create_containers = [ + self.thread_manager.container_pool.submit( + self._create_container_job, cont, headers=policy_header) + for cont in containers + ] + + # wait for container creation jobs to complete before any COPY + for r in interruptable_as_completed(create_containers): + res = r.result() + yield res + + copy_futures = [] + copy_objects = self._make_copy_objects(objects, options) + for copy_object in copy_objects: + obj = copy_object.object_name + obj_options = copy_object.options + destination = copy_object.destination + fresh_metadata = copy_object.fresh_metadata + headers = split_headers( + options['meta'], 'X-Object-Meta-') + # add header options to the headers object for the request. + headers.update( + split_headers(options['header'], '')) + if obj_options is not None: + if 'meta' in obj_options: + headers.update( + split_headers( + obj_options['meta'], 'X-Object-Meta-' + ) + ) + if 'header' in obj_options: + headers.update( + split_headers(obj_options['header'], '') + ) + + copy = self.thread_manager.object_uu_pool.submit( + self._copy_object_job, container, obj, destination, + headers, fresh_metadata + ) + copy_futures.append(copy) + + for r in interruptable_as_completed(copy_futures): + res = r.result() + yield res + + @staticmethod + def _make_copy_objects(objects, options): + copy_objects = [] + + for o in objects: + if isinstance(o, string_types): + obj = SwiftCopyObject(o, options) + copy_objects.append(obj) + elif isinstance(o, SwiftCopyObject): + copy_objects.append(o) + else: + raise SwiftError( + "The copy operation takes only strings or " + "SwiftCopyObjects as input", + obj=o) + + return copy_objects + + @staticmethod + def _copy_object_job(conn, container, obj, destination, headers, + fresh_metadata): + response_dict = {} + res = { + 'success': True, + 'action': 'copy_object', + 'container': container, + 'object': obj, + 'destination': destination, + 'headers': headers, + 'fresh_metadata': fresh_metadata, + 'response_dict': response_dict + } + try: + conn.copy_object( + container, obj, destination=destination, headers=headers, + fresh_metadata=fresh_metadata, response_dict=response_dict) + except Exception as err: + traceback, err_time = report_traceback() + logger.exception(err) + res.update({ + 'success': False, + 'error': err, + 'traceback': traceback, + 'error_timestamp': err_time + }) + + return res + # Capabilities related methods # def capabilities(self, url=None, refresh_cache=False): diff --git a/swiftclient/shell.py b/swiftclient/shell.py index 5eafe0b6..8da0e7ba 100755 --- a/swiftclient/shell.py +++ b/swiftclient/shell.py @@ -46,7 +46,7 @@ from pipes import quote as sh_quote BASENAME = 'swift' -commands = ('delete', 'download', 'list', 'post', 'stat', 'upload', +commands = ('delete', 'download', 'list', 'post', 'copy', 'stat', 'upload', 'capabilities', 'info', 'tempurl', 'auth') @@ -746,6 +746,105 @@ def st_post(parser, args, output_manager): output_manager.error(e.value) +st_copy_options = '''[--destination ] [--fresh-metadata] + [--meta ] [--header
] container object +''' + +st_copy_help = ''' +Copies object to new destination, optionally updates objects metadata. +If destination is not set, will update metadata of object + +Positional arguments: + container Name of container to copy from. + object Name of object to copy. Specify multiple times + for multiple objects + +Optional arguments: + -d, --destination + The container and name of the destination object. Name + of destination object can be ommited, then will be + same as name of source object. Supplying multiple + objects and destination with object name is invalid. + -M, --fresh-metadata Copy the object without any existing metadata, + If not set, metadata will be preserved or appended + -m, --meta + Sets a meta data item. This option may be repeated. + Example: -m Color:Blue -m Size:Large + -H, --header + Adds a customized request header. + This option may be repeated. Example + -H "content-type:text/plain" -H "Content-Length: 4000" +'''.strip('\n') + + +def st_copy(parser, args, output_manager): + parser.add_argument( + '-d', '--destination', help='The container and name of the ' + 'destination object') + parser.add_argument( + '-M', '--fresh-metadata', action='store_true', + help='Copy the object without any existing metadata', default=False) + parser.add_argument( + '-m', '--meta', action='append', dest='meta', default=[], + help='Sets a meta data item. This option may be repeated. ' + 'Example: -m Color:Blue -m Size:Large') + parser.add_argument( + '-H', '--header', action='append', dest='header', + default=[], help='Adds a customized request header. ' + 'This option may be repeated. ' + 'Example: -H "content-type:text/plain" ' + '-H "Content-Length: 4000"') + (options, args) = parse_args(parser, args) + args = args[1:] + + with SwiftService(options=options) as swift: + try: + if len(args) >= 2: + container = args[0] + if '/' in container: + output_manager.error( + 'WARNING: / in container name; you might have ' + "meant '%s' instead of '%s'." % + (args[0].replace('/', ' ', 1), args[0])) + return + objects = [arg for arg in args[1:]] + + for r in swift.copy( + container=container, objects=objects, + options=options): + if r['success']: + if options['verbose']: + if r['action'] == 'copy_object': + output_manager.print_msg( + '%s/%s copied to %s' % ( + r['container'], + r['object'], + r['destination'] or '')) + if r['action'] == 'create_container': + output_manager.print_msg( + 'created container %s' % r['container'] + ) + else: + error = r['error'] + if 'action' in r and r['action'] == 'create_container': + # it is not an error to be unable to create the + # container so print a warning and carry on + output_manager.warning( + 'Warning: failed to create container ' + "'%s': %s", container, error + ) + else: + output_manager.error("%s" % error) + else: + output_manager.error( + 'Usage: %s copy %s\n%s', BASENAME, + st_copy_options, st_copy_help) + return + + except SwiftError as e: + output_manager.error(e.value) + + st_upload_options = '''[--changed] [--skip-identical] [--segment-size ] [--segment-container ] [--leave-segments] [--object-threads ] [--segment-threads ] @@ -1268,6 +1367,7 @@ def main(arguments=None): for a container. post Updates meta information for the account, container, or object; creates containers if not present. + copy Copies object, optionally adds meta stat Displays information for the account, container, or object. upload Uploads files or directories to the given container. diff --git a/tests/functional/test_swiftclient.py b/tests/functional/test_swiftclient.py index 7a77c071..6e19abdb 100644 --- a/tests/functional/test_swiftclient.py +++ b/tests/functional/test_swiftclient.py @@ -405,6 +405,46 @@ def test_post_object(self): headers = self.conn.head_object(self.containername, self.objectname) self.assertEqual('Something', headers.get('x-object-meta-color')) + def test_copy_object(self): + self.conn.put_object( + self.containername, self.objectname, self.test_data) + self.conn.copy_object(self.containername, + self.objectname, + headers={'x-object-meta-color': 'Something'}) + + headers = self.conn.head_object(self.containername, self.objectname) + self.assertEqual('Something', headers.get('x-object-meta-color')) + + self.conn.copy_object(self.containername, + self.objectname, + headers={'x-object-meta-taste': 'Second'}) + + headers = self.conn.head_object(self.containername, self.objectname) + self.assertEqual('Something', headers.get('x-object-meta-color')) + self.assertEqual('Second', headers.get('x-object-meta-taste')) + + destination = "/%s/%s" % (self.containername, self.objectname_2) + self.conn.copy_object(self.containername, + self.objectname, + destination, + headers={'x-object-meta-taste': 'Second'}) + headers, data = self.conn.get_object(self.containername, + self.objectname_2) + self.assertEqual(self.test_data, data) + self.assertEqual('Something', headers.get('x-object-meta-color')) + self.assertEqual('Second', headers.get('x-object-meta-taste')) + + destination = "/%s/%s" % (self.containername, self.objectname_2) + self.conn.copy_object(self.containername, + self.objectname, + destination, + headers={'x-object-meta-color': 'Else'}, + fresh_metadata=True) + + headers = self.conn.head_object(self.containername, self.objectname_2) + self.assertEqual('Else', headers.get('x-object-meta-color')) + self.assertIsNone(headers.get('x-object-meta-taste')) + def test_get_capabilities(self): resp = self.conn.get_capabilities() self.assertTrue(resp.get('swift')) diff --git a/tests/unit/test_service.py b/tests/unit/test_service.py index cce8c7a9..5b04c44a 100644 --- a/tests/unit/test_service.py +++ b/tests/unit/test_service.py @@ -69,6 +69,42 @@ def test_create_with_invalid_name(self): self.assertRaises(SwiftError, self.spo, 1) +class TestSwiftCopyObject(unittest.TestCase): + + def setUp(self): + super(TestSwiftCopyObject, self).setUp() + self.sco = swiftclient.service.SwiftCopyObject + + def test_create(self): + sco = self.sco('obj_name') + + self.assertEqual(sco.object_name, 'obj_name') + self.assertIsNone(sco.destination) + self.assertFalse(sco.fresh_metadata) + + sco = self.sco('obj_name', + {'destination': '/dest', 'fresh_metadata': True}) + + self.assertEqual(sco.object_name, 'obj_name') + self.assertEqual(sco.destination, '/dest/obj_name') + self.assertTrue(sco.fresh_metadata) + + sco = self.sco('obj_name', + {'destination': '/dest/new_obj/a', + 'fresh_metadata': False}) + + self.assertEqual(sco.object_name, 'obj_name') + self.assertEqual(sco.destination, '/dest/new_obj/a') + self.assertFalse(sco.fresh_metadata) + + def test_create_with_invalid_name(self): + # empty strings are not allowed as names + self.assertRaises(SwiftError, self.sco, '') + + # names cannot be anything but strings + self.assertRaises(SwiftError, self.sco, 1) + + class TestSwiftReader(unittest.TestCase): def setUp(self): @@ -2344,3 +2380,73 @@ def test_object_post(self, res_iter, thread_manager): res_iter.assert_called_with( [tm_instance.object_uu_pool.submit()] * len(calls)) + + +class TestServiceCopy(_TestServiceBase): + + def setUp(self): + super(TestServiceCopy, self).setUp() + self.opts = swiftclient.service._default_local_options.copy() + + @mock.patch('swiftclient.service.MultiThreadingManager') + @mock.patch('swiftclient.service.interruptable_as_completed') + def test_object_copy(self, inter_compl, thread_manager): + """ + Check copy method translates strings and objects to _copy_object_job + calls correctly + """ + tm_instance = Mock() + thread_manager.return_value = tm_instance + + self.opts.update({'meta': ["meta1:test1"], "header": ["hdr1:test1"]}) + sco = swiftclient.service.SwiftCopyObject( + "test_sco", + options={'meta': ["meta1:test2"], "header": ["hdr1:test2"], + 'destination': "/cont_new/test_sco"}) + + res = SwiftService().copy('test_c', ['test_o', sco], self.opts) + res = list(res) + + calls = [ + mock.call( + SwiftService._create_container_job, 'cont_new', headers={}), + ] + tm_instance.container_pool.submit.assert_has_calls(calls, + any_order=True) + self.assertEqual( + tm_instance.container_pool.submit.call_count, len(calls)) + + calls = [ + mock.call( + SwiftService._copy_object_job, 'test_c', 'test_o', + None, + { + "X-Object-Meta-Meta1": "test1", + "Hdr1": "test1"}, + False), + mock.call( + SwiftService._copy_object_job, 'test_c', 'test_sco', + '/cont_new/test_sco', + { + "X-Object-Meta-Meta1": "test2", + "Hdr1": "test2"}, + False), + ] + tm_instance.object_uu_pool.submit.assert_has_calls(calls) + self.assertEqual( + tm_instance.object_uu_pool.submit.call_count, len(calls)) + + inter_compl.assert_called_with( + [tm_instance.object_uu_pool.submit()] * len(calls)) + + def test_object_copy_fail_dest(self): + """ + Destination in incorrect format and destination with object + used when multiple objects are copied raises SwiftError + """ + with self.assertRaises(SwiftError): + list(SwiftService().copy('test_c', ['test_o'], + {'destination': 'cont'})) + with self.assertRaises(SwiftError): + list(SwiftService().copy('test_c', ['test_o', 'test_o2'], + {'destination': '/cont/obj'})) diff --git a/tests/unit/test_shell.py b/tests/unit/test_shell.py index d82def62..407076b3 100644 --- a/tests/unit/test_shell.py +++ b/tests/unit/test_shell.py @@ -1244,6 +1244,139 @@ def test_post_object_too_many_args(self): self.assertTrue(output.err != '') self.assertTrue(output.err.startswith('Usage')) + @mock.patch('swiftclient.service.Connection') + def test_copy_object_no_destination(self, connection): + argv = ["", "copy", "container", "object", + "--meta", "Color:Blue", + "--header", "content-type:text/plain" + ] + with CaptureOutput() as output: + swiftclient.shell.main(argv) + connection.return_value.copy_object.assert_called_with( + 'container', 'object', destination=None, fresh_metadata=False, + headers={ + 'Content-Type': 'text/plain', + 'X-Object-Meta-Color': 'Blue'}, response_dict={}) + self.assertEqual(output.out, 'container/object copied to \n') + + @mock.patch('swiftclient.service.Connection') + def test_copy_object(self, connection): + argv = ["", "copy", "container", "object", + "--meta", "Color:Blue", + "--header", "content-type:text/plain", + "--destination", "/c/o" + ] + with CaptureOutput() as output: + swiftclient.shell.main(argv) + connection.return_value.copy_object.assert_called_with( + 'container', 'object', destination="/c/o", + fresh_metadata=False, + headers={ + 'Content-Type': 'text/plain', + 'X-Object-Meta-Color': 'Blue'}, response_dict={}) + self.assertEqual( + output.out, + 'created container c\ncontainer/object copied to /c/o\n' + ) + + @mock.patch('swiftclient.service.Connection') + def test_copy_object_fresh_metadata(self, connection): + argv = ["", "copy", "container", "object", + "--meta", "Color:Blue", "--fresh-metadata", + "--header", "content-type:text/plain", + "--destination", "/c/o" + ] + swiftclient.shell.main(argv) + connection.return_value.copy_object.assert_called_with( + 'container', 'object', destination="/c/o", fresh_metadata=True, + headers={ + 'Content-Type': 'text/plain', + 'X-Object-Meta-Color': 'Blue'}, response_dict={}) + + @mock.patch('swiftclient.service.Connection') + def test_copy_two_objects(self, connection): + argv = ["", "copy", "container", "object", "object2", + "--meta", "Color:Blue"] + connection.return_value.copy_object.return_value = None + swiftclient.shell.main(argv) + calls = [ + mock.call( + 'container', 'object', destination=None, + fresh_metadata=False, headers={'X-Object-Meta-Color': 'Blue'}, + response_dict={}), + mock.call( + 'container', 'object2', destination=None, + fresh_metadata=False, headers={'X-Object-Meta-Color': 'Blue'}, + response_dict={}) + ] + for call in calls: + self.assertIn(call, connection.return_value.copy_object.mock_calls) + self.assertEqual(len(connection.return_value.copy_object.mock_calls), + len(calls)) + + @mock.patch('swiftclient.service.Connection') + def test_copy_two_objects_destination(self, connection): + argv = ["", "copy", "container", "object", "object2", + "--meta", "Color:Blue", "--destination", "/c"] + swiftclient.shell.main(argv) + calls = [ + mock.call( + 'container', 'object', destination="/c/object", + fresh_metadata=False, headers={'X-Object-Meta-Color': 'Blue'}, + response_dict={}), + mock.call( + 'container', 'object2', destination="/c/object2", + fresh_metadata=False, headers={'X-Object-Meta-Color': 'Blue'}, + response_dict={}) + ] + connection.return_value.copy_object.assert_has_calls(calls) + + @mock.patch('swiftclient.service.Connection') + def test_copy_two_objects_bad_destination(self, connection): + argv = ["", "copy", "container", "object", "object2", + "--meta", "Color:Blue", "--destination", "/c/o"] + + with CaptureOutput() as output: + with self.assertRaises(SystemExit): + swiftclient.shell.main(argv) + + self.assertEqual( + output.err, + 'Combination of multiple objects and destination ' + 'including object is invalid\n') + + @mock.patch('swiftclient.service.Connection') + def test_copy_object_bad_auth(self, connection): + argv = ["", "copy", "container", "object"] + connection.return_value.copy_object.side_effect = \ + swiftclient.ClientException("bad auth") + + with CaptureOutput() as output: + with self.assertRaises(SystemExit): + swiftclient.shell.main(argv) + + self.assertEqual(output.err, 'bad auth\n') + + def test_copy_object_not_enough_args(self): + argv = ["", "copy", "container"] + + with CaptureOutput() as output: + with self.assertRaises(SystemExit): + swiftclient.shell.main(argv) + + self.assertTrue(output.err != '') + self.assertTrue(output.err.startswith('Usage')) + + def test_copy_bad_container(self): + argv = ["", "copy", "cont/ainer", "object"] + + with CaptureOutput() as output: + with self.assertRaises(SystemExit): + swiftclient.shell.main(argv) + + self.assertTrue(output.err != '') + self.assertTrue(output.err.startswith('WARN')) + @mock.patch('swiftclient.shell.generate_temp_url', return_value='') def test_temp_url(self, temp_url): argv = ["", "tempurl", "GET", "60", "/v1/AUTH_account/c/o", diff --git a/tests/unit/test_swiftclient.py b/tests/unit/test_swiftclient.py index cbb95dbe..cc6a4aca 100644 --- a/tests/unit/test_swiftclient.py +++ b/tests/unit/test_swiftclient.py @@ -1398,6 +1398,109 @@ def test_server_error(self): ]) +class TestCopyObject(MockHttpTest): + + def test_server_error(self): + c.http_connection = self.fake_http_connection(500) + self.assertRaises( + c.ClientException, c.copy_object, + 'http://www.test.com/v1/AUTH', 'asdf', 'asdf', 'asdf') + + def test_ok(self): + c.http_connection = self.fake_http_connection(200) + c.copy_object( + 'http://www.test.com/v1/AUTH', 'token', 'container', 'obj', + destination='/container2/obj') + self.assertRequests([ + ('COPY', 'http://www.test.com/v1/AUTH/container/obj', '', { + 'X-Auth-Token': 'token', + 'Destination': '/container2/obj', + }), + ]) + + def test_service_token(self): + c.http_connection = self.fake_http_connection(200) + c.copy_object('http://www.test.com/v1/AUTH', None, 'container', + 'obj', destination='/container2/obj', + service_token="TOKEN") + self.assertRequests([ + ('COPY', 'http://www.test.com/v1/AUTH/container/obj', '', { + 'X-Service-Token': 'TOKEN', + 'Destination': '/container2/obj', + + }), + ]) + + def test_headers(self): + c.http_connection = self.fake_http_connection(200) + c.copy_object( + 'http://www.test.com/v1/AUTH', 'token', 'container', 'obj', + destination='/container2/obj', + headers={'some-hdr': 'a', 'other-hdr': 'b'}) + self.assertRequests([ + ('COPY', 'http://www.test.com/v1/AUTH/container/obj', '', { + 'X-Auth-Token': 'token', + 'Destination': '/container2/obj', + 'some-hdr': 'a', + 'other-hdr': 'b', + }), + ]) + + def test_fresh_metadata_default(self): + c.http_connection = self.fake_http_connection(200) + c.copy_object( + 'http://www.test.com/v1/AUTH', 'token', 'container', 'obj', + '/container2/obj', {'x-fresh-metadata': 'hdr-value'}) + self.assertRequests([ + ('COPY', 'http://www.test.com/v1/AUTH/container/obj', '', { + 'X-Auth-Token': 'token', + 'Destination': '/container2/obj', + 'X-Fresh-Metadata': 'hdr-value', + }), + ]) + + def test_fresh_metadata_true(self): + c.http_connection = self.fake_http_connection(200) + c.copy_object( + 'http://www.test.com/v1/AUTH', 'token', 'container', 'obj', + destination='/container2/obj', + headers={'x-fresh-metadata': 'hdr-value'}, + fresh_metadata=True) + self.assertRequests([ + ('COPY', 'http://www.test.com/v1/AUTH/container/obj', '', { + 'X-Auth-Token': 'token', + 'Destination': '/container2/obj', + 'X-Fresh-Metadata': 'true', + }), + ]) + + def test_fresh_metadata_false(self): + c.http_connection = self.fake_http_connection(200) + c.copy_object( + 'http://www.test.com/v1/AUTH', 'token', 'container', 'obj', + destination='/container2/obj', + headers={'x-fresh-metadata': 'hdr-value'}, + fresh_metadata=False) + self.assertRequests([ + ('COPY', 'http://www.test.com/v1/AUTH/container/obj', '', { + 'x-auth-token': 'token', + 'Destination': '/container2/obj', + 'X-Fresh-Metadata': 'false', + }), + ]) + + def test_no_destination(self): + c.http_connection = self.fake_http_connection(200) + c.copy_object( + 'http://www.test.com/v1/AUTH', 'token', 'container', 'obj') + self.assertRequests([ + ('COPY', 'http://www.test.com/v1/AUTH/container/obj', '', { + 'x-auth-token': 'token', + 'Destination': '/container/obj', + }), + ]) + + class TestDeleteObject(MockHttpTest): def test_ok(self): @@ -2246,6 +2349,7 @@ class TestResponseDict(MockHttpTest): ('delete_container', 'c'), ('post_object', 'c', 'o', {}), ('put_object', 'c', 'o', 'body'), + ('copy_object', 'c', 'o'), ('delete_object', 'c', 'o')] def fake_get_auth(*args, **kwargs): From 304da900daccce76d9f54ecd661211674daca591 Mon Sep 17 00:00:00 2001 From: Tim Burke Date: Wed, 24 Aug 2016 17:27:52 -0700 Subject: [PATCH 010/316] Make functests py3-compatible Change-Id: I2b3bf17e874cf049eccab4c85ceac7da10d258ef --- tests/functional/test_swiftclient.py | 35 ++++++++++++++++------------ 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/tests/functional/test_swiftclient.py b/tests/functional/test_swiftclient.py index 6e19abdb..a31d4af4 100644 --- a/tests/functional/test_swiftclient.py +++ b/tests/functional/test_swiftclient.py @@ -110,15 +110,20 @@ def tearDown(self): pass def _check_account_headers(self, headers): - self.assertTrue(headers.get('content-length')) - self.assertTrue(headers.get('x-account-object-count')) - self.assertTrue(headers.get('x-timestamp')) - self.assertTrue(headers.get('x-trans-id')) - self.assertTrue(headers.get('date')) - self.assertTrue(headers.get('x-account-bytes-used')) - self.assertTrue(headers.get('x-account-container-count')) - self.assertTrue(headers.get('content-type')) - self.assertTrue(headers.get('accept-ranges')) + headers_to_check = [ + 'content-length', + 'x-account-object-count', + 'x-timestamp', + 'x-trans-id', + 'date', + 'x-account-bytes-used', + 'x-account-container-count', + 'content-type', + 'accept-ranges', + ] + for h in headers_to_check: + self.assertIn(h, headers) + self.assertTrue(headers[h]) def test_stat_account(self): headers = self.conn.head_account() @@ -322,16 +327,16 @@ def test_put_object_using_generator(self): # verify that put using a generator yielding empty strings does not # cause connection to be closed def data(): - yield "should" - yield "" - yield " tolerate" - yield "" - yield " empty chunks" + yield b"should" + yield b"" + yield b" tolerate" + yield b"" + yield b" empty chunks" self.conn.put_object( self.containername, self.objectname, data()) hdrs, body = self.conn.get_object(self.containername, self.objectname) - self.assertEqual("should tolerate empty chunks", body) + self.assertEqual(b"should tolerate empty chunks", body) def test_download_object_retry_chunked(self): resp_chunk_size = 2 From fd675b2c5a9453efac1fadadb17f40ef5fb908d2 Mon Sep 17 00:00:00 2001 From: Jamie Lennox Date: Wed, 24 Aug 2016 23:10:59 +1000 Subject: [PATCH 011/316] Use mock patch to handle get_auth_keystone Setting c.get_auth_keystone = fake_get_auth_keystone is setting a new method on the module. This information is not reset between test runs and therefore has the potential to corrupt other tests. Use mock patch so that the mock is reset after the test is complete. Change-Id: Ifb9ba72634cf477c72dda080b5aed8f8e3a7ac89 --- tests/unit/test_swiftclient.py | 277 +++++++++++++++++---------------- 1 file changed, 144 insertions(+), 133 deletions(-) diff --git a/tests/unit/test_swiftclient.py b/tests/unit/test_swiftclient.py index 4e4c9f47..6775acf6 100644 --- a/tests/unit/test_swiftclient.py +++ b/tests/unit/test_swiftclient.py @@ -335,79 +335,84 @@ def test_auth_v2_timeout(self): def test_auth_v2_with_tenant_name(self): os_options = {'tenant_name': 'asdf'} req_args = {'auth_version': '2.0'} - c.get_auth_keystone = fake_get_auth_keystone(os_options, - required_kwargs=req_args) - url, token = c.get_auth('http://www.test.com', 'asdf', 'asdf', - os_options=os_options, - auth_version="2.0") + ks = fake_get_auth_keystone(os_options, required_kwargs=req_args) + with mock.patch('swiftclient.client.get_auth_keystone', ks): + url, token = c.get_auth('http://www.test.com', 'asdf', 'asdf', + os_options=os_options, + auth_version="2.0") self.assertTrue(url.startswith("http")) self.assertTrue(token) def test_auth_v2_with_tenant_id(self): os_options = {'tenant_id': 'asdf'} req_args = {'auth_version': '2.0'} - c.get_auth_keystone = fake_get_auth_keystone(os_options, - required_kwargs=req_args) - url, token = c.get_auth('http://www.test.com', 'asdf', 'asdf', - os_options=os_options, - auth_version="2.0") + ks = fake_get_auth_keystone(os_options, required_kwargs=req_args) + with mock.patch('swiftclient.client.get_auth_keystone', ks): + url, token = c.get_auth('http://www.test.com', 'asdf', 'asdf', + os_options=os_options, + auth_version="2.0") self.assertTrue(url.startswith("http")) self.assertTrue(token) def test_auth_v2_with_project_name(self): os_options = {'project_name': 'asdf'} req_args = {'auth_version': '2.0'} - c.get_auth_keystone = fake_get_auth_keystone(os_options, - required_kwargs=req_args) - url, token = c.get_auth('http://www.test.com', 'asdf', 'asdf', - os_options=os_options, - auth_version="2.0") + ks = fake_get_auth_keystone(os_options, required_kwargs=req_args) + with mock.patch('swiftclient.client.get_auth_keystone', ks): + url, token = c.get_auth('http://www.test.com', 'asdf', 'asdf', + os_options=os_options, + auth_version="2.0") self.assertTrue(url.startswith("http")) self.assertTrue(token) def test_auth_v2_with_project_id(self): os_options = {'project_id': 'asdf'} req_args = {'auth_version': '2.0'} - c.get_auth_keystone = fake_get_auth_keystone(os_options, - required_kwargs=req_args) - url, token = c.get_auth('http://www.test.com', 'asdf', 'asdf', - os_options=os_options, - auth_version="2.0") + + ks = fake_get_auth_keystone(os_options, required_kwargs=req_args) + with mock.patch('swiftclient.client.get_auth_keystone', ks): + url, token = c.get_auth('http://www.test.com', 'asdf', 'asdf', + os_options=os_options, + auth_version="2.0") self.assertTrue(url.startswith("http")) self.assertTrue(token) def test_auth_v2_no_tenant_name_or_tenant_id(self): - c.get_auth_keystone = fake_get_auth_keystone({}) - self.assertRaises(c.ClientException, c.get_auth, - 'http://www.tests.com', 'asdf', 'asdf', - os_options={}, - auth_version='2.0') + with mock.patch('swiftclient.client.get_auth_keystone', + fake_get_auth_keystone({})): + self.assertRaises(c.ClientException, c.get_auth, + 'http://www.tests.com', 'asdf', 'asdf', + os_options={}, + auth_version='2.0') def test_auth_v2_with_tenant_name_none_and_tenant_id_none(self): os_options = {'tenant_name': None, 'tenant_id': None} - c.get_auth_keystone = fake_get_auth_keystone(os_options) - self.assertRaises(c.ClientException, c.get_auth, - 'http://www.tests.com', 'asdf', 'asdf', - os_options=os_options, - auth_version='2.0') + with mock.patch('swiftclient.client.get_auth_keystone', + fake_get_auth_keystone(os_options)): + self.assertRaises(c.ClientException, c.get_auth, + 'http://www.tests.com', 'asdf', 'asdf', + os_options=os_options, + auth_version='2.0') def test_auth_v2_with_tenant_user_in_user(self): tenant_option = {'tenant_name': 'foo'} - c.get_auth_keystone = fake_get_auth_keystone(tenant_option) - url, token = c.get_auth('http://www.test.com', 'foo:bar', 'asdf', - os_options={}, - auth_version="2.0") + with mock.patch('swiftclient.client.get_auth_keystone', + fake_get_auth_keystone(tenant_option)): + url, token = c.get_auth('http://www.test.com', 'foo:bar', 'asdf', + os_options={}, + auth_version="2.0") self.assertTrue(url.startswith("http")) self.assertTrue(token) def test_auth_v2_tenant_name_no_os_options(self): tenant_option = {'tenant_name': 'asdf'} - c.get_auth_keystone = fake_get_auth_keystone(tenant_option) - url, token = c.get_auth('http://www.test.com', 'asdf', 'asdf', - tenant_name='asdf', - os_options={}, - auth_version="2.0") + with mock.patch('swiftclient.client.get_auth_keystone', + fake_get_auth_keystone(tenant_option)): + url, token = c.get_auth('http://www.test.com', 'asdf', 'asdf', + tenant_name='asdf', + os_options={}, + auth_version="2.0") self.assertTrue(url.startswith("http")) self.assertTrue(token) @@ -415,138 +420,141 @@ def test_auth_v2_with_os_options(self): os_options = {'service_type': 'object-store', 'endpoint_type': 'internalURL', 'tenant_name': 'asdf'} - c.get_auth_keystone = fake_get_auth_keystone(os_options) - url, token = c.get_auth('http://www.test.com', 'asdf', 'asdf', - os_options=os_options, - auth_version="2.0") + with mock.patch('swiftclient.client.get_auth_keystone', + fake_get_auth_keystone(os_options)): + url, token = c.get_auth('http://www.test.com', 'asdf', 'asdf', + os_options=os_options, + auth_version="2.0") self.assertTrue(url.startswith("http")) self.assertTrue(token) def test_auth_v2_with_tenant_user_in_user_no_os_options(self): tenant_option = {'tenant_name': 'foo'} - c.get_auth_keystone = fake_get_auth_keystone(tenant_option) - url, token = c.get_auth('http://www.test.com', 'foo:bar', 'asdf', - auth_version="2.0") + with mock.patch('swiftclient.client.get_auth_keystone', + fake_get_auth_keystone(tenant_option)): + url, token = c.get_auth('http://www.test.com', 'foo:bar', 'asdf', + auth_version="2.0") self.assertTrue(url.startswith("http")) self.assertTrue(token) def test_auth_v2_with_os_region_name(self): os_options = {'region_name': 'good-region', 'tenant_name': 'asdf'} - c.get_auth_keystone = fake_get_auth_keystone(os_options) - url, token = c.get_auth('http://www.test.com', 'asdf', 'asdf', - os_options=os_options, - auth_version="2.0") + with mock.patch('swiftclient.client.get_auth_keystone', + fake_get_auth_keystone(os_options)): + url, token = c.get_auth('http://www.test.com', 'asdf', 'asdf', + os_options=os_options, + auth_version="2.0") self.assertTrue(url.startswith("http")) self.assertTrue(token) def test_auth_v2_no_endpoint(self): os_options = {'region_name': 'unknown_region', 'tenant_name': 'asdf'} - c.get_auth_keystone = fake_get_auth_keystone( - os_options, c.ClientException) - self.assertRaises(c.ClientException, c.get_auth, - 'http://www.tests.com', 'asdf', 'asdf', - os_options=os_options, auth_version='2.0') + with mock.patch('swiftclient.client.get_auth_keystone', + fake_get_auth_keystone(os_options, c.ClientException)): + self.assertRaises(c.ClientException, c.get_auth, + 'http://www.tests.com', 'asdf', 'asdf', + os_options=os_options, auth_version='2.0') def test_auth_v2_ks_exception(self): - c.get_auth_keystone = fake_get_auth_keystone( - {}, c.ClientException) - self.assertRaises(c.ClientException, c.get_auth, - 'http://www.tests.com', 'asdf', 'asdf', - os_options={}, - auth_version='2.0') + with mock.patch('swiftclient.client.get_auth_keystone', + fake_get_auth_keystone({}, c.ClientException)): + self.assertRaises(c.ClientException, c.get_auth, + 'http://www.tests.com', 'asdf', 'asdf', + os_options={}, + auth_version='2.0') def test_auth_v2_cacert(self): os_options = {'tenant_name': 'foo'} - c.get_auth_keystone = fake_get_auth_keystone( - os_options, None) - auth_url_secure = 'https://www.tests.com' auth_url_insecure = 'https://www.tests.com/self-signed-certificate' - url, token = c.get_auth(auth_url_secure, 'asdf', 'asdf', - os_options=os_options, auth_version='2.0', - insecure=False) - self.assertTrue(url.startswith("http")) - self.assertTrue(token) - - url, token = c.get_auth(auth_url_insecure, 'asdf', 'asdf', - os_options=os_options, auth_version='2.0', - cacert='ca.pem', insecure=False) - self.assertTrue(url.startswith("http")) - self.assertTrue(token) - - self.assertRaises(c.ClientException, c.get_auth, - auth_url_insecure, 'asdf', 'asdf', - os_options=os_options, auth_version='2.0') - self.assertRaises(c.ClientException, c.get_auth, - auth_url_insecure, 'asdf', 'asdf', - os_options=os_options, auth_version='2.0', - insecure=False) + with mock.patch('swiftclient.client.get_auth_keystone', + fake_get_auth_keystone(os_options, None)): + url, token = c.get_auth(auth_url_secure, 'asdf', 'asdf', + os_options=os_options, auth_version='2.0', + insecure=False) + self.assertTrue(url.startswith("http")) + self.assertTrue(token) + + url, token = c.get_auth(auth_url_insecure, 'asdf', 'asdf', + os_options=os_options, auth_version='2.0', + cacert='ca.pem', insecure=False) + self.assertTrue(url.startswith("http")) + self.assertTrue(token) + + self.assertRaises(c.ClientException, c.get_auth, + auth_url_insecure, 'asdf', 'asdf', + os_options=os_options, auth_version='2.0') + self.assertRaises(c.ClientException, c.get_auth, + auth_url_insecure, 'asdf', 'asdf', + os_options=os_options, auth_version='2.0', + insecure=False) def test_auth_v2_insecure(self): os_options = {'tenant_name': 'foo'} - c.get_auth_keystone = fake_get_auth_keystone( - os_options, None) - auth_url_secure = 'https://www.tests.com' auth_url_insecure = 'https://www.tests.com/invalid-certificate' - url, token = c.get_auth(auth_url_secure, 'asdf', 'asdf', - os_options=os_options, auth_version='2.0') - self.assertTrue(url.startswith("http")) - self.assertTrue(token) - - url, token = c.get_auth(auth_url_insecure, 'asdf', 'asdf', - os_options=os_options, auth_version='2.0', - insecure=True) - self.assertTrue(url.startswith("http")) - self.assertTrue(token) - - self.assertRaises(c.ClientException, c.get_auth, - auth_url_insecure, 'asdf', 'asdf', - os_options=os_options, auth_version='2.0') - self.assertRaises(c.ClientException, c.get_auth, - auth_url_insecure, 'asdf', 'asdf', - os_options=os_options, auth_version='2.0', - insecure=False) + with mock.patch('swiftclient.client.get_auth_keystone', + fake_get_auth_keystone(os_options, None)): + url, token = c.get_auth(auth_url_secure, 'asdf', 'asdf', + os_options=os_options, auth_version='2.0') + self.assertTrue(url.startswith("http")) + self.assertTrue(token) + + url, token = c.get_auth(auth_url_insecure, 'asdf', 'asdf', + os_options=os_options, auth_version='2.0', + insecure=True) + self.assertTrue(url.startswith("http")) + self.assertTrue(token) + + self.assertRaises(c.ClientException, c.get_auth, + auth_url_insecure, 'asdf', 'asdf', + os_options=os_options, auth_version='2.0') + self.assertRaises(c.ClientException, c.get_auth, + auth_url_insecure, 'asdf', 'asdf', + os_options=os_options, auth_version='2.0', + insecure=False) def test_auth_v2_cert(self): os_options = {'tenant_name': 'foo'} - c.get_auth_keystone = fake_get_auth_keystone(os_options, None) - auth_url_no_sslauth = 'https://www.tests.com' auth_url_sslauth = 'https://www.tests.com/client-certificate' - url, token = c.get_auth(auth_url_no_sslauth, 'asdf', 'asdf', - os_options=os_options, auth_version='2.0') - self.assertTrue(url.startswith("http")) - self.assertTrue(token) - - url, token = c.get_auth(auth_url_sslauth, 'asdf', 'asdf', - os_options=os_options, auth_version='2.0', - cert='minnie', cert_key='mickey') - self.assertTrue(url.startswith("http")) - self.assertTrue(token) - - self.assertRaises(c.ClientException, c.get_auth, - auth_url_sslauth, 'asdf', 'asdf', - os_options=os_options, auth_version='2.0') - self.assertRaises(c.ClientException, c.get_auth, - auth_url_sslauth, 'asdf', 'asdf', - os_options=os_options, auth_version='2.0', - cert='minnie') + with mock.patch('swiftclient.client.get_auth_keystone', + fake_get_auth_keystone(os_options, None)): + url, token = c.get_auth(auth_url_no_sslauth, 'asdf', 'asdf', + os_options=os_options, auth_version='2.0') + self.assertTrue(url.startswith("http")) + self.assertTrue(token) + + url, token = c.get_auth(auth_url_sslauth, 'asdf', 'asdf', + os_options=os_options, auth_version='2.0', + cert='minnie', cert_key='mickey') + self.assertTrue(url.startswith("http")) + self.assertTrue(token) + + self.assertRaises(c.ClientException, c.get_auth, + auth_url_sslauth, 'asdf', 'asdf', + os_options=os_options, auth_version='2.0') + self.assertRaises(c.ClientException, c.get_auth, + auth_url_sslauth, 'asdf', 'asdf', + os_options=os_options, auth_version='2.0', + cert='minnie') def test_auth_v3_with_tenant_name(self): # check the correct auth version is passed to get_auth_keystone os_options = {'tenant_name': 'asdf'} req_args = {'auth_version': '3'} - c.get_auth_keystone = fake_get_auth_keystone(os_options, - required_kwargs=req_args) - url, token = c.get_auth('http://www.test.com', 'asdf', 'asdf', - os_options=os_options, - auth_version="3") + + ks = fake_get_auth_keystone(os_options, required_kwargs=req_args) + with mock.patch('swiftclient.client.get_auth_keystone', ks): + url, token = c.get_auth('http://www.test.com', 'asdf', 'asdf', + os_options=os_options, + auth_version="3") + self.assertTrue(url.startswith("http")) self.assertTrue(token) @@ -554,10 +562,13 @@ def test_get_keystone_client_2_0(self): # check the correct auth version is passed to get_auth_keystone os_options = {'tenant_name': 'asdf'} req_args = {'auth_version': '2.0'} - c.get_auth_keystone = fake_get_auth_keystone(os_options, - required_kwargs=req_args) - url, token = c.get_keystoneclient_2_0('http://www.test.com', 'asdf', - 'asdf', os_options=os_options) + + ks = fake_get_auth_keystone(os_options, required_kwargs=req_args) + with mock.patch('swiftclient.client.get_auth_keystone', ks): + url, token = c.get_keystoneclient_2_0('http://www.test.com', + 'asdf', 'asdf', + os_options=os_options) + self.assertTrue(url.startswith("http")) self.assertTrue(token) From daed43a44ae77a58ae52033d95f23a0bdb4d6c49 Mon Sep 17 00:00:00 2001 From: Joel Wright Date: Sat, 4 Jun 2016 11:23:02 +0100 Subject: [PATCH 012/316] Fix examples and missing code-block This patch fixes a missing code-block section in the capabilities section of service-api.rst, and fixes the import of walk in the upload.py examples to support both python2 and python3. Change-Id: I572769f971f84e0029f2948e42c130e73517f434 --- doc/source/service-api.rst | 2 ++ examples/upload.py | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/doc/source/service-api.rst b/doc/source/service-api.rst index 50dd80e8..4da68a37 100644 --- a/doc/source/service-api.rst +++ b/doc/source/service-api.rst @@ -756,6 +756,8 @@ the method docstring. For each successful call to list capabilities, a result dictionary will be returned with the contents described below: +.. code-block:: python + { 'action': 'capabilities', 'timestamp':