Skip to content
This repository was archived by the owner on Mar 6, 2026. It is now read-only.

Commit f41df12

Browse files
committed
Adds condition attr to AccessEntry and unit tests
1 parent 04fdc8e commit f41df12

2 files changed

Lines changed: 292 additions & 23 deletions

File tree

google/cloud/bigquery/dataset.py

Lines changed: 40 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -443,9 +443,8 @@ def special_group(self, value):
443443
@property
444444
def condition(self) -> Optional["Condition"]:
445445
"""Optional[Condition]: The IAM condition associated with this entry."""
446-
value = self._properties.get("condition")
447-
if value:
448-
return Condition.from_api_repr(value)
446+
value = typing.cast(Dict[str, Any], self._properties.get("condition"))
447+
return Condition.from_api_repr(value) if value else None
449448

450449
@condition.setter
451450
def condition(self, value: Union["Condition", dict, None]):
@@ -467,7 +466,10 @@ def entity_type(self) -> Optional[str]:
467466
@property
468467
def entity_id(self) -> Optional[Union[Dict[str, Any], str]]:
469468
"""The entity_id of the entry."""
470-
return self._properties.get(self._entity_type) if self._entity_type else None
469+
return typing.cast(
470+
Optional[Union[Dict[str, Any], str]],
471+
self._properties.get(self._entity_type) if self._entity_type else None,
472+
)
471473

472474
def __eq__(self, other):
473475
if not isinstance(other, AccessEntry):
@@ -486,14 +488,20 @@ def _key(self):
486488
Returns:
487489
Tuple: The contents of this :class:`~google.cloud.bigquery.dataset.AccessEntry`.
488490
"""
491+
489492
properties = self._properties.copy()
493+
494+
# Dicts are not hashable.
495+
# Convert condition to a hashable datatype(s)
496+
condition = properties.get("condition")
497+
if isinstance(condition, dict):
498+
condition_key = tuple(sorted(condition.items()))
499+
properties["condition"] = condition_key
500+
490501
prop_tup = tuple(sorted(properties.items()))
491502
return (self.role, self._entity_type, self.entity_id, prop_tup)
492503

493504
def __hash__(self):
494-
# TODO: if a dict is a sub property, hash fails.
495-
print(f"DINOSAUR: {self._key()}")
496-
497505
return hash(self._key())
498506

499507
def to_api_repr(self):
@@ -522,13 +530,31 @@ def from_api_repr(cls, resource: dict) -> "AccessEntry":
522530
If the resource has more keys than ``role`` and one additional
523531
key.
524532
"""
533+
534+
# The api_repr for an AccessEntry object is expected to be a dict with
535+
# only a few keys. Two keys that may be present are role and condition.
536+
# Any additional key is going to have one of ~eight different names:
537+
# userByEmail, groupByEmail, domain, dataset, specialGroup, view,
538+
# routine, iamMember
539+
#
540+
# First we pop role and condition out of the dict, if present.
541+
# This should leave only one item in the dict which will be a key: value
542+
# pair that will be assigned to entity_type and entity_id respectively.
543+
525544
entry = resource.copy()
526545
role = entry.pop("role", None)
527-
entity_type, entity_id = entry.popitem()
528-
if len(entry) != 0:
529-
raise ValueError("Entry has unexpected keys remaining.", entry)
530-
531-
return cls(role, entity_type, entity_id)
546+
condition = entry.pop("condition", None)
547+
try:
548+
entity_type, entity_id = entry.popitem()
549+
except KeyError:
550+
entity_type = None
551+
entity_id = None
552+
553+
if condition:
554+
access_entry = cls(role, entity_type, entity_id, condition=condition)
555+
else:
556+
access_entry = cls(role, entity_type, entity_id)
557+
return access_entry
532558

533559

534560
class Dataset(object):
@@ -1185,8 +1211,8 @@ def from_api_repr(cls, resource: Dict[str, Any]) -> "Condition":
11851211

11861212
return cls(
11871213
expression=resource["expression"],
1188-
title=resource.get("title"),
1189-
description=resource.get("description"),
1214+
title=resource.get("title", None),
1215+
description=resource.get("description", None),
11901216
)
11911217

11921218
def __eq__(self, other: object) -> bool:

tests/unit/test_dataset.py

Lines changed: 252 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -179,15 +179,6 @@ def test_to_api_repr_w_extra_properties(self):
179179
exp_resource = entry.to_api_repr()
180180
self.assertEqual(resource, exp_resource)
181181

182-
def test_from_api_repr_entries_w_extra_keys(self):
183-
resource = {
184-
"role": "READER",
185-
"specialGroup": "projectReaders",
186-
"userByEmail": "salmon@example.com",
187-
}
188-
with self.assertRaises(ValueError):
189-
self._get_target_class().from_api_repr(resource)
190-
191182
def test_view_getter_setter(self):
192183
view = {
193184
"projectId": "my_project",
@@ -494,6 +485,258 @@ def test_dataset_target_types_getter_setter_w_dataset(self):
494485
self.assertEqual(entry.dataset_target_types, target_types)
495486

496487

488+
# --- Tests for AccessEntry when using Condition ---
489+
490+
EXPRESSION = "request.time < timestamp('2026-01-01T00:00:00Z')"
491+
TITLE = "Expires end 2025"
492+
DESCRIPTION = "Access expires at the start of 2026."
493+
494+
495+
@pytest.fixture
496+
def condition_1():
497+
"""Provides a sample Condition object."""
498+
return Condition(
499+
expression=EXPRESSION,
500+
title=TITLE,
501+
description=DESCRIPTION,
502+
)
503+
504+
505+
@pytest.fixture
506+
def condition_1_api_repr():
507+
"""Provides the API representation for condition_1."""
508+
# Use the actual to_api_repr method
509+
return Condition(
510+
expression=EXPRESSION,
511+
title=TITLE,
512+
description=DESCRIPTION,
513+
).to_api_repr()
514+
515+
516+
@pytest.fixture
517+
def condition_2():
518+
"""Provides a second, different Condition object."""
519+
return Condition(
520+
expression="resource.name.startsWith('projects/_/buckets/restricted/')",
521+
title="Restricted Buckets",
522+
)
523+
524+
525+
@pytest.fixture
526+
def condition_2_api_repr():
527+
"""Provides the API representation for condition2."""
528+
# Use the actual to_api_repr method
529+
return Condition(
530+
expression="resource.name.startsWith('projects/_/buckets/restricted/')",
531+
title="Restricted Buckets",
532+
).to_api_repr()
533+
534+
535+
class TestAccessEntryAndCondition:
536+
@staticmethod
537+
def _get_target_class():
538+
return AccessEntry
539+
540+
def _make_one(self, *args, **kw):
541+
return self._get_target_class()(*args, **kw)
542+
543+
# Test __init__ without condition
544+
def test_init_without_condition(self):
545+
entry = AccessEntry("READER", "userByEmail", "test@example.com")
546+
assert entry.role == "READER"
547+
assert entry.entity_type == "userByEmail"
548+
assert entry.entity_id == "test@example.com"
549+
assert entry.condition is None
550+
# Accessing _properties is for internal verification in tests
551+
assert "condition" not in entry._properties
552+
553+
# Test __init__ with condition object
554+
def test_init_with_condition_object(self, condition_1, condition_1_api_repr):
555+
entry = AccessEntry(
556+
"READER", "userByEmail", "test@example.com", condition=condition_1
557+
)
558+
assert entry.condition == condition_1
559+
assert entry._properties.get("condition") == condition_1_api_repr
560+
561+
# Test __init__ with condition=None
562+
def test_init_with_condition_none(self):
563+
entry = AccessEntry("READER", "userByEmail", "test@example.com", condition=None)
564+
assert entry.condition is None
565+
566+
# Test condition getter/setter
567+
def test_condition_getter_setter(
568+
self, condition_1, condition_1_api_repr, condition_2, condition_2_api_repr
569+
):
570+
entry = AccessEntry("WRITER", "group", "admins@example.com")
571+
assert entry.condition is None
572+
573+
# Set condition 1
574+
entry.condition = condition_1
575+
assert entry.condition.to_api_repr() == condition_1_api_repr
576+
assert entry._properties.get("condition") == condition_1_api_repr
577+
578+
# Set condition 2
579+
entry.condition = condition_2
580+
assert entry.condition.to_api_repr() == condition_2_api_repr
581+
assert entry._properties.get("condition") != condition_1_api_repr
582+
assert entry._properties.get("condition") == condition_2.to_api_repr()
583+
584+
# Set back to None
585+
entry.condition = None
586+
assert entry.condition is None
587+
588+
# Test setter validation
589+
def test_condition_setter_invalid_type(self):
590+
entry = AccessEntry("READER", "domain", "example.com")
591+
with pytest.raises(
592+
TypeError, match="condition must be a Condition object, dict, or None"
593+
):
594+
entry.condition = 123 # type: ignore
595+
596+
# Test equality/hash without condition
597+
def test_equality_and_hash_without_condition(self):
598+
entry1 = AccessEntry("OWNER", "specialGroup", "projectOwners")
599+
entry2 = AccessEntry("OWNER", "specialGroup", "projectOwners")
600+
entry3 = AccessEntry("WRITER", "specialGroup", "projectOwners")
601+
assert entry1 == entry2
602+
assert entry1 != entry3
603+
assert hash(entry1) == hash(entry2)
604+
assert hash(entry1) != hash(entry3) # Usually true
605+
606+
def test_equality_and_hash_with_condition(self, condition_1, condition_2):
607+
cond1a = Condition(
608+
condition_1.expression, condition_1.title, condition_1.description
609+
)
610+
cond1b = Condition(
611+
condition_1.expression, condition_1.title, condition_1.description
612+
) # Same values, different object
613+
614+
entry1a = AccessEntry(
615+
"READER", "userByEmail", "a@example.com", condition=cond1a
616+
)
617+
entry1b = AccessEntry(
618+
"READER", "userByEmail", "a@example.com", condition=cond1b
619+
) # Different Condition instance
620+
entry2 = AccessEntry(
621+
"READER", "userByEmail", "a@example.com", condition=condition_2
622+
)
623+
entry3 = AccessEntry("READER", "userByEmail", "a@example.com") # No condition
624+
entry4 = AccessEntry(
625+
"WRITER", "userByEmail", "a@example.com", condition=cond1a
626+
) # Different role
627+
628+
assert entry1a == entry1b
629+
assert entry1a != entry2
630+
assert entry1a != entry3
631+
assert entry1a != entry4
632+
assert entry2 != entry3
633+
634+
assert hash(entry1a) == hash(entry1b)
635+
assert hash(entry1a) != hash(entry2) # Usually true
636+
assert hash(entry1a) != hash(entry3) # Usually true
637+
assert hash(entry1a) != hash(entry4) # Usually true
638+
639+
# Test to_api_repr with condition
640+
def test_to_api_repr_with_condition(self, condition_1, condition_1_api_repr):
641+
entry = AccessEntry(
642+
"WRITER", "groupByEmail", "editors@example.com", condition=condition_1
643+
)
644+
expected_repr = {
645+
"role": "WRITER",
646+
"groupByEmail": "editors@example.com",
647+
"condition": condition_1_api_repr,
648+
}
649+
assert entry.to_api_repr() == expected_repr
650+
651+
def test_view_property_with_condition(self, condition_1):
652+
"""Test setting/getting view property when condition is present."""
653+
entry = AccessEntry(role=None, entity_type="view", condition=condition_1)
654+
view_ref = TableReference(DatasetReference("proj", "dset"), "view_tbl")
655+
entry.view = view_ref # Use the setter
656+
assert entry.view == view_ref
657+
assert entry.condition == condition_1 # Condition should persist
658+
assert entry.role is None
659+
assert entry.entity_type == "view"
660+
661+
# Check internal representation
662+
assert "view" in entry._properties
663+
assert "condition" in entry._properties
664+
665+
def test_user_by_email_property_with_condition(self, condition_1):
666+
"""Test setting/getting user_by_email property when condition is present."""
667+
entry = AccessEntry(
668+
role="READER", entity_type="userByEmail", condition=condition_1
669+
)
670+
email = "test@example.com"
671+
entry.user_by_email = email # Use the setter
672+
assert entry.user_by_email == email
673+
assert entry.condition == condition_1 # Condition should persist
674+
assert entry.role == "READER"
675+
assert entry.entity_type == "userByEmail"
676+
677+
# Check internal representation
678+
assert "userByEmail" in entry._properties
679+
assert "condition" in entry._properties
680+
681+
# Test from_api_repr without condition
682+
def test_from_api_repr_without_condition(self):
683+
api_repr = {"role": "OWNER", "userByEmail": "owner@example.com"}
684+
entry = AccessEntry.from_api_repr(api_repr)
685+
assert entry.role == "OWNER"
686+
assert entry.entity_type == "userByEmail"
687+
assert entry.entity_id == "owner@example.com"
688+
assert entry.condition is None
689+
690+
# Test from_api_repr with condition
691+
def test_from_api_repr_with_condition(self, condition_1, condition_1_api_repr):
692+
api_repr = {
693+
"role": "READER",
694+
"view": {"projectId": "p", "datasetId": "d", "tableId": "v"},
695+
"condition": condition_1_api_repr,
696+
}
697+
entry = AccessEntry.from_api_repr(api_repr)
698+
assert entry.role == "READER"
699+
assert entry.entity_type == "view"
700+
# The entity_id for view/routine/dataset is the dict itself
701+
assert entry.entity_id == {"projectId": "p", "datasetId": "d", "tableId": "v"}
702+
assert entry.condition == condition_1
703+
704+
# Test from_api_repr edge case
705+
def test_from_api_repr_no_entity(self, condition_1, condition_1_api_repr):
706+
api_repr = {"role": "READER", "condition": condition_1_api_repr}
707+
entry = AccessEntry.from_api_repr(api_repr)
708+
assert entry.role == "READER"
709+
assert entry.entity_type is None
710+
assert entry.entity_id is None
711+
assert entry.condition == condition_1
712+
713+
def test_dataset_property_with_condition(self, condition_1):
714+
project = "my-project"
715+
dataset_id = "my_dataset"
716+
dataset_ref = DatasetReference(project, dataset_id)
717+
entry = self._make_one(None)
718+
entry.dataset = dataset_ref
719+
entry.condition = condition_1
720+
721+
resource = entry.to_api_repr()
722+
exp_resource = {
723+
"role": None,
724+
"dataset": {
725+
"dataset": DatasetReference("my-project", "my_dataset"),
726+
"targetTypes": None,
727+
},
728+
"condition": {
729+
"expression": "request.time < timestamp('2026-01-01T00:00:00Z')",
730+
"title": "Expires end 2025",
731+
"description": "Access expires at the start of 2026.",
732+
},
733+
}
734+
assert resource == exp_resource
735+
# Check internal representation
736+
assert "dataset" in entry._properties
737+
assert "condition" in entry._properties
738+
739+
497740
class TestDatasetReference(unittest.TestCase):
498741
@staticmethod
499742
def _get_target_class():

0 commit comments

Comments
 (0)