Repository: googleapis/google-cloud-python

Issue:
Determine desired/expected return value(s) of storage.bucket.Bucket.delete_keys
From TODO:

``` python
    # TODO: What should be the return value here?
```

See [`storage.bucket.Bucket.delete_keys`](https://github.com/GoogleCloudPlatform/gcloud-python/blob/1f2aaf3606802f4f02eeb30e22e1082ef7f4594e/gcloud/storage/bucket.py#L192) for relevant code and #146 for some context.

`git blame` output

```
b7f1b774 (JJ Geewax   2014-01-29 18:02:29 -0500 192)     # TODO: What should be the return value here?
```

Determine desired/expected return value(s) of storage.bucket.Bucket.delete_keys
From TODO:

``` python
    # TODO: What should be the return value here?
```

See [`storage.bucket.Bucket.delete_keys`](https://github.com/GoogleCloudPlatform/gcloud-python/blob/1f2aaf3606802f4f02eeb30e22e1082ef7f4594e/gcloud/storage/bucket.py#L192) for relevant code and #146 for some context.

`git blame` output

```
b7f1b774 (JJ Geewax   2014-01-29 18:02:29 -0500 192)     # TODO: What should be the return value here?
```



Discussion:
From the code, note that in a similar situation the `boto` library returns a `MultiDeleteResult` instance.

ISTM that the "Pythonic" return would be a sequence of the deleted keys, i.e:

```
def delete_keys(self, keys):
    deleted = []
    for key in keys:
        deleted.append(self.delete_key(key))
    return deleted
```

We can't use a generator, because that would require the caller to iterate-consume it to perform any but the first deletion.

SGTM.

It may be worth looking into [`MultiDeleteResult`](https://github.com/boto/boto/blob/bbe8ce7750d1fa398612b6bfb95a12c5e91ae960/boto/s3/multidelete.py#L109). From [search results](https://github.com/boto/boto/search?q=multideleteresult) it seems to only be used in [`Bucket.delete_keys`](https://github.com/boto/boto/blob/069d04b96226034bea96f9ee4c56ce985e9e3d2c/boto/s3/bucket.py#L661).

Note that the `boto` bit is wrapping an XML response document for a multi-delete request.  Because the `gcloud` storage API does not support [multi-delete](https://developers.google.com/storage/docs/json_api/v1/#Objects), we would be synthesizing an entity which doesn't have a server-side analog.

I see, maybe worth opening a [feature request](http://docs.aws.amazon.com/AmazonS3/latest/API/multiobjectdeleteapi.html) with the API team? But in the mean time, an object which tracks both deleted and errors may be useful.

From the code, note that in a similar situation the `boto` library returns a `MultiDeleteResult` instance.

ISTM that the "Pythonic" return would be a sequence of the deleted keys, i.e:

```
def delete_keys(self, keys):
    deleted = []
    for key in keys:
        deleted.append(self.delete_key(key))
    return deleted
```

We can't use a generator, because that would require the caller to iterate-consume it to perform any but the first deletion.

SGTM.

It may be worth looking into [`MultiDeleteResult`](https://github.com/boto/boto/blob/bbe8ce7750d1fa398612b6bfb95a12c5e91ae960/boto/s3/multidelete.py#L109). From [search results](https://github.com/boto/boto/search?q=multideleteresult) it seems to only be used in [`Bucket.delete_keys`](https://github.com/boto/boto/blob/069d04b96226034bea96f9ee4c56ce985e9e3d2c/boto/s3/bucket.py#L661).

Note that the `boto` bit is wrapping an XML response document for a multi-delete request.  Because the `gcloud` storage API does not support [multi-delete](https://developers.google.com/storage/docs/json_api/v1/#Objects), we would be synthesizing an entity which doesn't have a server-side analog.

I see, maybe worth opening a [feature request](http://docs.aws.amazon.com/AmazonS3/latest/API/multiobjectdeleteapi.html) with the API team? But in the mean time, an object which tracks both deleted and errors may be useful.


Resolving patch:
diff --git a/gcloud/storage/bucket.py b/gcloud/storage/bucket.py
--- a/gcloud/storage/bucket.py
+++ b/gcloud/storage/bucket.py
@@ -180,17 +180,26 @@ def delete_key(self, key):
         self.connection.api_request(method='DELETE', path=key.path)
         return key
 
-    def delete_keys(self, keys):
+    def delete_keys(self, keys, on_error=None):
         """Deletes a list of keys from the current bucket.
 
         Uses :func:`Bucket.delete_key` to delete each individual key.
 
         :type keys: list of string or :class:`gcloud.storage.key.Key`
-        :param key: A list of key names or Key objects to delete.
+        :param keys: A list of key names or Key objects to delete.
+
+        :type on_error: a callable taking (key)
+        :param on_error: If not ``None``, called once for each key which
+                         raises a ``NotFoundError``.
         """
-        # NOTE: boto returns a MultiDeleteResult instance.
         for key in keys:
-            self.delete_key(key)
+            try:
+                self.delete_key(key)
+            except exceptions.NotFoundError:
+                if on_error is not None:
+                    on_error(key)
+                else:
+                    raise
 
     def copy_key(self, key, destination_bucket, new_name=None):
         """Copy the given key to the given bucket, optionally with a new name.


Test patch:
diff --git a/gcloud/storage/test_bucket.py b/gcloud/storage/test_bucket.py
--- a/gcloud/storage/test_bucket.py
+++ b/gcloud/storage/test_bucket.py
@@ -232,7 +232,7 @@ def test_delete_keys_hit(self):
         self.assertEqual(kw[0]['method'], 'DELETE')
         self.assertEqual(kw[0]['path'], '/b/%s/o/%s' % (NAME, KEY))
 
-    def test_delete_keys_miss(self):
+    def test_delete_keys_miss_no_on_error(self):
         from gcloud.storage.exceptions import NotFoundError
         NAME = 'name'
         KEY = 'key'
@@ -247,6 +247,22 @@ def test_delete_keys_miss(self):
         self.assertEqual(kw[1]['method'], 'DELETE')
         self.assertEqual(kw[1]['path'], '/b/%s/o/%s' % (NAME, NONESUCH))
 
+    def test_delete_keys_miss_w_on_error(self):
+        NAME = 'name'
+        KEY = 'key'
+        NONESUCH = 'nonesuch'
+        connection = _Connection({})
+        bucket = self._makeOne(connection, NAME)
+        errors = []
+        bucket.delete_keys([KEY, NONESUCH], errors.append)
+        self.assertEqual(errors, [NONESUCH])
+        kw = connection._requested
+        self.assertEqual(len(kw), 2)
+        self.assertEqual(kw[0]['method'], 'DELETE')
+        self.assertEqual(kw[0]['path'], '/b/%s/o/%s' % (NAME, KEY))
+        self.assertEqual(kw[1]['method'], 'DELETE')
+        self.assertEqual(kw[1]['path'], '/b/%s/o/%s' % (NAME, NONESUCH))
+
     def test_copy_keys_wo_name(self):
         SOURCE = 'source'
         DEST = 'dest'