Skip to content

Commit

Permalink
Merge pull request #165 from vu-smitt13/feature/bulk-update-endpoint-…
Browse files Browse the repository at this point in the history
…method

Added bulk update support to the endpoint class
  • Loading branch information
joewesch authored Feb 8, 2024
2 parents 7082244 + ff54054 commit 51c27df
Show file tree
Hide file tree
Showing 4 changed files with 253 additions and 24 deletions.
129 changes: 105 additions & 24 deletions pynautobot/core/endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
This file has been modified by NetworktoCode, LLC.
"""

from typing import Dict
from typing import List, Dict, Any
from uuid import UUID
from pynautobot.core.query import Request, RequestError
from pynautobot.core.response import Record
Expand Down Expand Up @@ -311,34 +311,71 @@ def create(self, *args, api_version=None, **kwargs):

return response_loader(req, self.return_obj, self)

def update(self, id: str, data: Dict[str, any]):
"""
Update a resource with a dictionary.
def update(self, *args, **kwargs):
r"""
Update a single resource with a dictionary or bulk update a list of objects.
Allows for bulk updating of existing objects on an endpoint.
Objects is a list which contain either json/dicts or Record
derived objects, which contain the updates to apply.
If json/dicts are used, then the id of the object *must* be
included
:arg list,optional \*args: A list of dicts or a list of Record
Accepts the id of the object that needs to be updated as well as
a dictionary of k/v pairs used to update an object. The object
is directly updated on the server using a PATCH request without
fetching object information.
:arg str,optional \**kwargs:
See Below
For fields requiring an object reference (such as a device location),
the API user is responsible for providing the object ID or the object
URL. This API will not accept the pynautobot object directly.
:Keyword Arguments:
* *id* (``string``) -- Identifier of the object being updated
* *data* (``dict``) -- k/v to update the record object with
:arg str id: Identifier of the object being updated
:arg dict data: Dictionary containing the k/v to update the
record object with.
:returns: True if PATCH request was successful.
:returns: A list or single :py:class:`.Record` object depending
on whether a bulk update was requested.
:example:
Accepts the id of the object that needs to be updated as well as a
dictionary of k/v pairs used to update an object
>>> nb.dcim.devices.update(id="0238a4e3-66f2-455a-831f-5f177215de0f", data={
... "name": "test-switch2",
... "serial": "ABC321",
... "name": "test",
... "serial": "1234",
... "location": "9b1f53c7-89fa-4fb2-a89a-b97364fef50c",
... })
True
>>>
Use bulk update by passing a list of dicts:
>>> devices = nb.dcim.devices.update([
... {'id': "db8770c4-61e5-4999-8372-e7fa576a4f65", 'name': 'test'},
... {'id': "e9b5f2e0-4f20-41ad-9179-90a4987f743e", 'name': 'test2'},
... ])
>>>
Use bulk update by passing a list of Records:
>>> devices = list(nb.dcim.devices.filter())
>>> devices
[Device1, Device2, Device3]
>>> for d in devices:
... d.name = d.name+'-test'
...
>>> nb.dcim.devices.update(devices)
>>>
"""
if not args and not kwargs:
raise ValueError("You must provide either a UUID and data dict or a list of objects to update")
uuid = kwargs.get("id", "")
data = kwargs.get("data", {})
if data and not uuid:
uuid = args[0]
if len(args) == 2:
uuid, data = args

if not any([uuid, data]):
return self.bulk_update(args[0])

req = Request(
key=id,
key=uuid,
base=self.url,
token=self.api.token,
http_session=self.api.http_session,
Expand All @@ -348,6 +385,46 @@ def update(self, id: str, data: Dict[str, any]):
return True
return False

def bulk_update(self, objects: List[Dict[str, Any]]):
r"""This method is called from the update() method if a bulk
update is detected.
Allows for bulk updating of existing objects on an endpoint.
Objects is a list which contain either json/dicts or Record
derived objects, which contain the updates to apply.
If json/dicts are used, then the id of the object *must* be
included
:arg list,optional \*args: A list of dicts or a list of Record
"""
if not isinstance(objects, list):
raise ValueError("objects must be a list[dict()|Record] not " + str(type(objects)))

bulk_data = []
for o in objects:
try:
if isinstance(o, dict):
bulk_data.append(o)
elif isinstance(o, Record):
if not hasattr(o, "id"):
raise ValueError("'Record' object has no attribute 'id'")
updates = o.updates()
if updates:
updates["id"] = o.id
bulk_data.append(updates)
else:
raise ValueError("Invalid object type: " + str(type(o)))
except ValueError as exc:
raise ValueError("Unexpected value in object list") from exc

req = Request(
base=self.url,
token=self.api.token,
http_session=self.api.http_session,
api_version=self.api.api_version,
).patch(bulk_data)
return response_loader(req, self.return_obj, self)

def delete(self, objects):
r"""Bulk deletes objects on an endpoint.
Expand Down Expand Up @@ -531,12 +608,16 @@ def list(self, api_version=None, **kwargs):
Returns the response from Nautobot for a detail endpoint.
Args:
:arg str,optional api_version: Override default or globally set Nautobot REST API version for this single request.
**kwargs: key/value pairs that get converted into url parameters when passed to the endpoint.
E.g. ``.list(method='get_facts')`` would be converted to ``.../?method=get_facts``.
:returns: A dictionary or list of dictionaries retrieved from
Nautobot.
:arg str,optional api_version: Override default or globally set Nautobot REST API version for this single request.
:arg \**kwargs:
See below
:Keyword Arugments:
key/value pairs that get converted into url parameters when passed to the endpoint.
E.g. ``.list(method='get_facts')`` would be converted to ``.../?method=get_facts``.
:returns: A dictionary or list of dictionaries retrieved from Nautobot.
"""
api_version = api_version or self.parent_obj.api.api_version

Expand Down
25 changes: 25 additions & 0 deletions pynautobot/core/response.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
This file has been modified by NetworktoCode, LLC.
"""

import copy
from collections import OrderedDict

Expand Down Expand Up @@ -361,6 +362,30 @@ def fmt_dict(k, v):
init = Hashabledict({fmt_dict(k, v) for k, v in self.serialize(init=True).items()})
return set([i[0] for i in set(current.items()) ^ set(init.items())])

def updates(self):
"""Compiles changes for an existing object into a dict.
Takes a diff between the objects current state and its state at init
and returns them as a dictionary, which will be empty if no changes.
:returns: dict.
:example:
>>> x = nb.dcim.devices.get(name='test1234')
>>> x.serial
''
>>> x.serial = '1234'
>>> x.updates()
{'serial': '1234'}
>>>
"""
if self.id:
diff = self._diff()
if diff:
serialized = self.serialize()
return {i: serialized[i] for i in diff}
return {}

def save(self):
"""Saves changes to an existing object.
Expand Down
93 changes: 93 additions & 0 deletions tests/unit/test_endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,99 @@ def test_choices(self):
self.assertEqual(choices["letter"][1]["display"], "B")
self.assertEqual(choices["letter"][1]["value"], 2)

def test_update_with_id_and_data(self):
with patch("pynautobot.core.query.Request._make_call", return_value=Mock()) as mock:
api = Mock(base_url="http://localhost:8000/api")
app = Mock(name="test")
test_obj = Endpoint(api, app, "test")
mock.return_value = [{"name": "test"}]
test = test_obj.update(id="db8770c4-61e5-4999-8372-e7fa576a4f65", data={"name": "test"})
mock.assert_called_with(verb="patch", data={"name": "test"})
self.assertTrue(test)

def test_update_with_id_and_data_args(self):
with patch("pynautobot.core.query.Request._make_call", return_value=Mock()) as mock:
api = Mock(base_url="http://localhost:8000/api")
app = Mock(name="test")
test_obj = Endpoint(api, app, "test")
mock.return_value = [{"name": "test"}]
test = test_obj.update("db8770c4-61e5-4999-8372-e7fa576a4f65", {"name": "test"})
mock.assert_called_with(verb="patch", data={"name": "test"})
self.assertTrue(test)

def test_update_with_id_and_data_args_kwargs(self):
with patch("pynautobot.core.query.Request._make_call", return_value=Mock()) as mock:
api = Mock(base_url="http://localhost:8000/api")
app = Mock(name="test")
test_obj = Endpoint(api, app, "test")
mock.return_value = [{"name": "test"}]
test = test_obj.update("db8770c4-61e5-4999-8372-e7fa576a4f65", data={"name": "test"})
mock.assert_called_with(verb="patch", data={"name": "test"})
self.assertTrue(test)

def test_update_with_dict(self):
with patch("pynautobot.core.query.Request._make_call", return_value=Mock()) as mock:
api = Mock(base_url="http://localhost:8000/api")
app = Mock(name="test")
test_obj = Endpoint(api, app, "test")
mock.return_value = [{"id": "db8770c4-61e5-4999-8372-e7fa576a4f65", "name": "test"}]
test = test_obj.update([{"id": "db8770c4-61e5-4999-8372-e7fa576a4f65", "name": "test"}])
mock.assert_called_with(verb="patch", data=[{"id": "db8770c4-61e5-4999-8372-e7fa576a4f65", "name": "test"}])
self.assertTrue(test)

def test_update_with_objects(self):
with patch("pynautobot.core.query.Request._make_call", return_value=Mock()) as mock:
ids = ["db8770c4-61e5-4999-8372-e7fa576a4f65", "e9b5f2e0-4f20-41ad-9179-90a4987f743e"]
api = Mock(base_url="http://localhost:8000/api")
app = Mock(name="test")
test_obj = Endpoint(api, app, "test")
objects = [Record({"id": i, "name": "test_" + str(i)}, api, test_obj) for i in ids]
for o in objects:
o.name = "new_" + str(o.id)
mock.return_value = [o.serialize() for o in objects]
test = test_obj.update(objects)
mock.assert_called_with(verb="patch", data=[{"id": i, "name": "new_" + str(i)} for i in ids])
self.assertTrue(test)

def test_update_with_invalid_input(self):
api = Mock(base_url="http://localhost:8000/api")
app = Mock(name="test")
test_obj = Endpoint(api, app, "test")
with self.assertRaises(ValueError) as exc:
test_obj.update()
self.assertEqual(
str(exc.exception), "You must provide either a UUID and data dict or a list of objects to update"
)

def test_update_with_invalid_objects_type(self):
objects = {"id": "db8770c4-61e5-4999-8372-e7fa576a4f65", "name": "test"}
api = Mock(base_url="http://localhost:8000/api")
app = Mock(name="test")
test_obj = Endpoint(api, app, "test")
with self.assertRaises(ValueError) as exc:
test_obj.update(objects)
self.assertEqual(str(exc.exception), "objects must be a list[dict()|Record] not <class 'dict'>")

def test_update_with_invalid_type_in_objects(self):
objects = [[{"id": "db8770c4-61e5-4999-8372-e7fa576a4f65", "name": "test"}]]
api = Mock(base_url="http://localhost:8000/api")
app = Mock(name="test")
test_obj = Endpoint(api, app, "test")
with self.assertRaises(ValueError) as exc:
test_obj.update(objects)
self.assertEqual(str(exc.exception.__cause__), "Invalid object type: <class 'list'>")

def test_update_with_missing_id_attribute(self):
api = Mock(base_url="http://localhost:8000/api")
app = Mock(name="test")
test_obj = Endpoint(api, app, "test")
objects = [
Record({"no_id": "db8770c4-61e5-4999-8372-e7fa576a4f65", "name": "test"}, api, test_obj),
]
with self.assertRaises(ValueError) as exc:
test_obj.update(objects)
self.assertEqual(str(exc.exception.__cause__), "'Record' object has no attribute 'id'")

def test_delete_with_ids(self):
with patch("pynautobot.core.query.Request._make_call", return_value=Mock()) as mock:
ids = ["db8770c4-61e5-4999-8372-e7fa576a4f65", "e9b5f2e0-4f20-41ad-9179-90a4987f743e"]
Expand Down
30 changes: 30 additions & 0 deletions tests/unit/test_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,36 @@ def test_diff(self):
test.local_context_data["data"].append("two")
self.assertEqual(test._diff(), {"tags", "nested_dict", "string_field", "local_context_data"})

def test_updates_with_changes(self):
test_values = {
"id": 123,
"custom_fields": {"foo": "bar"},
"string_field": "foobar",
"int_field": 1,
"nested_dict": {"id": 222, "name": "bar"},
"tags": ["foo", "bar"],
"int_list": [123, 321, 231],
"local_context_data": {"data": ["one"]},
}
test = Record(test_values, None, None)
test.string_field = "foobaz"
test.local_context_data["data"].append("two")
self.assertEqual(test.updates(), {"local_context_data": {"data": ["one", "two"]}, "string_field": "foobaz"})

def test_updates_with_no_changes(self):
test_values = {
"id": 123,
"custom_fields": {"foo": "bar"},
"string_field": "foobar",
"int_field": 1,
"nested_dict": {"id": 222, "name": "bar"},
"tags": ["foo", "bar"],
"int_list": [123, 321, 231],
"local_context_data": {"data": ["one"]},
}
test = Record(test_values, None, None)
self.assertEqual(test.updates(), {})

def test_diff_append_records_list(self):
test_values = {
"id": 123,
Expand Down

0 comments on commit 51c27df

Please sign in to comment.