Use bulk-delete middleware when available
When issuing `delete` commands that would require three or more
individual deletes, check whether the cluster supports bulk deletes
and use that if it's available.
Additionally, a new option is added to the `delete` command:
  * --prefix <prefix>
    Delete all objects that start with <prefix>. This is similar to the
    --prefix option for the `list` command.
Example:
$ swift delete c --prefix obj_prefix/
    ...will delete from container "c" all objects whose name begins with
    "obj_prefix/", such as "obj_prefix/foo" and "obj_prefix/bar".
Change-Id: I6b9504848d6ef562cf4f570bbcd17db4e3da8264
			
			
This commit is contained in:
		| @@ -93,6 +93,7 @@ You can specify optional headers with the repeatable cURL-like option | ||||
| \fBdelete\fR [\fIcommand-options\fR] [\fIcontainer\fR] [\fIobject\fR] [\fIobject\fR] [...] | ||||
| .RS 4 | ||||
| Deletes everything in the account (with \-\-all), or everything in a container, | ||||
| or all objects in a container that start with a given string (given by \-\-prefix), | ||||
| or a list of objects depending on the args given. Segments of manifest objects | ||||
| will be deleted as well, unless you specify the \-\-leave\-segments option. | ||||
| For more details and options see swift delete \-\-help. | ||||
|   | ||||
| @@ -686,7 +686,7 @@ def head_account(url, token, http_conn=None, service_token=None): | ||||
|  | ||||
|  | ||||
| def post_account(url, token, headers, http_conn=None, response_dict=None, | ||||
|                  service_token=None): | ||||
|                  service_token=None, query_string=None, data=None): | ||||
|     """ | ||||
|     Update an account's metadata. | ||||
|  | ||||
| @@ -698,17 +698,23 @@ def post_account(url, token, headers, http_conn=None, response_dict=None, | ||||
|     :param response_dict: an optional dictionary into which to place | ||||
|                      the response - status, reason and headers | ||||
|     :param service_token: service auth token | ||||
|     :param query_string: if set will be appended with '?' to generated path | ||||
|     :param data: an optional message body for the request | ||||
|     :raises ClientException: HTTP POST request failed | ||||
|     :returns: resp_headers, body | ||||
|     """ | ||||
|     if http_conn: | ||||
|         parsed, conn = http_conn | ||||
|     else: | ||||
|         parsed, conn = http_connection(url) | ||||
|     method = 'POST' | ||||
|     path = parsed.path | ||||
|     if query_string: | ||||
|         path += '?' + query_string | ||||
|     headers['X-Auth-Token'] = token | ||||
|     if service_token: | ||||
|         headers['X-Service-Token'] = service_token | ||||
|     conn.request(method, parsed.path, '', headers) | ||||
|     conn.request(method, path, data, headers) | ||||
|     resp = conn.getresponse() | ||||
|     body = resp.read() | ||||
|     http_log((url, method,), {'headers': headers}, resp, body) | ||||
| @@ -723,6 +729,10 @@ def post_account(url, token, headers, http_conn=None, response_dict=None, | ||||
|                               http_status=resp.status, | ||||
|                               http_reason=resp.reason, | ||||
|                               http_response_content=body) | ||||
|     resp_headers = {} | ||||
|     for header, value in resp.getheaders(): | ||||
|         resp_headers[header.lower()] = value | ||||
|     return resp_headers, body | ||||
|  | ||||
|  | ||||
| def get_container(url, token, container, marker=None, limit=None, | ||||
| @@ -1541,9 +1551,11 @@ class Connection(object): | ||||
|                            prefix=prefix, end_marker=end_marker, | ||||
|                            full_listing=full_listing) | ||||
|  | ||||
|     def post_account(self, headers, response_dict=None): | ||||
|     def post_account(self, headers, response_dict=None, | ||||
|                      query_string=None, data=None): | ||||
|         """Wrapper for :func:`post_account`""" | ||||
|         return self._retry(None, post_account, headers, | ||||
|                            query_string=query_string, data=data, | ||||
|                            response_dict=response_dict) | ||||
|  | ||||
|     def head_container(self, container, headers=None): | ||||
|   | ||||
| @@ -12,7 +12,9 @@ | ||||
| # implied. | ||||
| # See the License for the specific language governing permissions and | ||||
| # limitations under the License. | ||||
| from __future__ import unicode_literals | ||||
| import logging | ||||
|  | ||||
| import os | ||||
|  | ||||
| from concurrent.futures import as_completed, CancelledError, TimeoutError | ||||
| @@ -41,7 +43,7 @@ from swiftclient.command_helpers import ( | ||||
| ) | ||||
| from swiftclient.utils import ( | ||||
|     config_true_value, ReadableToIterable, LengthWrapper, EMPTY_ETAG, | ||||
|     parse_api_response, report_traceback | ||||
|     parse_api_response, report_traceback, n_groups | ||||
| ) | ||||
| from swiftclient.exceptions import ClientException | ||||
| from swiftclient.multithreading import MultiThreadingManager | ||||
| @@ -380,6 +382,7 @@ class SwiftService(object): | ||||
|             object_uu_threads=self._options['object_uu_threads'], | ||||
|             container_threads=self._options['container_threads'] | ||||
|         ) | ||||
|         self.capabilities_cache = {}  # Each instance should have its own cache | ||||
|  | ||||
|     def __enter__(self): | ||||
|         self.thread_manager.__enter__() | ||||
| @@ -2040,13 +2043,14 @@ class SwiftService(object): | ||||
|                             { | ||||
|                                 'yes_all': False, | ||||
|                                 'leave_segments': False, | ||||
|                                 'prefix': None, | ||||
|                             } | ||||
|  | ||||
|         :returns: A generator for returning the results of the delete | ||||
|                   operations. Each result yielded from the generator is either | ||||
|                   a 'delete_container', 'delete_object' or 'delete_segment' | ||||
|                   dictionary containing the results of an individual delete | ||||
|                   operation. | ||||
|                   a 'delete_container', 'delete_object', 'delete_segment', or | ||||
|                   'bulk_delete' dictionary containing the results of an | ||||
|                   individual delete operation. | ||||
|  | ||||
|         :raises: ClientException | ||||
|         :raises: SwiftError | ||||
| @@ -2056,19 +2060,24 @@ class SwiftService(object): | ||||
|         else: | ||||
|             options = self._options | ||||
|  | ||||
|         rq = Queue() | ||||
|         if container is not None: | ||||
|             if objects is not None: | ||||
|                 if options['prefix']: | ||||
|                     objects = [obj for obj in objects | ||||
|                                if obj.startswith(options['prefix'])] | ||||
|                 rq = Queue() | ||||
|                 obj_dels = {} | ||||
|                 for obj in objects: | ||||
|                     obj_del = self.thread_manager.object_dd_pool.submit( | ||||
|                         self._delete_object, container, obj, options, | ||||
|                         results_queue=rq | ||||
|                     ) | ||||
|                     obj_details = {'container': container, 'object': obj} | ||||
|                     obj_dels[obj_del] = obj_details | ||||
|  | ||||
|                 # Start a thread to watch for upload results | ||||
|                 if self._should_bulk_delete(objects): | ||||
|                     for obj_slice in n_groups( | ||||
|                             objects, self._options['object_dd_threads']): | ||||
|                         self._bulk_delete(container, obj_slice, options, | ||||
|                                           obj_dels) | ||||
|                 else: | ||||
|                     self._per_item_delete(container, objects, options, | ||||
|                                           obj_dels, rq) | ||||
|  | ||||
|                 # Start a thread to watch for delete results | ||||
|                 Thread( | ||||
|                     target=self._watch_futures, args=(obj_dels, rq) | ||||
|                 ).start() | ||||
| @@ -2091,6 +2100,8 @@ class SwiftService(object): | ||||
|         else: | ||||
|             if objects: | ||||
|                 raise SwiftError('Objects specified without container') | ||||
|             if options['prefix']: | ||||
|                 raise SwiftError('Prefix specified without container') | ||||
|             if options['yes_all']: | ||||
|                 cancelled = False | ||||
|                 containers = [] | ||||
| @@ -2114,6 +2125,33 @@ class SwiftService(object): | ||||
|                                     and not res['success']): | ||||
|                                 cancelled = True | ||||
|  | ||||
|     def _should_bulk_delete(self, objects): | ||||
|         if len(objects) < 2 * self._options['object_dd_threads']: | ||||
|             # Not many objects; may as well delete one-by-one | ||||
|             return False | ||||
|  | ||||
|         try: | ||||
|             cap_result = self.capabilities() | ||||
|             if not cap_result['success']: | ||||
|                 # This shouldn't actually happen, but just in case we start | ||||
|                 # being more nuanced about our capabilities result... | ||||
|                 return False | ||||
|         except ClientException: | ||||
|             # Old swift, presumably; assume no bulk middleware | ||||
|             return False | ||||
|  | ||||
|         swift_info = cap_result['capabilities'] | ||||
|         return 'bulk_delete' in swift_info | ||||
|  | ||||
|     def _per_item_delete(self, container, objects, options, rdict, rq): | ||||
|         for obj in objects: | ||||
|             obj_del = self.thread_manager.object_dd_pool.submit( | ||||
|                 self._delete_object, container, obj, options, | ||||
|                 results_queue=rq | ||||
|             ) | ||||
|             obj_details = {'container': container, 'object': obj} | ||||
|             rdict[obj_del] = obj_details | ||||
|  | ||||
|     @staticmethod | ||||
|     def _delete_segment(conn, container, obj, results_queue=None): | ||||
|         results_dict = {} | ||||
| @@ -2242,18 +2280,20 @@ class SwiftService(object): | ||||
|  | ||||
|     def _delete_container(self, container, options): | ||||
|         try: | ||||
|             for part in self.list(container=container): | ||||
|                 if part["success"]: | ||||
|                     objs = [o['name'] for o in part['listing']] | ||||
|             for part in self.list(container=container, options=options): | ||||
|                 if not part["success"]: | ||||
|  | ||||
|                     o_dels = self.delete( | ||||
|                         container=container, objects=objs, options=options | ||||
|                     ) | ||||
|                     for res in o_dels: | ||||
|                         yield res | ||||
|                 else: | ||||
|                     raise part["error"] | ||||
|  | ||||
|                 for res in self.delete( | ||||
|                         container=container, | ||||
|                         objects=[o['name'] for o in part['listing']], | ||||
|                         options=options): | ||||
|                     yield res | ||||
|             if options['prefix']: | ||||
|                 # We're only deleting a subset of objects within the container | ||||
|                 return | ||||
|  | ||||
|             con_del = self.thread_manager.container_pool.submit( | ||||
|                 self._delete_empty_container, container | ||||
|             ) | ||||
| @@ -2274,9 +2314,55 @@ class SwiftService(object): | ||||
|  | ||||
|         yield con_del_res | ||||
|  | ||||
|     # Bulk methods | ||||
|     # | ||||
|     def _bulk_delete(self, container, objects, options, rdict): | ||||
|         if objects: | ||||
|             bulk_del = self.thread_manager.object_dd_pool.submit( | ||||
|                 self._bulkdelete, container, objects, options | ||||
|             ) | ||||
|             bulk_details = {'container': container, 'objects': objects} | ||||
|             rdict[bulk_del] = bulk_details | ||||
|  | ||||
|     @staticmethod | ||||
|     def _bulkdelete(conn, container, objects, options): | ||||
|         results_dict = {} | ||||
|         try: | ||||
|             headers = { | ||||
|                 'Accept': 'application/json', | ||||
|                 'Content-Type': 'text/plain', | ||||
|             } | ||||
|             res = {'container': container, 'objects': objects} | ||||
|             objects = [quote(('/%s/%s' % (container, obj)).encode('utf-8')) | ||||
|                        for obj in objects] | ||||
|             headers, body = conn.post_account( | ||||
|                 headers=headers, | ||||
|                 query_string='bulk-delete', | ||||
|                 data=b''.join(obj.encode('utf-8') + b'\n' for obj in objects), | ||||
|                 response_dict=results_dict) | ||||
|             if body: | ||||
|                 res.update({'success': True, | ||||
|                             'result': parse_api_response(headers, body)}) | ||||
|             else: | ||||
|                 res.update({ | ||||
|                     'success': False, | ||||
|                     'error': SwiftError( | ||||
|                         'No content received on account POST. ' | ||||
|                         'Is the bulk operations middleware enabled?')}) | ||||
|         except Exception as e: | ||||
|             res.update({'success': False, 'error': e}) | ||||
|  | ||||
|         res.update({ | ||||
|             'action': 'bulk_delete', | ||||
|             'attempts': conn.attempts, | ||||
|             'response_dict': results_dict | ||||
|         }) | ||||
|  | ||||
|         return res | ||||
|  | ||||
|     # Capabilities related methods | ||||
|     # | ||||
|     def capabilities(self, url=None): | ||||
|     def capabilities(self, url=None, refresh_cache=False): | ||||
|         """ | ||||
|         List the cluster capabilities. | ||||
|  | ||||
| @@ -2285,13 +2371,15 @@ class SwiftService(object): | ||||
|         :returns: A dictionary containing the capabilities of the cluster. | ||||
|  | ||||
|         :raises: ClientException | ||||
|         :raises: SwiftError | ||||
|         """ | ||||
|         if not refresh_cache and url in self.capabilities_cache: | ||||
|             return self.capabilities_cache[url] | ||||
|  | ||||
|         res = { | ||||
|             'action': 'capabilities' | ||||
|             'action': 'capabilities', | ||||
|             'timestamp': time(), | ||||
|         } | ||||
|  | ||||
|         try: | ||||
|         cap = self.thread_manager.container_pool.submit( | ||||
|             self._get_capabilities, url | ||||
|         ) | ||||
| @@ -2304,11 +2392,8 @@ class SwiftService(object): | ||||
|             res.update({ | ||||
|                 'url': url | ||||
|             }) | ||||
|         except ClientException as err: | ||||
|             if err.http_status != 404: | ||||
|                 raise err | ||||
|             raise SwiftError('Account not found', exc=err) | ||||
|  | ||||
|         self.capabilities_cache[url] = res | ||||
|         return res | ||||
|  | ||||
|     @staticmethod | ||||
|   | ||||
| @@ -23,7 +23,8 @@ import socket | ||||
| from optparse import OptionParser, OptionGroup, SUPPRESS_HELP | ||||
| from os import environ, walk, _exit as os_exit | ||||
| from os.path import isfile, isdir, join | ||||
| from six import text_type | ||||
| from six import text_type, PY2 | ||||
| from six.moves.urllib.parse import unquote | ||||
| from sys import argv as sys_argv, exit, stderr | ||||
| from time import gmtime, strftime | ||||
|  | ||||
| @@ -81,6 +82,9 @@ def st_delete(parser, args, output_manager): | ||||
|     parser.add_option( | ||||
|         '-a', '--all', action='store_true', dest='yes_all', | ||||
|         default=False, help='Delete all containers and objects.') | ||||
|     parser.add_option( | ||||
|         '-p', '--prefix', dest='prefix', | ||||
|         help='Only delete items beginning with the <prefix>.') | ||||
|     parser.add_option( | ||||
|         '', '--leave-segments', action='store_true', | ||||
|         dest='leave_segments', default=False, | ||||
| @@ -128,9 +132,39 @@ def st_delete(parser, args, output_manager): | ||||
|                 o = r.get('object', '') | ||||
|                 a = r.get('attempts') | ||||
|  | ||||
|                 if r['action'] == 'bulk_delete': | ||||
|                     if r['success']: | ||||
|                         objs = r.get('objects', []) | ||||
|                         for o, err in r.get('result', {}).get('Errors', []): | ||||
|                             # o will be of the form quote("/<cont>/<obj>") | ||||
|                             o = unquote(o) | ||||
|                             if PY2: | ||||
|                                 # In PY3, unquote(unicode) uses utf-8 like we | ||||
|                                 # want, but PY2 uses latin-1 | ||||
|                                 o = o.encode('latin-1').decode('utf-8') | ||||
|                             output_manager.error('Error Deleting: {0}: {1}' | ||||
|                                                  .format(o[1:], err)) | ||||
|                             try: | ||||
|                                 objs.remove(o[len(c) + 2:]) | ||||
|                             except ValueError: | ||||
|                                 # shouldn't happen, but ignoring it won't hurt | ||||
|                                 pass | ||||
|  | ||||
|                         for o in objs: | ||||
|                             if options.yes_all: | ||||
|                                 p = '{0}/{1}'.format(c, o) | ||||
|                             else: | ||||
|                                 p = o | ||||
|                             output_manager.print_msg('{0}{1}'.format(p, a)) | ||||
|                     else: | ||||
|                         for o in r.get('objects', []): | ||||
|                             output_manager.error('Error Deleting: {0}/{1}: {2}' | ||||
|                                                  .format(c, o, r['error'])) | ||||
|                 else: | ||||
|                     if r['success']: | ||||
|                         if options.verbose: | ||||
|                         a = ' [after {0} attempts]'.format(a) if a > 1 else '' | ||||
|                             a = (' [after {0} attempts]'.format(a) | ||||
|                                  if a > 1 else '') | ||||
|  | ||||
|                             if r['action'] == 'delete_object': | ||||
|                                 if options.yes_all: | ||||
|   | ||||
| @@ -264,3 +264,13 @@ def iter_wrapper(iterable): | ||||
|             # causing the server to close the connection | ||||
|             continue | ||||
|         yield chunk | ||||
|  | ||||
|  | ||||
| def n_at_a_time(seq, n): | ||||
|     for i in range(0, len(seq), n): | ||||
|         yield seq[i:i + n] | ||||
|  | ||||
|  | ||||
| def n_groups(seq, n): | ||||
|     items_per_group = ((len(seq) - 1) // n) + 1 | ||||
|     return n_at_a_time(seq, items_per_group) | ||||
|   | ||||
| @@ -693,25 +693,147 @@ class TestShell(testtools.TestCase): | ||||
|                      'x-object-meta-mtime': mock.ANY}, | ||||
|             response_dict={}) | ||||
|  | ||||
|     @mock.patch.object(swiftclient.service.SwiftService, '_should_bulk_delete', | ||||
|                        lambda *a: False) | ||||
|     @mock.patch('swiftclient.service.Connection') | ||||
|     def test_delete_account(self, connection): | ||||
|         connection.return_value.get_account.side_effect = [ | ||||
|             [None, [{'name': 'container'}]], | ||||
|             [None, [{'name': 'container'}, {'name': 'container2'}]], | ||||
|             [None, [{'name': 'empty_container'}]], | ||||
|             [None, []], | ||||
|         ] | ||||
|         connection.return_value.get_container.side_effect = [ | ||||
|             [None, [{'name': 'object'}, {'name': 'obj\xe9ct2'}]], | ||||
|             [None, []], | ||||
|             [None, [{'name': 'object'}]], | ||||
|             [None, []], | ||||
|             [None, []], | ||||
|         ] | ||||
|         connection.return_value.attempts = 0 | ||||
|         argv = ["", "delete", "--all"] | ||||
|         connection.return_value.head_object.return_value = {} | ||||
|         swiftclient.shell.main(argv) | ||||
|         connection.return_value.delete_container.assert_called_with( | ||||
|             'container', response_dict={}) | ||||
|         connection.return_value.delete_object.assert_called_with( | ||||
|             'container', 'object', query_string=None, response_dict={}) | ||||
|         self.assertEqual( | ||||
|             connection.return_value.delete_object.mock_calls, [ | ||||
|                 mock.call('container', 'object', query_string=None, | ||||
|                           response_dict={}), | ||||
|                 mock.call('container', 'obj\xe9ct2', query_string=None, | ||||
|                           response_dict={}), | ||||
|                 mock.call('container2', 'object', query_string=None, | ||||
|                           response_dict={})]) | ||||
|         self.assertEqual( | ||||
|             connection.return_value.delete_container.mock_calls, [ | ||||
|                 mock.call('container', response_dict={}), | ||||
|                 mock.call('container2', response_dict={}), | ||||
|                 mock.call('empty_container', response_dict={})]) | ||||
|  | ||||
|     @mock.patch.object(swiftclient.service.SwiftService, '_should_bulk_delete', | ||||
|                        lambda *a: True) | ||||
|     @mock.patch('swiftclient.service.Connection') | ||||
|     def test_delete_bulk_account(self, connection): | ||||
|         connection.return_value.get_account.side_effect = [ | ||||
|             [None, [{'name': 'container'}, {'name': 'container2'}]], | ||||
|             [None, [{'name': 'empty_container'}]], | ||||
|             [None, []], | ||||
|         ] | ||||
|         connection.return_value.get_container.side_effect = [ | ||||
|             [None, [{'name': 'object'}, {'name': 'obj\xe9ct2'}, | ||||
|                     {'name': 'object3'}]], | ||||
|             [None, []], | ||||
|             [None, [{'name': 'object'}]], | ||||
|             [None, []], | ||||
|             [None, []], | ||||
|         ] | ||||
|         connection.return_value.attempts = 0 | ||||
|         argv = ["", "delete", "--all", "--object-threads", "2"] | ||||
|         connection.return_value.post_account.return_value = {}, ( | ||||
|             b'{"Number Not Found": 0, "Response Status": "200 OK", ' | ||||
|             b'"Errors": [], "Number Deleted": 1, "Response Body": ""}') | ||||
|         swiftclient.shell.main(argv) | ||||
|         self.assertEqual( | ||||
|             connection.return_value.post_account.mock_calls, [ | ||||
|                 mock.call(query_string='bulk-delete', | ||||
|                           data=b'/container/object\n/container/obj%C3%A9ct2\n', | ||||
|                           headers={'Content-Type': 'text/plain', | ||||
|                                    'Accept': 'application/json'}, | ||||
|                           response_dict={}), | ||||
|                 mock.call(query_string='bulk-delete', | ||||
|                           data=b'/container/object3\n', | ||||
|                           headers={'Content-Type': 'text/plain', | ||||
|                                    'Accept': 'application/json'}, | ||||
|                           response_dict={}), | ||||
|                 mock.call(query_string='bulk-delete', | ||||
|                           data=b'/container2/object\n', | ||||
|                           headers={'Content-Type': 'text/plain', | ||||
|                                    'Accept': 'application/json'}, | ||||
|                           response_dict={})]) | ||||
|         self.assertEqual( | ||||
|             connection.return_value.delete_container.mock_calls, [ | ||||
|                 mock.call('container', response_dict={}), | ||||
|                 mock.call('container2', response_dict={}), | ||||
|                 mock.call('empty_container', response_dict={})]) | ||||
|  | ||||
|     @mock.patch('swiftclient.service.Connection') | ||||
|     def test_delete_bulk_account_with_capabilities(self, connection): | ||||
|         connection.return_value.get_capabilities.return_value = { | ||||
|             'bulk_delete': { | ||||
|                 'max_deletes_per_request': 10000, | ||||
|                 'max_failed_deletes': 1000, | ||||
|             }, | ||||
|         } | ||||
|         connection.return_value.get_account.side_effect = [ | ||||
|             [None, [{'name': 'container'}]], | ||||
|             [None, [{'name': 'container2'}]], | ||||
|             [None, [{'name': 'empty_container'}]], | ||||
|             [None, []], | ||||
|         ] | ||||
|         connection.return_value.get_container.side_effect = [ | ||||
|             [None, [{'name': 'object'}, {'name': 'obj\xe9ct2'}, | ||||
|                     {'name': 'z_object'}, {'name': 'z_obj\xe9ct2'}]], | ||||
|             [None, []], | ||||
|             [None, [{'name': 'object'}, {'name': 'obj\xe9ct2'}, | ||||
|                     {'name': 'z_object'}, {'name': 'z_obj\xe9ct2'}]], | ||||
|             [None, []], | ||||
|             [None, []], | ||||
|         ] | ||||
|         connection.return_value.attempts = 0 | ||||
|         argv = ["", "delete", "--all", "--object-threads", "1"] | ||||
|         connection.return_value.post_account.return_value = {}, ( | ||||
|             b'{"Number Not Found": 0, "Response Status": "200 OK", ' | ||||
|             b'"Errors": [], "Number Deleted": 1, "Response Body": ""}') | ||||
|         swiftclient.shell.main(argv) | ||||
|         self.assertEqual( | ||||
|             connection.return_value.post_account.mock_calls, [ | ||||
|                 mock.call(query_string='bulk-delete', | ||||
|                           data=b''.join([ | ||||
|                               b'/container/object\n', | ||||
|                               b'/container/obj%C3%A9ct2\n', | ||||
|                               b'/container/z_object\n', | ||||
|                               b'/container/z_obj%C3%A9ct2\n' | ||||
|                           ]), | ||||
|                           headers={'Content-Type': 'text/plain', | ||||
|                                    'Accept': 'application/json'}, | ||||
|                           response_dict={}), | ||||
|                 mock.call(query_string='bulk-delete', | ||||
|                           data=b''.join([ | ||||
|                               b'/container2/object\n', | ||||
|                               b'/container2/obj%C3%A9ct2\n', | ||||
|                               b'/container2/z_object\n', | ||||
|                               b'/container2/z_obj%C3%A9ct2\n' | ||||
|                           ]), | ||||
|                           headers={'Content-Type': 'text/plain', | ||||
|                                    'Accept': 'application/json'}, | ||||
|                           response_dict={})]) | ||||
|         self.assertEqual( | ||||
|             connection.return_value.delete_container.mock_calls, [ | ||||
|                 mock.call('container', response_dict={}), | ||||
|                 mock.call('container2', response_dict={}), | ||||
|                 mock.call('empty_container', response_dict={})]) | ||||
|         self.assertEqual(connection.return_value.get_capabilities.mock_calls, | ||||
|                          [mock.call(None)])  # only one /info request | ||||
|  | ||||
|     @mock.patch.object(swiftclient.service.SwiftService, '_should_bulk_delete', | ||||
|                        lambda *a: False) | ||||
|     @mock.patch('swiftclient.service.Connection') | ||||
|     def test_delete_container(self, connection): | ||||
|         connection.return_value.get_container.side_effect = [ | ||||
| @@ -727,6 +849,28 @@ class TestShell(testtools.TestCase): | ||||
|         connection.return_value.delete_object.assert_called_with( | ||||
|             'container', 'object', query_string=None, response_dict={}) | ||||
|  | ||||
|     @mock.patch.object(swiftclient.service.SwiftService, '_should_bulk_delete', | ||||
|                        lambda *a: True) | ||||
|     @mock.patch('swiftclient.service.Connection') | ||||
|     def test_delete_bulk_container(self, connection): | ||||
|         connection.return_value.get_container.side_effect = [ | ||||
|             [None, [{'name': 'object'}]], | ||||
|             [None, []], | ||||
|         ] | ||||
|         connection.return_value.attempts = 0 | ||||
|         argv = ["", "delete", "container"] | ||||
|         connection.return_value.post_account.return_value = {}, ( | ||||
|             b'{"Number Not Found": 0, "Response Status": "200 OK", ' | ||||
|             b'"Errors": [], "Number Deleted": 1, "Response Body": ""}') | ||||
|         swiftclient.shell.main(argv) | ||||
|         connection.return_value.post_account.assert_called_with( | ||||
|             query_string='bulk-delete', data=b'/container/object\n', | ||||
|             headers={'Content-Type': 'text/plain', | ||||
|                      'Accept': 'application/json'}, | ||||
|             response_dict={}) | ||||
|         connection.return_value.delete_container.assert_called_with( | ||||
|             'container', response_dict={}) | ||||
|  | ||||
|     def test_delete_verbose_output_utf8(self): | ||||
|         container = 't\u00e9st_c' | ||||
|         base_argv = ['', '--verbose', 'delete'] | ||||
| @@ -759,8 +903,10 @@ class TestShell(testtools.TestCase): | ||||
|                 self.assertTrue(out.out.find( | ||||
|                     't\u00e9st_c [after 2 attempts]') >= 0, out) | ||||
|  | ||||
|     @mock.patch.object(swiftclient.service.SwiftService, '_should_bulk_delete', | ||||
|                        lambda *a: False) | ||||
|     @mock.patch('swiftclient.service.Connection') | ||||
|     def test_delete_object(self, connection): | ||||
|     def test_delete_per_object(self, connection): | ||||
|         argv = ["", "delete", "container", "object"] | ||||
|         connection.return_value.head_object.return_value = {} | ||||
|         connection.return_value.attempts = 0 | ||||
| @@ -768,6 +914,22 @@ class TestShell(testtools.TestCase): | ||||
|         connection.return_value.delete_object.assert_called_with( | ||||
|             'container', 'object', query_string=None, response_dict={}) | ||||
|  | ||||
|     @mock.patch.object(swiftclient.service.SwiftService, '_should_bulk_delete', | ||||
|                        lambda *a: True) | ||||
|     @mock.patch('swiftclient.service.Connection') | ||||
|     def test_delete_bulk_object(self, connection): | ||||
|         argv = ["", "delete", "container", "object"] | ||||
|         connection.return_value.post_account.return_value = {}, ( | ||||
|             b'{"Number Not Found": 0, "Response Status": "200 OK", ' | ||||
|             b'"Errors": [], "Number Deleted": 1, "Response Body": ""}') | ||||
|         connection.return_value.attempts = 0 | ||||
|         swiftclient.shell.main(argv) | ||||
|         connection.return_value.post_account.assert_called_with( | ||||
|             query_string='bulk-delete', data=b'/container/object\n', | ||||
|             headers={'Content-Type': 'text/plain', | ||||
|                      'Accept': 'application/json'}, | ||||
|             response_dict={}) | ||||
|  | ||||
|     def test_delete_verbose_output(self): | ||||
|         del_obj_res = {'success': True, 'response_dict': {}, 'attempts': 2, | ||||
|                        'container': 't\xe9st_c', 'action': 'delete_object', | ||||
|   | ||||
| @@ -596,6 +596,40 @@ class TestHeadAccount(MockHttpTest): | ||||
|         self.assertEqual(e.__str__()[-89:], new_body) | ||||
|  | ||||
|  | ||||
| class TestPostAccount(MockHttpTest): | ||||
|  | ||||
|     def test_ok(self): | ||||
|         c.http_connection = self.fake_http_connection(200, headers={ | ||||
|             'X-Account-Meta-Color': 'blue', | ||||
|         }, body='foo') | ||||
|         resp_headers, body = c.post_account( | ||||
|             'http://www.tests.com/path/to/account', 'asdf', | ||||
|             {'x-account-meta-shape': 'square'}, query_string='bar=baz', | ||||
|             data='some data') | ||||
|         self.assertEqual('blue', resp_headers.get('x-account-meta-color')) | ||||
|         self.assertEqual('foo', body) | ||||
|         self.assertRequests([ | ||||
|             ('POST', 'http://www.tests.com/path/to/account?bar=baz', | ||||
|              'some data', {'x-auth-token': 'asdf', | ||||
|                            'x-account-meta-shape': 'square'}) | ||||
|         ]) | ||||
|  | ||||
|     def test_server_error(self): | ||||
|         body = 'c' * 65 | ||||
|         c.http_connection = self.fake_http_connection(500, body=body) | ||||
|         e = self.assertRaises(c.ClientException, c.post_account, | ||||
|                               'http://www.tests.com', 'asdf', {}) | ||||
|         self.assertEqual(e.http_response_content, body) | ||||
|         self.assertEqual(e.http_status, 500) | ||||
|         self.assertRequests([ | ||||
|             ('POST', 'http://www.tests.com', None, {'x-auth-token': 'asdf'}) | ||||
|         ]) | ||||
|         # TODO: this is a fairly brittle test of the __repr__ on the | ||||
|         # ClientException which should probably be in a targeted test | ||||
|         new_body = "[first 60 chars of response] " + body[0:60] | ||||
|         self.assertEqual(e.__str__()[-89:], new_body) | ||||
|  | ||||
|  | ||||
| class TestGetContainer(MockHttpTest): | ||||
|  | ||||
|     def test_no_content(self): | ||||
| @@ -1976,7 +2010,8 @@ class TestResponseDict(MockHttpTest): | ||||
|     """ | ||||
|     Verify handling of optional response_dict argument. | ||||
|     """ | ||||
|     calls = [('post_container', 'c', {}), | ||||
|     calls = [('post_account', {}), | ||||
|              ('post_container', 'c', {}), | ||||
|              ('put_container', 'c'), | ||||
|              ('delete_container', 'c'), | ||||
|              ('post_object', 'c', 'o', {}), | ||||
|   | ||||
| @@ -290,3 +290,31 @@ class TestLengthWrapper(testtools.TestCase): | ||||
|                 self.assertEqual(segment_length, len(read_data)) | ||||
|                 self.assertEqual(s, read_data) | ||||
|                 self.assertEqual(md5(s).hexdigest(), data.get_md5sum()) | ||||
|  | ||||
|  | ||||
| class TestGroupers(testtools.TestCase): | ||||
|     def test_n_at_a_time(self): | ||||
|         result = list(u.n_at_a_time(range(100), 9)) | ||||
|         self.assertEqual([9] * 11 + [1], list(map(len, result))) | ||||
|  | ||||
|         result = list(u.n_at_a_time(range(100), 10)) | ||||
|         self.assertEqual([10] * 10, list(map(len, result))) | ||||
|  | ||||
|         result = list(u.n_at_a_time(range(100), 11)) | ||||
|         self.assertEqual([11] * 9 + [1], list(map(len, result))) | ||||
|  | ||||
|         result = list(u.n_at_a_time(range(100), 12)) | ||||
|         self.assertEqual([12] * 8 + [4], list(map(len, result))) | ||||
|  | ||||
|     def test_n_groups(self): | ||||
|         result = list(u.n_groups(range(100), 9)) | ||||
|         self.assertEqual([12] * 8 + [4], list(map(len, result))) | ||||
|  | ||||
|         result = list(u.n_groups(range(100), 10)) | ||||
|         self.assertEqual([10] * 10, list(map(len, result))) | ||||
|  | ||||
|         result = list(u.n_groups(range(100), 11)) | ||||
|         self.assertEqual([10] * 10, list(map(len, result))) | ||||
|  | ||||
|         result = list(u.n_groups(range(100), 12)) | ||||
|         self.assertEqual([9] * 11 + [1], list(map(len, result))) | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Tim Burke
					Tim Burke