diff options
-rw-r--r-- | README.rst | 2 | ||||
-rw-r--r-- | bindep.txt | 5 | ||||
-rw-r--r-- | doc/manpages/swift.1 | 46 | ||||
-rw-r--r-- | doc/source/cli.rst | 17 | ||||
-rw-r--r-- | doc/source/client-api.rst | 2 | ||||
-rw-r--r-- | doc/source/conf.py | 2 | ||||
-rw-r--r-- | doc/source/service-api.rst | 85 | ||||
-rw-r--r-- | examples/copy.py | 30 | ||||
-rw-r--r-- | setup.py | 6 | ||||
-rw-r--r-- | swiftclient/client.py | 105 | ||||
-rw-r--r-- | swiftclient/service.py | 235 | ||||
-rwxr-xr-x | swiftclient/shell.py | 133 | ||||
-rw-r--r-- | swiftclient/utils.py | 45 | ||||
-rw-r--r-- | tests/functional/test_swiftclient.py | 40 | ||||
-rw-r--r-- | tests/unit/test_service.py | 161 | ||||
-rw-r--r-- | tests/unit/test_shell.py | 167 | ||||
-rw-r--r-- | tests/unit/test_swiftclient.py | 135 | ||||
-rw-r--r-- | tests/unit/test_utils.py | 142 | ||||
-rw-r--r-- | tests/unit/utils.py | 3 |
19 files changed, 1245 insertions, 116 deletions
@@ -17,7 +17,7 @@ in the `OpenStack wiki`__. __ http://docs.openstack.org/infra/manual/developers.html -This code is based on original the client previously included with +This code is based on the original client previously included with `OpenStack's Swift`__ The python-swiftclient is licensed under the Apache License like the rest of OpenStack. diff --git a/bindep.txt b/bindep.txt new file mode 100644 index 0000000..c587dda --- /dev/null +++ b/bindep.txt @@ -0,0 +1,5 @@ +# This is a cross-platform list tracking distribution packages needed by tests; +# see http://docs.openstack.org/infra/bindep/ for additional information. + +pypy +pypy-dev diff --git a/doc/manpages/swift.1 b/doc/manpages/swift.1 index b9f99c4..5d21c18 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 <object\-name> is -an option that will upload file and name object to <object-name> or upload dir +that have changed since the last upload. The \-\-object\-name <object\-name> is +an option that will upload file and name object to <object\-name> or upload dir and use <object\-name> as object prefix. The \-S <size> or \-\-segment\-size <size> and \-\-leave\-segments and others are options as well (see swift upload \-\-help for more). .RE @@ -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 @@ -86,6 +99,7 @@ container, or a list of objects depending on the args given. For a single object download, you may use the \-o [\-\-output] <filename> 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 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 @@ -99,23 +113,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 ones. Addtionaly 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 @@ -139,7 +161,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/doc/source/cli.rst b/doc/source/cli.rst index 12de02f..9bb229d 100644 --- a/doc/source/cli.rst +++ b/doc/source/cli.rst @@ -186,7 +186,8 @@ Download container, or a list of objects depending on the arguments given. For a single object download, you may use the ``-o <filename>`` or ``--output <filename>`` option to redirect the output to a specific file or ``-`` to - redirect to stdout. You can specify optional headers with the repeatable + redirect to stdout. The ``--ignore-checksum`` is an option that turn off + checksum validation. You can specify optional headers with the repeatable cURL-like option ``-H [--header <name:value>]``. Delete @@ -199,6 +200,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/client-api.rst b/doc/source/client-api.rst index 5677f70..b0bb637 100644 --- a/doc/source/client-api.rst +++ b/doc/source/client-api.rst @@ -12,7 +12,7 @@ Authentication -------------- This section covers the various combinations of kwargs required when creating -and instance of the ``Connection`` object for communicating with a swift +an instance of the ``Connection`` object for communicating with a swift object store. The combinations of options required for each authentication version are detailed below, but are just a subset of those that can be used to successfully authenticate. These diff --git a/doc/source/conf.py b/doc/source/conf.py index 0b3e7e1..5af77b2 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -111,6 +111,8 @@ pygments_style = 'sphinx' # 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 = [] diff --git a/doc/source/service-api.rst b/doc/source/service-api.rst index 4da68a3..02b12d0 100644 --- a/doc/source/service-api.rst +++ b/doc/source/service-api.rst @@ -207,6 +207,9 @@ Options Affects uploads, and allows empty 'pseudofolder' objects to be created when the source of an upload is ``None``. + ``checksum``: ``True`` + Affects uploads and downloads. If set check md5 sum for the transfer. + ``shuffle``: ``False`` When downloading objects, the default behaviour of the CLI is to shuffle lists of objects in order to spread the load on storage drives when multiple @@ -217,6 +220,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 +749,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': <container>, + 'object': <object>, + 'destination': <destination>, + 'headers': {}, + 'fresh_metadata': <boolean>, + 'response_dict': <HTTP response details> + } + +Any failure in a copy operation will return a failure dictionary as described +below: + +.. code-block:: python + + { + 'action': 'copy_object', + 'success': False, + 'container': <container>, + 'object': <object>, + 'destination': <destination>, + 'headers': {}, + 'fresh_metadata': <boolean>, + 'response_dict': <HTTP response details>, + 'error': <error>, + 'traceback': <traceback>, + 'error_timestamp': <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 ~~~~~~~~~~~~ @@ -766,7 +843,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: @@ -821,7 +898,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 0000000..e928db4 --- /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) @@ -15,7 +15,11 @@ # limitations under the License. # THIS FILE IS MANAGED BY THE GLOBAL REQUIREMENTS REPO - DO NOT EDIT -import setuptools +import setuptools, sys + +if sys.version_info < (2, 7): + sys.exit('Sorry, Python < 2.7 is not supported for' + ' python-swiftclient>=3.0') setuptools.setup( setup_requires=['pbr'], diff --git a/swiftclient/client.py b/swiftclient/client.py index 744a876..602489d 100644 --- a/swiftclient/client.py +++ b/swiftclient/client.py @@ -827,7 +827,8 @@ def post_account(url, token, headers, http_conn=None, response_dict=None, def get_container(url, token, container, marker=None, limit=None, prefix=None, delimiter=None, end_marker=None, path=None, http_conn=None, - full_listing=False, service_token=None, headers=None): + full_listing=False, service_token=None, headers=None, + query_string=None): """ Get a listing of objects for the container. @@ -846,6 +847,7 @@ def get_container(url, token, container, marker=None, limit=None, of 10000 listings :param service_token: service auth token :param headers: additional headers to include in the request + :param query_string: if set will be appended with '?' to generated path :returns: a tuple of (response headers, a list of objects) The response headers will be a dict and all header names will be lowercase. :raises ClientException: HTTP GET request failed @@ -889,6 +891,8 @@ def get_container(url, token, container, marker=None, limit=None, qs += '&end_marker=%s' % quote(end_marker) if path: qs += '&path=%s' % quote(path) + if query_string: + qs += '&%s' % query_string.lstrip('?') if service_token: headers['X-Service-Token'] = service_token method = 'GET' @@ -950,7 +954,7 @@ def head_container(url, token, container, http_conn=None, headers=None, def put_container(url, token, container, headers=None, http_conn=None, - response_dict=None, service_token=None): + response_dict=None, service_token=None, query_string=None): """ Create a container @@ -963,6 +967,7 @@ def put_container(url, token, container, headers=None, http_conn=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 :raises ClientException: HTTP PUT request failed """ if http_conn: @@ -978,6 +983,8 @@ def put_container(url, token, container, headers=None, http_conn=None, headers['X-Service-Token'] = service_token if 'content-length' not in (k.lower() for k in headers): headers['Content-Length'] = '0' + if query_string: + path += '?' + query_string.lstrip('?') conn.request(method, path, '', headers) resp = conn.getresponse() body = resp.read() @@ -1031,7 +1038,8 @@ def post_container(url, token, container, headers, http_conn=None, def delete_container(url, token, container, http_conn=None, - response_dict=None, service_token=None): + response_dict=None, service_token=None, + query_string=None): """ Delete a container @@ -1043,6 +1051,7 @@ def delete_container(url, token, container, http_conn=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 :raises ClientException: HTTP DELETE request failed """ if http_conn: @@ -1053,6 +1062,8 @@ def delete_container(url, token, container, http_conn=None, headers = {'X-Auth-Token': token} if service_token: headers['X-Service-Token'] = service_token + if query_string: + path += '?' + query_string.lstrip('?') method = 'DELETE' conn.request(method, path, '', headers) resp = conn.getresponse() @@ -1319,6 +1330,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): @@ -1625,7 +1700,7 @@ class Connection(object): def get_container(self, container, marker=None, limit=None, prefix=None, delimiter=None, end_marker=None, path=None, - full_listing=False, headers=None): + full_listing=False, headers=None, query_string=None): """Wrapper for :func:`get_container`""" # TODO(unknown): With full_listing=True this will restart the entire # listing with each retry. Need to make a better version that just @@ -1633,22 +1708,27 @@ class Connection(object): return self._retry(None, get_container, container, marker=marker, limit=limit, prefix=prefix, delimiter=delimiter, end_marker=end_marker, path=path, - full_listing=full_listing, headers=headers) + full_listing=full_listing, headers=headers, + query_string=query_string) - def put_container(self, container, headers=None, response_dict=None): + def put_container(self, container, headers=None, response_dict=None, + query_string=None): """Wrapper for :func:`put_container`""" return self._retry(None, put_container, container, headers=headers, - response_dict=response_dict) + response_dict=response_dict, + query_string=query_string) def post_container(self, container, headers, response_dict=None): """Wrapper for :func:`post_container`""" return self._retry(None, post_container, container, headers, response_dict=response_dict) - def delete_container(self, container, response_dict=None): + def delete_container(self, container, response_dict=None, + query_string=None): """Wrapper for :func:`delete_container`""" return self._retry(None, delete_container, container, - response_dict=response_dict) + response_dict=response_dict, + query_string=query_string) def head_object(self, container, obj, headers=None): """Wrapper for :func:`head_object`""" @@ -1711,6 +1791,13 @@ class Connection(object): 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 12d3f21..0c16262 100644 --- a/swiftclient/service.py +++ b/swiftclient/service.py @@ -200,7 +200,9 @@ _default_local_options = { 'human': False, 'dir_marker': False, 'checksum': True, - 'shuffle': False + 'shuffle': False, + 'destination': None, + 'fresh_metadata': False, } POLICY = 'X-Storage-Policy' @@ -280,7 +282,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" @@ -330,13 +332,49 @@ class SwiftPostObject(object): 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 errors on failures caused by either invalid md5sum or size of the data read. """ - def __init__(self, path, body, headers): + def __init__(self, path, body, headers, checksum=True): self._path = path self._body = body self._actual_read = 0 @@ -345,7 +383,7 @@ class _SwiftReader(object): self._expected_etag = headers.get('etag') if ('x-object-manifest' not in headers - and 'x-static-large-object' not in headers): + and 'x-static-large-object' not in headers and checksum): self._actual_md5 = md5() if 'content-length' in headers: @@ -980,6 +1018,7 @@ class SwiftService(object): 'header': [], 'skip_identical': False, 'out_directory': None, + 'checksum': True, 'out_file': None, 'remove_prefix': False, 'shuffle' : False @@ -1135,7 +1174,8 @@ class SwiftService(object): headers_receipt = time() - obj_body = _SwiftReader(path, body, headers) + obj_body = _SwiftReader(path, body, headers, + options.get('checksum', True)) no_file = options['no_download'] if out_file == "-" and not no_file: @@ -2389,6 +2429,191 @@ class SwiftService(object): 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 486dc48..be1888d 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 @@ -46,7 +47,7 @@ except ImportError: 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') @@ -204,7 +205,7 @@ def st_delete(parser, args, output_manager): st_download_options = '''[--all] [--marker <marker>] [--prefix <prefix>] [--output <out_file>] [--output-dir <out_directory>] - [--object-threads <threads>] + [--object-threads <threads>] [--ignore-checksum] [--container-threads <threads>] [--no-download] [--skip-identical] [--remove-prefix] [--header <header:value>] [--no-shuffle] @@ -248,9 +249,10 @@ Optional arguments: -H, --header <header:value> 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. --no-shuffle By default, when downloading a complete account or container, download order is randomised in order to reduce the load on individual drives when multiple @@ -309,6 +311,9 @@ def st_download(parser, args, output_manager): default=False, help='Skip downloading files that are identical on ' 'both sides.') parser.add_argument( + '--ignore-checksum', action='store_false', dest='checksum', + default=True, help='Turn off checksum validation for downloads.') + parser.add_argument( '--no-shuffle', action='store_false', dest='shuffle', default=True, help='By default, download order is randomised in order ' 'to reduce the load on individual drives when multiple clients are ' @@ -746,6 +751,105 @@ def st_post(parser, args, output_manager): output_manager.error(e.value) +st_copy_options = '''[--destination </container/object>] [--fresh-metadata] + [--meta <name:value>] [--header <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 </container[/object]> + 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 <name:value> + Sets a meta data item. This option may be repeated. + Example: -m Color:Blue -m Size:Large + -H, --header <header:value> + 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 '<self>')) + 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 <size>] [--segment-container <container>] [--leave-segments] [--object-threads <thread>] [--segment-threads <threads>] @@ -785,7 +889,7 @@ Optional arguments: Default is 10. -H, --header <header:value> 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 @@ -836,7 +940,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, @@ -991,13 +1095,16 @@ def st_upload(parser, args, output_manager): output_manager.error(e.value) -st_capabilities_options = "[<proxy_url>]" +st_capabilities_options = "[--json] [<proxy_url>]" st_info_options = st_capabilities_options st_capabilities_help = ''' Retrieve capability of the proxy. Optional positional arguments: <proxy_url> 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 @@ -1013,6 +1120,8 @@ def st_capabilities(parser, args, output_manager): 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', @@ -1030,9 +1139,14 @@ def st_capabilities(parser, args, output_manager): 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) @@ -1268,6 +1382,7 @@ Positional arguments: 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/swiftclient/utils.py b/swiftclient/utils.py index 0abaed6..10687bf 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/functional/test_swiftclient.py b/tests/functional/test_swiftclient.py index 7a77c07..6e19abd 100644 --- a/tests/functional/test_swiftclient.py +++ b/tests/functional/test_swiftclient.py @@ -405,6 +405,46 @@ class TestFunctional(unittest.TestCase): 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 cce8c7a..546e495 100644 --- a/tests/unit/test_service.py +++ b/tests/unit/test_service.py @@ -59,7 +59,7 @@ class TestSwiftPostObject(unittest.TestCase): 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 @@ -69,6 +69,42 @@ class TestSwiftPostObject(unittest.TestCase): 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): @@ -81,10 +117,10 @@ class TestSwiftReader(unittest.TestCase): 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,16 +128,25 @@ class TestSwiftReader(unittest.TestCase): 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.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}) @@ -109,9 +154,9 @@ class TestSwiftReader(unittest.TestCase): 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 @@ -392,10 +437,10 @@ class TestSwiftError(unittest.TestCase): 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') @@ -487,7 +532,7 @@ class TestServiceUtils(unittest.TestCase): 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) @@ -517,12 +562,12 @@ class TestSwiftUploadObject(unittest.TestCase): 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') @@ -541,7 +586,7 @@ class TestSwiftUploadObject(unittest.TestCase): 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) @@ -550,9 +595,9 @@ class TestSwiftUploadObject(unittest.TestCase): 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) @@ -1859,7 +1904,7 @@ class TestServiceDownload(_TestServiceBase): 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: @@ -2344,3 +2389,73 @@ class TestServicePost(_TestServiceBase): 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 0639ac1..c0fcc41 100644 --- a/tests/unit/test_shell.py +++ b/tests/unit/test_shell.py @@ -16,6 +16,7 @@ from __future__ import unicode_literals from genericpath import getmtime import hashlib +import json import logging import mock import os @@ -367,6 +368,24 @@ class TestShell(unittest.TestCase): mock_open.assert_called_with('object', 'wb') self.assertEqual([], makedirs.mock_calls) + # Test downloading without md5 checks + objcontent = six.BytesIO(b'objcontent') + connection.return_value.get_object.side_effect = [ + ({'content-type': 'text/plain', + 'etag': '2cbbfe139a744d6abbe695e17f3c1991'}, + objcontent) + ] + with mock.patch(BUILTIN_OPEN) as mock_open, mock.patch( + 'swiftclient.service._SwiftReader') as sr: + argv = ["", "download", "container", "object", "--ignore-check"] + swiftclient.shell.main(argv) + connection.return_value.get_object.assert_called_with( + 'container', 'object', headers={}, resp_chunk_size=65536, + response_dict={}) + mock_open.assert_called_with('object', 'wb') + sr.assert_called_once_with('object', mock.ANY, mock.ANY, False) + self.assertEqual([], makedirs.mock_calls) + # Test downloading single object to stdout objcontent = six.BytesIO(b'objcontent') connection.return_value.get_object.side_effect = [ @@ -1244,6 +1263,139 @@ class TestShell(unittest.TestCase): 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 <self>\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", @@ -1267,6 +1419,21 @@ class TestShell(unittest.TestCase): 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"] diff --git a/tests/unit/test_swiftclient.py b/tests/unit/test_swiftclient.py index cbb95db..4e4c9f4 100644 --- a/tests/unit/test_swiftclient.py +++ b/tests/unit/test_swiftclient.py @@ -772,6 +772,16 @@ class TestGetContainer(MockHttpTest): }), ]) + def test_query_string(self): + c.http_connection = self.fake_http_connection( + 200, query_string="format=json&hello=20", body=b'[]') + c.get_container('http://www.test.com', 'asdf', 'asdf', + query_string="hello=20") + self.assertRequests([ + ('GET', '/asdf?format=json&hello=20', '', { + 'x-auth-token': 'asdf'}), + ]) + class TestHeadContainer(MockHttpTest): @@ -831,6 +841,17 @@ class TestPutContainer(MockHttpTest): 'content-length': '0'}), ]) + def test_query_string(self): + c.http_connection = self.fake_http_connection(200, + query_string="hello=20") + c.put_container('http://www.test.com', 'asdf', 'asdf', + query_string="hello=20") + for req in self.iter_request_log(): + self.assertEqual(req['method'], 'PUT') + self.assertEqual(req['parsed_path'].path, '/asdf') + self.assertEqual(req['parsed_path'].query, 'hello=20') + self.assertEqual(req['headers']['x-auth-token'], 'asdf') + class TestDeleteContainer(MockHttpTest): @@ -843,6 +864,16 @@ class TestDeleteContainer(MockHttpTest): 'x-auth-token': 'token'}), ]) + def test_query_string(self): + c.http_connection = self.fake_http_connection(200, + query_string="hello=20") + c.delete_container('http://www.test.com', 'token', 'container', + query_string="hello=20") + self.assertRequests([ + ('DELETE', 'http://www.test.com/container?hello=20', '', { + 'x-auth-token': 'token'}) + ]) + class TestGetObject(MockHttpTest): @@ -1398,6 +1429,109 @@ class TestPostObject(MockHttpTest): ]) +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 +2380,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): diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index aae466c..0f210a3 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -17,7 +17,7 @@ import unittest 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 @@ class TestPrtBytes(unittest.TestCase): 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): diff --git a/tests/unit/utils.py b/tests/unit/utils.py index d04583f..c05146e 100644 --- a/tests/unit/utils.py +++ b/tests/unit/utils.py @@ -494,6 +494,9 @@ class CaptureOutput(object): def __eq__(self, other): return self.out == other + def __ne__(self, other): + return not self.__eq__(other) + def __getattr__(self, name): return getattr(self.out, name) |