summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.rst2
-rw-r--r--bindep.txt5
-rw-r--r--doc/manpages/swift.146
-rw-r--r--doc/source/cli.rst17
-rw-r--r--doc/source/client-api.rst2
-rw-r--r--doc/source/conf.py2
-rw-r--r--doc/source/service-api.rst85
-rw-r--r--examples/copy.py30
-rw-r--r--setup.py6
-rw-r--r--swiftclient/client.py105
-rw-r--r--swiftclient/service.py235
-rwxr-xr-xswiftclient/shell.py133
-rw-r--r--swiftclient/utils.py45
-rw-r--r--tests/functional/test_swiftclient.py40
-rw-r--r--tests/unit/test_service.py161
-rw-r--r--tests/unit/test_shell.py167
-rw-r--r--tests/unit/test_swiftclient.py135
-rw-r--r--tests/unit/test_utils.py142
-rw-r--r--tests/unit/utils.py3
19 files changed, 1245 insertions, 116 deletions
diff --git a/README.rst b/README.rst
index 3677406..cd5efc3 100644
--- a/README.rst
+++ b/README.rst
@@ -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)
diff --git a/setup.py b/setup.py
index 70c2b3f..16a18f6 100644
--- a/setup.py
+++ b/setup.py
@@ -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)