Repository: googleapis/google-cloud-python

Issue:
BigQuery: add 'labels' properties to job and job config classes
See the [API ref](https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs#configuration.labels)

- Each job config class should have a writable `labels` property, taking / returning a mapping, `{str: str}`.

- Each job class should have a read-only `lables` property, returning a mapping, `{str: str}`.


Discussion:

Resolving patch:
diff --git a/bigquery/google/cloud/bigquery/dataset.py b/bigquery/google/cloud/bigquery/dataset.py
--- a/bigquery/google/cloud/bigquery/dataset.py
+++ b/bigquery/google/cloud/bigquery/dataset.py
@@ -473,7 +473,7 @@ def labels(self):
         Raises:
             ValueError: for invalid value types.
         """
-        return self._properties.get('labels', {})
+        return self._properties.setdefault('labels', {})
 
     @labels.setter
     def labels(self, value):
@@ -630,7 +630,7 @@ def friendly_name(self):
     @property
     def labels(self):
         """Dict[str, str]: Labels for the dataset."""
-        return self._properties.get('labels', {})
+        return self._properties.setdefault('labels', {})
 
     @property
     def reference(self):
diff --git a/bigquery/google/cloud/bigquery/job.py b/bigquery/google/cloud/bigquery/job.py
--- a/bigquery/google/cloud/bigquery/job.py
+++ b/bigquery/google/cloud/bigquery/job.py
@@ -340,6 +340,11 @@ def path(self):
         """
         return '/projects/%s/jobs/%s' % (self.project, self.job_id)
 
+    @property
+    def labels(self):
+        """Dict[str, str]: Labels for the job."""
+        return self._properties.setdefault('labels', {})
+
     @property
     def etag(self):
         """ETag for the job resource.
@@ -702,6 +707,25 @@ def __init__(self, job_type):
         self._job_type = job_type
         self._properties = {job_type: {}}
 
+    @property
+    def labels(self):
+        """Dict[str, str]: Labels for the job.
+
+        This method always returns a dict. To change a job's labels,
+        modify the dict, then call ``Client.update_job``. To delete a
+        label, set its value to :data:`None` before updating.
+
+        Raises:
+            ValueError: If ``value`` type is invalid.
+        """
+        return self._properties.setdefault('labels', {})
+
+    @labels.setter
+    def labels(self, value):
+        if not isinstance(value, dict):
+            raise ValueError("Pass a dict")
+        self._properties['labels'] = value
+
     def _get_sub_prop(self, key, default=None):
         """Get a value in the ``self._properties[self._job_type]`` dictionary.
 
diff --git a/bigquery/google/cloud/bigquery/table.py b/bigquery/google/cloud/bigquery/table.py
--- a/bigquery/google/cloud/bigquery/table.py
+++ b/bigquery/google/cloud/bigquery/table.py
@@ -362,7 +362,7 @@ def labels(self):
         Raises:
             ValueError: If ``value`` type is invalid.
         """
-        return self._properties.get('labels', {})
+        return self._properties.setdefault('labels', {})
 
     @labels.setter
     def labels(self, value):
@@ -830,7 +830,7 @@ def labels(self):
         modify the dict, then call ``Client.update_table``. To delete a
         label, set its value to :data:`None` before updating.
         """
-        return self._properties.get('labels', {})
+        return self._properties.setdefault('labels', {})
 
     @property
     def full_table_id(self):


Test patch:
diff --git a/bigquery/tests/unit/test_dataset.py b/bigquery/tests/unit/test_dataset.py
--- a/bigquery/tests/unit/test_dataset.py
+++ b/bigquery/tests/unit/test_dataset.py
@@ -443,6 +443,13 @@ def test_location_setter(self):
         dataset.location = 'LOCATION'
         self.assertEqual(dataset.location, 'LOCATION')
 
+    def test_labels_update_in_place(self):
+        dataset = self._make_one(self.DS_REF)
+        del dataset._properties['labels']  # don't start w/ existing dict
+        labels = dataset.labels
+        labels['foo'] = 'bar'  # update in place
+        self.assertEqual(dataset.labels, {'foo': 'bar'})
+
     def test_labels_setter(self):
         dataset = self._make_one(self.DS_REF)
         dataset.labels = {'color': 'green'}
@@ -613,6 +620,18 @@ def test_ctor_wo_reference(self):
         with self.assertRaises(ValueError):
             self._make_one({})
 
+    def test_labels_update_in_place(self):
+        resource = {
+            'datasetReference': {
+                'projectId': 'testproject',
+                'datasetId': 'testdataset',
+            },
+        }
+        dataset = self._make_one(resource)
+        labels = dataset.labels
+        labels['foo'] = 'bar'  # update in place
+        self.assertEqual(dataset.labels, {'foo': 'bar'})
+
     def test_table(self):
         from google.cloud.bigquery.table import TableReference
 
diff --git a/bigquery/tests/unit/test_job.py b/bigquery/tests/unit/test_job.py
--- a/bigquery/tests/unit/test_job.py
+++ b/bigquery/tests/unit/test_job.py
@@ -200,6 +200,27 @@ def test_job_type(self):
 
         self.assertEqual(derived.job_type, 'derived')
 
+    def test_labels_miss(self):
+        client = _make_client(project=self.PROJECT)
+        job = self._make_one(self.JOB_ID, client)
+        self.assertEqual(job.labels, {})
+
+    def test_labels_update_in_place(self):
+        client = _make_client(project=self.PROJECT)
+        job = self._make_one(self.JOB_ID, client)
+        labels = job.labels
+        labels['foo'] = 'bar'  # update in place
+        self.assertEqual(job.labels, {'foo': 'bar'})
+
+    def test_labels_hit(self):
+        labels = {
+            'foo': 'bar',
+        }
+        client = _make_client(project=self.PROJECT)
+        job = self._make_one(self.JOB_ID, client)
+        job._properties['labels'] = labels
+        self.assertEqual(job.labels, labels)
+
     def test_etag(self):
         etag = 'ETAG-123'
         client = _make_client(project=self.PROJECT)
@@ -916,6 +937,38 @@ def test_to_api_repr(self):
     # 'from_api_repr' cannot be tested on '_JobConfig', because it presumes
     # the ctor can be called w/o arguments
 
+    def test_labels_miss(self):
+        job_config = self._make_one()
+        self.assertEqual(job_config.labels, {})
+
+    def test_labels_update_in_place(self):
+        job_config = self._make_one()
+        labels = job_config.labels
+        labels['foo'] = 'bar'  # update in place
+        self.assertEqual(job_config.labels, {'foo': 'bar'})
+
+    def test_labels_hit(self):
+        labels = {
+            'foo': 'bar',
+        }
+        job_config = self._make_one()
+        job_config._properties['labels'] = labels
+        self.assertEqual(job_config.labels, labels)
+
+    def test_labels_setter_invalid(self):
+        labels = object()
+        job_config = self._make_one()
+        with self.assertRaises(ValueError):
+            job_config.labels = labels
+
+    def test_labels_setter(self):
+        labels = {
+            'foo': 'bar',
+        }
+        job_config = self._make_one()
+        job_config.labels = labels
+        self.assertEqual(job_config._properties['labels'], labels)
+
 
 class _Base(object):
     from google.cloud.bigquery.dataset import DatasetReference
diff --git a/bigquery/tests/unit/test_table.py b/bigquery/tests/unit/test_table.py
--- a/bigquery/tests/unit/test_table.py
+++ b/bigquery/tests/unit/test_table.py
@@ -647,6 +647,15 @@ def test_external_data_configuration_setter_bad_value(self):
         with self.assertRaises(ValueError):
             table.external_data_configuration = 12345
 
+    def test_labels_update_in_place(self):
+        dataset = DatasetReference(self.PROJECT, self.DS_ID)
+        table_ref = dataset.table(self.TABLE_NAME)
+        table = self._make_one(table_ref)
+        del table._properties['labels']  # don't start w/ existing dict
+        labels = table.labels
+        labels['foo'] = 'bar'  # update in place
+        self.assertEqual(table.labels, {'foo': 'bar'})
+
     def test_labels_setter_bad_value(self):
         dataset = DatasetReference(self.PROJECT, self.DS_ID)
         table_ref = dataset.table(self.TABLE_NAME)
@@ -1093,6 +1102,19 @@ def test_ctor_wo_reference(self):
         with self.assertRaises(ValueError):
             self._make_one({})
 
+    def test_labels_update_in_place(self):
+        resource = {
+            'tableReference': {
+                'projectId': 'testproject',
+                'datasetId': 'testdataset',
+                'tableId': 'testtable',
+            },
+        }
+        table = self._make_one(resource)
+        labels = table.labels
+        labels['foo'] = 'bar'  # update in place
+        self.assertEqual(table.labels, {'foo': 'bar'})
+
 
 class TestRow(unittest.TestCase):