Skip to content

Commit cbc503c

Browse files
Merge pull request #43 from networktocode/gfm-get_by_uids-error
get_by_uids() should raise ObjectNotFound when appropriate
2 parents a8f8e39 + dfabdd5 commit cbc503c

File tree

5 files changed

+142
-45
lines changed

5 files changed

+142
-45
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## Unreleased
44

5+
- `DiffSync.get_by_uids()` now raises `ObjectNotFound` if any of the provided uids cannot be located.
6+
- `DiffSync.get()` raises `ObjectNotFound` or `ValueError` on failure, instead of returning `None`.
57
- #34 - in diff dicts, change keys `src`/`dst`/`_src`/`_dst` to `-` and `+`
68
- #37 - add `sync_complete` callback, triggered on `sync_from` completion with changes.
79
- #41 - add `summary` API for Diff and DiffElement objects.

diffsync/__init__.py

Lines changed: 40 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -214,11 +214,11 @@ def str(self, include_children: bool = True, indent: int = 0) -> str:
214214
output += f": {child_ids}"
215215
else:
216216
for child_id in child_ids:
217-
child = self.diffsync.get(modelname, child_id)
218-
if not child:
219-
output += f"\n{margin} {child_id} (details unavailable)"
220-
else:
217+
try:
218+
child = self.diffsync.get(modelname, child_id)
221219
output += "\n" + child.str(include_children=include_children, indent=indent + 4)
220+
except ObjectNotFound:
221+
output += f"\n{margin} {child_id} (ERROR: details unavailable)"
222222
return output
223223

224224
@classmethod
@@ -428,6 +428,13 @@ def __init_subclass__(cls):
428428
f'Incorrect field name - {value.__name__} has type name "{value.get_type()}", not "{name}"'
429429
)
430430

431+
for name in cls.top_level:
432+
if not hasattr(cls, name):
433+
raise AttributeError(f'top_level references attribute "{name}" but it is not a class attribute!')
434+
value = getattr(cls, name)
435+
if not isclass(value) or not issubclass(value, DiffSyncModel):
436+
raise AttributeError(f'top_level references attribute "{name}" but it is not a DiffSyncModel subclass!')
437+
431438
def __str__(self):
432439
"""String representation of a DiffSync."""
433440
if self.type != self.name:
@@ -455,6 +462,8 @@ def str(self, indent: int = 0) -> str:
455462
margin = " " * indent
456463
output = ""
457464
for modelname in self.top_level:
465+
if output:
466+
output += "\n"
458467
output += f"{margin}{modelname}"
459468
models = self.get_all(modelname)
460469
if not models:
@@ -539,7 +548,10 @@ def _sync_from_diff_element(
539548
# So let's live with the slightly too high number of branches (14/12) for now.
540549
log = logger or self._log
541550
object_class = getattr(self, element.type)
542-
obj = self.get(object_class, element.keys)
551+
try:
552+
obj: Optional[DiffSyncModel] = self.get(object_class, element.keys)
553+
except ObjectNotFound:
554+
obj = None
543555
# Get the attributes that actually differ between source and dest
544556
diffs = element.get_attrs_diffs()
545557
log = log.bind(
@@ -634,12 +646,16 @@ def diff_to(
634646

635647
def get(
636648
self, obj: Union[Text, DiffSyncModel, Type[DiffSyncModel]], identifier: Union[Text, Mapping]
637-
) -> Optional[DiffSyncModel]:
649+
) -> DiffSyncModel:
638650
"""Get one object from the data store based on its unique id.
639651
640652
Args:
641653
obj: DiffSyncModel class or instance, or modelname string, that defines the type of the object to retrieve
642654
identifier: Unique ID of the object to retrieve, or dict of unique identifier keys/values
655+
656+
Raises:
657+
ValueError: if obj is a str and identifier is a dict (can't convert dict into a uid str without a model class)
658+
ObjectNotFound: if the requested object is not present
643659
"""
644660
if isinstance(obj, str):
645661
modelname = obj
@@ -656,13 +672,14 @@ def get(
656672
elif object_class:
657673
uid = object_class.create_unique_id(**identifier)
658674
else:
659-
self._log.warning(
660-
f"Tried to look up a {modelname} by identifier {identifier}, "
661-
"but don't know how to convert that to a uid string",
675+
raise ValueError(
676+
f"Invalid args: ({obj}, {identifier}): "
677+
f"either {obj} should be a class/instance or {identifier} should be a str"
662678
)
663-
return None
664679

665-
return self._data[modelname].get(uid)
680+
if uid not in self._data[modelname]:
681+
raise ObjectNotFound(f"{modelname} {uid} not present in {self.name}")
682+
return self._data[modelname][uid]
666683

667684
def get_all(self, obj: Union[Text, DiffSyncModel, Type[DiffSyncModel]]):
668685
"""Get all objects of a given type.
@@ -688,17 +705,20 @@ def get_by_uids(
688705
Args:
689706
uids: List of unique id / key identifying object in the database.
690707
obj: DiffSyncModel class or instance, or modelname string, that defines the type of the objects to retrieve
708+
709+
Raises:
710+
ObjectNotFound: if any of the requested UIDs are not found in the store
691711
"""
692712
if isinstance(obj, str):
693713
modelname = obj
694714
else:
695715
modelname = obj.get_type()
696716

697-
# TODO: should this raise an exception if any or all of the uids are not found?
698717
results = []
699718
for uid in uids:
700-
if uid in self._data[modelname]:
701-
results.append(self._data[modelname][uid])
719+
if uid not in self._data[modelname]:
720+
raise ObjectNotFound(f"{modelname} {uid} not present in {self.name}")
721+
results.append(self._data[modelname][uid])
702722
return results
703723

704724
def add(self, obj: DiffSyncModel):
@@ -735,7 +755,7 @@ def remove(self, obj: DiffSyncModel, remove_children: bool = False):
735755
uid = obj.get_unique_id()
736756

737757
if uid not in self._data[modelname]:
738-
raise ObjectNotFound(f"Object {uid} not present")
758+
raise ObjectNotFound(f"{modelname} {uid} not present in {self.name}")
739759

740760
if obj.diffsync is self:
741761
obj.diffsync = None
@@ -745,9 +765,12 @@ def remove(self, obj: DiffSyncModel, remove_children: bool = False):
745765
if remove_children:
746766
for child_type, child_fieldname in obj.get_children_mapping().items():
747767
for child_id in getattr(obj, child_fieldname):
748-
child_obj = self.get(child_type, child_id)
749-
if child_obj:
768+
try:
769+
child_obj = self.get(child_type, child_id)
750770
self.remove(child_obj, remove_children=remove_children)
771+
except ObjectNotFound:
772+
# Since this is "cleanup" code, log an error and continue, instead of letting the exception raise
773+
self._log.error(f"Unable to remove child {child_id} of {modelname} {uid} - not found!")
751774

752775

753776
# DiffSyncModel references DiffSync and DiffSync references DiffSyncModel. Break the typing loop:

tests/unit/conftest.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,14 +140,24 @@ def generic_diffsync():
140140
return DiffSync()
141141

142142

143+
class UnusedModel(DiffSyncModel):
144+
"""Concrete DiffSyncModel subclass that can be referenced as a class attribute but never has any data."""
145+
146+
_modelname = "unused"
147+
_identifiers = ("name",)
148+
149+
name: str
150+
151+
143152
class GenericBackend(DiffSync):
144153
"""An example semi-abstract subclass of DiffSync."""
145154

146155
site = Site # to be overridden by subclasses
147156
device = Device
148157
interface = Interface
158+
unused = UnusedModel
149159

150-
top_level = ["site"]
160+
top_level = ["site", "unused"]
151161

152162
DATA: dict = {}
153163

tests/unit/test_diffsync.py

Lines changed: 88 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -56,19 +56,25 @@ def test_diffsync_sync_self_with_no_data_is_noop(generic_diffsync):
5656
assert not generic_diffsync.sync_complete.called
5757

5858

59-
def test_diffsync_get_with_no_data_is_none(generic_diffsync):
60-
assert generic_diffsync.get("anything", "myname") is None
61-
assert generic_diffsync.get(DiffSyncModel, "") is None
59+
def test_diffsync_get_with_no_data_fails(generic_diffsync):
60+
with pytest.raises(ObjectNotFound):
61+
generic_diffsync.get("anything", "myname")
62+
with pytest.raises(ObjectNotFound):
63+
generic_diffsync.get(DiffSyncModel, "")
6264

6365

6466
def test_diffsync_get_all_with_no_data_is_empty_list(generic_diffsync):
6567
assert list(generic_diffsync.get_all("anything")) == []
6668
assert list(generic_diffsync.get_all(DiffSyncModel)) == []
6769

6870

69-
def test_diffsync_get_by_uids_with_no_data_is_empty_list(generic_diffsync):
70-
assert generic_diffsync.get_by_uids(["any", "another"], "anything") == []
71-
assert generic_diffsync.get_by_uids(["any", "another"], DiffSyncModel) == []
71+
def test_diffsync_get_by_uids_with_no_data(generic_diffsync):
72+
assert generic_diffsync.get_by_uids([], "anything") == []
73+
assert generic_diffsync.get_by_uids([], DiffSyncModel) == []
74+
with pytest.raises(ObjectNotFound):
75+
generic_diffsync.get_by_uids(["any", "another"], "anything")
76+
with pytest.raises(ObjectNotFound):
77+
generic_diffsync.get_by_uids(["any", "another"], DiffSyncModel)
7278

7379

7480
def test_diffsync_add(generic_diffsync, generic_diffsync_model):
@@ -83,12 +89,15 @@ def test_diffsync_get_with_generic_model(generic_diffsync, generic_diffsync_mode
8389
# The generic_diffsync_model has an empty identifier/unique-id
8490
assert generic_diffsync.get(DiffSyncModel, "") == generic_diffsync_model
8591
assert generic_diffsync.get(DiffSyncModel.get_type(), "") == generic_diffsync_model
86-
# DiffSync doesn't know how to construct a uid str for a "diffsyncmodel"
87-
assert generic_diffsync.get(DiffSyncModel.get_type(), {}) is None
92+
# DiffSync doesn't know how to construct a uid str for a "diffsyncmodel" (it needs the class or instance, not a str)
93+
with pytest.raises(ValueError):
94+
generic_diffsync.get(DiffSyncModel.get_type(), {})
8895
# Wrong object-type - no match
89-
assert generic_diffsync.get("", "") is None
96+
with pytest.raises(ObjectNotFound):
97+
generic_diffsync.get("", "")
9098
# Wrong unique-id - no match
91-
assert generic_diffsync.get(DiffSyncModel, "myname") is None
99+
with pytest.raises(ObjectNotFound):
100+
generic_diffsync.get(DiffSyncModel, "myname")
92101

93102

94103
def test_diffsync_get_all_with_generic_model(generic_diffsync, generic_diffsync_model):
@@ -104,9 +113,11 @@ def test_diffsync_get_by_uids_with_generic_model(generic_diffsync, generic_diffs
104113
assert generic_diffsync.get_by_uids([""], DiffSyncModel) == [generic_diffsync_model]
105114
assert generic_diffsync.get_by_uids([""], DiffSyncModel.get_type()) == [generic_diffsync_model]
106115
# Wrong unique-id - no match
107-
assert generic_diffsync.get_by_uids(["myname"], DiffSyncModel) == []
108-
# Valid unique-id mixed in with unknown ones - return the successful matches?
109-
assert generic_diffsync.get_by_uids(["aname", "", "anothername"], DiffSyncModel) == [generic_diffsync_model]
116+
with pytest.raises(ObjectNotFound):
117+
generic_diffsync.get_by_uids(["myname"], DiffSyncModel)
118+
# Valid unique-id mixed in with unknown ones
119+
with pytest.raises(ObjectNotFound):
120+
generic_diffsync.get_by_uids(["aname", "", "anothername"], DiffSyncModel)
110121

111122

112123
def test_diffsync_remove_with_generic_model(generic_diffsync, generic_diffsync_model):
@@ -115,18 +126,19 @@ def test_diffsync_remove_with_generic_model(generic_diffsync, generic_diffsync_m
115126
with pytest.raises(ObjectNotFound):
116127
generic_diffsync.remove(generic_diffsync_model)
117128

118-
assert generic_diffsync.get(DiffSyncModel, "") is None
129+
with pytest.raises(ObjectNotFound):
130+
generic_diffsync.get(DiffSyncModel, "")
119131
assert list(generic_diffsync.get_all(DiffSyncModel)) == []
120-
assert generic_diffsync.get_by_uids([""], DiffSyncModel) == []
132+
with pytest.raises(ObjectNotFound):
133+
generic_diffsync.get_by_uids([""], DiffSyncModel)
121134

122135

123-
def test_diffsync_subclass_validation():
124-
"""Test the declaration-time checks on a DiffSync subclass."""
136+
def test_diffsync_subclass_validation_name_mismatch():
125137
# pylint: disable=unused-variable
126138
with pytest.raises(AttributeError) as excinfo:
127139

128140
class BadElementName(DiffSync):
129-
"""Model with a DiffSyncModel attribute whose name does not match the modelname."""
141+
"""DiffSync with a DiffSyncModel attribute whose name does not match the modelname."""
130142

131143
dev_class = Device # should be device = Device
132144

@@ -135,6 +147,35 @@ class BadElementName(DiffSync):
135147
assert "dev_class" in str(excinfo.value)
136148

137149

150+
def test_diffsync_subclass_validation_missing_top_level():
151+
# pylint: disable=unused-variable
152+
with pytest.raises(AttributeError) as excinfo:
153+
154+
class MissingTopLevel(DiffSync):
155+
"""DiffSync whose top_level references an attribute that does not exist on the class."""
156+
157+
top_level = ["missing"]
158+
159+
assert "top_level" in str(excinfo.value)
160+
assert "missing" in str(excinfo.value)
161+
assert "is not a class attribute" in str(excinfo.value)
162+
163+
164+
def test_diffsync_subclass_validation_top_level_not_diffsyncmodel():
165+
# pylint: disable=unused-variable
166+
with pytest.raises(AttributeError) as excinfo:
167+
168+
class TopLevelNotDiffSyncModel(DiffSync):
169+
"""DiffSync whose top_level references an attribute that is not a DiffSyncModel subclass."""
170+
171+
age = 0
172+
top_level = ["age"]
173+
174+
assert "top_level" in str(excinfo.value)
175+
assert "age" in str(excinfo.value)
176+
assert "is not a DiffSyncModel" in str(excinfo.value)
177+
178+
138179
def test_diffsync_dict_with_data(backend_a):
139180
assert backend_a.dict() == {
140181
"device": {
@@ -238,7 +279,9 @@ def test_diffsync_str_with_data(backend_a):
238279
interface: rdu-spine2__eth0: {'interface_type': 'ethernet', 'description': 'Interface 0'}
239280
interface: rdu-spine2__eth1: {'interface_type': 'ethernet', 'description': 'Interface 1'}
240281
people
241-
person: Glenn Matthews: {}"""
282+
person: Glenn Matthews: {}
283+
unused: []\
284+
"""
242285
)
243286

244287

@@ -308,17 +351,21 @@ def test_diffsync_sync_from(backend_a, backend_b):
308351
site_atl_a = backend_a.get("site", "atl")
309352
assert isinstance(site_atl_a, Site)
310353
assert site_atl_a.name == "atl"
311-
assert backend_a.get(Site, "rdu") is None
312-
assert backend_a.get("nothing", "") is None
354+
with pytest.raises(ObjectNotFound):
355+
backend_a.get(Site, "rdu")
356+
with pytest.raises(ObjectNotFound):
357+
backend_a.get("nothing", "")
313358

314359
assert list(backend_a.get_all(Site)) == [site_nyc_a, site_sfo_a, site_atl_a]
315360
assert list(backend_a.get_all("site")) == [site_nyc_a, site_sfo_a, site_atl_a]
316361
assert list(backend_a.get_all("nothing")) == []
317362

318363
assert backend_a.get_by_uids(["nyc", "sfo"], Site) == [site_nyc_a, site_sfo_a]
319364
assert backend_a.get_by_uids(["sfo", "nyc"], "site") == [site_sfo_a, site_nyc_a]
320-
assert backend_a.get_by_uids(["nyc", "sfo"], Device) == []
321-
assert backend_a.get_by_uids(["nyc", "sfo"], "device") == []
365+
with pytest.raises(ObjectNotFound):
366+
backend_a.get_by_uids(["nyc", "sfo"], Device)
367+
with pytest.raises(ObjectNotFound):
368+
backend_a.get_by_uids(["nyc", "sfo"], "device")
322369

323370

324371
def test_diffsync_subclass_default_name_type(backend_a):
@@ -354,6 +401,18 @@ def test_diffsync_add_get_remove_with_subclass_and_data(backend_a):
354401
backend_a.remove(site_atl_a)
355402

356403

404+
def test_diffsync_remove_missing_child(log, backend_a):
405+
rdu_spine1 = backend_a.get(Device, "rdu-spine1")
406+
rdu_spine1_eth0 = backend_a.get(Interface, "rdu-spine1__eth0")
407+
# Usage error - remove rdu_spine1_eth0 from backend_a, but rdu_spine1 still has a reference to it
408+
backend_a.remove(rdu_spine1_eth0)
409+
# Should log an error but continue removing other child objects
410+
backend_a.remove(rdu_spine1, remove_children=True)
411+
assert log.has("Unable to remove child rdu-spine1__eth0 of device rdu-spine1 - not found!", diffsync=backend_a)
412+
with pytest.raises(ObjectNotFound):
413+
backend_a.get(Interface, "rdu-spine1__eth1")
414+
415+
357416
def test_diffsync_sync_from_exceptions_are_not_caught_by_default(error_prone_backend_a, backend_b):
358417
with pytest.raises(ObjectCrudException):
359418
error_prone_backend_a.sync_from(backend_b)
@@ -430,8 +489,10 @@ def test_diffsync_diff_with_skip_unmatched_both_flag(
430489
def test_diffsync_sync_with_skip_unmatched_src_flag(backend_a, backend_a_with_extra_models):
431490
backend_a.sync_from(backend_a_with_extra_models, flags=DiffSyncFlags.SKIP_UNMATCHED_SRC)
432491
# New objects should not have been created
433-
assert backend_a.get(backend_a.site, "lax") is None
434-
assert backend_a.get(backend_a.device, "nyc-spine3") is None
492+
with pytest.raises(ObjectNotFound):
493+
backend_a.get(backend_a.site, "lax")
494+
with pytest.raises(ObjectNotFound):
495+
backend_a.get(backend_a.device, "nyc-spine3")
435496
assert "nyc-spine3" not in backend_a.get(backend_a.site, "nyc").devices
436497

437498

@@ -491,7 +552,8 @@ class NoDeleteInterfaceDiffSync(BackendA):
491552
# NoDeleteInterface.delete() should not be called since we're deleting its parent only
492553
extra_models.sync_from(backend_a)
493554
# The extra interface should have been removed from the DiffSync without calling its delete() method
494-
assert extra_models.get(extra_models.interface, extra_interface.get_unique_id()) is None
555+
with pytest.raises(ObjectNotFound):
556+
extra_models.get(extra_models.interface, extra_interface.get_unique_id())
495557
# The sync should be complete, regardless
496558
diff = extra_models.diff_from(backend_a)
497559
print(diff.str()) # for debugging of any failure

tests/unit/test_diffsync_model.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ def test_diffsync_model_str_with_children(generic_diffsync, make_site, make_devi
195195
devices
196196
device: device1: {'role': 'default'}
197197
interfaces
198-
device1__eth0 (details unavailable)\
198+
device1__eth0 (ERROR: details unavailable)\
199199
"""
200200
)
201201

0 commit comments

Comments
 (0)