Repository: googleapis/google-cloud-python

Issue:
Storage: there is no way to remove labels from a bucket
Attempting to remove a label from a bucket does not appear to work:

```python
    # {'example': 'label'}
    labels = bucket.labels

    if 'example' in labels:
        del labels['example']

    # {}
    bucket.labels = labels

    bucket.patch()

    # Still {'example': 'label'}
    pprint.pprint(bucket.labels)
```


Discussion:
After stopping inside the `patch` call with the empty labels:

```python
(Pdb) pp update_properties
{'labels': {}}
```

stepping over the API call leaves an `api_response`:

```python
(Pdb) pp api_response
{'acl': [{'bucket': 'test-bucket-patch-1501616216661',
          'entity': 'project-owners-455332057385',
          'etag': 'CAM=',
          'id': 'test-bucket-patch-1501616216661/project-owners-455332057385',
          'kind': 'storage#bucketAccessControl',
          'projectTeam': {'projectNumber': '455332057385', 'team': 'owners'},
          'role': 'OWNER',
          'selfLink': 'https://www.googleapis.com/storage/v1/b/test-bucket-patch-1501616216661/acl/project-owners-455332057385'},
         {'bucket': 'test-bucket-patch-1501616216661',
          'entity': 'project-editors-455332057385',
          'etag': 'CAM=',
          'id': 'test-bucket-patch-1501616216661/project-editors-455332057385',
          'kind': 'storage#bucketAccessControl',
          'projectTeam': {'projectNumber': '455332057385', 'team': 'editors'},
          'role': 'OWNER',
          'selfLink': 'https://www.googleapis.com/storage/v1/b/test-bucket-patch-1501616216661/acl/project-editors-455332057385'},
         {'bucket': 'test-bucket-patch-1501616216661',
          'entity': 'project-viewers-455332057385',
          'etag': 'CAM=',
          'id': 'test-bucket-patch-1501616216661/project-viewers-455332057385',
          'kind': 'storage#bucketAccessControl',
          'projectTeam': {'projectNumber': '455332057385', 'team': 'viewers'},
          'role': 'READER',
          'selfLink': 'https://www.googleapis.com/storage/v1/b/test-bucket-patch-1501616216661/acl/project-viewers-455332057385'}],
 'defaultObjectAcl': [{'entity': 'project-owners-455332057385',
                       'etag': 'CAM=',
                       'kind': 'storage#objectAccessControl',
                       'projectTeam': {'projectNumber': '455332057385',
                                       'team': 'owners'},
                       'role': 'OWNER'},
                      {'entity': 'project-editors-455332057385',
                       'etag': 'CAM=',
                       'kind': 'storage#objectAccessControl',
                       'projectTeam': {'projectNumber': '455332057385',
                                       'team': 'editors'},
                       'role': 'OWNER'},
                      {'entity': 'project-viewers-455332057385',
                       'etag': 'CAM=',
                       'kind': 'storage#objectAccessControl',
                       'projectTeam': {'projectNumber': '455332057385',
                                       'team': 'viewers'},
                       'role': 'READER'}],
 'etag': 'CAM=',
 'id': 'test-bucket-patch-1501616216661',
 'kind': 'storage#bucket',
 'labels': {'test-label': 'label-value'},
 'location': 'US',
 'metageneration': '3',
 'name': 'test-bucket-patch-1501616216661',
 'owner': {'entity': 'project-owners-455332057385'},
 'projectNumber': '455332057385',
 'selfLink': 'https://www.googleapis.com/storage/v1/b/test-bucket-patch-1501616216661',
 'storageClass': 'STANDARD',
 'timeCreated': '2017-08-01T19:37:45.519Z',
 'updated': '2017-08-01T19:38:34.487Z'}

@tseaver I'm not sure how to process your last comment. Are you saying we're sending the right stuff to the server?
If I tweak the code to assign a different, non-empty mapping:

```python
bucket.labels = {'another-label': 'another-value'}
bucket.patch()
```

It passes the new value:

```python
(Pdb) p update_properties
{'labels': {'another-label': 'another-value'}}
```

But the back-end returns the *union* of the two:

```python
(Pdb) pp api_response['labels']
{'another-label': 'another-value', 'test-label': 'label-value'}
```
@jonparrott Yes, we are:  we send along `{'labels': {}}` as the payload for the `patch` API.
@frankyn can you figure out who to talk to about the backend? :)
This is the (failing) [system test](https://github.com/tseaver/google-cloud-python/commit/240fbe19aa74eb6f769b460f57768c982cc33018) I'm using to check out the payload (by adding `pdb`).
@dhermes Maybe *this* is the usecase which finally justifies wrapping the `buckets.update` API. ;)
What a hoot!  My guess was right!  If I hack in an `update` method:

```diff
diff --git a/storage/google/cloud/storage/_helpers.py b/storage/google/cloud/storage/_helpers.py
index 88f9b8d..56a75c6 100644
--- a/storage/google/cloud/storage/_helpers.py
+++ b/storage/google/cloud/storage/_helpers.py
@@ -147,6 +147,22 @@ class _PropertyMixin(object):
             query_params={'projection': 'full'}, _target_object=self)
         self._set_properties(api_response)
 
+    def update(self, client=None):
+        """Sends all properties in a PUT request.
+
+        Updates the ``_properties`` with the response from the backend.
+
+        :type client: :class:`~google.cloud.storage.client.Client` or
+                      ``NoneType``
+        :param client: the client to use.  If not passed, falls back to the
+                       ``client`` stored on the current object.
+        """
+        client = self._require_client(client)
+        api_response = client._connection.api_request(
+            method='PUT', path=self.path, data=self._properties,
+            query_params={'projection': 'full'}, _target_object=self)
+        self._set_properties(api_response)
+
 
 def _scalar_property(fieldname):
     """Create a property descriptor around the :class:`_PropertyMixin` helpers.
```

and tweak the system test to use it:

```diff
diff --git a/storage/tests/system.py b/storage/tests/system.py
index dfe35e3..934f761 100644
--- a/storage/tests/system.py
+++ b/storage/tests/system.py
@@ -127,11 +127,13 @@ class TestStorageBuckets(unittest.TestCase):
 
         new_labels = {'another-label': 'another-value'}
         bucket.labels = new_labels
-        bucket.patch()
+        #bucket.patch()
+        bucket.update()
         self.assertEqual(bucket.labels, new_labels)
 
         bucket.labels = {}
-        bucket.patch()
+        #bucket.patch()
+        bucket.update()
         self.assertEqual(bucket.labels, {})
```

it passes!
Maybe something in the [patch semantics docs](https://cloud.google.com/storage/docs/json_api/v1/how-tos/performance#patch) would explain this?
I think it's definitely because of patch semantics. Adding `bucket.update` and using that in the sample instead of `patch` seems like a reasonable way to solve this.

Resolving patch:
diff --git a/storage/google/cloud/storage/_helpers.py b/storage/google/cloud/storage/_helpers.py
--- a/storage/google/cloud/storage/_helpers.py
+++ b/storage/google/cloud/storage/_helpers.py
@@ -142,6 +142,8 @@ def patch(self, client=None):
         # to work properly w/ 'noAcl'.
         update_properties = {key: self._properties[key]
                              for key in self._changes}
+
+        # Make the API call.
         api_response = client._connection.api_request(
             method='PATCH', path=self.path, data=update_properties,
             query_params={'projection': 'full'}, _target_object=self)
diff --git a/storage/google/cloud/storage/bucket.py b/storage/google/cloud/storage/bucket.py
--- a/storage/google/cloud/storage/bucket.py
+++ b/storage/google/cloud/storage/bucket.py
@@ -115,6 +115,7 @@ def __init__(self, client, name=None):
         self._client = client
         self._acl = BucketACL(self)
         self._default_object_acl = DefaultObjectACL(self)
+        self._label_removals = set()
 
     def __repr__(self):
         return '<Bucket: %s>' % (self.name,)
@@ -124,6 +125,15 @@ def client(self):
         """The client bound to this bucket."""
         return self._client
 
+    def _set_properties(self, value):
+        """Set the properties for the current object.
+
+        :type value: dict or :class:`google.cloud.storage.batch._FutureDict`
+        :param value: The properties to be set.
+        """
+        self._label_removals.clear()
+        return super(Bucket, self)._set_properties(value)
+
     def blob(self, blob_name, chunk_size=None, encryption_key=None):
         """Factory constructor for blob object.
 
@@ -199,6 +209,27 @@ def create(self, client=None):
             data=properties, _target_object=self)
         self._set_properties(api_response)
 
+    def patch(self, client=None):
+        """Sends all changed properties in a PATCH request.
+
+        Updates the ``_properties`` with the response from the backend.
+
+        :type client: :class:`~google.cloud.storage.client.Client` or
+                      ``NoneType``
+        :param client: the client to use.  If not passed, falls back to the
+                       ``client`` stored on the current object.
+        """
+        # Special case: For buckets, it is possible that labels are being
+        # removed; this requires special handling.
+        if self._label_removals:
+            self._changes.add('labels')
+            self._properties.setdefault('labels', {})
+            for removed_label in self._label_removals:
+                self._properties['labels'][removed_label] = None
+
+        # Call the superclass method.
+        return super(Bucket, self).patch(client=client)
+
     @property
     def acl(self):
         """Create our ACL on demand."""
@@ -619,6 +650,15 @@ def labels(self, mapping):
         :type mapping: :class:`dict`
         :param mapping: Name-value pairs (string->string) labelling the bucket.
         """
+        # If any labels have been expressly removed, we need to track this
+        # so that a future .patch() call can do the correct thing.
+        existing = set([k for k in self.labels.keys()])
+        incoming = set([k for k in mapping.keys()])
+        self._label_removals = self._label_removals.union(
+            existing.difference(incoming),
+        )
+
+        # Actually update the labels on the object.
         self._patch_property('labels', copy.deepcopy(mapping))
 
     @property


Test patch:
diff --git a/storage/tests/system.py b/storage/tests/system.py
--- a/storage/tests/system.py
+++ b/storage/tests/system.py
@@ -127,7 +127,7 @@ def test_bucket_update_labels(self):
 
         new_labels = {'another-label': 'another-value'}
         bucket.labels = new_labels
-        bucket.update()
+        bucket.patch()
         self.assertEqual(bucket.labels, new_labels)
 
         bucket.labels = {}
diff --git a/storage/tests/unit/test_bucket.py b/storage/tests/unit/test_bucket.py
--- a/storage/tests/unit/test_bucket.py
+++ b/storage/tests/unit/test_bucket.py
@@ -710,6 +710,33 @@ def test_labels_setter(self):
         self.assertIsNot(bucket._properties['labels'], LABELS)
         self.assertIn('labels', bucket._changes)
 
+    def test_labels_setter_with_removal(self):
+        # Make sure the bucket labels look correct and follow the expected
+        # public structure.
+        bucket = self._make_one(name='name')
+        self.assertEqual(bucket.labels, {})
+        bucket.labels = {'color': 'red', 'flavor': 'cherry'}
+        self.assertEqual(bucket.labels, {'color': 'red', 'flavor': 'cherry'})
+        bucket.labels = {'color': 'red'}
+        self.assertEqual(bucket.labels, {'color': 'red'})
+
+        # Make sure that a patch call correctly removes the flavor label.
+        client = mock.NonCallableMock(spec=('_connection',))
+        client._connection = mock.NonCallableMock(spec=('api_request',))
+        bucket.patch(client=client)
+        client._connection.api_request.assert_called()
+        _, _, kwargs = client._connection.api_request.mock_calls[0]
+        self.assertEqual(len(kwargs['data']['labels']), 2)
+        self.assertEqual(kwargs['data']['labels']['color'], 'red')
+        self.assertIsNone(kwargs['data']['labels']['flavor'])
+
+        # A second patch call should be a no-op for labels.
+        client._connection.api_request.reset_mock()
+        bucket.patch(client=client)
+        client._connection.api_request.assert_called()
+        _, _, kwargs = client._connection.api_request.mock_calls[0]
+        self.assertNotIn('labels', kwargs['data'])
+
     def test_get_logging_w_prefix(self):
         NAME = 'name'
         LOG_BUCKET = 'logs'