Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: resource tags in dataset #2090

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
23 changes: 23 additions & 0 deletions google/cloud/bigquery/dataset.py
Original file line number Diff line number Diff line change
Expand Up @@ -530,6 +530,7 @@ class Dataset(object):
"storage_billing_model": "storageBillingModel",
"max_time_travel_hours": "maxTimeTravelHours",
"default_rounding_mode": "defaultRoundingMode",
"resource_tags": "resourceTags",
}

def __init__(self, dataset_ref) -> None:
Expand Down Expand Up @@ -801,6 +802,28 @@ def labels(self, value):
raise ValueError("Pass a dict")
self._properties["labels"] = value

@property
def resource_tags(self):
"""Dict[str, str]: Resource tags of the dataset.

Optional. The tags attached to this dataset. Tag keys are globally
unique. Tag key is expected to be in the namespaced format, for
example "123456789012/environment" where 123456789012 is
the ID of the parent organization or project resource for this tag
key. Tag value is expected to be the short name, for example
"Production".

Raises:
ValueError: for invalid value types.
"""
return self._properties.setdefault("resourceTags", {})

@resource_tags.setter
def resource_tags(self, value):
if not isinstance(value, dict) and value is not None:
raise ValueError("Pass a dict")
self._properties["resourceTags"] = value

@property
def default_encryption_configuration(self):
"""google.cloud.bigquery.encryption_configuration.EncryptionConfiguration: Custom
Expand Down
4 changes: 4 additions & 0 deletions noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,9 @@ def system(session):
# Data Catalog needed for the column ACL test with a real Policy Tag.
session.install("google-cloud-datacatalog", "-c", constraints_path)

# Resource Manager needed for test with a real Resource Tag.
session.install("google-cloud-resource-manager", "-c", constraints_path)

if session.python in ["3.11", "3.12"]:
extras = "[bqstorage,ipywidgets,pandas,tqdm,opentelemetry]"
else:
Expand Down Expand Up @@ -366,6 +369,7 @@ def prerelease_deps(session):
session.install(
"freezegun",
"google-cloud-datacatalog",
"google-cloud-resource-manager",
"google-cloud-storage",
"google-cloud-testutils",
"psutil",
Expand Down
26 changes: 24 additions & 2 deletions tests/system/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@
from google.cloud import storage
from google.cloud.datacatalog_v1 import types as datacatalog_types
from google.cloud.datacatalog_v1 import PolicyTagManagerClient
from google.cloud.resourcemanager_v3 import types as resourcemanager_types
from google.cloud.resourcemanager_v3 import TagKeysClient, TagValuesClient
import psutil
import pytest
from test_utils.retry import RetryErrors
Expand Down Expand Up @@ -159,6 +161,8 @@ def setUp(self):

def tearDown(self):
policy_tag_client = PolicyTagManagerClient()
tag_keys_client = TagKeysClient()
tag_values_client = TagValuesClient()

def _still_in_use(bad_request):
return any(
Expand All @@ -178,6 +182,10 @@ def _still_in_use(bad_request):
retry_in_use(Config.CLIENT.delete_table)(doomed)
elif isinstance(doomed, datacatalog_types.Taxonomy):
policy_tag_client.delete_taxonomy(name=doomed.name)
elif isinstance(doomed, resourcemanager_types.TagKey):
tag_keys_client.delete_tag_key(name=doomed.name).result()
elif isinstance(doomed, resourcemanager_types.TagValue):
tag_values_client.delete_tag_value(name=doomed.name).result()
else:
doomed.delete()

Expand Down Expand Up @@ -284,27 +292,41 @@ def test_update_dataset(self):
self.assertIsNone(dataset.friendly_name)
self.assertIsNone(dataset.description)
self.assertEqual(dataset.labels, {})
self.assertEqual(dataset.resource_tags, {})
self.assertIs(dataset.is_case_insensitive, False)

dataset.friendly_name = "Friendly"
dataset.description = "Description"
dataset.labels = {"priority": "high", "color": "blue"}
dataset.resource_tags = {"123456789012/env": "prod", "123456789012/component": "batch"}
dataset.is_case_insensitive = True
ds2 = Config.CLIENT.update_dataset(
dataset, ("friendly_name", "description", "labels", "is_case_insensitive")
dataset, ("friendly_name", "description", "labels", "resource_tags", "is_case_insensitive")
)
self.assertEqual(ds2.friendly_name, "Friendly")
self.assertEqual(ds2.description, "Description")
self.assertEqual(ds2.labels, {"priority": "high", "color": "blue"})
self.assertEqual(ds2.resource_tags, {"123456789012/env": "prod", "123456789012/component": "batch"})
self.assertIs(ds2.is_case_insensitive, True)

ds2.labels = {
"color": "green", # change
"shape": "circle", # add
"priority": None, # delete
}
ds3 = Config.CLIENT.update_dataset(ds2, ["labels"])
ds2.resource_tags = {
"123456789012/env": "dev", # change
"123456789012/project": "atlas", # add
"123456789012/component": None, # delete
}
ds3 = Config.CLIENT.update_dataset(ds2, ["labels", "resource_tags"])
self.assertEqual(ds3.labels, {"color": "green", "shape": "circle"})
self.assertEqual(ds3.resource_tags, {"123456789012/env": "dev", "123456789012/project": "atlas"})

# Remove all tags
ds3.resource_tags = None
ds4 = Config.CLIENT.update_table(ds3, ["resource_tags"])
self.assertEqual(ds4.resource_tags, {})

# If we try to update using d2 again, it will fail because the
# previous update changed the ETag.
Expand Down
6 changes: 6 additions & 0 deletions tests/unit/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2028,6 +2028,7 @@ def test_update_dataset(self):
LABELS = {"priority": "high"}
ACCESS = [{"role": "OWNER", "userByEmail": "[email protected]"}]
EXP = 17
RESOURCE_TAGS = {"123456789012/key": "value"}
RESOURCE = {
"datasetReference": {"projectId": self.PROJECT, "datasetId": self.DS_ID},
"etag": "etag",
Expand All @@ -2037,6 +2038,7 @@ def test_update_dataset(self):
"defaultTableExpirationMs": EXP,
"labels": LABELS,
"access": ACCESS,
"resourceTags": RESOURCE_TAGS,
}
creds = _make_credentials()
client = self._make_one(project=self.PROJECT, credentials=creds)
Expand All @@ -2048,12 +2050,14 @@ def test_update_dataset(self):
ds.default_table_expiration_ms = EXP
ds.labels = LABELS
ds.access_entries = [AccessEntry("OWNER", "userByEmail", "[email protected]")]
ds.resource_tags = RESOURCE_TAGS
fields = [
"description",
"friendly_name",
"location",
"labels",
"access_entries",
"resource_tags",
]

with mock.patch(
Expand All @@ -2077,6 +2081,7 @@ def test_update_dataset(self):
"location": LOCATION,
"labels": LABELS,
"access": ACCESS,
"resourceTags": RESOURCE_TAGS,
},
path="/" + PATH,
timeout=7.5,
Expand All @@ -2086,6 +2091,7 @@ def test_update_dataset(self):
self.assertEqual(ds2.location, ds.location)
self.assertEqual(ds2.labels, ds.labels)
self.assertEqual(ds2.access_entries, ds.access_entries)
self.assertEqual(ds2.resource_tags, ds.resource_tags)

# ETag becomes If-Match header.
ds._properties["etag"] = "etag"
Expand Down
5 changes: 5 additions & 0 deletions tests/unit/test_create_dataset.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ def test_create_dataset_w_attrs(client, PROJECT, DS_ID):
"tableId": "northern-hemisphere",
}
DEFAULT_ROUNDING_MODE = "ROUND_HALF_EVEN"
RESOURCE_TAGS = {"123456789012/foo": "bar"}
RESOURCE = {
"datasetReference": {"projectId": PROJECT, "datasetId": DS_ID},
"etag": "etag",
Expand All @@ -76,6 +77,7 @@ def test_create_dataset_w_attrs(client, PROJECT, DS_ID):
"labels": LABELS,
"access": [{"role": "OWNER", "userByEmail": USER_EMAIL}, {"view": VIEW}],
"defaultRoundingMode": DEFAULT_ROUNDING_MODE,
"resourceTags": RESOURCE_TAGS,
}
conn = client._connection = make_connection(RESOURCE)
entries = [
Expand All @@ -91,6 +93,7 @@ def test_create_dataset_w_attrs(client, PROJECT, DS_ID):
before.default_table_expiration_ms = 3600
before.location = LOCATION
before.labels = LABELS
before.resource_tags = RESOURCE_TAGS
before.default_rounding_mode = DEFAULT_ROUNDING_MODE
after = client.create_dataset(before)
assert after.dataset_id == DS_ID
Expand All @@ -103,6 +106,7 @@ def test_create_dataset_w_attrs(client, PROJECT, DS_ID):
assert after.default_table_expiration_ms == 3600
assert after.labels == LABELS
assert after.default_rounding_mode == DEFAULT_ROUNDING_MODE
assert after.resource_tags == RESOURCE_TAGS

conn.api_request.assert_called_once_with(
method="POST",
Expand All @@ -119,6 +123,7 @@ def test_create_dataset_w_attrs(client, PROJECT, DS_ID):
{"view": VIEW, "role": None},
],
"labels": LABELS,
"resourceTags": RESOURCE_TAGS,
},
timeout=DEFAULT_TIMEOUT,
)
Expand Down
22 changes: 22 additions & 0 deletions tests/unit/test_dataset.py
Original file line number Diff line number Diff line change
Expand Up @@ -894,6 +894,28 @@ def test_location_setter(self):
dataset.location = "LOCATION"
self.assertEqual(dataset.location, "LOCATION")

def test_resource_tags_update_in_place(self):
dataset = self._make_one(self.DS_REF)
tags = dataset.resource_tags
tags["123456789012/foo"] = "bar" # update in place
self.assertEqual(dataset.resource_tags, {"123456789012/foo": "bar"})

def test_resource_tags_setter(self):
dataset = self._make_one(self.DS_REF)
dataset.resource_tags = {"123456789012/foo": "bar"}
self.assertEqual(dataset.resource_tags, {"123456789012/foo": "bar"})

def test_resource_tags_setter_bad_value(self):
dataset = self._make_one(self.DS_REF)
with self.assertRaises(ValueError):
dataset.resource_tags = "invalid"
with self.assertRaises(ValueError):
dataset.resource_tags = 123

def test_resource_tags_getter_missing_value(self):
dataset = self._make_one(self.DS_REF)
self.assertEqual(dataset.resource_tags, {})

def test_labels_update_in_place(self):
dataset = self._make_one(self.DS_REF)
del dataset._properties["labels"] # don't start w/ existing dict
Expand Down