From 5f69213a78cb3732c29b5959659f60ff775c8794 Mon Sep 17 00:00:00 2001 From: Brian Curtin Date: Fri, 6 Nov 2015 18:15:56 -0600 Subject: [PATCH] Better support for metadata in Compute service Metadata for both Servers and Images as seen from the Compute service is less than optimal. This approach uses a mixin class and allows both the Server and Image resources to leverage the same code, as they both work with metadata in the same way. Change-Id: I1d6b1f4c840d1127257244472243e4fa170baa8d Closes-Bug: #1467732 --- openstack/compute/v2/_proxy.py | 183 ++++++++++++++++++ openstack/compute/v2/image.py | 3 +- openstack/compute/v2/metadata.py | 143 ++++++++++++++ openstack/compute/v2/server.py | 3 +- .../tests/functional/compute/v2/test_image.py | 40 ++++ .../functional/compute/v2/test_server.py | 40 ++++ .../tests/unit/compute/v2/test_metadata.py | 176 +++++++++++++++++ openstack/tests/unit/compute/v2/test_proxy.py | 59 ++++++ 8 files changed, 645 insertions(+), 2 deletions(-) create mode 100644 openstack/compute/v2/metadata.py create mode 100644 openstack/tests/unit/compute/v2/test_metadata.py diff --git a/openstack/compute/v2/_proxy.py b/openstack/compute/v2/_proxy.py index a54b93947..b8abb7338 100644 --- a/openstack/compute/v2/_proxy.py +++ b/openstack/compute/v2/_proxy.py @@ -188,6 +188,102 @@ class Proxy(proxy.BaseProxy): img = _image.ImageDetail if details else _image.Image return self._list(img, paginated=True, **query) + def _get_base_resource(self, res, base): + # Metadata calls for Image and Server can work for both those + # resources but also ImageDetail and ServerDetail. If we get + # either class, use it, otherwise create an instance of the base. + if isinstance(res, base): + return res + else: + return base({"id": res}) + + def get_image_metadata(self, image, key=None): + """Return a dictionary of metadata for an image + + :param server: Either the id of an image or a + :class:`~openstack.compute.v2.image.Image` or + :class:`~openstack.compute.v2.image.ImageDetail` + instance. + :param key: An optional key to retrieve from the image's metadata. + When no ``key`` is specified, all metadata is retrieved. + + :returns: A dictionary of the image's metadata. All keys and values + are Unicode text. + :rtype: dict + """ + res = self._get_base_resource(image, _image.Image) + return res.get_metadata(self.session, key) + + def create_image_metadata(self, image, **metadata): + """Create metadata for an image + + :param server: Either the id of an image or a + :class:`~openstack.compute.v2.image.Image` or + :class:`~openstack.compute.v2.image.ImageDetail` + instance. + :param kwargs metadata: Key/value pairs to be added as metadata + on the image. All keys and values + are stored as Unicode. + + :returns: A dictionary of the metadata that was created on the image. + All keys and values are Unicode text. + :rtype: dict + """ + res = self._get_base_resource(image, _image.Image) + return res.create_metadata(self.session, **metadata) + + def replace_image_metadata(self, image, **metadata): + """Replace metadata for an image + + :param server: Either the id of a image or a + :class:`~openstack.compute.v2.image.Image` or + :class:`~openstack.compute.v2.image.ImageDetail` + instance. + :param kwargs metadata: Key/value pairs to be added as metadata + on the image. Any other existing metadata + is removed. All keys and values are stored + as Unicode. + + :returns: A dictionary of the metadata for the image. All keys and + values are Unicode text. + :rtype: dict + """ + res = self._get_base_resource(image, _image.Image) + return res.replace_metadata(self.session, **metadata) + + def update_image_metadata(self, image, **metadata): + """Update metadata for an image + + :param server: Either the id of an image or a + :class:`~openstack.compute.v2.image.Image` or + :class:`~openstack.compute.v2.image.ImageDetail` + instance. + :param kwargs metadata: Key/value pairs to be updated in the image's + metadata. No other metadata is modified + by this call. All keys and values are stored + as Unicode. + + :returns: A dictionary of the metadata for the image. All keys and + values are Unicode text. + :rtype: dict + """ + res = self._get_base_resource(image, _image.Image) + return res.update_metadata(self.session, **metadata) + + def delete_image_metadata(self, image, key): + """Delete metadata for an image + + :param server: Either the id of an image or a + :class:`~openstack.compute.v2.image.Image` or + :class:`~openstack.compute.v2.image.ImageDetail` + instance. + :param key: The key to delete + + :rtype: ``None`` + """ + res = self._get_base_resource(image, _image.Image) + return res.delete_metadata(self.session, key) + def create_keypair(self, **attrs): """Create a new keypair from attributes @@ -583,3 +679,90 @@ class Proxy(proxy.BaseProxy): """ return self._list(availability_zone.AvailabilityZone, paginated=False, **query) + + def get_server_metadata(self, server, key=None): + """Return a dictionary of metadata for a server + + :param server: Either the id of a server or a + :class:`~openstack.compute.v2.server.Server` or + :class:`~openstack.compute.v2.server.ServerDetail` + instance. + :param key: An optional key to retrieve from the server's metadata. + When no ``key`` is specified, all metadata is retrieved. + + :returns: A dictionary of the server's metadata. All keys and values + are Unicode text. + :rtype: dict + """ + res = self._get_base_resource(server, _server.Server) + return res.get_metadata(self.session, key) + + def create_server_metadata(self, server, **metadata): + """Create metadata for a server + + :param server: Either the id of a server or a + :class:`~openstack.compute.v2.server.Server` or + :class:`~openstack.compute.v2.server.ServerDetail` + instance. + :param kwargs metadata: Key/value pairs to be added as metadata + on the server. All keys and values + are stored as Unicode. + + :returns: A dictionary of the metadata that was created on the server. + All keys and values are Unicode text. + :rtype: dict + """ + res = self._get_base_resource(server, _server.Server) + return res.create_metadata(self.session, **metadata) + + def replace_server_metadata(self, server, **metadata): + """Replace metadata for a server + + :param server: Either the id of a server or a + :class:`~openstack.compute.v2.server.Server` or + :class:`~openstack.compute.v2.server.ServerDetail` + instance. + :param kwargs metadata: Key/value pairs to be added as metadata + on the server. Any other existing metadata + is removed. All keys and values are stored + as Unicode. + + :returns: A dictionary of the metadata for the server. All keys and + values are Unicode text. + :rtype: dict + """ + res = self._get_base_resource(server, _server.Server) + return res.replace_metadata(self.session, **metadata) + + def update_server_metadata(self, server, **metadata): + """Update metadata for a server + + :param server: Either the id of a server or a + :class:`~openstack.compute.v2.server.Server` or + :class:`~openstack.compute.v2.server.ServerDetail` + instance. + :param kwargs metadata: Key/value pairs to be updated in the server's + metadata. No other metadata is modified + by this call. All keys and values are stored + as Unicode. + + :returns: A dictionary of the metadata for the server. All keys and + values are Unicode text. + :rtype: dict + """ + res = self._get_base_resource(server, _server.Server) + return res.update_metadata(self.session, **metadata) + + def delete_server_metadata(self, server, key): + """Delete metadata for a server + + :param server: Either the id of a server or a + :class:`~openstack.compute.v2.server.Server` or + :class:`~openstack.compute.v2.server.ServerDetail` + instance. + :param key: The key to delete + + :rtype: ``None`` + """ + res = self._get_base_resource(server, _server.Server) + return res.delete_metadata(self.session, key) diff --git a/openstack/compute/v2/image.py b/openstack/compute/v2/image.py index 8a780fb08..704455e90 100644 --- a/openstack/compute/v2/image.py +++ b/openstack/compute/v2/image.py @@ -11,10 +11,11 @@ # under the License. from openstack.compute import compute_service +from openstack.compute.v2 import metadata from openstack import resource -class Image(resource.Resource): +class Image(resource.Resource, metadata.MetadataMixin): resource_key = 'image' resources_key = 'images' base_path = '/images' diff --git a/openstack/compute/v2/metadata.py b/openstack/compute/v2/metadata.py new file mode 100644 index 000000000..783f6c661 --- /dev/null +++ b/openstack/compute/v2/metadata.py @@ -0,0 +1,143 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import six + +from openstack import utils + + +class MetadataMixin(object): + + def _metadata(self, method, key=None, clear=False, delete=False, + **metadata): + for k, v in metadata.items(): + if not isinstance(v, six.string_types): + raise ValueError("The value for %s (%s) must be " + "a text string" % (k, v)) + + # If we're in a ServerDetail, we need to pop the "detail" portion + # of the URL off and then everything else will work the same. + pos = self.base_path.find("detail") + if pos != -1: + base = self.base_path[:pos] + else: + base = self.base_path + + if key is not None: + url = utils.urljoin(base, self.id, "metadata", key) + else: + url = utils.urljoin(base, self.id, "metadata") + + kwargs = {"endpoint_filter": self.service} + if metadata or clear: + # 'meta' is the key for singlular modifications. + # 'metadata' is the key for mass modifications. + key = "meta" if key is not None else "metadata" + kwargs["json"] = {key: metadata} + + headers = {"Accept": ""} if delete else {} + + response = method(url, headers=headers, **kwargs) + + # DELETE doesn't return a JSON body while everything else does. + return response.json() if not delete else None + + def get_metadata(self, session, key=None): + """Retrieve metadata + + :param session: The session to use for this request. + :param key: If specified, retrieve metadata only for this key. + If not specified, or ``None`` (the default), + retrieve all available metadata. + + :returns: A dictionary of the requested metadata. All keys and values + are Unicode text. + :rtype: dict + """ + result = self._metadata(session.get, key=key) + return result["metadata"] if key is None else result["meta"] + + def create_metadata(self, session, **metadata): + """Create metadata + + NOTE: One PUT call will be made for each key/value pair specified. + + :param session: The session to use for this request. + :param kwargs metadata: key/value metadata pairs to be created on + this server instance. All keys and values + are stored as Unicode. + + :returns: A dictionary of the metadata that was created. All keys and + values are Unicode text. + :rtype: dict + """ + results = {} + # A PUT to /metadata will entirely replace any existing metadata, + # so we need to PUT each individual key/value to /metadata/key + # in order to preserve anything existing and only add new keys. + for key, value in metadata.items(): + result = self._metadata(session.put, key=key, **{key: value}) + results[key] = result["meta"][key] + return results + + def replace_metadata(self, session, **metadata): + """Replace metadata + + This call will replace any existing metadata with the key/value pairs + given here. + + :param session: The session to use for this request. + :param kwargs metadata: key/value metadata pairs to be created on + this server instance. Any other existing + metadata is removed. + When metadata is not set, it is effectively + cleared out, replacing the metadata + with nothing. + All keys and values are stored as Unicode. + + + :returns: A dictionary of the metadata after being replaced. + All keys and values are Unicode text. + :rtype: dict + """ + # A PUT with empty metadata will clear anything out. + clear = True if not metadata else False + result = self._metadata(session.put, clear=clear, **metadata) + return result["metadata"] + + def update_metadata(self, session, **metadata): + """Update metadata + + This call will replace only the metadata with the same keys + given here. Metadata with other keys will not be modified. + + :param session: The session to use for this request. + :param kwargs metadata: key/value metadata pairs to be update on + this server instance. All keys and values + are stored as Unicode. + + :returns: A dictionary of the metadata after being updated. + All keys and values are Unicode text. + :rtype: dict + """ + result = self._metadata(session.post, **metadata) + return result["metadata"] + + def delete_metadata(self, session, key): + """Delete metadata + + :param session: The session to use for this request. + :param string key: The key to delete. + + :rtype: ``None`` + """ + self._metadata(session.delete, key=key, delete=True) diff --git a/openstack/compute/v2/server.py b/openstack/compute/v2/server.py index 3d7438d8e..f91193194 100644 --- a/openstack/compute/v2/server.py +++ b/openstack/compute/v2/server.py @@ -13,11 +13,12 @@ from openstack.compute import compute_service from openstack.compute.v2 import flavor from openstack.compute.v2 import image +from openstack.compute.v2 import metadata from openstack import resource from openstack import utils -class Server(resource.Resource): +class Server(resource.Resource, metadata.MetadataMixin): resource_key = 'server' resources_key = 'servers' base_path = '/servers' diff --git a/openstack/tests/functional/compute/v2/test_image.py b/openstack/tests/functional/compute/v2/test_image.py index 0b80b7f5b..687537fbe 100644 --- a/openstack/tests/functional/compute/v2/test_image.py +++ b/openstack/tests/functional/compute/v2/test_image.py @@ -52,3 +52,43 @@ class TestImage(base.BaseFunctionalTest): self.assertIn('metadata', image) self.assertIn('progress', image) self.assertIn('status', image) + + def test_image_metadata(self): + image = self._get_non_test_image() + sot = self.conn.compute.get_image(image.id) + + # Start by clearing out any other metadata. + self.assertDictEqual(self.conn.compute.replace_image_metadata(sot), + {}) + + # Insert first and last name metadata + meta = {"first": "Matthew", "last": "Dellavedova"} + self.assertDictEqual( + self.conn.compute.create_image_metadata(sot, **meta), meta) + + # Update only the first name + short = {"first": "Matt", "last": "Dellavedova"} + self.assertDictEqual( + self.conn.compute.update_image_metadata(sot, + first=short["first"]), + short) + + # Get all metadata and then only the last name + self.assertDictEqual(self.conn.compute.get_image_metadata(sot), + short) + self.assertDictEqual( + self.conn.compute.get_image_metadata(sot, "last"), + {"last": short["last"]}) + + # Replace everything with just a nickname + nick = {"nickname": "Delly"} + self.assertDictEqual( + self.conn.compute.replace_image_metadata(sot, **nick), + nick) + self.assertDictEqual(self.conn.compute.get_image_metadata(sot), + nick) + + # Delete the only remaining key, make sure we're empty + self.assertIsNone( + self.conn.compute.delete_image_metadata(sot, "nickname")) + self.assertDictEqual(self.conn.compute.get_image_metadata(sot), {}) diff --git a/openstack/tests/functional/compute/v2/test_server.py b/openstack/tests/functional/compute/v2/test_server.py index 88514f84a..dc3d6db6a 100644 --- a/openstack/tests/functional/compute/v2/test_server.py +++ b/openstack/tests/functional/compute/v2/test_server.py @@ -41,6 +41,7 @@ class TestServer(base.BaseFunctionalTest): args = {} sot = cls.conn.compute.create_server( name=cls.NAME, flavor=flavor, image=image.id, **args) + cls.conn.compute.wait_for_server(sot) assert isinstance(sot, server.Server) cls.assertIs(cls.NAME, sot.name) cls.server = sot @@ -66,3 +67,42 @@ class TestServer(base.BaseFunctionalTest): def test_list(self): names = [o.name for o in self.conn.compute.servers()] self.assertIn(self.NAME, names) + + def test_server_metadata(self): + sot = self.conn.compute.get_server(self.server.id) + + # Start by clearing out any other metadata. + self.assertDictEqual(self.conn.compute.replace_server_metadata(sot), + {}) + + # Insert first and last name metadata + meta = {"first": "Matthew", "last": "Dellavedova"} + self.assertDictEqual( + self.conn.compute.create_server_metadata(sot, **meta), meta) + + # Update only the first name + short = {"first": "Matt", "last": "Dellavedova"} + self.assertDictEqual( + self.conn.compute.update_server_metadata(sot, + first=short["first"]), + short) + + # Get all metadata and then only the last name + self.assertDictEqual(self.conn.compute.get_server_metadata(sot), + short) + self.assertDictEqual( + self.conn.compute.get_server_metadata(sot, "last"), + {"last": short["last"]}) + + # Replace everything with just a nickname + nick = {"nickname": "Delly"} + self.assertDictEqual( + self.conn.compute.replace_server_metadata(sot, **nick), + nick) + self.assertDictEqual(self.conn.compute.get_server_metadata(sot), + nick) + + # Delete the only remaining key, make sure we're empty + self.assertIsNone( + self.conn.compute.delete_server_metadata(sot, "nickname")) + self.assertDictEqual(self.conn.compute.get_server_metadata(sot), {}) diff --git a/openstack/tests/unit/compute/v2/test_metadata.py b/openstack/tests/unit/compute/v2/test_metadata.py new file mode 100644 index 000000000..addaff89e --- /dev/null +++ b/openstack/tests/unit/compute/v2/test_metadata.py @@ -0,0 +1,176 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import mock +import testtools + +from openstack.compute.v2 import server + +IDENTIFIER = 'IDENTIFIER' + +# NOTE: The implementation for metadata is done via a mixin class that both +# the server and image resources inherit from. Currently this test class +# uses the Server resource to test it. Ideally it would be parameterized +# to run with both Server and Image when the tooling for subtests starts +# working. + + +class TestMetadata(testtools.TestCase): + + def setUp(self): + super(TestMetadata, self).setUp() + self.metadata_result = {"metadata": {"go": "cubs", "boo": "sox"}} + self.meta_result = {"meta": {"oh": "yeah"}} + + def test_get_all_metadata_Server(self): + self._test_get_all_metadata(server.Server({"id": IDENTIFIER})) + + def test_get_all_metadata_ServerDetail(self): + # This is tested explicitly so we know ServerDetail items are + # properly having /detail stripped out of their base_path. + self._test_get_all_metadata(server.ServerDetail({"id": IDENTIFIER})) + + def _test_get_all_metadata(self, sot): + response = mock.Mock() + response.json.return_value = self.metadata_result + sess = mock.Mock() + sess.get.return_value = response + + result = sot.get_metadata(sess) + + self.assertEqual(result, self.metadata_result["metadata"]) + sess.get.assert_called_once_with("servers/IDENTIFIER/metadata", + headers={}, + endpoint_filter=sot.service) + + def test_get_one_metadata(self): + response = mock.Mock() + response.json.return_value = self.meta_result + sess = mock.Mock() + sess.get.return_value = response + + sot = server.Server({"id": IDENTIFIER}) + + key = "lol" + result = sot.get_metadata(sess, key) + + self.assertEqual(result, self.meta_result["meta"]) + sess.get.assert_called_once_with("servers/IDENTIFIER/metadata/" + key, + headers={}, + endpoint_filter=sot.service) + + def test_create_metadata_bad_type(self): + sess = mock.Mock() + sess.put = mock.Mock() + + sot = server.Server({"id": IDENTIFIER}) + self.assertRaises(ValueError, + sot.create_metadata, sess, some_key=True) + + def test_create_metadata(self): + metadata = {"first": "1", "second": "2"} + responses = [] + for key, value in metadata.items(): + response = mock.Mock() + response.json.return_value = {"meta": {key: value}} + responses.append(response) + + sess = mock.Mock() + sess.put.side_effect = responses + + sot = server.Server({"id": IDENTIFIER}) + + result = sot.create_metadata(sess, **metadata) + + self.assertEqual(result, dict([(k, v) for k, v in metadata.items()])) + + # assert_called_with depends on sequence, which doesn't work nicely + # with all of the dictionaries we're working with here. Build up + # our own list of calls and check that they've happend + calls = [] + for key in metadata.keys(): + calls.append(mock.call("servers/IDENTIFIER/metadata/" + key, + endpoint_filter=sot.service, + headers={}, + json={"meta": {key: metadata[key]}})) + + sess.put.assert_has_calls(calls, any_order=True) + + def test_replace_metadata(self): + response = mock.Mock() + response.json.return_value = self.metadata_result + sess = mock.Mock() + sess.put.return_value = response + + sot = server.Server({"id": IDENTIFIER}) + + new_meta = {"lol": "rofl"} + + result = sot.replace_metadata(sess, **new_meta) + + self.assertEqual(result, self.metadata_result["metadata"]) + sess.put.assert_called_once_with("servers/IDENTIFIER/metadata", + endpoint_filter=sot.service, + headers={}, + json={"metadata": new_meta}) + + def test_replace_metadata_clear(self): + empty = {} + + response = mock.Mock() + response.json.return_value = {"metadata": empty} + sess = mock.Mock() + sess.put.return_value = response + + sot = server.Server({"id": IDENTIFIER}) + + result = sot.replace_metadata(sess) + + self.assertEqual(result, empty) + sess.put.assert_called_once_with("servers/IDENTIFIER/metadata", + endpoint_filter=sot.service, + headers={}, + json={"metadata": empty}) + + def test_update_metadata(self): + response = mock.Mock() + response.json.return_value = self.metadata_result + sess = mock.Mock() + sess.post.return_value = response + + sot = server.Server({"id": IDENTIFIER}) + + updated_meta = {"lol": "rofl"} + + result = sot.update_metadata(sess, **updated_meta) + + self.assertEqual(result, self.metadata_result["metadata"]) + sess.post.assert_called_once_with("servers/IDENTIFIER/metadata", + endpoint_filter=sot.service, + headers={}, + json={"metadata": updated_meta}) + + def test_delete_metadata(self): + sess = mock.Mock() + sess.delete.return_value = None + + sot = server.Server({"id": IDENTIFIER}) + + key = "hey" + + result = sot.delete_metadata(sess, key) + + self.assertIsNone(result) + sess.delete.assert_called_once_with( + "servers/IDENTIFIER/metadata/" + key, + headers={"Accept": ""}, + endpoint_filter=sot.service) diff --git a/openstack/tests/unit/compute/v2/test_proxy.py b/openstack/tests/unit/compute/v2/test_proxy.py index 8c8d7b5d9..e365239ed 100644 --- a/openstack/tests/unit/compute/v2/test_proxy.py +++ b/openstack/tests/unit/compute/v2/test_proxy.py @@ -223,6 +223,13 @@ class TestComputeProxy(test_proxy_base.TestProxyBase): def test_server_update(self): self.verify_update(self.proxy.update_server, server.Server) + def test_server_wait_for(self): + value = server.Server(attrs={'id': '1234'}) + self.verify_wait_for_status( + self.proxy.wait_for_server, + method_args=[value], + expected_args=[value, 'ACTIVE', ['ERROR'], 2, 120]) + def test_server_resize(self): self._verify("openstack.compute.v2.server.Server.resize", self.proxy.resize_server, @@ -277,3 +284,55 @@ class TestComputeProxy(test_proxy_base.TestProxyBase): def test_availability_zones(self): self.verify_list(self.proxy.availability_zones, az.AvailabilityZone, paginated=False) + + def test_get_all_server_metadata(self): + self._verify2("openstack.compute.v2.server.Server.get_metadata", + self.proxy.get_server_metadata, + expected_result={}, + method_args=["value"], + expected_args=[self.session, None]) + + def test_get_one_server_metadata(self): + self._verify2("openstack.compute.v2.server.Server.get_metadata", + self.proxy.get_server_metadata, + expected_result={}, + method_args=["value"], + method_kwargs={"key": "key"}, + expected_args=[self.session, "key"]) + + def test_create_server_metadata(self): + kwargs = {"a": "1", "b": "2"} + self._verify2("openstack.compute.v2.server.Server.create_metadata", + self.proxy.create_server_metadata, + expected_result={}, + method_args=["value"], + method_kwargs=kwargs, + expected_args=[self.session], + expected_kwargs=kwargs) + + def test_replace_server_metadata(self): + kwargs = {"a": "1", "b": "2"} + self._verify2("openstack.compute.v2.server.Server.replace_metadata", + self.proxy.replace_server_metadata, + expected_result={}, + method_args=["value"], + method_kwargs=kwargs, + expected_args=[self.session], + expected_kwargs=kwargs) + + def test_update_server_metadata(self): + kwargs = {"a": "1", "b": "2"} + self._verify2("openstack.compute.v2.server.Server.update_metadata", + self.proxy.update_server_metadata, + expected_result={}, + method_args=["value"], + method_kwargs=kwargs, + expected_args=[self.session], + expected_kwargs=kwargs) + + def test_delete_server_metadata(self): + self._verify2("openstack.compute.v2.server.Server.delete_metadata", + self.proxy.delete_server_metadata, + expected_result=None, + method_args=["value", "key"], + expected_args=[self.session, "key"])