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,30 +2371,29 @@ 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
 | 
			
		||||
            )
 | 
			
		||||
            capabilities = get_future_result(cap)
 | 
			
		||||
        cap = self.thread_manager.container_pool.submit(
 | 
			
		||||
            self._get_capabilities, url
 | 
			
		||||
        )
 | 
			
		||||
        capabilities = get_future_result(cap)
 | 
			
		||||
        res.update({
 | 
			
		||||
            'success': True,
 | 
			
		||||
            'capabilities': capabilities
 | 
			
		||||
        })
 | 
			
		||||
        if url is not None:
 | 
			
		||||
            res.update({
 | 
			
		||||
                'success': True,
 | 
			
		||||
                'capabilities': capabilities
 | 
			
		||||
                'url': url
 | 
			
		||||
            })
 | 
			
		||||
            if url is not None:
 | 
			
		||||
                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,25 +132,55 @@ def st_delete(parser, args, output_manager):
 | 
			
		||||
                o = r.get('object', '')
 | 
			
		||||
                a = r.get('attempts')
 | 
			
		||||
 | 
			
		||||
                if r['success']:
 | 
			
		||||
                    if options.verbose:
 | 
			
		||||
                        a = ' [after {0} attempts]'.format(a) if a > 1 else ''
 | 
			
		||||
                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
 | 
			
		||||
 | 
			
		||||
                        if r['action'] == 'delete_object':
 | 
			
		||||
                        for o in objs:
 | 
			
		||||
                            if options.yes_all:
 | 
			
		||||
                                p = '{0}/{1}'.format(c, o)
 | 
			
		||||
                            else:
 | 
			
		||||
                                p = o
 | 
			
		||||
                        elif r['action'] == 'delete_segment':
 | 
			
		||||
                            p = '{0}/{1}'.format(c, o)
 | 
			
		||||
                        elif r['action'] == 'delete_container':
 | 
			
		||||
                            p = c
 | 
			
		||||
 | 
			
		||||
                        output_manager.print_msg('{0}{1}'.format(p, a))
 | 
			
		||||
                            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:
 | 
			
		||||
                    p = '{0}/{1}'.format(c, o) if o else c
 | 
			
		||||
                    output_manager.error('Error Deleting: {0}: {1}'
 | 
			
		||||
                                         .format(p, r['error']))
 | 
			
		||||
                    if r['success']:
 | 
			
		||||
                        if options.verbose:
 | 
			
		||||
                            a = (' [after {0} attempts]'.format(a)
 | 
			
		||||
                                 if a > 1 else '')
 | 
			
		||||
 | 
			
		||||
                            if r['action'] == 'delete_object':
 | 
			
		||||
                                if options.yes_all:
 | 
			
		||||
                                    p = '{0}/{1}'.format(c, o)
 | 
			
		||||
                                else:
 | 
			
		||||
                                    p = o
 | 
			
		||||
                            elif r['action'] == 'delete_segment':
 | 
			
		||||
                                p = '{0}/{1}'.format(c, o)
 | 
			
		||||
                            elif r['action'] == 'delete_container':
 | 
			
		||||
                                p = c
 | 
			
		||||
 | 
			
		||||
                            output_manager.print_msg('{0}{1}'.format(p, a))
 | 
			
		||||
                    else:
 | 
			
		||||
                        p = '{0}/{1}'.format(c, o) if o else c
 | 
			
		||||
                        output_manager.error('Error Deleting: {0}: {1}'
 | 
			
		||||
                                             .format(p, r['error']))
 | 
			
		||||
        except SwiftError as err:
 | 
			
		||||
            output_manager.error(err.value)
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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