From 7c6d82564e1bca094a08673aed5a6eddaaa73395 Mon Sep 17 00:00:00 2001 From: wypior Date: Thu, 2 Jun 2016 12:40:18 +0100 Subject: [PATCH 1/4] Issues: Fixes #379, #452, #461, #462 Problem: SDK needs to be able to run on multiple versions of TMOS. Some API endpoints were not implemented in the base 11.6.0, but exist in 12.x. This SDK needs the ability to verify the TMOS version when instantiating the API classes. Analysis: This update forces the tmos version update upon ManagementRoot instantiation. Moreover it adds a new private method into LazyAttributeMixin class: _check_supported_versions which is responsible for veryfing if an instantiated class for the API is supported in the target's device tmos. Files affected: f5\bigip\__init__.py f5\bigip\mixins.py f5\bigip\resource.py f5\bigip\tm\cm\trust.py f5\bigip\tm\ltm\profile.py --- f5/bigip/__init__.py | 19 ++++++++++++++++++- f5/bigip/mixins.py | 18 ++++++++++++++++++ f5/bigip/resource.py | 4 ++++ f5/bigip/tm/cm/trust.py | 8 ++++++++ f5/bigip/tm/ltm/profile.py | 2 ++ 5 files changed, 50 insertions(+), 1 deletion(-) diff --git a/f5/bigip/__init__.py b/f5/bigip/__init__.py index 49dd1386d..2a2fb7f47 100644 --- a/f5/bigip/__init__.py +++ b/f5/bigip/__init__.py @@ -18,6 +18,9 @@ from icontrol.session import iControlRESTSession +from urlparse import parse_qs +from urlparse import urlparse + from f5.bigip.cm import Cm from f5.bigip.resource import PathElement @@ -54,8 +57,10 @@ def __init__(self, hostname, username, password, **kwargs): 'bigip': self, 'icontrol_version': icontrol_version, 'username': username, - 'password': password + 'password': password, + 'tmos_version': None, } + self._get_tmos_version() @property def hostname(self): @@ -65,6 +70,18 @@ def hostname(self): def icontrol_version(self): return self._meta_data['icontrol_version'] + @property + def tmos_version(self): + return self._meta_data['tmos_version'] + + def _get_tmos_version(self): + connect = self._meta_data['bigip']._meta_data['icr_session'] + base_uri = self._meta_data['uri'] + 'tm/sys/' + response = connect.get(base_uri) + ver = response.json() + version = str(parse_qs(urlparse(ver['selfLink']).query)['ver'][0]) + self._meta_data['tmos_version'] = version + class BigIP(ManagementRoot): """A shim class used to access the default config resources in 'mgmt/tm.' diff --git a/f5/bigip/mixins.py b/f5/bigip/mixins.py index 596dde495..635eb56f2 100644 --- a/f5/bigip/mixins.py +++ b/f5/bigip/mixins.py @@ -31,6 +31,14 @@ class UnsupportedMethod(F5SDKError): pass +class UnsupportedTmosVersion(F5SDKError): + """Raise the error if a class of an API is instantiated, + + on a TMOS version where API was not yet implemented/supported. + """ + pass + + class LazyAttributesRequired(F5SDKError): """Raised when a object accesses a lazy attribute that is not listed""" pass @@ -101,10 +109,20 @@ def __getattr__(container, name): if name == lazy_attribute.__name__.lower(): attribute = lazy_attribute(container) bases = [base.__name__ for base in lazy_attribute.__bases__] + # Doing version check per each resource + container._check_supported_versions(attribute) if 'Resource' not in bases: setattr(container, name, attribute) return attribute + def _check_supported_versions(container, attribute): + tmos_v = container._meta_data['bigip'].tmos_version + if tmos_v not in attribute._meta_data['supported_versions']: + error = "There was an attempt to access API which " \ + "has not been implemented or supported " \ + "in the device's TMOS version: {}".format(tmos_v) + raise UnsupportedTmosVersion(error) + class ExclusiveAttributesMixin(object): """Overrides ``__setattr__`` to remove exclusive attrs from the object.""" diff --git a/f5/bigip/resource.py b/f5/bigip/resource.py index d81b554fa..f97f2fab0 100644 --- a/f5/bigip/resource.py +++ b/f5/bigip/resource.py @@ -162,6 +162,7 @@ class PathElement(LazyAttributeMixin): those elements and does not support any of the CURDLE methods that the other objects do. """ + def __init__(self, container): self._meta_data = { 'container': container, @@ -170,6 +171,9 @@ def __init__(self, container): 'icontrol_version': container._meta_data['icontrol_version'] } self._set_meta_data_uri() + # Supported versions for each class will be defined here. + # List can be modified downstream in each sub-class + self._meta_data['supported_versions'] = set(['11.6.0', '12.0.0']) def _set_meta_data_uri(self): base_uri = self.__class__.__name__.lower() diff --git a/f5/bigip/tm/cm/trust.py b/f5/bigip/tm/cm/trust.py index b3c7a387f..b35b44fa8 100644 --- a/f5/bigip/tm/cm/trust.py +++ b/f5/bigip/tm/cm/trust.py @@ -31,6 +31,9 @@ class Add_To_Trust(UnnamedResourceMixin, ExclusiveAttributesMixin, def __init__(self, cm): super(Add_To_Trust, self).__init__(cm) + base_uri = type(self).__name__.replace('_', '-').lower() + self._meta_data['uri'] =\ + self._meta_data['container']._meta_data['uri'] + base_uri + '/' self._meta_data['exclusive_attributes'].append( ('caDevice', 'nonCaDevice')) self._meta_data['required_creation_parameters'].update( @@ -38,6 +41,7 @@ def __init__(self, cm): self._meta_data['required_json_kind'] = \ 'tm:cm:add-to-trust:runstate' self._meta_data['allowed_commands'].append('run') + self._meta_data['supported_versions'].discard('11.6.0') class Remove_From_Trust(UnnamedResourceMixin, CommandExecutionMixin, Resource): @@ -54,8 +58,12 @@ class Remove_From_Trust(UnnamedResourceMixin, CommandExecutionMixin, Resource): def __init__(self, cm): super(Remove_From_Trust, self).__init__(cm) + base_uri = type(self).__name__.replace('_', '-').lower() + self._meta_data['uri'] =\ + self._meta_data['container']._meta_data['uri'] + base_uri + '/' self._meta_data['required_creation_parameters'].update( ('deviceName',)) self._meta_data['required_json_kind'] = \ 'tm:cm:remove-from-trust:runstate' self._meta_data['allowed_commands'].append('run') + self._meta_data['supported_versions'].discard('11.6.0') diff --git a/f5/bigip/tm/ltm/profile.py b/f5/bigip/tm/ltm/profile.py index c5e2d1684..49c644890 100644 --- a/f5/bigip/tm/ltm/profile.py +++ b/f5/bigip/tm/ltm/profile.py @@ -511,6 +511,7 @@ def __init__(self, profile): self._meta_data['allowed_lazy_attributes'] = [Iiop] self._meta_data['attribute_registry'] = \ {'tm:ltm:profile:iiop:iiopstate': Iiop} + self._meta_data['supported_versions'].discard('11.6.0') class Iiop(Resource): @@ -1034,6 +1035,7 @@ def __init__(self, profile): self._meta_data['allowed_lazy_attributes'] = [Tftp] self._meta_data['attribute_registry'] = \ {'tm:ltm:profile:tftp:tftpstate': Tftp} + self._meta_data['supported_versions'].discard('11.6.0') class Tftp(Resource): From 0d50b11dfedad47bafdc0d485ed0f39e8e022790 Mon Sep 17 00:00:00 2001 From: Za Wilgustus Date: Thu, 2 Jun 2016 13:03:02 -0600 Subject: [PATCH 2/4] Add fixture for mocking iCRSSession Issues: Fixes # Problem: Analysis: Tests: --- conftest.py | 16 ++++++++++++++++ .../test/test_software_image_uploads.py | 6 +++--- f5/bigip/shared/test/test_file_uploads.py | 14 +++++++------- f5/bigip/test/test___init__.py | 4 ++-- f5/bigip/tm/auth/test/test_user.py | 2 +- f5/bigip/tm/cm/test/test___init__.py | 2 +- f5/bigip/tm/ltm/test/test_nat.py | 2 +- f5/bigip/tm/ltm/test/test_rule.py | 2 +- f5/bigip/tm/sys/test/test_db.py | 5 ++++- f5/bigip/tm/sys/test/test_folder.py | 4 +++- f5/bigip/tm/sys/test/test_performance.py | 5 +++-- f5/bigip/tm/sys/test/test_sys_application.py | 8 ++++---- .../cluster/test/test_cluster_manager.py | 2 +- 13 files changed, 47 insertions(+), 25 deletions(-) diff --git a/conftest.py b/conftest.py index 9cce6b952..808522f5e 100644 --- a/conftest.py +++ b/conftest.py @@ -14,8 +14,11 @@ # from f5.bigip import BigIP +import mock import pytest +from icontrol.session import iControlRESTSession + def pytest_addoption(parser): parser.addoption("--bigip", action="store", @@ -33,6 +36,19 @@ def pytest_addoption(parser): default='11.6.0') +@pytest.fixture +def fakeicontrolsession(monkeypatch): + class Response(object): + def json(self): + return {'selfLink': 'https://localhost/mgmt/tm/sys?ver=11.6.0'} + fakesessionclass = mock.create_autospec(iControlRESTSession, spec_set=True) + fakesessioninstance =\ + mock.create_autospec(iControlRESTSession('A', 'B'), spec_set=True) + fakesessioninstance.get = mock.MagicMock(return_value=Response()) + fakesessionclass.return_value = fakesessioninstance + monkeypatch.setattr('f5.bigip.iControlRESTSession', fakesessionclass) + + @pytest.fixture def opt_bigip(request): return request.config.getoption("--bigip") diff --git a/f5/bigip/cm/autodeploy/test/test_software_image_uploads.py b/f5/bigip/cm/autodeploy/test/test_software_image_uploads.py index f9a6865f9..3becf5221 100644 --- a/f5/bigip/cm/autodeploy/test/test_software_image_uploads.py +++ b/f5/bigip/cm/autodeploy/test/test_software_image_uploads.py @@ -24,7 +24,7 @@ CHUNKSIZE = 20 -def test_software_image_uploads_80a(tmpdir): +def test_software_image_uploads_80a(tmpdir, fakeicontrolsession): filepath = tmpdir.mkdir('testdir').join('eightya.iso') filepath.write(80*'a') mr = ManagementRoot('FAKENETLOC', 'FAKENAME', 'FAKEPASSWORD') @@ -37,7 +37,7 @@ def test_software_image_uploads_80a(tmpdir): assert d == 'a'*CHUNKSIZE -def test_software_image_uploads_70a(tmpdir): +def test_software_image_uploads_70a(tmpdir, fakeicontrolsession): filepath = tmpdir.mkdir('testdir').join('seventya.iso') filepath.write(70*'a') mr = ManagementRoot('FAKENETLOC', 'FAKENAME', 'FAKEPASSWORD') @@ -53,7 +53,7 @@ def test_software_image_uploads_70a(tmpdir): assert 10*'a' == lchunk -def test_non_ISO_extension(tmpdir): +def test_non_ISO_extension(tmpdir, fakeicontrolsession): filepath = tmpdir.mkdir('testdir').join('wrong.name') mr = ManagementRoot('FAKENETLOC', 'FAKENAME', 'FAKEPASSWORD') sius = mr.cm.autodeploy.software_image_uploads diff --git a/f5/bigip/shared/test/test_file_uploads.py b/f5/bigip/shared/test/test_file_uploads.py index ef336f6e3..7cf82a48b 100644 --- a/f5/bigip/shared/test/test_file_uploads.py +++ b/f5/bigip/shared/test/test_file_uploads.py @@ -22,7 +22,7 @@ FileMustNotHaveDotISOExtension -def test_file_upload_80a(tmpdir): +def test_file_upload_80a(tmpdir, fakeicontrolsession): filepath = tmpdir.mkdir('testdir').join('eightya.txt') filepath.write(80*'a') mr = ManagementRoot('FAKENETLOC', 'FAKENAME', 'FAKEPASSWORD') @@ -35,7 +35,7 @@ def test_file_upload_80a(tmpdir): assert d == 'aaaaaaaaaaaaaaaaaaaa' -def test_file_upload_70a(tmpdir): +def test_file_upload_70a(tmpdir, fakeicontrolsession): filepath = tmpdir.mkdir('testdir').join('seventya.txt') filepath.write(70*'a') mr = ManagementRoot('FAKENETLOC', 'FAKENAME', 'FAKEPASSWORD') @@ -51,7 +51,7 @@ def test_file_upload_70a(tmpdir): assert 10*'a' == lchunk -def test_ISO_extension(tmpdir): +def test_ISO_extension(tmpdir, fakeicontrolsession): filepath = tmpdir.mkdir('testdir').join('wrongname.iso') filepath.write('fake') mr = ManagementRoot('FAKENETLOC', 'FAKENAME', 'FAKEPASSWORD') @@ -61,7 +61,7 @@ def test_ISO_extension(tmpdir): assert EIO.value.message == 'wrongname.iso' -def test_stringio_upload_80a(tmpdir): +def test_stringio_upload_80a(tmpdir, fakeicontrolsession): sio = StringIO(80*'a') mr = ManagementRoot('FAKENETLOC', 'FAKENAME', 'FAKEPASSWORD') mr._meta_data['icr_session'] = mock.MagicMock() @@ -73,7 +73,7 @@ def test_stringio_upload_80a(tmpdir): assert d == 'aaaaaaaaaaaaaaaaaaaa' -def test_stringio_upload_70a(tmpdir): +def test_stringio_upload_70a(tmpdir, fakeicontrolsession): sio = StringIO(70*'a') mr = ManagementRoot('FAKENETLOC', 'FAKENAME', 'FAKEPASSWORD') mr._meta_data['icr_session'] = mock.MagicMock() @@ -87,7 +87,7 @@ def test_stringio_upload_70a(tmpdir): assert 10*'a' == lchunk -def test_bytes_upload_80a(tmpdir): +def test_bytes_upload_80a(tmpdir, fakeicontrolsession): bytestring80a = 80*'a' mr = ManagementRoot('FAKENETLOC', 'FAKENAME', 'FAKEPASSWORD') mr._meta_data['icr_session'] = mock.MagicMock() @@ -99,7 +99,7 @@ def test_bytes_upload_80a(tmpdir): assert d == 'aaaaaaaaaaaaaaaaaaaa' -def test_bytes_upload_70a(tmpdir): +def test_bytes_upload_70a(tmpdir, fakeicontrolsession): bytestring70a = 70*'a' mr = ManagementRoot('FAKENETLOC', 'FAKENAME', 'FAKEPASSWORD') mr._meta_data['icr_session'] = mock.MagicMock() diff --git a/f5/bigip/test/test___init__.py b/f5/bigip/test/test___init__.py index 790f17b26..ff3812f29 100644 --- a/f5/bigip/test/test___init__.py +++ b/f5/bigip/test/test___init__.py @@ -26,14 +26,14 @@ @pytest.fixture -def FakeBigIP(): +def FakeBigIP(fakeicontrolsession): FBIP = BigIP('FakeHostName', 'admin', 'admin') FBIP.icontrol = mock.MagicMock() return FBIP @pytest.fixture -def FakeBigIPWithPort(): +def FakeBigIPWithPort(fakeicontrolsession): FBIP = BigIP('FakeHostName', 'admin', 'admin', port='10443') FBIP.icontrol = mock.MagicMock() return FBIP diff --git a/f5/bigip/tm/auth/test/test_user.py b/f5/bigip/tm/auth/test/test_user.py index 186502e7f..2208bbb75 100644 --- a/f5/bigip/tm/auth/test/test_user.py +++ b/f5/bigip/tm/auth/test/test_user.py @@ -29,7 +29,7 @@ def FakeUser(): class TestCreate(object): - def test_create_two(self): + def test_create_two(self, fakeicontrolsession): b = BigIP('localhost', 'admin', 'admin') n1 = b.auth.users.user n2 = b.auth.users.user diff --git a/f5/bigip/tm/cm/test/test___init__.py b/f5/bigip/tm/cm/test/test___init__.py index 6a69fc3e9..ee901c432 100644 --- a/f5/bigip/tm/cm/test/test___init__.py +++ b/f5/bigip/tm/cm/test/test___init__.py @@ -20,7 +20,7 @@ @pytest.fixture -def FakeiControl(): +def FakeiControl(fakeicontrolsession): bigip = BigIP('host', 'fake_admin', 'fake_admin') mock_session = mock.MagicMock() mock_session.post.return_value.json.return_value = {} diff --git a/f5/bigip/tm/ltm/test/test_nat.py b/f5/bigip/tm/ltm/test/test_nat.py index d33344798..f0e498350 100644 --- a/f5/bigip/tm/ltm/test/test_nat.py +++ b/f5/bigip/tm/ltm/test/test_nat.py @@ -29,7 +29,7 @@ def FakeNat(): class TestCreate(object): - def test_create_two(self): + def test_create_two(self, fakeicontrolsession): b = BigIP('192.168.1.1', 'admin', 'admin') n1 = b.ltm.nats.nat n2 = b.ltm.nats.nat diff --git a/f5/bigip/tm/ltm/test/test_rule.py b/f5/bigip/tm/ltm/test/test_rule.py index b597cb196..21b685ff4 100644 --- a/f5/bigip/tm/ltm/test/test_rule.py +++ b/f5/bigip/tm/ltm/test/test_rule.py @@ -29,7 +29,7 @@ def FakeRule(): class TestCreate(object): - def test_create_two(self): + def test_create_two(self, fakeicontrolsession): b = BigIP('192.168.1.1', 'admin', 'admin') r1 = b.ltm.rules.rule r2 = b.ltm.rules.rule diff --git a/f5/bigip/tm/sys/test/test_db.py b/f5/bigip/tm/sys/test/test_db.py index 2058e81ff..a1c173855 100644 --- a/f5/bigip/tm/sys/test/test_db.py +++ b/f5/bigip/tm/sys/test/test_db.py @@ -22,12 +22,15 @@ @pytest.fixture def fake_dbs(): fake_sys = mock.MagicMock() - return Dbs(fake_sys) + dbs = Dbs(fake_sys) + dbs._meta_data['bigip'].tmos_version = '11.6.0' + return dbs class TestDb(object): def test_create_raises(self): dbs = fake_dbs() + print(dbs.raw) db = dbs.db with pytest.raises(UnsupportedOperation): db.create() diff --git a/f5/bigip/tm/sys/test/test_folder.py b/f5/bigip/tm/sys/test/test_folder.py index 248b5d16a..7c60775fd 100644 --- a/f5/bigip/tm/sys/test/test_folder.py +++ b/f5/bigip/tm/sys/test/test_folder.py @@ -23,7 +23,9 @@ @pytest.fixture def FakeFolders(): fake_sys = mock.MagicMock() - return Folders(fake_sys) + folders = Folders(fake_sys) + folders._meta_data['bigip'].tmos_version = '11.6.0' + return folders class TestFolder(object): diff --git a/f5/bigip/tm/sys/test/test_performance.py b/f5/bigip/tm/sys/test/test_performance.py index b89db31ce..a4913da2a 100644 --- a/f5/bigip/tm/sys/test/test_performance.py +++ b/f5/bigip/tm/sys/test/test_performance.py @@ -24,8 +24,9 @@ @pytest.fixture def FakePerformance(): fake_sys = mock.MagicMock() - return Performances(fake_sys) - + performances = Performances(fake_sys) + performances._meta_data['bigip'].tmos_version = '11.6.0' + return performances class TestPerformance(object): def test_get_collection_raises(self): diff --git a/f5/bigip/tm/sys/test/test_sys_application.py b/f5/bigip/tm/sys/test/test_sys_application.py index a60ce4fb3..84f33a431 100644 --- a/f5/bigip/tm/sys/test/test_sys_application.py +++ b/f5/bigip/tm/sys/test/test_sys_application.py @@ -171,7 +171,7 @@ def __init__(self): class TestServiceCreate(object): - def test_create_two(self): + def test_create_two(self, fakeicontrolsession): b = BigIP('192.168.1.1', 'admin', 'admin') serv1 = b.sys.applications.services.service serv2 = b.sys.applications.services.service @@ -323,7 +323,7 @@ def test_update_inherit_tg_false(self, FakeService): class TestTemplateCreate(object): - def test_create_two(self): + def test_create_two(self, fakeicontrolsession): b = BigIP('192.168.1.1', 'admin', 'admin') templ1 = b.sys.applications.templates.template templ2 = b.sys.applications.templates.template @@ -337,7 +337,7 @@ def test_create_no_args(self, FakeTemplate): class TestAplscript(object): - def test_create_two(self): + def test_create_two(self, fakeicontrolsession): b = BigIP('192.168.1.1', 'admin', 'admin') templ1 = b.sys.applications.aplscripts.aplscript templ2 = b.sys.applications.aplscripts.aplscript @@ -350,7 +350,7 @@ def test_create_no_args(self, FakeAplscript): class TestCustomstat(object): - def test_create_two(self): + def test_create_two(self, fakeicontrolsession): b = BigIP('192.168.1.1', 'admin', 'admin') templ1 = b.sys.applications.customstats.customstat templ2 = b.sys.applications.customstats.customstat diff --git a/f5/multi_device/cluster/test/test_cluster_manager.py b/f5/multi_device/cluster/test/test_cluster_manager.py index bbc1b1759..dbb966003 100644 --- a/f5/multi_device/cluster/test/test_cluster_manager.py +++ b/f5/multi_device/cluster/test/test_cluster_manager.py @@ -30,7 +30,7 @@ def __init__(self, name): @pytest.fixture -def BigIPs(): +def BigIPs(fakeicontrolsession): mock_bigips = [] for bigip in range(4): mock_bigips.append(ManagementRoot('test', 'un', 'pw')) From 5bc4db93e07b308980caa3c7151bed538ef886ec Mon Sep 17 00:00:00 2001 From: Za Wilgustus Date: Thu, 2 Jun 2016 16:26:15 -0600 Subject: [PATCH 3/4] Remove unnecessary code. Issues: Fixes # Problem: Analysis: Tests: --- f5/bigip/__init__.py | 2 +- f5/bigip/tm/cm/trust.py | 6 ------ 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/f5/bigip/__init__.py b/f5/bigip/__init__.py index 2a2fb7f47..98a45c198 100644 --- a/f5/bigip/__init__.py +++ b/f5/bigip/__init__.py @@ -79,7 +79,7 @@ def _get_tmos_version(self): base_uri = self._meta_data['uri'] + 'tm/sys/' response = connect.get(base_uri) ver = response.json() - version = str(parse_qs(urlparse(ver['selfLink']).query)['ver'][0]) + version = parse_qs(urlparse(ver['selfLink']).query)['ver'][0] self._meta_data['tmos_version'] = version diff --git a/f5/bigip/tm/cm/trust.py b/f5/bigip/tm/cm/trust.py index b35b44fa8..45690b498 100644 --- a/f5/bigip/tm/cm/trust.py +++ b/f5/bigip/tm/cm/trust.py @@ -31,9 +31,6 @@ class Add_To_Trust(UnnamedResourceMixin, ExclusiveAttributesMixin, def __init__(self, cm): super(Add_To_Trust, self).__init__(cm) - base_uri = type(self).__name__.replace('_', '-').lower() - self._meta_data['uri'] =\ - self._meta_data['container']._meta_data['uri'] + base_uri + '/' self._meta_data['exclusive_attributes'].append( ('caDevice', 'nonCaDevice')) self._meta_data['required_creation_parameters'].update( @@ -58,9 +55,6 @@ class Remove_From_Trust(UnnamedResourceMixin, CommandExecutionMixin, Resource): def __init__(self, cm): super(Remove_From_Trust, self).__init__(cm) - base_uri = type(self).__name__.replace('_', '-').lower() - self._meta_data['uri'] =\ - self._meta_data['container']._meta_data['uri'] + base_uri + '/' self._meta_data['required_creation_parameters'].update( ('deviceName',)) self._meta_data['required_json_kind'] = \ From 3b96a2e8cfb719f32c39fe3a3fc101193ba6475d Mon Sep 17 00:00:00 2001 From: Za Wilgustus Date: Thu, 2 Jun 2016 16:46:49 -0600 Subject: [PATCH 4/4] Fix flake8 Issues: Fixes # Problem: Analysis: Tests: --- f5/bigip/tm/sys/test/test_performance.py | 1 + 1 file changed, 1 insertion(+) diff --git a/f5/bigip/tm/sys/test/test_performance.py b/f5/bigip/tm/sys/test/test_performance.py index a4913da2a..ab820a335 100644 --- a/f5/bigip/tm/sys/test/test_performance.py +++ b/f5/bigip/tm/sys/test/test_performance.py @@ -28,6 +28,7 @@ def FakePerformance(): performances._meta_data['bigip'].tmos_version = '11.6.0' return performances + class TestPerformance(object): def test_get_collection_raises(self): perf = FakePerformance()